mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-08-30 05:22:01 +00:00
Compare commits
657
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efbadde4ed | ||
|
|
d1fa787bef | ||
|
|
c282fcb109 | ||
|
|
4995173de2 | ||
|
|
1c1068beba | ||
|
|
4a5240fcbc | ||
|
|
0f34672e33 | ||
|
|
ecd365767a | ||
|
|
f358140974 | ||
|
|
541c21f963 | ||
|
|
bdd253383d | ||
|
|
0fb53d5159 | ||
|
|
b9ff408bdd | ||
|
|
aeb4394cf8 | ||
|
|
8d419d3265 | ||
|
|
b70e30796f | ||
|
|
d3ef521773 | ||
|
|
2969c92d3d | ||
|
|
2fde421169 | ||
|
|
3494271150 | ||
|
|
675d930f86 | ||
|
|
5e20ed5050 | ||
|
|
91f4fcaa76 | ||
|
|
0754fe20f1 | ||
|
|
034fd20d3b | ||
|
|
363c603bbb | ||
|
|
24bf98bca9 | ||
|
|
275f6b9e6d | ||
|
|
5db9e422f4 | ||
|
|
705b6ba4b7 | ||
|
|
0edc057347 | ||
|
|
6ae5d998e6 | ||
|
|
cbb442ceb6 | ||
|
|
0103841fb5 | ||
|
|
b6b7f0d8b7 | ||
|
|
46c6827eda | ||
|
|
1ef6e120a8 | ||
|
|
65d518373d | ||
|
|
bca4da4b6c | ||
|
|
0fd78defcb | ||
|
|
452fd0ea2c | ||
|
|
dd8f2efb37 | ||
|
|
0feb8e798a | ||
|
|
d55d8edd2f | ||
|
|
451496f102 | ||
|
|
6d6351bd58 | ||
|
|
1a5fc8d1ef | ||
|
|
a44f30f7e5 | ||
|
|
f9ebd34de3 | ||
|
|
b47db300a6 | ||
|
|
d19d8573f9 | ||
|
|
799e120069 | ||
|
|
13f83e8795 | ||
|
|
7b5900aae4 | ||
|
|
e36c0bbe45 | ||
|
|
af723aed3a | ||
|
|
0978186679 | ||
|
|
17460ff271 | ||
|
|
f81b3852ee | ||
|
|
37abebf8f8 | ||
|
|
79fab8c0d6 | ||
|
|
9b70fb2778 | ||
|
|
bd2868748f | ||
|
|
1f47bf13b5 | ||
|
|
7d8b33afe0 | ||
|
|
367f7c78a4 | ||
|
|
1bd23ec4ae | ||
|
|
3461532679 | ||
|
|
4cfcdfa1f8 | ||
|
|
ac2f526a1c | ||
|
|
cfb624e9e0 | ||
|
|
e1bf45b5c8 | ||
|
|
e2dc611aa4 | ||
|
|
77c953626f | ||
|
|
0e14c9a925 | ||
|
|
a4e8585e2c | ||
|
|
cf7d64916e | ||
|
|
4ac80bbfe8 | ||
|
|
cfe8352d45 | ||
|
|
4a0fe61d30 | ||
|
|
c62e109aff | ||
|
|
ff400d3be3 | ||
|
|
1d243b8f1b | ||
|
|
732a7f86fe | ||
|
|
3c428f6a6d | ||
|
|
bff17f2d4e | ||
|
|
df600eaada | ||
|
|
dd4027c931 | ||
|
|
d1569195e4 | ||
|
|
d6e88f0c53 | ||
|
|
2a9fa81e56 | ||
|
|
5eed1fdfa0 | ||
|
|
cf7fc4f502 | ||
|
|
0ea31d631a | ||
|
|
74840ab63f | ||
|
|
31f33339f1 | ||
|
|
c0a0d0dc47 | ||
|
|
0cd77ee9b9 | ||
|
|
741d7aa8ca | ||
|
|
d06a80dc73 | ||
|
|
86b9c43b8b | ||
|
|
206981d4b4 | ||
|
|
7d5831b5f4 | ||
|
|
7ca63985bd | ||
|
|
06416a4e5f | ||
|
|
88739e8d1a | ||
|
|
ffaba4f496 | ||
|
|
c4f94a2bc7 | ||
|
|
49a3918244 | ||
|
|
caddff567f | ||
|
|
aed9e9f10d | ||
|
|
6557b49383 | ||
|
|
082cc4c743 | ||
|
|
e261ebe698 | ||
|
|
5a0dc69186 | ||
|
|
29d8bf3ea4 | ||
|
|
07a330cfd4 | ||
|
|
aa30571709 | ||
|
|
2bb9912cf5 | ||
|
|
8f77d9fe98 | ||
|
|
9cb32b3e8f | ||
|
|
080206925f | ||
|
|
54d2a8189c | ||
|
|
377bc12cf9 | ||
|
|
da715c8a8f | ||
|
|
fa4028296f | ||
|
|
dc629a3126 | ||
|
|
746c7ad5b3 | ||
|
|
aef1b9ab11 | ||
|
|
79c9eb3dda | ||
|
|
5a08ed68c2 | ||
|
|
e5a623c5a8 | ||
|
|
4586138bf1 | ||
|
|
1dddec110e | ||
|
|
46e9999e4c | ||
|
|
a664ae3a56 | ||
|
|
9bcdcb2cb7 | ||
|
|
0b8592559a | ||
|
|
c0b18be5ab | ||
|
|
46b2eb1ccd | ||
|
|
d323376bed | ||
|
|
838c86ae15 | ||
|
|
42925c857c | ||
|
|
ba981d281f | ||
|
|
4159b884de | ||
|
|
36b38421e5 | ||
|
|
b77c8a80e9 | ||
|
|
886dcd039f | ||
|
|
d99885d396 | ||
|
|
942de9c430 | ||
|
|
ae3ff9ecbb | ||
|
|
bcc4315ea9 | ||
|
|
2f7304ab2d | ||
|
|
0df01ba3d5 | ||
|
|
710e454fd0 | ||
|
|
c46dfa00a0 | ||
|
|
9a3ea05023 | ||
|
|
66d7a62d3a | ||
|
|
f1b6cd3e18 | ||
|
|
189d1477a8 | ||
|
|
9c3945f45a | ||
|
|
77c7d6c5d6 | ||
|
|
ab3009e9cc | ||
|
|
1c2291f9cf | ||
|
|
efb135ee48 | ||
|
|
0699c4a127 | ||
|
|
33cef5f8e1 | ||
|
|
32d467b6d9 | ||
|
|
6dfa770137 | ||
|
|
0dfee8ab22 | ||
|
|
85fe910f25 | ||
|
|
6c60b0116f | ||
|
|
8dfcf510f6 | ||
|
|
be1ed3d46d | ||
|
|
1fca578c07 | ||
|
|
a83665ac44 | ||
|
|
21e8a370c3 | ||
|
|
3dd4dd139b | ||
|
|
afe2046bc8 | ||
|
|
6d24388690 | ||
|
|
0b024a849a | ||
|
|
8745dc3567 | ||
|
|
1f4c7019d4 | ||
|
|
255e7b2fb5 | ||
|
|
7419e536cf | ||
|
|
74df000287 | ||
|
|
2e0127d609 | ||
|
|
2b9168e8df | ||
|
|
7bb3cf7c51 | ||
|
|
2ce5058be0 | ||
|
|
a5fc41f152 | ||
|
|
3245a27156 | ||
|
|
ce0fbf65aa | ||
|
|
eb87c3d865 | ||
|
|
990a90e461 | ||
|
|
45cb255a4d | ||
|
|
5e8b8a3575 | ||
|
|
c317e53c24 | ||
|
|
29b59d8450 | ||
|
|
97b7e2f86e | ||
|
|
2f55294b58 | ||
|
|
2161190d9a | ||
|
|
aaae301cbc | ||
|
|
9ca6511235 | ||
|
|
8582290db3 | ||
|
|
5ff33d7c58 | ||
|
|
0cfb69a240 | ||
|
|
110a9beda4 | ||
|
|
fd6a3f5929 | ||
|
|
82ad6ec427 | ||
|
|
1bfc7c960d | ||
|
|
332f49f483 | ||
|
|
b967c7a1bb | ||
|
|
519dbe1f77 | ||
|
|
c64855d4ad | ||
|
|
d50d10ba89 | ||
|
|
e79478c421 | ||
|
|
c401a84eb8 | ||
|
|
a9978a6202 | ||
|
|
cc9d0a4b30 | ||
|
|
696ef832f8 | ||
|
|
442f9d1d2e | ||
|
|
a45a64380c | ||
|
|
df7442c3dd | ||
|
|
1a949725f3 | ||
|
|
7c14f3437b | ||
|
|
bc402da365 | ||
|
|
b95b474660 | ||
|
|
691a3770d9 | ||
|
|
49ec54a3b5 | ||
|
|
2fc15f437e | ||
|
|
190f6ca7ab | ||
|
|
c683fd27d4 | ||
|
|
d815cc1010 | ||
|
|
fd84ff1200 | ||
|
|
a420bad305 | ||
|
|
6ef6e47580 | ||
|
|
94f83ec296 | ||
|
|
702569c701 | ||
|
|
d4940ca9ee | ||
|
|
dbd1c138ca | ||
|
|
0b49c55cf3 | ||
|
|
b1ec3b2eea | ||
|
|
9ea89feee7 | ||
|
|
4a843194a3 | ||
|
|
03503115fd | ||
|
|
1870aef60e | ||
|
|
ed8fd66d1e | ||
|
|
c1c4c8cdc5 | ||
|
|
256ec3b152 | ||
|
|
da490e5bbd | ||
|
|
ba0ac86eea | ||
|
|
b5544c4131 | ||
|
|
d94ea6aaf3 | ||
|
|
742dda8677 | ||
|
|
76e0db0cfb | ||
|
|
6458a6e2c5 | ||
|
|
cbf2294a8e | ||
|
|
9d558bf4e2 | ||
|
|
fdf067c25e | ||
|
|
5f9da47513 | ||
|
|
b150641e3b | ||
|
|
6355c35452 | ||
|
|
81ffbbd6b1 | ||
|
|
07c650a474 | ||
|
|
04443bcb5e | ||
|
|
d06974d556 | ||
|
|
80fa51fd0a | ||
|
|
a170134456 | ||
|
|
ba76e0dc6c | ||
|
|
4a79542a46 | ||
|
|
999e260a60 | ||
|
|
ed40b254e4 | ||
|
|
8e9b9e6347 | ||
|
|
4fa2d88204 | ||
|
|
cb6d4811d2 | ||
|
|
c19601b209 | ||
|
|
6e6215ad7e | ||
|
|
3a25e5f2ae | ||
|
|
97ae1a5e92 | ||
|
|
7fc6367b9e | ||
|
|
a4a521a6f8 | ||
|
|
cb5c83be41 | ||
|
|
2056a91071 | ||
|
|
c760e47154 | ||
|
|
f6209a8959 | ||
|
|
3d30835ffa | ||
|
|
72ed0ea8f8 | ||
|
|
ea47bb39eb | ||
|
|
d698f82299 | ||
|
|
c45967e175 | ||
|
|
ba4666c32a | ||
|
|
1b357de2f3 | ||
|
|
d6d37e8204 | ||
|
|
cf76a5ddd6 | ||
|
|
cb9dbfcc69 | ||
|
|
4e27bf0dc4 | ||
|
|
42b366ed0f | ||
|
|
39c082e4ee | ||
|
|
4021c507b6 | ||
|
|
95193fc8f9 | ||
|
|
4d8e82080d | ||
|
|
4f3131979f | ||
|
|
fca608b44e | ||
|
|
ad9a8ab0b0 | ||
|
|
34cb89a6ea | ||
|
|
1f3161216a | ||
|
|
69d5f269bd | ||
|
|
e5f7b164a3 | ||
|
|
459c974cb8 | ||
|
|
7974c92382 | ||
|
|
2f22cb01eb | ||
|
|
b51add45f1 | ||
|
|
cb132359fd | ||
|
|
b58b3658b5 | ||
|
|
9f25aca242 | ||
|
|
343c017c79 | ||
|
|
83af09cf58 | ||
|
|
449475785f | ||
|
|
047a8eab89 | ||
|
|
93835bd990 | ||
|
|
93ab3f3f3f | ||
|
|
de7703be83 | ||
|
|
ce1c4ee669 | ||
|
|
c60d9fdd61 | ||
|
|
2033542f49 | ||
|
|
3775cd0259 | ||
|
|
bec342d778 | ||
|
|
6305d805dd | ||
|
|
2ada2fddf7 | ||
|
|
66ba8b7ee8 | ||
|
|
2edf235941 | ||
|
|
57ef723368 | ||
|
|
366658b471 | ||
|
|
2a3244a8fa | ||
|
|
9912e6fef1 | ||
|
|
d475dcbcdc | ||
|
|
5ffe94e3a4 | ||
|
|
5809d3b664 | ||
|
|
2bed42a088 | ||
|
|
97e912c216 | ||
|
|
f657aa183c | ||
|
|
cf634c4464 | ||
|
|
a9b5d9ab57 | ||
|
|
f82267f8a7 | ||
|
|
46bd5819b3 | ||
|
|
d10a55d8cb | ||
|
|
e74d5ea97e | ||
|
|
1dfdff7024 | ||
|
|
f5c81e1b8e | ||
|
|
804b8853d9 | ||
|
|
f87a66bc79 | ||
|
|
74b9cc438c | ||
|
|
7829174cc0 | ||
|
|
2e558df96b | ||
|
|
474eea5f80 | ||
|
|
fc50d1f5ba | ||
|
|
3b63f48dfa | ||
|
|
95697de1d0 | ||
|
|
1aa9ecdc45 | ||
|
|
0a3dbc6342 | ||
|
|
72f215c301 | ||
|
|
add90984a5 | ||
|
|
857ac7a2c9 | ||
|
|
b807223614 | ||
|
|
c775dabd7e | ||
|
|
5ee0aae6e1 | ||
|
|
5c8e4d7754 | ||
|
|
79123448f6 | ||
|
|
8e4328f1eb | ||
|
|
63c810dcbb | ||
|
|
c71d4cfa87 | ||
|
|
2b17567a1d | ||
|
|
ba5f88af43 | ||
|
|
782f47c7c5 | ||
|
|
8f92ce9292 | ||
|
|
1c3b5082a2 | ||
|
|
8535f49786 | ||
|
|
92043cf415 | ||
|
|
c97a235e23 | ||
|
|
97c2fe6784 | ||
|
|
1baa6e36b8 | ||
|
|
9dbc8718a8 | ||
|
|
3107281cdc | ||
|
|
c5b2340fe6 | ||
|
|
2f6f977adb | ||
|
|
902443b511 | ||
|
|
322f173104 | ||
|
|
c61ba45d59 | ||
|
|
684e8d4c84 | ||
|
|
a96bde812d | ||
|
|
c0276f97d6 | ||
|
|
f60d2573e6 | ||
|
|
7e746ded60 | ||
|
|
11669c07ed | ||
|
|
0c0e4c9cc1 | ||
|
|
a3ab467c74 | ||
|
|
acb646ee2d | ||
|
|
5a02c38d57 | ||
|
|
bd9ac79ec9 | ||
|
|
d6c8b2f476 | ||
|
|
b616c59f1b | ||
|
|
c6603c0d83 | ||
|
|
ae0a5644b5 | ||
|
|
d87ab43a9b | ||
|
|
cfe084cb07 | ||
|
|
75521c87ba | ||
|
|
67042b39b3 | ||
|
|
c65b55d0c6 | ||
|
|
8e938acdb2 | ||
|
|
5db5b3c75a | ||
|
|
291df9d984 | ||
|
|
323c62f4e6 | ||
|
|
03e2bbbb91 | ||
|
|
4b8660932a | ||
|
|
35c7746e38 | ||
|
|
e2155aec45 | ||
|
|
aaf34a460e | ||
|
|
1bb30a9ed2 | ||
|
|
b1c68aa865 | ||
|
|
8b57d04a2e | ||
|
|
1a57bddef7 | ||
|
|
1d30716aa6 | ||
|
|
a2aabfc601 | ||
|
|
f7040a3c1c | ||
|
|
40d4a1fcb5 | ||
|
|
e01bb2d8d7 | ||
|
|
d8a4d2e39f | ||
|
|
dbbd5759ab | ||
|
|
0a59ff2357 | ||
|
|
4e1eb6654d | ||
|
|
b588ba740f | ||
|
|
af605a2be7 | ||
|
|
b2630a7e35 | ||
|
|
211fe92a35 | ||
|
|
be168a9f0d | ||
|
|
977f5dc6dd | ||
|
|
131cb7085d | ||
|
|
7fbd446a2a | ||
|
|
7b64699782 | ||
|
|
bad56037c4 | ||
|
|
180e54f278 | ||
|
|
ae89e0be26 | ||
|
|
f19b366b1b | ||
|
|
6b1bd573c8 | ||
|
|
43c82f2f16 | ||
|
|
e478486d73 | ||
|
|
32e5c69735 | ||
|
|
4ec5b491c5 | ||
|
|
6a0e4a695d | ||
|
|
e6d69331be | ||
|
|
f16e196040 | ||
|
|
2f67e3b04d | ||
|
|
4663751924 | ||
|
|
e6ae7e2545 | ||
|
|
74ec248b8c | ||
|
|
cd04eead07 | ||
|
|
2b4340a5d6 | ||
|
|
ff6913264f | ||
|
|
ffd88841d5 | ||
|
|
0ed0d2ed82 | ||
|
|
c2387c47ee | ||
|
|
65cc78de7d | ||
|
|
8bedfcf794 | ||
|
|
1cd059f842 | ||
|
|
42438f2bbd | ||
|
|
1fb6e5d660 | ||
|
|
3fa0588e03 | ||
|
|
5a008f4ddd | ||
|
|
1a0b3a75c3 | ||
|
|
c5ad19881c | ||
|
|
6188e5f2c6 | ||
|
|
6f19fbd300 | ||
|
|
5307da24ed | ||
|
|
e65da6fe4a | ||
|
|
7ed83e9437 | ||
|
|
f008f490d9 | ||
|
|
01109dedbd | ||
|
|
f2a78d8937 | ||
|
|
eef56b308c | ||
|
|
c9ca47b27d | ||
|
|
35e54f5ad5 | ||
|
|
310d516aac | ||
|
|
aee84e3cb3 | ||
|
|
f9fd68c0f4 | ||
|
|
9761a9e2d9 | ||
|
|
8bed7487de | ||
|
|
2b084a3cc8 | ||
|
|
1caabd1368 | ||
|
|
b5397ba303 | ||
|
|
36f9b53f9b | ||
|
|
26fcbaa9a6 | ||
|
|
a8a5b6e3ab | ||
|
|
588e087941 | ||
|
|
fe71db0967 | ||
|
|
9436575dcb | ||
|
|
4ea380d28e | ||
|
|
1f46b477e3 | ||
|
|
f7e17a10b5 | ||
|
|
6e19e29857 | ||
|
|
f6b5d72784 | ||
|
|
68f1334907 | ||
|
|
a83f4e9a2f | ||
|
|
fcd8709c7d | ||
|
|
854d0ea241 | ||
|
|
cff223ecd2 | ||
|
|
8f14762f43 | ||
|
|
c37ae638d2 | ||
|
|
bcfa46ec43 | ||
|
|
42cfe4aacf | ||
|
|
64dba88881 | ||
|
|
3f300ded96 | ||
|
|
1ceafc5c08 | ||
|
|
93adb06083 | ||
|
|
3ef1c81d13 | ||
|
|
84b4deffc6 | ||
|
|
2e7f1a7e52 | ||
|
|
59bea226e5 | ||
|
|
0bbef0c7a9 | ||
|
|
fc91192d0e | ||
|
|
8dbcaed813 | ||
|
|
577ec46f71 | ||
|
|
65bf4534c7 | ||
|
|
5b666e1fec | ||
|
|
e839f56a91 | ||
|
|
c5a327e2e0 | ||
|
|
5b5a11ac36 | ||
|
|
0c3efe326f | ||
|
|
b9100fb934 | ||
|
|
74a98713c1 | ||
|
|
339b80bbea | ||
|
|
ce0c3b3a1e | ||
|
|
1e611dd85b | ||
|
|
0791114e92 | ||
|
|
c85b9a01fd | ||
|
|
9c2a0e7f46 | ||
|
|
8699aef0da | ||
|
|
749bc67212 | ||
|
|
67e24e846b | ||
|
|
091def569a | ||
|
|
fb0cf9102b | ||
|
|
69ce48adb4 | ||
|
|
6c3ab975cc | ||
|
|
f501935365 | ||
|
|
c3593f335e | ||
|
|
351bdd9c58 | ||
|
|
812042b95e | ||
|
|
9957fcbce8 | ||
|
|
acb2c00cca | ||
|
|
9655b8715e | ||
|
|
13de30e8b5 | ||
|
|
1cfeae2ad2 | ||
|
|
31b4190eaf | ||
|
|
380e1c7482 | ||
|
|
f202800a0a | ||
|
|
96d246e64f | ||
|
|
75b460f4b0 | ||
|
|
1ba7af485d | ||
|
|
76c16c6312 | ||
|
|
66a09a8f1c | ||
|
|
235fed1b4e | ||
|
|
f75921ba03 | ||
|
|
202b261a53 | ||
|
|
95c39d258e | ||
|
|
71b35e4651 | ||
|
|
2465a4d6d3 | ||
|
|
30d4ce4781 | ||
|
|
27be70ea00 | ||
|
|
0c22267eaa | ||
|
|
d4f4ec67c3 | ||
|
|
df74d9a117 | ||
|
|
2fc7556f39 | ||
|
|
418dcde15f | ||
|
|
59db290cae | ||
|
|
2992d3586f | ||
|
|
7b8eea4865 | ||
|
|
6b2bba2854 | ||
|
|
4666e942b4 | ||
|
|
daa3c940ee | ||
|
|
e54c15dad1 | ||
|
|
054024e3e8 | ||
|
|
5be8c4548f | ||
|
|
60bd8ecf21 | ||
|
|
25edeaf709 | ||
|
|
1e7525f908 | ||
|
|
341e33a845 | ||
|
|
f5ef8c07a5 | ||
|
|
76da12da65 | ||
|
|
dacf93bd06 | ||
|
|
498bce5255 | ||
|
|
d67df58bc6 | ||
|
|
751c7163fe | ||
|
|
9bd5da8ac3 | ||
|
|
769e8545b6 | ||
|
|
a30c037138 | ||
|
|
3c7fc752fc | ||
|
|
54576e8cbc | ||
|
|
f2bac57ed3 | ||
|
|
a3220c36c1 | ||
|
|
821fc945cf | ||
|
|
784b3334f4 | ||
|
|
57c3355d3e | ||
|
|
9275e28e15 | ||
|
|
a82596dc70 | ||
|
|
add27186bc | ||
|
|
087ad0c120 | ||
|
|
5cd1db7e4b | ||
|
|
3cdbac1603 | ||
|
|
7402dd76cc | ||
|
|
de2e47628a | ||
|
|
aad0098169 | ||
|
|
922ce84000 | ||
|
|
aabfcd8c8b | ||
|
|
0e15d9f309 | ||
|
|
5ac89ac085 | ||
|
|
b3ec433d33 | ||
|
|
207b4cf6b3 | ||
|
|
03fbb3ef15 | ||
|
|
4ce62d6b98 | ||
|
|
1f504a8cd5 | ||
|
|
dd52f5a61f | ||
|
|
8369212bd3 | ||
|
|
c67911f1bb | ||
|
|
af95de8d15 | ||
|
|
ba2bfbd11a | ||
|
|
cc4b0ae90e | ||
|
|
399f55cae6 | ||
|
|
981513a557 | ||
|
|
326cfa838c | ||
|
|
b576e06099 | ||
|
|
1ad788c63f | ||
|
|
faf4d8e7ef | ||
|
|
d77095fe87 | ||
|
|
1bd1f611fe | ||
|
|
38f13524a1 | ||
|
|
d5588e3850 | ||
|
|
b7c2887d0c | ||
|
|
1cba9df571 | ||
|
|
32fc530a17 | ||
|
|
c9436f0a47 | ||
|
|
b8174a16f4 | ||
|
|
2a44a55764 | ||
|
|
0ad72c9108 | ||
|
|
b8340949d5 | ||
|
|
6c3433595f | ||
|
|
3e5e42fef9 | ||
|
|
c19f584b0d | ||
|
|
0bbf022c5d | ||
|
|
4516aa8300 | ||
|
|
30a53dd47c | ||
|
|
6e77c21021 | ||
|
|
f035cf4088 | ||
|
|
af5cba5ed6 | ||
|
|
8bb3d26b4c | ||
|
|
ef0829ab8f | ||
|
|
13f3546700 | ||
|
|
0cccba7b3b |
-346
@@ -1,346 +0,0 @@
|
||||
# NetDisk Fast Download - Agent 规则文件
|
||||
|
||||
## 项目概述
|
||||
网盘快速下载项目,支持多种网盘链接解析和下载加速。
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 后端
|
||||
- **Java 版本**: JDK 17
|
||||
- **构建工具**: Maven 3.x
|
||||
- **核心框架**: Vert.x 4.5.23
|
||||
- **日志框架**: SLF4J 2.0.5 + Logback 1.5.19
|
||||
- **工具库**:
|
||||
- Lombok 1.18.38
|
||||
- Apache Commons Lang3 3.18.0
|
||||
- Apache Commons BeanUtils 2.0.0
|
||||
- Jackson 2.14.2
|
||||
- Reflections 0.10.2
|
||||
|
||||
### 前端
|
||||
- Vue.js 框架
|
||||
- Monaco Editor (代码编辑器)
|
||||
|
||||
### 测试
|
||||
- JUnit 4.13.2
|
||||
- **Maven 测试配置**: 默认跳过测试,使用 `-Dmaven.test.skip=false` 执行测试
|
||||
|
||||
## 项目模块结构
|
||||
|
||||
```
|
||||
netdisk-fast-download/
|
||||
├── core/ # 核心功能模块
|
||||
├── core-database/ # 数据库模块
|
||||
├── parser/ # 解析器模块(支持自定义解析器)
|
||||
├── web-service/ # Web 服务模块
|
||||
└── web-front/ # 前端模块
|
||||
```
|
||||
|
||||
## 编码规范
|
||||
|
||||
### Java 代码规范
|
||||
1. **使用 Lombok 注解简化代码**
|
||||
- `@Data`, `@Getter`, `@Setter`, `@Builder` 等
|
||||
- `@Slf4j` 用于日志
|
||||
|
||||
2. **异步编程**
|
||||
- 使用 Vert.x 的 Future/Promise 模式
|
||||
- 遵循响应式编程范式
|
||||
- 避免阻塞操作
|
||||
|
||||
3. **日志规范**
|
||||
- 使用 SLF4J + Logback
|
||||
- 日志级别:ERROR(错误)、WARN(警告)、INFO(重要信息)、DEBUG(调试信息)
|
||||
- 日志文件按日期分目录存储在 `logs/` 下
|
||||
|
||||
4. **包命名规范**
|
||||
- 基础包名:`cn.qaiu`
|
||||
- 子包按模块功能划分
|
||||
|
||||
### 测试规范
|
||||
1. **默认跳过测试**: 打包时使用 `mvn clean package`
|
||||
2. **执行测试**: 使用 `mvn test -Dmaven.test.skip=false`
|
||||
3. 测试类放在 `src/test/java` 目录下
|
||||
|
||||
### Core 模块封装(禁止重复造轮子)
|
||||
|
||||
#### Web 路由封装
|
||||
**核心类**: `cn.qaiu.vx.core.handlerfactory.RouterHandlerFactory`
|
||||
|
||||
使用注解方式定义路由,无需手动创建 Router:
|
||||
```java
|
||||
// ✅ 推荐:使用注解定义路由
|
||||
@RouteHandler("/api") // 类级别路由前缀
|
||||
@Slf4j
|
||||
public class MyController {
|
||||
|
||||
@RouteMapping(value = "/users", method = RouteMethod.GET)
|
||||
public Future<List<User>> getUsers() {
|
||||
// 返回 Future,框架自动处理响应
|
||||
return userService.findAll();
|
||||
}
|
||||
|
||||
@RouteMapping(value = "/user/:id", method = RouteMethod.GET)
|
||||
public Future<User> getUserById(String id) {
|
||||
// 路径参数自动注入
|
||||
return userService.findById(id);
|
||||
}
|
||||
|
||||
@RouteMapping(value = "/user", method = RouteMethod.POST)
|
||||
public Future<JsonResult<User>> createUser(HttpServerRequest request, String name, Integer age) {
|
||||
// 查询参数自动注入
|
||||
return userService.create(name, age)
|
||||
.map(JsonResult::success);
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 避免:手动创建路由
|
||||
Router router = Router.router(vertx);
|
||||
router.get("/api/users").handler(ctx -> {
|
||||
// 不要这样写
|
||||
});
|
||||
```
|
||||
|
||||
**支持的注解:**
|
||||
- `@RouteHandler(value="/path", order=0)` - 标记路由处理类
|
||||
- `@RouteMapping(value="/path", method=RouteMethod.GET)` - 标记路由方法
|
||||
- `@SockRouteMapper("/ws")` - WebSocket 路由
|
||||
|
||||
**自动参数注入:**
|
||||
- `HttpServerRequest` - 请求对象
|
||||
- `HttpServerResponse` - 响应对象
|
||||
- `RoutingContext` - 路由上下文
|
||||
- `String param` - 路径参数或查询参数(自动匹配名称)
|
||||
- 自定义对象 - 自动从请求体反序列化
|
||||
|
||||
#### 响应处理工具
|
||||
**工具类**: `cn.qaiu.vx.core.util.ResponseUtil`
|
||||
|
||||
```java
|
||||
// ✅ 推荐:使用 ResponseUtil
|
||||
ResponseUtil.redirect(response, "https://example.com");
|
||||
ResponseUtil.fireJsonObjectResponse(ctx, jsonObject);
|
||||
ResponseUtil.fireJsonResultResponse(ctx, JsonResult.success(data));
|
||||
|
||||
// ❌ 避免:手动设置响应头
|
||||
response.putHeader("Content-Type", "application/json");
|
||||
response.end(json);
|
||||
```
|
||||
|
||||
#### 统一响应模型
|
||||
**模型类**: `cn.qaiu.vx.core.model.JsonResult<T>`
|
||||
|
||||
```java
|
||||
// ✅ 推荐:使用 JsonResult 统一响应格式
|
||||
public Future<JsonResult<User>> getUser(String id) {
|
||||
return userService.findById(id)
|
||||
.map(JsonResult::success) // 成功响应
|
||||
.otherwise(err -> JsonResult.error(err.getMessage())); // 错误响应
|
||||
}
|
||||
|
||||
// 响应格式:
|
||||
// {"code": 200, "msg": "success", "success": true, "data": {...}, "timestamp": 123456789}
|
||||
```
|
||||
|
||||
#### 异步服务代理
|
||||
**工具类**: `cn.qaiu.vx.core.util.AsyncServiceUtil`
|
||||
|
||||
```java
|
||||
// ✅ 推荐:使用服务代理
|
||||
private final UserService userService = AsyncServiceUtil.getAsyncServiceInstance(UserService.class);
|
||||
|
||||
// ❌ 避免:手动管理服务实例和 EventBus
|
||||
```
|
||||
|
||||
### Core-Database 模块封装(禁止重复造轮子)
|
||||
|
||||
#### DDL 自动生成
|
||||
**核心类**: `cn.qaiu.db.ddl.CreateTable`
|
||||
|
||||
使用注解定义实体,自动生成建表 SQL:
|
||||
```java
|
||||
// ✅ 推荐:使用注解定义实体
|
||||
@Data
|
||||
@Table("users") // 表名
|
||||
public class User {
|
||||
@Constraint(autoIncrement = true)
|
||||
private Long id; // 自动识别为主键
|
||||
|
||||
@Constraint(notNull = true, uniqueKey = "uk_email")
|
||||
@Length(varcharSize = 100)
|
||||
private String email;
|
||||
|
||||
@Constraint(notNull = true)
|
||||
private String name;
|
||||
|
||||
@Constraint(defaultValue = "0", defaultValueIsFunction = false)
|
||||
private Integer status;
|
||||
|
||||
@Constraint(defaultValue = "NOW()", defaultValueIsFunction = true)
|
||||
private Date createdAt;
|
||||
}
|
||||
|
||||
// 自动建表
|
||||
CreateTable.createTable(pool, JDBCType.MySQL);
|
||||
|
||||
// ❌ 避免:手写建表 SQL
|
||||
pool.query("CREATE TABLE users (...)").execute();
|
||||
```
|
||||
|
||||
**支持的注解:**
|
||||
- `@Table("tableName")` - 指定表名和主键
|
||||
- `@Constraint` - 字段约束
|
||||
- `notNull` - 非空约束
|
||||
- `uniqueKey` - 唯一键约束
|
||||
- `defaultValue` - 默认值
|
||||
- `autoIncrement` - 自增
|
||||
- `@Length` - 字段长度
|
||||
- `varcharSize` - VARCHAR 长度
|
||||
- `decimalSize` - DECIMAL 精度
|
||||
- `@TableGenIgnore` - 忽略字段(不生成列)
|
||||
- `@Column(name="column_name")` - 自定义列名
|
||||
|
||||
#### 自动数据库创建
|
||||
**工具类**: `cn.qaiu.db.ddl.CreateDatabase`
|
||||
|
||||
```java
|
||||
// ✅ 推荐:自动创建数据库
|
||||
JsonObject dbConfig = config.getJsonObject("database");
|
||||
CreateDatabase.createDatabase(dbConfig);
|
||||
|
||||
// ❌ 避免:手动连接和执行 SQL
|
||||
```
|
||||
|
||||
### Parser 模块特殊说明
|
||||
1. 支持自定义解析器(Java、Python、JavaScript)
|
||||
2. Python 解析器使用 GraalPy 实现
|
||||
3. 支持 WebSocket 连接到外部 Python 环境
|
||||
4. 包含安全测试和沙箱机制
|
||||
|
||||
## Maven 命令
|
||||
|
||||
### 常用命令
|
||||
```bash
|
||||
# 编译打包(跳过测试)
|
||||
mvn clean package
|
||||
|
||||
# 安装到本地仓库(跳过测试)
|
||||
mvn clean install
|
||||
|
||||
# 执行测试
|
||||
mvn test -Dmaven.test.skip=false
|
||||
|
||||
# 编译并执行测试
|
||||
mvn clean package -Dmaven.test.skip=false
|
||||
|
||||
# 只编译不打包
|
||||
mvn clean compile
|
||||
|
||||
# 清理
|
||||
mvn clean
|
||||
```
|
||||
|
||||
### 模块化构建
|
||||
```bash
|
||||
# 只构建特定模块
|
||||
mvn clean package -pl parser -am
|
||||
|
||||
# 构建多个模块
|
||||
mvn clean package -pl core,parser -am
|
||||
```
|
||||
|
||||
## 部署相关
|
||||
|
||||
### 目录结构
|
||||
- `bin/`: 启动脚本和服务安装脚本
|
||||
- `db/`: 数据库文件
|
||||
- `logs/`: 日志文件(按日期分目录)
|
||||
- `webroot/`: Web 静态资源根目录
|
||||
|
||||
### 脚本文件
|
||||
- `run.sh` / `run.bat`: 启动脚本
|
||||
- `stop.sh`: 停止脚本
|
||||
- `service-install.sh`: Linux 服务安装
|
||||
- `nfd-service-install.bat`: Windows 服务安装
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
1. **字符编码**: 统一使用 UTF-8
|
||||
2. **Java 版本**: 必须使用 JDK 17 或更高版本
|
||||
3. **Vert.x 异步**: 避免在 Event Loop 线程中执行阻塞操作
|
||||
4. **资源文件**:
|
||||
- 静态资源放在 `webroot/` 目录
|
||||
- 前端构建产物输出到 `web-front/public/`
|
||||
5. **日志文件**: 不要提交 `logs/` 目录到版本控制
|
||||
6. **测试**: 新增功能必须编写单元测试,使用 `-Dmaven.test.skip=false` 验证
|
||||
|
||||
## 代码审查要点
|
||||
|
||||
1. 是否正确处理异步操作
|
||||
2. 是否有潜在的资源泄漏(连接、文件句柄等)
|
||||
3. 异常处理是否完善
|
||||
4. 日志记录是否合理
|
||||
5. 是否遵循单一职责原则
|
||||
6. 是否有适当的注释说明复杂逻辑
|
||||
|
||||
## 性能优化建议
|
||||
|
||||
1. 使用 Vert.x 的异步特性,避免阻塞
|
||||
2. 合理使用缓存机制
|
||||
3. 数据库查询优化
|
||||
4. 静态资源压缩和缓存策略
|
||||
5. 使用连接池管理数据库连接
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
1. **Parser 模块**:
|
||||
- 自定义解析器需要经过安全验证
|
||||
- Python/JavaScript 代码执行需要沙箱隔离
|
||||
- 参考 `parser/doc/SECURITY_TESTING_GUIDE.md`
|
||||
|
||||
2. **输入验证**:
|
||||
- 所有外部输入必须验证和清理
|
||||
- 防止注入攻击
|
||||
|
||||
3. **敏感信息**:
|
||||
- 不要在日志中输出敏感信息
|
||||
- 配置文件中的密钥要加密存储
|
||||
|
||||
## 文档参考
|
||||
|
||||
- Parser 模块文档: `parser/doc/`
|
||||
- API 使用指南: `API_USAGE.md`
|
||||
- 自定义解析器指南: `CUSTOM_PARSER_GUIDE.md`
|
||||
- Python 解析器指南: `PYTHON_PARSER_GUIDE.md`
|
||||
- JavaScript 解析器指南: `JAVASCRIPT_PARSER_GUIDE.md`
|
||||
- 安全测试指南: `SECURITY_TESTING_GUIDE.md`
|
||||
|
||||
- 前端文档: `web-front/doc/`
|
||||
- Monaco Editor 集成: `MONACO_EDITOR_NPM.md`
|
||||
- Playground UI 升级: `PLAYGROUND_UI_UPGRADE.md`
|
||||
|
||||
## Git 提交规范
|
||||
|
||||
使用语义化提交信息:
|
||||
- `feat`: 新功能
|
||||
- `fix`: 修复 Bug
|
||||
- `docs`: 文档更新
|
||||
- `style`: 代码格式调整
|
||||
- `refactor`: 重构
|
||||
- `test`: 测试相关
|
||||
- `chore`: 构建/工具链相关
|
||||
|
||||
示例:
|
||||
```
|
||||
feat(parser): 添加新的网盘解析器支持
|
||||
fix(core): 修复下载链接过期问题
|
||||
docs(readme): 更新安装说明
|
||||
```
|
||||
|
||||
## AI 助手使用建议
|
||||
|
||||
1. 在修改代码前,先理解项目的模块结构和依赖关系
|
||||
2. 生成的代码要符合项目现有的编码风格
|
||||
3. 涉及异步操作时,优先使用 Vert.x 的 Future/Promise API
|
||||
4. 修改配置文件时要考虑向后兼容性
|
||||
5. 新增功能时同步更新相关文档
|
||||
@@ -1,495 +0,0 @@
|
||||
# GitHub Copilot Instructions - NetDisk Fast Download
|
||||
|
||||
## 项目简介
|
||||
网盘快速下载项目,支持多种网盘链接解析和下载加速的 Java Web 应用。
|
||||
|
||||
## 技术栈要求
|
||||
|
||||
### 核心技术
|
||||
- **Java**: JDK 17(必须)
|
||||
- **框架**: Vert.x 4.5.23(异步响应式框架)
|
||||
- **构建**: Maven 3.x
|
||||
- **日志**: SLF4J 2.0.5 + Logback 1.5.19
|
||||
- **前端**: Vue.js + Monaco Editor
|
||||
|
||||
### 重要依赖
|
||||
- Lombok 1.18.38 - 简化 Java 代码
|
||||
- Jackson 2.14.2 - JSON 处理
|
||||
- Commons Lang3 3.18.0 - 工具类
|
||||
- Reflections 0.10.2 - 反射工具
|
||||
|
||||
## 代码生成规范
|
||||
|
||||
### Java 代码风格
|
||||
|
||||
#### 1. 使用 Lombok 简化代码
|
||||
```java
|
||||
// ✅ 推荐:使用 Lombok 注解
|
||||
@Data
|
||||
@Builder
|
||||
@Slf4j
|
||||
public class Example {
|
||||
private String name;
|
||||
private int value;
|
||||
}
|
||||
|
||||
// ❌ 避免:手写 getter/setter
|
||||
public class Example {
|
||||
private String name;
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 异步编程模式(Vert.x)
|
||||
```java
|
||||
// ✅ 推荐:使用 Vert.x Future
|
||||
public Future<String> fetchData() {
|
||||
return vertx.createHttpClient()
|
||||
.request(HttpMethod.GET, "http://example.com")
|
||||
.compose(HttpClientRequest::send)
|
||||
.compose(response -> response.body())
|
||||
.map(Buffer::toString);
|
||||
}
|
||||
|
||||
// ❌ 避免:阻塞操作
|
||||
public String fetchData() {
|
||||
// 不要在 Event Loop 中执行阻塞代码
|
||||
Thread.sleep(1000); // ❌
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 日志记录
|
||||
```java
|
||||
// ✅ 推荐:使用 @Slf4j + 参数化日志
|
||||
@Slf4j
|
||||
public class Service {
|
||||
public void process(String id) {
|
||||
log.info("Processing item: {}", id);
|
||||
try {
|
||||
// ...
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to process item: {}", id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 避免:字符串拼接
|
||||
log.info("Processing item: " + id); // 性能差
|
||||
System.out.println("Debug info"); // 不使用 System.out
|
||||
```
|
||||
|
||||
#### 4. 异常处理
|
||||
```java
|
||||
// ✅ 推荐:完整的异常处理
|
||||
public Future<Result> operation() {
|
||||
return service.execute()
|
||||
.recover(err -> {
|
||||
log.error("Operation failed", err);
|
||||
return Future.succeededFuture(Result.error(err.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
// ❌ 避免:空的 catch 块或吞掉异常
|
||||
try {
|
||||
doSomething();
|
||||
} catch (Exception e) {
|
||||
// ❌ 空 catch
|
||||
}
|
||||
```
|
||||
|
||||
### 包和类命名
|
||||
|
||||
- 基础包名:`cn.qaiu`
|
||||
- 模块包结构:
|
||||
- `cn.qaiu.core.*` - 核心功能
|
||||
- `cn.qaiu.parser.*` - 解析器相关
|
||||
- `cn.qaiu.db.*` - 数据库相关
|
||||
- `cn.qaiu.service.*` - 业务服务
|
||||
- `cn.qaiu.web.*` - Web 相关
|
||||
|
||||
### 测试代码
|
||||
|
||||
```java
|
||||
// ✅ 推荐:JUnit 4 测试
|
||||
public class ServiceTest {
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
// 初始化
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMethod() {
|
||||
// Given
|
||||
String input = "test";
|
||||
|
||||
// When
|
||||
String result = service.process(input);
|
||||
|
||||
// Then
|
||||
assertEquals("expected", result);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
// 清理
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 特定模块指导
|
||||
|
||||
### Core 模块 - Web 路由封装(必须使用,禁止重复造轮子)
|
||||
|
||||
**核心思想:使用注解定义路由,框架自动处理请求和响应**
|
||||
|
||||
#### 1. 使用 @RouteHandler 和 @RouteMapping
|
||||
```java
|
||||
// ✅ 推荐:使用注解定义路由
|
||||
@RouteHandler(value = "/api/v1", order = 10)
|
||||
@Slf4j
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService = AsyncServiceUtil.getAsyncServiceInstance(UserService.class);
|
||||
|
||||
// GET /api/v1/users
|
||||
@RouteMapping(value = "/users", method = RouteMethod.GET)
|
||||
public Future<JsonResult<List<User>>> getUsers() {
|
||||
return userService.findAll()
|
||||
.map(JsonResult::success)
|
||||
.otherwise(err -> JsonResult.error(err.getMessage()));
|
||||
}
|
||||
|
||||
// GET /api/v1/user/:id (路径参数自动注入)
|
||||
@RouteMapping(value = "/user/:id", method = RouteMethod.GET)
|
||||
public Future<User> getUser(String id) {
|
||||
// 返回值自动序列化为 JSON
|
||||
return userService.findById(id);
|
||||
}
|
||||
|
||||
// POST /api/v1/user (查询参数自动注入)
|
||||
@RouteMapping(value = "/user", method = RouteMethod.POST)
|
||||
public Future<JsonResult<User>> createUser(HttpServerRequest request, String name, Integer age) {
|
||||
return userService.create(name, age)
|
||||
.map(JsonResult::success);
|
||||
}
|
||||
|
||||
// 重定向示例
|
||||
@RouteMapping(value = "/redirect/:id", method = RouteMethod.GET)
|
||||
public void redirect(HttpServerResponse response, String id) {
|
||||
String targetUrl = "https://example.com/" + id;
|
||||
ResponseUtil.redirect(response, targetUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 避免:手动创建 Router 和 Handler
|
||||
Router router = Router.router(vertx);
|
||||
router.get("/api/users").handler(ctx -> {
|
||||
// 不要这样写!使用注解方式
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. 自动参数注入规则
|
||||
- **路径参数**:`/user/:id` → `public Future<User> getUser(String id)`
|
||||
- **查询参数**:`?name=xxx&age=18` → `public Future<User> create(String name, Integer age)`
|
||||
- **Vert.x 对象**:自动注入 `HttpServerRequest`, `HttpServerResponse`, `RoutingContext`
|
||||
- **请求体**:POST/PUT 的 JSON 自动反序列化为方法参数对象
|
||||
|
||||
#### 3. 响应处理
|
||||
```java
|
||||
// 方式1:返回 Future,框架自动处理
|
||||
public Future<User> getUser(String id) {
|
||||
return userService.findById(id); // 自动序列化为 JSON
|
||||
}
|
||||
|
||||
// 方式2:返回 JsonResult 统一格式
|
||||
public Future<JsonResult<User>> getUser(String id) {
|
||||
return userService.findById(id).map(JsonResult::success);
|
||||
}
|
||||
|
||||
// 方式3:手动控制响应(仅在特殊情况使用)
|
||||
public void customResponse(HttpServerResponse response) {
|
||||
ResponseUtil.fireJsonObjectResponse(response, jsonObject);
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. WebSocket 路由
|
||||
```java
|
||||
@RouteHandler("/ws")
|
||||
public class WebSocketHandler {
|
||||
|
||||
@SockRouteMapper("/chat")
|
||||
public void handleChat(SockJSSocket socket) {
|
||||
socket.handler(buffer -> {
|
||||
log.info("Received: {}", buffer.toString());
|
||||
socket.write(buffer); // Echo
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Core-Database 模块 - DDL 自动生成(必须使用,禁止重复造轮子)
|
||||
|
||||
**核心思想:使用注解定义实体,自动生成建表 SQL**
|
||||
|
||||
#### 1. 定义实体类
|
||||
```java
|
||||
// ✅ 推荐:使用注解定义实体
|
||||
@Data
|
||||
@Table(value = "t_user", keyFields = "id") // 表名和主键
|
||||
public class User {
|
||||
|
||||
@Constraint(autoIncrement = true)
|
||||
private Long id; // 主键自增
|
||||
|
||||
@Constraint(notNull = true, uniqueKey = "uk_email")
|
||||
@Length(varcharSize = 100)
|
||||
private String email; // 非空 + 唯一索引 + 长度100
|
||||
|
||||
@Constraint(notNull = true)
|
||||
@Length(varcharSize = 50)
|
||||
private String name;
|
||||
|
||||
@Constraint(defaultValue = "0")
|
||||
private Integer status; // 默认值 0
|
||||
|
||||
@Constraint(defaultValue = "NOW()", defaultValueIsFunction = true)
|
||||
private Date createdAt; // 默认当前时间
|
||||
|
||||
@TableGenIgnore // 忽略此字段,不生成列
|
||||
private transient String tempField;
|
||||
}
|
||||
|
||||
// 应用启动时自动建表
|
||||
CreateTable.createTable(pool, JDBCType.MySQL);
|
||||
|
||||
// ❌ 避免:手写建表 SQL
|
||||
String sql = "CREATE TABLE t_user (id BIGINT AUTO_INCREMENT PRIMARY KEY, ...)";
|
||||
pool.query(sql).execute(); // 不要这样写!
|
||||
```
|
||||
|
||||
#### 2. 支持的注解
|
||||
|
||||
**@Table** - 表定义
|
||||
- `value` - 表名(默认类名转下划线)
|
||||
- `keyFields` - 主键字段名(默认 "id")
|
||||
|
||||
**@Constraint** - 字段约束
|
||||
- `notNull = true` - 非空约束
|
||||
- `uniqueKey = "uk_name"` - 唯一索引(相同名称的字段组成联合唯一索引)
|
||||
- `defaultValue = "value"` - 默认值
|
||||
- `defaultValueIsFunction = true` - 默认值是函数(如 NOW())
|
||||
- `autoIncrement = true` - 自增(仅用于主键)
|
||||
|
||||
**@Length** - 字段长度
|
||||
- `varcharSize = 255` - VARCHAR 长度(默认 255)
|
||||
- `decimalSize = {10, 2}` - DECIMAL 精度(默认 {22, 2})
|
||||
|
||||
**@Column** - 自定义列名
|
||||
- `name = "column_name"` - 指定数据库列名
|
||||
|
||||
**@TableGenIgnore** - 忽略字段(不生成列)
|
||||
|
||||
#### 3. 自动创建数据库
|
||||
```java
|
||||
// ✅ 推荐:自动创建数据库
|
||||
JsonObject dbConfig = new JsonObject()
|
||||
.put("jdbcUrl", "jdbc:mysql://localhost:3306/mydb")
|
||||
.put("username", "root")
|
||||
.put("password", "password");
|
||||
|
||||
CreateDatabase.createDatabase(dbConfig);
|
||||
|
||||
// ❌ 避免:手动连接和执行 CREATE DATABASE
|
||||
```
|
||||
|
||||
#### 4. 支持的数据库类型
|
||||
- `JDBCType.MySQL` - MySQL
|
||||
- `JDBCType.PostgreSQL` - PostgreSQL
|
||||
- `JDBCType.H2DB` - H2 数据库
|
||||
|
||||
### Parser 模块
|
||||
- 支持自定义解析器(Java/Python/JavaScript)
|
||||
- Python 使用 GraalPy 执行
|
||||
- 需要考虑安全性和沙箱隔离
|
||||
- WebSocket 支持外部 Python 环境连接
|
||||
|
||||
```java
|
||||
// Parser 接口实现示例
|
||||
public class CustomParser implements IParser {
|
||||
@Override
|
||||
public Future<ParseResult> parse(String url, Map<String, String> params) {
|
||||
return Future.future(promise -> {
|
||||
// 异步解析逻辑
|
||||
promise.complete(result);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Maven 配置注意事项
|
||||
|
||||
### 测试执行
|
||||
```bash
|
||||
# 默认打包跳过测试
|
||||
mvn clean package
|
||||
|
||||
# 执行测试
|
||||
mvn test -Dmaven.test.skip=false
|
||||
mvn clean package -Dmaven.test.skip=false
|
||||
```
|
||||
|
||||
### 模块化构建
|
||||
```bash
|
||||
# 构建特定模块
|
||||
mvn clean package -pl parser -am
|
||||
```
|
||||
|
||||
## 重要约定
|
||||
|
||||
### 1. 异步优先
|
||||
- 所有 I/O 操作必须异步
|
||||
- 使用 Vert.x Future/Promise API
|
||||
- 避免阻塞 Event Loop
|
||||
|
||||
### 2. 资源管理
|
||||
```java
|
||||
// ✅ 推荐:使用 try-with-resources
|
||||
try (InputStream is = new FileInputStream(file)) {
|
||||
// 使用资源
|
||||
}
|
||||
|
||||
// 或者确保在 finally 中关闭
|
||||
HttpClient client = vertx.createHttpClient();
|
||||
// 使用后必须关闭
|
||||
client.close();
|
||||
```
|
||||
|
||||
### 3. 配置外部化
|
||||
- 配置文件优先使用 JSON 格式
|
||||
- 敏感信息不要硬编码
|
||||
- 支持环境变量覆盖
|
||||
|
||||
### 4. 错误处理
|
||||
- 使用 Future 的 recover/otherwise
|
||||
- 记录详细的错误日志
|
||||
- 向用户返回友好的错误信息
|
||||
|
||||
## 性能考虑
|
||||
|
||||
1. **使用连接池**: 数据库连接、HTTP 客户端
|
||||
2. **缓存策略**: 解析结果、静态资源
|
||||
3. **批量操作**: 避免 N+1 查询问题
|
||||
4. **异步非阻塞**: 充分利用 Vert.x 优势
|
||||
|
||||
## 安全要求
|
||||
|
||||
### Parser 模块安全
|
||||
- 执行自定义代码必须沙箱隔离
|
||||
- 限制资源访问(文件、网络)
|
||||
- 设置执行超时
|
||||
- 验证输入参数
|
||||
|
||||
```java
|
||||
// ✅ 推荐:带安全检查的执行
|
||||
public Future<Result> executeUserCode(String code) {
|
||||
// 验证代码
|
||||
if (!SecurityValidator.isValid(code)) {
|
||||
return Future.failedFuture("Invalid code");
|
||||
}
|
||||
|
||||
// 在沙箱中执行
|
||||
return sandboxExecutor.execute(code, TIMEOUT);
|
||||
}
|
||||
```
|
||||
|
||||
### 输入验证
|
||||
```java
|
||||
// ✅ 推荐:验证所有外部输入
|
||||
public Future<Result> parse(String url) {
|
||||
if (StringUtils.isBlank(url) || !UrlValidator.isValid(url)) {
|
||||
return Future.failedFuture("Invalid URL");
|
||||
}
|
||||
// 继续处理
|
||||
}
|
||||
```
|
||||
|
||||
## 文档和注释
|
||||
|
||||
### JavaDoc 注释
|
||||
```java
|
||||
/**
|
||||
* 解析网盘链接获取下载信息
|
||||
*
|
||||
* @param url 网盘分享链接
|
||||
* @param params 额外参数(如密码)
|
||||
* @return Future<ParseResult> 解析结果
|
||||
*/
|
||||
public Future<ParseResult> parse(String url, Map<String, String> params) {
|
||||
// 实现
|
||||
}
|
||||
```
|
||||
|
||||
### 复杂逻辑注释
|
||||
```java
|
||||
// 处理特殊情况:某些网盘需要二次验证
|
||||
// 参考文档:docs/parser-flow.md
|
||||
if (needsSecondaryVerification) {
|
||||
// 实现二次验证逻辑
|
||||
}
|
||||
```
|
||||
|
||||
## 常见模式
|
||||
|
||||
### 链式异步调用
|
||||
```java
|
||||
return fetchMetadata(url)
|
||||
.compose(meta -> validateMetadata(meta))
|
||||
.compose(meta -> fetchDownloadUrl(meta))
|
||||
.compose(downloadUrl -> generateResult(downloadUrl))
|
||||
.recover(this::handleError);
|
||||
```
|
||||
|
||||
### 事件处理
|
||||
```java
|
||||
vertx.eventBus().<JsonObject>consumer("parser.request", msg -> {
|
||||
JsonObject body = msg.body();
|
||||
parse(body.getString("url"))
|
||||
.onSuccess(result -> msg.reply(JsonObject.mapFrom(result)))
|
||||
.onFailure(err -> msg.fail(500, err.getMessage()));
|
||||
});
|
||||
```
|
||||
|
||||
## 不应该做的事
|
||||
|
||||
1. ❌ 在 Event Loop 线程中执行阻塞操作
|
||||
2. ❌ 使用 `System.out.println()` 而不是日志框架
|
||||
3. ❌ 硬编码配置值(端口、路径、密钥等)
|
||||
4. ❌ 忽略异常或使用空 catch 块
|
||||
5. ❌ 返回 null,应该使用 Optional 或 Future.failedFuture()
|
||||
6. ❌ 在生产代码中使用 `e.printStackTrace()`
|
||||
7. ❌ 直接操作 Thread 而不使用 Vert.x 的 executeBlocking
|
||||
8. ❌ 提交包含 `logs/` 目录的代码
|
||||
|
||||
## 代码审查清单
|
||||
|
||||
生成代码时请确保:
|
||||
- [ ] 使用 Lombok 注解简化代码
|
||||
- [ ] 异步操作使用 Vert.x Future
|
||||
- [ ] 添加了 @Slf4j 和适当的日志
|
||||
- [ ] 异常处理完整
|
||||
- [ ] 输入参数已验证
|
||||
- [ ] 资源正确释放
|
||||
- [ ] 添加了必要的 JavaDoc
|
||||
- [ ] 遵循项目包命名规范
|
||||
- [ ] 没有阻塞操作在 Event Loop 中
|
||||
- [ ] 测试用例覆盖主要场景
|
||||
|
||||
## 参考资源
|
||||
|
||||
- Vert.x 文档: https://vertx.io/docs/
|
||||
- 项目 Parser 文档: `parser/doc/`
|
||||
- 前端文档: `web-front/doc/`
|
||||
- 安全测试指南: `parser/doc/SECURITY_TESTING_GUIDE.md`
|
||||
@@ -35,11 +35,11 @@ jobs:
|
||||
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: ${{ runner.os }}-m2
|
||||
|
||||
- name: 安装 GraalPy pip 包
|
||||
run: |
|
||||
cd parser
|
||||
chmod +x setup-graalpy-packages.sh
|
||||
./setup-graalpy-packages.sh
|
||||
|
||||
- name: 编译并打包项目
|
||||
run: ./mvnw clean package -DskipTests
|
||||
- name: 编译项目
|
||||
run: ./mvnw clean compile
|
||||
|
||||
# - name: 运行测试
|
||||
# run: ./mvnw test
|
||||
|
||||
- name: 打包项目
|
||||
run: ./mvnw package -DskipTests
|
||||
|
||||
+209
-63
@@ -1,24 +1,16 @@
|
||||
# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven
|
||||
name: Java CI(Maven 构建 + Docker 镜像 + 原生环境打包)
|
||||
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
name: Java CI with Maven
|
||||
|
||||
# The API requires write permission on the repository to submit dependencies
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- '*' # 只有推送tag时才会触发构建
|
||||
- '*'
|
||||
branches-ignore:
|
||||
- '*' # 排除所有分支的提交
|
||||
- '*'
|
||||
paths-ignore:
|
||||
- 'bin/**'
|
||||
- '.github/**'
|
||||
@@ -32,93 +24,247 @@ on:
|
||||
- "main"
|
||||
|
||||
jobs:
|
||||
# ================================================================
|
||||
# 阶段一:构建前端 + Maven 打包(只执行一次,产物共享)
|
||||
# ================================================================
|
||||
build:
|
||||
|
||||
name: 编译构建
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- name: 设置 Node.js 18
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Set up JDK 17
|
||||
- name: 设置 JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
cache: maven
|
||||
|
||||
- name: Build Frontend
|
||||
- name: 构建前端
|
||||
run: cd web-front && yarn install && yarn run build
|
||||
|
||||
- name: Install GraalPy pip packages (for Python tags)
|
||||
if: contains(github.ref, 'py')
|
||||
run: |
|
||||
cd parser
|
||||
chmod +x setup-graalpy-packages.sh
|
||||
./setup-graalpy-packages.sh
|
||||
|
||||
- name: Build with Maven
|
||||
- name: Maven 编译打包
|
||||
run: mvn -B package -DskipTests --file pom.xml
|
||||
|
||||
# Optional: Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive
|
||||
- name: Update dependency graph
|
||||
- name: 更新依赖图谱
|
||||
uses: advanced-security/maven-dependency-submission-action@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
with:
|
||||
ignore-maven-wrapper: true
|
||||
|
||||
# - uses: release-drafter/release-drafter@v5
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
- name: Upload Artifact
|
||||
- name: 分享应用打包目录(供原生包和 Docker 复用)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-package
|
||||
path: web-service/target/package/
|
||||
|
||||
- name: 分享 bin-zip(供 Docker 复用)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-bin-zip
|
||||
path: web-service/target/netdisk-fast-download-bin.zip
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
# ================================================================
|
||||
# 阶段二-A:Docker 镜像构建(并行)
|
||||
# ================================================================
|
||||
docker:
|
||||
name: Docker 镜像
|
||||
needs: build
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: 下载 bin-zip 产物
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: app-bin-zip
|
||||
path: web-service/target/
|
||||
|
||||
- name: 登录 GitHub 容器仓库
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
- name: 设置 QEMU(多平台构建支持)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: 设置 Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract git tag
|
||||
id: tag
|
||||
- name: 生成 Docker 标签
|
||||
id: docker_tag
|
||||
shell: bash
|
||||
run: |
|
||||
GIT_TAG=$(git tag --points-at HEAD | head -n 1)
|
||||
echo "tag=$GIT_TAG" >> $GITHUB_OUTPUT
|
||||
# 检查是否为 Python 版本标签(以 py 结尾)
|
||||
if [[ "$GIT_TAG" == *py ]]; then
|
||||
echo "is_python=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_python=false" >> $GITHUB_OUTPUT
|
||||
tag="$(printf '%s' "${GITHUB_REF_NAME}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_.-]+/-/g; s/^-+//; s/-+$//')"
|
||||
if [ -z "$tag" ]; then
|
||||
tag="snapshot"
|
||||
fi
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 构建并推送 Docker 镜像
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:${{ steps.docker_tag.outputs.tag }}
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 阶段二-B:原生环境打包 Linux + Windows(并行)
|
||||
# ================================================================
|
||||
native-package:
|
||||
name: 原生环境打包 → ${{ matrix.artifact-name }}
|
||||
needs: build
|
||||
if: github.event_name != 'pull_request'
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
artifact-name: netdisk-fast-download-linux-amd64
|
||||
- os: windows-latest
|
||||
artifact-name: netdisk-fast-download-windows-amd64
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: 设置 JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: 下载 Maven 构建产物
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: app-package
|
||||
path: web-service/target/package
|
||||
|
||||
# ============================================================
|
||||
# 选择 JDK 模块
|
||||
# ============================================================
|
||||
- name: 选择完整 JDK 模块集合
|
||||
run: |
|
||||
# 123 网盘/自定义 JS 解析器会通过 javax.script、Nashorn 和 SPI/反射加载模块,
|
||||
# jdeps 无法稳定识别这些运行时依赖。这里优先保证原生包可运行,使用完整
|
||||
# JDK 模块集合生成运行时,避免 Windows 精简 JRE 缺 java.scripting/jdk.dynalink 等模块。
|
||||
printf 'JDK_MODULES=ALL-MODULE-PATH\n' >> $GITHUB_ENV
|
||||
|
||||
# ============================================================
|
||||
# jlink 生成完整模块 JRE
|
||||
# ============================================================
|
||||
- name: 生成完整模块 JRE(jlink)
|
||||
run: |
|
||||
jlink \
|
||||
--module-path "$JAVA_HOME/jmods" \
|
||||
--add-modules "$JDK_MODULES" \
|
||||
--output "native-package/netdisk-fast-download/jre" \
|
||||
--strip-debug \
|
||||
--compress=2 \
|
||||
--no-header-files \
|
||||
--no-man-pages
|
||||
|
||||
echo "JRE size:"
|
||||
du -sh native-package/netdisk-fast-download/jre || true
|
||||
|
||||
# Windows: 确保 MSVC 运行时 DLL 到位
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
JRE_BIN="native-package/netdisk-fast-download/jre/bin"
|
||||
for dll in vcruntime140.dll msvcp140.dll vcruntime140_1.dll; do
|
||||
if [ ! -f "$JRE_BIN/$dll" ] && [ -f "$JAVA_HOME/bin/$dll" ]; then
|
||||
echo "jlink 未包含 $dll,从 JDK 补拷"
|
||||
cp "$JAVA_HOME/bin/$dll" "$JRE_BIN/"
|
||||
fi
|
||||
done
|
||||
echo "=== JRE bin 目录 DLL 清单 ==="
|
||||
ls -la "$JRE_BIN"/*.dll 2>/dev/null || echo "(无 .dll 文件)"
|
||||
fi
|
||||
|
||||
- name: Build and push Docker image (Standard)
|
||||
if: github.event_name != 'pull_request' && steps.tag.outputs.is_python == 'false'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
ghcr.io/qaiu/netdisk-fast-download:${{ steps.tag.outputs.tag }}
|
||||
ghcr.io/qaiu/netdisk-fast-download:latest
|
||||
echo "=== 关键 JDK 模块校验 ==="
|
||||
"native-package/netdisk-fast-download/jre/bin/java" --list-modules | grep -q '^java.scripting@'
|
||||
"native-package/netdisk-fast-download/jre/bin/java" --list-modules | grep -q '^jdk.dynalink@'
|
||||
"native-package/netdisk-fast-download/jre/bin/java" --list-modules | grep -q '^jdk.unsupported@'
|
||||
|
||||
- name: Build and push Docker image (Python)
|
||||
if: github.event_name != 'pull_request' && steps.tag.outputs.is_python == 'true'
|
||||
uses: docker/build-push-action@v5
|
||||
# ============================================================
|
||||
# 组装包目录
|
||||
# ============================================================
|
||||
- name: 组装包目录
|
||||
run: |
|
||||
PKG="native-package/netdisk-fast-download"
|
||||
SRC="web-service/target/package"
|
||||
|
||||
cp "$SRC/netdisk-fast-download.jar" "$PKG/"
|
||||
cp -r "$SRC/lib" "$PKG/"
|
||||
cp -r "$SRC/resources" "$PKG/"
|
||||
cp -r "$SRC/webroot" "$PKG/"
|
||||
|
||||
mkdir -p "$PKG/db"
|
||||
mkdir -p "$PKG/logs"
|
||||
|
||||
# ============================================================
|
||||
# 生成启动脚本
|
||||
# ============================================================
|
||||
- name: 生成启动脚本(Linux)
|
||||
run: |
|
||||
PKG="native-package/netdisk-fast-download"
|
||||
echo '#!/bin/bash' > "$PKG/run.sh"
|
||||
echo 'DIR="$(cd "$(dirname "$0")" && pwd)"' >> "$PKG/run.sh"
|
||||
echo 'cd "$DIR" || exit 1' >> "$PKG/run.sh"
|
||||
echo 'exec "$DIR/jre/bin/java" -Xmx512M -Dfile.encoding=utf-8 -jar "$DIR/netdisk-fast-download.jar" "$@"' >> "$PKG/run.sh"
|
||||
chmod +x "$PKG/run.sh"
|
||||
|
||||
- name: 生成启动脚本(Windows)
|
||||
run: |
|
||||
PKG="native-package/netdisk-fast-download"
|
||||
echo '@echo off' > "$PKG/run.bat"
|
||||
echo 'chcp 65001 > nul' >> "$PKG/run.bat"
|
||||
echo 'pushd %~dp0' >> "$PKG/run.bat"
|
||||
echo '"%~dp0jre\bin\java.exe" -Xmx512M -Dfile.encoding=utf-8 -jar "%~dp0netdisk-fast-download.jar" %*' >> "$PKG/run.bat"
|
||||
|
||||
# ============================================================
|
||||
# 打包为 zip
|
||||
# ============================================================
|
||||
- name: 打包 ZIP(Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
cd native-package
|
||||
zip -r "../${{ matrix.artifact-name }}.zip" netdisk-fast-download/
|
||||
|
||||
- name: 打包 ZIP(Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Compress-Archive -Path native-package/netdisk-fast-download -DestinationPath "${{ matrix.artifact-name }}.zip"
|
||||
|
||||
# ============================================================
|
||||
# 上传产物
|
||||
# ============================================================
|
||||
- name: 上传原生安装包
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
ghcr.io/qaiu/netdisk-fast-download:${{ steps.tag.outputs.tag }}
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: ${{ matrix.artifact-name }}.zip
|
||||
|
||||
- name: 上传到 Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ${{ matrix.artifact-name }}.zip
|
||||
tag_name: ${{ github.ref_name }}
|
||||
generate_release_notes: true
|
||||
|
||||
+12
-3
@@ -31,6 +31,7 @@ target/
|
||||
sdkTest.log
|
||||
app.yml
|
||||
app-local.yml
|
||||
secret.yml
|
||||
|
||||
|
||||
#some local files
|
||||
@@ -41,7 +42,9 @@ gradlew.bat
|
||||
unused.txt
|
||||
/web-service/src/main/generated/
|
||||
/db
|
||||
/netdisk-fast-download/
|
||||
/webroot/nfd-front/
|
||||
/netdisk-fast-download/webroot/nfd-front/
|
||||
package-lock.json
|
||||
|
||||
# Maven generated files
|
||||
@@ -81,6 +84,12 @@ yarn-error.log*
|
||||
*.ipr
|
||||
*.iws
|
||||
|
||||
# GraalPy pip packages (local installation)
|
||||
parser/src/main/resources/graalpy-packages/
|
||||
**/graalpy-packages/
|
||||
# Build directories
|
||||
**/target/
|
||||
**/build/
|
||||
**/classes/
|
||||
**/out/
|
||||
**/${project.build.directory}/
|
||||
**/${project.basedir}/target/
|
||||
**/${basedir}/target/
|
||||
.spec-workflow/
|
||||
|
||||
Vendored
+1
-60
@@ -1,77 +1,18 @@
|
||||
{
|
||||
// 使用 IntelliSense 了解相关属性。
|
||||
// 悬停以查看现有属性的描述。
|
||||
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "java",
|
||||
"name": "PythonSecurityTestMain",
|
||||
"request": "launch",
|
||||
"mainClass": "cn.qaiu.parser.custompy.PythonSecurityTestMain",
|
||||
"projectName": "parser"
|
||||
},
|
||||
{
|
||||
"type": "java",
|
||||
"name": "Current File",
|
||||
"request": "launch",
|
||||
"mainClass": "${file}"
|
||||
},
|
||||
{
|
||||
"type": "java",
|
||||
"name": "StringCase",
|
||||
"request": "launch",
|
||||
"mainClass": "cn.qaiu.vx.core.util.StringCase",
|
||||
"projectName": "core"
|
||||
},
|
||||
{
|
||||
"type": "java",
|
||||
"name": "FCURLParser",
|
||||
"request": "launch",
|
||||
"mainClass": "cn.qaiu.parser.FCURLParser",
|
||||
"projectName": "parser"
|
||||
},
|
||||
{
|
||||
"type": "java",
|
||||
"name": "QkTool",
|
||||
"request": "launch",
|
||||
"mainClass": "cn.qaiu.parser.impl.QkTool",
|
||||
"projectName": "parser"
|
||||
},
|
||||
{
|
||||
"type": "java",
|
||||
"name": "WebClientExample",
|
||||
"request": "launch",
|
||||
"mainClass": "qaiu.web.test.WebClientExample",
|
||||
"projectName": "parser"
|
||||
},
|
||||
{
|
||||
"type": "java",
|
||||
"name": "AppMain",
|
||||
"request": "launch",
|
||||
"mainClass": "cn.qaiu.lz.AppMain",
|
||||
"projectName": "web-service"
|
||||
},
|
||||
{
|
||||
"type": "java",
|
||||
"name": "TestJs",
|
||||
"request": "launch",
|
||||
"mainClass": "cn.qaiu.web.test.TestJs",
|
||||
"projectName": "web-service"
|
||||
},
|
||||
{
|
||||
"type": "java",
|
||||
"name": "TestOS",
|
||||
"request": "launch",
|
||||
"mainClass": "cn.qaiu.web.test.TestOS",
|
||||
"projectName": "web-service"
|
||||
},
|
||||
{
|
||||
"type": "java",
|
||||
"name": "WebProxyExamples",
|
||||
"request": "launch",
|
||||
"mainClass": "cn.qaiu.web.test.WebProxyExamples",
|
||||
"projectName": "web-service"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2
-1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"java.compile.nullAnalysis.mode": "automatic",
|
||||
"java.configuration.updateBuildConfiguration": "automatic"
|
||||
"java.configuration.updateBuildConfiguration": "interactive",
|
||||
"java.debug.settings.onBuildFailureProceed": true
|
||||
}
|
||||
+8
-3
@@ -10,8 +10,13 @@ COPY ./web-service/target/netdisk-fast-download-bin.zip .
|
||||
RUN unzip netdisk-fast-download-bin.zip && \
|
||||
mv netdisk-fast-download/* ./ && \
|
||||
rm netdisk-fast-download-bin.zip && \
|
||||
chmod +x run.sh
|
||||
chmod +x run.sh && \
|
||||
mkdir -p db logs
|
||||
|
||||
EXPOSE 6400 6401
|
||||
COPY ./docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["sh", "run.sh"]
|
||||
EXPOSE 6401
|
||||
|
||||
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
|
||||
@@ -1,43 +1,49 @@
|
||||
# 一款网盘分享链接云解析快速下载服务
|
||||
QQ交流群:1017480890
|
||||
<p align="center">
|
||||
<img src="https://github.com/user-attachments/assets/87401aae-b0b6-4ffb-bbeb-44756404d26f" alt="项目预览图" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/qaiu/netdisk-fast-download/actions/workflows/maven.yml"><img src="https://img.shields.io/github/actions/workflow/status/qaiu/netdisk-fast-download/maven.yml?branch=v0.1.9b8a&style=flat"></a>
|
||||
<a href="https://github.com/qaiu/netdisk-fast-download/actions/workflows/maven.yml"><img src="https://img.shields.io/github/actions/workflow/status/qaiu/netdisk-fast-download/build.yml?branch=main&style=flat"></a>
|
||||
<a href="https://www.oracle.com/cn/java/technologies/downloads"><img src="https://img.shields.io/badge/jdk-%3E%3D17-blue"></a>
|
||||
<a href="https://vertx-china.github.io"><img src="https://img.shields.io/badge/vert.x-4.5.23-blue?style=flat"></a>
|
||||
<a href="https://vertx-china.github.io"><img src="https://img.shields.io/badge/vert.x-4.5.27-blue?style=flat"></a>
|
||||
<a href="https://raw.githubusercontent.com/qaiu/netdisk-fast-download/master/LICENSE"><img src="https://img.shields.io/github/license/qaiu/netdisk-fast-download?style=flat"></a>
|
||||
<a href="https://github.com/qaiu/netdisk-fast-download/releases/"><img src="https://img.shields.io/github/v/release/qaiu/netdisk-fast-download?style=flat"></a>
|
||||
<a href="https://github.com/QAIU/netdisk-fast-download">
|
||||
<img src="https://img.shields.io/github/stars/QAIU/netdisk-fast-download?style=flat&logo=github" alt="GitHub Stars">
|
||||
</a>
|
||||
<a href="https://atomgit.com/QAIU/netdisk-fast-download"><img src="https://atomgit.com/QAIU/netdisk-fast-download/star/badge.svg" alt="AtomGit"></a>
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/12101" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12101" alt="qaiu%2Fnetdisk-fast-download | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
# netdisk-fast-download 网盘分享链接云解析服务
|
||||
QQ群:1017480890
|
||||
|
||||
> **📌 分支说明:**
|
||||
> - 本分支 (`feature/graalpy-parser`) 为 **Python 解析器支持版本**,包含 GraalPy 环境
|
||||
> - 如需标准版本(更小的镜像体积),请访问 [main 分支](https://github.com/qaiu/netdisk-fast-download/tree/main)
|
||||
> - Docker 镜像:Python 版本使用 py 标签(如 `v0.1.9b19py`),标准版本使用 `latest` 标签
|
||||
## 介绍
|
||||
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/永硕E盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
|
||||
|
||||
netdisk-fast-download网盘直链云解析(nfd云解析)能把网盘分享下载链接转化为直链,支持多款云盘,已支持蓝奏云/蓝奏云优享/奶牛快传/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
|
||||
[官方文档](https://nfd-parser.github.io/)
|
||||
[API接入](https://nfdparser.apifox.cn/)
|
||||
[公益解析,lz站](https://lz.qaiu.top)
|
||||
[公益解析,lz0站](https://lz0.qaiu.top)
|
||||
[专业版](https://189.qaiu.top)
|
||||
|
||||
|
||||
## 快速开始
|
||||
命令行下载分享文件:
|
||||
```shell
|
||||
curl -LOJ "https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/nQOaNRPW&pwd=1234"
|
||||
curl -LOJ "https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/Tk1F2kGQ&pwd=1234"
|
||||
```
|
||||
或者使用wget:
|
||||
```shell
|
||||
wget -O bilibili.mp4 "https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/nQOaNRPW&pwd=1234"
|
||||
wget -O bilibili.mp4 "https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/Tk1F2kGQ&pwd=1234"
|
||||
```
|
||||
或者使用浏览器[直接访问](https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fshare.feijipan.com%2Fs%2FnQOaNRPW&name=bilibili.mp4&ext=mp4):
|
||||
或者使用浏览器[直接访问](https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fwww.ilanzou.com%2Fs%2FCDx6xKbT&name=bilibili.mp4&ext=mp4):
|
||||
```
|
||||
### 调用演示站下载:
|
||||
https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/nQOaNRPW&pwd=1234
|
||||
https://lz.qaiu.top/parser?url=https://www.ilanzou.com/s/CDx6xKbT&pwd=1234
|
||||
### 调用演示站预览:
|
||||
https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fshare.feijipan.com%2Fs%2FnQOaNRPW&name=bilibili.mp4&ext=mp4
|
||||
https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fwww.ilanzou.com%2Fs%2FCDx6xKbT&name=bilibili.mp4&ext=mp4
|
||||
|
||||
```
|
||||
|
||||
@@ -45,42 +51,14 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
|
||||
|
||||
**JavaScript解析器文档:** [JavaScript解析器开发指南](parser/doc/JAVASCRIPT_PARSER_GUIDE.md) | [自定义解析器扩展指南](parser/doc/CUSTOM_PARSER_GUIDE.md) | [快速开始](parser/doc/CUSTOM_PARSER_QUICKSTART.md)
|
||||
|
||||
**Python解析器文档:** [Python解析器开发指南](parser/doc/PYTHON_PARSER_GUIDE.md) | [Playground测试报告](parser/doc/PYTHON_PLAYGROUND_TEST_REPORT.md) | [pylsp WebSocket集成](parser/doc/PYLSP_WEBSOCKET_GUIDE.md)
|
||||
**Playground功能:** [JS解析器演练场密码保护说明](web-service/doc/PLAYGROUND_PASSWORD_PROTECTION.md)
|
||||
|
||||
## 演练场(Playground)
|
||||
|
||||
在线编写、测试和发布解析器脚本,支持 JavaScript 和 Python 两种语言。
|
||||
|
||||
### 快速开始
|
||||
- **[演练场使用指南](web-service/doc/PLAYGROUND_GUIDE.md)** - 完整的使用教程和最佳实践
|
||||
- **[5分钟快速上手](parser/doc/CUSTOM_PARSER_QUICKSTART.md)** - 快速集成指南
|
||||
|
||||
### 开发文档
|
||||
- **JavaScript解析器**: [开发指南](parser/doc/JAVASCRIPT_PARSER_GUIDE.md) | [自定义扩展](parser/doc/CUSTOM_PARSER_GUIDE.md)
|
||||
- **Python解析器**: [开发指南](parser/doc/PYTHON_PARSER_GUIDE.md) | [Python LSP连接](parser/doc/PYLSP_WEBSOCKET_GUIDE.md)
|
||||
|
||||
### 配置和安全
|
||||
- **[密码保护配置](web-service/doc/PLAYGROUND_PASSWORD_PROTECTION.md)** - 访问控制和安全设置
|
||||
- **[界面功能说明](web-front/doc/PLAYGROUND_UI_UPGRADE.md)** - IDE功能和快捷键
|
||||
|
||||
### 测试报告
|
||||
- **[Python演练场测试报告](parser/doc/PYTHON_PLAYGROUND_TEST_REPORT.md)** - 功能验证和测试覆盖
|
||||
|
||||
### 在线体验
|
||||
访问演练场页面:`http://your_host/playground`(需要密码或配置公开访问)
|
||||
|
||||
## 预览地址
|
||||
[预览地址1](https://lz.qaiu.top)
|
||||
[预览地址2](https://lz0.qaiu.top)
|
||||
[移动/联通/天翼云盘大文件试用版](https://189.qaiu.top)
|
||||
|
||||
main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/qaiu/netdisk-fast-download/tree/main-jdk11)
|
||||
**0.1.8及以上版本json接口格式有调整 参考json返回数据格式示例**
|
||||
**小飞机解析有IP限制,多数云服务商的大陆IP会被拦截(可以自行配置代理),和本程序无关**
|
||||
**注意: 请不要过度依赖lz.qaiu.top预览地址服务,建议本地搭建或者云服务器自行搭建。解析次数过多IP会被部分网盘厂商限制,不推荐做公共解析。**
|
||||
**注意⚠️小飞机解析有IP限制,多数云服务商的大陆IP会被拦截(可以自行配置代理),和本程序无关**
|
||||
**注意⚠️收到很多用户反馈,小飞机近期封号频繁,请尽可能选择其他网盘分享**
|
||||
**注意⚠️请不要过度依赖 lz.qaiu.top,建议本地搭建或者云服务器自行搭建。请求量过多的话服务器可能会被云盘厂商限制,遇到解析失败的分享链接不要着急提issues,请先检查分享是否有效。**
|
||||
|
||||
## 网盘支持情况:
|
||||
> 20230905 奶牛云直链做了防盗链,需加入请求头:Referer: https://cowtransfer.com/
|
||||
> 20230824 123云盘解析大文件(>100MB)失效,需要登录
|
||||
> 20230722 UC网盘解析失效,需要登录
|
||||
|
||||
@@ -88,18 +66,17 @@ main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/qaiu/
|
||||
|
||||
- [蓝奏云-lz](https://pc.woozooo.com/)
|
||||
- [蓝奏云优享-iz](https://www.ilanzou.com/)
|
||||
- [奶牛快传-cow](https://cowtransfer.com/)
|
||||
- [移动云云空间-ec](https://www.ecpan.cn/web)
|
||||
- [小飞机网盘-fj](https://www.feijipan.com/)
|
||||
- [亿方云-fc](https://www.fangcloud.com/)
|
||||
- [123云盘-ye](https://www.123pan.com/)
|
||||
- [123云盘-ye](https://www.123pan.com/) ⚠️仅建议本地部署使用,需登录认证,公共/云端服务器风控严格建议自行部署(Windows 可直接用 run.bat 一键运行)
|
||||
- ~[115网盘(失效)-p115](https://115.com/)~
|
||||
- ~[118网盘(已停服)-p118](https://www.118pan.com/)~
|
||||
- [文叔叔-ws](https://www.wenshushu.cn/)
|
||||
- [联想乐云-le](https://lecloud.lenovo.com/)
|
||||
- [QQ邮箱云盘-qqw](https://mail.qq.com/)
|
||||
- [QQ闪传-qqsc](https://nutty.qq.com/nutty/ssr/26797.html)
|
||||
- [城通网盘-ct](https://www.ctfile.com)
|
||||
- [永硕E盘-ys](https://www.ysepan.com/)(空间分享,如 `https://xxx.ysepan.com/`,密码为空间访问密码;多文件请用文件列表接口)
|
||||
- [网易云音乐分享链接-mnes](https://music.163.com)
|
||||
- [酷狗音乐分享链接-mkgs](https://www.kugou.com)
|
||||
- [酷我音乐分享链接-mkws](https://kuwo.cn)
|
||||
@@ -107,20 +84,25 @@ main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/qaiu/
|
||||
- [Cloudreve自建网盘-ce](https://github.com/cloudreve/Cloudreve)
|
||||
- ~[微雨云存储-pvvy](https://www.vyuyun.com/)~
|
||||
- [超星云盘(需要referer: https://pan-yz.chaoxing.com)-pcx](https://pan-yz.chaoxing.com)
|
||||
- [飞书云盘-fs](https://www.feishu.cn/)
|
||||
- [WPS云文档-pwps](https://www.kdocs.cn/)
|
||||
- [汽水音乐-qishui_music](https://music.douyin.com/qishui/)
|
||||
- [咪咕音乐-migu](https://music.migu.cn/)
|
||||
- [一刻相册-baidu_photo](https://photo.baidu.com/)
|
||||
- Google云盘-pgd
|
||||
- Onedrive-pod
|
||||
- Dropbox-pdp
|
||||
- iCloud-pic
|
||||
### 仅专属版提供
|
||||
### 专业版提供
|
||||
- 迅雷网盘-xl
|
||||
- [夸克云盘-qk](https://pan.quark.cn/)
|
||||
- [UC云盘-uc](https://fast.uc.cn/)
|
||||
- [移动云盘-p139](https://yun.139.com/)
|
||||
- [联通云盘-pwo](https://pan.wo.cn/)
|
||||
- [天翼云盘-p189](https://cloud.189.cn/)
|
||||
|
||||
## API接口
|
||||
|
||||
[api接口文档](https://nfdparser.apifox.cn/)
|
||||
|
||||
### 服务端口
|
||||
- **6400**: API 服务端口(建议使用 Nginx 代理)
|
||||
@@ -165,6 +147,89 @@ GET /json/getFileList?url={分享链接}&pwd={密码}
|
||||
- `{网盘标识}` 参考支持的网盘列表
|
||||
- `your_host` 替换为您的域名或 IP
|
||||
|
||||
### 认证参数(v0.2.1+)
|
||||
[可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html)
|
||||
部分网盘(如夸克、UC)需要登录后的 Cookie 才能解析和下载。可通过 `auth` 参数传递认证信息:
|
||||
|
||||
**参数格式**:`auth` 参数值为 AES 加密后的 JSON 字符串,经过 Base64 编码和 URL 编码
|
||||
|
||||
**加密方式**:
|
||||
- 算法:AES/ECB/PKCS5Padding
|
||||
- 密钥:`nfd_auth_key2026`(16字节)
|
||||
- 流程:JSON → AES加密 → Base64 → URL编码
|
||||
|
||||
**JSON 结构**:
|
||||
```json
|
||||
{
|
||||
"authType": "cookie", // 认证类型: cookie/accesstoken/authorization/password/custom
|
||||
"token": "your_cookie_here", // Cookie 或 Token 内容
|
||||
"username": "", // 用户名(password 类型时使用)
|
||||
"password": "", // 密码(password 类型时使用)
|
||||
"ext1": "", // 扩展字段1(custom 类型时使用)
|
||||
"ext2": "" // 扩展字段2(custom 类型时使用)
|
||||
}
|
||||
```
|
||||
|
||||
**网盘认证要求**:
|
||||
| 网盘 | 认证要求 | 说明 |
|
||||
|------|---------|------|
|
||||
| 夸克网盘(QK) | **必须** | 必须配置 Cookie 才能解析 |
|
||||
| UC网盘(UC) | **必须** | 必须配置 Cookie 才能解析 |
|
||||
| 小飞机网盘(FJ) | 可选 | 大文件(>100MB)需要认证 |
|
||||
| 蓝奏优享(IZ) | 可选 | 大文件需要认证 |
|
||||
| 123网盘(YE) | 可选 | 需要下载大文件/需要登录的分享时才需要认证,支持账号密码或 token/authorization |
|
||||
|
||||
**使用示例**:
|
||||
```
|
||||
GET /parser?url={分享链接}&pwd={密码}&auth={加密后的认证参数}
|
||||
```
|
||||
|
||||
> 💡 提示:Web 界面已内置认证配置功能,可自动处理加密过程,无需手动构造参数。
|
||||
> [可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html)
|
||||
|
||||
#### 密钥作用说明
|
||||
|
||||
- `server.authEncryptKey`
|
||||
- 作用:用于 `auth` 参数的 AES 加解密
|
||||
- 要求:16位(AES-128)
|
||||
|
||||
- `server.donatedAccountFailureTokenSignKey`
|
||||
- 作用:用于“捐赠账号失败计数 token”的 HMAC 签名/验签
|
||||
- 目的:防止客户端伪造失败计数请求
|
||||
- 建议:使用高强度随机字符串,且不要与 `authEncryptKey` 相同
|
||||
|
||||
#### `auth` 临时认证参数 与 `auths` 静态配置认证 的区别
|
||||
|
||||
本项目存在两种互相独立的认证配置方式,作用范围不同,不要混淆:
|
||||
|
||||
| 方式 | 配置位置 | 生效范围 | 适用场景 |
|
||||
|------|---------|---------|---------|
|
||||
| `auth` 临时认证参数 | 请求 URL 上的 `auth` 查询参数 | **仅当次请求**,优先级高于 app-dev.yml 中的静态配置 | 调用方按用户临时提供的账号/Cookie/token 解析,不同请求可携带不同认证信息 |
|
||||
| `auths` 静态配置认证 | `app-dev.yml` 的 `auths.<网盘标识>` 节点 | **服务端长期生效**,所有未携带 `auth` 参数的请求都会复用 | 部署方自己长期配置一份账号,供所有请求默认使用 |
|
||||
|
||||
以 123网盘(`ye`)为例,`app-dev.yml` 中支持以下几种写法(三选一即可):
|
||||
|
||||
```yaml
|
||||
auths:
|
||||
ye:
|
||||
username: 你的123网盘账号
|
||||
password: 你的123网盘密码
|
||||
```
|
||||
```yaml
|
||||
auths:
|
||||
ye:
|
||||
token: 已登录后获取的 Authorization/AccessToken
|
||||
```
|
||||
```yaml
|
||||
auths:
|
||||
ye:
|
||||
authorization: 已登录后获取的 Authorization/AccessToken # 与 token 等价,二选一
|
||||
```
|
||||
|
||||
> ⚠️ 注意:YAML 中 key 后面不写值(如 `authorization:` 空着)等同于没配置,不会生效,必须填入真实的账号密码或 token 内容。
|
||||
|
||||
如果只是临时调用一次,不想改动服务端配置,也可以用上面提到的 `auth` 参数临时传递(`authType` 可选 `password`/`accesstoken`/`authorization`),无需重启服务,仅本次请求生效。
|
||||
|
||||
### 特殊说明
|
||||
|
||||
- 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值
|
||||
@@ -245,9 +310,12 @@ json返回数据格式示例:
|
||||
"timestamp": 1736489219402
|
||||
}
|
||||
```
|
||||
#### 3. 文件夹解析(仅支持蓝奏云/蓝奏优享/小飞机网盘)
|
||||
#### 3. 文件夹解析(支持蓝奏云/蓝奏优享/小飞机/永硕E盘等)
|
||||
/v2/getFileList?url=分享链接&pwd=分享密码
|
||||
|
||||
永硕E盘(`ys`)空间链接先返回目录列表,再带 `dirId` 获取目录内文件:
|
||||
`/v2/getFileList?url=https://xxx.ysepan.com/&pwd=空间密码&dirId=目录编号`
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
@@ -298,15 +366,16 @@ json返回数据格式示例:
|
||||
| 网盘名称 | 免登陆下载分享 | 加密分享 | 初始网盘空间 | 单文件大小限制 |
|
||||
|-------------|---------|----------|-----------|-----------------|
|
||||
| 蓝奏云 | √ | √ | 不限空间 | 100M |
|
||||
| 奶牛快传 | √ | X | 10G | 不限大小 |
|
||||
| 移动云云空间(个人版) | √ | √(密码可忽略) | 5G(个人) | 不限大小 |
|
||||
| 小飞机网盘 | √ | √(密码可忽略) | 10G | 不限大小 |
|
||||
| 360亿方云 | √ | √(密码可忽略) | 100G(须实名) | 不限大小 |
|
||||
| 小飞机网盘 | √ | √ | 10G | 不限大小 |
|
||||
| 360亿方云 | √ | √ | 100G(须实名) | 不限大小 |
|
||||
| 123云盘 | √ | √ | 2T | 100G(>100M需要登录) |
|
||||
| 文叔叔 | √ | √ | 10G | 5GB |
|
||||
| 永硕E盘 | √ | √(空间密码) | 视套餐 | 视套餐 |
|
||||
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
|
||||
| 夸克网盘 | x | √ | 10G | 不限大小 |
|
||||
| UC网盘 | x | √ | 10G | 不限大小 |
|
||||
| 飞书云盘 | √ | X | 15G | 不限大小 |
|
||||
|
||||
# 打包部署
|
||||
|
||||
@@ -318,51 +387,13 @@ json返回数据格式示例:
|
||||
|
||||
## 开发和打包
|
||||
|
||||
### 环境要求
|
||||
- JDK 17+
|
||||
- Maven 3.x
|
||||
- Python 3.x(可选,仅当需要 Playground 代码智能提示功能时)
|
||||
|
||||
### 本地构建步骤
|
||||
|
||||
#### 1. Maven 打包
|
||||
|
||||
```shell
|
||||
# 清理并打包(跳过测试)
|
||||
mvn clean package -DskipTests
|
||||
# 环境要求: Jdk17 + maven;
|
||||
mvn clean
|
||||
mvn package -DskipTests
|
||||
|
||||
# 如果遇到代理问题,可以禁用代理
|
||||
mvn clean package -DskipTests -Dhttp.proxyHost= -Dhttps.proxyHost=
|
||||
```
|
||||
|
||||
> **说明:**
|
||||
> - Maven 构建时会自动安装 **requests** 等核心 Python 包(由 graalpy-maven-plugin 处理)
|
||||
> - 打包好的文件位于 `web-service/target/netdisk-fast-download-bin.zip`
|
||||
|
||||
#### 2. (可选)安装 Python LSP 服务器
|
||||
|
||||
如果需要使用 **Playground 代码智能提示功能**,需要额外安装 python-lsp-server:
|
||||
|
||||
```shell
|
||||
# 进入 parser 目录
|
||||
cd parser
|
||||
|
||||
# 运行 pip 包安装脚本
|
||||
chmod +x setup-graalpy-packages.sh
|
||||
./setup-graalpy-packages.sh
|
||||
|
||||
# 返回项目根目录
|
||||
cd ..
|
||||
```
|
||||
|
||||
该脚本会将以下包安装到 `parser/src/main/resources/graalpy-packages/`:
|
||||
- **python-lsp-server** 及其依赖(jedi, python-lsp-jsonrpc, pluggy)- Python LSP 服务器(代码智能提示)
|
||||
- **pylsp 可选功能**(pyflakes, pycodestyle, autopep8, rope, yapf)- 代码检查和格式化
|
||||
|
||||
> **注意:**
|
||||
> - 这些包需要用系统 pip 安装,因为 python-lsp-server 依赖 ujson(需编译 C 扩展)
|
||||
> - 安装后的包会被打包进 jar 文件,不会被 `mvn clean` 清理
|
||||
> - 如果不需要 Playground 的代码智能提示功能,可以跳过此步骤
|
||||
打包好的文件位于 web-service/target/netdisk-fast-download-bin.zip
|
||||
|
||||
## 🚀 快速部署
|
||||
|
||||
@@ -370,82 +401,7 @@ cd ..
|
||||
|
||||
## Linux服务部署
|
||||
|
||||
### Docker 部署
|
||||
|
||||
> **注意:** 本分支(feature/graalpy-parser)包含 Python 解析器支持(GraalPy),镜像体积较大。
|
||||
> 如果不需要 Python 解析器功能,建议使用 main 分支的标准版本。
|
||||
|
||||
#### Python 版本特性
|
||||
- ✅ 支持 Python 自定义解析器(基于 GraalPy)
|
||||
- ✅ 内置 Python 语言服务器(pylsp)支持代码补全
|
||||
- ✅ 支持外部 Python 环境通过 WebSocket 连接
|
||||
- ⚠️ 镜像体积较大(约增加 200MB)
|
||||
|
||||
#### 海外服务器Docker部署(Python版)
|
||||
|
||||
> **注意:** 请根据 [Releases](https://github.com/qaiu/netdisk-fast-download/releases) 页面使用最新的 py 标签版本
|
||||
|
||||
```shell
|
||||
# 创建目录
|
||||
mkdir -p netdisk-fast-download
|
||||
cd netdisk-fast-download
|
||||
|
||||
# 拉取镜像(使用 py 标签,请替换为最新版本号)
|
||||
docker pull ghcr.io/qaiu/netdisk-fast-download:v0.1.9b19py
|
||||
|
||||
# 复制配置文件(或下载仓库web-service\src\main\resources)
|
||||
docker create --name netdisk-fast-download ghcr.io/qaiu/netdisk-fast-download:v0.1.9b19py
|
||||
docker cp netdisk-fast-download:/app/resources ./resources
|
||||
docker rm netdisk-fast-download
|
||||
|
||||
# 启动容器
|
||||
docker run -d -it --name netdisk-fast-download -p 6401:6401 --restart unless-stopped -e TZ=Asia/Shanghai -v ./resources:/app/resources -v ./db:/app/db -v ./logs:/app/logs ghcr.io/qaiu/netdisk-fast-download:v0.1.9b19py
|
||||
|
||||
# 反代6401端口
|
||||
|
||||
# 升级容器(请手动指定版本号)
|
||||
# 注意:Python 版本不会更新 latest 标签,需要手动指定版本
|
||||
docker pull ghcr.io/qaiu/netdisk-fast-download:v0.1.9b19py
|
||||
docker stop netdisk-fast-download
|
||||
docker rm netdisk-fast-download
|
||||
docker run -d -it --name netdisk-fast-download -p 6401:6401 --restart unless-stopped -e TZ=Asia/Shanghai -v ./resources:/app/resources -v ./db:/app/db -v ./logs:/app/logs ghcr.io/qaiu/netdisk-fast-download:v0.1.9b19py
|
||||
```
|
||||
|
||||
#### 国内Docker部署(Python版)
|
||||
|
||||
> **注意:** 请根据 [Releases](https://github.com/qaiu/netdisk-fast-download/releases) 页面使用最新的 py 标签版本
|
||||
|
||||
```shell
|
||||
# 创建目录
|
||||
mkdir -p netdisk-fast-download
|
||||
cd netdisk-fast-download
|
||||
|
||||
# 拉取镜像(使用 py 标签,请替换为最新版本号)
|
||||
docker pull ghcr.nju.edu.cn/qaiu/netdisk-fast-download:v0.1.9b19py
|
||||
|
||||
# 复制配置文件(或下载仓库web-service\src\main\resources)
|
||||
docker create --name netdisk-fast-download ghcr.nju.edu.cn/qaiu/netdisk-fast-download:v0.1.9b19py
|
||||
docker cp netdisk-fast-download:/app/resources ./resources
|
||||
docker rm netdisk-fast-download
|
||||
|
||||
# 启动容器
|
||||
docker run -d -it --name netdisk-fast-download -p 6401:6401 --restart unless-stopped -e TZ=Asia/Shanghai -v ./resources:/app/resources -v ./db:/app/db -v ./logs:/app/logs ghcr.nju.edu.cn/qaiu/netdisk-fast-download:v0.1.9b19py
|
||||
|
||||
# 反代6401端口
|
||||
|
||||
# 升级容器(请手动指定版本号)
|
||||
# 注意:Python 版本不会更新 latest 标签,需要手动指定版本
|
||||
docker pull ghcr.nju.edu.cn/qaiu/netdisk-fast-download:v0.1.9b19py
|
||||
docker stop netdisk-fast-download
|
||||
docker rm netdisk-fast-download
|
||||
docker run -d -it --name netdisk-fast-download -p 6401:6401 --restart unless-stopped -e TZ=Asia/Shanghai -v ./resources:/app/resources -v ./db:/app/db -v ./logs:/app/logs ghcr.nju.edu.cn/qaiu/netdisk-fast-download:v0.1.9b19py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Docker 部署(标准版 - Main分支)
|
||||
|
||||
如果不需要 Python 解析器功能,建议使用标准版本:
|
||||
### Docker 部署(Main分支)
|
||||
|
||||
#### 海外服务器Docker部署
|
||||
```shell
|
||||
@@ -499,7 +455,7 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtow
|
||||
> 注意: netdisk-fast-download.service中的ExecStart的路径改为实际路径
|
||||
```shell
|
||||
cd ~
|
||||
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v0.1.9b7/netdisk-fast-download-bin.zip
|
||||
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v3.0.2/netdisk-fast-download-bin.zip
|
||||
unzip netdisk-fast-download-bin.zip
|
||||
cd netdisk-fast-download
|
||||
bash service-install.sh
|
||||
@@ -572,23 +528,6 @@ auths:
|
||||
|
||||
**注意:** 目前仅支持 123(ye)的认证配置。
|
||||
|
||||
## 开发计划
|
||||
### v0.1.8~v0.1.9 ✓
|
||||
- API添加文件信息(专属版/开源版)
|
||||
- 目录解析(专属版/开源版)
|
||||
- 文件预览功能(专属版/开源版)
|
||||
- 文件夹预览功能(开源版)
|
||||
- 友好的错误提示和一键反馈功能(开源版)
|
||||
- 带cookie/token/username/pwd参数解析大文件(专属版)
|
||||
### v0.2.x
|
||||
- web后台管理--认证配置/分享链接管理(开源版/专属版)
|
||||
- 123/小飞机/蓝奏优享等大文件解析(开源版)
|
||||
- 直链分享(开源版/专属版)
|
||||
- aria2/idm+/curl/wget链接生成(开源版/专属版)
|
||||
- IP限流配置(开源版/专属版)
|
||||
- refere防盗链,API鉴权防盗链(专属版)
|
||||
- 123/小飞机/蓝奏优享/蓝奏文件夹解析API,天翼云盘/移动云盘文件夹解析API(专属版)
|
||||
- 用户管理面板--营销推广系统(专属版)
|
||||
|
||||
**技术栈:**
|
||||
Jdk17+Vert.x4
|
||||
@@ -597,7 +536,13 @@ Core模块集成Vert.x实现类似spring的注解式路由API
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#qaiu/netdisk-fast-download&Date)
|
||||
<a href="https://www.star-history.com/?repos=qaiu%2Fnetdisk-fast-download&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=qaiu/netdisk-fast-download&type=date&theme=dark&legend=bottom-right&sealed_token=dfQO_dJcTqcPkEnM7SfxRyHoFbV5Ah4LxoEhdlheMn4T2YLEV_WETxFZexeAbWN5OmNyYuycWan2d42PAFbw0CuU4oCTKgehfErFJ9eVl2CyVpP_4xrdQw" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=qaiu/netdisk-fast-download&type=date&legend=bottom-right&sealed_token=dfQO_dJcTqcPkEnM7SfxRyHoFbV5Ah4LxoEhdlheMn4T2YLEV_WETxFZexeAbWN5OmNyYuycWan2d42PAFbw0CuU4oCTKgehfErFJ9eVl2CyVpP_4xrdQw" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=qaiu/netdisk-fast-download&type=date&legend=bottom-right&sealed_token=dfQO_dJcTqcPkEnM7SfxRyHoFbV5Ah4LxoEhdlheMn4T2YLEV_WETxFZexeAbWN5OmNyYuycWan2d42PAFbw0CuU4oCTKgehfErFJ9eVl2CyVpP_4xrdQw" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## **免责声明**
|
||||
- 用户在使用本项目时,应自行承担风险,并确保其行为符合当地法律法规。开发者不对用户因使用本项目而导致的任何后果负责。
|
||||
@@ -613,20 +558,5 @@ Core模块集成Vert.x实现类似spring的注解式路由API
|
||||
</p>
|
||||
|
||||
|
||||
### 关于赞助定制专属版
|
||||
1. 专属版提供对小飞机,蓝奏优享大文件解析的支持, 提供天翼云盘/移动云盘/联通云盘的解析支持。
|
||||
2. 可提供托管服务:包含部署服务和云服务器环境。
|
||||
3. 可提供功能定制开发。
|
||||
您可能需要提供一定的资金赞助支持定制专属版, 请添加以下任意一个联系方式详谈赞助模式:
|
||||
<p>qq: 197575894</p>
|
||||
<p>wechat: imcoding_</p>
|
||||
|
||||
<!--
|
||||

|
||||
|
||||
[手机端支付宝打赏跳转链接](https://qr.alipay.com/fkx01882dnoxxtjenhlxt53)
|
||||
-->
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
#!/bin/bash
|
||||
# set -x
|
||||
LAUNCH_JAR="netdisk-fast-download.jar"
|
||||
nohup java -Xmx512M -jar "$LAUNCH_JAR" "$@" >startup.log 2>&1 &
|
||||
tail -f startup.log
|
||||
exec java -Xmx${JVM_XMX:-512M} ${JVM_OPTS} -jar "$LAUNCH_JAR" "$@"
|
||||
|
||||
|
||||
+1
-15
@@ -65,21 +65,7 @@
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.3</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 测试依赖 -->
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>4.13.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.38</version>
|
||||
<scope>test</scope>
|
||||
<version>42.7.13</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
@@ -53,7 +53,7 @@ public class CreateDatabase {
|
||||
stmt.executeUpdate("CREATE DATABASE IF NOT EXISTS " + dbName + " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||
LOGGER.info(">>>>>>>>>>> 数据库'{}'创建成功 <<<<<<<<<<<<", dbName);
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
LOGGER.error("创建数据库失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,35 +24,39 @@ import java.util.*;
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
*/
|
||||
public class CreateTable {
|
||||
public static Map<Class<?>, String> javaProperty2SqlColumnMap = new HashMap<>() {{
|
||||
public static final Map<Class<?>, String> javaProperty2SqlColumnMap;
|
||||
static {
|
||||
Map<Class<?>, String> map = new HashMap<>();
|
||||
// Java类型到SQL类型的映射
|
||||
put(Integer.class, "INT");
|
||||
put(Short.class, "SMALLINT");
|
||||
put(Byte.class, "TINYINT");
|
||||
put(Long.class, "BIGINT");
|
||||
put(java.math.BigDecimal.class, "DECIMAL");
|
||||
put(Double.class, "DOUBLE");
|
||||
put(Float.class, "REAL");
|
||||
put(Boolean.class, "BOOLEAN");
|
||||
put(String.class, "VARCHAR");
|
||||
put(Date.class, "TIMESTAMP");
|
||||
put(java.time.LocalDateTime.class, "TIMESTAMP");
|
||||
put(java.sql.Timestamp.class, "TIMESTAMP");
|
||||
put(java.sql.Date.class, "DATE");
|
||||
put(java.sql.Time.class, "TIME");
|
||||
map.put(Integer.class, "INT");
|
||||
map.put(Short.class, "SMALLINT");
|
||||
map.put(Byte.class, "TINYINT");
|
||||
map.put(Long.class, "BIGINT");
|
||||
map.put(java.math.BigDecimal.class, "DECIMAL");
|
||||
map.put(Double.class, "DOUBLE");
|
||||
map.put(Float.class, "REAL");
|
||||
map.put(Boolean.class, "BOOLEAN");
|
||||
map.put(String.class, "VARCHAR");
|
||||
map.put(Date.class, "TIMESTAMP");
|
||||
map.put(java.time.LocalDateTime.class, "TIMESTAMP");
|
||||
map.put(java.sql.Timestamp.class, "TIMESTAMP");
|
||||
map.put(java.sql.Date.class, "DATE");
|
||||
map.put(java.sql.Time.class, "TIME");
|
||||
|
||||
// 基本数据类型
|
||||
put(int.class, "INT");
|
||||
put(short.class, "SMALLINT");
|
||||
put(byte.class, "TINYINT");
|
||||
put(long.class, "BIGINT");
|
||||
put(double.class, "DOUBLE");
|
||||
put(float.class, "REAL");
|
||||
put(boolean.class, "BOOLEAN");
|
||||
}};
|
||||
map.put(int.class, "INT");
|
||||
map.put(short.class, "SMALLINT");
|
||||
map.put(byte.class, "TINYINT");
|
||||
map.put(long.class, "BIGINT");
|
||||
map.put(double.class, "DOUBLE");
|
||||
map.put(float.class, "REAL");
|
||||
map.put(boolean.class, "BOOLEAN");
|
||||
|
||||
javaProperty2SqlColumnMap = Collections.unmodifiableMap(map);
|
||||
}
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(CreateTable.class);
|
||||
public static String UNIQUE_PREFIX = "idx_";
|
||||
public static final String UNIQUE_PREFIX = "idx_";
|
||||
|
||||
private static Case getCase(Class<?> clz) {
|
||||
return switch (clz.getName()) {
|
||||
@@ -303,7 +307,7 @@ public class CreateTable {
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
List<Future<Object>> createFutures = new ArrayList<>();
|
||||
List<Future<Object>> futures = new ArrayList<>();
|
||||
|
||||
for (Class<?> clazz : tableClasses) {
|
||||
List<String> sqlList = getCreateTableSQL(clazz, type);
|
||||
@@ -312,41 +316,23 @@ public class CreateTable {
|
||||
for (String sql : sqlList) {
|
||||
try {
|
||||
pool.query(sql).execute().toCompletionStage().toCompletableFuture().join();
|
||||
createFutures.add(Future.succeededFuture());
|
||||
futures.add(Future.succeededFuture());
|
||||
LOGGER.debug("Executed SQL:\n{}", sql);
|
||||
} catch (Exception e) {
|
||||
String message = e.getMessage();
|
||||
if (message != null && message.contains("Duplicate key name")) {
|
||||
LOGGER.warn("Ignoring duplicate key error: {}", message);
|
||||
createFutures.add(Future.succeededFuture());
|
||||
futures.add(Future.succeededFuture());
|
||||
} else {
|
||||
LOGGER.error("SQL Error: {}\nSQL: {}", message, sql);
|
||||
createFutures.add(Future.failedFuture(e));
|
||||
futures.add(Future.failedFuture(e));
|
||||
throw new RuntimeException(e); // Stop execution for other exceptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建表完成后,执行表结构迁移检查
|
||||
Future.all(createFutures)
|
||||
.compose(v -> {
|
||||
LOGGER.info("开始检查表结构变更...");
|
||||
List<Future<Void>> migrationFutures = new ArrayList<>();
|
||||
for (Class<?> clazz : tableClasses) {
|
||||
migrationFutures.add(SchemaMigration.migrateTable(pool, clazz, type));
|
||||
}
|
||||
return Future.all(migrationFutures).mapEmpty();
|
||||
})
|
||||
.onSuccess(v -> {
|
||||
LOGGER.info("表结构检查和变更完成");
|
||||
promise.complete();
|
||||
})
|
||||
.onFailure(err -> {
|
||||
LOGGER.error("表结构变更失败", err);
|
||||
promise.fail(err);
|
||||
});
|
||||
|
||||
Future.all(futures).onSuccess(r -> promise.complete()).onFailure(promise::fail);
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package cn.qaiu.db.ddl;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 标识新增字段,用于数据库表结构迁移
|
||||
* 只有带此注解的字段才会被 SchemaMigration 检查和添加
|
||||
*
|
||||
* <p>使用场景:</p>
|
||||
* <ul>
|
||||
* <li>在现有实体类中添加新字段时,使用此注解标记</li>
|
||||
* <li>应用启动时会自动检测并添加到数据库表中</li>
|
||||
* <li>添加成功后可以移除此注解,避免重复检查</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>示例:</p>
|
||||
* <pre>{@code
|
||||
* @Data
|
||||
* @Table("users")
|
||||
* public class User {
|
||||
* private Long id;
|
||||
* private String name;
|
||||
*
|
||||
* @NewField // 标记为新增字段
|
||||
* @Length(varcharSize = 32)
|
||||
* @Constraint(defaultValue = "active")
|
||||
* private String status;
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
*/
|
||||
@Target(ElementType.FIELD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface NewField {
|
||||
|
||||
/**
|
||||
* 字段描述(可选)
|
||||
*/
|
||||
String value() default "";
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
package cn.qaiu.db.ddl;
|
||||
|
||||
import cn.qaiu.db.pool.JDBCType;
|
||||
import io.vertx.codegen.format.Case;
|
||||
import io.vertx.codegen.format.LowerCamelCase;
|
||||
import io.vertx.codegen.format.SnakeCase;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.sqlclient.Pool;
|
||||
import io.vertx.sqlclient.templates.annotations.Column;
|
||||
import io.vertx.sqlclient.templates.annotations.RowMapped;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 数据库表结构变更处理器
|
||||
* 用于在应用启动时自动检测并添加缺失的字段
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
*/
|
||||
public class SchemaMigration {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SchemaMigration.class);
|
||||
|
||||
/**
|
||||
* 检查并迁移表结构
|
||||
* 只处理带有 @NewField 注解的字段,避免检查所有字段导致的重复错误
|
||||
*
|
||||
* @param pool 数据库连接池
|
||||
* @param clazz 实体类
|
||||
* @param type 数据库类型
|
||||
* @return Future
|
||||
*/
|
||||
public static Future<Void> migrateTable(Pool pool, Class<?> clazz, JDBCType type) {
|
||||
Promise<Void> promise = Promise.promise();
|
||||
|
||||
try {
|
||||
String tableName = getTableName(clazz);
|
||||
|
||||
// 获取带有 @NewField 注解的字段
|
||||
List<Field> newFields = getNewFields(clazz);
|
||||
|
||||
if (newFields.isEmpty()) {
|
||||
log.debug("表 '{}' 没有标记为 @NewField 的字段,跳过结构检查", tableName);
|
||||
promise.complete();
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
log.info("开始检查表 '{}' 的结构变更,新增字段数: {}", tableName, newFields.size());
|
||||
|
||||
// 获取表的所有字段
|
||||
getTableColumns(pool, tableName, type)
|
||||
.compose(existingColumns -> {
|
||||
// 只添加带有 @NewField 注解且不存在的字段
|
||||
return addNewFields(pool, clazz, tableName, newFields, existingColumns, type);
|
||||
})
|
||||
.onSuccess(v -> {
|
||||
log.info("表 '{}' 结构变更完成", tableName);
|
||||
promise.complete();
|
||||
})
|
||||
.onFailure(err -> {
|
||||
log.error("表 '{}' 结构变更失败", tableName, err);
|
||||
promise.fail(err);
|
||||
});
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("检查表结构失败", e);
|
||||
promise.fail(e);
|
||||
}
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取带有 @NewField 注解的字段列表
|
||||
*/
|
||||
private static List<Field> getNewFields(Class<?> clazz) {
|
||||
List<Field> newFields = new ArrayList<>();
|
||||
for (Field field : clazz.getDeclaredFields()) {
|
||||
if (field.isAnnotationPresent(NewField.class) && !isIgnoredField(field)) {
|
||||
newFields.add(field);
|
||||
String desc = field.getAnnotation(NewField.class).value();
|
||||
if (StringUtils.isNotEmpty(desc)) {
|
||||
log.debug("发现新字段: {} - {}", field.getName(), desc);
|
||||
} else {
|
||||
log.debug("发现新字段: {}", field.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
return newFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表名
|
||||
*/
|
||||
private static String getTableName(Class<?> clazz) {
|
||||
if (clazz.isAnnotationPresent(Table.class)) {
|
||||
Table annotation = clazz.getAnnotation(Table.class);
|
||||
if (StringUtils.isNotEmpty(annotation.value())) {
|
||||
return annotation.value();
|
||||
}
|
||||
}
|
||||
|
||||
// 默认使用类名转下划线命名
|
||||
Case caseFormat = SnakeCase.INSTANCE;
|
||||
if (clazz.isAnnotationPresent(RowMapped.class)) {
|
||||
RowMapped annotation = clazz.getAnnotation(RowMapped.class);
|
||||
caseFormat = getCase(annotation.formatter());
|
||||
}
|
||||
return LowerCamelCase.INSTANCE.to(caseFormat, clazz.getSimpleName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表的现有字段
|
||||
*/
|
||||
private static Future<Set<String>> getTableColumns(Pool pool, String tableName, JDBCType type) {
|
||||
Promise<Set<String>> promise = Promise.promise();
|
||||
|
||||
String sql = switch (type) {
|
||||
case MySQL -> String.format(
|
||||
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '%s'",
|
||||
tableName
|
||||
);
|
||||
case H2DB -> String.format(
|
||||
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = SCHEMA() AND TABLE_NAME = '%s'",
|
||||
tableName.toUpperCase()
|
||||
);
|
||||
case PostgreSQL -> String.format(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = '%s'",
|
||||
tableName.toLowerCase()
|
||||
);
|
||||
};
|
||||
|
||||
pool.query(sql).execute()
|
||||
.onSuccess(rows -> {
|
||||
Set<String> columns = new HashSet<>();
|
||||
rows.forEach(row -> {
|
||||
String columnName = row.getString(0);
|
||||
if (columnName != null) {
|
||||
columns.add(columnName.toLowerCase());
|
||||
}
|
||||
});
|
||||
log.debug("表 '{}' 现有字段: {}", tableName, columns);
|
||||
promise.complete(columns);
|
||||
})
|
||||
.onFailure(err -> {
|
||||
log.warn("获取表 '{}' 字段列表失败,可能表不存在: {}", tableName, err.getMessage());
|
||||
promise.complete(new HashSet<>()); // 返回空集合,触发创建表逻辑
|
||||
});
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 添加新字段(只处理带 @NewField 注解的字段)
|
||||
*/
|
||||
private static Future<Void> addNewFields(Pool pool, Class<?> clazz, String tableName,
|
||||
List<Field> newFields, Set<String> existingColumns,
|
||||
JDBCType type) {
|
||||
List<Future<Void>> futures = new ArrayList<>();
|
||||
|
||||
Case caseFormat = SnakeCase.INSTANCE;
|
||||
if (clazz.isAnnotationPresent(RowMapped.class)) {
|
||||
RowMapped annotation = clazz.getAnnotation(RowMapped.class);
|
||||
caseFormat = getCase(annotation.formatter());
|
||||
}
|
||||
|
||||
String quotationMarks = type == JDBCType.MySQL ? "`" : "\"";
|
||||
|
||||
for (Field field : newFields) {
|
||||
// 获取字段名
|
||||
String columnName;
|
||||
if (field.isAnnotationPresent(Column.class)) {
|
||||
Column annotation = field.getAnnotation(Column.class);
|
||||
columnName = StringUtils.isNotEmpty(annotation.name())
|
||||
? annotation.name()
|
||||
: LowerCamelCase.INSTANCE.to(caseFormat, field.getName());
|
||||
} else {
|
||||
columnName = LowerCamelCase.INSTANCE.to(caseFormat, field.getName());
|
||||
}
|
||||
|
||||
// 检查字段是否已存在
|
||||
if (existingColumns.contains(columnName.toLowerCase())) {
|
||||
log.warn("字段 '{}' 已存在,请移除 @NewField 注解", columnName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 生成 ALTER TABLE 语句
|
||||
String sql = buildAlterTableSQL(tableName, field, columnName, quotationMarks, type);
|
||||
|
||||
log.info("添加字段: {}", sql);
|
||||
|
||||
Promise<Void> p = Promise.promise();
|
||||
pool.query(sql).execute()
|
||||
.onSuccess(v -> {
|
||||
log.info("字段 '{}' 添加成功", columnName);
|
||||
p.complete();
|
||||
})
|
||||
.onFailure(err -> {
|
||||
String errorMsg = err.getMessage();
|
||||
// 如果字段已存在,忽略错误(可能是并发执行或检测失败)
|
||||
if (errorMsg != null && (errorMsg.contains("Duplicate column") ||
|
||||
errorMsg.contains("already exists") ||
|
||||
errorMsg.contains("duplicate key"))) {
|
||||
log.warn("字段 '{}' 已存在,跳过添加", columnName);
|
||||
p.complete();
|
||||
} else {
|
||||
log.error("字段 '{}' 添加失败", columnName, err);
|
||||
p.fail(err);
|
||||
}
|
||||
});
|
||||
|
||||
futures.add(p.future());
|
||||
}
|
||||
|
||||
return Future.all(futures).mapEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 ALTER TABLE 添加字段的 SQL
|
||||
*/
|
||||
private static String buildAlterTableSQL(String tableName, Field field, String columnName,
|
||||
String quotationMarks, JDBCType type) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("ALTER TABLE ").append(quotationMarks).append(tableName).append(quotationMarks)
|
||||
.append(" ADD COLUMN ").append(quotationMarks).append(columnName).append(quotationMarks);
|
||||
|
||||
// 获取字段类型
|
||||
String sqlType = CreateTable.javaProperty2SqlColumnMap.get(field.getType());
|
||||
if (sqlType == null) {
|
||||
sqlType = "VARCHAR";
|
||||
}
|
||||
sb.append(" ").append(sqlType);
|
||||
|
||||
// 添加类型长度
|
||||
int[] decimalSize = {22, 2};
|
||||
int varcharSize = 255;
|
||||
if (field.isAnnotationPresent(Length.class)) {
|
||||
Length length = field.getAnnotation(Length.class);
|
||||
decimalSize = length.decimalSize();
|
||||
varcharSize = length.varcharSize();
|
||||
}
|
||||
|
||||
if ("DECIMAL".equals(sqlType)) {
|
||||
sb.append("(").append(decimalSize[0]).append(",").append(decimalSize[1]).append(")");
|
||||
} else if ("VARCHAR".equals(sqlType)) {
|
||||
sb.append("(").append(varcharSize).append(")");
|
||||
}
|
||||
|
||||
// 添加约束
|
||||
if (field.isAnnotationPresent(Constraint.class)) {
|
||||
Constraint constraint = field.getAnnotation(Constraint.class);
|
||||
|
||||
if (constraint.notNull()) {
|
||||
sb.append(" NOT NULL");
|
||||
}
|
||||
|
||||
if (StringUtils.isNotEmpty(constraint.defaultValue())) {
|
||||
String apostrophe = constraint.defaultValueIsFunction() ? "" : "'";
|
||||
sb.append(" DEFAULT ").append(apostrophe).append(constraint.defaultValue()).append(apostrophe);
|
||||
}
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否忽略字段
|
||||
*/
|
||||
private static boolean isIgnoredField(Field field) {
|
||||
int modifiers = field.getModifiers();
|
||||
return java.lang.reflect.Modifier.isStatic(modifiers)
|
||||
|| java.lang.reflect.Modifier.isTransient(modifiers)
|
||||
|| field.isAnnotationPresent(TableGenIgnore.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Case 类型
|
||||
*/
|
||||
private static Case getCase(Class<?> clz) {
|
||||
return switch (clz.getName()) {
|
||||
case "io.vertx.codegen.format.CamelCase" -> io.vertx.codegen.format.CamelCase.INSTANCE;
|
||||
case "io.vertx.codegen.format.SnakeCase" -> SnakeCase.INSTANCE;
|
||||
case "io.vertx.codegen.format.LowerCamelCase" -> LowerCamelCase.INSTANCE;
|
||||
default -> SnakeCase.INSTANCE;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import org.slf4j.LoggerFactory;
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
*/
|
||||
public class JDBCPoolInit {
|
||||
public class JDBCPoolInit implements AutoCloseable {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(JDBCPoolInit.class);
|
||||
|
||||
@@ -101,4 +101,16 @@ public class JDBCPoolInit {
|
||||
synchronized public JDBCPool getPool() {
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭连接池,释放数据库资源
|
||||
*/
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
if (pool != null) {
|
||||
pool.close();
|
||||
LOGGER.info("数据库连接池已关闭: URL={}", url);
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
package cn.qaiu.db.ddl;
|
||||
|
||||
import cn.qaiu.db.pool.JDBCType;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.jdbcclient.JDBCPool;
|
||||
import io.vertx.sqlclient.templates.annotations.Column;
|
||||
import lombok.Data;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* SchemaMigration 单元测试
|
||||
*/
|
||||
public class SchemaMigrationTest {
|
||||
|
||||
private Vertx vertx;
|
||||
private JDBCPool pool;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
vertx = Vertx.vertx();
|
||||
|
||||
// 创建 H2 内存数据库连接池
|
||||
pool = JDBCPool.pool(vertx,
|
||||
"jdbc:h2:mem:test;DB_CLOSE_DELAY=-1",
|
||||
"sa",
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (pool != null) {
|
||||
pool.close();
|
||||
}
|
||||
if (vertx != null) {
|
||||
vertx.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试添加新字段
|
||||
*/
|
||||
@Test
|
||||
public void testAddNewField() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
// 1. 先创建一个基础表
|
||||
String createTableSQL = """
|
||||
CREATE TABLE test_user (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL
|
||||
)
|
||||
""";
|
||||
|
||||
pool.query(createTableSQL).execute()
|
||||
.compose(v -> {
|
||||
// 2. 使用 SchemaMigration 添加新字段
|
||||
return SchemaMigration.migrateTable(pool, TestUserWithNewField.class, JDBCType.H2DB);
|
||||
})
|
||||
.compose(v -> {
|
||||
// 3. 验证新字段是否添加成功
|
||||
return pool.query("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'TEST_USER' AND COLUMN_NAME = 'EMAIL'")
|
||||
.execute();
|
||||
})
|
||||
.onSuccess(rows -> {
|
||||
assertEquals("应该找到新添加的 email 字段", 1, rows.size());
|
||||
latch.countDown();
|
||||
})
|
||||
.onFailure(err -> {
|
||||
fail("测试失败: " + err.getMessage());
|
||||
latch.countDown();
|
||||
});
|
||||
|
||||
assertTrue("测试超时", latch.await(10, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试不添加已存在的字段
|
||||
*/
|
||||
@Test
|
||||
public void testSkipExistingField() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
// 1. 创建包含 email 字段的表
|
||||
String createTableSQL = """
|
||||
CREATE TABLE test_user2 (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
email VARCHAR(100)
|
||||
)
|
||||
""";
|
||||
|
||||
pool.query(createTableSQL).execute()
|
||||
.compose(v -> {
|
||||
// 2. 尝试再次添加 email 字段(应该跳过)
|
||||
return SchemaMigration.migrateTable(pool, TestUserWithNewField2.class, JDBCType.H2DB);
|
||||
})
|
||||
.onSuccess(v -> {
|
||||
// 3. 验证表结构正常,没有错误
|
||||
latch.countDown();
|
||||
})
|
||||
.onFailure(err -> {
|
||||
fail("测试失败: " + err.getMessage());
|
||||
latch.countDown();
|
||||
});
|
||||
|
||||
assertTrue("测试超时", latch.await(10, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试没有 @NewField 注解时不执行迁移
|
||||
*/
|
||||
@Test
|
||||
public void testNoNewFieldAnnotation() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
// 1. 创建基础表
|
||||
String createTableSQL = """
|
||||
CREATE TABLE test_user3 (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL
|
||||
)
|
||||
""";
|
||||
|
||||
pool.query(createTableSQL).execute()
|
||||
.compose(v -> {
|
||||
// 2. 使用没有 @NewField 注解的实体类
|
||||
return SchemaMigration.migrateTable(pool, TestUserNoAnnotation.class, JDBCType.H2DB);
|
||||
})
|
||||
.compose(v -> {
|
||||
// 3. 验证没有添加 email 字段
|
||||
return pool.query("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'TEST_USER3' AND COLUMN_NAME = 'EMAIL'")
|
||||
.execute();
|
||||
})
|
||||
.onSuccess(rows -> {
|
||||
assertEquals("不应该添加没有 @NewField 注解的字段", 0, rows.size());
|
||||
latch.countDown();
|
||||
})
|
||||
.onFailure(err -> {
|
||||
fail("测试失败: " + err.getMessage());
|
||||
latch.countDown();
|
||||
});
|
||||
|
||||
assertTrue("测试超时", latch.await(10, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试多个新字段同时添加
|
||||
*/
|
||||
@Test
|
||||
public void testMultipleNewFields() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
// 1. 创建基础表
|
||||
String createTableSQL = """
|
||||
CREATE TABLE test_user4 (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL
|
||||
)
|
||||
""";
|
||||
|
||||
pool.query(createTableSQL).execute()
|
||||
.compose(v -> {
|
||||
// 2. 添加多个新字段
|
||||
return SchemaMigration.migrateTable(pool, TestUserMultipleNewFields.class, JDBCType.H2DB);
|
||||
})
|
||||
.compose(v -> {
|
||||
// 3. 验证所有新字段都添加成功
|
||||
return pool.query("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'TEST_USER4' AND COLUMN_NAME IN ('EMAIL', 'PHONE', 'ADDRESS')")
|
||||
.execute();
|
||||
})
|
||||
.onSuccess(rows -> {
|
||||
int count = rows.iterator().next().getInteger(0);
|
||||
assertEquals("应该添加 3 个新字段", 3, count);
|
||||
latch.countDown();
|
||||
})
|
||||
.onFailure(err -> {
|
||||
fail("测试失败: " + err.getMessage());
|
||||
latch.countDown();
|
||||
});
|
||||
|
||||
assertTrue("测试超时", latch.await(10, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
// ========== 测试实体类 ==========
|
||||
|
||||
@Data
|
||||
@Table("test_user")
|
||||
static class TestUserWithNewField {
|
||||
@Constraint(autoIncrement = true)
|
||||
private Long id;
|
||||
|
||||
@Length(varcharSize = 50)
|
||||
@Constraint(notNull = true)
|
||||
private String name;
|
||||
|
||||
@NewField("用户邮箱")
|
||||
@Length(varcharSize = 100)
|
||||
private String email;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Table("test_user2")
|
||||
static class TestUserWithNewField2 {
|
||||
@Constraint(autoIncrement = true)
|
||||
private Long id;
|
||||
|
||||
@Length(varcharSize = 50)
|
||||
@Constraint(notNull = true)
|
||||
private String name;
|
||||
|
||||
@NewField("用户邮箱")
|
||||
@Length(varcharSize = 100)
|
||||
private String email;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Table("test_user3")
|
||||
static class TestUserNoAnnotation {
|
||||
@Constraint(autoIncrement = true)
|
||||
private Long id;
|
||||
|
||||
@Length(varcharSize = 50)
|
||||
@Constraint(notNull = true)
|
||||
private String name;
|
||||
|
||||
// 没有 @NewField 注解
|
||||
@Length(varcharSize = 100)
|
||||
private String email;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Table("test_user4")
|
||||
static class TestUserMultipleNewFields {
|
||||
@Constraint(autoIncrement = true)
|
||||
private Long id;
|
||||
|
||||
@Length(varcharSize = 50)
|
||||
@Constraint(notNull = true)
|
||||
private String name;
|
||||
|
||||
@NewField("用户邮箱")
|
||||
@Length(varcharSize = 100)
|
||||
private String email;
|
||||
|
||||
@NewField("手机号")
|
||||
@Length(varcharSize = 20)
|
||||
private String phone;
|
||||
|
||||
@NewField("地址")
|
||||
@Length(varcharSize = 255)
|
||||
private String address;
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,12 @@
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package cn.qaiu.vx.core.verticle.conf;
|
||||
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.impl.JsonUtil;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Converter and mapper for {@link cn.qaiu.vx.core.verticle.conf.HttpProxyConf}.
|
||||
* NOTE: This class has been automatically generated from the {@link cn.qaiu.vx.core.verticle.conf.HttpProxyConf} original class using Vert.x codegen.
|
||||
*/
|
||||
public class HttpProxyConfConverter {
|
||||
|
||||
|
||||
private static final Base64.Decoder BASE64_DECODER = JsonUtil.BASE64_DECODER;
|
||||
private static final Base64.Encoder BASE64_ENCODER = JsonUtil.BASE64_ENCODER;
|
||||
|
||||
static void fromJson(Iterable<java.util.Map.Entry<String, Object>> json, HttpProxyConf obj) {
|
||||
for (java.util.Map.Entry<String, Object> member : json) {
|
||||
switch (member.getKey()) {
|
||||
case "password":
|
||||
if (member.getValue() instanceof String) {
|
||||
obj.setPassword((String)member.getValue());
|
||||
}
|
||||
break;
|
||||
case "port":
|
||||
if (member.getValue() instanceof Number) {
|
||||
obj.setPort(((Number)member.getValue()).intValue());
|
||||
}
|
||||
break;
|
||||
case "preProxyOptions":
|
||||
if (member.getValue() instanceof JsonObject) {
|
||||
obj.setPreProxyOptions(new io.vertx.core.net.ProxyOptions((io.vertx.core.json.JsonObject)member.getValue()));
|
||||
}
|
||||
break;
|
||||
case "timeout":
|
||||
if (member.getValue() instanceof Number) {
|
||||
obj.setTimeout(((Number)member.getValue()).intValue());
|
||||
}
|
||||
break;
|
||||
case "username":
|
||||
if (member.getValue() instanceof String) {
|
||||
obj.setUsername((String)member.getValue());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void toJson(HttpProxyConf obj, JsonObject json) {
|
||||
toJson(obj, json.getMap());
|
||||
}
|
||||
|
||||
static void toJson(HttpProxyConf obj, java.util.Map<String, Object> json) {
|
||||
if (obj.getPassword() != null) {
|
||||
json.put("password", obj.getPassword());
|
||||
}
|
||||
if (obj.getPort() != null) {
|
||||
json.put("port", obj.getPort());
|
||||
}
|
||||
if (obj.getPreProxyOptions() != null) {
|
||||
json.put("preProxyOptions", obj.getPreProxyOptions().toJson());
|
||||
}
|
||||
if (obj.getTimeout() != null) {
|
||||
json.put("timeout", obj.getTimeout());
|
||||
}
|
||||
if (obj.getUsername() != null) {
|
||||
json.put("username", obj.getUsername());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,26 @@ package cn.qaiu.vx.core;
|
||||
import cn.qaiu.vx.core.util.CommonUtil;
|
||||
import cn.qaiu.vx.core.util.ConfigUtil;
|
||||
import cn.qaiu.vx.core.util.VertxHolder;
|
||||
import cn.qaiu.vx.core.verticle.HttpProxyVerticle;
|
||||
import cn.qaiu.vx.core.verticle.PostExecVerticle;
|
||||
import cn.qaiu.vx.core.verticle.ReverseProxyVerticle;
|
||||
import cn.qaiu.vx.core.verticle.RouterVerticle;
|
||||
import cn.qaiu.vx.core.verticle.ServiceVerticle;
|
||||
import io.vertx.core.*;
|
||||
import io.vertx.core.dns.AddressResolverOptions;
|
||||
import io.vertx.core.impl.launcher.commands.VersionCommand;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.core.shareddata.LocalMap;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.locks.LockSupport;
|
||||
|
||||
import static cn.qaiu.vx.core.util.ConfigConstant.*;
|
||||
@@ -41,11 +47,25 @@ public final class Deploy {
|
||||
private Handler<JsonObject> handle;
|
||||
|
||||
private Thread mainThread;
|
||||
private final List<Runnable> preShutdownTasks = new CopyOnWriteArrayList<>();
|
||||
private final List<Runnable> postShutdownTasks = new CopyOnWriteArrayList<>();
|
||||
|
||||
public static Deploy instance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public void addPreShutdownTask(Runnable task) {
|
||||
if (task != null) {
|
||||
preShutdownTasks.add(task);
|
||||
}
|
||||
}
|
||||
|
||||
public void addPostShutdownTask(Runnable task) {
|
||||
if (task != null) {
|
||||
postShutdownTasks.add(task);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param args 启动参数
|
||||
@@ -54,15 +74,25 @@ public final class Deploy {
|
||||
public void start(String[] args, Handler<JsonObject> handle) {
|
||||
this.mainThread = Thread.currentThread();
|
||||
this.handle = handle;
|
||||
|
||||
if (args.length > 0 && args[0].startsWith("app-")) {
|
||||
// 启动参数dev或者prod
|
||||
path.append("-").append(args[0].replace("app-",""));
|
||||
}
|
||||
|
||||
// 读取yml配置
|
||||
// 读取yml配置,优先当前目录,其次 resources/ 子目录
|
||||
String configFile = path + ".yml";
|
||||
if (!Files.exists(Path.of(configFile)) && Files.exists(Path.of("resources", configFile))) {
|
||||
path.insert(0, "resources/");
|
||||
LOGGER.info("从 resources/ 目录加载配置: {}", path + ".yml");
|
||||
}
|
||||
ConfigUtil.readYamlConfig(path.toString(), tempVertx)
|
||||
.onSuccess(this::readConf)
|
||||
.onFailure(Throwable::printStackTrace);
|
||||
.onFailure(err -> {
|
||||
LOGGER.error("读取配置文件失败: {}", err.getMessage(), err);
|
||||
LockSupport.unpark(mainThread);
|
||||
System.exit(-1);
|
||||
});
|
||||
LockSupport.park();
|
||||
deployVerticle();
|
||||
}
|
||||
@@ -104,7 +134,7 @@ public final class Deploy {
|
||||
|
||||
System.out.printf(logoTemplate,
|
||||
CommonUtil.getAppVersion(),
|
||||
VersionCommand.getVersion(),
|
||||
"4x",
|
||||
conf.getString("copyright"),
|
||||
year
|
||||
);
|
||||
@@ -119,44 +149,112 @@ public final class Deploy {
|
||||
customConfig = globalConfig.getJsonObject(CUSTOM);
|
||||
|
||||
JsonObject vertxConfig = globalConfig.getJsonObject(VERTX);
|
||||
Integer vertxConfigELPS = vertxConfig.getInteger(EVENT_LOOP_POOL_SIZE);
|
||||
var vertxOptions = vertxConfigELPS == 0 ?
|
||||
new VertxOptions() : new VertxOptions(vertxConfig);
|
||||
JsonObject vertxOptionsConfig = vertxConfig.copy();
|
||||
if (vertxOptionsConfig.getInteger(EVENT_LOOP_POOL_SIZE, 0) == 0) {
|
||||
vertxOptionsConfig.remove(EVENT_LOOP_POOL_SIZE);
|
||||
}
|
||||
if (vertxOptionsConfig.getInteger("workerPoolSize", 0) == 0) {
|
||||
vertxOptionsConfig.remove("workerPoolSize");
|
||||
}
|
||||
Integer vertxConfigELPS = vertxConfig.getInteger(EVENT_LOOP_POOL_SIZE, 0);
|
||||
var vertxOptions = vertxOptionsConfig.isEmpty() ?
|
||||
new VertxOptions() : new VertxOptions(vertxOptionsConfig);
|
||||
|
||||
vertxOptions.setAddressResolverOptions(
|
||||
new AddressResolverOptions().
|
||||
addServer("114.114.114.114").
|
||||
addServer("114.114.115.115").
|
||||
addServer("8.8.8.8").
|
||||
addServer("8.8.4.4"));
|
||||
// vertxOptions.setAddressResolverOptions(
|
||||
// new AddressResolverOptions().
|
||||
// addServer("114.114.114.114").
|
||||
// addServer("114.114.115.115").
|
||||
// addServer("8.8.8.8").
|
||||
// addServer("8.8.4.4"));
|
||||
LOGGER.info("vertxConfigEventLoopPoolSize: {}, eventLoopPoolSize: {}, workerPoolSize: {}", vertxConfigELPS,
|
||||
vertxOptions.getEventLoopPoolSize(),
|
||||
vertxOptions.getWorkerPoolSize());
|
||||
var vertx = Vertx.vertx(vertxOptions);
|
||||
VertxHolder.init(vertx);
|
||||
|
||||
// 注册 ShutdownHook,确保进程退出时优雅关闭资源
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
LOGGER.info("JVM shutting down...");
|
||||
runShutdownTasks("before Vert.x close", preShutdownTasks);
|
||||
try {
|
||||
LOGGER.info("Closing Vert.x...");
|
||||
vertx.close().toCompletionStage().toCompletableFuture().get(10, java.util.concurrent.TimeUnit.SECONDS);
|
||||
LOGGER.info("Vert.x closed successfully");
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Vert.x close error or timeout", e);
|
||||
} finally {
|
||||
runShutdownTasks("after Vert.x close", postShutdownTasks);
|
||||
}
|
||||
}));
|
||||
//配置保存在共享数据中
|
||||
var sharedData = vertx.sharedData();
|
||||
LocalMap<String, Object> localMap = sharedData.getLocalMap(LOCAL);
|
||||
localMap.put(GLOBAL_CONFIG, globalConfig);
|
||||
localMap.put(CUSTOM_CONFIG, customConfig);
|
||||
localMap.put(SERVER, globalConfig.getJsonObject(SERVER));
|
||||
var future0 = vertx.createSharedWorkerExecutor("other-handle")
|
||||
.executeBlocking(() -> {
|
||||
WorkerExecutor otherHandleExecutor = vertx.createSharedWorkerExecutor("other-handle");
|
||||
var future0 = otherHandleExecutor.executeBlocking(() -> {
|
||||
handle.handle(globalConfig);
|
||||
return "Other handle complete";
|
||||
});
|
||||
|
||||
future0.onSuccess(res -> {
|
||||
otherHandleExecutor.close();
|
||||
LOGGER.info(res);
|
||||
// 部署 路由、异步service、反向代理 服务
|
||||
var future1 = vertx.deployVerticle(RouterVerticle.class, getWorkDeploymentOptions("Router"));
|
||||
var future2 = vertx.deployVerticle(ServiceVerticle.class, getWorkDeploymentOptions("Service"));
|
||||
var future3 = vertx.deployVerticle(ReverseProxyVerticle.class, getWorkDeploymentOptions("proxy"));
|
||||
var future3 = vertx.deployVerticle(ReverseProxyVerticle.class, getWorkDeploymentOptions("proxy", 1));
|
||||
|
||||
Future.all(future1, future2, future3)
|
||||
.onSuccess(this::deployWorkVerticalSuccess)
|
||||
.onFailure(this::deployVerticalFailed);
|
||||
}).onFailure(e -> LOGGER.error("Other handle error", e));
|
||||
|
||||
JsonObject jsonObject = ((JsonObject) localMap.get(GLOBAL_CONFIG)).getJsonObject("proxy-server");
|
||||
if (jsonObject != null) {
|
||||
genPwd(jsonObject);
|
||||
var future4 = vertx.deployVerticle(HttpProxyVerticle.class, getWorkDeploymentOptions("proxy", 1));
|
||||
future4.onSuccess(LOGGER::info);
|
||||
future4.onFailure(e -> LOGGER.error("Other handle error", e));
|
||||
Future.all(future1, future2, future3, future4)
|
||||
.onSuccess(this::deployWorkVerticalSuccess)
|
||||
.onFailure(this::deployVerticalFailed);
|
||||
} else {
|
||||
Future.all(future1, future2, future3)
|
||||
.onSuccess(this::deployWorkVerticalSuccess)
|
||||
.onFailure(this::deployVerticalFailed);
|
||||
}
|
||||
|
||||
}).onFailure(e -> {
|
||||
otherHandleExecutor.close();
|
||||
LOGGER.error("Other handle error", e);
|
||||
});
|
||||
}
|
||||
|
||||
private static void genPwd(JsonObject jsonObject) {
|
||||
if (jsonObject.getBoolean("randUserPwd")) {
|
||||
var username = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
var password = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
jsonObject.put("username", username);
|
||||
jsonObject.put("password", password);
|
||||
}
|
||||
LOGGER.info("=============server info=================");
|
||||
LOGGER.info("\nport: {}\nusername: {}\npassword: {}",
|
||||
jsonObject.getString("port"),
|
||||
jsonObject.getString("username"),
|
||||
jsonObject.getString("password"));
|
||||
LOGGER.info("==============server info================");
|
||||
}
|
||||
|
||||
private static void runShutdownTasks(String stage, List<Runnable> tasks) {
|
||||
if (tasks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
LOGGER.info("Running {} shutdown tasks: {}", stage, tasks.size());
|
||||
for (Runnable task : tasks) {
|
||||
try {
|
||||
task.run();
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Shutdown task failed at stage {}", stage, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,6 +276,42 @@ public final class Deploy {
|
||||
var t1 = ((double) (System.currentTimeMillis() - startTime)) / 1000;
|
||||
var t2 = ((double) System.currentTimeMillis() - ManagementFactory.getRuntimeMXBean().getStartTime()) / 1000;
|
||||
LOGGER.info("web服务启动成功 -> 用时: {}s, jvm启动用时: {}s", t1, t2);
|
||||
|
||||
// 检查是否处于安装引导模式(数据库未配置)
|
||||
Object installMode = VertxHolder.getVertxInstance().sharedData()
|
||||
.getLocalMap(LOCAL).get("installMode");
|
||||
if (Boolean.TRUE.equals(installMode)) {
|
||||
LOGGER.info("系统处于安装引导模式,等待用户完成数据库配置后再启动后置初始化...");
|
||||
return;
|
||||
}
|
||||
|
||||
// 正常模式:部署 PostExecVerticle 执行 AppRun 实现
|
||||
deployPostExec();
|
||||
}
|
||||
|
||||
/**
|
||||
* 部署 PostExecVerticle(执行所有 AppRun 实现)
|
||||
* 安装引导完成后也可手动调用此方法触发后置初始化
|
||||
*/
|
||||
public void deployPostExec() {
|
||||
var vertx = VertxHolder.getVertxInstance();
|
||||
var postExecFuture = vertx.deployVerticle(PostExecVerticle.class, getWorkDeploymentOptions("postExec", 2));
|
||||
postExecFuture.onSuccess(id -> {
|
||||
LOGGER.info("PostExecVerticle 部署成功,AppRun 实现执行完成");
|
||||
}).onFailure(e -> {
|
||||
LOGGER.error("PostExecVerticle 部署失败", e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新部署 ServiceVerticle,重新注册因 DB 未就绪而失败的服务到 EventBus
|
||||
* 安装引导完成、DB 初始化后调用
|
||||
*/
|
||||
public void redeployServices() {
|
||||
var vertx = VertxHolder.getVertxInstance();
|
||||
vertx.deployVerticle(ServiceVerticle.class, getWorkDeploymentOptions("Service"))
|
||||
.onSuccess(id -> LOGGER.info("ServiceVerticle 重新部署成功,DB 相关服务已注册"))
|
||||
.onFailure(e -> LOGGER.error("ServiceVerticle 重新部署失败", e));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.lang.annotation.*;
|
||||
public @interface HandleSortFilter {
|
||||
/**
|
||||
* 注册顺序,数字越大越先注册<br>
|
||||
* 前置拦截器会先执行后注册即数字小的, 后置拦截器会先执行先注册的即数字大的<br>
|
||||
* 值<0时会过滤掉该处理器
|
||||
*/
|
||||
int value() default 0;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package cn.qaiu.vx.core.base;
|
||||
|
||||
import io.vertx.core.json.JsonObject;
|
||||
|
||||
public interface AppRun {
|
||||
|
||||
/**
|
||||
* 执行方法
|
||||
* @param config 启动配置文件
|
||||
*/
|
||||
void execute(JsonObject config);
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
package cn.qaiu.vx.core.base;
|
||||
|
||||
import cn.qaiu.vx.core.annotaions.HandleSortFilter;
|
||||
import cn.qaiu.vx.core.interceptor.AfterInterceptor;
|
||||
import cn.qaiu.vx.core.model.JsonResult;
|
||||
import cn.qaiu.vx.core.util.CommonUtil;
|
||||
import cn.qaiu.vx.core.util.ReflectionUtil;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
import org.reflections.Reflections;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static cn.qaiu.vx.core.util.ResponseUtil.*;
|
||||
|
||||
@@ -22,9 +28,10 @@ public interface BaseHttpApi {
|
||||
|
||||
// 需要扫描注册的Router路径
|
||||
Reflections reflections = ReflectionUtil.getReflections();
|
||||
Logger LOGGER = LoggerFactory.getLogger(BaseHttpApi.class);
|
||||
|
||||
default void doFireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject) {
|
||||
if (!ctx.response().ended()) {
|
||||
if (!isResponseDone(ctx)) {
|
||||
fireJsonObjectResponse(ctx, jsonObject);
|
||||
}
|
||||
handleAfterInterceptor(ctx, jsonObject);
|
||||
@@ -32,31 +39,93 @@ public interface BaseHttpApi {
|
||||
|
||||
|
||||
default <T> void doFireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult) {
|
||||
if (!ctx.response().ended()) {
|
||||
if (!isResponseDone(ctx)) {
|
||||
fireJsonResultResponse(ctx, jsonResult);
|
||||
}
|
||||
handleAfterInterceptor(ctx, jsonResult.toJsonObject());
|
||||
}
|
||||
|
||||
default void doFireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject, int statusCode) {
|
||||
if (!isResponseDone(ctx)) {
|
||||
fireJsonObjectResponse(ctx, jsonObject, statusCode);
|
||||
}
|
||||
handleAfterInterceptor(ctx, jsonObject);
|
||||
}
|
||||
|
||||
|
||||
default <T> void doFireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult, int statusCode) {
|
||||
if (!isResponseDone(ctx)) {
|
||||
fireJsonResultResponse(ctx, jsonResult, statusCode);
|
||||
}
|
||||
handleAfterInterceptor(ctx, jsonResult.toJsonObject());
|
||||
}
|
||||
|
||||
default Set<AfterInterceptor> getAfterInterceptor() {
|
||||
return AfterInterceptorHolder.INSTANCES;
|
||||
}
|
||||
|
||||
Set<Class<? extends AfterInterceptor>> afterInterceptorClassSet =
|
||||
reflections.getSubTypesOf(AfterInterceptor.class);
|
||||
if (afterInterceptorClassSet == null) {
|
||||
return null;
|
||||
class AfterInterceptorHolder {
|
||||
private static final Set<AfterInterceptor> INSTANCES = loadAfterInterceptors();
|
||||
|
||||
private static Set<AfterInterceptor> loadAfterInterceptors() {
|
||||
Set<Class<? extends AfterInterceptor>> afterInterceptorClassSet =
|
||||
reflections.getSubTypesOf(AfterInterceptor.class);
|
||||
if (afterInterceptorClassSet == null || afterInterceptorClassSet.isEmpty()) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return afterInterceptorClassSet.stream()
|
||||
.filter(AfterInterceptorHolder::isEnabled)
|
||||
.sorted(AfterInterceptorHolder::compareOrder)
|
||||
.map(AfterInterceptorHolder::newInterceptor)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.collectingAndThen(
|
||||
Collectors.toCollection(LinkedHashSet::new),
|
||||
Collections::unmodifiableSet));
|
||||
}
|
||||
|
||||
private static boolean isEnabled(Class<? extends AfterInterceptor> clazz) {
|
||||
HandleSortFilter sort = clazz.getAnnotation(HandleSortFilter.class);
|
||||
return sort == null || sort.value() >= 0;
|
||||
}
|
||||
|
||||
private static int compareOrder(Class<? extends AfterInterceptor> left, Class<? extends AfterInterceptor> right) {
|
||||
return Integer.compare(order(left), order(right));
|
||||
}
|
||||
|
||||
private static int order(Class<? extends AfterInterceptor> clazz) {
|
||||
HandleSortFilter sort = clazz.getAnnotation(HandleSortFilter.class);
|
||||
return sort == null ? 0 : sort.value();
|
||||
}
|
||||
|
||||
private static AfterInterceptor newInterceptor(Class<? extends AfterInterceptor> clazz) {
|
||||
try {
|
||||
return ReflectionUtil.newWithNoParam(clazz);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("AfterInterceptor 初始化失败,已跳过: {}", clazz.getName(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return CommonUtil.sortClassSet(afterInterceptorClassSet);
|
||||
}
|
||||
|
||||
default void handleAfterInterceptor(RoutingContext ctx, JsonObject jsonObject) {
|
||||
Set<AfterInterceptor> afterInterceptor = getAfterInterceptor();
|
||||
if (afterInterceptor != null) {
|
||||
afterInterceptor.forEach(ai -> ai.handle(ctx, jsonObject));
|
||||
if (ctx.response().closed()) {
|
||||
return;
|
||||
}
|
||||
if (!ctx.response().ended()) {
|
||||
Set<AfterInterceptor> afterInterceptor = getAfterInterceptor();
|
||||
afterInterceptor.forEach(ai -> {
|
||||
try {
|
||||
ai.handle(ctx, jsonObject);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("AfterInterceptor 执行失败: {}", ai.getClass().getName(), e);
|
||||
}
|
||||
});
|
||||
if (!isResponseDone(ctx)) {
|
||||
fireTextResponse(ctx, "handleAfterInterceptor: response not end");
|
||||
}
|
||||
}
|
||||
|
||||
default boolean isResponseDone(RoutingContext ctx) {
|
||||
return ctx.response().ended() || ctx.response().closed();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package cn.qaiu.vx.core.base;
|
||||
|
||||
import cn.qaiu.vx.core.annotaions.HandleSortFilter;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* 默认的AppRun实现示例
|
||||
* <br>Create date 2024-01-01 00:00:00
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
*/
|
||||
@HandleSortFilter
|
||||
public class DefaultAppRun implements AppRun {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(DefaultAppRun.class);
|
||||
|
||||
@Override
|
||||
public void execute(JsonObject config) {
|
||||
LOGGER.info("======> AppRun实现类开始执行,配置数: {}", config.size());
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,6 @@ import io.vertx.ext.web.RoutingContext;
|
||||
import io.vertx.ext.web.handler.*;
|
||||
import io.vertx.ext.web.handler.sockjs.SockJSHandler;
|
||||
import io.vertx.ext.web.handler.sockjs.SockJSHandlerOptions;
|
||||
import io.vertx.ext.web.sstore.LocalSessionStore;
|
||||
import io.vertx.ext.web.sstore.SessionStore;
|
||||
import javassist.CtClass;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
@@ -36,6 +34,7 @@ import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -69,120 +68,22 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
||||
this.gatewayPrefix = gatewayPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在主路由上直接注册 WebSocket 路由
|
||||
* 必须使用 order(-1000) 确保在所有拦截器之前执行
|
||||
*/
|
||||
private void registerWebSocketRoutes(Router mainRouter) {
|
||||
try {
|
||||
Set<Class<?>> handlers = reflections.getTypesAnnotatedWith(RouteHandler.class);
|
||||
for (Class<?> handler : handlers) {
|
||||
String root = getRootPath(handler);
|
||||
Method[] methods = handler.getMethods();
|
||||
|
||||
for (Method method : methods) {
|
||||
if (method.isAnnotationPresent(SockRouteMapper.class)) {
|
||||
SockRouteMapper mapping = method.getAnnotation(SockRouteMapper.class);
|
||||
String routeUrl = getRouteUrl(mapping.value());
|
||||
String url = root.concat(routeUrl);
|
||||
|
||||
// 在这里创建实例,确保每个 handler 使用同一个实例
|
||||
final Object instance = ReflectionUtil.newWithNoParam(handler);
|
||||
final Method finalMethod = method;
|
||||
|
||||
LOGGER.info("========================================");
|
||||
LOGGER.info("注册 WebSocket Handler (主路由,优先级最高):");
|
||||
LOGGER.info(" 类: {}", handler.getName());
|
||||
LOGGER.info(" 方法: {}", method.getName());
|
||||
LOGGER.info(" 实例: {}", instance.getClass().getName());
|
||||
LOGGER.info(" 完整路径: {}/*", url);
|
||||
LOGGER.info("========================================");
|
||||
|
||||
SockJSHandlerOptions options = new SockJSHandlerOptions()
|
||||
.setHeartbeatInterval(2000)
|
||||
.setRegisterWriteHandler(true);
|
||||
|
||||
SockJSHandler sockJSHandler = SockJSHandler.create(VertxHolder.getVertxInstance(), options);
|
||||
|
||||
// SockJS 路径处理
|
||||
String sockJsPath = url;
|
||||
while (sockJsPath.endsWith("/") || sockJsPath.endsWith("*")) {
|
||||
sockJsPath = sockJsPath.substring(0, sockJsPath.length() - 1);
|
||||
}
|
||||
final String finalSockJsPath = sockJsPath;
|
||||
|
||||
// ✅ socketHandler() 返回 Router,用于挂载
|
||||
// 使用 final 变量确保闭包中引用正确
|
||||
Router sockJsRouter = sockJSHandler.socketHandler(sock -> {
|
||||
LOGGER.info("[WS] ==========================================");
|
||||
LOGGER.info("[WS] SockJS socketHandler 回调被调用!");
|
||||
LOGGER.info("[WS] Socket ID: {}", sock.writeHandlerID());
|
||||
LOGGER.info("[WS] Remote Address: {}", sock.remoteAddress());
|
||||
LOGGER.info("[WS] Local Address: {}", sock.localAddress());
|
||||
LOGGER.info("[WS] 即将调用 method: {}.{}", instance.getClass().getSimpleName(), finalMethod.getName());
|
||||
LOGGER.info("[WS] ==========================================");
|
||||
try {
|
||||
finalMethod.invoke(instance, sock);
|
||||
LOGGER.info("[WS] Handler 调用成功");
|
||||
} catch (Throwable e) {
|
||||
LOGGER.error("[WS] WebSocket handler 调用失败", e);
|
||||
if (e.getCause() != null) {
|
||||
LOGGER.error("[WS] 原始异常", e.getCause());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 添加调试 handler 来检查请求是否到达 SockJS 路径
|
||||
// 注意:使用 "path*" 格式与 SockJS subRouter 保持一致
|
||||
mainRouter.route(finalSockJsPath + "*").order(-1001).handler(ctx -> {
|
||||
LOGGER.info("[WS-DEBUG] 请求到达 SockJS 路径: {}", ctx.request().path());
|
||||
LOGGER.info("[WS-DEBUG] Method: {}, Upgrade: {}, Connection: {}",
|
||||
ctx.request().method(),
|
||||
ctx.request().headers().get("Upgrade"),
|
||||
ctx.request().headers().get("Connection"));
|
||||
ctx.next();
|
||||
});
|
||||
|
||||
// 为 SockJS xhr/xhr_send 路径添加 BodyHandler
|
||||
// 必须在 SockJS 路由之前,但 WebSocket 升级请求不需要
|
||||
mainRouter.route(finalSockJsPath + "*").order(-1000).handler(BodyHandler.create());
|
||||
|
||||
// ✅ 挂载 SockJS 路由 - 注意:subRouter 需要使用 "path*" 格式而不是 "path/*"
|
||||
mainRouter.route(finalSockJsPath + "*").order(-999).subRouter(sockJsRouter);
|
||||
|
||||
LOGGER.info("✅ WebSocket 路由注册完成: {} (order=-1000)", finalSockJsPath);
|
||||
LOGGER.info(" SockJS 端点: {}/info, {}/websocket, {}/xhr", finalSockJsPath, finalSockJsPath, finalSockJsPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("注册 WebSocket 路由失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始扫描并注册handler
|
||||
*/
|
||||
public Router createRouter() {
|
||||
// 主路由
|
||||
Router mainRouter = Router.router(VertxHolder.getVertxInstance());
|
||||
|
||||
// ⚠️ 重要:先注册 WebSocket 路由,必须在所有 handler 之前
|
||||
// SockJSHandler 不能在 subRouter 中,必须直接挂载到主路由
|
||||
// 注意:WebSocket 路由必须在 BodyHandler 之前注册,否则会干扰 WebSocket 升级
|
||||
registerWebSocketRoutes(mainRouter);
|
||||
|
||||
mainRouter.route().handler(ctx -> {
|
||||
String realPath = ctx.request().uri();
|
||||
|
||||
if (realPath.startsWith(REROUTE_PATH_PREFIX)) {
|
||||
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
|
||||
String rePath = realPath.substring(REROUTE_PATH_PREFIX.length());
|
||||
String rePath = realPath.replace(REROUTE_PATH_PREFIX, "");
|
||||
ctx.reroute(rePath);
|
||||
return;
|
||||
}
|
||||
|
||||
LOGGER.debug("The HTTP service request address information ===>path:{}, uri:{}, method:{}",
|
||||
LOGGER.debug("New request:{}, {}, {}",
|
||||
ctx.request().path(), ctx.request().absoluteURI(), ctx.request().method());
|
||||
ctx.response().headers().add(ACCESS_CONTROL_ALLOW_ORIGIN, "*");
|
||||
ctx.response().headers().add(DATE, LocalDateTime.now().format(ISO_LOCAL_DATE_TIME));
|
||||
@@ -196,24 +97,13 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
||||
mainRouter.route().handler(CorsHandler.create().addRelativeOrigin(".*").allowCredentials(true).allowedMethods(httpMethods));
|
||||
|
||||
// 配置文件上传路径
|
||||
// BodyHandler 用于处理 POST 请求体
|
||||
// SockJS 的 xhr/xhr_send 端点需要 BodyHandler,但 WebSocket 升级请求不需要
|
||||
// 因此为 SockJS 路径单独配置 BodyHandler(排除 websocket 子路径)
|
||||
mainRouter.route().handler(BodyHandler.create().setUploadsDirectory("uploads"));
|
||||
mainRouter.route().handler(BodyHandler.create()
|
||||
.setUploadsDirectory("uploads")
|
||||
.setBodyLimit(2L * 1024 * 1024));
|
||||
|
||||
// 配置Session管理 - 用于演练场登录状态持久化
|
||||
// 30天过期时间(毫秒)- 排除 WebSocket 路径
|
||||
SessionStore sessionStore = LocalSessionStore.create(VertxHolder.getVertxInstance());
|
||||
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
|
||||
.setSessionTimeout(30L * 24 * 60 * 60 * 1000) // 30天
|
||||
.setSessionCookieName("SESSIONID") // Cookie名称
|
||||
.setCookieHttpOnlyFlag(true) // 防止XSS攻击
|
||||
.setCookieSecureFlag(false); // 非HTTPS环境设置为false
|
||||
mainRouter.routeWithRegex("^(?!/v2/ws/).*").handler(sessionHandler);
|
||||
|
||||
// 拦截器 - 排除 WebSocket 路径
|
||||
// 拦截器
|
||||
Set<Handler<RoutingContext>> interceptorSet = getInterceptorSet();
|
||||
Route route0 = mainRouter.routeWithRegex("^(?!/v2/ws/).*");
|
||||
Route route0 = mainRouter.route("/*");
|
||||
interceptorSet.forEach(route0::handler);
|
||||
|
||||
try {
|
||||
@@ -240,8 +130,9 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
||||
// 错误请求处理
|
||||
mainRouter.errorHandler(405, ctx -> doFireJsonResultResponse(ctx, JsonResult
|
||||
.error("Method Not Allowed", 405)));
|
||||
mainRouter.errorHandler(404, ctx -> ctx.response().setStatusCode(404).setChunked(true)
|
||||
.end("Internal server error: 404 not found"));
|
||||
mainRouter.errorHandler(404, ctx -> {
|
||||
ctx.response().setStatusCode(404).end("404 not found");
|
||||
});
|
||||
|
||||
return mainRouter;
|
||||
}
|
||||
@@ -287,19 +178,38 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
||||
route.handler(TimeoutHandler.create(SharedDataUtil.getCustomConfig().getInteger(ROUTE_TIME_OUT)));
|
||||
route.handler(ResponseTimeHandler.create());
|
||||
route.handler(ctx -> handlerMethod(instance, method, ctx)).failureHandler(ctx -> {
|
||||
if (ctx.response().ended()) return;
|
||||
if (isResponseDone(ctx)) return;
|
||||
// 超时处理器状态码503
|
||||
if (ctx.statusCode() == 503 || ctx.failure() == null) {
|
||||
doFireJsonResultResponse(ctx, JsonResult.error("未知异常, 请联系管理员", 500));
|
||||
doFireJsonResultResponse(ctx, JsonResult.error("未知异常, 请联系管理员"), 503);
|
||||
} else {
|
||||
ctx.failure().printStackTrace();
|
||||
doFireJsonResultResponse(ctx, JsonResult.error(ctx.failure().getMessage(), 500));
|
||||
LOGGER.error("路由处理失败", ctx.failure());
|
||||
String msg = ctx.failure() != null ? ctx.failure().getMessage() : "未知异常";
|
||||
doFireJsonResultResponse(ctx, JsonResult.error(msg), 500);
|
||||
}
|
||||
});
|
||||
} else if (method.isAnnotationPresent(SockRouteMapper.class)) {
|
||||
// WebSocket 路由已在 registerWebSocketRoutes() 中提前注册
|
||||
// 跳过此处,避免重复注册
|
||||
continue;
|
||||
// websocket 基于sockJs
|
||||
SockRouteMapper mapping = method.getAnnotation(SockRouteMapper.class);
|
||||
String routeUrl = getRouteUrl(mapping.value());
|
||||
String url = root.concat(routeUrl);
|
||||
LOGGER.info("Register New Websocket Handler -> {}", url);
|
||||
SockJSHandlerOptions options = new SockJSHandlerOptions()
|
||||
.setHeartbeatInterval(2000)
|
||||
.setRegisterWriteHandler(true);
|
||||
|
||||
SockJSHandler sockJSHandler = SockJSHandler.create(VertxHolder.getVertxInstance(), options);
|
||||
Router route = sockJSHandler.socketHandler(sock -> {
|
||||
try {
|
||||
ReflectionUtil.invokeWithArguments(method, instance, sock);
|
||||
} catch (Throwable e) {
|
||||
LOGGER.error("WebSocket处理异常", e);
|
||||
}
|
||||
});
|
||||
if (url.endsWith("*")) {
|
||||
throw new IllegalArgumentException("Don't include * when mounting a sub router");
|
||||
}
|
||||
router.route(url + "*").subRouter(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -329,7 +239,7 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
||||
*/
|
||||
private Set<Handler<RoutingContext>> getInterceptorSet() {
|
||||
// 配置拦截
|
||||
return getBeforeInterceptor().stream().map(BeforeInterceptor::doHandle).collect(Collectors.toSet());
|
||||
return getBeforeInterceptor().stream().map(BeforeInterceptor::doHandle).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -398,32 +308,41 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
||||
|
||||
final MultiMap queryParams = ctx.queryParams();
|
||||
// 解析body-json参数
|
||||
// 只处理POST/PUT/PATCH等有body的请求方法,避免GET请求读取body导致"Request has already been read"错误
|
||||
String httpMethod = ctx.request().method().name();
|
||||
if (("POST".equals(httpMethod) || "PUT".equals(httpMethod) || "PATCH".equals(httpMethod))
|
||||
&& HttpHeaderValues.APPLICATION_JSON.toString().equals(ctx.parsedHeaders().contentType().value())
|
||||
&& ctx.body() != null && ctx.body().asJsonObject() != null) {
|
||||
if (HttpHeaderValues.APPLICATION_JSON.toString().equals(ctx.parsedHeaders().contentType().value())) {
|
||||
JsonObject body = ctx.body().asJsonObject();
|
||||
if (body != null) {
|
||||
methodParametersTemp.forEach((k, v) -> {
|
||||
String typeName = v.getRight().getName();
|
||||
// 直接绑定 JsonObject 类型参数
|
||||
if (JsonObject.class.getName().equals(typeName)) {
|
||||
parameterValueList.put(k, body);
|
||||
}
|
||||
// 只解析已配置包名前缀的实体类
|
||||
if (CommonUtil.matchRegList(entityPackagesReg.getList(), v.getRight().getName())) {
|
||||
else if (CommonUtil.matchRegList(entityPackagesReg.getList(), typeName)) {
|
||||
try {
|
||||
Class<?> aClass = Class.forName(v.getRight().getName());
|
||||
Class<?> aClass = Class.forName(typeName);
|
||||
JsonObject data = CommonUtil.getSubJsonForEntity(body, aClass);
|
||||
if (!data.isEmpty()) {
|
||||
Object entity = data.mapTo(aClass);
|
||||
parameterValueList.put(k, entity);
|
||||
}
|
||||
} catch (ClassNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
LOGGER.error("实体类绑定异常: {}", typeName, e);
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// body 可能是 JsonArray
|
||||
JsonArray bodyArray = ctx.body().asJsonArray();
|
||||
if (bodyArray != null) {
|
||||
methodParametersTemp.forEach((k, v) -> {
|
||||
if (JsonArray.class.getName().equals(v.getRight().getName())) {
|
||||
parameterValueList.put(k, bodyArray);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (("POST".equals(httpMethod) || "PUT".equals(httpMethod) || "PATCH".equals(httpMethod))
|
||||
&& ctx.body() != null) {
|
||||
} else if (ctx.body() != null) {
|
||||
queryParams.addAll(ParamUtil.paramsToMap(ctx.body().asString()));
|
||||
}
|
||||
|
||||
@@ -451,7 +370,21 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
||||
Object entity = ParamUtil.multiMapToEntity(queryParams, aClass);
|
||||
parameterValueList.put(k, entity);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
LOGGER.error("参数绑定异常: {}", v.getRight().getName(), e);
|
||||
}
|
||||
} else if (parameterValueList.get(k) == null
|
||||
&& JsonObject.class.getName().equals(v.getRight().getName())) {
|
||||
// 兜底: content-type 非 application/json 时尝试从 body 解析 JsonObject
|
||||
if (ctx.body() != null) {
|
||||
JsonObject jo = ctx.body().asJsonObject();
|
||||
if (jo != null) parameterValueList.put(k, jo);
|
||||
}
|
||||
} else if (parameterValueList.get(k) == null
|
||||
&& JsonArray.class.getName().equals(v.getRight().getName())) {
|
||||
// 兜底: content-type 非 application/json 时尝试从 body 解析 JsonArray
|
||||
if (ctx.body() != null) {
|
||||
JsonArray ja = ctx.body().asJsonArray();
|
||||
if (ja != null) parameterValueList.put(k, ja);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -462,40 +395,45 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
||||
Object data = ReflectionUtil.invokeWithArguments(method, instance, parameterValueArray);
|
||||
if (data != null) {
|
||||
|
||||
if (data instanceof JsonResult) {
|
||||
doFireJsonResultResponse(ctx, (JsonResult<?>) data);
|
||||
}
|
||||
if (data instanceof JsonObject) {
|
||||
if (data instanceof JsonResult jsonResult) {
|
||||
doFireJsonResultResponse(ctx, (JsonResult<?>) data, jsonResult.getCode());
|
||||
} else if (data instanceof JsonObject) {
|
||||
doFireJsonObjectResponse(ctx, ((JsonObject) data));
|
||||
} else if (data instanceof Future) { // 处理异步响应
|
||||
((Future<?>) data).onSuccess(res -> {
|
||||
if (res instanceof JsonResult) {
|
||||
doFireJsonResultResponse(ctx, (JsonResult<?>) res);
|
||||
Future<?> responseFuture = (Future<?>) data;
|
||||
AtomicReference<RoutingContext> ctxRef = new AtomicReference<>(ctx);
|
||||
ctx.addEndHandler(v -> ctxRef.set(null));
|
||||
responseFuture.onComplete(ar -> {
|
||||
RoutingContext responseCtx = ctxRef.getAndSet(null);
|
||||
if (responseCtx == null || isResponseDone(responseCtx)) {
|
||||
return;
|
||||
}
|
||||
if (res instanceof JsonObject) {
|
||||
doFireJsonObjectResponse(ctx, ((JsonObject) res));
|
||||
} else if (res != null) {
|
||||
doFireJsonResultResponse(ctx, JsonResult.data(res));
|
||||
if (ar.succeeded()) {
|
||||
Object res = ar.result();
|
||||
if (res instanceof JsonResult jsonResult) {
|
||||
doFireJsonResultResponse(responseCtx, jsonResult, jsonResult.getCode());
|
||||
} else if (res instanceof JsonObject) {
|
||||
doFireJsonObjectResponse(responseCtx, ((JsonObject) res));
|
||||
} else if (res != null) {
|
||||
doFireJsonResultResponse(responseCtx, JsonResult.data(res));
|
||||
} else {
|
||||
doFireJsonResultResponse(responseCtx, JsonResult.data(null));
|
||||
}
|
||||
} else {
|
||||
handleAfterInterceptor(ctx, null);
|
||||
Throwable e = ar.cause();
|
||||
LOGGER.error("请求处理失败", e);
|
||||
String msg = e != null && e.getMessage() != null ? e.getMessage() : "服务器内部错误";
|
||||
doFireJsonResultResponse(responseCtx, JsonResult.error(msg), 500);
|
||||
}
|
||||
|
||||
}).onFailure(e -> doFireJsonResultResponse(ctx, JsonResult.error(e.getMessage())));
|
||||
});
|
||||
} else {
|
||||
doFireJsonResultResponse(ctx, JsonResult.data(data));
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace();
|
||||
String err = e.getMessage();
|
||||
if (e.getCause() != null) {
|
||||
if (e.getCause() instanceof InvocationTargetException) {
|
||||
err = ((InvocationTargetException) e.getCause()).getTargetException().getMessage();
|
||||
} else {
|
||||
err = e.getCause().getMessage();
|
||||
}
|
||||
}
|
||||
doFireJsonResultResponse(ctx, JsonResult.error(err));
|
||||
LOGGER.error("请求处理异常", e);
|
||||
String msg = e.getMessage() != null ? e.getMessage() : "服务器内部错误";
|
||||
doFireJsonResultResponse(ctx, JsonResult.error(msg), 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ package cn.qaiu.vx.core.interceptor;
|
||||
import io.vertx.core.Handler;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
|
||||
import static cn.qaiu.vx.core.util.ResponseUtil.sendError;
|
||||
|
||||
/**
|
||||
* 前置拦截器接口
|
||||
* <p>
|
||||
* 注意:Vert.x是异步非阻塞框架,不能在Event Loop中使用synchronized等阻塞操作!
|
||||
* 所有操作都应该是非阻塞的,使用Vert.x的上下文数据存储机制保证线程安全。
|
||||
* </p>
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
*/
|
||||
@@ -14,28 +16,25 @@ public interface BeforeInterceptor extends Handler<RoutingContext> {
|
||||
String IS_NEXT = "RoutingContextIsNext";
|
||||
|
||||
default Handler<RoutingContext> doHandle() {
|
||||
|
||||
return ctx -> {
|
||||
// 加同步锁
|
||||
synchronized (BeforeInterceptor.class) {
|
||||
ctx.put(IS_NEXT, false);
|
||||
BeforeInterceptor.this.handle(ctx);
|
||||
if (!(Boolean) ctx.get(IS_NEXT) && !ctx.response().ended()) {
|
||||
sendError(ctx, 403);
|
||||
}
|
||||
}
|
||||
// 【优化】移除synchronized锁,Vert.x的RoutingContext本身就是线程安全的
|
||||
// 每个请求都有独立的RoutingContext,不需要额外加锁
|
||||
ctx.put(IS_NEXT, false);
|
||||
handle(ctx); // 调用具体的处理逻辑
|
||||
// 确保如果没有调用doNext()并且响应未结束,则返回错误
|
||||
// if (!(Boolean) ctx.get(IS_NEXT) && !ctx.response().ended()) {
|
||||
// sendError(ctx, 403);
|
||||
// }
|
||||
};
|
||||
}
|
||||
|
||||
default void doNext(RoutingContext context) {
|
||||
// 设置上下文状态为可以继续执行
|
||||
// 添加同步锁保障多线程下执行时序
|
||||
synchronized (BeforeInterceptor.class) {
|
||||
context.put(IS_NEXT, true);
|
||||
context.next();
|
||||
}
|
||||
// 【优化】移除synchronized锁
|
||||
// RoutingContext的put和next操作是线程安全的,不需要额外同步
|
||||
context.put(IS_NEXT, true);
|
||||
context.next(); // 继续执行下一个处理器
|
||||
}
|
||||
|
||||
void handle(RoutingContext context);
|
||||
|
||||
void handle(RoutingContext context); // 实现具体的拦截处理逻辑
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ public class JsonResult<T> implements Serializable {
|
||||
|
||||
private int code = SUCCESS_CODE;//状态码
|
||||
|
||||
private String msg = SUCCESS_MESSAGE; //消息
|
||||
private String msg = SUCCESS_MESSAGE;//消息
|
||||
|
||||
private boolean success = true; //是否成功
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ModuleGen cn.qaiu.vx.core
|
||||
*/
|
||||
@ModuleGen(name = "vertx-http-proxy", groupPackage = "cn.qaiu.vx.core", useFutures = true)
|
||||
@ModuleGen(name = "vertx-http-proxy", groupPackage = "cn.qaiu.vx.core")
|
||||
package cn.qaiu.vx.core;
|
||||
|
||||
import io.vertx.codegen.annotations.ModuleGen;
|
||||
|
||||
@@ -5,7 +5,7 @@ import io.vertx.serviceproxy.ServiceProxyBuilder;
|
||||
|
||||
/**
|
||||
* @author Xu Haidong
|
||||
* Create at 2018/8/15
|
||||
* @date 2018/8/15
|
||||
*/
|
||||
public final class AsyncServiceUtil {
|
||||
|
||||
|
||||
@@ -13,9 +13,12 @@ import java.net.Socket;
|
||||
import java.net.URL;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.List;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -28,6 +31,16 @@ public class CommonUtil {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(CommonUtil.class);
|
||||
|
||||
/** 正则表达式缓存,避免每次调用重新编译 */
|
||||
private static final ConcurrentHashMap<String, Pattern> PATTERN_CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 获取预编译的 Pattern(带缓存)
|
||||
*/
|
||||
private static Pattern getCachedPattern(String regex) {
|
||||
return PATTERN_CACHE.computeIfAbsent(regex, Pattern::compile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配正则list
|
||||
*
|
||||
@@ -38,7 +51,7 @@ public class CommonUtil {
|
||||
public static boolean matchRegList(List<?> regList, String destStr) {
|
||||
// 判断是否忽略
|
||||
for (Object ignores : regList) {
|
||||
if (destStr.matches(ignores.toString())) {
|
||||
if (getCachedPattern(ignores.toString()).matcher(destStr).matches()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -117,7 +130,7 @@ public class CommonUtil {
|
||||
return set.stream().filter(c1 -> {
|
||||
HandleSortFilter s1 = c1.getAnnotation(HandleSortFilter.class);
|
||||
if (s1 != null) {
|
||||
return s1.value() > 0;
|
||||
return s1.value() >= 0;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
@@ -138,7 +151,7 @@ public class CommonUtil {
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}).collect(Collectors.toSet());
|
||||
}).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
private static String appVersion;
|
||||
@@ -146,13 +159,15 @@ public class CommonUtil {
|
||||
public static String getAppVersion() {
|
||||
if (null == appVersion) {
|
||||
Properties properties = new Properties();
|
||||
try {
|
||||
properties.load(CommonUtil.class.getClassLoader().getResourceAsStream("app.properties"));
|
||||
if (!properties.isEmpty()) {
|
||||
appVersion = properties.getProperty("app.version") + "build" + properties.getProperty("build");
|
||||
try (var is = CommonUtil.class.getClassLoader().getResourceAsStream("app.properties")) {
|
||||
if (is != null) {
|
||||
properties.load(is);
|
||||
if (!properties.isEmpty()) {
|
||||
appVersion = properties.getProperty("app.version") + "build" + properties.getProperty("build");
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
LOGGER.error("读取app.properties失败", e);
|
||||
}
|
||||
}
|
||||
return appVersion;
|
||||
|
||||
@@ -4,9 +4,15 @@ import io.vertx.config.ConfigRetriever;
|
||||
import io.vertx.config.ConfigRetrieverOptions;
|
||||
import io.vertx.config.ConfigStoreOptions;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* 异步读取配置工具类
|
||||
* <br>Create date 2021/9/2 1:23
|
||||
@@ -24,7 +30,29 @@ public class ConfigUtil {
|
||||
* @return JsonObject的Future
|
||||
*/
|
||||
public static Future<JsonObject> readConfig(String format, String path, Vertx vertx) {
|
||||
// 读取yml配置
|
||||
// 支持 classpath: 前缀从类路径读取,否则从文件系统读取
|
||||
if (path != null && path.startsWith("classpath:")) {
|
||||
String resource = path.substring("classpath:".length());
|
||||
// 使用 executeBlocking(Callable) 直接返回 Future<JsonObject>
|
||||
return vertx.executeBlocking(() -> {
|
||||
InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(resource);
|
||||
if (is == null) {
|
||||
throw new RuntimeException("classpath resource not found: " + resource);
|
||||
}
|
||||
try (InputStream in = is) {
|
||||
byte[] bytes = in.readAllBytes();
|
||||
String content = new String(bytes, StandardCharsets.UTF_8);
|
||||
if ("json".equalsIgnoreCase(format)) {
|
||||
return new JsonObject(content);
|
||||
} else {
|
||||
throw new RuntimeException("unsupported classpath format: " + format);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Promise<JsonObject> promise = Promise.promise();
|
||||
|
||||
ConfigStoreOptions store = new ConfigStoreOptions()
|
||||
.setType("file")
|
||||
.setFormat(format)
|
||||
@@ -33,10 +61,45 @@ public class ConfigUtil {
|
||||
ConfigRetriever retriever = ConfigRetriever
|
||||
.create(vertx, new ConfigRetrieverOptions().addStore(store));
|
||||
|
||||
return retriever.getConfig();
|
||||
// 异步获取配置
|
||||
// 成功直接完成 promise
|
||||
retriever.getConfig()
|
||||
.onSuccess(config -> {
|
||||
promise.complete(config);
|
||||
retriever.close();
|
||||
})
|
||||
.onFailure(err -> {
|
||||
retriever.close();
|
||||
// 读取失败时,尝试从 resources/ 子目录读取(兼容 Docker 卷挂载场景)
|
||||
String resourcesPath = "resources/" + path;
|
||||
if (!path.startsWith("resources/") && Files.exists(Path.of(resourcesPath))) {
|
||||
ConfigStoreOptions fallbackStore = new ConfigStoreOptions()
|
||||
.setType("file")
|
||||
.setFormat(format)
|
||||
.setConfig(new JsonObject().put("path", resourcesPath));
|
||||
ConfigRetriever fallbackRetriever = ConfigRetriever
|
||||
.create(vertx, new ConfigRetrieverOptions().addStore(fallbackStore));
|
||||
fallbackRetriever.getConfig()
|
||||
.onSuccess(config -> {
|
||||
promise.complete(config);
|
||||
fallbackRetriever.close();
|
||||
})
|
||||
.onFailure(e2 -> {
|
||||
promise.fail(new RuntimeException(
|
||||
"读取配置文件失败: " + path + " (也尝试了 " + resourcesPath + ")", e2));
|
||||
fallbackRetriever.close();
|
||||
});
|
||||
} else {
|
||||
promise.fail(new RuntimeException(
|
||||
"读取配置文件失败: " + path, err));
|
||||
}
|
||||
});
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 异步读取Yaml配置文件
|
||||
*
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package cn.qaiu.vx.core.util;
|
||||
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public class FutureUtils {
|
||||
|
||||
/** 默认同步等待超时时间(秒) */
|
||||
private static final long DEFAULT_TIMEOUT_SECONDS = 120;
|
||||
|
||||
public static <T> T getResult(Future<T> future) {
|
||||
try {
|
||||
return future.toCompletionStage().toCompletableFuture()
|
||||
.get(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("线程被中断", e);
|
||||
} catch (TimeoutException e) {
|
||||
throw new RuntimeException("等待Future超时(" + DEFAULT_TIMEOUT_SECONDS + "秒)", e);
|
||||
} catch (ExecutionException e) {
|
||||
Throwable cause = e.getCause();
|
||||
throw new RuntimeException(cause != null ? cause : e);
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> T getResult(Promise<T> promise) {
|
||||
try {
|
||||
return promise.future().toCompletionStage().toCompletableFuture()
|
||||
.get(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("线程被中断", e);
|
||||
} catch (TimeoutException e) {
|
||||
throw new RuntimeException("等待Promise超时(" + DEFAULT_TIMEOUT_SECONDS + "秒)", e);
|
||||
} catch (ExecutionException e) {
|
||||
Throwable cause = e.getCause();
|
||||
throw new RuntimeException(cause != null ? cause : e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2023/10/14 9:07
|
||||
* @date 2023/10/14 9:07
|
||||
*/
|
||||
public class JacksonConfig {
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package cn.qaiu.vx.core.util;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* vertx 上下文外的本地容器 为不在vertx线程的方法传递数据
|
||||
@@ -10,11 +10,10 @@ import java.util.Map;
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
*/
|
||||
public class LocalConstant {
|
||||
private static final Map<String, Object> LOCAL_CONST = new HashMap<>();
|
||||
private static final Map<String, Object> LOCAL_CONST = new ConcurrentHashMap<>();
|
||||
|
||||
public static Map<String, Object> put(String k, Object v) {
|
||||
if (LOCAL_CONST.containsKey(k)) return LOCAL_CONST;
|
||||
LOCAL_CONST.put(k, v);
|
||||
LOCAL_CONST.putIfAbsent(k, v);
|
||||
return LOCAL_CONST;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,16 +36,20 @@ public final class ParamUtil {
|
||||
|
||||
public static MultiMap paramsToMap(String paramString) {
|
||||
MultiMap entries = MultiMap.caseInsensitiveMultiMap();
|
||||
if (paramString == null) return entries;
|
||||
if (paramString == null || paramString.isEmpty()) return entries;
|
||||
String[] params = paramString.split("&");
|
||||
if (params.length == 0) return entries;
|
||||
for (String param : params) {
|
||||
String[] kv = param.split("=");
|
||||
if (param == null || param.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String[] kv = param.split("=", 2);
|
||||
if (kv.length == 2) {
|
||||
entries.set(kv[0], kv[1]);
|
||||
} else {
|
||||
} else if (kv.length == 1) {
|
||||
entries.set(kv[0], "");
|
||||
}
|
||||
// kv.length == 0 时(空字符串),跳过
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ import java.lang.reflect.Method;
|
||||
import java.net.URL;
|
||||
import java.text.ParseException;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import static cn.qaiu.vx.core.util.ConfigConstant.BASE_LOCATIONS;
|
||||
|
||||
@@ -36,6 +40,16 @@ import static cn.qaiu.vx.core.util.ConfigConstant.BASE_LOCATIONS;
|
||||
*/
|
||||
public final class ReflectionUtil {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ReflectionUtil.class);
|
||||
|
||||
// 缓存Reflections实例,避免重复扫描(每次扫描约35K+值,耗时1-3秒,占用大量内存)
|
||||
private static final Map<String, Reflections> REFLECTIONS_CACHE = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
// 预编译的类型匹配正则,避免每次请求重新编译
|
||||
private static final Pattern BASIC_TYPE_PATTERN = Pattern.compile(
|
||||
"^java\\.lang\\.((Boolean)|(Character)|(Byte)|(Short)|(Integer)|(Long)|(Float)|(Double)|(String))$");
|
||||
private static final Pattern BASIC_TYPE_ARRAY_PATTERN = Pattern.compile(
|
||||
"^(boolean|char|byte|short|int|long|float|double|String)\\[]$");
|
||||
|
||||
/**
|
||||
* 以默认配置的基础包路径获取反射器
|
||||
@@ -47,52 +61,48 @@ public final class ReflectionUtil {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取反射器
|
||||
* 获取反射器(带缓存)
|
||||
*
|
||||
* @param packageAddress Package address String
|
||||
* @return Reflections object
|
||||
*/
|
||||
public static Reflections getReflections(String packageAddress) {
|
||||
List<String> packageAddressList;
|
||||
if (packageAddress.contains(",")) {
|
||||
packageAddressList = Arrays.asList(packageAddress.split(","));
|
||||
} else if (packageAddress.contains(";")) {
|
||||
packageAddressList = Arrays.asList(packageAddress.split(";"));
|
||||
} else {
|
||||
packageAddressList = Collections.singletonList(packageAddress);
|
||||
}
|
||||
|
||||
return getReflections(packageAddressList);
|
||||
return REFLECTIONS_CACHE.computeIfAbsent(packageAddress, key -> {
|
||||
List<String> packageAddressList;
|
||||
if (key.contains(",")) {
|
||||
packageAddressList = Arrays.asList(key.split(","));
|
||||
} else if (key.contains(";")) {
|
||||
packageAddressList = Arrays.asList(key.split(";"));
|
||||
} else {
|
||||
packageAddressList = Collections.singletonList(key);
|
||||
}
|
||||
return createReflections(packageAddressList);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取反射器
|
||||
* 获取反射器(带缓存)
|
||||
*
|
||||
* @param packageAddresses Package address List
|
||||
* @return Reflections object
|
||||
*/
|
||||
public static Reflections getReflections(List<String> packageAddresses) {
|
||||
ConfigurationBuilder configurationBuilder = new ConfigurationBuilder();
|
||||
FilterBuilder filterBuilder = new FilterBuilder();
|
||||
packageAddresses.forEach(str -> {
|
||||
Collection<URL> urls = ClasspathHelper.forPackage(str.trim());
|
||||
configurationBuilder.addUrls(urls);
|
||||
filterBuilder.includePackage(str.trim());
|
||||
});
|
||||
String cacheKey = String.join(",", packageAddresses);
|
||||
return REFLECTIONS_CACHE.computeIfAbsent(cacheKey, key -> createReflections(packageAddresses));
|
||||
}
|
||||
|
||||
// 采坑记录 2021-05-08
|
||||
// 发现注解api层 没有继承父类时 这里反射一直有问题(Scanner SubTypesScanner was not configured)
|
||||
// 因此这里需要手动配置各种Scanner扫描器 -- https://blog.csdn.net/qq_29499107/article/details/106889781
|
||||
configurationBuilder.setScanners(
|
||||
Scanners.SubTypes.filterResultsBy(s -> true), //允许getAllTypes获取所有Object的子类, 不设置为false则 getAllTypes
|
||||
// 会报错.默认为true.
|
||||
new MethodParameterNamesScanner(), //设置方法参数名称 扫描器,否则调用getConstructorParamNames 会报错
|
||||
Scanners.MethodsAnnotated, //设置方法注解 扫描器, 否则getConstructorsAnnotatedWith,getMethodsAnnotatedWith 会报错
|
||||
new MemberUsageScanner(), //设置 member 扫描器,否则 getMethodUsage 会报错
|
||||
Scanners.TypesAnnotated //设置类注解 扫描器 ,否则 getTypesAnnotatedWith 会报错
|
||||
);
|
||||
|
||||
configurationBuilder.filterInputsBy(filterBuilder);
|
||||
private static Reflections createReflections(List<String> packageAddresses) {
|
||||
ConfigurationBuilder configurationBuilder = new ConfigurationBuilder()
|
||||
.addClassLoaders(Thread.currentThread().getContextClassLoader())
|
||||
.forPackages(packageAddresses.toArray(new String[0]))
|
||||
.setScanners(
|
||||
Scanners.SubTypes.filterResultsBy(s -> true), //允许getAllTypes获取所有Object的子类, 不设置为false则 getAllTypes
|
||||
// 会报错.默认为true.
|
||||
new MethodParameterNamesScanner(), //设置方法参数名称 扫描器,否则调用getConstructorParamNames 会报错
|
||||
Scanners.MethodsAnnotated, //设置方法注解 扫描器, 否则getConstructorsAnnotatedWith,getMethodsAnnotatedWith 会报错
|
||||
new MemberUsageScanner(), //设置 member 扫描器,否则 getMethodUsage 会报错
|
||||
Scanners.TypesAnnotated //设置类注解 扫描器 ,否则 getTypesAnnotatedWith 会报错
|
||||
);
|
||||
return new Reflections(configurationBuilder);
|
||||
}
|
||||
|
||||
@@ -130,7 +140,7 @@ public final class ReflectionUtil {
|
||||
parameterTypes[j - k]));
|
||||
}
|
||||
} catch (NotFoundException e) {
|
||||
e.printStackTrace();
|
||||
LOGGER.error("获取方法参数失败", e);
|
||||
}
|
||||
return paramMap;
|
||||
}
|
||||
@@ -185,7 +195,7 @@ public final class ReflectionUtil {
|
||||
try {
|
||||
return DateUtils.parseDate(value, fmt);
|
||||
} catch (ParseException e) {
|
||||
e.printStackTrace();
|
||||
LOGGER.error("日期解析失败: {}", value, e);
|
||||
throw new RuntimeException("无法将格式化日期");
|
||||
}
|
||||
default:
|
||||
@@ -217,7 +227,7 @@ public final class ReflectionUtil {
|
||||
}
|
||||
return arr;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
LOGGER.error("数组类型转换失败: {}", value, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -231,8 +241,7 @@ public final class ReflectionUtil {
|
||||
if (ctClass.isPrimitive() || "java.util.Date".equals(ctClass.getName())) {
|
||||
return true;
|
||||
}
|
||||
return ctClass.getName().matches("^java\\.lang\\.((Boolean)|(Character)|(Byte)|(Short)|(Integer)|(Long)|" +
|
||||
"(Float)|(Double)|(String))$");
|
||||
return BASIC_TYPE_PATTERN.matcher(ctClass.getName()).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,7 +252,7 @@ public final class ReflectionUtil {
|
||||
public static boolean isBasicTypeArray(CtClass ctClass) {
|
||||
if (!ctClass.isArray()) {
|
||||
return false;
|
||||
} else return (ctClass.getName().matches("^(boolen|char|byte|short|int|long|float|double|String)\\[]$"));
|
||||
} else return BASIC_TYPE_ARRAY_PATTERN.matcher(ctClass.getName()).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,24 +12,46 @@ import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE;
|
||||
public class ResponseUtil {
|
||||
|
||||
public static void redirect(HttpServerResponse response, String url) {
|
||||
if (response.ended() || response.closed()) {
|
||||
return;
|
||||
}
|
||||
response.putHeader(CONTENT_TYPE, "text/html; charset=utf-8")
|
||||
.putHeader("Referrer-Policy", "no-referrer")
|
||||
.putHeader(HttpHeaders.LOCATION, url).setStatusCode(302).end();
|
||||
}
|
||||
|
||||
public static void redirect(HttpServerResponse response, String url, Promise<?> promise) {
|
||||
redirect(response, url);
|
||||
promise.complete();
|
||||
try {
|
||||
redirect(response, url);
|
||||
promise.tryComplete();
|
||||
} catch (Throwable t) {
|
||||
promise.tryFail(t);
|
||||
}
|
||||
}
|
||||
|
||||
public static void fireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject) {
|
||||
ctx.response().putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.setStatusCode(200)
|
||||
.end(jsonObject.encode());
|
||||
fireJsonObjectResponse(ctx, jsonObject, 200);
|
||||
}
|
||||
|
||||
public static void fireJsonObjectResponse(HttpServerResponse ctx, JsonObject jsonObject) {
|
||||
fireJsonObjectResponse(ctx, jsonObject, 200);
|
||||
}
|
||||
|
||||
public static void fireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject, int statusCode) {
|
||||
if (ctx.response().ended() || ctx.response().closed()) {
|
||||
return;
|
||||
}
|
||||
ctx.response().putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.setStatusCode(statusCode)
|
||||
.end(jsonObject.encode());
|
||||
}
|
||||
|
||||
public static void fireJsonObjectResponse(HttpServerResponse ctx, JsonObject jsonObject, int statusCode) {
|
||||
if (ctx.ended() || ctx.closed()) {
|
||||
return;
|
||||
}
|
||||
ctx.putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.setStatusCode(200)
|
||||
.setStatusCode(statusCode)
|
||||
.end(jsonObject.encode());
|
||||
}
|
||||
|
||||
@@ -37,15 +59,25 @@ public class ResponseUtil {
|
||||
fireJsonObjectResponse(ctx, jsonResult.toJsonObject());
|
||||
}
|
||||
|
||||
public static <T> void fireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult, int statusCode) {
|
||||
fireJsonObjectResponse(ctx, jsonResult.toJsonObject(), statusCode);
|
||||
}
|
||||
|
||||
public static <T> void fireJsonResultResponse(HttpServerResponse ctx, JsonResult<T> jsonResult) {
|
||||
fireJsonObjectResponse(ctx, jsonResult.toJsonObject());
|
||||
}
|
||||
|
||||
public static void fireTextResponse(RoutingContext ctx, String text) {
|
||||
if (ctx.response().ended() || ctx.response().closed()) {
|
||||
return;
|
||||
}
|
||||
ctx.response().putHeader(CONTENT_TYPE, "text/html; charset=utf-8").end(text);
|
||||
}
|
||||
|
||||
public static void sendError(RoutingContext ctx, int statusCode) {
|
||||
if (ctx.response().ended() || ctx.response().closed()) {
|
||||
return;
|
||||
}
|
||||
ctx.response().setStatusCode(statusCode).end();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package cn.qaiu.vx.core.util;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.core.shareddata.LocalMap;
|
||||
import io.vertx.core.shareddata.SharedData;
|
||||
|
||||
/**
|
||||
* vertx 共享数据
|
||||
@@ -13,10 +12,8 @@ import io.vertx.core.shareddata.SharedData;
|
||||
*/
|
||||
public class SharedDataUtil {
|
||||
|
||||
private static final SharedData sharedData = VertxHolder.getVertxInstance().sharedData();
|
||||
|
||||
public static SharedData shareData() {
|
||||
return sharedData;
|
||||
public static io.vertx.core.shareddata.SharedData shareData() {
|
||||
return VertxHolder.getVertxInstance().sharedData();
|
||||
}
|
||||
|
||||
public static LocalMap<String, Object> getLocalMap(String key) {
|
||||
@@ -24,7 +21,7 @@ public class SharedDataUtil {
|
||||
}
|
||||
|
||||
public static <T> LocalMap<String, T> getLocalMapWithCast(String key) {
|
||||
return sharedData.getLocalMap(key);
|
||||
return shareData().getLocalMap(key);
|
||||
}
|
||||
|
||||
public static JsonObject getJsonConfig(String key) {
|
||||
|
||||
@@ -1,57 +1,101 @@
|
||||
package cn.qaiu.vx.core.verticle;
|
||||
|
||||
import io.vertx.core.AbstractVerticle;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.VertxOptions;
|
||||
import io.vertx.core.dns.AddressResolverOptions;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.http.*;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.core.net.NetClient;
|
||||
import io.vertx.core.net.NetClientOptions;
|
||||
import io.vertx.core.net.NetSocket;
|
||||
import io.vertx.core.net.ProxyOptions;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Base64;
|
||||
|
||||
import static cn.qaiu.vx.core.util.ConfigConstant.GLOBAL_CONFIG;
|
||||
import static cn.qaiu.vx.core.util.ConfigConstant.LOCAL;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public class HttpProxyVerticle extends AbstractVerticle {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(HttpProxyVerticle.class);
|
||||
|
||||
private HttpClient httpClient;
|
||||
private NetClient netClient;
|
||||
private HttpServer httpServer;
|
||||
private volatile boolean stopping = false;
|
||||
|
||||
private JsonObject proxyPreConf;
|
||||
private JsonObject proxyServerConf;
|
||||
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
ProxyOptions proxyOptions = new ProxyOptions().setHost("127.0.0.1").setPort(7890);
|
||||
public void start(io.vertx.core.Promise<Void> startPromise) {
|
||||
stopping = false;
|
||||
proxyServerConf = ((JsonObject)vertx.sharedData().getLocalMap(LOCAL).get(GLOBAL_CONFIG)).getJsonObject("proxy-server");
|
||||
proxyPreConf = ((JsonObject)vertx.sharedData().getLocalMap(LOCAL).get(GLOBAL_CONFIG)).getJsonObject("proxy-pre");
|
||||
Integer serverPort = proxyServerConf.getInteger("port");
|
||||
|
||||
ProxyOptions proxyOptions = null;
|
||||
if (proxyPreConf != null && StringUtils.isNotBlank(proxyPreConf.getString("ip"))) {
|
||||
proxyOptions = new ProxyOptions(proxyPreConf);
|
||||
}
|
||||
|
||||
// 初始化 HTTP 客户端,用于向目标服务器发送 HTTP 请求
|
||||
HttpClientOptions httpClientOptions = new HttpClientOptions();
|
||||
httpClient = vertx.createHttpClient(httpClientOptions.setProxyOptions(proxyOptions));
|
||||
HttpClientOptions httpClientOptions = new HttpClientOptions()
|
||||
.setMaxPoolSize(64)
|
||||
.setMaxWaitQueueSize(256)
|
||||
.setConnectTimeout(15000)
|
||||
.setIdleTimeout(60)
|
||||
.setKeepAlive(true);
|
||||
if (proxyOptions != null) {
|
||||
httpClientOptions.setProxyOptions(proxyOptions);
|
||||
}
|
||||
httpClient = vertx.createHttpClient(httpClientOptions);
|
||||
|
||||
// 创建并启动 HTTP 代理服务器,监听指定端口
|
||||
HttpServer server = vertx.createHttpServer(new HttpServerOptions().setClientAuth(ClientAuth.REQUIRED));
|
||||
server.requestHandler(this::handleClientRequest);
|
||||
HttpServerOptions httpServerOptions = new HttpServerOptions();
|
||||
if (proxyServerConf.containsKey("username") &&
|
||||
StringUtils.isNotBlank(proxyServerConf.getString("username"))) {
|
||||
httpServerOptions.setClientAuth(ClientAuth.REQUIRED);
|
||||
}
|
||||
|
||||
httpServer = vertx.createHttpServer(httpServerOptions);
|
||||
httpServer.requestHandler(this::handleClientRequest);
|
||||
|
||||
// 初始化 NetClient,用于在 CONNECT 请求中建立 TCP 连接隧道
|
||||
netClient = vertx.createNetClient(new NetClientOptions()
|
||||
.setProxyOptions(proxyOptions)
|
||||
NetClientOptions netClientOptions = new NetClientOptions();
|
||||
|
||||
if (proxyOptions != null) {
|
||||
netClientOptions.setProxyOptions(proxyOptions);
|
||||
}
|
||||
|
||||
netClient = vertx.createNetClient(netClientOptions
|
||||
.setConnectTimeout(15000)
|
||||
.setTrustAll(true));
|
||||
|
||||
// 启动 HTTP 代理服务器
|
||||
server.listen(7891, ar -> {
|
||||
if (ar.succeeded()) {
|
||||
System.out.println("HTTP Proxy server started on port 7891");
|
||||
} else {
|
||||
System.err.println("Failed to start HTTP Proxy server: " + ar.cause());
|
||||
}
|
||||
});
|
||||
httpServer.listen(serverPort)
|
||||
.onSuccess(res -> {
|
||||
LOGGER.info("HTTP Proxy server started on port {}", serverPort);
|
||||
startPromise.complete();
|
||||
})
|
||||
.onFailure(err -> {
|
||||
LOGGER.error("Failed to start HTTP Proxy server: " + err.getMessage(), err);
|
||||
closeClients().onComplete(close -> startPromise.fail(err));
|
||||
});
|
||||
}
|
||||
|
||||
// 处理 HTTP CONNECT 请求,用于代理 HTTPS 流量
|
||||
private void handleConnectRequest(HttpServerRequest clientRequest) {
|
||||
String[] uriParts = clientRequest.uri().split(":");
|
||||
if (uriParts.length != 2) {
|
||||
clientRequest.response().setStatusCode(400).end("Bad Request: Invalid URI format");
|
||||
failClientResponse(clientRequest.response(), 400, "Bad Request: Invalid URI format");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,55 +105,85 @@ public class HttpProxyVerticle extends AbstractVerticle {
|
||||
try {
|
||||
targetPort = Integer.parseInt(uriParts[1]);
|
||||
} catch (NumberFormatException e) {
|
||||
clientRequest.response().setStatusCode(400).end("Bad Request: Invalid port");
|
||||
failClientResponse(clientRequest.response(), 400, "Bad Request: Invalid port");
|
||||
return;
|
||||
}
|
||||
clientRequest.pause();
|
||||
// 通过 NetClient 连接目标服务器并创建隧道
|
||||
netClient.connect(targetPort, targetHost, connectionAttempt -> {
|
||||
if (connectionAttempt.succeeded()) {
|
||||
NetSocket targetSocket = connectionAttempt.result();
|
||||
try {
|
||||
netClient.connect(targetPort, targetHost)
|
||||
.onSuccess(targetSocket -> {
|
||||
// Upgrade client connection to NetSocket and implement bidirectional data flow
|
||||
clientRequest.toNetSocket()
|
||||
.onSuccess(clientSocket -> {
|
||||
clientSocket.pipeTo(targetSocket)
|
||||
.onFailure(err -> {
|
||||
LOGGER.debug("CONNECT client -> target pipe closed", err);
|
||||
closeTunnelSockets(clientSocket, targetSocket);
|
||||
});
|
||||
targetSocket.pipeTo(clientSocket)
|
||||
.onFailure(err -> {
|
||||
LOGGER.debug("CONNECT target -> client pipe closed", err);
|
||||
closeTunnelSockets(clientSocket, targetSocket);
|
||||
});
|
||||
|
||||
// 升级客户端连接到 NetSocket 并实现双向数据流
|
||||
clientRequest.toNetSocket().onComplete(clientSocketAttempt -> {
|
||||
if (clientSocketAttempt.succeeded()) {
|
||||
NetSocket clientSocket = clientSocketAttempt.result();
|
||||
|
||||
// 设置双向数据流转发
|
||||
clientSocket.handler(targetSocket::write);
|
||||
targetSocket.handler(clientSocket::write);
|
||||
|
||||
// 关闭其中一方时关闭另一方
|
||||
clientSocket.closeHandler(v -> targetSocket.close());
|
||||
targetSocket.closeHandler(v -> clientSocket.close());
|
||||
} else {
|
||||
System.err.println("Failed to upgrade client connection to socket: " + clientSocketAttempt.cause().getMessage());
|
||||
targetSocket.close();
|
||||
clientRequest.response().setStatusCode(500).end("Internal Server Error");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
System.err.println("Failed to connect to target: " + connectionAttempt.cause().getMessage());
|
||||
clientRequest.response().setStatusCode(502).end("Bad Gateway: Unable to connect to target");
|
||||
}
|
||||
});
|
||||
// Close the other socket when one side closes
|
||||
clientSocket.closeHandler(v -> targetSocket.close());
|
||||
targetSocket.closeHandler(v -> clientSocket.close());
|
||||
})
|
||||
.onFailure(clientSocketAttempt -> {
|
||||
System.err.println("Failed to upgrade client connection to socket: " + clientSocketAttempt.getMessage());
|
||||
targetSocket.close();
|
||||
failClientRequestAndClose(clientRequest, 500, "Internal Server Error");
|
||||
});
|
||||
})
|
||||
.onFailure(connectionAttempt -> {
|
||||
LOGGER.warn("Failed to connect to target: {}", connectionAttempt.getMessage());
|
||||
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to connect to target");
|
||||
});
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("CONNECT 请求创建失败", e);
|
||||
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to connect to target");
|
||||
}
|
||||
}
|
||||
|
||||
// 处理客户端的 HTTP 请求
|
||||
private void handleClientRequest(HttpServerRequest clientRequest) {
|
||||
String s = clientRequest.headers().get("Proxy-Authorization");
|
||||
if (s == null) {
|
||||
clientRequest.response().setStatusCode(403).end();
|
||||
if (stopping) {
|
||||
failClientResponse(clientRequest.response(), 503, "Service Unavailable");
|
||||
return;
|
||||
}
|
||||
String[] split = new String(Base64.getDecoder().decode(s.replace("Basic ", ""))).split(":");
|
||||
if (split.length > 1) {
|
||||
System.out.println(split[0]);
|
||||
System.out.println(split[1]);
|
||||
// TODO
|
||||
// 打印来源ip和访问目标URI
|
||||
LOGGER.debug("source: {}, target: {}", clientRequest.remoteAddress().toString(), clientRequest.uri());
|
||||
if (proxyServerConf.containsKey("username") &&
|
||||
StringUtils.isNotBlank(proxyServerConf.getString("username"))) {
|
||||
String s = clientRequest.headers().get("Proxy-Authorization");
|
||||
if (s == null) {
|
||||
failClientResponse(clientRequest.response(), 403, null);
|
||||
return;
|
||||
}
|
||||
String[] split;
|
||||
try {
|
||||
split = new String(Base64.getDecoder().decode(s.replace("Basic ", ""))).split(":");
|
||||
} catch (IllegalArgumentException e) {
|
||||
LOGGER.warn("Proxy-Authorization header is not valid Base64");
|
||||
failClientResponse(clientRequest.response(), 403, null);
|
||||
return;
|
||||
}
|
||||
if (split.length <= 1) {
|
||||
LOGGER.warn("Proxy-Authorization header format invalid: missing username:password separator");
|
||||
failClientResponse(clientRequest.response(), 403, null);
|
||||
return;
|
||||
}
|
||||
String username = proxyServerConf.getString("username");
|
||||
String password = proxyServerConf.getString("password");
|
||||
if (!split[0].equals(username) || !split[1].equals(password)) {
|
||||
LOGGER.info("-----auth failed------\nusername: {}", split[0]);
|
||||
failClientResponse(clientRequest.response(), 403, null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (clientRequest.method() == HttpMethod.CONNECT) {
|
||||
// 处理 CONNECT 请求
|
||||
handleConnectRequest(clientRequest);
|
||||
@@ -124,61 +198,222 @@ public class HttpProxyVerticle extends AbstractVerticle {
|
||||
// 获取目标主机
|
||||
String hostHeader = clientRequest.getHeader("Host");
|
||||
if (hostHeader == null) {
|
||||
clientRequest.response().setStatusCode(400).end("Host header is missing");
|
||||
failClientResponse(clientRequest.response(), 400, "Host header is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
String targetHost = hostHeader.split(":")[0];
|
||||
int targetPort = 80; // 默认为 HTTP 的端口
|
||||
clientRequest.pause(); // 暂停客户端请求的读取,避免数据丢失
|
||||
HostAndPort target;
|
||||
try {
|
||||
target = parseHostHeader(hostHeader);
|
||||
} catch (IllegalArgumentException e) {
|
||||
failClientResponse(clientRequest.response(), 400, "Bad Request: Invalid Host header");
|
||||
return;
|
||||
}
|
||||
String targetHost = target.host();
|
||||
int targetPort = extractPortFromUrl(clientRequest.uri(), target.port()); // 默认为 HTTP 的端口
|
||||
if (targetPort <= 0) {
|
||||
failClientResponse(clientRequest.response(), 400, "Bad Request: Invalid target port");
|
||||
return;
|
||||
}
|
||||
clientRequest.pause(); // 暂停客户端请求的读取,等上游请求创建完成
|
||||
|
||||
httpClient.request(clientRequest.method(), targetPort, targetHost, clientRequest.uri())
|
||||
.onSuccess(request -> {
|
||||
clientRequest.resume(); // 恢复客户端请求的读取
|
||||
try {
|
||||
httpClient.request(clientRequest.method(), targetPort, targetHost, clientRequest.uri())
|
||||
.onSuccess(request -> {
|
||||
// 逐个设置请求头
|
||||
clientRequest.headers().forEach(header -> request.putHeader(header.getKey(), header.getValue()));
|
||||
|
||||
// 逐个设置请求头
|
||||
clientRequest.headers().forEach(header -> request.putHeader(header.getKey(), header.getValue()));
|
||||
request.response()
|
||||
.onSuccess(response -> {
|
||||
HttpServerResponse clientResponse = clientRequest.response();
|
||||
if (clientResponse.ended() || clientResponse.closed()) {
|
||||
response.resume();
|
||||
return;
|
||||
}
|
||||
clientResponse.setStatusCode(response.statusCode());
|
||||
clientResponse.headers().setAll(response.headers());
|
||||
response.pipeTo(clientResponse)
|
||||
.onFailure(err -> {
|
||||
LOGGER.error("HTTP代理响应转发失败", err);
|
||||
try {
|
||||
response.request().reset();
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("HTTP代理上游响应已关闭", e);
|
||||
}
|
||||
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to reach target");
|
||||
});
|
||||
})
|
||||
.onFailure(err -> {
|
||||
LOGGER.error("HTTP代理响应失败", err);
|
||||
try {
|
||||
request.reset();
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("HTTP代理上游请求已关闭", e);
|
||||
}
|
||||
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to reach target");
|
||||
});
|
||||
|
||||
// 将客户端请求的 body 转发给目标服务器
|
||||
clientRequest.bodyHandler(body -> request.send(body, ar -> {
|
||||
if (ar.succeeded()) {
|
||||
var response = ar.result();
|
||||
clientRequest.response().setStatusCode(response.statusCode());
|
||||
clientRequest.response().headers().setAll(response.headers());
|
||||
response.body().onSuccess(b-> clientRequest.response().end(b));
|
||||
} else {
|
||||
clientRequest.response().setStatusCode(502).end("Bad Gateway: Unable to reach target");
|
||||
}
|
||||
}));
|
||||
})
|
||||
.onFailure(err -> {
|
||||
err.printStackTrace();
|
||||
clientRequest.response().setStatusCode(502).end("Bad Gateway: Request failed");
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
// 停止 HTTP 客户端以释放资源
|
||||
if (httpClient != null) {
|
||||
httpClient.close();
|
||||
clientRequest.pipeTo(request)
|
||||
.onFailure(err -> {
|
||||
LOGGER.error("HTTP代理请求转发失败", err);
|
||||
try {
|
||||
request.reset();
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("HTTP代理上游请求已关闭", e);
|
||||
}
|
||||
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to reach target");
|
||||
});
|
||||
clientRequest.resume();
|
||||
})
|
||||
.onFailure(err -> {
|
||||
LOGGER.error("HTTP请求失败", err);
|
||||
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Request failed");
|
||||
});
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("HTTP请求创建失败", e);
|
||||
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Request failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO add Deploy
|
||||
* @param args
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
// 配置 DNS 解析器,使用多个 DNS 服务器来提升解析速度
|
||||
Vertx vertx = Vertx.vertx(new VertxOptions()
|
||||
.setAddressResolverOptions(new AddressResolverOptions()
|
||||
.addServer("114.114.114.114")
|
||||
.addServer("114.114.115.115")
|
||||
.addServer("8.8.8.8")
|
||||
.addServer("8.8.4.4")));
|
||||
|
||||
// 部署 Verticle 并启动动态 HTTP 代理服务器
|
||||
vertx.deployVerticle(new HttpProxyVerticle());
|
||||
private void failClientResponse(HttpServerResponse response, String message) {
|
||||
failClientResponse(response, 502, message);
|
||||
}
|
||||
|
||||
private void failClientResponse(HttpServerResponse response, int statusCode, String message) {
|
||||
if (response.ended() || response.closed()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!response.headWritten()) {
|
||||
response.setStatusCode(statusCode);
|
||||
if (message == null) {
|
||||
response.end();
|
||||
} else {
|
||||
response.end(message);
|
||||
}
|
||||
} else {
|
||||
response.reset();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("客户端响应已关闭,忽略代理错误响应", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void failClientRequestAndClose(HttpServerRequest request, int statusCode, String message) {
|
||||
HttpServerResponse response = request.response();
|
||||
if (response.ended() || response.closed()) {
|
||||
closeClientConnection(request);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!response.headWritten()) {
|
||||
response.setStatusCode(statusCode);
|
||||
Future<Void> endFuture = message == null ? response.end() : response.end(message);
|
||||
endFuture.onComplete(v -> closeClientConnection(request));
|
||||
} else {
|
||||
response.reset();
|
||||
closeClientConnection(request);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("客户端响应已关闭,关闭代理连接", e);
|
||||
closeClientConnection(request);
|
||||
}
|
||||
}
|
||||
|
||||
private void closeClientConnection(HttpServerRequest request) {
|
||||
try {
|
||||
request.connection().close();
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("关闭客户端代理连接失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void closeTunnelSockets(NetSocket clientSocket, NetSocket targetSocket) {
|
||||
try {
|
||||
clientSocket.close();
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("关闭CONNECT客户端socket失败", e);
|
||||
}
|
||||
try {
|
||||
targetSocket.close();
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("关闭CONNECT目标socket失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 从 URL 中提取端口号
|
||||
*
|
||||
* @param urlString URL 字符串
|
||||
* @return 提取的端口号,如果没有指定端口,则返回默认端口
|
||||
*/
|
||||
public static int extractPortFromUrl(String urlString) {
|
||||
return extractPortFromUrl(urlString, 80);
|
||||
}
|
||||
|
||||
public static int extractPortFromUrl(String urlString, int defaultPort) {
|
||||
try {
|
||||
URI uri = new URI(urlString);
|
||||
int port = uri.getPort();
|
||||
// 如果 URL 没有指定端口,使用默认端口
|
||||
if (port == -1) {
|
||||
if ("https".equalsIgnoreCase(uri.getScheme())) {
|
||||
port = 443; // HTTPS 默认端口
|
||||
} else {
|
||||
port = defaultPort; // HTTP 默认端口
|
||||
}
|
||||
}
|
||||
return port;
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("提取端口失败: {}", urlString, e);
|
||||
// 出现异常时返回 -1,表示提取失败
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private HostAndPort parseHostHeader(String hostHeader) {
|
||||
if (hostHeader.startsWith("[")) {
|
||||
int end = hostHeader.indexOf(']');
|
||||
if (end > 0) {
|
||||
String host = hostHeader.substring(1, end);
|
||||
int port = 80;
|
||||
if (hostHeader.length() > end + 2 && hostHeader.charAt(end + 1) == ':') {
|
||||
port = Integer.parseInt(hostHeader.substring(end + 2));
|
||||
}
|
||||
return new HostAndPort(host, port);
|
||||
}
|
||||
}
|
||||
int lastColon = hostHeader.lastIndexOf(':');
|
||||
if (lastColon > 0 && hostHeader.indexOf(':') == lastColon) {
|
||||
return new HostAndPort(hostHeader.substring(0, lastColon), Integer.parseInt(hostHeader.substring(lastColon + 1)));
|
||||
}
|
||||
return new HostAndPort(hostHeader, 80);
|
||||
}
|
||||
|
||||
private record HostAndPort(String host, int port) {
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void stop(Promise<Void> stopPromise) {
|
||||
stopping = true;
|
||||
Future<Void> serverClose = httpServer == null ? Future.succeededFuture() : httpServer.close();
|
||||
serverClose.onComplete(serverResult -> closeClients().onComplete(clientResult -> {
|
||||
if (serverResult.failed()) {
|
||||
stopPromise.fail(serverResult.cause());
|
||||
} else if (clientResult.failed()) {
|
||||
stopPromise.fail(clientResult.cause());
|
||||
} else {
|
||||
stopPromise.complete();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private Future<Void> closeClients() {
|
||||
Future<Void> httpClientClose = httpClient == null ? Future.succeededFuture() : httpClient.close();
|
||||
Future<Void> netClientClose = netClient == null ? Future.succeededFuture() : netClient.close();
|
||||
return Future.all(httpClientClose, netClientClose).mapEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package cn.qaiu.vx.core.verticle;
|
||||
|
||||
import cn.qaiu.vx.core.base.AppRun;
|
||||
import cn.qaiu.vx.core.base.DefaultAppRun;
|
||||
import cn.qaiu.vx.core.util.CommonUtil;
|
||||
import cn.qaiu.vx.core.util.ReflectionUtil;
|
||||
import cn.qaiu.vx.core.util.SharedDataUtil;
|
||||
import io.vertx.core.AbstractVerticle;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import org.reflections.Reflections;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* 后置执行Verticle - 在core启动后立即执行AppRun实现
|
||||
* <br>Create date 2024-01-01 00:00:00
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
*/
|
||||
public class PostExecVerticle extends AbstractVerticle {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(PostExecVerticle.class);
|
||||
private static final Set<AppRun> appRunImplementations;
|
||||
private static final AtomicBoolean lock = new AtomicBoolean(false);
|
||||
|
||||
static {
|
||||
Reflections reflections = ReflectionUtil.getReflections();
|
||||
Set<Class<? extends AppRun>> subTypesOf = reflections.getSubTypesOf(AppRun.class);
|
||||
subTypesOf.add(DefaultAppRun.class);
|
||||
appRunImplementations = CommonUtil.sortClassSet(subTypesOf);
|
||||
if (appRunImplementations.isEmpty()) {
|
||||
LOGGER.warn("未找到 AppRun 接口的实现类");
|
||||
} else {
|
||||
LOGGER.info("找到 {} 个 AppRun 接口的实现类", appRunImplementations.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(Promise<Void> startPromise) {
|
||||
if (!lock.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
LOGGER.info("PostExecVerticle 开始执行...");
|
||||
|
||||
if (appRunImplementations != null && !appRunImplementations.isEmpty()) {
|
||||
appRunImplementations.forEach(appRun -> {
|
||||
try {
|
||||
LOGGER.info("执行 AppRun 实现: {}", appRun.getClass().getName());
|
||||
JsonObject globalConfig = SharedDataUtil.getJsonConfig("globalConfig");
|
||||
appRun.execute(globalConfig);
|
||||
LOGGER.info("AppRun 实现 {} 执行完成", appRun.getClass().getName());
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("执行 AppRun 实现 {} 时发生错误",appRun.getClass().getName(), e);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
LOGGER.info("未找到 AppRun 接口的实现类");
|
||||
}
|
||||
|
||||
LOGGER.info("PostExecVerticle 执行完成");
|
||||
startPromise.complete();
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,26 @@ package cn.qaiu.vx.core.verticle;
|
||||
import cn.qaiu.vx.core.util.*;
|
||||
import io.vertx.core.AbstractVerticle;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Handler;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.http.HttpClient;
|
||||
import io.vertx.core.http.HttpClientOptions;
|
||||
import io.vertx.core.http.HttpServer;
|
||||
import io.vertx.core.http.HttpServerOptions;
|
||||
import io.vertx.core.http.HttpServerRequest;
|
||||
import io.vertx.core.http.HttpServerResponse;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.core.net.PemKeyCertOptions;
|
||||
import io.vertx.ext.web.Route;
|
||||
import io.vertx.ext.web.Router;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
import io.vertx.ext.web.handler.StaticHandler;
|
||||
import io.vertx.ext.web.proxy.handler.ProxyHandler;
|
||||
import io.vertx.httpproxy.HttpProxy;
|
||||
import io.vertx.httpproxy.ProxyContext;
|
||||
import io.vertx.httpproxy.ProxyInterceptor;
|
||||
import io.vertx.httpproxy.ProxyResponse;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -22,12 +30,18 @@ import org.slf4j.LoggerFactory;
|
||||
import java.io.File;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* <p>反向代理服务</p>
|
||||
* <p>可以根据配置文件自动生成代理服务</p>
|
||||
* <p>可以配置多个服务, 配置文件见示例</p>
|
||||
* <p>【优化】支持高并发场景,连接池复用,避免线程阻塞</p>
|
||||
* <br>Create date 2021/9/2 0:41
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
@@ -36,39 +50,201 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ReverseProxyVerticle.class);
|
||||
|
||||
private static final String PATH_PROXY_CONFIG = SharedDataUtil
|
||||
.getJsonConfig(ConfigConstant.GLOBAL_CONFIG)
|
||||
.getString("proxyConf");
|
||||
private static final Future<JsonObject> CONFIG = ConfigUtil.readYamlConfig(PATH_PROXY_CONFIG);
|
||||
private static final String DEFAULT_PATH_404 = "webroot/err/page404.html";
|
||||
|
||||
private static String serverName = "Vert.x-proxy-server"; //Server name in Http response header
|
||||
|
||||
public static String REROUTE_PATH_PREFIX = "/__rrvpspp"; //re_route_vert_proxy_server_path_prefix 硬编码
|
||||
|
||||
/**
|
||||
* 【优化】HttpClient连接池,按host:port缓存复用,避免每个请求都创建新连接
|
||||
*/
|
||||
private final Map<String, HttpClientEntry> httpClientPool = new ConcurrentHashMap<>();
|
||||
private final List<HttpServer> httpServers = new ArrayList<>();
|
||||
private volatile boolean stopping = false;
|
||||
|
||||
/**
|
||||
* 连接池条目。HttpProxy 会持有这里的 HttpClient 引用,不能在路由仍可用时关闭。
|
||||
*/
|
||||
private static class HttpClientEntry {
|
||||
final HttpClient client;
|
||||
|
||||
HttpClientEntry(HttpClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 【优化】高并发场景下的HttpClient配置
|
||||
*/
|
||||
private static final int MAX_POOL_SIZE = 32; // 最大连接池大小
|
||||
private static final int MAX_WAIT_QUEUE_SIZE = 128; // 最大等待队列大小
|
||||
private static final int CONNECT_TIMEOUT = 30000; // 连接超时30秒
|
||||
private static final int IDLE_TIMEOUT = 60; // 空闲超时60秒
|
||||
private static final boolean KEEP_ALIVE = true; // 启用Keep-Alive
|
||||
private static final boolean PIPELINING = false; // 代理场景关闭管线化,避免慢响应堆积
|
||||
@Override
|
||||
public void start(Promise<Void> startPromise) {
|
||||
CONFIG.onSuccess(this::handleProxyConfList);
|
||||
// createFileListener
|
||||
startPromise.complete();
|
||||
stopping = false;
|
||||
String pathProxyConfig = SharedDataUtil
|
||||
.getJsonConfig(ConfigConstant.GLOBAL_CONFIG)
|
||||
.getString("proxyConf");
|
||||
ConfigUtil.readYamlConfig(pathProxyConfig).onSuccess(config -> startProxyServers(config).onComplete(startPromise)).onFailure(e -> {
|
||||
LOGGER.info("web代理配置已禁用,当前仅支持API调用");
|
||||
startPromise.complete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 【优化】Verticle停止时清理HttpClient连接池
|
||||
*/
|
||||
@Override
|
||||
public void stop(Promise<Void> stopPromise) {
|
||||
stopping = true;
|
||||
LOGGER.info("Stopping ReverseProxyVerticle, closing {} servers and {} HttpClient connections...",
|
||||
httpServers.size(), httpClientPool.size());
|
||||
|
||||
List<Future<Void>> serverCloseFutures = new ArrayList<>();
|
||||
httpServers.forEach(server -> serverCloseFutures.add(server.close()));
|
||||
Future<Void> serverCloseFuture = serverCloseFutures.isEmpty()
|
||||
? Future.succeededFuture()
|
||||
: Future.all(serverCloseFutures).mapEmpty();
|
||||
|
||||
serverCloseFuture.onComplete(serverClose -> {
|
||||
List<Future<Void>> clientCloseFutures = new ArrayList<>();
|
||||
closeHttpClients(clientCloseFutures);
|
||||
Future<Void> clientCloseFuture = clientCloseFutures.isEmpty()
|
||||
? Future.succeededFuture()
|
||||
: Future.all(clientCloseFutures).mapEmpty();
|
||||
|
||||
clientCloseFuture.onComplete(clientClose -> {
|
||||
if (serverClose.succeeded()) {
|
||||
httpServers.clear();
|
||||
}
|
||||
if (clientClose.succeeded()) {
|
||||
httpClientPool.clear();
|
||||
}
|
||||
if (serverClose.failed()) {
|
||||
stopPromise.fail(serverClose.cause());
|
||||
} else if (clientClose.failed()) {
|
||||
stopPromise.fail(clientClose.cause());
|
||||
} else {
|
||||
stopPromise.complete();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void closeHttpClients(List<Future<Void>> closeFutures) {
|
||||
httpClientPool.values().forEach(entry -> {
|
||||
try {
|
||||
closeFutures.add(entry.client.close());
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Error closing HttpClient: {}", e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 【优化】获取或创建HttpClient,实现连接池复用
|
||||
* @param host 目标主机
|
||||
* @param port 目标端口
|
||||
* @return HttpClient实例
|
||||
*/
|
||||
private HttpClient getOrCreateHttpClient(String host, int port) {
|
||||
String key = host + ":" + port;
|
||||
HttpClientEntry entry = httpClientPool.computeIfAbsent(key, k -> {
|
||||
LOGGER.info("Creating new HttpClient for {}", key);
|
||||
HttpClientOptions options = new HttpClientOptions()
|
||||
.setMaxPoolSize(MAX_POOL_SIZE) // 连接池大小
|
||||
.setMaxWaitQueueSize(MAX_WAIT_QUEUE_SIZE) // 等待队列大小
|
||||
.setConnectTimeout(CONNECT_TIMEOUT) // 连接超时
|
||||
.setIdleTimeout(IDLE_TIMEOUT) // 空闲超时
|
||||
.setKeepAlive(KEEP_ALIVE) // Keep-Alive
|
||||
.setKeepAliveTimeout(120) // Keep-Alive超时120秒
|
||||
.setPipelining(PIPELINING) // HTTP管线化
|
||||
.setPipeliningLimit(10) // 管线化限制
|
||||
.setDecompressionSupported(false) // 代理不解压,避免放大内存
|
||||
.setTcpKeepAlive(true) // TCP Keep-Alive
|
||||
.setTcpNoDelay(true) // 禁用Nagle算法,降低延迟
|
||||
.setTcpFastOpen(true) // 启用TCP Fast Open
|
||||
.setTcpQuickAck(true) // 启用TCP Quick ACK
|
||||
.setReuseAddress(true) // 允许地址重用
|
||||
.setReusePort(true); // 允许端口重用
|
||||
return new HttpClientEntry(vertx.createHttpClient(options));
|
||||
});
|
||||
return entry.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局可信上游代理 IP 集合(如 nginx),仅这些 IP 的 X-Forwarded-For 会被信任
|
||||
*/
|
||||
private Set<String> globalTrustedProxies = new HashSet<>();
|
||||
|
||||
/**
|
||||
* 获取主配置文件
|
||||
*
|
||||
* @param config proxy config
|
||||
*/
|
||||
private void handleProxyConfList(JsonObject config) {
|
||||
private Future<Void> startProxyServers(JsonObject config) {
|
||||
serverName = config.getString("server-name");
|
||||
JsonArray proxyConfList = config.getJsonArray("proxy");
|
||||
if (proxyConfList != null) {
|
||||
proxyConfList.forEach(proxyConf -> {
|
||||
if (proxyConf instanceof JsonObject) {
|
||||
handleProxyConf((JsonObject) proxyConf);
|
||||
// 解析全局 trusted-proxies
|
||||
JsonArray trustedArr = config.getJsonArray("trusted-proxies");
|
||||
if (trustedArr != null) {
|
||||
trustedArr.forEach(ip -> {
|
||||
if (ip instanceof String) {
|
||||
globalTrustedProxies.add(((String) ip).trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
JsonArray proxyConfList = config.getJsonArray("proxy");
|
||||
List<Future<Void>> listenFutures = new ArrayList<>();
|
||||
if (proxyConfList != null) {
|
||||
proxyConfList.forEach(proxyConf -> {
|
||||
if (proxyConf instanceof JsonObject) {
|
||||
listenFutures.add(handleProxyConf((JsonObject) proxyConf));
|
||||
}
|
||||
});
|
||||
}
|
||||
return listenFutures.isEmpty() ? Future.succeededFuture() : Future.all(listenFutures).mapEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析真实客户端 IP。
|
||||
* 若直连来源在可信代理列表中,优先取 X-Real-IP,其次取 X-Forwarded-For 第一个值;
|
||||
* 否则直接使用直连对端地址。
|
||||
*/
|
||||
private String resolveClientIp(HttpServerRequest request) {
|
||||
String peerIp = request.remoteAddress().host();
|
||||
if (globalTrustedProxies.contains(peerIp)) {
|
||||
String realIp = request.getHeader("X-Real-IP");
|
||||
if (StringUtils.isNotBlank(realIp)) {
|
||||
return realIp.trim();
|
||||
}
|
||||
String xff = request.getHeader("X-Forwarded-For");
|
||||
if (StringUtils.isNotBlank(xff)) {
|
||||
return xff.split(",")[0].trim();
|
||||
}
|
||||
}
|
||||
return peerIp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 proxy-set-headers 中的 nginx 风格变量。
|
||||
* 支持:$remote_addr、$proxy_add_x_forwarded_for、$scheme、$host;
|
||||
* 其他值作为字面量直接使用。
|
||||
*/
|
||||
private String resolveHeaderVariable(String tpl, HttpServerRequest req, String clientIp) {
|
||||
return switch (tpl) {
|
||||
case "$remote_addr" -> clientIp;
|
||||
case "$proxy_add_x_forwarded_for" -> {
|
||||
String existing = req.getHeader("X-Forwarded-For");
|
||||
yield StringUtils.isNotBlank(existing) ? existing + ", " + clientIp : clientIp;
|
||||
}
|
||||
case "$scheme" -> req.isSSL() ? "https" : "http";
|
||||
case "$host" -> req.getHeader("Host");
|
||||
default -> tpl;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +252,7 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
||||
*
|
||||
* @param proxyConf 代理配置
|
||||
*/
|
||||
private void handleProxyConf(JsonObject proxyConf) {
|
||||
private Future<Void> handleProxyConf(JsonObject proxyConf) {
|
||||
// page404 path
|
||||
if (proxyConf.containsKey(
|
||||
|
||||
@@ -97,18 +273,31 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
||||
proxyConf.put("page404", DEFAULT_PATH_404);
|
||||
}
|
||||
|
||||
final HttpClient httpClient = VertxHolder.getVertxInstance().createHttpClient();
|
||||
Router proxyRouter = Router.router(vertx);
|
||||
|
||||
// Add Server name header
|
||||
proxyRouter.route().handler(ctx -> {
|
||||
ctx.response().putHeader("Server", serverName);
|
||||
if (stopping) {
|
||||
sendProxyError(ctx, 503, "Service Unavailable");
|
||||
return;
|
||||
}
|
||||
String realPath = ctx.request().uri();
|
||||
if (realPath.startsWith(REROUTE_PATH_PREFIX)) {
|
||||
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
|
||||
String rePath = realPath.replace(REROUTE_PATH_PREFIX, "");
|
||||
ctx.reroute(rePath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.response().ended() && !ctx.response().closed()) {
|
||||
ctx.response().putHeader("Server", serverName);
|
||||
}
|
||||
ctx.next();
|
||||
});
|
||||
|
||||
// http api proxy
|
||||
if (proxyConf.containsKey("location")) {
|
||||
handleLocation(proxyConf.getJsonArray("location"), httpClient, proxyRouter);
|
||||
handleLocation(proxyConf.getJsonArray("location"), proxyRouter);
|
||||
}
|
||||
|
||||
// static server
|
||||
@@ -117,20 +306,34 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
||||
}
|
||||
|
||||
// Send page404 page
|
||||
proxyRouter.errorHandler(404, ctx -> ctx.response().sendFile(proxyConf.getString("page404")));
|
||||
proxyRouter.errorHandler(404, ctx -> {
|
||||
sendNotFoundPage(ctx, proxyConf.getString("page404"));
|
||||
});
|
||||
proxyRouter.errorHandler(500, this::handleProxyFailure);
|
||||
|
||||
HttpServer server = getHttpsServer(proxyConf);
|
||||
server.requestHandler(proxyRouter);
|
||||
|
||||
Integer port = proxyConf.getInteger("listen");
|
||||
LOGGER.info("proxy server start on {} port", port);
|
||||
server.listen(port);
|
||||
return server.listen(port)
|
||||
.onSuccess(s -> httpServers.add(s))
|
||||
.onFailure(e -> LOGGER.error("proxy server start failed on {} port", port, e))
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
private HttpServer getHttpsServer(JsonObject proxyConf) {
|
||||
HttpServerOptions httpServerOptions = new HttpServerOptions()
|
||||
.setCompressionSupported(true);
|
||||
|
||||
// 【优化】高并发服务器配置
|
||||
.setTcpKeepAlive(true) // TCP Keep-Alive
|
||||
.setTcpNoDelay(true) // 禁用Nagle算法
|
||||
.setCompressionSupported(true) // 启用压缩
|
||||
.setAcceptBacklog(1024) // 限制积压队列,避免小容器内存膨胀
|
||||
.setIdleTimeout(120) // 空闲超时120秒
|
||||
.setTcpFastOpen(true) // 启用TCP Fast Open
|
||||
.setTcpQuickAck(true) // 启用TCP Quick ACK
|
||||
.setReuseAddress(true) // 允许地址重用
|
||||
.setReusePort(true); // 允许端口重用
|
||||
if (proxyConf.containsKey("ssl")) {
|
||||
JsonObject sslConfig = proxyConf.getJsonObject("ssl");
|
||||
|
||||
@@ -161,6 +364,67 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
||||
return vertx.createHttpServer(httpServerOptions);
|
||||
}
|
||||
|
||||
private void addProxyHandler(Route route, HttpProxy httpProxy) {
|
||||
Handler<RoutingContext> proxyHandler = ProxyHandler.create(httpProxy);
|
||||
route.handler(ctx -> {
|
||||
try {
|
||||
proxyHandler.handle(ctx);
|
||||
} catch (Throwable t) {
|
||||
LOGGER.error("反向代理处理异常", t);
|
||||
ctx.fail(t);
|
||||
}
|
||||
}).failureHandler(this::handleProxyFailure);
|
||||
}
|
||||
|
||||
private void handleProxyFailure(RoutingContext ctx) {
|
||||
Throwable failure = ctx.failure();
|
||||
if (failure != null) {
|
||||
LOGGER.error("反向代理路由失败", failure);
|
||||
}
|
||||
int statusCode = ctx.statusCode() > 0 ? ctx.statusCode() : 502;
|
||||
if (statusCode < 400) {
|
||||
statusCode = 502;
|
||||
}
|
||||
sendProxyError(ctx, statusCode, "Bad Gateway");
|
||||
}
|
||||
|
||||
private void sendNotFoundPage(RoutingContext ctx, String page404) {
|
||||
HttpServerResponse response = ctx.response();
|
||||
if (response.ended() || response.closed()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (response.headWritten()) {
|
||||
response.reset();
|
||||
return;
|
||||
}
|
||||
response.sendFile(page404)
|
||||
.onFailure(e -> {
|
||||
LOGGER.warn("发送代理 404 页面失败: {}", page404, e);
|
||||
sendProxyError(ctx, 404, "404 not found");
|
||||
});
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("发送代理 404 页面异常: {}", page404, e);
|
||||
sendProxyError(ctx, 404, "404 not found");
|
||||
}
|
||||
}
|
||||
|
||||
private void sendProxyError(RoutingContext ctx, int statusCode, String message) {
|
||||
HttpServerResponse response = ctx.response();
|
||||
if (response.ended() || response.closed()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!response.headWritten()) {
|
||||
response.setStatusCode(statusCode).end(message);
|
||||
} else {
|
||||
response.reset();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("代理响应已关闭,忽略错误响应", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理静态资源配置
|
||||
*
|
||||
@@ -184,7 +448,6 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
||||
} else {
|
||||
staticHandler = StaticHandler.create();
|
||||
}
|
||||
|
||||
if (staticConf.containsKey("directory-listing")) {
|
||||
staticHandler.setDirectoryListing(staticConf.getBoolean("directory-listing"));
|
||||
} else if (staticConf.containsKey("index")) {
|
||||
@@ -197,10 +460,9 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
||||
* 处理Location配置 代理请求Location(和nginx类似?)
|
||||
*
|
||||
* @param locationsConf location配置
|
||||
* @param httpClient 客户端
|
||||
* @param proxyRouter 代理路由
|
||||
*/
|
||||
private void handleLocation(JsonArray locationsConf, HttpClient httpClient, Router proxyRouter) {
|
||||
private void handleLocation(JsonArray locationsConf, Router proxyRouter) {
|
||||
|
||||
locationsConf.stream().map(e -> (JsonObject) e).forEach(location -> {
|
||||
// 代理规则
|
||||
@@ -211,14 +473,38 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
||||
String host = url.getHost();
|
||||
int port = url.getPort();
|
||||
if (port == -1) {
|
||||
port = 80;
|
||||
port = 443;
|
||||
}
|
||||
String originPath = url.getPath();
|
||||
LOGGER.info("path {}, originPath {}, to {}:{}", path, originPath, host, port);
|
||||
|
||||
// 注意这里不能origin多个代理地址, 一个实例只能代理一个origin
|
||||
// 【优化】使用连接池获取HttpClient,避免每个location都创建新连接
|
||||
final HttpClient httpClient = getOrCreateHttpClient(host, port);
|
||||
final HttpProxy httpProxy = HttpProxy.reverseProxy(httpClient);
|
||||
httpProxy.origin(port, host);
|
||||
|
||||
// proxy-set-headers 支持(nginx 风格变量替换)
|
||||
if (location.containsKey("proxy-set-headers")) {
|
||||
final JsonObject headerConf = location.getJsonObject("proxy-set-headers");
|
||||
httpProxy.addInterceptor(new ProxyInterceptor() {
|
||||
@Override
|
||||
public Future<ProxyResponse> handleProxyRequest(ProxyContext ctx) {
|
||||
HttpServerRequest incoming = ctx.request().proxiedRequest();
|
||||
String clientIp = resolveClientIp(incoming);
|
||||
headerConf.forEach(entry -> {
|
||||
Object val = entry.getValue();
|
||||
if (val != null) {
|
||||
String resolved = resolveHeaderVariable(val.toString(), incoming, clientIp);
|
||||
if (resolved != null) {
|
||||
ctx.request().putHeader(entry.getKey(), resolved);
|
||||
}
|
||||
}
|
||||
});
|
||||
return ProxyInterceptor.super.handleProxyRequest(ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (StringUtils.isEmpty(path)) {
|
||||
return;
|
||||
}
|
||||
@@ -227,24 +513,64 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
||||
if (StringUtils.isEmpty(originPath) || path.equals(originPath)) {
|
||||
Route route = path.startsWith("~") ? proxyRouter.routeWithRegex(path.substring(1))
|
||||
: proxyRouter.route(path);
|
||||
route.handler(ProxyHandler.create(httpProxy));
|
||||
addProxyHandler(route, httpProxy);
|
||||
} else {
|
||||
// 配置 /api/, / => 请求 /api/test 代理后 /test
|
||||
// 配置 /api/, /xxx => 请求 /api/test 代理后 /xxx/test
|
||||
final String path0 = path;
|
||||
final String originPath0 = REROUTE_PATH_PREFIX + originPath;
|
||||
final String path0 = path;
|
||||
final String originPath0 = REROUTE_PATH_PREFIX + originPath;
|
||||
|
||||
proxyRouter.route(originPath0 + "*").handler(ProxyHandler.create(httpProxy));
|
||||
proxyRouter.route(path0 + "*").handler(ctx -> {
|
||||
String realPath = ctx.request().uri();
|
||||
if (realPath.startsWith(path0)) {
|
||||
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
|
||||
String rePath = realPath.replaceAll("^" + path0, originPath0);
|
||||
ctx.reroute(rePath);
|
||||
} else {
|
||||
ctx.next();
|
||||
}
|
||||
});
|
||||
addProxyHandler(proxyRouter.route(originPath0 + "*"), httpProxy);
|
||||
proxyRouter.route(path0 + "*").handler(ctx -> {
|
||||
String realPath = ctx.request().uri();
|
||||
if (realPath.startsWith(path0)) {
|
||||
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
|
||||
String rePath = realPath.replaceAll("^" + path0, originPath0);
|
||||
ctx.reroute(rePath);
|
||||
} else {
|
||||
ctx.next();
|
||||
}
|
||||
});
|
||||
// 计算唯一后缀,避免多个 location 冲突
|
||||
// String uniqueKey = (host + ":" + port + "|" + path).replaceAll("[^a-zA-Z0-9:_|/]", "");
|
||||
// String uniqueSuffix = Integer.toHexString(uniqueKey.hashCode());
|
||||
//
|
||||
//// 规格化 originPath
|
||||
// //String originPath = url.getPath(); // 原值
|
||||
// if (StringUtils.isBlank(originPath)) originPath = "/";
|
||||
//
|
||||
//// 处理 index.html 的情况:用于首页兜底,其它子路径仍按目录穿透
|
||||
// String indexFile;
|
||||
// if (originPath.endsWith(".html")) {
|
||||
// indexFile = originPath; // 例如 /index.html
|
||||
// originPath = "/"; // 目录穿透基准改为根
|
||||
// } else {
|
||||
// indexFile = null;
|
||||
// }
|
||||
//
|
||||
//// 唯一内部挂载前缀
|
||||
// final String originMount = REROUTE_PATH_PREFIX + uniqueSuffix + originPath;
|
||||
//
|
||||
//// 1) 目标挂载:所有被重写的请求最终到这里走 ProxyHandler
|
||||
// proxyRouter.route(originMount + "*").handler(ProxyHandler.create(httpProxy));
|
||||
//
|
||||
//// 2) 从外部前缀 -> 内部挂载 的重写
|
||||
// final String path0 = path;
|
||||
// proxyRouter.route(path0 + "*").handler(ctx -> {
|
||||
// String uri = ctx.request().uri();
|
||||
// if (!uri.startsWith(path0)) { ctx.next(); return; }
|
||||
//
|
||||
// // 首页兜底:访问 /n2 或 /n2/ 时,重写到 index.html(如果配置了)
|
||||
// if (indexFile != null && (uri.equals(path0) || uri.equals(path0.substring(0, path0.length()-1)))) {
|
||||
// String rePath = originMount.endsWith("/") ? (originMount + indexFile.substring(1)) : (originMount + indexFile);
|
||||
// ctx.reroute(rePath);
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // 一般穿透:/n2/xxx -> originMount + xxx
|
||||
// String rePath = uri.replaceFirst("^" + path0, originMount);
|
||||
// ctx.reroute(rePath);
|
||||
// });
|
||||
}
|
||||
|
||||
} catch (MalformedURLException e) {
|
||||
|
||||
@@ -22,22 +22,22 @@ public class RouterVerticle extends AbstractVerticle {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(RouterVerticle.class);
|
||||
|
||||
private static final int port = SharedDataUtil.getValueForServerConfig("port");
|
||||
private static final Router router = new RouterHandlerFactory(
|
||||
SharedDataUtil.getJsonStringForServerConfig("contextPath")).createRouter();
|
||||
|
||||
private static final JsonObject globalConfig = SharedDataUtil.getJsonConfig("globalConfig");
|
||||
|
||||
private HttpServer server;
|
||||
private Router router;
|
||||
private int port;
|
||||
private JsonObject globalConfig;
|
||||
|
||||
static {
|
||||
LOGGER.info(JacksonConfig.class.getSimpleName() + " >> ");
|
||||
JacksonConfig.nothing();
|
||||
LOGGER.info("To start listening to port {} ......", port);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(Promise<Void> startPromise) {
|
||||
port = SharedDataUtil.getValueForServerConfig("port");
|
||||
globalConfig = SharedDataUtil.getJsonConfig("globalConfig");
|
||||
LOGGER.info("To start listening to port {} ......", port);
|
||||
|
||||
// 端口是否占用
|
||||
if (CommonUtil.isPortUsing(port)) {
|
||||
throw new RuntimeException("Start fail: the '" + port + "' port is already in use...");
|
||||
@@ -49,9 +49,23 @@ public class RouterVerticle extends AbstractVerticle {
|
||||
options = new HttpServerOptions();
|
||||
}
|
||||
options.setPort(port);
|
||||
|
||||
// 【优化】高并发服务器配置
|
||||
options.setTcpKeepAlive(true) // TCP Keep-Alive
|
||||
.setTcpNoDelay(true) // 禁用Nagle算法,降低延迟
|
||||
.setCompressionSupported(true) // 启用压缩
|
||||
.setAcceptBacklog(50000) // 增加积压队列到50000,防止高并发时连接被拒绝
|
||||
.setIdleTimeout(120) // 空闲超时120秒
|
||||
.setTcpFastOpen(true) // 启用TCP Fast Open
|
||||
.setTcpQuickAck(true) // 启用TCP Quick ACK
|
||||
.setReuseAddress(true) // 允许地址重用
|
||||
.setReusePort(true); // 允许端口重用
|
||||
|
||||
router = new RouterHandlerFactory(
|
||||
SharedDataUtil.getJsonStringForServerConfig("contextPath")).createRouter();
|
||||
server = vertx.createHttpServer(options);
|
||||
|
||||
server.requestHandler(router).webSocketHandler(s->{}).listen()
|
||||
server.requestHandler(router).listen()
|
||||
.onSuccess(s -> startPromise.complete())
|
||||
.onFailure(e -> startPromise.fail(e.getCause()));
|
||||
}
|
||||
|
||||
@@ -5,11 +5,15 @@ import cn.qaiu.vx.core.base.BaseAsyncService;
|
||||
import cn.qaiu.vx.core.util.ReflectionUtil;
|
||||
import io.vertx.core.AbstractVerticle;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.eventbus.MessageConsumer;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.serviceproxy.ServiceBinder;
|
||||
import org.reflections.Reflections;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@@ -24,26 +28,48 @@ public class ServiceVerticle extends AbstractVerticle {
|
||||
Logger LOGGER = LoggerFactory.getLogger(ServiceVerticle.class);
|
||||
private static final AtomicInteger ID = new AtomicInteger(1);
|
||||
private static final Set<Class<?>> handlers;
|
||||
private final List<MessageConsumer<JsonObject>> consumers = new ArrayList<>();
|
||||
|
||||
static {
|
||||
Reflections reflections = ReflectionUtil.getReflections();
|
||||
handlers = reflections.getTypesAnnotatedWith(Service.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(Promise<Void> startPromise) {
|
||||
ServiceBinder binder = new ServiceBinder(vertx);
|
||||
if (null != handlers && handlers.size() > 0) {
|
||||
// handlers转为拼接类列表,xxx,yyy,zzz
|
||||
StringBuilder serviceNames = new StringBuilder();
|
||||
handlers.forEach(asyncService -> {
|
||||
try {
|
||||
serviceNames.append(asyncService.getName()).append("|");
|
||||
BaseAsyncService asInstance = (BaseAsyncService) ReflectionUtil.newWithNoParam(asyncService);
|
||||
binder.setAddress(asInstance.getAddress()).register(asInstance.getAsyncInterfaceClass(), asInstance);
|
||||
String address = asInstance.getAddress();
|
||||
MessageConsumer<JsonObject> consumer = binder.setAddress(address)
|
||||
.register(asInstance.getAsyncInterfaceClass(), asInstance);
|
||||
consumers.add(consumer);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error(e.getMessage());
|
||||
LOGGER.error("Failed to register service: {}", asyncService.getName(), e);
|
||||
}
|
||||
});
|
||||
LOGGER.info("registered async services -> id: {}", ID.getAndIncrement());
|
||||
|
||||
LOGGER.info("registered async services -> id: {}, name: {}", ID.getAndIncrement(), serviceNames.toString());
|
||||
}
|
||||
startPromise.complete();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop(Promise<Void> stopPromise) {
|
||||
int count = consumers.size();
|
||||
consumers.forEach(consumer -> {
|
||||
try {
|
||||
consumer.unregister();
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to unregister service consumer at address: {}", consumer.address(), e);
|
||||
}
|
||||
});
|
||||
consumers.clear();
|
||||
LOGGER.info("ServiceVerticle stopped, unregistered {} services", count);
|
||||
stopPromise.complete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,14 @@ import io.vertx.core.net.ProxyOptions;
|
||||
import java.util.UUID;
|
||||
|
||||
@DataObject
|
||||
@JsonGen(publicConverter = false)
|
||||
//@JsonGen(publicConverter = false)
|
||||
public class HttpProxyConf {
|
||||
|
||||
public static final String DEFAULT_USERNAME = UUID.randomUUID().toString();
|
||||
|
||||
public static final String DEFAULT_PASSWORD = UUID.randomUUID().toString();
|
||||
|
||||
public static final Integer DEFAULT_PORT = 6402;
|
||||
public static final Integer DEFAULT_PORT = 6432;
|
||||
|
||||
public static final Integer DEFAULT_TIMEOUT = 15000;
|
||||
|
||||
@@ -32,7 +32,7 @@ public class HttpProxyConf {
|
||||
public HttpProxyConf() {
|
||||
this.username = DEFAULT_USERNAME;
|
||||
this.password = DEFAULT_PASSWORD;
|
||||
this.timeout = DEFAULT_PORT;
|
||||
this.port = DEFAULT_PORT;
|
||||
this.timeout = DEFAULT_TIMEOUT;
|
||||
this.preProxyOptions = new ProxyOptions();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package cn.qaiu.vx.core.test;
|
||||
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* 单元测试:验证 RouterHandlerFactory 关于 JsonObject/JsonArray 参数绑定的核心分支逻辑是否正确
|
||||
* (不启动整个 Vert.x 服务器,直接用 Vert.x JsonObject/JsonArray API 模拟验证关键逻辑)
|
||||
*/
|
||||
public class JsonBodyBindingLogicTest {
|
||||
|
||||
// === 模拟 handlerMethod 中的 JSON body 绑定逻辑 ===
|
||||
|
||||
/**
|
||||
* 模拟:content-type = application/json,body 是 JsonObject
|
||||
* 期望:JsonObject 类型参数被正确绑定
|
||||
*/
|
||||
@Test
|
||||
public void testJsonObjectBinding() {
|
||||
String bodyStr = "{\"name\":\"test\",\"value\":123}";
|
||||
|
||||
// 模拟 ctx.body().asJsonObject()
|
||||
JsonObject body = parseAsJsonObject(bodyStr);
|
||||
Assert.assertNotNull("body 应能解析为 JsonObject", body);
|
||||
|
||||
// 模拟绑定逻辑中的类型判断
|
||||
String targetType = JsonObject.class.getName();
|
||||
boolean matched = JsonObject.class.getName().equals(targetType);
|
||||
Assert.assertTrue("JsonObject 类型应命中绑定分支", matched);
|
||||
|
||||
// 模拟结果
|
||||
Object bound = body; // parameterValueList.put(k, body)
|
||||
Assert.assertNotNull("JsonObject 参数应被绑定(非null)", bound);
|
||||
Assert.assertEquals("name字段应为test", "test", ((JsonObject) bound).getString("name"));
|
||||
Assert.assertEquals("value字段应为123", 123, (int) ((JsonObject) bound).getInteger("value"));
|
||||
|
||||
System.out.println("[PASS] testJsonObjectBinding: JsonObject 绑定成功 -> " + bound);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟:content-type = application/json,body 是 JsonArray
|
||||
* 期望:JsonArray 类型参数被正确绑定
|
||||
*/
|
||||
@Test
|
||||
public void testJsonArrayBinding() {
|
||||
String bodyStr = "[1,2,3]";
|
||||
|
||||
// body 解析为 JsonObject 应返回 null
|
||||
JsonObject bodyAsObj = parseAsJsonObject(bodyStr);
|
||||
Assert.assertNull("JsonArray body 解析为 JsonObject 应为 null", bodyAsObj);
|
||||
|
||||
// 进入 else 分支,解析为 JsonArray
|
||||
JsonArray bodyArr = parseAsJsonArray(bodyStr);
|
||||
Assert.assertNotNull("body 应能解析为 JsonArray", bodyArr);
|
||||
|
||||
String targetType = JsonArray.class.getName();
|
||||
boolean matched = JsonArray.class.getName().equals(targetType);
|
||||
Assert.assertTrue("JsonArray 类型应命中绑定分支", matched);
|
||||
|
||||
Object bound = bodyArr;
|
||||
Assert.assertNotNull("JsonArray 参数应被绑定(非null)", bound);
|
||||
Assert.assertEquals("数组大小应为3", 3, ((JsonArray) bound).size());
|
||||
|
||||
System.out.println("[PASS] testJsonArrayBinding: JsonArray 绑定成功, size=" + ((JsonArray) bound).size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证旧代码的 bug:条件 ctx.body().asJsonObject() != null 会把 JsonArray body 排除在外
|
||||
* 新代码只判断 content-type,在 body==null 时才进 else 分支处理 JsonArray
|
||||
*/
|
||||
@Test
|
||||
public void testOldConditionBug() {
|
||||
String jsonArrayBody = "[1,2,3]";
|
||||
|
||||
// 旧代码条件:content-type==json && asJsonObject()!=null
|
||||
// 对于 JsonArray body,asJsonObject() 返回 null,整个 if 跳过
|
||||
JsonObject wrongParsed = parseAsJsonObject(jsonArrayBody);
|
||||
boolean oldConditionPassed = wrongParsed != null; // 旧代码的第二个条件
|
||||
Assert.assertFalse("旧代码 bug: JsonArray body 会导致 asJsonObject()==null,整个分支跳过", oldConditionPassed);
|
||||
|
||||
// 新代码:先进 if,body==null 再走 else 解析 JsonArray
|
||||
boolean newConditionFirst = true; // content-type 匹配
|
||||
JsonObject newBody = parseAsJsonObject(jsonArrayBody);
|
||||
boolean newBodyIsNull = newBody == null; // null -> 进 else
|
||||
Assert.assertTrue("新代码: body 解析为 null 时应走 else 分支解析 JsonArray", newBodyIsNull);
|
||||
|
||||
JsonArray newArr = parseAsJsonArray(jsonArrayBody);
|
||||
Assert.assertNotNull("新代码: else 分支正确解析出 JsonArray", newArr);
|
||||
|
||||
System.out.println("[PASS] testOldConditionBug: 修复验证通过,新代码正确处理 JsonArray body");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证:JsonObject 参数旧代码没有绑定分支(只处理实体类)
|
||||
*/
|
||||
@Test
|
||||
public void testOldMissingJsonObjectBranch() {
|
||||
String bodyStr = "{\"key\":\"value\"}";
|
||||
JsonObject body = parseAsJsonObject(bodyStr);
|
||||
|
||||
// 旧代码只调用 matchRegList(entityPackagesReg, typeName)
|
||||
// 对于 io.vertx.core.json.JsonObject,该方法返回 false,不会被绑定
|
||||
String typeName = JsonObject.class.getName(); // "io.vertx.core.json.JsonObject"
|
||||
// entityPackagesReg 一般是 "cn.qaiu.*" 这类,不会匹配 io.vertx
|
||||
boolean oldWouldBind = typeName.startsWith("cn.qaiu"); // 模拟旧代码逻辑
|
||||
Assert.assertFalse("旧代码 bug: JsonObject 参数不会被绑定", oldWouldBind);
|
||||
|
||||
// 新代码:增加了 JsonObject 类型判断
|
||||
boolean newWouldBind = JsonObject.class.getName().equals(typeName);
|
||||
Assert.assertTrue("新代码: JsonObject 参数应能被绑定", newWouldBind);
|
||||
|
||||
System.out.println("[PASS] testOldMissingJsonObjectBranch: 修复验证通过");
|
||||
}
|
||||
|
||||
// ===== 辅助方法:模拟 Vert.x RequestBody 的 asJsonObject/asJsonArray 行为 =====
|
||||
|
||||
private JsonObject parseAsJsonObject(String str) {
|
||||
try {
|
||||
return new JsonObject(str);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private JsonArray parseAsJsonArray(String str) {
|
||||
try {
|
||||
return new JsonArray(str);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package cn.qaiu.vx.core.test;
|
||||
|
||||
import cn.qaiu.vx.core.util.VertxHolder;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 集成测试: 验证 RouterHandlerFactory 对 JsonObject/JsonArray 参数绑定逻辑是否正确
|
||||
*
|
||||
* 运行方式: mvn test-compile -pl core && java -cp "core/target/test-classes:core/target/classes:..." \
|
||||
* cn.qaiu.vx.core.test.RouterHandlerBindingTest
|
||||
*
|
||||
* 或直接在 IDE 中运行 main 方法。
|
||||
*/
|
||||
public class RouterHandlerBindingTest {
|
||||
|
||||
static final int TEST_PORT = 18989;
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
System.out.println("=== RouterHandler JsonObject/JsonArray 绑定测试 ===\n");
|
||||
|
||||
// 1. 先初始化 Vert.x 与 VertxHolder ——必须在加载 RouterHandlerFactory 之前
|
||||
Vertx vertx = Vertx.vertx();
|
||||
VertxHolder.init(vertx);
|
||||
|
||||
// 2. 向 SharedData 注入最小化配置
|
||||
// baseLocations 指向测试包,使 Reflections 只扫描 TestJsonHandler
|
||||
vertx.sharedData().getLocalMap("local").put("customConfig", new JsonObject()
|
||||
.put("baseLocations", "cn.qaiu.vx.core.test")
|
||||
.put("routeTimeOut", 30000)
|
||||
.put("entityPackagesReg", new JsonArray()));
|
||||
// ReverseProxyVerticle.<clinit> 需要 globalConfig.proxyConf(非空字符串即可)
|
||||
vertx.sharedData().getLocalMap("local").put("globalConfig", new JsonObject()
|
||||
.put("proxyConf", "proxy.yml"));
|
||||
|
||||
// 3. 创建 Router(此时才触发 BaseHttpApi.reflections 静态字段初始化)
|
||||
// 用反射延迟加载,确保上面的 SharedData 已就绪
|
||||
cn.qaiu.vx.core.handlerfactory.RouterHandlerFactory factory =
|
||||
new cn.qaiu.vx.core.handlerfactory.RouterHandlerFactory("api");
|
||||
io.vertx.ext.web.Router router = factory.createRouter();
|
||||
|
||||
// 4. 启动 HTTP 服务器
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
vertx.createHttpServer()
|
||||
.requestHandler(router)
|
||||
.listen(TEST_PORT, res -> {
|
||||
if (res.succeeded()) {
|
||||
System.out.println("✔ 测试服务器启动成功 port=" + TEST_PORT);
|
||||
} else {
|
||||
System.err.println("✘ 服务器启动失败: " + res.cause().getMessage());
|
||||
}
|
||||
latch.countDown();
|
||||
});
|
||||
|
||||
if (!latch.await(5, TimeUnit.SECONDS)) {
|
||||
System.err.println("服务器启动超时");
|
||||
vertx.close();
|
||||
System.exit(1);
|
||||
}
|
||||
Thread.sleep(100); // 等 Vert.x 就绪
|
||||
|
||||
// 5. 执行测试
|
||||
boolean allPassed = true;
|
||||
allPassed &= testJsonObject();
|
||||
allPassed &= testJsonArray();
|
||||
|
||||
// 6. 关闭
|
||||
CountDownLatch closeLatch = new CountDownLatch(1);
|
||||
vertx.close(v -> closeLatch.countDown());
|
||||
closeLatch.await(3, TimeUnit.SECONDS);
|
||||
|
||||
System.out.println("\n" + (allPassed ? "✅ 全部测试通过!" : "❌ 存在测试失败!"));
|
||||
System.exit(allPassed ? 0 : 1);
|
||||
}
|
||||
|
||||
// ---------- 子测试 ----------
|
||||
|
||||
private static boolean testJsonObject() throws Exception {
|
||||
String bodyStr = "{\"name\":\"test\",\"value\":123}";
|
||||
String respBody = post("/api/test/json-object", bodyStr);
|
||||
System.out.println("[JsonObject] 响应: " + respBody);
|
||||
|
||||
JsonObject result = new JsonObject(respBody);
|
||||
JsonObject data = result.getJsonObject("data");
|
||||
boolean bound = data != null && Boolean.TRUE.equals(data.getBoolean("bound"));
|
||||
System.out.println("[JsonObject] " + (bound
|
||||
? "PASS ✅ body 正确绑定为 JsonObject"
|
||||
: "FAIL ❌ body 未绑定 (null)"));
|
||||
return bound;
|
||||
}
|
||||
|
||||
private static boolean testJsonArray() throws Exception {
|
||||
String bodyStr = "[1,2,3]";
|
||||
String respBody = post("/api/test/json-array", bodyStr);
|
||||
System.out.println("[JsonArray] 响应: " + respBody);
|
||||
|
||||
JsonObject result = new JsonObject(respBody);
|
||||
JsonObject data = result.getJsonObject("data");
|
||||
boolean bound = data != null
|
||||
&& Boolean.TRUE.equals(data.getBoolean("bound"))
|
||||
&& Integer.valueOf(3).equals(data.getInteger("size"));
|
||||
System.out.println("[JsonArray] " + (bound
|
||||
? "PASS ✅ body 正确绑定为 JsonArray, size=3"
|
||||
: "FAIL ❌ body 未绑定 或 size 不对"));
|
||||
return bound;
|
||||
}
|
||||
|
||||
private static String post(String path, String body) throws Exception {
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://localhost:" + TEST_PORT + path))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
return client.send(req, HttpResponse.BodyHandlers.ofString()).body();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.qaiu.vx.core.test;
|
||||
|
||||
import cn.qaiu.vx.core.annotaions.RouteHandler;
|
||||
import cn.qaiu.vx.core.annotaions.RouteMapping;
|
||||
import cn.qaiu.vx.core.enums.MIMEType;
|
||||
import cn.qaiu.vx.core.enums.RouteMethod;
|
||||
import cn.qaiu.vx.core.model.JsonResult;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
|
||||
/**
|
||||
* 用于测试 RouterHandlerFactory 对 JsonObject/JsonArray 参数绑定的测试 Handler
|
||||
*/
|
||||
@RouteHandler("test")
|
||||
public class TestJsonHandler {
|
||||
|
||||
/** POST /api/test/json-object Body: {"name":"test","value":123} */
|
||||
@RouteMapping(value = "/json-object", method = RouteMethod.POST, requestMIMEType = MIMEType.APPLICATION_JSON)
|
||||
public Future<JsonResult> testJsonObject(JsonObject body) {
|
||||
// 只返回是否绑定成功及已知字段值,不嵌套原始 body 避免 toJsonObject() 循环
|
||||
boolean bound = body != null;
|
||||
String nameVal = bound ? body.getString("name", "") : "";
|
||||
return Future.succeededFuture(JsonResult.data(new io.vertx.core.json.JsonObject()
|
||||
.put("bound", bound)
|
||||
.put("name", nameVal)));
|
||||
}
|
||||
|
||||
/** POST /api/test/json-array Body: [1,2,3] */
|
||||
@RouteMapping(value = "/json-array", method = RouteMethod.POST, requestMIMEType = MIMEType.APPLICATION_JSON)
|
||||
public Future<JsonResult> testJsonArray(JsonArray body) {
|
||||
return Future.succeededFuture(JsonResult.data(new io.vertx.core.json.JsonObject()
|
||||
.put("bound", body != null)
|
||||
.put("size", body != null ? body.size() : -1)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Fix permissions on volume-mounted directories (runs as root)
|
||||
chown -R appuser:appgroup /app/db /app/logs /app/resources 2>/dev/null || true
|
||||
|
||||
# Run Java directly - entrypoint is PID 1, exec makes Java PID 1
|
||||
# Docker SIGTERM goes directly to Java, triggering ShutdownHook
|
||||
DEFAULT_JVM_OPTS="-Xmx${JVM_XMX:-512M} -Xss${JVM_XSS:-512k} -XX:MaxDirectMemorySize=${JVM_MAX_DIRECT_MEMORY:-256M} -DNFD_LOG_LEVEL=${NFD_LOG_LEVEL:-info} -DNFD_PLAYGROUND_ENABLED=${NFD_PLAYGROUND_ENABLED:-false}"
|
||||
if [ -n "${JVM_MAX_METASPACE:-}" ]; then
|
||||
DEFAULT_JVM_OPTS="$DEFAULT_JVM_OPTS -XX:MaxMetaspaceSize=$JVM_MAX_METASPACE"
|
||||
fi
|
||||
exec java ${DEFAULT_JVM_OPTS} ${JVM_OPTS} -Duser.timezone=${TZ:-Asia/Shanghai} -jar /app/netdisk-fast-download.jar
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"mvn": "^3.5.0"
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -4,26 +4,26 @@ NFD 解析器模块:聚合各类网盘/分享页解析,统一输出文件列
|
||||
|
||||
- 语言:Java 17
|
||||
- 构建:Maven
|
||||
- 模块版本:10.1.17
|
||||
- 模块版本:10.2.5
|
||||
|
||||
## 依赖(Maven Central)
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>cn.qaiu</groupId>
|
||||
<artifactId>parser</artifactId>
|
||||
<version>10.1.17</version>
|
||||
<version>10.2.5</version>
|
||||
</dependency>
|
||||
```
|
||||
- Gradle Groovy DSL:
|
||||
```groovy
|
||||
dependencies {
|
||||
implementation 'cn.qaiu:parser:10.1.17'
|
||||
implementation 'cn.qaiu:parser:10.2.5'
|
||||
}
|
||||
```
|
||||
- Gradle Kotlin DSL:
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("cn.qaiu:parser:10.1.17")
|
||||
implementation("cn.qaiu:parser:10.2.5")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -96,7 +96,6 @@ String url = tool.parseSync();
|
||||
## 文档
|
||||
- parser/doc/README.md:解析约定、示例、IDEA `.http` 调试
|
||||
- **parser/doc/JAVASCRIPT_PARSER_GUIDE.md:JavaScript解析器开发完整指南** - 使用JavaScript编写自定义解析器
|
||||
- **parser/doc/PYTHON_PARSER_GUIDE.md:Python解析器开发完整指南** - 使用Python(GraalPy)编写自定义解析器
|
||||
- **parser/doc/CUSTOM_PARSER_GUIDE.md:自定义解析器扩展完整指南** - Java自定义解析器扩展
|
||||
- **parser/doc/CUSTOM_PARSER_QUICKSTART.md:自定义解析器快速开始** - 快速上手指南
|
||||
|
||||
|
||||
@@ -20,6 +20,32 @@
|
||||
|------|------|------|------|
|
||||
| url | string | ✅ 是 | 分享链接(需URL编码) |
|
||||
| pwd | string | ❌ 否 | 分享密码 |
|
||||
| auth | string | ❌ 否 | 认证参数(AES加密后的JSON,用于需要登录的网盘) |
|
||||
|
||||
### 认证参数说明(v0.2.1+)
|
||||
|
||||
部分网盘(如夸克QK、UC网盘)需要登录后的 Cookie 才能解析。`auth` 参数用于传递认证信息:
|
||||
|
||||
**加密方式**:
|
||||
- 算法:AES/ECB/PKCS5Padding
|
||||
- 密钥:`nfd_auth_key2026`(16字节)
|
||||
- 流程:JSON → AES加密 → Base64 → URL编码
|
||||
|
||||
**JSON 结构**:
|
||||
```json
|
||||
{
|
||||
"authType": "cookie", // 认证类型: cookie/accesstoken/authorization
|
||||
"token": "your_cookie_here" // Cookie 或 Token 内容
|
||||
}
|
||||
```
|
||||
|
||||
**网盘认证要求**:
|
||||
| 网盘 | 认证要求 |
|
||||
|------|---------|
|
||||
| 夸克网盘(QK) | **必须** |
|
||||
| UC网盘(UC) | **必须** |
|
||||
| 小飞机网盘(FJ) | 大文件需要 |
|
||||
| 蓝奏优享(IZ) | 大文件需要 |
|
||||
|
||||
### 请求示例
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# 自定义解析器扩展指南
|
||||
|
||||
> 最后更新:2026-01-11
|
||||
> 最后更新:2025-10-17
|
||||
|
||||
## 概述
|
||||
|
||||
本模块支持用户自定义解析器扩展。用户在依赖本项目的 Maven 坐标后,可以实现自己的网盘解析器并注册到系统中使用。
|
||||
|
||||
> **提示**:除了Java自定义解析器,本项目还支持使用脚本语言编写解析器,无需编译即可使用:
|
||||
> - [JavaScript解析器开发指南](JAVASCRIPT_PARSER_GUIDE.md) - 使用JavaScript编写解析器
|
||||
> - [Python解析器开发指南](PYTHON_PARSER_GUIDE.md) - 使用Python编写解析器(基于GraalPy)
|
||||
> **提示**:除了Java自定义解析器,本项目还支持使用JavaScript编写解析器,无需编译即可使用。
|
||||
> 查看 [JavaScript解析器开发指南](JAVASCRIPT_PARSER_GUIDE.md) 了解更多。
|
||||
|
||||
## 核心组件
|
||||
|
||||
@@ -29,7 +28,7 @@
|
||||
<dependency>
|
||||
<groupId>cn.qaiu</groupId>
|
||||
<artifactId>parser</artifactId>
|
||||
<version>10.1.17</version>
|
||||
<version>10.2.5</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<dependency>
|
||||
<groupId>cn.qaiu</groupId>
|
||||
<artifactId>parser</artifactId>
|
||||
<version>10.1.17</version>
|
||||
<version>10.2.5</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
|
||||
@@ -4,19 +4,6 @@
|
||||
|
||||
本指南介绍如何使用JavaScript编写自定义网盘解析器,支持通过JavaScript代码实现网盘解析逻辑,无需编写Java代码。
|
||||
|
||||
### 技术规格
|
||||
|
||||
- **JavaScript 引擎**: Nashorn (JDK 8-14 内置)
|
||||
- **ECMAScript 版本**: ES5.1 (ECMA-262 5.1 Edition)
|
||||
- **语法支持**: ES5 标准语法,不支持 ES6+ 特性(如箭头函数、async/await、模板字符串等)
|
||||
- **运行模式**: 同步执行,所有操作都是阻塞式的
|
||||
|
||||
### 参考文档
|
||||
|
||||
- **ECMAScript 5.1 规范**: https://262.ecma-international.org/5.1/
|
||||
- **MDN JavaScript 文档**: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript
|
||||
- **Nashorn 用户指南**: https://docs.oracle.com/javase/8/docs/technotes/guides/scripting/nashorn/
|
||||
|
||||
## 目录
|
||||
|
||||
- [快速开始](#快速开始)
|
||||
@@ -253,13 +240,13 @@ var encoded = JsHttpClient.urlEncode("hello world"); // "hello%20world"
|
||||
var decoded = JsHttpClient.urlDecode("hello%20world"); // "hello world"
|
||||
|
||||
// 发送简单表单数据
|
||||
var formResponse = http.sendForm({
|
||||
var formResponse = http.sendForm("https://api.example.com/login", {
|
||||
username: "user",
|
||||
password: "pass"
|
||||
});
|
||||
|
||||
// 发送JSON数据
|
||||
var jsonResponse = http.sendJson({
|
||||
var jsonResponse = http.sendJson("https://api.example.com/submit", {
|
||||
name: "test",
|
||||
value: 123
|
||||
});
|
||||
@@ -650,7 +637,7 @@ A: 使用 `shareLinkInfo.getSharePassword()` 方法。
|
||||
|
||||
### Q: 如何处理需要登录的网盘?
|
||||
|
||||
A: 使用 `http.putHeader()` 设置认证头,或使用 `http.sendForm()` 发送登录表单。
|
||||
A: 使用 `http.putHeader()` 设置认证头,或使用 `http.sendForm(url, data)` 发送登录表单。
|
||||
|
||||
### Q: 如何解析复杂的HTML?
|
||||
|
||||
@@ -724,17 +711,9 @@ var response = http.get("https://api.example.com/data");
|
||||
|
||||
## 相关文档
|
||||
|
||||
### 项目文档
|
||||
- [自定义解析器扩展指南](CUSTOM_PARSER_GUIDE.md) - Java自定义解析器扩展
|
||||
- [自定义解析器快速开始](CUSTOM_PARSER_QUICKSTART.md) - 快速上手指南
|
||||
- [解析器开发文档](README.md) - 解析器开发约定和规范
|
||||
- [Python解析器开发指南](PYTHON_PARSER_GUIDE.md) - Python 版本解析器指南
|
||||
|
||||
### 外部资源
|
||||
- **ECMAScript 5.1 规范**: https://262.ecma-international.org/5.1/
|
||||
- **MDN JavaScript 参考**: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference
|
||||
- **MDN JavaScript 指南**: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide
|
||||
- **Nashorn 文档**: https://docs.oracle.com/javase/8/docs/technotes/guides/scripting/nashorn/
|
||||
|
||||
## 更新日志
|
||||
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
# Python Playground pylsp WebSocket 集成指南
|
||||
|
||||
## 概述
|
||||
|
||||
本文档说明了如何将 jedi 的 pylsp (python-lsp-server) 通过 WebSocket 集成到 Python Playground 中,实现实时代码检查、自动完成和悬停提示等功能。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 前端 (Vue + Monaco) │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ PylspClient.js ││
|
||||
│ │ - 通过 WebSocket 发送 LSP JSON-RPC 消息 ││
|
||||
│ │ - 接收诊断信息并转换为 Monaco markers ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ WebSocket (SockJS)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 后端 (Vert.x + SockJS) │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ PylspWebSocketHandler.java ││
|
||||
│ │ - @SockRouteMapper("/pylsp/") ││
|
||||
│ │ - 管理 pylsp 子进程 ││
|
||||
│ │ - 转发 LSP 消息 ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ stdio (LSP协议)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ pylsp (python-lsp-server) │
|
||||
│ - jedi: 代码补全、定义跳转 │
|
||||
│ - pyflakes: 语法错误检查 │
|
||||
│ - pycodestyle: PEP8 风格检查 │
|
||||
│ - mccabe: 复杂度检查 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 后端 (Java)
|
||||
|
||||
1. **PylspWebSocketHandler.java**
|
||||
- 路径: `web-service/src/main/java/cn/qaiu/lz/web/controller/PylspWebSocketHandler.java`
|
||||
- 功能: WebSocket 端点,桥接前端与 pylsp 子进程
|
||||
- 端点: `/ws/pylsp/*`
|
||||
|
||||
### 前端 (JavaScript/Vue)
|
||||
|
||||
1. **pylspClient.js**
|
||||
- 路径: `web-front/src/utils/pylspClient.js`
|
||||
- 功能: LSP WebSocket 客户端,封装 LSP 协议
|
||||
|
||||
### 测试
|
||||
|
||||
1. **RequestsIntegrationTest.java**
|
||||
- 路径: `web-service/src/test/java/cn/qaiu/lz/web/playground/RequestsIntegrationTest.java`
|
||||
- 功能: requests 库集成测试
|
||||
|
||||
2. **test_playground_api.py**
|
||||
- 路径: `web-service/src/test/python/test_playground_api.py`
|
||||
- 功能: API 接口的 pytest 测试脚本
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 安装 pylsp
|
||||
|
||||
```bash
|
||||
pip install python-lsp-server[all]
|
||||
```
|
||||
|
||||
或者只安装核心功能:
|
||||
|
||||
```bash
|
||||
pip install python-lsp-server jedi
|
||||
```
|
||||
|
||||
### 2. 前端集成示例
|
||||
|
||||
```javascript
|
||||
import PylspClient from '@/utils/pylspClient';
|
||||
|
||||
// 创建客户端
|
||||
const pylsp = new PylspClient({
|
||||
onDiagnostics: (uri, markers) => {
|
||||
// 设置 Monaco Editor markers
|
||||
monaco.editor.setModelMarkers(model, 'pylsp', markers);
|
||||
},
|
||||
onConnected: () => {
|
||||
console.log('pylsp 已连接');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('pylsp 错误:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// 连接
|
||||
await pylsp.connect();
|
||||
|
||||
// 打开文档
|
||||
pylsp.openDocument(pythonCode);
|
||||
|
||||
// 更新文档(当代码改变时)
|
||||
pylsp.updateDocument(newCode);
|
||||
|
||||
// 获取补全
|
||||
const completions = await pylsp.getCompletions(line, column);
|
||||
|
||||
// 获取悬停信息
|
||||
const hover = await pylsp.getHover(line, column);
|
||||
|
||||
// 断开连接
|
||||
pylsp.disconnect();
|
||||
```
|
||||
|
||||
### 3. 与 Monaco Editor 集成
|
||||
|
||||
```javascript
|
||||
// 监听代码变化
|
||||
editor.onDidChangeModelContent((e) => {
|
||||
const content = editor.getValue();
|
||||
pylsp.updateDocument(content);
|
||||
});
|
||||
|
||||
// 注册补全提供者
|
||||
monaco.languages.registerCompletionItemProvider('python', {
|
||||
provideCompletionItems: async (model, position) => {
|
||||
const items = await pylsp.getCompletions(
|
||||
position.lineNumber - 1,
|
||||
position.column - 1
|
||||
);
|
||||
return { suggestions: items.map(convertToMonacoItem) };
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 已知限制
|
||||
|
||||
### GraalPy requests 库限制
|
||||
|
||||
由于 GraalPy 的 `unicodedata/LLVM` 限制,`requests` 库在后续创建的 Context 中无法正常导入(会抛出 `PolyglotException: null`)。
|
||||
|
||||
**错误链**:
|
||||
```
|
||||
requests → encodings.idna → stringprep → from unicodedata import ucd_3_2_0
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
1. 在代码顶层导入 requests(不要在函数内部导入)
|
||||
2. 使用标准库的 `urllib.request` 作为替代
|
||||
3. 首次执行时预热 requests 导入
|
||||
|
||||
### 测试注意事项
|
||||
|
||||
1. PyPlaygroundFullTest 中的测试2和测试5被标记为跳过(已知限制)
|
||||
2. 测试13(前端模板代码)使用不依赖 requests 的版本
|
||||
3. requests 功能在实际运行时通过首个 Context 可以正常使用
|
||||
|
||||
## 测试命令
|
||||
|
||||
### 运行 Java 单元测试
|
||||
|
||||
```bash
|
||||
# PyPlaygroundFullTest (13 个测试)
|
||||
cd parser && mvn exec:java \
|
||||
-Dexec.mainClass="cn.qaiu.parser.custompy.PyPlaygroundFullTest" \
|
||||
-Dexec.classpathScope=test -q
|
||||
|
||||
# RequestsIntegrationTest
|
||||
cd web-service && mvn exec:java \
|
||||
-Dexec.mainClass="cn.qaiu.lz.web.playground.RequestsIntegrationTest" \
|
||||
-Dexec.classpathScope=test -q
|
||||
```
|
||||
|
||||
### 运行 Python API 测试
|
||||
|
||||
```bash
|
||||
# 需要后端服务运行
|
||||
cd web-service/src/test/python
|
||||
pip install pytest requests
|
||||
pytest test_playground_api.py -v
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
### 后端配置
|
||||
|
||||
`PylspWebSocketHandler.java` 中可以配置:
|
||||
- pylsp 启动命令
|
||||
- 心跳间隔
|
||||
- 进程超时
|
||||
|
||||
### 前端配置
|
||||
|
||||
`pylspClient.js` 中可以配置:
|
||||
- WebSocket URL
|
||||
- 重连次数
|
||||
- 重连延迟
|
||||
- 请求超时
|
||||
|
||||
## 安全考虑
|
||||
|
||||
1. pylsp 进程在沙箱环境中运行
|
||||
2. 每个 WebSocket 连接对应一个独立的 pylsp 进程
|
||||
3. 连接关闭时自动清理进程
|
||||
4. Playground 访问需要认证(如果配置了密码)
|
||||
|
||||
## 未来改进
|
||||
|
||||
1. 支持多文件项目分析
|
||||
2. 添加 pyright 类型检查
|
||||
3. 支持代码格式化(black/autopep8)
|
||||
4. 添加重构功能
|
||||
5. 支持虚拟环境选择
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,147 +0,0 @@
|
||||
# Python Playground 测试报告
|
||||
|
||||
## 测试概述
|
||||
|
||||
本文档总结了 Python Playground 功能的单元测试和接口测试结果。
|
||||
|
||||
## 测试文件
|
||||
|
||||
| 文件 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| `PyPlaygroundFullTest.java` | parser/src/test/java/cn/qaiu/parser/custompy/ | 完整单元测试套件(13个测试) |
|
||||
| `PyCodeSecurityCheckerTest.java` | parser/src/test/java/cn/qaiu/parser/custompy/ | 安全检查器测试(17个测试) |
|
||||
| `PlaygroundApiTest.java` | parser/src/test/java/cn/qaiu/parser/custompy/ | API接口测试(需要后端运行) |
|
||||
|
||||
## 单元测试结果
|
||||
|
||||
### PyPlaygroundFullTest - 13/13 通过 ✅
|
||||
|
||||
| 测试 | 说明 | 结果 |
|
||||
|------|------|------|
|
||||
| 测试1 | 基础 Python 执行(1+2, 字符串操作) | ✅ 通过 |
|
||||
| 测试2 | requests 库导入 | ⚠️ 跳过(已知限制,功能由测试13验证) |
|
||||
| 测试3 | 标准库导入(json, re, base64, hashlib) | ✅ 通过 |
|
||||
| 测试4 | 简单 parse 函数 | ✅ 通过 |
|
||||
| 测试5 | 带 requests 的 parse 函数 | ⚠️ 跳过(已知限制,功能由测试13验证) |
|
||||
| 测试6 | 带 share_link_info 的 parse 函数 | ✅ 通过 |
|
||||
| 测试7 | PyPlaygroundExecutor 完整流程 | ✅ 通过 |
|
||||
| 测试8 | 安全检查 - 拦截 subprocess | ✅ 通过 |
|
||||
| 测试9 | 安全检查 - 拦截 socket | ✅ 通过 |
|
||||
| 测试10 | 安全检查 - 拦截 os.system | ✅ 通过 |
|
||||
| 测试11 | 安全检查 - 拦截 exec/eval | ✅ 通过 |
|
||||
| 测试12 | 安全检查 - 允许安全代码 | ✅ 通过 |
|
||||
| 测试13 | 前端模板代码执行(含 requests) | ✅ 通过 |
|
||||
|
||||
### PyCodeSecurityCheckerTest - 17/17 通过 ✅
|
||||
|
||||
所有安全检查器测试通过,验证了以下功能:
|
||||
- 危险模块拦截:subprocess, socket, ctypes, multiprocessing
|
||||
- 危险 os 方法拦截:system, popen, execv, fork, spawn, kill
|
||||
- 危险内置函数拦截:exec, eval, compile, __import__
|
||||
- 危险文件操作拦截:open with write mode
|
||||
- 安全代码正确放行
|
||||
|
||||
## 已知限制
|
||||
|
||||
### GraalPy unicodedata/LLVM 限制
|
||||
|
||||
由于 GraalPy 的限制,`requests` 库只能在**第一个**创建的 Context 中成功导入。后续创建的 Context 导入 `requests` 会触发以下错误:
|
||||
|
||||
```
|
||||
SystemError: GraalPy option 'NativeModules' is set to false, but the 'llvm' language,
|
||||
which is required for this feature, is not available.
|
||||
```
|
||||
|
||||
**原因**:`requests` 依赖的 `encodings.idna` 模块会导入 `unicodedata`,而该模块需要 LLVM 支持。
|
||||
|
||||
**影响**:
|
||||
- 在单元测试中,多个测试用例无法同时测试 `requests` 导入
|
||||
- 在实际运行中,只要使用 Context 池并确保 `requests` 在代码顶层导入,功能正常
|
||||
|
||||
**解决方案**:
|
||||
- 确保 `import requests` 放在 Python 代码的顶层,而不是函数内部
|
||||
- 前端模板已正确配置,实际使用不受影响
|
||||
|
||||
## 运行测试
|
||||
|
||||
### 运行单元测试
|
||||
|
||||
```bash
|
||||
cd parser
|
||||
mvn test-compile -q && mvn exec:java \
|
||||
-Dexec.mainClass="cn.qaiu.parser.custompy.PyPlaygroundFullTest" \
|
||||
-Dexec.classpathScope=test -q
|
||||
```
|
||||
|
||||
### 运行安全检查器测试
|
||||
|
||||
```bash
|
||||
cd parser
|
||||
mvn test-compile -q && mvn exec:java \
|
||||
-Dexec.mainClass="cn.qaiu.parser.custompy.PyCodeSecurityCheckerTest" \
|
||||
-Dexec.classpathScope=test -q
|
||||
```
|
||||
|
||||
### 运行 API 接口测试
|
||||
|
||||
**注意**:需要先启动后端服务
|
||||
|
||||
```bash
|
||||
# 启动后端服务
|
||||
cd web-service && mvn exec:java -Dexec.mainClass=cn.qaiu.lz.AppMain
|
||||
|
||||
# 在另一个终端运行测试
|
||||
cd parser
|
||||
mvn test-compile -q && mvn exec:java \
|
||||
-Dexec.mainClass="cn.qaiu.parser.custompy.PlaygroundApiTest" \
|
||||
-Dexec.classpathScope=test -q
|
||||
```
|
||||
|
||||
## API 接口测试内容
|
||||
|
||||
`PlaygroundApiTest` 测试以下接口:
|
||||
|
||||
1. **GET /v2/playground/status** - 获取演练场状态
|
||||
2. **POST /v2/playground/test (JavaScript)** - JavaScript 代码执行
|
||||
3. **POST /v2/playground/test (Python)** - Python 代码执行
|
||||
4. **POST /v2/playground/test (安全检查)** - 验证危险代码被拦截
|
||||
5. **POST /v2/playground/test (参数验证)** - 验证缺少参数时的错误处理
|
||||
|
||||
## 测试覆盖的核心组件
|
||||
|
||||
| 组件 | 说明 | 测试覆盖 |
|
||||
|------|------|----------|
|
||||
| `PyContextPool` | GraalPy Context 池管理 | ✅ 间接覆盖 |
|
||||
| `PyPlaygroundExecutor` | Python 代码执行器 | ✅ 直接测试 |
|
||||
| `PyCodeSecurityChecker` | 代码安全检查器 | ✅ 17个测试 |
|
||||
| `PyPlaygroundLogger` | 日志记录器 | ✅ 间接覆盖 |
|
||||
| `PyShareLinkInfoWrapper` | ShareLinkInfo 包装器 | ✅ 直接测试 |
|
||||
| `PyHttpClient` | HTTP 客户端封装 | ⚠️ 部分覆盖 |
|
||||
| `PyCryptoUtils` | 加密工具类 | ❌ 未直接测试 |
|
||||
|
||||
## 前端模板代码验证
|
||||
|
||||
测试13验证了前端 Python 模板代码的完整执行流程:
|
||||
|
||||
```python
|
||||
import requests
|
||||
import re
|
||||
import json
|
||||
|
||||
def parse(share_link_info, http, logger):
|
||||
share_url = share_link_info.get_share_url()
|
||||
logger.info(f"开始解析: {share_url}")
|
||||
# ... 解析逻辑
|
||||
return "https://download.example.com/test.zip"
|
||||
```
|
||||
|
||||
验证内容:
|
||||
- ✅ `requests` 库导入
|
||||
- ✅ `share_link_info.get_share_url()` 调用
|
||||
- ✅ `logger.info()` 日志记录
|
||||
- ✅ f-string 格式化
|
||||
- ✅ 函数返回值处理
|
||||
|
||||
## 结论
|
||||
|
||||
Python Playground 功能已通过全面测试,核心功能正常工作。唯一的限制是 GraalPy 的 unicodedata/LLVM 问题,但在实际使用中不影响功能。建议在正式部署前进行完整的集成测试。
|
||||
@@ -68,7 +68,7 @@ List<FileInfo> files = tool.parseFileListSync();
|
||||
```
|
||||
|
||||
要点:
|
||||
- 必须先 WebClientVertxInit.init(Vertx);若未显式初始化,内部将懒加载 Vertx.vertx(),建议显式注入以统一生命周期。
|
||||
- 必须先 WebClientVertxInit.init(Vertx);未初始化时会直接报错,避免解析器偷偷创建第二个 Vert.x 实例。
|
||||
- 支持三种同步方法:
|
||||
- `parseSync()`: 解析单个文件下载链接
|
||||
- `parseFileListSync()`: 解析文件列表
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
# 认证参数传递指南 (Auth Parameter Guide)
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述了网盘解析接口中携带认证参数的方法。通过 `auth` 参数,可以在解析请求时传递临时认证信息(如 Cookie、Token、用户名密码等),使解析器能够访问需要登录或授权的网盘资源。
|
||||
|
||||
## 网盘认证要求
|
||||
|
||||
| 网盘 | 类型代码 | 认证要求 | 说明 |
|
||||
|------|---------|---------|------|
|
||||
| 夸克网盘 | QK | **必须** | 必须配置 Cookie 才能解析和下载 |
|
||||
| UC网盘 | UC | **必须** | 必须配置 Cookie 才能解析和下载 |
|
||||
| 小飞机网盘 | FJ | 可选 | 大文件(>100MB)需要配置认证信息 |
|
||||
| 蓝奏优享 | IZ | 可选 | 大文件需要配置认证信息 |
|
||||
| 其他网盘 | - | 不需要 | 无需认证即可解析 |
|
||||
|
||||
> 💡 **如何获取 Cookie**: 在浏览器中登录对应网盘,打开开发者工具(F12),切换到 Network 标签,刷新页面,在请求头中找到 Cookie 字段并复制完整内容。
|
||||
|
||||
## 认证参数格式
|
||||
|
||||
### 编码流程
|
||||
|
||||
```
|
||||
JSON对象 → AES加密 → Base64编码 → URL编码
|
||||
```
|
||||
|
||||
### 解码流程
|
||||
|
||||
```
|
||||
URL解码 → Base64解码 → AES解密 → JSON对象
|
||||
```
|
||||
|
||||
### 加密配置
|
||||
|
||||
- **加密算法**: AES/ECB/PKCS5Padding
|
||||
- **密钥长度**: 16位(128位)
|
||||
- **默认密钥**: `nfd_auth_key2026`(可在 `app-dev.yml` 中通过 `server.authEncryptKey` 配置)
|
||||
|
||||
### 密钥作用说明(重要)
|
||||
|
||||
当前系统中涉及两类不同用途的密钥:
|
||||
|
||||
1. `server.authEncryptKey`
|
||||
- 用途:加解密 `auth` 参数(前端/调用方传入的认证信息)
|
||||
- 影响范围:`/parser`、`/json/parser`、`/v2/linkInfo` 等接口中的 `auth` 参数
|
||||
- 注意:这是 **AES 对称加密密钥**,要求 16 位
|
||||
|
||||
2. `server.donatedAccountFailureTokenSignKey`
|
||||
- 用途:签名和验签“捐赠账号失败计数 token”(用于防伪造、失败计数)
|
||||
- 影响范围:捐赠账号失败计数与自动失效逻辑
|
||||
- 注意:这是 **HMAC 签名密钥**,与 `authEncryptKey` 已解耦,建议使用高强度随机字符串
|
||||
|
||||
> 建议:生产环境务必同时自定义这两个密钥,且不要设置为相同值。
|
||||
|
||||
## JSON 模型定义
|
||||
|
||||
### AuthParam 对象
|
||||
|
||||
```json
|
||||
{
|
||||
"authType": "string", // 认证类型(必填)
|
||||
"username": "string", // 用户名
|
||||
"password": "string", // 密码
|
||||
"token": "string", // Token/AccessToken/Cookie值
|
||||
"cookie": "string", // Cookie 字符串
|
||||
"auth": "string", // Authorization 头内容
|
||||
"ext1": "string", // 扩展字段1(格式: key:value)
|
||||
"ext2": "string", // 扩展字段2(格式: key:value)
|
||||
"ext3": "string", // 扩展字段3(格式: key:value)
|
||||
"ext4": "string", // 扩展字段4(格式: key:value)
|
||||
"ext5": "string" // 扩展字段5(格式: key:value)
|
||||
}
|
||||
```
|
||||
|
||||
### 认证类型 (authType)
|
||||
|
||||
| authType | 说明 | 主要字段 |
|
||||
|----------|------|---------|
|
||||
| `accesstoken` | 使用 AccessToken 认证 | `token` |
|
||||
| `cookie` | 使用 Cookie 认证 | `token` (存放 cookie 值) |
|
||||
| `authorization` | 使用 Authorization 头认证 | `token` |
|
||||
| `password` / `username_password` | 用户名密码认证 | `username`, `password` |
|
||||
| `custom` | 自定义认证(使用扩展字段) | `token`, `ext1`-`ext5` |
|
||||
|
||||
### 示例 JSON
|
||||
|
||||
#### 1. Token 认证
|
||||
```json
|
||||
{
|
||||
"authType": "accesstoken",
|
||||
"token": "your_access_token_here"
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Cookie 认证
|
||||
```json
|
||||
{
|
||||
"authType": "cookie",
|
||||
"token": "session_id=abc123; user_token=xyz789"
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 用户名密码认证
|
||||
```json
|
||||
{
|
||||
"authType": "password",
|
||||
"username": "your_username",
|
||||
"password": "your_password"
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 自定义认证
|
||||
```json
|
||||
{
|
||||
"authType": "custom",
|
||||
"token": "main_token",
|
||||
"ext1": "refresh_token:your_refresh_token",
|
||||
"ext2": "device_id:device123"
|
||||
}
|
||||
```
|
||||
|
||||
## 接口调用示例
|
||||
|
||||
### 基础接口
|
||||
|
||||
#### 1. 解析并重定向 (GET /parser)
|
||||
|
||||
```
|
||||
GET /parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
- `url`: 网盘分享链接(必填)
|
||||
- `pwd`: 提取码(可选)
|
||||
- `auth`: 加密后的认证参数(可选)
|
||||
|
||||
**响应:** 302 重定向到直链
|
||||
|
||||
#### 2. 解析返回 JSON (GET /json/parser)
|
||||
|
||||
```
|
||||
GET /json/parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"shareKey": "lz:xxxx",
|
||||
"directLink": "https://...",
|
||||
"cacheHit": false,
|
||||
"expires": "2026-02-05 12:00:00",
|
||||
"expiration": 1738728000000
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 获取链接信息 (GET /v2/linkInfo)
|
||||
|
||||
```
|
||||
GET /v2/linkInfo?url={分享链接}&pwd={提取码}&auth={加密认证参数}
|
||||
```
|
||||
|
||||
**响应:** 返回下载链接、API 链接、预览链接等信息
|
||||
|
||||
## 各语言加密示例
|
||||
|
||||
### Java
|
||||
|
||||
```java
|
||||
import cn.qaiu.lz.common.util.AuthParamCodec;
|
||||
import cn.qaiu.lz.web.model.AuthParam;
|
||||
|
||||
// 方式1: 使用 AuthParam 对象
|
||||
AuthParam authParam = AuthParam.builder()
|
||||
.authType("accesstoken")
|
||||
.token("your_token_here")
|
||||
.build();
|
||||
String encrypted = AuthParamCodec.encode(authParam);
|
||||
|
||||
// 方式2: 快速编码
|
||||
String encrypted = AuthParamCodec.quickEncode("accesstoken", "your_token_here");
|
||||
|
||||
// 方式3: 用户名密码
|
||||
String encrypted = AuthParamCodec.quickEncodePassword("username", "password");
|
||||
|
||||
// 解码
|
||||
AuthParam decoded = AuthParamCodec.decode(encrypted);
|
||||
```
|
||||
|
||||
### JavaScript (浏览器/Node.js)
|
||||
|
||||
```javascript
|
||||
// 使用 CryptoJS 库
|
||||
const CryptoJS = require('crypto-js');
|
||||
|
||||
const AUTH_KEY = 'nfd_auth_key2026';
|
||||
|
||||
// 加密
|
||||
function encodeAuthParam(authObj) {
|
||||
const jsonStr = JSON.stringify(authObj);
|
||||
const encrypted = CryptoJS.AES.encrypt(jsonStr, CryptoJS.enc.Utf8.parse(AUTH_KEY), {
|
||||
mode: CryptoJS.mode.ECB,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
});
|
||||
const base64 = encrypted.toString();
|
||||
return encodeURIComponent(base64);
|
||||
}
|
||||
|
||||
// 解密
|
||||
function decodeAuthParam(encryptedAuth) {
|
||||
const base64 = decodeURIComponent(encryptedAuth);
|
||||
const decrypted = CryptoJS.AES.decrypt(base64, CryptoJS.enc.Utf8.parse(AUTH_KEY), {
|
||||
mode: CryptoJS.mode.ECB,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
});
|
||||
return JSON.parse(decrypted.toString(CryptoJS.enc.Utf8));
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
const auth = encodeAuthParam({
|
||||
authType: 'accesstoken',
|
||||
token: 'your_token_here'
|
||||
});
|
||||
const url = `http://127.0.0.1:6400/parser?url=${shareUrl}&auth=${auth}`;
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import json
|
||||
import base64
|
||||
from urllib.parse import quote, unquote
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad, unpad
|
||||
|
||||
AUTH_KEY = b'nfd_auth_key2026'
|
||||
|
||||
def encode_auth_param(auth_obj):
|
||||
"""加密认证参数"""
|
||||
json_str = json.dumps(auth_obj, ensure_ascii=False)
|
||||
cipher = AES.new(AUTH_KEY, AES.MODE_ECB)
|
||||
padded = pad(json_str.encode('utf-8'), AES.block_size)
|
||||
encrypted = cipher.encrypt(padded)
|
||||
base64_str = base64.b64encode(encrypted).decode('utf-8')
|
||||
return quote(base64_str)
|
||||
|
||||
def decode_auth_param(encrypted_auth):
|
||||
"""解密认证参数"""
|
||||
base64_str = unquote(encrypted_auth)
|
||||
encrypted = base64.b64decode(base64_str)
|
||||
cipher = AES.new(AUTH_KEY, AES.MODE_ECB)
|
||||
decrypted = unpad(cipher.decrypt(encrypted), AES.block_size)
|
||||
return json.loads(decrypted.decode('utf-8'))
|
||||
|
||||
# 使用示例
|
||||
auth = encode_auth_param({
|
||||
'authType': 'accesstoken',
|
||||
'token': 'your_token_here'
|
||||
})
|
||||
url = f'http://127.0.0.1:6400/parser?url={share_url}&auth={auth}'
|
||||
```
|
||||
|
||||
### cURL 命令行
|
||||
|
||||
```bash
|
||||
# 假设已加密的 auth 参数为 ENCRYPTED_AUTH
|
||||
curl -L "http://127.0.0.1:6400/parser?url=https://www.lanzoux.com/xxxx&auth=ENCRYPTED_AUTH"
|
||||
|
||||
# 获取 JSON 响应
|
||||
curl "http://127.0.0.1:6400/json/parser?url=https://www.lanzoux.com/xxxx&auth=ENCRYPTED_AUTH"
|
||||
```
|
||||
|
||||
## 解析器使用认证信息
|
||||
|
||||
解析器可以从 `shareLinkInfo.otherParam.get("auths")` 获取 MultiMap 格式的认证信息:
|
||||
|
||||
```java
|
||||
// 在解析器中获取认证信息
|
||||
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
|
||||
if (auths != null) {
|
||||
String authType = auths.get("authType");
|
||||
String token = auths.get("token");
|
||||
String username = auths.get("username");
|
||||
String password = auths.get("password");
|
||||
|
||||
// 根据 authType 使用相应的认证方式
|
||||
switch (authType) {
|
||||
case "accesstoken":
|
||||
// 使用 token 认证
|
||||
break;
|
||||
case "password":
|
||||
// 使用用户名密码登录
|
||||
break;
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **安全性**:
|
||||
- 不要在日志中打印完整的认证参数
|
||||
- 认证参数通过 HTTPS 传输更安全
|
||||
- 密钥应妥善保管,建议在生产环境中更换默认密钥
|
||||
|
||||
2. **缓存策略**:
|
||||
- 带有临时认证参数的请求目前不会被缓存
|
||||
- 每次请求都会重新解析
|
||||
|
||||
3. **兼容性**:
|
||||
- `auth` 参数与原有的 `pwd` 参数可以同时使用
|
||||
- 不提供 `auth` 参数时,使用后台配置的认证信息
|
||||
|
||||
4. **扩展字段**:
|
||||
- `ext1`-`ext5` 使用 `key:value` 格式
|
||||
- 适用于需要传递多个自定义参数的场景
|
||||
|
||||
## 配置说明
|
||||
|
||||
在 `app-dev.yml` 中配置密钥:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
# auth参数加密密钥(16位AES密钥)
|
||||
authEncryptKey: 'your_custom_key16'
|
||||
|
||||
# 捐赠账号失败计数token签名密钥(HMAC)
|
||||
# 建议使用较长随机字符串,并与 authEncryptKey 不同
|
||||
donatedAccountFailureTokenSignKey: 'your_random_hmac_sign_key'
|
||||
```
|
||||
|
||||
### 密钥管理建议
|
||||
|
||||
- 不要在公开仓库提交生产密钥
|
||||
- 建议通过环境变量或私有配置注入
|
||||
- 调整 `authEncryptKey` 会影响 `auth` 参数兼容性
|
||||
- 调整 `donatedAccountFailureTokenSignKey` 会使已签发的失败计数 token 失效(短期可接受)
|
||||
|
||||
## 更新日志
|
||||
|
||||
- **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型
|
||||
@@ -0,0 +1,118 @@
|
||||
# 认证参数传递指南 (简化版)
|
||||
|
||||
## JSON 对象模型
|
||||
|
||||
### AuthParam 对象
|
||||
|
||||
```json
|
||||
{
|
||||
"authType": "string", // 认证类型(必填)
|
||||
"username": "string", // 用户名
|
||||
"password": "string", // 密码
|
||||
"token": "string", // Token/AccessToken/Cookie值
|
||||
"cookie": "string", // Cookie 字符串
|
||||
"auth": "string", // Authorization 头内容
|
||||
"ext1": "string", // 扩展字段1(格式: key:value)
|
||||
"ext2": "string", // 扩展字段2(格式: key:value)
|
||||
"ext3": "string", // 扩展字段3(格式: key:value)
|
||||
"ext4": "string", // 扩展字段4(格式: key:value)
|
||||
"ext5": "string" // 扩展字段5(格式: key:value)
|
||||
}
|
||||
```
|
||||
|
||||
## 认证类型
|
||||
|
||||
| authType | 说明 | 主要字段 |
|
||||
|----------|------|---------|
|
||||
| `accesstoken` | AccessToken 认证 | `token` |
|
||||
| `cookie` | Cookie 认证 | `token` |
|
||||
| `authorization` | Authorization 头认证 | `token` |
|
||||
| `password` | 用户名密码认证 | `username`, `password` |
|
||||
| `custom` | 自定义认证 | `token`, `ext1`-`ext5` |
|
||||
|
||||
## 示例
|
||||
|
||||
### Token 认证
|
||||
```json
|
||||
{
|
||||
"authType": "accesstoken",
|
||||
"token": "your_access_token_here"
|
||||
}
|
||||
```
|
||||
|
||||
### Cookie 认证
|
||||
```json
|
||||
{
|
||||
"authType": "cookie",
|
||||
"token": "session_id=abc123; user_token=xyz789"
|
||||
}
|
||||
```
|
||||
|
||||
### 用户名密码
|
||||
```json
|
||||
{
|
||||
"authType": "password",
|
||||
"username": "your_username",
|
||||
"password": "your_password"
|
||||
}
|
||||
```
|
||||
|
||||
### 自定义认证
|
||||
```json
|
||||
{
|
||||
"authType": "custom",
|
||||
"token": "main_token",
|
||||
"ext1": "refresh_token:your_refresh_token",
|
||||
"ext2": "device_id:device123"
|
||||
}
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. **编码流程**: JSON对象 → AES加密 → Base64编码 → URL编码
|
||||
2. **加密配置**: AES/ECB/PKCS5Padding, 密钥: `nfd_auth_key2026` (16位)
|
||||
3. **接口调用**: `GET /parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}`
|
||||
|
||||
|
||||
|
||||
## 接口调用示例
|
||||
|
||||
### 基础接口
|
||||
|
||||
#### 1. 解析并重定向 (GET /parser)
|
||||
|
||||
```
|
||||
GET /parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
- `url`: 网盘分享链接(必填)
|
||||
- `pwd`: 提取码(可选)
|
||||
- `auth`: 加密后的认证参数(可选)
|
||||
|
||||
**响应:** 302 重定向到直链
|
||||
|
||||
#### 2. 解析返回 JSON (GET /json/parser)
|
||||
|
||||
```
|
||||
GET /json/parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"shareKey": "lz:xxxx",
|
||||
"directLink": "https://...",
|
||||
"cacheHit": false,
|
||||
"expires": "2026-02-05 12:00:00",
|
||||
"expiration": 1738728000000
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 获取链接信息 (GET /v2/linkInfo)
|
||||
|
||||
```
|
||||
GET /v2/linkInfo?url={分享链接}&pwd={提取码}&auth={加密认证参数}
|
||||
```
|
||||
|
||||
**响应:** 返回下载链接、API 链接、预览链接等信息
|
||||
@@ -17,7 +17,7 @@
|
||||
this.temporaryExecutor = WebClientVertxInit.get().createSharedWorkerExecutor(
|
||||
"playground-temp-" + System.currentTimeMillis(),
|
||||
1, // 每个请求只需要1个线程
|
||||
10000000000L // 设置非常长的超时,避免被vertx强制中断
|
||||
10000000000L // 设置非常长的超时,避免触发Vert.x阻塞线程告警
|
||||
);
|
||||
|
||||
// 执行完成或超时后关闭
|
||||
|
||||
@@ -106,7 +106,7 @@ executionFuture.toCompletionStage()
|
||||
### 长期方案(需大量工作)
|
||||
1. **迁移到GraalVM JavaScript引擎**
|
||||
- 支持CPU时间限制
|
||||
- 可以强制中断
|
||||
- 相比Nashorn更容易实现受控取消
|
||||
- 更好的性能
|
||||
- 但需要额外依赖
|
||||
|
||||
|
||||
+36
-64
@@ -12,7 +12,7 @@
|
||||
|
||||
<groupId>cn.qaiu</groupId>
|
||||
<artifactId>parser</artifactId>
|
||||
<version>10.2.3</version>
|
||||
<version>${parserVersion}</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>cn.qaiu:parser</name>
|
||||
@@ -35,9 +35,9 @@
|
||||
</developers>
|
||||
|
||||
<scm>
|
||||
<connection>scm:git:https://github.com/qaiu/netdisk-fast-download.git</connection>
|
||||
<developerConnection>scm:git:ssh://[email protected]:qaiu/netdisk-fast-download.git</developerConnection>
|
||||
<url>https://github.com/qaiu/netdisk-fast-download</url>
|
||||
<connection>scm:git:https://github.com/${github.owner}/${github.repo}.git</connection>
|
||||
<developerConnection>scm:git:ssh://[email protected]:${github.owner}/${github.repo}.git</developerConnection>
|
||||
<url>https://github.com/${github.owner}/${github.repo}</url>
|
||||
</scm>
|
||||
|
||||
<distributionManagement>
|
||||
@@ -52,23 +52,20 @@
|
||||
</distributionManagement>
|
||||
|
||||
<properties>
|
||||
<revision>0.1.8</revision>
|
||||
<java.version>17</java.version>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
|
||||
<!-- Versions -->
|
||||
<vertx.version>4.5.23</vertx.version>
|
||||
<vertx.version>4.5.27</vertx.version>
|
||||
<org.reflections.version>0.10.2</org.reflections.version>
|
||||
<lombok.version>1.18.38</lombok.version>
|
||||
<slf4j.version>2.0.5</slf4j.version>
|
||||
<slf4j.version>2.0.16</slf4j.version>
|
||||
<commons-lang3.version>3.18.0</commons-lang3.version>
|
||||
<jackson.version>2.14.2</jackson.version>
|
||||
<logback.version>1.5.19</logback.version>
|
||||
<jackson.version>2.18.6</jackson.version>
|
||||
<logback.version>1.5.32</logback.version>
|
||||
<junit.version>4.13.2</junit.version>
|
||||
<!-- GraalPy -->
|
||||
<graalpy.version>24.1.1</graalpy.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
@@ -107,32 +104,6 @@
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- GraalPy Python Runtime -->
|
||||
<dependency>
|
||||
<groupId>org.graalvm.polyglot</groupId>
|
||||
<artifactId>polyglot</artifactId>
|
||||
<version>${graalpy.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.graalvm.polyglot</groupId>
|
||||
<artifactId>python</artifactId>
|
||||
<version>${graalpy.version}</version>
|
||||
<type>pom</type>
|
||||
</dependency>
|
||||
<!-- GraalPy Python 包资源支持 -->
|
||||
<dependency>
|
||||
<groupId>org.graalvm.python</groupId>
|
||||
<artifactId>python-embedding</artifactId>
|
||||
<version>${graalpy.version}</version>
|
||||
</dependency>
|
||||
<!-- GraalPy LLVM 支持 - 允许多 Context 使用原生模块 (如 unicodedata) -->
|
||||
<dependency>
|
||||
<groupId>org.graalvm.polyglot</groupId>
|
||||
<artifactId>llvm-community</artifactId>
|
||||
<version>${graalpy.version}</version>
|
||||
<type>pom</type>
|
||||
</dependency>
|
||||
|
||||
<!-- Compression (Brotli) -->
|
||||
<dependency>
|
||||
<groupId>org.brotli</groupId>
|
||||
@@ -152,36 +123,37 @@
|
||||
<build>
|
||||
<plugins>
|
||||
|
||||
<!-- GraalPy Maven Plugin - 创建 Python Home 和 venv,自动安装 pip 包 -->
|
||||
<!--
|
||||
注意: python-lsp-server 依赖 ujson(需编译 C 扩展),GraalPy pip 无法安装。
|
||||
如需 python-lsp-server 功能,请运行 setup-graalpy-packages.sh 使用系统 pip 安装。
|
||||
|
||||
此处只配置 requests 及其纯 Python 依赖,Maven 构建时会自动安装到
|
||||
target/classes/org.graalvm.python.vfs/venv/lib/python3.11/site-packages/
|
||||
-->
|
||||
<!-- 从 git remote origin 自动识别 GitHub 仓库地址 -->
|
||||
<plugin>
|
||||
<groupId>org.graalvm.python</groupId>
|
||||
<artifactId>graalpy-maven-plugin</artifactId>
|
||||
<version>${graalpy.version}</version>
|
||||
<configuration>
|
||||
<!-- pip 包列表 - 仅纯 Python 包 -->
|
||||
<packages>
|
||||
<!-- requests 及其依赖 - HTTP 客户端 (解析器核心功能必需) -->
|
||||
<package>requests</package>
|
||||
<package>urllib3</package>
|
||||
<package>charset_normalizer</package>
|
||||
<package>idna</package>
|
||||
<package>certifi</package>
|
||||
</packages>
|
||||
</configuration>
|
||||
<groupId>org.codehaus.gmavenplus</groupId>
|
||||
<artifactId>gmavenplus-plugin</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.apache.groovy</groupId>
|
||||
<artifactId>groovy</artifactId>
|
||||
<version>4.0.24</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-python-resources</id>
|
||||
<phase>generate-resources</phase>
|
||||
<goals>
|
||||
<goal>process-graalpy-resources</goal>
|
||||
</goals>
|
||||
<phase>initialize</phase>
|
||||
<goals><goal>execute</goal></goals>
|
||||
<configuration>
|
||||
<scripts>
|
||||
<script>
|
||||
def url = 'git remote get-url origin'.execute().text.trim()
|
||||
def m = (url =~ 'github\\.com[:/]([^/]+)/([^/.]+?)(?:\\.git)?$')
|
||||
if (m.find()) {
|
||||
project.properties.setProperty('github.owner', m.group(1))
|
||||
project.properties.setProperty('github.repo', m.group(2))
|
||||
} else {
|
||||
project.properties.setProperty('github.owner', 'qaiu')
|
||||
project.properties.setProperty('github.repo', 'netdisk-fast-download')
|
||||
}
|
||||
</script>
|
||||
</scripts>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
#!/bin/bash
|
||||
# GraalPy pip 包安装脚本
|
||||
# 将 pip 包安装到 src/main/resources/graalpy-packages/,可打包进 jar
|
||||
# 不受 mvn clean 影响
|
||||
#
|
||||
# requests 是纯 Python 包,可以用系统 pip 安装
|
||||
# GraalPy 运行时可以正常加载这些包
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PARSER_DIR="$SCRIPT_DIR"
|
||||
PACKAGES_DIR="$PARSER_DIR/src/main/resources/graalpy-packages"
|
||||
|
||||
echo "=== GraalPy pip 包安装脚本 ==="
|
||||
echo ""
|
||||
echo "目标目录: $PACKAGES_DIR"
|
||||
echo ""
|
||||
|
||||
# 确保目标目录存在
|
||||
mkdir -p "$PACKAGES_DIR"
|
||||
|
||||
# 定义要安装的包列表
|
||||
# 1. requests 及其依赖 - HTTP 客户端
|
||||
# 2. python-lsp-server 及其依赖 - Python LSP 服务器(用于代码智能提示)
|
||||
PACKAGES=(
|
||||
# requests 依赖
|
||||
"requests"
|
||||
"urllib3"
|
||||
"charset_normalizer"
|
||||
"idna"
|
||||
"certifi"
|
||||
|
||||
# python-lsp-server (pylsp) 核心
|
||||
"python-lsp-server"
|
||||
"jedi"
|
||||
"python-lsp-jsonrpc"
|
||||
"pluggy"
|
||||
|
||||
# pylsp 可选功能
|
||||
"pyflakes" # 代码检查
|
||||
"pycodestyle" # PEP8 风格检查
|
||||
"autopep8" # 自动格式化
|
||||
"rope" # 重构支持
|
||||
"yapf" # 代码格式化
|
||||
)
|
||||
|
||||
echo "将安装以下包到 $PACKAGES_DIR :"
|
||||
printf '%s\n' "${PACKAGES[@]}"
|
||||
echo ""
|
||||
|
||||
# 使用系统 pip 安装包(纯 Python 包)
|
||||
echo "开始安装..."
|
||||
|
||||
# 尝试不同的 pip 命令
|
||||
if command -v pip3 &> /dev/null; then
|
||||
PIP_CMD="pip3"
|
||||
elif command -v pip &> /dev/null; then
|
||||
PIP_CMD="pip"
|
||||
elif command -v python3 &> /dev/null; then
|
||||
PIP_CMD="python3 -m pip"
|
||||
elif command -v python &> /dev/null; then
|
||||
PIP_CMD="python -m pip"
|
||||
else
|
||||
echo "✗ 未找到 pip,请先安装 Python 和 pip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "使用 pip 命令: $PIP_CMD"
|
||||
echo ""
|
||||
|
||||
# 安装所有包
|
||||
$PIP_CMD install --target="$PACKAGES_DIR" --upgrade "${PACKAGES[@]}" 2>&1
|
||||
|
||||
# 验证安装
|
||||
echo ""
|
||||
echo "验证安装..."
|
||||
FAILED=0
|
||||
|
||||
if [ -d "$PACKAGES_DIR/requests" ]; then
|
||||
echo "✓ requests 安装成功"
|
||||
else
|
||||
echo "✗ requests 安装失败"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
if [ -d "$PACKAGES_DIR/pylsp" ] || [ -d "$PACKAGES_DIR/python_lsp_server" ]; then
|
||||
echo "✓ python-lsp-server 安装成功"
|
||||
else
|
||||
echo "✗ python-lsp-server 安装失败"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
if [ -d "$PACKAGES_DIR/jedi" ]; then
|
||||
echo "✓ jedi 安装成功"
|
||||
else
|
||||
echo "✗ jedi 安装失败"
|
||||
FAILED=1
|
||||
fi
|
||||
if [ -d "$PACKAGES_DIR/jedi" ]; then
|
||||
echo "✓ jedi 安装成功"
|
||||
else
|
||||
echo "✗ jedi 安装失败"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
if [ $FAILED -eq 1 ]; then
|
||||
echo ""
|
||||
echo "✗ 部分包安装失败,请检查错误信息"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 列出已安装的包
|
||||
echo ""
|
||||
echo "已安装的主要包:"
|
||||
ls -1 "$PACKAGES_DIR" | grep -E "^(requests|jedi|pylsp|python_lsp)" | sort | uniq
|
||||
|
||||
echo ""
|
||||
echo "=== 安装完成 ==="
|
||||
echo ""
|
||||
echo "pip 包已安装到: $PACKAGES_DIR"
|
||||
echo "此目录会被打包进 jar,不受 mvn clean 影响"
|
||||
echo ""
|
||||
echo "包含以下功能:"
|
||||
echo " - requests: HTTP 客户端,用于网络请求"
|
||||
echo " - python-lsp-server: Python 语言服务器,提供代码智能提示"
|
||||
echo " - jedi: Python 自动完成和静态分析库"
|
||||
@@ -7,12 +7,15 @@ import org.slf4j.LoggerFactory;
|
||||
import cn.qaiu.parser.custom.CustomParserRegistry;
|
||||
|
||||
public class WebClientVertxInit {
|
||||
private Vertx vertx = null;
|
||||
private volatile Vertx vertx = null;
|
||||
private static final WebClientVertxInit INSTANCE = new WebClientVertxInit();
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WebClientVertxInit.class);
|
||||
|
||||
public static void init(Vertx vx) {
|
||||
public static synchronized void init(Vertx vx) {
|
||||
if (vx == null) {
|
||||
throw new IllegalArgumentException("Vertx instance must not be null");
|
||||
}
|
||||
INSTANCE.vertx = vx;
|
||||
|
||||
// 自动加载JavaScript解析器脚本
|
||||
@@ -23,18 +26,10 @@ public class WebClientVertxInit {
|
||||
}
|
||||
}
|
||||
|
||||
public static Vertx get() {
|
||||
public static synchronized Vertx get() {
|
||||
if (INSTANCE.vertx == null) {
|
||||
log.info("getVertx: Vertx实例不存在, 创建Vertx实例.");
|
||||
INSTANCE.vertx = Vertx.vertx();
|
||||
|
||||
// 如果Vertx实例是新创建的,也尝试加载JavaScript脚本
|
||||
try {
|
||||
CustomParserRegistry.autoLoadJsScripts();
|
||||
} catch (Exception e) {
|
||||
log.warn("自动加载JavaScript解析器脚本失败", e);
|
||||
}
|
||||
throw new IllegalStateException("Vertx实例未初始化,请先调用 WebClientVertxInit.init(vertx)");
|
||||
}
|
||||
return INSTANCE.vertx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,10 @@ public class ShareLinkInfo {
|
||||
// 将type和shareKey组合成一个字符串作为缓存key
|
||||
String key = type + ":" + shareKey;
|
||||
if (type.equals("p115")) {
|
||||
key += ("_" + otherParam.get("UA").toString().hashCode());
|
||||
Object ua = otherParam != null ? otherParam.get("UA") : null;
|
||||
if (ua != null) {
|
||||
key += ("_" + ua.toString().hashCode());
|
||||
}
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.qaiu.parser;//package cn.qaiu.lz.common.parser;
|
||||
|
||||
import cn.qaiu.WebClientVertxInit;
|
||||
import cn.qaiu.entity.FileInfo;
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGeneratorFactory;
|
||||
@@ -7,10 +8,26 @@ import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public interface IPanTool {
|
||||
public interface IPanTool extends AutoCloseable {
|
||||
|
||||
/** 同步等待超时时间(秒) */
|
||||
long SYNC_TIMEOUT_SECONDS = 120;
|
||||
|
||||
ScheduledExecutorService CLOSE_AFTER_SCHEDULER = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "pan-tool-close-after");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
/**
|
||||
* 解析文件
|
||||
@@ -18,8 +35,72 @@ public interface IPanTool {
|
||||
*/
|
||||
Future<String> parse();
|
||||
|
||||
static <T> Future<T> closeAfter(IPanTool tool, Supplier<Future<T>> action) {
|
||||
Promise<T> promise = Promise.promise();
|
||||
AtomicBoolean cleanupDone = new AtomicBoolean(false);
|
||||
ScheduledFuture<?> cleanupTask = null;
|
||||
try {
|
||||
Future<T> future = action.get();
|
||||
if (future == null) {
|
||||
closeQuietly(tool);
|
||||
return Future.failedFuture("解析器返回空 Future");
|
||||
}
|
||||
|
||||
cleanupTask = CLOSE_AFTER_SCHEDULER.schedule(() -> {
|
||||
if (cleanupDone.compareAndSet(false, true)) {
|
||||
closeQuietly(tool);
|
||||
failOnVertxContext(promise, "解析超时(" + SYNC_TIMEOUT_SECONDS + "秒)");
|
||||
}
|
||||
}, SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
ScheduledFuture<?> scheduledCleanupTask = cleanupTask;
|
||||
future.onComplete(ar -> {
|
||||
scheduledCleanupTask.cancel(false);
|
||||
if (!cleanupDone.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
closeQuietly(tool);
|
||||
if (ar.succeeded()) {
|
||||
promise.tryComplete(ar.result());
|
||||
} else {
|
||||
promise.tryFail(ar.cause());
|
||||
}
|
||||
});
|
||||
return promise.future();
|
||||
} catch (Throwable t) {
|
||||
if (cleanupTask != null) {
|
||||
cleanupTask.cancel(false);
|
||||
}
|
||||
closeQuietly(tool);
|
||||
return Future.failedFuture(t);
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> void failOnVertxContext(Promise<T> promise, String message) {
|
||||
try {
|
||||
WebClientVertxInit.get().runOnContext(ignored -> promise.tryFail(message));
|
||||
} catch (Exception ignored) {
|
||||
promise.tryFail(message);
|
||||
}
|
||||
}
|
||||
|
||||
static void closeQuietly(IPanTool tool) {
|
||||
if (tool == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
tool.close();
|
||||
} catch (Exception ignored) {
|
||||
// ignore cleanup failures
|
||||
}
|
||||
}
|
||||
|
||||
static void shutdownCloseAfterScheduler() {
|
||||
CLOSE_AFTER_SCHEDULER.shutdownNow();
|
||||
}
|
||||
|
||||
default String parseSync() {
|
||||
return parse().toCompletionStage().toCompletableFuture().join();
|
||||
return timedJoin(parse());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +114,7 @@ public interface IPanTool {
|
||||
}
|
||||
|
||||
default List<FileInfo> parseFileListSync() {
|
||||
return parseFileList().toCompletionStage().toCompletableFuture().join();
|
||||
return timedJoin(parseFileList());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,7 +128,7 @@ public interface IPanTool {
|
||||
}
|
||||
|
||||
default String parseByIdSync() {
|
||||
return parseById().toCompletionStage().toCompletableFuture().join();
|
||||
return timedJoin(parseById());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +207,7 @@ public interface IPanTool {
|
||||
* @return Map<ClientLinkType, String> 客户端下载链接集合
|
||||
*/
|
||||
default Map<ClientLinkType, String> parseWithClientLinksSync() {
|
||||
return parseWithClientLinks().toCompletionStage().toCompletableFuture().join();
|
||||
return timedJoin(parseWithClientLinks());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,4 +218,26 @@ public interface IPanTool {
|
||||
default ShareLinkInfo getShareLinkInfo() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
default void close() {
|
||||
// default no-op
|
||||
}
|
||||
|
||||
/**
|
||||
* 带超时的同步等待工具方法,替代无超时的 join()
|
||||
*/
|
||||
private static <T> T timedJoin(Future<T> future) {
|
||||
try {
|
||||
return future.toCompletionStage().toCompletableFuture()
|
||||
.get(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("线程被中断", e);
|
||||
} catch (TimeoutException e) {
|
||||
throw new RuntimeException("同步等待超时(" + SYNC_TIMEOUT_SECONDS + "秒)", e);
|
||||
} catch (java.util.concurrent.ExecutionException e) {
|
||||
throw new RuntimeException(e.getCause() != null ? e.getCause() : e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URL;
|
||||
import java.net.UnknownHostException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
@@ -34,30 +37,67 @@ import java.util.zip.GZIPInputStream;
|
||||
* <p>{网盘标识}Tool, 网盘标识不超过5个字符, 可以取网盘名称首字母缩写或拼音首字母, <br>
|
||||
* 音乐类型的解析以M开头, 例如网易云音乐Mne</p>
|
||||
*/
|
||||
public abstract class PanBase implements IPanTool {
|
||||
public abstract class PanBase implements IPanTool, Closeable {
|
||||
protected Logger log = LoggerFactory.getLogger(this.getClass());
|
||||
|
||||
protected Promise<String> promise = Promise.promise();
|
||||
|
||||
/**
|
||||
* Http client
|
||||
*/
|
||||
protected WebClient client = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions());
|
||||
private static final int MAX_COMPRESSED_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||
private static final int MAX_DECOMPRESSED_RESPONSE_CHARS = 16 * 1024 * 1024;
|
||||
private static final int MAX_ERROR_BODY_CHARS = 4096;
|
||||
|
||||
/**
|
||||
* Http client session (会话管理, 带cookie请求)
|
||||
* 共享的 WebClient 配置(设置超时避免连接无限期占用)
|
||||
*/
|
||||
private static final WebClientOptions SHARED_OPTIONS = new WebClientOptions()
|
||||
.setConnectTimeout(10000) // 连接超时 10 秒
|
||||
.setIdleTimeout(30) // 空闲超时 30 秒
|
||||
.setIdleTimeoutUnit(java.util.concurrent.TimeUnit.SECONDS);
|
||||
|
||||
private static final Object SHARED_CLIENT_LOCK = new Object();
|
||||
|
||||
/**
|
||||
* 共享的 WebClient 实例(线程安全,避免每请求创建导致资源泄漏)
|
||||
*/
|
||||
private static volatile WebClient sharedClient;
|
||||
private static volatile WebClient sharedClientNoRedirects;
|
||||
private static volatile WebClient sharedClientDisableUA;
|
||||
private static volatile boolean sharedClientsShutdown = false;
|
||||
|
||||
/**
|
||||
* Http client (默认使用共享实例,代理模式下使用独立实例)
|
||||
*/
|
||||
protected WebClient client = sharedClient();
|
||||
|
||||
/**
|
||||
* Http client session (会话管理, 带cookie请求, 每实例独立)
|
||||
*/
|
||||
protected WebClientSession clientSession = WebClientSession.create(client);
|
||||
|
||||
/**
|
||||
* Http client 不自动跳转
|
||||
*/
|
||||
protected WebClient clientNoRedirects = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions().setFollowRedirects(false));
|
||||
protected WebClient clientNoRedirects = sharedClientNoRedirects();
|
||||
|
||||
/**
|
||||
* Http client disable UserAgent
|
||||
*/
|
||||
protected WebClient clientDisableUA = sharedClientDisableUA();
|
||||
|
||||
protected ShareLinkInfo shareLinkInfo;
|
||||
|
||||
/**
|
||||
* 标记是否为代理模式(代理模式创建的 WebClient 需要手动关闭)
|
||||
*/
|
||||
private boolean isProxyMode = false;
|
||||
|
||||
/**
|
||||
* 代理模式下创建的独立 WebClient 实例(需要在 close 时释放)
|
||||
*/
|
||||
private WebClient proxyClient = null;
|
||||
private WebClient proxyClientNoRedirects = null;
|
||||
|
||||
|
||||
/**
|
||||
* 子类重写此构造方法不需要添加额外逻辑
|
||||
* 如:
|
||||
@@ -72,6 +112,7 @@ public abstract class PanBase implements IPanTool {
|
||||
public PanBase(ShareLinkInfo shareLinkInfo) {
|
||||
this.shareLinkInfo = shareLinkInfo;
|
||||
if (shareLinkInfo.getOtherParam().containsKey("proxy")) {
|
||||
this.isProxyMode = true;
|
||||
JsonObject proxy = (JsonObject) shareLinkInfo.getOtherParam().get("proxy");
|
||||
ProxyOptions proxyOptions = new ProxyOptions()
|
||||
.setType(ProxyType.valueOf(proxy.getString("type").toUpperCase()))
|
||||
@@ -83,22 +124,156 @@ public abstract class PanBase implements IPanTool {
|
||||
if (StringUtils.isNotEmpty(proxy.getString("password"))) {
|
||||
proxyOptions.setPassword(proxy.getString("password"));
|
||||
}
|
||||
this.client = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions()
|
||||
// 代理模式下创建独立的 WebClient 实例(应用超时配置)
|
||||
this.proxyClient = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions(SHARED_OPTIONS)
|
||||
.setUserAgentEnabled(false)
|
||||
.setProxyOptions(proxyOptions));
|
||||
this.proxyClientNoRedirects = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions(SHARED_OPTIONS).setFollowRedirects(false)
|
||||
.setUserAgentEnabled(false)
|
||||
.setProxyOptions(proxyOptions));
|
||||
|
||||
this.client = proxyClient;
|
||||
this.clientSession = WebClientSession.create(client);
|
||||
this.clientNoRedirects = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions().setFollowRedirects(false)
|
||||
.setUserAgentEnabled(false)
|
||||
.setProxyOptions(proxyOptions));
|
||||
this.clientNoRedirects = proxyClientNoRedirects;
|
||||
}
|
||||
}
|
||||
|
||||
protected PanBase() {
|
||||
}
|
||||
|
||||
private static WebClient sharedClient() {
|
||||
synchronized (SHARED_CLIENT_LOCK) {
|
||||
if (sharedClientsShutdown) {
|
||||
throw new IllegalStateException("共享 WebClient 已关闭");
|
||||
}
|
||||
if (sharedClient == null) {
|
||||
sharedClient = WebClient.create(WebClientVertxInit.get(), new WebClientOptions(SHARED_OPTIONS));
|
||||
}
|
||||
return sharedClient;
|
||||
}
|
||||
}
|
||||
|
||||
private static WebClient sharedClientNoRedirects() {
|
||||
synchronized (SHARED_CLIENT_LOCK) {
|
||||
if (sharedClientsShutdown) {
|
||||
throw new IllegalStateException("共享 WebClient 已关闭");
|
||||
}
|
||||
if (sharedClientNoRedirects == null) {
|
||||
sharedClientNoRedirects = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions(SHARED_OPTIONS).setFollowRedirects(false));
|
||||
}
|
||||
return sharedClientNoRedirects;
|
||||
}
|
||||
}
|
||||
|
||||
private static WebClient sharedClientDisableUA() {
|
||||
synchronized (SHARED_CLIENT_LOCK) {
|
||||
if (sharedClientsShutdown) {
|
||||
throw new IllegalStateException("共享 WebClient 已关闭");
|
||||
}
|
||||
if (sharedClientDisableUA == null) {
|
||||
sharedClientDisableUA = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions(SHARED_OPTIONS).setUserAgentEnabled(false));
|
||||
}
|
||||
return sharedClientDisableUA;
|
||||
}
|
||||
}
|
||||
|
||||
public static void shutdownSharedClients() {
|
||||
synchronized (SHARED_CLIENT_LOCK) {
|
||||
sharedClientsShutdown = true;
|
||||
closeSharedClient(sharedClient, "shared WebClient");
|
||||
closeSharedClient(sharedClientNoRedirects, "shared WebClientNoRedirects");
|
||||
closeSharedClient(sharedClientDisableUA, "shared WebClientDisableUA");
|
||||
sharedClient = null;
|
||||
sharedClientNoRedirects = null;
|
||||
sharedClientDisableUA = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeSharedClient(WebClient client, String name) {
|
||||
if (client == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
client.close();
|
||||
} catch (Exception e) {
|
||||
LoggerFactory.getLogger(PanBase.class).warn("关闭 {} 失败: {}", name, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF 防护: 校验通用自定义域名解析器 (CE/Ce4/Kd/Other 等) 即将请求的目标主机,
|
||||
* 拒绝解析到回环/内网/链路本地/组播等非公网地址的域名, 阻止攻击者通过可控 DNS
|
||||
* 记录 (或直接填写内网域名) 让服务端向内网/云元数据接口发起请求。
|
||||
* <p>
|
||||
* 必须在子类 parse() 中构造出 baseUrl/发起任何 clientSession 请求之前调用。
|
||||
*
|
||||
* @param url 从 shareLinkInfo.getShareUrl() 解析出的 URL
|
||||
* @throws IOException 当主机无法解析或解析结果落入禁止的地址段时抛出
|
||||
*/
|
||||
protected static void assertPublicHost(URL url) throws IOException {
|
||||
String host = url.getHost();
|
||||
InetAddress[] addresses;
|
||||
try {
|
||||
addresses = InetAddress.getAllByName(host);
|
||||
} catch (UnknownHostException e) {
|
||||
throw new IOException("无法解析目标主机: " + host, e);
|
||||
}
|
||||
if (addresses.length == 0) {
|
||||
throw new IOException("无法解析目标主机: " + host);
|
||||
}
|
||||
for (InetAddress addr : addresses) {
|
||||
if (isDisallowedAddress(addr)) {
|
||||
throw new IOException("目标地址不允许访问(内网/回环/链路本地/组播): "
|
||||
+ host + " -> " + addr.getHostAddress());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断地址是否落入禁止访问的范围: 0.0.0.0/8, 10/8, 127/8, 169.254/16(含云元数据
|
||||
* 169.254.169.254), 172.16/12, 192.168/16, 100.64/10(CGNAT), 224/4~255/4(组播/保留),
|
||||
* 以及对应的 IPv6 回环/链路本地/唯一本地地址(fc00::/7)/组播地址; IPv4-映射的 IPv6
|
||||
* 地址(::ffff:a.b.c.d) 会先还原为 IPv4 再判断,避免绕过。
|
||||
*/
|
||||
private static boolean isDisallowedAddress(InetAddress addr) {
|
||||
byte[] bytes = addr.getAddress();
|
||||
if (bytes.length == 16 && isIPv4Mapped(bytes)) {
|
||||
byte[] v4 = new byte[4];
|
||||
System.arraycopy(bytes, 12, v4, 0, 4);
|
||||
bytes = v4;
|
||||
}
|
||||
if (bytes.length == 4) {
|
||||
int b0 = bytes[0] & 0xFF;
|
||||
int b1 = bytes[1] & 0xFF;
|
||||
if (b0 == 0) return true; // 0.0.0.0/8
|
||||
if (b0 == 10) return true; // 10.0.0.0/8
|
||||
if (b0 == 127) return true; // 127.0.0.0/8 loopback
|
||||
if (b0 == 169 && b1 == 254) return true; // 169.254.0.0/16 (含云元数据 169.254.169.254)
|
||||
if (b0 == 172 && b1 >= 16 && b1 <= 31) return true; // 172.16.0.0/12
|
||||
if (b0 == 192 && b1 == 168) return true; // 192.168.0.0/16
|
||||
if (b0 == 100 && b1 >= 64 && b1 <= 127) return true;// 100.64.0.0/10 CGNAT
|
||||
return b0 >= 224; // 224.0.0.0/4 组播 + 240.0.0.0/4 保留
|
||||
}
|
||||
// IPv6
|
||||
if (addr.isAnyLocalAddress() || addr.isLoopbackAddress()
|
||||
|| addr.isLinkLocalAddress() || addr.isSiteLocalAddress()
|
||||
|| addr.isMulticastAddress()) {
|
||||
return true;
|
||||
}
|
||||
return bytes.length == 16 && (bytes[0] & 0xFE) == 0xFC; // fc00::/7 unique local
|
||||
}
|
||||
|
||||
private static boolean isIPv4Mapped(byte[] b) {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (b[i] != 0) return false;
|
||||
}
|
||||
return (b[10] & 0xFF) == 0xFF && (b[11] & 0xFF) == 0xFF;
|
||||
}
|
||||
|
||||
protected String baseMsg() {
|
||||
if (shareLinkInfo.getShareUrl() != null) {
|
||||
return shareLinkInfo.getPanName() + "-" + shareLinkInfo.getType() + ": url=" + shareLinkInfo.getShareUrl();
|
||||
@@ -123,16 +298,19 @@ public abstract class PanBase implements IPanTool {
|
||||
return;
|
||||
}
|
||||
String s = String.format(errorMsg.replaceAll("\\{}", "%s"), args);
|
||||
log.error("解析异常: " + s, t.fillInStackTrace());
|
||||
promise.fail(baseMsg() + ": 解析异常: " + s + " -> " + t);
|
||||
// 只记录异常消息和类型,不调用 fillInStackTrace 避免产生巨大栈信息
|
||||
log.error("解析异常: {} - {}: {}", s, t.getClass().getSimpleName(), t.getMessage());
|
||||
// 只传递异常消息,不传递完整异常对象,减少内存占用
|
||||
String failMsg = baseMsg() + ": 解析异常: " + s + " -> " + t.getClass().getSimpleName() + ": " + t.getMessage();
|
||||
promise.fail(failMsg);
|
||||
} catch (Exception e) {
|
||||
log.error("ErrorMsg format fail. The parameter has been discarded", e);
|
||||
log.error("解析异常: " + errorMsg, t.fillInStackTrace());
|
||||
log.error("解析异常: {} - {}: {}", errorMsg, t.getClass().getSimpleName(), t.getMessage());
|
||||
if (promise.future().isComplete()) {
|
||||
log.warn("ErrorMsg format. Promise 已经完成, 无法再次失败: {}", errorMsg);
|
||||
return;
|
||||
}
|
||||
promise.fail(baseMsg() + ": 解析异常: " + errorMsg + " -> " + t);
|
||||
promise.fail(baseMsg() + ": 解析异常: " + errorMsg + " -> " + t.getClass().getSimpleName() + ": " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +350,7 @@ public abstract class PanBase implements IPanTool {
|
||||
* @return Handler
|
||||
*/
|
||||
protected Handler<Throwable> handleFail(String errorMsg) {
|
||||
return t -> fail(baseMsg() + " - 请求异常 {}: -> {}", errorMsg, t.fillInStackTrace());
|
||||
return t -> fail(baseMsg() + " - 请求异常 {}: -> {}", errorMsg, t.getClass().getSimpleName() + ": " + t.getMessage());
|
||||
}
|
||||
|
||||
protected Handler<Throwable> handleFail() {
|
||||
@@ -191,28 +369,23 @@ public abstract class PanBase implements IPanTool {
|
||||
String contentEncoding = res.getHeader("Content-Encoding");
|
||||
try {
|
||||
if ("gzip".equalsIgnoreCase(contentEncoding)) {
|
||||
// 如果是gzip压缩的响应体,解压
|
||||
return new JsonObject(decompressGzip((Buffer) res.body()));
|
||||
// 如果是gzip压缩的响应体,解压(只解压一次,缓存结果)
|
||||
String decompressed = decompressGzip((Buffer) res.body());
|
||||
return new JsonObject(decompressed);
|
||||
} else {
|
||||
return res.bodyAsJsonObject();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
if ("gzip".equalsIgnoreCase(contentEncoding)) {
|
||||
// 如果是gzip压缩的响应体,解压
|
||||
try {
|
||||
log.error(decompressGzip((Buffer) res.body()));
|
||||
fail(decompressGzip((Buffer) res.body()));
|
||||
//throw new RuntimeException("响应不是JSON格式");
|
||||
} catch (IOException ex) {
|
||||
log.error("响应gzip解压失败");
|
||||
fail("响应gzip解压失败: {}", ex.getMessage());
|
||||
//throw new RuntimeException("响应gzip解压失败", ex);
|
||||
}
|
||||
// gzip解压失败,记录错误
|
||||
log.error("响应gzip解压或JSON解析失败: {}", e.getMessage());
|
||||
fail("响应gzip解压或JSON解析失败: {}", e.getMessage());
|
||||
} else {
|
||||
log.error("解析失败: json格式异常: {}", res.bodyAsString());
|
||||
fail("解析失败: json格式异常: {}", res.bodyAsString());
|
||||
//throw new RuntimeException("解析失败: json格式异常");
|
||||
// 上游响应体可能来自内网探测目标,仅写日志,避免经 HTTP 500 回传给调用方
|
||||
String bodyPreview = responseBodyPreview(res);
|
||||
log.error("解析失败: json格式异常: {}", bodyPreview);
|
||||
fail("解析失败: json格式异常");
|
||||
}
|
||||
return JsonObject.of();
|
||||
}
|
||||
@@ -242,7 +415,18 @@ public abstract class PanBase implements IPanTool {
|
||||
protected void completeWithMeta(String url, Map<String, String> headers) {
|
||||
shareLinkInfo.getOtherParam().put("downloadUrl", url);
|
||||
if (headers != null && !headers.isEmpty()) {
|
||||
shareLinkInfo.getOtherParam().put("downloadHeaders", headers);
|
||||
// 过滤 null/空值,避免 cookie:null 覆盖入口参数或污染 curl 命令
|
||||
Map<String, String> clean = new HashMap<>();
|
||||
headers.forEach((k, v) -> {
|
||||
if (k != null && v != null && !v.isBlank()) {
|
||||
clean.put(k, v);
|
||||
}
|
||||
});
|
||||
if (!clean.isEmpty()) {
|
||||
shareLinkInfo.getOtherParam().put("downloadHeaders", clean);
|
||||
// UC/夸克等需带 cookie 的直链,标记前端走下载器
|
||||
shareLinkInfo.getOtherParam().put("needDownloader", true);
|
||||
}
|
||||
}
|
||||
promise.complete(url);
|
||||
}
|
||||
@@ -275,11 +459,15 @@ public abstract class PanBase implements IPanTool {
|
||||
if (iterator.hasNext()) {
|
||||
PanDomainTemplate next = iterator.next();
|
||||
log.debug("规则不匹配, 处理解析器转发: {} -> {}", shareLinkInfo.getPanName(), next.getDisplayName());
|
||||
ParserCreate.fromType(next.name())
|
||||
.fromAnyShareUrl(shareLinkInfo.getShareUrl())
|
||||
.createTool()
|
||||
.parse()
|
||||
.onComplete(promise);
|
||||
try {
|
||||
IPanTool nextTool = ParserCreate.fromType(next.name())
|
||||
.fromAnyShareUrl(shareLinkInfo.getShareUrl())
|
||||
.createTool();
|
||||
IPanTool.closeAfter(nextTool, nextTool::parse)
|
||||
.onComplete(promise);
|
||||
} catch (Exception e) {
|
||||
fail(e, "转发到下一个解析器失败: {}", next.getDisplayName());
|
||||
}
|
||||
} else {
|
||||
fail("error: 没有下一个解析处理器");
|
||||
}
|
||||
@@ -295,6 +483,12 @@ public abstract class PanBase implements IPanTool {
|
||||
* @throws IOException IOException
|
||||
*/
|
||||
private String decompressGzip(Buffer compressedData) throws IOException {
|
||||
if (compressedData == null) {
|
||||
return "";
|
||||
}
|
||||
if (compressedData.length() > MAX_COMPRESSED_RESPONSE_BYTES) {
|
||||
throw new IOException("gzip响应体过大: " + compressedData.length() + " bytes");
|
||||
}
|
||||
try (ByteArrayInputStream bais = new ByteArrayInputStream(compressedData.getBytes());
|
||||
GZIPInputStream gzis = new GZIPInputStream(bais);
|
||||
InputStreamReader isr = new InputStreamReader(gzis, StandardCharsets.UTF_8);
|
||||
@@ -303,18 +497,97 @@ public abstract class PanBase implements IPanTool {
|
||||
char[] buffer = new char[4096];
|
||||
int n;
|
||||
while ((n = isr.read(buffer)) != -1) {
|
||||
writer.write(buffer, 0, n);
|
||||
writeLimited(writer, buffer, n);
|
||||
}
|
||||
return writer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
private void writeLimited(StringWriter writer, char[] buffer, int len) throws IOException {
|
||||
if (writer.getBuffer().length() + len > MAX_DECOMPRESSED_RESPONSE_CHARS) {
|
||||
throw new IOException("gzip解压后响应体过大");
|
||||
}
|
||||
writer.write(buffer, 0, len);
|
||||
}
|
||||
|
||||
private String responseBodyPreview(HttpResponse<?> res) {
|
||||
if (res == null || res.body() == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
if (res.body() instanceof Buffer body) {
|
||||
int length = Math.min(body.length(), MAX_ERROR_BODY_CHARS);
|
||||
String preview = new String(body.getBytes(0, length), StandardCharsets.UTF_8);
|
||||
return body.length() > length ? preview + "...(truncated " + body.length() + " bytes)" : preview;
|
||||
}
|
||||
String text = res.bodyAsString();
|
||||
if (text == null || text.length() <= MAX_ERROR_BODY_CHARS) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, MAX_ERROR_BODY_CHARS) + "...(truncated " + text.length() + " chars)";
|
||||
} catch (Exception e) {
|
||||
return "<body preview failed: " + e.getMessage() + ">";
|
||||
}
|
||||
}
|
||||
|
||||
protected String getDomainName(){
|
||||
return shareLinkInfo.getOtherParam().getOrDefault("domainName", "").toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将入口请求中的加密 auth 透传到子目录/下载链接,避免进入子目录后丢失认证。
|
||||
* otherParam 中的 key 为 {@code _authQuery}(由 web 层写入)。
|
||||
*/
|
||||
protected String appendAuthQuery(String url) {
|
||||
if (StringUtils.isBlank(url) || shareLinkInfo == null || shareLinkInfo.getOtherParam() == null) {
|
||||
return url;
|
||||
}
|
||||
Object authObj = shareLinkInfo.getOtherParam().get("_authQuery");
|
||||
if (authObj == null) {
|
||||
return url;
|
||||
}
|
||||
String auth = authObj.toString();
|
||||
if (StringUtils.isBlank(auth)) {
|
||||
return url;
|
||||
}
|
||||
// 已带 auth 则不再追加
|
||||
if (url.contains("auth=")) {
|
||||
return url;
|
||||
}
|
||||
try {
|
||||
String encoded = java.net.URLEncoder.encode(auth, StandardCharsets.UTF_8);
|
||||
return url + (url.contains("?") ? "&" : "?") + "auth=" + encoded;
|
||||
} catch (Exception e) {
|
||||
return url + (url.contains("?") ? "&" : "?") + "auth=" + auth;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ShareLinkInfo getShareLinkInfo() {
|
||||
return shareLinkInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭代理模式下创建的 WebClient 资源
|
||||
* 非代理模式使用共享实例,不需要关闭
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
if (isProxyMode) {
|
||||
try {
|
||||
if (proxyClient != null) {
|
||||
proxyClient.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("关闭代理 WebClient 失败: {}", e.getMessage());
|
||||
}
|
||||
try {
|
||||
if (proxyClientNoRedirects != null) {
|
||||
proxyClientNoRedirects.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("关闭代理 WebClientNoRedirects 失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,43 +68,44 @@ public enum PanDomainTemplate {
|
||||
t-is.cn
|
||||
*/
|
||||
LZ("蓝奏云",
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?(" +
|
||||
"lanzoul|" +
|
||||
"lanzouh|" +
|
||||
"lanosso|" +
|
||||
"lanpv|" +
|
||||
"bakstotre|" +
|
||||
"lanzouo|" +
|
||||
"lanzov|" +
|
||||
"lanpw|" +
|
||||
"ulanzou|" +
|
||||
"lanzouf|" +
|
||||
"lanzn|" +
|
||||
"lanzouj|" +
|
||||
"lanzouk|" +
|
||||
"lanzouq|" +
|
||||
"lanzouv|" +
|
||||
"lanzoue|" +
|
||||
"lanzouw|" +
|
||||
"lanzoub|" +
|
||||
"lanzouu|" +
|
||||
"lanwp|" +
|
||||
"lanzouy|" +
|
||||
"lanzoup|" +
|
||||
"woozooo|" +
|
||||
"lanzv|" +
|
||||
"dmpdmp|" +
|
||||
"lanrar|" +
|
||||
"webgetstore|" +
|
||||
"lanzb|" +
|
||||
"lanzoux|" +
|
||||
"lanzout|" +
|
||||
"lanzouc|" +
|
||||
"lanzoui|" +
|
||||
"lanzoug|" +
|
||||
"lanzoum" +
|
||||
")\\.com/(.+/)?(?<KEY>.+)"),
|
||||
"https://lanzoux.com/{shareKey}",
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?(?:" +
|
||||
"(?:lanzoul|" +
|
||||
"lanzouh|" +
|
||||
"lanosso|" +
|
||||
"lanpv|" +
|
||||
"bakstotre|" +
|
||||
"lanzouo|" +
|
||||
"lanzov|" +
|
||||
"lanpw|" +
|
||||
"ulanzou|" +
|
||||
"lanzouf|" +
|
||||
"lanzn|" +
|
||||
"lanzouj|" +
|
||||
"lanzouk|" +
|
||||
"lanzouq|" +
|
||||
"lanzouv|" +
|
||||
"lanzoue|" +
|
||||
"lanzouw|" +
|
||||
"lanzoub|" +
|
||||
"lanzouu|" +
|
||||
"lanwp|" +
|
||||
"lanzouy|" +
|
||||
"lanzoup|" +
|
||||
"woozooo|" +
|
||||
"lanzv|" +
|
||||
"dmpdmp|" +
|
||||
"lanrar|" +
|
||||
"webgetstore|" +
|
||||
"lanzb|" +
|
||||
"lanzoux|" +
|
||||
"lanzout|" +
|
||||
"lanzouc|" +
|
||||
"lanzoui|" +
|
||||
"lanzoug|" +
|
||||
"lanzoum)\\.com" +
|
||||
"|t-is\\.cn" +
|
||||
")/(?<KEY>.+)"),
|
||||
"https://w1.lanzn.com/{shareKey}",
|
||||
LzTool.class),
|
||||
|
||||
// https://www.feijix.com/s/
|
||||
@@ -114,15 +115,15 @@ public enum PanDomainTemplate {
|
||||
"https://www.feijix.com/s/{shareKey}",
|
||||
FjTool.class),
|
||||
|
||||
// https://lecloud.lenovo.com/share/
|
||||
LE("联想乐云",
|
||||
compile("https://lecloud?\\.lenovo\\.com/share/(?<KEY>.+)"),
|
||||
// https://lecloud.lenovo.com/share/ https://lecloud.lenovo.com/mshare/
|
||||
LE("联想乐云",
|
||||
compile("https://lecloud\\.lenovo\\.com/m?share/(?<KEY>.+)"),
|
||||
"https://lecloud.lenovo.com/share/{shareKey}",
|
||||
LeTool.class),
|
||||
|
||||
// https://v2.fangcloud.com/s/
|
||||
FC("亿方云",
|
||||
compile("https://v2\\.fangcloud\\.(com|cn)/(s|sharing)/(?<KEY>.+)"),
|
||||
compile("https://v2\\.fangcloud\\.(com|cn)/(s|share|sharing)/(?<KEY>.+)"),
|
||||
"https://v2.fangcloud.com/s/{shareKey}",
|
||||
"https://www.fangcloud.com/",
|
||||
FcTool.class),
|
||||
@@ -143,9 +144,41 @@ public enum PanDomainTemplate {
|
||||
compile("https://qfile\\.qq\\.com/q/(?<KEY>.+)"),
|
||||
"https://qfile.qq.com/q/{shareKey}",
|
||||
QQscTool.class),
|
||||
// https://f.ws59.cn/f/或者https://www.wenshushu.cn/f/
|
||||
// https://f.ws59.cn/f/ 或者 https://www.wenshushu.cn/f/ 等多个镜像域名
|
||||
/*
|
||||
f.wsNN.cn (如 f.ws59.cn, f.ws28.cn 等)
|
||||
www.wenshushu.cn
|
||||
新增域名:
|
||||
www.wenxiaozhan.net
|
||||
www.wenxiaozhan.cn
|
||||
www.wss.show
|
||||
www.ws28.cn
|
||||
www.wss.email
|
||||
www.wss1.cn
|
||||
www.ws59.cn
|
||||
www.wss.cc
|
||||
www.wss.pet
|
||||
www.wss.ink
|
||||
www.wenxiaozhan.com
|
||||
www.wenshushu.com
|
||||
www.wss.zone
|
||||
*/
|
||||
WS("文叔叔",
|
||||
compile("https://(f\\.ws(\\d{2})\\.cn|www\\.wenshushu\\.cn)/f/(?<KEY>.+)"),
|
||||
compile("https://(f\\.ws(\\d{2})\\.cn|" +
|
||||
"www\\.wenxiaozhan\\.net|" +
|
||||
"www\\.wenxiaozhan\\.cn|" +
|
||||
"www\\.wss\\.show|" +
|
||||
"www\\.ws28\\.cn|" +
|
||||
"www\\.wss\\.email|" +
|
||||
"www\\.wss1\\.cn|" +
|
||||
"www\\.ws59\\.cn|" +
|
||||
"www\\.wss\\.cc|" +
|
||||
"www\\.wss\\.pet|" +
|
||||
"www\\.wss\\.ink|" +
|
||||
"www\\.wenxiaozhan\\.com|" +
|
||||
"www\\.wenshushu\\.com|" +
|
||||
"www\\.wss\\.zone|" +
|
||||
"www\\.wenshushu\\.cn)/f/(?<KEY>.+)"),
|
||||
"https://www.wenshushu.cn/f/{shareKey}",
|
||||
WsTool.class),
|
||||
// https://www.123pan.com/s/
|
||||
@@ -175,7 +208,7 @@ public enum PanDomainTemplate {
|
||||
123795.com
|
||||
*/
|
||||
YE("123网盘",
|
||||
compile("https://www\\.(" +
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)*(" +
|
||||
"123254\\.com|" +
|
||||
"123957\\.com|" +
|
||||
"123295\\.com|" +
|
||||
@@ -199,7 +232,7 @@ public enum PanDomainTemplate {
|
||||
"123635\\.com|" +
|
||||
"123242\\.com|" +
|
||||
"123795\\.com" +
|
||||
")/s/(?<KEY>.+)(.html)?"),
|
||||
")/(?:(?:s|123pan)/|(?:[^/?#]+/)+)?(?<KEY>[a-zA-Z0-9]+-[a-zA-Z0-9]+|[a-zA-Z0-9_-]+)(?:\\.html)?(?:\\?.*)?"),
|
||||
"https://www.123pan.com/s/{shareKey}",
|
||||
Ye2Tool.class),
|
||||
// https://www.ecpan.cn/web/#/yunpanProxy?path=%2F%23%2Fdrive%2Foutside&data={code}&isShare=1
|
||||
@@ -210,19 +243,20 @@ public enum PanDomainTemplate {
|
||||
EcTool.class),
|
||||
// https://cowtransfer.com/s/
|
||||
COW("奶牛快传",
|
||||
compile("https://(.*)cowtransfer\\.com/s/(?<KEY>.+)"),
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?cowtransfer\\.com/s/(?<KEY>.+)"),
|
||||
"https://cowtransfer.com/s/{shareKey}",
|
||||
CowTool.class),
|
||||
CT("城通网盘",
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/f(ile)?/" +
|
||||
"(?<KEY>[0-9a-zA-Z_-]+)(\\?p=(?<PWD>\\w+))?"),
|
||||
"https://474b.com/file/{shareKey}",
|
||||
compile("https?://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/f(ile)?/" +
|
||||
"(?<KEY>[0-9a-zA-Z_-]+)/?(?:\\?(?:(?:[^#&]*&)*p=(?<PWD>\\w+)(?:&[^#]*)?|[^#]*))?"),
|
||||
"https://ctfile.com/file/{shareKey}",
|
||||
CtTool.class),
|
||||
// https://url94.ctfile.com/d/64115194-164803691-48508c?p=7609&d=164803691&fk=decb36
|
||||
CTD("城通网盘-目录",
|
||||
compile("https?://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/d/" +
|
||||
"(?<KEY>[0-9a-zA-Z_-]+)/?(?:\\?(?:(?:[^#&]*&)*p=(?<PWD>\\w+)(?:&[^#]*)?|[^#]*))?"),
|
||||
"https://ctfile.com/d/{shareKey}",
|
||||
CtTool.class),
|
||||
// https://xxx.118pan.com/bxxx
|
||||
P118("118网盘",
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?118pan\\.com/b(?<KEY>.+)"),
|
||||
"https://qaiu.118pan.com/b{shareKey}",
|
||||
P118Tool.class),
|
||||
// https://www.vyuyun.com/s/QMa6ie?password=I4KG7H
|
||||
// https://www.vyuyun.com/s/QMa6ie/file?password=I4KG7H
|
||||
PVYY("微雨云存储",
|
||||
@@ -238,7 +272,7 @@ public enum PanDomainTemplate {
|
||||
PodTool.class),
|
||||
// 404网盘 https://drive.google.com/file/d/xxx/view?usp=sharing
|
||||
PGD("GoogleDrive",
|
||||
compile("https://drive\\.google\\.com/file/d/(?<KEY>.+)/view(\\?usp=(sharing|drive_link))?"),
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?drive\\.google\\.com/file/d/(?<KEY>.+)/view(\\?usp=(sharing|drive_link))?"),
|
||||
"https://drive.google.com/file/d/{shareKey}/view?usp=sharing",
|
||||
PgdTool.class),
|
||||
// iCloud https://www.icloud.com.cn/iclouddrive/xxx#fonts
|
||||
@@ -248,11 +282,11 @@ public enum PanDomainTemplate {
|
||||
PicTool.class),
|
||||
// https://www.dropbox.com/scl/fi/cwnbms1yn8u6rcatzyta7/emqx-5.0.26-el7-amd64.tar.gz?rlkey=3uoi4bxz5mv93jmlaws0nlol1&e=8&st=fe0lclc2&dl=0
|
||||
PDB("dropbox",
|
||||
compile("https://www.dropbox.com/scl/fi/(?<KEY>\\w+)/.+?rlkey=(?<PWD>\\w+).*"),
|
||||
compile("https://www\\.dropbox\\.com/scl/fi/(?<KEY>\\w+)/.+?rlkey=(?<PWD>\\w+).*"),
|
||||
"https://www.dropbox.com/scl/fi/{shareKey}/?rlkey={pwd}&dl=0",
|
||||
PdbTool.class),
|
||||
P115("115网盘",
|
||||
compile("https://(115|anxia).com/s/(?<KEY>\\w+)(\\?password=(?<PWD>\\w+))?([&#].*)?"),
|
||||
compile("https://(115|anxia)\\.com/s/(?<KEY>\\w+)(\\?password=(?<PWD>\\w+))?([&#].*)?"),
|
||||
"https://115.com/s/{shareKey}?password={pwd}",
|
||||
P115Tool.class),
|
||||
// 链接:https://www.yunpan.com/surl_yD7wz4VgU9v(提取码:fc70)
|
||||
@@ -263,7 +297,7 @@ public enum PanDomainTemplate {
|
||||
|
||||
// https://pan-yz.cldisk.com/external/m/file/953658049102462976
|
||||
Pcx("超星云盘(需要referer头)",
|
||||
compile("https://pan-yz\\.cldisk\\.com/external/m/file/(?<KEY>\\w+)"),
|
||||
compile("https://pan-yz\\.(chaoxing\\.com|cldisk\\.com)/external/m/file/(?<KEY>\\w+)(\\?.*)?"),
|
||||
"https://pan-yz.cldisk.com/external/m/file/{shareKey}",
|
||||
PcxTool.class),
|
||||
// WPS:分享格式:https://www.kdocs.cn/l/ck0azivLlDi3 ;API格式:https://www.kdocs.cn/api/office/file/{shareKey}/download
|
||||
@@ -272,6 +306,27 @@ public enum PanDomainTemplate {
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?kdocs\\.cn/l/(?<KEY>.+)"),
|
||||
"https://www.kdocs.cn/l/{shareKey}",
|
||||
PwpsTool.class),
|
||||
|
||||
// https://fast.uc.cn/s/33197dd53ace4
|
||||
// https://drive.uc.cn/s/e623b6da278e4?public=1#/list/share
|
||||
UC("UC网盘",
|
||||
compile("https://(fast|drive)\\.uc\\.cn/s/(?<KEY>\\w+)(\\?public=\\d+)?([&#].*)?"),
|
||||
"https://drive.uc.cn/s/{shareKey}",
|
||||
UcTool.class),
|
||||
// https://pan.quark.cn/s/6a325cdaec58
|
||||
QK("夸克网盘",
|
||||
compile("https://pan\\.quark\\.cn/s/(?<KEY>\\w+)([&#].*)?"),
|
||||
"https://pan.quark.cn/s/{shareKey}",
|
||||
QkTool.class),
|
||||
|
||||
// https://xxx.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc
|
||||
// https://xxx.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg
|
||||
FS("飞书云盘",
|
||||
compile("https://[^.]+\\.feishu\\.cn/(?:file|drive/folder)/(?<KEY>[A-Za-z0-9_-]+)(\\?.*)?"),
|
||||
"https://feishu.cn/file/{shareKey}",
|
||||
"https://www.feishu.cn/",
|
||||
FsTool.class),
|
||||
|
||||
// =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)==========================
|
||||
// http://163cn.tv/xxx
|
||||
MNES("网易云音乐分享",
|
||||
@@ -280,7 +335,7 @@ public enum PanDomainTemplate {
|
||||
MnesTool.class),
|
||||
// https://music.163.com/#/song?id=xxx
|
||||
MNE("网易云音乐歌曲详情",
|
||||
compile("https://(y.)?music\\.163\\.com/(#|m/)?song\\?id=(?<KEY>.+)(&.*)?"),
|
||||
compile("https://(y\\.)?music\\.163\\.com/(?:#/|m/)?song\\?id=(?<KEY>.+)(&.*)?"),
|
||||
"https://music.163.com/#/song?id={shareKey}",
|
||||
MnesTool.MneTool.class),
|
||||
// https://c6.y.qq.com/base/fcgi-bin/u?__=xxx
|
||||
@@ -301,7 +356,7 @@ public enum PanDomainTemplate {
|
||||
MkgsTool.class),
|
||||
// https://www.kugou.com/share/2bi8Fe9CSV3.html?id=2bi8Fe9CSV3#6ed9gna4"
|
||||
MKGS2("酷狗音乐分享2",
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?kugou\\.com/share/(?<KEY>.+).html.*"),
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?kugou\\.com/share/(?<KEY>.+)\\.html.*"),
|
||||
"https://www.kugou.com/share/{shareKey}.html",
|
||||
MkgsTool.Mkgs2Tool.class),
|
||||
// https://www.kugou.com/mixsong/2bi8Fe9CSV3
|
||||
@@ -322,6 +377,16 @@ public enum PanDomainTemplate {
|
||||
MmgTool.class),
|
||||
// =====================私有盘解析==========================
|
||||
|
||||
// 永硕E盘空间分享:https://qaiu.ysepan.com/ (空间名即 shareKey,密码为空间访问密码)
|
||||
// 主域名 ysepan.com / ys168.com;备用 cccpan.com / ysupan.com / uupan.net / ysok.net
|
||||
YS("永硕E盘",
|
||||
compile("https?://(?!(?:www|zy|ht|api|c\\d+|ys-[a-zA-Z0-9]+)\\.)(?<KEY>[a-zA-Z\\d-]+)\\."
|
||||
+ "(?:ysepan\\.com|ys168\\.com|cccpan\\.com|ysupan\\.com|uupan\\.net|ysok\\.net)"
|
||||
+ "/?(?:\\?.*)?"),
|
||||
"https://{shareKey}.ysepan.com/",
|
||||
"https://www.ysepan.com/",
|
||||
YsTool.class),
|
||||
|
||||
// Cloudreve自定义域名解析, 解析器CeTool兜底策略, 即任意域名如果匹配不到对应的规则, 则由CeTool统一处理,
|
||||
// 如果不属于Cloudreve盘 则调用下一个自定义域名解析器, 若都处理不了则抛出异常, 这种匹配模式类似责任链
|
||||
// http(s)://pan.huang1111.cn/s/xxx
|
||||
|
||||
@@ -4,12 +4,13 @@ import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.custom.CustomParserConfig;
|
||||
import cn.qaiu.parser.custom.CustomParserRegistry;
|
||||
import cn.qaiu.parser.customjs.JsParserExecutor;
|
||||
import cn.qaiu.parser.custompy.PyParserExecutor;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
import static cn.qaiu.parser.PanDomainTemplate.KEY;
|
||||
@@ -24,6 +25,9 @@ import static cn.qaiu.parser.PanDomainTemplate.PWD;
|
||||
* Create at 2024/9/15 14:10
|
||||
*/
|
||||
public class ParserCreate {
|
||||
private static final Set<PanDomainTemplate> GENERIC_BUILT_IN_PARSERS =
|
||||
EnumSet.of(PanDomainTemplate.CE, PanDomainTemplate.KD, PanDomainTemplate.OTHER);
|
||||
|
||||
private final PanDomainTemplate panDomainTemplate;
|
||||
private final ShareLinkInfo shareLinkInfo;
|
||||
|
||||
@@ -82,16 +86,16 @@ public class ParserCreate {
|
||||
if (shareKey != null) {
|
||||
shareLinkInfo.setShareKey(shareKey);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
} catch (IllegalStateException | IllegalArgumentException ignored) {}
|
||||
|
||||
// 提取密码
|
||||
try {
|
||||
String pwd = matcher.group("PWD");
|
||||
if (StringUtils.isNotEmpty(pwd)) {
|
||||
shareLinkInfo.setSharePassword(pwd);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
} catch (IllegalStateException | IllegalArgumentException ignored) {}
|
||||
|
||||
// 设置标准URL
|
||||
if (customParserConfig.getStandardUrlTemplate() != null) {
|
||||
String standardUrl = customParserConfig.getStandardUrlTemplate()
|
||||
@@ -133,12 +137,12 @@ public class ParserCreate {
|
||||
if (StringUtils.isNotEmpty(pwd)) {
|
||||
shareLinkInfo.setSharePassword(pwd);
|
||||
}
|
||||
standardUrl = standardUrl.replace("{pwd}", pwd);
|
||||
} catch (Exception ignored) {}
|
||||
standardUrl = standardUrl.replace("{pwd}", StringUtils.defaultString(pwd));
|
||||
} catch (IllegalStateException | IllegalArgumentException ignored) {}
|
||||
|
||||
shareLinkInfo.setShareUrl(shareUrl);
|
||||
shareLinkInfo.setShareKey(shareKey);
|
||||
if (!(panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal())) {
|
||||
if (!isGenericBuiltInParser(panDomainTemplate)) {
|
||||
shareLinkInfo.setStandardUrl(standardUrl);
|
||||
}
|
||||
return this;
|
||||
@@ -156,9 +160,6 @@ public class ParserCreate {
|
||||
// 检查是否为JavaScript解析器
|
||||
if (customParserConfig.isJsParser()) {
|
||||
return new JsParserExecutor(shareLinkInfo, customParserConfig);
|
||||
} else if (customParserConfig.isPyParser()) {
|
||||
// Python解析器
|
||||
return new PyParserExecutor(shareLinkInfo, customParserConfig);
|
||||
} else {
|
||||
// Java实现的解析器
|
||||
try {
|
||||
@@ -201,7 +202,7 @@ public class ParserCreate {
|
||||
}
|
||||
|
||||
// 内置解析器处理
|
||||
if (panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal()) {
|
||||
if (isGenericBuiltInParser(panDomainTemplate)) {
|
||||
// 处理Cloudreve(ce)类: pan.huang1111.cn_s_wDz5TK _ -> /
|
||||
String[] s = shareKey.split("_");
|
||||
String standardUrl = "https://" + String.join("/", s);
|
||||
@@ -251,9 +252,19 @@ public class ParserCreate {
|
||||
return this;
|
||||
}
|
||||
|
||||
// 根据分享链接获取PanDomainTemplate实例(优先匹配自定义解析器)
|
||||
// 根据分享链接获取PanDomainTemplate实例
|
||||
public synchronized static ParserCreate fromShareUrl(String shareUrl) {
|
||||
// 优先查找支持正则匹配的自定义解析器
|
||||
if (StringUtils.isBlank(shareUrl)) {
|
||||
throw new IllegalArgumentException("shareUrl不能为空");
|
||||
}
|
||||
shareUrl = shareUrl.trim();
|
||||
|
||||
ParserCreate builtInParser = fromBuiltInShareUrl(shareUrl, false);
|
||||
if (builtInParser != null) {
|
||||
return builtInParser;
|
||||
}
|
||||
|
||||
// 明确内置解析器未命中时,再查找支持正则匹配的自定义解析器
|
||||
for (CustomParserConfig customConfig : CustomParserRegistry.getAll().values()) {
|
||||
if (customConfig.supportsFromShareUrl()) {
|
||||
Matcher matcher = customConfig.getMatchPattern().matcher(shareUrl);
|
||||
@@ -270,15 +281,15 @@ public class ParserCreate {
|
||||
if (shareKey != null) {
|
||||
shareLinkInfo.setShareKey(shareKey);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
} catch (IllegalStateException | IllegalArgumentException ignored) {}
|
||||
|
||||
try {
|
||||
String password = matcher.group("PWD");
|
||||
if (password != null) {
|
||||
shareLinkInfo.setSharePassword(password);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
} catch (IllegalStateException | IllegalArgumentException ignored) {}
|
||||
|
||||
// 设置标准URL(如果有模板)
|
||||
if (customConfig.getStandardUrlTemplate() != null) {
|
||||
String standardUrl = customConfig.getStandardUrlTemplate()
|
||||
@@ -299,22 +310,35 @@ public class ParserCreate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查找内置解析器
|
||||
|
||||
// 最后再走 Cloudreve/可道云/其他网盘这类泛化兜底,避免抢走自定义解析器
|
||||
builtInParser = fromBuiltInShareUrl(shareUrl, true);
|
||||
if (builtInParser != null) {
|
||||
return builtInParser;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Unsupported share URL");
|
||||
}
|
||||
|
||||
private static ParserCreate fromBuiltInShareUrl(String shareUrl, boolean genericOnly) {
|
||||
for (PanDomainTemplate panDomainTemplate : PanDomainTemplate.values()) {
|
||||
boolean genericParser = isGenericBuiltInParser(panDomainTemplate);
|
||||
if (genericOnly != genericParser) {
|
||||
continue;
|
||||
}
|
||||
if (panDomainTemplate.getPattern().matcher(shareUrl).matches()) {
|
||||
ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder()
|
||||
.type(panDomainTemplate.name().toLowerCase())
|
||||
.panName(panDomainTemplate.getDisplayName())
|
||||
.shareUrl(shareUrl).build();
|
||||
if (panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal()) {
|
||||
if (isGenericBuiltInParser(panDomainTemplate)) {
|
||||
shareLinkInfo.setStandardUrl(shareUrl);
|
||||
}
|
||||
ParserCreate parserCreate = new ParserCreate(panDomainTemplate, shareLinkInfo);
|
||||
return parserCreate.normalizeShareLink();
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported share URL");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 根据type获取枚举实例(优先查找自定义解析器)
|
||||
@@ -357,7 +381,7 @@ public class ParserCreate {
|
||||
// 自定义解析器处理
|
||||
if (isCustomParser) {
|
||||
path = this.shareLinkInfo.getType() + "/" + this.shareLinkInfo.getShareKey();
|
||||
} else if (panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal()) {
|
||||
} else if (isGenericBuiltInParser(panDomainTemplate)) {
|
||||
// 处理Cloudreve(ce)类: pan.huang1111.cn_s_wDz5TK _ -> /
|
||||
path = this.shareLinkInfo.getType() + "/"
|
||||
+ this.shareLinkInfo.getShareUrl()
|
||||
@@ -385,7 +409,11 @@ public class ParserCreate {
|
||||
public CustomParserConfig getCustomParserConfig() {
|
||||
return customParserConfig;
|
||||
}
|
||||
|
||||
|
||||
private static boolean isGenericBuiltInParser(PanDomainTemplate panDomainTemplate) {
|
||||
return GENERIC_BUILT_IN_PARSERS.contains(panDomainTemplate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内置解析器模板(仅当isCustomParser为false时有效)
|
||||
* @return 内置解析器模板,如果是自定义解析器则返回null
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package cn.qaiu.parser;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 解析器 Token/Cookie 缓存 — 支持多账号隔离。
|
||||
* <p>
|
||||
* 以 (diskType + "#" + accountKey) 作为缓存 key,不同账号的 token 互不覆盖。
|
||||
* accountKey 优先使用 _configId,其次使用 username、cookie 前16位等可区分标识。
|
||||
* </p>
|
||||
*/
|
||||
public final class TokenCache {
|
||||
|
||||
private TokenCache() {}
|
||||
|
||||
/** token 缓存 */
|
||||
private static final ConcurrentHashMap<String, String> tokenMap = new ConcurrentHashMap<>();
|
||||
/** 过期时间缓存(毫秒时间戳) */
|
||||
private static final ConcurrentHashMap<String, Long> expireMap = new ConcurrentHashMap<>();
|
||||
/** 同一 key 下的额外字符串缓存(如 userId) */
|
||||
private static final ConcurrentHashMap<String, String> extraMap = new ConcurrentHashMap<>();
|
||||
/** 布尔标记缓存(如 authFlag) */
|
||||
private static final ConcurrentHashMap<String, Boolean> flagMap = new ConcurrentHashMap<>();
|
||||
|
||||
// ============ key 构造 ============
|
||||
|
||||
public static String key(String diskType, String accountKey) {
|
||||
return diskType + "#" + (accountKey == null ? "_default" : accountKey);
|
||||
}
|
||||
|
||||
// ============ token ============
|
||||
|
||||
public static String getToken(String cacheKey) {
|
||||
return tokenMap.get(cacheKey);
|
||||
}
|
||||
|
||||
public static void putToken(String cacheKey, String token) {
|
||||
if (token == null) {
|
||||
tokenMap.remove(cacheKey);
|
||||
} else {
|
||||
tokenMap.put(cacheKey, token);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ expire ============
|
||||
|
||||
public static long getExpire(String cacheKey) {
|
||||
return expireMap.getOrDefault(cacheKey, 0L);
|
||||
}
|
||||
|
||||
public static void putExpire(String cacheKey, long expireMs) {
|
||||
expireMap.put(cacheKey, expireMs);
|
||||
}
|
||||
|
||||
public static boolean isExpired(String cacheKey) {
|
||||
long exp = getExpire(cacheKey);
|
||||
return exp <= 0 || System.currentTimeMillis() > exp;
|
||||
}
|
||||
|
||||
// ============ extra (userId 等) ============
|
||||
|
||||
public static String getExtra(String cacheKey) {
|
||||
return extraMap.get(cacheKey);
|
||||
}
|
||||
|
||||
public static void putExtra(String cacheKey, String value) {
|
||||
if (value == null) {
|
||||
extraMap.remove(cacheKey);
|
||||
} else {
|
||||
extraMap.put(cacheKey, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ flag (authFlag 等) ============
|
||||
|
||||
public static boolean getFlag(String cacheKey, boolean defaultValue) {
|
||||
return flagMap.getOrDefault(cacheKey, defaultValue);
|
||||
}
|
||||
|
||||
public static void putFlag(String cacheKey, boolean value) {
|
||||
flagMap.put(cacheKey, value);
|
||||
}
|
||||
|
||||
// ============ 清除 ============
|
||||
|
||||
public static void remove(String cacheKey) {
|
||||
tokenMap.remove(cacheKey);
|
||||
expireMap.remove(cacheKey);
|
||||
extraMap.remove(cacheKey);
|
||||
flagMap.remove(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定网盘类型的所有缓存(精准清除,不影响其他网盘类型)
|
||||
*/
|
||||
public static void removeByDiskType(String diskType) {
|
||||
String prefix = diskType + "#";
|
||||
tokenMap.keySet().removeIf(k -> k.startsWith(prefix));
|
||||
expireMap.keySet().removeIf(k -> k.startsWith(prefix));
|
||||
extraMap.keySet().removeIf(k -> k.startsWith(prefix));
|
||||
flagMap.keySet().removeIf(k -> k.startsWith(prefix));
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
tokenMap.clear();
|
||||
expireMap.clear();
|
||||
extraMap.clear();
|
||||
flagMap.clear();
|
||||
}
|
||||
|
||||
// ============ Token 持久化队列 ============
|
||||
|
||||
/** 待持久化的 cachedToken 数据 (cacheKey -> [token, expireMs]) */
|
||||
private static final ConcurrentHashMap<String, String[]> persistQueue = new ConcurrentHashMap<>();
|
||||
/** 待回写的凭据更新 (cacheKey -> newCredential),如 PaliTool refresh_token 轮换 */
|
||||
private static final ConcurrentHashMap<String, String> credentialUpdateQueue = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 解析器登录成功后,将 token 加入持久化队列(下次 recordConfigUsage 回写 DB)
|
||||
*/
|
||||
public static void queueCachedTokenPersist(String cacheKey, String token, long expireMs) {
|
||||
if (cacheKey != null && token != null) {
|
||||
persistQueue.put(cacheKey, new String[]{token, String.valueOf(expireMs)});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 凭据本身被替换(如 PaliTool refresh_token 轮换),加入回写队列
|
||||
*/
|
||||
public static void queueCredentialUpdate(String cacheKey, String newCredential) {
|
||||
if (cacheKey != null && newCredential != null) {
|
||||
credentialUpdateQueue.put(cacheKey, newCredential);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费持久化队列:返回 [token, expireMs] 并移除;无数据返回 null
|
||||
*/
|
||||
public static String[] pollCachedTokenPersist(String cacheKey) {
|
||||
return cacheKey == null ? null : persistQueue.remove(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费凭据更新队列:返回新凭据并移除;无数据返回 null
|
||||
*/
|
||||
public static String pollCredentialUpdate(String cacheKey) {
|
||||
return cacheKey == null ? null : credentialUpdateQueue.remove(cacheKey);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,13 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 客户端下载链接生成器工厂类
|
||||
* <p>
|
||||
* 支持的客户端类型:
|
||||
* <ul>
|
||||
* <li>CURL - cURL 命令,支持 Cookie</li>
|
||||
* <li>ARIA2 - Aria2 命令,支持 Cookie</li>
|
||||
* <li>THUNDER - 迅雷协议,不支持 Cookie</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
@@ -25,16 +32,10 @@ public class ClientLinkGeneratorFactory {
|
||||
// 静态初始化块,注册默认的生成器
|
||||
static {
|
||||
try {
|
||||
// 注册默认生成器 - 按指定顺序注册
|
||||
register(new Aria2LinkGenerator());
|
||||
register(new MotrixLinkGenerator());
|
||||
register(new BitCometLinkGenerator());
|
||||
register(new ThunderLinkGenerator());
|
||||
register(new WgetLinkGenerator());
|
||||
register(new CurlLinkGenerator());
|
||||
register(new IdmLinkGenerator());
|
||||
register(new FdmLinkGenerator());
|
||||
register(new PowerShellLinkGenerator());
|
||||
// 注册默认生成器 - 只保留3种(按需求)
|
||||
register(new CurlLinkGenerator()); // cURL 命令,支持 Cookie
|
||||
register(new Aria2LinkGenerator()); // Aria2 命令,支持 Cookie
|
||||
register(new ThunderLinkGenerator()); // 迅雷协议,不支持 Cookie
|
||||
|
||||
log.info("客户端链接生成器工厂初始化完成,已注册 {} 个生成器", generators.size());
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -2,27 +2,32 @@ package cn.qaiu.parser.clientlink;
|
||||
|
||||
/**
|
||||
* 客户端下载工具类型枚举
|
||||
* <p>
|
||||
* 支持的客户端类型:
|
||||
* <ul>
|
||||
* <li>CURL - cURL 命令行工具,支持 Cookie</li>
|
||||
* <li>ARIA2 - 多线程下载器,支持 Cookie</li>
|
||||
* <li>THUNDER - 迅雷下载器,不支持 Cookie(使用迅雷协议)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public enum ClientLinkType {
|
||||
ARIA2("aria2", "Aria2"),
|
||||
MOTRIX("motrix", "Motrix"),
|
||||
BITCOMET("bitcomet", "比特彗星"),
|
||||
THUNDER("thunder", "迅雷"),
|
||||
WGET("wget", "wget 命令"),
|
||||
CURL("curl", "cURL 命令"),
|
||||
IDM("idm", "IDM"),
|
||||
FDM("fdm", "Free Download Manager"),
|
||||
POWERSHELL("powershell", "PowerShell");
|
||||
CURL("curl", "cURL 命令", true, "命令行下载工具,支持Cookie"),
|
||||
ARIA2("aria2", "Aria2", true, "多线程下载器,支持Cookie"),
|
||||
THUNDER("thunder", "迅雷", false, "迅雷下载器,不支持Cookie");
|
||||
|
||||
private final String code;
|
||||
private final String displayName;
|
||||
private final boolean supportsCookie;
|
||||
private final String description;
|
||||
|
||||
ClientLinkType(String code, String displayName) {
|
||||
ClientLinkType(String code, String displayName, boolean supportsCookie, String description) {
|
||||
this.code = code;
|
||||
this.displayName = displayName;
|
||||
this.supportsCookie = supportsCookie;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
@@ -33,6 +38,14 @@ public enum ClientLinkType {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public boolean isSupportsCookie() {
|
||||
return supportsCookie;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return displayName;
|
||||
|
||||
@@ -7,6 +7,13 @@ import java.util.Map;
|
||||
/**
|
||||
* 客户端下载链接生成工具类
|
||||
* 提供便捷的静态方法来生成各种客户端下载链接
|
||||
* <p>
|
||||
* 支持的客户端类型:
|
||||
* <ul>
|
||||
* <li>CURL - cURL 命令,支持 Cookie</li>
|
||||
* <li>ARIA2 - Aria2 命令,支持 Cookie</li>
|
||||
* <li>THUNDER - 迅雷协议,不支持 Cookie</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
@@ -35,7 +42,7 @@ public class ClientLinkUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 curl 命令
|
||||
* 生成 curl 命令(支持 Cookie)
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return curl 命令字符串
|
||||
@@ -45,17 +52,7 @@ public class ClientLinkUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 wget 命令
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return wget 命令字符串
|
||||
*/
|
||||
public static String generateWgetCommand(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.WGET);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 aria2 命令
|
||||
* 生成 aria2 命令(支持 Cookie)
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return aria2 命令字符串
|
||||
@@ -65,7 +62,7 @@ public class ClientLinkUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成迅雷链接
|
||||
* 生成迅雷链接(不支持 Cookie)
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return 迅雷协议链接
|
||||
@@ -74,56 +71,6 @@ public class ClientLinkUtils {
|
||||
return generateClientLink(info, ClientLinkType.THUNDER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 IDM 链接
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return IDM 协议链接
|
||||
*/
|
||||
public static String generateIdmLink(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.IDM);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成比特彗星链接
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return 比特彗星协议链接
|
||||
*/
|
||||
public static String generateBitCometLink(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.BITCOMET);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Motrix 导入格式
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return Motrix JSON 格式字符串
|
||||
*/
|
||||
public static String generateMotrixFormat(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.MOTRIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 FDM 导入格式
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return FDM 格式字符串
|
||||
*/
|
||||
public static String generateFdmFormat(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.FDM);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 PowerShell 命令
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return PowerShell 命令字符串
|
||||
*/
|
||||
public static String generatePowerShellCommand(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.POWERSHELL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 ShareLinkInfo 是否包含有效的下载元数据
|
||||
*
|
||||
|
||||
@@ -41,6 +41,8 @@ public class Aria2LinkGenerator implements ClientLinkGenerator {
|
||||
parts.add("--continue"); // 支持断点续传
|
||||
parts.add("--max-tries=3"); // 最大重试次数
|
||||
parts.add("--retry-wait=5"); // 重试等待时间
|
||||
parts.add("-s 8"); // 分成8片段下载
|
||||
parts.add("-x 8"); // 每个服务器使用8个连接
|
||||
|
||||
// 添加URL
|
||||
parts.add("\"" + meta.getUrl() + "\"");
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 比特彗星协议链接生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class BitCometLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 比特彗星支持 HTTP 下载,格式类似 IDM
|
||||
String encodedUrl = Base64.getEncoder().encodeToString(
|
||||
meta.getUrl().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
StringBuilder link = new StringBuilder("bitcomet:///?url=").append(encodedUrl);
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
StringBuilder headerStr = new StringBuilder();
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
if (headerStr.length() > 0) {
|
||||
headerStr.append("\\r\\n");
|
||||
}
|
||||
headerStr.append(entry.getKey()).append(": ").append(entry.getValue());
|
||||
}
|
||||
|
||||
String encodedHeaders = Base64.getEncoder().encodeToString(
|
||||
headerStr.toString().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
link.append("&header=").append(encodedHeaders);
|
||||
}
|
||||
|
||||
// 添加文件名
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
String encodedFileName = Base64.getEncoder().encodeToString(
|
||||
meta.getFileName().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
link.append("&filename=").append(encodedFileName);
|
||||
}
|
||||
|
||||
return link.toString();
|
||||
|
||||
} catch (Exception e) {
|
||||
// 如果编码失败,返回简单的URL
|
||||
return "bitcomet:///?url=" + meta.getUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.BITCOMET;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Free Download Manager 导入格式生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class FdmLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// FDM 支持简单的文本格式导入
|
||||
StringBuilder result = new StringBuilder();
|
||||
result.append("URL=").append(meta.getUrl()).append("\n");
|
||||
|
||||
// 添加文件名
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
result.append("Filename=").append(meta.getFileName()).append("\n");
|
||||
}
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
result.append("Headers=");
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
if (!first) {
|
||||
result.append("; ");
|
||||
}
|
||||
result.append(entry.getKey()).append(": ").append(entry.getValue());
|
||||
first = false;
|
||||
}
|
||||
result.append("\n");
|
||||
}
|
||||
|
||||
result.append("Referer=").append(meta.getReferer() != null ? meta.getReferer() : "").append("\n");
|
||||
result.append("User-Agent=").append(meta.getUserAgent() != null ? meta.getUserAgent() : "").append("\n");
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.FDM;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IDM 协议链接生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class IdmLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 对URL进行Base64编码
|
||||
String encodedUrl = Base64.getEncoder().encodeToString(
|
||||
meta.getUrl().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
StringBuilder link = new StringBuilder("idm:///?url=").append(encodedUrl);
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
StringBuilder headerStr = new StringBuilder();
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
if (headerStr.length() > 0) {
|
||||
headerStr.append("\\r\\n");
|
||||
}
|
||||
headerStr.append(entry.getKey()).append(": ").append(entry.getValue());
|
||||
}
|
||||
|
||||
String encodedHeaders = Base64.getEncoder().encodeToString(
|
||||
headerStr.toString().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
link.append("&header=").append(encodedHeaders);
|
||||
}
|
||||
|
||||
// 添加文件名
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
String encodedFileName = Base64.getEncoder().encodeToString(
|
||||
meta.getFileName().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
link.append("&filename=").append(encodedFileName);
|
||||
}
|
||||
|
||||
return link.toString();
|
||||
|
||||
} catch (Exception e) {
|
||||
// 如果编码失败,返回简单的URL
|
||||
return "idm:///?url=" + meta.getUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.IDM;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Motrix 导入格式生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class MotrixLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 使用 Vert.x JsonObject 构建 JSON
|
||||
JsonObject taskJson = new JsonObject();
|
||||
taskJson.put("url", meta.getUrl());
|
||||
|
||||
// 添加文件名
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
taskJson.put("filename", meta.getFileName());
|
||||
}
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
JsonObject headersJson = new JsonObject();
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
headersJson.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
taskJson.put("headers", headersJson);
|
||||
}
|
||||
|
||||
// 设置输出文件名
|
||||
String outputFile = meta.getFileName() != null ? meta.getFileName() : "";
|
||||
taskJson.put("out", outputFile);
|
||||
|
||||
return taskJson.encodePrettily();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.MOTRIX;
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* PowerShell 命令生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class PowerShellLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> lines = new ArrayList<>();
|
||||
|
||||
// 创建 WebRequestSession
|
||||
lines.add("$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession");
|
||||
|
||||
// 设置 User-Agent(如果存在)
|
||||
String userAgent = meta.getUserAgent();
|
||||
if (userAgent == null && meta.getHeaders() != null) {
|
||||
userAgent = meta.getHeaders().get("User-Agent");
|
||||
}
|
||||
if (userAgent != null && !userAgent.trim().isEmpty()) {
|
||||
lines.add("$session.UserAgent = \"" + escapePowerShellString(userAgent) + "\"");
|
||||
}
|
||||
|
||||
// 构建 Invoke-WebRequest 命令
|
||||
List<String> invokeParams = new ArrayList<>();
|
||||
invokeParams.add("Invoke-WebRequest");
|
||||
invokeParams.add("-UseBasicParsing");
|
||||
invokeParams.add("-Uri \"" + escapePowerShellString(meta.getUrl()) + "\"");
|
||||
|
||||
// 添加 WebSession
|
||||
invokeParams.add("-WebSession $session");
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
List<String> headerLines = new ArrayList<>();
|
||||
headerLines.add("-Headers @{");
|
||||
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
if (!first) {
|
||||
headerLines.add("");
|
||||
}
|
||||
headerLines.add(" \"" + escapePowerShellString(entry.getKey()) + "\"=\"" +
|
||||
escapePowerShellString(entry.getValue()) + "\"");
|
||||
first = false;
|
||||
}
|
||||
|
||||
headerLines.add("}");
|
||||
|
||||
// 将头部参数添加到主命令中
|
||||
invokeParams.add(String.join("`\n", headerLines));
|
||||
}
|
||||
|
||||
// 设置输出文件(如果指定了文件名)
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
invokeParams.add("-OutFile \"" + escapePowerShellString(meta.getFileName()) + "\"");
|
||||
}
|
||||
|
||||
// 将所有参数连接起来
|
||||
String invokeCommand = String.join(" `\n", invokeParams);
|
||||
lines.add(invokeCommand);
|
||||
|
||||
return String.join("\n", lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义 PowerShell 字符串中的特殊字符
|
||||
*/
|
||||
private String escapePowerShellString(String str) {
|
||||
if (str == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return str.replace("`", "``")
|
||||
.replace("\"", "`\"")
|
||||
.replace("$", "`$");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.POWERSHELL;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* wget 命令生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class WgetLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> parts = new ArrayList<>();
|
||||
parts.add("wget");
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
parts.add("--header=\"" + entry.getKey() + ": " + entry.getValue() + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
// 设置输出文件名
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
parts.add("-O");
|
||||
parts.add("\"" + meta.getFileName() + "\"");
|
||||
}
|
||||
|
||||
// 添加URL
|
||||
parts.add("\"" + meta.getUrl() + "\"");
|
||||
|
||||
return String.join(" \\\n ", parts);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.WGET;
|
||||
}
|
||||
}
|
||||
@@ -53,26 +53,11 @@ public class CustomParserConfig {
|
||||
*/
|
||||
private final String jsCode;
|
||||
|
||||
/**
|
||||
* Python代码(用于Python解析器)
|
||||
*/
|
||||
private final String pyCode;
|
||||
|
||||
/**
|
||||
* 是否为JavaScript解析器
|
||||
*/
|
||||
private final boolean isJsParser;
|
||||
|
||||
/**
|
||||
* 是否为Python解析器
|
||||
*/
|
||||
private final boolean isPyParser;
|
||||
|
||||
/**
|
||||
* 脚本语言类型:javascript, python
|
||||
*/
|
||||
private final String language;
|
||||
|
||||
/**
|
||||
* 元数据信息(从脚本注释中解析)
|
||||
*/
|
||||
@@ -86,10 +71,7 @@ public class CustomParserConfig {
|
||||
this.panDomain = builder.panDomain;
|
||||
this.matchPattern = builder.matchPattern;
|
||||
this.jsCode = builder.jsCode;
|
||||
this.pyCode = builder.pyCode;
|
||||
this.isJsParser = builder.isJsParser;
|
||||
this.isPyParser = builder.isPyParser;
|
||||
this.language = builder.language;
|
||||
this.metadata = builder.metadata;
|
||||
}
|
||||
|
||||
@@ -121,22 +103,10 @@ public class CustomParserConfig {
|
||||
return jsCode;
|
||||
}
|
||||
|
||||
public String getPyCode() {
|
||||
return pyCode;
|
||||
}
|
||||
|
||||
public boolean isJsParser() {
|
||||
return isJsParser;
|
||||
}
|
||||
|
||||
public boolean isPyParser() {
|
||||
return isPyParser;
|
||||
}
|
||||
|
||||
public String getLanguage() {
|
||||
return language;
|
||||
}
|
||||
|
||||
public Map<String, String> getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
@@ -164,10 +134,7 @@ public class CustomParserConfig {
|
||||
private String panDomain;
|
||||
private Pattern matchPattern;
|
||||
private String jsCode;
|
||||
private String pyCode;
|
||||
private boolean isJsParser;
|
||||
private boolean isPyParser;
|
||||
private String language;
|
||||
private Map<String, String> metadata;
|
||||
|
||||
/**
|
||||
@@ -244,45 +211,12 @@ public class CustomParserConfig {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置Python代码(用于Python解析器)
|
||||
* @param pyCode Python代码
|
||||
*/
|
||||
public Builder pyCode(String pyCode) {
|
||||
this.pyCode = pyCode;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否为JavaScript解析器
|
||||
* @param isJsParser 是否为JavaScript解析器
|
||||
*/
|
||||
public Builder isJsParser(boolean isJsParser) {
|
||||
this.isJsParser = isJsParser;
|
||||
if (isJsParser) {
|
||||
this.language = "javascript";
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否为Python解析器
|
||||
* @param isPyParser 是否为Python解析器
|
||||
*/
|
||||
public Builder isPyParser(boolean isPyParser) {
|
||||
this.isPyParser = isPyParser;
|
||||
if (isPyParser) {
|
||||
this.language = "python";
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置脚本语言类型
|
||||
* @param language 语言类型:javascript, python
|
||||
*/
|
||||
public Builder language(String language) {
|
||||
this.language = language;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -312,11 +246,6 @@ public class CustomParserConfig {
|
||||
if (jsCode == null || jsCode.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("JavaScript解析器的jsCode不能为空");
|
||||
}
|
||||
} else if (isPyParser) {
|
||||
// 如果是Python解析器,验证pyCode
|
||||
if (pyCode == null || pyCode.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("Python解析器的pyCode不能为空");
|
||||
}
|
||||
} else {
|
||||
// 如果是Java解析器,验证toolClass
|
||||
if (toolClass == null) {
|
||||
@@ -359,10 +288,7 @@ public class CustomParserConfig {
|
||||
", panDomain='" + panDomain + '\'' +
|
||||
", matchPattern=" + (matchPattern != null ? matchPattern.pattern() : "null") +
|
||||
", jsCode=" + (jsCode != null ? "[JavaScript代码]" : "null") +
|
||||
", pyCode=" + (pyCode != null ? "[Python代码]" : "null") +
|
||||
", isJsParser=" + isJsParser +
|
||||
", isPyParser=" + isPyParser +
|
||||
", language='" + language + '\'' +
|
||||
", metadata=" + metadata +
|
||||
'}';
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import org.slf4j.LoggerFactory;
|
||||
import cn.qaiu.parser.PanDomainTemplate;
|
||||
import cn.qaiu.parser.customjs.JsScriptLoader;
|
||||
import cn.qaiu.parser.customjs.JsScriptMetadataParser;
|
||||
import cn.qaiu.parser.custompy.PyScriptLoader;
|
||||
import cn.qaiu.parser.custompy.PyScriptMetadataParser;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -23,6 +21,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
public class CustomParserRegistry {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CustomParserRegistry.class);
|
||||
private static final int MAX_CUSTOM_PARSERS = Integer.getInteger("parser.custom.maxRegistrySize", 256);
|
||||
|
||||
/**
|
||||
* 存储自定义解析器配置的Map,key为类型标识,value为配置对象
|
||||
@@ -35,7 +34,7 @@ public class CustomParserRegistry {
|
||||
* @param config 解析器配置
|
||||
* @throws IllegalArgumentException 如果type已存在或与内置解析器冲突
|
||||
*/
|
||||
public static void register(CustomParserConfig config) {
|
||||
public static synchronized void register(CustomParserConfig config) {
|
||||
if (config == null) {
|
||||
throw new IllegalArgumentException("config不能为空");
|
||||
}
|
||||
@@ -61,6 +60,11 @@ public class CustomParserRegistry {
|
||||
"类型标识 '" + type + "' 已被注册,请先注销或使用其他标识"
|
||||
);
|
||||
}
|
||||
if (CUSTOM_PARSERS.size() >= MAX_CUSTOM_PARSERS) {
|
||||
throw new IllegalArgumentException(
|
||||
"自定义解析器数量已达到上限(" + MAX_CUSTOM_PARSERS + "个),请先注销不需要的解析器"
|
||||
);
|
||||
}
|
||||
|
||||
CUSTOM_PARSERS.put(type, config);
|
||||
log.info("注册自定义解析器成功: {} ({})", config.getDisplayName(), type);
|
||||
@@ -84,24 +88,6 @@ public class CustomParserRegistry {
|
||||
register(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册Python解析器
|
||||
*
|
||||
* @param config Python解析器配置
|
||||
* @throws IllegalArgumentException 如果type已存在或与内置解析器冲突
|
||||
*/
|
||||
public static void registerPy(CustomParserConfig config) {
|
||||
if (config == null) {
|
||||
throw new IllegalArgumentException("config不能为空");
|
||||
}
|
||||
|
||||
if (!config.isPyParser()) {
|
||||
throw new IllegalArgumentException("config必须是Python解析器配置");
|
||||
}
|
||||
|
||||
register(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从JavaScript代码字符串注册解析器
|
||||
*
|
||||
@@ -159,63 +145,6 @@ public class CustomParserRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Python代码字符串注册解析器
|
||||
*
|
||||
* @param pyCode Python代码
|
||||
* @throws IllegalArgumentException 如果解析失败
|
||||
*/
|
||||
public static void registerPyFromCode(String pyCode) {
|
||||
if (pyCode == null || pyCode.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("Python代码不能为空");
|
||||
}
|
||||
|
||||
try {
|
||||
CustomParserConfig config = PyScriptMetadataParser.parseScript(pyCode);
|
||||
registerPy(config);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("解析Python代码失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件注册Python解析器
|
||||
*
|
||||
* @param filePath 文件路径
|
||||
* @throws IllegalArgumentException 如果文件不存在或解析失败
|
||||
*/
|
||||
public static void registerPyFromFile(String filePath) {
|
||||
if (filePath == null || filePath.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("文件路径不能为空");
|
||||
}
|
||||
|
||||
try {
|
||||
CustomParserConfig config = PyScriptLoader.loadFromFile(filePath);
|
||||
registerPy(config);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("从文件加载Python解析器失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从资源文件注册Python解析器
|
||||
*
|
||||
* @param resourcePath 资源路径
|
||||
* @throws IllegalArgumentException 如果资源不存在或解析失败
|
||||
*/
|
||||
public static void registerPyFromResource(String resourcePath) {
|
||||
if (resourcePath == null || resourcePath.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("资源路径不能为空");
|
||||
}
|
||||
|
||||
try {
|
||||
CustomParserConfig config = PyScriptLoader.loadFromResource(resourcePath);
|
||||
registerPy(config);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("从资源加载Python解析器失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动加载所有JavaScript脚本
|
||||
*/
|
||||
@@ -242,47 +171,13 @@ public class CustomParserRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动加载所有Python脚本
|
||||
*/
|
||||
public static void autoLoadPyScripts() {
|
||||
try {
|
||||
List<CustomParserConfig> configs = PyScriptLoader.loadAllScripts();
|
||||
int successCount = 0;
|
||||
int failCount = 0;
|
||||
|
||||
for (CustomParserConfig config : configs) {
|
||||
try {
|
||||
registerPy(config);
|
||||
successCount++;
|
||||
} catch (Exception e) {
|
||||
log.error("加载Python脚本失败: {}", config.getType(), e);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("自动加载Python脚本完成: 成功 {} 个,失败 {} 个", successCount, failCount);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("自动加载Python脚本时发生异常", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动加载所有脚本(JavaScript和Python)
|
||||
*/
|
||||
public static void autoLoadAllScripts() {
|
||||
autoLoadJsScripts();
|
||||
autoLoadPyScripts();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销自定义解析器
|
||||
*
|
||||
* @param type 解析器类型标识
|
||||
* @return 是否注销成功
|
||||
*/
|
||||
public static boolean unregister(String type) {
|
||||
public static synchronized boolean unregister(String type) {
|
||||
if (type == null || type.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
@@ -324,7 +219,7 @@ public class CustomParserRegistry {
|
||||
/**
|
||||
* 清空所有自定义解析器
|
||||
*/
|
||||
public static void clear() {
|
||||
public static synchronized void clear() {
|
||||
CUSTOM_PARSERS.clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,19 +2,21 @@ package cn.qaiu.parser.customjs;
|
||||
|
||||
import cn.qaiu.WebClientVertxInit;
|
||||
import cn.qaiu.util.HttpResponseHelper;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.MultiMap;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.http.HttpClient;
|
||||
import io.vertx.core.http.HttpClientOptions;
|
||||
import io.vertx.core.http.HttpClientRequest;
|
||||
import io.vertx.core.http.HttpClientResponse;
|
||||
import io.vertx.core.http.HttpHeaders;
|
||||
import io.vertx.core.http.HttpMethod;
|
||||
import io.vertx.core.http.RequestOptions;
|
||||
import io.vertx.core.json.Json;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.core.net.ProxyOptions;
|
||||
import io.vertx.core.net.ProxyType;
|
||||
import io.vertx.ext.web.client.HttpRequest;
|
||||
import io.vertx.ext.web.client.HttpResponse;
|
||||
import io.vertx.ext.web.client.WebClient;
|
||||
import io.vertx.ext.web.client.WebClientOptions;
|
||||
import io.vertx.ext.web.client.WebClientSession;
|
||||
import io.vertx.ext.web.multipart.MultipartForm;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -27,8 +29,13 @@ import java.net.UnknownHostException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
@@ -39,11 +46,60 @@ import java.util.regex.Pattern;
|
||||
* Create at 2025/10/17
|
||||
*/
|
||||
public class JsHttpClient {
|
||||
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JsHttpClient.class);
|
||||
|
||||
private final WebClient client;
|
||||
private final WebClientSession clientSession;
|
||||
private static final int MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024;
|
||||
private static final int MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024;
|
||||
private static final int MAX_HEADER_COUNT = 64;
|
||||
private static final int MAX_HEADER_VALUE_LENGTH = 4096;
|
||||
private static final int MAX_TIMEOUT_SECONDS = 120;
|
||||
private static final int MAX_REDIRECTS = 5;
|
||||
private static final String DEFAULT_ACCEPT_ENCODING = "gzip, deflate, br";
|
||||
|
||||
private static final Object SHARED_CLIENT_LOCK = new Object();
|
||||
// 共享 HttpClient 实例(非代理模式),懒加载避免类初始化阶段抢跑 Vert.x。
|
||||
private static volatile HttpClient sharedClient;
|
||||
private static volatile boolean sharedClientShutdown = false;
|
||||
|
||||
/**
|
||||
* 关闭共享 HttpClient(应用关闭时调用)
|
||||
*/
|
||||
public static void shutdownSharedClient() {
|
||||
synchronized (SHARED_CLIENT_LOCK) {
|
||||
sharedClientShutdown = true;
|
||||
if (sharedClient != null) {
|
||||
sharedClient.close();
|
||||
sharedClient = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpClient sharedClient() {
|
||||
synchronized (SHARED_CLIENT_LOCK) {
|
||||
ensureSharedClientAvailable();
|
||||
if (sharedClient == null) {
|
||||
sharedClient = WebClientVertxInit.get().createHttpClient(
|
||||
new HttpClientOptions()
|
||||
.setConnectTimeout(10000)
|
||||
.setIdleTimeout(30)
|
||||
.setIdleTimeoutUnit(TimeUnit.SECONDS)
|
||||
.setMaxPoolSize(64));
|
||||
}
|
||||
return sharedClient;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureSharedClientAvailable() {
|
||||
if (sharedClientShutdown) {
|
||||
throw new IllegalStateException("共享 JavaScript HttpClient 已关闭");
|
||||
}
|
||||
}
|
||||
|
||||
private final HttpClient client;
|
||||
private final boolean ownClient; // 标记是否为自建 client(需要 close)
|
||||
private final AtomicBoolean closed = new AtomicBoolean(false);
|
||||
private final Object requestLock = new Object();
|
||||
private final Set<HttpClientRequest> activeRequests = ConcurrentHashMap.newKeySet();
|
||||
private MultiMap headers;
|
||||
private int timeoutSeconds = 30; // 默认超时时间30秒
|
||||
|
||||
@@ -61,11 +117,12 @@ public class JsHttpClient {
|
||||
};
|
||||
|
||||
public JsHttpClient() {
|
||||
this.client = WebClient.create(WebClientVertxInit.get(), new WebClientOptions());;
|
||||
this.clientSession = WebClientSession.create(client);
|
||||
ensureSharedClientAvailable();
|
||||
this.client = sharedClient();
|
||||
this.ownClient = false;
|
||||
this.headers = MultiMap.caseInsensitiveMultiMap();
|
||||
// 设置默认的Accept-Encoding头以支持压缩响应
|
||||
this.headers.set("Accept-Encoding", "gzip, deflate, br, zstd");
|
||||
this.headers.set("Accept-Encoding", DEFAULT_ACCEPT_ENCODING);
|
||||
// 设置默认的User-Agent头
|
||||
this.headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0");
|
||||
// 设置默认的Accept-Language头
|
||||
@@ -77,31 +134,35 @@ public class JsHttpClient {
|
||||
* @param proxyConfig 代理配置JsonObject,包含type、host、port、username、password
|
||||
*/
|
||||
public JsHttpClient(JsonObject proxyConfig) {
|
||||
ensureSharedClientAvailable();
|
||||
if (proxyConfig != null && proxyConfig.containsKey("type")) {
|
||||
ProxyOptions proxyOptions = new ProxyOptions()
|
||||
.setType(ProxyType.valueOf(proxyConfig.getString("type").toUpperCase()))
|
||||
.setHost(proxyConfig.getString("host"))
|
||||
.setPort(proxyConfig.getInteger("port"));
|
||||
|
||||
|
||||
if (StringUtils.isNotEmpty(proxyConfig.getString("username"))) {
|
||||
proxyOptions.setUsername(proxyConfig.getString("username"));
|
||||
}
|
||||
if (StringUtils.isNotEmpty(proxyConfig.getString("password"))) {
|
||||
proxyOptions.setPassword(proxyConfig.getString("password"));
|
||||
}
|
||||
|
||||
this.client = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions()
|
||||
.setUserAgentEnabled(false)
|
||||
|
||||
this.client = WebClientVertxInit.get().createHttpClient(
|
||||
new HttpClientOptions()
|
||||
.setConnectTimeout(10000)
|
||||
.setIdleTimeout(30)
|
||||
.setIdleTimeoutUnit(TimeUnit.SECONDS)
|
||||
.setMaxPoolSize(16)
|
||||
.setProxyOptions(proxyOptions));
|
||||
this.clientSession = WebClientSession.create(client);
|
||||
this.ownClient = true;
|
||||
} else {
|
||||
this.client = WebClient.create(WebClientVertxInit.get());
|
||||
this.clientSession = WebClientSession.create(client);
|
||||
this.client = sharedClient();
|
||||
this.ownClient = false;
|
||||
}
|
||||
this.headers = MultiMap.caseInsensitiveMultiMap();
|
||||
// 设置默认的Accept-Encoding头以支持压缩响应
|
||||
this.headers.set("Accept-Encoding", "gzip, deflate, br, zstd");
|
||||
this.headers.set("Accept-Encoding", DEFAULT_ACCEPT_ENCODING);
|
||||
// 设置默认的User-Agent头
|
||||
this.headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0");
|
||||
// 设置默认的Accept-Language头
|
||||
@@ -183,13 +244,7 @@ public class JsHttpClient {
|
||||
*/
|
||||
public JsHttpResponse get(String url) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.getAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
return request.send();
|
||||
});
|
||||
return executeRequest(HttpMethod.GET, url, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,16 +253,25 @@ public class JsHttpClient {
|
||||
* @return HTTP响应
|
||||
*/
|
||||
public JsHttpResponse getWithRedirect(String url) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.getAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
String currentUrl = url;
|
||||
for (int redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
|
||||
validateUrlSecurity(currentUrl);
|
||||
JsHttpResponse response = executeRequest(HttpMethod.GET, currentUrl, null, false);
|
||||
if (!isRedirectStatus(response.statusCode())) {
|
||||
return response;
|
||||
}
|
||||
// 设置跟随重定向
|
||||
request.followRedirects(true);
|
||||
return request.send();
|
||||
});
|
||||
|
||||
if (redirectCount == MAX_REDIRECTS) {
|
||||
throw new RuntimeException("重定向次数超过限制: " + MAX_REDIRECTS);
|
||||
}
|
||||
|
||||
String location = response.header(HttpHeaders.LOCATION.toString());
|
||||
if (StringUtils.isBlank(location)) {
|
||||
throw new RuntimeException("重定向响应缺少Location头");
|
||||
}
|
||||
currentUrl = resolveRedirectUrl(currentUrl, location);
|
||||
}
|
||||
throw new RuntimeException("重定向处理失败");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,15 +281,7 @@ public class JsHttpClient {
|
||||
*/
|
||||
public JsHttpResponse getNoRedirect(String url) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.getAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
// 设置不跟随重定向
|
||||
request.followRedirects(false);
|
||||
return request.send();
|
||||
});
|
||||
return executeRequest(HttpMethod.GET, url, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,26 +292,7 @@ public class JsHttpClient {
|
||||
*/
|
||||
public JsHttpResponse post(String url, Object data) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.postAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
|
||||
if (data != null) {
|
||||
if (data instanceof String) {
|
||||
return request.sendBuffer(Buffer.buffer((String) data));
|
||||
} else if (data instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> mapData = (Map<String, String>) data;
|
||||
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
|
||||
} else {
|
||||
return request.sendJson(data);
|
||||
}
|
||||
} else {
|
||||
return request.send();
|
||||
}
|
||||
});
|
||||
return executeRequest(HttpMethod.POST, url, bodyFromData(data), false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,26 +303,7 @@ public class JsHttpClient {
|
||||
*/
|
||||
public JsHttpResponse put(String url, Object data) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.putAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
|
||||
if (data != null) {
|
||||
if (data instanceof String) {
|
||||
return request.sendBuffer(Buffer.buffer((String) data));
|
||||
} else if (data instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> mapData = (Map<String, String>) data;
|
||||
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
|
||||
} else {
|
||||
return request.sendJson(data);
|
||||
}
|
||||
} else {
|
||||
return request.send();
|
||||
}
|
||||
});
|
||||
return executeRequest(HttpMethod.PUT, url, bodyFromData(data), false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,13 +312,8 @@ public class JsHttpClient {
|
||||
* @return HTTP响应
|
||||
*/
|
||||
public JsHttpResponse delete(String url) {
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.deleteAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
return request.send();
|
||||
});
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(HttpMethod.DELETE, url, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,26 +323,8 @@ public class JsHttpClient {
|
||||
* @return HTTP响应
|
||||
*/
|
||||
public JsHttpResponse patch(String url, Object data) {
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.patchAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
|
||||
if (data != null) {
|
||||
if (data instanceof String) {
|
||||
return request.sendBuffer(Buffer.buffer((String) data));
|
||||
} else if (data instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> mapData = (Map<String, String>) data;
|
||||
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
|
||||
} else {
|
||||
return request.sendJson(data);
|
||||
}
|
||||
} else {
|
||||
return request.send();
|
||||
}
|
||||
});
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(HttpMethod.PATCH, url, bodyFromData(data), false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,6 +335,12 @@ public class JsHttpClient {
|
||||
*/
|
||||
public JsHttpClient putHeader(String name, String value) {
|
||||
if (name != null && value != null) {
|
||||
if (headers.size() >= MAX_HEADER_COUNT && !headers.contains(name)) {
|
||||
throw new IllegalArgumentException("请求头数量超过限制");
|
||||
}
|
||||
if (value.length() > MAX_HEADER_VALUE_LENGTH) {
|
||||
throw new IllegalArgumentException("请求头过长: " + name);
|
||||
}
|
||||
headers.set(name, value);
|
||||
}
|
||||
return this;
|
||||
@@ -353,9 +354,7 @@ public class JsHttpClient {
|
||||
public JsHttpClient putHeaders(Map<String, String> headersMap) {
|
||||
if (headersMap != null) {
|
||||
for (Map.Entry<String, String> entry : headersMap.entrySet()) {
|
||||
if (entry.getKey() != null && entry.getValue() != null) {
|
||||
headers.set(entry.getKey(), entry.getValue());
|
||||
}
|
||||
putHeader(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
return this;
|
||||
@@ -380,7 +379,7 @@ public class JsHttpClient {
|
||||
public JsHttpClient clearHeaders() {
|
||||
headers.clear();
|
||||
// 重新设置默认头
|
||||
headers.set("Accept-Encoding", "gzip, deflate, br, zstd");
|
||||
headers.set("Accept-Encoding", DEFAULT_ACCEPT_ENCODING);
|
||||
headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0");
|
||||
headers.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6");
|
||||
return this;
|
||||
@@ -405,7 +404,7 @@ public class JsHttpClient {
|
||||
*/
|
||||
public JsHttpClient setTimeout(int seconds) {
|
||||
if (seconds > 0) {
|
||||
this.timeoutSeconds = seconds;
|
||||
this.timeoutSeconds = Math.min(seconds, MAX_TIMEOUT_SECONDS);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
@@ -450,19 +449,12 @@ public class JsHttpClient {
|
||||
* @return HTTP响应
|
||||
*/
|
||||
public JsHttpResponse sendForm(Map<String, String> data) {
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.postAbs("");
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
|
||||
MultiMap formData = MultiMap.caseInsensitiveMultiMap();
|
||||
if (data != null) {
|
||||
formData.addAll(data);
|
||||
}
|
||||
|
||||
return request.sendForm(formData);
|
||||
});
|
||||
throw new IllegalArgumentException("sendForm(data) 缺少请求URL,请使用 post(url, data)");
|
||||
}
|
||||
|
||||
public JsHttpResponse sendForm(String url, Map<String, String> data) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(HttpMethod.POST, url, formBody(data), false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -474,34 +466,8 @@ public class JsHttpClient {
|
||||
* @return HTTP响应
|
||||
*/
|
||||
public JsHttpResponse sendMultipartForm(String url, Map<String, Object> data) {
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.postAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
|
||||
MultipartForm form = MultipartForm.create();
|
||||
|
||||
if (data != null) {
|
||||
for (Map.Entry<String, Object> entry : data.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
|
||||
if (value instanceof String) {
|
||||
form.attribute(key, (String) value);
|
||||
} else if (value instanceof byte[]) {
|
||||
form.binaryFileUpload(key, key, Buffer.buffer((byte[]) value), "application/octet-stream");
|
||||
} else if (value instanceof Buffer) {
|
||||
form.binaryFileUpload(key, key, (Buffer) value, "application/octet-stream");
|
||||
} else if (value != null) {
|
||||
// 其他类型转换为字符串
|
||||
form.attribute(key, value.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return request.sendMultipartForm(form);
|
||||
});
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(HttpMethod.POST, url, multipartBody(data), false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -510,44 +476,103 @@ public class JsHttpClient {
|
||||
* @return HTTP响应
|
||||
*/
|
||||
public JsHttpResponse sendJson(Object data) {
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.postAbs("");
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
|
||||
return request.sendJson(data);
|
||||
});
|
||||
throw new IllegalArgumentException("sendJson(data) 缺少请求URL,请使用 post(url, data)");
|
||||
}
|
||||
|
||||
public JsHttpResponse sendJson(String url, Object data) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(HttpMethod.POST, url, jsonBody(data), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行HTTP请求(同步)
|
||||
*/
|
||||
private JsHttpResponse executeRequest(RequestExecutor executor) {
|
||||
private JsHttpResponse executeRequest(HttpMethod method, String url, RequestBody requestBody, boolean followRedirects) {
|
||||
if (closed.get()) {
|
||||
throw new IllegalStateException("HTTP客户端已关闭");
|
||||
}
|
||||
AtomicReference<HttpClientRequest> requestRef = new AtomicReference<>();
|
||||
AtomicBoolean abandoned = new AtomicBoolean(false);
|
||||
try {
|
||||
Promise<HttpResponse<Buffer>> promise = Promise.promise();
|
||||
Future<HttpResponse<Buffer>> future = executor.execute();
|
||||
|
||||
future.onComplete(result -> {
|
||||
if (result.succeeded()) {
|
||||
promise.complete(result.result());
|
||||
} else {
|
||||
promise.fail(result.cause());
|
||||
Promise<JsHttpResponse> promise = Promise.promise();
|
||||
|
||||
RequestOptions options = new RequestOptions()
|
||||
.setMethod(method)
|
||||
.setAbsoluteURI(url)
|
||||
.setFollowRedirects(followRedirects)
|
||||
.setTimeout(TimeUnit.SECONDS.toMillis(timeoutSeconds))
|
||||
.setHeaders(MultiMap.caseInsensitiveMultiMap().setAll(headers));
|
||||
|
||||
client.request(options).onComplete(ar -> {
|
||||
if (ar.failed()) {
|
||||
promise.tryFail(ar.cause());
|
||||
return;
|
||||
}
|
||||
}).onFailure(Throwable::printStackTrace);
|
||||
|
||||
// 等待响应完成(使用配置的超时时间)
|
||||
HttpResponse<Buffer> response = promise.future().toCompletionStage()
|
||||
|
||||
HttpClientRequest request = ar.result();
|
||||
synchronized (requestLock) {
|
||||
if (closed.get() || abandoned.get()) {
|
||||
request.reset();
|
||||
promise.tryFail("HTTP客户端已关闭");
|
||||
return;
|
||||
}
|
||||
activeRequests.add(request);
|
||||
requestRef.set(request);
|
||||
request.exceptionHandler(e -> {
|
||||
finishRequest(request);
|
||||
promise.tryFail(e);
|
||||
});
|
||||
request.response().onComplete(responseAr -> {
|
||||
if (responseAr.succeeded()) {
|
||||
collectResponse(request, responseAr.result(), promise);
|
||||
} else {
|
||||
finishRequest(request);
|
||||
promise.tryFail(responseAr.cause());
|
||||
}
|
||||
});
|
||||
|
||||
if (closed.get() || abandoned.get()) {
|
||||
request.reset();
|
||||
finishRequest(request);
|
||||
promise.tryFail("HTTP客户端已关闭");
|
||||
return;
|
||||
}
|
||||
if (requestBody == null || requestBody.body() == null) {
|
||||
request.end().onFailure(e -> {
|
||||
finishRequest(request);
|
||||
promise.tryFail(e);
|
||||
});
|
||||
} else {
|
||||
request.headers().set(HttpHeaders.CONTENT_LENGTH, String.valueOf(requestBody.body().length()));
|
||||
if (StringUtils.isNotEmpty(requestBody.contentType())) {
|
||||
request.headers().set(HttpHeaders.CONTENT_TYPE, requestBody.contentType());
|
||||
}
|
||||
request.end(requestBody.body()).onFailure(e -> {
|
||||
finishRequest(request);
|
||||
promise.tryFail(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return promise.future().toCompletionStage()
|
||||
.toCompletableFuture()
|
||||
.get(timeoutSeconds, TimeUnit.SECONDS);
|
||||
|
||||
return new JsHttpResponse(response);
|
||||
|
||||
|
||||
} catch (TimeoutException e) {
|
||||
// RequestOptions timeout 通常会先触发;这里再兜底,避免等待线程返回后请求还在后台下载。
|
||||
String errorMsg = "HTTP请求超时(" + timeoutSeconds + "秒)";
|
||||
abandoned.set(true);
|
||||
synchronized (requestLock) {
|
||||
abortRequest(requestRef);
|
||||
}
|
||||
log.error(errorMsg, e);
|
||||
throw new RuntimeException(errorMsg, e);
|
||||
} catch (Exception e) {
|
||||
abandoned.set(true);
|
||||
synchronized (requestLock) {
|
||||
abortRequest(requestRef);
|
||||
}
|
||||
String errorMsg = e.getMessage();
|
||||
if (errorMsg == null || errorMsg.trim().isEmpty()) {
|
||||
errorMsg = e.getClass().getSimpleName();
|
||||
@@ -559,13 +584,196 @@ public class JsHttpClient {
|
||||
throw new RuntimeException("HTTP请求执行失败: " + errorMsg, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求执行器接口
|
||||
*/
|
||||
@FunctionalInterface
|
||||
private interface RequestExecutor {
|
||||
Future<HttpResponse<Buffer>> execute();
|
||||
|
||||
private static boolean isRedirectStatus(int statusCode) {
|
||||
return statusCode == 301 || statusCode == 302 || statusCode == 303
|
||||
|| statusCode == 307 || statusCode == 308;
|
||||
}
|
||||
|
||||
private String resolveRedirectUrl(String currentUrl, String location) {
|
||||
try {
|
||||
URI redirectUri = new URI(currentUrl).resolve(location.trim());
|
||||
String scheme = redirectUri.getScheme();
|
||||
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
|
||||
throw new SecurityException("🔒 安全拦截: 重定向协议不被允许");
|
||||
}
|
||||
String redirectUrl = redirectUri.toString();
|
||||
validateUrlSecurity(redirectUrl);
|
||||
return redirectUrl;
|
||||
} catch (SecurityException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("解析重定向地址失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void collectResponse(HttpClientRequest request, HttpClientResponse response, Promise<JsHttpResponse> promise) {
|
||||
Buffer body = Buffer.buffer();
|
||||
AtomicBoolean done = new AtomicBoolean(false);
|
||||
|
||||
String contentLengthHeader = response.getHeader(HttpHeaders.CONTENT_LENGTH.toString());
|
||||
if (StringUtils.isNumeric(contentLengthHeader)) {
|
||||
long contentLength = Long.parseLong(contentLengthHeader);
|
||||
if (contentLength > MAX_RESPONSE_BODY_BYTES) {
|
||||
done.set(true);
|
||||
request.reset();
|
||||
finishRequest(request);
|
||||
promise.tryFail("响应体过大: " + contentLength + " bytes");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
response.exceptionHandler(e -> {
|
||||
if (done.compareAndSet(false, true)) {
|
||||
finishRequest(request);
|
||||
promise.tryFail(e);
|
||||
}
|
||||
});
|
||||
response.handler(chunk -> {
|
||||
if (done.get()) {
|
||||
return;
|
||||
}
|
||||
if (body.length() + chunk.length() > MAX_RESPONSE_BODY_BYTES) {
|
||||
if (done.compareAndSet(false, true)) {
|
||||
request.reset();
|
||||
finishRequest(request);
|
||||
promise.tryFail("响应体过大: " + (body.length() + chunk.length()) + " bytes");
|
||||
}
|
||||
return;
|
||||
}
|
||||
body.appendBuffer(chunk);
|
||||
});
|
||||
response.endHandler(v -> {
|
||||
if (done.compareAndSet(false, true)) {
|
||||
finishRequest(request);
|
||||
promise.tryComplete(new JsHttpResponse(
|
||||
response.statusCode(),
|
||||
MultiMap.caseInsensitiveMultiMap().setAll(response.headers()),
|
||||
body,
|
||||
response.statusMessage(),
|
||||
null
|
||||
));
|
||||
}
|
||||
});
|
||||
response.resume();
|
||||
}
|
||||
|
||||
private void finishRequest(HttpClientRequest request) {
|
||||
if (request != null) {
|
||||
activeRequests.remove(request);
|
||||
}
|
||||
}
|
||||
|
||||
private void abortRequest(AtomicReference<HttpClientRequest> requestRef) {
|
||||
HttpClientRequest request = requestRef.get();
|
||||
if (request != null) {
|
||||
try {
|
||||
request.reset();
|
||||
} finally {
|
||||
finishRequest(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private RequestBody bodyFromData(Object data) {
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
if (data instanceof String str) {
|
||||
return plainTextBody(str);
|
||||
}
|
||||
if (data instanceof Buffer buffer) {
|
||||
return limitedBody(buffer, null);
|
||||
}
|
||||
if (data instanceof byte[] bytes) {
|
||||
return limitedBody(Buffer.buffer(bytes), null);
|
||||
}
|
||||
if (data instanceof Map<?, ?> map) {
|
||||
Map<String, String> formMap = new HashMap<>();
|
||||
map.forEach((key, value) -> {
|
||||
if (key != null && value != null) {
|
||||
formMap.put(String.valueOf(key), String.valueOf(value));
|
||||
}
|
||||
});
|
||||
return formBody(formMap);
|
||||
}
|
||||
return jsonBody(data);
|
||||
}
|
||||
|
||||
private RequestBody plainTextBody(String data) {
|
||||
return limitedBody(Buffer.buffer(data, StandardCharsets.UTF_8.name()), null);
|
||||
}
|
||||
|
||||
private RequestBody jsonBody(Object data) {
|
||||
Buffer body = data == null ? Buffer.buffer() : Buffer.buffer(Json.encode(data), StandardCharsets.UTF_8.name());
|
||||
return limitedBody(body, "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
private RequestBody formBody(Map<String, String> data) {
|
||||
StringBuilder encoded = new StringBuilder();
|
||||
if (data != null) {
|
||||
for (Map.Entry<String, String> entry : data.entrySet()) {
|
||||
if (encoded.length() > 0) {
|
||||
encoded.append('&');
|
||||
}
|
||||
encoded.append(urlEncode(entry.getKey()));
|
||||
encoded.append('=');
|
||||
encoded.append(urlEncode(entry.getValue()));
|
||||
}
|
||||
}
|
||||
return limitedBody(Buffer.buffer(encoded.toString(), StandardCharsets.UTF_8.name()),
|
||||
"application/x-www-form-urlencoded; charset=utf-8");
|
||||
}
|
||||
|
||||
private RequestBody multipartBody(Map<String, Object> data) {
|
||||
String boundary = "----NetdiskJsHttpClientBoundary" + UUID.randomUUID().toString().replace("-", "");
|
||||
Buffer body = Buffer.buffer();
|
||||
if (data != null) {
|
||||
for (Map.Entry<String, Object> entry : data.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
if (key == null || value == null) {
|
||||
continue;
|
||||
}
|
||||
appendAscii(body, "--" + boundary + "\r\n");
|
||||
if (value instanceof byte[] bytes) {
|
||||
appendAscii(body, "Content-Disposition: form-data; name=\"" + escapeMultipart(key)
|
||||
+ "\"; filename=\"" + escapeMultipart(key) + "\"\r\n");
|
||||
appendAscii(body, "Content-Type: application/octet-stream\r\n\r\n");
|
||||
body.appendBytes(bytes);
|
||||
appendAscii(body, "\r\n");
|
||||
} else if (value instanceof Buffer buffer) {
|
||||
appendAscii(body, "Content-Disposition: form-data; name=\"" + escapeMultipart(key)
|
||||
+ "\"; filename=\"" + escapeMultipart(key) + "\"\r\n");
|
||||
appendAscii(body, "Content-Type: application/octet-stream\r\n\r\n");
|
||||
body.appendBuffer(buffer);
|
||||
appendAscii(body, "\r\n");
|
||||
} else {
|
||||
appendAscii(body, "Content-Disposition: form-data; name=\"" + escapeMultipart(key) + "\"\r\n\r\n");
|
||||
body.appendString(String.valueOf(value), StandardCharsets.UTF_8.name());
|
||||
appendAscii(body, "\r\n");
|
||||
}
|
||||
ensureRequestBodyLimit(body);
|
||||
}
|
||||
}
|
||||
appendAscii(body, "--" + boundary + "--\r\n");
|
||||
return limitedBody(body, "multipart/form-data; boundary=" + boundary);
|
||||
}
|
||||
|
||||
private static void appendAscii(Buffer body, String value) {
|
||||
body.appendString(value, StandardCharsets.US_ASCII.name());
|
||||
}
|
||||
|
||||
private static String escapeMultipart(String value) {
|
||||
return value.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private static RequestBody limitedBody(Buffer body, String contentType) {
|
||||
ensureRequestBodyLimit(body);
|
||||
return new RequestBody(body, contentType);
|
||||
}
|
||||
|
||||
private record RequestBody(Buffer body, String contentType) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -573,10 +781,29 @@ public class JsHttpClient {
|
||||
*/
|
||||
public static class JsHttpResponse {
|
||||
|
||||
private final HttpResponse<Buffer> response;
|
||||
private final int statusCode;
|
||||
private final MultiMap headers;
|
||||
private final Buffer body;
|
||||
private final String statusMessage;
|
||||
private final HttpResponse<Buffer> originalResponse;
|
||||
|
||||
public JsHttpResponse(HttpResponse<Buffer> response) {
|
||||
this.response = response;
|
||||
this(
|
||||
response.statusCode(),
|
||||
MultiMap.caseInsensitiveMultiMap().setAll(response.headers()),
|
||||
response.body(),
|
||||
response.statusMessage(),
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
public JsHttpResponse(int statusCode, MultiMap headers, Buffer body, String statusMessage,
|
||||
HttpResponse<Buffer> originalResponse) {
|
||||
this.statusCode = statusCode;
|
||||
this.headers = headers == null ? MultiMap.caseInsensitiveMultiMap() : headers;
|
||||
this.body = body == null ? Buffer.buffer() : body;
|
||||
this.statusMessage = statusMessage;
|
||||
this.originalResponse = originalResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -584,7 +811,7 @@ public class JsHttpClient {
|
||||
* @return 响应体字符串
|
||||
*/
|
||||
public String body() {
|
||||
return HttpResponseHelper.asText(response);
|
||||
return HttpResponseHelper.asText(body, header(HttpHeaders.CONTENT_ENCODING.toString()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -593,7 +820,7 @@ public class JsHttpClient {
|
||||
*/
|
||||
public Object json() {
|
||||
try {
|
||||
JsonObject jsonObject = HttpResponseHelper.asJson(response);
|
||||
JsonObject jsonObject = HttpResponseHelper.asJson(body, header(HttpHeaders.CONTENT_ENCODING.toString()));
|
||||
if (jsonObject == null || jsonObject.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
@@ -611,7 +838,7 @@ public class JsHttpClient {
|
||||
* @return 状态码
|
||||
*/
|
||||
public int statusCode() {
|
||||
return response.statusCode();
|
||||
return statusCode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -620,7 +847,7 @@ public class JsHttpClient {
|
||||
* @return 头值
|
||||
*/
|
||||
public String header(String name) {
|
||||
return response.getHeader(name);
|
||||
return headers.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -628,10 +855,9 @@ public class JsHttpClient {
|
||||
* @return 响应头Map
|
||||
*/
|
||||
public Map<String, String> headers() {
|
||||
MultiMap responseHeaders = response.headers();
|
||||
Map<String, String> result = new HashMap<>();
|
||||
for (String name : responseHeaders.names()) {
|
||||
result.put(name, responseHeaders.get(name));
|
||||
for (String name : headers.names()) {
|
||||
result.put(name, headers.get(name));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -649,8 +875,14 @@ public class JsHttpClient {
|
||||
* 获取原始响应对象
|
||||
* @return HttpResponse对象
|
||||
*/
|
||||
@Deprecated
|
||||
public HttpResponse<Buffer> getOriginalResponse() {
|
||||
return response;
|
||||
if (originalResponse == null) {
|
||||
throw new UnsupportedOperationException(
|
||||
"流式HTTP客户端不再保留原始Vert.x HttpResponse,请使用statusCode/header/headers/body/bodyBytes方法"
|
||||
);
|
||||
}
|
||||
return originalResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -658,11 +890,8 @@ public class JsHttpClient {
|
||||
* @return 响应体字节数组
|
||||
*/
|
||||
public byte[] bodyBytes() {
|
||||
Buffer buffer = response.body();
|
||||
if (buffer == null) {
|
||||
return new byte[0];
|
||||
}
|
||||
return buffer.getBytes();
|
||||
ensureResponseBodyLimit(body);
|
||||
return body.getBytes();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -670,11 +899,46 @@ public class JsHttpClient {
|
||||
* @return 响应体大小(字节)
|
||||
*/
|
||||
public long bodySize() {
|
||||
Buffer buffer = response.body();
|
||||
if (buffer == null) {
|
||||
return 0;
|
||||
return body.length();
|
||||
}
|
||||
|
||||
public String statusMessage() {
|
||||
return statusMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 HttpClient 释放连接池资源
|
||||
* 仅关闭自建的 client(代理模式),共享实例不关闭
|
||||
*/
|
||||
public void close() {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
synchronized (requestLock) {
|
||||
for (HttpClientRequest request : activeRequests) {
|
||||
try {
|
||||
request.reset();
|
||||
} catch (Exception e) {
|
||||
log.debug("重置 JavaScript HTTP 请求失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
return buffer.length();
|
||||
activeRequests.clear();
|
||||
}
|
||||
if (ownClient && client != null) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureResponseBodyLimit(Buffer buffer) {
|
||||
if (buffer != null && buffer.length() > MAX_RESPONSE_BODY_BYTES) {
|
||||
throw new IllegalArgumentException("响应体过大: " + buffer.length() + " bytes");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureRequestBodyLimit(Buffer buffer) {
|
||||
if (buffer != null && buffer.length() > MAX_REQUEST_BODY_BYTES) {
|
||||
throw new IllegalArgumentException("请求体过大: " + buffer.length() + " bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.IPanTool;
|
||||
import cn.qaiu.parser.custom.CustomParserConfig;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.WorkerExecutor;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import org.openjdk.nashorn.api.scripting.NashornScriptEngineFactory;
|
||||
@@ -20,6 +21,13 @@ import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -29,21 +37,46 @@ import java.util.stream.Collectors;
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/10/17
|
||||
*/
|
||||
public class JsParserExecutor implements IPanTool {
|
||||
|
||||
public class JsParserExecutor implements IPanTool, AutoCloseable {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JsParserExecutor.class);
|
||||
|
||||
private static final WorkerExecutor EXECUTOR = WebClientVertxInit.get().createSharedWorkerExecutor("parser-executor", 32);
|
||||
|
||||
private static String FETCH_RUNTIME_JS = null;
|
||||
|
||||
private static volatile WorkerExecutor EXECUTOR;
|
||||
private static final Object EXECUTOR_LOCK = new Object();
|
||||
private static volatile boolean executorShutdown = false;
|
||||
|
||||
/** 安全网调度器:当 onComplete 未触发时,延迟强制释放资源 */
|
||||
private static final ScheduledExecutorService CLEANUP_SCHEDULER =
|
||||
Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "js-parser-cleanup-safety");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
private static final long EXECUTION_TIMEOUT_SECONDS = 30;
|
||||
private static final int MAX_RESULT_STRING_LENGTH = 1024 * 1024;
|
||||
private static final int MAX_FILE_LIST_SIZE = 1000;
|
||||
private static final int MAX_FILE_FIELD_LENGTH = 4096;
|
||||
private static final int MAX_CONCURRENT_EXECUTIONS =
|
||||
Math.max(1, Integer.getInteger("parser.custom.js.maxConcurrentExecutions", 32));
|
||||
private static final Semaphore EXECUTION_PERMITS = new Semaphore(MAX_CONCURRENT_EXECUTIONS);
|
||||
|
||||
private static volatile String FETCH_RUNTIME_JS = null;
|
||||
|
||||
private final CustomParserConfig config;
|
||||
private final ShareLinkInfo shareLinkInfo;
|
||||
private final ScriptEngine engine;
|
||||
private volatile ScriptEngine engine;
|
||||
private final Object engineLock = new Object();
|
||||
private final JsHttpClient httpClient;
|
||||
private final JsLogger jsLogger;
|
||||
private final JsShareLinkInfoWrapper shareLinkInfoWrapper;
|
||||
private final JsFetchBridge fetchBridge;
|
||||
/** 标记是否已释放,防止重复关闭 */
|
||||
private final AtomicBoolean closed = new AtomicBoolean(false);
|
||||
private final Object lifecycleLock = new Object();
|
||||
private volatile boolean running = false;
|
||||
private volatile boolean closeRequested = false;
|
||||
/** 安全网定时任务句柄,正常完成时取消 */
|
||||
private volatile ScheduledFuture<?> safetyCleanupFuture = null;
|
||||
|
||||
public JsParserExecutor(ShareLinkInfo shareLinkInfo, CustomParserConfig config) {
|
||||
this.config = config;
|
||||
@@ -59,7 +92,6 @@ public class JsParserExecutor implements IPanTool {
|
||||
this.jsLogger = new JsLogger("JsParser-" + config.getType());
|
||||
this.shareLinkInfoWrapper = new JsShareLinkInfoWrapper(shareLinkInfo);
|
||||
this.fetchBridge = new JsFetchBridge(httpClient);
|
||||
this.engine = initEngine();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +143,7 @@ public class JsParserExecutor implements IPanTool {
|
||||
if (engine == null) {
|
||||
throw new RuntimeException("无法创建JavaScript引擎,请确保Nashorn可用");
|
||||
}
|
||||
this.engine = engine;
|
||||
|
||||
// 注入Java对象到JavaScript环境
|
||||
engine.put("http", httpClient);
|
||||
@@ -145,28 +178,207 @@ public class JsParserExecutor implements IPanTool {
|
||||
throw new RuntimeException("JavaScript引擎初始化失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private ScriptEngine engine() {
|
||||
ScriptEngine current = engine;
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
synchronized (engineLock) {
|
||||
if (closed.get()) {
|
||||
throw new IllegalStateException("JavaScript解析器已关闭");
|
||||
}
|
||||
if (engine == null) {
|
||||
engine = initEngine();
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
|
||||
private void beginExecution() {
|
||||
synchronized (lifecycleLock) {
|
||||
if (closed.get() || closeRequested) {
|
||||
throw new IllegalStateException("JavaScript解析器已关闭");
|
||||
}
|
||||
if (running) {
|
||||
throw new IllegalStateException("JavaScript解析器已在运行");
|
||||
}
|
||||
running = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void finishExecution() {
|
||||
synchronized (lifecycleLock) {
|
||||
running = false;
|
||||
if (closeRequested) {
|
||||
doClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资源(ScriptEngine 和 HttpClient),避免内存泄漏
|
||||
* 幂等:可安全多次调用
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
synchronized (lifecycleLock) {
|
||||
closeRequested = true;
|
||||
cancelSafetyCleanup();
|
||||
if (running || closed.get()) {
|
||||
closeExternalResources();
|
||||
return;
|
||||
}
|
||||
doClose();
|
||||
}
|
||||
}
|
||||
|
||||
private void doClose() {
|
||||
if (!closed.compareAndSet(false, true)) return;
|
||||
closeRequested = false;
|
||||
closeExternalResources();
|
||||
cleanupEngine();
|
||||
}
|
||||
|
||||
private void closeExternalResources() {
|
||||
if (httpClient != null) {
|
||||
httpClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupEngine() {
|
||||
// 清除 ScriptEngine 持有的所有引用和内部状态,帮助 GC 回收
|
||||
if (engine != null) {
|
||||
try {
|
||||
engine.put("http", null);
|
||||
engine.put("logger", null);
|
||||
engine.put("shareLinkInfo", null);
|
||||
engine.put("JavaFetch", null);
|
||||
// 彻底清除 ENGINE_SCOPE bindings,释放 JS AST、编译函数、闭包等运行时状态
|
||||
var bindings = engine.getBindings(javax.script.ScriptContext.ENGINE_SCOPE);
|
||||
if (bindings != null) {
|
||||
bindings.clear();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("清理 ScriptEngine bindings 失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelSafetyCleanup() {
|
||||
// 取消安全网定时任务(如果正常完成则无需再触发)
|
||||
if (safetyCleanupFuture != null) {
|
||||
safetyCleanupFuture.cancel(false);
|
||||
safetyCleanupFuture = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭全局 WorkerExecutor 和清理调度器(应在应用关闭时调用)
|
||||
*/
|
||||
public static void shutdownExecutor() {
|
||||
synchronized (EXECUTOR_LOCK) {
|
||||
executorShutdown = true;
|
||||
if (EXECUTOR != null) {
|
||||
EXECUTOR.close();
|
||||
EXECUTOR = null;
|
||||
log.info("JsParserExecutor WorkerExecutor 已关闭");
|
||||
}
|
||||
}
|
||||
CLEANUP_SCHEDULER.shutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建 WorkerExecutor(懒加载)
|
||||
*/
|
||||
private static WorkerExecutor getExecutor() {
|
||||
synchronized (EXECUTOR_LOCK) {
|
||||
if (executorShutdown) {
|
||||
throw new IllegalStateException("JavaScript解析器 WorkerExecutor 已关闭");
|
||||
}
|
||||
if (EXECUTOR == null) {
|
||||
EXECUTOR = WebClientVertxInit.get().createSharedWorkerExecutor("parser-executor", 32);
|
||||
}
|
||||
return EXECUTOR;
|
||||
}
|
||||
}
|
||||
|
||||
private <T> Future<T> executeBlockingWithPermit(String operation, Callable<T> blockingCode) {
|
||||
if (!EXECUTION_PERMITS.tryAcquire()) {
|
||||
String message = "JavaScript " + operation + " 执行并发已满,请稍后重试";
|
||||
jsLogger.error(message);
|
||||
close();
|
||||
return Future.failedFuture(message);
|
||||
}
|
||||
|
||||
try {
|
||||
return getExecutor().executeBlocking(() -> {
|
||||
boolean executionStarted = false;
|
||||
try {
|
||||
beginExecution();
|
||||
executionStarted = true;
|
||||
return blockingCode.call();
|
||||
} finally {
|
||||
if (executionStarted) {
|
||||
finishExecution();
|
||||
}
|
||||
EXECUTION_PERMITS.release();
|
||||
}
|
||||
});
|
||||
} catch (Throwable e) {
|
||||
EXECUTION_PERMITS.release();
|
||||
close();
|
||||
return Future.failedFuture(e);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> Future<T> withTimeout(Future<T> executionFuture, String operation) {
|
||||
Promise<T> promise = Promise.promise();
|
||||
try {
|
||||
safetyCleanupFuture = CLEANUP_SCHEDULER.schedule(() -> {
|
||||
if (promise.tryFail("JavaScript " + operation + " 执行超时(" + EXECUTION_TIMEOUT_SECONDS + "秒)")) {
|
||||
jsLogger.error("{} 执行超时,已停止外部HTTP资源;ScriptEngine将在执行线程退出后清理", operation);
|
||||
close();
|
||||
}
|
||||
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
log.warn("安全网调度失败: {}", e.getMessage());
|
||||
}
|
||||
executionFuture.onComplete(ar -> {
|
||||
cancelSafetyCleanup();
|
||||
if (ar.succeeded()) {
|
||||
promise.tryComplete(ar.result());
|
||||
} else {
|
||||
promise.tryFail(ar.cause());
|
||||
}
|
||||
close();
|
||||
});
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parse() {
|
||||
jsLogger.info("开始执行JavaScript解析器: {}", config.getType());
|
||||
|
||||
|
||||
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
|
||||
return EXECUTOR.executeBlocking(() -> {
|
||||
Future<String> executionFuture = executeBlockingWithPermit("parse", () -> {
|
||||
ScriptEngine engine = engine();
|
||||
// 直接调用全局parse函数
|
||||
Object parseFunction = engine.get("parse");
|
||||
if (parseFunction == null) {
|
||||
throw new RuntimeException("JavaScript代码中未找到parse函数");
|
||||
}
|
||||
|
||||
|
||||
if (parseFunction instanceof ScriptObjectMirror parseMirror) {
|
||||
|
||||
Object result = parseMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger);
|
||||
|
||||
|
||||
if (result instanceof String) {
|
||||
jsLogger.info("解析成功: {}", result);
|
||||
return (String) result;
|
||||
String resultText = limitResultString((String) result, "parse");
|
||||
jsLogger.info("解析成功,结果长度: {}", resultText.length());
|
||||
return resultText;
|
||||
} else {
|
||||
jsLogger.error("parse方法返回值类型错误,期望String,实际: {}",
|
||||
jsLogger.error("parse方法返回值类型错误,期望String,实际: {}",
|
||||
result != null ? result.getClass().getSimpleName() : "null");
|
||||
throw new RuntimeException("parse方法返回值类型错误");
|
||||
}
|
||||
@@ -174,32 +386,34 @@ public class JsParserExecutor implements IPanTool {
|
||||
throw new RuntimeException("parse函数类型错误");
|
||||
}
|
||||
});
|
||||
return withTimeout(executionFuture, "parse");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<List<FileInfo>> parseFileList() {
|
||||
jsLogger.info("开始执行JavaScript文件列表解析: {}", config.getType());
|
||||
|
||||
|
||||
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
|
||||
return EXECUTOR.executeBlocking(() -> {
|
||||
Future<List<FileInfo>> executionFuture = executeBlockingWithPermit("parseFileList", () -> {
|
||||
ScriptEngine engine = engine();
|
||||
// 直接调用全局parseFileList函数
|
||||
Object parseFileListFunction = engine.get("parseFileList");
|
||||
if (parseFileListFunction == null) {
|
||||
throw new RuntimeException("JavaScript代码中未找到parseFileList函数");
|
||||
}
|
||||
|
||||
|
||||
// 调用parseFileList方法
|
||||
if (parseFileListFunction instanceof ScriptObjectMirror parseFileListMirror) {
|
||||
|
||||
Object result = parseFileListMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger);
|
||||
|
||||
|
||||
if (result instanceof ScriptObjectMirror resultMirror) {
|
||||
List<FileInfo> fileList = convertToFileInfoList(resultMirror);
|
||||
|
||||
|
||||
jsLogger.info("文件列表解析成功,共 {} 个文件", fileList.size());
|
||||
return fileList;
|
||||
} else {
|
||||
jsLogger.error("parseFileList方法返回值类型错误,期望数组,实际: {}",
|
||||
jsLogger.error("parseFileList方法返回值类型错误,期望数组,实际: {}",
|
||||
result != null ? result.getClass().getSimpleName() : "null");
|
||||
throw new RuntimeException("parseFileList方法返回值类型错误");
|
||||
}
|
||||
@@ -207,30 +421,33 @@ public class JsParserExecutor implements IPanTool {
|
||||
throw new RuntimeException("parseFileList函数类型错误");
|
||||
}
|
||||
});
|
||||
return withTimeout(executionFuture, "parseFileList");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parseById() {
|
||||
jsLogger.info("开始执行JavaScript按ID解析: {}", config.getType());
|
||||
|
||||
|
||||
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
|
||||
return EXECUTOR.executeBlocking(() -> {
|
||||
Future<String> executionFuture = executeBlockingWithPermit("parseById", () -> {
|
||||
ScriptEngine engine = engine();
|
||||
// 直接调用全局parseById函数
|
||||
Object parseByIdFunction = engine.get("parseById");
|
||||
if (parseByIdFunction == null) {
|
||||
throw new RuntimeException("JavaScript代码中未找到parseById函数");
|
||||
}
|
||||
|
||||
|
||||
// 调用parseById方法
|
||||
if (parseByIdFunction instanceof ScriptObjectMirror parseByIdMirror) {
|
||||
|
||||
Object result = parseByIdMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger);
|
||||
|
||||
|
||||
if (result instanceof String) {
|
||||
jsLogger.info("按ID解析成功: {}", result);
|
||||
return (String) result;
|
||||
String resultText = limitResultString((String) result, "parseById");
|
||||
jsLogger.info("按ID解析成功,结果长度: {}", resultText.length());
|
||||
return resultText;
|
||||
} else {
|
||||
jsLogger.error("parseById方法返回值类型错误,期望String,实际: {}",
|
||||
jsLogger.error("parseById方法返回值类型错误,期望String,实际: {}",
|
||||
result != null ? result.getClass().getSimpleName() : "null");
|
||||
throw new RuntimeException("parseById方法返回值类型错误");
|
||||
}
|
||||
@@ -238,6 +455,7 @@ public class JsParserExecutor implements IPanTool {
|
||||
throw new RuntimeException("parseById函数类型错误");
|
||||
}
|
||||
});
|
||||
return withTimeout(executionFuture, "parseById");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,6 +465,9 @@ public class JsParserExecutor implements IPanTool {
|
||||
List<FileInfo> fileList = new ArrayList<>();
|
||||
|
||||
if (resultMirror.isArray()) {
|
||||
if (resultMirror.size() > MAX_FILE_LIST_SIZE) {
|
||||
throw new RuntimeException("文件列表数量超过限制: " + resultMirror.size());
|
||||
}
|
||||
for (int i = 0; i < resultMirror.size(); i++) {
|
||||
Object item = resultMirror.get(String.valueOf(i));
|
||||
if (item instanceof ScriptObjectMirror) {
|
||||
@@ -270,13 +491,13 @@ public class JsParserExecutor implements IPanTool {
|
||||
|
||||
// 设置基本字段
|
||||
if (itemMirror.hasMember("fileName")) {
|
||||
fileInfo.setFileName(itemMirror.getMember("fileName").toString());
|
||||
fileInfo.setFileName(limitField(itemMirror.getMember("fileName")));
|
||||
}
|
||||
if (itemMirror.hasMember("fileId")) {
|
||||
fileInfo.setFileId(itemMirror.getMember("fileId").toString());
|
||||
fileInfo.setFileId(limitField(itemMirror.getMember("fileId")));
|
||||
}
|
||||
if (itemMirror.hasMember("fileType")) {
|
||||
fileInfo.setFileType(itemMirror.getMember("fileType").toString());
|
||||
fileInfo.setFileType(limitField(itemMirror.getMember("fileType")));
|
||||
}
|
||||
if (itemMirror.hasMember("size")) {
|
||||
Object size = itemMirror.getMember("size");
|
||||
@@ -285,16 +506,16 @@ public class JsParserExecutor implements IPanTool {
|
||||
}
|
||||
}
|
||||
if (itemMirror.hasMember("sizeStr")) {
|
||||
fileInfo.setSizeStr(itemMirror.getMember("sizeStr").toString());
|
||||
fileInfo.setSizeStr(limitField(itemMirror.getMember("sizeStr")));
|
||||
}
|
||||
if (itemMirror.hasMember("createTime")) {
|
||||
fileInfo.setCreateTime(itemMirror.getMember("createTime").toString());
|
||||
fileInfo.setCreateTime(limitField(itemMirror.getMember("createTime")));
|
||||
}
|
||||
if (itemMirror.hasMember("updateTime")) {
|
||||
fileInfo.setUpdateTime(itemMirror.getMember("updateTime").toString());
|
||||
fileInfo.setUpdateTime(limitField(itemMirror.getMember("updateTime")));
|
||||
}
|
||||
if (itemMirror.hasMember("createBy")) {
|
||||
fileInfo.setCreateBy(itemMirror.getMember("createBy").toString());
|
||||
fileInfo.setCreateBy(limitField(itemMirror.getMember("createBy")));
|
||||
}
|
||||
if (itemMirror.hasMember("downloadCount")) {
|
||||
Object downloadCount = itemMirror.getMember("downloadCount");
|
||||
@@ -303,16 +524,16 @@ public class JsParserExecutor implements IPanTool {
|
||||
}
|
||||
}
|
||||
if (itemMirror.hasMember("fileIcon")) {
|
||||
fileInfo.setFileIcon(itemMirror.getMember("fileIcon").toString());
|
||||
fileInfo.setFileIcon(limitField(itemMirror.getMember("fileIcon")));
|
||||
}
|
||||
if (itemMirror.hasMember("panType")) {
|
||||
fileInfo.setPanType(itemMirror.getMember("panType").toString());
|
||||
fileInfo.setPanType(limitField(itemMirror.getMember("panType")));
|
||||
}
|
||||
if (itemMirror.hasMember("parserUrl")) {
|
||||
fileInfo.setParserUrl(itemMirror.getMember("parserUrl").toString());
|
||||
fileInfo.setParserUrl(limitField(itemMirror.getMember("parserUrl")));
|
||||
}
|
||||
if (itemMirror.hasMember("previewUrl")) {
|
||||
fileInfo.setPreviewUrl(itemMirror.getMember("previewUrl").toString());
|
||||
fileInfo.setPreviewUrl(limitField(itemMirror.getMember("previewUrl")));
|
||||
}
|
||||
|
||||
return fileInfo;
|
||||
@@ -322,4 +543,22 @@ public class JsParserExecutor implements IPanTool {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String limitResultString(String value, String operation) {
|
||||
if (value.length() > MAX_RESULT_STRING_LENGTH) {
|
||||
throw new RuntimeException(operation + " 返回结果过大: " + value.length() + " 字符");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String limitField(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.toString();
|
||||
if (text.length() > MAX_FILE_FIELD_LENGTH) {
|
||||
throw new RuntimeException("文件字段过长: " + text.length() + " 字符");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,20 +21,37 @@ import java.util.concurrent.*;
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
*/
|
||||
public class JsPlaygroundExecutor {
|
||||
public class JsPlaygroundExecutor implements AutoCloseable {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JsPlaygroundExecutor.class);
|
||||
|
||||
// JavaScript执行超时时间(秒)
|
||||
private static final long EXECUTION_TIMEOUT_SECONDS = 30;
|
||||
private static final int MAX_RESULT_STRING_LENGTH = 1024 * 1024;
|
||||
private static final int MAX_FILE_LIST_SIZE = 1000;
|
||||
private static final int MAX_FILE_FIELD_LENGTH = 4096;
|
||||
private static final int TIMEOUT_LOG_RETAIN = 50;
|
||||
|
||||
// 使用独立的线程池,不受Vert.x的BlockedThreadChecker监控
|
||||
private static final ExecutorService INDEPENDENT_EXECUTOR = Executors.newCachedThreadPool(r -> {
|
||||
Thread thread = new Thread(r);
|
||||
thread.setName("playground-independent-" + System.currentTimeMillis());
|
||||
thread.setDaemon(true); // 设置为守护线程,服务关闭时自动清理
|
||||
return thread;
|
||||
});
|
||||
// 使用有界线程池,防止线程无限增长导致内存溢出
|
||||
private static final int POOL_MAX_THREADS = 16;
|
||||
private static final int POOL_QUEUE_CAPACITY = 256;
|
||||
private static final ExecutorService INDEPENDENT_EXECUTOR = new ThreadPoolExecutor(
|
||||
4, POOL_MAX_THREADS, 60L, TimeUnit.SECONDS,
|
||||
new ArrayBlockingQueue<>(POOL_QUEUE_CAPACITY),
|
||||
r -> {
|
||||
Thread thread = new Thread(r);
|
||||
thread.setName("playground-independent-" + thread.getId());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
},
|
||||
(r, executor) -> {
|
||||
// 拒绝策略:记录日志并抛出异常,避免阻塞 Vert.x EventLoop
|
||||
log.warn("演练场线程池已满,拒绝任务。活跃线程: {}, 队列大小: {}",
|
||||
((ThreadPoolExecutor) executor).getActiveCount(),
|
||||
((ThreadPoolExecutor) executor).getQueue().size());
|
||||
throw new java.util.concurrent.RejectedExecutionException("演练场线程池已满,请稍后重试");
|
||||
}
|
||||
);
|
||||
|
||||
// 超时调度线程池,用于处理超时中断
|
||||
private static final ScheduledExecutorService TIMEOUT_SCHEDULER = Executors.newScheduledThreadPool(2, r -> {
|
||||
@@ -43,14 +60,29 @@ public class JsPlaygroundExecutor {
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
|
||||
/**
|
||||
* 关闭静态线程池(应在应用关闭时调用)
|
||||
*/
|
||||
public static void shutdownPools() {
|
||||
INDEPENDENT_EXECUTOR.shutdown();
|
||||
TIMEOUT_SCHEDULER.shutdown();
|
||||
log.info("JsPlaygroundExecutor 线程池已关闭");
|
||||
}
|
||||
|
||||
private final ShareLinkInfo shareLinkInfo;
|
||||
private final String jsCode;
|
||||
private final ScriptEngine engine;
|
||||
private volatile ScriptEngine engine;
|
||||
private final Object engineLock = new Object();
|
||||
private final JsHttpClient httpClient;
|
||||
private final JsPlaygroundLogger playgroundLogger;
|
||||
private final JsShareLinkInfoWrapper shareLinkInfoWrapper;
|
||||
private final JsFetchBridge fetchBridge;
|
||||
/** 标记是否已释放,防止重复关闭 */
|
||||
private volatile boolean closed = false;
|
||||
private final Object lifecycleLock = new Object();
|
||||
private volatile boolean running = false;
|
||||
private volatile boolean closeRequested = false;
|
||||
|
||||
/**
|
||||
* 创建演练场执行器
|
||||
@@ -72,7 +104,6 @@ public class JsPlaygroundExecutor {
|
||||
this.playgroundLogger = new JsPlaygroundLogger();
|
||||
this.shareLinkInfoWrapper = new JsShareLinkInfoWrapper(shareLinkInfo);
|
||||
this.fetchBridge = new JsFetchBridge(httpClient);
|
||||
this.engine = initEngine();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,6 +120,7 @@ public class JsPlaygroundExecutor {
|
||||
if (engine == null) {
|
||||
throw new RuntimeException("无法创建JavaScript引擎,请确保Nashorn可用");
|
||||
}
|
||||
this.engine = engine;
|
||||
|
||||
// 注入Java对象到JavaScript环境
|
||||
engine.put("http", httpClient);
|
||||
@@ -133,9 +165,14 @@ public class JsPlaygroundExecutor {
|
||||
*/
|
||||
public Future<String> executeParseAsync() {
|
||||
Promise<String> promise = Promise.promise();
|
||||
|
||||
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
||||
CompletableFuture<String> executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||
|
||||
final CompletableFuture<String> executionFuture;
|
||||
try {
|
||||
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
||||
executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||
beginExecution();
|
||||
try {
|
||||
ScriptEngine engine = engine();
|
||||
playgroundLogger.infoJava("开始执行parse方法");
|
||||
try {
|
||||
Object parseFunction = engine.get("parse");
|
||||
@@ -151,8 +188,9 @@ public class JsPlaygroundExecutor {
|
||||
log.debug("[JsPlaygroundExecutor] parse函数执行完成,当前日志数量: {}", playgroundLogger.size());
|
||||
|
||||
if (result instanceof String) {
|
||||
playgroundLogger.infoJava("解析成功,返回结果: " + result);
|
||||
return (String) result;
|
||||
String resultText = limitResultString((String) result, "parse");
|
||||
playgroundLogger.infoJava("解析成功,返回结果长度: " + resultText.length());
|
||||
return resultText;
|
||||
} else {
|
||||
String errorMsg = "parse方法返回值类型错误,期望String,实际: " +
|
||||
(result != null ? result.getClass().getSimpleName() : "null");
|
||||
@@ -167,25 +205,27 @@ public class JsPlaygroundExecutor {
|
||||
playgroundLogger.errorJava("执行parse方法失败: " + e.getMessage(), e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}, INDEPENDENT_EXECUTOR);
|
||||
|
||||
// 创建超时任务,强制取消执行
|
||||
ScheduledFuture<?> timeoutTask = TIMEOUT_SCHEDULER.schedule(() -> {
|
||||
if (!executionFuture.isDone()) {
|
||||
executionFuture.cancel(true); // 强制中断执行线程
|
||||
playgroundLogger.errorJava("执行超时,已强制中断");
|
||||
log.warn("JavaScript执行超时,已强制取消");
|
||||
} finally {
|
||||
finishExecution();
|
||||
}
|
||||
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
}, INDEPENDENT_EXECUTOR);
|
||||
} catch (java.util.concurrent.RejectedExecutionException e) {
|
||||
log.warn("演练场线程池已满,任务被拒绝");
|
||||
close(); // 释放已创建的 ScriptEngine 和 HttpClient 资源
|
||||
promise.fail(new RuntimeException("演练场线程池已满,请稍后重试", e));
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
ScheduledFuture<?> timeoutTask = scheduleTimeout(executionFuture, "parse");
|
||||
|
||||
// 处理执行结果
|
||||
executionFuture.whenComplete((result, error) -> {
|
||||
// 取消超时任务
|
||||
timeoutTask.cancel(false);
|
||||
|
||||
|
||||
if (error != null) {
|
||||
if (error instanceof CancellationException) {
|
||||
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
|
||||
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已返回超时并停止外部HTTP资源;ScriptEngine将在执行线程退出后清理";
|
||||
playgroundLogger.errorJava(timeoutMsg);
|
||||
log.error(timeoutMsg);
|
||||
promise.fail(new RuntimeException(timeoutMsg));
|
||||
@@ -197,10 +237,10 @@ public class JsPlaygroundExecutor {
|
||||
promise.complete(result);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 执行parseFileList方法(异步,带超时控制)
|
||||
* 使用独立线程池,不受Vert.x BlockedThreadChecker监控
|
||||
@@ -209,9 +249,14 @@ public class JsPlaygroundExecutor {
|
||||
*/
|
||||
public Future<List<FileInfo>> executeParseFileListAsync() {
|
||||
Promise<List<FileInfo>> promise = Promise.promise();
|
||||
|
||||
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
||||
CompletableFuture<List<FileInfo>> executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||
|
||||
final CompletableFuture<List<FileInfo>> executionFuture;
|
||||
try {
|
||||
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
||||
executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||
beginExecution();
|
||||
try {
|
||||
ScriptEngine engine = engine();
|
||||
playgroundLogger.infoJava("开始执行parseFileList方法");
|
||||
try {
|
||||
Object parseFileListFunction = engine.get("parseFileList");
|
||||
@@ -242,25 +287,27 @@ public class JsPlaygroundExecutor {
|
||||
playgroundLogger.errorJava("执行parseFileList方法失败: " + e.getMessage(), e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}, INDEPENDENT_EXECUTOR);
|
||||
|
||||
// 创建超时任务,强制取消执行
|
||||
ScheduledFuture<?> timeoutTask = TIMEOUT_SCHEDULER.schedule(() -> {
|
||||
if (!executionFuture.isDone()) {
|
||||
executionFuture.cancel(true); // 强制中断执行线程
|
||||
playgroundLogger.errorJava("执行超时,已强制中断");
|
||||
log.warn("JavaScript执行超时,已强制取消");
|
||||
} finally {
|
||||
finishExecution();
|
||||
}
|
||||
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
}, INDEPENDENT_EXECUTOR);
|
||||
} catch (java.util.concurrent.RejectedExecutionException e) {
|
||||
log.warn("演练场线程池已满,任务被拒绝");
|
||||
close(); // 释放已创建的 ScriptEngine 和 HttpClient 资源
|
||||
promise.fail(new RuntimeException("演练场线程池已满,请稍后重试", e));
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
ScheduledFuture<?> timeoutTask = scheduleTimeout(executionFuture, "parseFileList");
|
||||
|
||||
// 处理执行结果
|
||||
executionFuture.whenComplete((result, error) -> {
|
||||
// 取消超时任务
|
||||
timeoutTask.cancel(false);
|
||||
|
||||
|
||||
if (error != null) {
|
||||
if (error instanceof CancellationException) {
|
||||
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
|
||||
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已返回超时并停止外部HTTP资源;ScriptEngine将在执行线程退出后清理";
|
||||
playgroundLogger.errorJava(timeoutMsg);
|
||||
log.error(timeoutMsg);
|
||||
promise.fail(new RuntimeException(timeoutMsg));
|
||||
@@ -272,10 +319,10 @@ public class JsPlaygroundExecutor {
|
||||
promise.complete(result);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 执行parseById方法(异步,带超时控制)
|
||||
* 使用独立线程池,不受Vert.x BlockedThreadChecker监控
|
||||
@@ -284,9 +331,14 @@ public class JsPlaygroundExecutor {
|
||||
*/
|
||||
public Future<String> executeParseByIdAsync() {
|
||||
Promise<String> promise = Promise.promise();
|
||||
|
||||
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
||||
CompletableFuture<String> executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||
|
||||
final CompletableFuture<String> executionFuture;
|
||||
try {
|
||||
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
||||
executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||
beginExecution();
|
||||
try {
|
||||
ScriptEngine engine = engine();
|
||||
playgroundLogger.infoJava("开始执行parseById方法");
|
||||
try {
|
||||
Object parseByIdFunction = engine.get("parseById");
|
||||
@@ -300,8 +352,9 @@ public class JsPlaygroundExecutor {
|
||||
Object result = parseByIdMirror.call(null, shareLinkInfoWrapper, httpClient, playgroundLogger);
|
||||
|
||||
if (result instanceof String) {
|
||||
playgroundLogger.infoJava("按ID解析成功: " + result);
|
||||
return (String) result;
|
||||
String resultText = limitResultString((String) result, "parseById");
|
||||
playgroundLogger.infoJava("按ID解析成功,返回结果长度: " + resultText.length());
|
||||
return resultText;
|
||||
} else {
|
||||
String errorMsg = "parseById方法返回值类型错误,期望String,实际: " +
|
||||
(result != null ? result.getClass().getSimpleName() : "null");
|
||||
@@ -316,25 +369,27 @@ public class JsPlaygroundExecutor {
|
||||
playgroundLogger.errorJava("执行parseById方法失败: " + e.getMessage(), e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}, INDEPENDENT_EXECUTOR);
|
||||
|
||||
// 创建超时任务,强制取消执行
|
||||
ScheduledFuture<?> timeoutTask = TIMEOUT_SCHEDULER.schedule(() -> {
|
||||
if (!executionFuture.isDone()) {
|
||||
executionFuture.cancel(true); // 强制中断执行线程
|
||||
playgroundLogger.errorJava("执行超时,已强制中断");
|
||||
log.warn("JavaScript执行超时,已强制取消");
|
||||
} finally {
|
||||
finishExecution();
|
||||
}
|
||||
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
}, INDEPENDENT_EXECUTOR);
|
||||
} catch (java.util.concurrent.RejectedExecutionException e) {
|
||||
log.warn("演练场线程池已满,任务被拒绝");
|
||||
close(); // 释放已创建的 ScriptEngine 和 HttpClient 资源
|
||||
promise.fail(new RuntimeException("演练场线程池已满,请稍后重试", e));
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
ScheduledFuture<?> timeoutTask = scheduleTimeout(executionFuture, "parseById");
|
||||
|
||||
// 处理执行结果
|
||||
executionFuture.whenComplete((result, error) -> {
|
||||
// 取消超时任务
|
||||
timeoutTask.cancel(false);
|
||||
|
||||
|
||||
if (error != null) {
|
||||
if (error instanceof CancellationException) {
|
||||
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
|
||||
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已返回超时并停止外部HTTP资源;ScriptEngine将在执行线程退出后清理";
|
||||
playgroundLogger.errorJava(timeoutMsg);
|
||||
log.error(timeoutMsg);
|
||||
promise.fail(new RuntimeException(timeoutMsg));
|
||||
@@ -346,7 +401,7 @@ public class JsPlaygroundExecutor {
|
||||
promise.complete(result);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
@@ -355,7 +410,7 @@ public class JsPlaygroundExecutor {
|
||||
*/
|
||||
public List<JsPlaygroundLogger.LogEntry> getLogs() {
|
||||
List<JsPlaygroundLogger.LogEntry> logs = playgroundLogger.getLogs();
|
||||
System.out.println("[JsPlaygroundExecutor] 获取日志,数量: " + logs.size());
|
||||
log.debug("获取日志,数量: {}", logs.size());
|
||||
return logs;
|
||||
}
|
||||
|
||||
@@ -373,6 +428,9 @@ public class JsPlaygroundExecutor {
|
||||
List<FileInfo> fileList = new ArrayList<>();
|
||||
|
||||
if (resultMirror.isArray()) {
|
||||
if (resultMirror.size() > MAX_FILE_LIST_SIZE) {
|
||||
throw new RuntimeException("文件列表数量超过限制: " + resultMirror.size());
|
||||
}
|
||||
for (int i = 0; i < resultMirror.size(); i++) {
|
||||
Object item = resultMirror.get(String.valueOf(i));
|
||||
if (item instanceof ScriptObjectMirror) {
|
||||
@@ -396,13 +454,13 @@ public class JsPlaygroundExecutor {
|
||||
|
||||
// 设置基本字段
|
||||
if (itemMirror.hasMember("fileName")) {
|
||||
fileInfo.setFileName(itemMirror.getMember("fileName").toString());
|
||||
fileInfo.setFileName(limitField(itemMirror.getMember("fileName")));
|
||||
}
|
||||
if (itemMirror.hasMember("fileId")) {
|
||||
fileInfo.setFileId(itemMirror.getMember("fileId").toString());
|
||||
fileInfo.setFileId(limitField(itemMirror.getMember("fileId")));
|
||||
}
|
||||
if (itemMirror.hasMember("fileType")) {
|
||||
fileInfo.setFileType(itemMirror.getMember("fileType").toString());
|
||||
fileInfo.setFileType(limitField(itemMirror.getMember("fileType")));
|
||||
}
|
||||
if (itemMirror.hasMember("size")) {
|
||||
Object size = itemMirror.getMember("size");
|
||||
@@ -411,16 +469,16 @@ public class JsPlaygroundExecutor {
|
||||
}
|
||||
}
|
||||
if (itemMirror.hasMember("sizeStr")) {
|
||||
fileInfo.setSizeStr(itemMirror.getMember("sizeStr").toString());
|
||||
fileInfo.setSizeStr(limitField(itemMirror.getMember("sizeStr")));
|
||||
}
|
||||
if (itemMirror.hasMember("createTime")) {
|
||||
fileInfo.setCreateTime(itemMirror.getMember("createTime").toString());
|
||||
fileInfo.setCreateTime(limitField(itemMirror.getMember("createTime")));
|
||||
}
|
||||
if (itemMirror.hasMember("updateTime")) {
|
||||
fileInfo.setUpdateTime(itemMirror.getMember("updateTime").toString());
|
||||
fileInfo.setUpdateTime(limitField(itemMirror.getMember("updateTime")));
|
||||
}
|
||||
if (itemMirror.hasMember("createBy")) {
|
||||
fileInfo.setCreateBy(itemMirror.getMember("createBy").toString());
|
||||
fileInfo.setCreateBy(limitField(itemMirror.getMember("createBy")));
|
||||
}
|
||||
if (itemMirror.hasMember("downloadCount")) {
|
||||
Object downloadCount = itemMirror.getMember("downloadCount");
|
||||
@@ -429,24 +487,154 @@ public class JsPlaygroundExecutor {
|
||||
}
|
||||
}
|
||||
if (itemMirror.hasMember("fileIcon")) {
|
||||
fileInfo.setFileIcon(itemMirror.getMember("fileIcon").toString());
|
||||
fileInfo.setFileIcon(limitField(itemMirror.getMember("fileIcon")));
|
||||
}
|
||||
if (itemMirror.hasMember("panType")) {
|
||||
fileInfo.setPanType(itemMirror.getMember("panType").toString());
|
||||
fileInfo.setPanType(limitField(itemMirror.getMember("panType")));
|
||||
}
|
||||
if (itemMirror.hasMember("parserUrl")) {
|
||||
fileInfo.setParserUrl(itemMirror.getMember("parserUrl").toString());
|
||||
fileInfo.setParserUrl(limitField(itemMirror.getMember("parserUrl")));
|
||||
}
|
||||
if (itemMirror.hasMember("previewUrl")) {
|
||||
fileInfo.setPreviewUrl(itemMirror.getMember("previewUrl").toString());
|
||||
fileInfo.setPreviewUrl(limitField(itemMirror.getMember("previewUrl")));
|
||||
}
|
||||
|
||||
return fileInfo;
|
||||
|
||||
|
||||
} catch (Exception e) {
|
||||
playgroundLogger.errorJava("转换FileInfo对象失败", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String limitResultString(String value, String operation) {
|
||||
if (value.length() > MAX_RESULT_STRING_LENGTH) {
|
||||
throw new RuntimeException(operation + " 返回结果过大: " + value.length() + " 字符");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String limitField(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.toString();
|
||||
if (text.length() > MAX_FILE_FIELD_LENGTH) {
|
||||
throw new RuntimeException("文件字段过长: " + text.length() + " 字符");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private void beginExecution() {
|
||||
synchronized (lifecycleLock) {
|
||||
if (closed) {
|
||||
throw new CancellationException("演练场执行器已关闭");
|
||||
}
|
||||
if (running) {
|
||||
throw new IllegalStateException("演练场执行器已在运行");
|
||||
}
|
||||
running = true;
|
||||
}
|
||||
}
|
||||
|
||||
private ScriptEngine engine() {
|
||||
ScriptEngine current = engine;
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
synchronized (engineLock) {
|
||||
if (closed) {
|
||||
throw new CancellationException("演练场执行器已关闭");
|
||||
}
|
||||
if (engine == null) {
|
||||
engine = initEngine();
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
|
||||
private void finishExecution() {
|
||||
synchronized (lifecycleLock) {
|
||||
running = false;
|
||||
if (closeRequested) {
|
||||
doClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ScheduledFuture<?> scheduleTimeout(CompletableFuture<?> executionFuture, String operation) {
|
||||
// cancel(true) 只能请求中断,Nashorn 死循环不保证立即停止。
|
||||
return TIMEOUT_SCHEDULER.schedule(() -> {
|
||||
if (!executionFuture.isDone()) {
|
||||
executionFuture.cancel(true);
|
||||
playgroundLogger.errorJava(operation + " 执行超时,已请求取消并停止外部HTTP资源");
|
||||
forceCloseAfterTimeout();
|
||||
log.warn("JavaScript {} 执行超时,已请求取消;Nashorn长循环可能继续占用线程,ScriptEngine将在执行线程退出后清理", operation);
|
||||
}
|
||||
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void forceCloseAfterTimeout() {
|
||||
synchronized (lifecycleLock) {
|
||||
closeRequested = true;
|
||||
if (running || closed) {
|
||||
closeExternalResources();
|
||||
} else {
|
||||
doClose();
|
||||
}
|
||||
}
|
||||
playgroundLogger.trimToLast(TIMEOUT_LOG_RETAIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资源(HttpClient 和 ScriptEngine),避免内存泄漏
|
||||
* 幂等:可安全多次调用
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
synchronized (lifecycleLock) {
|
||||
closeRequested = true;
|
||||
if (running || closed) {
|
||||
closeExternalResources();
|
||||
return;
|
||||
}
|
||||
doClose();
|
||||
}
|
||||
}
|
||||
|
||||
private void doClose() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
closeRequested = false;
|
||||
closeExternalResources();
|
||||
cleanupEngine();
|
||||
log.debug("JsPlaygroundExecutor 资源已释放");
|
||||
}
|
||||
|
||||
private void closeExternalResources() {
|
||||
if (httpClient != null) {
|
||||
httpClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupEngine() {
|
||||
// 清除 ScriptEngine 的所有 bindings,释放 JS 运行时引用
|
||||
if (engine != null) {
|
||||
try {
|
||||
// 清除注入的 Java 对象引用
|
||||
engine.put("http", null);
|
||||
engine.put("logger", null);
|
||||
engine.put("shareLinkInfo", null);
|
||||
engine.put("JavaFetch", null);
|
||||
// 清除所有 ENGINE_SCOPE bindings,包括 eval 加载的 JS 函数
|
||||
var bindings = engine.getBindings(javax.script.ScriptContext.ENGINE_SCOPE);
|
||||
if (bindings != null) {
|
||||
bindings.clear();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("清理 ScriptEngine bindings 失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* 演练场日志收集器
|
||||
* 收集JavaScript执行过程中的日志信息
|
||||
@@ -12,8 +15,12 @@ import java.util.List;
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
*/
|
||||
public class JsPlaygroundLogger {
|
||||
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JsPlaygroundLogger.class);
|
||||
|
||||
// 使用线程安全的列表
|
||||
private static final int MAX_LOG_SIZE = 1000;
|
||||
private static final int MAX_LOG_MESSAGE_LENGTH = 4096;
|
||||
private final List<LogEntry> logs = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
/**
|
||||
@@ -56,9 +63,25 @@ public class JsPlaygroundLogger {
|
||||
if (obj == null) {
|
||||
return "null";
|
||||
}
|
||||
return obj.toString();
|
||||
String msg = obj.toString();
|
||||
if (msg.length() <= MAX_LOG_MESSAGE_LENGTH) {
|
||||
return msg;
|
||||
}
|
||||
return msg.substring(0, MAX_LOG_MESSAGE_LENGTH) + "...[truncated]";
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加日志条目,超过最大容量时移除最早的条目
|
||||
*/
|
||||
private void addLog(LogEntry entry) {
|
||||
synchronized (logs) {
|
||||
if (logs.size() >= MAX_LOG_SIZE) {
|
||||
logs.remove(0);
|
||||
}
|
||||
logs.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录日志(内部方法)
|
||||
* @param level 日志级别
|
||||
@@ -67,8 +90,8 @@ public class JsPlaygroundLogger {
|
||||
*/
|
||||
private void log(String level, Object message, String source) {
|
||||
String msg = toString(message);
|
||||
logs.add(new LogEntry(level, msg, source));
|
||||
System.out.println("[" + source + "PlaygroundLogger] " + level + ": " + msg);
|
||||
addLog(new LogEntry(level, msg, source));
|
||||
log.debug("[{}PlaygroundLogger] {}: {}", source, level, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,10 +132,10 @@ public class JsPlaygroundLogger {
|
||||
public void error(Object message, Throwable throwable) {
|
||||
String msg = toString(message);
|
||||
if (throwable != null) {
|
||||
msg = msg + ": " + throwable.getMessage();
|
||||
msg = toString(msg + ": " + throwable.getMessage());
|
||||
}
|
||||
logs.add(new LogEntry("ERROR", msg, "JS"));
|
||||
System.out.println("[JSPlaygroundLogger] ERROR: " + msg);
|
||||
addLog(new LogEntry("ERROR", msg, "JS"));
|
||||
log.debug("[JSPlaygroundLogger] ERROR: {}", msg);
|
||||
}
|
||||
|
||||
// ===== 以下是供Java层调用的内部方法 =====
|
||||
@@ -149,12 +172,12 @@ public class JsPlaygroundLogger {
|
||||
* 错误日志(带异常,供Java层调用)
|
||||
*/
|
||||
public void errorJava(String message, Throwable throwable) {
|
||||
String msg = message;
|
||||
String msg = toString(message);
|
||||
if (throwable != null) {
|
||||
msg = msg + ": " + throwable.getMessage();
|
||||
msg = toString(msg + ": " + throwable.getMessage());
|
||||
}
|
||||
logs.add(new LogEntry("ERROR", msg, "JAVA"));
|
||||
System.out.println("[JAVAPlaygroundLogger] ERROR: " + msg);
|
||||
addLog(new LogEntry("ERROR", msg, "JAVA"));
|
||||
log.debug("[JAVAPlaygroundLogger] ERROR: {}", msg);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,4 +202,17 @@ public class JsPlaygroundLogger {
|
||||
public void clear() {
|
||||
logs.clear();
|
||||
}
|
||||
|
||||
public void trimToLast(int maxEntries) {
|
||||
if (maxEntries < 0) {
|
||||
throw new IllegalArgumentException("maxEntries不能小于0");
|
||||
}
|
||||
synchronized (logs) {
|
||||
int removeCount = logs.size() - maxEntries;
|
||||
if (removeCount <= 0) {
|
||||
return;
|
||||
}
|
||||
logs.subList(0, removeCount).clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ public class JsScriptLoader {
|
||||
|
||||
private static final String RESOURCE_PATH = "custom-parsers";
|
||||
private static final String EXTERNAL_PATH = "./custom-parsers";
|
||||
private static final long MAX_SCRIPT_SIZE_BYTES = 128 * 1024;
|
||||
private static final int MAX_EXTERNAL_SCRIPT_COUNT = 100;
|
||||
|
||||
// 系统属性配置的外部目录路径
|
||||
private static final String EXTERNAL_PATH_PROPERTY = "parser.custom-parsers.path";
|
||||
@@ -81,14 +83,16 @@ public class JsScriptLoader {
|
||||
try {
|
||||
InputStream inputStream = JsScriptLoader.class.getClassLoader()
|
||||
.getResourceAsStream(resourceFile);
|
||||
|
||||
|
||||
if (inputStream != null) {
|
||||
String jsCode = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode);
|
||||
configs.add(config);
|
||||
|
||||
String fileName = resourceFile.substring(resourceFile.lastIndexOf('/') + 1);
|
||||
log.debug("从资源目录加载脚本: {}", fileName);
|
||||
try (inputStream) {
|
||||
String jsCode = readResourceScript(inputStream, resourceFile);
|
||||
CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode);
|
||||
configs.add(config);
|
||||
|
||||
String fileName = resourceFile.substring(resourceFile.lastIndexOf('/') + 1);
|
||||
log.debug("从资源目录加载脚本: {}", fileName);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("加载资源脚本失败: {}", resourceFile, e);
|
||||
@@ -139,21 +143,20 @@ public class JsScriptLoader {
|
||||
|
||||
try {
|
||||
String jarPath = jarUrl.getPath().substring(5, jarUrl.getPath().indexOf("!"));
|
||||
JarFile jarFile = new JarFile(jarPath);
|
||||
|
||||
Enumeration<JarEntry> entries = jarFile.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
JarEntry entry = entries.nextElement();
|
||||
String entryName = entry.getName();
|
||||
|
||||
if (entryName.startsWith(RESOURCE_PATH + "/") &&
|
||||
entryName.endsWith(".js") &&
|
||||
!isExcludedFile(entryName.substring(entryName.lastIndexOf('/') + 1))) {
|
||||
resourceFiles.add(entryName);
|
||||
|
||||
try (JarFile jarFile = new JarFile(jarPath)) {
|
||||
Enumeration<JarEntry> entries = jarFile.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
JarEntry entry = entries.nextElement();
|
||||
String entryName = entry.getName();
|
||||
|
||||
if (entryName.startsWith(RESOURCE_PATH + "/") &&
|
||||
entryName.endsWith(".js") &&
|
||||
!isExcludedFile(entryName.substring(entryName.lastIndexOf('/') + 1))) {
|
||||
resourceFiles.add(entryName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jarFile.close();
|
||||
} catch (Exception e) {
|
||||
log.debug("解析JAR包资源文件失败", e);
|
||||
}
|
||||
@@ -208,8 +211,10 @@ public class JsScriptLoader {
|
||||
paths.filter(Files::isRegularFile)
|
||||
.filter(path -> path.toString().endsWith(".js"))
|
||||
.filter(path -> !isExcludedFile(path.getFileName().toString()))
|
||||
.limit(MAX_EXTERNAL_SCRIPT_COUNT)
|
||||
.forEach(path -> {
|
||||
try {
|
||||
ensureScriptSize(path);
|
||||
String jsCode = Files.readString(path, StandardCharsets.UTF_8);
|
||||
CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode);
|
||||
configs.add(config);
|
||||
@@ -263,6 +268,7 @@ public class JsScriptLoader {
|
||||
throw new IllegalArgumentException("文件不存在: " + filePath);
|
||||
}
|
||||
|
||||
ensureScriptSize(path);
|
||||
String jsCode = Files.readString(path, StandardCharsets.UTF_8);
|
||||
return JsScriptMetadataParser.parseScript(jsCode);
|
||||
|
||||
@@ -280,14 +286,16 @@ public class JsScriptLoader {
|
||||
try {
|
||||
InputStream inputStream = JsScriptLoader.class.getClassLoader()
|
||||
.getResourceAsStream(resourcePath);
|
||||
|
||||
|
||||
if (inputStream == null) {
|
||||
throw new IllegalArgumentException("资源文件不存在: " + resourcePath);
|
||||
}
|
||||
|
||||
String jsCode = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
return JsScriptMetadataParser.parseScript(jsCode);
|
||||
|
||||
|
||||
try (inputStream) {
|
||||
String jsCode = readResourceScript(inputStream, resourcePath);
|
||||
return JsScriptMetadataParser.parseScript(jsCode);
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("读取资源文件失败: " + resourcePath, e);
|
||||
}
|
||||
@@ -347,4 +355,19 @@ public class JsScriptLoader {
|
||||
fileName.contains(".test.") ||
|
||||
fileName.contains(".spec.");
|
||||
}
|
||||
|
||||
private static void ensureScriptSize(Path path) throws IOException {
|
||||
long size = Files.size(path);
|
||||
if (size > MAX_SCRIPT_SIZE_BYTES) {
|
||||
throw new IllegalArgumentException("JavaScript脚本超过128KB限制: " + path.getFileName());
|
||||
}
|
||||
}
|
||||
|
||||
private static String readResourceScript(InputStream inputStream, String name) throws IOException {
|
||||
byte[] bytes = inputStream.readNBytes((int) MAX_SCRIPT_SIZE_BYTES + 1);
|
||||
if (bytes.length > MAX_SCRIPT_SIZE_BYTES) {
|
||||
throw new IllegalArgumentException("JavaScript资源脚本超过128KB限制: " + name);
|
||||
}
|
||||
return new String(bytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
package cn.qaiu.parser.custompy;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Python 代码安全检查器
|
||||
* 在执行前对代码进行静态分析,检测危险操作
|
||||
*/
|
||||
public class PyCodeSecurityChecker {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PyCodeSecurityChecker.class);
|
||||
|
||||
/**
|
||||
* 危险的导入模块
|
||||
*/
|
||||
private static final Set<String> DANGEROUS_IMPORTS = Set.of(
|
||||
"subprocess", // 子进程执行
|
||||
"socket", // 原始网络套接字
|
||||
"ctypes", // C 语言接口
|
||||
"_ctypes", // C 语言接口
|
||||
"multiprocessing", // 多进程
|
||||
"threading", // 多线程(可选禁止)
|
||||
"asyncio", // 异步IO(可选禁止)
|
||||
"pty", // 伪终端
|
||||
"fcntl", // 文件控制
|
||||
"resource", // 资源限制
|
||||
"syslog", // 系统日志
|
||||
"signal" // 信号处理
|
||||
);
|
||||
|
||||
/**
|
||||
* 危险的 os 模块方法
|
||||
*/
|
||||
private static final Set<String> DANGEROUS_OS_METHODS = Set.of(
|
||||
"system", // 执行系统命令
|
||||
"popen", // 打开进程管道
|
||||
"spawn", // 生成进程
|
||||
"spawnl", "spawnle", "spawnlp", "spawnlpe",
|
||||
"spawnv", "spawnve", "spawnvp", "spawnvpe",
|
||||
"exec", "execl", "execle", "execlp", "execlpe",
|
||||
"execv", "execve", "execvp", "execvpe",
|
||||
"fork", "forkpty",
|
||||
"kill", "killpg",
|
||||
"remove", "unlink",
|
||||
"rmdir", "removedirs",
|
||||
"mkdir", "makedirs",
|
||||
"rename", "renames", "replace",
|
||||
"chmod", "chown", "lchown",
|
||||
"chroot",
|
||||
"mknod", "mkfifo",
|
||||
"link", "symlink"
|
||||
);
|
||||
|
||||
/**
|
||||
* 危险的内置函数
|
||||
*/
|
||||
private static final Set<String> DANGEROUS_BUILTINS = Set.of(
|
||||
"exec", // 执行代码
|
||||
"eval", // 评估表达式
|
||||
"compile", // 编译代码
|
||||
"__import__" // 动态导入
|
||||
);
|
||||
|
||||
/**
|
||||
* 检查代码安全性
|
||||
* @param code Python 代码
|
||||
* @return 安全检查结果
|
||||
*/
|
||||
public static SecurityCheckResult check(String code) {
|
||||
if (code == null || code.trim().isEmpty()) {
|
||||
return SecurityCheckResult.fail("代码为空");
|
||||
}
|
||||
|
||||
List<String> violations = new ArrayList<>();
|
||||
|
||||
// 1. 检查危险导入
|
||||
for (String module : DANGEROUS_IMPORTS) {
|
||||
if (containsImport(code, module)) {
|
||||
violations.add("禁止导入危险模块: " + module);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查危险的 os 方法调用
|
||||
for (String method : DANGEROUS_OS_METHODS) {
|
||||
if (containsOsMethodCall(code, method)) {
|
||||
violations.add("禁止使用危险的 os 方法: os." + method + "()");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查危险的内置函数
|
||||
for (String builtin : DANGEROUS_BUILTINS) {
|
||||
if (containsBuiltinCall(code, builtin)) {
|
||||
violations.add("禁止使用危险的内置函数: " + builtin + "()");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 检查危险的文件操作模式
|
||||
if (containsDangerousFileOperation(code)) {
|
||||
violations.add("禁止使用危险的文件写入操作");
|
||||
}
|
||||
|
||||
if (violations.isEmpty()) {
|
||||
return SecurityCheckResult.pass();
|
||||
} else {
|
||||
return SecurityCheckResult.fail(String.join("; ", violations));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否包含指定模块的导入
|
||||
*/
|
||||
private static boolean containsImport(String code, String module) {
|
||||
// 匹配: import module / from module import xxx
|
||||
String pattern1 = "(?m)^\\s*import\\s+" + Pattern.quote(module) + "\\b";
|
||||
String pattern2 = "(?m)^\\s*from\\s+" + Pattern.quote(module) + "\\s+import";
|
||||
|
||||
return Pattern.compile(pattern1).matcher(code).find() ||
|
||||
Pattern.compile(pattern2).matcher(code).find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否包含指定的 os 方法调用
|
||||
*/
|
||||
private static boolean containsOsMethodCall(String code, String method) {
|
||||
// 匹配: os.method(
|
||||
String pattern = "\\bos\\s*\\.\\s*" + Pattern.quote(method) + "\\s*\\(";
|
||||
return Pattern.compile(pattern).matcher(code).find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否包含指定的内置函数调用
|
||||
*/
|
||||
private static boolean containsBuiltinCall(String code, String builtin) {
|
||||
// 匹配: builtin( 但排除方法调用 xxx.builtin(
|
||||
String pattern = "(?<!\\.)\\b" + Pattern.quote(builtin) + "\\s*\\(";
|
||||
return Pattern.compile(pattern).matcher(code).find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否包含危险的文件操作
|
||||
*/
|
||||
private static boolean containsDangerousFileOperation(String code) {
|
||||
// 检查 open() 的写入模式
|
||||
Pattern openPattern = Pattern.compile("\\bopen\\s*\\([^)]*['\"][wax+]['\"]");
|
||||
if (openPattern.matcher(code).find()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查直接的文件写入
|
||||
Pattern writePattern = Pattern.compile("\\.write\\s*\\(|\\.writelines\\s*\\(");
|
||||
if (writePattern.matcher(code).find()) {
|
||||
// 需要进一步判断是否是文件写入而不是 response 写入等
|
||||
// 这里简单处理,如果有 write 调用但没有 requests/http 相关的上下文,则禁止
|
||||
if (!code.contains("requests") && !code.contains("http")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全检查结果
|
||||
*/
|
||||
public static class SecurityCheckResult {
|
||||
private final boolean passed;
|
||||
private final String message;
|
||||
|
||||
private SecurityCheckResult(boolean passed, String message) {
|
||||
this.passed = passed;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public static SecurityCheckResult pass() {
|
||||
return new SecurityCheckResult(true, null);
|
||||
}
|
||||
|
||||
public static SecurityCheckResult fail(String message) {
|
||||
return new SecurityCheckResult(false, message);
|
||||
}
|
||||
|
||||
public boolean isPassed() {
|
||||
return passed;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return passed ? "PASSED" : "FAILED: " + message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,817 +0,0 @@
|
||||
package cn.qaiu.parser.custompy;
|
||||
|
||||
import org.graalvm.polyglot.Context;
|
||||
import org.graalvm.polyglot.Engine;
|
||||
import org.graalvm.polyglot.HostAccess;
|
||||
import org.graalvm.polyglot.Value;
|
||||
import org.graalvm.polyglot.io.IOAccess;
|
||||
import org.graalvm.python.embedding.utils.GraalPyResources;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* GraalPy Context 池化管理器
|
||||
* 提供共享的 Engine 实例和 Context 池化支持
|
||||
* 支持真正的 pip 包(如 requests)
|
||||
*
|
||||
* <p>特性:
|
||||
* <ul>
|
||||
* <li>共享单个 Engine 实例,减少内存占用和启动时间</li>
|
||||
* <li>Context 对象池,避免重复创建和销毁的开销</li>
|
||||
* <li>支持真正的 pip 包(通过 GraalPy Resources)</li>
|
||||
* <li>支持安全的沙箱配置</li>
|
||||
* <li>线程安全的池化管理</li>
|
||||
* <li>支持优雅关闭和资源清理</li>
|
||||
* <li>路径缓存,避免重复检测文件系统</li>
|
||||
* <li>预热机制,在后台预导入常用模块</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author QAIU
|
||||
*/
|
||||
public class PyContextPool {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PyContextPool.class);
|
||||
|
||||
// 池化配置 - 增加初始池大小和延长生命周期
|
||||
private static final int INITIAL_POOL_SIZE = 4;
|
||||
private static final int MAX_POOL_SIZE = 10;
|
||||
private static final long CONTEXT_TIMEOUT_MS = 30000; // 30秒获取超时
|
||||
private static final long CONTEXT_MAX_AGE_MS = 900000; // 15分钟最大使用时间
|
||||
|
||||
// 路径缓存 - 避免重复检测文件系统
|
||||
private static volatile List<String> cachedValidPaths = null;
|
||||
private static final Object PATH_CACHE_LOCK = new Object();
|
||||
|
||||
// 单例实例
|
||||
private static volatile PyContextPool instance;
|
||||
private static final Object LOCK = new Object();
|
||||
|
||||
// 共享的GraalPy引擎
|
||||
private final Engine sharedEngine;
|
||||
|
||||
// Context 池
|
||||
private final BlockingQueue<PooledContext> contextPool;
|
||||
|
||||
// 已创建的Context数量
|
||||
private final AtomicInteger createdCount = new AtomicInteger(0);
|
||||
|
||||
// 是否已关闭
|
||||
private final AtomicBoolean closed = new AtomicBoolean(false);
|
||||
|
||||
// 定期清理过期Context的调度器
|
||||
private final ScheduledExecutorService cleanupScheduler;
|
||||
|
||||
// Python执行专用线程池
|
||||
private final ExecutorService pythonExecutor;
|
||||
|
||||
// 超时调度器
|
||||
private final ScheduledExecutorService timeoutScheduler;
|
||||
|
||||
/**
|
||||
* 池化的Context包装器
|
||||
*/
|
||||
public static class PooledContext implements AutoCloseable {
|
||||
private final Context context;
|
||||
private final long createdTime;
|
||||
private final PyContextPool pool;
|
||||
private volatile boolean inUse = false;
|
||||
private volatile long lastUsedTime;
|
||||
|
||||
private PooledContext(Context context, PyContextPool pool) {
|
||||
this.context = context;
|
||||
this.pool = pool;
|
||||
this.createdTime = System.currentTimeMillis();
|
||||
this.lastUsedTime = createdTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取底层Context
|
||||
*/
|
||||
public Context getContext() {
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否过期
|
||||
*/
|
||||
public boolean isExpired() {
|
||||
return System.currentTimeMillis() - createdTime > CONTEXT_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归还到池中或关闭
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
pool.release(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制关闭Context
|
||||
*/
|
||||
void forceClose() {
|
||||
try {
|
||||
context.close(true);
|
||||
} catch (Exception e) {
|
||||
log.warn("关闭Context失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置Context状态(清除绑定等)
|
||||
*/
|
||||
boolean reset() {
|
||||
try {
|
||||
// 由于GraalPy的Context不能很好地重置状态,
|
||||
// 简单场景下我们选择创建新的Context
|
||||
// 但对于短生命周期的执行,可以尝试继续使用
|
||||
lastUsedTime = System.currentTimeMillis();
|
||||
return !isExpired();
|
||||
} catch (Exception e) {
|
||||
log.warn("重置Context失败: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 私有构造函数
|
||||
*/
|
||||
private PyContextPool() {
|
||||
log.info("初始化GraalPy Context池...");
|
||||
|
||||
// 创建共享Engine - 使用标准Polyglot API
|
||||
Engine engine = null;
|
||||
try {
|
||||
engine = Engine.newBuilder()
|
||||
.option("engine.WarnInterpreterOnly", "false")
|
||||
.build();
|
||||
|
||||
// 验证Python语言是否可用
|
||||
if (!engine.getLanguages().containsKey("python")) {
|
||||
throw new IllegalStateException("Python语言不可用,请检查GraalPy依赖配置");
|
||||
}
|
||||
log.info("Engine创建成功,可用语言: {}", engine.getLanguages().keySet());
|
||||
} catch (Exception e) {
|
||||
log.error("创建Engine失败: {}", e.getMessage());
|
||||
checkGraalPyAvailability();
|
||||
throw new RuntimeException("无法初始化GraalPy Engine,请确保GraalPy依赖正确配置", e);
|
||||
}
|
||||
this.sharedEngine = engine;
|
||||
|
||||
// 创建Context池
|
||||
this.contextPool = new LinkedBlockingQueue<>(MAX_POOL_SIZE);
|
||||
|
||||
// 创建Python执行专用线程池
|
||||
this.pythonExecutor = Executors.newCachedThreadPool(r -> {
|
||||
Thread thread = new Thread(r);
|
||||
thread.setName("py-context-pool-worker-" + System.currentTimeMillis());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
|
||||
// 创建超时调度器
|
||||
this.timeoutScheduler = Executors.newScheduledThreadPool(2, r -> {
|
||||
Thread thread = new Thread(r);
|
||||
thread.setName("py-context-timeout-" + System.currentTimeMillis());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
|
||||
// 创建清理调度器
|
||||
this.cleanupScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread thread = new Thread(r);
|
||||
thread.setName("py-context-cleanup");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
|
||||
// 预热:初始化一些Context
|
||||
warmup();
|
||||
|
||||
// 定期清理过期的Context
|
||||
cleanupScheduler.scheduleWithFixedDelay(this::cleanup, 60, 60, TimeUnit.SECONDS);
|
||||
|
||||
log.info("GraalPy Context池初始化完成,初始大小: {}", INITIAL_POOL_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
public static PyContextPool getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (LOCK) {
|
||||
if (instance == null) {
|
||||
instance = new PyContextPool();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取共享Engine
|
||||
*/
|
||||
public Engine getSharedEngine() {
|
||||
return sharedEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Python执行线程池
|
||||
*/
|
||||
public ExecutorService getPythonExecutor() {
|
||||
return pythonExecutor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取超时调度器
|
||||
*/
|
||||
public ScheduledExecutorService getTimeoutScheduler() {
|
||||
return timeoutScheduler;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预热Context池
|
||||
* 在后台线程中预创建 Context 并预导入常用模块
|
||||
*/
|
||||
private void warmup() {
|
||||
log.info("开始预热 Context 池,目标数量: {}", INITIAL_POOL_SIZE);
|
||||
|
||||
// 使用线程池并行预热
|
||||
for (int i = 0; i < INITIAL_POOL_SIZE; i++) {
|
||||
final int index = i;
|
||||
pythonExecutor.submit(() -> {
|
||||
try {
|
||||
long start = System.currentTimeMillis();
|
||||
PooledContext pc = createPooledContext();
|
||||
|
||||
// 预导入 requests 模块(主要耗时点)
|
||||
try {
|
||||
warmupContext(pc.getContext());
|
||||
} catch (Exception e) {
|
||||
log.debug("预热 Context {} 导入模块失败(非首个Context的NativeModules限制): {}",
|
||||
index, e.getMessage());
|
||||
}
|
||||
|
||||
if (!contextPool.offer(pc)) {
|
||||
pc.forceClose();
|
||||
} else {
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
log.info("预热 Context {} 完成,耗时: {}ms", index, elapsed);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("预热 Context {} 失败: {}", index, e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预热单个 Context - 预导入常用模块
|
||||
*/
|
||||
private void warmupContext(Context context) {
|
||||
String warmupScript = """
|
||||
# 预导入常用模块
|
||||
import json
|
||||
import re
|
||||
import base64
|
||||
import hashlib
|
||||
import urllib.parse
|
||||
|
||||
# 尝试导入 requests(可能因 NativeModules 限制失败)
|
||||
try:
|
||||
import requests
|
||||
except (ImportError, SystemError):
|
||||
pass
|
||||
""";
|
||||
context.eval("python", warmupScript);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的池化Context
|
||||
* 使用 GraalPyResources 支持 pip 包
|
||||
*/
|
||||
private PooledContext createPooledContext() {
|
||||
if (closed.get()) {
|
||||
throw new IllegalStateException("Context池已关闭");
|
||||
}
|
||||
|
||||
Context context;
|
||||
try {
|
||||
// 检查 VFS 资源是否存在
|
||||
var vfsResource = getClass().getClassLoader().getResource("org.graalvm.python.vfs/venv");
|
||||
log.info("GraalPy VFS资源检查: venv={}", vfsResource != null ? "存在" : "不存在");
|
||||
|
||||
// 使用 GraalPyResources 创建支持 pip 包的 Context
|
||||
// 注意:不传入共享 Engine,让 GraalPyResources 管理自己的 Engine
|
||||
log.info("正在创建 GraalPyResources Context...");
|
||||
context = GraalPyResources.contextBuilder()
|
||||
.allowHostAccess(HostAccess.newBuilder(HostAccess.EXPLICIT)
|
||||
.allowArrayAccess(true)
|
||||
.allowListAccess(true)
|
||||
.allowMapAccess(true)
|
||||
.allowIterableAccess(true)
|
||||
.allowIteratorAccess(true)
|
||||
.build())
|
||||
.allowExperimentalOptions(true)
|
||||
.allowCreateThread(true)
|
||||
// 允许 IO 以支持 pip 包加载和网络请求
|
||||
.allowIO(IOAccess.ALL)
|
||||
.allowNativeAccess(true)
|
||||
.option("engine.WarnInterpreterOnly", "false")
|
||||
.build();
|
||||
log.info("GraalPyResources Context 创建成功");
|
||||
|
||||
// 配置 Python 路径
|
||||
setupPythonPath(context);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("使用GraalPyResources创建Context失败: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("无法创建支持pip包的Python Context: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
createdCount.incrementAndGet();
|
||||
log.debug("创建新的GraalPy Context,当前总数: {}", createdCount.get());
|
||||
|
||||
return new PooledContext(context, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从池中获取Context
|
||||
*
|
||||
* @return 池化的Context,用完后需要调用close()归还
|
||||
* @throws InterruptedException 如果等待被中断
|
||||
* @throws TimeoutException 如果超时未获取到
|
||||
*/
|
||||
public PooledContext acquire() throws InterruptedException, TimeoutException {
|
||||
if (closed.get()) {
|
||||
throw new IllegalStateException("Context池已关闭");
|
||||
}
|
||||
|
||||
// 尝试从池中获取
|
||||
PooledContext pc = contextPool.poll();
|
||||
|
||||
if (pc != null) {
|
||||
if (!pc.isExpired() && pc.reset()) {
|
||||
pc.inUse = true;
|
||||
log.debug("从池中获取Context,池剩余: {}", contextPool.size());
|
||||
return pc;
|
||||
} else {
|
||||
// Context已过期,关闭它
|
||||
pc.forceClose();
|
||||
createdCount.decrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
// 池中没有可用的,检查是否可以创建新的
|
||||
if (createdCount.get() < MAX_POOL_SIZE) {
|
||||
try {
|
||||
pc = createPooledContext();
|
||||
pc.inUse = true;
|
||||
return pc;
|
||||
} catch (Exception e) {
|
||||
log.error("创建新Context失败: {}", e.getMessage());
|
||||
throw new RuntimeException("无法创建GraalPy Context", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 已达最大数量,等待归还
|
||||
pc = contextPool.poll(CONTEXT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||
if (pc == null) {
|
||||
throw new TimeoutException("获取GraalPy Context超时");
|
||||
}
|
||||
|
||||
if (!pc.isExpired() && pc.reset()) {
|
||||
pc.inUse = true;
|
||||
return pc;
|
||||
} else {
|
||||
pc.forceClose();
|
||||
createdCount.decrementAndGet();
|
||||
// 递归重试
|
||||
return acquire();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个新的非池化Context(用于需要独立生命周期的场景)
|
||||
* 调用者负责管理其生命周期
|
||||
* 支持真正的 pip 包(如 requests, zlib 等)
|
||||
*
|
||||
* 注意:GraalPyResources 需要独立的 Engine,不能与共享 Engine 一起使用
|
||||
*/
|
||||
public Context createFreshContext() {
|
||||
try {
|
||||
// 检查 VFS 资源是否存在
|
||||
var vfsResource = getClass().getClassLoader().getResource("org.graalvm.python.vfs/venv");
|
||||
var homeResource = getClass().getClassLoader().getResource("org.graalvm.python.vfs/home");
|
||||
log.info("GraalPy VFS资源检查: venv={}, home={}",
|
||||
vfsResource != null ? "存在" : "不存在",
|
||||
homeResource != null ? "存在" : "不存在");
|
||||
|
||||
// 使用 GraalPyResources 创建支持 pip 包的 Context
|
||||
// 注意:不传入共享 Engine,让 GraalPyResources 管理自己的 Engine
|
||||
log.info("正在创建 GraalPyResources FreshContext...");
|
||||
Context ctx = GraalPyResources.contextBuilder()
|
||||
.allowHostAccess(HostAccess.newBuilder(HostAccess.EXPLICIT)
|
||||
.allowArrayAccess(true)
|
||||
.allowListAccess(true)
|
||||
.allowMapAccess(true)
|
||||
.allowIterableAccess(true)
|
||||
.allowIteratorAccess(true)
|
||||
.build())
|
||||
.allowExperimentalOptions(true)
|
||||
.allowCreateThread(true)
|
||||
// 允许 IO 以支持 pip 包加载和网络请求
|
||||
.allowIO(IOAccess.ALL)
|
||||
.allowNativeAccess(true)
|
||||
.option("engine.WarnInterpreterOnly", "false")
|
||||
.build();
|
||||
log.info("GraalPyResources FreshContext 创建成功");
|
||||
|
||||
// 手动配置 Python 路径以加载 VFS 中的 pip 包
|
||||
setupPythonPath(ctx);
|
||||
|
||||
return ctx;
|
||||
} catch (Exception e) {
|
||||
log.error("使用GraalPyResources创建Context失败: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("无法创建支持pip包的Python Context: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置 Python 路径,确保能够加载 pip 包
|
||||
* 使用路径缓存机制,避免重复检测文件系统
|
||||
*
|
||||
* pip 包安装来源:
|
||||
* 1. Maven 构建时 graalpy-maven-plugin 自动安装 requests 等核心包到
|
||||
* target/classes/org.graalvm.python.vfs/venv/lib/python3.11/site-packages/
|
||||
* 2. 可选:运行 parser/setup-graalpy-packages.sh 安装 python-lsp-server(Playground 代码提示)
|
||||
*
|
||||
* 注意:GraalPy 的 NativeModules 限制 - 只有进程中的第一个 Context 可以使用原生模块。
|
||||
* 后续 Context 会回退到 LLVM 模式,这可能导致某些依赖原生模块的库无法正常工作。
|
||||
*/
|
||||
private void setupPythonPath(Context context) {
|
||||
try {
|
||||
log.debug("配置 Python 环境...");
|
||||
|
||||
// 使用缓存的有效路径
|
||||
List<String> validPaths = getValidPythonPaths();
|
||||
|
||||
if (validPaths.isEmpty()) {
|
||||
log.warn("未找到有效的 Python 包路径");
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建添加路径的脚本 - 使用已验证的路径,跳过文件系统检测
|
||||
StringBuilder pathsJson = new StringBuilder("[");
|
||||
boolean first = true;
|
||||
for (String path : validPaths) {
|
||||
if (!first) pathsJson.append(", ");
|
||||
first = false;
|
||||
pathsJson.append("'").append(path.replace("\\", "/").replace("'", "\\'")).append("'");
|
||||
}
|
||||
pathsJson.append("]");
|
||||
|
||||
// 简化的路径添加脚本 - 不再调用 os.path.isdir,直接添加已验证的路径
|
||||
String addPathScript = String.format("""
|
||||
import sys
|
||||
|
||||
_paths_to_add = %s
|
||||
_added_paths = []
|
||||
for path in _paths_to_add:
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
_added_paths.append(path)
|
||||
|
||||
_added_paths_str = ', '.join(_added_paths) if _added_paths else ''
|
||||
""", pathsJson);
|
||||
|
||||
context.eval("python", addPathScript);
|
||||
Value bindings = context.getBindings("python");
|
||||
String addedPaths = bindings.getMember("_added_paths_str").asString();
|
||||
|
||||
if (!addedPaths.isEmpty()) {
|
||||
log.debug("添加的 Python 路径: {}", addedPaths);
|
||||
}
|
||||
|
||||
// 验证 requests 是否可用(简化版,不阻塞)
|
||||
// 注意:在多 Context 环境中,可能因 NativeModules 限制而失败
|
||||
String verifyScript = """
|
||||
import sys
|
||||
|
||||
_requests_available = False
|
||||
_requests_version = ''
|
||||
_error_msg = ''
|
||||
_native_module_error = False
|
||||
|
||||
try:
|
||||
import requests
|
||||
_requests_available = True
|
||||
_requests_version = requests.__version__
|
||||
except SystemError as e:
|
||||
# NativeModules 冲突 - GraalPy 限制
|
||||
_error_msg = str(e)
|
||||
if 'NativeModules' in _error_msg or 'llvm' in _error_msg:
|
||||
_native_module_error = True
|
||||
except ImportError as e:
|
||||
_error_msg = str(e)
|
||||
|
||||
_sys_path_length = len(sys.path)
|
||||
""";
|
||||
|
||||
context.eval("python", verifyScript);
|
||||
|
||||
boolean requestsAvailable = bindings.getMember("_requests_available").asBoolean();
|
||||
boolean nativeModuleError = bindings.getMember("_native_module_error").asBoolean();
|
||||
int pathLength = bindings.getMember("_sys_path_length").asInt();
|
||||
|
||||
if (requestsAvailable) {
|
||||
String version = bindings.getMember("_requests_version").asString();
|
||||
log.info("Python 环境配置完成: requests {} 可用, sys.path长度: {}", version, pathLength);
|
||||
} else if (nativeModuleError) {
|
||||
// GraalPy 的 NativeModules 限制 - 这是已知限制,不是配置错误
|
||||
log.debug("Python 环境配置: requests 因 NativeModules 限制不可用 (非首个 Context). " +
|
||||
"这是 GraalPy 的已知限制,标准库仍可正常使用。");
|
||||
} else {
|
||||
String error = bindings.getMember("_error_msg").asString();
|
||||
log.warn("Python 环境配置: requests 不可用 ({}), sys.path长度: {}. " +
|
||||
"检查 Maven 构建是否正常完成 (graalpy-maven-plugin)", error, pathLength);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
String msg = e.getMessage();
|
||||
// 检查是否是 NativeModules 相关的错误
|
||||
if (msg != null && (msg.contains("NativeModules") || msg.contains("llvm"))) {
|
||||
log.debug("Python 环境配置: 因 NativeModules 限制跳过 requests 验证 (非首个 Context)");
|
||||
} else {
|
||||
log.warn("Python 环境配置失败,继续使用默认配置: {}", msg);
|
||||
}
|
||||
// 不抛出异常,允许 Context 继续使用
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置安全的 OS 模块限制
|
||||
* 只允许安全的读取操作,禁止危险的文件系统操作
|
||||
*
|
||||
* 注意:此方法应在所有必要的库导入完成后调用,
|
||||
* 因为替换 os 模块会影响依赖它的库(如 requests)
|
||||
*/
|
||||
private void setupSecureOsModule(Context context) {
|
||||
// 此方法当前禁用,因为会影响 requests 库的正常工作
|
||||
// 安全限制将在代码执行层面实现,而不是替换系统模块
|
||||
log.debug("OS 模块安全策略:通过代码审查实现,不替换系统模块");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取有效的 Python 包路径(带缓存)
|
||||
* 首次调用时检测文件系统,后续直接返回缓存
|
||||
*/
|
||||
private List<String> getValidPythonPaths() {
|
||||
if (cachedValidPaths != null) {
|
||||
return cachedValidPaths;
|
||||
}
|
||||
|
||||
synchronized (PATH_CACHE_LOCK) {
|
||||
if (cachedValidPaths != null) {
|
||||
return cachedValidPaths;
|
||||
}
|
||||
|
||||
log.debug("首次检测 Python 包路径...");
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
List<String> validPaths = new ArrayList<>();
|
||||
String userDir = System.getProperty("user.dir");
|
||||
|
||||
// 尝试从 classpath 获取 graalpy-packages 路径
|
||||
String classpathPackages = null;
|
||||
try {
|
||||
var resource = getClass().getClassLoader().getResource("graalpy-packages");
|
||||
if (resource != null) {
|
||||
classpathPackages = resource.getPath();
|
||||
// 处理 jar 内路径
|
||||
if (classpathPackages.contains("!")) {
|
||||
classpathPackages = null; // jar 内无法直接作为文件系统路径
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("无法从 classpath 获取 graalpy-packages: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// 可能的 pip 包路径列表
|
||||
String[] possiblePaths = {
|
||||
classpathPackages,
|
||||
userDir + "/resources/graalpy-packages",
|
||||
userDir + "/src/main/resources/graalpy-packages",
|
||||
userDir + "/parser/src/main/resources/graalpy-packages",
|
||||
userDir + "/target/classes/graalpy-packages",
|
||||
userDir + "/parser/target/classes/graalpy-packages",
|
||||
userDir + "/graalpy-venv/lib/python3.11/site-packages",
|
||||
userDir + "/parser/graalpy-venv/lib/python3.11/site-packages",
|
||||
};
|
||||
|
||||
// 检测有效路径
|
||||
for (String path : possiblePaths) {
|
||||
if (path != null) {
|
||||
java.io.File dir = new java.io.File(path);
|
||||
if (dir.isDirectory()) {
|
||||
validPaths.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
log.info("Python 包路径检测完成,耗时: {}ms,有效路径数: {}", elapsed, validPaths.size());
|
||||
if (!validPaths.isEmpty()) {
|
||||
log.debug("有效路径: {}", validPaths);
|
||||
}
|
||||
|
||||
cachedValidPaths = validPaths;
|
||||
return validPaths;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全策略说明:
|
||||
*
|
||||
* 由于 requests 等第三方库内部会使用 os 模块的功能,
|
||||
* 直接替换 os 模块会导致这些库无法正常工作。
|
||||
*
|
||||
* 因此,安全控制通过以下方式实现:
|
||||
* 1. 代码静态检查(在执行前扫描危险的 os.system 等调用)
|
||||
* 2. 在 PyPlaygroundExecutor 中对用户代码进行预处理
|
||||
* 3. 使用 GraalPy 的沙箱机制限制文件系统访问
|
||||
*
|
||||
* 禁止的操作:
|
||||
* - os.system(), os.popen() - 系统命令执行
|
||||
* - os.remove(), os.unlink(), os.rmdir() - 文件删除
|
||||
* - os.mkdir(), os.makedirs() - 目录创建
|
||||
* - subprocess.* - 子进程操作
|
||||
*
|
||||
* 允许的操作:
|
||||
* - requests.* - HTTP 请求
|
||||
* - os.path.* - 路径操作(只读)
|
||||
* - os.getcwd() - 获取当前目录
|
||||
* - json, re, base64, hashlib 等标准库
|
||||
*/
|
||||
|
||||
/**
|
||||
* 归还Context到池中
|
||||
*/
|
||||
private void release(PooledContext pc) {
|
||||
if (pc == null) return;
|
||||
|
||||
pc.inUse = false;
|
||||
|
||||
if (closed.get() || pc.isExpired()) {
|
||||
// 池已关闭或Context已过期,直接销毁
|
||||
pc.forceClose();
|
||||
createdCount.decrementAndGet();
|
||||
log.debug("Context已过期或池已关闭,销毁Context");
|
||||
} else if (!contextPool.offer(pc)) {
|
||||
// 池已满,销毁Context
|
||||
pc.forceClose();
|
||||
createdCount.decrementAndGet();
|
||||
log.debug("池已满,销毁多余Context");
|
||||
} else {
|
||||
log.debug("归还Context到池,池当前大小: {}", contextPool.size());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的Context
|
||||
*/
|
||||
private void cleanup() {
|
||||
if (closed.get()) return;
|
||||
|
||||
int removed = 0;
|
||||
PooledContext pc;
|
||||
|
||||
while ((pc = contextPool.poll()) != null) {
|
||||
if (pc.isExpired() || closed.get()) {
|
||||
pc.forceClose();
|
||||
createdCount.decrementAndGet();
|
||||
removed++;
|
||||
} else {
|
||||
// 还没过期,放回池中
|
||||
if (!contextPool.offer(pc)) {
|
||||
pc.forceClose();
|
||||
createdCount.decrementAndGet();
|
||||
removed++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
log.info("清理了 {} 个过期的Context,当前池大小: {}", removed, contextPool.size());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取池状态信息
|
||||
*/
|
||||
public String getStatus() {
|
||||
return String.format("PyContextPool[total=%d, available=%d, maxSize=%d]",
|
||||
createdCount.get(), contextPool.size(), MAX_POOL_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取池中可用的Context数量
|
||||
*/
|
||||
public int getAvailableCount() {
|
||||
return contextPool.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已创建的Context总数
|
||||
*/
|
||||
public int getCreatedCount() {
|
||||
return createdCount.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查GraalPy是否可用
|
||||
*/
|
||||
private void checkGraalPyAvailability() {
|
||||
log.error("===== GraalPy 可用性检查 =====");
|
||||
|
||||
// 检查类路径
|
||||
try {
|
||||
Class.forName("org.graalvm.polyglot.Engine");
|
||||
log.info("✓ org.graalvm.polyglot.Engine 类存在");
|
||||
} catch (ClassNotFoundException e) {
|
||||
log.error("✗ org.graalvm.polyglot.Engine 类不存在");
|
||||
}
|
||||
|
||||
try {
|
||||
Class.forName("org.graalvm.python.embedding.GraalPyResources");
|
||||
log.info("✓ org.graalvm.python.embedding.GraalPyResources 类存在");
|
||||
} catch (ClassNotFoundException e) {
|
||||
log.warn(" python-embedding 类不存在(可选依赖)");
|
||||
}
|
||||
|
||||
// 尝试列出可用语言
|
||||
try {
|
||||
log.info("尝试使用标准 Polyglot API 创建 Context...");
|
||||
try (Engine engine = Engine.create()) {
|
||||
log.info(" 可用语言: {}", engine.getLanguages().keySet());
|
||||
if (engine.getLanguages().containsKey("python")) {
|
||||
log.info("✓ Python 语言可用");
|
||||
} else {
|
||||
log.error("✗ Python 语言不可用");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("✗ 创建 Engine 失败: {}", e.getMessage());
|
||||
}
|
||||
|
||||
log.error("================================");
|
||||
log.error("请检查以下依赖是否正确配置:");
|
||||
log.error(" 1. org.graalvm.polyglot:polyglot");
|
||||
log.error(" 2. org.graalvm.polyglot:python (type=pom)");
|
||||
log.error("================================");
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭Context池
|
||||
*/
|
||||
public void shutdown() {
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
log.info("关闭GraalPy Context池...");
|
||||
|
||||
// 停止清理调度器
|
||||
cleanupScheduler.shutdownNow();
|
||||
timeoutScheduler.shutdownNow();
|
||||
pythonExecutor.shutdownNow();
|
||||
|
||||
// 关闭所有池中的Context
|
||||
PooledContext pc;
|
||||
while ((pc = contextPool.poll()) != null) {
|
||||
pc.forceClose();
|
||||
}
|
||||
|
||||
// 关闭共享Engine
|
||||
try {
|
||||
sharedEngine.close(true);
|
||||
} catch (Exception e) {
|
||||
log.warn("关闭共享Engine失败: {}", e.getMessage());
|
||||
}
|
||||
|
||||
log.info("GraalPy Context池已关闭");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查池是否已关闭
|
||||
*/
|
||||
public boolean isClosed() {
|
||||
return closed.get();
|
||||
}
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
package cn.qaiu.parser.custompy;
|
||||
|
||||
import org.graalvm.polyglot.HostAccess;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Python加密工具类
|
||||
* 为Python脚本提供常用的加密解密功能
|
||||
*
|
||||
* @author QAIU
|
||||
*/
|
||||
public class PyCryptoUtils {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PyCryptoUtils.class);
|
||||
|
||||
// ==================== MD5 ====================
|
||||
|
||||
/**
|
||||
* MD5加密(返回32位小写)
|
||||
* @param data 待加密数据
|
||||
* @return MD5值(32位小写)
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String md5(String data) {
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] digest = md.digest(data.getBytes(StandardCharsets.UTF_8));
|
||||
return bytesToHex(digest);
|
||||
} catch (Exception e) {
|
||||
log.error("MD5加密失败", e);
|
||||
throw new RuntimeException("MD5加密失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MD5加密(返回16位小写,取中间16位)
|
||||
* @param data 待加密数据
|
||||
* @return MD5值(16位小写)
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String md5_16(String data) {
|
||||
String md5 = md5(data);
|
||||
return md5 != null ? md5.substring(8, 24) : null;
|
||||
}
|
||||
|
||||
// ==================== SHA ====================
|
||||
|
||||
/**
|
||||
* SHA-1加密
|
||||
* @param data 待加密数据
|
||||
* @return SHA-1值(小写)
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String sha1(String data) {
|
||||
return sha(data, "SHA-1");
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256加密
|
||||
* @param data 待加密数据
|
||||
* @return SHA-256值(小写)
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String sha256(String data) {
|
||||
return sha(data, "SHA-256");
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-512加密
|
||||
* @param data 待加密数据
|
||||
* @return SHA-512值(小写)
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String sha512(String data) {
|
||||
return sha(data, "SHA-512");
|
||||
}
|
||||
|
||||
private String sha(String data, String algorithm) {
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance(algorithm);
|
||||
byte[] digest = md.digest(data.getBytes(StandardCharsets.UTF_8));
|
||||
return bytesToHex(digest);
|
||||
} catch (Exception e) {
|
||||
log.error(algorithm + "加密失败", e);
|
||||
throw new RuntimeException(algorithm + "加密失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Base64 ====================
|
||||
|
||||
/**
|
||||
* Base64编码
|
||||
* @param data 待编码数据
|
||||
* @return Base64字符串
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String base64_encode(String data) {
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
return Base64.getEncoder().encodeToString(data.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64编码(字节数组)
|
||||
* @param data 待编码字节数组
|
||||
* @return Base64字符串
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String base64_encode_bytes(byte[] data) {
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
return Base64.getEncoder().encodeToString(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64解码
|
||||
* @param data Base64字符串
|
||||
* @return 解码后的字符串
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String base64_decode(String data) {
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
byte[] decoded = Base64.getDecoder().decode(data);
|
||||
return new String(decoded, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
log.error("Base64解码失败", e);
|
||||
throw new RuntimeException("Base64解码失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64解码(返回字节数组)
|
||||
* @param data Base64字符串
|
||||
* @return 解码后的字节数组
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public byte[] base64_decode_bytes(String data) {
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Base64.getDecoder().decode(data);
|
||||
} catch (Exception e) {
|
||||
log.error("Base64解码失败", e);
|
||||
throw new RuntimeException("Base64解码失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URL安全的Base64编码
|
||||
* @param data 待编码数据
|
||||
* @return URL安全的Base64字符串
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String base64_url_encode(String data) {
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
return Base64.getUrlEncoder().encodeToString(data.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/**
|
||||
* URL安全的Base64解码
|
||||
* @param data URL安全的Base64字符串
|
||||
* @return 解码后的字符串
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String base64_url_decode(String data) {
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
byte[] decoded = Base64.getUrlDecoder().decode(data);
|
||||
return new String(decoded, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
log.error("Base64 URL解码失败", e);
|
||||
throw new RuntimeException("Base64 URL解码失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== AES ====================
|
||||
|
||||
/**
|
||||
* AES加密(ECB模式,PKCS5Padding)
|
||||
* @param data 待加密数据
|
||||
* @param key 密钥(16/24/32字节)
|
||||
* @return Base64编码的密文
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String aes_encrypt_ecb(String data, String key) {
|
||||
if (data == null || key == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
SecretKeySpec secretKey = new SecretKeySpec(padKey(key), "AES");
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
|
||||
byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(encrypted);
|
||||
} catch (Exception e) {
|
||||
log.error("AES ECB加密失败", e);
|
||||
throw new RuntimeException("AES ECB加密失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AES解密(ECB模式,PKCS5Padding)
|
||||
* @param data Base64编码的密文
|
||||
* @param key 密钥(16/24/32字节)
|
||||
* @return 明文
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String aes_decrypt_ecb(String data, String key) {
|
||||
if (data == null || key == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
SecretKeySpec secretKey = new SecretKeySpec(padKey(key), "AES");
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey);
|
||||
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(data));
|
||||
return new String(decrypted, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
log.error("AES ECB解密失败", e);
|
||||
throw new RuntimeException("AES ECB解密失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AES加密(CBC模式,PKCS5Padding)
|
||||
* @param data 待加密数据
|
||||
* @param key 密钥(16/24/32字节)
|
||||
* @param iv 初始向量(16字节)
|
||||
* @return Base64编码的密文
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String aes_encrypt_cbc(String data, String key, String iv) {
|
||||
if (data == null || key == null || iv == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
SecretKeySpec secretKey = new SecretKeySpec(padKey(key), "AES");
|
||||
IvParameterSpec ivSpec = new IvParameterSpec(padIv(iv));
|
||||
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec);
|
||||
byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(encrypted);
|
||||
} catch (Exception e) {
|
||||
log.error("AES CBC加密失败", e);
|
||||
throw new RuntimeException("AES CBC加密失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AES解密(CBC模式,PKCS5Padding)
|
||||
* @param data Base64编码的密文
|
||||
* @param key 密钥(16/24/32字节)
|
||||
* @param iv 初始向量(16字节)
|
||||
* @return 明文
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String aes_decrypt_cbc(String data, String key, String iv) {
|
||||
if (data == null || key == null || iv == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
SecretKeySpec secretKey = new SecretKeySpec(padKey(key), "AES");
|
||||
IvParameterSpec ivSpec = new IvParameterSpec(padIv(iv));
|
||||
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec);
|
||||
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(data));
|
||||
return new String(decrypted, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
log.error("AES CBC解密失败", e);
|
||||
throw new RuntimeException("AES CBC解密失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Hex ====================
|
||||
|
||||
/**
|
||||
* 字节数组转十六进制字符串
|
||||
* @param bytes 字节数组
|
||||
* @return 十六进制字符串(小写)
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String bytes_to_hex(byte[] bytes) {
|
||||
return bytesToHex(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 十六进制字符串转字节数组
|
||||
* @param hex 十六进制字符串
|
||||
* @return 字节数组
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public byte[] hex_to_bytes(String hex) {
|
||||
if (hex == null || hex.length() % 2 != 0) {
|
||||
return null;
|
||||
}
|
||||
int len = hex.length();
|
||||
byte[] data = new byte[len / 2];
|
||||
for (int i = 0; i < len; i += 2) {
|
||||
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
|
||||
+ Character.digit(hex.charAt(i + 1), 16));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// ==================== 工具方法 ====================
|
||||
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
if (bytes == null) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将密钥填充到16/24/32字节
|
||||
*/
|
||||
private byte[] padKey(String key) {
|
||||
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
|
||||
int len = keyBytes.length;
|
||||
|
||||
// 根据密钥长度决定填充到16/24/32字节
|
||||
int targetLen;
|
||||
if (len <= 16) {
|
||||
targetLen = 16;
|
||||
} else if (len <= 24) {
|
||||
targetLen = 24;
|
||||
} else {
|
||||
targetLen = 32;
|
||||
}
|
||||
|
||||
if (len == targetLen) {
|
||||
return keyBytes;
|
||||
}
|
||||
|
||||
byte[] paddedKey = new byte[targetLen];
|
||||
System.arraycopy(keyBytes, 0, paddedKey, 0, Math.min(len, targetLen));
|
||||
return paddedKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将IV填充到16字节
|
||||
*/
|
||||
private byte[] padIv(String iv) {
|
||||
byte[] ivBytes = iv.getBytes(StandardCharsets.UTF_8);
|
||||
if (ivBytes.length == 16) {
|
||||
return ivBytes;
|
||||
}
|
||||
|
||||
byte[] paddedIv = new byte[16];
|
||||
System.arraycopy(ivBytes, 0, paddedIv, 0, Math.min(ivBytes.length, 16));
|
||||
return paddedIv;
|
||||
}
|
||||
}
|
||||
@@ -1,649 +0,0 @@
|
||||
package cn.qaiu.parser.custompy;
|
||||
|
||||
import cn.qaiu.WebClientVertxInit;
|
||||
import cn.qaiu.util.HttpResponseHelper;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.MultiMap;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.core.net.ProxyOptions;
|
||||
import io.vertx.core.net.ProxyType;
|
||||
import io.vertx.ext.web.client.HttpRequest;
|
||||
import io.vertx.ext.web.client.HttpResponse;
|
||||
import io.vertx.ext.web.client.WebClient;
|
||||
import io.vertx.ext.web.client.WebClientOptions;
|
||||
import io.vertx.ext.web.client.WebClientSession;
|
||||
import io.vertx.ext.web.multipart.MultipartForm;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.graalvm.polyglot.HostAccess;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.UnknownHostException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Python HTTP客户端封装
|
||||
* 为Python脚本提供类似requests库的HTTP请求功能
|
||||
* 基于Vert.x WebClient实现,提供同步API风格
|
||||
*
|
||||
* @author QAIU
|
||||
*/
|
||||
public class PyHttpClient {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PyHttpClient.class);
|
||||
|
||||
private final WebClient client;
|
||||
private final WebClientSession clientSession;
|
||||
private MultiMap headers;
|
||||
private int timeoutSeconds = 30; // 默认超时时间30秒
|
||||
|
||||
// SSRF防护:内网IP正则表达式
|
||||
private static final Pattern PRIVATE_IP_PATTERN = Pattern.compile(
|
||||
"^(127\\..*|10\\..*|172\\.(1[6-9]|2[0-9]|3[01])\\..*|192\\.168\\..*|169\\.254\\..*|::1|[fF][cCdD].*)"
|
||||
);
|
||||
|
||||
// SSRF防护:危险域名黑名单
|
||||
private static final String[] DANGEROUS_HOSTS = {
|
||||
"localhost",
|
||||
"169.254.169.254", // AWS/阿里云等云服务元数据API
|
||||
"metadata.google.internal", // GCP元数据
|
||||
"100.100.100.200" // 阿里云元数据
|
||||
};
|
||||
|
||||
public PyHttpClient() {
|
||||
this.client = WebClient.create(WebClientVertxInit.get(), new WebClientOptions());
|
||||
this.clientSession = WebClientSession.create(client);
|
||||
this.headers = MultiMap.caseInsensitiveMultiMap();
|
||||
initDefaultHeaders();
|
||||
}
|
||||
|
||||
/**
|
||||
* 带代理配置的构造函数
|
||||
* @param proxyConfig 代理配置JsonObject,包含type、host、port、username、password
|
||||
*/
|
||||
public PyHttpClient(JsonObject proxyConfig) {
|
||||
if (proxyConfig != null && proxyConfig.containsKey("type")) {
|
||||
ProxyOptions proxyOptions = new ProxyOptions()
|
||||
.setType(ProxyType.valueOf(proxyConfig.getString("type").toUpperCase()))
|
||||
.setHost(proxyConfig.getString("host"))
|
||||
.setPort(proxyConfig.getInteger("port"));
|
||||
|
||||
if (StringUtils.isNotEmpty(proxyConfig.getString("username"))) {
|
||||
proxyOptions.setUsername(proxyConfig.getString("username"));
|
||||
}
|
||||
if (StringUtils.isNotEmpty(proxyConfig.getString("password"))) {
|
||||
proxyOptions.setPassword(proxyConfig.getString("password"));
|
||||
}
|
||||
|
||||
this.client = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions()
|
||||
.setUserAgentEnabled(false)
|
||||
.setProxyOptions(proxyOptions));
|
||||
this.clientSession = WebClientSession.create(client);
|
||||
} else {
|
||||
this.client = WebClient.create(WebClientVertxInit.get());
|
||||
this.clientSession = WebClientSession.create(client);
|
||||
}
|
||||
this.headers = MultiMap.caseInsensitiveMultiMap();
|
||||
initDefaultHeaders();
|
||||
}
|
||||
|
||||
private void initDefaultHeaders() {
|
||||
// 设置默认的Accept-Encoding头以支持压缩响应
|
||||
this.headers.set("Accept-Encoding", "gzip, deflate, br, zstd");
|
||||
// 设置默认的User-Agent头
|
||||
this.headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0");
|
||||
// 设置默认的Accept-Language头
|
||||
this.headers.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证URL安全性(SSRF防护)- 仅拦截明显的内网攻击
|
||||
* @param url 待验证的URL
|
||||
* @throws SecurityException 如果URL不安全
|
||||
*/
|
||||
private void validateUrlSecurity(String url) {
|
||||
try {
|
||||
URI uri = new URI(url);
|
||||
String host = uri.getHost();
|
||||
|
||||
if (host == null) {
|
||||
log.debug("URL没有host信息: {}", url);
|
||||
return;
|
||||
}
|
||||
|
||||
String lowerHost = host.toLowerCase();
|
||||
|
||||
// 1. 检查明确的危险域名(云服务元数据API等)
|
||||
for (String dangerous : DANGEROUS_HOSTS) {
|
||||
if (lowerHost.equals(dangerous)) {
|
||||
log.warn("🔒 安全拦截: 尝试访问云服务元数据API - {}", host);
|
||||
throw new SecurityException("🔒 安全拦截: 禁止访问云服务元数据API");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果host是IP地址格式,检查是否为内网IP
|
||||
if (isIpAddress(lowerHost)) {
|
||||
if (PRIVATE_IP_PATTERN.matcher(lowerHost).find()) {
|
||||
log.warn("🔒 安全拦截: 尝试访问内网IP - {}", host);
|
||||
throw new SecurityException("🔒 安全拦截: 禁止访问内网IP地址");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 对于域名,尝试解析IP(但不因解析失败而拦截)
|
||||
if (!isIpAddress(lowerHost)) {
|
||||
try {
|
||||
InetAddress addr = InetAddress.getByName(host);
|
||||
String ip = addr.getHostAddress();
|
||||
|
||||
if (PRIVATE_IP_PATTERN.matcher(ip).find()) {
|
||||
log.warn("🔒 安全拦截: 域名解析到内网IP - {} -> {}", host, ip);
|
||||
throw new SecurityException("🔒 安全拦截: 该域名指向内网地址");
|
||||
}
|
||||
} catch (UnknownHostException e) {
|
||||
log.debug("DNS解析失败,允许继续: {}", host);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("URL安全检查通过: {}", url);
|
||||
|
||||
} catch (SecurityException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.debug("URL验证异常,允许继续: {}", url, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为IP地址格式
|
||||
*/
|
||||
private boolean isIpAddress(String host) {
|
||||
return host.matches("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$") || host.contains(":");
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起GET请求
|
||||
* @param url 请求URL
|
||||
* @return HTTP响应
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public PyHttpResponse get(String url) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.getAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
return request.send();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起GET请求并跟随重定向
|
||||
* @param url 请求URL
|
||||
* @return HTTP响应
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public PyHttpResponse get_with_redirect(String url) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.getAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
request.followRedirects(true);
|
||||
return request.send();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起GET请求但不跟随重定向(用于获取Location头)
|
||||
* @param url 请求URL
|
||||
* @return HTTP响应
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public PyHttpResponse get_no_redirect(String url) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.getAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
request.followRedirects(false);
|
||||
return request.send();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起POST请求
|
||||
* @param url 请求URL
|
||||
* @param data 请求数据(支持String、Map)
|
||||
* @return HTTP响应
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public PyHttpResponse post(String url, Object data) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.postAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
|
||||
if (data != null) {
|
||||
if (data instanceof String) {
|
||||
return request.sendBuffer(Buffer.buffer((String) data));
|
||||
} else if (data instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> mapData = (Map<String, String>) data;
|
||||
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
|
||||
} else {
|
||||
return request.sendJson(data);
|
||||
}
|
||||
} else {
|
||||
return request.send();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起POST请求(JSON数据)
|
||||
* @param url 请求URL
|
||||
* @param jsonData JSON字符串或Map
|
||||
* @return HTTP响应
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public PyHttpResponse post_json(String url, Object jsonData) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.postAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
headers.set("Content-Type", "application/json");
|
||||
|
||||
if (jsonData instanceof String) {
|
||||
return request.sendBuffer(Buffer.buffer((String) jsonData));
|
||||
} else {
|
||||
return request.sendJson(jsonData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起PUT请求
|
||||
* @param url 请求URL
|
||||
* @param data 请求数据
|
||||
* @return HTTP响应
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public PyHttpResponse put(String url, Object data) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.putAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
|
||||
if (data != null) {
|
||||
if (data instanceof String) {
|
||||
return request.sendBuffer(Buffer.buffer((String) data));
|
||||
} else if (data instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> mapData = (Map<String, String>) data;
|
||||
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
|
||||
} else {
|
||||
return request.sendJson(data);
|
||||
}
|
||||
} else {
|
||||
return request.send();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起DELETE请求
|
||||
* @param url 请求URL
|
||||
* @return HTTP响应
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public PyHttpResponse delete(String url) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.deleteAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
return request.send();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起PATCH请求
|
||||
* @param url 请求URL
|
||||
* @param data 请求数据
|
||||
* @return HTTP响应
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public PyHttpResponse patch(String url, Object data) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.patchAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
|
||||
if (data != null) {
|
||||
if (data instanceof String) {
|
||||
return request.sendBuffer(Buffer.buffer((String) data));
|
||||
} else if (data instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> mapData = (Map<String, String>) data;
|
||||
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
|
||||
} else {
|
||||
return request.sendJson(data);
|
||||
}
|
||||
} else {
|
||||
return request.send();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求头
|
||||
* @param name 头名称
|
||||
* @param value 头值
|
||||
* @return 当前客户端实例(支持链式调用)
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public PyHttpClient put_header(String name, String value) {
|
||||
if (name != null && value != null) {
|
||||
headers.set(name, value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置请求头
|
||||
* @param headersMap 请求头Map
|
||||
* @return 当前客户端实例(支持链式调用)
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public PyHttpClient put_headers(Map<String, String> headersMap) {
|
||||
if (headersMap != null) {
|
||||
for (Map.Entry<String, String> entry : headersMap.entrySet()) {
|
||||
if (entry.getKey() != null && entry.getValue() != null) {
|
||||
headers.set(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定请求头
|
||||
* @param name 头名称
|
||||
* @return 当前客户端实例(支持链式调用)
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public PyHttpClient remove_header(String name) {
|
||||
if (name != null) {
|
||||
headers.remove(name);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有请求头(保留默认头)
|
||||
* @return 当前客户端实例(支持链式调用)
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public PyHttpClient clear_headers() {
|
||||
headers.clear();
|
||||
initDefaultHeaders();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有请求头
|
||||
* @return 请求头Map
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public Map<String, String> get_headers() {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
for (String name : headers.names()) {
|
||||
result.put(name, headers.get(name));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求超时时间
|
||||
* @param seconds 超时时间(秒)
|
||||
* @return 当前客户端实例(支持链式调用)
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public PyHttpClient set_timeout(int seconds) {
|
||||
if (seconds > 0) {
|
||||
this.timeoutSeconds = seconds;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL编码
|
||||
* @param str 要编码的字符串
|
||||
* @return 编码后的字符串
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public static String url_encode(String str) {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return URLEncoder.encode(str, StandardCharsets.UTF_8.name());
|
||||
} catch (Exception e) {
|
||||
log.error("URL编码失败", e);
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URL解码
|
||||
* @param str 要解码的字符串
|
||||
* @return 解码后的字符串
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public static String url_decode(String str) {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return URLDecoder.decode(str, StandardCharsets.UTF_8.name());
|
||||
} catch (Exception e) {
|
||||
log.error("URL解码失败", e);
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行HTTP请求(同步)
|
||||
*/
|
||||
private PyHttpResponse executeRequest(RequestExecutor executor) {
|
||||
try {
|
||||
Promise<HttpResponse<Buffer>> promise = Promise.promise();
|
||||
Future<HttpResponse<Buffer>> future = executor.execute();
|
||||
|
||||
future.onComplete(result -> {
|
||||
if (result.succeeded()) {
|
||||
promise.complete(result.result());
|
||||
} else {
|
||||
promise.fail(result.cause());
|
||||
}
|
||||
}).onFailure(Throwable::printStackTrace);
|
||||
|
||||
// 等待响应完成(使用配置的超时时间)
|
||||
HttpResponse<Buffer> response = promise.future().toCompletionStage()
|
||||
.toCompletableFuture()
|
||||
.get(timeoutSeconds, TimeUnit.SECONDS);
|
||||
|
||||
return new PyHttpResponse(response);
|
||||
|
||||
} catch (TimeoutException e) {
|
||||
String errorMsg = "HTTP请求超时(" + timeoutSeconds + "秒)";
|
||||
log.error(errorMsg, e);
|
||||
throw new RuntimeException(errorMsg, e);
|
||||
} catch (Exception e) {
|
||||
String errorMsg = e.getMessage();
|
||||
if (errorMsg == null || errorMsg.trim().isEmpty()) {
|
||||
errorMsg = e.getClass().getSimpleName();
|
||||
if (e.getCause() != null && e.getCause().getMessage() != null) {
|
||||
errorMsg += ": " + e.getCause().getMessage();
|
||||
}
|
||||
}
|
||||
log.error("HTTP请求执行失败: " + errorMsg, e);
|
||||
throw new RuntimeException("HTTP请求执行失败: " + errorMsg, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求执行器接口
|
||||
*/
|
||||
@FunctionalInterface
|
||||
private interface RequestExecutor {
|
||||
Future<HttpResponse<Buffer>> execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Python HTTP响应封装
|
||||
*/
|
||||
public static class PyHttpResponse {
|
||||
|
||||
private final HttpResponse<Buffer> response;
|
||||
|
||||
public PyHttpResponse(HttpResponse<Buffer> response) {
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取响应体(字符串)
|
||||
* @return 响应体字符串
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String text() {
|
||||
return HttpResponseHelper.asText(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取响应体(字符串)- 别名
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String body() {
|
||||
return text();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析JSON响应
|
||||
* @return JSON对象的Map表示
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public Object json() {
|
||||
try {
|
||||
JsonObject jsonObject = HttpResponseHelper.asJson(response);
|
||||
if (jsonObject == null || jsonObject.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return jsonObject.getMap();
|
||||
} catch (Exception e) {
|
||||
log.error("解析JSON响应失败", e);
|
||||
throw new RuntimeException("解析JSON响应失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取HTTP状态码
|
||||
* @return 状态码
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public int status_code() {
|
||||
return response.statusCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取响应头
|
||||
* @param name 头名称
|
||||
* @return 头值
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String header(String name) {
|
||||
return response.getHeader(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有响应头
|
||||
* @return 响应头Map
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public Map<String, String> headers() {
|
||||
MultiMap responseHeaders = response.headers();
|
||||
Map<String, String> result = new HashMap<>();
|
||||
for (String name : responseHeaders.names()) {
|
||||
result.put(name, responseHeaders.get(name));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查请求是否成功
|
||||
* @return true表示成功(2xx状态码)
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public boolean ok() {
|
||||
int status = status_code();
|
||||
return status >= 200 && status < 300;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取响应体字节数组
|
||||
* @return 响应体字节数组
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public byte[] content() {
|
||||
Buffer buffer = response.body();
|
||||
if (buffer == null) {
|
||||
return new byte[0];
|
||||
}
|
||||
return buffer.getBytes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取响应体大小
|
||||
* @return 响应体大小(字节)
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public long content_length() {
|
||||
Buffer buffer = response.body();
|
||||
if (buffer == null) {
|
||||
return 0;
|
||||
}
|
||||
return buffer.length();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始响应对象
|
||||
*/
|
||||
public HttpResponse<Buffer> getOriginalResponse() {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package cn.qaiu.parser.custompy;
|
||||
|
||||
import org.graalvm.polyglot.HostAccess;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Python日志封装
|
||||
* 为Python脚本提供日志功能
|
||||
*
|
||||
* @author QAIU
|
||||
*/
|
||||
public class PyLogger {
|
||||
|
||||
private final Logger logger;
|
||||
private final String prefix;
|
||||
|
||||
public PyLogger(String name) {
|
||||
this.logger = LoggerFactory.getLogger(name);
|
||||
this.prefix = "[" + name + "] ";
|
||||
}
|
||||
|
||||
public PyLogger(Class<?> clazz) {
|
||||
this.logger = LoggerFactory.getLogger(clazz);
|
||||
this.prefix = "[" + clazz.getSimpleName() + "] ";
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试日志
|
||||
* @param message 日志消息
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public void debug(String message) {
|
||||
logger.debug(prefix + message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试日志(带参数)
|
||||
* @param message 日志消息模板
|
||||
* @param args 参数
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public void debug(String message, Object... args) {
|
||||
logger.debug(prefix + message, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 信息日志
|
||||
* @param message 日志消息
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public void info(String message) {
|
||||
logger.info(prefix + message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 信息日志(带参数)
|
||||
* @param message 日志消息模板
|
||||
* @param args 参数
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public void info(String message, Object... args) {
|
||||
logger.info(prefix + message, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 警告日志
|
||||
* @param message 日志消息
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public void warn(String message) {
|
||||
logger.warn(prefix + message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 警告日志(带参数)
|
||||
* @param message 日志消息模板
|
||||
* @param args 参数
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public void warn(String message, Object... args) {
|
||||
logger.warn(prefix + message, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误日志
|
||||
* @param message 日志消息
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public void error(String message) {
|
||||
logger.error(prefix + message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误日志(带参数)
|
||||
* @param message 日志消息模板
|
||||
* @param args 参数
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public void error(String message, Object... args) {
|
||||
logger.error(prefix + message, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误日志(带异常)
|
||||
* @param message 日志消息
|
||||
* @param throwable 异常对象
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public void error(String message, Throwable throwable) {
|
||||
logger.error(prefix + message, throwable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否启用调试级别日志
|
||||
* @return true表示启用,false表示不启用
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public boolean isDebugEnabled() {
|
||||
return logger.isDebugEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否启用信息级别日志
|
||||
* @return true表示启用,false表示不启用
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public boolean isInfoEnabled() {
|
||||
return logger.isInfoEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始Logger对象
|
||||
* @return Logger对象
|
||||
*/
|
||||
public Logger getOriginalLogger() {
|
||||
return logger;
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
package cn.qaiu.parser.custompy;
|
||||
|
||||
import cn.qaiu.WebClientVertxInit;
|
||||
import cn.qaiu.entity.FileInfo;
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.IPanTool;
|
||||
import cn.qaiu.parser.custom.CustomParserConfig;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.WorkerExecutor;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import org.graalvm.polyglot.Context;
|
||||
import org.graalvm.polyglot.Value;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Python解析器执行器
|
||||
* 使用GraalPy执行Python解析器脚本
|
||||
* 实现IPanTool接口,执行Python解析器逻辑
|
||||
* 使用 PyContextPool 进行 Engine 池化管理
|
||||
*
|
||||
* @author QAIU
|
||||
*/
|
||||
public class PyParserExecutor implements IPanTool {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PyParserExecutor.class);
|
||||
|
||||
private static final WorkerExecutor EXECUTOR = WebClientVertxInit.get()
|
||||
.createSharedWorkerExecutor("py-parser-executor", 32);
|
||||
|
||||
// Context池实例
|
||||
private static final PyContextPool CONTEXT_POOL = PyContextPool.getInstance();
|
||||
|
||||
private final CustomParserConfig config;
|
||||
private final ShareLinkInfo shareLinkInfo;
|
||||
private final PyHttpClient httpClient;
|
||||
private final PyLogger pyLogger;
|
||||
private final PyShareLinkInfoWrapper shareLinkInfoWrapper;
|
||||
private final PyCryptoUtils cryptoUtils;
|
||||
|
||||
public PyParserExecutor(ShareLinkInfo shareLinkInfo, CustomParserConfig config) {
|
||||
this.config = config;
|
||||
this.shareLinkInfo = shareLinkInfo;
|
||||
|
||||
// 检查是否有代理配置
|
||||
JsonObject proxyConfig = null;
|
||||
if (shareLinkInfo.getOtherParam().containsKey("proxy")) {
|
||||
proxyConfig = (JsonObject) shareLinkInfo.getOtherParam().get("proxy");
|
||||
}
|
||||
|
||||
this.httpClient = new PyHttpClient(proxyConfig);
|
||||
this.pyLogger = new PyLogger("PyParser-" + config.getType());
|
||||
this.shareLinkInfoWrapper = new PyShareLinkInfoWrapper(shareLinkInfo);
|
||||
this.cryptoUtils = new PyCryptoUtils();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取ShareLinkInfo对象
|
||||
* @return ShareLinkInfo对象
|
||||
*/
|
||||
@Override
|
||||
public ShareLinkInfo getShareLinkInfo() {
|
||||
return shareLinkInfo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parse() {
|
||||
pyLogger.info("开始执行Python解析器: {}", config.getType());
|
||||
|
||||
return EXECUTOR.executeBlocking(() -> {
|
||||
// 使用池化的 Context,自动归还
|
||||
try (PyContextPool.PooledContext pc = CONTEXT_POOL.acquire()) {
|
||||
Context context = pc.getContext();
|
||||
// 注入Java对象到Python环境
|
||||
Value bindings = context.getBindings("python");
|
||||
bindings.putMember("http", httpClient);
|
||||
bindings.putMember("logger", pyLogger);
|
||||
bindings.putMember("share_link_info", shareLinkInfoWrapper);
|
||||
bindings.putMember("crypto", cryptoUtils);
|
||||
|
||||
// 执行Python代码(已支持真正的 pip 包如 requests, zlib 等)
|
||||
context.eval("python", config.getPyCode());
|
||||
|
||||
// 调用parse函数
|
||||
Value parseFunc = bindings.getMember("parse");
|
||||
if (parseFunc == null || !parseFunc.canExecute()) {
|
||||
throw new RuntimeException("Python代码中未找到parse函数");
|
||||
}
|
||||
|
||||
Value result = parseFunc.execute(shareLinkInfoWrapper, httpClient, pyLogger);
|
||||
|
||||
if (result.isString()) {
|
||||
String downloadUrl = result.asString();
|
||||
pyLogger.info("解析成功: {}", downloadUrl);
|
||||
return downloadUrl;
|
||||
} else {
|
||||
pyLogger.error("parse方法返回值类型错误,期望String,实际: {}",
|
||||
result.getMetaObject().toString());
|
||||
throw new RuntimeException("parse方法返回值类型错误");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
pyLogger.error("Python解析器执行失败: {}", e.getMessage());
|
||||
throw new RuntimeException("Python解析器执行失败: " + e.getMessage(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<List<FileInfo>> parseFileList() {
|
||||
pyLogger.info("开始执行Python文件列表解析: {}", config.getType());
|
||||
|
||||
return EXECUTOR.executeBlocking(() -> {
|
||||
// 使用池化的 Context,自动归还
|
||||
try (PyContextPool.PooledContext pc = CONTEXT_POOL.acquire()) {
|
||||
Context context = pc.getContext();
|
||||
// 注入Java对象到Python环境
|
||||
Value bindings = context.getBindings("python");
|
||||
bindings.putMember("http", httpClient);
|
||||
bindings.putMember("logger", pyLogger);
|
||||
bindings.putMember("share_link_info", shareLinkInfoWrapper);
|
||||
bindings.putMember("crypto", cryptoUtils);
|
||||
|
||||
// 执行Python代码(已支持真正的 pip 包)
|
||||
context.eval("python", config.getPyCode());
|
||||
|
||||
// 调用parseFileList函数
|
||||
Value parseFileListFunc = bindings.getMember("parse_file_list");
|
||||
if (parseFileListFunc == null || !parseFileListFunc.canExecute()) {
|
||||
throw new RuntimeException("Python代码中未找到parse_file_list函数");
|
||||
}
|
||||
|
||||
Value result = parseFileListFunc.execute(shareLinkInfoWrapper, httpClient, pyLogger);
|
||||
|
||||
List<FileInfo> fileList = convertToFileInfoList(result);
|
||||
pyLogger.info("文件列表解析成功,共 {} 个文件", fileList.size());
|
||||
return fileList;
|
||||
} catch (Exception e) {
|
||||
pyLogger.error("Python文件列表解析失败: {}", e.getMessage());
|
||||
throw new RuntimeException("Python文件列表解析失败: " + e.getMessage(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parseById() {
|
||||
pyLogger.info("开始执行Python按ID解析: {}", config.getType());
|
||||
|
||||
return EXECUTOR.executeBlocking(() -> {
|
||||
// 使用池化的 Context,自动归还
|
||||
try (PyContextPool.PooledContext pc = CONTEXT_POOL.acquire()) {
|
||||
Context context = pc.getContext();
|
||||
// 注入Java对象到Python环境
|
||||
Value bindings = context.getBindings("python");
|
||||
bindings.putMember("http", httpClient);
|
||||
bindings.putMember("logger", pyLogger);
|
||||
bindings.putMember("share_link_info", shareLinkInfoWrapper);
|
||||
bindings.putMember("crypto", cryptoUtils);
|
||||
|
||||
// 执行Python代码(已支持真正的 pip 包)
|
||||
context.eval("python", config.getPyCode());
|
||||
|
||||
// 调用parseById函数
|
||||
Value parseByIdFunc = bindings.getMember("parse_by_id");
|
||||
if (parseByIdFunc == null || !parseByIdFunc.canExecute()) {
|
||||
throw new RuntimeException("Python代码中未找到parse_by_id函数");
|
||||
}
|
||||
|
||||
Value result = parseByIdFunc.execute(shareLinkInfoWrapper, httpClient, pyLogger);
|
||||
|
||||
if (result.isString()) {
|
||||
String downloadUrl = result.asString();
|
||||
pyLogger.info("按ID解析成功: {}", downloadUrl);
|
||||
return downloadUrl;
|
||||
} else {
|
||||
pyLogger.error("parse_by_id方法返回值类型错误,期望String,实际: {}",
|
||||
result.getMetaObject().toString());
|
||||
throw new RuntimeException("parse_by_id方法返回值类型错误");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
pyLogger.error("Python按ID解析失败: {}", e.getMessage());
|
||||
throw new RuntimeException("Python按ID解析失败: " + e.getMessage(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Python列表转换为FileInfo列表
|
||||
*/
|
||||
private List<FileInfo> convertToFileInfoList(Value result) {
|
||||
List<FileInfo> fileList = new ArrayList<>();
|
||||
|
||||
if (result.hasArrayElements()) {
|
||||
long size = result.getArraySize();
|
||||
for (long i = 0; i < size; i++) {
|
||||
Value item = result.getArrayElement(i);
|
||||
FileInfo fileInfo = convertToFileInfo(item);
|
||||
if (fileInfo != null) {
|
||||
fileList.add(fileInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fileList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Python字典转换为FileInfo
|
||||
*/
|
||||
private FileInfo convertToFileInfo(Value item) {
|
||||
try {
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
|
||||
if (item.hasMember("file_name") || item.hasMember("fileName")) {
|
||||
Value val = item.hasMember("file_name") ? item.getMember("file_name") : item.getMember("fileName");
|
||||
if (val != null && !val.isNull()) {
|
||||
fileInfo.setFileName(val.asString());
|
||||
}
|
||||
}
|
||||
if (item.hasMember("file_id") || item.hasMember("fileId")) {
|
||||
Value val = item.hasMember("file_id") ? item.getMember("file_id") : item.getMember("fileId");
|
||||
if (val != null && !val.isNull()) {
|
||||
fileInfo.setFileId(val.asString());
|
||||
}
|
||||
}
|
||||
if (item.hasMember("file_type") || item.hasMember("fileType")) {
|
||||
Value val = item.hasMember("file_type") ? item.getMember("file_type") : item.getMember("fileType");
|
||||
if (val != null && !val.isNull()) {
|
||||
fileInfo.setFileType(val.asString());
|
||||
}
|
||||
}
|
||||
if (item.hasMember("size")) {
|
||||
Value val = item.getMember("size");
|
||||
if (val != null && !val.isNull() && val.isNumber()) {
|
||||
fileInfo.setSize(val.asLong());
|
||||
}
|
||||
}
|
||||
if (item.hasMember("size_str") || item.hasMember("sizeStr")) {
|
||||
Value val = item.hasMember("size_str") ? item.getMember("size_str") : item.getMember("sizeStr");
|
||||
if (val != null && !val.isNull()) {
|
||||
fileInfo.setSizeStr(val.asString());
|
||||
}
|
||||
}
|
||||
if (item.hasMember("create_time") || item.hasMember("createTime")) {
|
||||
Value val = item.hasMember("create_time") ? item.getMember("create_time") : item.getMember("createTime");
|
||||
if (val != null && !val.isNull()) {
|
||||
fileInfo.setCreateTime(val.asString());
|
||||
}
|
||||
}
|
||||
if (item.hasMember("pan_type") || item.hasMember("panType")) {
|
||||
Value val = item.hasMember("pan_type") ? item.getMember("pan_type") : item.getMember("panType");
|
||||
if (val != null && !val.isNull()) {
|
||||
fileInfo.setPanType(val.asString());
|
||||
}
|
||||
}
|
||||
if (item.hasMember("parser_url") || item.hasMember("parserUrl")) {
|
||||
Value val = item.hasMember("parser_url") ? item.getMember("parser_url") : item.getMember("parserUrl");
|
||||
if (val != null && !val.isNull()) {
|
||||
fileInfo.setParserUrl(val.asString());
|
||||
}
|
||||
}
|
||||
|
||||
return fileInfo;
|
||||
|
||||
} catch (Exception e) {
|
||||
pyLogger.error("转换FileInfo对象失败", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
package cn.qaiu.parser.custompy;
|
||||
|
||||
import cn.qaiu.entity.FileInfo;
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import org.graalvm.polyglot.Context;
|
||||
import org.graalvm.polyglot.Value;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/**
|
||||
* Python演练场执行器
|
||||
* 用于临时执行Python代码,不注册到解析器注册表
|
||||
* 使用独立线程池避免Vert.x BlockedThreadChecker警告
|
||||
* 使用 PyContextPool 进行 Engine 和 Context 池化管理
|
||||
*
|
||||
* @author QAIU
|
||||
*/
|
||||
public class PyPlaygroundExecutor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PyPlaygroundExecutor.class);
|
||||
|
||||
// Python执行超时时间(秒)
|
||||
private static final long EXECUTION_TIMEOUT_SECONDS = 30;
|
||||
|
||||
// Context池实例
|
||||
private static final PyContextPool CONTEXT_POOL = PyContextPool.getInstance();
|
||||
|
||||
private final ShareLinkInfo shareLinkInfo;
|
||||
private final String pyCode;
|
||||
private final PyHttpClient httpClient;
|
||||
private final PyPlaygroundLogger playgroundLogger;
|
||||
private final PyShareLinkInfoWrapper shareLinkInfoWrapper;
|
||||
private final PyCryptoUtils cryptoUtils;
|
||||
|
||||
/**
|
||||
* 创建演练场执行器
|
||||
*
|
||||
* @param shareLinkInfo 分享链接信息
|
||||
* @param pyCode Python代码
|
||||
*/
|
||||
public PyPlaygroundExecutor(ShareLinkInfo shareLinkInfo, String pyCode) {
|
||||
this.shareLinkInfo = shareLinkInfo;
|
||||
this.pyCode = pyCode;
|
||||
|
||||
// 检查是否有代理配置
|
||||
JsonObject proxyConfig = null;
|
||||
if (shareLinkInfo.getOtherParam().containsKey("proxy")) {
|
||||
proxyConfig = (JsonObject) shareLinkInfo.getOtherParam().get("proxy");
|
||||
}
|
||||
|
||||
this.httpClient = new PyHttpClient(proxyConfig);
|
||||
this.playgroundLogger = new PyPlaygroundLogger();
|
||||
this.shareLinkInfoWrapper = new PyShareLinkInfoWrapper(shareLinkInfo);
|
||||
this.cryptoUtils = new PyCryptoUtils();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行parse方法(异步,带超时控制)
|
||||
*/
|
||||
public Future<String> executeParseAsync() {
|
||||
Promise<String> promise = Promise.promise();
|
||||
|
||||
// 在执行前进行安全检查
|
||||
PyCodeSecurityChecker.SecurityCheckResult securityResult = PyCodeSecurityChecker.check(pyCode);
|
||||
if (!securityResult.isPassed()) {
|
||||
playgroundLogger.errorJava("安全检查失败: " + securityResult.getMessage());
|
||||
promise.fail(new SecurityException("代码安全检查失败: " + securityResult.getMessage()));
|
||||
return promise.future();
|
||||
}
|
||||
playgroundLogger.debugJava("安全检查通过");
|
||||
|
||||
CompletableFuture<String> executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||
playgroundLogger.infoJava("开始执行parse方法");
|
||||
|
||||
// 使用池化的 Context,自动归还
|
||||
try (PyContextPool.PooledContext pc = CONTEXT_POOL.acquire()) {
|
||||
Context context = pc.getContext();
|
||||
// 注入Java对象到Python环境
|
||||
Value bindings = context.getBindings("python");
|
||||
bindings.putMember("http", httpClient);
|
||||
bindings.putMember("logger", playgroundLogger);
|
||||
bindings.putMember("share_link_info", shareLinkInfoWrapper);
|
||||
bindings.putMember("crypto", cryptoUtils);
|
||||
|
||||
// 执行Python代码(已支持真正的 pip 包如 requests, zlib 等)
|
||||
playgroundLogger.debugJava("执行Python代码");
|
||||
context.eval("python", pyCode);
|
||||
|
||||
// 调用parse函数
|
||||
Value parseFunc = bindings.getMember("parse");
|
||||
if (parseFunc == null || !parseFunc.canExecute()) {
|
||||
playgroundLogger.errorJava("Python代码中未找到parse函数");
|
||||
throw new RuntimeException("Python代码中未找到parse函数");
|
||||
}
|
||||
|
||||
playgroundLogger.debugJava("调用parse函数");
|
||||
Value result = parseFunc.execute(shareLinkInfoWrapper, httpClient, playgroundLogger);
|
||||
|
||||
if (result.isString()) {
|
||||
String downloadUrl = result.asString();
|
||||
playgroundLogger.infoJava("解析成功,返回结果: " + downloadUrl);
|
||||
return downloadUrl;
|
||||
} else {
|
||||
String errorMsg = "parse方法返回值类型错误,期望String,实际: " +
|
||||
(result.isNull() ? "null" : result.getMetaObject().toString());
|
||||
playgroundLogger.errorJava(errorMsg);
|
||||
throw new RuntimeException(errorMsg);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
String errorMsg = e.getMessage();
|
||||
if (errorMsg == null || errorMsg.isEmpty()) {
|
||||
errorMsg = e.getClass().getName();
|
||||
if (e.getCause() != null) {
|
||||
errorMsg += ": " + (e.getCause().getMessage() != null ?
|
||||
e.getCause().getMessage() : e.getCause().getClass().getName());
|
||||
}
|
||||
}
|
||||
playgroundLogger.errorJava("执行parse方法失败: " + errorMsg, e);
|
||||
throw new RuntimeException(errorMsg, e);
|
||||
}
|
||||
}, CONTEXT_POOL.getPythonExecutor());
|
||||
|
||||
// 创建超时任务
|
||||
ScheduledFuture<?> timeoutTask = CONTEXT_POOL.getTimeoutScheduler().schedule(() -> {
|
||||
if (!executionFuture.isDone()) {
|
||||
executionFuture.cancel(true);
|
||||
playgroundLogger.errorJava("执行超时,已强制中断");
|
||||
log.warn("Python执行超时,已强制取消");
|
||||
}
|
||||
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
// 处理执行结果
|
||||
executionFuture.whenComplete((result, error) -> {
|
||||
timeoutTask.cancel(false);
|
||||
|
||||
if (error != null) {
|
||||
if (error instanceof CancellationException) {
|
||||
String timeoutMsg = "Python执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
|
||||
playgroundLogger.errorJava(timeoutMsg);
|
||||
log.error(timeoutMsg);
|
||||
promise.fail(new RuntimeException(timeoutMsg));
|
||||
} else {
|
||||
Throwable cause = error.getCause();
|
||||
promise.fail(cause != null ? cause : error);
|
||||
}
|
||||
} else {
|
||||
promise.complete(result);
|
||||
}
|
||||
});
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行parseFileList方法(异步,带超时控制)
|
||||
*/
|
||||
public Future<List<FileInfo>> executeParseFileListAsync() {
|
||||
Promise<List<FileInfo>> promise = Promise.promise();
|
||||
|
||||
CompletableFuture<List<FileInfo>> executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||
playgroundLogger.infoJava("开始执行parse_file_list方法");
|
||||
|
||||
// 使用池化的 Context,自动归还
|
||||
try (PyContextPool.PooledContext pc = CONTEXT_POOL.acquire()) {
|
||||
Context context = pc.getContext();
|
||||
Value bindings = context.getBindings("python");
|
||||
bindings.putMember("http", httpClient);
|
||||
bindings.putMember("logger", playgroundLogger);
|
||||
bindings.putMember("share_link_info", shareLinkInfoWrapper);
|
||||
bindings.putMember("crypto", cryptoUtils);
|
||||
|
||||
// 执行Python代码(已支持真正的 pip 包)
|
||||
context.eval("python", pyCode);
|
||||
|
||||
Value parseFileListFunc = bindings.getMember("parse_file_list");
|
||||
if (parseFileListFunc == null || !parseFileListFunc.canExecute()) {
|
||||
playgroundLogger.errorJava("Python代码中未找到parse_file_list函数");
|
||||
throw new RuntimeException("Python代码中未找到parse_file_list函数");
|
||||
}
|
||||
|
||||
playgroundLogger.debugJava("调用parse_file_list函数");
|
||||
Value result = parseFileListFunc.execute(shareLinkInfoWrapper, httpClient, playgroundLogger);
|
||||
|
||||
List<FileInfo> fileList = convertToFileInfoList(result);
|
||||
playgroundLogger.infoJava("文件列表解析成功,共 " + fileList.size() + " 个文件");
|
||||
return fileList;
|
||||
} catch (Exception e) {
|
||||
playgroundLogger.errorJava("执行parse_file_list方法失败: " + e.getMessage(), e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}, CONTEXT_POOL.getPythonExecutor());
|
||||
|
||||
ScheduledFuture<?> timeoutTask = CONTEXT_POOL.getTimeoutScheduler().schedule(() -> {
|
||||
if (!executionFuture.isDone()) {
|
||||
executionFuture.cancel(true);
|
||||
playgroundLogger.errorJava("执行超时,已强制中断");
|
||||
}
|
||||
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
executionFuture.whenComplete((result, error) -> {
|
||||
timeoutTask.cancel(false);
|
||||
|
||||
if (error != null) {
|
||||
if (error instanceof CancellationException) {
|
||||
String timeoutMsg = "Python执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
|
||||
promise.fail(new RuntimeException(timeoutMsg));
|
||||
} else {
|
||||
Throwable cause = error.getCause();
|
||||
promise.fail(cause != null ? cause : error);
|
||||
}
|
||||
} else {
|
||||
promise.complete(result);
|
||||
}
|
||||
});
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行parseById方法(异步,带超时控制)
|
||||
*/
|
||||
public Future<String> executeParseByIdAsync() {
|
||||
Promise<String> promise = Promise.promise();
|
||||
|
||||
CompletableFuture<String> executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||
playgroundLogger.infoJava("开始执行parse_by_id方法");
|
||||
|
||||
// 使用池化的 Context,自动归还
|
||||
try (PyContextPool.PooledContext pc = CONTEXT_POOL.acquire()) {
|
||||
Context context = pc.getContext();
|
||||
Value bindings = context.getBindings("python");
|
||||
bindings.putMember("http", httpClient);
|
||||
bindings.putMember("logger", playgroundLogger);
|
||||
bindings.putMember("share_link_info", shareLinkInfoWrapper);
|
||||
bindings.putMember("crypto", cryptoUtils);
|
||||
|
||||
// 执行Python代码(已支持真正的 pip 包)
|
||||
context.eval("python", pyCode);
|
||||
|
||||
Value parseByIdFunc = bindings.getMember("parse_by_id");
|
||||
if (parseByIdFunc == null || !parseByIdFunc.canExecute()) {
|
||||
playgroundLogger.errorJava("Python代码中未找到parse_by_id函数");
|
||||
throw new RuntimeException("Python代码中未找到parse_by_id函数");
|
||||
}
|
||||
|
||||
playgroundLogger.debugJava("调用parse_by_id函数");
|
||||
Value result = parseByIdFunc.execute(shareLinkInfoWrapper, httpClient, playgroundLogger);
|
||||
|
||||
if (result.isString()) {
|
||||
String downloadUrl = result.asString();
|
||||
playgroundLogger.infoJava("按ID解析成功,返回结果: " + downloadUrl);
|
||||
return downloadUrl;
|
||||
} else {
|
||||
String errorMsg = "parse_by_id方法返回值类型错误";
|
||||
playgroundLogger.errorJava(errorMsg);
|
||||
throw new RuntimeException(errorMsg);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
playgroundLogger.errorJava("执行parse_by_id方法失败: " + e.getMessage(), e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}, CONTEXT_POOL.getPythonExecutor());
|
||||
|
||||
ScheduledFuture<?> timeoutTask = CONTEXT_POOL.getTimeoutScheduler().schedule(() -> {
|
||||
if (!executionFuture.isDone()) {
|
||||
executionFuture.cancel(true);
|
||||
playgroundLogger.errorJava("执行超时,已强制中断");
|
||||
}
|
||||
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
executionFuture.whenComplete((result, error) -> {
|
||||
timeoutTask.cancel(false);
|
||||
|
||||
if (error != null) {
|
||||
if (error instanceof CancellationException) {
|
||||
String timeoutMsg = "Python执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
|
||||
promise.fail(new RuntimeException(timeoutMsg));
|
||||
} else {
|
||||
Throwable cause = error.getCause();
|
||||
promise.fail(cause != null ? cause : error);
|
||||
}
|
||||
} else {
|
||||
promise.complete(result);
|
||||
}
|
||||
});
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志列表
|
||||
*/
|
||||
public List<PyPlaygroundLogger.LogEntry> getLogs() {
|
||||
return playgroundLogger.getLogs();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Python列表转换为FileInfo列表
|
||||
*/
|
||||
private List<FileInfo> convertToFileInfoList(Value result) {
|
||||
List<FileInfo> fileList = new ArrayList<>();
|
||||
|
||||
if (result.hasArrayElements()) {
|
||||
long size = result.getArraySize();
|
||||
for (long i = 0; i < size; i++) {
|
||||
Value item = result.getArrayElement(i);
|
||||
FileInfo fileInfo = convertToFileInfo(item);
|
||||
if (fileInfo != null) {
|
||||
fileList.add(fileInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fileList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Python字典转换为FileInfo
|
||||
*/
|
||||
private FileInfo convertToFileInfo(Value item) {
|
||||
try {
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
|
||||
if (item.hasMember("file_name") || item.hasMember("fileName")) {
|
||||
Value val = item.hasMember("file_name") ? item.getMember("file_name") : item.getMember("fileName");
|
||||
if (val != null && !val.isNull()) {
|
||||
fileInfo.setFileName(val.asString());
|
||||
}
|
||||
}
|
||||
if (item.hasMember("file_id") || item.hasMember("fileId")) {
|
||||
Value val = item.hasMember("file_id") ? item.getMember("file_id") : item.getMember("fileId");
|
||||
if (val != null && !val.isNull()) {
|
||||
fileInfo.setFileId(val.asString());
|
||||
}
|
||||
}
|
||||
if (item.hasMember("file_type") || item.hasMember("fileType")) {
|
||||
Value val = item.hasMember("file_type") ? item.getMember("file_type") : item.getMember("fileType");
|
||||
if (val != null && !val.isNull()) {
|
||||
fileInfo.setFileType(val.asString());
|
||||
}
|
||||
}
|
||||
if (item.hasMember("size")) {
|
||||
Value val = item.getMember("size");
|
||||
if (val != null && !val.isNull() && val.isNumber()) {
|
||||
fileInfo.setSize(val.asLong());
|
||||
}
|
||||
}
|
||||
if (item.hasMember("pan_type") || item.hasMember("panType")) {
|
||||
Value val = item.hasMember("pan_type") ? item.getMember("pan_type") : item.getMember("panType");
|
||||
if (val != null && !val.isNull()) {
|
||||
fileInfo.setPanType(val.asString());
|
||||
}
|
||||
}
|
||||
if (item.hasMember("parser_url") || item.hasMember("parserUrl")) {
|
||||
Value val = item.hasMember("parser_url") ? item.getMember("parser_url") : item.getMember("parserUrl");
|
||||
if (val != null && !val.isNull()) {
|
||||
fileInfo.setParserUrl(val.asString());
|
||||
}
|
||||
}
|
||||
|
||||
return fileInfo;
|
||||
|
||||
} catch (Exception e) {
|
||||
playgroundLogger.errorJava("转换FileInfo对象失败: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package cn.qaiu.parser.custompy;
|
||||
|
||||
import org.graalvm.polyglot.HostAccess;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Python演练场日志封装
|
||||
* 收集日志信息用于前端显示
|
||||
*
|
||||
* @author QAIU
|
||||
*/
|
||||
public class PyPlaygroundLogger extends PyLogger {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PyPlaygroundLogger.class);
|
||||
|
||||
private final List<LogEntry> logs = new ArrayList<>();
|
||||
|
||||
public PyPlaygroundLogger() {
|
||||
super("PyPlayground");
|
||||
}
|
||||
|
||||
@Override
|
||||
@HostAccess.Export
|
||||
public void debug(String message) {
|
||||
super.debug(message);
|
||||
addLog("DEBUG", message);
|
||||
}
|
||||
|
||||
@Override
|
||||
@HostAccess.Export
|
||||
public void debug(String message, Object... args) {
|
||||
super.debug(message, args);
|
||||
addLog("DEBUG", formatMessage(message, args));
|
||||
}
|
||||
|
||||
@Override
|
||||
@HostAccess.Export
|
||||
public void info(String message) {
|
||||
super.info(message);
|
||||
addLog("INFO", message);
|
||||
}
|
||||
|
||||
@Override
|
||||
@HostAccess.Export
|
||||
public void info(String message, Object... args) {
|
||||
super.info(message, args);
|
||||
addLog("INFO", formatMessage(message, args));
|
||||
}
|
||||
|
||||
@Override
|
||||
@HostAccess.Export
|
||||
public void warn(String message) {
|
||||
super.warn(message);
|
||||
addLog("WARN", message);
|
||||
}
|
||||
|
||||
@Override
|
||||
@HostAccess.Export
|
||||
public void warn(String message, Object... args) {
|
||||
super.warn(message, args);
|
||||
addLog("WARN", formatMessage(message, args));
|
||||
}
|
||||
|
||||
@Override
|
||||
@HostAccess.Export
|
||||
public void error(String message) {
|
||||
super.error(message);
|
||||
addLog("ERROR", message);
|
||||
}
|
||||
|
||||
@Override
|
||||
@HostAccess.Export
|
||||
public void error(String message, Object... args) {
|
||||
super.error(message, args);
|
||||
addLog("ERROR", formatMessage(message, args));
|
||||
}
|
||||
|
||||
@Override
|
||||
@HostAccess.Export
|
||||
public void error(String message, Throwable throwable) {
|
||||
super.error(message, throwable);
|
||||
addLog("ERROR", message + " - " + throwable.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加Java内部日志(不在Python脚本中调用)
|
||||
*/
|
||||
public void infoJava(String message) {
|
||||
log.info("[PyPlayground] " + message);
|
||||
addLog("INFO", "[Java] " + message, "java");
|
||||
}
|
||||
|
||||
public void debugJava(String message) {
|
||||
log.debug("[PyPlayground] " + message);
|
||||
addLog("DEBUG", "[Java] " + message, "java");
|
||||
}
|
||||
|
||||
public void errorJava(String message) {
|
||||
log.error("[PyPlayground] " + message);
|
||||
addLog("ERROR", "[Java] " + message, "java");
|
||||
}
|
||||
|
||||
public void errorJava(String message, Throwable throwable) {
|
||||
log.error("[PyPlayground] " + message, throwable);
|
||||
addLog("ERROR", "[Java] " + message + " - " + throwable.getMessage(), "java");
|
||||
}
|
||||
|
||||
private void addLog(String level, String message) {
|
||||
addLog(level, message, "python");
|
||||
}
|
||||
|
||||
private void addLog(String level, String message, String source) {
|
||||
logs.add(new LogEntry(level, message, System.currentTimeMillis(), source));
|
||||
}
|
||||
|
||||
private String formatMessage(String message, Object... args) {
|
||||
if (args == null || args.length == 0) {
|
||||
return message;
|
||||
}
|
||||
|
||||
// 简单的占位符替换
|
||||
String result = message;
|
||||
for (Object arg : args) {
|
||||
int index = result.indexOf("{}");
|
||||
if (index >= 0) {
|
||||
result = result.substring(0, index) + (arg != null ? arg.toString() : "null") + result.substring(index + 2);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有日志
|
||||
*/
|
||||
public List<LogEntry> getLogs() {
|
||||
return new ArrayList<>(logs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
public void clearLogs() {
|
||||
logs.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志数量
|
||||
*/
|
||||
public int size() {
|
||||
return logs.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志条目
|
||||
*/
|
||||
public static class LogEntry {
|
||||
private final String level;
|
||||
private final String message;
|
||||
private final long timestamp;
|
||||
private final String source;
|
||||
|
||||
public LogEntry(String level, String message, long timestamp) {
|
||||
this(level, message, timestamp, "python");
|
||||
}
|
||||
|
||||
public LogEntry(String level, String message, long timestamp, String source) {
|
||||
this.level = level;
|
||||
this.message = message;
|
||||
this.timestamp = timestamp;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
public String getLevel() {
|
||||
return level;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public String getSource() {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
package cn.qaiu.parser.custompy;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import cn.qaiu.parser.custom.CustomParserConfig;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Python脚本加载器
|
||||
* 自动加载资源目录和外部目录的Python脚本文件
|
||||
*
|
||||
* @author QAIU
|
||||
*/
|
||||
public class PyScriptLoader {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PyScriptLoader.class);
|
||||
|
||||
private static final String RESOURCE_PATH = "custom-parsers/py";
|
||||
private static final String EXTERNAL_PATH = "./custom-parsers/py";
|
||||
|
||||
// 系统属性配置的外部目录路径
|
||||
private static final String EXTERNAL_PATH_PROPERTY = "parser.custom-parsers.py.path";
|
||||
|
||||
/**
|
||||
* 加载所有Python脚本
|
||||
* @return 解析器配置列表
|
||||
*/
|
||||
public static List<CustomParserConfig> loadAllScripts() {
|
||||
List<CustomParserConfig> configs = new ArrayList<>();
|
||||
|
||||
// 1. 加载资源目录下的Python文件
|
||||
try {
|
||||
List<CustomParserConfig> resourceConfigs = loadFromResources();
|
||||
configs.addAll(resourceConfigs);
|
||||
log.info("从资源目录加载了 {} 个Python解析器", resourceConfigs.size());
|
||||
} catch (Exception e) {
|
||||
log.warn("从资源目录加载Python脚本失败", e);
|
||||
}
|
||||
|
||||
// 2. 加载外部目录下的Python文件
|
||||
try {
|
||||
List<CustomParserConfig> externalConfigs = loadFromExternal();
|
||||
configs.addAll(externalConfigs);
|
||||
log.info("从外部目录加载了 {} 个Python解析器", externalConfigs.size());
|
||||
} catch (Exception e) {
|
||||
log.warn("从外部目录加载Python脚本失败", e);
|
||||
}
|
||||
|
||||
log.info("总共加载了 {} 个Python解析器", configs.size());
|
||||
return configs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从资源目录加载Python脚本
|
||||
*/
|
||||
private static List<CustomParserConfig> loadFromResources() {
|
||||
List<CustomParserConfig> configs = new ArrayList<>();
|
||||
|
||||
try {
|
||||
List<String> resourceFiles = getResourceFileList();
|
||||
resourceFiles.sort(String::compareTo);
|
||||
|
||||
for (String resourceFile : resourceFiles) {
|
||||
try {
|
||||
InputStream inputStream = PyScriptLoader.class.getClassLoader()
|
||||
.getResourceAsStream(resourceFile);
|
||||
|
||||
if (inputStream != null) {
|
||||
String pyCode = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
CustomParserConfig config = PyScriptMetadataParser.parseScript(pyCode);
|
||||
configs.add(config);
|
||||
|
||||
String fileName = resourceFile.substring(resourceFile.lastIndexOf('/') + 1);
|
||||
log.debug("从资源目录加载Python脚本: {}", fileName);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("加载资源脚本失败: {}", resourceFile, e);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("从资源目录加载脚本时发生异常", e);
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源目录中的Python文件列表
|
||||
*/
|
||||
private static List<String> getResourceFileList() {
|
||||
List<String> resourceFiles = new ArrayList<>();
|
||||
|
||||
try {
|
||||
java.net.URL resourceUrl = PyScriptLoader.class.getClassLoader()
|
||||
.getResource(RESOURCE_PATH);
|
||||
|
||||
if (resourceUrl != null) {
|
||||
String protocol = resourceUrl.getProtocol();
|
||||
|
||||
if ("jar".equals(protocol)) {
|
||||
resourceFiles = getJarResourceFiles(resourceUrl);
|
||||
} else if ("file".equals(protocol)) {
|
||||
resourceFiles = getFileSystemResourceFiles(resourceUrl);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("获取资源文件列表失败", e);
|
||||
}
|
||||
|
||||
return resourceFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取JAR包内的Python资源文件列表
|
||||
*/
|
||||
private static List<String> getJarResourceFiles(java.net.URL jarUrl) {
|
||||
List<String> resourceFiles = new ArrayList<>();
|
||||
|
||||
try {
|
||||
String jarPath = jarUrl.getPath().substring(5, jarUrl.getPath().indexOf("!"));
|
||||
JarFile jarFile = new JarFile(jarPath);
|
||||
|
||||
Enumeration<JarEntry> entries = jarFile.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
JarEntry entry = entries.nextElement();
|
||||
String entryName = entry.getName();
|
||||
|
||||
if (entryName.startsWith(RESOURCE_PATH + "/") &&
|
||||
entryName.endsWith(".py") &&
|
||||
!isExcludedFile(entryName.substring(entryName.lastIndexOf('/') + 1))) {
|
||||
resourceFiles.add(entryName);
|
||||
}
|
||||
}
|
||||
|
||||
jarFile.close();
|
||||
} catch (Exception e) {
|
||||
log.debug("解析JAR包资源文件失败", e);
|
||||
}
|
||||
|
||||
return resourceFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件系统中的Python资源文件列表
|
||||
*/
|
||||
private static List<String> getFileSystemResourceFiles(java.net.URL fileUrl) {
|
||||
List<String> resourceFiles = new ArrayList<>();
|
||||
|
||||
try {
|
||||
java.io.File resourceDir = new java.io.File(fileUrl.getPath());
|
||||
if (resourceDir.exists() && resourceDir.isDirectory()) {
|
||||
java.io.File[] files = resourceDir.listFiles();
|
||||
if (files != null) {
|
||||
for (java.io.File file : files) {
|
||||
if (file.isFile() && file.getName().endsWith(".py") &&
|
||||
!isExcludedFile(file.getName())) {
|
||||
resourceFiles.add(RESOURCE_PATH + "/" + file.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("解析文件系统资源文件失败", e);
|
||||
}
|
||||
|
||||
return resourceFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从外部目录加载Python脚本
|
||||
*/
|
||||
private static List<CustomParserConfig> loadFromExternal() {
|
||||
List<CustomParserConfig> configs = new ArrayList<>();
|
||||
|
||||
try {
|
||||
String externalPath = getExternalPath();
|
||||
Path externalDir = Paths.get(externalPath);
|
||||
|
||||
if (!Files.exists(externalDir) || !Files.isDirectory(externalDir)) {
|
||||
log.debug("外部目录 {} 不存在或不是目录", externalPath);
|
||||
return configs;
|
||||
}
|
||||
|
||||
try (Stream<Path> paths = Files.walk(externalDir)) {
|
||||
paths.filter(Files::isRegularFile)
|
||||
.filter(path -> path.toString().endsWith(".py"))
|
||||
.filter(path -> !isExcludedFile(path.getFileName().toString()))
|
||||
.forEach(path -> {
|
||||
try {
|
||||
String pyCode = Files.readString(path, StandardCharsets.UTF_8);
|
||||
CustomParserConfig config = PyScriptMetadataParser.parseScript(pyCode);
|
||||
configs.add(config);
|
||||
log.debug("从外部目录加载Python脚本: {}", path.getFileName());
|
||||
} catch (Exception e) {
|
||||
log.warn("加载外部脚本失败: {}", path.getFileName(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("从外部目录加载脚本时发生异常", e);
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取外部目录路径
|
||||
*/
|
||||
private static String getExternalPath() {
|
||||
// 1. 检查系统属性
|
||||
String systemProperty = System.getProperty(EXTERNAL_PATH_PROPERTY);
|
||||
if (systemProperty != null && !systemProperty.trim().isEmpty()) {
|
||||
log.debug("使用系统属性配置的Python外部目录: {}", systemProperty);
|
||||
return systemProperty;
|
||||
}
|
||||
|
||||
// 2. 检查环境变量
|
||||
String envVariable = System.getenv("PARSER_CUSTOM_PARSERS_PY_PATH");
|
||||
if (envVariable != null && !envVariable.trim().isEmpty()) {
|
||||
log.debug("使用环境变量配置的Python外部目录: {}", envVariable);
|
||||
return envVariable;
|
||||
}
|
||||
|
||||
// 3. 使用默认路径
|
||||
log.debug("使用默认Python外部目录: {}", EXTERNAL_PATH);
|
||||
return EXTERNAL_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从指定文件加载Python脚本
|
||||
* @param filePath 文件路径
|
||||
* @return 解析器配置
|
||||
*/
|
||||
public static CustomParserConfig loadFromFile(String filePath) {
|
||||
try {
|
||||
Path path = Paths.get(filePath);
|
||||
if (!Files.exists(path)) {
|
||||
throw new IllegalArgumentException("文件不存在: " + filePath);
|
||||
}
|
||||
|
||||
String pyCode = Files.readString(path, StandardCharsets.UTF_8);
|
||||
return PyScriptMetadataParser.parseScript(pyCode);
|
||||
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("读取文件失败: " + filePath, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从指定资源路径加载Python脚本
|
||||
* @param resourcePath 资源路径
|
||||
* @return 解析器配置
|
||||
*/
|
||||
public static CustomParserConfig loadFromResource(String resourcePath) {
|
||||
try {
|
||||
InputStream inputStream = PyScriptLoader.class.getClassLoader()
|
||||
.getResourceAsStream(resourcePath);
|
||||
|
||||
if (inputStream == null) {
|
||||
throw new IllegalArgumentException("资源文件不存在: " + resourcePath);
|
||||
}
|
||||
|
||||
String pyCode = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
return PyScriptMetadataParser.parseScript(pyCode);
|
||||
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("读取资源文件失败: " + resourcePath, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查外部目录是否存在
|
||||
*/
|
||||
public static boolean isExternalDirectoryExists() {
|
||||
Path externalDir = Paths.get(EXTERNAL_PATH);
|
||||
return Files.exists(externalDir) && Files.isDirectory(externalDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建外部目录
|
||||
*/
|
||||
public static boolean createExternalDirectory() {
|
||||
try {
|
||||
Path externalDir = Paths.get(EXTERNAL_PATH);
|
||||
Files.createDirectories(externalDir);
|
||||
log.info("创建Python外部目录成功: {}", EXTERNAL_PATH);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
log.error("创建Python外部目录失败: {}", EXTERNAL_PATH, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取外部目录路径
|
||||
*/
|
||||
public static String getExternalDirectoryPath() {
|
||||
return EXTERNAL_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源目录路径
|
||||
*/
|
||||
public static String getResourceDirectoryPath() {
|
||||
return RESOURCE_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否应该被排除
|
||||
*/
|
||||
private static boolean isExcludedFile(String fileName) {
|
||||
return fileName.equals("types.pyi") ||
|
||||
fileName.equals("__init__.py") ||
|
||||
fileName.equals("README.md") ||
|
||||
fileName.contains("_test.") ||
|
||||
fileName.contains("_spec.") ||
|
||||
fileName.startsWith("test_");
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
package cn.qaiu.parser.custompy;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import cn.qaiu.parser.custom.CustomParserConfig;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Python脚本元数据解析器
|
||||
* 解析类油猴格式的元数据注释(Python风格)
|
||||
*
|
||||
* @author QAIU
|
||||
*/
|
||||
public class PyScriptMetadataParser {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PyScriptMetadataParser.class);
|
||||
|
||||
// 元数据块匹配正则(Python注释风格)
|
||||
// 支持 # ==UserScript== 格式
|
||||
private static final Pattern METADATA_BLOCK_PATTERN = Pattern.compile(
|
||||
"#\\s*==UserScript==\\s*(.*?)\\s*#\\s*==/UserScript==",
|
||||
Pattern.DOTALL
|
||||
);
|
||||
|
||||
// 元数据行匹配正则
|
||||
private static final Pattern METADATA_LINE_PATTERN = Pattern.compile(
|
||||
"#\\s*@(\\w+)\\s+(.*)"
|
||||
);
|
||||
|
||||
/**
|
||||
* 解析Python脚本,提取元数据并构建CustomParserConfig
|
||||
*
|
||||
* @param pyCode Python代码
|
||||
* @return CustomParserConfig配置对象
|
||||
* @throws IllegalArgumentException 如果解析失败或缺少必填字段
|
||||
*/
|
||||
public static CustomParserConfig parseScript(String pyCode) {
|
||||
if (StringUtils.isBlank(pyCode)) {
|
||||
throw new IllegalArgumentException("Python代码不能为空");
|
||||
}
|
||||
|
||||
// 1. 提取元数据块
|
||||
Map<String, String> metadata = extractMetadata(pyCode);
|
||||
|
||||
// 2. 验证必填字段
|
||||
validateRequiredFields(metadata);
|
||||
|
||||
// 3. 构建CustomParserConfig
|
||||
return buildConfig(metadata, pyCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取元数据
|
||||
*/
|
||||
private static Map<String, String> extractMetadata(String pyCode) {
|
||||
Map<String, String> metadata = new HashMap<>();
|
||||
|
||||
Matcher blockMatcher = METADATA_BLOCK_PATTERN.matcher(pyCode);
|
||||
if (!blockMatcher.find()) {
|
||||
throw new IllegalArgumentException("未找到元数据块,请确保包含 # ==UserScript== ... # ==/UserScript== 格式的注释");
|
||||
}
|
||||
|
||||
String metadataBlock = blockMatcher.group(1);
|
||||
Matcher lineMatcher = METADATA_LINE_PATTERN.matcher(metadataBlock);
|
||||
|
||||
while (lineMatcher.find()) {
|
||||
String key = lineMatcher.group(1).toLowerCase();
|
||||
String value = lineMatcher.group(2).trim();
|
||||
metadata.put(key, value);
|
||||
}
|
||||
|
||||
log.debug("解析到Python脚本元数据: {}", metadata);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证必填字段
|
||||
*/
|
||||
private static void validateRequiredFields(Map<String, String> metadata) {
|
||||
if (!metadata.containsKey("name")) {
|
||||
throw new IllegalArgumentException("缺少必填字段 @name");
|
||||
}
|
||||
if (!metadata.containsKey("type")) {
|
||||
throw new IllegalArgumentException("缺少必填字段 @type");
|
||||
}
|
||||
if (!metadata.containsKey("displayname")) {
|
||||
throw new IllegalArgumentException("缺少必填字段 @displayName");
|
||||
}
|
||||
if (!metadata.containsKey("match")) {
|
||||
throw new IllegalArgumentException("缺少必填字段 @match");
|
||||
}
|
||||
|
||||
// 验证match字段包含KEY命名捕获组
|
||||
String matchPattern = metadata.get("match");
|
||||
if (!matchPattern.contains("(?P<KEY>") && !matchPattern.contains("(?<KEY>")) {
|
||||
throw new IllegalArgumentException("@match 正则表达式必须包含命名捕获组 KEY(Python格式: (?P<KEY>...) 或 Java格式: (?<KEY>...))");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建CustomParserConfig
|
||||
*/
|
||||
private static CustomParserConfig buildConfig(Map<String, String> metadata, String pyCode) {
|
||||
CustomParserConfig.Builder builder = CustomParserConfig.builder()
|
||||
.type(metadata.get("type"))
|
||||
.displayName(metadata.get("displayname"))
|
||||
.isPyParser(true)
|
||||
.pyCode(pyCode)
|
||||
.language("python")
|
||||
.metadata(metadata);
|
||||
|
||||
// 设置匹配正则(将Python风格的(?P<KEY>...)转换为Java风格的(?<KEY>...))
|
||||
String matchPattern = metadata.get("match");
|
||||
if (StringUtils.isNotBlank(matchPattern)) {
|
||||
// 将Python命名捕获组转换为Java格式
|
||||
matchPattern = matchPattern.replace("(?P<", "(?<");
|
||||
builder.matchPattern(matchPattern);
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查Python代码是否包含有效的元数据块
|
||||
*
|
||||
* @param pyCode Python代码
|
||||
* @return true表示包含有效元数据,false表示不包含
|
||||
*/
|
||||
public static boolean hasValidMetadata(String pyCode) {
|
||||
if (StringUtils.isBlank(pyCode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, String> metadata = extractMetadata(pyCode);
|
||||
return metadata.containsKey("name") &&
|
||||
metadata.containsKey("type") &&
|
||||
metadata.containsKey("displayname") &&
|
||||
metadata.containsKey("match");
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取脚本类型(不验证必填字段)
|
||||
*
|
||||
* @param pyCode Python代码
|
||||
* @return 脚本类型,如果无法提取则返回null
|
||||
*/
|
||||
public static String getScriptType(String pyCode) {
|
||||
if (StringUtils.isBlank(pyCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, String> metadata = extractMetadata(pyCode);
|
||||
return metadata.get("type");
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取脚本显示名称(不验证必填字段)
|
||||
*
|
||||
* @param pyCode Python代码
|
||||
* @return 显示名称,如果无法提取则返回null
|
||||
*/
|
||||
public static String getScriptDisplayName(String pyCode) {
|
||||
if (StringUtils.isBlank(pyCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, String> metadata = extractMetadata(pyCode);
|
||||
return metadata.get("displayname");
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
package cn.qaiu.parser.custompy;
|
||||
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import org.graalvm.polyglot.HostAccess;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* ShareLinkInfo的Python包装器
|
||||
* 为Python脚本提供ShareLinkInfo对象的访问接口
|
||||
*
|
||||
* @author QAIU
|
||||
*/
|
||||
public class PyShareLinkInfoWrapper {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PyShareLinkInfoWrapper.class);
|
||||
|
||||
private final ShareLinkInfo shareLinkInfo;
|
||||
|
||||
public PyShareLinkInfoWrapper(ShareLinkInfo shareLinkInfo) {
|
||||
this.shareLinkInfo = shareLinkInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分享URL
|
||||
* @return 分享URL
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String getShareUrl() {
|
||||
return shareLinkInfo.getShareUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Python风格方法名 - 获取分享URL
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String get_share_url() {
|
||||
return getShareUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分享Key
|
||||
* @return 分享Key
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String getShareKey() {
|
||||
return shareLinkInfo.getShareKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Python风格方法名 - 获取分享Key
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String get_share_key() {
|
||||
return getShareKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分享密码
|
||||
* @return 分享密码
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String getSharePassword() {
|
||||
return shareLinkInfo.getSharePassword();
|
||||
}
|
||||
|
||||
/**
|
||||
* Python风格方法名 - 获取分享密码
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String get_share_password() {
|
||||
return getSharePassword();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网盘类型
|
||||
* @return 网盘类型
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String getType() {
|
||||
return shareLinkInfo.getType();
|
||||
}
|
||||
|
||||
/**
|
||||
* Python风格方法名 - 获取网盘类型
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String get_type() {
|
||||
return getType();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网盘名称
|
||||
* @return 网盘名称
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String getPanName() {
|
||||
return shareLinkInfo.getPanName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Python风格方法名 - 获取网盘名称
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String get_pan_name() {
|
||||
return getPanName();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取其他参数
|
||||
* @param key 参数键
|
||||
* @return 参数值
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public Object getOtherParam(String key) {
|
||||
if (key == null) {
|
||||
return null;
|
||||
}
|
||||
return shareLinkInfo.getOtherParam().get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Python风格方法名 - 获取其他参数
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public Object get_other_param(String key) {
|
||||
return getOtherParam(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有其他参数
|
||||
* @return 参数Map
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public Map<String, Object> getAllOtherParams() {
|
||||
return shareLinkInfo.getOtherParam();
|
||||
}
|
||||
|
||||
/**
|
||||
* Python风格方法名 - 获取所有其他参数
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public Map<String, Object> get_all_other_params() {
|
||||
return getAllOtherParams();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否包含指定参数
|
||||
* @param key 参数键
|
||||
* @return true表示包含,false表示不包含
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public boolean hasOtherParam(String key) {
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
return shareLinkInfo.getOtherParam().containsKey(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Python风格方法名 - 检查是否包含指定参数
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public boolean has_other_param(String key) {
|
||||
return hasOtherParam(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取其他参数的字符串值
|
||||
* @param key 参数键
|
||||
* @return 参数值(字符串形式)
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String getOtherParamAsString(String key) {
|
||||
Object value = getOtherParam(key);
|
||||
return value != null ? value.toString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Python风格方法名 - 获取其他参数的字符串值
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public String get_other_param_as_string(String key) {
|
||||
return getOtherParamAsString(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取其他参数的整数值
|
||||
* @param key 参数键
|
||||
* @return 参数值(整数形式)
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public Integer getOtherParamAsInteger(String key) {
|
||||
Object value = getOtherParam(key);
|
||||
if (value instanceof Integer) {
|
||||
return (Integer) value;
|
||||
} else if (value instanceof Number) {
|
||||
return ((Number) value).intValue();
|
||||
} else if (value instanceof String) {
|
||||
try {
|
||||
return Integer.parseInt((String) value);
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("无法将参数 {} 转换为整数: {}", key, value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Python风格方法名 - 获取其他参数的整数值
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public Integer get_other_param_as_integer(String key) {
|
||||
return getOtherParamAsInteger(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取其他参数的布尔值
|
||||
* @param key 参数键
|
||||
* @return 参数值(布尔形式)
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public Boolean getOtherParamAsBoolean(String key) {
|
||||
Object value = getOtherParam(key);
|
||||
if (value instanceof Boolean) {
|
||||
return (Boolean) value;
|
||||
} else if (value instanceof String) {
|
||||
return Boolean.parseBoolean((String) value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Python风格方法名 - 获取其他参数的布尔值
|
||||
*/
|
||||
@HostAccess.Export
|
||||
public Boolean get_other_param_as_boolean(String key) {
|
||||
return getOtherParamAsBoolean(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始的ShareLinkInfo对象
|
||||
* @return ShareLinkInfo对象
|
||||
*/
|
||||
public ShareLinkInfo getOriginalShareLinkInfo() {
|
||||
return shareLinkInfo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "PyShareLinkInfoWrapper{" +
|
||||
"shareUrl='" + getShareUrl() + '\'' +
|
||||
", shareKey='" + getShareKey() + '\'' +
|
||||
", sharePassword='" + getSharePassword() + '\'' +
|
||||
", type='" + getType() + '\'' +
|
||||
", panName='" + getPanName() + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user