Compare commits
1968
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7616899d8 | ||
|
|
a563f63857 | ||
|
|
eb167347f6 | ||
|
|
c8608d50ba | ||
|
|
dff040b509 | ||
|
|
26a203b4eb | ||
|
|
21128b895f | ||
|
|
fafcf75014 | ||
|
|
aa195cef66 | ||
|
|
fe1b2a6a46 | ||
|
|
e8f6bb32dc | ||
|
|
45fbd6eff8 | ||
|
|
2c330bf9df | ||
|
|
4bef8577fa | ||
|
|
db4354bda8 | ||
|
|
aa33514ec6 | ||
|
|
66a0beb653 | ||
|
|
5cd108b39a | ||
|
|
b0acbfbb34 | ||
|
|
662dead053 | ||
|
|
2971d6f7f0 | ||
|
|
81e4123b10 | ||
|
|
732cf99007 | ||
|
|
5fd74e9c8c | ||
|
|
af97cd17d0 | ||
|
|
5f5133bd68 | ||
|
|
339c70bb56 | ||
|
|
e4bf573146 | ||
|
|
4348296daf | ||
|
|
9ea03eb540 | ||
|
|
c9186c37a5 | ||
|
|
6211d4ae37 | ||
|
|
fee0268391 | ||
|
|
38a1f1079a | ||
|
|
58685945b1 | ||
|
|
ecded9e58b | ||
|
|
488c0318ff | ||
|
|
000125c3ec | ||
|
|
0708bbad2e | ||
|
|
30f48bd926 | ||
|
|
358feb5567 | ||
|
|
5855529e97 | ||
|
|
4e2ea89403 | ||
|
|
a3a5862f89 | ||
|
|
b8739eebf0 | ||
|
|
607aaf74dc | ||
|
|
4277601999 | ||
|
|
ae33780dae | ||
|
|
097bec33b1 | ||
|
|
abf4e45e39 | ||
|
|
2631bd3af4 | ||
|
|
cda5f3dc32 | ||
|
|
caff693ad8 | ||
|
|
204ae10737 | ||
|
|
23ce486539 | ||
|
|
994215d981 | ||
|
|
42679d4d54 | ||
|
|
14b8cc625b | ||
|
|
4f9a63fb01 | ||
|
|
a1d5ebdab2 | ||
|
|
628b1a5732 | ||
|
|
c6bd6af5ce | ||
|
|
095a95e64a | ||
|
|
dbb93a06c2 | ||
|
|
22d79e08f9 | ||
|
|
235b9469d9 | ||
|
|
99215bbb79 | ||
|
|
d81d9cbda4 | ||
|
|
e19d303a76 | ||
|
|
72d7ed6962 | ||
|
|
d663d53747 | ||
|
|
b7fb3a5054 | ||
|
|
7079455eb0 | ||
|
|
471e50ffe1 | ||
|
|
8bc8518496 | ||
|
|
814eb11d2f | ||
|
|
1112d11c80 | ||
|
|
63883f4ffd | ||
|
|
d311d9dc8d | ||
|
|
2b3b13913b | ||
|
|
5ace2fb584 | ||
|
|
6e7d922853 | ||
|
|
1425b284dd | ||
|
|
d017f5d881 | ||
|
|
bc9c78122b | ||
|
|
287edcf32b | ||
|
|
166280da5a | ||
|
|
e14bdb4f49 | ||
|
|
83dc6dea3f | ||
|
|
6193043880 | ||
|
|
674914c651 | ||
|
|
2e2a354ba6 | ||
|
|
6024faa9aa | ||
|
|
0aa188445b | ||
|
|
c0e1d42b62 | ||
|
|
c640fa2d0d | ||
|
|
4ae158c93e | ||
|
|
913b9e9262 | ||
|
|
765b6e6208 | ||
|
|
16d0b9c80f | ||
|
|
d029f9a19e | ||
|
|
3d3507a770 | ||
|
|
54f79b3902 | ||
|
|
1d0b0918ba | ||
|
|
f1fb5662ca | ||
|
|
242a76221e | ||
|
|
b033a89aaf | ||
|
|
c2bb769676 | ||
|
|
f151e74f5c | ||
|
|
38958c891a | ||
|
|
c07131b26f | ||
|
|
ec7e9b36ec | ||
|
|
146d468ff4 | ||
|
|
55764497c8 | ||
|
|
03601ab0aa | ||
|
|
c84b5f460a | ||
|
|
89a05a2348 | ||
|
|
cebcdfb66e | ||
|
|
496fc11b0a | ||
|
|
39f914b3b5 | ||
|
|
6e9466d0b4 | ||
|
|
ed8c9b1543 | ||
|
|
ce4eb4e8a2 | ||
|
|
3b86a6d779 | ||
|
|
c920d954ef | ||
|
|
f7a871e3e4 | ||
|
|
c13bf96cb9 | ||
|
|
4c9878c338 | ||
|
|
d08ed2f2ca | ||
|
|
2d7b6e4381 | ||
|
|
25db631ff7 | ||
|
|
ca4e7086e9 | ||
|
|
7ae91eb276 | ||
|
|
1fdd978084 | ||
|
|
341b7635c7 | ||
|
|
afd3bbaa3d | ||
|
|
2c8c26490a | ||
|
|
bf693fab6b | ||
|
|
d689112656 | ||
|
|
99d206eb00 | ||
|
|
a41c271a67 | ||
|
|
a84a9b730e | ||
|
|
ae1a809357 | ||
|
|
51c0d77fd4 | ||
|
|
86e05a77e6 | ||
|
|
6e9f4b7932 | ||
|
|
cbc316b34d | ||
|
|
b71380de31 | ||
|
|
e42d358e71 | ||
|
|
06de9b1c93 | ||
|
|
fb98b1a8ee | ||
|
|
8526697b08 | ||
|
|
b702ed9f45 | ||
|
|
d4dc4f875a | ||
|
|
d782b2cd24 | ||
|
|
3b137f8946 | ||
|
|
a1f1d93902 | ||
|
|
0eb58f9dd1 | ||
|
|
9b333df720 | ||
|
|
46507e41c6 | ||
|
|
c06e788b0f | ||
|
|
6c4ee2bfde | ||
|
|
a1cd47dabd | ||
|
|
ea3aae6093 | ||
|
|
c545a77190 | ||
|
|
3cb4a5676a | ||
|
|
b4b00ae3dd | ||
|
|
29b562ebf5 | ||
|
|
43494cbfa3 | ||
|
|
d4b72594ab | ||
|
|
15ab355dc2 | ||
|
|
c6471d06ff | ||
|
|
05302490fb | ||
|
|
8e36bd90ef | ||
|
|
0ac2b849c3 | ||
|
|
84864c2ecf | ||
|
|
d1b4cc9c0f | ||
|
|
331f750871 | ||
|
|
25aae44918 | ||
|
|
4b5b639adc | ||
|
|
71111bf21b | ||
|
|
369613a2e7 | ||
|
|
12a5093b41 | ||
|
|
721d92ac7b | ||
|
|
6e1d90aafe | ||
|
|
3a252dfc70 | ||
|
|
83049ed9e5 | ||
|
|
46ae8ab1c6 | ||
|
|
47dc9e8bf6 | ||
|
|
c4c1bddeec | ||
|
|
24f5ccf7ff | ||
|
|
885cac13a4 | ||
|
|
2c970f252d | ||
|
|
373f1b2c18 | ||
|
|
c0fbe2698b | ||
|
|
a973be2103 | ||
|
|
040f3bbd93 | ||
|
|
0d75e311d0 | ||
|
|
1202cafadf | ||
|
|
d11abb78ac | ||
|
|
261cdc87cb | ||
|
|
7e37770f26 | ||
|
|
74bf0e73c5 | ||
|
|
023b28e31f | ||
|
|
f6605a1ab0 | ||
|
|
c1aaaf207a | ||
|
|
3b5454fb99 | ||
|
|
bac7e0b214 | ||
|
|
1e15a2838a | ||
|
|
b4b3ae452c | ||
|
|
c4a7c2a22d | ||
|
|
2ec5414b39 | ||
|
|
7fd7699f6e | ||
|
|
fddae546a0 | ||
|
|
e94271706c | ||
|
|
4f9433c39a | ||
|
|
6b6d217774 | ||
|
|
ed99ecba1c | ||
|
|
315a510969 | ||
|
|
2c0f55d9b7 | ||
|
|
a24a223a35 | ||
|
|
3a9ee57e53 | ||
|
|
c1ddb658bd | ||
|
|
16d2d9111a | ||
|
|
e09f8785e1 | ||
|
|
d89198ee21 | ||
|
|
27a89a6ff3 | ||
|
|
136a2bc3b5 | ||
|
|
1cb1f2d0c0 | ||
|
|
c216f393a7 | ||
|
|
cfdedd8861 | ||
|
|
dbdd7bb066 | ||
|
|
27c0fe80f6 | ||
|
|
5c6cd6af44 | ||
|
|
001219460f | ||
|
|
564cfa107c | ||
|
|
7ff6512864 | ||
|
|
ec0c06ff88 | ||
|
|
6346e7bd95 | ||
|
|
bc8c1467ee | ||
|
|
60543adf63 | ||
|
|
4bde1a8352 | ||
|
|
261c720c77 | ||
|
|
090f57bfb1 | ||
|
|
adaac7aca4 | ||
|
|
f13d340620 | ||
|
|
8ac2400043 | ||
|
|
0c26e1e99d | ||
|
|
7577650eee | ||
|
|
113d2b1fec | ||
|
|
5864e62443 | ||
|
|
5b4d9624a9 | ||
|
|
548a133de6 | ||
|
|
47acfc991b | ||
|
|
3afb3bcfd1 | ||
|
|
5ea1c277bc | ||
|
|
6fc32574c7 | ||
|
|
d7981f7575 | ||
|
|
cc1fa74c63 | ||
|
|
c194dd2862 | ||
|
|
3f8ac23aba | ||
|
|
8e46ba6985 | ||
|
|
e1cb01e6fb | ||
|
|
b371fab53d | ||
|
|
9e4a838ba1 | ||
|
|
49ac9222b5 | ||
|
|
a0a4e03da9 | ||
|
|
b000b056d1 | ||
|
|
5ad3fec449 | ||
|
|
67c528c3e2 | ||
|
|
4bded73040 | ||
|
|
ea9732749a | ||
|
|
1302275b71 | ||
|
|
76f02d8d45 | ||
|
|
7319fba35a | ||
|
|
22133fe604 | ||
|
|
cbd5dc0ca2 | ||
|
|
055cd29e29 | ||
|
|
791d1a650f | ||
|
|
dc993c9713 | ||
|
|
446fad33d0 | ||
|
|
ba4179b28c | ||
|
|
8a735020e3 | ||
|
|
c46a68e32b | ||
|
|
9cd709edee | ||
|
|
e8cf5e70ba | ||
|
|
3f6083ff02 | ||
|
|
19e9913f30 | ||
|
|
1f7ddf7bc9 | ||
|
|
0c8ede3889 | ||
|
|
6dbc899626 | ||
|
|
48faf43e37 | ||
|
|
3987562890 | ||
|
|
18b39b4378 | ||
|
|
99a469e6cf | ||
|
|
f2e25ec34b | ||
|
|
f56c68d952 | ||
|
|
6d922516f6 | ||
|
|
bab967acce | ||
|
|
e5af1ba758 | ||
|
|
dbc7688516 | ||
|
|
c516347565 | ||
|
|
3bf2612470 | ||
|
|
499354ffd2 | ||
|
|
4ff55ba3e0 | ||
|
|
78a4d4a089 | ||
|
|
14696fb1a1 | ||
|
|
e49ad96cc4 | ||
|
|
d05ff1cd26 | ||
|
|
8700fc3423 | ||
|
|
e740047e03 | ||
|
|
8f356b1337 | ||
|
|
18935f1379 | ||
|
|
bf8a985f22 | ||
|
|
dd52bfd043 | ||
|
|
780d37e1d2 | ||
|
|
7d9ee18196 | ||
|
|
a6fd0ba3c5 | ||
|
|
cb6c54ffe5 | ||
|
|
274ecac474 | ||
|
|
eadb431f33 | ||
|
|
34cac9c42a | ||
|
|
65c12eaf5e | ||
|
|
ba7dcd39f2 | ||
|
|
d4d5d79070 | ||
|
|
30559fd4eb | ||
|
|
d93185e750 | ||
|
|
588227f181 | ||
|
|
4968745764 | ||
|
|
912e69aeb2 | ||
|
|
933dd4cdef | ||
|
|
51bf4e5558 | ||
|
|
ef1eef3278 | ||
|
|
2992871975 | ||
|
|
18d22418ca | ||
|
|
4e129a7f4a | ||
|
|
d858357c5d | ||
|
|
c55bd7f4b6 | ||
|
|
bde1cd532e | ||
|
|
5e683da3b9 | ||
|
|
c50e9f97d1 | ||
|
|
dc05eb3f8a | ||
|
|
375e355faf | ||
|
|
7530a95fbd | ||
|
|
1db6e744c8 | ||
|
|
0319bb51a0 | ||
|
|
4cd51a4955 | ||
|
|
9803dcfd2c | ||
|
|
c0c6e21833 | ||
|
|
d1a13cdc80 | ||
|
|
c55ecabbfb | ||
|
|
25580dab86 | ||
|
|
afb02a0a1b | ||
|
|
8c1905418e | ||
|
|
58dfcc21f6 | ||
|
|
4b531c7e22 | ||
|
|
d0df738bba | ||
|
|
c9a1e93299 | ||
|
|
fa51d9235c | ||
|
|
4655090f76 | ||
|
|
e15bec3052 | ||
|
|
195446e532 | ||
|
|
f532e5ee7e | ||
|
|
2d73ce9f37 | ||
|
|
0b34f99be5 | ||
|
|
8b9b135ab5 | ||
|
|
a291bd6d45 | ||
|
|
8a957790fc | ||
|
|
8d9d38ed4c | ||
|
|
36a74eb58e | ||
|
|
32fb9ec7b3 | ||
|
|
babc7de4b6 | ||
|
|
94881d23fd | ||
|
|
b436f3cf1f | ||
|
|
1a7e4800f5 | ||
|
|
71202bce70 | ||
|
|
22584e7fde | ||
|
|
0919a61661 | ||
|
|
39af04f169 | ||
|
|
041a27c325 | ||
|
|
13028d8a5a | ||
|
|
bd73c7a326 | ||
|
|
7f3c0690cd | ||
|
|
74f0d823da | ||
|
|
eae630e60d | ||
|
|
d320e78bbc | ||
|
|
80632ba409 | ||
|
|
125ca34ba4 | ||
|
|
5cb2a5145f | ||
|
|
2f0b202590 | ||
|
|
4a14001a09 | ||
|
|
c7f880300c | ||
|
|
6731be1a6b | ||
|
|
c7945ad8fd | ||
|
|
e5832d5c67 | ||
|
|
f87caf9dd1 | ||
|
|
221227204d | ||
|
|
88c7fcaca9 | ||
|
|
6c0a026cc4 | ||
|
|
64a70c9f28 | ||
|
|
7bc4c52ba4 | ||
|
|
a530205d2b | ||
|
|
c2927ab9a6 | ||
|
|
2c60a29e0f | ||
|
|
54ad26c607 | ||
|
|
41d8387cd5 | ||
|
|
60f61d7e0a | ||
|
|
cb8e5fb145 | ||
|
|
098cc785d8 | ||
|
|
8df0f4e823 | ||
|
|
875d2497f4 | ||
|
|
3f00896411 | ||
|
|
ed36c43af4 | ||
|
|
dd792cd792 | ||
|
|
a07bcda2c9 | ||
|
|
5816cd0958 | ||
|
|
6a3b6e22e6 | ||
|
|
3f65b5f0ae | ||
|
|
6954932ce3 | ||
|
|
08bf8f9a5a | ||
|
|
7a4486b1bc | ||
|
|
f380d4ecb0 | ||
|
|
01efb9f157 | ||
|
|
736e94c808 | ||
|
|
e63cb2b29e | ||
|
|
ab64699245 | ||
|
|
582ea098f9 | ||
|
|
2bb98d2047 | ||
|
|
f6e70ea5d0 | ||
|
|
cf76f7d6d3 | ||
|
|
465d26ba42 | ||
|
|
2c4f1ea37b | ||
|
|
a6ca9a8a18 | ||
|
|
f1218cef02 | ||
|
|
ac3acfce6a | ||
|
|
4bec639a23 | ||
|
|
95da66c763 | ||
|
|
ed692ee3bb | ||
|
|
69f2a18a44 | ||
|
|
48de46739c | ||
|
|
4777f29c5b | ||
|
|
cdff6ba69d | ||
|
|
4525790534 | ||
|
|
93e2e8fb44 | ||
|
|
9f025edc96 | ||
|
|
703b0f63ce | ||
|
|
5f7df21f9c | ||
|
|
5927034b1c | ||
|
|
5d365f9040 | ||
|
|
c266cdee9a | ||
|
|
7680c3e5c2 | ||
|
|
12f87537d7 | ||
|
|
e46d541780 | ||
|
|
ff2386e5bf | ||
|
|
5a895bf214 | ||
|
|
8940c4bcff | ||
|
|
bdf14dffb5 | ||
|
|
4e9663e852 | ||
|
|
b31405af43 | ||
|
|
0e62b2f45b | ||
|
|
f7b009bcdf | ||
|
|
91464e031f | ||
|
|
2486a01225 | ||
|
|
bcf44affd7 | ||
|
|
56f4e6e35c | ||
|
|
d03862d47e | ||
|
|
b991eac3c3 | ||
|
|
b89400e6dc | ||
|
|
186b6be7d8 | ||
|
|
49e63eca9f | ||
|
|
66c2651be7 | ||
|
|
c346c6b1ba | ||
|
|
8f1fafe572 | ||
|
|
eb8662070a | ||
|
|
f5e53db24f | ||
|
|
d2fb8bb372 | ||
|
|
7e0498af23 | ||
|
|
83f78a4ecb | ||
|
|
ca2d896403 | ||
|
|
b78b878250 | ||
|
|
4a5267d332 | ||
|
|
a0cf38f7b8 | ||
|
|
ead27a6a11 | ||
|
|
d5d3364275 | ||
|
|
7a8f9c8968 | ||
|
|
3a2a8d8f79 | ||
|
|
95537d2d8d | ||
|
|
e19a30cc16 | ||
|
|
85379df182 | ||
|
|
b1f97bef0f | ||
|
|
9ad0b9f4c3 | ||
|
|
98b7d89ab8 | ||
|
|
2b71a00303 | ||
|
|
a29f4fc749 | ||
|
|
39cdfe1c8f | ||
|
|
9a2b9c5d11 | ||
|
|
f3755b1348 | ||
|
|
9c91bc0faa | ||
|
|
9a29c1aeb0 | ||
|
|
99b3096633 | ||
|
|
e5952d79a7 | ||
|
|
54a281b780 | ||
|
|
b3b8ccaa18 | ||
|
|
8baefb0511 | ||
|
|
78bed591e0 | ||
|
|
ed8efc9a35 | ||
|
|
7525754e03 | ||
|
|
d34bf88b7f | ||
|
|
e58f087e97 | ||
|
|
5a6bfb2a30 | ||
|
|
6f36bf680f | ||
|
|
962dbe7c02 | ||
|
|
10a2ab3e42 | ||
|
|
0d145b84db | ||
|
|
64fbde2c90 | ||
|
|
aa02ec9df8 | ||
|
|
267e32ba0f | ||
|
|
52fad8505b | ||
|
|
c26b68c53a | ||
|
|
5c01b34503 | ||
|
|
b21690d943 | ||
|
|
2555eda797 | ||
|
|
abca6882c3 | ||
|
|
81517945f6 | ||
|
|
2813be3581 | ||
|
|
ab68334af3 | ||
|
|
212262e300 | ||
|
|
0c7ff73e2d | ||
|
|
16849c2d6e | ||
|
|
e696cf33a6 | ||
|
|
1ebc128c59 | ||
|
|
d3ce268ca3 | ||
|
|
9a8412279c | ||
|
|
1ec8ef4e2b | ||
|
|
914c6fbebf | ||
|
|
74ed71bfc0 | ||
|
|
cf09720beb | ||
|
|
8d66610964 | ||
|
|
03374c83f6 | ||
|
|
656f197737 | ||
|
|
36f5aa4ae3 | ||
|
|
9147190d67 | ||
|
|
8f2f485402 | ||
|
|
3b4e288564 | ||
|
|
92286e18de | ||
|
|
d4919fe78b | ||
|
|
2f0b421131 | ||
|
|
af37c150c0 | ||
|
|
c1983d47a5 | ||
|
|
1eb4aa19df | ||
|
|
9805bc7acc | ||
|
|
2aa80f08d0 | ||
|
|
2bf3a1c8eb | ||
|
|
ea37015c80 | ||
|
|
b4824ddf71 | ||
|
|
b50d59a058 | ||
|
|
e3415a8778 | ||
|
|
f87fcf65de | ||
|
|
799aa45a66 | ||
|
|
2213647db4 | ||
|
|
088c219c20 | ||
|
|
f09aa0923e | ||
|
|
bd03ed5671 | ||
|
|
db07111e63 | ||
|
|
4c7325e701 | ||
|
|
93b58fdd6d | ||
|
|
80778e017e | ||
|
|
6211230564 | ||
|
|
bf99bf9048 | ||
|
|
8ada2ef673 | ||
|
|
417cbc6363 | ||
|
|
20a748cf7f | ||
|
|
d8e368bf55 | ||
|
|
0fef9a8133 | ||
|
|
82ff6589cb | ||
|
|
2ce2fff8d7 | ||
|
|
cb008ad64d | ||
|
|
0dec690235 | ||
|
|
75c2bd092f | ||
|
|
11e5c6b769 | ||
|
|
de5b1fa889 | ||
|
|
02844da360 | ||
|
|
0e52f4e11e | ||
|
|
a7d458cddd | ||
|
|
398f4e6983 | ||
|
|
99b1156f96 | ||
|
|
784f7ea6cd | ||
|
|
b12e5c6ffa | ||
|
|
b3162c6d69 | ||
|
|
b9e0c8ea2f | ||
|
|
493d62e04c | ||
|
|
ee97565f67 | ||
|
|
55eb30f065 | ||
|
|
bbf000a38a | ||
|
|
b7dc7ebbb0 | ||
|
|
78a36d1a39 | ||
|
|
9360f973ef | ||
|
|
c909d9044e | ||
|
|
b5b57f6cc0 | ||
|
|
507763a0d6 | ||
|
|
fcff45d8a0 | ||
|
|
d5c4059942 | ||
|
|
b4182f838c | ||
|
|
aa2953172b | ||
|
|
9a7b1bb2b8 | ||
|
|
4b69f13d76 | ||
|
|
28d3d6b8b6 | ||
|
|
a519942cb3 | ||
|
|
1fb02db516 | ||
|
|
aedfcc3abf | ||
|
|
fd27a0ed21 | ||
|
|
7d6ca17c02 | ||
|
|
98d15c30bb | ||
|
|
c8a99f9fff | ||
|
|
02d2474141 | ||
|
|
1b7ef17c89 | ||
|
|
895d0f7639 | ||
|
|
2d902308b5 | ||
|
|
b0c2e1d967 | ||
|
|
674cac8ec2 | ||
|
|
1fc3e04723 | ||
|
|
387cd46a6b | ||
|
|
4f59f7bd28 | ||
|
|
13a8dde0a5 | ||
|
|
263068ac85 | ||
|
|
6be4e764a9 | ||
|
|
27d281d4b3 | ||
|
|
29f05b7b09 | ||
|
|
e7436263d7 | ||
|
|
3b8b6f5aa2 | ||
|
|
662667aba3 | ||
|
|
9dc344bdef | ||
|
|
1fe99eb6d0 | ||
|
|
522afd0185 | ||
|
|
66c68cdc89 | ||
|
|
e147bec604 | ||
|
|
6ffd56a0a3 | ||
|
|
eed95a384a | ||
|
|
cd16687d25 | ||
|
|
bf8256a817 | ||
|
|
9ab1c328ba | ||
|
|
1cd10a9d3b | ||
|
|
c6797a8aad | ||
|
|
7ce662949b | ||
|
|
545ad2083d | ||
|
|
8797c0bef9 | ||
|
|
dfcd280dd7 | ||
|
|
053e59bec1 | ||
|
|
8488d4d989 | ||
|
|
392f5b7386 | ||
|
|
854d484142 | ||
|
|
b79cd9ec42 | ||
|
|
4571e3ac16 | ||
|
|
8f0622058d | ||
|
|
912feca56f | ||
|
|
de29e639e4 | ||
|
|
35c6c6ec03 | ||
|
|
dcd8cc2c85 | ||
|
|
011aa80870 | ||
|
|
a975b07968 | ||
|
|
a098c4377c | ||
|
|
a4c659f4c6 | ||
|
|
c099b19176 | ||
|
|
8b19085bc4 | ||
|
|
4b671bb0ab | ||
|
|
45018987ef | ||
|
|
6b0de57a4b | ||
|
|
28fa2a001d | ||
|
|
c207a4d3b7 | ||
|
|
506525524b | ||
|
|
fd86c1b801 | ||
|
|
cadc5a175e | ||
|
|
6d5cc03741 | ||
|
|
7edea222d8 | ||
|
|
c65389b2c2 | ||
|
|
791918c0dc | ||
|
|
d1a00b8e01 | ||
|
|
a474ca4d93 | ||
|
|
6a2075fe4b | ||
|
|
f9649c44e9 | ||
|
|
1bb69698de | ||
|
|
960b5d6b0a | ||
|
|
9914d40593 | ||
|
|
a7a6d78568 | ||
|
|
13ff7e1513 | ||
|
|
2c183f0def | ||
|
|
9e53a0d11f | ||
|
|
9888072d8c | ||
|
|
37c3e676a4 | ||
|
|
b2c5bafb8e | ||
|
|
a400563552 | ||
|
|
6e35993e61 | ||
|
|
c1f01e1e87 | ||
|
|
3fb9909dd3 | ||
|
|
3ebe254c45 | ||
|
|
88e2fb0eb1 | ||
|
|
419950482f | ||
|
|
4f13ee2ec3 | ||
|
|
70145e6b3b | ||
|
|
6dbcb2128c | ||
|
|
ebc299d58a | ||
|
|
d53de922db | ||
|
|
d9a4384585 | ||
|
|
3910b8fcce | ||
|
|
b3d74c5441 | ||
|
|
e26b39ba0a | ||
|
|
a9959620f1 | ||
|
|
6f917f9b2a | ||
|
|
4c048d0760 | ||
|
|
dbc4926d14 | ||
|
|
64bddb1f81 | ||
|
|
8a1e587ee4 | ||
|
|
57358d7665 | ||
|
|
e0af45db02 | ||
|
|
57f62ae97b | ||
|
|
78bbd69a26 | ||
|
|
26c37deb55 | ||
|
|
99f2c730e0 | ||
|
|
f9fa859391 | ||
|
|
13d2fe8e3a | ||
|
|
fe888f9278 | ||
|
|
059757507a | ||
|
|
cdac78e4f7 | ||
|
|
46b4d14898 | ||
|
|
cd578a8615 | ||
|
|
60ebc2e376 | ||
|
|
febe265c50 | ||
|
|
1d258ab27b | ||
|
|
de9516d19e | ||
|
|
d8254c4f1d | ||
|
|
d8e2dc59ae | ||
|
|
a2ea0ba9e6 | ||
|
|
e55ab73e61 | ||
|
|
fb66d9200a | ||
|
|
54f5bdfb1f | ||
|
|
035537e5cc | ||
|
|
481ab348ff | ||
|
|
d4fec8294d | ||
|
|
6c7bc1dc97 | ||
|
|
f7ef8be18f | ||
|
|
8cf7c4b9c9 | ||
|
|
cc2aed72d9 | ||
|
|
f26e4b2972 | ||
|
|
e467d60e8f | ||
|
|
d33922ad35 | ||
|
|
2191bacbfe | ||
|
|
dcd079b5b7 | ||
|
|
06b9f3eda7 | ||
|
|
019db2900a | ||
|
|
30d6896c8a | ||
|
|
63ce4c48a7 | ||
|
|
458a4cfa50 | ||
|
|
c8e8245853 | ||
|
|
1683977e0b | ||
|
|
b6c66565b9 | ||
|
|
87ae82a114 | ||
|
|
010475a4c3 | ||
|
|
51ce609ab1 | ||
|
|
2d928d667b | ||
|
|
64fa620c19 | ||
|
|
e1319cb389 | ||
|
|
071f807b87 | ||
|
|
774f0472e8 | ||
|
|
cc7c2f0954 | ||
|
|
0db11517db | ||
|
|
183b242d45 | ||
|
|
3b97eedc3d | ||
|
|
137f8173ab | ||
|
|
1f20516d23 | ||
|
|
b57ca7def7 | ||
|
|
e61050c490 | ||
|
|
69076ea712 | ||
|
|
749d403ed4 | ||
|
|
d2cff49b9e | ||
|
|
d60689ce02 | ||
|
|
7fd2e70024 | ||
|
|
a4b7247c57 | ||
|
|
b2f85501ac | ||
|
|
220d159cec | ||
|
|
81f4f9163c | ||
|
|
fe278f2ba6 | ||
|
|
abbacc683e | ||
|
|
543ebee5cc | ||
|
|
4f27e33daf | ||
|
|
3209b2e647 | ||
|
|
7dff9b57ed | ||
|
|
9abb0004dd | ||
|
|
2bd12f8a5f | ||
|
|
ed12769858 | ||
|
|
4294a5c3db | ||
|
|
89573fd1f0 | ||
|
|
56635ad4e4 | ||
|
|
e531ae9407 | ||
|
|
6fa2260572 | ||
|
|
e3dc5329aa | ||
|
|
6cf144e53d | ||
|
|
800ebb875f | ||
|
|
7b35373da3 | ||
|
|
47a70406f6 | ||
|
|
69e80a736e | ||
|
|
2b73245c96 | ||
|
|
07b5f6e077 | ||
|
|
c480f957c5 | ||
|
|
155dd9d77d | ||
|
|
5ffd6c0118 | ||
|
|
959d43e308 | ||
|
|
2fc2fca9fd | ||
|
|
72e62c0532 | ||
|
|
56d555668d | ||
|
|
2e6a18c58e | ||
|
|
d37e6cf147 | ||
|
|
343c6e278a | ||
|
|
1ce99e710d | ||
|
|
f5d6c37080 | ||
|
|
f0b8514890 | ||
|
|
a5db6fbbb6 | ||
|
|
2963acc32b | ||
|
|
bf7579f93b | ||
|
|
6d0cd3e301 | ||
|
|
0dc0053787 | ||
|
|
3e4d6b76aa | ||
|
|
c026edbb2f | ||
|
|
a3890e7a55 | ||
|
|
c051be3a67 | ||
|
|
98f61ba3ed | ||
|
|
443b01c8a8 | ||
|
|
c1a36c6f40 | ||
|
|
2821c5e9c7 | ||
|
|
e8890389ff | ||
|
|
1ef289cd09 | ||
|
|
e9aa2ac047 | ||
|
|
8731efcbea | ||
|
|
a926a23b87 | ||
|
|
630150a7c7 | ||
|
|
e72e74b21e | ||
|
|
37a28b9c11 | ||
|
|
87e65043f3 | ||
|
|
3e4b9d0a9a | ||
|
|
8487eeb1b0 | ||
|
|
107374bec5 | ||
|
|
a05b263ca8 | ||
|
|
c8ab4d3de5 | ||
|
|
6737c4c327 | ||
|
|
eac6875877 | ||
|
|
71604c9c39 | ||
|
|
b3e0ea9199 | ||
|
|
f3bbe0e15f | ||
|
|
5ccce6af71 | ||
|
|
4cc3e5372b | ||
|
|
e0d8b4f76a | ||
|
|
90a0b009da | ||
|
|
e30255ff58 | ||
|
|
30b9db9628 | ||
|
|
1e66fc6c22 | ||
|
|
3e01dec4f8 | ||
|
|
7678fd85ff | ||
|
|
d9603aa7da | ||
|
|
1c4f35f61c | ||
|
|
0d016bc5fc | ||
|
|
45e7c98b3f | ||
|
|
c8ae626e87 | ||
|
|
acf9bd7135 | ||
|
|
1dfb19276c | ||
|
|
312501cc70 | ||
|
|
2a9f79604b | ||
|
|
3b02affcb3 | ||
|
|
4fee718424 | ||
|
|
fc5ce1f45f | ||
|
|
be882d53d1 | ||
|
|
88b489eb77 | ||
|
|
2b0e66ca7e | ||
|
|
de1f7e8ddd | ||
|
|
6f0622ed36 | ||
|
|
7c6fa248ac | ||
|
|
48bc8bf0c1 | ||
|
|
83e95d764e | ||
|
|
7113703e3c | ||
|
|
17b9f3eb73 | ||
|
|
95828a0ec6 | ||
|
|
9fdb199972 | ||
|
|
2980308f4d | ||
|
|
1ee32b7a6f | ||
|
|
5f671e164f | ||
|
|
250b623f3a | ||
|
|
3727ff141c | ||
|
|
f31a049444 | ||
|
|
1a092bc9d7 | ||
|
|
90a2eda86a | ||
|
|
748855793d | ||
|
|
90973532f0 | ||
|
|
a3354c92c2 | ||
|
|
655237e37f | ||
|
|
ac3ce3703e | ||
|
|
44ac24a9e2 | ||
|
|
0d8539e72f | ||
|
|
5fb147f7aa | ||
|
|
c3ae15abfe | ||
|
|
e5d826b4a2 | ||
|
|
465d9dfc10 | ||
|
|
f37e9ce555 | ||
|
|
81b729929c | ||
|
|
156520b560 | ||
|
|
3627239708 | ||
|
|
cde8acf111 | ||
|
|
0d157e4dd9 | ||
|
|
5ecee65720 | ||
|
|
87d6469f02 | ||
|
|
7e2626513e | ||
|
|
4379b721bc | ||
|
|
9d492cb5ed | ||
|
|
646221d133 | ||
|
|
bfcec93017 | ||
|
|
68a5612598 | ||
|
|
a99a54493b | ||
|
|
47774ea8fe | ||
|
|
bf89a04883 | ||
|
|
cda6521078 | ||
|
|
71580da8d7 | ||
|
|
c5862db98d | ||
|
|
045d0d815b | ||
|
|
41225248d0 | ||
|
|
4ccdae0665 | ||
|
|
66ba59a51a | ||
|
|
96b5f66d5c | ||
|
|
86646ccd69 | ||
|
|
04c056f744 | ||
|
|
74fa4789f5 | ||
|
|
c5d73b8e8f | ||
|
|
decfe4ab1f | ||
|
|
859fba4ecc | ||
|
|
a0b1c761b7 | ||
|
|
f7e3863898 | ||
|
|
3cfd45f4bb | ||
|
|
b11ad31d5f | ||
|
|
b797af706a | ||
|
|
8452f1410f | ||
|
|
2abbb3cc8f | ||
|
|
0d73e185c0 | ||
|
|
5da3417429 | ||
|
|
e7cbe98766 | ||
|
|
df0088750b | ||
|
|
2ba5f0c307 | ||
|
|
990b74f3e8 | ||
|
|
7e76fed728 | ||
|
|
094d9c1e81 | ||
|
|
7b47de47c6 | ||
|
|
cf8053b7ce | ||
|
|
93704a24ce | ||
|
|
5086de23a7 | ||
|
|
fa9888d0e1 | ||
|
|
a88a04fd24 | ||
|
|
4c1e9aa714 | ||
|
|
0c984e8f5f | ||
|
|
3e15ad6b5b | ||
|
|
2121743753 | ||
|
|
7d64135e79 | ||
|
|
735b5abdcc | ||
|
|
00990fedcc | ||
|
|
f7f4c80806 | ||
|
|
0b9cab7c1a | ||
|
|
4bbcda2a75 | ||
|
|
ae44b82a85 | ||
|
|
8e3e94b286 | ||
|
|
a060f8dca8 | ||
|
|
55fda1f616 | ||
|
|
e3a5ea7341 | ||
|
|
0c4d7c644e | ||
|
|
e9ad7e333a | ||
|
|
781669dfaf | ||
|
|
1cf6722d6e | ||
|
|
55c36cf290 | ||
|
|
d0c4d5f997 | ||
|
|
23ac00eb61 | ||
|
|
4a403aaa30 | ||
|
|
1fb139bc71 | ||
|
|
8e0001dc56 | ||
|
|
2e2ca3bef4 | ||
|
|
f81fac8f6b | ||
|
|
aa7f067e54 | ||
|
|
c6156771b4 | ||
|
|
4d4008cdb3 | ||
|
|
93bf192e3d | ||
|
|
cafa66fd37 | ||
|
|
fc079fcdc0 | ||
|
|
249bf0ab4b | ||
|
|
da5cf0eb64 | ||
|
|
11076cef00 | ||
|
|
b19b828861 | ||
|
|
2f01fadeeb | ||
|
|
d94c902d75 | ||
|
|
19d9a89651 | ||
|
|
b0c81e337d | ||
|
|
75e67fa980 | ||
|
|
d1090671ce | ||
|
|
d42629d289 | ||
|
|
82337da0cc | ||
|
|
6514634dcb | ||
|
|
3e7fa40a99 | ||
|
|
47419aad00 | ||
|
|
f6b0bca2bf | ||
|
|
db98da5689 | ||
|
|
047ed674b8 | ||
|
|
3b88e5f6ab | ||
|
|
cd47317e8a | ||
|
|
6ac80bf659 | ||
|
|
1fd2b544a5 | ||
|
|
fcb4c055e2 | ||
|
|
c6657929da | ||
|
|
1a1a19dea7 | ||
|
|
3aaba2d6a3 | ||
|
|
13d8640b4c | ||
|
|
2a27e28f26 | ||
|
|
f3a0a45e59 | ||
|
|
b0fd797cd2 | ||
|
|
751bdbf880 | ||
|
|
0a9c798580 | ||
|
|
60a41ec3ea | ||
|
|
eead11e99d | ||
|
|
442518c382 | ||
|
|
6c2d169798 | ||
|
|
ba81dece5e | ||
|
|
e597bebafd | ||
|
|
2e60f2cd77 | ||
|
|
78a73a1b0e | ||
|
|
e9182e8cfe | ||
|
|
40fe0ba0e1 | ||
|
|
6908969307 | ||
|
|
a0ad3382af | ||
|
|
07688005a1 | ||
|
|
d318f4a450 | ||
|
|
3c05525e27 | ||
|
|
096ab8d48b | ||
|
|
847df6a85f | ||
|
|
cc6df5993f | ||
|
|
61ffff1be0 | ||
|
|
287c0f3ce8 | ||
|
|
bb68e8a580 | ||
|
|
eeb087e453 | ||
|
|
e32fe65fa8 | ||
|
|
f018ca85a9 | ||
|
|
ac18f69ec5 | ||
|
|
98dafc13ca | ||
|
|
4895c4f738 | ||
|
|
df8610f458 | ||
|
|
e0bb7de0cf | ||
|
|
d622ddd37f | ||
|
|
ff2b02b102 | ||
|
|
f0939c91c3 | ||
|
|
1b47158770 | ||
|
|
693e10188c | ||
|
|
2c5fda33e7 | ||
|
|
fcf1771967 | ||
|
|
30e883fe0c | ||
|
|
51e5c6dc64 | ||
|
|
e281a8f20b | ||
|
|
0aecc90d11 | ||
|
|
a077f828ae | ||
|
|
c6b598daa7 | ||
|
|
41582fe17b | ||
|
|
a99ca36316 | ||
|
|
4963889024 | ||
|
|
6b479d1a68 | ||
|
|
0c8f6fe2b3 | ||
|
|
e52ecb9315 | ||
|
|
9d213b3843 | ||
|
|
3ae8964fe5 | ||
|
|
7fdc58380f | ||
|
|
1a7d4bb39f | ||
|
|
42ddc19826 | ||
|
|
54af43a99d | ||
|
|
0fbab09890 | ||
|
|
20b3074cb1 | ||
|
|
64bf5aa0fe | ||
|
|
81917423eb | ||
|
|
af7852416a | ||
|
|
3c535731f7 | ||
|
|
718a54ec9e | ||
|
|
5fbd2bbd04 | ||
|
|
f62283fb7f | ||
|
|
8400636df4 | ||
|
|
ede63fac24 | ||
|
|
ba4407eb0d | ||
|
|
6f2b2babc6 | ||
|
|
37183ac2f0 | ||
|
|
3df6fb511f | ||
|
|
cf955477d8 | ||
|
|
b4fff67303 | ||
|
|
ad101ea42e | ||
|
|
5792630231 | ||
|
|
6002e4223b | ||
|
|
7bfccc87fc | ||
|
|
77817b4c94 | ||
|
|
7c5ed85a36 | ||
|
|
991bab9cb1 | ||
|
|
61b7b3fb02 | ||
|
|
07bcf316e8 | ||
|
|
1e980638c4 | ||
|
|
23bb915d92 | ||
|
|
d284fc0637 | ||
|
|
b8a649e705 | ||
|
|
e477f73983 | ||
|
|
56a6e690ed | ||
|
|
1f1a7825e3 | ||
|
|
c6282e7e2f | ||
|
|
6cd09ed2fa | ||
|
|
dc6a0debca | ||
|
|
c3f45c6bff | ||
|
|
eba86bb666 | ||
|
|
15b16634db | ||
|
|
9c96bdfaa8 | ||
|
|
7babcb9e0d | ||
|
|
c8c402ffc5 | ||
|
|
c55f2aed11 | ||
|
|
4aed53a8af | ||
|
|
1c8d058d71 | ||
|
|
21796cb83d | ||
|
|
2c21d27f61 | ||
|
|
e23c5dcb7b | ||
|
|
8a0366af5b | ||
|
|
f94fbaa9b4 | ||
|
|
5e013489e0 | ||
|
|
760c7b561d | ||
|
|
2c94e212ca | ||
|
|
053c538f94 | ||
|
|
3399ae7488 | ||
|
|
f6247ce232 | ||
|
|
b79d136925 | ||
|
|
f70d38a034 | ||
|
|
b428ac1c6c | ||
|
|
93f06f8ff8 | ||
|
|
a08559e163 | ||
|
|
7feb80689b | ||
|
|
7d9b2749d5 | ||
|
|
660e284e25 | ||
|
|
944ecb2bf3 | ||
|
|
81470359f2 | ||
|
|
54cb9ceade | ||
|
|
a274d029bb | ||
|
|
d6956d396c | ||
|
|
58bf9a3503 | ||
|
|
f556d3ca7e | ||
|
|
db6ed676ac | ||
|
|
209255f17e | ||
|
|
9bf0b4d331 | ||
|
|
1407f616a1 | ||
|
|
8099343fca | ||
|
|
5c094fd0fd | ||
|
|
b697e4c9f0 | ||
|
|
566b07be62 | ||
|
|
b971ca2f7c | ||
|
|
6f32971d79 | ||
|
|
55cad93be3 | ||
|
|
f23f3ea26d | ||
|
|
451c791f10 | ||
|
|
c3729249c4 | ||
|
|
7a0e158b51 | ||
|
|
da23683440 | ||
|
|
a0ba933db3 | ||
|
|
e6859c470c | ||
|
|
4ca5a57e6a | ||
|
|
edadca18c6 | ||
|
|
b5be60140e | ||
|
|
ce3cd7e80f | ||
|
|
65b29adb01 | ||
|
|
d3eaa901f5 | ||
|
|
b8826c3e92 | ||
|
|
eca2c94aa3 | ||
|
|
2b461cc6e8 | ||
|
|
a5aecf6171 | ||
|
|
da4cb16d52 | ||
|
|
bfb139c7c7 | ||
|
|
effab80db9 | ||
|
|
26c2d7aacd | ||
|
|
2baf1b1460 | ||
|
|
dd1682a83b | ||
|
|
1a3f94dfcb | ||
|
|
baabfdabba | ||
|
|
0ad8bb66cc | ||
|
|
47a043324f | ||
|
|
171f450e59 | ||
|
|
a24d55f526 | ||
|
|
8b2c2b32b2 | ||
|
|
2063d29d16 | ||
|
|
4df0ae4811 | ||
|
|
acdc0943c0 | ||
|
|
514a585de2 | ||
|
|
ca288dec72 | ||
|
|
66ad9f5c85 | ||
|
|
61adfc7818 | ||
|
|
880f923462 | ||
|
|
03591d6fbd | ||
|
|
1d8432b8ef | ||
|
|
d7132536a8 | ||
|
|
8911d4a242 | ||
|
|
6130a13b6b | ||
|
|
b8bdff2f1f | ||
|
|
997419ea6b | ||
|
|
253ca82b99 | ||
|
|
3664f036cc | ||
|
|
5c524fb92c | ||
|
|
a66ecfab98 | ||
|
|
dac02e5253 | ||
|
|
0307acf859 | ||
|
|
36d35276b7 | ||
|
|
904b495613 | ||
|
|
29ec202563 | ||
|
|
e29695eb3f | ||
|
|
6eff5a4b9b | ||
|
|
55592a1f08 | ||
|
|
a927903a66 | ||
|
|
2041819df5 | ||
|
|
732fc66670 | ||
|
|
0d52ee5948 | ||
|
|
fb91168614 | ||
|
|
f561e6bb09 | ||
|
|
8de52f65bd | ||
|
|
bdcc32a095 | ||
|
|
59b42c7579 | ||
|
|
fd3dbb2fc6 | ||
|
|
b15d003217 | ||
|
|
757f6ef24f | ||
|
|
d56af7e036 | ||
|
|
a3715f618f | ||
|
|
cf77e19765 | ||
|
|
6b2339e50a | ||
|
|
16a60a9175 | ||
|
|
12b749d7d8 | ||
|
|
7359fbc093 | ||
|
|
59add49408 | ||
|
|
11d89435f5 | ||
|
|
6f5398770b | ||
|
|
e4ee999098 | ||
|
|
fa9a98f078 | ||
|
|
71f6dfa1eb | ||
|
|
4521090967 | ||
|
|
9adbdfbcc1 | ||
|
|
4e2703295c | ||
|
|
b998a0c073 | ||
|
|
30090e741f | ||
|
|
eef45eda65 | ||
|
|
8246093af1 | ||
|
|
596a5c925c | ||
|
|
62855915fc | ||
|
|
6be5e0d81f | ||
|
|
6296f032f0 | ||
|
|
2a0f942234 | ||
|
|
18c673811f | ||
|
|
5099a46581 | ||
|
|
3bdaba2c60 | ||
|
|
c5c80d5ab6 | ||
|
|
e06c12c263 | ||
|
|
6cd477a037 | ||
|
|
a8c71fef12 | ||
|
|
1b1f2718b3 | ||
|
|
42021b351e | ||
|
|
03d04431f2 | ||
|
|
12fbf2aa67 | ||
|
|
7eabbdcd45 | ||
|
|
57d0073b40 | ||
|
|
601ae83504 | ||
|
|
cc04a5570c | ||
|
|
337033b350 | ||
|
|
afdee17ec0 | ||
|
|
5f688d6131 | ||
|
|
feda5cd061 | ||
|
|
ef74579f2c | ||
|
|
137e4e7c60 | ||
|
|
ecf4eb1211 | ||
|
|
2902ab6077 | ||
|
|
25c4dd50b3 | ||
|
|
9e0d2f6cdb | ||
|
|
e946d99ea3 | ||
|
|
0c36ea0e12 | ||
|
|
eab8a1ae5f | ||
|
|
70d66c2fae | ||
|
|
b65f98451d | ||
|
|
e48e8dca42 | ||
|
|
8da57487c0 | ||
|
|
3d81b22850 | ||
|
|
19a6af1c6d | ||
|
|
9ea4ccb9b1 | ||
|
|
b6800a48c6 | ||
|
|
afb38f3c3f | ||
|
|
25ffd62e14 | ||
|
|
5d098c9762 | ||
|
|
f4c139815c | ||
|
|
69ab3e9a90 | ||
|
|
68340eb616 | ||
|
|
f7f8ff53c0 | ||
|
|
275d4df35d | ||
|
|
e3fd48c3b9 | ||
|
|
a4d8fa9f8b | ||
|
|
1574263836 | ||
|
|
08be508501 | ||
|
|
9e70f4339a | ||
|
|
f0da6e5110 | ||
|
|
d786b88030 | ||
|
|
6acb5297e5 | ||
|
|
bb9fb9ffe6 | ||
|
|
8e7835fe86 | ||
|
|
d0d0b2cdda | ||
|
|
31996491be | ||
|
|
e0a1f15777 | ||
|
|
371aa3dbcb | ||
|
|
5f8b7baa06 | ||
|
|
37c37c75c8 | ||
|
|
70bbdd5b7b | ||
|
|
da56f261cd | ||
|
|
8a66af81b1 | ||
|
|
e83dff6ac7 | ||
|
|
79c390ad58 | ||
|
|
a4a4306154 | ||
|
|
f3be06a3e0 | ||
|
|
b5d7be7d28 | ||
|
|
666add1cf9 | ||
|
|
cc02037f78 | ||
|
|
fd9a070efc | ||
|
|
7e6c7a4b9d | ||
|
|
cd749733de | ||
|
|
571a5544a3 | ||
|
|
fa7a7b8630 | ||
|
|
f4a3b58959 | ||
|
|
e19224c4b7 | ||
|
|
d965a82cfd | ||
|
|
7f9649f309 | ||
|
|
20eb524a51 | ||
|
|
bc596878d6 | ||
|
|
aa77cece23 | ||
|
|
ad5e0812bf | ||
|
|
04e0111756 | ||
|
|
b85e021cbe | ||
|
|
e82cefe493 | ||
|
|
47723c7c0f | ||
|
|
b9cb5555e2 | ||
|
|
858061452c | ||
|
|
83f4e1a3de | ||
|
|
b2ec00754a | ||
|
|
94c67b5c51 | ||
|
|
6449daf823 | ||
|
|
974d7b1e43 | ||
|
|
8e17401f37 | ||
|
|
539030d2b6 | ||
|
|
d88e5cffce | ||
|
|
a5d787b279 | ||
|
|
b4e496e9cb | ||
|
|
9fc20d5482 | ||
|
|
271a77fd86 | ||
|
|
2ec8fcb886 | ||
|
|
405d6b94d7 | ||
|
|
176d146b8e | ||
|
|
28526bf43e | ||
|
|
caffcb90a9 | ||
|
|
222c3790dc | ||
|
|
b6e81bab6b | ||
|
|
1c26f7db9b | ||
|
|
0ba3df2fce | ||
|
|
9098d8dfdf | ||
|
|
20d0d84240 | ||
|
|
eb385fe906 | ||
|
|
ecc83ed64a | ||
|
|
c319986738 | ||
|
|
a8f8ff2b6d | ||
|
|
ca8127ce83 | ||
|
|
e02c463be7 | ||
|
|
aa828ca1eb | ||
|
|
df1ffbc6bc | ||
|
|
8b2a964bfb | ||
|
|
07ffd8ddce | ||
|
|
d1225845ec | ||
|
|
4d7ea28a61 | ||
|
|
8a94c541cb | ||
|
|
b223ce82d6 | ||
|
|
1e8f504f15 | ||
|
|
94395faeef | ||
|
|
579c61879d | ||
|
|
41837180f8 | ||
|
|
a10e32a012 | ||
|
|
980d37b9c2 | ||
|
|
f2958bef6e | ||
|
|
0e1ad4ed45 | ||
|
|
76d2da5bf9 | ||
|
|
46f5a73c47 | ||
|
|
b610a128de | ||
|
|
f0ac0c42c9 | ||
|
|
105660bd09 | ||
|
|
2ccfc38cdf | ||
|
|
f551cd8c7a | ||
|
|
d2092bf0b6 | ||
|
|
97168b19d8 | ||
|
|
8b0c99593e | ||
|
|
9e32efdd90 | ||
|
|
6eaf484fcd | ||
|
|
53078b4281 | ||
|
|
49d216cc13 | ||
|
|
9a3b408de0 | ||
|
|
68b584128c | ||
|
|
b5bc77b153 | ||
|
|
ffed9bf898 | ||
|
|
de0eddeee8 | ||
|
|
4991536065 | ||
|
|
7ace1ded56 | ||
|
|
5af79df4bd | ||
|
|
6eb53751a3 | ||
|
|
09e81b429c | ||
|
|
179a20fc38 | ||
|
|
f86fab838a | ||
|
|
6a0f51f136 | ||
|
|
7d9eaa7f43 | ||
|
|
bfc2a4dbd4 | ||
|
|
0613d1095e | ||
|
|
3d7990250f | ||
|
|
2c3efad70a | ||
|
|
cdcf3e0f4f | ||
|
|
3d6216910f | ||
|
|
505188c3fd | ||
|
|
477971dda1 | ||
|
|
2f1bda8369 | ||
|
|
249b182e7f | ||
|
|
936412f351 | ||
|
|
0cc4eae209 | ||
|
|
f2a980e5c1 | ||
|
|
47ce84d122 | ||
|
|
01385c4bc7 | ||
|
|
9b09663b2e | ||
|
|
838bd93bc4 | ||
|
|
60d45613c7 | ||
|
|
4ddfc6fb99 | ||
|
|
df0fbc1c7f | ||
|
|
4795ab958c | ||
|
|
1288c8937c | ||
|
|
ba8e3595e4 | ||
|
|
17d81b0b94 | ||
|
|
c9792b1f63 | ||
|
|
a213be67c5 | ||
|
|
0122ad314b | ||
|
|
ea615b864a | ||
|
|
a4189a0074 | ||
|
|
314ce09b5d | ||
|
|
942dbace19 | ||
|
|
b386f35ab4 | ||
|
|
d6f47e650c | ||
|
|
bef91151a3 | ||
|
|
bf1dc1f209 | ||
|
|
aa583efa4a | ||
|
|
a4429f95b7 | ||
|
|
ff6a8498a6 | ||
|
|
ca9e7b60e8 | ||
|
|
0aa77327d5 | ||
|
|
5c83b81e8a | ||
|
|
c9ee319e9d | ||
|
|
4581c8a294 | ||
|
|
9a15e78b2e | ||
|
|
bfcb80b326 | ||
|
|
0cf76df168 | ||
|
|
ba641754de | ||
|
|
81ef43c9fd | ||
|
|
0605c32829 | ||
|
|
66816d5341 | ||
|
|
de101621dd | ||
|
|
b4db37392d | ||
|
|
b1ab909184 | ||
|
|
775dd71227 | ||
|
|
3339ec91f9 | ||
|
|
ac6d3289a6 | ||
|
|
e6ee9cb442 | ||
|
|
da32a67d7f | ||
|
|
969ff05815 | ||
|
|
7f565a24d6 | ||
|
|
1e389d95dd | ||
|
|
f53ff76143 | ||
|
|
f87647d4eb | ||
|
|
833f928a30 | ||
|
|
621aec9b2b | ||
|
|
f72238e43f | ||
|
|
7a15481e2d | ||
|
|
2dca7dfdb2 | ||
|
|
1c51373704 | ||
|
|
9664aa4bf0 | ||
|
|
603bd29cc2 | ||
|
|
0afe75379e | ||
|
|
0ea4aeecc9 | ||
|
|
5ddaa4d51f | ||
|
|
8a7c92bb0f | ||
|
|
4f2b670ebf | ||
|
|
66b9c3aa77 | ||
|
|
bb9673358d | ||
|
|
6b5f2f0565 | ||
|
|
fe68551124 | ||
|
|
f998bed09c | ||
|
|
1e5ce54858 | ||
|
|
b34fbb2bd1 | ||
|
|
a6f266687e | ||
|
|
6a83008ba6 | ||
|
|
dd117b8fc2 | ||
|
|
a719a903cd | ||
|
|
3b1b4ab9b2 | ||
|
|
9d54c57b7c | ||
|
|
e72ac14425 | ||
|
|
ec8f84c1c0 | ||
|
|
2b7f66cb04 | ||
|
|
c7061b8a74 | ||
|
|
5250f304be | ||
|
|
f5f72d681d | ||
|
|
c226b6a99b | ||
|
|
600b2a2dc4 | ||
|
|
5f341d6fb5 | ||
|
|
76c24867a4 | ||
|
|
8d39c61863 | ||
|
|
61a97cd057 | ||
|
|
ecf48886d5 | ||
|
|
1db3e48506 | ||
|
|
0f2e201c38 | ||
|
|
adabec13d3 | ||
|
|
0fb51211aa | ||
|
|
d7bff980ca | ||
|
|
ed3c1dafcc | ||
|
|
0a3ec0b8a6 | ||
|
|
3b38fdd33d | ||
|
|
7e0911951d | ||
|
|
a79999d97c | ||
|
|
daf0ee2acd | ||
|
|
086a0f9c89 | ||
|
|
c80307a838 | ||
|
|
575cee7a86 | ||
|
|
c1b136c3d6 | ||
|
|
4612181ab0 | ||
|
|
b4c46280de | ||
|
|
15b563610c | ||
|
|
079ff43f73 | ||
|
|
554b6c93b7 | ||
|
|
817e356c21 | ||
|
|
9b6e257f8d | ||
|
|
ca12f58d38 | ||
|
|
8fb63e9adc | ||
|
|
0103ea96c8 | ||
|
|
3975759458 | ||
|
|
d736fdba1b | ||
|
|
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 | ||
|
|
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 | ||
|
|
87eb1aa72f | ||
|
|
36a048a2bc | ||
|
|
261a8bf2df | ||
|
|
06c264db0a | ||
|
|
ce6963a25e | ||
|
|
91e781b3ff | ||
|
|
66089f9b99 | ||
|
|
0bc752a2be |
@@ -1,4 +1,5 @@
|
||||
target/
|
||||
node_modules/
|
||||
.docker-context/
|
||||
*.log
|
||||
.idea/
|
||||
@@ -13,6 +14,7 @@ logs/
|
||||
data/
|
||||
.env
|
||||
.env.*
|
||||
.env.development
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
application-local.yml
|
||||
@@ -27,5 +29,6 @@ reports/
|
||||
uploads/
|
||||
.codex-temp*
|
||||
.codex-tmp*
|
||||
.runtime-*/
|
||||
.codex-*.out
|
||||
.codex-*.err
|
||||
|
||||
+56
-2
@@ -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
|
||||
@@ -40,10 +93,11 @@ cache:
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,67 +1,164 @@
|
||||
# 国内头程后端项目指引
|
||||
# 头程与海外仓后端项目指引
|
||||
|
||||
> 当前为单 TEST、统一 Gateway/Auth 会话/Nacos 与业务 Outbox 直接投递。架构规则及部署/验收状态唯一见 `../docs/测试环境单机部署与配置契约.md`、`../docs/统一认证与服务通信契约.md`。模块旧双区域说明不能覆盖这两份契约;构建、部署与整体验收分别核对。
|
||||
|
||||
根 POM 当前聚合 20 个模块(7 个公共/依赖模块、13 个服务),部署映射见 `deploy/service-map.yml`。`gateway-overseas/` 与 `event-bridge-service/` 为退役待清理源码,不是当前构建或部署单元。
|
||||
|
||||
## 适用范围与定位
|
||||
|
||||
- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库;进入具体服务后继续读取该服务的 `AGENTS.md`。
|
||||
- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。
|
||||
- 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。
|
||||
- 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。
|
||||
- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;统一 Token、API 和可靠直接投递公共契约见 `../docs/统一认证与服务通信契约.md`。本文件只保留后端执行所需摘要。
|
||||
|
||||
## 不可突破的产品与数据边界
|
||||
|
||||
- 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
|
||||
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
|
||||
- 订单交易状态归 `order`,履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,仓储资源归 `warehouse`,文件对象归 `file`,OCR 适配归 `integration-service`;通过内部客户端、DTO 和服务令牌协作。
|
||||
- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,所有端公网入口归 `gateway`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。
|
||||
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
|
||||
|
||||
## 服务路由(按关键词定位)
|
||||
|
||||
| 任务关键词 | 首选服务 | 先看入口 |
|
||||
|---|---|---|
|
||||
| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` |
|
||||
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
|
||||
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
|
||||
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
|
||||
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
|
||||
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 |
|
||||
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
|
||||
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
|
||||
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
|
||||
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
|
||||
| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` |
|
||||
| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` |
|
||||
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` |
|
||||
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
|
||||
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
|
||||
| PDF 标签、审计、事件 | `print/`、`audit-service/`、`event-bridge-service/` | 各服务指引 |
|
||||
本表只保留定位入口;命中业务后用 `rg -n -F '关键词' docs/backend-business-index.md` 读取对应行/段的详细约束,再沿代码引用追踪;不默认全文加载详细索引。已知目标文件时直接定位,并定向核对该业务约束。
|
||||
|
||||
### 后端任务最小读取集
|
||||
|
||||
- 单服务 Controller/Service/Mapper 任务:本文件、目标服务 `AGENTS.md`、目标类及其直接调用点。
|
||||
- 跨服务 API/事件任务:在上述基础上增加直接调用方、被调用方及受影响的 OpenAPI/事件契约;公开路径、访问域或入口鉴权变化才增加 Gateway,内部调用和事件不经公网 Gateway。不因业务关键词自动加载整个业务专题。
|
||||
- 只有状态、权限、数据库或验收边界无法从上述入口确定时,才读取 `docs/业务快速索引.md`、专题 README 或完整契约。
|
||||
- 读取预算遵循工作区根指引:超过约 250 行或 8000 字符时先读职责、硬边界和验证章节,再检索任务命中段;不得仅因行数少而全文加载长业务表。聚合构建或架构任务才读取相关 `README.md`、`pom.xml` 和目录结构文档,已知单模块任务不增加概览文档。
|
||||
|
||||
| 任务关键词 | 首选服务 |
|
||||
|---|---|
|
||||
| 登录、Token、密码、注册与顶层授权快照 | `auth/` |
|
||||
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` |
|
||||
| 客户建档默认箱规与权重系数初始化及补偿 | `iam/`、`firstleg/` |
|
||||
| 消息中心、站内通知、消息模板、收件箱 | `iam/` |
|
||||
| 订单、货物、快照、状态、幂等、Outbox | `order/` |
|
||||
| 客户头程订单中文批量导入 | `order/` |
|
||||
| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` |
|
||||
| 平台订单批量审核预览 | `order/` |
|
||||
| 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` |
|
||||
| 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` |
|
||||
| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` |
|
||||
| 客户专属价格簿、柜级阶梯运费与受控调价 | `firstleg/` |
|
||||
| Firstleg 内部模块化与依赖基线 | `firstleg/` |
|
||||
| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 |
|
||||
| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` |
|
||||
| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` |
|
||||
| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` |
|
||||
| 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` |
|
||||
| 客户入库结果与头程费用确认 | `firstleg/` |
|
||||
| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` |
|
||||
| 目的港码头拖车与还空预警 | `firstleg/` |
|
||||
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` |
|
||||
| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` |
|
||||
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` |
|
||||
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`finance/` |
|
||||
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` |
|
||||
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` |
|
||||
| 供应商、国内整柜库存与出入库 | `warehouse/` |
|
||||
| 客户运营统计、在途柜量 | `firstleg/` |
|
||||
| 平台操作日志与审计查询 | `audit-service/` |
|
||||
| 资质、导出、导出退避重试、测量图片、文件下载 | `file/` |
|
||||
| 路由、CORS、JWT 入口、访问域 | `gateway/` |
|
||||
| 百度/Amazon/OCR/第三方适配 | `integration-service/` |
|
||||
| 统一 API 路由、Token、Audience/JWKS | `gateway/`、`auth/`、`common-security/` |
|
||||
| 统一会话与账号/客户权限撤销 | `auth/`、`iam/`、`common-security/` |
|
||||
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` |
|
||||
| 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` |
|
||||
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` |
|
||||
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` |
|
||||
| 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` |
|
||||
| 海外异常退回、库存恢复与客户回访 | `lastleg-service/`、`file/` |
|
||||
| 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` |
|
||||
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` |
|
||||
| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` |
|
||||
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` |
|
||||
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` |
|
||||
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` |
|
||||
| 自营领星采集、经营统计、SKU 日销量 | `lastleg-service/selfoperated/` 查询与采集编排;领星签名/分页/外部接口查 `integration-service/`,不因采集页面任务同时读取两服务 |
|
||||
| 跨客户同仓预约、预约跟进、到港日历 | `lastleg-service/inboundplan/`、`appointmentplanning/`;到港事实查 `firstleg/portschedule/` |
|
||||
| 车辆、司机证照、车次、外接预订、车队报价、出车记录 | `lastleg-service/fleet/`;先读 `lastleg-service/AGENTS.md` 对应入口,客户排车关联再沿 `dispatch/fleet/` 实际调用 |
|
||||
| PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` |
|
||||
|
||||
## 公共模块快速索引
|
||||
|
||||
| 关键词 | 模块与入口 |
|
||||
|---|---|
|
||||
| 公共依赖版本 | `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` |
|
||||
|
||||
## 全量验收路由
|
||||
|
||||
- 当前一期、二期、三期及财务统一进入全量验收;按本次链路读取对应阶段/专题文档,不以历史阶段终点阻断跨服务联调或回归。
|
||||
- 当前订单与客户、入库测量与计费、国内货柜履约、海外仓业务及财务统一进入全量验收;按本次链路读取对应业务域/专题文档,不以历史规划终点阻断跨服务联调或回归。
|
||||
- `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。
|
||||
|
||||
## 国内头程与海外仓协同
|
||||
|
||||
- Auth 是唯一用户账号与 Token 签发方;目标为各端共用 Gateway 和 Redis 会话,保留 RS256/JWKS、客户隔离、权限撤销、PDA 仓绑定及服务身份校验。当前源码已移除旧海外会话验证与 Lastleg 安全投影,改为 Auth 权威会话校验;本地验证不等于测试环境部署验收。
|
||||
- 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;旧 Bridge 源码已退出聚合和发布清单,退役待清理;实际停机仍须替代链验证与授权,不得据源码状态认定已部署。
|
||||
- `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。
|
||||
|
||||
## 数据库访问与中文代码注释(所有服务及公共模块强制)
|
||||
|
||||
- 本节适用于全部后端服务和公共模块;新增、修改的方法必须执行,触及的存量代码同步整改。登记本约束不代表存量代码已全部完成迁移和注释补齐。
|
||||
- 生产代码的关系型数据库读写统一经 MyBatis/MyBatis-Plus Mapper 执行;Controller、Service、定时任务、消费者、编号生成器和公共基础组件不得直接使用 `JdbcTemplate`、`NamedParameterJdbcTemplate`、原生 JDBC/Connection/Statement 或其他通道执行 SQL。仅改成 Repository 包装 JDBC 不算整改,SQL 必须进入 Mapper;无数据库职责的模块不得为满足本条新增数据库访问。
|
||||
- 正常业务数据库访问路径统一为 `Controller → Service → Repository → Mapper`,四层职责明确,不得省略 Repository,也不得由 Controller 直接调用 Repository/Mapper,或由 Service 直接调用 Mapper。定时任务、消费者等非 HTTP 入口从 Service 进入,同样经 Repository → Mapper 完成持久化访问。
|
||||
- Controller 负责请求接收、参数校验、入口权限和响应转换;Service 负责业务规则、编排及业务事务;Repository 负责领域数据访问封装、查询组合与持久化模型转换,对 Service 提供有业务含义的数据操作;Mapper 负责 SQL 执行及数据库记录映射。Repository 不承载业务状态流转,不直接用 JDBC 执行 SQL;Service 不继承 MyBatis-Plus `ServiceImpl` 等方式绕过 Repository。
|
||||
- 复杂查询、批量处理、原子计数、锁定读取、Outbox 和数据库监控查询同样经 Repository → Mapper,不以 SQL 特殊或实现方便为由跳层。公共模块需要数据库能力时,按现有模块边界定义 Repository/持久化接口,由所属服务的 Repository → Mapper 实现,禁止借此跨服务读库。
|
||||
- 迁移数据库操作须保留客户隔离条件、事务传播、行锁、幂等和并发语义;`LAST_INSERT_ID()` 等连接级操作必须在同一事务连接完成,并明确关闭相关读取缓存,不能仅机械搬移 SQL。Mapper 注册及扫描仍遵守下文统一规范。
|
||||
- 测试代码可用 `JdbcTemplate` 创建隔离测试表、准备数据及核对结果;被测生产路径仍必须调用实际 Mapper,不得把测试造数做法带入生产。数据库基线和迁移脚本按现有部署契约管理。
|
||||
- 每个手写方法及构造器均须添加简短中文注释,通常 1—3 行说明业务用途;接口方法和 Mapper 方法优先使用中文 Javadoc。覆盖方法同样须有简短中文说明,实现中的关键规则仍须就地注释;自动生成的方法不手工逐个补注释。
|
||||
- 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。
|
||||
- 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。
|
||||
|
||||
## 契约、数据库与验证入口
|
||||
|
||||
- 对外 Controller/API 新增、改名或删除时,同时核对调用端、统一 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
|
||||
|
||||
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
|
||||
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。
|
||||
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。
|
||||
- 所有生产 MyBatis Mapper 接口必须显式标记 `@Mapper`;每个持久化服务只允许在启动类使用一次服务根包 `@MapperScan(..., annotationClass = Mapper.class)`,不得新增子域包白名单或分散的 `*MapperConfiguration`。新增、移动 Mapper 或修改扫描配置后运行 `node scripts/mapper-scan-contract.mjs`;全量后端回归已在 Maven 前执行该门禁时复用结果,输入未变不重复执行。
|
||||
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前正式权限契约不保留旧独立 `*:view`;Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。若外部已有不可变权限契约,先按当前对话建立单独兼容规则。
|
||||
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
|
||||
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
|
||||
- 数据库只访问所属服务库。模块迁移位于 `<module>/src/main/resources/db/`;测试/存量环境唯一部署入口是 `deploy/sql/头程数据库统一增量-MySQL8.sql`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。
|
||||
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||
- 全量验证:`mvn -DskipTests validate`、`mvn test`;单服务优先 `mvn -pl <module> -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。
|
||||
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
||||
- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||
- 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。
|
||||
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
|
||||
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
|
||||
|
||||
## 全量 HTTP 验收脚本路由
|
||||
|
||||
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期兼容场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`。
|
||||
- 脚本管理的唯一总索引是 `scripts/README.md`,最近执行规则是 `scripts/AGENTS.md`。仓库级脚本、Admin 内部模块、服务局部合同和部署脚本的目录归属以这两个文件为准,不再通过无范围搜索猜测入口。
|
||||
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;入库测量与计费专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。
|
||||
- Client 快速下单定向验收使用 `scripts/client-quick-order-test-env-acceptance.mjs`,只验证批量草稿接口、逐行隔离和幂等行为,不提交、不审核,也不替代 Client 全量入口。
|
||||
- 固定客户待审核订单使用 `scripts/create-pending-review-orders.mjs`;固定“IT测试”与“义乌仓”、随机真实线路并推进至 `WAIT_SPLIT` 使用 `scripts/it-test-wait-split-bulk.mjs`。二者都是造数入口,不属于全量验证;会写目标环境且不自动清理成功数据。
|
||||
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
|
||||
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
|
||||
- 订舱与拖车跨服务完整状态链由 Admin 注册模块 `service-center-flow` 验证,必须复用本轮已放舱订舱并由总入口展开依赖,不得直接执行模块文件或借历史数据。
|
||||
- 尾程公网控制由 `phase4-tail-order-controls` 在海外 PDA 前验证整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、暂扣/解除、八个人工节点和逐票人工计划;目的港码头拖车由 `phase5-terminal-drayage` 在卸柜后验证选车队、预约提柜、实际提柜和实际还柜,`phase4-carrier-nodes` 只读核验最终船节点终态。
|
||||
- 这些脚本会创建、更新或最终停用测试数据;指定测试环境和服务后,本轮非破坏性写入复用一次授权,删除、重置、SQL、部署或发布仍逐项授权,并保存 JSON 报告;未授权不得请求 Gateway。
|
||||
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
|
||||
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
|
||||
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
|
||||
- 修改脚本目录、入口或说明后,至少运行 `node --test scripts/script-documentation-contract.test.mjs`;修改 `AGENTS.md` 后继续运行 `./scripts/agents-index-contract.ps1`。两者均为离线只读合同,不能代替真实 HTTP 验收。
|
||||
|
||||
## 阶段文档入口
|
||||
## 业务域文档入口
|
||||
|
||||
- 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md`
|
||||
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
|
||||
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
|
||||
- 订单与客户:`../docs/订单与客户/README.md`、`../docs/订单与客户/订单与客户头程订单闭环设计.md`
|
||||
- 入库测量与计费:`../docs/入库测量与计费/README.md`;流程细节按 `../docs/头程到尾程/README.md` 指向的当前阶段文档读取,需求确认和联调报告仅在历史追溯或验收证据任务中读取
|
||||
- 国内货柜履约:`../docs/国内货柜履约/README.md`,报关规则变化再查 `../docs/国内货柜履约/报关资料与船期价格接入梳理.md`。规划、确认与验收材料按归档位置及文档角色判断,当前入口指定的有效规则仍按需核对,历史材料只用于追溯或证据任务
|
||||
- 海外仓业务:`../docs/海外仓业务/README.md`;统一入口 `gateway/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
|
||||
- 海外仓全流程:以正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外接柜、入仓、库存、库内服务、排车、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/`
|
||||
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
|
||||
|
||||
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
|
||||
全量验收需要证据时再读取对应阶段报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
|
||||
|
||||
Vendored
+248
-59
@@ -1,6 +1,6 @@
|
||||
def serviceModules = [
|
||||
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||
'integration-service', 'event-bridge-service', 'audit-service'
|
||||
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'audit-service'
|
||||
]
|
||||
|
||||
def serviceCheckboxes = [
|
||||
@@ -14,23 +14,128 @@ def serviceCheckboxes = [
|
||||
'file': 'SERVICE_FILE',
|
||||
'print': 'SERVICE_PRINT',
|
||||
'integration-service': 'SERVICE_INTEGRATION',
|
||||
'event-bridge-service': 'SERVICE_EVENT_BRIDGE',
|
||||
'lastleg-service': 'SERVICE_LASTLEG',
|
||||
'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS',
|
||||
'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', 'config/nacos/test/', 'deploy/scripts/', 'Jenkinsfile'
|
||||
]
|
||||
|
||||
def commonPathServices = [
|
||||
'common-web/': [
|
||||
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
|
||||
'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',
|
||||
'audit-service'
|
||||
],
|
||||
'common-audit/': [
|
||||
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
|
||||
'warehouse-overseas-service', 'audit-service'
|
||||
],
|
||||
'common-event-contract/': [
|
||||
'firstleg', 'finance', 'lastleg-service'
|
||||
],
|
||||
'deploy/docker-compose.audit.yml': ['audit-service'],
|
||||
]
|
||||
|
||||
def databaseMigrationPaths = ['deploy/sql/']
|
||||
// 暂停数据库结构只读门禁;保留实现,完成 Credentials 配置后改为 true。
|
||||
def databaseSchemaContractEnabled = false
|
||||
|
||||
def downstreamResults = [:]
|
||||
def downstreamBuildUrls = [:]
|
||||
def downstreamBuildNumbers = [:]
|
||||
|
||||
def parseAutoBuildState(String description, List<String> allowedServices) {
|
||||
if (!description?.startsWith('AUTO ')) return null
|
||||
int markerIndex = description.indexOf(' | ')
|
||||
if (markerIndex < 0) return null
|
||||
String commit = description.substring('AUTO '.length(), markerIndex).trim()
|
||||
if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null
|
||||
String state = description.substring(markerIndex + ' | '.length()).trim()
|
||||
List<String> failedServices = []
|
||||
if (state.startsWith('RESULTS')) {
|
||||
String summary = state.substring('RESULTS'.length()).trim()
|
||||
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
|
||||
}
|
||||
}
|
||||
} else if (state.startsWith('PENDING')) {
|
||||
// 上一轮若在统一测试、排队或并行部署期间失败/取消,description 仍是 PENDING。
|
||||
// 将该轮已选服务作为精确补跑范围,同时让 Git 基线前进到该轮提交,
|
||||
// 避免回退到很早的成功构建后把历史公共文件再次解释成全量变更。
|
||||
String selected = state.substring('PENDING'.length()).trim()
|
||||
int suffixIndex = selected.indexOf(' | ')
|
||||
if (suffixIndex >= 0) selected = selected.substring(0, suffixIndex).trim()
|
||||
failedServices.addAll(selected.split(',').collect { it.trim() }
|
||||
.findAll { allowedServices.contains(it) })
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
return [commit: commit, failedServices: failedServices.unique()]
|
||||
}
|
||||
|
||||
def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
|
||||
def candidate = previousBuild
|
||||
int inspected = 0
|
||||
while (candidate != null && inspected < 30) {
|
||||
def state = parseAutoBuildState(candidate.description, allowedServices)
|
||||
// 完成的 RESULTS 和中断留下的 PENDING 都能推进 AUTO 基线;PENDING 中的服务会被精确补跑。
|
||||
if (state != null) return state
|
||||
candidate = candidate.previousBuild
|
||||
inspected++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
def environmentChoicesForJob(String jobBaseName) {
|
||||
return [fixedEnvironmentForJob(jobBaseName) ?: 'test']
|
||||
}
|
||||
|
||||
def fixedEnvironmentForJob(String jobBaseName) {
|
||||
switch (jobBaseName) {
|
||||
case 'backend-java':
|
||||
return 'test'
|
||||
case 'backend-java-prod':
|
||||
return 'production'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
def fixedDownstreamJobPrefixForJob(String jobBaseName) {
|
||||
switch (jobBaseName) {
|
||||
case 'backend-java':
|
||||
return 'baiyue-firstleg-'
|
||||
case 'backend-java-prod':
|
||||
return 'baiyue-firstleg-prod-'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
def downstreamJobPrefixChoicesForJob(String jobBaseName) {
|
||||
return [fixedDownstreamJobPrefixForJob(jobBaseName) ?: 'baiyue-firstleg-']
|
||||
}
|
||||
|
||||
pipeline {
|
||||
// 总控流水线只识别变更并调度独立的服务 Job;实际 Maven、镜像和部署在 Jenkinsfile.service 中完成。
|
||||
agent any
|
||||
agent none
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
@@ -42,7 +147,7 @@ pipeline {
|
||||
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_AUTH', defaultValue: false, description: '统一认证服务(auth)')
|
||||
booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)')
|
||||
booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '客户与权限服务(iam)')
|
||||
booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)')
|
||||
booleanParam(name: 'SERVICE_FINANCE', defaultValue: false, description: '财务服务(finance)')
|
||||
booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)')
|
||||
@@ -50,43 +155,57 @@ pipeline {
|
||||
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_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')
|
||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)')
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库地址 host[:port]/owner/image')
|
||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
|
||||
}
|
||||
|
||||
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://')
|
||||
}
|
||||
String resolvedEnvironment = fixedEnvironmentForJob(env.JOB_BASE_NAME)
|
||||
String resolvedJobPrefix = fixedDownstreamJobPrefixForJob(env.JOB_BASE_NAME)
|
||||
if (!resolvedEnvironment || !resolvedJobPrefix) {
|
||||
error("未配置总控 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续")
|
||||
}
|
||||
if (params.ENVIRONMENT != resolvedEnvironment ||
|
||||
params.DOWNSTREAM_JOB_PREFIX?.trim() != resolvedJobPrefix) {
|
||||
error('部署环境和下游前缀必须与当前 Job 固定映射一致')
|
||||
}
|
||||
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
|
||||
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
|
||||
|
||||
// 先记录当前提交,使后续生产校验失败时通知仍能定位版本。
|
||||
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
||||
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||
|
||||
|
||||
|
||||
String requestedRepository = params.IMAGE_REPOSITORY?.trim()
|
||||
if (!requestedRepository) {
|
||||
error('必须填写真实镜像仓库地址')
|
||||
}
|
||||
if (requestedRepository.tokenize('/')[0] ==~ /(?i)(?:[a-z0-9-]+\.)*example\.(?:com|net|org)(?::[0-9]+)?/) {
|
||||
error('IMAGE_REPOSITORY 不能使用 example 示例域名,请填写已核实的镜像仓库地址')
|
||||
}
|
||||
if (requestedRepository.contains('://') || !requestedRepository.contains('/')) {
|
||||
error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://')
|
||||
}
|
||||
echo "${env.RESOLVED_ENVIRONMENT} 环境使用共享镜像仓库: ${requestedRepository}"
|
||||
env.RESOLVED_IMAGE_REPOSITORY = requestedRepository
|
||||
|
||||
String jobPrefix = params.DOWNSTREAM_JOB_PREFIX?.trim()
|
||||
if (!jobPrefix) {
|
||||
error('DOWNSTREAM_JOB_PREFIX 不能为空')
|
||||
}
|
||||
env.RESOLVED_JOB_PREFIX = jobPrefix
|
||||
env.RESOLVED_JOB_PREFIX = resolvedJobPrefix
|
||||
env.DATABASE_CHANGE_DETECTED = 'false'
|
||||
|
||||
|
||||
List selected = []
|
||||
if (params.BUILD_MODE == 'ALL') {
|
||||
selected = serviceModules
|
||||
@@ -98,9 +217,18 @@ 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) {
|
||||
// 首次启用或旧构建记录已淘汰时,只比较当前提交的直接父提交。
|
||||
// 需要初始化全部服务时应显式使用 ALL,不能因历史成功基线过旧而意外全量部署。
|
||||
base = sh(script: 'git rev-parse HEAD^ 2>/dev/null || true', returnStdout: true).trim()
|
||||
if (base) echo "AUTO 未找到历史记录,本次使用直接父提交作为基线: ${base}"
|
||||
}
|
||||
if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) {
|
||||
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 }
|
||||
@@ -108,13 +236,27 @@ pipeline {
|
||||
databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
||||
}
|
||||
env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false'
|
||||
boolean commonChanged = changed.any { path ->
|
||||
boolean databaseMigration = databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
||||
!databaseMigration && commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
|
||||
List serviceRelevantChanges = changed.findAll { path ->
|
||||
!databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
||||
}
|
||||
selected = commonChanged ? serviceModules : serviceModules.findAll { module ->
|
||||
changed.any { it == module || it.startsWith("${module}/") }
|
||||
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(',') ?: '无'}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,17 +266,20 @@ pipeline {
|
||||
}
|
||||
|
||||
selected = selected.unique()
|
||||
|
||||
env.SELECTED_SERVICES = selected.join(',')
|
||||
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
||||
if (!selected) {
|
||||
currentBuild.description = env.DATABASE_CHANGE_DETECTED == 'true'
|
||||
? '仅数据库迁移变更 | 需单独审核执行'
|
||||
: '没有需要构建的业务服务'
|
||||
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()
|
||||
String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : ''
|
||||
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
|
||||
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') {
|
||||
@@ -144,25 +289,67 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
stage('生产环境部署确认(仅 production 执行)') {
|
||||
stage('Gateway 路由静态门禁') {
|
||||
when {
|
||||
allOf {
|
||||
expression { params.DEPLOY && params.ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' }
|
||||
}
|
||||
expression { env.NO_SERVICE_CHANGE != 'true' }
|
||||
}
|
||||
agent { label 'java-builder' }
|
||||
steps {
|
||||
input message: "确认将 ${env.SELECTED_SERVICES} 的镜像 ${env.IMAGE_TAG} 分别部署到生产环境?", ok: '确认部署'
|
||||
sh 'node --test scripts/gateway-route-contract.test.mjs'
|
||||
}
|
||||
}
|
||||
|
||||
stage('测试数据库初始化(可选)') {
|
||||
stage('数据库结构只读门禁') {
|
||||
when {
|
||||
allOf {
|
||||
expression { params.ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE }
|
||||
expression { databaseSchemaContractEnabled }
|
||||
}
|
||||
agent { label 'java-builder' }
|
||||
steps {
|
||||
script {
|
||||
String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-schema-readonly"
|
||||
withCredentials([file(credentialsId: credentialId, variable: 'SCHEMA_CHECK_ENV_FILE')]) {
|
||||
sh '''
|
||||
set -eu
|
||||
set -a
|
||||
. "$SCHEMA_CHECK_ENV_FILE"
|
||||
set +a
|
||||
test "${SCHEMA_CHECK_READ_ONLY:-}" = "true"
|
||||
node scripts/database-schema-contract.mjs --region BOTH
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('统一 Maven 测试门禁') {
|
||||
when {
|
||||
expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS }
|
||||
}
|
||||
agent { label 'java-builder' }
|
||||
tools { maven 'maven-3.8.8' }
|
||||
steps {
|
||||
sh 'sh deploy/scripts/initialize-test-databases.sh'
|
||||
script {
|
||||
String selectedModules = env.SELECTED_SERVICES.split(',')
|
||||
.collect { it.trim() }
|
||||
.findAll { it }
|
||||
.join(',')
|
||||
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
|
||||
lock(resource: 'firstleg-test-maven') {
|
||||
timeout(time: 50, unit: 'MINUTES') {
|
||||
sh """
|
||||
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
|
||||
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
|
||||
sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
|
||||
"""
|
||||
}
|
||||
}
|
||||
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,14 +370,17 @@ pipeline {
|
||||
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: '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)
|
||||
]
|
||||
)
|
||||
downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN'
|
||||
downstreamBuildUrls[service] = downstreamBuild.absoluteUrl ?: ''
|
||||
downstreamBuildNumbers[service] = downstreamBuild.number?.toString() ?: ''
|
||||
echo "${service} 执行结果: ${downstreamResults[service]} (${downstreamBuild.absoluteUrl})"
|
||||
}
|
||||
}
|
||||
@@ -207,7 +397,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(', ')}")
|
||||
}
|
||||
@@ -216,7 +408,4 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always { cleanWs(deleteDirs: true, notFailBuild: true) }
|
||||
}
|
||||
}
|
||||
|
||||
+187
-107
@@ -1,6 +1,6 @@
|
||||
def serviceModules = [
|
||||
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||
'integration-service', 'event-bridge-service', 'audit-service'
|
||||
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'audit-service'
|
||||
]
|
||||
|
||||
def serviceJars = [
|
||||
@@ -14,17 +14,26 @@ def serviceJars = [
|
||||
'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',
|
||||
'event-bridge-service': 'event-bridge-service/target/event-bridge-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',
|
||||
'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'
|
||||
def fixedEnvironmentForServiceJob(String jobBaseName) {
|
||||
if (jobBaseName?.startsWith('baiyue-firstleg-prod-')) return 'production'
|
||||
if (jobBaseName?.startsWith('baiyue-firstleg-') && !jobBaseName.startsWith('baiyue-firstleg-dev-') && !jobBaseName.startsWith('baiyue-firstleg-prod-')) {
|
||||
return 'test'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
def environmentChoicesForServiceJob(String jobBaseName) {
|
||||
return [fixedEnvironmentForServiceJob(jobBaseName) ?: 'test']
|
||||
}
|
||||
|
||||
pipeline {
|
||||
// 构建在任意 java-builder 节点执行,部署再回到目标环境节点。
|
||||
agent none
|
||||
|
||||
options {
|
||||
skipDefaultCheckout(true)
|
||||
@@ -36,117 +45,152 @@ 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: environmentChoicesForServiceJob(env.JOB_BASE_NAME), description: '由当前单服务 Job 固定的部署环境')
|
||||
booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭')
|
||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
||||
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 基础镜像')
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库基础路径,不包含服务名和标签')
|
||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||
}
|
||||
|
||||
environment {
|
||||
REGISTRY_CREDENTIALS = 'gitea-test-token'
|
||||
MAVEN_SETTINGS = 'deploy/maven/settings-cn.xml'
|
||||
MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3'
|
||||
REGISTRY_CREDENTIALS = 'gitea-registry-token'
|
||||
MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml'
|
||||
}
|
||||
|
||||
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()
|
||||
String resolvedEnvironment = fixedEnvironmentForServiceJob(env.JOB_BASE_NAME)
|
||||
|
||||
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(', ')}")
|
||||
}
|
||||
if (!resolvedEnvironment) {
|
||||
error("未配置单服务 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续")
|
||||
}
|
||||
if (params.ENVIRONMENT != resolvedEnvironment) {
|
||||
error('部署环境必须与当前单服务 Job 固定映射一致')
|
||||
}
|
||||
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.tokenize('/')[0] ==~ /(?i)(?:[a-z0-9-]+\.)*example\.(?:com|net|org)(?::[0-9]+)?/) {
|
||||
error('IMAGE_REPOSITORY 不能使用 example 示例域名,请填写已核实的镜像仓库地址')
|
||||
}
|
||||
if (!imageRepository.contains('/')) {
|
||||
error('IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
|
||||
}
|
||||
|
||||
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_ENVIRONMENT = resolvedEnvironment
|
||||
env.RESOLVED_SOURCE_COMMIT = sourceCommit
|
||||
env.RESOLVED_IMAGE_TAG = imageTag
|
||||
env.RESOLVED_IMAGE_REPOSITORY = imageRepository
|
||||
|
||||
|
||||
}
|
||||
|
||||
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}")"
|
||||
if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || {
|
||||
echo "灰度发布提交必须已经合并到 main" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
'''
|
||||
|
||||
|
||||
script {
|
||||
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${env.RESOLVED_ENVIRONMENT} | ${env.NODE_NAME}"
|
||||
echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT},节点: ${env.NODE_NAME}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
script {
|
||||
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${params.ENVIRONMENT}"
|
||||
echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Maven 测试与打包') {
|
||||
steps {
|
||||
script {
|
||||
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) {
|
||||
error('生产环境禁止跳过测试')
|
||||
boolean skipTestExecution = params.SKIP_TESTS
|
||||
String testOption = skipTestExecution ? '-DskipTests' : ''
|
||||
if (params.SKIP_TESTS && coordinatorPinnedCommit) {
|
||||
echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包"
|
||||
}
|
||||
lock(resource: 'firstleg-test-maven') {
|
||||
timeout(time: 50, unit: 'MINUTES') {
|
||||
sh "sh deploy/scripts/run-ci-maven.sh --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
|
||||
String testOption = skipTestExecution ? '-DskipTests' : ''
|
||||
if (params.ENVIRONMENT == 'test') {
|
||||
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
|
||||
post {
|
||||
always {
|
||||
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
|
||||
}
|
||||
}
|
||||
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}'"
|
||||
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'"
|
||||
|
||||
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"
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,25 +198,61 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
stage('部署单个服务') {
|
||||
when { expression { params.DEPLOY } }
|
||||
stage('部署 TEST') {
|
||||
when { expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'test' } }
|
||||
agent none
|
||||
steps {
|
||||
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\""
|
||||
lock(resource: "firstleg-test-${env.RESOLVED_SERVICE}-deploy", inversePrecedence: true) {
|
||||
node('java-test') {
|
||||
deleteDir()
|
||||
unstash 'deployment-files'
|
||||
withCredentials([
|
||||
file(credentialsId: 'firstleg-test-env', variable: 'DEPLOY_ENV_FILE'),
|
||||
usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')
|
||||
]) {
|
||||
withEnv(['TARGET_IS_LOCAL=true', "ALLOW_SAME_CONTRACT_ROLLBACK=${params.ALLOW_SAME_CONTRACT_ROLLBACK}"]) {
|
||||
sh 'sh deploy/scripts/deploy-built-service.sh test "$RESOLVED_IMAGE_REPOSITORY" "$RESOLVED_IMAGE_TAG" "$RESOLVED_SERVICE" "$DEPLOY_ENV_FILE"'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('部署临时灰度') {
|
||||
when { expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' } }
|
||||
agent none
|
||||
steps {
|
||||
script {
|
||||
lock(resource: "firstleg-production-${env.RESOLVED_SERVICE}-deploy", inversePrecedence: true) {
|
||||
node('java-builder') {
|
||||
deleteDir()
|
||||
unstash 'deployment-files'
|
||||
withCredentials([
|
||||
file(credentialsId: 'firstleg-production-env', variable: 'DEPLOY_ENV_FILE'),
|
||||
usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'),
|
||||
sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'),
|
||||
string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'),
|
||||
file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS')
|
||||
]) {
|
||||
withEnv(['TARGET_IS_LOCAL=false', 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST,
|
||||
"ALLOW_SAME_CONTRACT_ROLLBACK=${params.ALLOW_SAME_CONTRACT_ROLLBACK}"]) {
|
||||
sh 'sh deploy/scripts/deploy-built-service.sh production "$RESOLVED_IMAGE_REPOSITORY" "$RESOLVED_IMAGE_TAG" "$RESOLVED_SERVICE" "$DEPLOY_ENV_FILE"'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
echo "${env.RESOLVED_SERVICE} 已独立完成构建${params.DEPLOY ? '和部署' : ''}"
|
||||
}
|
||||
always {
|
||||
cleanWs(deleteDirs: true, notFailBuild: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,56 @@
|
||||
# 柏悦头程平台
|
||||
|
||||
本仓库承载国内头程业务,负责统一登录、客户与企业成员、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。
|
||||
本仓库承载头程与海外仓后端。当前只有一个测试环境,目标为所有服务同机部署,统一 Gateway、Auth 会话及 Nacos 命名空间,业务服务继续按职责拥有独立 Schema。部署与切换见[测试环境单机部署与配置契约](../docs/测试环境单机部署与配置契约.md),路由、认证和事件投递见[统一认证与服务通信契约](../docs/统一认证与服务通信契约.md)。
|
||||
|
||||
根 POM 与发布清单已收敛为 20 个模块、13 个服务;本地配置变化尚未经过真实 TEST 部署验收。`gateway-overseas/` 与 `event-bridge-service/` 为退役待清理源码,已不参与当前聚合构建和发布。
|
||||
|
||||
> 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。
|
||||
|
||||
## 模块
|
||||
|
||||
- `gateway`:四端统一 API 入口。
|
||||
- `finance`:财务账单、费用、充值、授信与资金核销。
|
||||
- `auth`:统一登录、令牌签发与刷新。
|
||||
- `iam`:客户主档、用户、企业成员、角色、菜单、按钮权限和数据范围;部分路径、表和类保留 `tenant` 历史命名。
|
||||
- `order`:订单主数据及订单状态编排。
|
||||
- `firstleg`:头程揽收、入仓、装载、发运和交接。
|
||||
- `warehouse`:国内仓储能力。
|
||||
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。
|
||||
- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。
|
||||
- `lastleg-service`、`warehouse-overseas-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
|
||||
```
|
||||
|
||||
2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
|
||||
GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
|
||||
|
||||
本地一期完整联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。
|
||||
2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
|
||||
|
||||
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
|
||||
唯一发布配置为 `deploy/docker-compose.yml`、`deploy/env/.env.test.example` 和 `config/nacos/test/`;所有保留服务共用实际 Namespace ID、Auth/Redis 会话和单机内部网络,各服务 Schema 保留。往来单位由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。发布、健康检查和失败恢复见 `deploy/TEST单机部署执行清单.md`。真实凭据不得提交;本地编译不代表另有开发环境。
|
||||
|
||||
## 容器日志
|
||||
|
||||
所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。统一业务及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。
|
||||
|
||||
单容器默认保留 `50m × 3`,可在 TEST 部署变量中覆盖:
|
||||
|
||||
| 环境 | `DOCKER_LOG_DRIVER` | `DOCKER_LOG_MAX_SIZE` | `DOCKER_LOG_MAX_FILE` | 长期留存 |
|
||||
|---|---|---:|---:|---|
|
||||
| 测试 | `json-file` | `50m` | `3` | 不要求 |
|
||||
|
||||
当前优先保证宝塔可观测性,因此不启用 `json-file` 压缩或非阻塞投递,避免面板无法读取轮转文件或在日志压力下静默丢弃。接入集中日志系统后,可通过 `DOCKER_LOG_DRIVER` 切换驱动,并由集中系统承担长期检索与留存。
|
||||
|
||||
调整日志策略后必须重新创建容器;只重启既有容器不会改变其日志驱动。历史 `/data/logs/baiyue-firstleg` 文件和旧容器日志不自动删除,确认无需保留后再单独清理。
|
||||
|
||||
## 网关跨域配置
|
||||
|
||||
本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如:
|
||||
本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到 TEST 时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如:
|
||||
|
||||
```text
|
||||
CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com
|
||||
@@ -41,4 +60,6 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com
|
||||
|
||||
## 仓库边界
|
||||
|
||||
仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。
|
||||
仓库结构入口见 `docs/头程项目目录结构与服务说明.md`。服务保持数据归属、禁止跨库直接查询其他服务业务表;同机可共用 MySQL 实例、Redis 和 Nacos。内部调用不通过公网,部署及通信规则以新的两份主契约为准。
|
||||
|
||||
前端 phase4 定向离线检查入口为 `apps/admin-web/scripts/phase4-test-frontend-acceptance.mjs`(工作区相对路径),由 Admin 的 `pnpm test:phase4:test` 使用 `vitest.phase4-test.config.ts` 执行;不替代真实HTTP、页面或PDA验收。
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
# Audit Service 模块指引
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
- Mapper 只由 `BaiyueAuditApplication.java` 对 `com.baiyue.audit` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||
- 负责接收 `common-audit` 产生的审计事件并持久化到 `baiyue_audit`。
|
||||
- 审计注解、切面、脱敏和发送策略在 `common-audit/`;本服务不复制业务服务的审计采集逻辑。
|
||||
|
||||
@@ -16,7 +22,7 @@
|
||||
| 入库编排 | `service/AuditIngestService.java` |
|
||||
| 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` |
|
||||
| 表结构 | `src/main/resources/db/audit.sql` |
|
||||
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/audit-service.yml` |
|
||||
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/test/audit-service.yml` |
|
||||
|
||||
## 修改与验证
|
||||
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
package com.baiyue.audit;
|
||||
|
||||
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
/**
|
||||
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
|
||||
*/
|
||||
@MapperScan("com.baiyue.audit.mapper")
|
||||
@EnableScheduling
|
||||
@MapperScan(basePackages = "com.baiyue.audit", annotationClass = Mapper.class)
|
||||
@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,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.baiyue.audit.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.audit.entity.AuditLogEntity;
|
||||
|
||||
/**
|
||||
* 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。
|
||||
*/
|
||||
@Mapper
|
||||
public interface AuditLogMapper extends BaseMapper<AuditLogEntity> {
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
spring:
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${AUDIT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
|
||||
username: ${AUDIT_DB_USERNAME:root}
|
||||
password: ${AUDIT_DB_PASSWORD:123456}
|
||||
@@ -1,6 +0,0 @@
|
||||
spring:
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${AUDIT_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
|
||||
username: ${AUDIT_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
|
||||
password: ${AUDIT_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
|
||||
@@ -3,27 +3,27 @@ server:
|
||||
|
||||
spring:
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
active: ${SPRING_PROFILES_ACTIVE:test}
|
||||
application:
|
||||
name: audit-service
|
||||
config:
|
||||
import:
|
||||
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
|
||||
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
|
||||
- nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
|
||||
- nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
username: ${NACOS_USERNAME:nacos}
|
||||
password: ${NACOS_PASSWORD:nacos}
|
||||
server-addr: ${NACOS_SERVER_ADDR}
|
||||
username: ${NACOS_USERNAME}
|
||||
password: ${NACOS_PASSWORD}
|
||||
discovery:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
server-addr: ${NACOS_SERVER_ADDR}
|
||||
enabled: ${NACOS_DISCOVERY_ENABLED:true}
|
||||
register-enabled: ${NACOS_REGISTER_ENABLED:true}
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
namespace: ${NACOS_NAMESPACE}
|
||||
config:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
server-addr: ${NACOS_SERVER_ADDR}
|
||||
namespace: ${NACOS_NAMESPACE}
|
||||
|
||||
baiyue.audit.enabled: false
|
||||
baiyue:
|
||||
@@ -40,14 +40,3 @@ baiyue:
|
||||
rocketmq:
|
||||
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
|
||||
logging:
|
||||
file:
|
||||
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
|
||||
logback:
|
||||
rollingpolicy:
|
||||
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
|
||||
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
|
||||
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
|
||||
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
|
||||
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.baiyue.audit;
|
||||
|
||||
import com.baiyue.common.web.CommonWebAutoConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class AuditHealthEndpointTest {
|
||||
private final WebApplicationContextRunner context = new WebApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(
|
||||
JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class,
|
||||
WebMvcAutoConfiguration.class, DispatcherServletAutoConfiguration.class, EndpointAutoConfiguration.class,
|
||||
HealthEndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class,
|
||||
ManagementContextAutoConfiguration.class, CommonWebAutoConfiguration.class))
|
||||
.withPropertyValues("management.endpoints.web.exposure.include=health");
|
||||
|
||||
@Test
|
||||
void actuatorHealthReturnsUpInsteadOfBusinessResourceNotFound() {
|
||||
context.withBean("dependencyHealthIndicator", HealthIndicator.class, () -> () -> Health.up().build())
|
||||
.run(application -> MockMvcBuilders.webAppContextSetup(application).build()
|
||||
.perform(get("/actuator/health"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("UP"))
|
||||
.andExpect(jsonPath("$.code").doesNotExist()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedHealthContributorReturnsDownAndServiceUnavailable() {
|
||||
context.withBean("dependencyHealthIndicator", HealthIndicator.class, () -> () -> Health.down().build())
|
||||
.run(application -> MockMvcBuilders.webAppContextSetup(application).build()
|
||||
.perform(get("/actuator/health"))
|
||||
.andExpect(status().isServiceUnavailable())
|
||||
.andExpect(jsonPath("$.status").value("DOWN"))
|
||||
.andExpect(jsonPath("$.code").doesNotExist()));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
+17
-3
@@ -1,9 +1,16 @@
|
||||
# Auth Service 模块指引
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责与边界
|
||||
|
||||
- Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
|
||||
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
|
||||
- 修改统一 Access/Refresh Token、Audience、JWKS、Redis 会话或权限撤销前,先读 `../../docs/统一认证与服务通信契约.md` 的认证章节;平台、客户及两类 PDA 保持各自访问域和仓绑定边界。
|
||||
- `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。
|
||||
|
||||
## 模块入口
|
||||
@@ -12,7 +19,11 @@
|
||||
|---|---|
|
||||
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
|
||||
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
|
||||
| 小 JWT 签发 | `service/AuthService.java`;JWT 只承载稳定身份、应用域和版本声明,角色/权限随 Redis 会话加载 |
|
||||
| 登录标识与企业联系人解耦 | `service/AccountManagementService.java`、`dto/AccountManagementDtos.java`、`service/MerchantRegistrationService.java`;注册手机号只在创建时同时初始化首位管理员登录手机号和公司联系电话,创建后二者独立维护;账号手机号变更需撤销该账号全部会话 |
|
||||
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
|
||||
| 统一小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,各业务服务结合 Redis 会话校验 |
|
||||
| 统一 RS256 签发 | `config/UnifiedJwtConfiguration.java`、`config/UnifiedJwtProperties.java`;登录、刷新、代登录使用唯一 Access Token,`JWT_RSA_JWK_SET` 和 `JWT_CURRENT_KID` 仅配置在 Auth;不再提供海外 Token 兑换 |
|
||||
| 统一会话权威校验 | `controller/InternalAccountController.java`、`service/SessionStateService.java`;内部 `/internal/auth/accounts/sessions/validate` 接收 jwtId/userId,核对 Redis Access 与状态指纹,再查询当前 Auth/IAM;内部服务令牌保护,不接收或转发用户 JWT |
|
||||
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
|
||||
| 注册协作 | `service/MerchantRegistrationService.java` |
|
||||
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
|
||||
@@ -22,10 +33,13 @@
|
||||
|
||||
## 修改约束
|
||||
|
||||
- 密码、Token、敏感 Claim 不得写日志;小 JWT 不嵌入角色和权限全集,实时授权来自 Redis 会话中的 IAM 授权快照。修改 Claim、Redis Key、权限快照或过期策略时联查 `common-security/`、`gateway/`、`iam/`。
|
||||
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。统一 Token 不携带角色或权限集,权限来自共享 Redis 会话;用户、客户、权限和仓绑定变更必须撤销受影响会话。本地源码已采用统一权威状态校验并移除安全投影,不能将本地改造视为测试环境已部署。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
|
||||
- 登录、刷新和账号安全修改通过账号行锁串行化;IAM 身份、权限、客户版本、仓绑定由单条 SQL 快照组成指纹,Access/Refresh 均绑定指纹。Gateway 与业务服务逐请求在线核对,无正结果缓存;提交后开始的请求不得接受旧指纹,提交前已经完成认证的在途业务请求不追溯中断。撤销失败使变更失败或回滚,丢失撤销响应也不得恢复旧会话。
|
||||
- 校验链为 Gateway/业务服务 → Auth → IAM,不转发 Bearer、无递归。统一 `baiyue.security.session-validation-uri` 默认 `http://auth:9100/internal/auth/accounts/sessions/validate`;外层请求总超时 4 秒,Auth→IAM 连接 1 秒/读取 2 秒且不自动重试。Auth/IAM/Redis 任一不可用均失败关闭;真实吞吐与耗时仍需测试环境验证。
|
||||
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
|
||||
- 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。
|
||||
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
|
||||
|
||||
## 全量验收导航
|
||||
|
||||
一期、二期、三期及财务全量验收均复用本模块认证能力;按本次链路读取对应阶段/专题文档,重点核对 Token、应用域、客户隔离、失效和跨 Auth/IAM 补偿,不因历史阶段划分排除其他消费者。
|
||||
订单与客户、入库测量与计费、国内货柜履约、海外仓业务及财务全量验收均复用本模块认证能力;按本次链路读取对应业务域/专题文档,重点核对 Token、应用域、客户隔离、失效和跨 Auth/IAM 补偿,不因历史规划边界排除其他消费者。
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
|
||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
||||
</project>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.auth;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
@@ -8,7 +9,7 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
/**
|
||||
* 认证服务启动入口,负责装配统一登录、令牌签发与会话管理相关组件。
|
||||
*/
|
||||
@MapperScan("com.baiyue.auth.mapper")
|
||||
@MapperScan(basePackages = "com.baiyue.auth", annotationClass = Mapper.class)
|
||||
@EnableFeignClients(basePackages = "com.baiyue.auth.client")
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
public class BaiyueAuthApplication {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.auth.client;
|
||||
|
||||
import feign.Request;
|
||||
import feign.Retryer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Only the IAM authorization client uses this bounded, single-attempt chain. */
|
||||
public class AuthorizationFeignConfiguration {
|
||||
@Bean public Request.Options authorizationRequestOptions() {
|
||||
return new Request.Options(1, TimeUnit.SECONDS, 2, TimeUnit.SECONDS, false);
|
||||
}
|
||||
@Bean public Retryer authorizationRetryer() { return Retryer.NEVER_RETRY; }
|
||||
}
|
||||
@@ -21,4 +21,28 @@ public class AuthorizationSnapshot {
|
||||
private Set<String> roles;
|
||||
private Set<String> permissions;
|
||||
private Long permissionVersion;
|
||||
private Long overseasWarehouseId;
|
||||
private Long domesticWarehouseId;
|
||||
private Long tenantVersion;
|
||||
|
||||
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
|
||||
String clientType, Set<String> roles, Set<String> permissions, Long permissionVersion,
|
||||
Long overseasWarehouseId, Long domesticWarehouseId) {
|
||||
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
|
||||
permissionVersion, overseasWarehouseId, domesticWarehouseId, 0L);
|
||||
}
|
||||
|
||||
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
|
||||
String clientType, Set<String> roles, Set<String> permissions,
|
||||
Long permissionVersion) {
|
||||
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
|
||||
permissionVersion, null, null);
|
||||
}
|
||||
|
||||
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
|
||||
String clientType, Set<String> roles, Set<String> permissions,
|
||||
Long permissionVersion, Long overseasWarehouseId) {
|
||||
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
|
||||
permissionVersion, overseasWarehouseId, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
|
||||
/**
|
||||
* 认证服务访问 IAM 内部授权接口的声明式客户端,用于加载用户在指定应用和租户下的权限快照。
|
||||
*/
|
||||
@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient",
|
||||
@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient", configuration = AuthorizationFeignConfiguration.class,
|
||||
url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
|
||||
public interface IamAuthorizationClient {
|
||||
@PostMapping("/authorization-snapshot")
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
@@ -16,14 +17,20 @@ public interface IamOnboardingClient {
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@RequestBody OnboardingRequest request);
|
||||
|
||||
@GetMapping("/merchant-registrations/{authUserId}")
|
||||
ApiResponse<OnboardingResponse> registration(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@PathVariable("authUserId") Long authUserId);
|
||||
|
||||
@DeleteMapping("/merchant-registrations/{authUserId}/compensation")
|
||||
ApiResponse<Void> compensate(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@PathVariable("authUserId") Long authUserId);
|
||||
|
||||
record OnboardingRequest(Long authUserId, String displayName, String companyName,
|
||||
String legalPerson, String legalPhone, String contactEmail,
|
||||
String legalPerson, String administratorPhone, String contactEmail,
|
||||
String countryCode, String address) { }
|
||||
|
||||
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus) { }
|
||||
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus,
|
||||
String dimensionInitializationStatus) { }
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import java.time.Duration;
|
||||
* 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "baiyue.security")
|
||||
public record AuthSecurityProperties(String jwtSecret, String issuer, String audience,
|
||||
public record AuthSecurityProperties(String issuer, String audience,
|
||||
Duration accessTokenTtl, Duration refreshTokenTtl,
|
||||
int maxLoginFailures, Duration lockDuration) {
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import com.nimbusds.jose.jwk.source.ImmutableSecret;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** 国内现有 HS256 编码器,始终为认证服务主编码器。 */
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class DomesticJwtConfiguration {
|
||||
@Bean("jwtEncoder")
|
||||
@Primary
|
||||
JwtEncoder jwtEncoder(AuthSecurityProperties properties) {
|
||||
byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
|
||||
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
|
||||
return new NimbusJwtEncoder(new ImmutableSecret<>(secret));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/** 海外尾程 RS256 短时令牌配置,默认关闭。 */
|
||||
@ConfigurationProperties(prefix = "baiyue.security.overseas-jwt")
|
||||
public record OverseasJwtProperties(boolean enabled, String issuer, String audience,
|
||||
String rsaJwkSet, String currentKid, Duration accessTokenTtl) { }
|
||||
+8
-10
@@ -3,7 +3,6 @@ package com.baiyue.auth.config;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -11,27 +10,26 @@ import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(OverseasJwtProperties.class)
|
||||
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
|
||||
public class OverseasJwtConfiguration {
|
||||
@Bean("overseasJwtEncoder")
|
||||
JwtEncoder overseasJwtEncoder(OverseasJwtProperties properties) {
|
||||
@EnableConfigurationProperties(UnifiedJwtProperties.class)
|
||||
public class UnifiedJwtConfiguration {
|
||||
@Bean("jwtEncoder")
|
||||
JwtEncoder jwtEncoder(UnifiedJwtProperties properties) {
|
||||
try {
|
||||
JWKSet set = JWKSet.parse(properties.rsaJwkSet());
|
||||
RSAKey current = set.getKeyByKeyId(properties.currentKid()) instanceof RSAKey rsa ? rsa : null;
|
||||
if (current == null || !current.isPrivate()) {
|
||||
throw new IllegalStateException("海外 current-kid 必须指向包含私钥的 RSA 密钥");
|
||||
throw new IllegalStateException("统一 current-kid 必须指向包含私钥的 RSA 密钥");
|
||||
}
|
||||
if (set.getKeys().stream().anyMatch(jwk -> !(jwk instanceof RSAKey))) {
|
||||
throw new IllegalStateException("海外 JWK Set 只能包含 RSA 密钥");
|
||||
throw new IllegalStateException("统一 JWK Set 只能包含 RSA 密钥");
|
||||
}
|
||||
if (set.getKeys().stream().anyMatch(jwk -> !properties.currentKid().equals(jwk.getKeyID())
|
||||
&& jwk instanceof RSAKey rsa && rsa.isPrivate())) {
|
||||
throw new IllegalStateException("海外非当前密钥只能配置公钥");
|
||||
throw new IllegalStateException("统一非当前密钥只能配置公钥");
|
||||
}
|
||||
return new NimbusJwtEncoder(new ImmutableJWKSet<>(set));
|
||||
} catch (java.text.ParseException exception) {
|
||||
throw new IllegalStateException("海外 RSA JWK Set 配置无效", exception);
|
||||
throw new IllegalStateException("统一 RSA JWK Set 配置无效", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Unified RS256 signing key configuration. Private keys remain in Auth. */
|
||||
@ConfigurationProperties(prefix = "baiyue.security.jwt")
|
||||
public record UnifiedJwtProperties(String rsaJwkSet, String currentKid) { }
|
||||
@@ -23,8 +23,6 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos;
|
||||
import com.baiyue.auth.service.AccountManagementService;
|
||||
import com.baiyue.auth.service.OverseasTokenService;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
/**
|
||||
@@ -40,19 +38,6 @@ public class AuthController {
|
||||
private MerchantRegistrationService registrationService;
|
||||
@Resource
|
||||
private AccountManagementService accountManagementService;
|
||||
@Resource
|
||||
private ObjectProvider<OverseasTokenService> overseasTokens;
|
||||
|
||||
@PostMapping("/overseas/token")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。")
|
||||
public ApiResponse<OverseasTokenService.OverseasTokenResponse> exchangeOverseasToken(
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
OverseasTokenService service = overseasTokens.getIfAvailable();
|
||||
if (service == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外认证未启用");
|
||||
return ApiResponse.success(service.exchange(user.userId()));
|
||||
}
|
||||
|
||||
@PutMapping("/password")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@Operation(summary = "修改当前用户密码")
|
||||
@@ -77,7 +62,7 @@ public class AuthController {
|
||||
@SecurityRequirements
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "Merchant registration / 客户注册",
|
||||
description = "Create a login account and an enterprise profile awaiting the required business license and legal-person ID; other qualification files are optional.")
|
||||
description = "Create a login account and enterprise profile. The supplied phone initializes both the first administrator login phone and company contact phone; they are maintained independently after creation. Only the business license and unified social credit code are required for review; legal-person ID is optional.")
|
||||
public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) {
|
||||
return ApiResponse.success(registrationService.register(request));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -18,11 +19,13 @@ import java.security.MessageDigest;
|
||||
/** 仅供可信内部服务调用的账号管理接口。 */
|
||||
@RestController
|
||||
@RequestMapping("/internal/auth/accounts")
|
||||
@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and unified session APIs")
|
||||
public class InternalAccountController {
|
||||
@Resource
|
||||
private AccountManagementService service;
|
||||
@Resource
|
||||
private AuthService authService;
|
||||
@Resource private com.baiyue.auth.service.SessionStateService sessionStateService;
|
||||
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String configuredToken;
|
||||
@@ -64,11 +67,36 @@ public class InternalAccountController {
|
||||
@Valid @RequestBody RevokeAccountSessionsRequest request) {
|
||||
verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null);
|
||||
}
|
||||
public record SessionValidationRequest(@jakarta.validation.constraints.NotBlank String jwtId,
|
||||
@jakarta.validation.constraints.NotNull Long userId) { }
|
||||
|
||||
@PostMapping("/sessions/validate")
|
||||
public ApiResponse<Boolean> validateSession(
|
||||
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@Valid @RequestBody SessionValidationRequest request) {
|
||||
verify(token);
|
||||
return ApiResponse.success(sessionStateService.validate(request.jwtId(), request.userId()));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/profile")
|
||||
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
|
||||
verify(token); service.updateProfile(id, request); return ApiResponse.success(null);
|
||||
}
|
||||
@PutMapping("/{id}/login-identifiers")
|
||||
public ApiResponse<AccountSummary> loginIdentifiers(
|
||||
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable Long id, @Valid @RequestBody ChangeLoginIdentifiersRequest request) {
|
||||
verify(token);
|
||||
return ApiResponse.success(service.changeLoginIdentifiers(id, request));
|
||||
}
|
||||
@PutMapping("/{id}/phone")
|
||||
public ApiResponse<Void> phone(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable Long id, @Valid @RequestBody UpdateAccountPhoneRequest request) {
|
||||
verify(token);
|
||||
service.updatePhone(id, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
@PutMapping("/{id}/password")
|
||||
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package com.baiyue.auth.controller;
|
||||
|
||||
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||
import com.baiyue.auth.config.UnifiedJwtProperties;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -13,17 +12,16 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */
|
||||
/** 发布统一 JWT 的 RSA 公钥。 */
|
||||
@RestController
|
||||
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
|
||||
public class JwksController {
|
||||
private final OverseasJwtProperties properties;
|
||||
private final UnifiedJwtProperties properties;
|
||||
|
||||
public JwksController(OverseasJwtProperties properties) { this.properties = properties; }
|
||||
public JwksController(UnifiedJwtProperties properties) { this.properties = properties; }
|
||||
|
||||
@GetMapping("/api/auth/.well-known/jwks.json")
|
||||
@SecurityRequirements
|
||||
@Operation(summary = "获取海外 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。")
|
||||
@Operation(summary = "获取统一 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。")
|
||||
public ResponseEntity<Map<String, Object>> jwks() throws Exception {
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
|
||||
.body(JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet().toJSONObject());
|
||||
|
||||
@@ -21,7 +21,8 @@ public final class AccountManagementDtos {
|
||||
@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 @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
|
||||
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
|
||||
@NotBlank @Size(min = 8, max = 64)
|
||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
|
||||
@NotBlank String displayName, @NotNull Long tenantId,
|
||||
@@ -32,9 +33,19 @@ public final class AccountManagementDtos {
|
||||
String accountStatus) {
|
||||
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
|
||||
Long tenantId, Long membershipId, String appCode) {
|
||||
this(accountId, username, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
|
||||
this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
|
||||
}
|
||||
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
|
||||
Long tenantId, Long membershipId, String appCode, String accountStatus) {
|
||||
this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, accountStatus);
|
||||
}
|
||||
}
|
||||
public record ChangeLoginIdentifiersRequest(
|
||||
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
|
||||
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
|
||||
@NotBlank String currentPassword) { }
|
||||
public record UpdateAccountPhoneRequest(
|
||||
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone) { }
|
||||
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
|
||||
public record UpdateAccountStatusRequest(@NotBlank String status) { }
|
||||
public record RevokeAccountSessionsRequest(
|
||||
@@ -56,10 +67,10 @@ public final class AccountManagementDtos {
|
||||
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,
|
||||
public record AccountSummary(Long accountId, String username, String phone, String displayName, String status,
|
||||
LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) {
|
||||
public AccountSummary(Long accountId, String username, String displayName, String status) {
|
||||
this(accountId, username, displayName, status, null, null, null, null);
|
||||
this(accountId, username, null, displayName, status, null, null, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,15 @@ package com.baiyue.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 登录请求契约,接收账号、密码和目标应用域等统一登录参数。
|
||||
*/
|
||||
@Schema(name = "LoginRequest", description = "Unified login credentials / 统一登录凭据")
|
||||
public record LoginRequest(
|
||||
@Schema(description = "Login username / 登录用户名", example = "merchant_admin") @NotBlank String username,
|
||||
@Schema(description = "Username or mainland China mobile number / 用户名或中国大陆手机号", example = "merchant_admin")
|
||||
@NotBlank @Size(max = 64) String loginId,
|
||||
@Schema(description = "Login password / 登录密码", example = "ChangeMe123!", format = "password") @NotBlank String password
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ import jakarta.validation.constraints.Size;
|
||||
|
||||
@Schema(name = "MerchantRegisterRequest", description = "Phase-one merchant account registration")
|
||||
public record MerchantRegisterRequest(
|
||||
@NotBlank @Pattern(regexp = "[A-Za-z0-9_]{4,64}") String username,
|
||||
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
|
||||
@NotBlank @Size(min = 8, max = 72) String password,
|
||||
@NotBlank @Size(max = 64) String displayName,
|
||||
@NotBlank @Size(max = 128) String companyName,
|
||||
@NotBlank @Size(max = 64) String legalPerson,
|
||||
@NotBlank @Size(max = 32) String legalPhone,
|
||||
@Size(max = 64) String legalPerson,
|
||||
@Schema(description = "首个企业管理员的登录手机号;注册时同时初始化企业联系电话,创建后两者独立维护")
|
||||
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
|
||||
@Email @Size(max = 128) String contactEmail,
|
||||
@Size(min = 2, max = 2) String countryCode,
|
||||
@Size(max = 255) String address
|
||||
|
||||
@@ -5,5 +5,6 @@ public record MerchantRegisterResponse(
|
||||
Long tenantId,
|
||||
Long membershipId,
|
||||
String tenantStatus,
|
||||
String dimensionInitializationStatus,
|
||||
String nextStep
|
||||
) { }
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.baiyue.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 认证令牌响应契约,返回访问令牌、刷新令牌及其有效期等会话信息。
|
||||
*/
|
||||
@@ -11,6 +13,12 @@ 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 = "Current role snapshot returned outside JWT / JWT 外返回的当前角色快照") Set<String> roles,
|
||||
@Schema(description = "Current authority snapshot returned outside JWT / JWT 外返回的当前权限快照") Set<String> authorities
|
||||
) {
|
||||
}
|
||||
public TokenResponse {
|
||||
roles = roles == null ? Set.of() : Set.copyOf(roles);
|
||||
authorities = authorities == null ? Set.of() : Set.copyOf(authorities);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import java.time.LocalDateTime;
|
||||
public class AuthAccount {
|
||||
@TableId private Long id;
|
||||
private String username;
|
||||
private String phone;
|
||||
private String passwordHash;
|
||||
private String displayName;
|
||||
private String accountStatus;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.baiyue.auth.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
/**
|
||||
* 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。
|
||||
*/
|
||||
@Mapper
|
||||
public interface AuthAccountMapper extends BaseMapper<AuthAccount> { }
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.baiyue.auth.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import com.baiyue.auth.entity.AuthRefreshToken;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
/**
|
||||
* 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。
|
||||
*/
|
||||
@Mapper
|
||||
public interface AuthRefreshTokenMapper extends BaseMapper<AuthRefreshToken> { }
|
||||
|
||||
@@ -19,7 +19,13 @@ public class AuthAccountRepository {
|
||||
public AuthAccount findByUsername(String username) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
|
||||
.eq(AuthAccount::getUsername, username)
|
||||
.last("LIMIT 1"));
|
||||
.last("LIMIT 1 FOR UPDATE"));
|
||||
}
|
||||
|
||||
public AuthAccount findByPhone(String phone) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
|
||||
.eq(AuthAccount::getPhone, phone)
|
||||
.last("LIMIT 1 FOR UPDATE"));
|
||||
}
|
||||
|
||||
public boolean existsByUsername(String username) {
|
||||
@@ -27,16 +33,35 @@ public class AuthAccountRepository {
|
||||
.eq(AuthAccount::getUsername, username)) > 0;
|
||||
}
|
||||
|
||||
public boolean existsByPhone(String phone) {
|
||||
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
|
||||
.eq(AuthAccount::getPhone, phone)) > 0;
|
||||
}
|
||||
|
||||
public boolean existsByUsernameForOtherAccount(String username, Long accountId) {
|
||||
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
|
||||
.eq(AuthAccount::getUsername, username)
|
||||
.ne(AuthAccount::getId, accountId)) > 0;
|
||||
}
|
||||
|
||||
public boolean existsByPhoneForOtherAccount(String phone, Long accountId) {
|
||||
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
|
||||
.eq(AuthAccount::getPhone, phone)
|
||||
.ne(AuthAccount::getId, accountId)) > 0;
|
||||
}
|
||||
|
||||
public void insert(AuthAccount account) { mapper.insert(account); }
|
||||
|
||||
public AuthAccount findById(Long id) {
|
||||
return mapper.selectById(id);
|
||||
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>().eq(AuthAccount::getId, id).last("FOR UPDATE"));
|
||||
}
|
||||
|
||||
public AuthAccount findCurrentById(Long id) { return mapper.selectById(id); }
|
||||
|
||||
public List<AuthAccount> findMerchantByTenant(Long tenantId) {
|
||||
return mapper.selectList(new LambdaQueryWrapper<AuthAccount>()
|
||||
.eq(AuthAccount::getDefaultTenantId, tenantId)
|
||||
.eq(AuthAccount::getDefaultAppCode, "MERCHANT"));
|
||||
.eq(AuthAccount::getDefaultAppCode, "MERCHANT").orderByAsc(AuthAccount::getId).last("FOR UPDATE"));
|
||||
}
|
||||
|
||||
public void save(AuthAccount account) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import jakarta.annotation.Resource;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
|
||||
import java.util.Locale;
|
||||
@@ -63,7 +64,9 @@ public class AccountManagementService {
|
||||
String appCode = request.appCode() == null ? "MERCHANT"
|
||||
: request.appCode().trim().toUpperCase(Locale.ROOT);
|
||||
validateScope(appCode, request.tenantId());
|
||||
AuthAccount existing = repository.findById(request.accountId());
|
||||
// Locking a missing key takes an InnoDB gap lock and can deadlock
|
||||
// unrelated account inserts. The unique indexes arbitrate creation.
|
||||
AuthAccount existing = repository.findCurrentById(request.accountId());
|
||||
if (existing != null) {
|
||||
if (sameIdentity(existing, request, appCode)) return summary(existing);
|
||||
throw new BizException(ResultCode.CONFLICT, "认证账号标识已被其他成员使用");
|
||||
@@ -71,9 +74,13 @@ public class AccountManagementService {
|
||||
if (repository.existsByUsername(request.username())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
|
||||
}
|
||||
if (request.phone() != null && repository.existsByPhone(request.phone())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
|
||||
}
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(request.accountId());
|
||||
account.setUsername(request.username());
|
||||
account.setPhone(request.phone());
|
||||
account.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
account.setDisplayName(request.displayName());
|
||||
account.setAccountStatus(request.accountStatus() == null ? "ACTIVE" : request.accountStatus());
|
||||
@@ -84,7 +91,13 @@ public class AccountManagementService {
|
||||
account.setDefaultMembershipId(request.membershipId());
|
||||
account.setDefaultAppCode(appCode);
|
||||
account.setDeleted(0);
|
||||
repository.insert(account);
|
||||
try {
|
||||
repository.insert(account);
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
AuthAccount winner = repository.findById(request.accountId());
|
||||
if (winner != null && sameIdentity(winner, request, appCode)) return summary(winner);
|
||||
throw new BizException(ResultCode.CONFLICT, "认证账号标识或登录账号已被使用");
|
||||
}
|
||||
return summary(account);
|
||||
}
|
||||
|
||||
@@ -104,7 +117,7 @@ public class AccountManagementService {
|
||||
resourceId = "'RBAC_CHANGE'", description = "RBAC 变更批量撤销账号会话",
|
||||
captureBefore = false, captureAfter = false)
|
||||
public void revokeSessions(Set<Long> accountIds) {
|
||||
accountIds.forEach(accountId -> bumpVersion(required(accountId)));
|
||||
accountIds.stream().sorted().forEach(accountId -> bumpVersion(required(accountId)));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -117,6 +130,45 @@ public class AccountManagementService {
|
||||
bumpVersion(account);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "ACCOUNT_LOGIN_IDENTIFIERS_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
|
||||
description = "修改本人用户名和手机号", captureAfter = false)
|
||||
public AccountSummary changeLoginIdentifiers(Long accountId, ChangeLoginIdentifiersRequest request) {
|
||||
AuthAccount account = required(accountId);
|
||||
if (!passwordEncoder.matches(request.currentPassword(), account.getPasswordHash())) {
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "当前密码错误");
|
||||
}
|
||||
String username = request.username().trim();
|
||||
String phone = request.phone().trim();
|
||||
if (repository.existsByUsernameForOtherAccount(username, accountId)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
|
||||
}
|
||||
if (repository.existsByPhoneForOtherAccount(phone, accountId)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
|
||||
}
|
||||
if (Objects.equals(account.getUsername(), username) && Objects.equals(account.getPhone(), phone)) {
|
||||
return summary(account);
|
||||
}
|
||||
account.setUsername(username);
|
||||
account.setPhone(phone);
|
||||
bumpVersion(account);
|
||||
return summary(account);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "ACCOUNT_PHONE_SYNC", resourceType = "ACCOUNT", resourceId = "#accountId",
|
||||
description = "同步受管账号手机号", captureAfter = false)
|
||||
public void updatePhone(Long accountId, UpdateAccountPhoneRequest request) {
|
||||
AuthAccount account = required(accountId);
|
||||
String phone = request.phone() == null ? null : request.phone().trim();
|
||||
if (Objects.equals(account.getPhone(), phone)) return;
|
||||
if (phone != null && repository.existsByPhoneForOtherAccount(phone, accountId)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
|
||||
}
|
||||
account.setPhone(phone);
|
||||
bumpVersion(account);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
|
||||
public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
|
||||
@@ -185,7 +237,7 @@ public class AccountManagementService {
|
||||
}
|
||||
|
||||
private AccountSummary summary(AuthAccount a) {
|
||||
return new AccountSummary(a.getId(), a.getUsername(), a.getDisplayName(), a.getAccountStatus(),
|
||||
return new AccountSummary(a.getId(), a.getUsername(), a.getPhone(), a.getDisplayName(), a.getAccountStatus(),
|
||||
a.getLastLoginAt(), a.getDefaultTenantId(), a.getDefaultMembershipId(), a.getDefaultAppCode());
|
||||
}
|
||||
|
||||
@@ -201,6 +253,7 @@ public class AccountManagementService {
|
||||
|
||||
private boolean sameIdentity(AuthAccount account, CreateAccountRequest request, String appCode) {
|
||||
return Objects.equals(account.getUsername(), request.username())
|
||||
&& Objects.equals(account.getPhone(), request.phone())
|
||||
&& Objects.equals(account.getDefaultTenantId(), request.tenantId())
|
||||
&& Objects.equals(account.getOperatorTenantId(), request.tenantId())
|
||||
&& Objects.equals(account.getDefaultMembershipId(), request.membershipId())
|
||||
|
||||
@@ -16,13 +16,14 @@ import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
import org.springframework.security.oauth2.jwt.JwsHeader;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import com.baiyue.auth.config.UnifiedJwtProperties;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
@@ -49,15 +50,20 @@ public class AuthService {
|
||||
private AuthorizationLoader authorizationLoader;
|
||||
@Resource
|
||||
private TokenSessionStore tokenSessionStore;
|
||||
@Resource
|
||||
private UnifiedJwtProperties signingProperties;
|
||||
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.username",
|
||||
@BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.loginId",
|
||||
description = "User login / 用户登录", captureAfter = false)
|
||||
public TokenResponse login(LoginRequest request) {
|
||||
AuthAccount account = accountRepository.findByUsername(request.username());
|
||||
String loginId = request.loginId().trim();
|
||||
AuthAccount account = loginId.matches("^1[3-9]\\d{9}$")
|
||||
? accountRepository.findByPhone(loginId)
|
||||
: accountRepository.findByUsername(loginId);
|
||||
if (account == null) throw invalidCredentials();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (!"ACTIVE".equals(account.getAccountStatus()))
|
||||
@@ -86,7 +92,8 @@ public class AuthService {
|
||||
AuthAccount account = accountRepository.findById(session.userId());
|
||||
if (account == null || !"ACTIVE".equals(account.getAccountStatus()))
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
|
||||
return issue(account);
|
||||
if (session.fingerprint() == null) throw new BizException(ResultCode.UNAUTHORIZED, "刷新会话缺少授权状态");
|
||||
return issue(account, session.fingerprint());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -103,17 +110,23 @@ public class AuthService {
|
||||
accountRepository.save(account);
|
||||
}
|
||||
|
||||
private TokenResponse issue(AuthAccount account) {
|
||||
private TokenResponse issue(AuthAccount account) { return issue(account, null); }
|
||||
|
||||
private TokenResponse issue(AuthAccount account, String expectedState) {
|
||||
AuthorizationSnapshot authorization = authorizationLoader.load(account);
|
||||
if (expectedState != null && !expectedState.equals(SessionStateService.fingerprint(account, authorization))) {
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "授权状态已变更,请重新登录");
|
||||
}
|
||||
Instant issuedAt = Instant.now();
|
||||
String jwtId = UUID.randomUUID().toString();
|
||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||
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())
|
||||
@@ -124,18 +137,26 @@ public class AuthService {
|
||||
.claim("userId", account.getId())
|
||||
.claim("tenantId", account.getDefaultTenantId())
|
||||
.claim("appCode", account.getDefaultAppCode())
|
||||
.claim("permissionVersion", account.getTokenVersion())
|
||||
.build();
|
||||
.claim("permissionVersion", account.getTokenVersion());
|
||||
if (authorization.getOverseasWarehouseId() != null) {
|
||||
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
|
||||
}
|
||||
if (authorization.getDomesticWarehouseId() != null) {
|
||||
claimsBuilder.claim("domestic_warehouse_id", authorization.getDomesticWarehouseId());
|
||||
}
|
||||
JwtClaimsSet claims = claimsBuilder.build();
|
||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
|
||||
signingHeader(), claims)).getTokenValue();
|
||||
|
||||
byte[] random = new byte[48];
|
||||
secureRandom.nextBytes(random);
|
||||
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
|
||||
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh,
|
||||
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(),
|
||||
SessionStateService.fingerprint(account, authorization), rawRefresh,
|
||||
properties.accessTokenTtl(), properties.refreshTokenTtl());
|
||||
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
|
||||
rawRefresh, properties.refreshTokenTtl().toSeconds());
|
||||
rawRefresh, properties.refreshTokenTtl().toSeconds(),
|
||||
authorization.getRoles(), authorization.getPermissions());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -176,7 +197,7 @@ public class AuthService {
|
||||
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("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())
|
||||
@@ -188,18 +209,26 @@ public class AuthService {
|
||||
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build();
|
||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue();
|
||||
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
|
||||
authorization.getPermissions(), Duration.ofMinutes(15));
|
||||
return new ImpersonationTokenResponse("Bearer", accessToken, 900);
|
||||
authorization.getPermissions(), SessionStateService.fingerprint(account, authorization), Duration.ofMinutes(15));
|
||||
return new ImpersonationTokenResponse("Bearer", accessToken, 900,
|
||||
authorization.getRoles(), authorization.getPermissions());
|
||||
}
|
||||
|
||||
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn) { }
|
||||
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn,
|
||||
java.util.Set<String> roles,
|
||||
java.util.Set<String> authorities) {
|
||||
public ImpersonationTokenResponse {
|
||||
roles = roles == null ? java.util.Set.of() : java.util.Set.copyOf(roles);
|
||||
authorities = authorities == null ? java.util.Set.of() : java.util.Set.copyOf(authorities);
|
||||
}
|
||||
}
|
||||
|
||||
private JwsHeader signingHeader() {
|
||||
return JwsHeader.with(MacAlgorithm.HS256).build();
|
||||
return JwsHeader.with(SignatureAlgorithm.RS256).keyId(signingProperties.currentKid()).build();
|
||||
}
|
||||
|
||||
private static BizException invalidCredentials() {
|
||||
return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误");
|
||||
return new BizException(ResultCode.UNAUTHORIZED, "登录标识或密码错误");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -35,8 +35,20 @@ public class MerchantRegistrationService {
|
||||
@BusinessAudit(event = "MERCHANT_REGISTER", resourceType = "ACCOUNT",
|
||||
resourceId = "#request.username", description = "商户注册", captureAfter = false)
|
||||
public MerchantRegisterResponse register(MerchantRegisterRequest request) {
|
||||
if (accountRepository.existsByUsername(request.username())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Username already exists");
|
||||
AuthAccount usernameAccount = accountRepository.findByUsername(request.username());
|
||||
AuthAccount phoneAccount = accountRepository.findByPhone(request.phone());
|
||||
if (usernameAccount != null && phoneAccount != null
|
||||
&& usernameAccount.getId().equals(phoneAccount.getId())
|
||||
&& "MERCHANT".equals(usernameAccount.getDefaultAppCode())
|
||||
&& "ACTIVE".equals(usernameAccount.getAccountStatus())
|
||||
&& passwordEncoder.matches(request.password(), usernameAccount.getPasswordHash())) {
|
||||
return existingRegistration(usernameAccount);
|
||||
}
|
||||
if (usernameAccount != null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
|
||||
}
|
||||
if (phoneAccount != null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "该手机号已注册,请直接登录后继续完善企业资质");
|
||||
}
|
||||
long accountId = IdWorker.getId();
|
||||
String passwordHash = passwordEncoder.encode(request.password());
|
||||
@@ -46,7 +58,7 @@ public class MerchantRegistrationService {
|
||||
try {
|
||||
var response = onboardingClient.register(internalToken,
|
||||
new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(),
|
||||
request.companyName(), request.legalPerson(), request.legalPhone(),
|
||||
request.companyName(), request.legalPerson(), request.phone(),
|
||||
request.contactEmail(), request.countryCode(), request.address()));
|
||||
if (response == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed");
|
||||
@@ -81,6 +93,7 @@ public class MerchantRegistrationService {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(accountId);
|
||||
account.setUsername(request.username());
|
||||
account.setPhone(request.phone());
|
||||
account.setPasswordHash(passwordHash);
|
||||
account.setDisplayName(request.displayName());
|
||||
account.setAccountStatus("ACTIVE");
|
||||
@@ -94,7 +107,7 @@ public class MerchantRegistrationService {
|
||||
try {
|
||||
accountRepository.insert(account);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
BizException failure = new BizException(ResultCode.CONFLICT, "Username already exists");
|
||||
BizException failure = new BizException(ResultCode.CONFLICT, "用户名或手机号已存在");
|
||||
failure.addSuppressed(exception);
|
||||
compensateOnboarding(accountId, failure, compensationCompleted);
|
||||
throw failure;
|
||||
@@ -103,7 +116,26 @@ 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 MerchantRegisterResponse existingRegistration(AuthAccount account) {
|
||||
try {
|
||||
var response = onboardingClient.registration(internalToken, account.getId());
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质");
|
||||
}
|
||||
var onboarding = response.data();
|
||||
if (!java.util.Set.of("PROFILE_REQUIRED", "REJECTED").contains(onboarding.tenantStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "该账号已注册,请直接登录");
|
||||
}
|
||||
return new MerchantRegisterResponse(account.getId(), onboarding.tenantId(), onboarding.membershipId(),
|
||||
onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(),
|
||||
"LOGIN_AND_COMPLETE_QUALIFICATIONS");
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质");
|
||||
}
|
||||
}
|
||||
|
||||
private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.AuthorizationLoader;
|
||||
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
import org.springframework.security.oauth2.jwt.JwsHeader;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */
|
||||
@Service
|
||||
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
|
||||
public class OverseasTokenService {
|
||||
@Resource private AuthAccountRepository accounts;
|
||||
@Resource private AuthorizationLoader authorizationLoader;
|
||||
@Resource private OverseasJwtProperties properties;
|
||||
@Resource @Qualifier("overseasJwtEncoder") private JwtEncoder encoder;
|
||||
|
||||
public OverseasTokenResponse exchange(long authenticatedUserId) {
|
||||
AuthAccount account = accounts.findById(authenticatedUserId);
|
||||
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) {
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
|
||||
}
|
||||
AuthorizationSnapshot authorization = authorizationLoader.load(account);
|
||||
Instant issuedAt = Instant.now();
|
||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
||||
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
|
||||
.issuedAt(issuedAt).expiresAt(expiresAt)
|
||||
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
|
||||
.claim("appCode", account.getDefaultAppCode()).claim("roles", authorization.getRoles())
|
||||
.claim("authorities", authorization.getPermissions())
|
||||
.claim("permissionVersion", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
|
||||
.build();
|
||||
String token = encoder.encode(JwtEncoderParameters.from(
|
||||
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
|
||||
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds());
|
||||
}
|
||||
|
||||
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.AuthorizationLoader;
|
||||
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/** No cache: a validation started after a security commit must observe that commit. */
|
||||
@Service
|
||||
public class SessionStateService {
|
||||
private static final ObjectMapper FINGERPRINT_JSON = new ObjectMapper();
|
||||
@Resource private AuthAccountRepository accounts;
|
||||
@Resource private AuthorizationLoader authorizationLoader;
|
||||
@Resource private TokenSessionStore sessions;
|
||||
|
||||
public static String fingerprint(AuthAccount account, AuthorizationSnapshot snapshot) {
|
||||
try {
|
||||
if (account.getTokenVersion() == null || snapshot.getPermissionVersion() == null
|
||||
|| snapshot.getTenantVersion() == null) throw new IllegalArgumentException("Missing persisted security version");
|
||||
// Ordered JSON array avoids set ordering and delimiter ambiguities.
|
||||
Object[] state = {account.getId(), account.getTokenVersion(), account.getDefaultTenantId(),
|
||||
account.getDefaultMembershipId(), account.getDefaultAppCode(), account.getOperatorTenantId(),
|
||||
snapshot.getPermissionVersion(), snapshot.getTenantVersion(), snapshot.getClientType(),
|
||||
snapshot.getOverseasWarehouseId(), snapshot.getDomesticWarehouseId(),
|
||||
new TreeSet<>(snapshot.getRoles()), new TreeSet<>(snapshot.getPermissions())};
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(FINGERPRINT_JSON.writeValueAsBytes(state)));
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("Cannot fingerprint authorization state", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireCurrent(Long userId, String expected) {
|
||||
AuthAccount account = accounts.findCurrentById(userId);
|
||||
if (account == null || !"ACTIVE".equals(account.getAccountStatus())
|
||||
|| Integer.valueOf(1).equals(account.getDeleted()) || expected == null
|
||||
|| !expected.equals(fingerprint(account, authorizationLoader.load(account)))) {
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "登录授权已变更,请重新登录");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW,
|
||||
isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public boolean validate(String jwtId, Long userId) {
|
||||
TokenSessionStore.SessionState state = sessions.loadState(jwtId);
|
||||
if (state == null || !state.userId().equals(userId)) {
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "登录会话已失效");
|
||||
}
|
||||
requireCurrent(userId, state.fingerprint());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.SetOperations;
|
||||
import org.springframework.data.redis.core.Cursor;
|
||||
import org.springframework.data.redis.core.ScanOptions;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -21,8 +19,6 @@ import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Set;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 认证会话存储服务,使用 Redis 保存和一次性消费刷新会话,支持刷新令牌轮换及主动撤销。
|
||||
@@ -42,7 +38,7 @@ public class TokenSessionStore {
|
||||
}
|
||||
|
||||
public void store(String jwtId, Long userId, Set<String> roles, Set<String> permissions,
|
||||
String rawRefreshToken, Duration accessTtl, Duration refreshTtl) {
|
||||
String fingerprint, String rawRefreshToken, Duration accessTtl, Duration refreshTtl) {
|
||||
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
|
||||
String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken));
|
||||
String userKey = userSessionsKey(userId);
|
||||
@@ -50,25 +46,27 @@ public class TokenSessionStore {
|
||||
String accessValue = objectMapper.writeValueAsString(
|
||||
new AccessSession(1, userId, normalized(roles), normalized(permissions)));
|
||||
redisTemplate.opsForValue().set(accessKey, accessValue, accessTtl);
|
||||
String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId));
|
||||
storeState(jwtId, userId, fingerprint, accessTtl);
|
||||
String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId, fingerprint));
|
||||
redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl);
|
||||
SetOperations<String, String> sets = redisTemplate.opsForSet();
|
||||
if (sets != null) sets.add(userKey, accessKey, refreshKey);
|
||||
if (sets != null) sets.add(userKey, accessKey, refreshKey, stateKey(jwtId));
|
||||
redisTemplate.expire(userKey, refreshTtl);
|
||||
} catch (Exception exception) {
|
||||
try { redisTemplate.delete(Set.of(accessKey, refreshKey, userKey)); } catch (DataAccessException ignored) { }
|
||||
try { redisTemplate.delete(Set.of(accessKey, refreshKey, stateKey(jwtId))); } catch (DataAccessException ignored) { }
|
||||
throw unavailable(exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void storeAccessOnly(String jwtId, Long userId, Set<String> roles,
|
||||
Set<String> permissions, Duration ttl) {
|
||||
Set<String> permissions, String fingerprint, Duration ttl) {
|
||||
try {
|
||||
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
|
||||
String accessValue = objectMapper.writeValueAsString(
|
||||
new AccessSession(1, userId, normalized(roles), normalized(permissions)));
|
||||
redisTemplate.opsForValue().set(accessKey, accessValue, ttl);
|
||||
redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey);
|
||||
storeState(jwtId, userId, fingerprint, ttl);
|
||||
redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey, stateKey(jwtId));
|
||||
redisTemplate.expire(userSessionsKey(userId), ttl);
|
||||
} catch (Exception exception) {
|
||||
throw unavailable(exception);
|
||||
@@ -103,6 +101,7 @@ public class TokenSessionStore {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
RefreshSession session = objectMapper.readValue(value, RefreshSession.class);
|
||||
redisTemplate.delete(RedisTokenKeys.access(keyPrefix, session.accessJwtId()));
|
||||
redisTemplate.delete(stateKey(session.accessJwtId()));
|
||||
return session;
|
||||
} catch (DataAccessException exception) {
|
||||
throw unavailable(exception);
|
||||
@@ -121,43 +120,26 @@ public class TokenSessionStore {
|
||||
try {
|
||||
Set<String> keys = redisTemplate.opsForSet().members(userKey);
|
||||
if (keys != null && !keys.isEmpty()) redisTemplate.delete(keys);
|
||||
// 兼容功能上线前尚未写入用户会话索引的存量令牌,SCAN 不阻塞 Redis 主线程。
|
||||
List<String> legacyKeys = new ArrayList<>();
|
||||
collectLegacyKeys(keyPrefix + ":access:*", userId, legacyKeys, false);
|
||||
collectLegacyKeys(keyPrefix + ":refresh:*", userId, legacyKeys, true);
|
||||
if (!legacyKeys.isEmpty()) redisTemplate.delete(legacyKeys);
|
||||
redisTemplate.delete(userKey);
|
||||
} catch (DataAccessException exception) {
|
||||
throw unavailable(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void collectLegacyKeys(String pattern, Long userId, List<String> result, boolean refresh) {
|
||||
Cursor<String> cursor = redisTemplate.scan(ScanOptions.scanOptions().match(pattern).count(500).build());
|
||||
if (cursor == null) return;
|
||||
try (cursor) {
|
||||
while (cursor.hasNext()) {
|
||||
String key = cursor.next();
|
||||
String value = redisTemplate.opsForValue().get(key);
|
||||
if ((!refresh && accessBelongsTo(value, userId)) || (refresh && belongsTo(value, userId))) {
|
||||
result.add(key);
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
throw unavailable(exception);
|
||||
}
|
||||
private String stateKey(String jwtId) { return keyPrefix + ":state:" + jwtId; }
|
||||
|
||||
private void storeState(String jwtId, Long userId, String fingerprint, Duration ttl) throws Exception {
|
||||
if (fingerprint == null || fingerprint.isBlank()) throw new IllegalArgumentException("Missing security state");
|
||||
redisTemplate.opsForValue().set(stateKey(jwtId),
|
||||
objectMapper.writeValueAsString(new SessionState(userId, fingerprint)), ttl);
|
||||
}
|
||||
|
||||
private boolean belongsTo(String value, Long userId) {
|
||||
if (value == null || value.isBlank()) return false;
|
||||
try { return userId.equals(objectMapper.readValue(value, RefreshSession.class).userId()); }
|
||||
catch (Exception ignored) { return false; }
|
||||
}
|
||||
|
||||
private boolean accessBelongsTo(String value, Long userId) {
|
||||
if (value == null || value.isBlank()) return false;
|
||||
try { return userId.equals(objectMapper.readValue(value, AccessSession.class).userId()); }
|
||||
catch (Exception ignored) { return false; }
|
||||
public SessionState loadState(String jwtId) {
|
||||
try {
|
||||
if (!Boolean.TRUE.equals(redisTemplate.hasKey(RedisTokenKeys.access(keyPrefix, jwtId)))) return null;
|
||||
String value = redisTemplate.opsForValue().get(stateKey(jwtId));
|
||||
return value == null ? null : objectMapper.readValue(value, SessionState.class);
|
||||
} catch (Exception failure) { throw unavailable(failure); }
|
||||
}
|
||||
|
||||
private static Set<String> normalized(Set<String> values) {
|
||||
@@ -182,7 +164,8 @@ public class TokenSessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
public record RefreshSession(Long userId, String accessJwtId) { }
|
||||
public record SessionState(Long userId, String fingerprint) { }
|
||||
public record RefreshSession(Long userId, String accessJwtId, String fingerprint) { }
|
||||
public record AccessSession(int version, Long userId, Set<String> roles, Set<String> permissions) {
|
||||
public AccessSession {
|
||||
roles = normalized(roles);
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
spring:
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
|
||||
username: ${AUTH_DB_USERNAME:root}
|
||||
password: ${AUTH_DB_PASSWORD:123456}
|
||||
@@ -1,15 +0,0 @@
|
||||
spring:
|
||||
cloud:
|
||||
nacos:
|
||||
config:
|
||||
namespace: ${NACOS_NAMESPACE:test}
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
encode: UTF-8
|
||||
discovery:
|
||||
namespace: ${NACOS_NAMESPACE:test}
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${AUTH_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
|
||||
username: ${AUTH_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
|
||||
password: ${AUTH_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
|
||||
@@ -2,36 +2,35 @@ server:
|
||||
port: ${AUTH_PORT:9100}
|
||||
spring:
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
active: ${SPRING_PROFILES_ACTIVE:test}
|
||||
application:
|
||||
name: auth-service
|
||||
config:
|
||||
import:
|
||||
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
|
||||
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
|
||||
- nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
|
||||
- nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:127.0.0.1}
|
||||
host: ${REDIS_HOST}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
database: ${REDIS_DATABASE:0}
|
||||
timeout: ${REDIS_TIMEOUT:3s}
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
username: ${NACOS_USERNAME:nacos}
|
||||
password: ${NACOS_PASSWORD:nacos}
|
||||
server-addr: ${NACOS_SERVER_ADDR}
|
||||
username: ${NACOS_USERNAME}
|
||||
password: ${NACOS_PASSWORD}
|
||||
discovery:
|
||||
enabled: ${NACOS_DISCOVERY_ENABLED:true}
|
||||
register-enabled: ${NACOS_REGISTER_ENABLED:true}
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
namespace: ${NACOS_NAMESPACE}
|
||||
config:
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
namespace: ${NACOS_NAMESPACE}
|
||||
encode: UTF-8
|
||||
baiyue.security:
|
||||
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
|
||||
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
|
||||
issuer: ${JWT_ISSUER:auth-service}
|
||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||
@@ -39,13 +38,12 @@ baiyue.security:
|
||||
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
|
||||
max-login-failures: ${MAX_LOGIN_FAILURES:5}
|
||||
lock-duration: ${LOGIN_LOCK_DURATION:15m}
|
||||
overseas-jwt:
|
||||
enabled: ${OVERSEAS_JWT_ENABLED:false}
|
||||
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
|
||||
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
|
||||
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
|
||||
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
|
||||
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
|
||||
jwt-mode: jwks
|
||||
session-mode: redis
|
||||
jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json}
|
||||
jwt:
|
||||
rsa-jwk-set: ${JWT_RSA_JWK_SET:}
|
||||
current-kid: ${JWT_CURRENT_KID:}
|
||||
|
||||
baiyue.clients:
|
||||
iam:
|
||||
@@ -53,13 +51,4 @@ baiyue.clients:
|
||||
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
|
||||
logging:
|
||||
file:
|
||||
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
|
||||
logback:
|
||||
rollingpolicy:
|
||||
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
|
||||
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
|
||||
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
|
||||
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
|
||||
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
|
||||
baiyue.security.session-validation-uri: ${AUTH_SESSION_VALIDATION_URL:http://auth:9100/internal/auth/accounts/sessions/validate}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Auth 单一登录标识增量:用户名或中国大陆手机号。
|
||||
-- 可重复执行;手机号仅从 IAM 当前用户档案回填,无法确认的账号保持 NULL。
|
||||
USE baiyue_auth;
|
||||
|
||||
DROP PROCEDURE IF EXISTS auth_add_login_phone;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE auth_add_login_phone()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_auth' AND table_name = 'auth_account' AND column_name = 'phone') THEN
|
||||
ALTER TABLE auth_account ADD COLUMN phone VARCHAR(32) NULL COMMENT '全局唯一中国大陆登录手机号' AFTER username;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL auth_add_login_phone();
|
||||
DROP PROCEDURE auth_add_login_phone;
|
||||
|
||||
DROP PROCEDURE IF EXISTS auth_backfill_login_phone;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE auth_backfill_login_phone()
|
||||
BEGIN
|
||||
UPDATE auth_account
|
||||
SET phone = NULL
|
||||
WHERE phone IS NOT NULL AND phone NOT REGEXP '^1[3-9][0-9]{9}$';
|
||||
UPDATE auth_account account
|
||||
JOIN baiyue_iam.iam_user user ON user.auth_user_id = account.id AND user.deleted = 0
|
||||
JOIN (
|
||||
SELECT phone
|
||||
FROM baiyue_iam.iam_user
|
||||
WHERE deleted = 0 AND phone REGEXP '^1[3-9][0-9]{9}$'
|
||||
GROUP BY phone
|
||||
HAVING COUNT(DISTINCT auth_user_id) = 1
|
||||
) unique_phone ON unique_phone.phone = user.phone
|
||||
SET account.phone = user.phone
|
||||
WHERE account.phone IS NULL AND user.phone REGEXP '^1[3-9][0-9]{9}$';
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_auth' AND table_name = 'auth_account'
|
||||
AND index_name = 'uk_auth_account_phone') THEN
|
||||
ALTER TABLE auth_account ADD UNIQUE KEY uk_auth_account_phone (phone);
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL auth_backfill_login_phone();
|
||||
DROP PROCEDURE auth_backfill_login_phone;
|
||||
@@ -6,6 +6,7 @@ USE baiyue_auth;
|
||||
CREATE TABLE IF NOT EXISTS auth_account (
|
||||
id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)',
|
||||
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
|
||||
phone VARCHAR(32) DEFAULT 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锁定',
|
||||
@@ -23,6 +24,7 @@ CREATE TABLE IF NOT EXISTS auth_account (
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_auth_account_username (username),
|
||||
UNIQUE KEY uk_auth_account_phone (phone),
|
||||
KEY idx_auth_account_status (account_status, locked_until)
|
||||
) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态';
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -10,9 +10,7 @@ INSERT INTO auth_account (
|
||||
operator_tenant_id, default_membership_id, default_app_code, deleted
|
||||
) VALUES
|
||||
(1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
|
||||
'平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0),
|
||||
(1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
|
||||
'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 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),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Auth internal session validation
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/internal/auth/accounts/sessions/validate:
|
||||
post:
|
||||
summary: Validate persisted account and IAM authorization state
|
||||
description: Internal network only. No Gateway public route. Verifies the Redis Access session and state fingerprint against current Auth account and a consistent IAM snapshot. No positive cache; missing state, stale state and dependency failure deny access. Does not accept or forward a user Bearer token.
|
||||
security:
|
||||
- internalServiceToken: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [jwtId, userId]
|
||||
properties:
|
||||
jwtId: {type: string, minLength: 1}
|
||||
userId: {type: integer, format: int64}
|
||||
responses:
|
||||
'200':
|
||||
description: Only string code 200 and boolean data true constitute success. Business errors deny access.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [code]
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
data: {type: boolean, nullable: true}
|
||||
components:
|
||||
securitySchemes:
|
||||
internalServiceToken:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Internal-Service-Token
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package com.baiyue.auth.client;
|
||||
|
||||
import feign.Client;
|
||||
import feign.FeignException;
|
||||
import feign.Request;
|
||||
import feign.Response;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
|
||||
import org.springframework.boot.env.YamlPropertySourceLoader;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.cloud.openfeign.FeignAutoConfiguration;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.cloud.openfeign.FeignClientProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class AuthorizationFeignConfigurationContractTest {
|
||||
@Test
|
||||
void actualNamedNacosConfigMatchesContextIdAndOverridesGlobalFeignBudget() {
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(FeignAutoConfiguration.class,
|
||||
JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class))
|
||||
.withUserConfiguration(OfflineFeignConfiguration.class)
|
||||
.withPropertyValues("baiyue.clients.iam.url=http://127.0.0.1:1")
|
||||
.withInitializer(context -> {
|
||||
for (String file : new String[]{"baiyue-common.yml", "auth-service.yml"}) {
|
||||
Path path = Path.of("config", "nacos", "test", file);
|
||||
if (!Files.isRegularFile(path)) path = Path.of("..").resolve(path);
|
||||
try {
|
||||
// Load real configuration as properties only, without config-data imports.
|
||||
for (var source : new YamlPropertySourceLoader().load(file, new FileSystemResource(path))) {
|
||||
context.getEnvironment().getPropertySources().addLast(source);
|
||||
}
|
||||
} catch (java.io.IOException failure) {
|
||||
throw new IllegalStateException("Cannot read actual Auth Feign configuration", failure);
|
||||
}
|
||||
}
|
||||
})
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
String contextId = IamAuthorizationClient.class.getAnnotation(FeignClient.class).contextId();
|
||||
var properties = context.getBean(FeignClientProperties.class);
|
||||
var named = properties.getConfig().get(contextId);
|
||||
assertThat(named).as("Nacos key must match actual @FeignClient contextId").isNotNull();
|
||||
assertThat(named.getConnectTimeout()).isEqualTo(1000);
|
||||
assertThat(named.getReadTimeout()).isEqualTo(2000);
|
||||
assertThat(named.isFollowRedirects()).isFalse();
|
||||
assertThat(properties.getConfig().get("default").getConnectTimeout()).isEqualTo(3000);
|
||||
assertThat(properties.getConfig().get("default").getReadTimeout()).isEqualTo(5000);
|
||||
|
||||
var transport = context.getBean(CapturingClient.class);
|
||||
var client = context.getBean(IamAuthorizationClient.class);
|
||||
var request = new AuthorizationSnapshotRequest(1L, 7L, 9L, "MERCHANT");
|
||||
client.load("offline-service-token", request);
|
||||
assertThat(transport.options.get().connectTimeoutMillis()).isEqualTo(1000);
|
||||
assertThat(transport.options.get().readTimeoutMillis()).isEqualTo(2000);
|
||||
assertThat(transport.options.get().isFollowRedirects()).isFalse();
|
||||
|
||||
transport.status.set(503);
|
||||
assertThatThrownBy(() -> client.load("offline-service-token", request)).isInstanceOf(FeignException.class);
|
||||
assertThat(transport.calls.get()).as("One success plus one failure, without automatic retry").isEqualTo(2);
|
||||
});
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableFeignClients(clients = IamAuthorizationClient.class)
|
||||
static class OfflineFeignConfiguration {
|
||||
@Bean CapturingClient capturingClient() { return new CapturingClient(); }
|
||||
}
|
||||
|
||||
/** The real Feign factory builds the client; only network I/O is replaced. */
|
||||
static class CapturingClient implements Client {
|
||||
final AtomicReference<Request.Options> options = new AtomicReference<>();
|
||||
final AtomicInteger calls = new AtomicInteger();
|
||||
final AtomicInteger status = new AtomicInteger(204);
|
||||
|
||||
@Override public Response execute(Request request, Request.Options effectiveOptions) {
|
||||
options.set(effectiveOptions);
|
||||
calls.incrementAndGet();
|
||||
return Response.builder().request(request).status(status.get()).reason("offline")
|
||||
.headers(Map.of()).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -10,14 +10,14 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class AuthTokenTtlConfigurationContractTest {
|
||||
@Test
|
||||
void cnNacosConfigurationKeepsShortAccessAndSevenDayRefreshTokens() throws Exception {
|
||||
void testNacosConfigurationKeepsUnifiedShortAccessAndSevenDayRefreshTokens() throws Exception {
|
||||
String configuration = Files.readString(
|
||||
Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8);
|
||||
Path.of("..", "config", "nacos", "test", "auth-service.yml"), StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(configuration)
|
||||
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:24m}")
|
||||
.contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}")
|
||||
.contains("access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}")
|
||||
.doesNotContain("OVERSEAS_ACCESS_TOKEN_TTL")
|
||||
.doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DualTrackJwtConfigurationTest {
|
||||
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(DomesticJwtConfiguration.class, OverseasJwtConfiguration.class))
|
||||
.withBean(AuthSecurityProperties.class, () -> new AuthSecurityProperties(
|
||||
"domestic-secret-at-least-32-bytes-long", "auth-service", "lastleg-api",
|
||||
java.time.Duration.ofMinutes(24), java.time.Duration.ofDays(7), 5,
|
||||
java.time.Duration.ofMinutes(15)))
|
||||
.withPropertyValues(
|
||||
"baiyue.security.overseas-jwt.enabled=false");
|
||||
|
||||
@Test
|
||||
void startsDomesticEncoderWithoutRsaConfiguration() {
|
||||
runner.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context).hasBean("jwtEncoder");
|
||||
assertThat(context).doesNotHaveBean("overseasJwtEncoder");
|
||||
assertThat(context).hasSingleBean(JwtEncoder.class);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.*;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class UnifiedJwtConfigurationTest {
|
||||
@Test
|
||||
void signsWithCurrentKeyAndRetainsPreviousPublicKeyForRotation() throws Exception {
|
||||
var current = new RSAKeyGenerator(2048).keyID("current").algorithm(JWSAlgorithm.RS256).generate();
|
||||
var previous = new RSAKeyGenerator(2048).keyID("previous").algorithm(JWSAlgorithm.RS256).generate();
|
||||
var properties = new UnifiedJwtProperties(new JWKSet(List.of(current, previous.toPublicJWK())).toString(false), "current");
|
||||
var encoder = new UnifiedJwtConfiguration().jwtEncoder(properties);
|
||||
var now = Instant.now();
|
||||
var token = encoder.encode(JwtEncoderParameters.from(
|
||||
JwsHeader.with(SignatureAlgorithm.RS256).keyId("current").build(),
|
||||
JwtClaimsSet.builder().subject("42").issuedAt(now).expiresAt(now.plusSeconds(60)).build()));
|
||||
var decoded = NimbusJwtDecoder.withPublicKey(current.toRSAPublicKey()).build().decode(token.getTokenValue());
|
||||
assertThat(decoded.getSubject()).isEqualTo("42");
|
||||
assertThat(decoded.getHeaders()).containsEntry("kid", "current").containsEntry("alg", "RS256");
|
||||
assertThatThrownBy(() -> NimbusJwtDecoder.withPublicKey(previous.toRSAPublicKey()).build().decode(token.getTokenValue()))
|
||||
.isInstanceOf(JwtException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingPrivateKeyAndPreviousPrivateKey() throws Exception {
|
||||
var current = new RSAKeyGenerator(2048).keyID("current").generate();
|
||||
var previous = new RSAKeyGenerator(2048).keyID("previous").generate();
|
||||
var config = new UnifiedJwtConfiguration();
|
||||
assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties(new JWKSet(current.toPublicJWK()).toString(), "current")))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties(new JWKSet(List.of(current, previous)).toString(false), "current")))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties("", "current")))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.baiyue.auth.controller;
|
||||
|
||||
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||
import com.baiyue.auth.config.UnifiedJwtProperties;
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.KeyUse;
|
||||
@@ -20,8 +20,7 @@ class JwksControllerTest {
|
||||
RSAKey current = key("current", true);
|
||||
RSAKey previous = key("previous", false);
|
||||
String json = new JWKSet(List.of(current, previous)).toString(false);
|
||||
var properties = new OverseasJwtProperties(true, "auth-service", "lastleg-api", json,
|
||||
"current", Duration.ofMinutes(15));
|
||||
var properties = new UnifiedJwtProperties(json, "current");
|
||||
|
||||
var body = new JwksController(properties).jwks().getBody();
|
||||
String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body);
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.auth.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class AuthLoginIdentifiersSqlContractTest {
|
||||
@Test
|
||||
void legacyMigrationBackfillsOnlyUniquelyOwnedPhonesAndKeepsDuplicatesUnbound() throws Exception {
|
||||
assertSafeBackfill(new ClassPathResource("db/auth-login-identifiers.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyDatabaseInitializationDeclaresPhoneUniquenessWithoutLegacyBackfill() throws Exception {
|
||||
String sql = Files.readString(unifiedSql(), StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql)
|
||||
.contains("phone varchar(32) DEFAULT NULL")
|
||||
.contains("UNIQUE KEY uk_auth_account_phone (phone)")
|
||||
.doesNotContain("phone NOT REGEXP '^1[3-9][0-9]{9}$'")
|
||||
.doesNotContain("HAVING COUNT(DISTINCT auth_user_id) = 1")
|
||||
.doesNotContain("ALTER TABLE auth_account");
|
||||
}
|
||||
|
||||
private static void assertSafeBackfill(String sql) {
|
||||
assertThat(sql)
|
||||
.contains("phone NOT REGEXP '^1[3-9][0-9]{9}$'")
|
||||
.contains("HAVING COUNT(DISTINCT auth_user_id) = 1")
|
||||
.contains("ADD UNIQUE KEY uk_auth_account_phone (phone)")
|
||||
.doesNotContain("IAM 存在重复手机号,禁止建立 Auth 唯一登录索引");
|
||||
}
|
||||
|
||||
private static Path unifiedSql() {
|
||||
Path path = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
return Files.exists(path) ? path : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class InitialAccountSeedSqlContractTest {
|
||||
@Test
|
||||
void initializesOnlyThePlatformAndItTestAdministratorAccounts() throws Exception {
|
||||
void initializesOnlyThePlatformAdministratorAccount() throws Exception {
|
||||
assertAccountSeed(new ClassPathResource("db/phase1-admin-accounts.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8));
|
||||
assertAccountSeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8));
|
||||
@@ -19,11 +19,12 @@ class InitialAccountSeedSqlContractTest {
|
||||
|
||||
private static void assertAccountSeed(String sql) {
|
||||
assertThat(sql)
|
||||
.contains("(1000001, 'platform_admin'")
|
||||
.contains("'平台系统管理员', 'ACTIVE'")
|
||||
.contains("(1000002, 'merchant_admin'")
|
||||
.contains("'IT 测试客户管理员', 'ACTIVE'")
|
||||
.containsPattern("\\(1000001\\s*,\\s*'platform_admin'")
|
||||
.containsPattern("'平台系统管理员'\\s*,\\s*'ACTIVE'")
|
||||
.contains("password_change_required")
|
||||
.doesNotContain("merchant_admin")
|
||||
.doesNotContain("IT_TEST")
|
||||
.doesNotContain("(1000002, 'merchant_admin'")
|
||||
.doesNotContain("一期演示企业管理员");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.auth.repository;
|
||||
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest;
|
||||
import com.baiyue.auth.dto.MerchantRegisterRequest;
|
||||
import jakarta.validation.Validation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LoginIdentifierContractTest {
|
||||
|
||||
@Test
|
||||
void registrationExposesAnAccountPhoneInsteadOfALegalPersonPhoneAlias() {
|
||||
var componentNames = Arrays.stream(MerchantRegisterRequest.class.getRecordComponents())
|
||||
.map(component -> component.getName())
|
||||
.toList();
|
||||
|
||||
assertThat(componentNames).contains("phone").doesNotContain("legalPhone");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mainlandMobileRuleRejectsPlaceholderNumbersAcrossJavaAndSql() throws Exception {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
var validator = factory.getValidator();
|
||||
var invalidRegistration = new MerchantRegisterRequest(
|
||||
"merchant.user", "Password1", "测试用户", "测试企业", "张三",
|
||||
"11111111111", null, "CN", null);
|
||||
var validChange = new ChangeLoginIdentifiersRequest(
|
||||
"merchant.user", "13800138000", "Password1");
|
||||
var invalidChange = new ChangeLoginIdentifiersRequest(
|
||||
"merchant.user", "11111111111", "Password1");
|
||||
|
||||
assertThat(validator.validate(invalidRegistration)).isNotEmpty();
|
||||
assertThat(validator.validate(validChange)).isEmpty();
|
||||
assertThat(validator.validate(invalidChange)).isNotEmpty();
|
||||
}
|
||||
|
||||
String sql = new ClassPathResource("db/auth-login-identifiers.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(sql)
|
||||
.contains("REGEXP '^1[3-9][0-9]{9}$'")
|
||||
.doesNotContain("REGEXP '^1[0-9]{10}$'");
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountPhoneRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
@@ -18,6 +20,7 @@ import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
@@ -83,7 +86,7 @@ class AccountManagementServiceTest {
|
||||
}
|
||||
|
||||
@Test void createPlatformAccountPersistsPlatformAppCodeAndPasswordHashOnly() {
|
||||
when(repository.findById(20L)).thenReturn(null);
|
||||
when(repository.findCurrentById(20L)).thenReturn(null);
|
||||
when(repository.existsByUsername("platform.user")).thenReturn(false);
|
||||
|
||||
var result = service.create(new CreateAccountRequest(20L, "platform.user", "Password1",
|
||||
@@ -95,6 +98,7 @@ class AccountManagementServiceTest {
|
||||
assertEquals("PLATFORM", captor.getValue().getDefaultAppCode());
|
||||
assertTrue(encoder.matches("Password1", captor.getValue().getPasswordHash()));
|
||||
assertNotEquals("Password1", captor.getValue().getPasswordHash());
|
||||
verify(repository, never()).findById(20L);
|
||||
}
|
||||
|
||||
@Test void usernameAvailabilityUsesTheAuthAccountRepositoryAsTheFactSource() {
|
||||
@@ -105,7 +109,7 @@ class AccountManagementServiceTest {
|
||||
}
|
||||
|
||||
@Test void createDisabledAccountAndSummaryExposeLastLoginAndScope() {
|
||||
when(repository.findById(20L)).thenReturn(null);
|
||||
when(repository.findCurrentById(20L)).thenReturn(null);
|
||||
when(repository.existsByUsername("merchant.user")).thenReturn(false);
|
||||
|
||||
var created = service.create(new CreateAccountRequest(20L, "merchant.user", "Password1",
|
||||
@@ -139,6 +143,7 @@ class AccountManagementServiceTest {
|
||||
account.setUsername("platform.user"); account.setDisplayName("平台用户");
|
||||
account.setDefaultTenantId(0L); account.setOperatorTenantId(0L);
|
||||
account.setDefaultMembershipId(30L); account.setDefaultAppCode("PLATFORM"); account.setDeleted(0);
|
||||
when(repository.findCurrentById(10L)).thenReturn(account);
|
||||
|
||||
var result = service.create(new CreateAccountRequest(10L, "platform.user", "Password1",
|
||||
"平台用户", 0L, 30L, "PLATFORM"));
|
||||
@@ -147,6 +152,44 @@ class AccountManagementServiceTest {
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test void concurrentInsertOfSameIdentityReturnsDurableWinner() {
|
||||
account.setUsername("platform.user"); account.setDefaultTenantId(0L);
|
||||
account.setOperatorTenantId(0L); account.setDefaultMembershipId(30L);
|
||||
account.setDefaultAppCode("PLATFORM"); account.setDeleted(0);
|
||||
when(repository.findCurrentById(10L)).thenReturn(null);
|
||||
doThrow(new DuplicateKeyException("concurrent insert")).when(repository).insert(any());
|
||||
|
||||
var result = service.create(new CreateAccountRequest(10L, "platform.user", "Password1",
|
||||
"平台用户", 0L, 30L, "PLATFORM"));
|
||||
|
||||
assertEquals(10L, result.accountId());
|
||||
var order = inOrder(repository);
|
||||
order.verify(repository).findCurrentById(10L);
|
||||
order.verify(repository).existsByUsername("platform.user");
|
||||
order.verify(repository).insert(any());
|
||||
order.verify(repository).findById(10L);
|
||||
verify(repository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test void concurrentInsertOfDifferentIdentityIsRejected() {
|
||||
when(repository.findCurrentById(10L)).thenReturn(null);
|
||||
doThrow(new DuplicateKeyException("concurrent insert")).when(repository).insert(any());
|
||||
|
||||
assertThrows(BizException.class, () -> service.create(new CreateAccountRequest(10L,
|
||||
"platform.user", "Password1", "平台用户", 0L, 30L, "PLATFORM")));
|
||||
verify(repository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test void concurrentUsernameConflictDoesNotCreateOrReturnAnotherAccount() {
|
||||
when(repository.findCurrentById(20L)).thenReturn(null);
|
||||
when(repository.findById(20L)).thenReturn(null);
|
||||
doThrow(new DuplicateKeyException("username conflict")).when(repository).insert(any());
|
||||
|
||||
assertThrows(BizException.class, () -> service.create(new CreateAccountRequest(20L,
|
||||
"platform.user", "Password1", "平台用户", 0L, 30L, "PLATFORM")));
|
||||
verify(repository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test void synchronizeDisplayNameRevokesExistingSessions() {
|
||||
account.setDisplayName("旧名称");
|
||||
|
||||
@@ -157,6 +200,44 @@ class AccountManagementServiceTest {
|
||||
verify(sessions).revokeAll(10L);
|
||||
}
|
||||
|
||||
@Test void changeLoginIdentifiersRequiresCurrentPasswordAndRevokesSessions() {
|
||||
account.setUsername("old.user");
|
||||
account.setPhone("13800138000");
|
||||
when(repository.existsByUsernameForOtherAccount("new.user", 10L)).thenReturn(false);
|
||||
when(repository.existsByPhoneForOtherAccount("13900139000", 10L)).thenReturn(false);
|
||||
|
||||
var result = service.changeLoginIdentifiers(10L,
|
||||
new ChangeLoginIdentifiersRequest("new.user", "13900139000", "OldPass1"));
|
||||
|
||||
assertEquals("new.user", result.username());
|
||||
assertEquals("13900139000", result.phone());
|
||||
assertEquals(3, account.getTokenVersion());
|
||||
verify(sessions).revokeAll(10L);
|
||||
}
|
||||
|
||||
@Test void managedPhoneChangeOrRemovalRevokesOnlyThatAccountsSessions() {
|
||||
account.setPhone("13800138000");
|
||||
|
||||
service.updatePhone(10L, new UpdateAccountPhoneRequest(null));
|
||||
|
||||
assertEquals(null, account.getPhone());
|
||||
assertEquals(3, account.getTokenVersion());
|
||||
verify(repository).save(account);
|
||||
verify(sessions).revokeAll(10L);
|
||||
}
|
||||
|
||||
@Test void duplicatePhoneDoesNotChangeLoginIdentifiers() {
|
||||
account.setUsername("old.user");
|
||||
account.setPhone("13800138000");
|
||||
when(repository.existsByUsernameForOtherAccount("new.user", 10L)).thenReturn(false);
|
||||
when(repository.existsByPhoneForOtherAccount("13900139000", 10L)).thenReturn(true);
|
||||
|
||||
assertThrows(BizException.class, () -> service.changeLoginIdentifiers(10L,
|
||||
new ChangeLoginIdentifiersRequest("new.user", "13900139000", "OldPass1")));
|
||||
verify(repository, never()).save(any());
|
||||
verify(sessions, never()).revokeAll(any());
|
||||
}
|
||||
|
||||
@Test void rbacChangeRevokesAccessAndRefreshSessionsAndBumpsTokenVersions() {
|
||||
AuthAccount second = new AuthAccount();
|
||||
second.setId(11L); second.setTokenVersion(7); second.setAccountStatus("ACTIVE");
|
||||
|
||||
@@ -12,7 +12,7 @@ import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
@@ -23,7 +23,10 @@ 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;
|
||||
|
||||
@@ -38,16 +41,20 @@ class AuthServiceSmallJwtTest {
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class TestConfig {
|
||||
@Bean
|
||||
com.baiyue.auth.config.UnifiedJwtProperties signingProperties() {
|
||||
return new com.baiyue.auth.config.UnifiedJwtProperties("unused", "current");
|
||||
}
|
||||
@Bean
|
||||
AuthSecurityProperties properties() {
|
||||
return new AuthSecurityProperties("unused", "auth-service", "lastleg-api",
|
||||
return new AuthSecurityProperties("auth-service", "lastleg-api",
|
||||
Duration.ofMinutes(30),
|
||||
Duration.ofDays(7), 5, Duration.ofMinutes(10));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void signsCrossRegionClaimsAndStoresSession() {
|
||||
void signsUnifiedClaimsAndStoresSession() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(42L);
|
||||
account.setUsername("operator");
|
||||
@@ -59,7 +66,7 @@ class AuthServiceSmallJwtTest {
|
||||
account.setDefaultAppCode("PLATFORM");
|
||||
account.setTokenVersion(1);
|
||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
|
||||
"WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"));
|
||||
"WEB", Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("platform:order:list"), 1L, 900L);
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
|
||||
when(repository.findByUsername("operator")).thenReturn(account);
|
||||
@@ -68,7 +75,7 @@ class AuthServiceSmallJwtTest {
|
||||
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||
when(encoded.getTokenValue()).thenReturn("small-token");
|
||||
|
||||
service.login(new LoginRequest("operator", "secret"));
|
||||
var response = service.login(new LoginRequest("operator", "secret"));
|
||||
|
||||
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||
verify(jwtEncoder).encode(jwt.capture());
|
||||
@@ -79,15 +86,111 @@ class AuthServiceSmallJwtTest {
|
||||
.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();
|
||||
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256);
|
||||
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isEqualTo("current");
|
||||
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), anyString(), any(),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||
assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR");
|
||||
assertThat(response.authorities()).containsExactly("platform:order:list");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mainlandPhoneIsResolvedAsTheSinglePhoneLoginIdentifier() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(42L);
|
||||
account.setUsername("operator");
|
||||
account.setPhone("13800138000");
|
||||
account.setPasswordHash("hash");
|
||||
account.setAccountStatus("ACTIVE");
|
||||
account.setDefaultTenantId(0L);
|
||||
account.setOperatorTenantId(0L);
|
||||
account.setDefaultMembershipId(84L);
|
||||
account.setDefaultAppCode("PLATFORM");
|
||||
account.setTokenVersion(1);
|
||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
|
||||
"WEB", Set.of(), Set.of(), 1L);
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
when(repository.findByPhone("13800138000")).thenReturn(account);
|
||||
when(passwordEncoder.matches("secret", "hash")).thenReturn(true);
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||
when(encoded.getTokenValue()).thenReturn("small-token");
|
||||
|
||||
service.login(new LoginRequest("13800138000", "secret"));
|
||||
|
||||
verify(repository).findByPhone("13800138000");
|
||||
verify(repository, never()).findByUsername("13800138000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void placeholderNumberIsNeverResolvedAsAMobileLoginIdentifier() {
|
||||
when(repository.findByUsername("11111111111")).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.login(new LoginRequest("11111111111", "secret")))
|
||||
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
|
||||
.hasMessageContaining("登录标识或密码错误");
|
||||
|
||||
verify(repository).findByUsername("11111111111");
|
||||
verify(repository, never()).findByPhone("11111111111");
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshRotatesUnifiedTokenFromCurrentAuthorizationSnapshot() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
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", SessionStateService.fingerprint(account, snapshot)));
|
||||
when(repository.findById(52L)).thenReturn(account);
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||
when(encoded.getTokenValue()).thenReturn("domestic-refreshed");
|
||||
|
||||
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.roles()).containsExactly("MERCHANT_ADMIN");
|
||||
assertThat(response.authorities()).containsExactly("merchant:order:list");
|
||||
verify(tokenSessionStore).consume("refresh-old");
|
||||
verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
|
||||
anyString(), org.mockito.ArgumentMatchers.eq(response.refreshToken()),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||
verify(authorizationLoader).load(account);
|
||||
}
|
||||
|
||||
@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());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -102,7 +205,7 @@ class AuthServiceSmallJwtTest {
|
||||
account.setDefaultAppCode("MERCHANT");
|
||||
account.setTokenVersion(2);
|
||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT",
|
||||
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"));
|
||||
"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");
|
||||
@@ -113,7 +216,10 @@ class AuthServiceSmallJwtTest {
|
||||
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||
when(encoded.getTokenValue()).thenReturn("small-impersonation-token");
|
||||
|
||||
service.exchangeImpersonationTicket("one-time-code");
|
||||
AuthService.ImpersonationTokenResponse response = service.exchangeImpersonationTicket("one-time-code");
|
||||
|
||||
assertThat(response.roles()).containsExactly("MERCHANT_OWNER");
|
||||
assertThat(response.authorities()).containsExactly("merchant:order:list");
|
||||
|
||||
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||
verify(jwtEncoder).encode(jwt.capture());
|
||||
@@ -125,6 +231,6 @@ class AuthServiceSmallJwtTest {
|
||||
verify(tokenSessionStore).storeAccessOnly(any(), org.mockito.ArgumentMatchers.eq(43L),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
|
||||
anyString(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.never;
|
||||
|
||||
@SpringJUnitConfig(MerchantRegistrationService.class)
|
||||
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
|
||||
@@ -35,7 +36,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", "注册人", "测试企业", "张三",
|
||||
@@ -54,15 +55,61 @@ class MerchantRegistrationServiceTest {
|
||||
assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId());
|
||||
assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode());
|
||||
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
|
||||
assertEquals("13800138000", storedAccount.getValue().getPhone());
|
||||
assertEquals("13800138000", onboardingRequest.getValue().administratorPhone());
|
||||
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
|
||||
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
|
||||
assertEquals("PENDING", result.dimensionInitializationStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactIncompleteRegistrationRetryReturnsTheExistingRegistration() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(9001L);
|
||||
account.setUsername("registered_owner");
|
||||
account.setPhone("13800138000");
|
||||
account.setPasswordHash("encoded-password");
|
||||
account.setAccountStatus("ACTIVE");
|
||||
account.setDefaultAppCode("MERCHANT");
|
||||
when(accounts.findByUsername("registered_owner")).thenReturn(account);
|
||||
when(accounts.findByPhone("13800138000")).thenReturn(account);
|
||||
when(encoder.matches("Password123", "encoded-password")).thenReturn(true);
|
||||
when(onboarding.registration("internal-token", 9001L)).thenReturn(ApiResponse.success(
|
||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "READY")));
|
||||
|
||||
var result = service.register(new MerchantRegisterRequest(
|
||||
"registered_owner", "Password123", "注册人", "测试企业", null,
|
||||
"13800138000", "owner@example.com", "CN", "深圳市宝安区"));
|
||||
|
||||
assertEquals(9001L, result.accountId());
|
||||
assertEquals(10001L, result.tenantId());
|
||||
assertEquals("READY", result.dimensionInitializationStatus());
|
||||
verify(onboarding, never()).register(eq("internal-token"), any());
|
||||
verify(accounts, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void phoneOwnedByAnotherUsernameDirectsTheUserBackToLogin() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(9001L);
|
||||
account.setUsername("registered_owner");
|
||||
account.setPhone("13800138000");
|
||||
when(accounts.findByPhone("13800138000")).thenReturn(account);
|
||||
|
||||
var failure = assertThrows(com.baiyue.common.core.exception.BizException.class,
|
||||
() -> service.register(new MerchantRegisterRequest(
|
||||
"another_owner", "Password123", "注册人", "测试企业", null,
|
||||
"13800138000", "owner@example.com", "CN", "深圳市宝安区")));
|
||||
|
||||
assertEquals("该手机号已注册,请直接登录后继续完善企业资质", failure.getMessage());
|
||||
verify(onboarding, never()).register(eq("internal-token"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
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 +129,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();
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.AuthorizationLoader;
|
||||
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class})
|
||||
@TestPropertySource(properties = "baiyue.security.overseas-jwt.enabled=true")
|
||||
class OverseasTokenServiceTest {
|
||||
@MockBean private AuthAccountRepository accounts;
|
||||
@MockBean private AuthorizationLoader authorizationLoader;
|
||||
@MockBean(name = "overseasJwtEncoder") private JwtEncoder encoder;
|
||||
@Resource private OverseasTokenService service;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class Config {
|
||||
@Bean OverseasJwtProperties properties() {
|
||||
return new OverseasJwtProperties(true, "auth-service", "lastleg-api", "{}", "current-kid",
|
||||
Duration.ofMinutes(15));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void signsShortRs256TokenWithCrossRegionClaims() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(100L);
|
||||
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
|
||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 100L, 84L, "MERCHANT", "WEB",
|
||||
Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 7L);
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
when(accounts.findById(42L)).thenReturn(account);
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||
when(encoder.encode(any())).thenReturn(encoded);
|
||||
when(encoded.getTokenValue()).thenReturn("overseas-token");
|
||||
|
||||
var response = service.exchange(42L);
|
||||
|
||||
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||
verify(encoder).encode(captor.capture());
|
||||
var parameters = captor.getValue();
|
||||
assertThat(parameters.getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256);
|
||||
assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid");
|
||||
assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api");
|
||||
assertThat(parameters.getClaims().getClaims())
|
||||
.containsEntry("userId", 42L).containsEntry("tenantId", 100L)
|
||||
.containsEntry("appCode", "MERCHANT").containsEntry("permissionVersion", 7L)
|
||||
.containsEntry("roles", Set.of("MERCHANT_ADMIN"))
|
||||
.containsEntry("authorities", Set.of("merchant:order:list"))
|
||||
.doesNotContainKeys("user_id", "tenant_id", "app_code");
|
||||
assertThat(response.expiresIn()).isEqualTo(900);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.AuthorizationLoader;
|
||||
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||
import com.baiyue.auth.config.AuthSecurityProperties;
|
||||
import com.baiyue.auth.config.UnifiedJwtProperties;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ResetPasswordRequest;
|
||||
import com.baiyue.auth.dto.LoginRequest;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.data.redis.core.SetOperations;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** Real MyBatis repository, JDBC transactions/row locks and production login/refresh/revocation services.
|
||||
* Redis is an atomic in-memory transport double; this is not a Redis/MySQL environment test. */
|
||||
class SessionStateConcurrencyTest {
|
||||
JdbcTemplate authDb, iamDb;
|
||||
DataSourceTransactionManager authTx, iamTx;
|
||||
AuthService auth;
|
||||
AccountManagementService accounts;
|
||||
SessionStateService states;
|
||||
TokenSessionStore sessions;
|
||||
JwtEncoder encoder;
|
||||
AtomicBoolean redisFailure;
|
||||
final ConcurrentMap<String, String> values = new ConcurrentHashMap<>();
|
||||
final ConcurrentMap<String, Set<String>> sets = new ConcurrentHashMap<>();
|
||||
|
||||
@BeforeEach void setup() throws Exception {
|
||||
authDb = database(); iamDb = database();
|
||||
authTx = new DataSourceTransactionManager(authDb.getDataSource());
|
||||
iamTx = new DataSourceTransactionManager(iamDb.getDataSource());
|
||||
authDb.execute("CREATE TABLE auth_account(id BIGINT PRIMARY KEY, username VARCHAR(50),phone VARCHAR(50),"
|
||||
+ "password_hash VARCHAR(100),display_name VARCHAR(50),account_status VARCHAR(20),failed_login_count INT,"
|
||||
+ "locked_until TIMESTAMP,token_version INT,password_change_required BOOLEAN,default_tenant_id BIGINT,"
|
||||
+ "operator_tenant_id BIGINT,default_membership_id BIGINT,default_app_code VARCHAR(20),deleted INT,"
|
||||
+ "last_login_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)");
|
||||
authDb.update("INSERT INTO auth_account(id,username,password_hash,account_status,token_version,default_tenant_id,"
|
||||
+ "operator_tenant_id,default_membership_id,default_app_code,deleted) VALUES(1,'operator','old-password','ACTIVE',1,7,7,9,'MERCHANT',0)");
|
||||
iamDb.execute("CREATE TABLE authorization_state(id BIGINT PRIMARY KEY, version BIGINT, tenant_version BIGINT, permission VARCHAR(50), warehouse BIGINT, active BOOLEAN)");
|
||||
iamDb.update("INSERT INTO authorization_state VALUES (1,1,1,'write',10,true)");
|
||||
|
||||
var factory = new com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(authDb.getDataSource());
|
||||
var config = new com.baomidou.mybatisplus.core.MybatisConfiguration();
|
||||
config.setMapUnderscoreToCamelCase(true);
|
||||
config.addMapper(com.baiyue.auth.mapper.AuthAccountMapper.class);
|
||||
factory.setConfiguration(config);
|
||||
var mapper = new org.mybatis.spring.SqlSessionTemplate(factory.getObject())
|
||||
.getMapper(com.baiyue.auth.mapper.AuthAccountMapper.class);
|
||||
var repository = new AuthAccountRepository();
|
||||
ReflectionTestUtils.setField(repository, "mapper", mapper);
|
||||
AuthorizationLoader loader = mock(AuthorizationLoader.class);
|
||||
when(loader.load(any())).thenAnswer(call -> {
|
||||
var tx = new TransactionTemplate(iamTx);
|
||||
tx.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_REPEATABLE_READ);
|
||||
return tx.execute(status -> iamDb.queryForObject("SELECT * FROM authorization_state WHERE id=1", (rs, row) -> {
|
||||
if (!rs.getBoolean("active")) throw new IllegalArgumentException("IAM identity disabled");
|
||||
return new AuthorizationSnapshot(1L, 7L, 9L, "MERCHANT", "MERCHANT", Set.of("OPERATOR"),
|
||||
Set.of(rs.getString("permission")), rs.getLong("version"), rs.getLong("warehouse"), null,
|
||||
rs.getLong("tenant_version"));
|
||||
}));
|
||||
});
|
||||
sessions = new TokenSessionStore();
|
||||
ReflectionTestUtils.setField(sessions, "objectMapper", new ObjectMapper());
|
||||
ReflectionTestUtils.setField(sessions, "keyPrefix", "test");
|
||||
ReflectionTestUtils.setField(sessions, "redisTemplate", redis());
|
||||
PasswordEncoder passwords = mock(PasswordEncoder.class);
|
||||
when(passwords.matches(anyString(), anyString())).thenAnswer(c -> c.getArgument(0).equals(c.getArgument(1)));
|
||||
when(passwords.encode(anyString())).thenAnswer(c -> c.getArgument(0));
|
||||
encoder = mock(JwtEncoder.class);
|
||||
when(encoder.encode(any())).thenAnswer(c -> {
|
||||
var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims();
|
||||
return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build();
|
||||
});
|
||||
var service = new AuthService();
|
||||
ReflectionTestUtils.setField(service, "accountRepository", repository);
|
||||
ReflectionTestUtils.setField(service, "authorizationLoader", loader);
|
||||
ReflectionTestUtils.setField(service, "tokenSessionStore", sessions);
|
||||
ReflectionTestUtils.setField(service, "passwordEncoder", passwords);
|
||||
ReflectionTestUtils.setField(service, "jwtEncoder", encoder);
|
||||
ReflectionTestUtils.setField(service, "properties", new AuthSecurityProperties("issuer", "aud", Duration.ofMinutes(5), Duration.ofHours(1), 5, Duration.ofMinutes(1)));
|
||||
ReflectionTestUtils.setField(service, "signingProperties", new UnifiedJwtProperties("unused", "kid"));
|
||||
auth = transactional(service);
|
||||
var management = new AccountManagementService();
|
||||
ReflectionTestUtils.setField(management, "repository", repository);
|
||||
ReflectionTestUtils.setField(management, "tokenSessionStore", sessions);
|
||||
ReflectionTestUtils.setField(management, "passwordEncoder", passwords);
|
||||
accounts = transactional(management);
|
||||
var state = new SessionStateService();
|
||||
ReflectionTestUtils.setField(state, "accounts", repository);
|
||||
ReflectionTestUtils.setField(state, "authorizationLoader", loader);
|
||||
ReflectionTestUtils.setField(state, "sessions", sessions);
|
||||
states = transactional(state);
|
||||
}
|
||||
|
||||
@Test void lateLoginAfterRevokeBeforeIamCommitCannotUseOldPermissionsOrRefresh() throws Exception {
|
||||
var revoked = new CountDownLatch(1);
|
||||
var snapshotLoaded = new CountDownLatch(1);
|
||||
var committed = new CountDownLatch(1);
|
||||
doAnswer(c -> {
|
||||
snapshotLoaded.countDown(); await(committed);
|
||||
var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims();
|
||||
return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build();
|
||||
}).when(encoder).encode(any());
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<?> change = pool.submit(() -> {
|
||||
new TransactionTemplate(iamTx).executeWithoutResult(tx -> {
|
||||
iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1");
|
||||
accounts.revokeSessions(Set.of(1L));
|
||||
revoked.countDown(); await(snapshotLoaded);
|
||||
});
|
||||
committed.countDown();
|
||||
});
|
||||
await(revoked);
|
||||
var login = pool.submit(() -> auth.login(new LoginRequest("operator", "old-password")));
|
||||
var stale = login.get(5, TimeUnit.SECONDS); change.get(5, TimeUnit.SECONDS);
|
||||
// Access and state were written AFTER revoke, yet the first post-commit request is denied.
|
||||
assertThat(values).containsKey("test:access:" + stale.accessToken());
|
||||
assertThatThrownBy(() -> states.validate(stale.accessToken(), 1L)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> auth.refresh(stale.refreshToken())).isInstanceOf(BizException.class);
|
||||
var fresh = auth.login(new LoginRequest("operator", "old-password"));
|
||||
assertThat(states.validate(fresh.accessToken(), 1L)).isTrue();
|
||||
assertThat(fresh.authorities()).containsExactly("read");
|
||||
assertThatThrownBy(() -> auth.refresh(stale.refreshToken())).isInstanceOf(BizException.class);
|
||||
} finally { committed.countDown(); pool.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test void passwordResetWaitsForConcurrentLoginThenInvalidatesAccessAndRefresh() throws Exception {
|
||||
var loaded = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
doAnswer(c -> {
|
||||
loaded.countDown(); await(release);
|
||||
var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims();
|
||||
return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build();
|
||||
}).when(encoder).encode(any());
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var login = pool.submit(() -> auth.login(new LoginRequest("operator", "old-password")));
|
||||
await(loaded);
|
||||
var resetting = new CountDownLatch(1);
|
||||
var reset = pool.submit(() -> { resetting.countDown(); accounts.resetPassword(1L, new ResetPasswordRequest("new-password", false), false); });
|
||||
await(resetting);
|
||||
assertThatThrownBy(() -> reset.get(100, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class);
|
||||
release.countDown();
|
||||
var old = login.get(5, TimeUnit.SECONDS); reset.get(5, TimeUnit.SECONDS);
|
||||
assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(2);
|
||||
assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(BizException.class);
|
||||
} finally { release.countDown(); pool.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test void failedRevocationRollsBackAuthAndIamAndDoesNotReportSuccess() {
|
||||
var old = auth.login(new LoginRequest("operator", "old-password"));
|
||||
redisFailure.set(true);
|
||||
assertThatThrownBy(() -> new TransactionTemplate(iamTx).executeWithoutResult(tx -> {
|
||||
iamDb.update("UPDATE authorization_state SET version=version+1, active=false WHERE id=1");
|
||||
accounts.revokeSessions(Set.of(1L));
|
||||
})).isInstanceOf(BizException.class);
|
||||
redisFailure.set(false);
|
||||
assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(1);
|
||||
assertThat(iamDb.queryForObject("SELECT version FROM authorization_state WHERE id=1", Long.class)).isEqualTo(1);
|
||||
assertThat(states.validate(old.accessToken(), 1L)).isTrue();
|
||||
}
|
||||
|
||||
@Test void committedIamVersionRejectsResidualSessionWhenNoRedisDeletionHappens() {
|
||||
var old = auth.login(new LoginRequest("operator", "old-password"));
|
||||
new TransactionTemplate(iamTx).executeWithoutResult(tx ->
|
||||
iamDb.update("UPDATE authorization_state SET tenant_version=tenant_version+1 WHERE id=1"));
|
||||
assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(BizException.class);
|
||||
var current = auth.login(new LoginRequest("operator", "old-password"));
|
||||
assertThat(states.validate(current.accessToken(), 1L)).isTrue();
|
||||
iamDb.update("UPDATE authorization_state SET warehouse=20 WHERE id=1");
|
||||
assertThatThrownBy(() -> states.validate(current.accessToken(), 1L)).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test void iamRollbackPreservesFingerprintAndOtherUserCannotUseSession() {
|
||||
var old = auth.login(new LoginRequest("operator", "old-password"));
|
||||
new TransactionTemplate(iamTx).executeWithoutResult(tx -> {
|
||||
iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1");
|
||||
tx.setRollbackOnly();
|
||||
});
|
||||
assertThat(states.validate(old.accessToken(), 1L)).isTrue();
|
||||
assertThatThrownBy(() -> states.validate(old.accessToken(), 2L)).isInstanceOf(BizException.class);
|
||||
var refreshed = auth.refresh(old.refreshToken());
|
||||
assertThat(states.validate(refreshed.accessToken(), 1L)).isTrue();
|
||||
}
|
||||
|
||||
@Test void lostRevokeAcknowledgementRollsBackIamButCannotRestoreOldAuthSession() {
|
||||
var old = auth.login(new LoginRequest("operator", "old-password"));
|
||||
assertThatThrownBy(() -> new TransactionTemplate(iamTx).executeWithoutResult(tx -> {
|
||||
iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1");
|
||||
accounts.revokeSessions(Set.of(1L));
|
||||
throw new IllegalStateException("Auth response timed out after its commit");
|
||||
})).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(iamDb.queryForObject("SELECT permission FROM authorization_state WHERE id=1", String.class)).isEqualTo("write");
|
||||
assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(2);
|
||||
assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class);
|
||||
var fresh = auth.login(new LoginRequest("operator", "old-password"));
|
||||
assertThat(states.validate(fresh.accessToken(), 1L)).isTrue();
|
||||
assertThat(fresh.authorities()).containsExactly("write");
|
||||
}
|
||||
|
||||
@Test void disabledCustomerOrMemberAndUnavailableIamRejectAccessAndRefresh() {
|
||||
var old = auth.login(new LoginRequest("operator", "old-password"));
|
||||
// No Redis deletion: the authoritative identity gate alone closes the session.
|
||||
iamDb.update("UPDATE authorization_state SET active=false, tenant_version=tenant_version+1 WHERE id=1");
|
||||
assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(IllegalArgumentException.class);
|
||||
iamDb.update("UPDATE authorization_state SET active=true WHERE id=1");
|
||||
var fresh = auth.login(new LoginRequest("operator", "old-password"));
|
||||
iamDb.execute("DROP TABLE authorization_state");
|
||||
assertThatThrownBy(() -> states.validate(fresh.accessToken(), 1L)).isInstanceOf(org.springframework.dao.DataAccessException.class);
|
||||
assertThatThrownBy(() -> auth.refresh(fresh.refreshToken())).isInstanceOf(org.springframework.dao.DataAccessException.class);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked") private StringRedisTemplate redis() {
|
||||
redisFailure = new AtomicBoolean();
|
||||
var redis = mock(StringRedisTemplate.class);
|
||||
ValueOperations<String,String> ops = mock(ValueOperations.class);
|
||||
SetOperations<String,String> index = mock(SetOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(ops); when(redis.opsForSet()).thenReturn(index);
|
||||
doAnswer(c -> { values.put(c.getArgument(0), c.getArgument(1)); return null; }).when(ops).set(anyString(), anyString(), any(Duration.class));
|
||||
when(ops.get(anyString())).thenAnswer(c -> values.get(c.getArgument(0)));
|
||||
when(ops.getAndDelete(anyString())).thenAnswer(c -> values.remove(c.getArgument(0)));
|
||||
when(redis.hasKey(anyString())).thenAnswer(c -> values.containsKey(c.getArgument(0)));
|
||||
when(redis.delete(anyString())).thenAnswer(c -> values.remove(c.getArgument(0)) != null);
|
||||
when(redis.delete(anyCollection())).thenAnswer(c -> {
|
||||
if (redisFailure.get()) throw new org.springframework.data.redis.RedisConnectionFailureException("offline");
|
||||
long count = 0; for (Object key : (java.util.Collection<?>) c.getArgument(0)) if (values.remove(key) != null) count++;
|
||||
return count;
|
||||
});
|
||||
when(index.members(anyString())).thenAnswer(c -> sets.getOrDefault(c.getArgument(0), Set.of()));
|
||||
doAnswer(c -> {
|
||||
Set<String> members = sets.computeIfAbsent(c.getArgument(0), key -> ConcurrentHashMap.newKeySet());
|
||||
for (int i=1; i<c.getArguments().length; i++) members.add(c.getArgument(i));
|
||||
return 1L;
|
||||
}).when(index).add(anyString(), any(String[].class));
|
||||
return redis;
|
||||
}
|
||||
|
||||
static JdbcTemplate database() {
|
||||
var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=3000");
|
||||
return new JdbcTemplate(ds);
|
||||
}
|
||||
@SuppressWarnings("unchecked") <T> T transactional(T target) {
|
||||
var factory = new ProxyFactory(target); factory.setProxyTargetClass(true);
|
||||
factory.addAdvice(new TransactionInterceptor(authTx, new AnnotationTransactionAttributeSource()));
|
||||
return (T) factory.getProxy();
|
||||
}
|
||||
static void await(CountDownLatch latch) {
|
||||
try { if (!latch.await(5, TimeUnit.SECONDS)) throw new AssertionError("Barrier timed out"); }
|
||||
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new AssertionError(e); }
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ class TokenSessionStoreTest {
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
|
||||
store.store("jti-1", 42L, Set.of("ADMIN"), Set.of("order:read"),
|
||||
"raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7));
|
||||
"state", "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7));
|
||||
|
||||
verify(values).set(eq("baiyue:auth:access:jti-1"),
|
||||
org.mockito.ArgumentMatchers.argThat(value -> value.contains("\"version\":1")
|
||||
@@ -64,7 +64,7 @@ class TokenSessionStoreTest {
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
String json = new ObjectMapper().writeValueAsString(
|
||||
new TokenSessionStore.RefreshSession(42L, "jti-1"));
|
||||
new TokenSessionStore.RefreshSession(42L, "jti-1", "state"));
|
||||
when(values.getAndDelete(anyString())).thenReturn(json);
|
||||
|
||||
TokenSessionStore.RefreshSession session = store.consume("raw-refresh");
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Common Audit 模块指引
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
- 负责业务审计事件采集、注解切面、敏感字段脱敏以及日志/HTTP 输出。
|
||||
|
||||
-2
@@ -7,14 +7,12 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 审计 Outbox 的 RocketMQ 发布配置。
|
||||
* 该配置与基础审计配置隔离:未引入 RocketMQ 的服务仍可使用 HTTP 审计投递。
|
||||
*/
|
||||
@AutoConfiguration(after = BaiyueAuditAutoConfiguration.class)
|
||||
@EnableScheduling
|
||||
@ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate")
|
||||
@ConditionalOnBean(type = {
|
||||
"com.baiyue.common.audit.AuditOutboxRepository",
|
||||
|
||||
@@ -48,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);
|
||||
@@ -68,9 +87,14 @@ class BusinessAuditAspectTest {
|
||||
when(joinPoint.getSignature()).thenReturn(signature);
|
||||
when(joinPoint.getTarget()).thenReturn(target);
|
||||
when(joinPoint.getArgs()).thenReturn(new Object[]{user, requestedTenantId});
|
||||
when(joinPoint.proceed()).thenReturn("ok");
|
||||
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());
|
||||
|
||||
@@ -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("******");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Common Core 模块指引
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
- 提供不依赖具体业务域的返回模型、异常、基础实体和 MyBatis-Plus 通用配置。
|
||||
@@ -14,11 +19,14 @@
|
||||
| 业务异常 | `src/main/java/com/baiyue/common/core/exception/BizException.java` |
|
||||
| 通用审计字段 | `src/main/java/com/baiyue/common/core/model/BaseEntity.java` |
|
||||
| MyBatis-Plus 与审计人 | `src/main/java/com/baiyue/common/core/mybatis/` |
|
||||
| 有限重试与退避 | `src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java`;供多个服务的 Outbox 复用,不拥有持久化或投递逻辑 |
|
||||
| 人工重试事务与凭据 | `src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java`、`EventOperationsTokenGuard.java`;三个生产服务显式选择本库表及事件白名单,锁定 DEAD 原记录,状态恢复与审计同事务;不访问其他服务数据库或直接投递事件 |
|
||||
| 逐次投递审计事务 | `src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java`;领取与STARTED、持有者CAS完成与审计各自同库REQUIRES_NEW事务;HTTP在两个事务之间执行,迟到结果不得写入;JDBC为optional依赖,Print等无数据库服务不被引入JDBC |
|
||||
| 响应契约测试 | `src/test/java/com/baiyue/common/core/ApiResponseTest.java` |
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 公共响应、错误码或基础实体变化会影响所有服务;先检索消费者并保持序列化和数据库兼容。
|
||||
- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约。当前正式模型不保留旧序列化或数据库兼容分支;若已有不可变外部契约,先单独定义升级规则。
|
||||
- `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。
|
||||
- `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。
|
||||
- 新能力只有被多个模块稳定复用时才放入本模块。
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<name>baiyue-common-core</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency><groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-annotations</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework</groupId><artifactId>spring-web</artifactId></dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
@@ -24,5 +25,7 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency><groupId>org.springframework</groupId><artifactId>spring-jdbc</artifactId><optional>true</optional></dependency>
|
||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.baiyue.common.core.api;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
@@ -8,7 +10,7 @@ import java.time.Instant;
|
||||
public record ApiResponse<T>(
|
||||
String code,
|
||||
String message,
|
||||
T data,
|
||||
@JsonInclude(JsonInclude.Include.ALWAYS) T data,
|
||||
String requestId,
|
||||
String traceId,
|
||||
Instant timestamp) {
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.common.core.container;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Platform-wide container types and practical loading limits. */
|
||||
public final class ContainerSpecificationCatalog {
|
||||
public static final String DEFAULT_CONTAINER_TYPE = "40HQ";
|
||||
|
||||
private static final List<Specification> ORDERED_VALUES = List.of(
|
||||
new Specification("40GP", new BigDecimal("56"), new BigDecimal("58"), new BigDecimal("19500"),
|
||||
"美线电商较少使用,通常优先选择40HQ"),
|
||||
new Specification("40HQ", new BigDecimal("65"), new BigDecimal("68"), new BigDecimal("19500"),
|
||||
"轻泡货首选,适合家居、玩具、日用品"),
|
||||
new Specification("45HQ", new BigDecimal("82"), new BigDecimal("86"), new BigDecimal("19500"),
|
||||
"抛货量大时考虑,底盘和海外仓预约资源较少")
|
||||
);
|
||||
private static final Map<String, Specification> VALUES = ORDERED_VALUES.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(Specification::containerType, Function.identity()));
|
||||
|
||||
private ContainerSpecificationCatalog() {}
|
||||
|
||||
public static List<Specification> values() {
|
||||
return ORDERED_VALUES;
|
||||
}
|
||||
|
||||
public static Specification require(String containerType) {
|
||||
String normalized = containerType == null ? "" : containerType.trim().toUpperCase(Locale.ROOT);
|
||||
Specification specification = VALUES.get(normalized);
|
||||
if (specification == null) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "柜型仅支持40GP、40HQ、45HQ");
|
||||
}
|
||||
return specification;
|
||||
}
|
||||
|
||||
public static Specification requireMatch(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) {
|
||||
Specification specification = require(containerType);
|
||||
if (capacityCbm == null || maxWeightKg == null
|
||||
|| specification.capacityCbm().compareTo(capacityCbm) != 0
|
||||
|| specification.maxWeightKg().compareTo(maxWeightKg) != 0) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"柜型容量和货重上限必须使用平台统一定义");
|
||||
}
|
||||
return specification;
|
||||
}
|
||||
|
||||
public record Specification(String containerType, BigDecimal recommendedMinCbm, BigDecimal capacityCbm,
|
||||
BigDecimal maxWeightKg, String description) {}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.baiyue.common.core.outbox;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.IntSupplier;
|
||||
|
||||
/** Same-database audit. HTTP must only run after begin returns; never hold a DB transaction over HTTP. */
|
||||
public final class EventDeliveryJournal {
|
||||
public enum Outcome { APPLIED, DUPLICATE, STALE, RETRY, DEAD, LEASE_EXPIRED }
|
||||
private final JdbcTemplate jdbc;
|
||||
private final TransactionTemplate transaction;
|
||||
|
||||
public EventDeliveryJournal(JdbcTemplate jdbc, PlatformTransactionManager manager) {
|
||||
this.jdbc = Objects.requireNonNull(jdbc);
|
||||
if (!(manager instanceof DataSourceTransactionManager local)
|
||||
|| local.getDataSource() != jdbc.getDataSource()) {
|
||||
throw new IllegalArgumentException("Delivery audit must share the local Outbox transaction manager");
|
||||
}
|
||||
transaction = new TransactionTemplate(manager);
|
||||
transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
}
|
||||
|
||||
public boolean begin(String attempt, String service, String eventType, String entryId,
|
||||
String eventId, String target, LocalDateTime now, BooleanSupplier claim) {
|
||||
return Boolean.TRUE.equals(transaction.execute(status -> {
|
||||
if (!claim.getAsBoolean()) return false;
|
||||
jdbc.update("INSERT INTO event_delivery_attempt(attempt_id,service_name,event_type,entry_id,event_id,target_name,started_at,outcome) VALUES(?,?,?,?,?,?,?,'STARTED')",
|
||||
attempt, service, eventType, entryId, eventId, target, now);
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
public boolean complete(String attempt, Outcome outcome, LocalDateTime now,
|
||||
String errorCode, LocalDateTime nextRetry, IntSupplier updateOwnedOutbox) {
|
||||
if (errorCode != null && !Set.of("RETRY_EXHAUSTED", "LEASE_EXPIRED", "INVALID_EVENT", "INVALID_RESPONSE", "PERMANENT_FAILURE", "DELIVERY_FAILURE").contains(errorCode)
|
||||
&& !errorCode.matches("HTTP_[1-5][0-9]{2}")) throw new IllegalArgumentException("Unbounded audit error code");
|
||||
return Boolean.TRUE.equals(transaction.execute(status -> {
|
||||
int changed = updateOwnedOutbox.getAsInt();
|
||||
if (changed == 0) return false; // A stale worker has no right to change either record.
|
||||
if (changed != 1) throw new IllegalStateException("Unexpected Outbox update count");
|
||||
LocalDateTime started = jdbc.queryForObject(
|
||||
"SELECT started_at FROM event_delivery_attempt WHERE attempt_id=? AND outcome='STARTED' FOR UPDATE",
|
||||
LocalDateTime.class, attempt);
|
||||
long duration = Math.max(0, Duration.between(Objects.requireNonNull(started), now).toMillis());
|
||||
int audited = jdbc.update("UPDATE event_delivery_attempt SET finished_at=?,duration_ms=?,outcome=?,error_code=?,next_retry_at=? WHERE attempt_id=? AND outcome='STARTED'",
|
||||
now, duration, outcome.name(), errorCode, nextRetry, attempt);
|
||||
if (audited != 1) throw new IllegalStateException("Delivery completion audit is missing");
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.common.core.outbox;
|
||||
|
||||
/** Marks services that require durable attempt audit and cached delivery metrics. */
|
||||
public interface EventDeliveryProducer { }
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.common.core.outbox;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/** Dedicated operations credential; a missing or weak configuration disables this capability. */
|
||||
public final class EventOperationsTokenGuard {
|
||||
private final byte[] expected;
|
||||
public EventOperationsTokenGuard(String token) {
|
||||
expected = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
public void require(String supplied) {
|
||||
if (expected.length < 32 || supplied == null || supplied.length() > 4096
|
||||
|| !MessageDigest.isEqual(expected, supplied.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "事件运维凭据无效或入口未启用");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.baiyue.common.core.outbox;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Transactional operator retry of an immutable, service-owned Outbox entry. No cross-schema access. */
|
||||
public final class OutboxOperations {
|
||||
public record Layout(String table, String idColumn, String statusColumn, Set<String> eventTypes) {
|
||||
public Layout {
|
||||
for (String name : List.of(table, idColumn, statusColumn)) {
|
||||
if (!name.matches("[a-z][a-z0-9_]*")) throw new IllegalArgumentException("Invalid Outbox identifier");
|
||||
}
|
||||
eventTypes = Set.copyOf(eventTypes);
|
||||
if (eventTypes.isEmpty()) throw new IllegalArgumentException("Event allowlist is required");
|
||||
}
|
||||
}
|
||||
public record Identity(String eventId, String eventType, String payloadHash) { }
|
||||
public record RetryRequest(String requestId, String operator, String reason, String expectedPayloadHash) { }
|
||||
public record RetryResult(String entryId, String eventId, String payloadHash, String requestId, boolean duplicate) { }
|
||||
public record Entry(String entryId, String eventId, String eventType, String payloadHash,
|
||||
String status, int failures, String lastError, Object nextRetryAt) { }
|
||||
private final JdbcTemplate jdbc;
|
||||
private final Layout layout;
|
||||
private final Function<String, Identity> identityReader;
|
||||
private final TransactionTemplate transaction;
|
||||
|
||||
public OutboxOperations(JdbcTemplate jdbc, PlatformTransactionManager transactions, Layout layout,
|
||||
Function<String, Identity> identityReader) {
|
||||
this.jdbc = jdbc;
|
||||
this.layout = layout;
|
||||
this.identityReader = identityReader;
|
||||
this.transaction = new TransactionTemplate(transactions);
|
||||
}
|
||||
|
||||
public Entry inspect(String entryId) {
|
||||
validateId(entryId);
|
||||
Map<String, Object> row = find(entryId, false);
|
||||
Identity identity = identity(row);
|
||||
return new Entry(String.valueOf(row.get("entry_id")), identity.eventId(), identity.eventType(), identity.payloadHash(),
|
||||
String.valueOf(row.get("entry_status")), ((Number) row.get("retry_count")).intValue(),
|
||||
(String) row.get("last_error"), row.get("next_retry_at"));
|
||||
}
|
||||
|
||||
public RetryResult retry(String entryId, RetryRequest request) {
|
||||
validateId(entryId);
|
||||
validateRequest(request);
|
||||
try {
|
||||
return transaction.execute(status -> {
|
||||
Map<String, Object> row = find(entryId, true);
|
||||
String canonicalId = String.valueOf(row.get("entry_id"));
|
||||
Identity identity = identity(row);
|
||||
if (!identity.payloadHash().equalsIgnoreCase(request.expectedPayloadHash())) {
|
||||
throw conflict("事件摘要已变化,拒绝重试");
|
||||
}
|
||||
List<Map<String, Object>> prior = jdbc.queryForList(
|
||||
"SELECT producer_table,entry_id,event_id,payload_hash,operator_name,reason FROM event_delivery_retry_audit WHERE request_id=?",
|
||||
request.requestId());
|
||||
if (!prior.isEmpty()) {
|
||||
Map<String, Object> audit = prior.get(0);
|
||||
if (!layout.table().equals(audit.get("producer_table")) || !canonicalId.equals(audit.get("entry_id"))
|
||||
|| !identity.eventId().equals(audit.get("event_id"))
|
||||
|| !identity.payloadHash().equals(audit.get("payload_hash"))
|
||||
|| !request.operator().equals(audit.get("operator_name")) || !request.reason().equals(audit.get("reason"))) {
|
||||
throw conflict("操作请求编号已用于不同重试参数");
|
||||
}
|
||||
return result(canonicalId, identity, request, true);
|
||||
}
|
||||
if (!"DEAD".equals(row.get("entry_status"))) throw conflict("仅允许重试 DEAD 事件");
|
||||
// Relay due-time comparisons use UTC, independently of the JVM default zone.
|
||||
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
|
||||
int updated = jdbc.update("UPDATE " + layout.table() + " SET " + layout.statusColumn()
|
||||
+ "='PENDING',retry_count=0,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=NULL WHERE "
|
||||
+ layout.idColumn() + "=? AND " + layout.statusColumn() + "='DEAD'", now, canonicalId);
|
||||
if (updated != 1) throw conflict("事件状态已变化");
|
||||
jdbc.update("INSERT INTO event_delivery_retry_audit(request_id,producer_table,entry_id,event_id,event_type,payload_hash,operator_name,reason,previous_failures,previous_error,requested_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
request.requestId(), layout.table(), canonicalId, identity.eventId(), identity.eventType(), identity.payloadHash(),
|
||||
request.operator(), request.reason(), row.get("retry_count"), row.get("last_error"), now);
|
||||
return result(canonicalId, identity, request, false);
|
||||
});
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
throw conflict("操作请求编号冲突,原事务已回滚");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> find(String id, boolean lock) {
|
||||
List<Map<String, Object>> rows = jdbc.queryForList("SELECT " + layout.idColumn() + " AS entry_id,payload_json," + layout.statusColumn()
|
||||
+ " AS entry_status,retry_count,last_error,next_retry_at FROM " + layout.table() + " WHERE "
|
||||
+ layout.idColumn() + "=?" + (lock ? " FOR UPDATE" : ""), id);
|
||||
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "事件不存在");
|
||||
return rows.get(0);
|
||||
}
|
||||
private Identity identity(Map<String, Object> row) {
|
||||
Identity identity = identityReader.apply(String.valueOf(row.get("payload_json")));
|
||||
if (!layout.eventTypes().contains(identity.eventType())) throw new BizException(ResultCode.FORBIDDEN, "事件不属于本运维入口");
|
||||
return identity;
|
||||
}
|
||||
private static RetryResult result(String entryId, Identity identity, RetryRequest request, boolean duplicate) {
|
||||
return new RetryResult(entryId, identity.eventId(), identity.payloadHash(), request.requestId(), duplicate);
|
||||
}
|
||||
private static void validateId(String entryId) {
|
||||
if (entryId == null || !entryId.matches("[A-Za-z0-9-]{1,64}")) throw new BizException(ResultCode.BAD_REQUEST, "事件记录标识无效");
|
||||
}
|
||||
private static void validateRequest(RetryRequest request) {
|
||||
if (request == null) throw new BizException(ResultCode.BAD_REQUEST, "重试参数不能为空");
|
||||
try {
|
||||
if (!UUID.fromString(request.requestId()).toString().equalsIgnoreCase(request.requestId())) throw new IllegalArgumentException();
|
||||
} catch (RuntimeException failure) { throw new BizException(ResultCode.BAD_REQUEST, "requestId 必须为标准 UUID"); }
|
||||
requireText(request.operator(), 128, "操作人");
|
||||
requireText(request.reason(), 500, "原因");
|
||||
if (request.expectedPayloadHash() == null || !request.expectedPayloadHash().matches("sha256:[0-9a-fA-F]{64}")) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "必须提供预期事件摘要");
|
||||
}
|
||||
}
|
||||
private static void requireText(String value, int length, String label) {
|
||||
if (value == null || value.isBlank() || value.length() > length || value.chars().anyMatch(Character::isISOControl)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, label + "无效");
|
||||
}
|
||||
}
|
||||
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.common.core.retry;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/** Bounded exponential retry schedule; callers persist failed or expired attempts. */
|
||||
public record BoundedRetryPolicy(int maxAttempts, long baseDelaySeconds, long maxDelaySeconds, double jitter) {
|
||||
public BoundedRetryPolicy {
|
||||
if (maxAttempts < 1 || baseDelaySeconds < 1 || maxDelaySeconds < baseDelaySeconds
|
||||
|| maxDelaySeconds > 86400 || !Double.isFinite(jitter) || jitter < 0 || jitter > 0.5) {
|
||||
throw new IllegalArgumentException("Invalid bounded retry policy");
|
||||
}
|
||||
}
|
||||
public boolean exhausted(int failedAttempts) {
|
||||
return failedAttempts >= maxAttempts;
|
||||
}
|
||||
public Duration delayAfterFailure(int failedAttempts) {
|
||||
return delayAfterFailure(failedAttempts, ThreadLocalRandom.current().nextDouble());
|
||||
}
|
||||
public Duration delayAfterFailure(int failedAttempts, double random) {
|
||||
if (failedAttempts < 1 || !Double.isFinite(random) || random < 0 || random > 1) {
|
||||
throw new IllegalArgumentException("Invalid retry attempt or jitter sample");
|
||||
}
|
||||
long seconds = baseDelaySeconds;
|
||||
for (int i = 1; i < failedAttempts && seconds < maxDelaySeconds; i++) {
|
||||
seconds = Math.min(maxDelaySeconds, seconds * 2);
|
||||
}
|
||||
long millis = Math.round(seconds * 1000 * (1 - jitter + 2 * jitter * random));
|
||||
return Duration.ofMillis(Math.min(maxDelaySeconds * 1000, Math.max(1, millis)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.common.core;
|
||||
|
||||
import com.baiyue.common.core.retry.BoundedRetryPolicy;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class BoundedRetryPolicyTest {
|
||||
@Test void startsAtFiveSecondsDoublesAndCapsAtThirtyMinutes() {
|
||||
var policy = new BoundedRetryPolicy(12, 5, 1800, 0);
|
||||
assertThat(policy.delayAfterFailure(1).toSeconds()).isEqualTo(5);
|
||||
assertThat(policy.delayAfterFailure(2).toSeconds()).isEqualTo(10);
|
||||
assertThat(policy.delayAfterFailure(9).toSeconds()).isEqualTo(1280);
|
||||
assertThat(policy.delayAfterFailure(10).toSeconds()).isEqualTo(1800);
|
||||
assertThat(policy.delayAfterFailure(Integer.MAX_VALUE).toSeconds()).isEqualTo(1800);
|
||||
assertThat(policy.exhausted(11)).isFalse();
|
||||
assertThat(policy.exhausted(12)).isTrue();
|
||||
}
|
||||
@Test void jitterStaysBoundedEvenAtMaximumDelay() {
|
||||
var policy = new BoundedRetryPolicy(12, 5, 1800, 0.1);
|
||||
assertThat(policy.delayAfterFailure(1, 0).toMillis()).isEqualTo(4500);
|
||||
assertThat(policy.delayAfterFailure(1, 1).toMillis()).isEqualTo(5500);
|
||||
assertThat(policy.delayAfterFailure(20, 1).toSeconds()).isEqualTo(1800);
|
||||
assertThat(policy.delayAfterFailure(20, 0).toSeconds()).isEqualTo(1620);
|
||||
}
|
||||
@Test void invalidConfigurationFailsClosed() {
|
||||
assertThatThrownBy(() -> new BoundedRetryPolicy(0, 5, 1800, 0.1)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new BoundedRetryPolicy(12, 0, 1800, 0.1)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new BoundedRetryPolicy(12, 5, 3, 0.1)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new BoundedRetryPolicy(12, 5, 1800, Double.NaN)).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.baiyue.common.core;
|
||||
|
||||
import com.baiyue.common.core.outbox.EventDeliveryJournal;
|
||||
import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class EventDeliveryJournalTest {
|
||||
JdbcTemplate jdbc;
|
||||
EventDeliveryJournal journal;
|
||||
DataSourceTransactionManager transactions;
|
||||
@BeforeEach void setup() {
|
||||
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(source);
|
||||
transactions = new DataSourceTransactionManager(source);
|
||||
journal = new EventDeliveryJournal(jdbc, transactions);
|
||||
jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))");
|
||||
jdbc.execute("CREATE TABLE work(id INT PRIMARY KEY,state VARCHAR(20),owner VARCHAR(36))");
|
||||
jdbc.update("INSERT INTO work VALUES(1,'PENDING',NULL)");
|
||||
}
|
||||
boolean begin(String owner) {
|
||||
return journal.begin(owner, "firstleg", "FirstLegLoadingPlanCompleted", "1", UUID.randomUUID().toString(), "lastleg-events", LocalDateTime.now(),
|
||||
() -> jdbc.update("UPDATE work SET state='PROCESSING',owner=? WHERE id=1 AND state='PENDING'", owner) == 1);
|
||||
}
|
||||
int finish(String owner) { return jdbc.update("UPDATE work SET state='PUBLISHED',owner=NULL WHERE id=1 AND owner=?", owner); }
|
||||
String state() { return jdbc.queryForObject("SELECT state FROM work WHERE id=1", String.class); }
|
||||
String outcome(String attempt) { return jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, attempt); }
|
||||
|
||||
@Test void startedIsCommittedBeforeCallerContinuesEvenWithinAnOuterTransaction() {
|
||||
new TransactionTemplate(transactions).executeWithoutResult(status -> {
|
||||
assertThat(begin("a")).isTrue();
|
||||
// Another connection sees STARTED before any HTTP would be invoked.
|
||||
var other = new JdbcTemplate(jdbc.getDataSource());
|
||||
assertThat(other.queryForObject("SELECT outcome FROM event_delivery_attempt", String.class)).isEqualTo("STARTED");
|
||||
status.setRollbackOnly();
|
||||
});
|
||||
assertThat(state()).isEqualTo("PROCESSING");
|
||||
assertThat(outcome("a")).isEqualTo("STARTED"); // process interruption leaves diagnostic evidence
|
||||
assertThat(begin("b")).isFalse();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_attempt", Integer.class)).isEqualTo(1);
|
||||
}
|
||||
@Test void startAuditFailureRollsBackTheClaim() {
|
||||
assertThat(begin("a")).isTrue();
|
||||
jdbc.update("UPDATE work SET state='PENDING',owner=NULL");
|
||||
assertThatThrownBy(() -> begin("a")).isInstanceOf(RuntimeException.class);
|
||||
assertThat(state()).isEqualTo("PENDING");
|
||||
}
|
||||
@Test void completionAuditFailureRollsBackOutboxAndPreservesStartedRecord() {
|
||||
begin("a");
|
||||
jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')");
|
||||
assertThatThrownBy(() -> journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isInstanceOf(RuntimeException.class);
|
||||
assertThat(state()).isEqualTo("PROCESSING");
|
||||
assertThat(outcome("a")).isEqualTo("STARTED");
|
||||
jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied");
|
||||
assertThat(journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isTrue();
|
||||
assertThat(state()).isEqualTo("PUBLISHED");
|
||||
assertThat(outcome("a")).isEqualTo("APPLIED");
|
||||
assertThat(jdbc.queryForObject("SELECT duration_ms FROM event_delivery_attempt", Long.class)).isNotNegative();
|
||||
}
|
||||
@Test void recoveryAndLateResultCannotOverwriteTheNewOwnerOrItsAudit() {
|
||||
begin("a");
|
||||
var retryAt = LocalDateTime.now().plusSeconds(5);
|
||||
assertThat(journal.complete("a", Outcome.LEASE_EXPIRED, LocalDateTime.now(), "LEASE_EXPIRED", retryAt,
|
||||
() -> jdbc.update("UPDATE work SET state='PENDING',owner=NULL WHERE owner='a'"))).isTrue();
|
||||
begin("b");
|
||||
assertThat(journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isFalse();
|
||||
assertThat(outcome("a")).isEqualTo("LEASE_EXPIRED");
|
||||
assertThat(outcome("b")).isEqualTo("STARTED");
|
||||
assertThat(state()).isEqualTo("PROCESSING");
|
||||
}
|
||||
@Test void unexpectedCompletionWithoutStartedRecordRollsBackInsteadOfUsingAFallback() {
|
||||
jdbc.update("UPDATE work SET state='PROCESSING',owner='missing'");
|
||||
assertThatThrownBy(() -> journal.complete("missing", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("missing"))).isInstanceOf(RuntimeException.class);
|
||||
assertThat(state()).isEqualTo("PROCESSING");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.common.core;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.outbox.EventOperationsTokenGuard;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class EventOperationsTokenGuardTest {
|
||||
@Test void missingOrShortConfigurationDisablesOperations() {
|
||||
for (String configured : new String[] {null, "", "short"}) {
|
||||
var guard = new EventOperationsTokenGuard(configured);
|
||||
assertThatThrownBy(() -> guard.require(configured)).isInstanceOf(BizException.class);
|
||||
}
|
||||
}
|
||||
@Test void onlyExactCredentialIsAccepted() {
|
||||
String token = "operation-secret-".repeat(3);
|
||||
var guard = new EventOperationsTokenGuard(token);
|
||||
assertThatCode(() -> guard.require(token)).doesNotThrowAnyException();
|
||||
for (String supplied : new String[] {null, "", token + " ", token.toUpperCase(), "a".repeat(4097)}) {
|
||||
assertThatThrownBy(() -> guard.require(supplied)).isInstanceOf(BizException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.baiyue.common.core;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.outbox.OutboxOperations;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.parallel.Isolated;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Set;
|
||||
import java.util.TimeZone;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
@Isolated("UTC retry regression temporarily changes the JVM default time zone")
|
||||
class OutboxOperationsTest {
|
||||
private static final String HASH = "sha256:" + "a".repeat(64);
|
||||
private JdbcTemplate jdbc;
|
||||
private OutboxOperations operations;
|
||||
|
||||
@BeforeEach void setup() {
|
||||
var source = new JdbcDataSource();
|
||||
source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000");
|
||||
jdbc = new JdbcTemplate(source);
|
||||
jdbc.execute("CREATE TABLE events(id VARCHAR(64) PRIMARY KEY,payload_json VARCHAR(200),status VARCHAR(20),retry_count INT,last_error VARCHAR(1000),next_retry_at TIMESTAMP,locked_by VARCHAR(100),locked_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE event_delivery_retry_audit(request_id VARCHAR(36) PRIMARY KEY,producer_table VARCHAR(100),entry_id VARCHAR(64),event_id VARCHAR(64),event_type VARCHAR(100),payload_hash VARCHAR(71),operator_name VARCHAR(128),reason VARCHAR(500),previous_failures INT,previous_error VARCHAR(1000),requested_at TIMESTAMP)");
|
||||
operations = new OutboxOperations(jdbc, new DataSourceTransactionManager(source),
|
||||
new OutboxOperations.Layout("events", "id", "status", Set.of("business.completed")),
|
||||
payload -> new OutboxOperations.Identity(payload, "business.completed", HASH));
|
||||
jdbc.update("INSERT INTO events VALUES('1','event-1','DEAD',12,'timeout',CURRENT_TIMESTAMP,'old-worker',CURRENT_TIMESTAMP)");
|
||||
}
|
||||
private OutboxOperations.RetryRequest request() {
|
||||
return new OutboxOperations.RetryRequest(UUID.randomUUID().toString(), "operator-7", "dependency restored", HASH);
|
||||
}
|
||||
|
||||
@Test void retryPreservesPayloadAndAuditsPreviousFailureThenDeduplicates() {
|
||||
var request = request();
|
||||
assertThat(operations.retry("1", request).duplicate()).isFalse();
|
||||
assertThat(operations.retry("1", request).duplicate()).isTrue();
|
||||
assertThat(jdbc.queryForMap("SELECT * FROM events WHERE id='1'"))
|
||||
.containsEntry("PAYLOAD_JSON", "event-1").containsEntry("STATUS", "PENDING")
|
||||
.containsEntry("RETRY_COUNT", 0).containsEntry("LOCKED_BY", null).containsEntry("LAST_ERROR", null);
|
||||
assertThat(jdbc.queryForMap("SELECT * FROM event_delivery_retry_audit"))
|
||||
.containsEntry("PREVIOUS_FAILURES", 12).containsEntry("PREVIOUS_ERROR", "timeout")
|
||||
.containsEntry("OPERATOR_NAME", "operator-7").containsEntry("PAYLOAD_HASH", HASH);
|
||||
}
|
||||
|
||||
@Test void auditInsertFailureRollsBackRequeue() {
|
||||
jdbc.execute("ALTER TABLE event_delivery_retry_audit ADD CONSTRAINT reject_audit CHECK(previous_failures < 0)");
|
||||
assertThatThrownBy(() -> operations.retry("1", request())).isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
|
||||
assertThat(operations.inspect("1").status()).isEqualTo("DEAD");
|
||||
assertThat(operations.inspect("1").failures()).isEqualTo(12);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isZero();
|
||||
}
|
||||
|
||||
@Test void retryIsImmediatelyDueInUtcUnderShanghaiDefaultZoneAndReplayKeepsTimes() {
|
||||
TimeZone original = TimeZone.getDefault();
|
||||
try {
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
|
||||
var request = request();
|
||||
LocalDateTime before = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1);
|
||||
assertThat(operations.retry("1", request).duplicate()).isFalse();
|
||||
LocalDateTime nextRetry = jdbc.queryForObject("SELECT next_retry_at FROM events WHERE id='1'",
|
||||
(row, index) -> row.getObject(1, LocalDateTime.class));
|
||||
LocalDateTime requested = jdbc.queryForObject("SELECT requested_at FROM event_delivery_retry_audit",
|
||||
(row, index) -> row.getObject(1, LocalDateTime.class));
|
||||
LocalDateTime scan = LocalDateTime.now(ZoneOffset.UTC);
|
||||
// Allow database timestamp precision rounding, but never a default-zone offset.
|
||||
assertThat(nextRetry).isBetween(before, scan.plusSeconds(1));
|
||||
assertThat(requested).isEqualTo(nextRetry);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM events WHERE id='1' AND status='PENDING' AND next_retry_at<=?",
|
||||
Integer.class, scan)).isEqualTo(1);
|
||||
assertThat(operations.retry("1", request).duplicate()).isTrue();
|
||||
assertThat(jdbc.<LocalDateTime>queryForObject("SELECT next_retry_at FROM events WHERE id='1'",
|
||||
(row, index) -> row.getObject(1, LocalDateTime.class))).isEqualTo(nextRetry);
|
||||
assertThat(jdbc.<LocalDateTime>queryForObject("SELECT requested_at FROM event_delivery_retry_audit",
|
||||
(row, index) -> row.getObject(1, LocalDateTime.class))).isEqualTo(requested);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT payload_json FROM events WHERE id='1'", String.class)).isEqualTo("event-1");
|
||||
} finally {
|
||||
TimeZone.setDefault(original);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void staleHashAndNonDeadAndReusedRequestAreRejected() {
|
||||
var original = request();
|
||||
var stale = new OutboxOperations.RetryRequest(original.requestId(), original.operator(), original.reason(), "sha256:" + "b".repeat(64));
|
||||
assertThatThrownBy(() -> operations.retry("1", stale)).isInstanceOf(BizException.class);
|
||||
operations.retry("1", original);
|
||||
assertThatThrownBy(() -> operations.retry("1", request())).isInstanceOf(BizException.class);
|
||||
var changed = new OutboxOperations.RetryRequest(original.requestId(), "someone-else", original.reason(), HASH);
|
||||
assertThatThrownBy(() -> operations.retry("1", changed)).isInstanceOf(BizException.class);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test void concurrentSameRequestHasOneAuditAndOneDuplicate() throws Exception {
|
||||
var request = request();
|
||||
var start = new CountDownLatch(1);
|
||||
var pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var first = pool.submit(() -> { start.await(); return operations.retry("1", request); });
|
||||
var second = pool.submit(() -> { start.await(); return operations.retry("1", request); });
|
||||
start.countDown();
|
||||
assertThat(first.get(10, TimeUnit.SECONDS).duplicate()).isNotEqualTo(second.get(10, TimeUnit.SECONDS).duplicate());
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1);
|
||||
} finally { pool.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test void crossEntryRequestCollisionRollsBackSecondEntry() {
|
||||
jdbc.update("INSERT INTO events VALUES('2','event-2','DEAD',12,'timeout',CURRENT_TIMESTAMP,NULL,NULL)");
|
||||
var request = request();
|
||||
operations.retry("1", request);
|
||||
assertThatThrownBy(() -> operations.retry("2", request)).isInstanceOf(BizException.class);
|
||||
assertThat(operations.inspect("2").status()).isEqualTo("DEAD");
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.common.core.container;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ContainerSpecificationCatalogTest {
|
||||
@Test
|
||||
void exposesTheThreeCanonicalContainerTypesInBusinessOrder() {
|
||||
assertThat(ContainerSpecificationCatalog.values())
|
||||
.extracting(ContainerSpecificationCatalog.Specification::containerType)
|
||||
.containsExactly("40GP", "40HQ", "45HQ");
|
||||
assertThat(ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE).isEqualTo("40HQ");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesPracticalLoadingRangesAndRejectsRemovedTypes() {
|
||||
var specification = ContainerSpecificationCatalog.requireMatch(
|
||||
"40hq", new BigDecimal("68.000"), new BigDecimal("19500.000"));
|
||||
|
||||
assertThat(specification.containerType()).isEqualTo("40HQ");
|
||||
assertThat(specification.recommendedMinCbm()).isEqualByComparingTo("65");
|
||||
assertThatThrownBy(() -> ContainerSpecificationCatalog.require("40HC"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("40GP、40HQ、45HQ");
|
||||
assertThatThrownBy(() -> ContainerSpecificationCatalog.requireMatch(
|
||||
"40HQ", new BigDecimal("76"), new BigDecimal("19500")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("统一定义");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
# Common Event Contract 模块指引
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
- 维护国内头程与国外尾程之间稳定、可版本化的事件契约。
|
||||
- 只放事件信封、类型、区域、标识、规范化、哈希和校验;不放任一侧领域 Entity、数据库模型或发送实现。
|
||||
- 维护 Firstleg、Lastleg、Finance 之间稳定、可版本化的业务事件契约。
|
||||
- 只放事件信封、类型、标识、规范化、哈希和校验;不放部署区域、领域 Entity、数据库模型或发送实现。
|
||||
|
||||
## 快速索引
|
||||
|
||||
@@ -13,13 +18,23 @@
|
||||
| 契约校验 | `EventEnvelopeValidator.java` |
|
||||
| 事件 ID 与业务键 | `EventIdentifiers.java` |
|
||||
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
|
||||
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
|
||||
| 事件类型与直接目标 | `EventTypes.java`、`EventDeliveryRoute.java`;明确白名单,不按区域或默认目标分派 |
|
||||
| 头程装柜完成 V3(当前生产) | `FirstLegLoadingPlanCompletedV3.java` 在 V2 订单快照上增加物理柜空仓、半仓、满仓、锁仓、带锁码五类不可变照片引用;订单级仍冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java`;V1/V2 仅用于历史事件读取 |
|
||||
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
|
||||
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次,信封固定使用 `tenantId=0` 平台技术范围;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
|
||||
| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 |
|
||||
| 海外派送完成 V1 | `OverseasDeliveryCompletedV1.java`、`EventTypes.OVERSEAS_DELIVERY_COMPLETED`;每个按 FBA 仓拆分的派送单完成后,按关联来源入仓单分别发布,携带来源订单、入仓码、FBA 仓和签收时间,国内幂等推进对应 Firstleg 入仓单与 Order 完成 |
|
||||
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
||||
| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Lastleg Outbox 直接投递 `finance/`,Finance 不跨区同步回查 |
|
||||
| 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 |
|
||||
| 契约说明 | `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`、`FirstLegLoadingPlanCompletedV3Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
|
||||
| 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` |
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 所有字段与事件类型都视为跨仓公共契约;优先新增可选字段和新版本,禁止无迁移直接改名、改语义或删除。
|
||||
- 同步检查 `event-bridge-service/`、国内生产者以及固定 BOM 版本的尾程消费者。
|
||||
- 哈希算法或规范化规则变化必须保留跨语言、跨版本确定性测试。
|
||||
- 所有字段与事件类型都视为跨仓公共契约;当前正式模型不保留旧事件字段、旧版本或兼容解析,契约变化时直接修改当前模型,并同步更新 Firstleg/Lastleg/Finance 直接生产者、消费者和全部契约测试。若存在不可变外部事件契约,先单独定义版本升级规则。
|
||||
- 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。
|
||||
- 业务事件不携带用户 Access Token;直接投递使用内部服务身份,信封保留事件ID、业务类型/版本、客户、聚合、追踪及Payload哈希,不再包含sourceRegion/targetRegion。
|
||||
- `OVERSEAS_CONTAINER_UNLOADED` 固定由Lastleg Outbox直投Firstleg,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间规则沿用现业务实现,实际报空时间允许后续补充。
|
||||
- 从父目录运行 `mvn -pl common-event-contract -am test`。
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 跨区域业务费用来源的完整不可变快照。
|
||||
*
|
||||
* <p>海外业务与国内 Finance 不做同步回查,因此事件必须携带可独立校验、入池的完整快照。
|
||||
* 标识统一使用字符串,避免跨语言大整数精度损失。</p>
|
||||
*/
|
||||
public record BusinessFeeSourceChangedV1(
|
||||
String action,
|
||||
String sourceSystem,
|
||||
String sourceType,
|
||||
String sourceFeeId,
|
||||
int sourceVersion,
|
||||
String customerId,
|
||||
String customerName,
|
||||
String sourceNo,
|
||||
String businessType,
|
||||
String businessId,
|
||||
String businessNo,
|
||||
String orderId,
|
||||
String inboundId,
|
||||
String inboundCode,
|
||||
String containerId,
|
||||
String containerBookingId,
|
||||
String containerNo,
|
||||
String warehouseId,
|
||||
String feeItemCode,
|
||||
String feeItemName,
|
||||
String direction,
|
||||
String supplierId,
|
||||
String supplierName,
|
||||
String supplierCategory,
|
||||
String settlementMode,
|
||||
Instant paidAt,
|
||||
String paymentMethod,
|
||||
String paymentReferenceNo,
|
||||
List<String> paymentVoucherFileIds,
|
||||
LocalDate businessDate,
|
||||
String currency,
|
||||
BigDecimal totalAmount,
|
||||
String originalCurrency,
|
||||
BigDecimal originalAmount,
|
||||
BigDecimal exchangeRate,
|
||||
String exchangeRateMonth,
|
||||
BigDecimal chargeableQuantity,
|
||||
String quantityUnit,
|
||||
BigDecimal unitPrice,
|
||||
LocalDate periodStart,
|
||||
LocalDate periodEnd,
|
||||
String reviewStatus,
|
||||
String publicationStatus,
|
||||
String sourceStatus,
|
||||
String sourceHash,
|
||||
WalletSettlementReference walletSettlement
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** Canonical physical container number used by cross-region business facts. */
|
||||
public final class ContainerNumbers {
|
||||
private static final int[] LETTER_VALUES = {
|
||||
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
||||
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
|
||||
};
|
||||
|
||||
private ContainerNumbers() { }
|
||||
|
||||
public static String normalizeRequired(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("containerNo is required");
|
||||
}
|
||||
StringBuilder compact = new StringBuilder(value.length());
|
||||
value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
|
||||
&& !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint);
|
||||
String normalized = compact.toString().toUpperCase(Locale.ROOT);
|
||||
if (!normalized.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
|
||||
throw new IllegalArgumentException("containerNo must comply with ISO 6346");
|
||||
}
|
||||
int sum = 0;
|
||||
for (int index = 0; index < 10; index++) {
|
||||
char current = normalized.charAt(index);
|
||||
int number = Character.isDigit(current) ? current - '0' : LETTER_VALUES[current - 'A'];
|
||||
sum += number * (1 << index);
|
||||
}
|
||||
if (normalized.charAt(10) - '0' != sum % 11 % 10) {
|
||||
throw new IllegalArgumentException("containerNo must comply with ISO 6346 check digit");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static String requireCanonical(String value) {
|
||||
String normalized = normalizeRequired(value);
|
||||
if (!normalized.equals(value)) {
|
||||
throw new IllegalArgumentException("containerNo must be normalized");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** A consumer returns this only after its durable processing transaction commits. */
|
||||
public record DeliveryAck(String eventId, String payloadHash, Outcome outcome) {
|
||||
public enum Outcome { APPLIED, DUPLICATE, STALE }
|
||||
|
||||
public DeliveryAck {
|
||||
if (!EventIdentifiers.isSupported(eventId)) throw new IllegalArgumentException("Invalid ACK eventId");
|
||||
if (payloadHash == null || !payloadHash.matches("sha256:[0-9a-fA-F]{64}")) {
|
||||
throw new IllegalArgumentException("Invalid ACK payloadHash");
|
||||
}
|
||||
Objects.requireNonNull(outcome, "ACK outcome is required");
|
||||
}
|
||||
|
||||
public static DeliveryAck of(EventEnvelope event, Outcome outcome) {
|
||||
return new DeliveryAck(event.eventId(), event.payloadHash(), outcome);
|
||||
}
|
||||
|
||||
/** HTTP and ApiResponse status must also be checked by the transport. */
|
||||
public void requireMatches(EventEnvelope sent) {
|
||||
if (!eventId.equals(sent.eventId()) || !payloadHash.equalsIgnoreCase(sent.payloadHash())) {
|
||||
throw new IllegalArgumentException("Consumer ACK does not match the sent event identity and payload");
|
||||
}
|
||||
EventDeliveryRoute route = EventDeliveryRoute.forEventType(sent.eventType());
|
||||
if (outcome == Outcome.STALE && !route.acceptsStale()) {
|
||||
throw new IllegalArgumentException("STALE is not an allowed result for " + sent.eventType());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
/** Explicit business-event destinations. There is no regional or default destination. */
|
||||
public record EventDeliveryRoute(String serviceName, String path, boolean acceptsStale) {
|
||||
public static EventDeliveryRoute forEventType(String eventType) {
|
||||
if (eventType == null) throw new IllegalArgumentException("Event type is required");
|
||||
return switch (eventType) {
|
||||
case EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, EventTypes.WALLET_COMMAND_COMPLETED ->
|
||||
new EventDeliveryRoute("lastleg-service", "/internal/lastleg/events", false);
|
||||
case EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED ->
|
||||
new EventDeliveryRoute("lastleg-service", "/internal/lastleg/events", true);
|
||||
case EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_DELIVERY_COMPLETED ->
|
||||
new EventDeliveryRoute("firstleg-service", "/internal/firstleg/v1/container-events", false);
|
||||
case EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED ->
|
||||
new EventDeliveryRoute("firstleg-service", "/internal/firstleg/v1/container-events", true);
|
||||
case EventTypes.WALLET_COMMAND_REQUESTED ->
|
||||
new EventDeliveryRoute("finance-service", "/internal/finance/v1/wallet-command-events", false);
|
||||
case EventTypes.BUSINESS_FEE_SOURCE_CHANGED ->
|
||||
new EventDeliveryRoute("finance-service", "/internal/finance/v1/business-fee-source-events", false);
|
||||
case EventTypes.COMPANY_VEHICLE_COST_SUBMITTED ->
|
||||
new EventDeliveryRoute("finance-service", "/internal/finance/v1/vehicle-costs/events", false);
|
||||
default -> throw new IllegalArgumentException("Unsupported direct-delivery event type: " + eventType);
|
||||
};
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
/** Required business state has not arrived yet; retry the same event with backoff. */
|
||||
public class EventDependencyNotReadyException extends IllegalArgumentException {
|
||||
public EventDependencyNotReadyException(String message) { super(message); }
|
||||
public EventDependencyNotReadyException(String message, Throwable cause) { super(message, cause); }
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 跨服务和跨区域传输使用的不可变事件信封。
|
||||
* 统一测试环境中跨业务服务传输使用的不可变事件信封。
|
||||
*
|
||||
* <p>租户 ID 和聚合 ID 使用字符串,避免跨语言传输时发生大整数精度丢失。
|
||||
* {@code payloadHash} 是原始规范化负载字节的 SHA-256 摘要。</p>
|
||||
@@ -14,8 +14,6 @@ import java.time.Instant;
|
||||
* @param eventType 事件类型,不包含版本号
|
||||
* @param eventVersion 事件契约版本,从 1 开始
|
||||
* @param occurredAt 业务事实实际发生时间,使用 UTC 时间点
|
||||
* @param sourceRegion 事件来源业务单元
|
||||
* @param targetRegion 事件目标业务单元;国内一期允许与来源区域相同
|
||||
* @param tenantId 事件所属客户租户 ID
|
||||
* @param aggregateType 聚合类型
|
||||
* @param aggregateId 聚合 ID
|
||||
@@ -32,8 +30,6 @@ public record EventEnvelope(
|
||||
String eventType,
|
||||
int eventVersion,
|
||||
Instant occurredAt,
|
||||
RegionCode sourceRegion,
|
||||
RegionCode targetRegion,
|
||||
String tenantId,
|
||||
String aggregateType,
|
||||
String aggregateId,
|
||||
|
||||
+1
-7
@@ -37,12 +37,6 @@ public final class EventEnvelopeValidator {
|
||||
if (envelope.occurredAt() == null) {
|
||||
errors.add("occurredAt 不能为空");
|
||||
}
|
||||
if (envelope.sourceRegion() == null) {
|
||||
errors.add("sourceRegion 不能为空");
|
||||
}
|
||||
if (envelope.targetRegion() == null) {
|
||||
errors.add("targetRegion 不能为空");
|
||||
}
|
||||
requireText(errors, "tenantId", envelope.tenantId(), MAX_ID_LENGTH);
|
||||
if (envelope.aggregateType() == null
|
||||
|| !AGGREGATE_TYPE_PATTERN.matcher(envelope.aggregateType()).matches()) {
|
||||
@@ -75,7 +69,7 @@ public final class EventEnvelopeValidator {
|
||||
public static void requireValid(EventEnvelope envelope) {
|
||||
List<String> errors = validate(envelope);
|
||||
if (!errors.isEmpty()) {
|
||||
throw new IllegalArgumentException(String.join(";", errors));
|
||||
throw new EventProtocolException(String.join(";", errors));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
/** An immutable event violates the consumption protocol; retrying unchanged data cannot fix it. */
|
||||
public class EventProtocolException extends IllegalArgumentException {
|
||||
public EventProtocolException(String message) { super(message); }
|
||||
public EventProtocolException(String message, Throwable cause) { super(message, cause); }
|
||||
}
|
||||
@@ -10,9 +10,15 @@ public final class EventTypes {
|
||||
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 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 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 OVERSEAS_DELIVERY_COMPLETED = "OverseasDeliveryCompleted";
|
||||
public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged";
|
||||
public static final String COMPANY_VEHICLE_COST_SUBMITTED = "CompanyVehicleCostSubmitted";
|
||||
public static final String WALLET_COMMAND_REQUESTED = "WalletCommandRequested";
|
||||
public static final String WALLET_COMMAND_COMPLETED = "WalletCommandCompleted";
|
||||
public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted";
|
||||
public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected";
|
||||
public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged";
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
|
||||
public record FirstLegContainerShippingNodeRecordedV1(
|
||||
String queryId,
|
||||
String oceanBookingId,
|
||||
@JsonProperty("sourceContainerBookingId") 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()) {
|
||||
throw new IllegalArgumentException("船节点跟踪任务标识不能为空字符串");
|
||||
}
|
||||
if (oceanBookingId != null && oceanBookingId.isBlank()) {
|
||||
throw new IllegalArgumentException("船节点订舱标识不能为空字符串");
|
||||
}
|
||||
if (containerBookingId == null || containerBookingId.isBlank()
|
||||
|| containerNo == null || containerNo.isBlank()) {
|
||||
throw new IllegalArgumentException("船节点交接业务标识不能为空");
|
||||
}
|
||||
containerNo = ContainerNumbers.requireCanonical(containerNo);
|
||||
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("船节点交接供应商审计字段不完整");
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
-3
@@ -7,7 +7,14 @@ import java.util.List;
|
||||
public record FirstLegLoadingPlanCompletedV1(
|
||||
String loadingPlanId, String planNo, String containerBookingId,
|
||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
|
||||
List<Order> orders) {
|
||||
public FirstLegLoadingPlanCompletedV1(String loadingPlanId, String planNo, String containerBookingId,
|
||||
String containerNo, String cabinetNo, String warehouseId,
|
||||
String warehouseName, List<Order> orders) {
|
||||
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
|
||||
warehouseName, null, null, null, orders);
|
||||
}
|
||||
|
||||
public record Order(
|
||||
String sourceInboundId, String rootInboundId,
|
||||
@@ -16,9 +23,17 @@ public record FirstLegLoadingPlanCompletedV1(
|
||||
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) {
|
||||
BigDecimal volumeWeightKg,
|
||||
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
|
||||
String destinationType, String lxCode) {
|
||||
public Order {
|
||||
items = items == null ? List.of() : items;
|
||||
if (customerId == null || customerId.isBlank()) {
|
||||
throw new IllegalArgumentException("customerId is required");
|
||||
}
|
||||
if (customerName == null || customerName.isBlank()) {
|
||||
throw new IllegalArgumentException("customerName is required");
|
||||
}
|
||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||
pallets = pallets == null ? List.of() : pallets;
|
||||
}
|
||||
@@ -28,11 +43,41 @@ public record FirstLegLoadingPlanCompletedV1(
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items) {
|
||||
List<Item> items, String customerId, String customerName,
|
||||
String orderType, List<PalletSnapshot> pallets) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of());
|
||||
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
|
||||
orderType, pallets, null, 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, String customerId, String customerName,
|
||||
String orderType, List<PalletSnapshot> pallets, String destinationType) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
|
||||
orderType, pallets, destinationType, null);
|
||||
}
|
||||
|
||||
public Order(String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
String inboundCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
BigDecimal volumeWeightKg, List<Item> items, String customerId, String customerName,
|
||||
String orderType, List<PalletSnapshot> pallets, String destinationType) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
|
||||
packageCount, productQuantity, palletCount, grossWeightKg, netWeightKg, volumeCbm,
|
||||
volumeWeightKg, items, customerId, customerName, orderType, pallets, destinationType, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user