Compare commits
811
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6a0f51f136 | ||
|
|
7d9eaa7f43 | ||
|
|
bfc2a4dbd4 | ||
|
|
0613d1095e | ||
|
|
3d7990250f | ||
|
|
2c3efad70a | ||
|
|
cdcf3e0f4f | ||
|
|
3d6216910f | ||
|
|
505188c3fd | ||
|
|
477971dda1 | ||
|
|
249b182e7f | ||
|
|
936412f351 | ||
|
|
0cc4eae209 | ||
|
|
f2a980e5c1 | ||
|
|
47ce84d122 | ||
|
|
01385c4bc7 | ||
|
|
9b09663b2e | ||
|
|
838bd93bc4 | ||
|
|
60d45613c7 | ||
|
|
4ddfc6fb99 | ||
|
|
df0fbc1c7f | ||
|
|
4795ab958c | ||
|
|
1288c8937c | ||
|
|
ba8e3595e4 | ||
|
|
17d81b0b94 | ||
|
|
c9792b1f63 | ||
|
|
0122ad314b | ||
|
|
ea615b864a | ||
|
|
314ce09b5d | ||
|
|
942dbace19 | ||
|
|
b386f35ab4 | ||
|
|
d6f47e650c | ||
|
|
bef91151a3 | ||
|
|
bf1dc1f209 | ||
|
|
aa583efa4a | ||
|
|
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 | ||
|
|
bb9673358d | ||
|
|
6b5f2f0565 | ||
|
|
fe68551124 | ||
|
|
f998bed09c | ||
|
|
1e5ce54858 | ||
|
|
b34fbb2bd1 | ||
|
|
a6f266687e | ||
|
|
dd117b8fc2 | ||
|
|
a719a903cd | ||
|
|
3b1b4ab9b2 | ||
|
|
9d54c57b7c | ||
|
|
e72ac14425 | ||
|
|
ec8f84c1c0 | ||
|
|
2b7f66cb04 | ||
|
|
c7061b8a74 | ||
|
|
5250f304be | ||
|
|
f5f72d681d | ||
|
|
c226b6a99b | ||
|
|
600b2a2dc4 | ||
|
|
76c24867a4 | ||
|
|
61a97cd057 | ||
|
|
ecf48886d5 | ||
|
|
1db3e48506 | ||
|
|
0f2e201c38 | ||
|
|
adabec13d3 | ||
|
|
0fb51211aa | ||
|
|
d7bff980ca | ||
|
|
ed3c1dafcc | ||
|
|
0a3ec0b8a6 | ||
|
|
3b38fdd33d | ||
|
|
daf0ee2acd | ||
|
|
086a0f9c89 | ||
|
|
c80307a838 | ||
|
|
575cee7a86 | ||
|
|
c1b136c3d6 | ||
|
|
4612181ab0 | ||
|
|
b4c46280de | ||
|
|
15b563610c | ||
|
|
079ff43f73 | ||
|
|
554b6c93b7 | ||
|
|
817e356c21 | ||
|
|
ca12f58d38 | ||
|
|
8fb63e9adc | ||
|
|
d736fdba1b |
@@ -28,5 +28,6 @@ reports/
|
|||||||
uploads/
|
uploads/
|
||||||
.codex-temp*
|
.codex-temp*
|
||||||
.codex-tmp*
|
.codex-tmp*
|
||||||
|
.runtime-*/
|
||||||
.codex-*.out
|
.codex-*.out
|
||||||
.codex-*.err
|
.codex-*.err
|
||||||
|
|||||||
Binary file not shown.
@@ -1,39 +0,0 @@
|
|||||||
import java.sql.Connection;
|
|
||||||
import java.sql.DriverManager;
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.ResultSetMetaData;
|
|
||||||
import java.sql.Statement;
|
|
||||||
|
|
||||||
public final class DbQuery {
|
|
||||||
public static void main(String[] args) throws Exception {
|
|
||||||
if (args.length < 2) {
|
|
||||||
throw new IllegalArgumentException("usage: DbQuery <database> <sql>");
|
|
||||||
}
|
|
||||||
String url = "jdbc:mysql://127.0.0.1:3306/" + args[0]
|
|
||||||
+ "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"
|
|
||||||
+ "&useSSL=false&allowPublicKeyRetrieval=true";
|
|
||||||
try (Connection connection = DriverManager.getConnection(url, "root", "123456");
|
|
||||||
Statement statement = connection.createStatement()) {
|
|
||||||
boolean hasResult = statement.execute(args[1]);
|
|
||||||
if (!hasResult) {
|
|
||||||
System.out.println("updateCount=" + statement.getUpdateCount());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try (ResultSet result = statement.getResultSet()) {
|
|
||||||
ResultSetMetaData meta = result.getMetaData();
|
|
||||||
for (int column = 1; column <= meta.getColumnCount(); column++) {
|
|
||||||
if (column > 1) System.out.print("\t");
|
|
||||||
System.out.print(meta.getColumnLabel(column));
|
|
||||||
}
|
|
||||||
System.out.println();
|
|
||||||
while (result.next()) {
|
|
||||||
for (int column = 1; column <= meta.getColumnCount(); column++) {
|
|
||||||
if (column > 1) System.out.print("\t");
|
|
||||||
System.out.print(String.valueOf(result.getObject(column)));
|
|
||||||
}
|
|
||||||
System.out.println();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -18,37 +18,57 @@
|
|||||||
|
|
||||||
| 任务关键词 | 首选服务 | 先看入口 |
|
| 任务关键词 | 首选服务 | 先看入口 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` |
|
| 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 |
|
||||||
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
|
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
|
||||||
| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 |
|
| 客户建档默认箱规与权重系数初始化及补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults` 在同一事务生成四档尺寸和默认权重系数规则,未全部完成不得标记 `READY` 或审核通过 |
|
||||||
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
|
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
|
||||||
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
|
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 |
|
||||||
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
|
| 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` |
|
||||||
|
| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 |
|
||||||
|
| 平台订单批量审核预览 | `order/` | `PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`BatchOrderReviewPreviewService.java`、`BatchOrderReviewPreviewResponse.java`、`order-v1.yaml`;一次请求固定用订单与货物两次批量查询返回同序快照,不允许前端逐票请求详情,逐项资格以 `canApprove/blockReason` 为准 |
|
||||||
|
| 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md`;客户四档箱规与 `ADD/MULTIPLY` 默认算法从 `CustomerDimensionConfigService` 进入,单箱测量和最终计价分别由 `CustomerDimensionWeightingService`、`FbaPricingService` 保存并消费服务端快照 |
|
||||||
|
| 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` | Firstleg `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`;平台接口 `/api/platform/inbounds/{inboundId}/hold`、`/release`、`/evidence`。扣货不改变 Order 主状态,命令使用入库聚合版本和幂等键;凭证只按客户、入库单及 `INBOUND_HOLD_EVIDENCE` 精确归属,所有装柜、PDA 拣货和国内出仓写入口统一复用门禁并失败关闭 |
|
||||||
|
| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。称重确认尽力建块、提交时再次补齐,库位指引失败不阻断测量主流程;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 |
|
||||||
|
| 客户专属价格簿、柜级阶梯运费与受控调价 | `firstleg/` | `PlatformPriceBookService.java`、`ActivePriceBookEstimator.java`、`FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`、`InboundFeeDetailService.java`、`fba-pricing-v1.yaml`。`customerId=0` 为公共报价,正数为客户专属版本;计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。同柜区域重量阶梯按实际成员计费量聚合命中并守恒分摊,装柜变化只重算未锁定客户费用;人工计费量调整限基准上下 10%,私人仓尾程派送费只绑定 `PRIVATE` 入库单;一件代发 `WAIT_PALLETIZE` 允许在预装确认前试算,写费用时必须保留托盘化状态 |
|
||||||
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
|
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
|
||||||
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 |
|
| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 |
|
||||||
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
|
| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` |
|
||||||
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
|
| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;创建柜次时原子生成唯一预装计划,加入入仓码时同步建立关联,不设推送步骤;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在。上传以幂等键绑定包含文件内容的请求哈希,完全重放不得再次调用 File |
|
||||||
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
|
| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 |
|
||||||
|
| 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` | Firstleg `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,业务编排 `service/SmartLoadingService.java`;待审核事实由 `client/PendingReviewOrderClient.java` 经内部令牌查询 Order `/internal/order/v1/pending-review-orders` 与 `PendingReviewOrderQueryService.java` 的权威 `SUBMITTED/PENDING_REVIEW` 行。候选四态只用于筛选展示,资格以 `canAssign` 为准;待审核或待测量可先占位,确认预装和启动 PDA 前仍须进入 `WAIT_SPLIT`;计划明细总量/余量由 Order 原始申报或最新已审核测量补齐,调整时重验权威数量;人工加入允许跨渠道,智能推荐仍按柜次渠道;同柜组整组分页、分配和移除 |
|
||||||
|
| 客户入库结果与头程费用确认 | `firstleg/` | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`,接口 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` 与 `/confirm`;客户按登录态范围读取并以费用版本和幂等键确认当前 `SENT` 费用,确认后锁定 |
|
||||||
|
| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` |
|
||||||
|
| 目的港码头拖车与还空预警 | `firstleg/` | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`src/main/resources/openapi/terminal-drayage-v1.yaml`;按 `containerBookingId` 聚合并与订舱级拖车分离,引用 Warehouse `ACTIVE + TRUCKING` 供应商,提柜/还柜复用船节点,服务端按海外卸柜完成时间加 72 小时计算还空预警 |
|
||||||
|
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;客户账单行 `inboundCode` 由 `FinanceQueryService` 从费用来源快照提供,Client 复用头程入仓关系详情;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
|
||||||
|
| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | 国内 `FinanceWalletService.java` 持有唯一 CNY 主账和不可变流水;平台、客户、内部入口见 `PlatformFinanceWalletController.java`、`ClientFinanceWalletController.java`、`InternalFinanceWalletController.java` 与 `finance-wallet-v1.yaml`。海外采购和换标由 Lastleg `wallet/` 发布 `WALLET_COMMAND_REQUESTED`,Finance 冻结、结算或释放后发布 `WALLET_COMMAND_COMPLETED` 回传,不跨区域同步调用钱包 |
|
||||||
|
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 |
|
||||||
|
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | Lastleg `financefee/` 审核五类海外费用并发布 `BUSINESS_FEE_SOURCE_CHANGED` 完整快照;Event Bridge 对 `targetRegion=CN` 定向投递 Finance `/internal/finance/v1/business-fee-source-events`;Finance 按事件 ID、Payload 哈希、来源系统/费用 ID/版本幂等入池,`PAYABLE + DIRECT_PAID` 单独形成已确认成本且不进入 AP 待结算 |
|
||||||
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
|
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
|
||||||
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
|
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
|
||||||
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
|
| 供应商、国内整柜库存与出入库 | `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` |
|
| 客户运营统计、在途柜量 | `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` |
|
| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` |
|
||||||
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` |
|
| 资质、导出、导出退避重试、测量图片、文件下载 | `file/` | `file/AGENTS.md`;法人身份证选填,可在已有营业执照后单独补传或替换;`PENDING` 且已有失败尝试的退避任务可人工立即到期并复用当前投递,不创建并发任务 |
|
||||||
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
|
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
|
||||||
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
|
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
|
||||||
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
|
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
|
||||||
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
|
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
|
||||||
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
|
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
|
||||||
|
| 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合并分页返回全部稳定来源柜,已完成计划转换的柜仍用于接柜仓查询与修改,生成资格由 `allPlansReady/pendingPlanCount` 表达,详情返回全柜数据。`inbound-plans/container-preview` 由 `InboundPlanService.java` 返回服务端分组、托数、阻断项及快照版本/哈希;`ContainerInboundPlanConversionCoordinator.java` 校验同一快照并以唯一 Saga 先向 `WarehousePalletBatchService.java` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,失败只补偿未确认资源,同柜重放不得新建第二批。暂扣/解除和固定八行人工节点进入 `LastlegOrderCommandService.java`;命令按订单版本、客户范围和幂等键执行,暂扣中的订单不得进入排车或出库 |
|
||||||
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
|
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
|
||||||
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
|
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
|
||||||
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
|
| 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` |
|
||||||
| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 |
|
| 海外异常退回、库存恢复与客户回访 | `lastleg-service/`、`file/` | `exceptionorder/`、`inventory/command/`、`customervisit/`;异常按版本和幂等键执行 `PENDING → PROCESSING → COMPLETED`,完成证据固定归 `OVERSEAS_EXCEPTION_EVIDENCE`。派送退回明细只能来自原出库拣货事实,退回海外仓时校验真实目标托盘/库位并以 `RETURN_STOCK_IN` 恢复库存后,原子完成异常单与派送 `RETURNED`;回访仍以 Lastleg 客户范围和派送状态为准 |
|
||||||
|
| 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU 与每箱数量;Firstleg `InboundCartonLabelService` 冻结箱唛 SKU,`DropshipPalletizationService` 完成清点、组托和封托,智能排柜只按 `SEALED` 整托分配,并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 |
|
||||||
|
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`relabel/pda/`、`transfer/`、`discard/` 的 Merchant/PDA Controller 和 Service;换标下单按历史入仓库存选择,每个我方箱唛只形成一条箱任务,支持箱唛、SKU 和组合换标,SKU 子项不重复冻结。涉及箱唛换标时冻结派送方式,PDA 逐箱作业并上传完工照片,仓库复核后必须由客户查看证据确认,之后卡车进入预排车,自提/快递进入换标出库待创建;不伪造已入库单。Client 消费端 `../html/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
|
||||||
|
| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;托盘作业保留托盘-入仓单组成明细,合托通过入仓计划托盘分配和合托记录留痕;`pallet-operations/relabel-disassemble` 按换标单箱唛快照解析原库存仓位,在一个幂等事务内移动对应冻结箱数并绑定已扫码目标托盘,同时更新换标明细和未开始拣货的出库托盘/箱唛快照;出库可扫描托盘但后台按托盘下各入仓单箱数核平;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 |
|
||||||
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 |
|
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 |
|
||||||
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 |
|
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 |
|
||||||
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 |
|
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 |
|
||||||
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` |
|
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` |
|
||||||
|
|
||||||
|
散货托盘码与跨区传递:`firstleg/` 的 `LoadingPalletLabelService.java` 生成冻结标签并调用 Print 输出 PDF,`LoadingCompletedOutboxService.java` 传递装柜快照;`lastleg-service/` 通过 `LastlegSourcePalletMapper.java` 保存来源托盘,`InboundPlanService.java` 和 `ContainerInboundPlanConversionCoordinator.java` 消费。标签与资源预留的细节继续读对应服务指引。
|
||||||
|
|
||||||
## 公共模块快速索引
|
## 公共模块快速索引
|
||||||
|
|
||||||
| 关键词 | 模块与入口 |
|
| 关键词 | 模块与入口 |
|
||||||
@@ -63,20 +83,23 @@
|
|||||||
|
|
||||||
## 全量验收路由
|
## 全量验收路由
|
||||||
|
|
||||||
- 当前一期、二期、三期及财务统一进入全量验收;按本次链路读取对应阶段/专题文档,不以历史阶段终点阻断跨服务联调或回归。
|
- 当前订单与客户、入库测量与计费、国内货柜履约、海外仓业务及财务统一进入全量验收;按本次链路读取对应业务域/专题文档,不以历史规划终点阻断跨服务联调或回归。
|
||||||
- `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。
|
- `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。
|
||||||
|
|
||||||
## 国内头程与海外仓协同
|
## 国内头程与海外仓协同
|
||||||
|
|
||||||
- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。
|
- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。
|
||||||
- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验,海外服务不得访问国内 Redis,只按限定 Audience 的海外 Token Claims 完成本地认证和授权;海外业务请求不得经国内 Gateway 中转。
|
- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验;海外 Token 只携带稳定身份、客户/仓库范围和版本声明,不携带角色或权限集。海外 Gateway 本地验签后必须通过 Lastleg 安全投影取得当前角色/权限并失败关闭,再清除外部伪造头、写入受内部服务令牌保护的可信身份上下文;海外服务不得访问国内 Redis,海外业务请求不得经国内 Gateway 中转。
|
||||||
- 头程装柜完成向海外建尾程单、海外 PDA 卸柜完成向国内回写货柜报空事实,以及后续费用来源和财务回传,统一走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。
|
- 头程装柜完成向海外建尾程单、海外 PDA 卸柜完成向国内回写货柜报空事实,以及后续费用来源和财务回传,统一走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。
|
||||||
- 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。
|
- 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。
|
||||||
|
|
||||||
## 契约、数据库与验证入口
|
## 契约、数据库与验证入口
|
||||||
|
|
||||||
|
- 对外 Controller/API 新增、改名或删除时,同时核对调用端、区域 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
|
||||||
|
|
||||||
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
|
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
|
||||||
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。
|
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。
|
||||||
|
- 所有生产 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`。
|
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。
|
||||||
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
|
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
|
||||||
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
|
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
|
||||||
@@ -84,24 +107,32 @@
|
|||||||
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
||||||
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||||
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||||
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置/独立更新/语言预留、人工分段验收链、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
|
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外仓业务范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置/独立更新/语言预留、人工分段验收链、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
|
||||||
|
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
|
||||||
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
|
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
|
||||||
|
|
||||||
## 全量 HTTP 验收脚本路由
|
## 全量 HTTP 验收脚本路由
|
||||||
|
|
||||||
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。
|
- 脚本管理的唯一总索引是 `scripts/README.md`,最近执行规则是 `scripts/AGENTS.md`。仓库级脚本、Admin 内部模块、服务局部合同和部署脚本的目录归属以这两个文件为准,不再通过无范围搜索猜测入口。
|
||||||
|
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;入库测量与计费专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。
|
||||||
|
- Client 快速下单定向验收使用 `scripts/client-quick-order-test-env-acceptance.mjs`,只验证批量草稿接口、逐行隔离和幂等行为,不提交、不审核,也不替代 Client 全量入口。
|
||||||
|
- 固定客户待审核订单使用 `scripts/create-pending-review-orders.mjs`;固定“IT测试”与“义乌仓”、随机真实线路并推进至 `WAIT_SPLIT` 使用 `scripts/it-test-wait-split-bulk.mjs`。二者都是造数入口,不属于全量验证;会写目标环境且不自动清理成功数据。
|
||||||
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
|
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
|
||||||
|
- 订舱与拖车跨服务完整状态链由 Admin 注册模块 `service-center-flow` 验证,必须复用本轮已放舱订舱并由总入口展开依赖,不得直接执行模块文件或借历史数据。
|
||||||
|
- 尾程公网控制由 `phase4-tail-order-controls` 在海外 PDA 前验证整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、暂扣/解除、八个人工节点和逐票人工计划;目的港码头拖车由 `phase5-terminal-drayage` 在卸柜后验证选车队、预约提柜、实际提柜和实际还柜,`phase4-carrier-nodes` 只读核验最终船节点终态。
|
||||||
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
|
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
|
||||||
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
|
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
|
||||||
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
|
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
|
||||||
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
|
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
|
||||||
|
- 修改脚本目录、入口或说明后,至少运行 `node --test scripts/script-documentation-contract.test.mjs`;修改 `AGENTS.md` 后继续运行 `./scripts/agents-index-contract.ps1`。两者均为离线只读合同,不能代替真实 HTTP 验收。
|
||||||
|
|
||||||
## 阶段文档入口
|
## 业务域文档入口
|
||||||
|
|
||||||
- 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md`
|
- 订单与客户:`../docs/订单与客户/README.md`、`../docs/订单与客户/订单与客户头程订单闭环设计.md`
|
||||||
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
|
- 入库测量与计费:`../docs/入库测量与计费/README.md`、`../docs/入库测量与计费/入库测量与计费需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
|
||||||
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
|
- 国内货柜履约:`../docs/国内货柜履约/README.md`、`../docs/国内货柜履约/国内货柜履约开工门禁.md`、`../docs/国内货柜履约/国内货柜履约开发规划.md`、`../docs/国内货柜履约/国内货柜履约需求确认表.md`、页面与原型清单
|
||||||
- 四期:`../docs/四期/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
|
- 海外仓业务:`../docs/海外仓业务/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
|
||||||
|
- 海外仓全流程:以正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外接柜、入仓、库存、库内服务、排车、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/`
|
||||||
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
|
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
|
||||||
|
|
||||||
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
|
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
|
||||||
|
|||||||
Vendored
+74
-27
@@ -60,14 +60,15 @@ def databaseMigrationPaths = ['deploy/sql/']
|
|||||||
def downstreamResults = [:]
|
def downstreamResults = [:]
|
||||||
|
|
||||||
def parseAutoBuildState(String description, List<String> allowedServices) {
|
def parseAutoBuildState(String description, List<String> allowedServices) {
|
||||||
String marker = ' | RESULTS'
|
if (!description?.startsWith('AUTO ')) return null
|
||||||
if (!description?.startsWith('AUTO ') || !description.contains(marker)) return null
|
int markerIndex = description.indexOf(' | ')
|
||||||
int markerIndex = description.indexOf(marker)
|
if (markerIndex < 0) return null
|
||||||
String commit = description.substring('AUTO '.length(), markerIndex).trim()
|
String commit = description.substring('AUTO '.length(), markerIndex).trim()
|
||||||
if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null
|
if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null
|
||||||
String summary = description.substring(markerIndex + marker.length()).trim()
|
String state = description.substring(markerIndex + ' | '.length()).trim()
|
||||||
List<String> failedServices = []
|
List<String> failedServices = []
|
||||||
if (summary) {
|
if (state.startsWith('RESULTS')) {
|
||||||
|
String summary = state.substring('RESULTS'.length()).trim()
|
||||||
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
|
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
|
||||||
int separator = item.indexOf('=')
|
int separator = item.indexOf('=')
|
||||||
if (separator > 0) {
|
if (separator > 0) {
|
||||||
@@ -76,6 +77,17 @@ def parseAutoBuildState(String description, List<String> allowedServices) {
|
|||||||
if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service
|
if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (state.startsWith('PENDING')) {
|
||||||
|
// 上一轮若在统一测试、排队或并行部署期间失败/取消,description 仍是 PENDING。
|
||||||
|
// 将该轮已选服务作为精确补跑范围,同时让 Git 基线前进到该轮提交,
|
||||||
|
// 避免回退到很早的成功构建后把历史公共文件再次解释成全量变更。
|
||||||
|
String selected = state.substring('PENDING'.length()).trim()
|
||||||
|
int suffixIndex = selected.indexOf(' | ')
|
||||||
|
if (suffixIndex >= 0) selected = selected.substring(0, suffixIndex).trim()
|
||||||
|
failedServices.addAll(selected.split(',').collect { it.trim() }
|
||||||
|
.findAll { allowedServices.contains(it) })
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
return [commit: commit, failedServices: failedServices.unique()]
|
return [commit: commit, failedServices: failedServices.unique()]
|
||||||
}
|
}
|
||||||
@@ -85,7 +97,8 @@ def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
|
|||||||
int inspected = 0
|
int inspected = 0
|
||||||
while (candidate != null && inspected < 30) {
|
while (candidate != null && inspected < 30) {
|
||||||
def state = parseAutoBuildState(candidate.description, allowedServices)
|
def state = parseAutoBuildState(candidate.description, allowedServices)
|
||||||
if (state != null && ['SUCCESS', 'UNSTABLE'].contains(candidate.result)) return state
|
// 完成的 RESULTS 和中断留下的 PENDING 都能推进 AUTO 基线;PENDING 中的服务会被精确补跑。
|
||||||
|
if (state != null) return state
|
||||||
candidate = candidate.previousBuild
|
candidate = candidate.previousBuild
|
||||||
inspected++
|
inspected++
|
||||||
}
|
}
|
||||||
@@ -93,15 +106,37 @@ def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
def environmentChoicesForJob(String jobBaseName) {
|
def environmentChoicesForJob(String jobBaseName) {
|
||||||
return jobBaseName == 'backend-java-dev'
|
return [fixedEnvironmentForJob(jobBaseName) ?: 'test']
|
||||||
? ['development', 'test', 'staging', 'production']
|
|
||||||
: ['test', 'development', 'staging', 'production']
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def downstreamJobPrefixForJob(String jobBaseName) {
|
def fixedEnvironmentForJob(String jobBaseName) {
|
||||||
return jobBaseName == 'backend-java-dev'
|
switch (jobBaseName) {
|
||||||
? 'baiyue-firstleg-dev-'
|
case 'backend-java-dev':
|
||||||
: 'baiyue-firstleg-'
|
return 'development'
|
||||||
|
case 'backend-java':
|
||||||
|
return 'test'
|
||||||
|
case 'backend-java-prod':
|
||||||
|
return 'production'
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def fixedDownstreamJobPrefixForJob(String jobBaseName) {
|
||||||
|
switch (jobBaseName) {
|
||||||
|
case 'backend-java-dev':
|
||||||
|
return 'baiyue-firstleg-dev-'
|
||||||
|
case 'backend-java':
|
||||||
|
return 'baiyue-firstleg-'
|
||||||
|
case 'backend-java-prod':
|
||||||
|
return 'baiyue-firstleg-prod-'
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def downstreamJobPrefixChoicesForJob(String jobBaseName) {
|
||||||
|
return [fixedDownstreamJobPrefixForJob(jobBaseName) ?: 'baiyue-firstleg-']
|
||||||
}
|
}
|
||||||
|
|
||||||
pipeline {
|
pipeline {
|
||||||
@@ -137,7 +172,7 @@ pipeline {
|
|||||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选')
|
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选')
|
||||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址')
|
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址')
|
||||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
|
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
|
||||||
string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: downstreamJobPrefixForJob(env.JOB_BASE_NAME), description: '单服务 Jenkins Job 名称前缀;开发总控自动使用 dev 专属 Job')
|
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
@@ -145,11 +180,15 @@ pipeline {
|
|||||||
agent { label 'java-builder' }
|
agent { label 'java-builder' }
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
// 开发环境总控 Job 固定部署到 development,避免首次构建沿用 ENVIRONMENT 的默认 test 值。
|
String resolvedEnvironment = fixedEnvironmentForJob(env.JOB_BASE_NAME)
|
||||||
// 其他 Job(包括现有 backend-java 测试环境)仍完全沿用原有参数。
|
String resolvedJobPrefix = fixedDownstreamJobPrefixForJob(env.JOB_BASE_NAME)
|
||||||
String resolvedEnvironment = env.JOB_BASE_NAME == 'backend-java-dev'
|
if (!resolvedEnvironment || !resolvedJobPrefix) {
|
||||||
? 'development'
|
error("未配置总控 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续")
|
||||||
: params.ENVIRONMENT
|
}
|
||||||
|
if (params.ENVIRONMENT != resolvedEnvironment ||
|
||||||
|
params.DOWNSTREAM_JOB_PREFIX?.trim() != resolvedJobPrefix) {
|
||||||
|
echo "忽略外部传入的环境或 Job 前缀;${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment} / ${resolvedJobPrefix}"
|
||||||
|
}
|
||||||
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
|
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
|
||||||
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
|
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
|
||||||
|
|
||||||
@@ -163,11 +202,7 @@ pipeline {
|
|||||||
echo "${env.RESOLVED_ENVIRONMENT} 环境使用共享镜像仓库: ${requestedRepository}"
|
echo "${env.RESOLVED_ENVIRONMENT} 环境使用共享镜像仓库: ${requestedRepository}"
|
||||||
env.RESOLVED_IMAGE_REPOSITORY = requestedRepository
|
env.RESOLVED_IMAGE_REPOSITORY = requestedRepository
|
||||||
|
|
||||||
String jobPrefix = params.DOWNSTREAM_JOB_PREFIX?.trim()
|
env.RESOLVED_JOB_PREFIX = resolvedJobPrefix
|
||||||
if (!jobPrefix) {
|
|
||||||
error('DOWNSTREAM_JOB_PREFIX 不能为空')
|
|
||||||
}
|
|
||||||
env.RESOLVED_JOB_PREFIX = jobPrefix
|
|
||||||
env.DATABASE_CHANGE_DETECTED = 'false'
|
env.DATABASE_CHANGE_DETECTED = 'false'
|
||||||
if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) {
|
if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) {
|
||||||
error('生产环境禁止跳过测试')
|
error('生产环境禁止跳过测试')
|
||||||
@@ -190,9 +225,10 @@ pipeline {
|
|||||||
String base = autoState?.commit?.trim()
|
String base = autoState?.commit?.trim()
|
||||||
List retryServices = autoState?.failedServices ?: []
|
List retryServices = autoState?.failedServices ?: []
|
||||||
if (!base) {
|
if (!base) {
|
||||||
// 首次启用新算法时兼容旧构建记录;后续只读取标记为 AUTO 的完成记录,
|
// 首次启用或旧构建记录已淘汰时,只比较当前提交的直接父提交。
|
||||||
// SELECT/ALL 不会推进 AUTO 基线。
|
// 需要初始化全部服务时应显式使用 ALL,不能因历史成功基线过旧而意外全量部署。
|
||||||
base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim()
|
base = sh(script: 'git rev-parse HEAD^ 2>/dev/null || true', returnStdout: true).trim()
|
||||||
|
if (base) echo "AUTO 未找到历史记录,本次使用直接父提交作为基线: ${base}"
|
||||||
}
|
}
|
||||||
if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) {
|
if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) {
|
||||||
selected = serviceModules
|
selected = serviceModules
|
||||||
@@ -280,6 +316,16 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stage('Gateway 路由静态门禁') {
|
||||||
|
when {
|
||||||
|
expression { env.NO_SERVICE_CHANGE != 'true' }
|
||||||
|
}
|
||||||
|
agent { label 'java-builder' }
|
||||||
|
steps {
|
||||||
|
sh 'node --test scripts/gateway-route-contract.test.mjs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stage('统一 Maven 测试门禁') {
|
stage('统一 Maven 测试门禁') {
|
||||||
when {
|
when {
|
||||||
expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS }
|
expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS }
|
||||||
@@ -295,6 +341,7 @@ pipeline {
|
|||||||
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
|
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
|
||||||
sh """
|
sh """
|
||||||
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
|
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
|
||||||
|
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
|
||||||
mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
|
mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
|
||||||
"""
|
"""
|
||||||
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
|
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
|
||||||
|
|||||||
+43
-18
@@ -21,6 +21,23 @@ def serviceJars = [
|
|||||||
'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar'
|
'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def fixedEnvironmentForServiceJob(String jobBaseName) {
|
||||||
|
if (jobBaseName?.startsWith('baiyue-firstleg-dev-')) {
|
||||||
|
return 'development'
|
||||||
|
}
|
||||||
|
if (jobBaseName?.startsWith('baiyue-firstleg-prod-')) {
|
||||||
|
return 'production'
|
||||||
|
}
|
||||||
|
if (jobBaseName?.startsWith('baiyue-firstleg-')) {
|
||||||
|
return 'test'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
def environmentChoicesForServiceJob(String jobBaseName) {
|
||||||
|
return [fixedEnvironmentForServiceJob(jobBaseName) ?: 'test']
|
||||||
|
}
|
||||||
|
|
||||||
pipeline {
|
pipeline {
|
||||||
// 构建在任意 java-builder 节点执行,部署再回到目标环境节点。
|
// 构建在任意 java-builder 节点执行,部署再回到目标环境节点。
|
||||||
agent none
|
agent none
|
||||||
@@ -35,7 +52,7 @@ pipeline {
|
|||||||
parameters {
|
parameters {
|
||||||
string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入')
|
string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入')
|
||||||
string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位')
|
string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位')
|
||||||
choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境')
|
choice(name: 'ENVIRONMENT', choices: environmentChoicesForServiceJob(env.JOB_BASE_NAME), description: '由当前单服务 Job 固定的部署环境')
|
||||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
||||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
||||||
choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值')
|
choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值')
|
||||||
@@ -65,6 +82,7 @@ pipeline {
|
|||||||
String sourceCommit = params.SOURCE_COMMIT?.trim()
|
String sourceCommit = params.SOURCE_COMMIT?.trim()
|
||||||
String imageTag = params.IMAGE_TAG?.trim()
|
String imageTag = params.IMAGE_TAG?.trim()
|
||||||
String imageRepository = params.IMAGE_REPOSITORY?.trim()
|
String imageRepository = params.IMAGE_REPOSITORY?.trim()
|
||||||
|
String resolvedEnvironment = fixedEnvironmentForServiceJob(env.JOB_BASE_NAME)
|
||||||
|
|
||||||
if (!sourceCommit) {
|
if (!sourceCommit) {
|
||||||
sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
||||||
@@ -76,6 +94,12 @@ pipeline {
|
|||||||
if (!serviceModules.contains(service)) {
|
if (!serviceModules.contains(service)) {
|
||||||
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
|
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
|
||||||
}
|
}
|
||||||
|
if (!resolvedEnvironment) {
|
||||||
|
error("未配置单服务 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续")
|
||||||
|
}
|
||||||
|
if (params.ENVIRONMENT != resolvedEnvironment) {
|
||||||
|
echo "忽略外部传入的环境 ${params.ENVIRONMENT};${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment}"
|
||||||
|
}
|
||||||
boolean deployBothBridges = service == 'event-bridge-service' &&
|
boolean deployBothBridges = service == 'event-bridge-service' &&
|
||||||
imageTag?.endsWith('-both')
|
imageTag?.endsWith('-both')
|
||||||
if (deployBothBridges) {
|
if (deployBothBridges) {
|
||||||
@@ -95,6 +119,7 @@ pipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
env.RESOLVED_SERVICE = service
|
env.RESOLVED_SERVICE = service
|
||||||
|
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
|
||||||
env.RESOLVED_SOURCE_COMMIT = sourceCommit
|
env.RESOLVED_SOURCE_COMMIT = sourceCommit
|
||||||
env.RESOLVED_IMAGE_TAG = imageTag
|
env.RESOLVED_IMAGE_TAG = imageTag
|
||||||
env.RESOLVED_IMAGE_REPOSITORY = imageRepository
|
env.RESOLVED_IMAGE_REPOSITORY = imageRepository
|
||||||
@@ -106,7 +131,7 @@ pipeline {
|
|||||||
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
|
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
|
||||||
}
|
}
|
||||||
if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) &&
|
if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) &&
|
||||||
!['development', 'test'].contains(params.ENVIRONMENT)) {
|
!['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) {
|
||||||
error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS')
|
error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +147,7 @@ pipeline {
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
script {
|
script {
|
||||||
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${params.ENVIRONMENT} | ${env.NODE_NAME}"
|
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${env.RESOLVED_ENVIRONMENT} | ${env.NODE_NAME}"
|
||||||
echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT},节点: ${env.NODE_NAME}"
|
echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT},节点: ${env.NODE_NAME}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,14 +157,14 @@ pipeline {
|
|||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean
|
boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean
|
||||||
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) {
|
if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) {
|
||||||
error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包')
|
error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包')
|
||||||
}
|
}
|
||||||
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
|
boolean skipTestExecution = env.RESOLVED_ENVIRONMENT == 'test' || params.SKIP_TESTS
|
||||||
String testOption = skipTestExecution ? '-DskipTests' : ''
|
String testOption = skipTestExecution ? '-DskipTests' : ''
|
||||||
if (params.SKIP_TESTS && coordinatorPinnedCommit) {
|
if (params.SKIP_TESTS && coordinatorPinnedCommit) {
|
||||||
echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包"
|
echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包"
|
||||||
} else if (params.ENVIRONMENT == 'test') {
|
} else if (env.RESOLVED_ENVIRONMENT == 'test') {
|
||||||
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
|
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
|
||||||
}
|
}
|
||||||
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
|
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
|
||||||
@@ -188,15 +213,15 @@ pipeline {
|
|||||||
stage('部署开发或测试环境') {
|
stage('部署开发或测试环境') {
|
||||||
when {
|
when {
|
||||||
beforeAgent true
|
beforeAgent true
|
||||||
expression { params.DEPLOY && ['development', 'test'].contains(params.ENVIRONMENT) }
|
expression { params.DEPLOY && ['development', 'test'].contains(env.RESOLVED_ENVIRONMENT) }
|
||||||
}
|
}
|
||||||
agent none
|
agent none
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
String deploySlotLabel = "firstleg-${params.ENVIRONMENT}-deploy-slot"
|
String deploySlotLabel = "firstleg-${env.RESOLVED_ENVIRONMENT}-deploy-slot"
|
||||||
String serviceDeployResource = "firstleg-${params.ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy"
|
String serviceDeployResource = "firstleg-${env.RESOLVED_ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy"
|
||||||
String credentialId = "firstleg-${params.ENVIRONMENT}-env"
|
String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env"
|
||||||
String targetLabel = params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'
|
String targetLabel = env.RESOLVED_ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'
|
||||||
// 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。
|
// 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。
|
||||||
// resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。
|
// resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。
|
||||||
lock(resource: serviceDeployResource, inversePrecedence: true) {
|
lock(resource: serviceDeployResource, inversePrecedence: true) {
|
||||||
@@ -205,7 +230,7 @@ pipeline {
|
|||||||
deleteDir()
|
deleteDir()
|
||||||
try {
|
try {
|
||||||
unstash 'deployment-files'
|
unstash 'deployment-files'
|
||||||
echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}"
|
echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${env.RESOLVED_ENVIRONMENT}"
|
||||||
List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' &&
|
List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' &&
|
||||||
(env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' ||
|
(env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' ||
|
||||||
['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)))
|
['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)))
|
||||||
@@ -224,7 +249,7 @@ pipeline {
|
|||||||
'TARGET_IS_LOCAL=true',
|
'TARGET_IS_LOCAL=true',
|
||||||
"EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}"
|
"EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}"
|
||||||
]) {
|
]) {
|
||||||
sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
|
sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,18 +266,18 @@ pipeline {
|
|||||||
stage('部署单个服务') {
|
stage('部署单个服务') {
|
||||||
when {
|
when {
|
||||||
beforeAgent true
|
beforeAgent true
|
||||||
expression { params.DEPLOY && !['development', 'test'].contains(params.ENVIRONMENT) }
|
expression { params.DEPLOY && !['development', 'test'].contains(env.RESOLVED_ENVIRONMENT) }
|
||||||
}
|
}
|
||||||
agent {
|
agent {
|
||||||
label "${params.ENVIRONMENT == 'development' ? 'java-dev' : 'master'}"
|
label "${env.RESOLVED_ENVIRONMENT == 'development' ? 'java-dev' : 'master'}"
|
||||||
}
|
}
|
||||||
steps {
|
steps {
|
||||||
deleteDir()
|
deleteDir()
|
||||||
unstash 'deployment-files'
|
unstash 'deployment-files'
|
||||||
script {
|
script {
|
||||||
String credentialId = "firstleg-${params.ENVIRONMENT}-env"
|
String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env"
|
||||||
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
|
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
|
||||||
boolean localDevelopmentDeploy = params.ENVIRONMENT == 'development' &&
|
boolean localDevelopmentDeploy = env.RESOLVED_ENVIRONMENT == 'development' &&
|
||||||
env.NODE_LABELS?.tokenize()?.contains('java-dev')
|
env.NODE_LABELS?.tokenize()?.contains('java-dev')
|
||||||
|
|
||||||
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
||||||
@@ -263,7 +288,7 @@ pipeline {
|
|||||||
"EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}"
|
"EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}"
|
||||||
]) {
|
]) {
|
||||||
withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) {
|
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\""
|
sh "sh deploy/scripts/deploy-services.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -27,12 +27,28 @@ mvn test
|
|||||||
|
|
||||||
GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
|
GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
|
||||||
|
|
||||||
2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
|
2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
|
||||||
|
|
||||||
国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。
|
国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。
|
||||||
|
|
||||||
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
|
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
|
||||||
|
|
||||||
|
## 容器日志
|
||||||
|
|
||||||
|
所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。国内、海外及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。
|
||||||
|
|
||||||
|
单容器默认保留 `50m × 3`,可在各环境部署变量中覆盖:
|
||||||
|
|
||||||
|
| 环境 | `DOCKER_LOG_DRIVER` | `DOCKER_LOG_MAX_SIZE` | `DOCKER_LOG_MAX_FILE` | 长期留存 |
|
||||||
|
|---|---|---:|---:|---|
|
||||||
|
| 开发 | `json-file` | `20m` | `2` | 不要求 |
|
||||||
|
| 测试 | `json-file` | `50m` | `3` | 不要求 |
|
||||||
|
| 生产前期 | `json-file` | `100m` | `5` | 宝塔与 `docker logs` 提供短期排障能力 |
|
||||||
|
|
||||||
|
当前优先保证宝塔可观测性,因此不启用 `json-file` 压缩或非阻塞投递,避免面板无法读取轮转文件或在日志压力下静默丢弃。接入集中日志系统后,可通过 `DOCKER_LOG_DRIVER` 切换驱动,并由集中系统承担长期检索与留存。
|
||||||
|
|
||||||
|
调整日志策略后必须重新创建容器;只重启既有容器不会改变其日志驱动。历史 `/data/logs/baiyue-firstleg` 文件和旧容器日志不自动删除,确认无需保留后再单独清理。
|
||||||
|
|
||||||
## 网关跨域配置
|
## 网关跨域配置
|
||||||
|
|
||||||
本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如:
|
本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## 职责边界
|
## 职责边界
|
||||||
|
|
||||||
|
- Mapper 只由 `BaiyueAuditApplication.java` 对 `com.baiyue.audit` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||||
- 负责接收 `common-audit` 产生的审计事件并持久化到 `baiyue_audit`。
|
- 负责接收 `common-audit` 产生的审计事件并持久化到 `baiyue_audit`。
|
||||||
- 审计注解、切面、脱敏和发送策略在 `common-audit/`;本服务不复制业务服务的审计采集逻辑。
|
- 审计注解、切面、脱敏和发送策略在 `common-audit/`;本服务不复制业务服务的审计采集逻辑。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.baiyue.audit;
|
package com.baiyue.audit;
|
||||||
|
|
||||||
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
|
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
@@ -10,7 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
/**
|
/**
|
||||||
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
|
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
|
||||||
*/
|
*/
|
||||||
@MapperScan("com.baiyue.audit.mapper")
|
@MapperScan(basePackages = "com.baiyue.audit", annotationClass = Mapper.class)
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@Import(MybatisPlusConfiguration.class)
|
@Import(MybatisPlusConfiguration.class)
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package com.baiyue.audit.mapper;
|
package com.baiyue.audit.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.baiyue.audit.entity.AuditLogEntity;
|
import com.baiyue.audit.entity.AuditLogEntity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。
|
* 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。
|
||||||
*/
|
*/
|
||||||
|
@Mapper
|
||||||
public interface AuditLogMapper extends BaseMapper<AuditLogEntity> {
|
public interface AuditLogMapper extends BaseMapper<AuditLogEntity> {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,14 +40,3 @@ baiyue:
|
|||||||
rocketmq:
|
rocketmq:
|
||||||
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
|
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
|
||||||
management.endpoints.web.exposure.include: health,info,prometheus
|
management.endpoints.web.exposure.include: health,info,prometheus
|
||||||
|
|
||||||
logging:
|
|
||||||
file:
|
|
||||||
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
|
|
||||||
logback:
|
|
||||||
rollingpolicy:
|
|
||||||
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
|
|
||||||
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
|
|
||||||
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
|
|
||||||
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
|
|
||||||
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
|
|
||||||
|
|||||||
+6
-3
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## 职责与边界
|
## 职责与边界
|
||||||
|
|
||||||
|
- Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||||
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
|
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
|
||||||
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
|
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
|
||||||
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
|
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
|
||||||
@@ -13,9 +14,10 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
|
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
|
||||||
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
|
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
|
||||||
|
| 登录标识与企业联系人解耦 | `service/AccountManagementService.java`、`dto/AccountManagementDtos.java`、`service/MerchantRegistrationService.java`;注册手机号只在创建时同时初始化首位管理员登录手机号和公司联系电话,创建后二者独立维护;账号手机号变更需撤销该账号全部会话 |
|
||||||
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
|
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
|
||||||
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
|
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
|
||||||
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 |
|
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只携带稳定身份、客户范围和版本声明,不携带角色或权限集;前端统一认证层负责按区域 API 自动选择 |
|
||||||
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
|
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
|
||||||
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
|
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
|
||||||
| 注册协作 | `service/MerchantRegistrationService.java` |
|
| 注册协作 | `service/MerchantRegistrationService.java` |
|
||||||
@@ -26,10 +28,11 @@
|
|||||||
|
|
||||||
## 修改约束
|
## 修改约束
|
||||||
|
|
||||||
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;登录/刷新同时签发短时海外 Token并携带必要权限 Claims,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
|
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;短时海外 Token 不携带角色或权限集,海外授权由 IAM 事件投影到 Lastleg,再由海外 Gateway 验证并以可信内网上下文传递,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
|
||||||
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
|
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
|
||||||
|
- 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。
|
||||||
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
|
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
|
||||||
|
|
||||||
## 全量验收导航
|
## 全量验收导航
|
||||||
|
|
||||||
一期、二期、三期及财务全量验收均复用本模块认证能力;按本次链路读取对应阶段/专题文档,重点核对 Token、应用域、客户隔离、失效和跨 Auth/IAM 补偿,不因历史阶段划分排除其他消费者。
|
订单与客户、入库测量与计费、国内货柜履约、海外仓业务及财务全量验收均复用本模块认证能力;按本次链路读取对应业务域/专题文档,重点核对 Token、应用域、客户隔离、失效和跨 Auth/IAM 补偿,不因历史规划边界排除其他消费者。
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.baiyue.auth;
|
package com.baiyue.auth;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
@@ -8,7 +9,7 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
|
|||||||
/**
|
/**
|
||||||
* 认证服务启动入口,负责装配统一登录、令牌签发与会话管理相关组件。
|
* 认证服务启动入口,负责装配统一登录、令牌签发与会话管理相关组件。
|
||||||
*/
|
*/
|
||||||
@MapperScan("com.baiyue.auth.mapper")
|
@MapperScan(basePackages = "com.baiyue.auth", annotationClass = Mapper.class)
|
||||||
@EnableFeignClients(basePackages = "com.baiyue.auth.client")
|
@EnableFeignClients(basePackages = "com.baiyue.auth.client")
|
||||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||||
public class BaiyueAuthApplication {
|
public class BaiyueAuthApplication {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
|
|||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.cloud.openfeign.FeignClient;
|
import org.springframework.cloud.openfeign.FeignClient;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestHeader;
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
|
||||||
@@ -16,13 +17,18 @@ public interface IamOnboardingClient {
|
|||||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||||
@RequestBody OnboardingRequest request);
|
@RequestBody OnboardingRequest request);
|
||||||
|
|
||||||
|
@GetMapping("/merchant-registrations/{authUserId}")
|
||||||
|
ApiResponse<OnboardingResponse> registration(
|
||||||
|
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||||
|
@PathVariable("authUserId") Long authUserId);
|
||||||
|
|
||||||
@DeleteMapping("/merchant-registrations/{authUserId}/compensation")
|
@DeleteMapping("/merchant-registrations/{authUserId}/compensation")
|
||||||
ApiResponse<Void> compensate(
|
ApiResponse<Void> compensate(
|
||||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||||
@PathVariable("authUserId") Long authUserId);
|
@PathVariable("authUserId") Long authUserId);
|
||||||
|
|
||||||
record OnboardingRequest(Long authUserId, String displayName, String companyName,
|
record OnboardingRequest(Long authUserId, String displayName, String companyName,
|
||||||
String legalPerson, String legalPhone, String contactEmail,
|
String legalPerson, String administratorPhone, String contactEmail,
|
||||||
String countryCode, String address) { }
|
String countryCode, String address) { }
|
||||||
|
|
||||||
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus,
|
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus,
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public class AuthController {
|
|||||||
@SecurityRequirements
|
@SecurityRequirements
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
@Operation(summary = "Merchant registration / 客户注册",
|
@Operation(summary = "Merchant registration / 客户注册",
|
||||||
description = "Create a login account and an enterprise profile awaiting the required business license and legal-person ID; other qualification files are optional.")
|
description = "Create a login account and enterprise profile. The supplied phone initializes both the first administrator login phone and company contact phone; they are maintained independently after creation. Only the business license and unified social credit code are required for review; legal-person ID is optional.")
|
||||||
public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) {
|
public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) {
|
||||||
return ApiResponse.success(registrationService.register(request));
|
return ApiResponse.success(registrationService.register(request));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,20 @@ public class InternalAccountController {
|
|||||||
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
|
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
|
||||||
verify(token); service.updateProfile(id, request); return ApiResponse.success(null);
|
verify(token); service.updateProfile(id, request); return ApiResponse.success(null);
|
||||||
}
|
}
|
||||||
|
@PutMapping("/{id}/login-identifiers")
|
||||||
|
public ApiResponse<AccountSummary> loginIdentifiers(
|
||||||
|
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
|
@PathVariable Long id, @Valid @RequestBody ChangeLoginIdentifiersRequest request) {
|
||||||
|
verify(token);
|
||||||
|
return ApiResponse.success(service.changeLoginIdentifiers(id, request));
|
||||||
|
}
|
||||||
|
@PutMapping("/{id}/phone")
|
||||||
|
public ApiResponse<Void> phone(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
|
@PathVariable Long id, @Valid @RequestBody UpdateAccountPhoneRequest request) {
|
||||||
|
verify(token);
|
||||||
|
service.updatePhone(id, request);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
@PutMapping("/{id}/password")
|
@PutMapping("/{id}/password")
|
||||||
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
|
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ public final class AccountManagementDtos {
|
|||||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
|
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
|
||||||
String newPassword) { }
|
String newPassword) { }
|
||||||
public record CreateAccountRequest(@NotNull Long accountId,
|
public record CreateAccountRequest(@NotNull Long accountId,
|
||||||
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
|
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
|
||||||
|
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
|
||||||
@NotBlank @Size(min = 8, max = 64)
|
@NotBlank @Size(min = 8, max = 64)
|
||||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
|
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
|
||||||
@NotBlank String displayName, @NotNull Long tenantId,
|
@NotBlank String displayName, @NotNull Long tenantId,
|
||||||
@@ -32,9 +33,19 @@ public final class AccountManagementDtos {
|
|||||||
String accountStatus) {
|
String accountStatus) {
|
||||||
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
|
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
|
||||||
Long tenantId, Long membershipId, String appCode) {
|
Long tenantId, Long membershipId, String appCode) {
|
||||||
this(accountId, username, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
|
this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
|
||||||
|
}
|
||||||
|
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
|
||||||
|
Long tenantId, Long membershipId, String appCode, String accountStatus) {
|
||||||
|
this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, accountStatus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public record ChangeLoginIdentifiersRequest(
|
||||||
|
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
|
||||||
|
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
|
||||||
|
@NotBlank String currentPassword) { }
|
||||||
|
public record UpdateAccountPhoneRequest(
|
||||||
|
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone) { }
|
||||||
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
|
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
|
||||||
public record UpdateAccountStatusRequest(@NotBlank String status) { }
|
public record UpdateAccountStatusRequest(@NotBlank String status) { }
|
||||||
public record RevokeAccountSessionsRequest(
|
public record RevokeAccountSessionsRequest(
|
||||||
@@ -56,10 +67,10 @@ public final class AccountManagementDtos {
|
|||||||
public record ImpersonationExchangeRequest(@NotBlank String code) { }
|
public record ImpersonationExchangeRequest(@NotBlank String code) { }
|
||||||
public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId,
|
public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId,
|
||||||
@NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { }
|
@NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { }
|
||||||
public record AccountSummary(Long accountId, String username, String displayName, String status,
|
public record AccountSummary(Long accountId, String username, String phone, String displayName, String status,
|
||||||
LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) {
|
LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) {
|
||||||
public AccountSummary(Long accountId, String username, String displayName, String status) {
|
public AccountSummary(Long accountId, String username, String displayName, String status) {
|
||||||
this(accountId, username, displayName, status, null, null, null, null);
|
this(accountId, username, null, displayName, status, null, null, null, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ package com.baiyue.auth.dto;
|
|||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录请求契约,接收账号、密码和目标应用域等统一登录参数。
|
* 登录请求契约,接收账号、密码和目标应用域等统一登录参数。
|
||||||
*/
|
*/
|
||||||
@Schema(name = "LoginRequest", description = "Unified login credentials / 统一登录凭据")
|
@Schema(name = "LoginRequest", description = "Unified login credentials / 统一登录凭据")
|
||||||
public record LoginRequest(
|
public record LoginRequest(
|
||||||
@Schema(description = "Login username / 登录用户名", example = "merchant_admin") @NotBlank String username,
|
@Schema(description = "Username or mainland China mobile number / 用户名或中国大陆手机号", example = "merchant_admin")
|
||||||
|
@NotBlank @Size(max = 64) String loginId,
|
||||||
@Schema(description = "Login password / 登录密码", example = "ChangeMe123!", format = "password") @NotBlank String password
|
@Schema(description = "Login password / 登录密码", example = "ChangeMe123!", format = "password") @NotBlank String password
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -8,12 +8,13 @@ import jakarta.validation.constraints.Size;
|
|||||||
|
|
||||||
@Schema(name = "MerchantRegisterRequest", description = "Phase-one merchant account registration")
|
@Schema(name = "MerchantRegisterRequest", description = "Phase-one merchant account registration")
|
||||||
public record MerchantRegisterRequest(
|
public record MerchantRegisterRequest(
|
||||||
@NotBlank @Pattern(regexp = "[A-Za-z0-9_]{4,64}") String username,
|
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
|
||||||
@NotBlank @Size(min = 8, max = 72) String password,
|
@NotBlank @Size(min = 8, max = 72) String password,
|
||||||
@NotBlank @Size(max = 64) String displayName,
|
@NotBlank @Size(max = 64) String displayName,
|
||||||
@NotBlank @Size(max = 128) String companyName,
|
@NotBlank @Size(max = 128) String companyName,
|
||||||
@NotBlank @Size(max = 64) String legalPerson,
|
@Size(max = 64) String legalPerson,
|
||||||
@NotBlank @Size(max = 32) String legalPhone,
|
@Schema(description = "首个企业管理员的登录手机号;注册时同时初始化企业联系电话,创建后两者独立维护")
|
||||||
|
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
|
||||||
@Email @Size(max = 128) String contactEmail,
|
@Email @Size(max = 128) String contactEmail,
|
||||||
@Size(min = 2, max = 2) String countryCode,
|
@Size(min = 2, max = 2) String countryCode,
|
||||||
@Size(max = 255) String address
|
@Size(max = 255) String address
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.baiyue.auth.dto;
|
|||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 认证令牌响应契约,返回访问令牌、刷新令牌及其有效期等会话信息。
|
* 认证令牌响应契约,返回访问令牌、刷新令牌及其有效期等会话信息。
|
||||||
*/
|
*/
|
||||||
@@ -15,6 +17,12 @@ public record TokenResponse(
|
|||||||
@Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空",
|
@Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空",
|
||||||
format = "password", nullable = true) String overseasAccessToken,
|
format = "password", nullable = true) String overseasAccessToken,
|
||||||
@Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0",
|
@Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0",
|
||||||
example = "900", minimum = "0") long overseasExpiresIn
|
example = "900", minimum = "0") long overseasExpiresIn,
|
||||||
|
@Schema(description = "Current role snapshot returned outside JWT / JWT 外返回的当前角色快照") Set<String> roles,
|
||||||
|
@Schema(description = "Current authority snapshot returned outside JWT / JWT 外返回的当前权限快照") Set<String> authorities
|
||||||
) {
|
) {
|
||||||
|
public TokenResponse {
|
||||||
|
roles = roles == null ? Set.of() : Set.copyOf(roles);
|
||||||
|
authorities = authorities == null ? Set.of() : Set.copyOf(authorities);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import java.time.LocalDateTime;
|
|||||||
public class AuthAccount {
|
public class AuthAccount {
|
||||||
@TableId private Long id;
|
@TableId private Long id;
|
||||||
private String username;
|
private String username;
|
||||||
|
private String phone;
|
||||||
private String passwordHash;
|
private String passwordHash;
|
||||||
private String displayName;
|
private String displayName;
|
||||||
private String accountStatus;
|
private String accountStatus;
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.baiyue.auth.mapper;
|
package com.baiyue.auth.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
import com.baiyue.auth.entity.AuthAccount;
|
import com.baiyue.auth.entity.AuthAccount;
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。
|
* 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。
|
||||||
*/
|
*/
|
||||||
|
@Mapper
|
||||||
public interface AuthAccountMapper extends BaseMapper<AuthAccount> { }
|
public interface AuthAccountMapper extends BaseMapper<AuthAccount> { }
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.baiyue.auth.mapper;
|
package com.baiyue.auth.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
import com.baiyue.auth.entity.AuthRefreshToken;
|
import com.baiyue.auth.entity.AuthRefreshToken;
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。
|
* 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。
|
||||||
*/
|
*/
|
||||||
|
@Mapper
|
||||||
public interface AuthRefreshTokenMapper extends BaseMapper<AuthRefreshToken> { }
|
public interface AuthRefreshTokenMapper extends BaseMapper<AuthRefreshToken> { }
|
||||||
|
|||||||
@@ -22,11 +22,34 @@ public class AuthAccountRepository {
|
|||||||
.last("LIMIT 1"));
|
.last("LIMIT 1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AuthAccount findByPhone(String phone) {
|
||||||
|
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
|
||||||
|
.eq(AuthAccount::getPhone, phone)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
public boolean existsByUsername(String username) {
|
public boolean existsByUsername(String username) {
|
||||||
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
|
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
|
||||||
.eq(AuthAccount::getUsername, username)) > 0;
|
.eq(AuthAccount::getUsername, username)) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean existsByPhone(String phone) {
|
||||||
|
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
|
||||||
|
.eq(AuthAccount::getPhone, phone)) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean existsByUsernameForOtherAccount(String username, Long accountId) {
|
||||||
|
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
|
||||||
|
.eq(AuthAccount::getUsername, username)
|
||||||
|
.ne(AuthAccount::getId, accountId)) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean existsByPhoneForOtherAccount(String phone, Long accountId) {
|
||||||
|
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
|
||||||
|
.eq(AuthAccount::getPhone, phone)
|
||||||
|
.ne(AuthAccount::getId, accountId)) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
public void insert(AuthAccount account) { mapper.insert(account); }
|
public void insert(AuthAccount account) { mapper.insert(account); }
|
||||||
|
|
||||||
public AuthAccount findById(Long id) {
|
public AuthAccount findById(Long id) {
|
||||||
|
|||||||
@@ -71,9 +71,13 @@ public class AccountManagementService {
|
|||||||
if (repository.existsByUsername(request.username())) {
|
if (repository.existsByUsername(request.username())) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
|
throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
|
||||||
}
|
}
|
||||||
|
if (request.phone() != null && repository.existsByPhone(request.phone())) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
|
||||||
|
}
|
||||||
AuthAccount account = new AuthAccount();
|
AuthAccount account = new AuthAccount();
|
||||||
account.setId(request.accountId());
|
account.setId(request.accountId());
|
||||||
account.setUsername(request.username());
|
account.setUsername(request.username());
|
||||||
|
account.setPhone(request.phone());
|
||||||
account.setPasswordHash(passwordEncoder.encode(request.password()));
|
account.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||||
account.setDisplayName(request.displayName());
|
account.setDisplayName(request.displayName());
|
||||||
account.setAccountStatus(request.accountStatus() == null ? "ACTIVE" : request.accountStatus());
|
account.setAccountStatus(request.accountStatus() == null ? "ACTIVE" : request.accountStatus());
|
||||||
@@ -117,6 +121,45 @@ public class AccountManagementService {
|
|||||||
bumpVersion(account);
|
bumpVersion(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "ACCOUNT_LOGIN_IDENTIFIERS_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
|
||||||
|
description = "修改本人用户名和手机号", captureAfter = false)
|
||||||
|
public AccountSummary changeLoginIdentifiers(Long accountId, ChangeLoginIdentifiersRequest request) {
|
||||||
|
AuthAccount account = required(accountId);
|
||||||
|
if (!passwordEncoder.matches(request.currentPassword(), account.getPasswordHash())) {
|
||||||
|
throw new BizException(ResultCode.UNAUTHORIZED, "当前密码错误");
|
||||||
|
}
|
||||||
|
String username = request.username().trim();
|
||||||
|
String phone = request.phone().trim();
|
||||||
|
if (repository.existsByUsernameForOtherAccount(username, accountId)) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
|
||||||
|
}
|
||||||
|
if (repository.existsByPhoneForOtherAccount(phone, accountId)) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
|
||||||
|
}
|
||||||
|
if (Objects.equals(account.getUsername(), username) && Objects.equals(account.getPhone(), phone)) {
|
||||||
|
return summary(account);
|
||||||
|
}
|
||||||
|
account.setUsername(username);
|
||||||
|
account.setPhone(phone);
|
||||||
|
bumpVersion(account);
|
||||||
|
return summary(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "ACCOUNT_PHONE_SYNC", resourceType = "ACCOUNT", resourceId = "#accountId",
|
||||||
|
description = "同步受管账号手机号", captureAfter = false)
|
||||||
|
public void updatePhone(Long accountId, UpdateAccountPhoneRequest request) {
|
||||||
|
AuthAccount account = required(accountId);
|
||||||
|
String phone = request.phone() == null ? null : request.phone().trim();
|
||||||
|
if (Objects.equals(account.getPhone(), phone)) return;
|
||||||
|
if (phone != null && repository.existsByPhoneForOtherAccount(phone, accountId)) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
|
||||||
|
}
|
||||||
|
account.setPhone(phone);
|
||||||
|
bumpVersion(account);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
|
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
|
||||||
public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
|
public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
|
||||||
@@ -185,7 +228,7 @@ public class AccountManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private AccountSummary summary(AuthAccount a) {
|
private AccountSummary summary(AuthAccount a) {
|
||||||
return new AccountSummary(a.getId(), a.getUsername(), a.getDisplayName(), a.getAccountStatus(),
|
return new AccountSummary(a.getId(), a.getUsername(), a.getPhone(), a.getDisplayName(), a.getAccountStatus(),
|
||||||
a.getLastLoginAt(), a.getDefaultTenantId(), a.getDefaultMembershipId(), a.getDefaultAppCode());
|
a.getLastLoginAt(), a.getDefaultTenantId(), a.getDefaultMembershipId(), a.getDefaultAppCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +244,7 @@ public class AccountManagementService {
|
|||||||
|
|
||||||
private boolean sameIdentity(AuthAccount account, CreateAccountRequest request, String appCode) {
|
private boolean sameIdentity(AuthAccount account, CreateAccountRequest request, String appCode) {
|
||||||
return Objects.equals(account.getUsername(), request.username())
|
return Objects.equals(account.getUsername(), request.username())
|
||||||
|
&& Objects.equals(account.getPhone(), request.phone())
|
||||||
&& Objects.equals(account.getDefaultTenantId(), request.tenantId())
|
&& Objects.equals(account.getDefaultTenantId(), request.tenantId())
|
||||||
&& Objects.equals(account.getOperatorTenantId(), request.tenantId())
|
&& Objects.equals(account.getOperatorTenantId(), request.tenantId())
|
||||||
&& Objects.equals(account.getDefaultMembershipId(), request.membershipId())
|
&& Objects.equals(account.getDefaultMembershipId(), request.membershipId())
|
||||||
|
|||||||
@@ -57,10 +57,13 @@ public class AuthService {
|
|||||||
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.username",
|
@BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.loginId",
|
||||||
description = "User login / 用户登录", captureAfter = false)
|
description = "User login / 用户登录", captureAfter = false)
|
||||||
public TokenResponse login(LoginRequest request) {
|
public TokenResponse login(LoginRequest request) {
|
||||||
AuthAccount account = accountRepository.findByUsername(request.username());
|
String loginId = request.loginId().trim();
|
||||||
|
AuthAccount account = loginId.matches("^1[3-9]\\d{9}$")
|
||||||
|
? accountRepository.findByPhone(loginId)
|
||||||
|
: accountRepository.findByUsername(loginId);
|
||||||
if (account == null) throw invalidCredentials();
|
if (account == null) throw invalidCredentials();
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
if (!"ACTIVE".equals(account.getAccountStatus()))
|
if (!"ACTIVE".equals(account.getAccountStatus()))
|
||||||
@@ -147,7 +150,8 @@ public class AuthService {
|
|||||||
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
|
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
|
||||||
rawRefresh, properties.refreshTokenTtl().toSeconds(),
|
rawRefresh, properties.refreshTokenTtl().toSeconds(),
|
||||||
overseas == null ? null : overseas.accessToken(),
|
overseas == null ? null : overseas.accessToken(),
|
||||||
overseas == null ? 0L : overseas.expiresIn());
|
overseas == null ? 0L : overseas.expiresIn(),
|
||||||
|
authorization.getRoles(), authorization.getPermissions());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -201,17 +205,31 @@ public class AuthService {
|
|||||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue();
|
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue();
|
||||||
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
|
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
|
||||||
authorization.getPermissions(), Duration.ofMinutes(15));
|
authorization.getPermissions(), Duration.ofMinutes(15));
|
||||||
return new ImpersonationTokenResponse("Bearer", accessToken, 900);
|
OverseasTokenService service = overseasTokens.getIfAvailable();
|
||||||
|
OverseasTokenService.OverseasTokenResponse overseas = service == null
|
||||||
|
? null : service.issue(account, authorization);
|
||||||
|
return new ImpersonationTokenResponse("Bearer", accessToken, 900,
|
||||||
|
overseas == null ? null : overseas.accessToken(),
|
||||||
|
overseas == null ? 0L : overseas.expiresIn(),
|
||||||
|
authorization.getRoles(), authorization.getPermissions());
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn) { }
|
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn,
|
||||||
|
String overseasAccessToken, long overseasExpiresIn,
|
||||||
|
java.util.Set<String> roles,
|
||||||
|
java.util.Set<String> authorities) {
|
||||||
|
public ImpersonationTokenResponse {
|
||||||
|
roles = roles == null ? java.util.Set.of() : java.util.Set.copyOf(roles);
|
||||||
|
authorities = authorities == null ? java.util.Set.of() : java.util.Set.copyOf(authorities);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private JwsHeader signingHeader() {
|
private JwsHeader signingHeader() {
|
||||||
return JwsHeader.with(MacAlgorithm.HS256).build();
|
return JwsHeader.with(MacAlgorithm.HS256).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static BizException invalidCredentials() {
|
private static BizException invalidCredentials() {
|
||||||
return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误");
|
return new BizException(ResultCode.UNAUTHORIZED, "登录标识或密码错误");
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,20 @@ public class MerchantRegistrationService {
|
|||||||
@BusinessAudit(event = "MERCHANT_REGISTER", resourceType = "ACCOUNT",
|
@BusinessAudit(event = "MERCHANT_REGISTER", resourceType = "ACCOUNT",
|
||||||
resourceId = "#request.username", description = "商户注册", captureAfter = false)
|
resourceId = "#request.username", description = "商户注册", captureAfter = false)
|
||||||
public MerchantRegisterResponse register(MerchantRegisterRequest request) {
|
public MerchantRegisterResponse register(MerchantRegisterRequest request) {
|
||||||
if (accountRepository.existsByUsername(request.username())) {
|
AuthAccount usernameAccount = accountRepository.findByUsername(request.username());
|
||||||
throw new BizException(ResultCode.CONFLICT, "Username already exists");
|
AuthAccount phoneAccount = accountRepository.findByPhone(request.phone());
|
||||||
|
if (usernameAccount != null && phoneAccount != null
|
||||||
|
&& usernameAccount.getId().equals(phoneAccount.getId())
|
||||||
|
&& "MERCHANT".equals(usernameAccount.getDefaultAppCode())
|
||||||
|
&& "ACTIVE".equals(usernameAccount.getAccountStatus())
|
||||||
|
&& passwordEncoder.matches(request.password(), usernameAccount.getPasswordHash())) {
|
||||||
|
return existingRegistration(usernameAccount);
|
||||||
|
}
|
||||||
|
if (usernameAccount != null) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
|
||||||
|
}
|
||||||
|
if (phoneAccount != null) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "该手机号已注册,请直接登录后继续完善企业资质");
|
||||||
}
|
}
|
||||||
long accountId = IdWorker.getId();
|
long accountId = IdWorker.getId();
|
||||||
String passwordHash = passwordEncoder.encode(request.password());
|
String passwordHash = passwordEncoder.encode(request.password());
|
||||||
@@ -46,7 +58,7 @@ public class MerchantRegistrationService {
|
|||||||
try {
|
try {
|
||||||
var response = onboardingClient.register(internalToken,
|
var response = onboardingClient.register(internalToken,
|
||||||
new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(),
|
new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(),
|
||||||
request.companyName(), request.legalPerson(), request.legalPhone(),
|
request.companyName(), request.legalPerson(), request.phone(),
|
||||||
request.contactEmail(), request.countryCode(), request.address()));
|
request.contactEmail(), request.countryCode(), request.address()));
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed");
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed");
|
||||||
@@ -81,6 +93,7 @@ public class MerchantRegistrationService {
|
|||||||
AuthAccount account = new AuthAccount();
|
AuthAccount account = new AuthAccount();
|
||||||
account.setId(accountId);
|
account.setId(accountId);
|
||||||
account.setUsername(request.username());
|
account.setUsername(request.username());
|
||||||
|
account.setPhone(request.phone());
|
||||||
account.setPasswordHash(passwordHash);
|
account.setPasswordHash(passwordHash);
|
||||||
account.setDisplayName(request.displayName());
|
account.setDisplayName(request.displayName());
|
||||||
account.setAccountStatus("ACTIVE");
|
account.setAccountStatus("ACTIVE");
|
||||||
@@ -94,7 +107,7 @@ public class MerchantRegistrationService {
|
|||||||
try {
|
try {
|
||||||
accountRepository.insert(account);
|
accountRepository.insert(account);
|
||||||
} catch (DuplicateKeyException exception) {
|
} catch (DuplicateKeyException exception) {
|
||||||
BizException failure = new BizException(ResultCode.CONFLICT, "Username already exists");
|
BizException failure = new BizException(ResultCode.CONFLICT, "用户名或手机号已存在");
|
||||||
failure.addSuppressed(exception);
|
failure.addSuppressed(exception);
|
||||||
compensateOnboarding(accountId, failure, compensationCompleted);
|
compensateOnboarding(accountId, failure, compensationCompleted);
|
||||||
throw failure;
|
throw failure;
|
||||||
@@ -107,6 +120,24 @@ public class MerchantRegistrationService {
|
|||||||
"LOGIN_AND_COMPLETE_QUALIFICATIONS");
|
"LOGIN_AND_COMPLETE_QUALIFICATIONS");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private MerchantRegisterResponse existingRegistration(AuthAccount account) {
|
||||||
|
try {
|
||||||
|
var response = onboardingClient.registration(internalToken, account.getId());
|
||||||
|
if (response == null || !response.successful() || response.data() == null) {
|
||||||
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质");
|
||||||
|
}
|
||||||
|
var onboarding = response.data();
|
||||||
|
if (!java.util.Set.of("PROFILE_REQUIRED", "REJECTED").contains(onboarding.tenantStatus())) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "该账号已注册,请直接登录");
|
||||||
|
}
|
||||||
|
return new MerchantRegisterResponse(account.getId(), onboarding.tenantId(), onboarding.membershipId(),
|
||||||
|
onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(),
|
||||||
|
"LOGIN_AND_COMPLETE_QUALIFICATIONS");
|
||||||
|
} catch (FeignException exception) {
|
||||||
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
|
private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
|
||||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -19,21 +19,12 @@ import org.springframework.stereotype.Service;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */
|
/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */
|
||||||
@Service
|
@Service
|
||||||
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
|
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
|
||||||
public class OverseasTokenService {
|
public class OverseasTokenService {
|
||||||
private static final List<String> OVERSEAS_AUTHORITY_PREFIXES = List.of(
|
|
||||||
"platform:overseas-warehouse:",
|
|
||||||
"platform:overseas-business:",
|
|
||||||
"platform:overseas-master-data:",
|
|
||||||
"lastleg:"
|
|
||||||
);
|
|
||||||
|
|
||||||
@Resource private AuthAccountRepository accounts;
|
@Resource private AuthAccountRepository accounts;
|
||||||
@Resource private AuthorizationLoader authorizationLoader;
|
@Resource private AuthorizationLoader authorizationLoader;
|
||||||
@Resource private OverseasJwtProperties properties;
|
@Resource private OverseasJwtProperties properties;
|
||||||
@@ -58,8 +49,6 @@ public class OverseasTokenService {
|
|||||||
.claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId())
|
.claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId())
|
||||||
.claim("membership_id", authorization.getMembershipId())
|
.claim("membership_id", authorization.getMembershipId())
|
||||||
.claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType())
|
.claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType())
|
||||||
.claim("roles", authorization.getRoles())
|
|
||||||
.claim("authorities", overseasAuthorities(authorization.getPermissions()))
|
|
||||||
.claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion())
|
.claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion())
|
||||||
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion());
|
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion());
|
||||||
if (authorization.getOverseasWarehouseId() != null) {
|
if (authorization.getOverseasWarehouseId() != null) {
|
||||||
@@ -68,15 +57,15 @@ public class OverseasTokenService {
|
|||||||
JwtClaimsSet claims = claimsBuilder.build();
|
JwtClaimsSet claims = claimsBuilder.build();
|
||||||
String token = encoder.encode(JwtEncoderParameters.from(
|
String token = encoder.encode(JwtEncoderParameters.from(
|
||||||
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
|
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
|
||||||
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds());
|
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds(),
|
||||||
|
authorization.getRoles(), authorization.getPermissions());
|
||||||
}
|
}
|
||||||
|
|
||||||
private Set<String> overseasAuthorities(Set<String> permissions) {
|
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn,
|
||||||
if (permissions == null || permissions.isEmpty()) return Set.of();
|
java.util.Set<String> roles, java.util.Set<String> authorities) {
|
||||||
return permissions.stream()
|
public OverseasTokenResponse {
|
||||||
.filter(permission -> permission != null && OVERSEAS_AUTHORITY_PREFIXES.stream().anyMatch(permission::startsWith))
|
roles = roles == null ? java.util.Set.of() : java.util.Set.copyOf(roles);
|
||||||
.collect(Collectors.toUnmodifiableSet());
|
authorities = authorities == null ? java.util.Set.of() : java.util.Set.copyOf(authorities);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,14 +52,3 @@ baiyue.clients:
|
|||||||
url: ${IAM_SERVICE_URL:}
|
url: ${IAM_SERVICE_URL:}
|
||||||
|
|
||||||
management.endpoints.web.exposure.include: health,info,prometheus
|
management.endpoints.web.exposure.include: health,info,prometheus
|
||||||
|
|
||||||
logging:
|
|
||||||
file:
|
|
||||||
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
|
|
||||||
logback:
|
|
||||||
rollingpolicy:
|
|
||||||
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
|
|
||||||
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
|
|
||||||
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
|
|
||||||
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
|
|
||||||
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
|
|
||||||
|
|||||||
@@ -0,0 +1,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 (
|
CREATE TABLE IF NOT EXISTS auth_account (
|
||||||
id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)',
|
id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)',
|
||||||
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
|
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
|
||||||
|
phone VARCHAR(32) DEFAULT NULL COMMENT '全局唯一中国大陆登录手机号',
|
||||||
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
|
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
|
||||||
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
|
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
|
||||||
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定',
|
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定',
|
||||||
@@ -23,6 +24,7 @@ CREATE TABLE IF NOT EXISTS auth_account (
|
|||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_auth_account_username (username),
|
UNIQUE KEY uk_auth_account_username (username),
|
||||||
|
UNIQUE KEY uk_auth_account_phone (phone),
|
||||||
KEY idx_auth_account_status (account_status, locked_until)
|
KEY idx_auth_account_status (account_status, locked_until)
|
||||||
) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态';
|
) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态';
|
||||||
|
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
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 backfillsOnlyUniquelyOwnedPhonesAndKeepsDuplicateLegacyPhonesUnbound() throws Exception {
|
||||||
|
assertSafeBackfill(new ClassPathResource("db/auth-login-identifiers.sql")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8));
|
||||||
|
assertSafeBackfill(Files.readString(unifiedSql(), StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.baiyue.auth.repository;
|
||||||
|
|
||||||
|
import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest;
|
||||||
|
import com.baiyue.auth.dto.MerchantRegisterRequest;
|
||||||
|
import jakarta.validation.Validation;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class LoginIdentifierContractTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registrationExposesAnAccountPhoneInsteadOfALegalPersonPhoneAlias() {
|
||||||
|
var componentNames = Arrays.stream(MerchantRegisterRequest.class.getRecordComponents())
|
||||||
|
.map(component -> component.getName())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
assertThat(componentNames).contains("phone").doesNotContain("legalPhone");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mainlandMobileRuleRejectsPlaceholderNumbersAcrossJavaAndSql() throws Exception {
|
||||||
|
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||||
|
var validator = factory.getValidator();
|
||||||
|
var invalidRegistration = new MerchantRegisterRequest(
|
||||||
|
"merchant.user", "Password1", "测试用户", "测试企业", "张三",
|
||||||
|
"11111111111", null, "CN", null);
|
||||||
|
var validChange = new ChangeLoginIdentifiersRequest(
|
||||||
|
"merchant.user", "13800138000", "Password1");
|
||||||
|
var invalidChange = new ChangeLoginIdentifiersRequest(
|
||||||
|
"merchant.user", "11111111111", "Password1");
|
||||||
|
|
||||||
|
assertThat(validator.validate(invalidRegistration)).isNotEmpty();
|
||||||
|
assertThat(validator.validate(validChange)).isEmpty();
|
||||||
|
assertThat(validator.validate(invalidChange)).isNotEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
String sql = new ClassPathResource("db/auth-login-identifiers.sql")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertThat(sql)
|
||||||
|
.contains("REGEXP '^1[3-9][0-9]{9}$'")
|
||||||
|
.doesNotContain("REGEXP '^1[0-9]{10}$'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
|
|||||||
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
|
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
|
||||||
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
|
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
|
||||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
|
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
|
||||||
|
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountPhoneRequest;
|
||||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
|
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
|
||||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest;
|
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest;
|
||||||
|
import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest;
|
||||||
import com.baiyue.auth.entity.AuthAccount;
|
import com.baiyue.auth.entity.AuthAccount;
|
||||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
@@ -157,6 +159,44 @@ class AccountManagementServiceTest {
|
|||||||
verify(sessions).revokeAll(10L);
|
verify(sessions).revokeAll(10L);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test void changeLoginIdentifiersRequiresCurrentPasswordAndRevokesSessions() {
|
||||||
|
account.setUsername("old.user");
|
||||||
|
account.setPhone("13800138000");
|
||||||
|
when(repository.existsByUsernameForOtherAccount("new.user", 10L)).thenReturn(false);
|
||||||
|
when(repository.existsByPhoneForOtherAccount("13900139000", 10L)).thenReturn(false);
|
||||||
|
|
||||||
|
var result = service.changeLoginIdentifiers(10L,
|
||||||
|
new ChangeLoginIdentifiersRequest("new.user", "13900139000", "OldPass1"));
|
||||||
|
|
||||||
|
assertEquals("new.user", result.username());
|
||||||
|
assertEquals("13900139000", result.phone());
|
||||||
|
assertEquals(3, account.getTokenVersion());
|
||||||
|
verify(sessions).revokeAll(10L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void managedPhoneChangeOrRemovalRevokesOnlyThatAccountsSessions() {
|
||||||
|
account.setPhone("13800138000");
|
||||||
|
|
||||||
|
service.updatePhone(10L, new UpdateAccountPhoneRequest(null));
|
||||||
|
|
||||||
|
assertEquals(null, account.getPhone());
|
||||||
|
assertEquals(3, account.getTokenVersion());
|
||||||
|
verify(repository).save(account);
|
||||||
|
verify(sessions).revokeAll(10L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void duplicatePhoneDoesNotChangeLoginIdentifiers() {
|
||||||
|
account.setUsername("old.user");
|
||||||
|
account.setPhone("13800138000");
|
||||||
|
when(repository.existsByUsernameForOtherAccount("new.user", 10L)).thenReturn(false);
|
||||||
|
when(repository.existsByPhoneForOtherAccount("13900139000", 10L)).thenReturn(true);
|
||||||
|
|
||||||
|
assertThrows(BizException.class, () -> service.changeLoginIdentifiers(10L,
|
||||||
|
new ChangeLoginIdentifiersRequest("new.user", "13900139000", "OldPass1")));
|
||||||
|
verify(repository, never()).save(any());
|
||||||
|
verify(sessions, never()).revokeAll(any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test void rbacChangeRevokesAccessAndRefreshSessionsAndBumpsTokenVersions() {
|
@Test void rbacChangeRevokesAccessAndRefreshSessionsAndBumpsTokenVersions() {
|
||||||
AuthAccount second = new AuthAccount();
|
AuthAccount second = new AuthAccount();
|
||||||
second.setId(11L); second.setTokenVersion(7); second.setAccountStatus("ACTIVE");
|
second.setId(11L); second.setTokenVersion(7); second.setAccountStatus("ACTIVE");
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class AuthServiceSmallJwtTest {
|
|||||||
account.setDefaultAppCode("PLATFORM");
|
account.setDefaultAppCode("PLATFORM");
|
||||||
account.setTokenVersion(1);
|
account.setTokenVersion(1);
|
||||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
|
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
|
||||||
"WEB", Set.of("WAREHOUSE_PICKER"), Set.of("platform:order:list"), 1L, 900L);
|
"WEB", Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("platform:order:list"), 1L, 900L);
|
||||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
|
||||||
when(repository.findByUsername("operator")).thenReturn(account);
|
when(repository.findByUsername("operator")).thenReturn(account);
|
||||||
@@ -72,7 +72,8 @@ class AuthServiceSmallJwtTest {
|
|||||||
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||||
when(encoded.getTokenValue()).thenReturn("small-token");
|
when(encoded.getTokenValue()).thenReturn("small-token");
|
||||||
when(overseasTokenService.issue(account, snapshot)).thenReturn(
|
when(overseasTokenService.issue(account, snapshot)).thenReturn(
|
||||||
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900));
|
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900,
|
||||||
|
snapshot.getRoles(), snapshot.getPermissions()));
|
||||||
|
|
||||||
var response = service.login(new LoginRequest("operator", "secret"));
|
var response = service.login(new LoginRequest("operator", "secret"));
|
||||||
|
|
||||||
@@ -91,13 +92,55 @@ class AuthServiceSmallJwtTest {
|
|||||||
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
|
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
|
||||||
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
|
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
|
||||||
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
|
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
|
||||||
org.mockito.ArgumentMatchers.eq(Set.of("WAREHOUSE_PICKER")),
|
org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")),
|
||||||
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
|
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
|
||||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||||
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||||
verify(overseasTokenService).issue(account, snapshot);
|
verify(overseasTokenService).issue(account, snapshot);
|
||||||
assertThat(response.overseasAccessToken()).isEqualTo("overseas-token");
|
assertThat(response.overseasAccessToken()).isEqualTo("overseas-token");
|
||||||
assertThat(response.overseasExpiresIn()).isEqualTo(900);
|
assertThat(response.overseasExpiresIn()).isEqualTo(900);
|
||||||
|
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
|
@Test
|
||||||
@@ -122,7 +165,8 @@ class AuthServiceSmallJwtTest {
|
|||||||
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||||
when(encoded.getTokenValue()).thenReturn("domestic-refreshed");
|
when(encoded.getTokenValue()).thenReturn("domestic-refreshed");
|
||||||
when(overseasTokenService.issue(account, snapshot)).thenReturn(
|
when(overseasTokenService.issue(account, snapshot)).thenReturn(
|
||||||
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900));
|
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900,
|
||||||
|
snapshot.getRoles(), snapshot.getPermissions()));
|
||||||
|
|
||||||
var response = service.refresh("refresh-old");
|
var response = service.refresh("refresh-old");
|
||||||
|
|
||||||
@@ -133,6 +177,8 @@ class AuthServiceSmallJwtTest {
|
|||||||
assertThat(response.refreshExpiresIn()).isEqualTo(604800);
|
assertThat(response.refreshExpiresIn()).isEqualTo(604800);
|
||||||
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed");
|
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed");
|
||||||
assertThat(response.overseasExpiresIn()).isEqualTo(900);
|
assertThat(response.overseasExpiresIn()).isEqualTo(900);
|
||||||
|
assertThat(response.roles()).containsExactly("MERCHANT_ADMIN");
|
||||||
|
assertThat(response.authorities()).containsExactly("merchant:order:list");
|
||||||
verify(tokenSessionStore).consume("refresh-old");
|
verify(tokenSessionStore).consume("refresh-old");
|
||||||
verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L),
|
verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L),
|
||||||
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")),
|
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")),
|
||||||
@@ -179,8 +225,16 @@ class AuthServiceSmallJwtTest {
|
|||||||
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||||
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||||
when(encoded.getTokenValue()).thenReturn("small-impersonation-token");
|
when(encoded.getTokenValue()).thenReturn("small-impersonation-token");
|
||||||
|
when(overseasTokenService.issue(account, snapshot)).thenReturn(
|
||||||
|
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-impersonation-token", 900,
|
||||||
|
Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list")));
|
||||||
|
|
||||||
service.exchangeImpersonationTicket("one-time-code");
|
AuthService.ImpersonationTokenResponse response = service.exchangeImpersonationTicket("one-time-code");
|
||||||
|
|
||||||
|
assertThat(response.overseasAccessToken()).isEqualTo("overseas-impersonation-token");
|
||||||
|
assertThat(response.overseasExpiresIn()).isEqualTo(900);
|
||||||
|
assertThat(response.roles()).containsExactly("MERCHANT_OWNER");
|
||||||
|
assertThat(response.authorities()).containsExactly("merchant:order:list");
|
||||||
|
|
||||||
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||||
verify(jwtEncoder).encode(jwt.capture());
|
verify(jwtEncoder).encode(jwt.capture());
|
||||||
@@ -193,5 +247,6 @@ class AuthServiceSmallJwtTest {
|
|||||||
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")),
|
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")),
|
||||||
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
|
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
|
||||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
|
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
|
||||||
|
verify(overseasTokenService).issue(account, snapshot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
|
||||||
@SpringJUnitConfig(MerchantRegistrationService.class)
|
@SpringJUnitConfig(MerchantRegistrationService.class)
|
||||||
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
|
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
|
||||||
@@ -54,11 +55,56 @@ class MerchantRegistrationServiceTest {
|
|||||||
assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId());
|
assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId());
|
||||||
assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode());
|
assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode());
|
||||||
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
|
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
|
||||||
|
assertEquals("13800138000", storedAccount.getValue().getPhone());
|
||||||
|
assertEquals("13800138000", onboardingRequest.getValue().administratorPhone());
|
||||||
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
|
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
|
||||||
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
|
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
|
||||||
assertEquals("PENDING", result.dimensionInitializationStatus());
|
assertEquals("PENDING", result.dimensionInitializationStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exactIncompleteRegistrationRetryReturnsTheExistingRegistration() {
|
||||||
|
AuthAccount account = new AuthAccount();
|
||||||
|
account.setId(9001L);
|
||||||
|
account.setUsername("registered_owner");
|
||||||
|
account.setPhone("13800138000");
|
||||||
|
account.setPasswordHash("encoded-password");
|
||||||
|
account.setAccountStatus("ACTIVE");
|
||||||
|
account.setDefaultAppCode("MERCHANT");
|
||||||
|
when(accounts.findByUsername("registered_owner")).thenReturn(account);
|
||||||
|
when(accounts.findByPhone("13800138000")).thenReturn(account);
|
||||||
|
when(encoder.matches("Password123", "encoded-password")).thenReturn(true);
|
||||||
|
when(onboarding.registration("internal-token", 9001L)).thenReturn(ApiResponse.success(
|
||||||
|
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "READY")));
|
||||||
|
|
||||||
|
var result = service.register(new MerchantRegisterRequest(
|
||||||
|
"registered_owner", "Password123", "注册人", "测试企业", null,
|
||||||
|
"13800138000", "owner@example.com", "CN", "深圳市宝安区"));
|
||||||
|
|
||||||
|
assertEquals(9001L, result.accountId());
|
||||||
|
assertEquals(10001L, result.tenantId());
|
||||||
|
assertEquals("READY", result.dimensionInitializationStatus());
|
||||||
|
verify(onboarding, never()).register(eq("internal-token"), any());
|
||||||
|
verify(accounts, never()).insert(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void phoneOwnedByAnotherUsernameDirectsTheUserBackToLogin() {
|
||||||
|
AuthAccount account = new AuthAccount();
|
||||||
|
account.setId(9001L);
|
||||||
|
account.setUsername("registered_owner");
|
||||||
|
account.setPhone("13800138000");
|
||||||
|
when(accounts.findByPhone("13800138000")).thenReturn(account);
|
||||||
|
|
||||||
|
var failure = assertThrows(com.baiyue.common.core.exception.BizException.class,
|
||||||
|
() -> service.register(new MerchantRegisterRequest(
|
||||||
|
"another_owner", "Password123", "注册人", "测试企业", null,
|
||||||
|
"13800138000", "owner@example.com", "CN", "深圳市宝安区")));
|
||||||
|
|
||||||
|
assertEquals("该手机号已注册,请直接登录后继续完善企业资质", failure.getMessage());
|
||||||
|
verify(onboarding, never()).register(eq("internal-token"), any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void failedAuthInsertCompensatesTheIamRegistration() {
|
void failedAuthInsertCompensatesTheIamRegistration() {
|
||||||
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
||||||
|
|||||||
+1
-1
@@ -66,7 +66,7 @@ class OverseasSessionValidationServiceTest {
|
|||||||
|
|
||||||
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
|
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
|
||||||
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA",
|
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA",
|
||||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), version, warehouseId);
|
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), version, warehouseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void inject(String fieldName, Object value) throws Exception {
|
private void inject(String fieldName, Object value) throws Exception {
|
||||||
|
|||||||
@@ -49,10 +49,15 @@ class OverseasTokenServiceTest {
|
|||||||
account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(999L);
|
account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(999L);
|
||||||
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
|
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
|
||||||
String authority = "platform:overseas-master-data:quote:list";
|
String authority = "platform:overseas-master-data:quote:list";
|
||||||
|
String merchantAuthority = "merchant:warehouse:delivery:list";
|
||||||
|
String merchantCrossBorderAuthority = "merchant:cross-border:procurement";
|
||||||
String lastlegAuthority = "lastleg:pda:operate";
|
String lastlegAuthority = "lastleg:pda:operate";
|
||||||
|
String financeAuthority = "platform:finance:fee-source:edit";
|
||||||
String domesticAuthority = "platform:firstleg:order:list";
|
String domesticAuthority = "platform:firstleg:order:list";
|
||||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
|
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
|
||||||
Set.of("WAREHOUSE_PICKER"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L);
|
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"),
|
||||||
|
Set.of(authority, merchantAuthority, merchantCrossBorderAuthority, lastlegAuthority,
|
||||||
|
financeAuthority, domesticAuthority), 7L, 900L);
|
||||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
when(accounts.findById(42L)).thenReturn(account);
|
when(accounts.findById(42L)).thenReturn(account);
|
||||||
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||||
@@ -74,10 +79,12 @@ class OverseasTokenServiceTest {
|
|||||||
.containsEntry("permission_version", 7L)
|
.containsEntry("permission_version", 7L)
|
||||||
.containsEntry("token_version", 0)
|
.containsEntry("token_version", 0)
|
||||||
.containsEntry("overseas_warehouse_id", 900L)
|
.containsEntry("overseas_warehouse_id", 900L)
|
||||||
.containsEntry("roles", Set.of("WAREHOUSE_PICKER"))
|
.doesNotContainKeys("roles", "authorities", "permissions", "userId", "tenantId", "appCode",
|
||||||
.containsEntry("authorities", Set.of(authority, lastlegAuthority))
|
"permissionVersion");
|
||||||
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
|
|
||||||
assertThat(response.expiresIn()).isEqualTo(900);
|
assertThat(response.expiresIn()).isEqualTo(900);
|
||||||
|
assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR");
|
||||||
|
assertThat(response.authorities()).containsExactlyInAnyOrder(authority, merchantAuthority,
|
||||||
|
merchantCrossBorderAuthority, lastlegAuthority, financeAuthority, domesticAuthority);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -87,9 +94,9 @@ class OverseasTokenServiceTest {
|
|||||||
account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM");
|
account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM");
|
||||||
account.setTokenVersion(8);
|
account.setTokenVersion(8);
|
||||||
AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
||||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 8L, 900L);
|
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 8L, 900L);
|
||||||
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
||||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 9L, 901L);
|
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 9L, 901L);
|
||||||
Jwt first = org.mockito.Mockito.mock(Jwt.class);
|
Jwt first = org.mockito.Mockito.mock(Jwt.class);
|
||||||
Jwt second = org.mockito.Mockito.mock(Jwt.class);
|
Jwt second = org.mockito.Mockito.mock(Jwt.class);
|
||||||
when(encoder.encode(any())).thenReturn(first, second);
|
when(encoder.encode(any())).thenReturn(first, second);
|
||||||
@@ -117,7 +124,7 @@ class OverseasTokenServiceTest {
|
|||||||
AuthAccount account = new AuthAccount();
|
AuthAccount account = new AuthAccount();
|
||||||
account.setId(44L); account.setAccountStatus("ACTIVE");
|
account.setId(44L); account.setAccountStatus("ACTIVE");
|
||||||
AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA",
|
AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA",
|
||||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 10L, null);
|
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 10L, null);
|
||||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
when(encoder.encode(any())).thenReturn(encoded);
|
when(encoder.encode(any())).thenReturn(encoded);
|
||||||
when(encoded.getTokenValue()).thenReturn("overseas-unbound");
|
when(encoded.getTokenValue()).thenReturn("overseas-unbound");
|
||||||
|
|||||||
@@ -16,9 +16,11 @@
|
|||||||
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
|
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
|
||||||
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` |
|
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` |
|
||||||
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
|
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
|
||||||
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
|
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次,信封固定使用 `tenantId=0` 平台技术范围;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
|
||||||
| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 |
|
| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 |
|
||||||
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
||||||
|
| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 |
|
||||||
|
| 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 |
|
||||||
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
|
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
|
||||||
| 契约说明 | `package-info.java` |
|
| 契约说明 | `package-info.java` |
|
||||||
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
|
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
|
||||||
|
|||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ public final class EventTypes {
|
|||||||
"FirstLegContainerShippingNodeRecorded";
|
"FirstLegContainerShippingNodeRecorded";
|
||||||
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
|
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
|
||||||
public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged";
|
public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged";
|
||||||
|
public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged";
|
||||||
|
public static final String WALLET_COMMAND_REQUESTED = "WalletCommandRequested";
|
||||||
|
public static final String WALLET_COMMAND_COMPLETED = "WalletCommandCompleted";
|
||||||
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
|
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
|
||||||
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
|
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
|
||||||
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
|
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
|
||||||
|
|||||||
+10
-5
@@ -1,14 +1,14 @@
|
|||||||
package com.baiyue.common.event;
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
|
/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
|
||||||
public record FirstLegContainerShippingNodeRecordedV1(
|
public record FirstLegContainerShippingNodeRecordedV1(
|
||||||
String queryId,
|
String queryId,
|
||||||
String customerId,
|
|
||||||
String oceanBookingId,
|
String oceanBookingId,
|
||||||
String containerBookingId,
|
@JsonProperty("sourceContainerBookingId") String containerBookingId,
|
||||||
String containerNo,
|
String containerNo,
|
||||||
String standardNode,
|
String standardNode,
|
||||||
int nodeSequence,
|
int nodeSequence,
|
||||||
@@ -28,12 +28,17 @@ public record FirstLegContainerShippingNodeRecordedV1(
|
|||||||
"EMPTY_RETURNED", 100);
|
"EMPTY_RETURNED", 100);
|
||||||
|
|
||||||
public FirstLegContainerShippingNodeRecordedV1 {
|
public FirstLegContainerShippingNodeRecordedV1 {
|
||||||
if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank()
|
if (queryId != null && queryId.isBlank()) {
|
||||||
|| oceanBookingId == null || oceanBookingId.isBlank()
|
throw new IllegalArgumentException("船节点跟踪任务标识不能为空字符串");
|
||||||
|| containerBookingId == null || containerBookingId.isBlank()
|
}
|
||||||
|
if (oceanBookingId != null && oceanBookingId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("船节点订舱标识不能为空字符串");
|
||||||
|
}
|
||||||
|
if (containerBookingId == null || containerBookingId.isBlank()
|
||||||
|| containerNo == null || containerNo.isBlank()) {
|
|| containerNo == null || containerNo.isBlank()) {
|
||||||
throw new IllegalArgumentException("船节点交接业务标识不能为空");
|
throw new IllegalArgumentException("船节点交接业务标识不能为空");
|
||||||
}
|
}
|
||||||
|
containerNo = ContainerNumbers.requireCanonical(containerNo);
|
||||||
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|
||||||
|| observedAt == null || actualAt == null) {
|
|| observedAt == null || actualAt == null) {
|
||||||
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
|
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
|
||||||
|
|||||||
+38
-6
@@ -7,7 +7,14 @@ import java.util.List;
|
|||||||
public record FirstLegLoadingPlanCompletedV1(
|
public record FirstLegLoadingPlanCompletedV1(
|
||||||
String loadingPlanId, String planNo, String containerBookingId,
|
String loadingPlanId, String planNo, String containerBookingId,
|
||||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||||
|
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
|
||||||
List<Order> orders) {
|
List<Order> orders) {
|
||||||
|
public FirstLegLoadingPlanCompletedV1(String loadingPlanId, String planNo, String containerBookingId,
|
||||||
|
String containerNo, String cabinetNo, String warehouseId,
|
||||||
|
String warehouseName, List<Order> orders) {
|
||||||
|
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
|
||||||
|
warehouseName, null, null, null, orders);
|
||||||
|
}
|
||||||
|
|
||||||
public record Order(
|
public record Order(
|
||||||
String sourceInboundId, String rootInboundId,
|
String sourceInboundId, String rootInboundId,
|
||||||
@@ -16,10 +23,17 @@ public record FirstLegLoadingPlanCompletedV1(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
|
BigDecimal volumeWeightKg,
|
||||||
String destinationType) {
|
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
|
||||||
|
String destinationType, String lxCode) {
|
||||||
public Order {
|
public Order {
|
||||||
items = items == null ? List.of() : items;
|
items = items == null ? List.of() : items;
|
||||||
|
if (customerId == null || customerId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("customerId is required");
|
||||||
|
}
|
||||||
|
if (customerName == null || customerName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("customerName is required");
|
||||||
|
}
|
||||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||||
pallets = pallets == null ? List.of() : pallets;
|
pallets = pallets == null ? List.of() : pallets;
|
||||||
}
|
}
|
||||||
@@ -29,11 +43,13 @@ public record FirstLegLoadingPlanCompletedV1(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
List<Item> items, String customerId, String customerName,
|
||||||
|
String orderType, List<PalletSnapshot> pallets) {
|
||||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
|
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
|
||||||
|
orderType, pallets, null, null);
|
||||||
}
|
}
|
||||||
public Order(String sourceInboundId, String rootInboundId,
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
@@ -41,11 +57,27 @@ public record FirstLegLoadingPlanCompletedV1(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
List<Item> items) {
|
List<Item> items, String customerId, String customerName,
|
||||||
|
String orderType, List<PalletSnapshot> pallets, String destinationType) {
|
||||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
|
||||||
|
orderType, pallets, destinationType, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
|
String inboundCode, String destinationWarehouseId,
|
||||||
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
|
BigDecimal volumeWeightKg, List<Item> items, String customerId, String customerName,
|
||||||
|
String orderType, List<PalletSnapshot> pallets, String destinationType) {
|
||||||
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
|
inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
|
||||||
|
packageCount, productQuantity, palletCount, grossWeightKg, netWeightKg, volumeCbm,
|
||||||
|
volumeWeightKg, items, customerId, customerName, orderType, pallets, destinationType, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+83
-12
@@ -1,13 +1,38 @@
|
|||||||
package com.baiyue.common.event;
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */
|
/** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */
|
||||||
public record FirstLegLoadingPlanCompletedV2(
|
public record FirstLegLoadingPlanCompletedV2(
|
||||||
String loadingPlanId, String planNo, String containerBookingId,
|
String loadingPlanId, String planNo,
|
||||||
|
@JsonProperty("sourceContainerBookingId") String containerBookingId,
|
||||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||||
|
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
|
||||||
List<Order> orders) {
|
List<Order> orders) {
|
||||||
|
public FirstLegLoadingPlanCompletedV2 {
|
||||||
|
if (loadingPlanId == null || loadingPlanId.isBlank()
|
||||||
|
|| containerBookingId == null || containerBookingId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("loadingPlanId and sourceContainerBookingId are required");
|
||||||
|
}
|
||||||
|
containerNo = ContainerNumbers.requireCanonical(containerNo);
|
||||||
|
orders = orders == null ? List.of() : List.copyOf(orders);
|
||||||
|
if (orders.isEmpty()) throw new IllegalArgumentException("orders are required");
|
||||||
|
Order owner = orders.get(0);
|
||||||
|
if (orders.stream().anyMatch(order -> !owner.customerId().equals(order.customerId())
|
||||||
|
|| !owner.customerName().equals(order.customerName()))) {
|
||||||
|
throw new IllegalArgumentException("orders must share one immutable customerId/customerName snapshot");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId,
|
||||||
|
String containerNo, String cabinetNo, String warehouseId,
|
||||||
|
String warehouseName, List<Order> orders) {
|
||||||
|
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
|
||||||
|
warehouseName, null, null, null, orders);
|
||||||
|
}
|
||||||
|
|
||||||
public record Order(
|
public record Order(
|
||||||
String sourceInboundId, String rootInboundId,
|
String sourceInboundId, String rootInboundId,
|
||||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
@@ -15,11 +40,23 @@ public record FirstLegLoadingPlanCompletedV2(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
|
BigDecimal volumeWeightKg,
|
||||||
|
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
|
||||||
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
|
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
|
||||||
String destinationType) {
|
String destinationType, String lxCode) {
|
||||||
public Order {
|
public Order {
|
||||||
items = items == null ? List.of() : items;
|
items = items == null ? List.of() : items;
|
||||||
|
if (customerId == null || customerId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("customerId is required");
|
||||||
|
}
|
||||||
|
if (customerName == null || customerName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("customerName is required");
|
||||||
|
}
|
||||||
|
customerId = customerId.trim();
|
||||||
|
customerName = customerName.trim();
|
||||||
|
if (customerId.length() > 128 || customerName.length() > 128) {
|
||||||
|
throw new IllegalArgumentException("customerId/customerName snapshot is too long");
|
||||||
|
}
|
||||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||||
pallets = pallets == null ? List.of() : pallets;
|
pallets = pallets == null ? List.of() : pallets;
|
||||||
}
|
}
|
||||||
@@ -30,11 +67,13 @@ public record FirstLegLoadingPlanCompletedV2(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
List<Item> items, String customerId, String customerName,
|
||||||
|
String orderType, List<PalletSnapshot> pallets) {
|
||||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
|
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
|
||||||
|
orderType, pallets, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Order(String sourceInboundId, String rootInboundId,
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
@@ -43,11 +82,27 @@ public record FirstLegLoadingPlanCompletedV2(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
List<Item> items) {
|
List<Item> items, String customerId, String customerName,
|
||||||
|
String orderType, List<PalletSnapshot> pallets, String destinationType) {
|
||||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
|
||||||
|
orderType, pallets, destinationType, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
|
String inboundCode, String destinationWarehouseId,
|
||||||
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
|
BigDecimal volumeWeightKg, List<Item> items, String customerId, String customerName,
|
||||||
|
String orderType, List<PalletSnapshot> pallets, String destinationType) {
|
||||||
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
|
inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
|
||||||
|
packageCount, productQuantity, palletCount, grossWeightKg, netWeightKg, volumeCbm,
|
||||||
|
volumeWeightKg, items, customerId, customerName, orderType, pallets, destinationType, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,11 +141,27 @@ public record FirstLegLoadingPlanCompletedV2(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */
|
/** 普通 FBA 只复用 palletNo;一件代发还携带同一物理 DP 的完整清单摘要。 */
|
||||||
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
|
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages,
|
||||||
|
Integer manifestVersion, Integer manifestPackageCount,
|
||||||
|
Integer manifestFragmentCount, String manifestHash) {
|
||||||
|
public PalletSnapshot {
|
||||||
|
skuPackages = skuPackages == null ? List.of() : List.copyOf(skuPackages);
|
||||||
|
}
|
||||||
|
|
||||||
/** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */
|
public PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {
|
||||||
public record SkuPackageSnapshot(String skuCode, String productName,
|
this(palletNo, skuPackages, null, null, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 托盘下来源入库明细、SKU、箱数、数量及箱号冻结快照;同 SKU 多明细按 sourceInboundDetailId 精确区分。 */
|
||||||
|
public record SkuPackageSnapshot(String sourceInboundDetailId, String skuCode, String productName,
|
||||||
int packageCount, int productQuantity,
|
int packageCount, int productQuantity,
|
||||||
List<String> cartonMarks) {}
|
List<String> cartonMarks) {
|
||||||
|
public SkuPackageSnapshot {
|
||||||
|
if (sourceInboundDetailId == null || sourceInboundDetailId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("sourceInboundDetailId is required for each pallet SKU snapshot");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -1,6 +1,7 @@
|
|||||||
package com.baiyue.common.event;
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/** IAM 向海外服务发布的 V1 安全投影事件负载。 */
|
/** IAM 向海外服务发布的 V1 安全投影事件负载。 */
|
||||||
public final class IamSecurityEventsV1 {
|
public final class IamSecurityEventsV1 {
|
||||||
@@ -14,5 +15,11 @@ public final class IamSecurityEventsV1 {
|
|||||||
public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { }
|
public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { }
|
||||||
|
|
||||||
public record PermissionVersionChanged(long authUserId, long tenantId, String appCode,
|
public record PermissionVersionChanged(long authUserId, long tenantId, String appCode,
|
||||||
long permissionVersion, String reason, Instant changedAt) { }
|
long permissionVersion, String reason, Instant changedAt,
|
||||||
|
Set<String> roles, Set<String> authorities) {
|
||||||
|
public PermissionVersionChanged {
|
||||||
|
roles = roles == null ? Set.of() : Set.copyOf(roles);
|
||||||
|
authorities = authorities == null ? Set.of() : Set.copyOf(authorities);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -13,4 +13,10 @@ public record OverseasContainerUnloadedV1(
|
|||||||
Instant unloadingPhotoUploadedAt,
|
Instant unloadingPhotoUploadedAt,
|
||||||
Instant actualEmptyReportedAt
|
Instant actualEmptyReportedAt
|
||||||
) {
|
) {
|
||||||
|
public OverseasContainerUnloadedV1 {
|
||||||
|
if (sourceContainerBookingId == null || sourceContainerBookingId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("sourceContainerBookingId is required");
|
||||||
|
}
|
||||||
|
containerNo = ContainerNumbers.requireCanonical(containerNo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** 国内 Finance 对海外资金指令的最终、可幂等投影结果。 */
|
||||||
|
public record WalletCommandCompletedV1(
|
||||||
|
String commandEventId,
|
||||||
|
String operation,
|
||||||
|
long customerId,
|
||||||
|
String businessType,
|
||||||
|
String businessNo,
|
||||||
|
String result,
|
||||||
|
String holdStatus,
|
||||||
|
BigDecimal amount,
|
||||||
|
String errorCode,
|
||||||
|
String errorMessage,
|
||||||
|
Instant completedAt
|
||||||
|
) {
|
||||||
|
public static final int VERSION = 1;
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** 海外业务向国内 Finance 发起的客户资金冻结、结算或释放指令。 */
|
||||||
|
public record WalletCommandRequestedV1(
|
||||||
|
String operation,
|
||||||
|
long customerId,
|
||||||
|
String businessType,
|
||||||
|
String businessNo,
|
||||||
|
BigDecimal amount,
|
||||||
|
String remark,
|
||||||
|
String idempotencyKey,
|
||||||
|
Instant requestedAt
|
||||||
|
) {
|
||||||
|
public static final int VERSION = 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Baiyue Cross-region Event Contracts
|
||||||
|
version: 1.0.0
|
||||||
|
paths: {}
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
FirstLegLoadingPlanCompletedV2:
|
||||||
|
type: object
|
||||||
|
required: [loadingPlanId, sourceContainerBookingId, containerNo, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders]
|
||||||
|
properties:
|
||||||
|
loadingPlanId: {type: string}
|
||||||
|
sourceContainerBookingId: {type: string, minLength: 1, description: Immutable Firstleg container booking identity}
|
||||||
|
containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number frozen before loading completion publication}
|
||||||
|
cabinetNo: {type: string}
|
||||||
|
warehouseId: {type: string}
|
||||||
|
warehouseName: {type: string}
|
||||||
|
estimatedPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated port arrival; null when Firstleg has no fact}
|
||||||
|
actualPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local actual port arrival; never copied from an estimate}
|
||||||
|
estimatedWarehouseArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated warehouse arrival; null when Firstleg has no fact}
|
||||||
|
orders:
|
||||||
|
type: array
|
||||||
|
items: {$ref: '#/components/schemas/FirstLegLoadingPlanCompletedOrderV2'}
|
||||||
|
FirstLegLoadingPlanCompletedOrderV2:
|
||||||
|
type: object
|
||||||
|
required: [sourceInboundId, customerId, customerName, palletCount, packageCount, productQuantity, volumeCbm, volumeWeightKg, items, orderType, pallets, destinationType]
|
||||||
|
properties:
|
||||||
|
sourceInboundId: {type: string}
|
||||||
|
sourceOrderId: {type: string}
|
||||||
|
sourceOrderNo: {type: string}
|
||||||
|
inboundCode: {type: string}
|
||||||
|
customerId: {type: string, minLength: 1, description: Trusted customer identity frozen by Firstleg}
|
||||||
|
customerName: {type: string, minLength: 1, maxLength: 128, description: Immutable trusted container/customer name snapshot; consumers must not query domestic IAM}
|
||||||
|
packageCount: {type: integer, minimum: 1}
|
||||||
|
productQuantity: {type: [integer, 'null'], minimum: 0, description: Final loaded product quantity; null when unavailable}
|
||||||
|
palletCount: {type: [integer, 'null'], minimum: 1, description: Distinct physical pallet count from Firstleg allocation pallet identity; never a planned zero placeholder}
|
||||||
|
volumeCbm: {type: [number, 'null'], minimum: 0, description: Final loaded volume in cubic metres}
|
||||||
|
volumeWeightKg: {type: [number, 'null'], minimum: 0, description: Sum of final loaded item dimensional weight facts; null when unavailable}
|
||||||
|
items: {type: array, items: {type: object}}
|
||||||
|
orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]}
|
||||||
|
pallets: {type: array, items: {$ref: '#/components/schemas/FirstLegPalletSnapshotV2'}}
|
||||||
|
destinationType: {type: string, enum: [FBA, OVERSEAS]}
|
||||||
|
FirstLegContainerShippingNodeRecordedV1:
|
||||||
|
type: object
|
||||||
|
required: [sourceContainerBookingId, containerNo, standardNode, nodeSequence, observedAt, actualAt, providerCode, providerEventId, providerNodeCode, rawPayloadHash]
|
||||||
|
properties:
|
||||||
|
queryId: {type: [string, 'null'], description: Automatic tracking task ID; null for a direct platform manual node.}
|
||||||
|
oceanBookingId: {type: [string, 'null'], description: Ocean booking ID when the physical container has already been linked; null is valid for a direct manual node.}
|
||||||
|
sourceContainerBookingId: {type: string, minLength: 1}
|
||||||
|
containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number; never cabinetNo or a technical identity}
|
||||||
|
standardNode: {type: string}
|
||||||
|
nodeSequence: {type: integer}
|
||||||
|
observedAt: {type: string, format: date-time}
|
||||||
|
actualAt: {type: string, format: date-time}
|
||||||
|
providerCode: {type: string}
|
||||||
|
providerEventId: {type: string}
|
||||||
|
providerNodeCode: {type: string}
|
||||||
|
rawPayloadHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'}
|
||||||
|
FirstLegPalletSnapshotV2:
|
||||||
|
type: object
|
||||||
|
required: [palletNo, skuPackages]
|
||||||
|
properties:
|
||||||
|
palletNo: {type: string, minLength: 1}
|
||||||
|
skuPackages:
|
||||||
|
type: array
|
||||||
|
items: {$ref: '#/components/schemas/FirstLegSkuPackageSnapshotV2'}
|
||||||
|
FirstLegSkuPackageSnapshotV2:
|
||||||
|
type: object
|
||||||
|
required: [sourceInboundDetailId, skuCode, productName, packageCount, productQuantity, cartonMarks]
|
||||||
|
properties:
|
||||||
|
sourceInboundDetailId: {type: string, minLength: 1, description: Exact source inbound detail identity matching the order item; never infer from SKU because multiple details may share a SKU}
|
||||||
|
skuCode: {type: string}
|
||||||
|
productName: {type: [string, 'null']}
|
||||||
|
packageCount: {type: integer, minimum: 1}
|
||||||
|
productQuantity: {type: integer, minimum: 1}
|
||||||
|
cartonMarks: {type: array, items: {type: string}, minItems: 1}
|
||||||
|
OverseasContainerUnloadedV1:
|
||||||
|
type: object
|
||||||
|
required: [sourceContainerBookingId, containerNo, unloadingCompletedAt, unloadingPhotoUploadedAt]
|
||||||
|
properties:
|
||||||
|
sourceContainerBookingId: {type: string, minLength: 1}
|
||||||
|
containerNo: {type: string, minLength: 1, description: Must equal the immutable normalized Firstleg container number; the CN consumer never overwrites it}
|
||||||
|
unloadingCompletedAt: {type: string, format: date-time}
|
||||||
|
unloadingPhotoUploadedAt: {type: string, format: date-time}
|
||||||
|
actualEmptyReportedAt: {type: [string, 'null'], format: date-time}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class ContainerNumbersTest {
|
||||||
|
@Test
|
||||||
|
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactIso6346Value() {
|
||||||
|
assertThat(ContainerNumbers.normalizeRequired(" zcsu 7881970 ")).isEqualTo("ZCSU7881970");
|
||||||
|
assertThat(ContainerNumbers.requireCanonical("ZCSU7881970")).isEqualTo("ZCSU7881970");
|
||||||
|
assertThatThrownBy(() -> ContainerNumbers.requireCanonical("zcsu7881970"))
|
||||||
|
.hasMessageContaining("normalized");
|
||||||
|
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("ZCSU7881971"))
|
||||||
|
.hasMessageContaining("check digit");
|
||||||
|
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("CAB-260831-0001"))
|
||||||
|
.hasMessageContaining("ISO 6346");
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-3
@@ -21,18 +21,43 @@ class FirstLegContainerShippingNodeRecordedV1Test {
|
|||||||
assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003");
|
assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003");
|
||||||
assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
|
assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
|
||||||
assertThat(restored.nodeSequence()).isEqualTo(80);
|
assertThat(restored.nodeSequence()).isEqualTo(80);
|
||||||
|
var json = mapper.valueToTree(payload);
|
||||||
|
assertThat(json.path("sourceContainerBookingId").asText()).isEqualTo("2088000000000000003");
|
||||||
|
assertThat(json.has("containerBookingId")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNonCanonicalPhysicalContainerNumber() {
|
||||||
|
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
||||||
|
"1", "3", "4", " zcsu7881970 ", "PICKUP_READY", 80,
|
||||||
|
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
|
||||||
|
null, null, "ZIM", "evt", "AVAILABLE", "ZIM:evt", "a".repeat(64)))
|
||||||
|
.hasMessageContaining("normalized");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsDirectManualNodeWithoutTrackingQuery() {
|
||||||
|
var payload = payload();
|
||||||
|
var manual = new FirstLegContainerShippingNodeRecordedV1(
|
||||||
|
null, null, payload.containerBookingId(), payload.containerNo(),
|
||||||
|
payload.standardNode(), payload.nodeSequence(), payload.observedAt(), payload.actualAt(),
|
||||||
|
payload.locationCode(), payload.locationName(), "MANUAL", "manual-event", payload.providerNodeCode(),
|
||||||
|
"MANUAL:7", payload.rawPayloadHash());
|
||||||
|
|
||||||
|
assertThat(manual.queryId()).isNull();
|
||||||
|
assertThat(manual.oceanBookingId()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsEstimatedOnlyOrIncompleteProviderFacts() {
|
void rejectsEstimatedOnlyOrIncompleteProviderFacts() {
|
||||||
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
||||||
"1", "2", "3", "4", "ZCSU7881970", "PICKUP_READY", 80,
|
"1", "3", "4", "ZCSU7881970", "PICKUP_READY", 80,
|
||||||
Instant.parse("2026-08-25T01:00:00Z"), null, null, null,
|
Instant.parse("2026-08-25T01:00:00Z"), null, null, null,
|
||||||
"ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64)))
|
"ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64)))
|
||||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间");
|
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间");
|
||||||
|
|
||||||
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
||||||
"1", "2", "3", "4", "ZCSU7881970", "ARRIVED", 60,
|
"1", "3", "4", "ZCSU7881970", "ARRIVED", 60,
|
||||||
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
|
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
|
||||||
null, null, "ZIM", "evt-2", "ARRIVED", "ZIM:evt-2", "b".repeat(64)))
|
null, null, "ZIM", "evt-2", "ARRIVED", "ZIM:evt-2", "b".repeat(64)))
|
||||||
.isInstanceOf(IllegalArgumentException.class);
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
@@ -40,7 +65,7 @@ class FirstLegContainerShippingNodeRecordedV1Test {
|
|||||||
|
|
||||||
private static FirstLegContainerShippingNodeRecordedV1 payload() {
|
private static FirstLegContainerShippingNodeRecordedV1 payload() {
|
||||||
return new FirstLegContainerShippingNodeRecordedV1(
|
return new FirstLegContainerShippingNodeRecordedV1(
|
||||||
"2088000000000000001", "2088000000000000002", "2088000000000000004",
|
"2088000000000000001", "2088000000000000004",
|
||||||
"2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80,
|
"2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80,
|
||||||
Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
|
Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
|
||||||
"USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE",
|
"USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE",
|
||||||
|
|||||||
+12
-4
@@ -19,7 +19,7 @@ class FirstLegLoadingPlanCompletedV1Test {
|
|||||||
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
|
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
|
||||||
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
|
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
|
||||||
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"),
|
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"),
|
||||||
new BigDecimal("0.200"), List.of(item));
|
new BigDecimal("0.200"), List.of(item), "9001", "客户甲", "FIRST_LEG", List.of());
|
||||||
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
|
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
|
||||||
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
|
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
|
||||||
String json = mapper.writeValueAsString(payload);
|
String json = mapper.writeValueAsString(payload);
|
||||||
@@ -35,7 +35,7 @@ class FirstLegLoadingPlanCompletedV1Test {
|
|||||||
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
|
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
|
||||||
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
|
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
|
||||||
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE,
|
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE,
|
||||||
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP",
|
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "客户甲", "ONE_CLICK_DROPSHIP",
|
||||||
List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
|
List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
|
||||||
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
|
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
|
||||||
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
|
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
|
||||||
@@ -52,12 +52,20 @@ class FirstLegLoadingPlanCompletedV1Test {
|
|||||||
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
|
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
|
||||||
String json = """
|
String json = """
|
||||||
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
|
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
|
||||||
"warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100",
|
"warehouseId":"3","warehouseName":"海外仓","estimatedPortArrivalAt":"2026-09-30T12:00",
|
||||||
"destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]}
|
"actualPortArrivalAt":null,"estimatedWarehouseArrivalAt":"2026-10-03T09:00",
|
||||||
|
"orders":[{"sourceInboundId":"100","destinationWarehouseId":"3","packageCount":1,
|
||||||
|
"palletCount":2,"volumeWeightKg":12.500,"items":[],"customerId":"9001",
|
||||||
|
"customerName":"客户甲","destinationType":"OVERSEAS"}]}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
|
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
|
||||||
|
|
||||||
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS");
|
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS");
|
||||||
|
assertThat(restored.estimatedPortArrivalAt()).isEqualTo("2026-09-30T12:00");
|
||||||
|
assertThat(restored.actualPortArrivalAt()).isNull();
|
||||||
|
assertThat(restored.estimatedWarehouseArrivalAt()).isEqualTo("2026-10-03T09:00");
|
||||||
|
assertThat(restored.orders().get(0).palletCount()).isEqualTo(2);
|
||||||
|
assertThat(restored.orders().get(0).volumeWeightKg()).isEqualByComparingTo("12.500");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-9
@@ -4,18 +4,74 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
class FirstLegLoadingPlanCompletedV2Test {
|
class FirstLegLoadingPlanCompletedV2Test {
|
||||||
|
@Test
|
||||||
|
void palletSkuRequiresExplicitSourceDetailIncludingJsonDeserialization() throws Exception {
|
||||||
|
var mapper = new ObjectMapper();
|
||||||
|
var first = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SAME", "产品", 1, 10, List.of("BOX-101"));
|
||||||
|
var second = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("102", "SAME", "产品", 1, 20, List.of("BOX-102"));
|
||||||
|
assertThat(mapper.readValue(mapper.writeValueAsString(first), FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot.class))
|
||||||
|
.isEqualTo(first).isNotEqualTo(second);
|
||||||
|
for (String invalid : new String[]{null, "", " "}) {
|
||||||
|
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(invalid, "SAME", "产品", 1, 10, List.of("BOX-101")))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("sourceInboundDetailId");
|
||||||
|
}
|
||||||
|
assertThatThrownBy(() -> mapper.readValue("{\"skuCode\":\"SAME\",\"packageCount\":1,\"productQuantity\":10,\"cartonMarks\":[\"BOX-101\"]}",
|
||||||
|
FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot.class)).hasMessageContaining("sourceInboundDetailId");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void carriesOnlyFrozenContainerTimesAndActualOrderAggregates() {
|
||||||
|
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
|
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8",
|
||||||
|
12, 120, 3, null, null, new BigDecimal("2.500"), new BigDecimal("18.750"),
|
||||||
|
List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "OVERSEAS");
|
||||||
|
var payload = new FirstLegLoadingPlanCompletedV2(
|
||||||
|
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH",
|
||||||
|
"2026-09-30T12:00", null, "2026-10-03T09:00", List.of(order));
|
||||||
|
|
||||||
|
var json = new ObjectMapper().valueToTree(payload);
|
||||||
|
|
||||||
|
assertThat(json.at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00");
|
||||||
|
assertThat(json.at("/actualPortArrivalAt").isNull()).isTrue();
|
||||||
|
assertThat(json.at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
|
||||||
|
assertThat(json.at("/sourceContainerBookingId").asText()).isEqualTo("5");
|
||||||
|
assertThat(json.has("containerBookingId")).isFalse();
|
||||||
|
assertThat(json.at("/orders/0/palletCount").asInt()).isEqualTo(3);
|
||||||
|
assertThat(json.at("/orders/0/packageCount").asInt()).isEqualTo(12);
|
||||||
|
assertThat(json.at("/orders/0/productQuantity").asInt()).isEqualTo(120);
|
||||||
|
assertThat(json.at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2.500");
|
||||||
|
assertThat(json.at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("18.750");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNonCanonicalOrMissingPhysicalContainerIdentity() {
|
||||||
|
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
|
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8",
|
||||||
|
1, 1, null, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of());
|
||||||
|
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
|
||||||
|
"4", "P", "5", " zcsu7881970 ", "CAB", "6", "WH", List.of(order)))
|
||||||
|
.hasMessageContaining("normalized");
|
||||||
|
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
|
||||||
|
"4", "P", "5", " ", "CAB", "6", "WH", List.of(order)))
|
||||||
|
.hasMessageContaining("containerNo");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void carriesTheThreeItemRemarks() throws Exception {
|
void carriesTheThreeItemRemarks() throws Exception {
|
||||||
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
|
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
|
||||||
2, null, null, null, null, null, null, null, null, null, null, null,
|
2, null, null, null, null, null, null, null, null, null, null, null,
|
||||||
"客户备注", "内部备注", "仓库备注");
|
"客户备注", "内部备注", "仓库备注");
|
||||||
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
||||||
null, null, null, 1, 2, null, null, null, null, List.of(item));
|
null, null, null, 1, 2, null, null, null, null, List.of(item),
|
||||||
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
|
"11", "客户甲", "FIRST_LEG", List.of());
|
||||||
|
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order));
|
||||||
String json = new ObjectMapper().writeValueAsString(payload);
|
String json = new ObjectMapper().writeValueAsString(payload);
|
||||||
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
|
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
|
||||||
assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText())
|
assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText())
|
||||||
@@ -25,29 +81,31 @@ class FirstLegLoadingPlanCompletedV2Test {
|
|||||||
@Test
|
@Test
|
||||||
void carriesDropshipPalletAndCartonSnapshots() throws Exception {
|
void carriesDropshipPalletAndCartonSnapshots() throws Exception {
|
||||||
var sku = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(
|
var sku = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(
|
||||||
"SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002"));
|
"101", "SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002"));
|
||||||
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
|
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
|
||||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
||||||
2, 20, 1, null, null, null, List.of(), "11", "ONE_CLICK_DROPSHIP", List.of(pallet));
|
2, 20, 1, null, null, null, List.of(), "11", "客户甲", "ONE_CLICK_DROPSHIP", List.of(pallet));
|
||||||
var payload = new FirstLegLoadingPlanCompletedV2(
|
var payload = new FirstLegLoadingPlanCompletedV2(
|
||||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
|
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order));
|
||||||
|
|
||||||
var json = new ObjectMapper().valueToTree(payload);
|
var json = new ObjectMapper().valueToTree(payload);
|
||||||
|
|
||||||
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11");
|
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11");
|
||||||
|
assertThat(json.at("/orders/0/customerName").asText()).isEqualTo("客户甲");
|
||||||
assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
|
assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
|
||||||
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
|
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
|
||||||
|
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/sourceInboundDetailId").asText()).isEqualTo("101");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void carriesFrozenDestinationType() {
|
void carriesFrozenDestinationType() {
|
||||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
|
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
|
||||||
2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA");
|
2, 20, 0, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "FBA");
|
||||||
|
|
||||||
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order)));
|
||||||
|
|
||||||
assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA");
|
assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA");
|
||||||
}
|
}
|
||||||
@@ -58,12 +116,55 @@ class FirstLegLoadingPlanCompletedV2Test {
|
|||||||
20, null, null, null, null, null, null, null, null, null, null, null,
|
20, null, null, null, null, null, null, null, null, null, null, null,
|
||||||
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
|
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
|
||||||
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
||||||
null, null, null, 2, 20, null, null, null, null, List.of(item));
|
null, null, null, 2, 20, null, null, null, null, List.of(item),
|
||||||
|
"11", "客户甲", "FIRST_LEG", List.of());
|
||||||
|
|
||||||
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order)));
|
||||||
|
|
||||||
assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2");
|
assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2");
|
||||||
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2);
|
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingImmutableCustomerName() {
|
||||||
|
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
|
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
||||||
|
1, 1, 0, null, null, null, List.of(), "11", " ", "FIRST_LEG", List.of()))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("customerName");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void freezesTrimmedOwnerSnapshotAndRejectsMixedOwners() {
|
||||||
|
var first = new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
|
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
||||||
|
1, 1, 0, null, null, null, List.of(), " 11 ", " 客户甲 ", "FIRST_LEG", List.of());
|
||||||
|
assertThat(first.customerId()).isEqualTo("11");
|
||||||
|
assertThat(first.customerName()).isEqualTo("客户甲");
|
||||||
|
|
||||||
|
var anotherOwner = new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
|
"3", "3", "4", "ORD-2", "4", "RK-2", null, null, null,
|
||||||
|
1, 1, 0, null, null, null, List.of(), "12", "客户乙", "FIRST_LEG", List.of());
|
||||||
|
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
|
||||||
|
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(first, anotherOwner)))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("immutable customerId/customerName");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openApiRequiresImmutableCustomerNameNextToCustomerId() throws Exception {
|
||||||
|
String yaml;
|
||||||
|
try (var input = FirstLegLoadingPlanCompletedV2Test.class.getResourceAsStream(
|
||||||
|
"/openapi/cross-region-events-v1.yaml")) {
|
||||||
|
assertThat(input).isNotNull();
|
||||||
|
yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
assertThat(yaml)
|
||||||
|
.contains("required: [sourceInboundId, customerId, customerName")
|
||||||
|
.contains("customerId: {type: string, minLength: 1")
|
||||||
|
.contains("customerName: {type: string, minLength: 1, maxLength: 128")
|
||||||
|
.contains("consumers must not query domestic IAM")
|
||||||
|
.contains("estimatedPortArrivalAt", "actualPortArrivalAt", "estimatedWarehouseArrivalAt")
|
||||||
|
.contains("palletCount", "volumeWeightKg", "never a planned zero placeholder");
|
||||||
|
assertThat(yaml).contains("sourceContainerBookingId", "Uppercase normalized physical container number");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -7,20 +7,28 @@ import org.junit.jupiter.api.Test;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
class OverseasContainerUnloadedV1Test {
|
class OverseasContainerUnloadedV1Test {
|
||||||
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception {
|
void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception {
|
||||||
var payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567",
|
var payload = new OverseasContainerUnloadedV1("1001", "ZCSU7881970",
|
||||||
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
|
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
|
||||||
|
|
||||||
String json = mapper.writeValueAsString(payload);
|
String json = mapper.writeValueAsString(payload);
|
||||||
|
|
||||||
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload);
|
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload);
|
||||||
assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt");
|
assertThat(json).contains("sourceContainerBookingId", "1001", "ZCSU7881970", "unloadingPhotoUploadedAt");
|
||||||
assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt());
|
assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt());
|
||||||
assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt());
|
assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsContainerNumberDriftBeforeDomesticConsumption() {
|
||||||
|
assertThatThrownBy(() -> new OverseasContainerUnloadedV1("1001", "zcsu7881970",
|
||||||
|
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null))
|
||||||
|
.hasMessageContaining("normalized");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -1,6 +1,7 @@
|
|||||||
package com.baiyue.common.security;
|
package com.baiyue.common.security;
|
||||||
|
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 访问令牌会话有效性校验扩展点,用于在 JWT 校验之外确认服务端登录会话状态。
|
* 访问令牌会话有效性校验扩展点,用于在 JWT 校验之外确认服务端登录会话状态。
|
||||||
@@ -8,4 +9,8 @@ import org.springframework.security.oauth2.jwt.Jwt;
|
|||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface AccessTokenSessionValidator {
|
public interface AccessTokenSessionValidator {
|
||||||
AccessTokenSession validate(Jwt jwt);
|
AccessTokenSession validate(Jwt jwt);
|
||||||
|
|
||||||
|
default AccessTokenSession validate(Jwt jwt, HttpServletRequest request) {
|
||||||
|
return validate(jwt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -50,9 +50,11 @@ public class BaiyueSecurityAutoConfiguration {
|
|||||||
org.springframework.beans.factory.ObjectProvider<StringRedisTemplate> redis,
|
org.springframework.beans.factory.ObjectProvider<StringRedisTemplate> redis,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix,
|
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix,
|
||||||
|
@Value("${baiyue.security.gateway-internal-service-token:${baiyue.security.internal-service-token:${INTERNAL_SERVICE_TOKEN:}}}") String gatewayToken,
|
||||||
@Value("${baiyue.security.session-mode:redis}") String sessionMode) {
|
@Value("${baiyue.security.session-mode:redis}") String sessionMode) {
|
||||||
AccessTokenSessionValidator validator;
|
AccessTokenSessionValidator validator;
|
||||||
if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator();
|
if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator();
|
||||||
|
else if ("gateway".equalsIgnoreCase(sessionMode)) validator = new GatewayAccessTokenSessionValidator(gatewayToken);
|
||||||
else {
|
else {
|
||||||
StringRedisTemplate template=redis.getIfAvailable();
|
StringRedisTemplate template=redis.getIfAvailable();
|
||||||
if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate");
|
if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate");
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package com.baiyue.common.security;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** Uses the overseas gateway's authenticated authorization snapshot instead of large JWT claims. */
|
||||||
|
public final class GatewayAccessTokenSessionValidator implements AccessTokenSessionValidator {
|
||||||
|
public static final String TOKEN_HEADER = "X-Internal-Service-Token";
|
||||||
|
public static final String ROLES_HEADER = "X-Overseas-Roles";
|
||||||
|
public static final String AUTHORITIES_HEADER = "X-Overseas-Authorities";
|
||||||
|
|
||||||
|
private final byte[] expectedToken;
|
||||||
|
|
||||||
|
public GatewayAccessTokenSessionValidator(String expectedToken) {
|
||||||
|
if (expectedToken == null || expectedToken.isBlank()) {
|
||||||
|
throw new IllegalStateException("session-mode=gateway requires an internal service token");
|
||||||
|
}
|
||||||
|
this.expectedToken = expectedToken.getBytes(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AccessTokenSession validate(Jwt jwt) {
|
||||||
|
throw new IllegalArgumentException("Gateway authorization headers are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AccessTokenSession validate(Jwt jwt, HttpServletRequest request) {
|
||||||
|
String supplied = request.getHeader(TOKEN_HEADER);
|
||||||
|
if (supplied == null || supplied.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Missing overseas gateway authorization context");
|
||||||
|
}
|
||||||
|
byte[] suppliedToken = supplied.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (!MessageDigest.isEqual(expectedToken, suppliedToken)) {
|
||||||
|
throw new IllegalArgumentException("Invalid overseas gateway authorization context (expectedBytes="
|
||||||
|
+ expectedToken.length + ", suppliedBytes=" + suppliedToken.length + ")");
|
||||||
|
}
|
||||||
|
Long userId = number(jwt.getClaims().getOrDefault("userId", jwt.getClaims().get("user_id")));
|
||||||
|
if (userId == null) {
|
||||||
|
userId = number(jwt.getSubject());
|
||||||
|
}
|
||||||
|
return new AccessTokenSession(1, userId, values(request.getHeader(ROLES_HEADER)),
|
||||||
|
values(request.getHeader(AUTHORITIES_HEADER)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long number(Object value) {
|
||||||
|
if (value instanceof Number number) return number.longValue();
|
||||||
|
try { return value == null ? null : Long.valueOf(value.toString()); }
|
||||||
|
catch (NumberFormatException exception) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> values(String header) {
|
||||||
|
if (header == null || header.isBlank()) return Set.of();
|
||||||
|
LinkedHashSet<String> values = new LinkedHashSet<>();
|
||||||
|
for (String value : header.split(",")) {
|
||||||
|
String normalized = value.trim();
|
||||||
|
if (!normalized.isEmpty()) values.add(normalized);
|
||||||
|
}
|
||||||
|
return Set.copyOf(values);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -12,6 +12,8 @@ import org.springframework.security.oauth2.jwt.JwtDecoder;
|
|||||||
import org.springframework.security.oauth2.jwt.JwtException;
|
import org.springframework.security.oauth2.jwt.JwtException;
|
||||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ import java.io.IOException;
|
|||||||
* Servlet JWT 认证过滤器,解析 Bearer 令牌、校验会话并建立当前请求的安全上下文。
|
* Servlet JWT 认证过滤器,解析 Bearer 令牌、校验会话并建立当前请求的安全上下文。
|
||||||
*/
|
*/
|
||||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
|
||||||
private static final String BEARER = "Bearer ";
|
private static final String BEARER = "Bearer ";
|
||||||
private final JwtDecoder jwtDecoder;
|
private final JwtDecoder jwtDecoder;
|
||||||
private final JwtClaimsMapper claimsMapper;
|
private final JwtClaimsMapper claimsMapper;
|
||||||
@@ -55,11 +58,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
String tokenValue = header.substring(BEARER.length()).trim();
|
String tokenValue = header.substring(BEARER.length()).trim();
|
||||||
try {
|
try {
|
||||||
Jwt jwt = jwtDecoder.decode(tokenValue);
|
Jwt jwt = jwtDecoder.decode(tokenValue);
|
||||||
AccessTokenSession session = sessionValidator.validate(jwt);
|
AccessTokenSession session = sessionValidator.validate(jwt, request);
|
||||||
Authentication authentication = claimsMapper.map(jwt, session);
|
Authentication authentication = claimsMapper.map(jwt, session);
|
||||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
} catch (JwtException | IllegalArgumentException exception) {
|
} catch (JwtException | IllegalArgumentException exception) {
|
||||||
|
LOG.warn("JWT authentication rejected for {}: {}", request.getRequestURI(), exception.getMessage());
|
||||||
SecurityContextHolder.clearContext();
|
SecurityContextHolder.clearContext();
|
||||||
authenticationEntryPoint.commence(request, response,
|
authenticationEntryPoint.commence(request, response,
|
||||||
new InvalidJwtAuthenticationException("Invalid or expired access token", exception));
|
new InvalidJwtAuthenticationException("Invalid or expired access token", exception));
|
||||||
|
|||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package com.baiyue.common.security;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class GatewayAccessTokenSessionValidatorTest {
|
||||||
|
private final GatewayAccessTokenSessionValidator validator =
|
||||||
|
new GatewayAccessTokenSessionValidator("gateway-secret");
|
||||||
|
|
||||||
|
@Test void acceptsOnlyGatewayAuthenticatedAuthorizationSnapshot() {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret");
|
||||||
|
request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN");
|
||||||
|
request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER,
|
||||||
|
"platform:overseas-warehouse:last-mile-order:list,platform:finance:fee-source:view");
|
||||||
|
|
||||||
|
AccessTokenSession session = validator.validate(jwt(), request);
|
||||||
|
|
||||||
|
assertThat(session.userId()).isEqualTo(42L);
|
||||||
|
assertThat(session.roles()).containsExactly("PLATFORM_ADMIN");
|
||||||
|
assertThat(session.permissions()).containsExactlyInAnyOrder(
|
||||||
|
"platform:overseas-warehouse:last-mile-order:list", "platform:finance:fee-source:view");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void rejectsForgedOrMissingGatewayToken() {
|
||||||
|
MockHttpServletRequest forged = new MockHttpServletRequest();
|
||||||
|
forged.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "forged");
|
||||||
|
forged.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER,
|
||||||
|
"platform:overseas-warehouse:last-mile-order:list");
|
||||||
|
assertThatThrownBy(() -> validator.validate(jwt(), forged))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Invalid overseas gateway");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> validator.validate(jwt(), new MockHttpServletRequest()))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Missing overseas gateway");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Jwt jwt() {
|
||||||
|
return Jwt.withTokenValue("small-overseas-token").header("alg", "RS256").subject("42")
|
||||||
|
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||||
|
.claim("tenant_id", 0L).claim("app_code", "PLATFORM")
|
||||||
|
.claim("permission_version", 7L).claim("token_version", 1).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -41,4 +41,34 @@ class JwtAuthenticationFilterTest {
|
|||||||
assertThat(invoked).isTrue();
|
assertThat(invoked).isTrue();
|
||||||
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
|
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void authenticatesMinimalOverseasTokenWithGatewaySnapshot() throws Exception {
|
||||||
|
Jwt jwt = Jwt.withTokenValue("overseas").header("alg", "RS256").subject("42")
|
||||||
|
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(60))
|
||||||
|
.claim("tenant_id", 0L).claim("app_code", "PLATFORM")
|
||||||
|
.claim("permission_version", 7L).claim("token_version", 1).build();
|
||||||
|
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(token -> jwt, new JwtClaimsMapper(),
|
||||||
|
new JsonAuthenticationEntryPoint(new ObjectMapper()),
|
||||||
|
new GatewayAccessTokenSessionValidator("gateway-secret"));
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.setRequestURI("/api/platform/v1/last-leg/orders");
|
||||||
|
request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer overseas");
|
||||||
|
request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret");
|
||||||
|
request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN");
|
||||||
|
request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, "platform:lastleg:view");
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
AtomicBoolean invoked = new AtomicBoolean();
|
||||||
|
|
||||||
|
filter.doFilter(request, response, (request1, response1) -> {
|
||||||
|
var authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
assertThat(authentication).isNotNull();
|
||||||
|
assertThat(authentication.getAuthorities()).extracting("authority")
|
||||||
|
.contains("APP_PLATFORM", "ROLE_PLATFORM_ADMIN", "platform:lastleg:view");
|
||||||
|
invoked.set(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertThat(invoked).isTrue();
|
||||||
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework</groupId>
|
||||||
|
<artifactId>spring-tx</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.security</groupId>
|
<groupId>org.springframework.security</groupId>
|
||||||
<artifactId>spring-security-core</artifactId>
|
<artifactId>spring-security-core</artifactId>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.springframework.security.core.AuthenticationException;
|
|||||||
import org.springframework.validation.BindException;
|
import org.springframework.validation.BindException;
|
||||||
import org.springframework.validation.FieldError;
|
import org.springframework.validation.FieldError;
|
||||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
|
import org.springframework.dao.CannotAcquireLockException;
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.MissingRequestHeaderException;
|
import org.springframework.web.bind.MissingRequestHeaderException;
|
||||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||||
@@ -23,6 +24,8 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
|
|||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||||
|
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -64,7 +67,8 @@ public class GlobalExceptionHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler({HttpMessageNotReadableException.class, MissingRequestHeaderException.class,
|
@ExceptionHandler({HttpMessageNotReadableException.class, MissingRequestHeaderException.class,
|
||||||
MissingServletRequestParameterException.class, MethodArgumentTypeMismatchException.class})
|
MissingServletRequestParameterException.class, MissingServletRequestPartException.class,
|
||||||
|
MethodArgumentTypeMismatchException.class})
|
||||||
public ResponseEntity<ApiResponse<Void>> handleBadRequest(Exception exception, HttpServletRequest request) {
|
public ResponseEntity<ApiResponse<Void>> handleBadRequest(Exception exception, HttpServletRequest request) {
|
||||||
return failure(ResultCode.BAD_REQUEST, ResultCode.BAD_REQUEST.message(), request);
|
return failure(ResultCode.BAD_REQUEST, ResultCode.BAD_REQUEST.message(), request);
|
||||||
}
|
}
|
||||||
@@ -96,6 +100,24 @@ public class GlobalExceptionHandler {
|
|||||||
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request);
|
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(CannotAcquireLockException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleCannotAcquireLock(
|
||||||
|
CannotAcquireLockException exception, HttpServletRequest request) {
|
||||||
|
log.warn("Database lock acquisition conflict requestId={} traceId={}",
|
||||||
|
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
|
||||||
|
return failure(ResultCode.CONFLICT, "资源正在处理中,请稍后重试", request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleMaxUploadSize(
|
||||||
|
MaxUploadSizeExceededException exception, HttpServletRequest request) {
|
||||||
|
long maxBytes = exception.getMaxUploadSize();
|
||||||
|
String message = maxBytes > 0
|
||||||
|
? "上传文件不能超过" + Math.max(1, (maxBytes + 1024 * 1024 - 1) / (1024 * 1024)) + "MB"
|
||||||
|
: "上传文件超过服务允许大小";
|
||||||
|
return failure(ResultCode.BAD_REQUEST, message, request);
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(Exception.class)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
|
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
|
||||||
log.error("Unhandled request failure requestId={} traceId={}",
|
log.error("Unhandled request failure requestId={} traceId={}",
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import org.springframework.web.servlet.resource.NoResourceFoundException;
|
|||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.dao.CannotAcquireLockException;
|
||||||
|
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||||
|
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
@@ -63,6 +66,17 @@ class GlobalExceptionHandlerTest {
|
|||||||
assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code());
|
assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingRequiredMultipartPartMapsToBadRequest() {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
|
||||||
|
var response = handler.handleBadRequest(
|
||||||
|
new MissingServletRequestPartException("file"), request);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void validationDetailsAreIncludedInMessageAndData() {
|
void validationDetailsAreIncludedInMessageAndData() {
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
@@ -116,6 +130,32 @@ class GlobalExceptionHandlerTest {
|
|||||||
assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code());
|
assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lockWaitTimeoutMapsToCanonicalConflict() {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.setAttribute(CorrelationContext.REQUEST_ID, "lock-timeout");
|
||||||
|
|
||||||
|
var response = handler.handleCannotAcquireLock(
|
||||||
|
new CannotAcquireLockException("Lock wait timeout exceeded"), request);
|
||||||
|
|
||||||
|
assertThat(response.getBody().code()).isEqualTo(ResultCode.CONFLICT.code());
|
||||||
|
assertThat(response.getBody().requestId()).isEqualTo("lock-timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oversizedMultipartReturnsClearBadRequestInsteadOfInternalError() {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.setAttribute(CorrelationContext.REQUEST_ID, "oversized-photo");
|
||||||
|
|
||||||
|
var response = handler.handleMaxUploadSize(
|
||||||
|
new MaxUploadSizeExceededException(10L * 1024 * 1024), request);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code());
|
||||||
|
assertThat(response.getBody().message()).isEqualTo("上传文件不能超过10MB");
|
||||||
|
assertThat(response.getBody().requestId()).isEqualTo("oversized-photo");
|
||||||
|
}
|
||||||
|
|
||||||
private void paging(long page) {
|
private void paging(long page) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,6 @@ spring:
|
|||||||
client.config.default:
|
client.config.default:
|
||||||
connect-timeout: 3000
|
connect-timeout: 3000
|
||||||
read-timeout: 5000
|
read-timeout: 5000
|
||||||
logging:
|
|
||||||
file:
|
|
||||||
path: ${LOG_PATH:/app/logs}
|
|
||||||
server:
|
server:
|
||||||
shutdown: graceful
|
shutdown: graceful
|
||||||
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
|
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
|
||||||
|
|||||||
@@ -11,3 +11,5 @@ baiyue:
|
|||||||
event-dispatcher:
|
event-dispatcher:
|
||||||
consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
|
consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
|
||||||
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
|
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
|
||||||
|
finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/business-fee-source-events}
|
||||||
|
finance-wallet-consumer-url: ${FINANCE_WALLET_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/wallet-command-events}
|
||||||
|
|||||||
@@ -22,6 +22,26 @@ baiyue:
|
|||||||
max-attempts: 3
|
max-attempts: 3
|
||||||
batch-size: 500
|
batch-size: 500
|
||||||
|
|
||||||
|
DOMESTIC_WAREHOUSE_ORDER_LIST:
|
||||||
|
provider-service: order-service
|
||||||
|
provider-path: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches
|
||||||
|
required-permission: platform:order:export
|
||||||
|
app-scope: PLATFORM
|
||||||
|
enabled: true
|
||||||
|
max-rows: 200000
|
||||||
|
max-attempts: 3
|
||||||
|
batch-size: 200
|
||||||
|
|
||||||
|
LOADING_LIST_BUY_DECLARATION_ITEMS:
|
||||||
|
provider-service: firstleg-service
|
||||||
|
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
|
||||||
|
required-permission: platform:firstleg:customs:manage
|
||||||
|
app-scope: PLATFORM
|
||||||
|
enabled: true
|
||||||
|
max-rows: 200000
|
||||||
|
max-attempts: 3
|
||||||
|
batch-size: 500
|
||||||
|
|
||||||
FINANCE_CUSTOMER_ACCOUNT_LIST:
|
FINANCE_CUSTOMER_ACCOUNT_LIST:
|
||||||
provider-service: finance-service
|
provider-service: finance-service
|
||||||
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
|
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
|
||||||
|
|||||||
@@ -5,3 +5,6 @@ spring:
|
|||||||
password: ${FINANCE_DB_PASSWORD}
|
password: ${FINANCE_DB_PASSWORD}
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||||
|
baiyue.finance.recharge.wechat-qr-code-url: ${FINANCE_RECHARGE_WECHAT_QR_CODE_URL:}
|
||||||
|
baiyue.finance.recharge.alipay-qr-code-url: ${FINANCE_RECHARGE_ALIPAY_QR_CODE_URL:}
|
||||||
|
baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ spring:
|
|||||||
username: ${FIRSTLEG_DB_USERNAME}
|
username: ${FIRSTLEG_DB_USERNAME}
|
||||||
password: ${FIRSTLEG_DB_PASSWORD}
|
password: ${FIRSTLEG_DB_PASSWORD}
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB}
|
||||||
|
max-request-size: ${FIRSTLEG_MULTIPART_MAX_REQUEST_SIZE:52MB}
|
||||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||||
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
||||||
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
||||||
@@ -17,6 +21,7 @@ baiyue:
|
|||||||
emc-automated: ${EMC_TRACKING_ENABLED:false}
|
emc-automated: ${EMC_TRACKING_ENABLED:false}
|
||||||
hede-automated: ${HEDE_TRACKING_ENABLED:false}
|
hede-automated: ${HEDE_TRACKING_ENABLED:false}
|
||||||
scheduler:
|
scheduler:
|
||||||
|
enabled: ${CARRIER_SCHEDULE_SCHEDULER_ENABLED:false}
|
||||||
zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai}
|
zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai}
|
||||||
scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M}
|
scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M}
|
||||||
scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S}
|
scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S}
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ spring:
|
|||||||
client.config.default:
|
client.config.default:
|
||||||
connect-timeout: 3000
|
connect-timeout: 3000
|
||||||
read-timeout: 5000
|
read-timeout: 5000
|
||||||
logging:
|
|
||||||
file:
|
|
||||||
path: ${LOG_PATH:./logs}
|
|
||||||
server:
|
server:
|
||||||
shutdown: graceful
|
shutdown: graceful
|
||||||
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
|
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
|
||||||
|
|||||||
@@ -18,6 +18,26 @@ baiyue:
|
|||||||
max-attempts: 3
|
max-attempts: 3
|
||||||
batch-size: 500
|
batch-size: 500
|
||||||
|
|
||||||
|
DOMESTIC_WAREHOUSE_ORDER_LIST:
|
||||||
|
provider-service: order-service
|
||||||
|
provider-path: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches
|
||||||
|
required-permission: platform:order:export
|
||||||
|
app-scope: PLATFORM
|
||||||
|
enabled: true
|
||||||
|
max-rows: 200000
|
||||||
|
max-attempts: 3
|
||||||
|
batch-size: 200
|
||||||
|
|
||||||
|
LOADING_LIST_BUY_DECLARATION_ITEMS:
|
||||||
|
provider-service: firstleg-service
|
||||||
|
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
|
||||||
|
required-permission: platform:firstleg:customs:manage
|
||||||
|
app-scope: PLATFORM
|
||||||
|
enabled: true
|
||||||
|
max-rows: 200000
|
||||||
|
max-attempts: 3
|
||||||
|
batch-size: 500
|
||||||
|
|
||||||
FINANCE_CUSTOMER_ACCOUNT_LIST:
|
FINANCE_CUSTOMER_ACCOUNT_LIST:
|
||||||
provider-service: finance-service
|
provider-service: finance-service
|
||||||
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
|
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
|
||||||
|
|||||||
@@ -5,3 +5,6 @@ spring:
|
|||||||
username: ${FINANCE_DB_USERNAME:root}
|
username: ${FINANCE_DB_USERNAME:root}
|
||||||
password: ${FINANCE_DB_PASSWORD:123456}
|
password: ${FINANCE_DB_PASSWORD:123456}
|
||||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||||
|
baiyue.finance.recharge.wechat-qr-code-url: ${FINANCE_RECHARGE_WECHAT_QR_CODE_URL:/customer-recharge-qr.svg}
|
||||||
|
baiyue.finance.recharge.alipay-qr-code-url: ${FINANCE_RECHARGE_ALIPAY_QR_CODE_URL:/customer-recharge-qr.svg}
|
||||||
|
baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ spring.datasource:
|
|||||||
url: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
|
url: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
|
||||||
username: ${FIRSTLEG_DB_USERNAME:root}
|
username: ${FIRSTLEG_DB_USERNAME:root}
|
||||||
password: ${FIRSTLEG_DB_PASSWORD:123456}
|
password: ${FIRSTLEG_DB_PASSWORD:123456}
|
||||||
|
spring.servlet.multipart.max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB}
|
||||||
|
spring.servlet.multipart.max-request-size: ${FIRSTLEG_MULTIPART_MAX_REQUEST_SIZE:52MB}
|
||||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||||
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
||||||
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
||||||
@@ -13,6 +15,7 @@ baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_E
|
|||||||
baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false}
|
baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false}
|
||||||
baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false}
|
baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false}
|
||||||
baiyue.carrier-schedule.scheduler.zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai}
|
baiyue.carrier-schedule.scheduler.zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai}
|
||||||
|
baiyue.carrier-schedule.scheduler.enabled: ${CARRIER_SCHEDULE_SCHEDULER_ENABLED:false}
|
||||||
baiyue.carrier-schedule.scheduler.scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M}
|
baiyue.carrier-schedule.scheduler.scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M}
|
||||||
baiyue.carrier-schedule.scheduler.scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S}
|
baiyue.carrier-schedule.scheduler.scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S}
|
||||||
baiyue.carrier-schedule.scheduler.batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20}
|
baiyue.carrier-schedule.scheduler.batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ spring.datasource:
|
|||||||
username: ${IAM_DB_USERNAME:root}
|
username: ${IAM_DB_USERNAME:root}
|
||||||
password: ${IAM_DB_PASSWORD:123456}
|
password: ${IAM_DB_PASSWORD:123456}
|
||||||
baiyue:
|
baiyue:
|
||||||
|
security:
|
||||||
|
# 本机只运行一个 Event Bridge;部署环境通过 SECURITY_EVENT_BRIDGE_URL 指向海外实例。
|
||||||
|
event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://127.0.0.1:9312}
|
||||||
customer-dimension-initialization:
|
customer-dimension-initialization:
|
||||||
fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}
|
fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}
|
||||||
max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}
|
max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ baiyue:
|
|||||||
security:
|
security:
|
||||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
|
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
|
||||||
jwt-mode: jwks
|
jwt-mode: jwks
|
||||||
session-mode: claims
|
session-mode: gateway
|
||||||
issuer: ${JWT_ISSUER:auth-service}
|
issuer: ${JWT_ISSUER:auth-service}
|
||||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||||
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
|
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ baiyue:
|
|||||||
token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
|
token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
|
||||||
security:
|
security:
|
||||||
jwt-mode: jwks
|
jwt-mode: jwks
|
||||||
session-mode: claims
|
session-mode: gateway
|
||||||
issuer: ${JWT_ISSUER:auth-service}
|
issuer: ${JWT_ISSUER:auth-service}
|
||||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||||
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
|
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ spring:
|
|||||||
connect-timeout: 3000
|
connect-timeout: 3000
|
||||||
read-timeout: 5000
|
read-timeout: 5000
|
||||||
server:
|
server:
|
||||||
|
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
|
||||||
shutdown: graceful
|
shutdown: graceful
|
||||||
management:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
spring:
|
spring:
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: ${LASTLEG_MULTIPART_MAX_FILE_SIZE:10MB}
|
||||||
|
max-request-size: ${LASTLEG_MULTIPART_MAX_REQUEST_SIZE:11MB}
|
||||||
datasource:
|
datasource:
|
||||||
url: ${LASTLEG_DB_URL}
|
url: ${LASTLEG_DB_URL}
|
||||||
username: ${LASTLEG_DB_USERNAME}
|
username: ${LASTLEG_DB_USERNAME}
|
||||||
@@ -18,6 +22,12 @@ baiyue:
|
|||||||
relabel-photo-file:
|
relabel-photo-file:
|
||||||
connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s}
|
connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s}
|
||||||
read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s}
|
read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s}
|
||||||
|
customer-visit-file:
|
||||||
|
connect-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_CONNECT_TIMEOUT:3s}
|
||||||
|
read-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_READ_TIMEOUT:30s}
|
||||||
|
procurement-file:
|
||||||
|
connect-timeout: ${LASTLEG_PROCUREMENT_FILE_CONNECT_TIMEOUT:3s}
|
||||||
|
read-timeout: ${LASTLEG_PROCUREMENT_FILE_READ_TIMEOUT:30s}
|
||||||
warehouse-resource:
|
warehouse-resource:
|
||||||
connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s}
|
connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s}
|
||||||
read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s}
|
read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: firstleg-${DEPLOY_ENV:-test}-overseas
|
name: firstleg-${DEPLOY_ENV:-test}-overseas
|
||||||
|
|
||||||
x-overseas-common: &overseas-common
|
x-overseas-common: &overseas-common
|
||||||
restart: unless-stopped
|
restart: "on-failure:3"
|
||||||
networks: [firstleg-overseas-network, infra-network]
|
networks: [firstleg-overseas-network, infra-network]
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
@@ -24,13 +24,14 @@ x-overseas-common: &overseas-common
|
|||||||
DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas}
|
DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas}
|
||||||
DATA_RESIDENCY: OVERSEAS
|
DATA_RESIDENCY: OVERSEAS
|
||||||
SPRING_SQL_INIT_MODE: never
|
SPRING_SQL_INIT_MODE: never
|
||||||
LOG_PATH: /app/logs
|
SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB}
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
# 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。
|
||||||
|
driver: ${DOCKER_LOG_DRIVER:-json-file}
|
||||||
options:
|
options:
|
||||||
max-size: 100m
|
max-size: ${DOCKER_LOG_MAX_SIZE:-50m}
|
||||||
max-file: "5"
|
max-file: "${DOCKER_LOG_MAX_FILE:-3}"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
gateway-overseas:
|
gateway-overseas:
|
||||||
@@ -38,7 +39,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas
|
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *overseas-environment
|
<<: *overseas-environment
|
||||||
SERVER_PORT: 9300
|
SERVER_PORT: 9300
|
||||||
@@ -55,7 +55,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
|
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *overseas-environment
|
<<: *overseas-environment
|
||||||
SERVER_PORT: 9303
|
SERVER_PORT: 9303
|
||||||
@@ -66,6 +65,8 @@ services:
|
|||||||
FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312}
|
FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312}
|
||||||
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
|
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
|
||||||
FILE_SERVICE_URL: ${FILE_SERVICE_URL}
|
FILE_SERVICE_URL: ${FILE_SERVICE_URL}
|
||||||
|
# Print 注册在国内 Nacos namespace;海外服务通过共享 infra-network 的 Docker DNS 直连。
|
||||||
|
PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207}
|
||||||
WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305}
|
WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305}
|
||||||
|
|
||||||
warehouse-overseas-service:
|
warehouse-overseas-service:
|
||||||
@@ -73,7 +74,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas
|
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *overseas-environment
|
<<: *overseas-environment
|
||||||
SERVER_PORT: 9305
|
SERVER_PORT: 9305
|
||||||
@@ -87,7 +87,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas
|
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *overseas-environment
|
<<: *overseas-environment
|
||||||
SERVER_PORT: 9312
|
SERVER_PORT: 9312
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
name: firstleg-${DEPLOY_ENV:-test}-rocketmq
|
name: firstleg-${DEPLOY_ENV:-test}-rocketmq
|
||||||
|
|
||||||
|
x-bounded-logging: &bounded-logging
|
||||||
|
driver: ${DOCKER_LOG_DRIVER:-json-file}
|
||||||
|
options:
|
||||||
|
max-size: ${DOCKER_LOG_MAX_SIZE:-50m}
|
||||||
|
max-file: "${DOCKER_LOG_MAX_FILE:-3}"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
rocketmq-namesrv:
|
rocketmq-namesrv:
|
||||||
image: apache/rocketmq:5.3.1
|
image: apache/rocketmq:5.3.1
|
||||||
restart: unless-stopped
|
restart: "on-failure:3"
|
||||||
command: sh mqnamesrv
|
command: sh mqnamesrv
|
||||||
|
logging: *bounded-logging
|
||||||
networks: [infra-network]
|
networks: [infra-network]
|
||||||
|
|
||||||
rocketmq-broker:
|
rocketmq-broker:
|
||||||
image: apache/rocketmq:5.3.1
|
image: apache/rocketmq:5.3.1
|
||||||
restart: unless-stopped
|
restart: "on-failure:3"
|
||||||
depends_on: [rocketmq-namesrv]
|
depends_on: [rocketmq-namesrv]
|
||||||
environment:
|
environment:
|
||||||
ROCKETMQ_BROKER_IP: ${ROCKETMQ_BROKER_IP:?ROCKETMQ_BROKER_IP is required}
|
ROCKETMQ_BROKER_IP: ${ROCKETMQ_BROKER_IP:?ROCKETMQ_BROKER_IP is required}
|
||||||
@@ -17,6 +24,7 @@ services:
|
|||||||
- ./rocketmq:/config:ro
|
- ./rocketmq:/config:ro
|
||||||
- ${ROCKETMQ_DATA_DIR:-/data/firstleg/rocketmq/store}:/home/rocketmq/store
|
- ${ROCKETMQ_DATA_DIR:-/data/firstleg/rocketmq/store}:/home/rocketmq/store
|
||||||
command: sh /config/render-broker-config.sh
|
command: sh /config/render-broker-config.sh
|
||||||
|
logging: *bounded-logging
|
||||||
networks: [infra-network]
|
networks: [infra-network]
|
||||||
|
|
||||||
rocketmq-topic-init:
|
rocketmq-topic-init:
|
||||||
@@ -45,7 +53,8 @@ services:
|
|||||||
sh mqadmin updateTopic -n rocketmq-namesrv:9876 -c BaiyueCluster
|
sh mqadmin updateTopic -n rocketmq-namesrv:9876 -c BaiyueCluster
|
||||||
-t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-audit-consumer-v1" -r 1 -w 1 -p 6'
|
-t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-audit-consumer-v1" -r 1 -w 1 -p 6'
|
||||||
# Broker 注册较慢或任一初始化命令失败时整组重试;所有命令均幂等。
|
# Broker 注册较慢或任一初始化命令失败时整组重试;所有命令均幂等。
|
||||||
restart: on-failure
|
restart: "on-failure:3"
|
||||||
|
logging: *bounded-logging
|
||||||
networks: [infra-network]
|
networks: [infra-network]
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
+17
-17
@@ -1,12 +1,13 @@
|
|||||||
name: firstleg-${DEPLOY_ENV:-test}
|
name: firstleg-${DEPLOY_ENV:-test}
|
||||||
|
|
||||||
x-common: &common
|
x-common: &common
|
||||||
restart: unless-stopped
|
restart: "on-failure:3"
|
||||||
networks: [firstleg-network, infra-network]
|
networks: [firstleg-network, infra-network]
|
||||||
# 测试机复用宿主机的 MySQL、Redis、Nacos 时,容器通过此别名访问宿主机。
|
# 测试机复用宿主机的 MySQL、Redis、Nacos 时,容器通过此别名访问宿主机。
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
environment: &common-environment
|
environment: &common-environment
|
||||||
|
DEPLOY_ENV: ${DEPLOY_ENV:-test}
|
||||||
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-cn}
|
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-cn}
|
||||||
NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR}
|
NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR}
|
||||||
NACOS_USERNAME: ${NACOS_USERNAME}
|
NACOS_USERNAME: ${NACOS_USERNAME}
|
||||||
@@ -26,13 +27,13 @@ x-common: &common
|
|||||||
REGION_CODE: ${REGION_CODE:-CN}
|
REGION_CODE: ${REGION_CODE:-CN}
|
||||||
DEPLOYMENT_CELL: ${DEPLOYMENT_CELL:-test-cn}
|
DEPLOYMENT_CELL: ${DEPLOYMENT_CELL:-test-cn}
|
||||||
DATA_RESIDENCY: ${DATA_RESIDENCY:-CN}
|
DATA_RESIDENCY: ${DATA_RESIDENCY:-CN}
|
||||||
LOG_PATH: /app/logs
|
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
# 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。
|
||||||
|
driver: ${DOCKER_LOG_DRIVER:-json-file}
|
||||||
options:
|
options:
|
||||||
max-size: 100m
|
max-size: ${DOCKER_LOG_MAX_SIZE:-50m}
|
||||||
max-file: "5"
|
max-file: "${DOCKER_LOG_MAX_FILE:-3}"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
gateway:
|
gateway:
|
||||||
@@ -40,7 +41,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-gateway
|
container_name: firstleg-${DEPLOY_ENV:-test}-gateway
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9000
|
SERVER_PORT: 9000
|
||||||
@@ -53,7 +53,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-auth
|
container_name: firstleg-${DEPLOY_ENV:-test}-auth
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/auth:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9100
|
SERVER_PORT: 9100
|
||||||
@@ -72,7 +71,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-iam
|
container_name: firstleg-${DEPLOY_ENV:-test}-iam
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/iam:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9201
|
SERVER_PORT: 9201
|
||||||
@@ -86,7 +84,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-business
|
container_name: firstleg-${DEPLOY_ENV:-test}-business
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/firstleg:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9202
|
SERVER_PORT: 9202
|
||||||
@@ -100,32 +97,39 @@ services:
|
|||||||
COSCO_OPEN_API_ENABLED: ${COSCO_OPEN_API_ENABLED:-false}
|
COSCO_OPEN_API_ENABLED: ${COSCO_OPEN_API_ENABLED:-false}
|
||||||
EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false}
|
EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false}
|
||||||
HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false}
|
HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false}
|
||||||
|
CARRIER_SCHEDULE_SCHEDULER_ENABLED: ${CARRIER_SCHEDULE_SCHEDULER_ENABLED:-false}
|
||||||
CARRIER_SCHEDULE_ZONE: ${CARRIER_SCHEDULE_ZONE:-Asia/Shanghai}
|
CARRIER_SCHEDULE_ZONE: ${CARRIER_SCHEDULE_ZONE:-Asia/Shanghai}
|
||||||
CARRIER_SCHEDULE_SCAN_FIXED_DELAY: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:-PT1M}
|
CARRIER_SCHEDULE_SCAN_FIXED_DELAY: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:-PT1M}
|
||||||
CARRIER_SCHEDULE_SCAN_INITIAL_DELAY: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:-PT10S}
|
CARRIER_SCHEDULE_SCAN_INITIAL_DELAY: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:-PT10S}
|
||||||
CARRIER_SCHEDULE_BATCH_SIZE: ${CARRIER_SCHEDULE_BATCH_SIZE:-20}
|
CARRIER_SCHEDULE_BATCH_SIZE: ${CARRIER_SCHEDULE_BATCH_SIZE:-20}
|
||||||
CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:-2000}
|
CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:-2000}
|
||||||
CARRIER_SCHEDULE_LEASE_DURATION: ${CARRIER_SCHEDULE_LEASE_DURATION:-PT5M}
|
CARRIER_SCHEDULE_LEASE_DURATION: ${CARRIER_SCHEDULE_LEASE_DURATION:-PT5M}
|
||||||
|
TERMINAL_DRAYAGE_MAIL_ENABLED: ${TERMINAL_DRAYAGE_MAIL_ENABLED:-false}
|
||||||
|
TERMINAL_DRAYAGE_MAIL_HOST: ${TERMINAL_DRAYAGE_MAIL_HOST:-}
|
||||||
|
TERMINAL_DRAYAGE_MAIL_PORT: ${TERMINAL_DRAYAGE_MAIL_PORT:-587}
|
||||||
|
TERMINAL_DRAYAGE_MAIL_USERNAME: ${TERMINAL_DRAYAGE_MAIL_USERNAME:-}
|
||||||
|
TERMINAL_DRAYAGE_MAIL_PASSWORD: ${TERMINAL_DRAYAGE_MAIL_PASSWORD:-}
|
||||||
|
TERMINAL_DRAYAGE_MAIL_SSL: ${TERMINAL_DRAYAGE_MAIL_SSL:-false}
|
||||||
|
TERMINAL_DRAYAGE_MAIL_SCAN_DELAY: ${TERMINAL_DRAYAGE_MAIL_SCAN_DELAY:-10000}
|
||||||
|
|
||||||
finance:
|
finance:
|
||||||
<<: *common
|
<<: *common
|
||||||
image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-finance
|
container_name: firstleg-${DEPLOY_ENV:-test}-finance
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/finance:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9203
|
SERVER_PORT: 9203
|
||||||
FINANCE_DB_URL: ${FINANCE_DB_URL}
|
FINANCE_DB_URL: ${FINANCE_DB_URL}
|
||||||
FINANCE_DB_USERNAME: ${FINANCE_DB_USERNAME}
|
FINANCE_DB_USERNAME: ${FINANCE_DB_USERNAME}
|
||||||
FINANCE_DB_PASSWORD: ${FINANCE_DB_PASSWORD}
|
FINANCE_DB_PASSWORD: ${FINANCE_DB_PASSWORD}
|
||||||
|
OVERSEAS_EVENT_BRIDGE_URL: ${OVERSEAS_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312}
|
||||||
|
|
||||||
order:
|
order:
|
||||||
<<: *common
|
<<: *common
|
||||||
image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-order
|
container_name: firstleg-${DEPLOY_ENV:-test}-order
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/order:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9204
|
SERVER_PORT: 9204
|
||||||
@@ -138,7 +142,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse
|
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9205
|
SERVER_PORT: 9205
|
||||||
@@ -153,7 +156,6 @@ services:
|
|||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"]
|
||||||
volumes:
|
volumes:
|
||||||
- "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files"
|
- "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files"
|
||||||
- "${LOG_ROOT:-/data/logs/baiyue-firstleg}/file:/app/logs"
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9206
|
SERVER_PORT: 9206
|
||||||
@@ -175,7 +177,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-print
|
container_name: firstleg-${DEPLOY_ENV:-test}-print
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/print:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9207
|
SERVER_PORT: 9207
|
||||||
@@ -186,7 +187,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-integration
|
container_name: firstleg-${DEPLOY_ENV:-test}-integration
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/integration-service:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9210
|
SERVER_PORT: 9210
|
||||||
@@ -236,7 +236,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge
|
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9312
|
SERVER_PORT: 9312
|
||||||
@@ -245,6 +244,8 @@ services:
|
|||||||
EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD}
|
EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD}
|
||||||
EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
|
EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
|
||||||
FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
|
FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
|
||||||
|
FINANCE_FEE_EVENT_CONSUMER_URL: ${FINANCE_FEE_EVENT_CONSUMER_URL:-http://finance:9203/internal/finance/v1/business-fee-source-events}
|
||||||
|
FINANCE_WALLET_EVENT_CONSUMER_URL: ${FINANCE_WALLET_EVENT_CONSUMER_URL:-http://finance:9203/internal/finance/v1/wallet-command-events}
|
||||||
SPRING_SQL_INIT_MODE: never
|
SPRING_SQL_INIT_MODE: never
|
||||||
networks:
|
networks:
|
||||||
firstleg-network:
|
firstleg-network:
|
||||||
@@ -256,7 +257,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-audit
|
container_name: firstleg-${DEPLOY_ENV:-test}-audit
|
||||||
expose: ["9700"]
|
expose: ["9700"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/audit-service:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9700
|
SERVER_PORT: 9700
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ RUN addgroup -S app \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 预建镜像内运行时数据和日志目录并归属 app 用户;宿主机 bind mount 的属主需在部署前单独设置为 UID 10001。
|
# 预建镜像内运行时数据和日志目录并归属 app 用户;宿主机 bind mount 的属主需在部署前单独设置为 UID 10001。
|
||||||
RUN mkdir -p /data/files /app/logs && chown -R app:app /data /app/logs
|
RUN mkdir -p /data/files && chown -R app:app /data
|
||||||
|
|
||||||
# 依赖层稳定、业务代码层高频变化;Registry 只需传输实际变化的 Layer。
|
# 依赖层稳定、业务代码层高频变化;Registry 只需传输实际变化的 Layer。
|
||||||
COPY --from=extractor --chown=app:app /workspace/dependencies/ ./
|
COPY --from=extractor --chown=app:app /workspace/dependencies/ ./
|
||||||
|
|||||||
@@ -2,38 +2,4 @@
|
|||||||
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
|
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
|
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
|
||||||
<mirrors>
|
|
||||||
<mirror>
|
|
||||||
<id>aliyun-public</id>
|
|
||||||
<name>阿里云 Maven 公共镜像</name>
|
|
||||||
<url>https://maven.aliyun.com/repository/public</url>
|
|
||||||
<mirrorOf>central</mirrorOf>
|
|
||||||
</mirror>
|
|
||||||
</mirrors>
|
|
||||||
|
|
||||||
<profiles>
|
|
||||||
<profile>
|
|
||||||
<id>国内公共镜像</id>
|
|
||||||
<repositories>
|
|
||||||
<repository>
|
|
||||||
<id>central</id>
|
|
||||||
<url>https://repo.maven.apache.org/maven2</url>
|
|
||||||
<releases><enabled>true</enabled></releases>
|
|
||||||
<snapshots><enabled>false</enabled></snapshots>
|
|
||||||
</repository>
|
|
||||||
</repositories>
|
|
||||||
<pluginRepositories>
|
|
||||||
<pluginRepository>
|
|
||||||
<id>central</id>
|
|
||||||
<url>https://repo.maven.apache.org/maven2</url>
|
|
||||||
<releases><enabled>true</enabled></releases>
|
|
||||||
<snapshots><enabled>false</enabled></snapshots>
|
|
||||||
</pluginRepository>
|
|
||||||
</pluginRepositories>
|
|
||||||
</profile>
|
|
||||||
</profiles>
|
|
||||||
|
|
||||||
<activeProfiles>
|
|
||||||
<activeProfile>国内公共镜像</activeProfile>
|
|
||||||
</activeProfiles>
|
|
||||||
</settings>
|
</settings>
|
||||||
|
|||||||
@@ -130,6 +130,14 @@ if [ "$SERVICE" = "firstleg" ]; then
|
|||||||
echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2
|
echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
if [ "$(env_file_value TERMINAL_DRAYAGE_MAIL_ENABLED)" = "true" ]; then
|
||||||
|
for key in TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD; do
|
||||||
|
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
|
||||||
|
echo "启用码头车队邮件时,环境文件必须包含非空 $key" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$SERVICE" = "lastleg-service" ]; then
|
if [ "$SERVICE" = "lastleg-service" ]; then
|
||||||
|
|||||||
+47
-17
@@ -1,12 +1,13 @@
|
|||||||
# 测试环境 SQL 执行说明
|
# 测试环境 SQL 执行说明
|
||||||
|
|
||||||
## 唯一数据库初始化与发布入口
|
## 数据库初始化与权限发布入口
|
||||||
|
|
||||||
- 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。
|
- 国内区域全量入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境升级,不再按日期拆分结构发布脚本。
|
||||||
- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口会创建并校验独立的 `baiyue_event_bridge_overseas`,再升级 `baiyue_lastleg`与 `baiyue_lastlag_warehouse`;IAM 安全事件 Inbox 和可恢复投影已并入 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。
|
- 通用权限点独立入口:`权限点初始化-MySQL8.sql`。国内与海外业务的 Admin/Client 权限主数据均在 `baiyue_iam` 统一管理。当发布内容只新增或更新 IAM 权限资源及默认角色授权,且 IAM 基础表、父菜单和系统角色已存在时,可只执行该文件。它不建表,不创建账号、客户、仓库或业务数据。
|
||||||
- Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;其他既有开发账号不建立兼容表或长期补投入口。
|
- 海外区域固定入口:`海外区域统一增量-MySQL8.sql`。该文件已经内联 Lastleg 与 Warehouse Overseas 的完整空库基线,会自行创建并初始化 `baiyue_lastleg`、`baiyue_lastlag_warehouse` 和独立的 `baiyue_event_bridge_overseas`,清库后不得再额外执行模块基线。IAM 安全事件 Inbox 和可恢复投影位于 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。
|
||||||
|
- Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;AUTHORIZATION 同步冻结当前 `PLATFORM_ADMIN` 角色及 IAM 跨区前缀下的完整权限集合,保证空库重建后海外 Gateway 可立即鉴权。统一 SQL 重放只修正仍处于固定 baseline version=0 的该行,其他既有开发账号不建立兼容表或长期补投入口。
|
||||||
- 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。
|
- 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。
|
||||||
- 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。
|
- 该脚本汇总订单与客户、入库测量与计费及已实现国内货柜履约出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。
|
||||||
- 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。
|
- 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。
|
||||||
- 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。
|
- 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。
|
||||||
- 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。
|
- 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。
|
||||||
@@ -25,6 +26,13 @@
|
|||||||
4. 每个增量区块末尾增加只读断言,并同步维护统一脚本末尾的总体验证。
|
4. 每个增量区块末尾增加只读断言,并同步维护统一脚本末尾的总体验证。
|
||||||
5. MySQL DDL 会隐式提交,执行前仍应在目标环境确认连接和授权;不能把跨库脚本视为可整体回滚事务。
|
5. MySQL DDL 会隐式提交,执行前仍应在目标环境确认连接和授权;不能把跨库脚本视为可整体回滚事务。
|
||||||
|
|
||||||
|
### 权限点独立入口边界
|
||||||
|
|
||||||
|
- 新增权限时,同步维护 IAM 模块来源、`权限点初始化-MySQL8.sql`、对应区域的全量统一 SQL、前端按钮/路由、Controller `@PreAuthorize` 和 OpenAPI。
|
||||||
|
- 权限点入口只用于既有 IAM 基线上的权限新增或元数据/默认授权更新。涉及建表、字段、索引、角色创建或其他业务数据时,仍执行全量统一 SQL。
|
||||||
|
- 独立入口必须可重复执行,执行前校验稳定权限 ID/权限码没有被其他语义占用,执行后只在授权完整时返回 `permission_initialization_status = PASS`。
|
||||||
|
- 权限写入后不修改 Redis 中已签发的会话快照;受影响用户必须退出并重新登录。权限撤销不通过该 SQL 直接处理,应走 IAM 角色授权命令以同步撤销会话。
|
||||||
|
|
||||||
## Finance 首期人工财务基线
|
## Finance 首期人工财务基线
|
||||||
|
|
||||||
- Finance 数据库为 `baiyue_finance`,模块溯源脚本为 `finance/src/main/resources/db/finance.sql`,测试和存量环境仍只执行统一入口。
|
- Finance 数据库为 `baiyue_finance`,模块溯源脚本为 `finance/src/main/resources/db/finance.sql`,测试和存量环境仍只执行统一入口。
|
||||||
@@ -32,9 +40,9 @@
|
|||||||
- 不包含钱包、发票、供应商在线应付、银行自动对账、复杂核销、汇率和月结。
|
- 不包含钱包、发票、供应商在线应付、银行自动对账、复杂核销、汇率和月结。
|
||||||
- 统一脚本末尾会强校验 13 张 Finance 表、客户隔离/CNY/人工凭证关键字段,以及费用来源、Inbox、账单版本快照、收款、成本和写操作幂等唯一约束;任一缺失不会返回 `PASS`。
|
- 统一脚本末尾会强校验 13 张 Finance 表、客户隔离/CNY/人工凭证关键字段,以及费用来源、Inbox、账单版本快照、收款、成本和写操作幂等唯一约束;任一缺失不会返回 `PASS`。
|
||||||
|
|
||||||
## 历史一期回归脚本
|
## 历史订单与客户回归脚本
|
||||||
|
|
||||||
- 历史文件:`2026-07-31一期回归测试环境增量迁移.sql`,仅供追溯,不再作为统一入口。
|
- 历史文件:`2026-07-31订单与客户回归测试环境增量迁移.sql`,仅供追溯,不再作为统一入口。
|
||||||
- 适用数据库:MySQL 8.0。
|
- 适用数据库:MySQL 8.0。
|
||||||
- 执行账号需要对 `baiyue_iam`、`baiyue_warehouse`、`baiyue_order`、`baiyue_firstleg` 具备建表、改表、建索引、创建临时存储过程和数据写入权限。
|
- 执行账号需要对 `baiyue_iam`、`baiyue_warehouse`、`baiyue_order`、`baiyue_firstleg` 具备建表、改表、建索引、创建临时存储过程和数据写入权限。
|
||||||
- 脚本不会创建数据库,不会清理或删除业务数据,也不会写入演示租户、演示用户、演示仓库、演示往来单位或快递公司样本。
|
- 脚本不会创建数据库,不会清理或删除业务数据,也不会写入演示租户、演示用户、演示仓库、演示往来单位或快递公司样本。
|
||||||
@@ -53,7 +61,7 @@
|
|||||||
|
|
||||||
固定入口同时幂等补齐审核自动开户所需的全局 `MERCHANT_OWNER` 角色模板、全局商户菜单/按钮权限和角色授权;执行后应使用 `iam/src/test/resources/db/verify-phase1-admin-rbac.sql` 确认模板存在且权限完整。
|
固定入口同时幂等补齐审核自动开户所需的全局 `MERCHANT_OWNER` 角色模板、全局商户菜单/按钮权限和角色授权;执行后应使用 `iam/src/test/resources/db/verify-phase1-admin-rbac.sql` 确认模板存在且权限完整。
|
||||||
|
|
||||||
测试环境只执行当前固定入口;历史脚本和模块脚本不应在固定入口执行后再次串行执行。
|
测试环境结构发布只执行全量固定入口;纯权限点发布可改用权限点独立入口。历史脚本和模块脚本不应在任一发布入口执行后再次串行执行。
|
||||||
|
|
||||||
## 执行方式
|
## 执行方式
|
||||||
|
|
||||||
@@ -63,13 +71,13 @@
|
|||||||
|
|
||||||
执行结束后必须看到 `migration_status = PASS`。如果中途出现 `SQLSTATE 45000`,必须按错误中给出的缺失表、字段或权限处理后重新完整执行。
|
执行结束后必须看到 `migration_status = PASS`。如果中途出现 `SQLSTATE 45000`,必须按错误中给出的缺失表、字段或权限处理后重新完整执行。
|
||||||
|
|
||||||
## 三期分柜、预装、拣货与装柜当前基线
|
## 国内货柜履约分柜、预装、拣货与装柜当前基线
|
||||||
|
|
||||||
- 模块表结构来源:`firstleg/src/main/resources/db/phase3-split-preload-schema.sql`、`firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql`。
|
- 模块表结构来源:`firstleg/src/main/resources/db/phase3-split-preload-schema.sql`、`firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql`。
|
||||||
- 三期平台权限直接维护在统一初始化与发布入口中,避免额外增加独立权限脚本。
|
- 国内货柜履约平台权限直接维护在统一初始化与发布入口中,避免额外增加独立权限脚本。
|
||||||
- 测试和生产环境仍只执行固定入口 `头程数据库统一增量-MySQL8.sql`,不得再串行执行上述模块脚本。
|
- 测试和生产环境仍只执行固定入口 `头程数据库统一增量-MySQL8.sql`,不得再串行执行上述模块脚本。
|
||||||
- 必须先执行统一增量并取得 `PASS`,再启动或重启 Firstleg Service;否则统一订单摘要查询会因缺少三期操作表而失败。
|
- 必须先执行统一增量并取得 `PASS`,再启动或重启 Firstleg Service;否则统一订单摘要查询会因缺少国内货柜履约操作表而失败。
|
||||||
- 统一脚本会创建智能装柜任务 `fl_loading_pick_task`,并把历史入库汇总任务按字段签名无损迁移到 `fl_inbound_loading_pick_task`;未知混合结构会直接终止。`WAIT_SPLIT` 是二期有效终点,脚本不会改写主表或状态历史。
|
- 统一脚本会创建智能装柜任务 `fl_loading_pick_task`,并把历史入库汇总任务按字段签名无损迁移到 `fl_inbound_loading_pick_task`;未知混合结构会直接终止。`WAIT_SPLIT` 是入库测量与计费有效终点,脚本不会改写主表或状态历史。
|
||||||
- 下载中心固定使用 `5103040-5103041`,库位权限迁移到 `5103060-5103064`;入库装柜权限迁移到 `5102121-5102129`,迁移会按 `permission_code` 保留角色授权,目标 ID 被其他语义占用时直接终止。
|
- 下载中心固定使用 `5103040-5103041`,库位权限迁移到 `5103060-5103064`;入库装柜权限迁移到 `5102121-5102129`,迁移会按 `permission_code` 保留角色授权,目标 ID 被其他语义占用时直接终止。
|
||||||
|
|
||||||
部署前可只读盘点:
|
部署前可只读盘点:
|
||||||
@@ -123,11 +131,11 @@ SELECT COUNT(*) = 8 AS scheduler_columns_ready
|
|||||||
|
|
||||||
任一结果不是 1,禁止启动 File Service;只能继续追加兼容性增量修复,不得删除存量导出任务。
|
任一结果不是 1,禁止启动 File Service;只能继续追加兼容性增量修复,不得删除存量导出任务。
|
||||||
|
|
||||||
## 二期第三方测量增量
|
## 入库测量与计费第三方测量增量
|
||||||
|
|
||||||
- 模块脚本:`firstleg/src/main/resources/db/phase2-third-party-single-measurement-migration.sql`。
|
- 模块脚本:`firstleg/src/main/resources/db/phase2-third-party-single-measurement-migration.sql`。
|
||||||
- 历史二期脚本:`2026-08-04-二期入库新增表字段-MySQL8一键执行.sql` 已冻结,仅供追溯。
|
- 历史入库测量与计费脚本:`2026-08-04-入库新增表字段-MySQL8一键执行.sql` 已冻结,仅供追溯。
|
||||||
- 当前固定入口 `头程数据库统一增量-MySQL8.sql` 已内联该增量以及订单船司字段、二期 28 项 IAM 权限和最新仓储资源租户字段。
|
- 当前固定入口 `头程数据库统一增量-MySQL8.sql` 已内联该增量以及订单船司字段、入库测量与计费 28 项 IAM 权限和最新仓储资源租户字段。
|
||||||
- 新增 `external_request_hash`、`inbound_detail_id`、`external_detail_no` 和第三方明细唯一索引,用于一条回传绑定一条订单货物明细及一张文件中心图片。
|
- 新增 `external_request_hash`、`inbound_detail_id`、`external_detail_no` 和第三方明细唯一索引,用于一条回传绑定一条订单货物明细及一张文件中心图片。
|
||||||
- 部署 Firstleg 新版本前必须完整执行固定入口,并确认末尾总体验证为 `PASS`。
|
- 部署 Firstleg 新版本前必须完整执行固定入口,并确认末尾总体验证为 `PASS`。
|
||||||
|
|
||||||
@@ -221,8 +229,30 @@ SELECT
|
|||||||
- IAM 新增 `platform:overseas-storage-fee:list/create/edit/delete` 四项权限,默认授予平台系统管理员。
|
- IAM 新增 `platform:overseas-storage-fee:list/create/edit/delete` 四项权限,默认授予平台系统管理员。
|
||||||
- Warehouse Service 启动前必须先执行固定入口;末尾只有两张表、五个索引和四项权限均存在才返回 `PASS`。
|
- Warehouse Service 启动前必须先执行固定入口;末尾只有两张表、五个索引和四项权限均存在才返回 `PASS`。
|
||||||
|
|
||||||
## 四期海外权限目录
|
## 海外仓权限目录
|
||||||
|
|
||||||
- 固定入口已同步 `iam/src/main/resources/db/lastleg-permissions.sql` 的 `platform:overseas-*` 权限,并默认授予平台系统管理员。
|
- 固定入口已同步 `iam/src/main/resources/db/lastleg-permissions.sql` 的 `platform:overseas-*` 权限,并默认授予平台系统管理员。
|
||||||
- 旧 `platform:lastleg:*` 权限会撤销角色关联并停用;不保留旧码兼容映射。
|
- 旧 `platform:lastleg:*` 权限会撤销角色关联并停用;不保留旧码兼容映射。
|
||||||
- 本批不创建箱号、产品库、海外出库、退货出库或海外派送权限。
|
- 海外仓业务目录保留海外出库、派送列表入口;海外仓出库与派送写操作和客户派送权限由后续海外仓出库与派送区块直接收敛,不使用旧码兼容映射。
|
||||||
|
|
||||||
|
## 海外排车、出库与派送权限
|
||||||
|
|
||||||
|
- 国内固定入口已在海外仓业务 `lastleg-permissions.sql` 权限段之后完整内联 `iam/src/main/resources/db/phase5-delivery-permissions.sql`;测试和部署只执行固定入口,不再单独执行模块脚本。
|
||||||
|
- 平台端固定使用排车列表/编辑/取消/转派送、出库列表/创建/取消、派送列表/退回/状态/资料管理、异常订单列表/创建/处理和客户回访列表/编辑十六项权限;列表权限包含详情及只读辅助接口,写操作保持独立授权。
|
||||||
|
- 派送三类资料上传、替换、解绑和保存统一使用 `platform:overseas-warehouse:delivery:documents`,读取和下载仍使用 `platform:overseas-warehouse:delivery:list`;默认授予 `PLATFORM_ADMIN`、`PLATFORM_CUSTOM_ADMIN`、`WAREHOUSE_OPERATOR`。
|
||||||
|
- 客户端固定使用 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`;客户默认管理员获得两项权限,其他客户角色不自动扩权。
|
||||||
|
- `WAREHOUSE_OPERATOR` 获得十六项后台作业权限,`WAREHOUSE_TALLY` 只获得排车、出库、派送、异常订单和客户回访五项列表只读;国内、海外 PDA 岗位不得获得上述后台权限。
|
||||||
|
|
||||||
|
## 海外排车与出库结构升级
|
||||||
|
|
||||||
|
- 派送三类资料当前基线包含 `ll_delivery_order.bol_no/documents_json` 与 `ll_delivery_document_revision`;海外固定入口和 Lastleg 模块基线同步维护这些定义。仅保存装车照片、BOL文件(含签名)、签收文件(POD签收/快递签收)三个类别,摘要按类别计数。资料历史不可变,文件对象由 File 按真实客户与派送单绑定;不按 BOL 文本复用文件,不生成旧类别迁移或清理脚本。
|
||||||
|
|
||||||
|
- 海外仓出库与派送数据库部署只执行 `海外区域统一增量-MySQL8.sql`;无论空库还是存量测试库都不得在此前单独执行 Lastleg 或 Warehouse Overseas 模块基线,完整执行固定入口并确认最末 `migration_status = PASS`。
|
||||||
|
- 该海外固定入口会在任何海外仓出库与派送 `ALTER` 与数据门禁前幂等创建排车 PDA、出库、派送、一件代发、异常订单和客户回访的完整当前结构,因此既有海外仓业务库不依赖模块基线或历史 `scripts/db/incremental.sql`。国内固定入口只保留 IAM 海外仓出库与派送权限目录,不创建或修改 `baiyue_lastleg` 业务表。
|
||||||
|
- 固定入口只补目标字段并做 fail-fast 校验,不回填、改号、清理或从其他表推断开发旧行。`ll_dispatch_pda_task.warehouse_id` 存在 `NULL`、空值或 `0`,或任一待收敛全局业务号/任务号、完成幂等键发生冲突时,脚本立即 `SIGNAL`;应重新造数,重置或清理仍需用户另行授权。
|
||||||
|
- 数据门禁通过后,脚本原位把 `dispatch_no`、`outbound_no`、排车 PDA `task_no`、出库 PDA `task_no` 收敛为全局唯一,并建立 `(customer_id, completion_idempotency_key)` 完成命令唯一约束;旧客户复合索引不保留。
|
||||||
|
- 必须在启动或重启 Lastleg Service 前完成固定入口;不得单独执行模块基线补丁、日期脚本或手工兼容 SQL。
|
||||||
|
|
||||||
|
## 码头车队邮件
|
||||||
|
|
||||||
|
国内统一入口包含 `fl_terminal_drayage_mail`,用于冻结收件地址、两类邮件正文、投递状态和失败信息;创建后才可发布相关 Firstleg 功能。发信配置默认关闭,配置说明见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。邮件功能涉及 Admin、Firstleg 和 Warehouse 内部联系资料接口,无新增权限码。
|
||||||
|
|||||||
+4072
-1258
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
|||||||
|
-- 通用 IAM 权限点独立初始化入口(MySQL 8.0)。
|
||||||
|
-- 国内与海外业务的 Admin/Client 权限主数据均在 baiyue_iam 统一管理。
|
||||||
|
-- 只允许写入 baiyue_iam 的权限资源和角色授权;
|
||||||
|
-- 不建表、不创建账号/客户/仓库,不写业务测试数据。
|
||||||
|
-- 前置:IAM 基础表、相关父菜单及系统角色已由全量基线创建。
|
||||||
|
USE baiyue_iam;
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS apply_permission_points;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE apply_permission_points()
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM iam_permission WHERE id = 5102010 AND tenant_id = 0
|
||||||
|
AND permission_code = 'platform:order:list' AND permission_type = 'MENU') THEN
|
||||||
|
SIGNAL SQLSTATE '45000'
|
||||||
|
SET MESSAGE_TEXT = '权限初始化失败:订单列表父菜单不存在,请先执行全量基线';
|
||||||
|
END IF;
|
||||||
|
IF (SELECT COUNT(*) FROM iam_role WHERE id IN (4000001, 4000003) AND tenant_id = 0
|
||||||
|
AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0) <> 2 THEN
|
||||||
|
SIGNAL SQLSTATE '45000'
|
||||||
|
SET MESSAGE_TEXT = '权限初始化失败:平台管理员或订单运营角色不存在';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM iam_permission WHERE id = 5102027
|
||||||
|
AND permission_code <> 'platform:order:declaration:edit')
|
||||||
|
OR EXISTS (SELECT 1 FROM iam_permission
|
||||||
|
WHERE permission_code = 'platform:order:declaration:edit' AND id <> 5102027) THEN
|
||||||
|
SIGNAL SQLSTATE '45000'
|
||||||
|
SET MESSAGE_TEXT = '权限初始化失败:5102027 或权限码已被其他语义占用';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
START TRANSACTION;
|
||||||
|
INSERT INTO iam_permission
|
||||||
|
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
|
||||||
|
route_path, http_method, sort_no, status, deleted)
|
||||||
|
VALUES
|
||||||
|
(5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit',
|
||||||
|
'修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
|
||||||
|
permission_type = VALUES(permission_type), app_code = VALUES(app_code),
|
||||||
|
sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
|
||||||
|
|
||||||
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||||
|
SELECT role_id, 5102027, 0
|
||||||
|
FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles
|
||||||
|
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
|
||||||
|
|
||||||
|
IF (SELECT COUNT(*) FROM iam_permission WHERE id = 5102027 AND tenant_id = 0
|
||||||
|
AND app_code = 'PLATFORM' AND permission_type = 'BUTTON' AND parent_id = 5102010
|
||||||
|
AND permission_code = 'platform:order:declaration:edit'
|
||||||
|
AND status = 'ACTIVE' AND deleted = 0) <> 1
|
||||||
|
OR (SELECT COUNT(DISTINCT role_id) FROM iam_role_permission
|
||||||
|
WHERE tenant_id = 0 AND permission_id = 5102027
|
||||||
|
AND role_id IN (4000001, 4000003)) <> 2 THEN
|
||||||
|
ROLLBACK;
|
||||||
|
SIGNAL SQLSTATE '45000'
|
||||||
|
SET MESSAGE_TEXT = '权限初始化失败:订单申报与运营预估权限未完整写入';
|
||||||
|
END IF;
|
||||||
|
COMMIT;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
CALL apply_permission_points();
|
||||||
|
DROP PROCEDURE IF EXISTS apply_permission_points;
|
||||||
|
|
||||||
|
SELECT 'PASS' AS permission_initialization_status;
|
||||||
+3952
-19
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,11 @@
|
|||||||
name: firstleg-test-infra
|
name: firstleg-test-infra
|
||||||
|
|
||||||
|
x-bounded-logging: &bounded-logging
|
||||||
|
driver: ${DOCKER_LOG_DRIVER:-json-file}
|
||||||
|
options:
|
||||||
|
max-size: ${DOCKER_LOG_MAX_SIZE:-50m}
|
||||||
|
max-file: "${DOCKER_LOG_MAX_FILE:-3}"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
nacos:
|
nacos:
|
||||||
image: docker.m.daocloud.io/nacos/nacos-server:v2.3.2
|
image: docker.m.daocloud.io/nacos/nacos-server:v2.3.2
|
||||||
@@ -22,6 +28,7 @@ services:
|
|||||||
retries: 20
|
retries: 20
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *bounded-logging
|
||||||
networks: [baiyue]
|
networks: [baiyue]
|
||||||
|
|
||||||
mysql:
|
mysql:
|
||||||
@@ -46,6 +53,7 @@ services:
|
|||||||
retries: 20
|
retries: 20
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *bounded-logging
|
||||||
networks: [baiyue]
|
networks: [baiyue]
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
@@ -62,6 +70,7 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *bounded-logging
|
||||||
networks: [baiyue]
|
networks: [baiyue]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
5. 平台身份只检查 `app_code=PLATFORM`,部分 Gateway/模块守卫没有再次断言登录主体 `tenant_id=0`;客户身份也应断言 `tenant_id>0`。
|
5. 平台身份只检查 `app_code=PLATFORM`,部分 Gateway/模块守卫没有再次断言登录主体 `tenant_id=0`;客户身份也应断言 `tenant_id>0`。
|
||||||
6. 平台代客业务审计当前可能记录平台主体 `tenant_id=0`,导致按目标客户查询审计日志漏记;应同时保留操作者作用域和目标客户归属。
|
6. 平台代客业务审计当前可能记录平台主体 `tenant_id=0`,导致按目标客户查询审计日志漏记;应同时保留操作者作用域和目标客户归属。
|
||||||
7. Integration 内部令牌存在可预测默认值,必须改为环境显式注入并保持启动失败保护。
|
7. Integration 内部令牌存在可预测默认值,必须改为环境显式注入并保持启动失败保护。
|
||||||
8. 模块级 `AGENTS.md`、README、分期文档和 OpenAPI 仍把兼容 `tenant_id` 描述成真实上级租户,三期存在重新按三级租户开发的返工风险。
|
8. 模块级 `AGENTS.md`、README、分期文档和 OpenAPI 仍把兼容 `tenant_id` 描述成真实上级租户,国内货柜履约存在重新按三级租户开发的返工风险。
|
||||||
|
|
||||||
## P2 最小加固
|
## P2 最小加固
|
||||||
|
|
||||||
@@ -63,6 +63,6 @@
|
|||||||
- 订单履约:`order/`、`firstleg/` 的生产代码与测试,不修改 SQL、OpenAPI、README、AGENTS。
|
- 订单履约:`order/`、`firstleg/` 的生产代码与测试,不修改 SQL、OpenAPI、README、AGENTS。
|
||||||
- 公共审计事件:`common-audit/`、`audit-service/`、`integration-service/`、`event-bridge-service/`、`common-openapi/` 的生产代码与测试。
|
- 公共审计事件:`common-audit/`、`audit-service/`、`integration-service/`、`event-bridge-service/`、`common-openapi/` 的生产代码与测试。
|
||||||
- Warehouse/File/迁移:`warehouse/`、`file/` 生产代码与测试,以及各模块数据库脚本、`deploy/sql/`、`config/nacos/`;平台公共资源(含往来单位、快递公司)作用域迁移为 `0`,客户私有资源保持客户 ID。
|
- Warehouse/File/迁移:`warehouse/`、`file/` 生产代码与测试,以及各模块数据库脚本、`deploy/sql/`、`config/nacos/`;平台公共资源(含往来单位、快递公司)作用域迁移为 `0`,客户私有资源保持客户 ID。
|
||||||
- 文档契约:模块 `AGENTS.md`、README、OpenAPI 和一期/二期/三期指导性文档;不修改 Java 或 SQL。
|
- 文档契约:模块 `AGENTS.md`、README、OpenAPI 和订单与客户/入库测量与计费/国内货柜履约指导性文档;不修改 Java 或 SQL。
|
||||||
|
|
||||||
跨模块新接口、公共 DTO、事件结构和公共仓库作用域变更由主对话统一裁决,单个修改对话不得自行扩展范围。
|
跨模块新接口、公共 DTO、事件结构和公共仓库作用域变更由主对话统一裁决,单个修改对话不得自行扩展范围。
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
|
|
||||||
重点检查商户下单、平台代客、列表详情、状态流转、货物/参与方/历史/备注、幂等、Outbox、IAM 资格查询和 Warehouse 主数据调用。必须区分当前隔离键 `tenant_id` 与往来单位语义的 `customer_id`。
|
重点检查商户下单、平台代客、列表详情、状态流转、货物/参与方/历史/备注、幂等、Outbox、IAM 资格查询和 Warehouse 主数据调用。必须区分当前隔离键 `tenant_id` 与往来单位语义的 `customer_id`。
|
||||||
|
|
||||||
### 对话 C:Firstleg 履约与二期入库
|
### 对话 C:Firstleg 履约与入库
|
||||||
|
|
||||||
负责目录:`firstleg/`。
|
负责目录:`firstleg/`。
|
||||||
|
|
||||||
@@ -78,11 +78,11 @@
|
|||||||
|
|
||||||
重点检查表、索引、唯一键、增量迁移、种子数据和环境配置中的客户隔离维度;盘点 `tenant_id = 0`、`10001` 等保留值的真实用途;提出上线前只读数据核验 SQL,不执行写操作。
|
重点检查表、索引、唯一键、增量迁移、种子数据和环境配置中的客户隔离维度;盘点 `tenant_id = 0`、`10001` 等保留值的真实用途;提出上线前只读数据核验 SQL,不执行写操作。
|
||||||
|
|
||||||
### 对话 G:测试、OpenAPI、文档与三期防返工
|
### 对话 G:测试、OpenAPI、文档与国内货柜履约防返工
|
||||||
|
|
||||||
只负责 `src/test/`、各模块 `src/main/resources/openapi/`、`docs/` 和模块 `README` / `AGENTS.md`,不修改生产代码。
|
只负责 `src/test/`、各模块 `src/main/resources/openapi/`、`docs/` 和模块 `README` / `AGENTS.md`,不修改生产代码。
|
||||||
|
|
||||||
检查测试是否覆盖跨客户拒绝、平台跨客户、内部调用归属和端域隔离;检查契约及文档是否仍把当前 `tenant_id` 描述成真实上级租户;定向核对三期文档,列出可能诱导后续按三级租户开发的内容。
|
检查测试是否覆盖跨客户拒绝、平台跨客户、内部调用归属和端域隔离;检查契约及文档是否仍把当前 `tenant_id` 描述成真实上级租户;定向核对国内货柜履约文档,列出可能诱导后续按三级租户开发的内容。
|
||||||
|
|
||||||
## 五、每个审计对话的统一输出格式
|
## 五、每个审计对话的统一输出格式
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
# 二期、三期业务功能回归报告
|
# 入库测量与计费、国内货柜履约业务功能回归报告
|
||||||
|
|
||||||
测试时间:2026-08-07
|
测试时间:2026-08-07
|
||||||
|
|
||||||
## 测试范围
|
## 测试范围
|
||||||
|
|
||||||
- 二期:企业资质、平台审核、公共主数据、客户计费规则、头程下单、入库测量、称重、计费与入库。
|
- 入库测量与计费:企业资质、平台审核、公共主数据、客户计费规则、头程下单、入库测量、称重、计费与入库。
|
||||||
- 三期:虚拟柜、智能装柜、预装、PDA 拣货和最终装柜清单。
|
- 国内货柜履约:虚拟柜、智能装柜、预装、PDA 拣货和最终装柜清单。
|
||||||
- 测试环境:本机 MySQL、Redis、Nacos;隔离服务端口 `19300` 到 `19306`。未停止、未占用用户的 `192xx` 服务端口。
|
- 测试环境:本机 MySQL、Redis、Nacos;隔离服务端口 `19300` 到 `19306`。未停止、未占用用户的 `192xx` 服务端口。
|
||||||
- 测试结束后已停止本次启动的 Gateway 和全部 `193xx` 隔离服务;未停止 MySQL、Redis、Nacos 或用户服务。
|
- 测试结束后已停止本次启动的 Gateway 和全部 `193xx` 隔离服务;未停止 MySQL、Redis、Nacos 或用户服务。
|
||||||
|
|
||||||
## 项目索引结论
|
## 项目索引结论
|
||||||
|
|
||||||
- 二期主状态和测量、计费、入库编排在 `firstleg`;订单货物在 `order`;仓库、托盘、库位、往来单位在 `warehouse`;文件在 `file`。
|
- 入库测量与计费主状态和测量、计费、入库编排在 `firstleg`;订单货物在 `order`;仓库、托盘、库位、往来单位在 `warehouse`;文件在 `file`。
|
||||||
- 三期智能装柜、预装、PDA 拣货、装柜清单均在 `firstleg`。
|
- 国内货柜履约智能装柜、预装、PDA 拣货、装柜清单均在 `firstleg`。
|
||||||
- 当前 `tenant_id` 在业务上代表客户归属;公共主数据应使用 `tenant_id=0`,订单服务仅可读取公共仓库和公共往来单位。
|
- 当前 `tenant_id` 在业务上代表客户归属;公共主数据应使用 `tenant_id=0`,订单服务仅可读取公共仓库和公共往来单位。
|
||||||
|
|
||||||
## 二期结果
|
## 入库测量与计费结果
|
||||||
|
|
||||||
结果:未通过,主链路在创建头程订单草稿前被阻断。
|
结果:未通过,主链路在创建头程订单草稿前被阻断。
|
||||||
|
|
||||||
@@ -29,9 +29,9 @@
|
|||||||
2. 当前平台往来单位 API 对 `tenantId=0` 返回 `A0422`,校验信息为“最小不能小于 1”;公共主数据无法经 API 创建或查询。
|
2. 当前平台往来单位 API 对 `tenantId=0` 返回 `A0422`,校验信息为“最小不能小于 1”;公共主数据无法经 API 创建或查询。
|
||||||
3. 旧订单请求仍强制 `carrierId`、`carrierName`。本次不传时返回 `A0422`。根据最新业务确认,头程业务不存在“承运商”概念,这一历史字段、校验、分配接口和费率依赖应移除或改为船司领域模型,不能继续作为下单前置条件。
|
3. 旧订单请求仍强制 `carrierId`、`carrierName`。本次不传时返回 `A0422`。根据最新业务确认,头程业务不存在“承运商”概念,这一历史字段、校验、分配接口和费率依赖应移除或改为船司领域模型,不能继续作为下单前置条件。
|
||||||
|
|
||||||
结论:在修复公共主数据 `tenantId=0` API 契约,并移除订单承运商强制校验前,二期无法从下单继续验证测量、称重、计费和入库。
|
结论:在修复公共主数据 `tenantId=0` API 契约,并移除订单承运商强制校验前,入库测量与计费无法从下单继续验证测量、称重、计费和入库。
|
||||||
|
|
||||||
## 三期结果
|
## 国内货柜履约结果
|
||||||
|
|
||||||
结果:通过。
|
结果:通过。
|
||||||
|
|
||||||
@@ -59,13 +59,13 @@
|
|||||||
## 构建与脚本观察
|
## 构建与脚本观察
|
||||||
|
|
||||||
- 离线全量模块测试执行到 `firstleg`:211 项中 1 项失败。失败项是 `InboundParentChildSqlContractTest`,原因是统一 SQL 已使用最终索引命名,但测试仍断言旧索引名 `uk_inbound_parent_sequence`;属于 SQL 契约断言滞后,不是 Java 业务断言失败。
|
- 离线全量模块测试执行到 `firstleg`:211 项中 1 项失败。失败项是 `InboundParentChildSqlContractTest`,原因是统一 SQL 已使用最终索引命名,但测试仍断言旧索引名 `uk_inbound_parent_sequence`;属于 SQL 契约断言滞后,不是 Java 业务断言失败。
|
||||||
- 二期验收脚本需要 PowerShell 7 的 `SkipHttpErrorCheck`,而本机只有 Windows PowerShell 5;测试时使用了临时 UTF-8 BOM 兼容副本。
|
- 入库测量与计费验收脚本需要 PowerShell 7 的 `SkipHttpErrorCheck`,而本机只有 Windows PowerShell 5;测试时使用了临时 UTF-8 BOM 兼容副本。
|
||||||
- 原脚本引用的 `c7.png`、`c8.png` 示例图片未随项目提供;测试目录使用最小 PNG 仅验证上传协议。
|
- 原脚本引用的 `c7.png`、`c8.png` 示例图片未随项目提供;测试目录使用最小 PNG 仅验证上传协议。
|
||||||
- 初始 File 隔离 JAR 未包含当前资质包上传端点,使用当前编译类启动后接口通过。发布前应重新打包 File 服务,避免运行包与源码不一致。
|
- 初始 File 隔离 JAR 未包含当前资质包上传端点,使用当前编译类启动后接口通过。发布前应重新打包 File 服务,避免运行包与源码不一致。
|
||||||
|
|
||||||
## 建议处理顺序
|
## 建议处理顺序
|
||||||
|
|
||||||
1. 移除头程订单及二期计费中的承运商强制字段、校验和分配逻辑;如需要船司,改为独立船司主数据,不复用往来单位承运商。
|
1. 移除头程订单及入库测量与计费计费中的承运商强制字段、校验和分配逻辑;如需要船司,改为独立船司主数据,不复用往来单位承运商。
|
||||||
2. 修复平台公共主数据 API:允许且仅允许平台身份使用 `tenantId=0` 创建、查询公共仓库和往来单位。
|
2. 修复平台公共主数据 API:允许且仅允许平台身份使用 `tenantId=0` 创建、查询公共仓库和往来单位。
|
||||||
3. 为订单下单提供可选的公共发货单位选择,或明确订单客户快照不再依赖 `SHIPPER` 往来单位。
|
3. 为订单下单提供可选的公共发货单位选择,或明确订单客户快照不再依赖 `SHIPPER` 往来单位。
|
||||||
4. 完成以上两项后,重新执行二期全链路:下单、审核、入仓单、测量、OCR 称重、费用试算、费用确认、入库完成。
|
4. 完成以上两项后,重新执行入库测量与计费全链路:下单、审核、入仓单、测量、OCR 称重、费用试算、费用确认、入库完成。
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
- `file-service` 统一承载任务、可靠调度、通用 XLSX、存储、过期清理、下载鉴权和指标。
|
- `file-service` 统一承载任务、可靠调度、通用 XLSX、存储、过期清理、下载鉴权和指标。
|
||||||
- File 不跨库读取 IAM、Order、Warehouse 等业务数据;业务服务只实现标准只读 Provider。
|
- File 不跨库读取 IAM、Order、Warehouse 等业务数据;业务服务只实现标准只读 Provider。
|
||||||
- MQ 只传任务标识,不传筛选条件、业务行、Token 或文件。
|
- MQ 只传任务标识,不传筛选条件、业务行、Token 或文件。
|
||||||
- 一期存储是 `FILE_STORAGE_ROOT` 目录且按单节点部署;多实例前必须使用跨主机共享卷或对象存储。
|
- 订单与客户存储是 `FILE_STORAGE_ROOT` 目录且按单节点部署;多实例前必须使用跨主机共享卷或对象存储。
|
||||||
- 当前首个类型为 `CUSTOMER_LIST`,由 IAM 提供数据。
|
- 当前首个类型为 `CUSTOMER_LIST`,由 IAM 提供数据。
|
||||||
|
|
||||||
## 2. 执行链路
|
## 2. 执行链路
|
||||||
|
|||||||
+15
-15
@@ -54,7 +54,7 @@
|
|||||||
- 下载中心继续使用统一任务表,不依赖 MQ 查询状态。
|
- 下载中心继续使用统一任务表,不依赖 MQ 查询状态。
|
||||||
- 新业务服务只实现公共 Provider 契约并注册导出类型,不写任务、Excel、存储、下载代码。
|
- 新业务服务只实现公共 Provider 契约并注册导出类型,不写任务、Excel、存储、下载代码。
|
||||||
- File Service 统一负责 XLSX、存储、下载鉴权、过期清理、可观测性。
|
- File Service 统一负责 XLSX、存储、下载鉴权、过期清理、可观测性。
|
||||||
- 代码层面支持 File Service 多实例(租约、条件抢占、`lease_owner` 齐备);**部署层面一期按单节点运行**,因为存储是宿主机目录(§11.1)。滚动发布期间的短暂双实例由租约保证安全。
|
- 代码层面支持 File Service 多实例(租约、条件抢占、`lease_owner` 齐备);**部署层面订单与客户按单节点运行**,因为存储是宿主机目录(§11.1)。滚动发布期间的短暂双实例由租约保证安全。
|
||||||
|
|
||||||
### 2.2 非目标
|
### 2.2 非目标
|
||||||
|
|
||||||
@@ -65,8 +65,8 @@
|
|||||||
- 不以 Redis 作为任务、重试或幂等的事实来源。
|
- 不以 Redis 作为任务、重试或幂等的事实来源。
|
||||||
- 不实现用户自定义列编排器。
|
- 不实现用户自定义列编排器。
|
||||||
- 不实现导出断点续传:每次执行从零重写整个文件(见 §9.5)。
|
- 不实现导出断点续传:每次执行从零重写整个文件(见 §9.5)。
|
||||||
- 一期不引入 MinIO/OSS(见 §21 决策 6),但存储访问必须走接口抽象。
|
- 订单与客户不引入 MinIO/OSS(见 §21 决策 6),但存储访问必须走接口抽象。
|
||||||
- 一期不引入 Testcontainers(见 §21 决策 8)。
|
- 订单与客户不引入 Testcontainers(见 §21 决策 8)。
|
||||||
|
|
||||||
## 3. 核心原则
|
## 3. 核心原则
|
||||||
|
|
||||||
@@ -821,7 +821,7 @@ Provider 可将 Token 实现为签名后的最大 ID、截止时间或数据库
|
|||||||
| 截止时间戳 + 按 `updated_at` 过滤 | ✅ | ⚠️ 取决于是否有历史版本 | ❌ |
|
| 截止时间戳 + 按 `updated_at` 过滤 | ✅ | ⚠️ 取决于是否有历史版本 | ❌ |
|
||||||
| 数据库快照(如 MySQL 一致性读快照、或导出前落临时表) | ✅ | ✅ | ✅ |
|
| 数据库快照(如 MySQL 一致性读快照、或导出前落临时表) | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
**一期 `CUSTOMER_LIST` 采用"签名后的最大 ID",即只保证不混入新增数据,不保证修改与删除的一致性。** 这对客户列表导出是可接受的——导出期间恰好有客户资料被改动的概率极低,且影响仅是个别字段取值偏新。
|
**订单与客户 `CUSTOMER_LIST` 采用"签名后的最大 ID",即只保证不混入新增数据,不保证修改与删除的一致性。** 这对客户列表导出是可接受的——导出期间恰好有客户资料被改动的概率极低,且影响仅是个别字段取值偏新。
|
||||||
|
|
||||||
因此对外表述应为“**不会混入导出开始后新增的数据**”,而非笼统的“数据快照一致”。若某个导出类型确实需要完整快照(如财务对账),该 Provider 需自行实现临时表或一致性读方案,本契约不阻止,但也不代为实现。
|
因此对外表述应为“**不会混入导出开始后新增的数据**”,而非笼统的“数据快照一致”。若某个导出类型确实需要完整快照(如财务对账),该 Provider 需自行实现临时表或一致性读方案,本契约不阻止,但也不代为实现。
|
||||||
|
|
||||||
@@ -916,15 +916,15 @@ baiyue:
|
|||||||
|
|
||||||
将现有 `LocalExportStorage` 提取为接口 `ExportStorage`(`create` / `size` / `load` / `delete`),实现类 `SharedDirectoryExportStorage` 保持当前行为与对象键格式 `exports/{taskId}/{uuid}.xlsx`。Consumer、API、Reconciler 只依赖接口。
|
将现有 `LocalExportStorage` 提取为接口 `ExportStorage`(`create` / `size` / `load` / `delete`),实现类 `SharedDirectoryExportStorage` 保持当前行为与对象键格式 `exports/{taskId}/{uuid}.xlsx`。Consumer、API、Reconciler 只依赖接口。
|
||||||
|
|
||||||
一期不引入对象存储(§21 决策 6),但接口就位后未来替换为 MinIO/OSS 不需要改动调用方。**对象键始终由 File Service 生成与校验,业务服务不得提供。**
|
订单与客户不引入对象存储(§21 决策 6),但接口就位后未来替换为 MinIO/OSS 不需要改动调用方。**对象键始终由 File Service 生成与校验,业务服务不得提供。**
|
||||||
|
|
||||||
**普通宿主机目录挂载不构成"共享存储",它只在同一宿主机的容器之间共享。** 这是第一版的一处不实表述:部署已有的 `FILE_UPLOADS_HOST_DIR:/data/files` 是宿主机 bind mount,若 File Service 的两个实例被调度到**不同物理机**,各自看到的是各自宿主机上的独立目录——A 实例生成的文件,B 实例既读不到也删不掉,表现为:下载随机 404(请求打到没有该文件的实例)、过期清理留下永久孤儿文件。
|
**普通宿主机目录挂载不构成"共享存储",它只在同一宿主机的容器之间共享。** 这是第一版的一处不实表述:部署已有的 `FILE_UPLOADS_HOST_DIR:/data/files` 是宿主机 bind mount,若 File Service 的两个实例被调度到**不同物理机**,各自看到的是各自宿主机上的独立目录——A 实例生成的文件,B 实例既读不到也删不掉,表现为:下载随机 404(请求打到没有该文件的实例)、过期清理留下永久孤儿文件。
|
||||||
|
|
||||||
因此一期存储的明确边界,**二选一,必须在部署前确认**:
|
因此订单与客户存储的明确边界,**二选一,必须在部署前确认**:
|
||||||
|
|
||||||
| 方案 | 约束 | 适用 |
|
| 方案 | 约束 | 适用 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **A. 单节点部署**(一期默认) | File Service 只允许 1 个实例;若用编排系统需固定节点亲和,禁止自动扩容 | 1000 用户量级,导出低频,单实例足够 |
|
| **A. 单节点部署**(订单与客户默认) | File Service 只允许 1 个实例;若用编排系统需固定节点亲和,禁止自动扩容 | 1000 用户量级,导出低频,单实例足够 |
|
||||||
| **B. 跨主机共享卷** | 挂载 NFS / CephFS / 云盘共享卷到同一路径 | 需要多实例时 |
|
| **B. 跨主机共享卷** | 挂载 NFS / CephFS / 云盘共享卷到同一路径 | 需要多实例时 |
|
||||||
|
|
||||||
选 A 时必须注意:**代码不得因此引入任何单实例假设**——租约、`lease_owner`、条件更新全部保留,因为滚动发布期间会短暂存在两个实例,且方案 B 随时可能启用。单实例是部署约束,不是设计约束。
|
选 A 时必须注意:**代码不得因此引入任何单实例假设**——租约、`lease_owner`、条件更新全部保留,因为滚动发布期间会短暂存在两个实例,且方案 B 随时可能启用。单实例是部署约束,不是设计约束。
|
||||||
@@ -1020,7 +1020,7 @@ spring:
|
|||||||
- 文件删除成功或确认不存在后,任务才能标记 `EXPIRED`;删除失败保持原状并重试 + 告警(沿用现有实现)。
|
- 文件删除成功或确认不存在后,任务才能标记 `EXPIRED`;删除失败保持原状并重试 + 告警(沿用现有实现)。
|
||||||
- `file_export_outbox` 的 `SENT` 行保留 7 天后**物理删除**(每日一次,每次 ≤5000 行分批删除)。清理必须限定 `status = 'SENT'`,绝不能删除 `PENDING` / `CLAIMED` 行——它们是尚未送达的命令。
|
- `file_export_outbox` 的 `SENT` 行保留 7 天后**物理删除**(每日一次,每次 ≤5000 行分批删除)。清理必须限定 `status = 'SENT'`,绝不能删除 `PENDING` / `CLAIMED` 行——它们是尚未送达的命令。
|
||||||
- `file_export_task` 终态行长期保留(下载中心需要历史),当前量级无需归档。
|
- `file_export_task` 终态行长期保留(下载中心需要历史),当前量级无需归档。
|
||||||
- 存储:一期按 §11.1 方案 A(单节点)或方案 B(跨主机共享卷)二选一,部署前必须确认。宿主机 bind mount 只在同一主机内共享,**不足以支撑跨主机多实例**。
|
- 存储:订单与客户按 §11.1 方案 A(单节点)或方案 B(跨主机共享卷)二选一,部署前必须确认。宿主机 bind mount 只在同一主机内共享,**不足以支撑跨主机多实例**。
|
||||||
- 对象键由 File Service 生成和校验,业务服务不得提供。
|
- 对象键由 File Service 生成和校验,业务服务不得提供。
|
||||||
|
|
||||||
## 16. 基础设施落地
|
## 16. 基础设施落地
|
||||||
@@ -1333,7 +1333,7 @@ RocketMQ NameServer/Broker 就绪
|
|||||||
- 存储的多实例边界已明确(§11.1),不把宿主机 bind mount 当作跨主机共享存储。
|
- 存储的多实例边界已明确(§11.1),不把宿主机 bind mount 当作跨主机共享存储。
|
||||||
- MQ 不承担延迟与业务重排;Reconciler 是唯一重派通道;DLQ 只承载损坏消息。
|
- MQ 不承担延迟与业务重排;Reconciler 是唯一重派通道;DLQ 只承载损坏消息。
|
||||||
- Outbox 与任务均有租约,无“领取后崩溃即永久卡死”的路径。
|
- Outbox 与任务均有租约,无“领取后崩溃即永久卡死”的路径。
|
||||||
- 至少一次投递、幂等、租约、Outbox、Reconciler、DLQ、指标与告警全部纳入一期实现。
|
- 至少一次投递、幂等、租约、Outbox、Reconciler、DLQ、指标与告警全部纳入订单与客户实现。
|
||||||
- 新业务类型能通过 Provider 契约 + 受控注册接入,不复制客户导出代码。
|
- 新业务类型能通过 Provider 契约 + 受控注册接入,不复制客户导出代码。
|
||||||
|
|
||||||
## 21. 已定型决策
|
## 21. 已定型决策
|
||||||
@@ -1342,12 +1342,12 @@ RocketMQ NameServer/Broker 就绪
|
|||||||
|
|
||||||
| # | 决策 | 取值 | 理由 |
|
| # | 决策 | 取值 | 理由 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | 一期是否含 MQ | 含 | 业务方已定。可靠性由数据库承担,MQ 引入不降低稳定性 |
|
| 1 | 订单与客户是否含 MQ | 含 | 业务方已定。可靠性由数据库承担,MQ 引入不降低稳定性 |
|
||||||
| 2 | MQ 客户端 | `rocketmq-spring-boot-starter` 2.3.3,Remoting 协议 | 需要显式控制消费返回语义;不引入 Proxy 少一个可失效组件;同时删除 `firstleg` 的 Stream 死依赖。Boot 3.2.9 兼容性与 `rocketmq-client 5.3.1` 传递版本已实证核验,见 §6.1 |
|
| 2 | MQ 客户端 | `rocketmq-spring-boot-starter` 2.3.3,Remoting 协议 | 需要显式控制消费返回语义;不引入 Proxy 少一个可失效组件;同时删除 `firstleg` 的 Stream 死依赖。Boot 3.2.9 兼容性与 `rocketmq-client 5.3.1` 传递版本已实证核验,见 §6.1 |
|
||||||
| 3 | Outbox 是否抽公共模块 | 一期不抽,`file_export_outbox` 独立建表并作为全仓基线 | 一期只有一个真实使用者,过早抽象会把接口定错。`order` 的 Outbox 目前**根本没有发布器**,是独立待修问题;后续收敛时以本表结构为准(含 `lease_owner`/`lease_until`/`next_retry_at`),命名统一为 `<模块>_outbox` |
|
| 3 | Outbox 是否抽公共模块 | 订单与客户不抽,`file_export_outbox` 独立建表并作为全仓基线 | 订单与客户只有一个真实使用者,过早抽象会把接口定错。`order` 的 Outbox 目前**根本没有发布器**,是独立待修问题;后续收敛时以本表结构为准(含 `lease_owner`/`lease_until`/`next_retry_at`),命名统一为 `<模块>_outbox` |
|
||||||
| 4 | 契约放哪 | 放入 `common-core` 的 `com.baiyue.common.core.export` 包,**不新建模块**,模块总数仍为 18 | 契约是 file 与 iam 双方共用的 HTTP 结构。放 `file` 内会迫使 iam 依赖 `file-service`(拖入 POI/MyBatis/任务表实体,且业务服务反向依赖 file);新建模块则多一个模块。放 `common-core` 两全:双方已通过 `common-web` 传递依赖到它,pom 都不用改。与 `common-event-contract` 不合并——后者是跨区域事件信封(含 `sourceRegion`/`payloadHash` 等无关字段),强行复用会互相牵制 |
|
| 4 | 契约放哪 | 放入 `common-core` 的 `com.baiyue.common.core.export` 包,**不新建模块**,模块总数仍为 18 | 契约是 file 与 iam 双方共用的 HTTP 结构。放 `file` 内会迫使 iam 依赖 `file-service`(拖入 POI/MyBatis/任务表实体,且业务服务反向依赖 file);新建模块则多一个模块。放 `common-core` 两全:双方已通过 `common-web` 传递依赖到它,pom 都不用改。与 `common-event-contract` 不合并——后者是跨区域事件信封(含 `sourceRegion`/`payloadHash` 等无关字段),强行复用会互相牵制 |
|
||||||
| 5 | Prometheus | 一期补 `micrometer-registry-prometheus`;服务端由运维落地 | 无 registry 则 §13 全部指标无处可采,等于没有可观测性 |
|
| 5 | Prometheus | 订单与客户补 `micrometer-registry-prometheus`;服务端由运维落地 | 无 registry 则 §13 全部指标无处可采,等于没有可观测性 |
|
||||||
| 6 | 文件存储 | 一期共享目录挂载 + `ExportStorage` 接口抽象 | 1000 用户、7 天保留、单文件 ≤50MB,总量在 GB 级,共享目录足够。引入 MinIO 多一个有状态组件与备份负担。接口就位后替换不改调用方 |
|
| 6 | 文件存储 | 订单与客户共享目录挂载 + `ExportStorage` 接口抽象 | 1000 用户、7 天保留、单文件 ≤50MB,总量在 GB 级,共享目录足够。引入 MinIO 多一个有状态组件与备份负担。接口就位后替换不改调用方 |
|
||||||
| 7 | 导出类型注册 | Nacos 受控配置 + 代码内置服务白名单 + 启动强校验 | 纯 Nacos 有 SSRF 面;纯代码注册每加类型都要发版。两者结合:配置只能从白名单里选 |
|
| 7 | 导出类型注册 | Nacos 受控配置 + 代码内置服务白名单 + 启动强校验 | 纯 Nacos 有 SSRF 面;纯代码注册每加类型都要发版。两者结合:配置只能从白名单里选 |
|
||||||
| 8 | Testcontainers | 不引入,集成测试连测试环境 Broker | 全仓无 Testcontainers 先例,RocketMQ 容器需同时起 NameServer+Broker 并处理 `brokerIP1`,投入产出比低于直接用测试环境 |
|
| 8 | Testcontainers | 不引入,集成测试连测试环境 Broker | 全仓无 Testcontainers 先例,RocketMQ 容器需同时起 NameServer+Broker 并处理 `brokerIP1`,投入产出比低于直接用测试环境 |
|
||||||
| 9 | 新旧双轨 | 不双轨,阶段 3 直接停用旧 Worker | 旧 `claim()` 不识别 `next_retry_at` 与 `dispatch_version`,双轨会提前执行退避中的任务。回滚靠配置开关,不靠双轨 |
|
| 9 | 新旧双轨 | 不双轨,阶段 3 直接停用旧 Worker | 旧 `claim()` 不识别 `next_retry_at` 与 `dispatch_version`,双轨会提前执行退避中的任务。回滚靠配置开关,不靠双轨 |
|
||||||
@@ -1359,8 +1359,8 @@ RocketMQ NameServer/Broker 就绪
|
|||||||
| 15 | Outbox 失败终态 | 取消 `DEAD`,发送无限退避重试 + 告警 | 设终态会让 Broker 长期故障时任务静默停滞,违背 §2.1 的“提交成功的任务最终一定被调度”。靠告警驱动人工介入,Broker 恢复后自动追平 |
|
| 15 | Outbox 失败终态 | 取消 `DEAD`,发送无限退避重试 + 告警 | 设终态会让 Broker 长期故障时任务静默停滞,违背 §2.1 的“提交成功的任务最终一定被调度”。靠告警驱动人工介入,Broker 恢复后自动追平 |
|
||||||
| 16 | 续租方式 | 独立 watchdog 线程按 60 秒周期,覆盖全执行期 | 挂在批次循环里会漏掉最终 `write()`/压缩/落盘阶段,慢磁盘下大文件会在临成功时被误判失联并反复重跑 |
|
| 16 | 续租方式 | 独立 watchdog 线程按 60 秒周期,覆盖全执行期 | 挂在批次循环里会漏掉最终 `write()`/压缩/落盘阶段,慢磁盘下大文件会在临成功时被误判失联并反复重跑 |
|
||||||
| 17 | 时间基准 | 全部走数据库 `CURRENT_TIMESTAMP(3)` | 实例时钟偏差会导致提前抢占(两实例同写一任务)或延迟恢复 |
|
| 17 | 时间基准 | 全部走数据库 `CURRENT_TIMESTAMP(3)` | 实例时钟偏差会导致提前抢占(两实例同写一任务)或延迟恢复 |
|
||||||
| 18 | 多实例部署 | 代码支持,**一期部署按单节点**;需多实例时先上跨主机共享卷 | 宿主机 bind mount 只在同主机内共享,跨主机会导致下载随机 404 与永久孤儿文件(§11.1) |
|
| 18 | 多实例部署 | 代码支持,**订单与客户部署按单节点**;需多实例时先上跨主机共享卷 | 宿主机 bind mount 只在同主机内共享,跨主机会导致下载随机 404 与永久孤儿文件(§11.1) |
|
||||||
| 19 | 快照强度 | 一期只保证“不混入导出开始后新增的数据”,不冻结修改与删除 | 最大 ID 游标的固有限制。对客户列表可接受;需要完整快照的类型由该 Provider 自行实现临时表或一致性读(§10.2) |
|
| 19 | 快照强度 | 订单与客户只保证“不混入导出开始后新增的数据”,不冻结修改与删除 | 最大 ID 游标的固有限制。对客户列表可接受;需要完整快照的类型由该 Provider 自行实现临时表或一致性读(§10.2) |
|
||||||
| 20 | 权限取值时点 | 创建与人工重试时校验,执行阶段不校验;不存权限快照 | 执行在后台无用户会话,无法重新鉴权。已受理即视为已授权,代价与已知缺口写明于 §10.4 |
|
| 20 | 权限取值时点 | 创建与人工重试时校验,执行阶段不校验;不存权限快照 | 执行在后台无用户会话,无法重新鉴权。已受理即视为已授权,代价与已知缺口写明于 §10.4 |
|
||||||
| 21 | 毒消息处置 | 独立 DLQ Handler 自动将匹配的 Outbox 置 `QUARANTINED`、任务置 `FAILED` | 人工隔离无法保证在 Reconciler 10 分钟补投窗口内完成;定位字段放 User Properties,避免依赖损坏 payload |
|
| 21 | 毒消息处置 | 独立 DLQ Handler 自动将匹配的 Outbox 置 `QUARANTINED`、任务置 `FAILED` | 人工隔离无法保证在 Reconciler 10 分钟补投窗口内完成;定位字段放 User Properties,避免依赖损坏 payload |
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 目的与口径
|
## 目的与口径
|
||||||
|
|
||||||
本基线覆盖当前官方 22 个 Maven 模块、15 个可执行服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。海外安全 Inbox/投影属于 Lastleg 内部子域,不再作为独立服务节点。基线用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或四期业务状态机。
|
本基线覆盖当前官方 22 个 Maven 模块、15 个可执行服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。海外安全 Inbox/投影属于 Lastleg 内部子域,不再作为独立服务节点。基线用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或海外仓业务业务状态机。
|
||||||
|
|
||||||
“存在双向服务边”不等于“一次在线请求会递归调用”。每条边必须继续区分:
|
“存在双向服务边”不等于“一次在线请求会递归调用”。每条边必须继续区分:
|
||||||
|
|
||||||
@@ -54,12 +54,14 @@
|
|||||||
|
|
||||||
#### Lastleg 四个有界 RestClient
|
#### Lastleg 四个有界 RestClient
|
||||||
|
|
||||||
四个客户端统一从 `LastlegRestClientFactory` 构建 JDK HTTP 传输,由 `LastlegRestClientProperties` 绑定独立超时。读取超时是单次请求的硬边界;客户端没有 Retry、fallback 或默认成功配置。
|
六个客户端统一从 `LastlegRestClientFactory` 构建 JDK HTTP 传输,由 `LastlegRestClientProperties` 绑定独立超时。读取超时是单次请求的硬边界;客户端没有 Retry、fallback 或默认成功配置。
|
||||||
|
|
||||||
| 客户端 | 构建与调用路径 | 写操作幂等性 | 默认超时 |
|
| 客户端 | 构建与调用路径 | 写操作幂等性 | 默认超时 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `HttpUnloadPhotoFileGateway` | `ReceivingContainerController → ReceivingContainerService → UnloadPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 每次写文件和元数据,没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 |
|
| `HttpUnloadPhotoFileGateway` | `ReceivingContainerController → ReceivingContainerService → UnloadPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 每次写文件和元数据,没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 |
|
||||||
| `HttpRelabelPhotoFileGateway` | `RelabelPdaController/Service → RelabelPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 |
|
| `HttpRelabelPhotoFileGateway` | `RelabelPdaController/Service → RelabelPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 |
|
||||||
|
| `HttpCustomerVisitFileGateway` | `CustomerVisitService → CustomerVisitFileGateway → /internal/file/v1/business-files/**` | 附件上传没有幂等键,禁止自动重试;元数据读取可重复 | 连接 3 秒、读取 30 秒 |
|
||||||
|
| `HttpProcurementFileGateway` | `OverseasProcurementService → ProcurementFileGateway → /internal/file/v1/business-files/**` | 采购凭证和签收文件上传没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 |
|
||||||
| `HttpWarehouseResourceGateway` | `InboundPdaWorkflowService`、PDA 仓内查询/盘点及 Dropship → Warehouse Resource/Pallet 内部接口 | 查询可重复;`reserve` 使用 `reservationKey`,`ensureDropship` 使用业务绑定,release/scrap 由服务端状态收敛;尽管服务端具备业务幂等,客户端仍不自动重试 | 连接 3 秒、读取 5 秒 |
|
| `HttpWarehouseResourceGateway` | `InboundPdaWorkflowService`、PDA 仓内查询/盘点及 Dropship → Warehouse Resource/Pallet 内部接口 | 查询可重复;`reserve` 使用 `reservationKey`,`ensureDropship` 使用业务绑定,release/scrap 由服务端状态收敛;尽管服务端具备业务幂等,客户端仍不自动重试 | 连接 3 秒、读取 5 秒 |
|
||||||
| `HttpPalletPhysicalGateway` | `PdaPalletOperationService`、`PdaInventoryMoveService` → `/internal/v1/pda-pallet-transitions/**` | 查询可重复;apply/compensate 使用稳定 `operationKey` 与请求哈希,服务端幂等;客户端仍不自动重试 | 连接 3 秒、读取 5 秒 |
|
| `HttpPalletPhysicalGateway` | `PdaPalletOperationService`、`PdaInventoryMoveService` → `/internal/v1/pda-pallet-transitions/**` | 查询可重复;apply/compensate 使用稳定 `operationKey` 与请求哈希,服务端幂等;客户端仍不自动重试 | 连接 3 秒、读取 5 秒 |
|
||||||
|
|
||||||
@@ -106,7 +108,7 @@ File 的五个导出 Provider、Order→Firstleg 入库 Outbox、该入库消费
|
|||||||
|
|
||||||
### 已完成的第一个 REQUEST 边退出
|
### 已完成的第一个 REQUEST 边退出
|
||||||
|
|
||||||
`IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化已经从 REQUEST WRITE 改为可靠后台编排。IAM 与客户主档同事务持久化稳定 `initializationKey` 的任务,显式维护 `PENDING/READY/FAILED`,由租约调度重试并提供诊断查询和人工重试;审批通过与下单资格在未 READY 时失败关闭。此边现在仅属于 BACKGROUND,在线五服务强连通分量实际拆为 Auth↔IAM 与 Order↔Firstleg 两个子环,未触碰财务、Event Bridge 或四期业务状态机。
|
`IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化已经从 REQUEST WRITE 改为可靠后台编排。IAM 与客户主档同事务持久化稳定 `initializationKey` 的任务,显式维护 `PENDING/READY/FAILED`,由租约调度重试并提供诊断查询和人工重试;审批通过与下单资格在未 READY 时失败关闭。此边现在仅属于 BACKGROUND,在线五服务强连通分量实际拆为 Auth↔IAM 与 Order↔Firstleg 两个子环,未触碰财务、Event Bridge 或海外仓业务业务状态机。
|
||||||
|
|
||||||
### Order↔Firstleg 第二阶段结论
|
### Order↔Firstleg 第二阶段结论
|
||||||
|
|
||||||
@@ -143,7 +145,7 @@ File 的五个导出 Provider、Order→Firstleg 入库 Outbox、该入库消费
|
|||||||
| IAM/Firstleg | `mvn -pl iam,firstleg -am test` |
|
| IAM/Firstleg | `mvn -pl iam,firstleg -am test` |
|
||||||
| Firstleg/Finance | `mvn -pl firstleg,finance -am test`;业务口径先由财务总控确认 |
|
| Firstleg/Finance | `mvn -pl firstleg,finance -am test`;业务口径先由财务总控确认 |
|
||||||
| Firstleg/File 或 File/Finance | `mvn -pl firstleg,file,finance -am test`,按实际消费者缩小模块集 |
|
| Firstleg/File 或 File/Finance | `mvn -pl firstleg,file,finance -am test`,按实际消费者缩小模块集 |
|
||||||
| Lastleg 原始 HTTP | `mvn -pl lastleg-service,warehouse-overseas-service,file -am test`;不改变四期状态机 |
|
| Lastleg 原始 HTTP | `mvn -pl lastleg-service,warehouse-overseas-service,file -am test`;不改变海外仓业务状态机 |
|
||||||
| 全部治理阶段收口 | `mvn -DskipTests validate` 后执行 `mvn test` |
|
| 全部治理阶段收口 | `mvn -DskipTests validate` 后执行 `mvn test` |
|
||||||
|
|
||||||
以上均为本地离线验证;未经明确授权不启动 Java/Nacos/数据库,不执行 SQL、部署或测试环境 HTTP 验收。
|
以上均为本地离线验证;未经明确授权不启动 Java/Nacos/数据库,不执行 SQL、部署或测试环境 HTTP 验收。
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ baiyue-firstleg/
|
|||||||
├─ auth/ # 统一登录与账号凭据
|
├─ auth/ # 统一登录与账号凭据
|
||||||
├─ iam/ # 租户、成员、角色、权限、企业审核
|
├─ iam/ # 租户、成员、角色、权限、企业审核
|
||||||
├─ order/ # 头程订单中心
|
├─ order/ # 头程订单中心
|
||||||
├─ firstleg/ # 履约任务、二期入库、测量与计费
|
├─ firstleg/ # 履约任务、入库、测量与计费
|
||||||
├─ warehouse/ # 仓库主数据、托盘与库位资源
|
├─ warehouse/ # 仓库主数据、托盘与库位资源
|
||||||
├─ finance/ # 财务账期、账单、收付款与核销
|
├─ finance/ # 财务账期、账单、收付款与核销
|
||||||
├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务
|
├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务
|
||||||
@@ -93,9 +93,9 @@ baiyue-firstleg/
|
|||||||
| `gateway-overseas` | `gateway-overseas` | 9300 | 无 | 海外区域 API 入口、海外 Audience/JWKS 校验及海外服务路由 |
|
| `gateway-overseas` | `gateway-overseas` | 9300 | 无 | 海外区域 API 入口、海外 Audience/JWKS 校验及海外服务路由 |
|
||||||
| `auth` | `auth-service` | 9100 | `baiyue_auth` | 登录、刷新 Token、退出、商户注册、账号密码和账号内部管理 |
|
| `auth` | `auth-service` | 9100 | `baiyue_auth` | 登录、刷新 Token、退出、商户注册、账号密码和账号内部管理 |
|
||||||
| `iam` | `iam-service` | 9201 | `baiyue_iam` | 租户、企业资料、企业审核、成员、角色、权限、按钮权限、个人资料和授权快照 |
|
| `iam` | `iam-service` | 9201 | `baiyue_iam` | 租户、企业资料、企业审核、成员、角色、权限、按钮权限、个人资料和授权快照 |
|
||||||
| `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务与分配;二期入库单、测量审核、FBA 计费、费用发布和完成入库编排;不是订单主数据服务 |
|
| `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务与分配;入库单、测量审核、FBA 计费、费用发布和完成入库编排;不是订单主数据服务 |
|
||||||
| `order` | `order-service` | 9204 | `baiyue_order` | 订单草稿、编辑、提交、详情、状态历史、货物、往来单位快照、内部备注和 Outbox |
|
| `order` | `order-service` | 9204 | `baiyue_order` | 订单草稿、编辑、提交、详情、状态历史、货物、往来单位快照、内部备注和 Outbox |
|
||||||
| `warehouse` | `warehouse-service` | 9205 | `baiyue_warehouse` | 仓库、地址、服务能力、往来单位、快递公司,以及二期托盘、库位、预占和占用;往来单位不单独拆服务 |
|
| `warehouse` | `warehouse-service` | 9205 | `baiyue_warehouse` | 仓库、地址、服务能力、往来单位、快递公司,以及入库测量与计费托盘、库位、预占和占用;往来单位不单独拆服务 |
|
||||||
| `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 |
|
| `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 |
|
||||||
| `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF |
|
| `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF |
|
||||||
| `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 |
|
| `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 |
|
||||||
@@ -128,13 +128,13 @@ Order 负责订单草稿、提交、平台处理状态、货物、参与方和
|
|||||||
|
|
||||||
### 5.5 `firstleg/`:头程履约中心
|
### 5.5 `firstleg/`:头程履约中心
|
||||||
|
|
||||||
Firstleg 承担订单提交后的履约任务、操作分配与历史,以及二期入库单查询、测量回传、人工测量、审核、异常放行、FBA 计费、费用发布和完成入库状态编排。
|
Firstleg 承担订单提交后的履约任务、操作分配与历史,以及入库单查询、测量回传、人工测量、审核、异常放行、FBA 计费、费用发布和完成入库状态编排。
|
||||||
|
|
||||||
订单主单和货物主数据仍属于 Order;托盘和库位资源属于 Warehouse;图片对象属于 File;OCR 供应商协议属于 Integration。Firstleg 只保存自身业务状态、快照和逻辑引用,通过内部 DTO 调用其他服务,不复制对方领域模型。
|
订单主单和货物主数据仍属于 Order;托盘和库位资源属于 Warehouse;图片对象属于 File;OCR 供应商协议属于 Integration。Firstleg 只保存自身业务状态、快照和逻辑引用,通过内部 DTO 调用其他服务,不复制对方领域模型。
|
||||||
|
|
||||||
### 5.6 `warehouse/`:仓库与仓储资源
|
### 5.6 `warehouse/`:仓库与仓储资源
|
||||||
|
|
||||||
Warehouse 负责仓库、地址、服务能力、往来单位、快递公司,以及二期托盘、库位、预占和占用资源。往来单位只是 Warehouse 下的主数据配置,保存在 `baiyue_warehouse.wh_partner`,不创建独立服务和数据库。
|
Warehouse 负责仓库、地址、服务能力、往来单位、快递公司,以及入库测量与计费托盘、库位、预占和占用资源。往来单位只是 Warehouse 下的主数据配置,保存在 `baiyue_warehouse.wh_partner`,不创建独立服务和数据库。
|
||||||
|
|
||||||
Firstleg 通过内部接口发起托盘库位预占、确认和释放,只保存逻辑引用与入库快照。容量、共享溢出、预占和占用一致性由 Warehouse 的数据库事务和条件更新最终保证。
|
Firstleg 通过内部接口发起托盘库位预占、确认和释放,只保存逻辑引用与入库快照。容量、共享溢出、预占和占用一致性由 Warehouse 的数据库事务和条件更新最终保证。
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ Print 是无数据库的无状态 PDF 渲染服务。业务归属服务完成权
|
|||||||
|
|
||||||
### 5.10 `integration-service/`:第三方集成中心
|
### 5.10 `integration-service/`:第三方集成中心
|
||||||
|
|
||||||
Integration 负责百度 OCR、重量识别和 Amazon Logistics/SP-API 等第三方协议适配及能力状态。二期重量 OCR 由 File 调用,业务图片和入库状态不归 Integration;服务不使用业务数据库,真实外部调用默认关闭,密钥必须通过 Jenkins Credentials、环境变量或 Nacos 密文注入。
|
Integration 负责百度 OCR、重量识别和 Amazon Logistics/SP-API 等第三方协议适配及能力状态。入库测量与计费重量 OCR 由 File 调用,业务图片和入库状态不归 Integration;服务不使用业务数据库,真实外部调用默认关闭,密钥必须通过 Jenkins Credentials、环境变量或 Nacos 密文注入。
|
||||||
|
|
||||||
### 5.11 `event-bridge-service/`:跨区域事件桥
|
### 5.11 `event-bridge-service/`:跨区域事件桥
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ deploy/
|
|||||||
|
|
||||||
- `local-env.ps1`:本地服务环境变量
|
- `local-env.ps1`:本地服务环境变量
|
||||||
- `publish-local-nacos-config.ps1`:发布本地 Nacos 配置
|
- `publish-local-nacos-config.ps1`:发布本地 Nacos 配置
|
||||||
- `phase1-acceptance.ps1`:一期验收入口
|
- `phase1-acceptance.ps1`:订单与客户验收入口
|
||||||
- `phase1-business-maturity-regression.ps1`:业务成熟度回归
|
- `phase1-business-maturity-regression.ps1`:业务成熟度回归
|
||||||
- `phase1-onboarding-order-e2e.ps1`:企业入驻到订单端到端验证
|
- `phase1-onboarding-order-e2e.ps1`:企业入驻到订单端到端验证
|
||||||
- `order-warehouse-real-api-regression.ps1`:订单、仓库、往来单位真实 API 回归
|
- `order-warehouse-real-api-regression.ps1`:订单、仓库、往来单位真实 API 回归
|
||||||
|
|||||||
+2
-2
@@ -76,7 +76,7 @@
|
|||||||
| 库位占用记录 | `OCCUPIED` | 正在占用。 |
|
| 库位占用记录 | `OCCUPIED` | 正在占用。 |
|
||||||
| 库位占用记录 | `RELEASED` | 已释放。 |
|
| 库位占用记录 | `RELEASED` | 已释放。 |
|
||||||
|
|
||||||
## 二期入库
|
## 入库
|
||||||
|
|
||||||
### 入库单主流程状态
|
### 入库单主流程状态
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
| 入库与计费幂等记录 | `PROCESSING` | 请求正在执行。 |
|
| 入库与计费幂等记录 | `PROCESSING` | 请求正在执行。 |
|
||||||
| 入库与计费幂等记录 | `COMPLETED` | 请求已成功完成,可返回幂等结果。 |
|
| 入库与计费幂等记录 | `COMPLETED` | 请求已成功完成,可返回幂等结果。 |
|
||||||
|
|
||||||
## 三期智能装柜和 PDA
|
## 国内货柜履约智能装柜和 PDA
|
||||||
|
|
||||||
| 字段 | 状态值 | 中文含义 |
|
| 字段 | 状态值 | 中文含义 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## 职责边界
|
## 职责边界
|
||||||
|
|
||||||
|
- Mapper 只由 `EventBridgeApplication.java` 对 `com.baiyue.eventbridge` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||||
- 负责跨区事件接收、Inbox 持久化、按目标区域向海外尾程或国内头程投递、失败重试、投递记录和人工补投。
|
- 负责跨区事件接收、Inbox 持久化、按目标区域向海外尾程或国内头程投递、失败重试、投递记录和人工补投。
|
||||||
- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
|
- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
|
||||||
- 事件结构和 Payload 哈希以 `common-event-contract` 为准。
|
- 事件结构和 Payload 哈希以 `common-event-contract` 为准。
|
||||||
@@ -12,9 +13,10 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
|
| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
|
||||||
| Inbox 接收与幂等 | `service/EventInboxService.java` |
|
| Inbox 接收与幂等 | `service/EventInboxService.java` |
|
||||||
| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload |
|
| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 的 `BUSINESS_FEE_SOURCE_CHANGED` 与 `WALLET_COMMAND_REQUESTED` 定向投 Finance,其余国内回投 Firstleg;海外业务事件及 `WALLET_COMMAND_COMPLETED` 投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload |
|
||||||
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
|
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
|
||||||
| 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` |
|
| 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` |
|
||||||
|
| 国内 Finance 事件客户端 | `service/FinanceEventClient.java`、`HttpFinanceEventClient.java`;费用入口 `/internal/finance/v1/business-fee-source-events`,资金指令入口 `/internal/finance/v1/wallet-command-events`,分别配置 `finance-consumer-url`、`finance-wallet-consumer-url`,不得误投 Firstleg |
|
||||||
| 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` |
|
| 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` |
|
||||||
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
|
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
|
||||||
| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` |
|
| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` |
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
package com.baiyue.eventbridge;
|
package com.baiyue.eventbridge;
|
||||||
|
|
||||||
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
@@ -10,7 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
/**
|
/**
|
||||||
* 跨区域事件桥启动入口。
|
* 跨区域事件桥启动入口。
|
||||||
*/
|
*/
|
||||||
@MapperScan("com.baiyue.eventbridge.mapper")
|
@MapperScan(basePackages = "com.baiyue.eventbridge", annotationClass = Mapper.class)
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@EnableConfigurationProperties(EventDispatcherProperties.class)
|
@EnableConfigurationProperties(EventDispatcherProperties.class)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user