Compare commits
1015 Commits
revert-292
...
v1.1.262
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ebef1b116 | ||
|
|
35f755246e | ||
|
|
338d44faee | ||
|
|
968398ffa5 | ||
|
|
645ab43675 | ||
|
|
1027a2e3e2 | ||
|
|
0f5321b0ef | ||
|
|
c7d7bf47d6 | ||
|
|
ebc30b6026 | ||
|
|
d5a7af2d7d | ||
|
|
81a3e26e27 | ||
|
|
64db4a270d | ||
|
|
ca027ecb90 | ||
|
|
21e6944abb | ||
|
|
4ea3d4830f | ||
|
|
3000632d4e | ||
|
|
9e3a4cf45a | ||
|
|
eb992697b6 | ||
|
|
35ab34d687 | ||
|
|
bc4b050c69 | ||
|
|
189d53d793 | ||
|
|
b148537428 | ||
|
|
9d1a451027 | ||
|
|
ba815de08f | ||
|
|
b26027731e | ||
|
|
f535b35a1c | ||
|
|
962e01b080 | ||
|
|
fcc6ac4e22 | ||
|
|
3a03147ac9 | ||
|
|
94f239b56a | ||
|
|
b07873772c | ||
|
|
549c95eb80 | ||
|
|
b397954ea4 | ||
|
|
ed835d0c28 | ||
|
|
28b27e6a7b | ||
|
|
810fe9fe90 | ||
|
|
141b07db78 | ||
|
|
1dad810d15 | ||
|
|
4723328be4 | ||
|
|
944ef096b3 | ||
|
|
114e9facee | ||
|
|
e20ce86ad4 | ||
|
|
6caabb5444 | ||
|
|
b924c3c559 | ||
|
|
6682e0a982 | ||
|
|
b9c088ce58 | ||
|
|
2ff74c21d2 | ||
|
|
8a4dadbbc0 | ||
|
|
adf2890f65 | ||
|
|
7d892a69f1 | ||
|
|
a749ddfede | ||
|
|
dbd4fb19cf | ||
|
|
39ba345a43 | ||
|
|
2693fd77b7 | ||
|
|
3cc3219a90 | ||
|
|
1b834ffcdb | ||
|
|
41999f56b4 | ||
|
|
b81c2b946f | ||
|
|
0a59a0f9d4 | ||
|
|
c4448db6ab | ||
|
|
c67d2bce9d | ||
|
|
a345812cd7 | ||
|
|
a0cbafd759 | ||
|
|
3c64038fa7 | ||
|
|
45b81bd478 | ||
|
|
fc57133230 | ||
|
|
1f06af4a56 | ||
|
|
6165fad090 | ||
|
|
d53a399d41 | ||
|
|
3f98267738 | ||
|
|
e187b8946a | ||
|
|
8917019a78 | ||
|
|
9960f237b8 | ||
|
|
b6da77cabe | ||
|
|
e561387e81 | ||
|
|
982cca1020 | ||
|
|
792ba51290 | ||
|
|
74d138a2fb | ||
|
|
b88698191e | ||
|
|
11c38b23d1 | ||
|
|
b2dfc2eb25 | ||
|
|
18a493e805 | ||
|
|
59ce0f091c | ||
|
|
67c20fa30e | ||
|
|
671451253f | ||
|
|
534fbf6ac2 | ||
|
|
0173ab224b | ||
|
|
11fb77c8bd | ||
|
|
3d67f0b124 | ||
|
|
84f19b348b | ||
|
|
8ec8a59b07 | ||
|
|
00d8ac4bec | ||
|
|
b6f3459522 | ||
|
|
e56d797d87 | ||
|
|
4c6879a9c2 | ||
|
|
1c8084a3b1 | ||
|
|
f6f4b5cfec | ||
|
|
26ca696b91 | ||
|
|
ce496ed9e6 | ||
|
|
f6ed420401 | ||
|
|
5863816882 | ||
|
|
638d2ff189 | ||
|
|
fa2fc2fb16 | ||
|
|
6d56601550 | ||
|
|
dd8a0c95c3 | ||
|
|
126eee3712 | ||
|
|
26bfdd6892 | ||
|
|
cd3f51e9e2 | ||
|
|
9977245d59 | ||
|
|
09cf951cdc | ||
|
|
33ea26f2ac | ||
|
|
ba93ae55a9 | ||
|
|
53cda0fd18 | ||
|
|
151cb7536c | ||
|
|
0994eb346f | ||
|
|
4863a37328 | ||
|
|
052e236a93 | ||
|
|
c79ea19aa1 | ||
|
|
79f2cebdb8 | ||
|
|
bd7b8884ab | ||
|
|
38e0adb499 | ||
|
|
7698f5ce11 | ||
|
|
ce13e5ddb1 | ||
|
|
baafebbf7b | ||
|
|
87426133a2 | ||
|
|
60f5cbe780 | ||
|
|
86d8ed52d7 | ||
|
|
07633ddbf8 | ||
|
|
dd90c426e4 | ||
|
|
059357f834 | ||
|
|
ceee3a9295 | ||
|
|
403f609f69 | ||
|
|
304c8dda4e | ||
|
|
c4d923c46f | ||
|
|
fa9f9146a2 | ||
|
|
dc9409a5a6 | ||
|
|
51aa8dc381 | ||
|
|
5061f4d9fd | ||
|
|
4337af06d4 | ||
|
|
d226d57325 | ||
|
|
9f92c58640 | ||
|
|
8901994644 | ||
|
|
e3ca555df7 | ||
|
|
3b9c96dff8 | ||
|
|
cb94a4260e | ||
|
|
ac9499aa6d | ||
|
|
fc25840f95 | ||
|
|
b409adf9d8 | ||
|
|
b76776d7b0 | ||
|
|
8499992abd | ||
|
|
dc96447d72 | ||
|
|
f5d1c25295 | ||
|
|
95870883a1 | ||
|
|
aa71c58400 | ||
|
|
698f3d7daa | ||
|
|
5af5e55d80 | ||
|
|
5a18f54abd | ||
|
|
6d3b51510a | ||
|
|
c79fdc4d71 | ||
|
|
659072075d | ||
|
|
cf93128a96 | ||
|
|
909b5ad37f | ||
|
|
bab7073822 | ||
|
|
0035f8cb4f | ||
|
|
d49cc0cec8 | ||
|
|
c4d6ab97f2 | ||
|
|
7053d5f1ac | ||
|
|
24796fc889 | ||
|
|
201d95c84e | ||
|
|
b978d864e3 | ||
|
|
175c041e5a | ||
|
|
b441506199 | ||
|
|
eb2341fb16 | ||
|
|
e89e2964e7 | ||
|
|
b3e27e9f15 | ||
|
|
d0b397b45a | ||
|
|
195e42e0a5 | ||
|
|
ebecee4c6f | ||
|
|
0607322cc7 | ||
|
|
0828746281 | ||
|
|
e1df90684a | ||
|
|
f74f77ef65 | ||
|
|
b63c3217bc | ||
|
|
d81a16b98d | ||
|
|
30727be92f | ||
|
|
b8a6cc627a | ||
|
|
01c63bf5df | ||
|
|
4317962955 | ||
|
|
b66fd7f655 | ||
|
|
ac280ef563 | ||
|
|
c70070d912 | ||
|
|
849d8e047b | ||
|
|
065aa6d35e | ||
|
|
10a1d61427 | ||
|
|
cfdcc97cc7 | ||
|
|
ea053c6a16 | ||
|
|
84a8fdeaba | ||
|
|
c1c941aa4c | ||
|
|
f78e376dea | ||
|
|
530d38e4a4 | ||
|
|
0bf7bfae04 | ||
|
|
fbb660138c | ||
|
|
9c970fda3b | ||
|
|
bfa3f528a2 | ||
|
|
9b0d0bee96 | ||
|
|
ff30bfab82 | ||
|
|
93497cc13c | ||
|
|
2429bad2b7 | ||
|
|
a03753030c | ||
|
|
94aca4dc22 | ||
|
|
6bfef2525a | ||
|
|
5a636a36f6 | ||
|
|
b61e1062bf | ||
|
|
6ab91c0c75 | ||
|
|
675e7b9111 | ||
|
|
f82db11e7d | ||
|
|
06b18b7186 | ||
|
|
12cb841a64 | ||
|
|
dc868522cf | ||
|
|
b1dc27b5d7 | ||
|
|
b94bd2b822 | ||
|
|
827c0f6207 | ||
|
|
0b3cf5112b | ||
|
|
3db268fff7 | ||
|
|
81971436e6 | ||
|
|
69a1006f4c | ||
|
|
4cf1762467 | ||
|
|
0d64d40654 | ||
|
|
1b18a1226d | ||
|
|
0b2372abab | ||
|
|
8aca1f9dd1 | ||
|
|
95ef04c1a3 | ||
|
|
4919e392a5 | ||
|
|
354d8da13f | ||
|
|
3df0c7c650 | ||
|
|
6a3dce523b | ||
|
|
9fe2918a54 | ||
|
|
92b30e1924 | ||
|
|
b63f2f78fc | ||
|
|
c971d239ff | ||
|
|
01d6e30e82 | ||
|
|
5fd78b6411 | ||
|
|
9ad5c85c2c | ||
|
|
279cd72f23 | ||
|
|
81e89d2dc4 | ||
|
|
c38b3d2a78 | ||
|
|
e8e6f972b4 | ||
|
|
d3155b82ea | ||
|
|
02018e10f3 | ||
|
|
e17cd1d61b | ||
|
|
b9d53647bd | ||
|
|
a872529b2e | ||
|
|
dfee7be944 | ||
|
|
392601efd5 | ||
|
|
249e256360 | ||
|
|
876b126ce0 | ||
|
|
6ec4f4bf5b | ||
|
|
326adaaeca | ||
|
|
d89344ad87 | ||
|
|
68f003976e | ||
|
|
63a7c2514b | ||
|
|
c6a7771b81 | ||
|
|
b58b8b1ac7 | ||
|
|
53553c7e76 | ||
|
|
4a0ba6ed63 | ||
|
|
28caa93d99 | ||
|
|
d9476230c6 | ||
|
|
49645e8a50 | ||
|
|
7db70e2dc0 | ||
|
|
fd2b8a0114 | ||
|
|
851809a132 | ||
|
|
4aeb47062b | ||
|
|
0c124ef37b | ||
|
|
94ff095754 | ||
|
|
291642d8ff | ||
|
|
89238818eb | ||
|
|
4d21c85f83 | ||
|
|
9179776688 | ||
|
|
8d07672ac5 | ||
|
|
3fb874fc29 | ||
|
|
6e95607285 | ||
|
|
86cf907f3b | ||
|
|
919501a2f1 | ||
|
|
dea6964116 | ||
|
|
b619208970 | ||
|
|
e0500f0530 | ||
|
|
255b3a0a0d | ||
|
|
22fbabbc47 | ||
|
|
82e63ef55b | ||
|
|
25f455ac1c | ||
|
|
a4dcfb842e | ||
|
|
fab2df0cf5 | ||
|
|
8f2cf211de | ||
|
|
6ebe7d0250 | ||
|
|
a0a7aae28e | ||
|
|
691b492bc7 | ||
|
|
8e09eb227e | ||
|
|
c7276f10b8 | ||
|
|
7706d3480d | ||
|
|
53d2f1ff9b | ||
|
|
8863075fde | ||
|
|
bae39d5468 | ||
|
|
b197cba325 | ||
|
|
e4302f80c3 | ||
|
|
c47bb7295e | ||
|
|
c0f517b10c | ||
|
|
57ce3ecf9c | ||
|
|
dfc2a8e053 | ||
|
|
145dc64a96 | ||
|
|
1b784dcb43 | ||
|
|
186d82bde9 | ||
|
|
c33771ef82 | ||
|
|
22e10c57ea | ||
|
|
6f9ac4aa84 | ||
|
|
8882d92fd8 | ||
|
|
823be8acfc | ||
|
|
61929874eb | ||
|
|
e9b6416c06 | ||
|
|
9b0a1f9bda | ||
|
|
14af4a4333 | ||
|
|
11028fb173 | ||
|
|
5d0b5ed946 | ||
|
|
696a095fb9 | ||
|
|
1b7016c3dc | ||
|
|
87449de76c | ||
|
|
854aa6e6bc | ||
|
|
fb7a8f841a | ||
|
|
b1853a0760 | ||
|
|
9eccc7da49 | ||
|
|
94925e57bd | ||
|
|
26ad7482ba | ||
|
|
6d8bf99e78 | ||
|
|
d7358107f8 | ||
|
|
77938b6e39 | ||
|
|
1c47e1bb4f | ||
|
|
87f37f6486 | ||
|
|
03c611a948 | ||
|
|
861ad11647 | ||
|
|
ba6fe1c8af | ||
|
|
d0f23dac46 | ||
|
|
69ecf02f46 | ||
|
|
ea0585d6cb | ||
|
|
fdded1b8c3 | ||
|
|
e27b66383c | ||
|
|
19c270fca1 | ||
|
|
1f2258021c | ||
|
|
aa5510e502 | ||
|
|
47d7a394c9 | ||
|
|
a64b0d557f | ||
|
|
7a6c287a7e | ||
|
|
e130405809 | ||
|
|
008c7a2b03 | ||
|
|
df796a005a | ||
|
|
cc82812732 | ||
|
|
154e568663 | ||
|
|
91ad0658a9 | ||
|
|
baffd02b02 | ||
|
|
9291cdc041 | ||
|
|
24c8afbbee | ||
|
|
3525fe5697 | ||
|
|
80307f005e | ||
|
|
189c769698 | ||
|
|
e7cb532833 | ||
|
|
9b15e08624 | ||
|
|
5c021115ef | ||
|
|
ff1b982ed0 | ||
|
|
5ac0b80161 | ||
|
|
a2b04eea07 | ||
|
|
0d94ff82f4 | ||
|
|
42fc164fa4 | ||
|
|
3abd0b0f36 | ||
|
|
fd27050934 | ||
|
|
1458d609ca | ||
|
|
b61a3103e9 | ||
|
|
edf302fd6b | ||
|
|
abef8a4e31 | ||
|
|
580afadf79 | ||
|
|
d3489d1bfd | ||
|
|
1ed0ca31ec | ||
|
|
6ea2012ab1 | ||
|
|
2ec17360d6 | ||
|
|
299b16e9c6 | ||
|
|
17311f2d3b | ||
|
|
b0e6ac3923 | ||
|
|
aa66d89021 | ||
|
|
05f4454c10 | ||
|
|
e3a2d33428 | ||
|
|
484689e479 | ||
|
|
3e381ea211 | ||
|
|
5cff6fdd6d | ||
|
|
ad9a65d3c9 | ||
|
|
9ed4a344be | ||
|
|
77bca73094 | ||
|
|
b0917b75a4 | ||
|
|
8f58fe6264 | ||
|
|
28b709d30b | ||
|
|
cad285e8af | ||
|
|
b5efb23a5e | ||
|
|
c3e9082367 | ||
|
|
be67af6340 | ||
|
|
c9d4ee1cf5 | ||
|
|
f6eb077d82 | ||
|
|
83f7353284 | ||
|
|
86cecaa356 | ||
|
|
2ac088fd3a | ||
|
|
994e474155 | ||
|
|
4b011fe8b1 | ||
|
|
2f0839c7da | ||
|
|
d3cf66e2c0 | ||
|
|
6c0f38f5e8 | ||
|
|
d606cb2e38 | ||
|
|
b9d2e855f3 | ||
|
|
d275b0d4b6 | ||
|
|
0092c92196 | ||
|
|
914c1b6120 | ||
|
|
3cb674279a | ||
|
|
472fb535cf | ||
|
|
77124aa501 | ||
|
|
8ab3c76c6f | ||
|
|
c2669da4b3 | ||
|
|
d72897f835 | ||
|
|
1bd9002af9 | ||
|
|
6bb74376ca | ||
|
|
b886012f97 | ||
|
|
344599f318 | ||
|
|
e540ec3a52 | ||
|
|
92712277db | ||
|
|
62aea5a4a8 | ||
|
|
6c60478777 | ||
|
|
aaa2bda407 | ||
|
|
82433c3b8d | ||
|
|
1f61478fbc | ||
|
|
cd5df4f76b | ||
|
|
cbc3a83f11 | ||
|
|
b4658e5d5f | ||
|
|
4b3ffa4136 | ||
|
|
86ccb0273e | ||
|
|
dfea5fe534 | ||
|
|
914142541a | ||
|
|
d6a9beff2f | ||
|
|
ba60a2dcbb | ||
|
|
6e1ed12771 | ||
|
|
1e7465e533 | ||
|
|
8dd07919f4 | ||
|
|
582348d615 | ||
|
|
38c61e1018 | ||
|
|
8d84e2fa6e | ||
|
|
e051ade27e | ||
|
|
ea3ad2157f | ||
|
|
46ba514801 | ||
|
|
1f9afc788b | ||
|
|
268f041588 | ||
|
|
222b2862cc | ||
|
|
8ddb905213 | ||
|
|
96eca07ff2 | ||
|
|
2215b0acd8 | ||
|
|
43dee194f4 | ||
|
|
3ab2c0ec20 | ||
|
|
8093dfb11c | ||
|
|
f302c94d3c | ||
|
|
1145fb7b7d | ||
|
|
cea6f976b9 | ||
|
|
508d9aad1b | ||
|
|
a67c34bee1 | ||
|
|
44a7a61f14 | ||
|
|
ad64bd3c51 | ||
|
|
6f6c274877 | ||
|
|
7d4bf9f94f | ||
|
|
692abbc4f8 | ||
|
|
a2844e802a | ||
|
|
5611e86154 | ||
|
|
e6d9a46b98 | ||
|
|
22928aeae3 | ||
|
|
2fa1b0b1dc | ||
|
|
33e69ac6e2 | ||
|
|
0b00682e74 | ||
|
|
45dab2af40 | ||
|
|
40b7c68694 | ||
|
|
f513be4328 | ||
|
|
a3dacbccd0 | ||
|
|
62e457932e | ||
|
|
0d7a200505 | ||
|
|
a84f344df6 | ||
|
|
8e415f8ff8 | ||
|
|
df2527a86c | ||
|
|
b7cd143b4c | ||
|
|
a58d67940c | ||
|
|
44c6be129b | ||
|
|
c8c337099e | ||
|
|
e337d1fb43 | ||
|
|
26894f485b | ||
|
|
0b2610842a | ||
|
|
53dee11a10 | ||
|
|
6dcb8b9449 | ||
|
|
b7fe75cb60 | ||
|
|
c81ec34ad8 | ||
|
|
6b85a027bd | ||
|
|
56fe7be8ec | ||
|
|
b408a7122d | ||
|
|
cd9a2025b2 | ||
|
|
3862a1d77c | ||
|
|
9b211b063b | ||
|
|
4a925e2f8b | ||
|
|
a6f5876eca | ||
|
|
6f2307721b | ||
|
|
6c2ef2eef3 | ||
|
|
c56bebdbe5 | ||
|
|
19fa518e65 | ||
|
|
a82dcebd7b | ||
|
|
1c80970aef | ||
|
|
80059e2b09 | ||
|
|
c9ad287587 | ||
|
|
6b02dbf040 | ||
|
|
e150bc4beb | ||
|
|
1198ee1619 | ||
|
|
14e54c0473 | ||
|
|
66fe3cf74a | ||
|
|
5165d6c536 | ||
|
|
6a5b53c047 | ||
|
|
e209a23ae7 | ||
|
|
b28631e737 | ||
|
|
fad9e52c98 | ||
|
|
1811290c0b | ||
|
|
42db271848 | ||
|
|
99dbb154dd | ||
|
|
6b1062caa6 | ||
|
|
75804f4c2e | ||
|
|
2fc84a6aca | ||
|
|
df5e04b4c4 | ||
|
|
4de2ea3d17 | ||
|
|
8f9286c30e | ||
|
|
705bd7611c | ||
|
|
786a62e2e3 | ||
|
|
cffd023239 | ||
|
|
eb304c7e70 | ||
|
|
9209f10fc3 | ||
|
|
642ea1a33a | ||
|
|
bd445eef29 | ||
|
|
6e770146fd | ||
|
|
9c022e6642 | ||
|
|
cac1b90d23 | ||
|
|
88429e1a24 | ||
|
|
1777309218 | ||
|
|
52af60b3c9 | ||
|
|
454f366c50 | ||
|
|
bb95c0b7d5 | ||
|
|
5d7225b2eb | ||
|
|
61e5cb4584 | ||
|
|
1705412bb0 | ||
|
|
4d380e03f1 | ||
|
|
530dac0e7f | ||
|
|
1fdfce1e4f | ||
|
|
2872198259 | ||
|
|
cd72a29674 | ||
|
|
d44582dc31 | ||
|
|
57e75cd526 | ||
|
|
bda1875466 | ||
|
|
06a3aff069 | ||
|
|
a3666e3a3e | ||
|
|
ea0f818251 | ||
|
|
7183903147 | ||
|
|
fe894cc07a | ||
|
|
3f79e56209 | ||
|
|
9148913ca4 | ||
|
|
5024628fa6 | ||
|
|
7d1608edfe | ||
|
|
4eddfd99dd | ||
|
|
7fd5224e0a | ||
|
|
782e912a0d | ||
|
|
e88f07ca92 | ||
|
|
11c214449f | ||
|
|
87bd54d9ea | ||
|
|
3e348cb7c8 | ||
|
|
fcf54565ec | ||
|
|
4ab91f233f | ||
|
|
66b9b391d9 | ||
|
|
4ad1ccc22c | ||
|
|
c62b397fde | ||
|
|
29ee52ee2e | ||
|
|
c19acf2b01 | ||
|
|
f8f0a7042e | ||
|
|
e705a3b16c | ||
|
|
973e51e6a7 | ||
|
|
3e69f17330 | ||
|
|
286a269446 | ||
|
|
f0b7f5066c | ||
|
|
b4d7ed06c5 | ||
|
|
aca2b1cccb | ||
|
|
506bd5a205 | ||
|
|
323f3ab6c4 | ||
|
|
5e015e87e0 | ||
|
|
b123cc35c1 | ||
|
|
90dce32cfc | ||
|
|
5ce385d2bc | ||
|
|
a12e076413 | ||
|
|
3077c3d789 | ||
|
|
b9c606e82e | ||
|
|
ca2a12e7e8 | ||
|
|
e197fbdf80 | ||
|
|
cb9778f01e | ||
|
|
e675c5878e | ||
|
|
ea28222c71 | ||
|
|
5e730db7f9 | ||
|
|
0b46eff4ed | ||
|
|
774343d9e2 | ||
|
|
89829d7e57 | ||
|
|
d1bbc71796 | ||
|
|
2eb50c78c6 | ||
|
|
c8b72b4eaa | ||
|
|
0e724c9901 | ||
|
|
c142cbf9ea | ||
|
|
f97db927c0 | ||
|
|
9f24f108b2 | ||
|
|
25d1c3f74e | ||
|
|
66bb3419b7 | ||
|
|
a4af38ff83 | ||
|
|
fe3d94648d | ||
|
|
4ceaa80cbe | ||
|
|
c15ef0b6ae | ||
|
|
991dd1436f | ||
|
|
0d8311958b | ||
|
|
f105b1cc31 | ||
|
|
79fb5fb072 | ||
|
|
69cf8646e9 | ||
|
|
749ebf0a82 | ||
|
|
ad672c3c4c | ||
|
|
6167cff451 | ||
|
|
54bf1ce49b | ||
|
|
547b67e702 | ||
|
|
e1a481af46 | ||
|
|
894837fff4 | ||
|
|
b0077cecd7 | ||
|
|
01dfb49d5b | ||
|
|
0a66609c1b | ||
|
|
dabac673a7 | ||
|
|
ad443ea18a | ||
|
|
8b8e9703a1 | ||
|
|
f56d1edce0 | ||
|
|
b89305ad4d | ||
|
|
9c3914bf79 | ||
|
|
00faa21e4b | ||
|
|
598b101f02 | ||
|
|
24d1f0a494 | ||
|
|
303c0c4e15 | ||
|
|
5a2199f9a9 | ||
|
|
0ba048aced | ||
|
|
bd091ede61 | ||
|
|
86668c3de9 | ||
|
|
22b5e89b1b | ||
|
|
6fb5330212 | ||
|
|
fd77b00f26 | ||
|
|
ff73375f0a | ||
|
|
f9c397cc1f | ||
|
|
92ce10e86b | ||
|
|
6f1c6e5c95 | ||
|
|
c5ce32e029 | ||
|
|
588b181eb9 | ||
|
|
3628bb2b7a | ||
|
|
08c2b7a444 | ||
|
|
398f00c4cb | ||
|
|
df634a4565 | ||
|
|
a929ff4242 | ||
|
|
200149b9ee | ||
|
|
ec28b66e7f | ||
|
|
4d78471891 | ||
|
|
6e98c46371 | ||
|
|
e9559eec6b | ||
|
|
43cfb0f4f3 | ||
|
|
507336a1ff | ||
|
|
6f302069ab | ||
|
|
3a407f5c3e | ||
|
|
7fc3919034 | ||
|
|
f70c3babc9 | ||
|
|
0881cc09e2 | ||
|
|
5a1d812e69 | ||
|
|
f2dc834bba | ||
|
|
932b0e3f9d | ||
|
|
ae4bbe8253 | ||
|
|
77337bb266 | ||
|
|
44ea1f0077 | ||
|
|
51cb92d395 | ||
|
|
646e62d6be | ||
|
|
c0d6ecefac | ||
|
|
158a9b9a31 | ||
|
|
aabf909c61 | ||
|
|
4a568f75bb | ||
|
|
b7da43f615 | ||
|
|
9c4dc714f8 | ||
|
|
1d915d8327 | ||
|
|
af8350b850 | ||
|
|
a039d817db | ||
|
|
ebafbdcc55 | ||
|
|
67e72f1aaf | ||
|
|
99d72516ae | ||
|
|
5ea3623736 | ||
|
|
e36bacfd6b | ||
|
|
22e27738aa | ||
|
|
8522d20cad | ||
|
|
d5b9f809b0 | ||
|
|
022724336b | ||
|
|
482eb7c8f7 | ||
|
|
01eadea10b | ||
|
|
5f5826ce56 | ||
|
|
97b94eeff9 | ||
|
|
9836f88068 | ||
|
|
26d8c98c9d | ||
|
|
5706c32933 | ||
|
|
2de5191c05 | ||
|
|
2b40552eab | ||
|
|
30acf4a374 | ||
|
|
be7416386f | ||
|
|
1beed324d9 | ||
|
|
2e09896d0b | ||
|
|
e80c49c1ce | ||
|
|
27034997a6 | ||
|
|
24ad052d02 | ||
|
|
19ca374527 | ||
|
|
27c0804219 | ||
|
|
cd7959f3bf | ||
|
|
e88e97b485 | ||
|
|
4aae4aaec0 | ||
|
|
c7e1a3429d | ||
|
|
74d37486b8 | ||
|
|
1eadc94592 | ||
|
|
87591365bc | ||
|
|
f4b873315a | ||
|
|
cb1b7bc0e3 | ||
|
|
504b9e3ea7 | ||
|
|
19ad0cd5f8 | ||
|
|
009f7c84f6 | ||
|
|
7c4feec5aa | ||
|
|
b6b16d05f0 | ||
|
|
02989a7588 | ||
|
|
fef2c8c3c2 | ||
|
|
c0735b1bc5 | ||
|
|
0eb95b3b06 | ||
|
|
f667a95d88 | ||
|
|
03db930354 | ||
|
|
2fcfccb2fc | ||
|
|
78adf82f0d | ||
|
|
fe1f05fadd | ||
|
|
cd5573ecde | ||
|
|
fcc2f51f81 | ||
|
|
4fd4dbfa51 | ||
|
|
ce8706d1b6 | ||
|
|
d3fcd95b94 | ||
|
|
433f0c5f23 | ||
|
|
7712d5516c | ||
|
|
bdae9d6ceb | ||
|
|
08946c67ea | ||
|
|
8b0e9b8d8e | ||
|
|
1dd00e1463 | ||
|
|
5938180583 | ||
|
|
fb6d0e7f55 | ||
|
|
3c5068866c | ||
|
|
c0059c68eb | ||
|
|
7f9869ae20 | ||
|
|
61cf1166ff | ||
|
|
27fe3b6853 | ||
|
|
af3d688e98 | ||
|
|
1c3b74f45b | ||
|
|
929852a881 | ||
|
|
c58d8d2040 | ||
|
|
4ee9e0b546 | ||
|
|
8064bc24b9 | ||
|
|
4592773ea2 | ||
|
|
da744528bc | ||
|
|
d3577fae03 | ||
|
|
b581a618b9 | ||
|
|
f375f9f841 | ||
|
|
52820a7e49 | ||
|
|
5d677b4f17 | ||
|
|
268a262281 | ||
|
|
283362acd0 | ||
|
|
756918b0ce | ||
|
|
4c2660a2d3 | ||
|
|
908e323db0 | ||
|
|
97da7d44ba | ||
|
|
ca79e08c81 | ||
|
|
86ed5c6344 | ||
|
|
6e16df0b45 | ||
|
|
73d3df56e5 | ||
|
|
c4f1e7a411 | ||
|
|
3f5004626a | ||
|
|
7f8fae70e6 | ||
|
|
3239965cbe | ||
|
|
fec80a16fa | ||
|
|
399e6b9d8c | ||
|
|
d77605a8ad | ||
|
|
4dcd251662 | ||
|
|
5c8136ddd4 | ||
|
|
8b13403304 | ||
|
|
8cb9f52c1a | ||
|
|
3aa7c89e25 | ||
|
|
b46ccb10d0 | ||
|
|
f51d345ad9 | ||
|
|
bed7b7f000 | ||
|
|
bd2f25dc19 | ||
|
|
cc27b377d8 | ||
|
|
9cbf3195e0 | ||
|
|
9fa7602947 | ||
|
|
9bd7f7ae7b | ||
|
|
0a43bb2645 | ||
|
|
94c5c2e364 | ||
|
|
f284d5666f | ||
|
|
e824858d60 | ||
|
|
9d05c03a3a | ||
|
|
0fc5309ff9 | ||
|
|
92ec3ffc72 | ||
|
|
8c9d6381f3 | ||
|
|
fc5c60a9b4 | ||
|
|
8f43b9367b | ||
|
|
8dd58900d6 | ||
|
|
4e67e597b0 | ||
|
|
a9a560da67 | ||
|
|
b11305f4e9 | ||
|
|
ed02c8abec | ||
|
|
9e01095249 | ||
|
|
e28080bb51 | ||
|
|
4104858bc0 | ||
|
|
b4e7c760b2 | ||
|
|
6efd95ed9d | ||
|
|
abe18211c0 | ||
|
|
33bb5d7895 | ||
|
|
0cb58c099d | ||
|
|
6479db0b16 | ||
|
|
9d1906c0b1 | ||
|
|
2c2f772071 | ||
|
|
c14abe5132 | ||
|
|
cd2ccef5a1 | ||
|
|
3e6edae198 | ||
|
|
8b51c2ef64 | ||
|
|
d2f3f6866c | ||
|
|
0e746b1056 | ||
|
|
723f13eb2b | ||
|
|
d7c80b69e8 | ||
|
|
a71f0e58a2 | ||
|
|
56c48a4304 | ||
|
|
2f6e5ab289 | ||
|
|
96e505d662 | ||
|
|
d4989f5401 | ||
|
|
ed10fb06b2 | ||
|
|
503f20b06b | ||
|
|
19cf38d92d | ||
|
|
c16cfe60ab | ||
|
|
4cc937a144 | ||
|
|
7d20810179 | ||
|
|
4e8e630904 | ||
|
|
8c158d82fa | ||
|
|
d8e833ef1a | ||
|
|
bdd17a85e9 | ||
|
|
192fd19632 | ||
|
|
9d94475d3f | ||
|
|
b2e7d686fe | ||
|
|
ae727d381c | ||
|
|
4b0861eb7f | ||
|
|
861af192bf | ||
|
|
566f15768f | ||
|
|
0cc8714c3c | ||
|
|
d6745dbe4a | ||
|
|
75ac51bb57 | ||
|
|
6e353893d1 | ||
|
|
5a29502fcd | ||
|
|
aa869521c0 | ||
|
|
8f08d7843f | ||
|
|
1ff14e38cb | ||
|
|
86f92a774e | ||
|
|
5ed07f4407 | ||
|
|
ac9107aa5f | ||
|
|
5bed7c932b | ||
|
|
9dd1b07e45 | ||
|
|
69795f2ed0 | ||
|
|
39c49fe2bb | ||
|
|
7fa3ed239f | ||
|
|
3fd9110ba7 | ||
|
|
26c57148f7 | ||
|
|
631931990b | ||
|
|
79097d5b40 | ||
|
|
16d397125a | ||
|
|
3e6b7c729f | ||
|
|
eba52a6e88 | ||
|
|
8ab8cf4a7a | ||
|
|
6aeb05f685 | ||
|
|
ff99f5d123 | ||
|
|
2c0ffd07d0 | ||
|
|
54d1bc076c | ||
|
|
bec9cf565b | ||
|
|
f69333f312 | ||
|
|
0039569471 | ||
|
|
a5361c15a1 | ||
|
|
d93a157380 | ||
|
|
aeace0c5f0 | ||
|
|
088cf8401f | ||
|
|
a1005e91c8 | ||
|
|
b158a90b72 | ||
|
|
941cfacea9 | ||
|
|
1fc35197e1 | ||
|
|
2e6feeb1c1 | ||
|
|
da0ffa07ec | ||
|
|
886ec35edc | ||
|
|
58fcf6962c | ||
|
|
b0990e7169 | ||
|
|
3860f7d9b3 | ||
|
|
81ad098678 | ||
|
|
59d7705697 | ||
|
|
8b0d8088d1 | ||
|
|
9c7ec8758d | ||
|
|
d56da4d799 | ||
|
|
945e0ac198 | ||
|
|
37e6c14eac | ||
|
|
4627475b7c | ||
|
|
58958cf246 | ||
|
|
5ee98597e7 | ||
|
|
1165427df0 | ||
|
|
7a9e4abdd5 | ||
|
|
e973158472 | ||
|
|
9c3fec7568 | ||
|
|
558aa173fe | ||
|
|
1a9746c84d | ||
|
|
aa04487c79 | ||
|
|
3f570d5fc2 | ||
|
|
86c243e1a4 | ||
|
|
4e094c21b7 | ||
|
|
b1ca898dff | ||
|
|
85196911ce | ||
|
|
e00872f9db | ||
|
|
9f3fff1f27 | ||
|
|
23cb44f60f | ||
|
|
60428921a1 | ||
|
|
5406b5790c | ||
|
|
d0eef7e98e | ||
|
|
96cf49d3b7 | ||
|
|
f2c2bdf6d6 | ||
|
|
68603bc046 | ||
|
|
e2e621341c | ||
|
|
19eaed2f32 | ||
|
|
5cfa3cc72f | ||
|
|
c979be5aab | ||
|
|
e0c926c53d | ||
|
|
50b372473c | ||
|
|
246bdc928a | ||
|
|
f77ab03d18 | ||
|
|
26438e0c9b | ||
|
|
86f5a3e670 | ||
|
|
9a46310238 | ||
|
|
07e9bc1137 | ||
|
|
ef21c118e9 | ||
|
|
e84c6a5555 | ||
|
|
0240a17c1e | ||
|
|
e4078e36ad | ||
|
|
01274a6a96 | ||
|
|
87c2f1dfe2 | ||
|
|
7c4cbe6ed7 | ||
|
|
bf732b9525 | ||
|
|
b00d0eb9e1 | ||
|
|
1762669de4 | ||
|
|
dc3d311def | ||
|
|
a54622e3d7 | ||
|
|
3bc239e85c | ||
|
|
70c8cb5aff | ||
|
|
92f4fbcef3 | ||
|
|
2cf2574ebe | ||
|
|
06f7e3c28f | ||
|
|
90574bc4e6 | ||
|
|
d01bcdbaca | ||
|
|
76ec2e6afb | ||
|
|
34629a9bb2 | ||
|
|
c638c8b82c | ||
|
|
3f1117e8f6 | ||
|
|
a608b267ae | ||
|
|
43cf7d3c28 | ||
|
|
7c3257764c | ||
|
|
7ce55c006e | ||
|
|
71b3374761 | ||
|
|
1726e6d3f3 | ||
|
|
79c7d1d116 | ||
|
|
fb57cfd293 | ||
|
|
a7009e6864 | ||
|
|
fcc8387c24 | ||
|
|
d5f5e0f4dd | ||
|
|
b0ad541f5d | ||
|
|
77338276db | ||
|
|
7a0acbdfdc | ||
|
|
71ce1e33b7 | ||
|
|
94eed70cf2 | ||
|
|
6b4ce99237 | ||
|
|
283583d289 | ||
|
|
c80446ae98 | ||
|
|
65620a4cde | ||
|
|
a7c6445f36 | ||
|
|
4509f303e6 | ||
|
|
aff9966ed1 | ||
|
|
5d850a7c1c | ||
|
|
70e87de639 | ||
|
|
9efe429912 | ||
|
|
8ea150a975 | ||
|
|
c413fddec0 | ||
|
|
1ba55401f9 | ||
|
|
983cc520ae | ||
|
|
02a801c290 | ||
|
|
2756671117 | ||
|
|
a3c9e39401 | ||
|
|
9a46ac3928 | ||
|
|
bb60df8b41 | ||
|
|
aa86e062f1 | ||
|
|
4a1423615f | ||
|
|
d8af7959e2 | ||
|
|
1f3fd9c285 | ||
|
|
39c6e3146c | ||
|
|
1ad720304c | ||
|
|
2a0be1b187 | ||
|
|
8ab4ad32fe | ||
|
|
56e4630827 | ||
|
|
f193db926d | ||
|
|
eb150b4937 |
@@ -15,6 +15,7 @@ logs/
|
||||
# Data files
|
||||
data/
|
||||
temp/
|
||||
redis_data/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
|
||||
152
.env.example
@@ -15,23 +15,6 @@ ENCRYPTION_KEY=your-encryption-key-here
|
||||
# ADMIN_USERNAME=cr_admin_custom
|
||||
# ADMIN_PASSWORD=your-secure-password
|
||||
|
||||
|
||||
# 🏢 LDAP/Windows AD 域控认证配置(可选,用于企业内部用户登录)
|
||||
# 启用LDAP认证功能
|
||||
# LDAP_ENABLED=true
|
||||
# AD域控服务器地址
|
||||
# LDAP_URL=ldap://your-domain-controller-ip:389
|
||||
# 绑定用户
|
||||
# LDAP_BIND_DN=your-bind-user
|
||||
# 绑定用户密码
|
||||
# LDAP_BIND_PASSWORD=your-bind-password
|
||||
# 搜索基础DN
|
||||
# LDAP_BASE_DN=OU=YourOU,DC=your,DC=domain,DC=com
|
||||
# 用户搜索过滤器
|
||||
# LDAP_SEARCH_FILTER=(&(objectClass=user)(|(cn={username})(sAMAccountName={username})))
|
||||
# 连接超时设置
|
||||
# LDAP_TIMEOUT=10000
|
||||
|
||||
# 📊 Redis 配置
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
@@ -39,16 +22,100 @@ REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
REDIS_ENABLE_TLS=
|
||||
|
||||
# 🔗 会话管理配置
|
||||
# 粘性会话TTL配置(小时),默认1小时
|
||||
STICKY_SESSION_TTL_HOURS=1
|
||||
# 续期阈值(分钟),默认0分钟(不续期)
|
||||
STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES=15
|
||||
|
||||
# 🎯 Claude API 配置
|
||||
CLAUDE_API_URL=https://api.anthropic.com/v1/messages
|
||||
CLAUDE_API_VERSION=2023-06-01
|
||||
CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
|
||||
|
||||
# 🤖 Gemini OAuth / Antigravity 配置(可选)
|
||||
# 不配置时使用内置默认值;如需自定义或避免在代码中出现 client secret,可在此覆盖
|
||||
# GEMINI_OAUTH_CLIENT_ID=
|
||||
# GEMINI_OAUTH_CLIENT_SECRET=
|
||||
# Gemini CLI OAuth redirect_uri(可选,默认 https://codeassist.google.com/authcode)
|
||||
# GEMINI_OAUTH_REDIRECT_URI=
|
||||
# ANTIGRAVITY_OAUTH_CLIENT_ID=
|
||||
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
|
||||
# Antigravity OAuth redirect_uri(可选,默认 http://localhost:45462;用于避免 redirect_uri_mismatch)
|
||||
# ANTIGRAVITY_OAUTH_REDIRECT_URI=http://localhost:45462
|
||||
# Antigravity 上游地址(可选,默认 sandbox)
|
||||
# ANTIGRAVITY_API_URL=https://daily-cloudcode-pa.sandbox.googleapis.com
|
||||
# Antigravity User-Agent(可选)
|
||||
# ANTIGRAVITY_USER_AGENT=antigravity/1.11.3 windows/amd64
|
||||
|
||||
# Claude Code(Anthropic Messages API)路由分流(无需额外环境变量):
|
||||
# - /api -> Claude 账号池(默认)
|
||||
# - /antigravity/api -> Antigravity OAuth
|
||||
# - /gemini-cli/api -> Gemini CLI OAuth
|
||||
|
||||
# ============================================================================
|
||||
# 🐛 调试 Dump 配置(可选)
|
||||
# ============================================================================
|
||||
# 以下开启后会在项目根目录写入 .jsonl 调试文件,便于排查问题。
|
||||
# ⚠️ 生产环境建议关闭,避免磁盘占用。
|
||||
#
|
||||
# 📄 输出文件列表:
|
||||
# - anthropic-requests-dump.jsonl (客户端请求)
|
||||
# - anthropic-responses-dump.jsonl (返回给客户端的响应)
|
||||
# - anthropic-tools-dump.jsonl (工具定义快照)
|
||||
# - antigravity-upstream-requests-dump.jsonl (发往上游的请求)
|
||||
# - antigravity-upstream-responses-dump.jsonl (上游 SSE 响应)
|
||||
#
|
||||
# 📌 开关配置:
|
||||
# ANTHROPIC_DEBUG_REQUEST_DUMP=true
|
||||
# ANTHROPIC_DEBUG_RESPONSE_DUMP=true
|
||||
# ANTHROPIC_DEBUG_TOOLS_DUMP=true
|
||||
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true
|
||||
# ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP=true
|
||||
#
|
||||
# 📏 单条记录大小上限(字节),默认 2MB:
|
||||
# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152
|
||||
# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152
|
||||
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152
|
||||
#
|
||||
# 📦 整个 Dump 文件大小上限(字节),超过后自动轮转为 .bak 文件,默认 10MB:
|
||||
# DUMP_MAX_FILE_SIZE_BYTES=10485760
|
||||
#
|
||||
# 🔧 工具失败继续:当 tool_result 标记 is_error=true 时,提示模型不要中断任务
|
||||
# (仅 /antigravity/api 分流生效)
|
||||
# ANTHROPIC_TOOL_ERROR_CONTINUE=true
|
||||
|
||||
|
||||
# 🚫 529错误处理配置
|
||||
# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
|
||||
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
|
||||
|
||||
# 400错误处理:0表示禁用,>0表示临时禁用时间(分钟)
|
||||
# 只有匹配特定错误模式的 400 才会触发临时禁用
|
||||
# - organization has been disabled
|
||||
# - account has been disabled
|
||||
# - account is disabled
|
||||
# - no account supporting
|
||||
# - account not found
|
||||
# - invalid account
|
||||
# - Too many active sessions
|
||||
CLAUDE_CONSOLE_BLOCKED_HANDLING_MINUTES=10
|
||||
|
||||
# 🌐 代理配置
|
||||
DEFAULT_PROXY_TIMEOUT=60000
|
||||
DEFAULT_PROXY_TIMEOUT=600000
|
||||
MAX_PROXY_RETRIES=3
|
||||
# IP协议族配置:true=IPv4, false=IPv6, 默认IPv4(兼容性更好)
|
||||
PROXY_USE_IPV4=true
|
||||
# 代理连接池 / Keep-Alive 配置(默认关闭,如需启用请取消注释)
|
||||
# PROXY_KEEP_ALIVE=true
|
||||
# PROXY_MAX_SOCKETS=50
|
||||
# PROXY_MAX_FREE_SOCKETS=10
|
||||
|
||||
# ⏱️ 请求超时配置
|
||||
REQUEST_TIMEOUT=600000 # 请求超时设置(毫秒),默认10分钟
|
||||
|
||||
# 🔧 请求体大小配置
|
||||
REQUEST_MAX_SIZE_MB=60
|
||||
|
||||
# 📈 使用限制
|
||||
DEFAULT_TOKEN_LIMIT=1000000
|
||||
@@ -62,10 +129,10 @@ LOG_MAX_FILES=5
|
||||
CLEANUP_INTERVAL=3600000
|
||||
TOKEN_USAGE_RETENTION=2592000000
|
||||
HEALTH_CHECK_INTERVAL=60000
|
||||
SYSTEM_TIMEZONE=Asia/Shanghai
|
||||
TIMEZONE_OFFSET=8
|
||||
# 实时指标统计窗口(分钟),可选1-60,默认5分钟
|
||||
METRICS_WINDOW=5
|
||||
TIMEZONE_OFFSET=8 # UTC偏移小时数,默认+8(中国时区)
|
||||
METRICS_WINDOW=5 # 实时指标统计窗口(分钟),可选1-60,默认5分钟
|
||||
# 启动时清理残留的并发排队计数器(默认true,多实例部署时建议设为false)
|
||||
CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=true
|
||||
|
||||
# 🎨 Web 界面配置
|
||||
WEB_TITLE=Claude Relay Service
|
||||
@@ -74,15 +141,46 @@ WEB_LOGO_URL=/assets/logo.png
|
||||
|
||||
# 🛠️ 开发配置
|
||||
DEBUG=false
|
||||
DEBUG_HTTP_TRAFFIC=false # 启用HTTP请求/响应调试日志(仅开发环境)
|
||||
ENABLE_CORS=true
|
||||
TRUST_PROXY=true
|
||||
|
||||
# 🔒 客户端限制(可选)
|
||||
# ALLOW_CUSTOM_CLIENTS=false
|
||||
|
||||
# 📢 Webhook 通知配置
|
||||
WEBHOOK_ENABLED=true
|
||||
WEBHOOK_URLS=https://your-webhook-url.com/notify,https://backup-webhook.com/notify
|
||||
WEBHOOK_TIMEOUT=10000
|
||||
WEBHOOK_RETRIES=3
|
||||
# 🔐 LDAP 认证配置
|
||||
LDAP_ENABLED=false
|
||||
LDAP_URL=ldaps://ldap-1.test1.bj.yxops.net:636
|
||||
LDAP_BIND_DN=cn=admin,dc=example,dc=com
|
||||
LDAP_BIND_PASSWORD=admin_password
|
||||
LDAP_SEARCH_BASE=dc=example,dc=com
|
||||
LDAP_SEARCH_FILTER=(uid={{username}})
|
||||
LDAP_SEARCH_ATTRIBUTES=dn,uid,cn,mail,givenName,sn
|
||||
LDAP_TIMEOUT=5000
|
||||
LDAP_CONNECT_TIMEOUT=10000
|
||||
|
||||
# 🔒 LDAP TLS/SSL 配置 (用于 ldaps:// URL)
|
||||
# 是否忽略证书验证错误 (设置为false可忽略自签名证书错误)
|
||||
LDAP_TLS_REJECT_UNAUTHORIZED=true
|
||||
# CA 证书文件路径 (可选,用于自定义CA证书)
|
||||
# LDAP_TLS_CA_FILE=/path/to/ca-cert.pem
|
||||
# 客户端证书文件路径 (可选,用于双向认证)
|
||||
# LDAP_TLS_CERT_FILE=/path/to/client-cert.pem
|
||||
# 客户端私钥文件路径 (可选,用于双向认证)
|
||||
# LDAP_TLS_KEY_FILE=/path/to/client-key.pem
|
||||
# 服务器名称 (可选,用于 SNI)
|
||||
# LDAP_TLS_SERVERNAME=ldap.example.com
|
||||
|
||||
# 🗺️ LDAP 用户属性映射
|
||||
LDAP_USER_ATTR_USERNAME=uid
|
||||
LDAP_USER_ATTR_DISPLAY_NAME=cn
|
||||
LDAP_USER_ATTR_EMAIL=mail
|
||||
LDAP_USER_ATTR_FIRST_NAME=givenName
|
||||
LDAP_USER_ATTR_LAST_NAME=sn
|
||||
|
||||
# 👥 用户管理配置
|
||||
USER_MANAGEMENT_ENABLED=false
|
||||
DEFAULT_USER_ROLE=user
|
||||
USER_SESSION_TIMEOUT=86400000
|
||||
MAX_API_KEYS_PER_USER=1
|
||||
ALLOW_USER_DELETE_API_KEYS=false
|
||||
|
||||
12
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Your GitHub username for GitHub Sponsors
|
||||
patreon: # Replace with your Patreon username if you have one
|
||||
open_collective: # Replace with your Open Collective username if you have one
|
||||
ko_fi: # Replace with your Ko-fi username if you have one
|
||||
tidelift: # Replace with your Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with your Community Bridge project-name
|
||||
liberapay: # Replace with your Liberapay username
|
||||
issuehunt: # Replace with your IssueHunt username
|
||||
otechie: # Replace with your Otechie username
|
||||
custom: ['https://afdian.com/a/claude-relay-service'] # Your custom donation link (Afdian)
|
||||
79
.github/workflows/auto-release-pipeline.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: # 支持手动触发
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -24,6 +25,17 @@ jobs:
|
||||
- name: Check if version bump is needed
|
||||
id: check
|
||||
run: |
|
||||
# 检查提交消息是否包含强制发布标记([force release])
|
||||
COMMIT_MSG=$(git log -1 --pretty=%B | tr -d '\r')
|
||||
echo "Latest commit message:"
|
||||
echo "$COMMIT_MSG"
|
||||
|
||||
FORCE_RELEASE=false
|
||||
if echo "$COMMIT_MSG" | grep -qi "\[force release\]"; then
|
||||
echo "Detected [force release] marker, forcing version bump"
|
||||
FORCE_RELEASE=true
|
||||
fi
|
||||
|
||||
# 检测是否是合并提交
|
||||
PARENT_COUNT=$(git rev-list --parents -n 1 HEAD | wc -w)
|
||||
PARENT_COUNT=$((PARENT_COUNT - 1))
|
||||
@@ -67,8 +79,15 @@ jobs:
|
||||
break
|
||||
fi
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
if [ "$SIGNIFICANT_CHANGES" = true ]; then
|
||||
|
||||
# 检查是否是手动触发
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "Manual workflow trigger detected, forcing version bump"
|
||||
echo "needs_bump=true" >> $GITHUB_OUTPUT
|
||||
elif [ "$FORCE_RELEASE" = true ]; then
|
||||
echo "Force release marker detected, forcing version bump"
|
||||
echo "needs_bump=true" >> $GITHUB_OUTPUT
|
||||
elif [ "$SIGNIFICANT_CHANGES" = true ]; then
|
||||
echo "Significant changes detected, version bump needed"
|
||||
echo "needs_bump=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -246,6 +265,23 @@ jobs:
|
||||
git tag -a "$NEW_TAG" -m "Release $NEW_TAG"
|
||||
git push origin HEAD:main "$NEW_TAG"
|
||||
|
||||
- name: Prepare image names
|
||||
id: image_names
|
||||
if: steps.check.outputs.needs_bump == 'true'
|
||||
run: |
|
||||
DOCKER_USERNAME="${{ secrets.DOCKERHUB_USERNAME }}"
|
||||
if [ -z "$DOCKER_USERNAME" ]; then
|
||||
DOCKER_USERNAME="weishaw"
|
||||
fi
|
||||
|
||||
DOCKER_IMAGE=$(echo "${DOCKER_USERNAME}/claude-relay-service" | tr '[:upper:]' '[:lower:]')
|
||||
GHCR_IMAGE=$(echo "ghcr.io/${{ github.repository_owner }}/claude-relay-service" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
{
|
||||
echo "docker_image=${DOCKER_IMAGE}"
|
||||
echo "ghcr_image=${GHCR_IMAGE}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check.outputs.needs_bump == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
@@ -256,8 +292,10 @@ jobs:
|
||||
## 🐳 Docker 镜像
|
||||
|
||||
```bash
|
||||
docker pull ${{ secrets.DOCKERHUB_USERNAME || 'weishaw' }}/claude-relay-service:${{ steps.next_version.outputs.new_tag }}
|
||||
docker pull ${{ secrets.DOCKERHUB_USERNAME || 'weishaw' }}/claude-relay-service:latest
|
||||
docker pull ${{ steps.image_names.outputs.docker_image }}:${{ steps.next_version.outputs.new_tag }}
|
||||
docker pull ${{ steps.image_names.outputs.docker_image }}:latest
|
||||
docker pull ${{ steps.image_names.outputs.ghcr_image }}:${{ steps.next_version.outputs.new_tag }}
|
||||
docker pull ${{ steps.image_names.outputs.ghcr_image }}:latest
|
||||
```
|
||||
|
||||
## 📦 主要更新
|
||||
@@ -388,20 +426,32 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: steps.check.outputs.needs_bump == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: steps.check.outputs.needs_bump == 'true'
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:${{ steps.next_version.outputs.new_tag }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:latest
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:${{ steps.next_version.outputs.new_version }}
|
||||
${{ steps.image_names.outputs.docker_image }}:${{ steps.next_version.outputs.new_tag }}
|
||||
${{ steps.image_names.outputs.docker_image }}:latest
|
||||
${{ steps.image_names.outputs.docker_image }}:${{ steps.next_version.outputs.new_version }}
|
||||
${{ steps.image_names.outputs.ghcr_image }}:${{ steps.next_version.outputs.new_tag }}
|
||||
${{ steps.image_names.outputs.ghcr_image }}:latest
|
||||
${{ steps.image_names.outputs.ghcr_image }}:${{ steps.next_version.outputs.new_version }}
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -410,6 +460,8 @@ jobs:
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
DOCKER_IMAGE: ${{ steps.image_names.outputs.docker_image }}
|
||||
GHCR_IMAGE: ${{ steps.image_names.outputs.ghcr_image }}
|
||||
continue-on-error: true
|
||||
run: |
|
||||
VERSION="${{ steps.next_version.outputs.new_version }}"
|
||||
@@ -430,13 +482,16 @@ jobs:
|
||||
MESSAGE+="${CHANGELOG_TRUNCATED}"$'\n'$'\n'
|
||||
MESSAGE+="🐳 *Docker 部署:*"$'\n'
|
||||
MESSAGE+="\`\`\`bash"$'\n'
|
||||
MESSAGE+="docker pull weishaw/claude-relay-service:${TAG}"$'\n'
|
||||
MESSAGE+="docker pull weishaw/claude-relay-service:latest"$'\n'
|
||||
MESSAGE+="docker pull ${DOCKER_IMAGE}:${TAG}"$'\n'
|
||||
MESSAGE+="docker pull ${DOCKER_IMAGE}:latest"$'\n'
|
||||
MESSAGE+="docker pull ${GHCR_IMAGE}:${TAG}"$'\n'
|
||||
MESSAGE+="docker pull ${GHCR_IMAGE}:latest"$'\n'
|
||||
MESSAGE+="\`\`\`"$'\n'$'\n'
|
||||
MESSAGE+="🔗 *相关链接:*"$'\n'
|
||||
MESSAGE+="• [GitHub Release](https://github.com/${REPO}/releases/tag/${TAG})"$'\n'
|
||||
MESSAGE+="• [完整更新日志](https://github.com/${REPO}/releases)"$'\n'
|
||||
MESSAGE+="• [Docker Hub](https://hub.docker.com/r/weishaw/claude-relay-service)"$'\n'$'\n'
|
||||
MESSAGE+="• [Docker Hub](https://hub.docker.com/r/${DOCKER_IMAGE%/*}/claude-relay-service)"$'\n'
|
||||
MESSAGE+="• [GHCR](https://ghcr.io/${GHCR_IMAGE#ghcr.io/})"$'\n'$'\n'
|
||||
MESSAGE+="#ClaudeRelay #Update #v${VERSION//./_}"
|
||||
|
||||
# 使用 jq 构建 JSON 并发送
|
||||
@@ -451,4 +506,4 @@ jobs:
|
||||
}' | \
|
||||
curl -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @-
|
||||
-d @-
|
||||
|
||||
62
.github/workflows/sync-model-pricing.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: 同步模型价格数据
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *'
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
sync-pricing:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: 检出 price-mirror 分支
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: price-mirror
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 下载上游价格文件
|
||||
id: fetch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json \
|
||||
-o model_prices_and_context_window.json.new
|
||||
|
||||
NEW_HASH=$(sha256sum model_prices_and_context_window.json.new | awk '{print $1}')
|
||||
|
||||
if [ -f model_prices_and_context_window.sha256 ]; then
|
||||
OLD_HASH=$(cat model_prices_and_context_window.sha256 | tr -d ' \n\r')
|
||||
else
|
||||
OLD_HASH=""
|
||||
fi
|
||||
|
||||
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
|
||||
echo "价格文件无变化,跳过提交"
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
rm -f model_prices_and_context_window.json.new
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mv model_prices_and_context_window.json.new model_prices_and_context_window.json
|
||||
echo "$NEW_HASH" > model_prices_and_context_window.sha256
|
||||
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "hash=$NEW_HASH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 提交并推送变更
|
||||
if: steps.fetch.outputs.changed == 'true'
|
||||
env:
|
||||
NEW_HASH: ${{ steps.fetch.outputs.hash }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add model_prices_and_context_window.json model_prices_and_context_window.sha256
|
||||
COMMIT_MSG="chore: 同步模型价格数据"
|
||||
if [ -n "${NEW_HASH}" ]; then
|
||||
COMMIT_MSG="$COMMIT_MSG (${NEW_HASH})"
|
||||
fi
|
||||
git commit -m "$COMMIT_MSG"
|
||||
git push origin price-mirror
|
||||
5
.gitignore
vendored
@@ -26,6 +26,7 @@ redis_data/
|
||||
|
||||
# Logs directory
|
||||
logs/
|
||||
logs1/
|
||||
*.log
|
||||
startup.log
|
||||
app.log
|
||||
@@ -216,6 +217,10 @@ local/
|
||||
debug.log
|
||||
error.log
|
||||
access.log
|
||||
http-debug*.log
|
||||
logs/http-debug-*.log
|
||||
|
||||
src/middleware/debugInterceptor.js
|
||||
|
||||
# Session files
|
||||
sessions/
|
||||
|
||||
482
CLAUDE.md
@@ -6,34 +6,89 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## 项目概述
|
||||
|
||||
Claude Relay Service 是一个功能完整的 AI API 中转服务,支持 Claude 和 Gemini 双平台。提供多账户管理、API Key 认证、代理配置和现代化 Web 管理界面。该服务作为客户端(如 SillyTavern、Claude Code、Gemini CLI)与 AI API 之间的中间件,提供认证、限流、监控等功能。
|
||||
Claude Relay Service 是一个多平台 AI API 中转服务,支持 **Claude (官方/Console)、Gemini、OpenAI Responses (Codex)、AWS Bedrock、Azure OpenAI、Droid (Factory.ai)、CCR** 等多种账户类型。提供完整的多账户管理、API Key 认证、代理配置、用户管理、LDAP认证、Webhook通知和现代化 Web 管理界面。该服务作为客户端(如 Claude Code、Gemini CLI、Codex、Droid CLI、Cherry Studio 等)与 AI API 之间的中间件,提供认证、限流、监控、定价计算、成本统计等功能。
|
||||
|
||||
## 核心架构
|
||||
|
||||
### 关键架构概念
|
||||
|
||||
- **代理认证流**: 客户端用自建API Key → 验证 → 获取Claude账户OAuth token → 转发到Anthropic
|
||||
- **统一调度系统**: 使用 unifiedClaudeScheduler、unifiedGeminiScheduler、unifiedOpenAIScheduler、droidScheduler 实现跨账户类型的智能调度
|
||||
- **多账户类型支持**: 支持 claude-official、claude-console、bedrock、ccr、droid、gemini、openai-responses、azure-openai 等账户类型
|
||||
- **代理认证流**: 客户端用自建API Key → 验证 → 统一调度器选择账户 → 获取账户token → 转发到对应API
|
||||
- **Token管理**: 自动监控OAuth token过期并刷新,支持10秒提前刷新策略
|
||||
- **代理支持**: 每个Claude账户支持独立代理配置,OAuth token交换也通过代理进行
|
||||
- **数据加密**: 敏感数据(refreshToken, accessToken)使用AES加密存储在Redis
|
||||
- **代理支持**: 每个账户支持独立代理配置,OAuth token交换也通过代理进行
|
||||
- **数据加密**: 敏感数据(refreshToken, accessToken, credentials)使用AES加密存储在Redis
|
||||
- **粘性会话**: 支持会话级别的账户绑定,同一会话使用同一账户,确保上下文连续性
|
||||
- **权限控制**: API Key支持权限配置(all/claude/gemini/openai等),控制可访问的服务类型
|
||||
- **客户端限制**: 基于User-Agent的客户端识别和限制,支持ClaudeCode、Gemini-CLI等预定义客户端
|
||||
- **模型黑名单**: 支持API Key级别的模型访问限制
|
||||
- **并发请求排队**: 当API Key并发数超限时,请求进入队列等待而非立即返回429,支持配置最大排队数、超时时间,适用于Claude Code Agent并行工具调用场景
|
||||
|
||||
### 主要服务组件
|
||||
|
||||
- **claudeRelayService.js**: 核心代理服务,处理请求转发和流式响应
|
||||
- **claudeAccountService.js**: Claude账户管理,OAuth token刷新和账户选择
|
||||
- **geminiAccountService.js**: Gemini账户管理,Google OAuth token刷新和账户选择
|
||||
- **apiKeyService.js**: API Key管理,验证、限流和使用统计
|
||||
#### 核心转发服务
|
||||
|
||||
- **claudeRelayService.js**: Claude官方API转发,处理OAuth认证和流式响应
|
||||
- **claudeConsoleRelayService.js**: Claude Console账户转发服务
|
||||
- **geminiRelayService.js**: Gemini API转发服务
|
||||
- **bedrockRelayService.js**: AWS Bedrock API转发服务
|
||||
- **azureOpenaiRelayService.js**: Azure OpenAI API转发服务
|
||||
- **droidRelayService.js**: Droid (Factory.ai) API转发服务
|
||||
- **ccrRelayService.js**: CCR账户转发服务
|
||||
- **openaiResponsesRelayService.js**: OpenAI Responses (Codex) 转发服务
|
||||
|
||||
#### 账户管理服务
|
||||
|
||||
- **claudeAccountService.js**: Claude官方账户管理,OAuth token刷新和账户选择
|
||||
- **claudeConsoleAccountService.js**: Claude Console账户管理
|
||||
- **geminiAccountService.js**: Gemini账户管理,Google OAuth token刷新
|
||||
- **bedrockAccountService.js**: AWS Bedrock账户管理
|
||||
- **azureOpenaiAccountService.js**: Azure OpenAI账户管理
|
||||
- **droidAccountService.js**: Droid账户管理
|
||||
- **ccrAccountService.js**: CCR账户管理
|
||||
- **openaiResponsesAccountService.js**: OpenAI Responses账户管理
|
||||
- **openaiAccountService.js**: OpenAI兼容账户管理
|
||||
- **accountGroupService.js**: 账户组管理,支持账户分组和优先级
|
||||
|
||||
#### 统一调度器
|
||||
|
||||
- **unifiedClaudeScheduler.js**: Claude多账户类型统一调度(claude-official/console/bedrock/ccr)
|
||||
- **unifiedGeminiScheduler.js**: Gemini账户统一调度
|
||||
- **unifiedOpenAIScheduler.js**: OpenAI兼容服务统一调度
|
||||
- **droidScheduler.js**: Droid账户调度
|
||||
|
||||
#### 核心功能服务
|
||||
|
||||
- **apiKeyService.js**: API Key管理,验证、限流、使用统计、成本计算
|
||||
- **userService.js**: 用户管理系统,支持用户注册、登录、API Key管理
|
||||
- **userMessageQueueService.js**: 用户消息串行队列,防止同账户并发用户消息触发限流
|
||||
- **pricingService.js**: 定价服务,模型价格管理和成本计算
|
||||
- **costInitService.js**: 成本数据初始化服务
|
||||
- **webhookService.js**: Webhook通知服务
|
||||
- **webhookConfigService.js**: Webhook配置管理
|
||||
- **ldapService.js**: LDAP认证服务
|
||||
- **tokenRefreshService.js**: Token自动刷新服务
|
||||
- **rateLimitCleanupService.js**: 速率限制状态清理服务
|
||||
- **claudeCodeHeadersService.js**: Claude Code客户端请求头处理
|
||||
|
||||
#### 工具服务
|
||||
|
||||
- **oauthHelper.js**: OAuth工具,PKCE流程实现和代理支持
|
||||
- **workosOAuthHelper.js**: WorkOS OAuth集成
|
||||
- **openaiToClaude.js**: OpenAI格式到Claude格式的转换
|
||||
|
||||
### 认证和代理流程
|
||||
|
||||
1. 客户端使用自建API Key(cr\_前缀格式)发送请求
|
||||
2. authenticateApiKey中间件验证API Key有效性和速率限制
|
||||
3. claudeAccountService自动选择可用Claude账户
|
||||
4. 检查OAuth access token有效性,过期则自动刷新(使用代理)
|
||||
5. 移除客户端API Key,使用OAuth Bearer token转发请求
|
||||
6. 通过账户配置的代理发送到Anthropic API
|
||||
7. 流式或非流式返回响应,记录使用统计
|
||||
1. 客户端使用自建API Key(cr\_前缀格式)发送请求到对应路由(/api、/claude、/gemini、/openai、/droid等)
|
||||
2. **authenticateApiKey中间件**验证API Key有效性、速率限制、权限、客户端限制、模型黑名单
|
||||
3. **统一调度器**(如 unifiedClaudeScheduler)根据请求模型、会话hash、API Key权限选择最优账户
|
||||
4. 检查选中账户的token有效性,过期则自动刷新(使用代理)
|
||||
5. 根据账户类型调用对应的转发服务(claudeRelayService、geminiRelayService等)
|
||||
6. 移除客户端API Key,使用账户凭据(OAuth Bearer token、API Key等)转发请求
|
||||
7. 通过账户配置的代理发送到目标API(Anthropic、Google、AWS等)
|
||||
8. 流式或非流式返回响应,捕获真实usage数据
|
||||
9. 记录使用统计(input/output/cache_create/cache_read tokens)和成本计算
|
||||
10. 更新速率限制计数器和并发控制
|
||||
|
||||
### OAuth集成
|
||||
|
||||
@@ -42,6 +97,51 @@ Claude Relay Service 是一个功能完整的 AI API 中转服务,支持 Claud
|
||||
- **代理支持**: OAuth授权和token交换全程支持代理配置
|
||||
- **安全存储**: claudeAiOauth数据加密存储,包含accessToken、refreshToken、scopes
|
||||
|
||||
## 新增功能概览(相比旧版本)
|
||||
|
||||
### 多平台支持
|
||||
|
||||
- ✅ **Claude Console账户**: 支持Claude Console类型账户
|
||||
- ✅ **AWS Bedrock**: 完整的AWS Bedrock API支持
|
||||
- ✅ **Azure OpenAI**: Azure OpenAI服务支持
|
||||
- ✅ **Droid (Factory.ai)**: Factory.ai API支持
|
||||
- ✅ **CCR账户**: CCR凭据支持
|
||||
- ✅ **OpenAI兼容**: OpenAI格式转换和Responses格式支持
|
||||
|
||||
### 用户和权限系统
|
||||
|
||||
- ✅ **用户管理**: 完整的用户注册、登录、API Key管理系统
|
||||
- ✅ **LDAP认证**: 企业级LDAP/Active Directory集成
|
||||
- ✅ **权限控制**: API Key级别的服务权限(all/claude/gemini/openai)
|
||||
- ✅ **客户端限制**: 基于User-Agent的客户端识别和限制
|
||||
- ✅ **模型黑名单**: API Key级别的模型访问控制
|
||||
|
||||
### 统一调度和会话管理
|
||||
|
||||
- ✅ **统一调度器**: 跨账户类型的智能调度系统
|
||||
- ✅ **粘性会话**: 会话级账户绑定,支持自动续期
|
||||
- ✅ **并发控制**: Redis Sorted Set实现的并发限制
|
||||
- ✅ **负载均衡**: 自动账户选择和故障转移
|
||||
|
||||
### 成本和监控
|
||||
|
||||
- ✅ **定价服务**: 模型价格管理和自动成本计算
|
||||
- ✅ **成本统计**: 详细的token使用和费用统计
|
||||
- ✅ **缓存监控**: 全局缓存统计和命中率分析
|
||||
- ✅ **实时指标**: 可配置窗口的实时统计(METRICS_WINDOW)
|
||||
|
||||
### Webhook和通知
|
||||
|
||||
- ✅ **Webhook系统**: 事件通知和Webhook配置管理
|
||||
- ✅ **多URL支持**: 支持多个Webhook URL(逗号分隔)
|
||||
|
||||
### 高级功能
|
||||
|
||||
- ✅ **529错误处理**: 自动识别Claude过载状态并暂时排除账户
|
||||
- ✅ **HTTP调试**: DEBUG_HTTP_TRAFFIC模式详细记录HTTP请求/响应
|
||||
- ✅ **数据迁移**: 完整的数据导入导出工具(含加密/脱敏)
|
||||
- ✅ **自动清理**: 并发计数、速率限制、临时错误状态自动清理
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 基本开发命令
|
||||
@@ -69,19 +169,52 @@ npm run service:logs # 查看日志
|
||||
npm run service:stop # 停止服务
|
||||
|
||||
### 开发环境配置
|
||||
必须配置的环境变量:
|
||||
|
||||
#### 必须配置的环境变量
|
||||
- `JWT_SECRET`: JWT密钥(32字符以上随机字符串)
|
||||
- `ENCRYPTION_KEY`: 数据加密密钥(32字符固定长度)
|
||||
- `REDIS_HOST`: Redis主机地址(默认localhost)
|
||||
- `REDIS_PORT`: Redis端口(默认6379)
|
||||
- `REDIS_PASSWORD`: Redis密码(可选)
|
||||
|
||||
初始化命令:
|
||||
#### 新增重要环境变量(可选)
|
||||
- `USER_MANAGEMENT_ENABLED`: 启用用户管理系统(默认false)
|
||||
- `LDAP_ENABLED`: 启用LDAP认证(默认false)
|
||||
- `LDAP_URL`: LDAP服务器地址(如 ldaps://ldap.example.com:636)
|
||||
- `LDAP_TLS_REJECT_UNAUTHORIZED`: LDAP证书验证(默认true)
|
||||
- `WEBHOOK_ENABLED`: 启用Webhook通知(默认true)
|
||||
- `WEBHOOK_URLS`: Webhook通知URL列表(逗号分隔)
|
||||
- `CLAUDE_OVERLOAD_HANDLING_MINUTES`: Claude 529错误处理持续时间(分钟,0表示禁用)
|
||||
- `STICKY_SESSION_TTL_HOURS`: 粘性会话TTL(小时,默认1)
|
||||
- `STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES`: 粘性会话续期阈值(分钟,默认0)
|
||||
- `USER_MESSAGE_QUEUE_ENABLED`: 启用用户消息串行队列(默认false)
|
||||
- `USER_MESSAGE_QUEUE_DELAY_MS`: 用户消息请求间隔(毫秒,默认200)
|
||||
- `USER_MESSAGE_QUEUE_TIMEOUT_MS`: 队列等待超时(毫秒,默认5000,锁持有时间短无需长等待)
|
||||
- `USER_MESSAGE_QUEUE_LOCK_TTL_MS`: 锁TTL(毫秒,默认5000,请求发送后立即释放无需长TTL)
|
||||
- `METRICS_WINDOW`: 实时指标统计窗口(分钟,1-60,默认5)
|
||||
- `MAX_API_KEYS_PER_USER`: 每用户最大API Key数量(默认1)
|
||||
- `ALLOW_USER_DELETE_API_KEYS`: 允许用户删除自己的API Keys(默认false)
|
||||
- `DEBUG_HTTP_TRAFFIC`: 启用HTTP请求/响应调试日志(默认false,仅开发环境)
|
||||
- `PROXY_USE_IPV4`: 代理使用IPv4(默认true)
|
||||
- `REQUEST_TIMEOUT`: 请求超时时间(毫秒,默认600000即10分钟)
|
||||
- `CLEAR_CONCURRENCY_QUEUES_ON_STARTUP`: 启动时清理残留的并发排队计数器(默认true,多实例部署时建议设为false)
|
||||
|
||||
#### AWS Bedrock配置(可选)
|
||||
- `CLAUDE_CODE_USE_BEDROCK`: 启用Bedrock(设置为1启用)
|
||||
- `AWS_REGION`: AWS默认区域(默认us-east-1)
|
||||
- `ANTHROPIC_MODEL`: Bedrock默认模型
|
||||
- `ANTHROPIC_SMALL_FAST_MODEL`: Bedrock小型快速模型
|
||||
- `ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION`: 小型模型区域
|
||||
- `CLAUDE_CODE_MAX_OUTPUT_TOKENS`: 最大输出tokens(默认4096)
|
||||
- `MAX_THINKING_TOKENS`: 最大思考tokens(默认1024)
|
||||
- `DISABLE_PROMPT_CACHING`: 禁用提示缓存(设置为1禁用)
|
||||
|
||||
#### 初始化命令
|
||||
```bash
|
||||
cp config/config.example.js config/config.js
|
||||
cp .env.example .env
|
||||
npm run setup # 自动生成密钥并创建管理员账户
|
||||
````
|
||||
```
|
||||
|
||||
## Web界面功能
|
||||
|
||||
@@ -95,31 +228,82 @@ npm run setup # 自动生成密钥并创建管理员账户
|
||||
|
||||
### 核心管理功能
|
||||
|
||||
- **实时仪表板**: 系统统计、账户状态、使用量监控
|
||||
- **API Key管理**: 创建、配额设置、使用统计查看
|
||||
- **Claude账户管理**: OAuth账户添加、代理配置、状态监控
|
||||
- **系统日志**: 实时日志查看,多级别过滤
|
||||
- **实时仪表板**: 系统统计、账户状态、使用量监控、实时指标(METRICS_WINDOW配置窗口)
|
||||
- **API Key管理**: 创建、配额设置、使用统计查看、权限配置、客户端限制、模型黑名单
|
||||
- **多平台账户管理**:
|
||||
- Claude账户(官方/Console): OAuth账户添加、代理配置、状态监控
|
||||
- Gemini账户: Google OAuth授权、代理配置
|
||||
- OpenAI Responses (Codex)账户: API Key配置
|
||||
- AWS Bedrock账户: AWS凭据配置
|
||||
- Azure OpenAI账户: Azure凭据和端点配置
|
||||
- Droid账户: Factory.ai API Key配置
|
||||
- CCR账户: CCR凭据配置
|
||||
- **用户管理**: 用户注册、登录、API Key分配(USER_MANAGEMENT_ENABLED启用时)
|
||||
- **系统日志**: 实时日志查看,多级别过滤,HTTP调试日志(DEBUG_HTTP_TRAFFIC启用时)
|
||||
- **Webhook配置**: Webhook URL管理、事件配置
|
||||
- **主题系统**: 支持明亮/暗黑模式切换,自动保存用户偏好设置
|
||||
- **成本分析**: 详细的token使用和成本统计(基于pricingService)
|
||||
- **缓存监控**: 解密缓存统计和性能监控
|
||||
|
||||
## 重要端点
|
||||
|
||||
### API转发端点
|
||||
### API转发端点(多路由支持)
|
||||
|
||||
- `POST /api/v1/messages` - 主要消息处理端点(支持流式)
|
||||
- `GET /api/v1/models` - 模型列表(兼容性)
|
||||
#### Claude服务路由
|
||||
- `POST /api/v1/messages` - Claude消息处理(支持流式)
|
||||
- `POST /claude/v1/messages` - Claude消息处理(别名路由)
|
||||
- `POST /v1/messages/count_tokens` - Token计数Beta API
|
||||
- `GET /api/v1/models` - 模型列表
|
||||
- `GET /api/v1/usage` - 使用统计查询
|
||||
- `GET /api/v1/key-info` - API Key信息
|
||||
- `GET /v1/me` - 用户信息(Claude Code客户端需要)
|
||||
- `GET /v1/organizations/:org_id/usage` - 组织使用统计
|
||||
|
||||
### OAuth管理端点
|
||||
#### Gemini服务路由
|
||||
- `POST /gemini/v1/models/:model:generateContent` - 标准Gemini API格式
|
||||
- `POST /gemini/v1/models/:model:streamGenerateContent` - Gemini流式
|
||||
- `GET /gemini/v1/models` - Gemini模型列表
|
||||
- 其他Gemini兼容路由(保持向后兼容)
|
||||
|
||||
#### OpenAI兼容路由
|
||||
- `POST /openai/v1/chat/completions` - OpenAI格式转发(支持responses格式)
|
||||
- `POST /openai/claude/v1/chat/completions` - OpenAI格式转Claude
|
||||
- `POST /openai/gemini/v1/chat/completions` - OpenAI格式转Gemini
|
||||
- `GET /openai/v1/models` - OpenAI格式模型列表
|
||||
|
||||
#### Droid (Factory.ai) 路由
|
||||
- `POST /droid/claude/v1/messages` - Droid Claude转发
|
||||
- `POST /droid/openai/v1/chat/completions` - Droid OpenAI转发
|
||||
|
||||
#### Azure OpenAI 路由
|
||||
- `POST /azure/...` - Azure OpenAI API转发
|
||||
|
||||
### 管理端点
|
||||
|
||||
#### OAuth和账户管理
|
||||
- `POST /admin/claude-accounts/generate-auth-url` - 生成OAuth授权URL(含代理)
|
||||
- `POST /admin/claude-accounts/exchange-code` - 交换authorization code
|
||||
- `POST /admin/claude-accounts` - 创建OAuth账户
|
||||
- `POST /admin/claude-accounts` - 创建Claude OAuth账户
|
||||
- 各平台账户CRUD端点(gemini、openai、bedrock、azure、droid、ccr)
|
||||
|
||||
#### 用户管理(USER_MANAGEMENT_ENABLED启用时)
|
||||
- `POST /users/register` - 用户注册
|
||||
- `POST /users/login` - 用户登录
|
||||
- `GET /users/profile` - 用户资料
|
||||
- `POST /users/api-keys` - 创建用户API Key
|
||||
|
||||
#### Webhook管理
|
||||
- `GET /admin/webhook/configs` - 获取Webhook配置
|
||||
- `POST /admin/webhook/configs` - 创建Webhook配置
|
||||
- `PUT /admin/webhook/configs/:id` - 更新Webhook配置
|
||||
- `DELETE /admin/webhook/configs/:id` - 删除Webhook配置
|
||||
|
||||
### 系统端点
|
||||
|
||||
- `GET /health` - 健康检查
|
||||
- `GET /web` - Web管理界面
|
||||
- `GET /health` - 健康检查(包含组件状态、版本、内存等)
|
||||
- `GET /metrics` - 系统指标(使用统计、uptime、内存)
|
||||
- `GET /web` - 传统Web管理界面
|
||||
- `GET /admin-next/` - 新版SPA管理界面(主界面)
|
||||
- `GET /admin/dashboard` - 系统概览数据
|
||||
|
||||
## 故障排除
|
||||
@@ -138,17 +322,72 @@ npm run setup # 自动生成密钥并创建管理员账户
|
||||
|
||||
### 常见开发问题
|
||||
|
||||
1. **Redis连接失败**: 确认Redis服务运行,检查连接配置
|
||||
2. **管理员登录失败**: 检查init.json同步到Redis,运行npm run setup
|
||||
3. **API Key格式错误**: 确保使用cr\_前缀格式
|
||||
4. **代理连接问题**: 验证SOCKS5/HTTP代理配置和认证信息
|
||||
1. **Redis连接失败**: 确认Redis服务运行,检查REDIS_HOST、REDIS_PORT、REDIS_PASSWORD配置
|
||||
2. **管理员登录失败**: 检查data/init.json存在,运行npm run setup重新初始化
|
||||
3. **API Key格式错误**: 确保使用cr\_前缀格式(可通过API_KEY_PREFIX配置修改)
|
||||
4. **代理连接问题**: 验证SOCKS5/HTTP代理配置和认证信息,检查PROXY_USE_IPV4设置
|
||||
5. **粘性会话失效**: 检查Redis中session数据,确认STICKY_SESSION_TTL_HOURS配置,通过Nginx代理时需添加 `underscores_in_headers on;`
|
||||
6. **LDAP认证失败**:
|
||||
- 检查LDAP_URL、LDAP_BIND_DN、LDAP_BIND_PASSWORD配置
|
||||
- 自签名证书问题:设置 LDAP_TLS_REJECT_UNAUTHORIZED=false
|
||||
- 查看日志中的LDAP连接错误详情
|
||||
7. **用户管理功能不可用**: 确认USER_MANAGEMENT_ENABLED=true,检查userService初始化
|
||||
8. **Webhook通知失败**:
|
||||
- 确认WEBHOOK_ENABLED=true
|
||||
- 检查WEBHOOK_URLS格式(逗号分隔)
|
||||
- 查看logs/webhook-*.log日志
|
||||
9. **统一调度器选择账户失败**:
|
||||
- 检查账户状态(status: 'active')
|
||||
- 确认账户类型与请求路由匹配
|
||||
- 查看粘性会话绑定情况
|
||||
10. **并发计数泄漏**: 系统每分钟自动清理过期并发计数(concurrency cleanup task),重启时也会自动清理
|
||||
11. **速率限制未清理**: rateLimitCleanupService每5分钟自动清理过期限流状态
|
||||
12. **成本统计不准确**: 运行 `npm run init:costs` 初始化成本数据,检查pricingService是否正确加载模型价格
|
||||
13. **缓存命中率低**: 查看缓存监控统计,调整LRU缓存大小配置
|
||||
14. **用户消息队列超时**: 优化后锁持有时间已从分钟级降到毫秒级(请求发送后立即释放),默认 `USER_MESSAGE_QUEUE_TIMEOUT_MS=5000` 已足够。如仍有超时,检查网络延迟或禁用此功能(`USER_MESSAGE_QUEUE_ENABLED=false`)
|
||||
15. **并发请求排队问题**:
|
||||
- 排队超时:检查 `concurrentRequestQueueTimeoutMs` 配置是否合理(默认10秒)
|
||||
- 排队数过多:调整 `concurrentRequestQueueMaxSize` 和 `concurrentRequestQueueMaxSizeMultiplier`
|
||||
- 查看排队统计:访问 `/admin/concurrency-queue/stats` 接口查看 entered/success/timeout/cancelled/socket_changed/rejected_overload 统计
|
||||
- 排队计数泄漏:系统重启时自动清理,或访问 `/admin/concurrency-queue` DELETE 接口手动清理
|
||||
- Socket 身份验证失败:查看 `socket_changed` 统计,如果频繁发生,检查代理配置或客户端连接稳定性
|
||||
- 健康检查拒绝:查看 `rejected_overload` 统计,表示队列过载时的快速失败次数
|
||||
|
||||
### 代理配置要求(并发请求排队)
|
||||
|
||||
使用并发请求排队功能时,需要正确配置代理(如 Nginx)的超时参数:
|
||||
|
||||
- **推荐配置**: `proxy_read_timeout >= max(2 × concurrentRequestQueueTimeoutMs, 60s)`
|
||||
- 当前默认排队超时 10 秒,Nginx 默认 `proxy_read_timeout = 60s` 已满足要求
|
||||
- 如果调整排队超时到 60 秒,推荐代理超时 ≥ 120 秒
|
||||
- **Nginx 配置示例**:
|
||||
```nginx
|
||||
location /api/ {
|
||||
proxy_read_timeout 120s; # 排队超时 60s 时推荐 120s
|
||||
proxy_connect_timeout 10s;
|
||||
# ...其他配置
|
||||
}
|
||||
```
|
||||
- **企业防火墙环境**:
|
||||
- 某些企业防火墙可能静默关闭长时间无数据的连接(20-40 秒)
|
||||
- 如遇此问题,联系网络管理员调整空闲连接超时策略
|
||||
- 或降低 `concurrentRequestQueueTimeoutMs` 配置
|
||||
- **后续升级说明**: 如有需要,后续版本可能提供可选的轻量级心跳机制
|
||||
|
||||
### 调试工具
|
||||
|
||||
- **日志系统**: Winston结构化日志,支持不同级别
|
||||
- **CLI工具**: 命令行状态查看和管理
|
||||
- **Web界面**: 实时日志查看和系统监控
|
||||
- **健康检查**: /health端点提供系统状态
|
||||
- **日志系统**: Winston结构化日志,支持不同级别,logs/目录下分类存储
|
||||
- `logs/claude-relay-*.log` - 应用主日志
|
||||
- `logs/token-refresh-error.log` - Token刷新错误
|
||||
- `logs/webhook-*.log` - Webhook通知日志
|
||||
- `logs/http-debug-*.log` - HTTP调试日志(DEBUG_HTTP_TRAFFIC=true时)
|
||||
- **CLI工具**: 命令行状态查看和管理(npm run cli)
|
||||
- **Web界面**: 实时日志查看和系统监控(/admin-next/)
|
||||
- **健康检查**: /health端点提供系统状态(redis、logger、内存、版本等)
|
||||
- **系统指标**: /metrics端点提供详细的使用统计和性能指标
|
||||
- **缓存监控**: cacheMonitor提供全局缓存统计和命中率分析
|
||||
- **数据导出工具**: npm run data:export 导出Redis数据进行调试
|
||||
- **Redis Key调试**: npm run data:debug 查看所有Redis键
|
||||
|
||||
## 开发最佳实践
|
||||
|
||||
@@ -197,23 +436,66 @@ npm run setup # 自动生成密钥并创建管理员账户
|
||||
|
||||
### 常见文件位置
|
||||
|
||||
- 核心服务逻辑:`src/services/` 目录
|
||||
- 路由处理:`src/routes/` 目录
|
||||
- 中间件:`src/middleware/` 目录
|
||||
- 配置管理:`config/config.js`
|
||||
- 核心服务逻辑:`src/services/` 目录(30+服务文件)
|
||||
- 路由处理:`src/routes/` 目录(api.js、admin.js、geminiRoutes.js、openaiRoutes.js等13个路由文件)
|
||||
- 中间件:`src/middleware/` 目录(auth.js、browserFallback.js、debugInterceptor.js等)
|
||||
- 配置管理:`config/config.js`(完整的多平台配置)
|
||||
- Redis 模型:`src/models/redis.js`
|
||||
- 工具函数:`src/utils/` 目录
|
||||
- `logger.js` - 日志系统
|
||||
- `oauthHelper.js` - OAuth工具
|
||||
- `proxyHelper.js` - 代理工具
|
||||
- `sessionHelper.js` - 会话管理
|
||||
- `cacheMonitor.js` - 缓存监控
|
||||
- `costCalculator.js` - 成本计算
|
||||
- `rateLimitHelper.js` - 速率限制
|
||||
- `webhookNotifier.js` - Webhook通知
|
||||
- `tokenMask.js` - Token脱敏
|
||||
- `workosOAuthHelper.js` - WorkOS OAuth
|
||||
- `modelHelper.js` - 模型工具
|
||||
- `inputValidator.js` - 输入验证
|
||||
- CLI工具:`cli/index.js` 和 `src/cli/` 目录
|
||||
- 脚本目录:`scripts/` 目录
|
||||
- `setup.js` - 初始化脚本
|
||||
- `manage.js` - 服务管理
|
||||
- `migrate-apikey-expiry.js` - API Key过期迁移
|
||||
- `fix-usage-stats.js` - 使用统计修复
|
||||
- `data-transfer.js` / `data-transfer-enhanced.js` - 数据导入导出
|
||||
- `update-model-pricing.js` - 模型价格更新
|
||||
- `test-pricing-fallback.js` - 价格回退测试
|
||||
- `debug-redis-keys.js` - Redis调试
|
||||
- 前端主题管理:`web/admin-spa/src/stores/theme.js`
|
||||
- 前端组件:`web/admin-spa/src/components/` 目录
|
||||
- 前端页面:`web/admin-spa/src/views/` 目录
|
||||
- 初始化数据:`data/init.json`(管理员凭据存储)
|
||||
- 日志目录:`logs/`(各类日志文件)
|
||||
|
||||
### 重要架构决策
|
||||
|
||||
- 所有敏感数据(OAuth token、refreshToken)都使用 AES 加密存储在 Redis
|
||||
- 每个 Claude 账户支持独立的代理配置,包括 SOCKS5 和 HTTP 代理
|
||||
- API Key 使用哈希存储,支持 `cr_` 前缀格式
|
||||
- 请求流程:API Key 验证 → 账户选择 → Token 刷新(如需)→ 请求转发
|
||||
- 支持流式和非流式响应,客户端断开时自动清理资源
|
||||
- **统一调度系统**: 使用统一调度器(unifiedClaudeScheduler等)实现跨账户类型的智能调度,支持粘性会话、负载均衡、故障转移
|
||||
- **多账户类型支持**: 支持8种账户类型(claude-official、claude-console、bedrock、ccr、droid、gemini、openai-responses、azure-openai)
|
||||
- **加密存储**: 所有敏感数据(OAuth token、refreshToken、credentials)都使用 AES 加密存储在 Redis
|
||||
- **独立代理**: 每个账户支持独立的代理配置(SOCKS5/HTTP),包括OAuth授权流程
|
||||
- **API Key哈希**: 使用SHA-256哈希存储,支持自定义前缀(默认 `cr_`)
|
||||
- **权限系统**: API Key支持细粒度权限控制(all/claude/gemini/openai等)
|
||||
- **请求流程**: API Key验证(含权限、客户端、模型黑名单) → 统一调度器选择账户 → Token刷新(如需)→ 请求转发 → Usage捕获 → 成本计算
|
||||
- **流式响应**: 支持SSE流式响应,实时捕获真实usage数据,客户端断开时自动清理资源(AbortController)
|
||||
- **粘性会话**: 基于请求内容hash的会话绑定,同一会话始终使用同一账户,支持自动续期
|
||||
- **自动清理**: 定时清理任务(过期Key、错误账户、临时错误、并发计数、速率限制状态)
|
||||
- **缓存优化**: 多层LRU缓存(解密缓存、账户缓存),全局缓存监控和统计
|
||||
- **成本追踪**: 实时token使用统计(input/output/cache_create/cache_read)和成本计算(基于pricingService)
|
||||
- **并发控制**: Redis Sorted Set实现的并发计数,支持自动过期清理
|
||||
- **并发请求排队**: 当API Key并发超限时,请求进入队列等待而非直接返回429
|
||||
- **工作原理**: 采用「先占后检查」模式,每次轮询尝试占位,超限则释放继续等待
|
||||
- **指数退避**: 初始200ms,指数增长至最大2秒,带±20%抖动防惊群效应
|
||||
- **智能清理**: 排队计数有TTL保护(超时+30秒),进程崩溃也能自动清理
|
||||
- **Socket身份验证**: 使用UUID token + socket对象引用双重验证,避免HTTP Keep-Alive连接复用导致的身份混淆
|
||||
- **健康检查**: P90等待时间超过阈值时快速失败(返回429),避免新请求在过载时继续排队
|
||||
- **配置参数**: `concurrentRequestQueueEnabled`(默认false)、`concurrentRequestQueueMaxSize`(默认3)、`concurrentRequestQueueMaxSizeMultiplier`(默认0)、`concurrentRequestQueueTimeoutMs`(默认10秒)、`concurrentRequestQueueMaxRedisFailCount`(默认5)、`concurrentRequestQueueHealthCheckEnabled`(默认true)、`concurrentRequestQueueHealthThreshold`(默认0.8)
|
||||
- **最大排队数**: max(固定值, 并发限制×倍数),例如并发限制=10、倍数=2时最大排队数=20
|
||||
- **适用场景**: Claude Code Agent并行工具调用、批量请求处理
|
||||
- **客户端识别**: 基于User-Agent的客户端限制,支持预定义客户端(ClaudeCode、Gemini-CLI等)
|
||||
- **错误处理**: 529错误自动标记账户过载状态,配置时长内自动排除该账户
|
||||
|
||||
### 核心数据流和性能优化
|
||||
|
||||
@@ -235,36 +517,115 @@ npm run setup # 自动生成密钥并创建管理员账户
|
||||
|
||||
### Redis 数据结构
|
||||
|
||||
- **API Keys**: `api_key:{id}` (详细信息) + `api_key_hash:{hash}` (快速查找)
|
||||
- **Claude 账户**: `claude_account:{id}` (加密的 OAuth 数据)
|
||||
- **管理员**: `admin:{id}` + `admin_username:{username}` (用户名映射)
|
||||
- **会话**: `session:{token}` (JWT 会话管理)
|
||||
- **使用统计**: `usage:daily:{date}:{key}:{model}` (多维度统计)
|
||||
- **系统信息**: `system_info` (系统状态缓存)
|
||||
- **API Keys**:
|
||||
- `api_key:{id}` - API Key详细信息(含权限、客户端限制、模型黑名单等)
|
||||
- `api_key_hash:{hash}` - 哈希到ID的快速映射
|
||||
- `api_key_usage:{keyId}` - 使用统计数据
|
||||
- `api_key_cost:{keyId}` - 成本统计数据
|
||||
- **账户数据**(多类型):
|
||||
- `claude_account:{id}` - Claude官方账户(加密的OAuth数据)
|
||||
- `claude_console_account:{id}` - Claude Console账户
|
||||
- `gemini_account:{id}` - Gemini账户
|
||||
- `openai_responses_account:{id}` - OpenAI Responses账户
|
||||
- `bedrock_account:{id}` - AWS Bedrock账户
|
||||
- `azure_openai_account:{id}` - Azure OpenAI账户
|
||||
- `droid_account:{id}` - Droid账户
|
||||
- `ccr_account:{id}` - CCR账户
|
||||
- **用户管理**:
|
||||
- `user:{id}` - 用户信息
|
||||
- `user_email:{email}` - 邮箱到用户ID映射
|
||||
- `user_session:{token}` - 用户会话
|
||||
- **管理员**:
|
||||
- `admin:{id}` - 管理员信息
|
||||
- `admin_username:{username}` - 用户名映射
|
||||
- `admin_credentials` - 管理员凭据(从data/init.json同步)
|
||||
- **会话管理**:
|
||||
- `session:{token}` - JWT会话管理
|
||||
- `sticky_session:{sessionHash}` - 粘性会话账户绑定
|
||||
- `session_window:{accountId}` - 账户会话窗口
|
||||
- **使用统计**:
|
||||
- `usage:daily:{date}:{key}:{model}` - 按日期、Key、模型的使用统计
|
||||
- `usage:account:{accountId}:{date}` - 按账户的使用统计
|
||||
- `usage:global:{date}` - 全局使用统计
|
||||
- **速率限制**:
|
||||
- `rate_limit:{keyId}:{window}` - 速率限制计数器
|
||||
- `rate_limit_state:{accountId}` - 账户限流状态
|
||||
- `overload:{accountId}` - 账户过载状态(529错误)
|
||||
- **并发控制**:
|
||||
- `concurrency:{accountId}` - Redis Sorted Set实现的并发计数
|
||||
- **并发请求排队**:
|
||||
- `concurrency:queue:{apiKeyId}` - API Key级别的排队计数器(TTL由 `concurrentRequestQueueTimeoutMs` + 30秒缓冲决定)
|
||||
- `concurrency:queue:stats:{apiKeyId}` - 排队统计(entered/success/timeout/cancelled)
|
||||
- `concurrency:queue:wait_times:{apiKeyId}` - 按API Key的等待时间记录(用于P50/P90/P99计算)
|
||||
- `concurrency:queue:wait_times:global` - 全局等待时间记录
|
||||
- **Webhook配置**:
|
||||
- `webhook_config:{id}` - Webhook配置
|
||||
- **用户消息队列**:
|
||||
- `user_msg_queue_lock:{accountId}` - 用户消息队列锁(当前持有者requestId)
|
||||
- `user_msg_queue_last:{accountId}` - 上次请求完成时间戳(用于延迟计算)
|
||||
- **系统信息**:
|
||||
- `system_info` - 系统状态缓存
|
||||
- `model_pricing` - 模型价格数据(pricingService)
|
||||
|
||||
### 流式响应处理
|
||||
|
||||
- 支持 SSE (Server-Sent Events) 流式传输
|
||||
- 自动从流中解析 usage 数据并记录
|
||||
- 客户端断开时通过 AbortController 清理资源
|
||||
- 错误时发送适当的 SSE 错误事件
|
||||
- 支持 SSE (Server-Sent Events) 流式传输,实时推送响应数据
|
||||
- 自动从SSE流中解析真实usage数据(input/output/cache_create/cache_read tokens)
|
||||
- 客户端断开时通过 AbortController 清理资源和并发计数
|
||||
- 错误时发送适当的 SSE 错误事件(带时间戳和错误类型)
|
||||
- 支持大文件流式传输(REQUEST_TIMEOUT配置超时时间)
|
||||
- 禁用Nagle算法确保数据立即发送(socket.setNoDelay)
|
||||
- 设置 `X-Accel-Buffering: no` 禁用Nginx缓冲
|
||||
|
||||
### CLI 工具使用示例
|
||||
|
||||
```bash
|
||||
# 创建新的 API Key
|
||||
# API Key管理
|
||||
npm run cli keys create -- --name "MyApp" --limit 1000
|
||||
npm run cli keys list
|
||||
npm run cli keys delete -- --id <keyId>
|
||||
npm run cli keys update -- --id <keyId> --limit 2000
|
||||
|
||||
# 查看系统状态
|
||||
npm run cli status
|
||||
# 系统状态查看
|
||||
npm run cli status # 查看系统概况
|
||||
npm run status # 统一状态脚本
|
||||
npm run status:detail # 详细状态
|
||||
|
||||
# 管理 Claude 账户
|
||||
# Claude账户管理
|
||||
npm run cli accounts list
|
||||
npm run cli accounts refresh <accountId>
|
||||
npm run cli accounts add -- --name "Account1"
|
||||
|
||||
# Gemini账户管理
|
||||
npm run cli gemini list
|
||||
npm run cli gemini add -- --name "Gemini1"
|
||||
|
||||
# 管理员操作
|
||||
npm run cli admin create -- --username admin2
|
||||
npm run cli admin reset-password -- --username admin
|
||||
npm run cli admin list
|
||||
|
||||
# 数据管理
|
||||
npm run data:export # 导出Redis数据
|
||||
npm run data:export:sanitized # 导出脱敏数据
|
||||
npm run data:export:enhanced # 增强导出(含解密)
|
||||
npm run data:export:encrypted # 导出加密数据
|
||||
npm run data:import # 导入数据
|
||||
npm run data:import:enhanced # 增强导入
|
||||
npm run data:debug # 调试Redis键
|
||||
|
||||
# 数据迁移和修复
|
||||
npm run migrate:apikey-expiry # API Key过期时间迁移
|
||||
npm run migrate:apikey-expiry:dry # 干跑模式
|
||||
npm run migrate:fix-usage-stats # 修复使用统计
|
||||
|
||||
# 成本和定价
|
||||
npm run init:costs # 初始化成本数据
|
||||
npm run update:pricing # 更新模型价格
|
||||
npm run test:pricing-fallback # 测试价格回退
|
||||
|
||||
# 监控
|
||||
npm run monitor # 增强监控脚本
|
||||
```
|
||||
|
||||
# important-instruction-reminders
|
||||
@@ -273,3 +634,4 @@ Do what has been asked; nothing more, nothing less.
|
||||
NEVER create files unless they're absolutely necessary for achieving your goal.
|
||||
ALWAYS prefer editing an existing file to creating a new one.
|
||||
NEVER proactively create documentation files (\*.md) or README files. Only create documentation files if explicitly requested by the User.
|
||||
````
|
||||
|
||||
29
Dockerfile
@@ -1,4 +1,17 @@
|
||||
# 🎯 前端构建阶段
|
||||
# 🎯 后端依赖阶段 (与前端构建并行)
|
||||
FROM node:18-alpine AS backend-deps
|
||||
|
||||
# 📁 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 📦 复制 package 文件
|
||||
COPY package*.json ./
|
||||
|
||||
# 🔽 安装依赖 (生产环境) - 使用 BuildKit 缓存加速
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --only=production
|
||||
|
||||
# 🎯 前端构建阶段 (与后端依赖并行)
|
||||
FROM node:18-alpine AS frontend-builder
|
||||
|
||||
# 📁 设置工作目录
|
||||
@@ -7,8 +20,9 @@ WORKDIR /app/web/admin-spa
|
||||
# 📦 复制前端依赖文件
|
||||
COPY web/admin-spa/package*.json ./
|
||||
|
||||
# 🔽 安装前端依赖
|
||||
RUN npm ci
|
||||
# 🔽 安装前端依赖 - 使用 BuildKit 缓存加速
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci
|
||||
|
||||
# 📋 复制前端源代码
|
||||
COPY web/admin-spa/ ./
|
||||
@@ -34,17 +48,16 @@ RUN apk add --no-cache \
|
||||
# 📁 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 📦 复制 package 文件
|
||||
# 📦 复制 package 文件 (用于版本信息等)
|
||||
COPY package*.json ./
|
||||
|
||||
# 🔽 安装依赖 (生产环境)
|
||||
RUN npm ci --only=production && \
|
||||
npm cache clean --force
|
||||
# 📦 从后端依赖阶段复制 node_modules (已预装好)
|
||||
COPY --from=backend-deps /app/node_modules ./node_modules
|
||||
|
||||
# 📋 复制应用代码
|
||||
COPY . .
|
||||
|
||||
# 📦 从构建阶段复制前端产物
|
||||
# 📦 从前端构建阶段复制前端产物
|
||||
COPY --from=frontend-builder /app/web/admin-spa/dist /app/web/admin-spa/dist
|
||||
|
||||
# 🔧 复制并设置启动脚本权限
|
||||
|
||||
8
Makefile
@@ -1,7 +1,7 @@
|
||||
# Claude Relay Service Makefile
|
||||
# 功能完整的 AI API 中转服务,支持 Claude 和 Gemini 双平台
|
||||
|
||||
.PHONY: help install setup dev start test lint clean docker-up docker-down service-start service-stop service-status logs cli-admin cli-keys cli-accounts cli-status
|
||||
.PHONY: help install setup dev start test lint clean docker-up docker-down service-start service-stop service-status logs cli-admin cli-keys cli-accounts cli-status ci-release-trigger
|
||||
|
||||
# 默认目标:显示帮助信息
|
||||
help:
|
||||
@@ -185,6 +185,10 @@ quick-daemon: setup service-daemon
|
||||
@echo "运行 'make service-status' 查看状态"
|
||||
@echo "运行 'make logs-follow' 查看实时日志"
|
||||
|
||||
# CI 触发占位目标:用于在不影响功能的情况下触发自动发布
|
||||
ci-release-trigger:
|
||||
@echo "⚙️ 触发自动发布流水线的占位目标,避免引入功能变更"
|
||||
|
||||
# 全栈开发环境
|
||||
dev-full: install install-web build-web setup dev
|
||||
@echo "🚀 全栈开发环境启动!"
|
||||
@@ -256,4 +260,4 @@ security-audit:
|
||||
|
||||
security-fix:
|
||||
@echo "🔧 修复安全漏洞..."
|
||||
npm audit fix
|
||||
npm audit fix
|
||||
|
||||
586
README.md
@@ -1,5 +1,10 @@
|
||||
# Claude Relay Service
|
||||
|
||||
> [!CAUTION]
|
||||
> **安全更新通知**:v1.1.248 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。
|
||||
>
|
||||
> **请立即更新到 v1.1.249+ 版本**,或迁移到新一代项目 **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
@@ -11,16 +16,23 @@
|
||||
|
||||
**🔐 自行搭建Claude API中转服务,支持多账户管理**
|
||||
|
||||
[English](#english) • [中文文档](#中文文档) • [📸 界面预览](docs/preview.md) • [📢 公告频道](https://t.me/claude_relay_service)
|
||||
[English](README_EN.md) • [快速开始](https://pincc.ai/) • [演示站点](https://demo.pincc.ai/admin-next/login) • [公告频道](https://t.me/claude_relay_service)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ⭐ 如果觉得有用,点个Star支持一下吧!
|
||||
## 💎 Claude/Codex 拼车服务推荐
|
||||
|
||||
> 开源不易,你的Star是我持续更新的动力 🚀
|
||||
> 欢迎加入 [Telegram 公告频道](https://t.me/claude_relay_service) 获取最新动态
|
||||
<div align="center">
|
||||
|
||||
| 平台 | 类型 | 服务 | 介绍 |
|
||||
|:---|:---|:---|:---|
|
||||
| **[pincc.ai](https://pincc.ai/)** | 🏆 **官方运营** | <small>✅ Claude Code<br>✅ Codex CLI</small> | 项目直营,提供稳定的 Claude Code / Codex CLI 拼车服务 |
|
||||
| **[ctok.ai](https://ctok.ai/)** | 🤝 合作伙伴 | <small>✅ Claude Code<br>✅ Codex CLI</small> | 社区认证,提供 Claude Code / Codex CLI 拼车 |
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
@@ -42,27 +54,14 @@
|
||||
|
||||
如果有以上困惑,那这个项目可能适合你。
|
||||
|
||||
> 💡 **热心网友福利**
|
||||
> 热心网友正在用本项目,正在拼车官方Claude Code Max 20X 200刀版本,是现在最稳定的方案。
|
||||
> 有需要自取: [https://ctok.ai/](https://ctok.ai/)
|
||||
|
||||
### 适合的场景
|
||||
|
||||
✅ **找朋友拼车**: 三五好友一起分摊Claude Code Max订阅,Opus爽用
|
||||
✅ **找朋友拼车**: 三五好友一起分摊Claude Code Max订阅
|
||||
✅ **隐私敏感**: 不想让第三方镜像看到你的对话内容
|
||||
✅ **技术折腾**: 有基本的技术基础,愿意自己搭建和维护
|
||||
✅ **稳定需求**: 需要长期稳定的Claude访问,不想受制于镜像站
|
||||
✅ **地区受限**: 无法直接访问Claude官方服务
|
||||
|
||||
### 不适合的场景
|
||||
|
||||
❌ **纯小白**: 完全不懂技术,连服务器都不会买
|
||||
❌ **偶尔使用**: 一个月用不了几次,没必要折腾
|
||||
❌ **注册问题**: 无法自行注册Claude账号
|
||||
❌ **支付问题**: 没有支付渠道订阅Claude Code
|
||||
|
||||
**如果你只是普通用户,对隐私要求不高,随便玩玩、想快速体验 Claude,那选个你熟知的镜像站会更合适。**
|
||||
|
||||
---
|
||||
|
||||
## 💭 为什么要自己搭?
|
||||
@@ -84,8 +83,6 @@
|
||||
|
||||
## 🚀 核心功能
|
||||
|
||||
> 📸 **[点击查看界面预览](docs/preview.md)** - 查看Web管理界面的详细截图
|
||||
|
||||
### 基础功能
|
||||
|
||||
- ✅ **多账户管理**: 可以添加多个Claude账户自动轮换
|
||||
@@ -134,13 +131,7 @@
|
||||
### 快速安装
|
||||
|
||||
```bash
|
||||
# 下载并运行管理脚本
|
||||
curl -fsSL https://raw.githubusercontent.com/Wei-Shaw/claude-relay-service/main/scripts/manage.sh -o manage.sh
|
||||
chmod +x manage.sh
|
||||
./manage.sh install
|
||||
|
||||
# 安装后可以使用 crs 命令管理服务
|
||||
crs # 显示交互式菜单
|
||||
curl -fsSL https://pincc.ai/manage.sh -o manage.sh && chmod +x manage.sh && ./manage.sh install
|
||||
```
|
||||
|
||||
### 脚本功能
|
||||
@@ -250,20 +241,6 @@ REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# AD域控配置(可选,用于企业内部用户登录)
|
||||
LDAP_ENABLED=true
|
||||
LDAP_URL=ldap://your-domain-controller-ip:389
|
||||
LDAP_BIND_DN=your-bind-user
|
||||
LDAP_BIND_PASSWORD=your-bind-password
|
||||
LDAP_BASE_DN=DC=your-domain,DC=com
|
||||
LDAP_SEARCH_FILTER=(&(objectClass=user)(|(cn={username})(sAMAccountName={username})))
|
||||
LDAP_TIMEOUT=10000
|
||||
|
||||
# Webhook通知配置(可选)
|
||||
WEBHOOK_ENABLED=true
|
||||
WEBHOOK_URLS=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key
|
||||
WEBHOOK_TIMEOUT=10000
|
||||
WEBHOOK_RETRIES=3
|
||||
```
|
||||
|
||||
**编辑 `config/config.js` 文件:**
|
||||
@@ -312,60 +289,15 @@ npm run service:status
|
||||
|
||||
## 🐳 Docker 部署
|
||||
|
||||
### 使用 Docker Hub 镜像(最简单)
|
||||
|
||||
> 🚀 使用官方镜像,自动构建,始终保持最新版本
|
||||
### Docker compose
|
||||
|
||||
#### 第一步:下载构建docker-compose.yml文件的脚本并执行
|
||||
```bash
|
||||
# 拉取镜像(支持 amd64 和 arm64)
|
||||
docker pull weishaw/claude-relay-service:latest
|
||||
curl -fsSL https://pincc.ai/crs-compose.sh -o crs-compose.sh && chmod +x crs-compose.sh && ./crs-compose.sh
|
||||
```
|
||||
|
||||
# 使用 docker-compose
|
||||
# 创建 .env 文件用于 docker-compose 的环境变量:
|
||||
cat > .env << 'EOF'
|
||||
# 必填:安全密钥(请修改为随机值)
|
||||
JWT_SECRET=your-random-secret-key-at-least-32-chars
|
||||
ENCRYPTION_KEY=your-32-character-encryption-key
|
||||
|
||||
# 可选:管理员凭据
|
||||
ADMIN_USERNAME=cr_admin
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
EOF
|
||||
|
||||
# 创建 docker-compose.yml 文件:
|
||||
cat > docker-compose.yml << 'EOF'
|
||||
version: '3.8'
|
||||
services:
|
||||
claude-relay:
|
||||
image: weishaw/claude-relay-service:latest
|
||||
container_name: claude-relay-service
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||
- REDIS_HOST=redis
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./data:/app/data
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: claude-relay-redis
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
EOF
|
||||
|
||||
# 启动服务
|
||||
#### 第二步:启动
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
@@ -378,7 +310,6 @@ docker-compose.yml 已包含:
|
||||
- ✅ Redis数据库
|
||||
- ✅ 健康检查
|
||||
- ✅ 自动重启
|
||||
- ✅ 所有配置通过环境变量管理
|
||||
|
||||
### 环境变量说明
|
||||
|
||||
@@ -463,19 +394,75 @@ docker-compose.yml 已包含:
|
||||
|
||||
**Claude Code 设置环境变量:**
|
||||
|
||||
|
||||
**使用标准 Claude 账号池**
|
||||
|
||||
默认使用标准 Claude 账号池:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
|
||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||
```
|
||||
|
||||
**Gemini CLI 设置环境变量:**
|
||||
**使用 Antigravity 账户池**
|
||||
|
||||
适用于通过 Antigravity 渠道使用 Claude 模型(如 `claude-opus-4-5` 等)。
|
||||
|
||||
```bash
|
||||
export CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名
|
||||
export GOOGLE_CLOUD_ACCESS_TOKEN="后台创建的API密钥" # 使用相同的API密钥即可
|
||||
export GOOGLE_GENAI_USE_GCA="true"
|
||||
# 1. 设置 Base URL 为 Antigravity 专用路径
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
|
||||
|
||||
# 2. 设置 API Key(在后台创建,权限需包含 'all' 或 'gemini')
|
||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||
|
||||
# 3. 指定模型名称(直接使用短名,无需前缀!)
|
||||
export ANTHROPIC_MODEL="claude-opus-4-5"
|
||||
|
||||
# 4. 启动
|
||||
claude
|
||||
```
|
||||
|
||||
**VSCode Claude 插件配置:**
|
||||
|
||||
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"primaryApiKey": "crs"
|
||||
}
|
||||
```
|
||||
|
||||
如果该文件不存在,请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。
|
||||
|
||||
> 💡 **IntelliJ IDEA 用户推荐**:[Claude Code Plus](https://github.com/touwaeriol/claude-code-plus) - 将 Claude Code 直接集成到 IDE,支持代码理解、文件读写、命令执行。插件市场搜索 `Claude Code Plus` 即可安装。
|
||||
|
||||
**Gemini CLI 设置环境变量:**
|
||||
|
||||
**方式一(推荐):通过 Gemini Assist API 方式访问**
|
||||
|
||||
```bash
|
||||
CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名
|
||||
GOOGLE_CLOUD_ACCESS_TOKEN="后台创建的API密钥"
|
||||
GOOGLE_GENAI_USE_GCA="true"
|
||||
GEMINI_MODEL="gemini-2.5-pro" # 如果你有gemini3权限可以填: gemini-3-pro-preview
|
||||
```
|
||||
|
||||
> **认证**:只能选 ```Login with Google``` 进行认证,如果跳 Google请删除 ```~/.gemini/settings.json``` 后再尝试启动```gemini```。
|
||||
> **注意**:gemini-cli 控制台会提示 `Failed to fetch user info: 401 Unauthorized`,但使用不受任何影响。
|
||||
|
||||
**方式二:通过 Gemini API 方式访问**
|
||||
|
||||
|
||||
```bash
|
||||
GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名
|
||||
GEMINI_API_KEY="后台创建的API密钥"
|
||||
GEMINI_MODEL="gemini-2.5-pro" # 如果你有gemini3权限可以填: gemini-3-pro-preview
|
||||
```
|
||||
|
||||
> **认证**:只能选 ```Use Gemini API Key``` 进行认证,如果提示 ```Enter Gemini API Key``` 请直接留空按回车。如果一打开就跳 Google请删除 ```~/.gemini/settings.json``` 后再尝试启动```gemini```。
|
||||
|
||||
> 💡 **进阶用法**:想在 Claude Code 中直接使用 Gemini 3 模型?请参考 [Claude Code 调用 Gemini 3 模型指南](docs/claude-code-gemini3-guide/README.md)
|
||||
|
||||
**使用 Claude Code:**
|
||||
|
||||
```bash
|
||||
@@ -488,103 +475,168 @@ claude
|
||||
gemini # 或其他 Gemini CLI 命令
|
||||
```
|
||||
|
||||
**Codex 设置环境变量:**
|
||||
**Codex 配置:**
|
||||
|
||||
在 `~/.codex/config.toml` 文件**开头**添加以下配置:
|
||||
|
||||
```toml
|
||||
model_provider = "crs"
|
||||
model = "gpt-5.1-codex-max"
|
||||
model_reasoning_effort = "high"
|
||||
disable_response_storage = true
|
||||
preferred_auth_method = "apikey"
|
||||
|
||||
[model_providers.crs]
|
||||
name = "crs"
|
||||
base_url = "http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true
|
||||
env_key = "CRS_OAI_KEY"
|
||||
```
|
||||
|
||||
在 `~/.codex/auth.json` 文件中配置API密钥为 null:
|
||||
|
||||
```json
|
||||
{
|
||||
"OPENAI_API_KEY": null
|
||||
}
|
||||
```
|
||||
|
||||
环境变量设置:
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名
|
||||
export OPENAI_API_KEY="后台创建的API密钥" # 使用后台创建的API密钥
|
||||
export CRS_OAI_KEY="后台创建的API密钥"
|
||||
```
|
||||
|
||||
> ⚠️ 在通过 Nginx 反向代理 CRS 服务并使用 Codex CLI 时,需要在 http 块中添加 underscores_in_headers on;。因为 Nginx 默认会移除带下划线的请求头(如 session_id),一旦该头被丢弃,多账号环境下的粘性会话功能将失效。
|
||||
|
||||
**Droid CLI 配置:**
|
||||
|
||||
Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义模型以指向本服务的新端点:
|
||||
|
||||
```json
|
||||
{
|
||||
"custom_models": [
|
||||
{
|
||||
"model_display_name": "Opus 4.5 [crs]",
|
||||
"model": "claude-opus-4-5-20251101",
|
||||
"base_url": "http://127.0.0.1:3000/droid/claude",
|
||||
"api_key": "后台创建的API密钥",
|
||||
"provider": "anthropic",
|
||||
"max_tokens": 64000
|
||||
},
|
||||
{
|
||||
"model_display_name": "GPT5-Codex [crs]",
|
||||
"model": "gpt-5-codex",
|
||||
"base_url": "http://127.0.0.1:3000/droid/openai",
|
||||
"api_key": "后台创建的API密钥",
|
||||
"provider": "openai",
|
||||
"max_tokens": 16384
|
||||
},
|
||||
{
|
||||
"model_display_name": "Gemini-3-Pro [crs]",
|
||||
"model": "gemini-3-pro-preview",
|
||||
"base_url": "http://127.0.0.1:3000/droid/comm/v1/",
|
||||
"api_key": "后台创建的API密钥",
|
||||
"provider": "generic-chat-completion-api",
|
||||
"max_tokens": 65535
|
||||
},
|
||||
{
|
||||
"model_display_name": "GLM-4.6 [crs]",
|
||||
"model": "glm-4.6",
|
||||
"base_url": "http://127.0.0.1:3000/droid/comm/v1/",
|
||||
"api_key": "后台创建的API密钥",
|
||||
"provider": "generic-chat-completion-api",
|
||||
"max_tokens": 202800
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> 💡 将示例中的 `http://127.0.0.1:3000` 替换为你的服务域名或公网地址,并写入后台生成的 API 密钥(cr_ 开头)。
|
||||
|
||||
### 5. 第三方工具API接入
|
||||
|
||||
本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等):
|
||||
本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等)。
|
||||
|
||||
**Claude标准格式:**
|
||||
#### Cherry Studio 接入示例
|
||||
|
||||
Cherry Studio支持多种AI服务的接入,下面是不同账号类型的详细配置:
|
||||
|
||||
**1. Claude账号接入:**
|
||||
|
||||
```
|
||||
# 如果工具支持Claude标准格式,请使用该接口
|
||||
http://你的服务器:3000/claude/
|
||||
# API地址
|
||||
http://你的服务器:3000/claude
|
||||
|
||||
# 模型ID示例
|
||||
claude-sonnet-4-5-20250929 # Claude Sonnet 4.5
|
||||
claude-opus-4-20250514 # Claude Opus 4
|
||||
```
|
||||
|
||||
**OpenAI兼容格式:**
|
||||
配置步骤:
|
||||
- 供应商类型选择"Anthropic"
|
||||
- API地址填入:`http://你的服务器:3000/claude`
|
||||
- API Key填入:后台创建的API密钥(cr_开头)
|
||||
|
||||
**2. Gemini账号接入:**
|
||||
|
||||
```
|
||||
# 适用于需要OpenAI格式的第三方工具
|
||||
http://你的服务器:3000/openai/claude/v1/
|
||||
# API地址
|
||||
http://你的服务器:3000/gemini
|
||||
|
||||
# 模型ID示例
|
||||
gemini-2.5-pro # Gemini 2.5 Pro
|
||||
```
|
||||
|
||||
**接入示例:**
|
||||
配置步骤:
|
||||
- 供应商类型选择"Gemini"
|
||||
- API地址填入:`http://你的服务器:3000/gemini`
|
||||
- API Key填入:后台创建的API密钥(cr_开头)
|
||||
|
||||
- **Cherry Studio**: 使用OpenAI格式 `http://你的服务器:3000/openai/claude/v1/` 使用Codex cli API `http://你的服务器:3000/openai/responses`
|
||||
- **其他支持自定义API的工具**: 根据工具要求选择合适的格式
|
||||
**3. Codex接入:**
|
||||
|
||||
```
|
||||
# API地址
|
||||
http://你的服务器:3000/openai
|
||||
|
||||
# 模型ID(固定)
|
||||
gpt-5 # Codex使用固定模型ID
|
||||
```
|
||||
|
||||
配置步骤:
|
||||
- 供应商类型选择"Openai-Response"
|
||||
- API地址填入:`http://你的服务器:3000/openai`
|
||||
- API Key填入:后台创建的API密钥(cr_开头)
|
||||
- **重要**:Codex只支持Openai-Response标准
|
||||
|
||||
|
||||
**Cherry Studio 地址格式重要说明:**
|
||||
|
||||
- ✅ **推荐格式**:`http://你的服务器:3000/claude`(不加结尾 `/`,让 Cherry Studio 自动加上 v1)
|
||||
- ✅ **等效格式**:`http://你的服务器:3000/claude/v1/`(手动指定 v1 并加结尾 `/`)
|
||||
- 💡 **说明**:这两种格式在 Cherry Studio 中是完全等效的
|
||||
- ❌ **错误格式**:`http://你的服务器:3000/claude/`(单独的 `/` 结尾会被 Cherry Studio 忽略 v1 版本)
|
||||
|
||||
#### 其他第三方工具接入
|
||||
|
||||
**接入要点:**
|
||||
|
||||
- 所有账号类型都使用相同的API密钥(在后台统一创建)
|
||||
- 根据不同的路由前缀自动识别账号类型
|
||||
- `/claude/` - 使用Claude账号池
|
||||
- `/antigravity/api/` - 使用Antigravity账号池(推荐用于Claude Code)
|
||||
- `/droid/claude/` - 使用Droid类型Claude账号池(只建议api调用或Droid Cli中使用)
|
||||
- `/gemini/` - 使用Gemini账号池
|
||||
- `/openai/` - 使用Codex账号(只支持Openai-Response格式)
|
||||
- `/droid/openai/` - 使用Droid类型OpenAI兼容账号池(只建议api调用或Droid Cli中使用)
|
||||
- 支持所有标准API端点(messages、models等)
|
||||
|
||||
**重要说明:**
|
||||
|
||||
- 所有格式都支持相同的功能,仅是路径不同
|
||||
- `/api/v1/messages` = `/claude/v1/messages` = `/openai/claude/v1/messages`
|
||||
- 选择适合你使用工具的格式即可
|
||||
- 支持所有Claude API端点(messages、models等)
|
||||
|
||||
---
|
||||
|
||||
## 📢 Webhook 通知功能
|
||||
|
||||
### 功能说明
|
||||
|
||||
当系统检测到账号异常时,会自动发送 webhook 通知,支持企业微信、钉钉、Slack 等平台。
|
||||
|
||||
### 通知触发场景
|
||||
|
||||
- **Claude OAuth 账户**: token 过期或未授权时
|
||||
- **Claude Console 账户**: 系统检测到账户被封锁时
|
||||
- **Gemini 账户**: token 刷新失败时
|
||||
- **手动禁用账户**: 管理员手动禁用账户时
|
||||
|
||||
### 配置方法
|
||||
|
||||
**1. 环境变量配置**
|
||||
|
||||
```bash
|
||||
# 启用 webhook 通知
|
||||
WEBHOOK_ENABLED=true
|
||||
|
||||
# 企业微信 webhook 地址(替换为你的实际地址)
|
||||
WEBHOOK_URLS=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key
|
||||
|
||||
# 多个地址用逗号分隔
|
||||
WEBHOOK_URLS=https://webhook1.com,https://webhook2.com
|
||||
|
||||
# 请求超时时间(毫秒,默认10秒)
|
||||
WEBHOOK_TIMEOUT=10000
|
||||
|
||||
# 重试次数(默认3次)
|
||||
WEBHOOK_RETRIES=3
|
||||
```
|
||||
|
||||
**2. 企业微信设置**
|
||||
|
||||
1. 在企业微信群中添加「群机器人」
|
||||
2. 获取 webhook 地址:`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`
|
||||
3. 将地址配置到 `WEBHOOK_URLS` 环境变量
|
||||
|
||||
### 通知内容格式
|
||||
|
||||
系统会发送结构化的通知消息:
|
||||
|
||||
```
|
||||
账户名称 账号异常,异常代码 ERROR_CODE
|
||||
平台:claude-oauth
|
||||
时间:2025-08-14 17:30:00
|
||||
原因:Token expired
|
||||
```
|
||||
|
||||
### 测试 Webhook
|
||||
|
||||
可以通过管理后台测试 webhook 连通性:
|
||||
|
||||
1. 登录管理后台:`http://你的服务器:3000/web`
|
||||
2. 访问:`/admin/webhook/test`
|
||||
3. 发送测试通知确认配置正确
|
||||
- 确保在后台已添加对应类型的账号(Claude/Gemini/Codex)
|
||||
- API密钥可以通用,系统会根据路由自动选择账号类型
|
||||
- 建议为不同用户创建不同的API密钥便于使用统计
|
||||
|
||||
---
|
||||
|
||||
@@ -670,23 +722,6 @@ npm run service:status
|
||||
- 客户端验证失败时会返回403错误并记录详细信息
|
||||
- 通过日志可以查看实际的User-Agent格式,方便配置自定义客户端
|
||||
|
||||
### 自定义客户端配置
|
||||
|
||||
如需添加自定义客户端,可以修改 `config/config.js` 文件:
|
||||
|
||||
```javascript
|
||||
clientRestrictions: {
|
||||
predefinedClients: [
|
||||
// ... 现有客户端配置
|
||||
{
|
||||
id: 'my_custom_client',
|
||||
name: 'My Custom Client',
|
||||
description: '我的自定义客户端',
|
||||
userAgentPattern: /^MyClient\/[\d\.]+/i
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 日志示例
|
||||
|
||||
@@ -733,13 +768,17 @@ redis-cli ping
|
||||
|
||||
## 🛠️ 进阶
|
||||
|
||||
### 生产环境部署建议(重要!)
|
||||
### 反向代理部署指南
|
||||
|
||||
**强烈建议使用Caddy反向代理(自动HTTPS)**
|
||||
在生产环境中,建议通过反向代理进行连接,以便使用自动 HTTPS、安全头部和性能优化。下面提供两种常用方案: **Caddy** 和 **Nginx Proxy Manager (NPM)**。
|
||||
|
||||
建议使用Caddy作为反向代理,它会自动申请和更新SSL证书,配置更简单:
|
||||
---
|
||||
|
||||
**1. 安装Caddy**
|
||||
## Caddy 方案
|
||||
|
||||
Caddy 是一款自动管理 HTTPS 证书的 Web 服务器,配置简单、性能优秀,很适合不需要 Docker 环境的部署方案。
|
||||
|
||||
**1. 安装 Caddy**
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
@@ -755,23 +794,23 @@ sudo yum copr enable @caddy/caddy
|
||||
sudo yum install caddy
|
||||
```
|
||||
|
||||
**2. Caddy配置(超简单!)**
|
||||
**2. Caddy 配置**
|
||||
|
||||
编辑 `/etc/caddy/Caddyfile`:
|
||||
编辑 `/etc/caddy/Caddyfile` :
|
||||
|
||||
```
|
||||
```caddy
|
||||
your-domain.com {
|
||||
# 反向代理到本地服务
|
||||
reverse_proxy 127.0.0.1:3000 {
|
||||
# 支持流式响应(SSE)
|
||||
# 支持流式响应或 SSE
|
||||
flush_interval -1
|
||||
|
||||
# 传递真实IP
|
||||
# 传递真实 IP
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
|
||||
# 超时设置(适合长连接)
|
||||
# 长读/写超时配置
|
||||
transport http {
|
||||
read_timeout 300s
|
||||
write_timeout 300s
|
||||
@@ -789,42 +828,132 @@ your-domain.com {
|
||||
}
|
||||
```
|
||||
|
||||
**3. 启动Caddy**
|
||||
**3. 启动 Caddy**
|
||||
|
||||
```bash
|
||||
# 测试配置
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile
|
||||
|
||||
# 启动服务
|
||||
sudo systemctl start caddy
|
||||
sudo systemctl enable caddy
|
||||
|
||||
# 查看状态
|
||||
sudo systemctl status caddy
|
||||
```
|
||||
|
||||
**4. 更新服务配置**
|
||||
**4. 服务配置**
|
||||
|
||||
修改你的服务配置,让它只监听本地:
|
||||
Caddy 会自动管理 HTTPS,因此可以将服务限制在本地进行监听:
|
||||
|
||||
```javascript
|
||||
// config/config.js
|
||||
module.exports = {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '127.0.0.1' // 只监听本地,通过nginx代理
|
||||
host: '127.0.0.1' // 只监听本地
|
||||
}
|
||||
// ... 其他配置
|
||||
}
|
||||
```
|
||||
|
||||
**Caddy优势:**
|
||||
**Caddy 特点**
|
||||
|
||||
- 🔒 **自动HTTPS**: 自动申请和续期Let's Encrypt证书,零配置
|
||||
- 🛡️ **安全默认**: 默认启用现代安全协议和加密套件
|
||||
- 🚀 **流式支持**: 原生支持SSE/WebSocket等流式传输
|
||||
- 📊 **简单配置**: 配置文件极其简洁,易于维护
|
||||
- ⚡ **HTTP/2**: 默认启用HTTP/2,提升传输性能
|
||||
* 🔒 自动 HTTPS,零配置证书管理
|
||||
* 🛡️ 安全默认配置,启用现代 TLS 套件
|
||||
* ⚡ HTTP/2 和流式传输支持
|
||||
* 🔧 配置文件简洁,易于维护
|
||||
|
||||
---
|
||||
|
||||
## Nginx Proxy Manager (NPM) 方案
|
||||
|
||||
Nginx Proxy Manager 通过图形化界面管理反向代理和 HTTPS 证书,並以 Docker 容器部署。
|
||||
|
||||
**1. 在 NPM 创建新的 Proxy Host**
|
||||
|
||||
Details 配置如下:
|
||||
|
||||
| 项目 | 设置 |
|
||||
| --------------------- | ----------------------- |
|
||||
| Domain Names | relay.example.com |
|
||||
| Scheme | http |
|
||||
| Forward Hostname / IP | 192.168.0.1 (docker 机器 IP) |
|
||||
| Forward Port | 3000 |
|
||||
| Block Common Exploits | ☑️ |
|
||||
| Websockets Support | ❌ **关闭** |
|
||||
| Cache Assets | ❌ **关闭** |
|
||||
| Access List | Publicly Accessible |
|
||||
|
||||
> 注意:
|
||||
> - 请确保 Claude Relay Service **监听 host 为 `0.0.0.0` 、容器 IP 或本机 IP**,以便 NPM 实现内网连接。
|
||||
> - **Websockets Support 和 Cache Assets 必须关闭**,否则会导致 SSE / 流式响应失败。
|
||||
|
||||
**2. Custom locations**
|
||||
|
||||
無需添加任何内容,保持为空。
|
||||
|
||||
**3. SSL 设置**
|
||||
|
||||
* **SSL Certificate**: Request a new SSL Certificate (Let's Encrypt) 或已有证书
|
||||
* ☑️ **Force SSL**
|
||||
* ☑️ **HTTP/2 Support**
|
||||
* ☑️ **HSTS Enabled**
|
||||
* ☑️ **HSTS Subdomains**
|
||||
|
||||
**4. Advanced 配置**
|
||||
|
||||
Custom Nginx Configuration 中添加以下内容:
|
||||
|
||||
```nginx
|
||||
# 传递真实用户 IP
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 支持 WebSocket / SSE 等流式通信
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
|
||||
# 长连接 / 超时设置(适合 AI 聊天流式传输)
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 30s;
|
||||
|
||||
# ---- 安全性设置 ----
|
||||
# 严格 HTTPS 策略 (HSTS)
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# 阻挡点击劫持与内容嗅探
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
# Referrer / Permissions 限制策略
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# 隐藏服务器信息(等效于 Caddy 的 `-Server`)
|
||||
proxy_hide_header Server;
|
||||
|
||||
# ---- 性能微调 ----
|
||||
# 关闭代理端缓存,确保即时响应(SSE / Streaming)
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_no_cache $http_upgrade;
|
||||
proxy_request_buffering off;
|
||||
```
|
||||
|
||||
**4. 启动和验证**
|
||||
|
||||
* 保存后等待 NPM 自动申请 Let's Encrypt 证书(如果有)。
|
||||
* Dashboard 中查看 Proxy Host 状态,确保显示为 "Online"。
|
||||
* 访问 `https://relay.example.com`,如果显示绿色锁图标即表示 HTTPS 正常。
|
||||
|
||||
**NPM 特点**
|
||||
|
||||
* 🔒 自动申请和续期证书
|
||||
* 🔧 图形化界面,方便管理多服务
|
||||
* ⚡ 原生支持 HTTP/2 / HTTPS
|
||||
* 🚀 适合 Docker 容器部署
|
||||
|
||||
---
|
||||
|
||||
上述两种方案均可用于生产部署。
|
||||
|
||||
---
|
||||
|
||||
@@ -862,6 +991,27 @@ module.exports = {
|
||||
|
||||
---
|
||||
|
||||
## ❤️ 赞助支持
|
||||
|
||||
如果您觉得这个项目对您有帮助,请考虑赞助支持项目的持续开发。您的支持是我们最大的动力!
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://afdian.com/a/claude-relay-service" target="_blank">
|
||||
<img src="https://img.shields.io/badge/请我喝杯咖啡-爱发电-946ce6?style=for-the-badge&logo=buy-me-a-coffee&logoColor=white" alt="Sponsor">
|
||||
</a>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="docs/sponsoring/wechat.jpg" width="200" alt="wechat" /></td>
|
||||
<td><img src="docs/sponsoring/alipay.jpg" width="200" alt="alipay" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 [MIT许可证](LICENSE)。
|
||||
|
||||
224
README_EN.md
@@ -1,5 +1,10 @@
|
||||
# Claude Relay Service
|
||||
|
||||
> [!CAUTION]
|
||||
> **Security Update**: v1.1.248 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
|
||||
>
|
||||
> **Please update to v1.1.249+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
@@ -9,7 +14,7 @@
|
||||
|
||||
**🔐 Self-hosted Claude API relay service with multi-account management**
|
||||
|
||||
[English](#english) • [中文文档](#中文文档) • [📸 Interface Preview](docs/preview.md) • [📢 Telegram Channel](https://t.me/claude_relay_service)
|
||||
[中文文档](README.md) • [Preview](https://demo.pincc.ai/admin-next/login) • [Telegram Channel](https://t.me/claude_relay_service)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -30,17 +35,6 @@
|
||||
|
||||
📖 **Disclaimer**: This project is for technical learning and research purposes only. The author is not responsible for any account bans, service interruptions, or other losses caused by using this project.
|
||||
|
||||
---
|
||||
|
||||
> 💡 **Thanks to [@vista8](https://x.com/vista8) for the recommendation!**
|
||||
>
|
||||
> If you're interested in Vibe coding, follow:
|
||||
>
|
||||
> - 🐦 **X**: [@vista8](https://x.com/vista8) - Sharing cutting-edge tech trends
|
||||
> - 📱 **WeChat**: 向阳乔木推荐看
|
||||
|
||||
---
|
||||
|
||||
## 🤔 Is This Project Right for You?
|
||||
|
||||
- 🌍 **Regional Restrictions**: Can't directly access Claude Code service in your region?
|
||||
@@ -243,21 +237,68 @@ Assign a key to each user:
|
||||
4. Set usage limits (optional)
|
||||
5. Save, note down the generated key
|
||||
|
||||
### 4. Start Using Claude Code
|
||||
### 4. Start Using Claude Code and Gemini CLI
|
||||
|
||||
Now you can replace the official API with your own service:
|
||||
|
||||
**Set environment variables:**
|
||||
**Claude Code Set Environment Variables:**
|
||||
|
||||
Default uses standard Claude account pool:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain according to actual situation
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain
|
||||
export ANTHROPIC_AUTH_TOKEN="API key created in the backend"
|
||||
```
|
||||
|
||||
**Use claude:**
|
||||
**VSCode Claude Plugin Configuration:**
|
||||
|
||||
If using VSCode Claude plugin, configure in `~/.claude/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"primaryApiKey": "crs"
|
||||
}
|
||||
```
|
||||
|
||||
If the file doesn't exist, create it manually. Windows users path is `C:\Users\YourUsername\.claude\config.json`.
|
||||
|
||||
**Gemini CLI Set Environment Variables:**
|
||||
|
||||
**Method 1 (Recommended): Via Gemini Assist API**
|
||||
|
||||
Each account enjoys 1000 requests per day, 60 requests per minute free quota.
|
||||
|
||||
```bash
|
||||
CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # Fill in your server's IP address or domain
|
||||
GOOGLE_CLOUD_ACCESS_TOKEN="API key created in the backend"
|
||||
GOOGLE_GENAI_USE_GCA="true"
|
||||
GEMINI_MODEL="gemini-2.5-pro"
|
||||
```
|
||||
|
||||
> **Note**: gemini-cli console will show `Failed to fetch user info: 401 Unauthorized`, but this doesn't affect usage.
|
||||
|
||||
**Method 2: Via Gemini API**
|
||||
|
||||
Very limited free quota, easily triggers 429 errors.
|
||||
|
||||
```bash
|
||||
GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # Fill in your server's IP address or domain
|
||||
GEMINI_API_KEY="API key created in the backend"
|
||||
GEMINI_MODEL="gemini-2.5-pro"
|
||||
```
|
||||
|
||||
**Use Claude Code:**
|
||||
|
||||
```bash
|
||||
claude
|
||||
```
|
||||
|
||||
**Use Gemini CLI:**
|
||||
|
||||
```bash
|
||||
gemini
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Daily Maintenance
|
||||
@@ -338,13 +379,18 @@ redis-cli ping
|
||||
|
||||
## 🛠️ Advanced Usage
|
||||
|
||||
### Production Deployment Recommendations (Important!)
|
||||
### Reverse Proxy Deployment Guide
|
||||
|
||||
**Strongly recommend using Caddy reverse proxy (Automatic HTTPS)**
|
||||
For production environments, it is recommended to use a reverse proxy for automatic HTTPS, security headers, and performance optimization. Two common solutions are provided below: **Caddy** and **Nginx Proxy Manager (NPM)**.
|
||||
|
||||
Recommend using Caddy as reverse proxy, it will automatically apply and renew SSL certificates with simpler configuration:
|
||||
---
|
||||
|
||||
## Caddy Solution
|
||||
|
||||
Caddy is a web server that automatically manages HTTPS certificates, with simple configuration and excellent performance, ideal for deployments without Docker environments.
|
||||
|
||||
**1. Install Caddy**
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
|
||||
@@ -359,29 +405,30 @@ sudo yum copr enable @caddy/caddy
|
||||
sudo yum install caddy
|
||||
```
|
||||
|
||||
**2. Caddy Configuration (Super Simple!)**
|
||||
**2. Caddy Configuration**
|
||||
|
||||
Edit `/etc/caddy/Caddyfile`:
|
||||
```
|
||||
|
||||
```caddy
|
||||
your-domain.com {
|
||||
# Reverse proxy to local service
|
||||
reverse_proxy 127.0.0.1:3000 {
|
||||
# Support streaming responses (SSE)
|
||||
# Support streaming responses or SSE
|
||||
flush_interval -1
|
||||
|
||||
|
||||
# Pass real IP
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
|
||||
# Timeout settings (suitable for long connections)
|
||||
|
||||
# Long read/write timeout configuration
|
||||
transport http {
|
||||
read_timeout 300s
|
||||
write_timeout 300s
|
||||
dial_timeout 30s
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
@@ -393,38 +440,131 @@ your-domain.com {
|
||||
```
|
||||
|
||||
**3. Start Caddy**
|
||||
```bash
|
||||
# Test configuration
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile
|
||||
|
||||
# Start service
|
||||
```bash
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile
|
||||
sudo systemctl start caddy
|
||||
sudo systemctl enable caddy
|
||||
|
||||
# Check status
|
||||
sudo systemctl status caddy
|
||||
```
|
||||
|
||||
**4. Update service configuration**
|
||||
**4. Service Configuration**
|
||||
|
||||
Since Caddy automatically manages HTTPS, you can restrict the service to listen locally only:
|
||||
|
||||
Modify your service configuration to listen only locally:
|
||||
```javascript
|
||||
// config/config.js
|
||||
module.exports = {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '127.0.0.1' // Listen only locally, proxy through nginx
|
||||
host: '127.0.0.1' // Listen locally only
|
||||
}
|
||||
// ... other configurations
|
||||
}
|
||||
```
|
||||
|
||||
**Caddy Advantages:**
|
||||
- 🔒 **Automatic HTTPS**: Automatically apply and renew Let's Encrypt certificates, zero configuration
|
||||
- 🛡️ **Secure by Default**: Modern security protocols and cipher suites enabled by default
|
||||
- 🚀 **Streaming Support**: Native support for SSE/WebSocket streaming
|
||||
- 📊 **Simple Configuration**: Extremely concise configuration files, easy to maintain
|
||||
- ⚡ **HTTP/2**: HTTP/2 enabled by default for improved performance
|
||||
**Caddy Features**
|
||||
|
||||
* 🔒 Automatic HTTPS with zero-configuration certificate management
|
||||
* 🛡️ Secure default configuration with modern TLS suites
|
||||
* ⚡ HTTP/2 and streaming support
|
||||
* 🔧 Concise configuration files, easy to maintain
|
||||
|
||||
---
|
||||
|
||||
## Nginx Proxy Manager (NPM) Solution
|
||||
|
||||
Nginx Proxy Manager manages reverse proxies and HTTPS certificates through a graphical interface, deployed as a Docker container.
|
||||
|
||||
**1. Create a New Proxy Host in NPM**
|
||||
|
||||
Configure the Details as follows:
|
||||
|
||||
| Item | Setting |
|
||||
| --------------------- | ------------------------ |
|
||||
| Domain Names | relay.example.com |
|
||||
| Scheme | http |
|
||||
| Forward Hostname / IP | 192.168.0.1 (docker host IP) |
|
||||
| Forward Port | 3000 |
|
||||
| Block Common Exploits | ☑️ |
|
||||
| Websockets Support | ❌ **Disable** |
|
||||
| Cache Assets | ❌ **Disable** |
|
||||
| Access List | Publicly Accessible |
|
||||
|
||||
> Note:
|
||||
> - Ensure Claude Relay Service **listens on `0.0.0.0`, container IP, or host IP** to allow NPM internal network connections.
|
||||
> - **Websockets Support and Cache Assets must be disabled**, otherwise SSE / streaming responses will fail.
|
||||
|
||||
**2. Custom locations**
|
||||
|
||||
No content needed, keep it empty.
|
||||
|
||||
**3. SSL Settings**
|
||||
|
||||
* **SSL Certificate**: Request a new SSL Certificate (Let's Encrypt) or existing certificate
|
||||
* ☑️ **Force SSL**
|
||||
* ☑️ **HTTP/2 Support**
|
||||
* ☑️ **HSTS Enabled**
|
||||
* ☑️ **HSTS Subdomains**
|
||||
|
||||
**4. Advanced Configuration**
|
||||
|
||||
Add the following to Custom Nginx Configuration:
|
||||
|
||||
```nginx
|
||||
# Pass real user IP
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Support WebSocket / SSE streaming
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
|
||||
# Long connection / timeout settings (for AI chat streaming)
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 30s;
|
||||
|
||||
# ---- Security Settings ----
|
||||
# Strict HTTPS policy (HSTS)
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Block clickjacking and content sniffing
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
# Referrer / Permissions restriction policies
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# Hide server information (equivalent to Caddy's `-Server`)
|
||||
proxy_hide_header Server;
|
||||
|
||||
# ---- Performance Tuning ----
|
||||
# Disable proxy caching for real-time responses (SSE / Streaming)
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_no_cache $http_upgrade;
|
||||
proxy_request_buffering off;
|
||||
```
|
||||
|
||||
**5. Launch and Verify**
|
||||
|
||||
* After saving, wait for NPM to automatically request Let's Encrypt certificate (if applicable).
|
||||
* Check Proxy Host status in Dashboard to ensure it shows "Online".
|
||||
* Visit `https://relay.example.com`, if the green lock icon appears, HTTPS is working properly.
|
||||
|
||||
**NPM Features**
|
||||
|
||||
* 🔒 Automatic certificate application and renewal
|
||||
* 🔧 Graphical interface for easy multi-service management
|
||||
* ⚡ Native HTTP/2 / HTTPS support
|
||||
* 🚀 Ideal for Docker container deployments
|
||||
|
||||
---
|
||||
|
||||
Both solutions are suitable for production deployment. If you use a Docker environment, **Nginx Proxy Manager is more convenient**; if you want to keep software lightweight and automated, **Caddy is a better choice**.
|
||||
|
||||
---
|
||||
|
||||
|
||||
21
SECURITY.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Use this section to tell people about which versions of your project are
|
||||
currently being supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 5.1.x | :white_check_mark: |
|
||||
| 5.0.x | :x: |
|
||||
| 4.0.x | :white_check_mark: |
|
||||
| < 4.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Use this section to tell people how to report a vulnerability.
|
||||
|
||||
Tell them where to go, how often they can expect to get an update on a
|
||||
reported vulnerability, what to expect if the vulnerability is accepted or
|
||||
declined, etc.
|
||||
@@ -32,13 +32,28 @@ const config = {
|
||||
enableTLS: process.env.REDIS_ENABLE_TLS === 'true'
|
||||
},
|
||||
|
||||
// 🔗 会话管理配置
|
||||
session: {
|
||||
// 粘性会话TTL配置(小时),默认1小时
|
||||
stickyTtlHours: parseFloat(process.env.STICKY_SESSION_TTL_HOURS) || 1,
|
||||
// 续期阈值(分钟),默认0分钟(不续期)
|
||||
renewalThresholdMinutes: parseInt(process.env.STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES) || 0
|
||||
},
|
||||
|
||||
// 🎯 Claude API配置
|
||||
claude: {
|
||||
apiUrl: process.env.CLAUDE_API_URL || 'https://api.anthropic.com/v1/messages',
|
||||
apiVersion: process.env.CLAUDE_API_VERSION || '2023-06-01',
|
||||
betaHeader:
|
||||
process.env.CLAUDE_BETA_HEADER ||
|
||||
'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
||||
'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14',
|
||||
overloadHandling: {
|
||||
enabled: (() => {
|
||||
const minutes = parseInt(process.env.CLAUDE_OVERLOAD_HANDLING_MINUTES) || 0
|
||||
// 验证配置值:限制在0-1440分钟(24小时)内
|
||||
return Math.max(0, Math.min(minutes, 1440))
|
||||
})()
|
||||
}
|
||||
},
|
||||
|
||||
// ☁️ Bedrock API配置
|
||||
@@ -56,12 +71,39 @@ const config = {
|
||||
|
||||
// 🌐 代理配置
|
||||
proxy: {
|
||||
timeout: parseInt(process.env.DEFAULT_PROXY_TIMEOUT) || 30000,
|
||||
timeout: parseInt(process.env.DEFAULT_PROXY_TIMEOUT) || 600000, // 10分钟
|
||||
maxRetries: parseInt(process.env.MAX_PROXY_RETRIES) || 3,
|
||||
// 连接池与 Keep-Alive 配置(默认关闭,需要显式开启)
|
||||
keepAlive: (() => {
|
||||
if (process.env.PROXY_KEEP_ALIVE === undefined || process.env.PROXY_KEEP_ALIVE === '') {
|
||||
return false
|
||||
}
|
||||
return process.env.PROXY_KEEP_ALIVE === 'true'
|
||||
})(),
|
||||
maxSockets: (() => {
|
||||
if (process.env.PROXY_MAX_SOCKETS === undefined || process.env.PROXY_MAX_SOCKETS === '') {
|
||||
return undefined
|
||||
}
|
||||
const parsed = parseInt(process.env.PROXY_MAX_SOCKETS)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
|
||||
})(),
|
||||
maxFreeSockets: (() => {
|
||||
if (
|
||||
process.env.PROXY_MAX_FREE_SOCKETS === undefined ||
|
||||
process.env.PROXY_MAX_FREE_SOCKETS === ''
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = parseInt(process.env.PROXY_MAX_FREE_SOCKETS)
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
|
||||
})(),
|
||||
// IP协议族配置:true=IPv4, false=IPv6, 默认IPv4(兼容性更好)
|
||||
useIPv4: process.env.PROXY_USE_IPV4 !== 'false' // 默认 true,只有明确设置为 'false' 才使用 IPv6
|
||||
},
|
||||
|
||||
// ⏱️ 请求超时配置
|
||||
requestTimeout: parseInt(process.env.REQUEST_TIMEOUT) || 600000, // 默认 10 分钟
|
||||
|
||||
// 📈 使用限制
|
||||
limits: {
|
||||
defaultTokenLimit: parseInt(process.env.DEFAULT_TOKEN_LIMIT) || 1000000
|
||||
@@ -95,36 +137,56 @@ const config = {
|
||||
sessionSecret: process.env.WEB_SESSION_SECRET || 'CHANGE-THIS-SESSION-SECRET'
|
||||
},
|
||||
|
||||
// 🔒 客户端限制配置
|
||||
clientRestrictions: {
|
||||
// 预定义的客户端列表
|
||||
predefinedClients: [
|
||||
{
|
||||
id: 'claude_code',
|
||||
name: 'ClaudeCode',
|
||||
description: 'Official Claude Code CLI',
|
||||
// 匹配 Claude CLI 的 User-Agent
|
||||
// 示例: claude-cli/1.0.58 (external, cli)
|
||||
userAgentPattern: /^claude-cli\/[\d.]+\s+\(/i
|
||||
},
|
||||
{
|
||||
id: 'gemini_cli',
|
||||
name: 'Gemini-CLI',
|
||||
description: 'Gemini Command Line Interface',
|
||||
// 匹配 GeminiCLI 的 User-Agent
|
||||
// 示例: GeminiCLI/v18.20.8 (darwin; arm64)
|
||||
userAgentPattern: /^GeminiCLI\/v?[\d.]+\s+\(/i
|
||||
// 🔐 LDAP 认证配置
|
||||
ldap: {
|
||||
enabled: process.env.LDAP_ENABLED === 'true',
|
||||
server: {
|
||||
url: process.env.LDAP_URL || 'ldap://localhost:389',
|
||||
bindDN: process.env.LDAP_BIND_DN || 'cn=admin,dc=example,dc=com',
|
||||
bindCredentials: process.env.LDAP_BIND_PASSWORD || 'admin',
|
||||
searchBase: process.env.LDAP_SEARCH_BASE || 'dc=example,dc=com',
|
||||
searchFilter: process.env.LDAP_SEARCH_FILTER || '(uid={{username}})',
|
||||
searchAttributes: process.env.LDAP_SEARCH_ATTRIBUTES
|
||||
? process.env.LDAP_SEARCH_ATTRIBUTES.split(',')
|
||||
: ['dn', 'uid', 'cn', 'mail', 'givenName', 'sn'],
|
||||
timeout: parseInt(process.env.LDAP_TIMEOUT) || 5000,
|
||||
connectTimeout: parseInt(process.env.LDAP_CONNECT_TIMEOUT) || 10000,
|
||||
// TLS/SSL 配置
|
||||
tls: {
|
||||
// 是否忽略证书错误 (用于自签名证书)
|
||||
rejectUnauthorized: process.env.LDAP_TLS_REJECT_UNAUTHORIZED !== 'false', // 默认验证证书,设置为false则忽略
|
||||
// CA证书文件路径 (可选,用于自定义CA证书)
|
||||
ca: process.env.LDAP_TLS_CA_FILE
|
||||
? require('fs').readFileSync(process.env.LDAP_TLS_CA_FILE)
|
||||
: undefined,
|
||||
// 客户端证书文件路径 (可选,用于双向认证)
|
||||
cert: process.env.LDAP_TLS_CERT_FILE
|
||||
? require('fs').readFileSync(process.env.LDAP_TLS_CERT_FILE)
|
||||
: undefined,
|
||||
// 客户端私钥文件路径 (可选,用于双向认证)
|
||||
key: process.env.LDAP_TLS_KEY_FILE
|
||||
? require('fs').readFileSync(process.env.LDAP_TLS_KEY_FILE)
|
||||
: undefined,
|
||||
// 服务器名称 (用于SNI,可选)
|
||||
servername: process.env.LDAP_TLS_SERVERNAME || undefined
|
||||
}
|
||||
// 添加自定义客户端示例:
|
||||
// {
|
||||
// id: 'custom_client',
|
||||
// name: 'My Custom Client',
|
||||
// description: 'My custom API client',
|
||||
// userAgentPattern: /^MyClient\/[\d\.]+/i
|
||||
// }
|
||||
],
|
||||
// 是否允许自定义客户端(未来功能)
|
||||
allowCustomClients: process.env.ALLOW_CUSTOM_CLIENTS === 'true'
|
||||
},
|
||||
userMapping: {
|
||||
username: process.env.LDAP_USER_ATTR_USERNAME || 'uid',
|
||||
displayName: process.env.LDAP_USER_ATTR_DISPLAY_NAME || 'cn',
|
||||
email: process.env.LDAP_USER_ATTR_EMAIL || 'mail',
|
||||
firstName: process.env.LDAP_USER_ATTR_FIRST_NAME || 'givenName',
|
||||
lastName: process.env.LDAP_USER_ATTR_LAST_NAME || 'sn'
|
||||
}
|
||||
},
|
||||
|
||||
// 👥 用户管理配置
|
||||
userManagement: {
|
||||
enabled: process.env.USER_MANAGEMENT_ENABLED === 'true',
|
||||
defaultUserRole: process.env.DEFAULT_USER_ROLE || 'user',
|
||||
userSessionTimeout: parseInt(process.env.USER_SESSION_TIMEOUT) || 86400000, // 24小时
|
||||
maxApiKeysPerUser: parseInt(process.env.MAX_API_KEYS_PER_USER) || 1,
|
||||
allowUserDeleteApiKeys: process.env.ALLOW_USER_DELETE_API_KEYS === 'true' // 默认不允许用户删除自己的API Keys
|
||||
},
|
||||
|
||||
// 📢 Webhook通知配置
|
||||
@@ -141,6 +203,23 @@ const config = {
|
||||
development: {
|
||||
debug: process.env.DEBUG === 'true',
|
||||
hotReload: process.env.HOT_RELOAD === 'true'
|
||||
},
|
||||
|
||||
// 💰 账户余额相关配置
|
||||
accountBalance: {
|
||||
// 是否允许执行自定义余额脚本(安全开关)
|
||||
// 说明:脚本能力可发起任意 HTTP 请求并在服务端执行 extractor 逻辑,建议仅在受控环境开启
|
||||
// 默认保持开启;如需禁用请显式设置:BALANCE_SCRIPT_ENABLED=false
|
||||
enableBalanceScript: process.env.BALANCE_SCRIPT_ENABLED !== 'false'
|
||||
},
|
||||
|
||||
// 📬 用户消息队列配置
|
||||
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算
|
||||
userMessageQueue: {
|
||||
enabled: process.env.USER_MESSAGE_QUEUE_ENABLED === 'true', // 默认关闭
|
||||
delayMs: parseInt(process.env.USER_MESSAGE_QUEUE_DELAY_MS) || 200, // 请求间隔(毫秒)
|
||||
timeoutMs: parseInt(process.env.USER_MESSAGE_QUEUE_TIMEOUT_MS) || 5000, // 队列等待超时(毫秒),锁持有时间短,无需长等待
|
||||
lockTtlMs: parseInt(process.env.USER_MESSAGE_QUEUE_LOCK_TTL_MS) || 5000 // 锁TTL(毫秒),5秒足以覆盖请求发送
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
17
config/pricingSource.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const repository =
|
||||
process.env.PRICE_MIRROR_REPO || process.env.GITHUB_REPOSITORY || 'Wei-Shaw/claude-relay-service'
|
||||
const branch = process.env.PRICE_MIRROR_BRANCH || 'price-mirror'
|
||||
const pricingFileName = process.env.PRICE_MIRROR_FILENAME || 'model_prices_and_context_window.json'
|
||||
const hashFileName = process.env.PRICE_MIRROR_HASH_FILENAME || 'model_prices_and_context_window.sha256'
|
||||
|
||||
const baseUrl = process.env.PRICE_MIRROR_BASE_URL
|
||||
? process.env.PRICE_MIRROR_BASE_URL.replace(/\/$/, '')
|
||||
: `https://raw.githubusercontent.com/${repository}/${branch}`
|
||||
|
||||
module.exports = {
|
||||
pricingFileName,
|
||||
hashFileName,
|
||||
pricingUrl:
|
||||
process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`,
|
||||
hashUrl: process.env.PRICE_MIRROR_HASH_URL || `${baseUrl}/${hashFileName}`
|
||||
}
|
||||
@@ -21,6 +21,9 @@ services:
|
||||
- PORT=3000
|
||||
- HOST=0.0.0.0
|
||||
|
||||
# 🔧 请求体大小配置
|
||||
- REQUEST_MAX_SIZE_MB=60
|
||||
|
||||
# 🔐 安全配置(必填)
|
||||
- JWT_SECRET=${JWT_SECRET} # 必填:至少32字符的随机字符串
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # 必填:32字符的加密密钥
|
||||
@@ -46,6 +49,10 @@ services:
|
||||
# 🌐 代理配置
|
||||
- DEFAULT_PROXY_TIMEOUT=${DEFAULT_PROXY_TIMEOUT:-60000}
|
||||
- MAX_PROXY_RETRIES=${MAX_PROXY_RETRIES:-3}
|
||||
- PROXY_USE_IPV4=${PROXY_USE_IPV4:-true}
|
||||
- PROXY_KEEP_ALIVE=${PROXY_KEEP_ALIVE:-}
|
||||
- PROXY_MAX_SOCKETS=${PROXY_MAX_SOCKETS:-}
|
||||
- PROXY_MAX_FREE_SOCKETS=${PROXY_MAX_FREE_SOCKETS:-}
|
||||
|
||||
# 📈 使用限制
|
||||
- DEFAULT_TOKEN_LIMIT=${DEFAULT_TOKEN_LIMIT:-1000000}
|
||||
@@ -162,4 +169,4 @@ volumes:
|
||||
|
||||
networks:
|
||||
claude-relay-network:
|
||||
driver: bridge
|
||||
driver: bridge
|
||||
|
||||
240
docs/claude-code-gemini3-guide/README.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# Claude Code 调用 Gemini 3 模型指南
|
||||
|
||||
本文档介绍如何通过 **claude-code-router (CCR)** 在 Claude Code 中调用 Gemini 3 模型,其他模型也可以参照此教程尝试。
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
通过 CCR 转换格式,你可以让 Claude Code 客户端无缝使用 Gemini 3 模型。
|
||||
|
||||
### 工作原理
|
||||
|
||||
```
|
||||
Claude Code → CCR (模型路由) → CRS (账户调度) → Gemini API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第一步:安装 claude-code-router
|
||||
|
||||
安装 CCR:
|
||||
|
||||
> **安装位置建议**:
|
||||
> - 如果只是本地使用,可以只安装到使用 Claude Code 的电脑上
|
||||
> - 如果需要 CRS 项目接入 CCR,建议安装在与 CRS 同一台服务器上
|
||||
|
||||
```bash
|
||||
npm install -g @musistudio/claude-code-router
|
||||
```
|
||||
|
||||
验证安装:
|
||||
|
||||
```bash
|
||||
ccr -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第二步:配置 CCR
|
||||
|
||||
创建或编辑 CCR 配置文件(通常位于 `~/.claude-code-router/config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"APIKEY": "sk-c0e7fed7b-这里随便你自定义",
|
||||
"LOG": true,
|
||||
"HOST": "127.0.0.1",
|
||||
"API_TIMEOUT_MS": 600000,
|
||||
"NON_INTERACTIVE_MODE": false,
|
||||
"Providers": [
|
||||
{
|
||||
"name": "gemini",
|
||||
"api_base_url": "http://127.0.0.1:3000/gemini/v1beta/models/",
|
||||
"api_key": "cr_xxxxxxxxxxxxxxxxxxxxx",
|
||||
"models": ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-pro-preview"],
|
||||
"transformer": {
|
||||
"use": ["gemini"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"Router": {
|
||||
"default": "gemini",
|
||||
"background": "gemini,gemini-3-pro-preview",
|
||||
"think": "gemini,gemini-3-pro-preview",
|
||||
"longContext": "gemini,gemini-3-pro-preview",
|
||||
"longContextThreshold": 60000,
|
||||
"webSearch": "gemini,gemini-2.5-flash"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `APIKEY` | CCR 自定义的 API Key,Claude Code 将使用这个 Key 访问 CCR |
|
||||
| `api_base_url` | CRS 服务的 Gemini API 地址 |
|
||||
| `api_key` | CRS 后台创建的 API Key(cr_ 开头),用于调度 OAuth、Gemini-API 账号 |
|
||||
|
||||
---
|
||||
|
||||
## 第三步:在 CRS 中配置 Gemini 账号
|
||||
|
||||
确保你的 CRS 服务已添加 Gemini 账号:
|
||||
|
||||
1. 登录 CRS 管理界面
|
||||
2. 进入「Gemini 账户」页面
|
||||
3. 添加 Gemini OAuth 账号或 API Key 账号
|
||||
4. 确保账号状态为「活跃」
|
||||
|
||||
---
|
||||
|
||||
## 第四步:启动 CCR 服务
|
||||
|
||||
保存配置后,启动 CCR 服务:
|
||||
|
||||
```bash
|
||||
ccr start
|
||||
```
|
||||
|
||||
查看服务状态:
|
||||
|
||||
```bash
|
||||
ccr status
|
||||
```
|
||||
|
||||
输出示例:
|
||||
|
||||
```
|
||||
API Endpoint: http://127.0.0.1:3456
|
||||
```
|
||||
|
||||
**重要**:每次修改配置后,需要重启 CCR 服务才能生效:
|
||||
|
||||
```bash
|
||||
ccr restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第五步:配置 Claude Code
|
||||
|
||||
现在需要让 Claude Code 连接到 CCR 服务。有两种方式:
|
||||
|
||||
### 方式一:本地直接使用
|
||||
|
||||
设置环境变量让 Claude Code 直接连接 CCR:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3456/"
|
||||
export ANTHROPIC_AUTH_TOKEN="sk-c0e7fed7b-你的自定义Key"
|
||||
```
|
||||
|
||||
然后启动 Claude Code:
|
||||
|
||||
```bash
|
||||
claude
|
||||
```
|
||||
|
||||
### 方式二:通过 CRS 统一管理(推荐)
|
||||
|
||||
如果你希望通过 CRS 统一管理所有用户的访问,可以在 CRS 中添加 Claude Console 类型账号来代理 CCR。
|
||||
|
||||
#### 1. 在 CRS 添加 Claude Console 账号
|
||||
|
||||
登录 CRS 管理界面,添加一个 **Claude Console** 类型的账号:
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| 账户名称 | CCR-Gemini3(或自定义名称)|
|
||||
| 账户类型 | Claude Console |
|
||||
| API 地址 | `http://127.0.0.1:3456`(CCR 服务地址)|
|
||||
| API Key | `sk-c0e7fed7b-你的自定义Key`(CCR 配置中的 APIKEY)|
|
||||
|
||||
> **注意**:如果 CCR 运行在其他服务器上,请将 `127.0.0.1` 替换为实际的服务器地址,配置文件中需要修改HOST参数为```0.0.0.0```。
|
||||
|
||||
#### 2. 配置模型映射
|
||||
|
||||
在 CRS 中配置模型映射,将 Claude 模型名映射到 Gemini 模型:
|
||||
|
||||
| Claude 模型 | 映射到 Gemini 模型 |
|
||||
|-------------|-------------------|
|
||||
| `claude-opus-4-1-20250805` | `gemini-3-pro-preview` |
|
||||
| `claude-sonnet-4-5-20250929` | `gemini-3-pro-preview` |
|
||||
| `claude-haiku-4-5-20251001` | `gemini-2.5-flash` |
|
||||
|
||||
**配置界面示例:**
|
||||
|
||||

|
||||
|
||||
> **说明**:
|
||||
> - Opus 和 Sonnet 映射到性能更强的 `gemini-3-pro-preview`
|
||||
> - Haiku 映射到响应更快的 `gemini-2.5-flash`
|
||||
|
||||
#### 3. 用户使用方式
|
||||
|
||||
用户现在可以通过 CRS 统一入口使用 Claude Code:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://你的CRS服务器:3000/api/"
|
||||
export ANTHROPIC_AUTH_TOKEN="cr_用户的APIKey"
|
||||
```
|
||||
|
||||
Claude Code 会自动将请求路由到 CCR,再由 CCR 转发到 Gemini API。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: CCR 配置修改后没有生效?
|
||||
|
||||
A: 配置修改后必须重启 CCR 服务:
|
||||
|
||||
```bash
|
||||
ccr restart
|
||||
```
|
||||
|
||||
### Q: 连接超时怎么办?
|
||||
|
||||
A: 检查以下几点:
|
||||
1. CRS 服务是否正常运行
|
||||
2. CCR 配置中的 `api_base_url` 是否正确
|
||||
3. 防火墙是否允许相应端口
|
||||
4. 尝试增加 `API_TIMEOUT_MS` 的值
|
||||
|
||||
### Q: 模型映射不生效?
|
||||
|
||||
A: 确保:
|
||||
1. CRS 中已正确配置 Claude Console 账号
|
||||
2. 模型映射配置已保存
|
||||
3. 重启 CRS 服务使配置生效
|
||||
|
||||
### Q: 如何测试连接?
|
||||
|
||||
A: 使用 curl 测试 CCR 服务:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3456/api/v1/messages \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: sk-c0e7fed7b-你的自定义Key" \
|
||||
-d '{
|
||||
"model": "claude-sonnet-4-5-20250929",
|
||||
"max_tokens": 100,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **生产环境**:将 CCR 部署在与 CRS 相同的服务器上,减少网络延迟
|
||||
2. **API Key 管理**:为每个用户创建独立的 CRS API Key,便于使用统计
|
||||
3. **超时配置**:对于长时间运行的任务,适当增加 `API_TIMEOUT_MS`
|
||||
|
||||
---
|
||||
|
||||
## 相关资源
|
||||
|
||||
- [CCR 官方文档](https://github.com/musistudio/claude-code-router)
|
||||
BIN
docs/claude-code-gemini3-guide/model-mapping.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 553 KiB |
|
Before Width: | Height: | Size: 562 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 641 KiB |
|
Before Width: | Height: | Size: 346 KiB |
@@ -1,47 +0,0 @@
|
||||
# Claude Relay Service 界面预览
|
||||
|
||||
<div align="center">
|
||||
|
||||
**🎨 Web管理界面截图展示**
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📊 管理面板概览
|
||||
|
||||
|
||||
### 仪表板
|
||||

|
||||
|
||||
*实时显示API调用次数、Token使用量、成本统计等关键指标*
|
||||
|
||||
---
|
||||
|
||||
## 🔑 API密钥管理
|
||||
|
||||
### API密钥列表
|
||||

|
||||
|
||||
*查看和管理所有创建的API密钥,包括使用量统计和状态信息*
|
||||
|
||||
---
|
||||
|
||||
## 👤 Claude账户管理
|
||||
|
||||
### 账户列表
|
||||

|
||||
|
||||
*管理多个Claude账户,查看账户状态和使用情况*
|
||||
|
||||
### 添加新账户
|
||||

|
||||
|
||||
*通过OAuth授权添加新的Claude账户*
|
||||
|
||||
### 使用教程
|
||||

|
||||
|
||||
*windows、macos、linux、wsl不同环境的claude code安装教程*
|
||||
|
||||
---
|
||||
BIN
docs/sponsoring/alipay.jpg
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
docs/sponsoring/wechat.jpg
Normal file
|
After Width: | Height: | Size: 127 KiB |
2023
package-lock.json
generated
@@ -63,13 +63,14 @@
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"inquirer": "^8.2.6",
|
||||
"ioredis": "^5.3.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ldapjs": "^3.0.7",
|
||||
"morgan": "^1.10.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^7.0.6",
|
||||
"ora": "^5.4.1",
|
||||
"rate-limiter-flexible": "^5.0.5",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"string-similarity": "^4.0.4",
|
||||
"table": "^6.8.1",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0",
|
||||
@@ -83,6 +84,7 @@
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
6428
pnpm-lock.yaml
generated
Normal file
@@ -1,11 +1,11 @@
|
||||
# Model Pricing Data
|
||||
|
||||
This directory contains a local copy of the LiteLLM model pricing data as a fallback mechanism.
|
||||
This directory contains a local copy of the mirrored model pricing data as a fallback mechanism.
|
||||
|
||||
## Source
|
||||
The original file is maintained by the LiteLLM project:
|
||||
- Repository: https://github.com/BerriAI/litellm
|
||||
- File: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
|
||||
The original file is maintained by the LiteLLM project and mirrored into the `price-mirror` branch of this repository via GitHub Actions:
|
||||
- Mirror branch (configurable via `PRICE_MIRROR_REPO`): https://raw.githubusercontent.com/<your-repo>/price-mirror/model_prices_and_context_window.json
|
||||
- Upstream source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
|
||||
|
||||
## Purpose
|
||||
This local copy serves as a fallback when the remote file cannot be downloaded due to:
|
||||
@@ -22,7 +22,7 @@ The pricingService will:
|
||||
3. Log a warning when using the fallback file
|
||||
|
||||
## Manual Update
|
||||
To manually update this file with the latest pricing data:
|
||||
To manually update this file with the latest pricing data (if automation is unavailable):
|
||||
```bash
|
||||
curl -s https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json -o model_prices_and_context_window.json
|
||||
```
|
||||
@@ -34,4 +34,4 @@ The file contains JSON data with model pricing information including:
|
||||
- Context window sizes
|
||||
- Model capabilities
|
||||
|
||||
Last updated: 2025-08-10
|
||||
Last updated: 2025-08-10
|
||||
|
||||
@@ -86,6 +86,33 @@ function decryptGeminiData(encryptedData) {
|
||||
}
|
||||
}
|
||||
|
||||
// API Key 哈希函数(与apiKeyService保持一致)
|
||||
function hashApiKey(apiKey) {
|
||||
if (!apiKey || !config.security.encryptionKey) {
|
||||
return apiKey
|
||||
}
|
||||
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(apiKey + config.security.encryptionKey)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
// 检查是否为明文API Key(通过格式判断,不依赖前缀)
|
||||
function isPlaintextApiKey(apiKey) {
|
||||
if (!apiKey || typeof apiKey !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
// SHA256哈希值固定为64个十六进制字符,如果是哈希值则返回false
|
||||
if (apiKey.length === 64 && /^[a-f0-9]+$/i.test(apiKey)) {
|
||||
return false // 已经是哈希值
|
||||
}
|
||||
|
||||
// 其他情况都认为是明文API Key(包括sk-ant-、cr_、自定义前缀等)
|
||||
return true
|
||||
}
|
||||
|
||||
// 数据加密函数(用于导入)
|
||||
function encryptClaudeData(data) {
|
||||
if (!data || !config.security.encryptionKey) {
|
||||
@@ -651,6 +678,13 @@ Important Notes:
|
||||
- If importing decrypted data, it will be re-encrypted automatically
|
||||
- If importing encrypted data, it will be stored as-is
|
||||
- Sanitized exports cannot be properly imported (missing sensitive data)
|
||||
- Automatic handling of plaintext API Keys
|
||||
* Uses your configured API_KEY_PREFIX from config (sk-, cr_, etc.)
|
||||
* Automatically detects plaintext vs hashed API Keys by format
|
||||
* Plaintext API Keys are automatically hashed during import
|
||||
* Hash mappings are created correctly for plaintext keys
|
||||
* Supports custom prefixes and legacy format detection
|
||||
* No manual conversion needed - just import your backup file
|
||||
|
||||
Examples:
|
||||
# Export all data with decryption (for migration)
|
||||
@@ -659,7 +693,7 @@ Examples:
|
||||
# Export without decrypting (for backup)
|
||||
node scripts/data-transfer-enhanced.js export --decrypt=false
|
||||
|
||||
# Import data (auto-handles encryption)
|
||||
# Import data (auto-handles encryption and plaintext API keys)
|
||||
node scripts/data-transfer-enhanced.js import --input=backup.json
|
||||
|
||||
# Import with force overwrite
|
||||
@@ -773,6 +807,26 @@ async function importData() {
|
||||
const apiKeyData = { ...apiKey }
|
||||
delete apiKeyData.usageStats
|
||||
|
||||
// 检查并处理API Key哈希
|
||||
let plainTextApiKey = null
|
||||
let hashedApiKey = null
|
||||
|
||||
if (apiKeyData.apiKey && isPlaintextApiKey(apiKeyData.apiKey)) {
|
||||
// 如果是明文API Key,保存明文并计算哈希
|
||||
plainTextApiKey = apiKeyData.apiKey
|
||||
hashedApiKey = hashApiKey(plainTextApiKey)
|
||||
logger.info(`🔐 Detected plaintext API Key for: ${apiKey.name} (${apiKey.id})`)
|
||||
} else if (apiKeyData.apiKey) {
|
||||
// 如果已经是哈希值,直接使用
|
||||
hashedApiKey = apiKeyData.apiKey
|
||||
logger.info(`🔍 Using existing hashed API Key for: ${apiKey.name} (${apiKey.id})`)
|
||||
}
|
||||
|
||||
// API Key字段始终存储哈希值
|
||||
if (hashedApiKey) {
|
||||
apiKeyData.apiKey = hashedApiKey
|
||||
}
|
||||
|
||||
// 使用 hset 存储到哈希表
|
||||
const pipeline = redis.client.pipeline()
|
||||
for (const [field, value] of Object.entries(apiKeyData)) {
|
||||
@@ -780,9 +834,12 @@ async function importData() {
|
||||
}
|
||||
await pipeline.exec()
|
||||
|
||||
// 更新哈希映射
|
||||
if (apiKey.apiKey && !importDataObj.metadata.sanitized) {
|
||||
await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id)
|
||||
// 更新哈希映射:hash_map的key必须是哈希值
|
||||
if (!importDataObj.metadata.sanitized && hashedApiKey) {
|
||||
await redis.client.hset('apikey:hash_map', hashedApiKey, apiKey.id)
|
||||
logger.info(
|
||||
`📝 Updated hash mapping: ${hashedApiKey.substring(0, 8)}... -> ${apiKey.id}`
|
||||
)
|
||||
}
|
||||
|
||||
// 导入使用统计数据
|
||||
|
||||
@@ -84,16 +84,214 @@ function sanitizeData(data, type) {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// CSV 字段映射配置
|
||||
const CSV_FIELD_MAPPING = {
|
||||
// 基本信息
|
||||
id: 'ID',
|
||||
name: '名称',
|
||||
description: '描述',
|
||||
isActive: '状态',
|
||||
createdAt: '创建时间',
|
||||
lastUsedAt: '最后使用时间',
|
||||
createdBy: '创建者',
|
||||
|
||||
// API Key 信息
|
||||
apiKey: 'API密钥',
|
||||
tokenLimit: '令牌限制',
|
||||
|
||||
// 过期设置
|
||||
expirationMode: '过期模式',
|
||||
expiresAt: '过期时间',
|
||||
activationDays: '激活天数',
|
||||
activationUnit: '激活单位',
|
||||
isActivated: '已激活',
|
||||
activatedAt: '激活时间',
|
||||
|
||||
// 权限设置
|
||||
permissions: '服务权限',
|
||||
|
||||
// 限制设置
|
||||
rateLimitWindow: '速率窗口(分钟)',
|
||||
rateLimitRequests: '请求次数限制',
|
||||
rateLimitCost: '费用限制(美元)',
|
||||
concurrencyLimit: '并发限制',
|
||||
dailyCostLimit: '日费用限制(美元)',
|
||||
totalCostLimit: '总费用限制(美元)',
|
||||
weeklyOpusCostLimit: '周Opus费用限制(美元)',
|
||||
|
||||
// 账户绑定
|
||||
claudeAccountId: 'Claude专属账户',
|
||||
claudeConsoleAccountId: 'Claude控制台账户',
|
||||
geminiAccountId: 'Gemini专属账户',
|
||||
openaiAccountId: 'OpenAI专属账户',
|
||||
azureOpenaiAccountId: 'Azure OpenAI专属账户',
|
||||
bedrockAccountId: 'Bedrock专属账户',
|
||||
|
||||
// 限制配置
|
||||
enableModelRestriction: '启用模型限制',
|
||||
restrictedModels: '限制的模型',
|
||||
enableClientRestriction: '启用客户端限制',
|
||||
allowedClients: '允许的客户端',
|
||||
|
||||
// 标签和用户
|
||||
tags: '标签',
|
||||
userId: '用户ID',
|
||||
userUsername: '用户名',
|
||||
|
||||
// 其他信息
|
||||
icon: '图标'
|
||||
}
|
||||
|
||||
// 数据格式化函数
|
||||
function formatCSVValue(key, value, shouldSanitize = false) {
|
||||
if (!value || value === '' || value === 'null' || value === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'apiKey':
|
||||
if (shouldSanitize && value.length > 10) {
|
||||
return `${value.substring(0, 10)}...[已脱敏]`
|
||||
}
|
||||
return value
|
||||
|
||||
case 'isActive':
|
||||
case 'isActivated':
|
||||
case 'enableModelRestriction':
|
||||
case 'enableClientRestriction':
|
||||
return value === 'true' ? '是' : '否'
|
||||
|
||||
case 'expirationMode':
|
||||
return value === 'activation' ? '首次使用后激活' : value === 'fixed' ? '固定时间' : value
|
||||
|
||||
case 'activationUnit':
|
||||
return value === 'hours' ? '小时' : value === 'days' ? '天' : value
|
||||
|
||||
case 'permissions':
|
||||
switch (value) {
|
||||
case 'all':
|
||||
return '全部服务'
|
||||
case 'claude':
|
||||
return '仅Claude'
|
||||
case 'gemini':
|
||||
return '仅Gemini'
|
||||
case 'openai':
|
||||
return '仅OpenAI'
|
||||
default:
|
||||
return value
|
||||
}
|
||||
|
||||
case 'restrictedModels':
|
||||
case 'allowedClients':
|
||||
case 'tags':
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed.join('; ') : value
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
|
||||
case 'createdAt':
|
||||
case 'lastUsedAt':
|
||||
case 'activatedAt':
|
||||
case 'expiresAt':
|
||||
if (value) {
|
||||
try {
|
||||
return new Date(value).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ''
|
||||
|
||||
case 'rateLimitWindow':
|
||||
case 'rateLimitRequests':
|
||||
case 'concurrencyLimit':
|
||||
case 'activationDays':
|
||||
case 'tokenLimit':
|
||||
return value === '0' || value === 0 ? '无限制' : value
|
||||
|
||||
case 'rateLimitCost':
|
||||
case 'dailyCostLimit':
|
||||
case 'totalCostLimit':
|
||||
case 'weeklyOpusCostLimit':
|
||||
return value === '0' || value === 0 ? '无限制' : `$${value}`
|
||||
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// 转义 CSV 字段
|
||||
function escapeCSVField(field) {
|
||||
if (field === null || field === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const str = String(field)
|
||||
|
||||
// 如果包含逗号、引号或换行符,需要用引号包围
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
// 先转义引号(双引号变成两个双引号)
|
||||
const escaped = str.replace(/"/g, '""')
|
||||
return `"${escaped}"`
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
// 转换数据为 CSV 格式
|
||||
function convertToCSV(exportDataObj, shouldSanitize = false) {
|
||||
if (!exportDataObj.data.apiKeys || exportDataObj.data.apiKeys.length === 0) {
|
||||
throw new Error('CSV format only supports API Keys export. Please use --types=apikeys')
|
||||
}
|
||||
|
||||
const { apiKeys } = exportDataObj.data
|
||||
const fields = Object.keys(CSV_FIELD_MAPPING)
|
||||
const headers = Object.values(CSV_FIELD_MAPPING)
|
||||
|
||||
// 生成标题行
|
||||
const csvLines = [headers.map(escapeCSVField).join(',')]
|
||||
|
||||
// 生成数据行
|
||||
for (const apiKey of apiKeys) {
|
||||
const row = fields.map((field) => {
|
||||
const value = formatCSVValue(field, apiKey[field], shouldSanitize)
|
||||
return escapeCSVField(value)
|
||||
})
|
||||
csvLines.push(row.join(','))
|
||||
}
|
||||
|
||||
return csvLines.join('\n')
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
async function exportData() {
|
||||
try {
|
||||
const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json`
|
||||
const format = params.format || 'json'
|
||||
const fileExtension = format === 'csv' ? '.csv' : '.json'
|
||||
const defaultFileName = `backup-${new Date().toISOString().split('T')[0]}${fileExtension}`
|
||||
const outputFile = params.output || defaultFileName
|
||||
const types = params.types ? params.types.split(',') : ['all']
|
||||
const shouldSanitize = params.sanitize === true
|
||||
|
||||
// CSV 格式验证
|
||||
if (format === 'csv' && !types.includes('apikeys') && !types.includes('all')) {
|
||||
logger.error('❌ CSV format only supports API Keys export. Please use --types=apikeys')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
logger.info('🔄 Starting data export...')
|
||||
logger.info(`📁 Output file: ${outputFile}`)
|
||||
logger.info(`📋 Data types: ${types.join(', ')}`)
|
||||
logger.info(`📄 Output format: ${format.toUpperCase()}`)
|
||||
logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`)
|
||||
|
||||
// 连接 Redis
|
||||
@@ -203,8 +401,16 @@ async function exportData() {
|
||||
logger.success(`✅ Exported ${admins.length} admins`)
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
await fs.writeFile(outputFile, JSON.stringify(exportData, null, 2))
|
||||
// 根据格式写入文件
|
||||
let fileContent
|
||||
if (format === 'csv') {
|
||||
fileContent = convertToCSV(exportDataObj, shouldSanitize)
|
||||
// 添加 UTF-8 BOM 以便 Excel 正确识别中文
|
||||
fileContent = `\ufeff${fileContent}`
|
||||
await fs.writeFile(outputFile, fileContent, 'utf8')
|
||||
} else {
|
||||
await fs.writeFile(outputFile, JSON.stringify(exportDataObj, null, 2))
|
||||
}
|
||||
|
||||
// 显示导出摘要
|
||||
console.log(`\n${'='.repeat(60)}`)
|
||||
@@ -471,8 +677,9 @@ Commands:
|
||||
import Import data from a JSON file to Redis
|
||||
|
||||
Export Options:
|
||||
--output=FILE Output filename (default: backup-YYYY-MM-DD.json)
|
||||
--output=FILE Output filename (default: backup-YYYY-MM-DD.json/.csv)
|
||||
--types=TYPE,... Data types to export: apikeys,accounts,admins,all (default: all)
|
||||
--format=FORMAT Output format: json,csv (default: json)
|
||||
--sanitize Remove sensitive data from export
|
||||
|
||||
Import Options:
|
||||
@@ -492,6 +699,12 @@ Examples:
|
||||
|
||||
# Export specific data types
|
||||
node scripts/data-transfer.js export --types=apikeys,accounts --output=prod-data.json
|
||||
|
||||
# Export API keys to CSV format
|
||||
node scripts/data-transfer.js export --types=apikeys --format=csv --sanitize
|
||||
|
||||
# Export to CSV with custom filename
|
||||
node scripts/data-transfer.js export --types=apikeys --format=csv --output=api-keys.csv
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ class ServiceManager {
|
||||
|
||||
restart(daemon = false) {
|
||||
console.log('🔄 重启服务...')
|
||||
|
||||
this.stop()
|
||||
// 等待停止完成
|
||||
setTimeout(() => {
|
||||
this.start(daemon)
|
||||
|
||||
@@ -288,12 +288,12 @@ check_redis() {
|
||||
# 测试Redis连接
|
||||
print_info "测试 Redis 连接..."
|
||||
if command_exists redis-cli; then
|
||||
local redis_test_cmd="redis-cli -h $REDIS_HOST -p $REDIS_PORT"
|
||||
local redis_args=(-h "$REDIS_HOST" -p "$REDIS_PORT")
|
||||
if [ -n "$REDIS_PASSWORD" ]; then
|
||||
redis_test_cmd="$redis_test_cmd -a '$REDIS_PASSWORD'"
|
||||
redis_args+=(-a "$REDIS_PASSWORD")
|
||||
fi
|
||||
|
||||
if $redis_test_cmd ping 2>/dev/null | grep -q "PONG"; then
|
||||
|
||||
if redis-cli "${redis_args[@]}" ping 2>/dev/null | grep -q "PONG"; then
|
||||
print_success "Redis 连接成功"
|
||||
return 0
|
||||
else
|
||||
@@ -363,6 +363,19 @@ check_installation() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# 将安装路径持久化到本地(用于后续 update/status 自动识别自定义安装目录)
|
||||
persist_install_path() {
|
||||
local conf_dir="$HOME/.config/crs"
|
||||
local conf_file="$conf_dir/install.conf"
|
||||
|
||||
mkdir -p "$conf_dir" 2>/dev/null || true
|
||||
if ! { echo "INSTALL_DIR=\"$INSTALL_DIR\"" > "$conf_file" && echo "APP_DIR=\"$APP_DIR\"" >> "$conf_file"; }; then
|
||||
print_warning "无法写入 $conf_file,后续 update 可能找不到安装目录"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# 安装服务
|
||||
install_service() {
|
||||
print_info "开始安装 Claude Relay Service..."
|
||||
@@ -739,6 +752,9 @@ update_service() {
|
||||
|
||||
# 更新软链接到最新版本
|
||||
create_symlink
|
||||
|
||||
# 持久化安装路径,便于后续 update/status 自动识别
|
||||
persist_install_path || true
|
||||
|
||||
# 如果之前在运行,则重新启动服务
|
||||
if [ "$was_running" = true ]; then
|
||||
@@ -937,15 +953,61 @@ stop_service() {
|
||||
# 强制停止所有相关进程
|
||||
pkill -f "node.*src/app.js" 2>/dev/null || true
|
||||
|
||||
# 等待进程完全退出(最多等待10秒)
|
||||
local wait_count=0
|
||||
while pgrep -f "node.*src/app.js" > /dev/null; do
|
||||
if [ $wait_count -ge 10 ]; then
|
||||
print_warning "进程停止超时,尝试强制终止..."
|
||||
pkill -9 -f "node.*src/app.js" 2>/dev/null || true
|
||||
sleep 1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
wait_count=$((wait_count + 1))
|
||||
done
|
||||
|
||||
# 最终确认进程已停止
|
||||
if pgrep -f "node.*src/app.js" > /dev/null; then
|
||||
print_error "无法完全停止服务进程"
|
||||
return 1
|
||||
fi
|
||||
|
||||
print_success "服务已停止"
|
||||
}
|
||||
|
||||
# 重启服务
|
||||
restart_service() {
|
||||
print_info "重启服务..."
|
||||
stop_service
|
||||
sleep 2
|
||||
start_service
|
||||
|
||||
# 停止服务并检查结果
|
||||
if ! stop_service; then
|
||||
print_error "停止服务失败"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 短暂等待,确保端口释放
|
||||
sleep 1
|
||||
|
||||
# 启动服务,如果失败则重试
|
||||
local retry_count=0
|
||||
while [ $retry_count -lt 3 ]; do
|
||||
# 清除可能的僵尸进程检测
|
||||
if ! pgrep -f "node.*src/app.js" > /dev/null; then
|
||||
# 进程确实已停止,可以启动
|
||||
if start_service; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
retry_count=$((retry_count + 1))
|
||||
if [ $retry_count -lt 3 ]; then
|
||||
print_warning "启动失败,等待2秒后重试(第 $retry_count 次)..."
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
|
||||
print_error "重启服务失败"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 更新模型价格
|
||||
@@ -1585,30 +1647,87 @@ create_symlink() {
|
||||
|
||||
# 加载已安装的配置
|
||||
load_config() {
|
||||
# 尝试找到安装目录
|
||||
if [ -z "$INSTALL_DIR" ]; then
|
||||
if [ -d "$DEFAULT_INSTALL_DIR" ]; then
|
||||
INSTALL_DIR="$DEFAULT_INSTALL_DIR"
|
||||
# 1) 优先使用外部显式提供的 APP_DIR
|
||||
if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/package.json" ]; then
|
||||
:
|
||||
else
|
||||
# 2) 若提供了 INSTALL_DIR,则据此推导 APP_DIR
|
||||
if [ -n "$INSTALL_DIR" ]; then
|
||||
if [ -d "$INSTALL_DIR/app" ] && [ -f "$INSTALL_DIR/app/package.json" ]; then
|
||||
APP_DIR="$INSTALL_DIR/app"
|
||||
elif [ -f "$INSTALL_DIR/package.json" ]; then
|
||||
APP_DIR="$INSTALL_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3) 尝试从持久化配置读取安装位置
|
||||
if [ -z "$APP_DIR" ]; then
|
||||
local conf_file="$HOME/.config/crs/install.conf"
|
||||
if [ -f "$conf_file" ]; then
|
||||
local conf_install_dir
|
||||
local conf_app_dir
|
||||
conf_install_dir=$(awk -F= '/^INSTALL_DIR=/{sub(/^"/,"",$2); sub(/"$/, "", $2); print $2}' "$conf_file" 2>/dev/null)
|
||||
conf_app_dir=$(awk -F= '/^APP_DIR=/{sub(/^"/,"",$2); sub(/"$/, "", $2); print $2}' "$conf_file" 2>/dev/null)
|
||||
|
||||
if [ -n "$conf_app_dir" ] && [ -f "$conf_app_dir/package.json" ]; then
|
||||
APP_DIR="$conf_app_dir"
|
||||
[ -z "$INSTALL_DIR" ] && INSTALL_DIR="$(cd "$conf_app_dir/.." 2>/dev/null && pwd)"
|
||||
elif [ -n "$conf_install_dir" ]; then
|
||||
if [ -d "$conf_install_dir/app" ] && [ -f "$conf_install_dir/app/package.json" ]; then
|
||||
INSTALL_DIR="$conf_install_dir"
|
||||
APP_DIR="$conf_install_dir/app"
|
||||
elif [ -f "$conf_install_dir/package.json" ]; then
|
||||
INSTALL_DIR="$conf_install_dir"
|
||||
APP_DIR="$conf_install_dir"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4) 基于脚本自身路径推导(处理从 app/scripts/manage.sh 或软链调用的情形)
|
||||
if [ -z "$APP_DIR" ]; then
|
||||
local script_path=""
|
||||
if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/scripts/manage.sh" ]; then
|
||||
script_path="$APP_DIR/scripts/manage.sh"
|
||||
elif command_exists realpath; then
|
||||
script_path="$(realpath "$0" 2>/dev/null)"
|
||||
elif command_exists readlink && readlink -f "$0" >/dev/null 2>&1; then
|
||||
script_path="$(readlink -f "$0")"
|
||||
else
|
||||
script_path="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
|
||||
fi
|
||||
local script_dir="$(cd "$(dirname "$script_path")" && pwd)"
|
||||
local parent_dir="$(cd "$script_dir/.." && pwd)"
|
||||
if [ -f "$parent_dir/package.json" ]; then
|
||||
APP_DIR="$parent_dir"
|
||||
INSTALL_DIR="$(cd "$parent_dir/.." 2>/dev/null && pwd)"
|
||||
elif [ -f "$parent_dir/app/package.json" ]; then
|
||||
APP_DIR="$parent_dir/app"
|
||||
INSTALL_DIR="$parent_dir"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 5) 退回到默认目录逻辑
|
||||
if [ -z "$INSTALL_DIR" ]; then
|
||||
if [ -d "$DEFAULT_INSTALL_DIR" ]; then
|
||||
INSTALL_DIR="$DEFAULT_INSTALL_DIR"
|
||||
fi
|
||||
fi
|
||||
if [ -n "$INSTALL_DIR" ] && [ -z "$APP_DIR" ]; then
|
||||
if [ -d "$INSTALL_DIR/app" ] && [ -f "$INSTALL_DIR/app/package.json" ]; then
|
||||
APP_DIR="$INSTALL_DIR/app"
|
||||
elif [ -f "$INSTALL_DIR/package.json" ]; then
|
||||
APP_DIR="$INSTALL_DIR"
|
||||
else
|
||||
APP_DIR="$INSTALL_DIR/app"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$INSTALL_DIR" ]; then
|
||||
# 检查是否使用了标准的安装结构(项目在 app 子目录)
|
||||
if [ -d "$INSTALL_DIR/app" ] && [ -f "$INSTALL_DIR/app/package.json" ]; then
|
||||
APP_DIR="$INSTALL_DIR/app"
|
||||
# 检查是否直接克隆了项目(项目在根目录)
|
||||
elif [ -f "$INSTALL_DIR/package.json" ]; then
|
||||
APP_DIR="$INSTALL_DIR"
|
||||
else
|
||||
APP_DIR="$INSTALL_DIR/app"
|
||||
fi
|
||||
|
||||
# 加载.env配置
|
||||
if [ -f "$APP_DIR/.env" ]; then
|
||||
export $(cat "$APP_DIR/.env" | grep -v '^#' | xargs)
|
||||
# 特别加载端口配置
|
||||
APP_PORT=$(grep "^PORT=" "$APP_DIR/.env" 2>/dev/null | cut -d'=' -f2)
|
||||
fi
|
||||
|
||||
# 6) 加载 .env 配置(如存在)
|
||||
if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/.env" ]; then
|
||||
export $(cat "$APP_DIR/.env" | grep -v '^#' | xargs)
|
||||
APP_PORT=$(grep "^PORT=" "$APP_DIR/.env" 2>/dev/null | cut -d'=' -f2)
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1708,4 +1827,4 @@ main() {
|
||||
}
|
||||
|
||||
# 运行主函数
|
||||
main "$@"
|
||||
main "$@"
|
||||
|
||||
340
scripts/test-billing-events.js
Executable file
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 计费事件测试脚本
|
||||
*
|
||||
* 用于测试计费事件的发布和消费功能
|
||||
*
|
||||
* 使用方法:
|
||||
* node scripts/test-billing-events.js [command]
|
||||
*
|
||||
* 命令:
|
||||
* publish - 发布测试事件
|
||||
* consume - 消费事件(测试模式)
|
||||
* info - 查看队列状态
|
||||
* clear - 清空队列(危险操作)
|
||||
*/
|
||||
|
||||
const path = require('path')
|
||||
const Redis = require('ioredis')
|
||||
|
||||
// 加载配置
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') })
|
||||
|
||||
const config = {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT) || 6379,
|
||||
password: process.env.REDIS_PASSWORD || '',
|
||||
db: parseInt(process.env.REDIS_DB) || 0
|
||||
}
|
||||
|
||||
const redis = new Redis(config)
|
||||
const STREAM_KEY = 'billing:events'
|
||||
|
||||
// ========================================
|
||||
// 命令实现
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 发布测试事件
|
||||
*/
|
||||
async function publishTestEvent() {
|
||||
console.log('📤 Publishing test billing event...')
|
||||
|
||||
const testEvent = {
|
||||
eventId: `test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
eventType: 'usage.recorded',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
apiKey: {
|
||||
id: 'test-key-123',
|
||||
name: 'Test API Key',
|
||||
userId: 'test-user-456'
|
||||
},
|
||||
usage: {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
inputTokens: 1500,
|
||||
outputTokens: 800,
|
||||
cacheCreateTokens: 200,
|
||||
cacheReadTokens: 100,
|
||||
ephemeral5mTokens: 150,
|
||||
ephemeral1hTokens: 50,
|
||||
totalTokens: 2600
|
||||
},
|
||||
cost: {
|
||||
total: 0.0156,
|
||||
currency: 'USD',
|
||||
breakdown: {
|
||||
input: 0.0045,
|
||||
output: 0.012,
|
||||
cacheCreate: 0.00075,
|
||||
cacheRead: 0.00003,
|
||||
ephemeral5m: 0.0005625,
|
||||
ephemeral1h: 0.0001875
|
||||
}
|
||||
},
|
||||
account: {
|
||||
id: 'test-account-789',
|
||||
type: 'claude-official'
|
||||
},
|
||||
context: {
|
||||
isLongContext: false,
|
||||
requestTimestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const messageId = await redis.xadd(
|
||||
STREAM_KEY,
|
||||
'MAXLEN',
|
||||
'~',
|
||||
100000,
|
||||
'*',
|
||||
'data',
|
||||
JSON.stringify(testEvent)
|
||||
)
|
||||
|
||||
console.log('✅ Event published successfully!')
|
||||
console.log(` Message ID: ${messageId}`)
|
||||
console.log(` Event ID: ${testEvent.eventId}`)
|
||||
console.log(` Cost: $${testEvent.cost.total}`)
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to publish event:', error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费事件(测试模式,不创建消费者组)
|
||||
*/
|
||||
async function consumeTestEvents() {
|
||||
console.log('📬 Consuming test events...')
|
||||
console.log(' Press Ctrl+C to stop\n')
|
||||
|
||||
let isRunning = true
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n⏹️ Stopping consumer...')
|
||||
isRunning = false
|
||||
})
|
||||
|
||||
let lastId = '0' // 从头开始
|
||||
|
||||
while (isRunning) {
|
||||
try {
|
||||
// 使用 XREAD 而不是 XREADGROUP(测试模式)
|
||||
const messages = await redis.xread('BLOCK', 5000, 'COUNT', 10, 'STREAMS', STREAM_KEY, lastId)
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const [streamKey, entries] = messages[0]
|
||||
console.log(`📬 Received ${entries.length} messages from ${streamKey}\n`)
|
||||
|
||||
for (const [messageId, fields] of entries) {
|
||||
try {
|
||||
const data = {}
|
||||
for (let i = 0; i < fields.length; i += 2) {
|
||||
data[fields[i]] = fields[i + 1]
|
||||
}
|
||||
|
||||
const event = JSON.parse(data.data)
|
||||
|
||||
console.log(`📊 Event: ${event.eventId}`)
|
||||
console.log(` API Key: ${event.apiKey.name} (${event.apiKey.id})`)
|
||||
console.log(` Model: ${event.usage.model}`)
|
||||
console.log(` Tokens: ${event.usage.totalTokens}`)
|
||||
console.log(` Cost: $${event.cost.total.toFixed(6)}`)
|
||||
console.log(` Timestamp: ${event.timestamp}`)
|
||||
console.log('')
|
||||
|
||||
lastId = messageId // 更新位置
|
||||
} catch (parseError) {
|
||||
console.error(`❌ Failed to parse message ${messageId}:`, parseError.message)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isRunning) {
|
||||
console.error('❌ Error consuming messages:', error.message)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('👋 Consumer stopped')
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看队列状态
|
||||
*/
|
||||
async function showQueueInfo() {
|
||||
console.log('📊 Queue Information\n')
|
||||
|
||||
try {
|
||||
// Stream 长度
|
||||
const length = await redis.xlen(STREAM_KEY)
|
||||
console.log(`Stream: ${STREAM_KEY}`)
|
||||
console.log(`Length: ${length} messages\n`)
|
||||
|
||||
if (length === 0) {
|
||||
console.log('ℹ️ Queue is empty')
|
||||
return
|
||||
}
|
||||
|
||||
// Stream 详细信息
|
||||
const info = await redis.xinfo('STREAM', STREAM_KEY)
|
||||
const infoObj = {}
|
||||
for (let i = 0; i < info.length; i += 2) {
|
||||
infoObj[info[i]] = info[i + 1]
|
||||
}
|
||||
|
||||
console.log('Stream Details:')
|
||||
console.log(` First Entry ID: ${infoObj['first-entry'] ? infoObj['first-entry'][0] : 'N/A'}`)
|
||||
console.log(` Last Entry ID: ${infoObj['last-entry'] ? infoObj['last-entry'][0] : 'N/A'}`)
|
||||
console.log(` Consumer Groups: ${infoObj.groups || 0}\n`)
|
||||
|
||||
// 消费者组信息
|
||||
if (infoObj.groups > 0) {
|
||||
console.log('Consumer Groups:')
|
||||
const groups = await redis.xinfo('GROUPS', STREAM_KEY)
|
||||
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const group = groups[i]
|
||||
const groupObj = {}
|
||||
for (let j = 0; j < group.length; j += 2) {
|
||||
groupObj[group[j]] = group[j + 1]
|
||||
}
|
||||
|
||||
console.log(`\n Group: ${groupObj.name}`)
|
||||
console.log(` Consumers: ${groupObj.consumers}`)
|
||||
console.log(` Pending: ${groupObj.pending}`)
|
||||
console.log(` Last Delivered ID: ${groupObj['last-delivered-id']}`)
|
||||
|
||||
// 消费者详情
|
||||
if (groupObj.consumers > 0) {
|
||||
const consumers = await redis.xinfo('CONSUMERS', STREAM_KEY, groupObj.name)
|
||||
console.log(' Consumer Details:')
|
||||
|
||||
for (let k = 0; k < consumers.length; k++) {
|
||||
const consumer = consumers[k]
|
||||
const consumerObj = {}
|
||||
for (let l = 0; l < consumer.length; l += 2) {
|
||||
consumerObj[consumer[l]] = consumer[l + 1]
|
||||
}
|
||||
|
||||
console.log(` - ${consumerObj.name}`)
|
||||
console.log(` Pending: ${consumerObj.pending}`)
|
||||
console.log(` Idle: ${Math.round(consumerObj.idle / 1000)}s`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最新 5 条消息
|
||||
console.log('\n📬 Latest 5 Messages:')
|
||||
const latest = await redis.xrevrange(STREAM_KEY, '+', '-', 'COUNT', 5)
|
||||
|
||||
if (latest.length === 0) {
|
||||
console.log(' No messages')
|
||||
} else {
|
||||
for (const [messageId, fields] of latest) {
|
||||
const data = {}
|
||||
for (let i = 0; i < fields.length; i += 2) {
|
||||
data[fields[i]] = fields[i + 1]
|
||||
}
|
||||
|
||||
try {
|
||||
const event = JSON.parse(data.data)
|
||||
console.log(`\n ${messageId}`)
|
||||
console.log(` Event ID: ${event.eventId}`)
|
||||
console.log(` Model: ${event.usage.model}`)
|
||||
console.log(` Cost: $${event.cost.total.toFixed(6)}`)
|
||||
console.log(` Time: ${event.timestamp}`)
|
||||
} catch (e) {
|
||||
console.log(`\n ${messageId} (Parse Error)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get queue info:', error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空队列(危险操作)
|
||||
*/
|
||||
async function clearQueue() {
|
||||
console.log('⚠️ WARNING: This will delete all messages in the queue!')
|
||||
console.log(` Stream: ${STREAM_KEY}`)
|
||||
|
||||
// 简单的确认机制
|
||||
const readline = require('readline')
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
|
||||
rl.question('Type "yes" to confirm: ', async (answer) => {
|
||||
if (answer.toLowerCase() === 'yes') {
|
||||
try {
|
||||
await redis.del(STREAM_KEY)
|
||||
console.log('✅ Queue cleared successfully')
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to clear queue:', error.message)
|
||||
}
|
||||
} else {
|
||||
console.log('❌ Operation cancelled')
|
||||
}
|
||||
rl.close()
|
||||
redis.quit()
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// CLI 处理
|
||||
// ========================================
|
||||
|
||||
async function main() {
|
||||
const command = process.argv[2] || 'info'
|
||||
|
||||
console.log('🔧 Billing Events Test Tool\n')
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'publish':
|
||||
await publishTestEvent()
|
||||
break
|
||||
|
||||
case 'consume':
|
||||
await consumeTestEvents()
|
||||
break
|
||||
|
||||
case 'info':
|
||||
await showQueueInfo()
|
||||
break
|
||||
|
||||
case 'clear':
|
||||
await clearQueue()
|
||||
return // clearQueue 会自己关闭连接
|
||||
|
||||
default:
|
||||
console.error(`❌ Unknown command: ${command}`)
|
||||
console.log('\nAvailable commands:')
|
||||
console.log(' publish - Publish a test event')
|
||||
console.log(' consume - Consume events (test mode)')
|
||||
console.log(' info - Show queue status')
|
||||
console.log(' clear - Clear the queue (dangerous)')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await redis.quit()
|
||||
} catch (error) {
|
||||
console.error('💥 Fatal error:', error)
|
||||
await redis.quit()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,379 +0,0 @@
|
||||
/**
|
||||
* 多分组功能测试脚本
|
||||
* 测试一个账户可以属于多个分组的功能
|
||||
*/
|
||||
|
||||
require('dotenv').config()
|
||||
const redis = require('../src/models/redis')
|
||||
const accountGroupService = require('../src/services/accountGroupService')
|
||||
const claudeAccountService = require('../src/services/claudeAccountService')
|
||||
|
||||
// 测试配置
|
||||
const TEST_PREFIX = 'multi_group_test_'
|
||||
const CLEANUP_ON_FINISH = true
|
||||
|
||||
// 测试数据存储
|
||||
const testData = {
|
||||
groups: [],
|
||||
accounts: []
|
||||
}
|
||||
|
||||
// 颜色输出
|
||||
const colors = {
|
||||
green: '\x1b[32m',
|
||||
red: '\x1b[31m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
reset: '\x1b[0m'
|
||||
}
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const color =
|
||||
{
|
||||
success: colors.green,
|
||||
error: colors.red,
|
||||
warning: colors.yellow,
|
||||
info: colors.blue
|
||||
}[type] || colors.reset
|
||||
|
||||
console.log(`${color}${message}${colors.reset}`)
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// 清理测试数据
|
||||
async function cleanup() {
|
||||
log('\n🧹 清理测试数据...', 'info')
|
||||
|
||||
// 删除测试账户
|
||||
for (const account of testData.accounts) {
|
||||
try {
|
||||
await claudeAccountService.deleteAccount(account.id)
|
||||
log(`✅ 删除测试账户: ${account.name}`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 删除账户失败: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除测试分组
|
||||
for (const group of testData.groups) {
|
||||
try {
|
||||
// 先移除所有成员
|
||||
const members = await accountGroupService.getGroupMembers(group.id)
|
||||
for (const memberId of members) {
|
||||
await accountGroupService.removeAccountFromGroup(memberId, group.id)
|
||||
}
|
||||
|
||||
await accountGroupService.deleteGroup(group.id)
|
||||
log(`✅ 删除测试分组: ${group.name}`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 删除分组失败: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 测试1: 创建测试数据
|
||||
async function test1_createTestData() {
|
||||
log('\n📝 测试1: 创建测试数据', 'info')
|
||||
|
||||
try {
|
||||
// 创建3个测试分组
|
||||
const group1 = await accountGroupService.createGroup({
|
||||
name: `${TEST_PREFIX}高优先级组`,
|
||||
platform: 'claude',
|
||||
description: '高优先级账户分组'
|
||||
})
|
||||
testData.groups.push(group1)
|
||||
log(`✅ 创建分组1: ${group1.name}`, 'success')
|
||||
|
||||
const group2 = await accountGroupService.createGroup({
|
||||
name: `${TEST_PREFIX}备用组`,
|
||||
platform: 'claude',
|
||||
description: '备用账户分组'
|
||||
})
|
||||
testData.groups.push(group2)
|
||||
log(`✅ 创建分组2: ${group2.name}`, 'success')
|
||||
|
||||
const group3 = await accountGroupService.createGroup({
|
||||
name: `${TEST_PREFIX}专用组`,
|
||||
platform: 'claude',
|
||||
description: '专用账户分组'
|
||||
})
|
||||
testData.groups.push(group3)
|
||||
log(`✅ 创建分组3: ${group3.name}`, 'success')
|
||||
|
||||
// 创建测试账户
|
||||
const account1 = await claudeAccountService.createAccount({
|
||||
name: `${TEST_PREFIX}测试账户1`,
|
||||
email: 'test1@example.com',
|
||||
refreshToken: 'test_refresh_token_1',
|
||||
accountType: 'group'
|
||||
})
|
||||
testData.accounts.push(account1)
|
||||
log(`✅ 创建测试账户1: ${account1.name}`, 'success')
|
||||
|
||||
const account2 = await claudeAccountService.createAccount({
|
||||
name: `${TEST_PREFIX}测试账户2`,
|
||||
email: 'test2@example.com',
|
||||
refreshToken: 'test_refresh_token_2',
|
||||
accountType: 'group'
|
||||
})
|
||||
testData.accounts.push(account2)
|
||||
log(`✅ 创建测试账户2: ${account2.name}`, 'success')
|
||||
|
||||
log(`✅ 测试数据创建完成: 3个分组, 2个账户`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 测试1失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试2: 账户加入多个分组
|
||||
async function test2_addAccountToMultipleGroups() {
|
||||
log('\n📝 测试2: 账户加入多个分组', 'info')
|
||||
|
||||
try {
|
||||
const [group1, group2, group3] = testData.groups
|
||||
const [account1, account2] = testData.accounts
|
||||
|
||||
// 账户1加入分组1和分组2
|
||||
await accountGroupService.addAccountToGroup(account1.id, group1.id, 'claude')
|
||||
log(`✅ 账户1加入分组1: ${group1.name}`, 'success')
|
||||
|
||||
await accountGroupService.addAccountToGroup(account1.id, group2.id, 'claude')
|
||||
log(`✅ 账户1加入分组2: ${group2.name}`, 'success')
|
||||
|
||||
// 账户2加入分组2和分组3
|
||||
await accountGroupService.addAccountToGroup(account2.id, group2.id, 'claude')
|
||||
log(`✅ 账户2加入分组2: ${group2.name}`, 'success')
|
||||
|
||||
await accountGroupService.addAccountToGroup(account2.id, group3.id, 'claude')
|
||||
log(`✅ 账户2加入分组3: ${group3.name}`, 'success')
|
||||
|
||||
log(`✅ 多分组关系建立完成`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 测试2失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试3: 验证多分组关系
|
||||
async function test3_verifyMultiGroupRelationships() {
|
||||
log('\n📝 测试3: 验证多分组关系', 'info')
|
||||
|
||||
try {
|
||||
const [group1, group2, group3] = testData.groups
|
||||
const [account1, account2] = testData.accounts
|
||||
|
||||
// 验证账户1的分组关系
|
||||
const account1Groups = await accountGroupService.getAccountGroup(account1.id)
|
||||
log(`📊 账户1所属分组数量: ${account1Groups.length}`, 'info')
|
||||
|
||||
const account1GroupNames = account1Groups.map((g) => g.name).sort()
|
||||
const expectedAccount1Groups = [group1.name, group2.name].sort()
|
||||
|
||||
if (JSON.stringify(account1GroupNames) === JSON.stringify(expectedAccount1Groups)) {
|
||||
log(`✅ 账户1分组关系正确: [${account1GroupNames.join(', ')}]`, 'success')
|
||||
} else {
|
||||
throw new Error(
|
||||
`账户1分组关系错误,期望: [${expectedAccount1Groups.join(', ')}], 实际: [${account1GroupNames.join(', ')}]`
|
||||
)
|
||||
}
|
||||
|
||||
// 验证账户2的分组关系
|
||||
const account2Groups = await accountGroupService.getAccountGroup(account2.id)
|
||||
log(`📊 账户2所属分组数量: ${account2Groups.length}`, 'info')
|
||||
|
||||
const account2GroupNames = account2Groups.map((g) => g.name).sort()
|
||||
const expectedAccount2Groups = [group2.name, group3.name].sort()
|
||||
|
||||
if (JSON.stringify(account2GroupNames) === JSON.stringify(expectedAccount2Groups)) {
|
||||
log(`✅ 账户2分组关系正确: [${account2GroupNames.join(', ')}]`, 'success')
|
||||
} else {
|
||||
throw new Error(
|
||||
`账户2分组关系错误,期望: [${expectedAccount2Groups.join(', ')}], 实际: [${account2GroupNames.join(', ')}]`
|
||||
)
|
||||
}
|
||||
|
||||
log(`✅ 多分组关系验证通过`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 测试3失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试4: 验证分组成员关系
|
||||
async function test4_verifyGroupMemberships() {
|
||||
log('\n📝 测试4: 验证分组成员关系', 'info')
|
||||
|
||||
try {
|
||||
const [group1, group2, group3] = testData.groups
|
||||
const [account1, account2] = testData.accounts
|
||||
|
||||
// 验证分组1的成员
|
||||
const group1Members = await accountGroupService.getGroupMembers(group1.id)
|
||||
if (group1Members.includes(account1.id) && group1Members.length === 1) {
|
||||
log(`✅ 分组1成员正确: [${account1.name}]`, 'success')
|
||||
} else {
|
||||
throw new Error(`分组1成员错误,期望: [${account1.id}], 实际: [${group1Members.join(', ')}]`)
|
||||
}
|
||||
|
||||
// 验证分组2的成员(应该包含两个账户)
|
||||
const group2Members = await accountGroupService.getGroupMembers(group2.id)
|
||||
const expectedGroup2Members = [account1.id, account2.id].sort()
|
||||
const actualGroup2Members = group2Members.sort()
|
||||
|
||||
if (JSON.stringify(actualGroup2Members) === JSON.stringify(expectedGroup2Members)) {
|
||||
log(`✅ 分组2成员正确: [${account1.name}, ${account2.name}]`, 'success')
|
||||
} else {
|
||||
throw new Error(
|
||||
`分组2成员错误,期望: [${expectedGroup2Members.join(', ')}], 实际: [${actualGroup2Members.join(', ')}]`
|
||||
)
|
||||
}
|
||||
|
||||
// 验证分组3的成员
|
||||
const group3Members = await accountGroupService.getGroupMembers(group3.id)
|
||||
if (group3Members.includes(account2.id) && group3Members.length === 1) {
|
||||
log(`✅ 分组3成员正确: [${account2.name}]`, 'success')
|
||||
} else {
|
||||
throw new Error(`分组3成员错误,期望: [${account2.id}], 实际: [${group3Members.join(', ')}]`)
|
||||
}
|
||||
|
||||
log(`✅ 分组成员关系验证通过`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 测试4失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试5: 从部分分组中移除账户
|
||||
async function test5_removeFromPartialGroups() {
|
||||
log('\n📝 测试5: 从部分分组中移除账户', 'info')
|
||||
|
||||
try {
|
||||
const [group1, group2] = testData.groups
|
||||
const [account1] = testData.accounts
|
||||
|
||||
// 将账户1从分组1中移除(但仍在分组2中)
|
||||
await accountGroupService.removeAccountFromGroup(account1.id, group1.id)
|
||||
log(`✅ 从分组1中移除账户1`, 'success')
|
||||
|
||||
// 验证账户1现在只属于分组2
|
||||
const account1Groups = await accountGroupService.getAccountGroup(account1.id)
|
||||
if (account1Groups.length === 1 && account1Groups[0].id === group2.id) {
|
||||
log(`✅ 账户1现在只属于分组2: ${account1Groups[0].name}`, 'success')
|
||||
} else {
|
||||
const groupNames = account1Groups.map((g) => g.name)
|
||||
throw new Error(`账户1分组状态错误,期望只在分组2中,实际: [${groupNames.join(', ')}]`)
|
||||
}
|
||||
|
||||
// 验证分组1现在为空
|
||||
const group1Members = await accountGroupService.getGroupMembers(group1.id)
|
||||
if (group1Members.length === 0) {
|
||||
log(`✅ 分组1现在为空`, 'success')
|
||||
} else {
|
||||
throw new Error(`分组1应该为空,但还有成员: [${group1Members.join(', ')}]`)
|
||||
}
|
||||
|
||||
// 验证分组2仍有两个成员
|
||||
const group2Members = await accountGroupService.getGroupMembers(group2.id)
|
||||
if (group2Members.length === 2) {
|
||||
log(`✅ 分组2仍有两个成员`, 'success')
|
||||
} else {
|
||||
throw new Error(`分组2应该有2个成员,实际: ${group2Members.length}个`)
|
||||
}
|
||||
|
||||
log(`✅ 部分移除测试通过`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 测试5失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试6: 账户完全移除时的分组清理
|
||||
async function test6_accountDeletionGroupCleanup() {
|
||||
log('\n📝 测试6: 账户删除时的分组清理', 'info')
|
||||
|
||||
try {
|
||||
const [, group2, group3] = testData.groups // 跳过第一个元素
|
||||
const [account1, account2] = testData.accounts
|
||||
|
||||
// 记录删除前的状态
|
||||
const beforeGroup2Members = await accountGroupService.getGroupMembers(group2.id)
|
||||
const beforeGroup3Members = await accountGroupService.getGroupMembers(group3.id)
|
||||
|
||||
log(`📊 删除前分组2成员数: ${beforeGroup2Members.length}`, 'info')
|
||||
log(`📊 删除前分组3成员数: ${beforeGroup3Members.length}`, 'info')
|
||||
|
||||
// 删除账户2(这应该会触发从所有分组中移除的逻辑)
|
||||
await claudeAccountService.deleteAccount(account2.id)
|
||||
log(`✅ 删除账户2: ${account2.name}`, 'success')
|
||||
|
||||
// 从测试数据中移除,避免cleanup时重复删除
|
||||
testData.accounts = testData.accounts.filter((acc) => acc.id !== account2.id)
|
||||
|
||||
// 等待一下确保删除操作完成
|
||||
await sleep(500)
|
||||
|
||||
// 验证分组2现在只有账户1
|
||||
const afterGroup2Members = await accountGroupService.getGroupMembers(group2.id)
|
||||
if (afterGroup2Members.length === 1 && afterGroup2Members[0] === account1.id) {
|
||||
log(`✅ 分组2现在只有账户1`, 'success')
|
||||
} else {
|
||||
throw new Error(`分组2成员状态错误,期望只有账户1,实际: [${afterGroup2Members.join(', ')}]`)
|
||||
}
|
||||
|
||||
// 验证分组3现在为空
|
||||
const afterGroup3Members = await accountGroupService.getGroupMembers(group3.id)
|
||||
if (afterGroup3Members.length === 0) {
|
||||
log(`✅ 分组3现在为空`, 'success')
|
||||
} else {
|
||||
throw new Error(`分组3应该为空,但还有成员: [${afterGroup3Members.join(', ')}]`)
|
||||
}
|
||||
|
||||
log(`✅ 账户删除的分组清理测试通过`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 测试6失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 主测试函数
|
||||
async function runTests() {
|
||||
log('\n🚀 开始多分组功能测试\n', 'info')
|
||||
|
||||
try {
|
||||
// 连接Redis
|
||||
await redis.connect()
|
||||
log('✅ Redis连接成功', 'success')
|
||||
|
||||
// 执行测试
|
||||
await test1_createTestData()
|
||||
await test2_addAccountToMultipleGroups()
|
||||
await test3_verifyMultiGroupRelationships()
|
||||
await test4_verifyGroupMemberships()
|
||||
await test5_removeFromPartialGroups()
|
||||
await test6_accountDeletionGroupCleanup()
|
||||
|
||||
log('\n🎉 所有测试通过!多分组功能工作正常', 'success')
|
||||
} catch (error) {
|
||||
log(`\n❌ 测试失败: ${error.message}`, 'error')
|
||||
console.error(error)
|
||||
} finally {
|
||||
// 清理测试数据
|
||||
if (CLEANUP_ON_FINISH) {
|
||||
await cleanup()
|
||||
} else {
|
||||
log('\n⚠️ 测试数据未清理,请手动清理', 'warning')
|
||||
}
|
||||
|
||||
// 关闭Redis连接
|
||||
await redis.disconnect()
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runTests()
|
||||
108
scripts/test-official-models.js
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 官方模型版本识别测试 - 最终版 v2
|
||||
*/
|
||||
|
||||
const { isOpus45OrNewer } = require('../src/utils/modelHelper')
|
||||
|
||||
// 官方模型
|
||||
const officialModels = [
|
||||
{ name: 'claude-3-opus-20240229', desc: 'Opus 3 (已弃用)', expectPro: false },
|
||||
{ name: 'claude-opus-4-20250514', desc: 'Opus 4.0', expectPro: false },
|
||||
{ name: 'claude-opus-4-1-20250805', desc: 'Opus 4.1', expectPro: false },
|
||||
{ name: 'claude-opus-4-5-20251101', desc: 'Opus 4.5', expectPro: true }
|
||||
]
|
||||
|
||||
// 非 Opus 模型
|
||||
const nonOpusModels = [
|
||||
{ name: 'claude-sonnet-4-20250514', desc: 'Sonnet 4' },
|
||||
{ name: 'claude-sonnet-4-5-20250929', desc: 'Sonnet 4.5' },
|
||||
{ name: 'claude-haiku-4-5-20251001', desc: 'Haiku 4.5' },
|
||||
{ name: 'claude-3-5-haiku-20241022', desc: 'Haiku 3.5' },
|
||||
{ name: 'claude-3-haiku-20240307', desc: 'Haiku 3' },
|
||||
{ name: 'claude-3-7-sonnet-20250219', desc: 'Sonnet 3.7 (已弃用)' }
|
||||
]
|
||||
|
||||
// 其他格式测试
|
||||
const otherFormats = [
|
||||
{ name: 'claude-opus-4.5', expected: true, desc: 'Opus 4.5 点分隔' },
|
||||
{ name: 'claude-opus-4-5', expected: true, desc: 'Opus 4.5 横线分隔' },
|
||||
{ name: 'opus-4.5', expected: true, desc: 'Opus 4.5 无前缀' },
|
||||
{ name: 'opus-4-5', expected: true, desc: 'Opus 4-5 无前缀' },
|
||||
{ name: 'opus-latest', expected: true, desc: 'Opus latest' },
|
||||
{ name: 'claude-opus-5', expected: true, desc: 'Opus 5 (未来)' },
|
||||
{ name: 'claude-opus-5-0', expected: true, desc: 'Opus 5.0 (未来)' },
|
||||
{ name: 'opus-4.0', expected: false, desc: 'Opus 4.0' },
|
||||
{ name: 'opus-4.1', expected: false, desc: 'Opus 4.1' },
|
||||
{ name: 'opus-4.4', expected: false, desc: 'Opus 4.4' },
|
||||
{ name: 'opus-4', expected: false, desc: 'Opus 4' },
|
||||
{ name: 'opus-4-0', expected: false, desc: 'Opus 4-0' },
|
||||
{ name: 'opus-4-1', expected: false, desc: 'Opus 4-1' },
|
||||
{ name: 'opus-4-4', expected: false, desc: 'Opus 4-4' },
|
||||
{ name: 'opus', expected: false, desc: '仅 opus' },
|
||||
{ name: null, expected: false, desc: 'null' },
|
||||
{ name: '', expected: false, desc: '空字符串' }
|
||||
]
|
||||
|
||||
console.log('='.repeat(90))
|
||||
console.log('官方模型版本识别测试 - 最终版 v2')
|
||||
console.log('='.repeat(90))
|
||||
console.log()
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
|
||||
// 测试官方 Opus 模型
|
||||
console.log('📌 官方 Opus 模型:')
|
||||
for (const m of officialModels) {
|
||||
const result = isOpus45OrNewer(m.name)
|
||||
const status = result === m.expectPro ? '✅ PASS' : '❌ FAIL'
|
||||
if (result === m.expectPro) {
|
||||
passed++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
const proSupport = result ? 'Pro 可用 ✅' : 'Pro 不可用 ❌'
|
||||
console.log(` ${status} | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${proSupport}`)
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log('📌 非 Opus 模型 (不受此函数影响):')
|
||||
for (const m of nonOpusModels) {
|
||||
const result = isOpus45OrNewer(m.name)
|
||||
console.log(
|
||||
` ➖ | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${result ? '⚠️ 异常' : '正确跳过'}`
|
||||
)
|
||||
if (result) {
|
||||
failed++ // 非 Opus 模型不应返回 true
|
||||
}
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log('📌 其他格式测试:')
|
||||
for (const m of otherFormats) {
|
||||
const result = isOpus45OrNewer(m.name)
|
||||
const status = result === m.expected ? '✅ PASS' : '❌ FAIL'
|
||||
if (result === m.expected) {
|
||||
passed++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
const display = m.name === null ? 'null' : m.name === '' ? '""' : m.name
|
||||
console.log(
|
||||
` ${status} | ${display.padEnd(25)} | ${m.desc.padEnd(18)} | ${result ? 'Pro 可用' : 'Pro 不可用'}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log('='.repeat(90))
|
||||
console.log('测试结果:', passed, '通过,', failed, '失败')
|
||||
console.log('='.repeat(90))
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\n❌ 有测试失败,请检查函数逻辑')
|
||||
process.exit(1)
|
||||
} else {
|
||||
console.log('\n✅ 所有测试通过!函数可以安全使用')
|
||||
process.exit(0)
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
/**
|
||||
* 手动更新模型价格数据脚本
|
||||
* 从 LiteLLM 仓库下载最新的模型价格和上下文窗口信息
|
||||
* 从价格镜像分支下载最新的模型价格和上下文窗口信息
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const https = require('https')
|
||||
const crypto = require('crypto')
|
||||
const pricingSource = require('../config/pricingSource')
|
||||
|
||||
// 颜色输出
|
||||
const colors = {
|
||||
@@ -32,8 +34,8 @@ const log = {
|
||||
const config = {
|
||||
dataDir: path.join(process.cwd(), 'data'),
|
||||
pricingFile: path.join(process.cwd(), 'data', 'model_pricing.json'),
|
||||
pricingUrl:
|
||||
'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json',
|
||||
hashFile: path.join(process.cwd(), 'data', 'model_pricing.sha256'),
|
||||
pricingUrl: pricingSource.pricingUrl,
|
||||
fallbackFile: path.join(
|
||||
process.cwd(),
|
||||
'resources',
|
||||
@@ -85,8 +87,8 @@ function restoreBackup() {
|
||||
// 下载价格数据
|
||||
function downloadPricingData() {
|
||||
return new Promise((resolve, reject) => {
|
||||
log.info('Downloading model pricing data from LiteLLM...')
|
||||
log.info(`URL: ${config.pricingUrl}`)
|
||||
log.info('正在从价格镜像分支拉取最新的模型价格数据...')
|
||||
log.info(`拉取地址: ${config.pricingUrl}`)
|
||||
|
||||
const request = https.get(config.pricingUrl, (response) => {
|
||||
if (response.statusCode !== 200) {
|
||||
@@ -115,7 +117,11 @@ function downloadPricingData() {
|
||||
}
|
||||
|
||||
// 保存到文件
|
||||
fs.writeFileSync(config.pricingFile, JSON.stringify(jsonData, null, 2))
|
||||
const formattedJson = JSON.stringify(jsonData, null, 2)
|
||||
fs.writeFileSync(config.pricingFile, formattedJson)
|
||||
|
||||
const hash = crypto.createHash('sha256').update(formattedJson).digest('hex')
|
||||
fs.writeFileSync(config.hashFile, `${hash}\n`)
|
||||
|
||||
const modelCount = Object.keys(jsonData).length
|
||||
const fileSize = Math.round(fs.statSync(config.pricingFile).size / 1024)
|
||||
|
||||
326
src/app.js
@@ -14,16 +14,19 @@ const cacheMonitor = require('./utils/cacheMonitor')
|
||||
|
||||
// Import routes
|
||||
const apiRoutes = require('./routes/api')
|
||||
const unifiedRoutes = require('./routes/unified')
|
||||
const adminRoutes = require('./routes/admin')
|
||||
const webRoutes = require('./routes/web')
|
||||
const apiStatsRoutes = require('./routes/apiStats')
|
||||
const geminiRoutes = require('./routes/geminiRoutes')
|
||||
const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
|
||||
const standardGeminiRoutes = require('./routes/standardGeminiRoutes')
|
||||
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
|
||||
const openaiRoutes = require('./routes/openaiRoutes')
|
||||
const droidRoutes = require('./routes/droidRoutes')
|
||||
const userRoutes = require('./routes/userRoutes')
|
||||
const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes')
|
||||
const webhookRoutes = require('./routes/webhook')
|
||||
const ldapRoutes = require('./routes/ldapRoutes')
|
||||
|
||||
// Import middleware
|
||||
const {
|
||||
@@ -34,6 +37,7 @@ const {
|
||||
globalRateLimit,
|
||||
requestSizeLimit
|
||||
} = require('./middleware/auth')
|
||||
const { browserFallbackMiddleware } = require('./middleware/browserFallback')
|
||||
|
||||
class Application {
|
||||
constructor() {
|
||||
@@ -48,10 +52,25 @@ class Application {
|
||||
await redis.connect()
|
||||
logger.success('✅ Redis connected successfully')
|
||||
|
||||
// 💳 初始化账户余额查询服务(Provider 注册)
|
||||
try {
|
||||
const accountBalanceService = require('./services/accountBalanceService')
|
||||
const { registerAllProviders } = require('./services/balanceProviders')
|
||||
registerAllProviders(accountBalanceService)
|
||||
logger.info('✅ 账户余额查询服务已初始化')
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ 账户余额查询服务初始化失败:', error.message)
|
||||
}
|
||||
|
||||
// 💰 初始化价格服务
|
||||
logger.info('🔄 Initializing pricing service...')
|
||||
await pricingService.initialize()
|
||||
|
||||
// 📋 初始化模型服务
|
||||
logger.info('🔄 Initializing model service...')
|
||||
const modelService = require('./services/modelService')
|
||||
await modelService.initialize()
|
||||
|
||||
// 📊 初始化缓存监控
|
||||
await this.initializeCacheMonitoring()
|
||||
|
||||
@@ -59,6 +78,10 @@ class Application {
|
||||
logger.info('🔄 Initializing admin credentials...')
|
||||
await this.initializeAdmin()
|
||||
|
||||
// 🔒 安全启动:清理无效/伪造的管理员会话
|
||||
logger.info('🔒 Cleaning up invalid admin sessions...')
|
||||
await this.cleanupInvalidSessions()
|
||||
|
||||
// 💰 初始化费用数据
|
||||
logger.info('💰 Checking cost data initialization...')
|
||||
const costInitService = require('./services/costInitService')
|
||||
@@ -71,11 +94,25 @@ class Application {
|
||||
)
|
||||
}
|
||||
|
||||
// 💰 启动回填:本周 Claude 周费用(用于 API Key 维度周限额)
|
||||
try {
|
||||
logger.info('💰 Backfilling current-week Claude weekly cost...')
|
||||
const weeklyClaudeCostInitService = require('./services/weeklyClaudeCostInitService')
|
||||
await weeklyClaudeCostInitService.backfillCurrentWeekClaudeCosts()
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ Weekly Claude cost backfill failed (startup continues):', error.message)
|
||||
}
|
||||
|
||||
// 🕐 初始化Claude账户会话窗口
|
||||
logger.info('🕐 Initializing Claude account session windows...')
|
||||
const claudeAccountService = require('./services/claudeAccountService')
|
||||
await claudeAccountService.initializeSessionWindows()
|
||||
|
||||
// 📊 初始化费用排序索引服务
|
||||
logger.info('📊 Initializing cost rank service...')
|
||||
const costRankService = require('./services/costRankService')
|
||||
await costRankService.initialize()
|
||||
|
||||
// 超早期拦截 /admin-next/ 请求 - 在所有中间件之前
|
||||
this.app.use((req, res, next) => {
|
||||
if (req.path === '/admin-next/' && req.method === 'GET') {
|
||||
@@ -109,6 +146,9 @@ class Application {
|
||||
this.app.use(corsMiddleware)
|
||||
}
|
||||
|
||||
// 🆕 兜底中间件:处理Chrome插件兼容性(必须在认证之前)
|
||||
this.app.use(browserFallbackMiddleware)
|
||||
|
||||
// 📦 压缩 - 排除流式响应(SSE)
|
||||
this.app.use(
|
||||
compression({
|
||||
@@ -134,10 +174,21 @@ class Application {
|
||||
// 📝 请求日志(使用自定义logger而不是morgan)
|
||||
this.app.use(requestLogger)
|
||||
|
||||
// 🐛 HTTP调试拦截器(仅在启用调试时生效)
|
||||
if (process.env.DEBUG_HTTP_TRAFFIC === 'true') {
|
||||
try {
|
||||
const { debugInterceptor } = require('./middleware/debugInterceptor')
|
||||
this.app.use(debugInterceptor)
|
||||
logger.info('🐛 HTTP调试拦截器已启用 - 日志输出到 logs/http-debug-*.log')
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ 无法加载HTTP调试拦截器:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 基础中间件
|
||||
this.app.use(
|
||||
express.json({
|
||||
limit: '10mb',
|
||||
limit: '100mb',
|
||||
verify: (req, res, buf, encoding) => {
|
||||
// 验证JSON格式
|
||||
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
|
||||
@@ -146,7 +197,7 @@ class Application {
|
||||
}
|
||||
})
|
||||
)
|
||||
this.app.use(express.urlencoded({ extended: true, limit: '10mb' }))
|
||||
this.app.use(express.urlencoded({ extended: true, limit: '100mb' }))
|
||||
this.app.use(securityMiddleware)
|
||||
|
||||
// 🎯 信任代理
|
||||
@@ -234,18 +285,43 @@ class Application {
|
||||
|
||||
// 🛣️ 路由
|
||||
this.app.use('/api', apiRoutes)
|
||||
this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等)
|
||||
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
|
||||
// Anthropic (Claude Code) 路由:按路径强制分流到 Gemini OAuth 账户
|
||||
// - /antigravity/api/v1/messages -> Antigravity OAuth
|
||||
// - /gemini-cli/api/v1/messages -> Gemini CLI OAuth
|
||||
this.app.use(
|
||||
'/antigravity/api',
|
||||
(req, res, next) => {
|
||||
req._anthropicVendor = 'antigravity'
|
||||
next()
|
||||
},
|
||||
apiRoutes
|
||||
)
|
||||
this.app.use(
|
||||
'/gemini-cli/api',
|
||||
(req, res, next) => {
|
||||
req._anthropicVendor = 'gemini-cli'
|
||||
next()
|
||||
},
|
||||
apiRoutes
|
||||
)
|
||||
this.app.use('/admin', adminRoutes)
|
||||
this.app.use('/users', userRoutes)
|
||||
// 使用 web 路由(包含 auth 和页面重定向)
|
||||
this.app.use('/web', webRoutes)
|
||||
this.app.use('/apiStats', apiStatsRoutes)
|
||||
this.app.use('/gemini', geminiRoutes)
|
||||
// Gemini 路由:同时支持标准格式和原有格式
|
||||
this.app.use('/gemini', standardGeminiRoutes) // 标准 Gemini API 格式路由
|
||||
this.app.use('/gemini', geminiRoutes) // 保留原有路径以保持向后兼容
|
||||
this.app.use('/openai/gemini', openaiGeminiRoutes)
|
||||
this.app.use('/openai/claude', openaiClaudeRoutes)
|
||||
this.app.use('/openai', openaiRoutes)
|
||||
this.app.use('/openai', unifiedRoutes) // 复用统一智能路由,支持 /openai/v1/chat/completions
|
||||
this.app.use('/openai', openaiRoutes) // Codex API 路由(/openai/responses, /openai/v1/responses)
|
||||
// Droid 路由:支持多种 Factory.ai 端点
|
||||
this.app.use('/droid', droidRoutes) // Droid (Factory.ai) API 转发
|
||||
this.app.use('/azure', azureOpenaiRoutes)
|
||||
this.app.use('/admin/webhook', webhookRoutes)
|
||||
this.app.use('/admin/ldap', ldapRoutes)
|
||||
|
||||
// 🏠 根路径重定向到新版管理界面
|
||||
this.app.get('/', (req, res) => {
|
||||
@@ -392,6 +468,54 @@ class Application {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔒 清理无效/伪造的管理员会话(安全启动检查)
|
||||
async cleanupInvalidSessions() {
|
||||
try {
|
||||
const client = redis.getClient()
|
||||
|
||||
// 获取所有 session:* 键
|
||||
const sessionKeys = await client.keys('session:*')
|
||||
|
||||
let validCount = 0
|
||||
let invalidCount = 0
|
||||
|
||||
for (const key of sessionKeys) {
|
||||
// 跳过 admin_credentials(系统凭据)
|
||||
if (key === 'session:admin_credentials') {
|
||||
continue
|
||||
}
|
||||
|
||||
const sessionData = await client.hgetall(key)
|
||||
|
||||
// 检查会话完整性:必须有 username 和 loginTime
|
||||
const hasUsername = !!sessionData.username
|
||||
const hasLoginTime = !!sessionData.loginTime
|
||||
|
||||
if (!hasUsername || !hasLoginTime) {
|
||||
// 无效会话 - 可能是漏洞利用创建的伪造会话
|
||||
invalidCount++
|
||||
logger.security(
|
||||
`🔒 Removing invalid session: ${key} (username: ${hasUsername}, loginTime: ${hasLoginTime})`
|
||||
)
|
||||
await client.del(key)
|
||||
} else {
|
||||
validCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidCount > 0) {
|
||||
logger.security(`🔒 Startup security check: Removed ${invalidCount} invalid sessions`)
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed`
|
||||
)
|
||||
} catch (error) {
|
||||
// 清理失败不应阻止服务启动
|
||||
logger.error('❌ Failed to cleanup invalid sessions:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 Redis健康检查
|
||||
async checkRedisHealth() {
|
||||
try {
|
||||
@@ -526,6 +650,136 @@ class Application {
|
||||
logger.info(
|
||||
`🔄 Cleanup tasks scheduled every ${config.system.cleanupInterval / 1000 / 60} minutes`
|
||||
)
|
||||
|
||||
// 🚨 启动限流状态自动清理服务
|
||||
// 每5分钟检查一次过期的限流状态,确保账号能及时恢复调度
|
||||
const rateLimitCleanupService = require('./services/rateLimitCleanupService')
|
||||
const cleanupIntervalMinutes = config.system.rateLimitCleanupInterval || 5 // 默认5分钟
|
||||
rateLimitCleanupService.start(cleanupIntervalMinutes)
|
||||
logger.info(
|
||||
`🚨 Rate limit cleanup service started (checking every ${cleanupIntervalMinutes} minutes)`
|
||||
)
|
||||
|
||||
// 🔢 启动并发计数自动清理任务(Phase 1 修复:解决并发泄漏问题)
|
||||
// 每分钟主动清理所有过期的并发项,不依赖请求触发
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const keys = await redis.keys('concurrency:*')
|
||||
if (keys.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
let totalCleaned = 0
|
||||
let legacyCleaned = 0
|
||||
|
||||
// 使用 Lua 脚本批量清理所有过期项
|
||||
for (const key of keys) {
|
||||
// 跳过已知非 Sorted Set 类型的键(这些键有各自的清理逻辑)
|
||||
// - concurrency:queue:stats:* 是 Hash 类型
|
||||
// - concurrency:queue:wait_times:* 是 List 类型
|
||||
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
|
||||
if (
|
||||
key.startsWith('concurrency:queue:stats:') ||
|
||||
key.startsWith('concurrency:queue:wait_times:') ||
|
||||
(key.startsWith('concurrency:queue:') &&
|
||||
!key.includes(':stats:') &&
|
||||
!key.includes(':wait_times:'))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用原子 Lua 脚本:先检查类型,再执行清理
|
||||
// 返回值:0 = 正常清理无删除,1 = 清理后删除空键,-1 = 遗留键已删除
|
||||
const result = await redis.client.eval(
|
||||
`
|
||||
local key = KEYS[1]
|
||||
local now = tonumber(ARGV[1])
|
||||
|
||||
-- 先检查键类型,只对 Sorted Set 执行清理
|
||||
local keyType = redis.call('TYPE', key)
|
||||
if keyType.ok ~= 'zset' then
|
||||
-- 非 ZSET 类型的遗留键,直接删除
|
||||
redis.call('DEL', key)
|
||||
return -1
|
||||
end
|
||||
|
||||
-- 清理过期项
|
||||
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
|
||||
|
||||
-- 获取剩余计数
|
||||
local count = redis.call('ZCARD', key)
|
||||
|
||||
-- 如果计数为0,删除键
|
||||
if count <= 0 then
|
||||
redis.call('DEL', key)
|
||||
return 1
|
||||
end
|
||||
|
||||
return 0
|
||||
`,
|
||||
1,
|
||||
key,
|
||||
now
|
||||
)
|
||||
if (result === 1) {
|
||||
totalCleaned++
|
||||
} else if (result === -1) {
|
||||
legacyCleaned++
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCleaned > 0) {
|
||||
logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
|
||||
}
|
||||
if (legacyCleaned > 0) {
|
||||
logger.warn(`🧹 Concurrency cleanup: removed ${legacyCleaned} legacy keys (wrong type)`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Concurrency cleanup task failed:', error)
|
||||
}
|
||||
}, 60000) // 每分钟执行一次
|
||||
|
||||
logger.info('🔢 Concurrency cleanup task started (running every 1 minute)')
|
||||
|
||||
// 📬 启动用户消息队列服务
|
||||
const userMessageQueueService = require('./services/userMessageQueueService')
|
||||
// 先清理服务重启后残留的锁,防止旧锁阻塞新请求
|
||||
userMessageQueueService.cleanupStaleLocks().then(() => {
|
||||
// 然后启动定时清理任务
|
||||
userMessageQueueService.startCleanupTask()
|
||||
})
|
||||
|
||||
// 🚦 清理服务重启后残留的并发排队计数器
|
||||
// 多实例部署时建议关闭此开关,避免新实例启动时清空其他实例的队列计数
|
||||
// 可通过 DELETE /admin/concurrency/queue 接口手动清理
|
||||
const clearQueuesOnStartup = process.env.CLEAR_CONCURRENCY_QUEUES_ON_STARTUP !== 'false'
|
||||
if (clearQueuesOnStartup) {
|
||||
redis.clearAllConcurrencyQueues().catch((error) => {
|
||||
logger.error('❌ Error clearing concurrency queues on startup:', error)
|
||||
})
|
||||
} else {
|
||||
logger.info(
|
||||
'🚦 Skipping concurrency queue cleanup on startup (CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=false)'
|
||||
)
|
||||
}
|
||||
|
||||
// 🧪 启动账户定时测试调度器
|
||||
// 根据配置定期测试账户连通性并保存测试历史
|
||||
const accountTestSchedulerEnabled =
|
||||
process.env.ACCOUNT_TEST_SCHEDULER_ENABLED !== 'false' &&
|
||||
config.accountTestScheduler?.enabled !== false
|
||||
if (accountTestSchedulerEnabled) {
|
||||
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
|
||||
accountTestSchedulerService.start()
|
||||
logger.info('🧪 Account test scheduler service started')
|
||||
} else {
|
||||
logger.info('🧪 Account test scheduler service disabled')
|
||||
}
|
||||
}
|
||||
|
||||
setupGracefulShutdown() {
|
||||
@@ -544,6 +798,66 @@ class Application {
|
||||
logger.error('❌ Error cleaning up pricing service:', error)
|
||||
}
|
||||
|
||||
// 清理 model service 的文件监听器
|
||||
try {
|
||||
const modelService = require('./services/modelService')
|
||||
modelService.cleanup()
|
||||
logger.info('📋 Model service cleaned up')
|
||||
} catch (error) {
|
||||
logger.error('❌ Error cleaning up model service:', error)
|
||||
}
|
||||
|
||||
// 停止限流清理服务
|
||||
try {
|
||||
const rateLimitCleanupService = require('./services/rateLimitCleanupService')
|
||||
rateLimitCleanupService.stop()
|
||||
logger.info('🚨 Rate limit cleanup service stopped')
|
||||
} catch (error) {
|
||||
logger.error('❌ Error stopping rate limit cleanup service:', error)
|
||||
}
|
||||
|
||||
// 停止用户消息队列清理服务
|
||||
try {
|
||||
const userMessageQueueService = require('./services/userMessageQueueService')
|
||||
userMessageQueueService.stopCleanupTask()
|
||||
logger.info('📬 User message queue service stopped')
|
||||
} catch (error) {
|
||||
logger.error('❌ Error stopping user message queue service:', error)
|
||||
}
|
||||
|
||||
// 停止费用排序索引服务
|
||||
try {
|
||||
const costRankService = require('./services/costRankService')
|
||||
costRankService.shutdown()
|
||||
logger.info('📊 Cost rank service stopped')
|
||||
} catch (error) {
|
||||
logger.error('❌ Error stopping cost rank service:', error)
|
||||
}
|
||||
|
||||
// 停止账户定时测试调度器
|
||||
try {
|
||||
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
|
||||
accountTestSchedulerService.stop()
|
||||
logger.info('🧪 Account test scheduler service stopped')
|
||||
} catch (error) {
|
||||
logger.error('❌ Error stopping account test scheduler service:', error)
|
||||
}
|
||||
|
||||
// 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏)
|
||||
try {
|
||||
logger.info('🔢 Cleaning up all concurrency counters...')
|
||||
const keys = await redis.keys('concurrency:*')
|
||||
if (keys.length > 0) {
|
||||
await redis.client.del(...keys)
|
||||
logger.info(`✅ Cleaned ${keys.length} concurrency keys`)
|
||||
} else {
|
||||
logger.info('✅ No concurrency keys to clean')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Error cleaning up concurrency counters:', error)
|
||||
// 不阻止退出流程
|
||||
}
|
||||
|
||||
try {
|
||||
await redis.disconnect()
|
||||
logger.info('👋 Redis disconnected')
|
||||
|
||||
2813
src/handlers/geminiHandlers.js
Normal file
78
src/middleware/browserFallback.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 浏览器/Chrome插件兜底中间件
|
||||
* 专门处理第三方插件的兼容性问题
|
||||
*/
|
||||
const browserFallbackMiddleware = (req, res, next) => {
|
||||
const userAgent = req.headers['user-agent'] || ''
|
||||
const origin = req.headers['origin'] || ''
|
||||
|
||||
const extractHeader = (value) => {
|
||||
let candidate = value
|
||||
|
||||
if (Array.isArray(candidate)) {
|
||||
candidate = candidate.find((item) => typeof item === 'string' && item.trim())
|
||||
}
|
||||
|
||||
if (typeof candidate !== 'string') {
|
||||
return ''
|
||||
}
|
||||
|
||||
let trimmed = candidate.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (/^Bearer\s+/i.test(trimmed)) {
|
||||
trimmed = trimmed.replace(/^Bearer\s+/i, '').trim()
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const apiKeyHeader =
|
||||
extractHeader(req.headers['x-api-key']) || extractHeader(req.headers['x-goog-api-key'])
|
||||
const normalizedKey = extractHeader(req.headers['authorization']) || apiKeyHeader
|
||||
|
||||
// 检查是否为Chrome插件或浏览器请求
|
||||
const isChromeExtension = origin.startsWith('chrome-extension://')
|
||||
const isBrowserRequest = userAgent.includes('Mozilla/') && userAgent.includes('Chrome/')
|
||||
const hasApiKey = normalizedKey.startsWith('cr_') // 我们的API Key格式
|
||||
|
||||
if ((isChromeExtension || isBrowserRequest) && hasApiKey) {
|
||||
// 为Chrome插件请求添加特殊标记
|
||||
req.isBrowserFallback = true
|
||||
req.originalUserAgent = userAgent
|
||||
|
||||
// 🆕 关键修改:伪装成claude-cli请求以绕过客户端限制
|
||||
req.headers['user-agent'] = 'claude-cli/1.0.110 (external, cli, browser-fallback)'
|
||||
|
||||
// 确保设置正确的认证头
|
||||
if (!req.headers['authorization'] && apiKeyHeader) {
|
||||
req.headers['authorization'] = `Bearer ${apiKeyHeader}`
|
||||
}
|
||||
|
||||
// 添加必要的Anthropic头
|
||||
if (!req.headers['anthropic-version']) {
|
||||
req.headers['anthropic-version'] = '2023-06-01'
|
||||
}
|
||||
|
||||
if (!req.headers['anthropic-dangerous-direct-browser-access']) {
|
||||
req.headers['anthropic-dangerous-direct-browser-access'] = 'true'
|
||||
}
|
||||
|
||||
logger.api(
|
||||
`🔧 Browser fallback activated for ${isChromeExtension ? 'Chrome extension' : 'browser'} request`
|
||||
)
|
||||
logger.api(` Original User-Agent: "${req.originalUserAgent}"`)
|
||||
logger.api(` Origin: "${origin}"`)
|
||||
logger.api(` Modified User-Agent: "${req.headers['user-agent']}"`)
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
browserFallbackMiddleware
|
||||
}
|
||||
2429
src/models/redis.js
5738
src/routes/admin.js
214
src/routes/admin/accountBalance.js
Normal file
@@ -0,0 +1,214 @@
|
||||
const express = require('express')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const accountBalanceService = require('../../services/accountBalanceService')
|
||||
const balanceScriptService = require('../../services/balanceScriptService')
|
||||
const { isBalanceScriptEnabled } = require('../../utils/featureFlags')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
const ensureValidPlatform = (rawPlatform) => {
|
||||
const normalized = accountBalanceService.normalizePlatform(rawPlatform)
|
||||
if (!normalized) {
|
||||
return { ok: false, status: 400, error: '缺少 platform 参数' }
|
||||
}
|
||||
|
||||
const supported = accountBalanceService.getSupportedPlatforms()
|
||||
if (!supported.includes(normalized)) {
|
||||
return { ok: false, status: 400, error: `不支持的平台: ${normalized}` }
|
||||
}
|
||||
|
||||
return { ok: true, platform: normalized }
|
||||
}
|
||||
|
||||
// 1) 获取账户余额(默认本地统计优先,可选触发 Provider)
|
||||
// GET /admin/accounts/:accountId/balance?platform=xxx&queryApi=false
|
||||
router.get('/accounts/:accountId/balance', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform, queryApi } = req.query
|
||||
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
const balance = await accountBalanceService.getAccountBalance(accountId, valid.platform, {
|
||||
queryApi
|
||||
})
|
||||
|
||||
if (!balance) {
|
||||
return res.status(404).json({ success: false, error: 'Account not found' })
|
||||
}
|
||||
|
||||
return res.json(balance)
|
||||
} catch (error) {
|
||||
logger.error('获取账户余额失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 2) 强制刷新账户余额(强制触发查询:优先脚本;Provider 仅为降级)
|
||||
// POST /admin/accounts/:accountId/balance/refresh
|
||||
// Body: { platform: 'xxx' }
|
||||
router.post('/accounts/:accountId/balance/refresh', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform } = req.body || {}
|
||||
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
logger.info(`手动刷新余额: ${valid.platform}:${accountId}`)
|
||||
|
||||
const balance = await accountBalanceService.refreshAccountBalance(accountId, valid.platform)
|
||||
if (!balance) {
|
||||
return res.status(404).json({ success: false, error: 'Account not found' })
|
||||
}
|
||||
|
||||
return res.json(balance)
|
||||
} catch (error) {
|
||||
logger.error('刷新账户余额失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 3) 批量获取平台所有账户余额
|
||||
// GET /admin/accounts/balance/platform/:platform?queryApi=false
|
||||
router.get('/accounts/balance/platform/:platform', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { platform } = req.params
|
||||
const { queryApi } = req.query
|
||||
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
const balances = await accountBalanceService.getAllAccountsBalance(valid.platform, { queryApi })
|
||||
|
||||
return res.json({ success: true, data: balances })
|
||||
} catch (error) {
|
||||
logger.error('批量获取余额失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 4) 获取余额汇总(Dashboard 用)
|
||||
// GET /admin/accounts/balance/summary
|
||||
router.get('/accounts/balance/summary', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const summary = await accountBalanceService.getBalanceSummary()
|
||||
return res.json({ success: true, data: summary })
|
||||
} catch (error) {
|
||||
logger.error('获取余额汇总失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 5) 清除缓存
|
||||
// DELETE /admin/accounts/:accountId/balance/cache?platform=xxx
|
||||
router.delete('/accounts/:accountId/balance/cache', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform } = req.query
|
||||
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
await accountBalanceService.clearCache(accountId, valid.platform)
|
||||
|
||||
return res.json({ success: true, message: '缓存已清除' })
|
||||
} catch (error) {
|
||||
logger.error('清除缓存失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 6) 获取/保存/测试余额脚本配置(单账户)
|
||||
router.get('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform } = req.query
|
||||
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
const config = await accountBalanceService.redis.getBalanceScriptConfig(
|
||||
valid.platform,
|
||||
accountId
|
||||
)
|
||||
return res.json({ success: true, data: config || null })
|
||||
} catch (error) {
|
||||
logger.error('获取余额脚本配置失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform } = req.query
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
const payload = req.body || {}
|
||||
await accountBalanceService.redis.setBalanceScriptConfig(valid.platform, accountId, payload)
|
||||
return res.json({ success: true, data: payload })
|
||||
} catch (error) {
|
||||
logger.error('保存余额脚本配置失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/accounts/:accountId/balance/script/test', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform } = req.query
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
if (!isBalanceScriptEnabled()) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)'
|
||||
})
|
||||
}
|
||||
|
||||
const payload = req.body || {}
|
||||
const { scriptBody } = payload
|
||||
if (!scriptBody) {
|
||||
return res.status(400).json({ success: false, error: '脚本内容不能为空' })
|
||||
}
|
||||
|
||||
const result = await balanceScriptService.execute({
|
||||
scriptBody,
|
||||
timeoutSeconds: payload.timeoutSeconds || 10,
|
||||
variables: {
|
||||
baseUrl: payload.baseUrl || '',
|
||||
apiKey: payload.apiKey || '',
|
||||
token: payload.token || '',
|
||||
accountId,
|
||||
platform: valid.platform,
|
||||
extra: payload.extra || ''
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('测试余额脚本失败', error)
|
||||
return res.status(400).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
153
src/routes/admin/accountGroups.js
Normal file
@@ -0,0 +1,153 @@
|
||||
const express = require('express')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||
const geminiAccountService = require('../../services/geminiAccountService')
|
||||
const openaiAccountService = require('../../services/openaiAccountService')
|
||||
const droidAccountService = require('../../services/droidAccountService')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// 👥 账户分组管理
|
||||
|
||||
// 创建账户分组
|
||||
router.post('/', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { name, platform, description } = req.body
|
||||
|
||||
const group = await accountGroupService.createGroup({
|
||||
name,
|
||||
platform,
|
||||
description
|
||||
})
|
||||
|
||||
return res.json({ success: true, data: group })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create account group:', error)
|
||||
return res.status(400).json({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 获取所有分组
|
||||
router.get('/', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { platform } = req.query
|
||||
const groups = await accountGroupService.getAllGroups(platform)
|
||||
return res.json({ success: true, data: groups })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get account groups:', error)
|
||||
return res.status(500).json({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 获取分组详情
|
||||
router.get('/:groupId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { groupId } = req.params
|
||||
const group = await accountGroupService.getGroup(groupId)
|
||||
|
||||
if (!group) {
|
||||
return res.status(404).json({ error: '分组不存在' })
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: group })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get account group:', error)
|
||||
return res.status(500).json({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 更新分组
|
||||
router.put('/:groupId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { groupId } = req.params
|
||||
const updates = req.body
|
||||
|
||||
const updatedGroup = await accountGroupService.updateGroup(groupId, updates)
|
||||
return res.json({ success: true, data: updatedGroup })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update account group:', error)
|
||||
return res.status(400).json({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 删除分组
|
||||
router.delete('/:groupId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { groupId } = req.params
|
||||
await accountGroupService.deleteGroup(groupId)
|
||||
return res.json({ success: true, message: '分组删除成功' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete account group:', error)
|
||||
return res.status(400).json({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 获取分组成员
|
||||
router.get('/:groupId/members', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { groupId } = req.params
|
||||
const group = await accountGroupService.getGroup(groupId)
|
||||
|
||||
if (!group) {
|
||||
return res.status(404).json({ error: '分组不存在' })
|
||||
}
|
||||
|
||||
const memberIds = await accountGroupService.getGroupMembers(groupId)
|
||||
|
||||
// 获取成员详细信息
|
||||
const members = []
|
||||
for (const memberId of memberIds) {
|
||||
// 根据分组平台优先查找对应账户
|
||||
let account = null
|
||||
switch (group.platform) {
|
||||
case 'droid':
|
||||
account = await droidAccountService.getAccount(memberId)
|
||||
break
|
||||
case 'gemini':
|
||||
account = await geminiAccountService.getAccount(memberId)
|
||||
break
|
||||
case 'openai':
|
||||
account = await openaiAccountService.getAccount(memberId)
|
||||
break
|
||||
case 'claude':
|
||||
default:
|
||||
account = await claudeAccountService.getAccount(memberId)
|
||||
if (!account) {
|
||||
account = await claudeConsoleAccountService.getAccount(memberId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 兼容旧数据:若按平台未找到,则继续尝试其他平台
|
||||
if (!account) {
|
||||
account = await claudeAccountService.getAccount(memberId)
|
||||
}
|
||||
if (!account) {
|
||||
account = await claudeConsoleAccountService.getAccount(memberId)
|
||||
}
|
||||
if (!account) {
|
||||
account = await geminiAccountService.getAccount(memberId)
|
||||
}
|
||||
if (!account) {
|
||||
account = await openaiAccountService.getAccount(memberId)
|
||||
}
|
||||
if (!account && group.platform !== 'droid') {
|
||||
account = await droidAccountService.getAccount(memberId)
|
||||
}
|
||||
|
||||
if (account) {
|
||||
members.push(account)
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: members })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get group members:', error)
|
||||
return res.status(500).json({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
2398
src/routes/admin/apiKeys.js
Normal file
417
src/routes/admin/azureOpenaiAccounts.js
Normal file
@@ -0,0 +1,417 @@
|
||||
const express = require('express')
|
||||
const azureOpenaiAccountService = require('../../services/azureOpenaiAccountService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
const axios = require('axios')
|
||||
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// 获取所有 Azure OpenAI 账户
|
||||
router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { platform, groupId } = req.query
|
||||
let accounts = await azureOpenaiAccountService.getAllAccounts()
|
||||
|
||||
// 根据查询参数进行筛选
|
||||
if (platform && platform !== 'all' && platform !== 'azure_openai') {
|
||||
// 如果指定了其他平台,返回空数组
|
||||
accounts = []
|
||||
}
|
||||
|
||||
// 如果指定了分组筛选
|
||||
if (groupId && groupId !== 'all') {
|
||||
if (groupId === 'ungrouped') {
|
||||
// 筛选未分组账户
|
||||
const filteredAccounts = []
|
||||
for (const account of accounts) {
|
||||
const groups = await accountGroupService.getAccountGroups(account.id)
|
||||
if (!groups || groups.length === 0) {
|
||||
filteredAccounts.push(account)
|
||||
}
|
||||
}
|
||||
accounts = filteredAccounts
|
||||
} else {
|
||||
// 筛选特定分组的账户
|
||||
const groupMembers = await accountGroupService.getGroupMembers(groupId)
|
||||
accounts = accounts.filter((account) => groupMembers.includes(account.id))
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个账户添加使用统计信息和分组信息
|
||||
const accountsWithStats = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
try {
|
||||
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
total: usageStats.total,
|
||||
averages: usageStats.averages
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to get usage stats for Azure OpenAI account ${account.id}:`, error)
|
||||
try {
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}
|
||||
} catch (groupError) {
|
||||
logger.debug(`Failed to get group info for account ${account.id}:`, groupError)
|
||||
return {
|
||||
...account,
|
||||
groupInfos: [],
|
||||
usage: {
|
||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: accountsWithStats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch Azure OpenAI accounts:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch accounts',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 创建 Azure OpenAI 账户
|
||||
router.post('/azure-openai-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
accountType,
|
||||
azureEndpoint,
|
||||
apiVersion,
|
||||
deploymentName,
|
||||
apiKey,
|
||||
supportedModels,
|
||||
proxy,
|
||||
groupId,
|
||||
groupIds,
|
||||
priority,
|
||||
isActive,
|
||||
schedulable
|
||||
} = req.body
|
||||
|
||||
// 验证必填字段
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Account name is required'
|
||||
})
|
||||
}
|
||||
|
||||
if (!azureEndpoint) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Azure endpoint is required'
|
||||
})
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'API key is required'
|
||||
})
|
||||
}
|
||||
|
||||
if (!deploymentName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Deployment name is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 Azure endpoint 格式
|
||||
if (!azureEndpoint.match(/^https:\/\/[\w-]+\.openai\.azure\.com$/)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
'Invalid Azure OpenAI endpoint format. Expected: https://your-resource.openai.azure.com'
|
||||
})
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
try {
|
||||
const testUrl = `${azureEndpoint}/openai/deployments/${deploymentName}?api-version=${
|
||||
apiVersion || '2024-02-01'
|
||||
}`
|
||||
await axios.get(testUrl, {
|
||||
headers: {
|
||||
'api-key': apiKey
|
||||
},
|
||||
timeout: 5000
|
||||
})
|
||||
} catch (testError) {
|
||||
if (testError.response?.status === 404) {
|
||||
logger.warn('Azure OpenAI deployment not found, but continuing with account creation')
|
||||
} else if (testError.response?.status === 401) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid API key or unauthorized access'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const account = await azureOpenaiAccountService.createAccount({
|
||||
name,
|
||||
description,
|
||||
accountType: accountType || 'shared',
|
||||
azureEndpoint,
|
||||
apiVersion: apiVersion || '2024-02-01',
|
||||
deploymentName,
|
||||
apiKey,
|
||||
supportedModels,
|
||||
proxy,
|
||||
groupId,
|
||||
priority: priority || 50,
|
||||
isActive: isActive !== false,
|
||||
schedulable: schedulable !== false
|
||||
})
|
||||
|
||||
// 如果是分组类型,将账户添加到分组
|
||||
if (accountType === 'group') {
|
||||
if (groupIds && groupIds.length > 0) {
|
||||
// 使用多分组设置
|
||||
await accountGroupService.setAccountGroups(account.id, groupIds, 'azure_openai')
|
||||
} else if (groupId) {
|
||||
// 兼容单分组模式
|
||||
await accountGroupService.addAccountToGroup(account.id, groupId, 'azure_openai')
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: account,
|
||||
message: 'Azure OpenAI account created successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to create Azure OpenAI account:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to create account',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 更新 Azure OpenAI 账户
|
||||
router.put('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const updates = req.body
|
||||
|
||||
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
|
||||
const mappedUpdates = mapExpiryField(updates, 'Azure OpenAI', id)
|
||||
|
||||
const account = await azureOpenaiAccountService.updateAccount(id, mappedUpdates)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: account,
|
||||
message: 'Azure OpenAI account updated successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update Azure OpenAI account:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to update account',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 删除 Azure OpenAI 账户
|
||||
router.delete('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
// 自动解绑所有绑定的 API Keys
|
||||
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'azure_openai')
|
||||
|
||||
await azureOpenaiAccountService.deleteAccount(id)
|
||||
|
||||
let message = 'Azure OpenAI账号已成功删除'
|
||||
if (unboundCount > 0) {
|
||||
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||
}
|
||||
|
||||
logger.success(`🗑️ Admin deleted Azure OpenAI account: ${id}, unbound ${unboundCount} keys`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message,
|
||||
unboundKeys: unboundCount
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete Azure OpenAI account:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to delete account',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 切换 Azure OpenAI 账户状态
|
||||
router.put('/azure-openai-accounts/:id/toggle', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const account = await azureOpenaiAccountService.getAccount(id)
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Account not found'
|
||||
})
|
||||
}
|
||||
|
||||
const newStatus = account.isActive === 'true' ? 'false' : 'true'
|
||||
await azureOpenaiAccountService.updateAccount(id, { isActive: newStatus })
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Account ${newStatus === 'true' ? 'activated' : 'deactivated'} successfully`,
|
||||
isActive: newStatus === 'true'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle Azure OpenAI account status:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to toggle account status',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 切换 Azure OpenAI 账户调度状态
|
||||
router.put(
|
||||
'/azure-openai-accounts/:accountId/toggle-schedulable',
|
||||
authenticateAdmin,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const result = await azureOpenaiAccountService.toggleSchedulable(accountId)
|
||||
|
||||
// 如果账号被禁用,发送webhook通知
|
||||
if (!result.schedulable) {
|
||||
// 获取账号信息
|
||||
const account = await azureOpenaiAccountService.getAccount(accountId)
|
||||
if (account) {
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId: account.id,
|
||||
accountName: account.name || 'Azure OpenAI Account',
|
||||
platform: 'azure-openai',
|
||||
status: 'disabled',
|
||||
errorCode: 'AZURE_OPENAI_MANUALLY_DISABLED',
|
||||
reason: '账号已被管理员手动禁用调度',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
schedulable: result.schedulable,
|
||||
message: result.schedulable ? '已启用调度' : '已禁用调度'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('切换 Azure OpenAI 账户调度状态失败:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '切换调度状态失败',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 健康检查单个 Azure OpenAI 账户
|
||||
router.post('/azure-openai-accounts/:id/health-check', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const healthResult = await azureOpenaiAccountService.healthCheckAccount(id)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: healthResult
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to perform health check:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to perform health check',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 批量健康检查所有 Azure OpenAI 账户
|
||||
router.post('/azure-openai-accounts/health-check-all', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const healthResults = await azureOpenaiAccountService.performHealthChecks()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: healthResults
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to perform batch health check:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to perform batch health check',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 迁移 API Keys 以支持 Azure OpenAI
|
||||
router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const migratedCount = await azureOpenaiAccountService.migrateApiKeysForAzureSupport()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Successfully migrated ${migratedCount} API keys for Azure OpenAI support`
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to migrate API keys:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to migrate API keys',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
41
src/routes/admin/balanceScripts.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const express = require('express')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const balanceScriptService = require('../../services/balanceScriptService')
|
||||
const router = express.Router()
|
||||
|
||||
// 获取全部脚本配置列表
|
||||
router.get('/balance-scripts', authenticateAdmin, (req, res) => {
|
||||
const items = balanceScriptService.listConfigs()
|
||||
return res.json({ success: true, data: items })
|
||||
})
|
||||
|
||||
// 获取单个脚本配置
|
||||
router.get('/balance-scripts/:name', authenticateAdmin, (req, res) => {
|
||||
const { name } = req.params
|
||||
const config = balanceScriptService.getConfig(name || 'default')
|
||||
return res.json({ success: true, data: config })
|
||||
})
|
||||
|
||||
// 保存脚本配置
|
||||
router.put('/balance-scripts/:name', authenticateAdmin, (req, res) => {
|
||||
try {
|
||||
const { name } = req.params
|
||||
const saved = balanceScriptService.saveConfig(name || 'default', req.body || {})
|
||||
return res.json({ success: true, data: saved })
|
||||
} catch (error) {
|
||||
return res.status(400).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 测试脚本(不落库)
|
||||
router.post('/balance-scripts/:name/test', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params
|
||||
const result = await balanceScriptService.testScript(name || 'default', req.body || {})
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
return res.status(400).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
366
src/routes/admin/bedrockAccounts.js
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Admin Routes - Bedrock Accounts Management
|
||||
* AWS Bedrock 账户管理路由
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const bedrockAccountService = require('../../services/bedrockAccountService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||
|
||||
// ☁️ Bedrock 账户管理
|
||||
|
||||
// 获取所有Bedrock账户
|
||||
router.get('/', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { platform, groupId } = req.query
|
||||
const result = await bedrockAccountService.getAllAccounts()
|
||||
if (!result.success) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to get Bedrock accounts', message: result.error })
|
||||
}
|
||||
|
||||
let accounts = result.data
|
||||
|
||||
// 根据查询参数进行筛选
|
||||
if (platform && platform !== 'all' && platform !== 'bedrock') {
|
||||
// 如果指定了其他平台,返回空数组
|
||||
accounts = []
|
||||
}
|
||||
|
||||
// 如果指定了分组筛选
|
||||
if (groupId && groupId !== 'all') {
|
||||
if (groupId === 'ungrouped') {
|
||||
// 筛选未分组账户
|
||||
const filteredAccounts = []
|
||||
for (const account of accounts) {
|
||||
const groups = await accountGroupService.getAccountGroups(account.id)
|
||||
if (!groups || groups.length === 0) {
|
||||
filteredAccounts.push(account)
|
||||
}
|
||||
}
|
||||
accounts = filteredAccounts
|
||||
} else {
|
||||
// 筛选特定分组的账户
|
||||
const groupMembers = await accountGroupService.getGroupMembers(groupId)
|
||||
accounts = accounts.filter((account) => groupMembers.includes(account.id))
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个账户添加使用统计信息
|
||||
const accountsWithStats = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
try {
|
||||
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
total: usageStats.total,
|
||||
averages: usageStats.averages
|
||||
}
|
||||
}
|
||||
} catch (statsError) {
|
||||
logger.warn(
|
||||
`⚠️ Failed to get usage stats for Bedrock account ${account.id}:`,
|
||||
statsError.message
|
||||
)
|
||||
try {
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}
|
||||
} catch (groupError) {
|
||||
logger.warn(
|
||||
`⚠️ Failed to get group info for account ${account.id}:`,
|
||||
groupError.message
|
||||
)
|
||||
return {
|
||||
...account,
|
||||
groupInfos: [],
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return res.json({ success: true, data: accountsWithStats })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Bedrock accounts:', error)
|
||||
return res.status(500).json({ error: 'Failed to get Bedrock accounts', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 创建新的Bedrock账户
|
||||
router.post('/', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
region,
|
||||
awsCredentials,
|
||||
bearerToken,
|
||||
defaultModel,
|
||||
priority,
|
||||
accountType,
|
||||
credentialType
|
||||
} = req.body
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name is required' })
|
||||
}
|
||||
|
||||
// 验证priority的有效性(1-100)
|
||||
if (priority !== undefined && (priority < 1 || priority > 100)) {
|
||||
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
|
||||
}
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (accountType && !['shared', 'dedicated'].includes(accountType)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid account type. Must be "shared" or "dedicated"' })
|
||||
}
|
||||
|
||||
// 验证credentialType的有效性
|
||||
if (credentialType && !['access_key', 'bearer_token'].includes(credentialType)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await bedrockAccountService.createAccount({
|
||||
name,
|
||||
description: description || '',
|
||||
region: region || 'us-east-1',
|
||||
awsCredentials,
|
||||
bearerToken,
|
||||
defaultModel,
|
||||
priority: priority || 50,
|
||||
accountType: accountType || 'shared',
|
||||
credentialType: credentialType || 'access_key'
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to create Bedrock account', message: result.error })
|
||||
}
|
||||
|
||||
logger.success(`☁️ Admin created Bedrock account: ${name}`)
|
||||
const formattedAccount = formatAccountExpiry(result.data)
|
||||
return res.json({ success: true, data: formattedAccount })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create Bedrock account:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to create Bedrock account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 更新Bedrock账户
|
||||
router.put('/:accountId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const updates = req.body
|
||||
|
||||
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
|
||||
const mappedUpdates = mapExpiryField(updates, 'Bedrock', accountId)
|
||||
|
||||
// 验证priority的有效性(1-100)
|
||||
if (
|
||||
mappedUpdates.priority !== undefined &&
|
||||
(mappedUpdates.priority < 1 || mappedUpdates.priority > 100)
|
||||
) {
|
||||
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
|
||||
}
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (mappedUpdates.accountType && !['shared', 'dedicated'].includes(mappedUpdates.accountType)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid account type. Must be "shared" or "dedicated"' })
|
||||
}
|
||||
|
||||
// 验证credentialType的有效性
|
||||
if (
|
||||
mappedUpdates.credentialType &&
|
||||
!['access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await bedrockAccountService.updateAccount(accountId, mappedUpdates)
|
||||
|
||||
if (!result.success) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to update Bedrock account', message: result.error })
|
||||
}
|
||||
|
||||
logger.success(`📝 Admin updated Bedrock account: ${accountId}`)
|
||||
return res.json({ success: true, message: 'Bedrock account updated successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update Bedrock account:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to update Bedrock account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 删除Bedrock账户
|
||||
router.delete('/:accountId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
// 自动解绑所有绑定的 API Keys
|
||||
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'bedrock')
|
||||
|
||||
const result = await bedrockAccountService.deleteAccount(accountId)
|
||||
|
||||
if (!result.success) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to delete Bedrock account', message: result.error })
|
||||
}
|
||||
|
||||
let message = 'Bedrock账号已成功删除'
|
||||
if (unboundCount > 0) {
|
||||
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||
}
|
||||
|
||||
logger.success(`🗑️ Admin deleted Bedrock account: ${accountId}, unbound ${unboundCount} keys`)
|
||||
return res.json({
|
||||
success: true,
|
||||
message,
|
||||
unboundKeys: unboundCount
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete Bedrock account:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to delete Bedrock account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 切换Bedrock账户状态
|
||||
router.put('/:accountId/toggle', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const accountResult = await bedrockAccountService.getAccount(accountId)
|
||||
if (!accountResult.success) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
const newStatus = !accountResult.data.isActive
|
||||
const updateResult = await bedrockAccountService.updateAccount(accountId, {
|
||||
isActive: newStatus
|
||||
})
|
||||
|
||||
if (!updateResult.success) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to toggle account status', message: updateResult.error })
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`🔄 Admin toggled Bedrock account status: ${accountId} -> ${
|
||||
newStatus ? 'active' : 'inactive'
|
||||
}`
|
||||
)
|
||||
return res.json({ success: true, isActive: newStatus })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to toggle Bedrock account status:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to toggle account status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 切换Bedrock账户调度状态
|
||||
router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const accountResult = await bedrockAccountService.getAccount(accountId)
|
||||
if (!accountResult.success) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
const newSchedulable = !accountResult.data.schedulable
|
||||
const updateResult = await bedrockAccountService.updateAccount(accountId, {
|
||||
schedulable: newSchedulable
|
||||
})
|
||||
|
||||
if (!updateResult.success) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to toggle schedulable status', message: updateResult.error })
|
||||
}
|
||||
|
||||
// 如果账号被禁用,发送webhook通知
|
||||
if (!newSchedulable) {
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId: accountResult.data.id,
|
||||
accountName: accountResult.data.name || 'Bedrock Account',
|
||||
platform: 'bedrock',
|
||||
status: 'disabled',
|
||||
errorCode: 'BEDROCK_MANUALLY_DISABLED',
|
||||
reason: '账号已被管理员手动禁用调度',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`🔄 Admin toggled Bedrock account schedulable status: ${accountId} -> ${
|
||||
newSchedulable ? 'schedulable' : 'not schedulable'
|
||||
}`
|
||||
)
|
||||
return res.json({ success: true, schedulable: newSchedulable })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to toggle Bedrock account schedulable status:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to toggle schedulable status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 测试Bedrock账户连接(SSE 流式)
|
||||
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
await bedrockAccountService.testAccountConnection(accountId, res)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to test Bedrock account:', error)
|
||||
// 错误已在服务层处理,这里仅做日志记录
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
416
src/routes/admin/ccrAccounts.js
Normal file
@@ -0,0 +1,416 @@
|
||||
const express = require('express')
|
||||
const ccrAccountService = require('../../services/ccrAccountService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// 🔧 CCR 账户管理
|
||||
|
||||
// 获取所有CCR账户
|
||||
router.get('/', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { platform, groupId } = req.query
|
||||
let accounts = await ccrAccountService.getAllAccounts()
|
||||
|
||||
// 根据查询参数进行筛选
|
||||
if (platform && platform !== 'all' && platform !== 'ccr') {
|
||||
// 如果指定了其他平台,返回空数组
|
||||
accounts = []
|
||||
}
|
||||
|
||||
// 如果指定了分组筛选
|
||||
if (groupId && groupId !== 'all') {
|
||||
if (groupId === 'ungrouped') {
|
||||
// 筛选未分组账户
|
||||
const filteredAccounts = []
|
||||
for (const account of accounts) {
|
||||
const groups = await accountGroupService.getAccountGroups(account.id)
|
||||
if (!groups || groups.length === 0) {
|
||||
filteredAccounts.push(account)
|
||||
}
|
||||
}
|
||||
accounts = filteredAccounts
|
||||
} else {
|
||||
// 筛选特定分组的账户
|
||||
const groupMembers = await accountGroupService.getGroupMembers(groupId)
|
||||
accounts = accounts.filter((account) => groupMembers.includes(account.id))
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个账户添加使用统计信息
|
||||
const accountsWithStats = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
try {
|
||||
const usageStats = await redis.getAccountUsageStats(account.id)
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
// 转换schedulable为布尔值
|
||||
schedulable: account.schedulable === 'true' || account.schedulable === true,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
total: usageStats.total,
|
||||
averages: usageStats.averages
|
||||
}
|
||||
}
|
||||
} catch (statsError) {
|
||||
logger.warn(
|
||||
`⚠️ Failed to get usage stats for CCR account ${account.id}:`,
|
||||
statsError.message
|
||||
)
|
||||
try {
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
// 转换schedulable为布尔值
|
||||
schedulable: account.schedulable === 'true' || account.schedulable === true,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}
|
||||
} catch (groupError) {
|
||||
logger.warn(
|
||||
`⚠️ Failed to get group info for CCR account ${account.id}:`,
|
||||
groupError.message
|
||||
)
|
||||
return {
|
||||
...account,
|
||||
groupInfos: [],
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return res.json({ success: true, data: accountsWithStats })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get CCR accounts:', error)
|
||||
return res.status(500).json({ error: 'Failed to get CCR accounts', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 创建新的CCR账户
|
||||
router.post('/', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
apiUrl,
|
||||
apiKey,
|
||||
priority,
|
||||
supportedModels,
|
||||
userAgent,
|
||||
rateLimitDuration,
|
||||
proxy,
|
||||
accountType,
|
||||
groupId,
|
||||
dailyQuota,
|
||||
quotaResetTime
|
||||
} = req.body
|
||||
|
||||
if (!name || !apiUrl || !apiKey) {
|
||||
return res.status(400).json({ error: 'Name, API URL and API Key are required' })
|
||||
}
|
||||
|
||||
// 验证priority的有效性(1-100)
|
||||
if (priority !== undefined && (priority < 1 || priority > 100)) {
|
||||
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
|
||||
}
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||
}
|
||||
|
||||
// 如果是分组类型,验证groupId
|
||||
if (accountType === 'group' && !groupId) {
|
||||
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
|
||||
}
|
||||
|
||||
const newAccount = await ccrAccountService.createAccount({
|
||||
name,
|
||||
description,
|
||||
apiUrl,
|
||||
apiKey,
|
||||
priority: priority || 50,
|
||||
supportedModels: supportedModels || [],
|
||||
userAgent,
|
||||
rateLimitDuration:
|
||||
rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60,
|
||||
proxy,
|
||||
accountType: accountType || 'shared',
|
||||
dailyQuota: dailyQuota || 0,
|
||||
quotaResetTime: quotaResetTime || '00:00'
|
||||
})
|
||||
|
||||
// 如果是分组类型,将账户添加到分组
|
||||
if (accountType === 'group' && groupId) {
|
||||
await accountGroupService.addAccountToGroup(newAccount.id, groupId)
|
||||
}
|
||||
|
||||
logger.success(`🔧 Admin created CCR account: ${name}`)
|
||||
const formattedAccount = formatAccountExpiry(newAccount)
|
||||
return res.json({ success: true, data: formattedAccount })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create CCR account:', error)
|
||||
return res.status(500).json({ error: 'Failed to create CCR account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 更新CCR账户
|
||||
router.put('/:accountId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const updates = req.body
|
||||
|
||||
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
|
||||
const mappedUpdates = mapExpiryField(updates, 'CCR', accountId)
|
||||
|
||||
// 验证priority的有效性(1-100)
|
||||
if (
|
||||
mappedUpdates.priority !== undefined &&
|
||||
(mappedUpdates.priority < 1 || mappedUpdates.priority > 100)
|
||||
) {
|
||||
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
|
||||
}
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (
|
||||
mappedUpdates.accountType &&
|
||||
!['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType)
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||
}
|
||||
|
||||
// 如果更新为分组类型,验证groupId
|
||||
if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) {
|
||||
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
|
||||
}
|
||||
|
||||
// 获取账户当前信息以处理分组变更
|
||||
const currentAccount = await ccrAccountService.getAccount(accountId)
|
||||
if (!currentAccount) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 处理分组的变更
|
||||
if (mappedUpdates.accountType !== undefined) {
|
||||
// 如果之前是分组类型,需要从所有分组中移除
|
||||
if (currentAccount.accountType === 'group') {
|
||||
const oldGroups = await accountGroupService.getAccountGroups(accountId)
|
||||
for (const oldGroup of oldGroups) {
|
||||
await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id)
|
||||
}
|
||||
}
|
||||
// 如果新类型是分组,处理多分组支持
|
||||
if (mappedUpdates.accountType === 'group') {
|
||||
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) {
|
||||
// 如果明确提供了 groupIds 参数(包括空数组)
|
||||
if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) {
|
||||
// 设置新的多分组
|
||||
await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude')
|
||||
} else {
|
||||
// groupIds 为空数组,从所有分组中移除
|
||||
await accountGroupService.removeAccountFromAllGroups(accountId)
|
||||
}
|
||||
} else if (mappedUpdates.groupId) {
|
||||
// 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑
|
||||
await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ccrAccountService.updateAccount(accountId, mappedUpdates)
|
||||
|
||||
logger.success(`📝 Admin updated CCR account: ${accountId}`)
|
||||
return res.json({ success: true, message: 'CCR account updated successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update CCR account:', error)
|
||||
return res.status(500).json({ error: 'Failed to update CCR account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 删除CCR账户
|
||||
router.delete('/:accountId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
// 尝试自动解绑(CCR账户实际上不会绑定API Key,但保持代码一致性)
|
||||
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'ccr')
|
||||
|
||||
// 获取账户信息以检查是否在分组中
|
||||
const account = await ccrAccountService.getAccount(accountId)
|
||||
if (account && account.accountType === 'group') {
|
||||
const groups = await accountGroupService.getAccountGroups(accountId)
|
||||
for (const group of groups) {
|
||||
await accountGroupService.removeAccountFromGroup(accountId, group.id)
|
||||
}
|
||||
}
|
||||
|
||||
await ccrAccountService.deleteAccount(accountId)
|
||||
|
||||
let message = 'CCR账号已成功删除'
|
||||
if (unboundCount > 0) {
|
||||
// 理论上不会发生,但保持消息格式一致
|
||||
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||
}
|
||||
|
||||
logger.success(`🗑️ Admin deleted CCR account: ${accountId}`)
|
||||
return res.json({
|
||||
success: true,
|
||||
message,
|
||||
unboundKeys: unboundCount
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete CCR account:', error)
|
||||
return res.status(500).json({ error: 'Failed to delete CCR account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 切换CCR账户状态
|
||||
router.put('/:accountId/toggle', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
const newStatus = !account.isActive
|
||||
await ccrAccountService.updateAccount(accountId, { isActive: newStatus })
|
||||
|
||||
logger.success(
|
||||
`🔄 Admin toggled CCR account status: ${accountId} -> ${newStatus ? 'active' : 'inactive'}`
|
||||
)
|
||||
return res.json({ success: true, isActive: newStatus })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to toggle CCR account status:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to toggle account status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 切换CCR账户调度状态
|
||||
router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
const newSchedulable = !account.schedulable
|
||||
await ccrAccountService.updateAccount(accountId, { schedulable: newSchedulable })
|
||||
|
||||
// 如果账号被禁用,发送webhook通知
|
||||
if (!newSchedulable) {
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId: account.id,
|
||||
accountName: account.name || 'CCR Account',
|
||||
platform: 'ccr',
|
||||
status: 'disabled',
|
||||
errorCode: 'CCR_MANUALLY_DISABLED',
|
||||
reason: '账号已被管理员手动禁用调度',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`🔄 Admin toggled CCR account schedulable status: ${accountId} -> ${
|
||||
newSchedulable ? 'schedulable' : 'not schedulable'
|
||||
}`
|
||||
)
|
||||
return res.json({ success: true, schedulable: newSchedulable })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to toggle CCR account schedulable status:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to toggle schedulable status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 获取CCR账户的使用统计
|
||||
router.get('/:accountId/usage', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const usageStats = await ccrAccountService.getAccountUsageStats(accountId)
|
||||
|
||||
if (!usageStats) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
return res.json(usageStats)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get CCR account usage stats:', error)
|
||||
return res.status(500).json({ error: 'Failed to get usage stats', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 手动重置CCR账户的每日使用量
|
||||
router.post('/:accountId/reset-usage', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
await ccrAccountService.resetDailyUsage(accountId)
|
||||
|
||||
logger.success(`✅ Admin manually reset daily usage for CCR account: ${accountId}`)
|
||||
return res.json({ success: true, message: 'Daily usage reset successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset CCR account daily usage:', error)
|
||||
return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 重置CCR账户状态(清除所有异常状态)
|
||||
router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const result = await ccrAccountService.resetAccountStatus(accountId)
|
||||
logger.success(`✅ Admin reset status for CCR account: ${accountId}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset CCR account status:', error)
|
||||
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 手动重置所有CCR账户的每日使用量
|
||||
router.post('/reset-all-usage', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
await ccrAccountService.resetAllDailyUsage()
|
||||
|
||||
logger.success('✅ Admin manually reset daily usage for all CCR accounts')
|
||||
return res.json({ success: true, message: 'All daily usage reset successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset all CCR accounts daily usage:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to reset all daily usage', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
1126
src/routes/admin/claudeAccounts.js
Normal file
498
src/routes/admin/claudeConsoleAccounts.js
Normal file
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Admin Routes - Claude Console 账户管理
|
||||
* API Key 方式的 Claude Console 账户
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
|
||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||
const claudeConsoleRelayService = require('../../services/claudeConsoleRelayService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||
|
||||
// 获取所有Claude Console账户
|
||||
router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { platform, groupId } = req.query
|
||||
let accounts = await claudeConsoleAccountService.getAllAccounts()
|
||||
|
||||
// 根据查询参数进行筛选
|
||||
if (platform && platform !== 'all' && platform !== 'claude-console') {
|
||||
// 如果指定了其他平台,返回空数组
|
||||
accounts = []
|
||||
}
|
||||
|
||||
// 如果指定了分组筛选
|
||||
if (groupId && groupId !== 'all') {
|
||||
if (groupId === 'ungrouped') {
|
||||
// 筛选未分组账户
|
||||
const filteredAccounts = []
|
||||
for (const account of accounts) {
|
||||
const groups = await accountGroupService.getAccountGroups(account.id)
|
||||
if (!groups || groups.length === 0) {
|
||||
filteredAccounts.push(account)
|
||||
}
|
||||
}
|
||||
accounts = filteredAccounts
|
||||
} else {
|
||||
// 筛选特定分组的账户
|
||||
const groupMembers = await accountGroupService.getGroupMembers(groupId)
|
||||
accounts = accounts.filter((account) => groupMembers.includes(account.id))
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个账户添加使用统计信息
|
||||
const accountsWithStats = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
try {
|
||||
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
// 转换schedulable为布尔值
|
||||
schedulable: account.schedulable === 'true' || account.schedulable === true,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
total: usageStats.total,
|
||||
averages: usageStats.averages
|
||||
}
|
||||
}
|
||||
} catch (statsError) {
|
||||
logger.warn(
|
||||
`⚠️ Failed to get usage stats for Claude Console account ${account.id}:`,
|
||||
statsError.message
|
||||
)
|
||||
try {
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
// 转换schedulable为布尔值
|
||||
schedulable: account.schedulable === 'true' || account.schedulable === true,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}
|
||||
} catch (groupError) {
|
||||
logger.warn(
|
||||
`⚠️ Failed to get group info for Claude Console account ${account.id}:`,
|
||||
groupError.message
|
||||
)
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
groupInfos: [],
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return res.json({ success: true, data: accountsWithStats })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Claude Console accounts:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to get Claude Console accounts', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 创建新的Claude Console账户
|
||||
router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
apiUrl,
|
||||
apiKey,
|
||||
priority,
|
||||
supportedModels,
|
||||
userAgent,
|
||||
rateLimitDuration,
|
||||
proxy,
|
||||
accountType,
|
||||
groupId,
|
||||
dailyQuota,
|
||||
quotaResetTime,
|
||||
maxConcurrentTasks,
|
||||
disableAutoProtection,
|
||||
interceptWarmup
|
||||
} = req.body
|
||||
|
||||
if (!name || !apiUrl || !apiKey) {
|
||||
return res.status(400).json({ error: 'Name, API URL and API Key are required' })
|
||||
}
|
||||
|
||||
// 验证priority的有效性(1-100)
|
||||
if (priority !== undefined && (priority < 1 || priority > 100)) {
|
||||
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
|
||||
}
|
||||
|
||||
// 验证maxConcurrentTasks的有效性(非负整数)
|
||||
if (maxConcurrentTasks !== undefined && maxConcurrentTasks !== null) {
|
||||
const concurrent = Number(maxConcurrentTasks)
|
||||
if (!Number.isInteger(concurrent) || concurrent < 0) {
|
||||
return res.status(400).json({ error: 'maxConcurrentTasks must be a non-negative integer' })
|
||||
}
|
||||
}
|
||||
|
||||
// 校验上游错误自动防护开关
|
||||
const normalizedDisableAutoProtection =
|
||||
disableAutoProtection === true || disableAutoProtection === 'true'
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||
}
|
||||
|
||||
// 如果是分组类型,验证groupId
|
||||
if (accountType === 'group' && !groupId) {
|
||||
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
|
||||
}
|
||||
|
||||
const newAccount = await claudeConsoleAccountService.createAccount({
|
||||
name,
|
||||
description,
|
||||
apiUrl,
|
||||
apiKey,
|
||||
priority: priority || 50,
|
||||
supportedModels: supportedModels || [],
|
||||
userAgent,
|
||||
rateLimitDuration:
|
||||
rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60,
|
||||
proxy,
|
||||
accountType: accountType || 'shared',
|
||||
dailyQuota: dailyQuota || 0,
|
||||
quotaResetTime: quotaResetTime || '00:00',
|
||||
maxConcurrentTasks:
|
||||
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
|
||||
? Number(maxConcurrentTasks)
|
||||
: 0,
|
||||
disableAutoProtection: normalizedDisableAutoProtection,
|
||||
interceptWarmup: interceptWarmup === true || interceptWarmup === 'true'
|
||||
})
|
||||
|
||||
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
||||
if (accountType === 'group' && groupId) {
|
||||
await accountGroupService.addAccountToGroup(newAccount.id, groupId, 'claude')
|
||||
}
|
||||
|
||||
logger.success(`🎮 Admin created Claude Console account: ${name}`)
|
||||
const formattedAccount = formatAccountExpiry(newAccount)
|
||||
return res.json({ success: true, data: formattedAccount })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create Claude Console account:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to create Claude Console account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 更新Claude Console账户
|
||||
router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const updates = req.body
|
||||
|
||||
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
|
||||
const mappedUpdates = mapExpiryField(updates, 'Claude Console', accountId)
|
||||
|
||||
// 验证priority的有效性(1-100)
|
||||
if (
|
||||
mappedUpdates.priority !== undefined &&
|
||||
(mappedUpdates.priority < 1 || mappedUpdates.priority > 100)
|
||||
) {
|
||||
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
|
||||
}
|
||||
|
||||
// 验证maxConcurrentTasks的有效性(非负整数)
|
||||
if (
|
||||
mappedUpdates.maxConcurrentTasks !== undefined &&
|
||||
mappedUpdates.maxConcurrentTasks !== null
|
||||
) {
|
||||
const concurrent = Number(mappedUpdates.maxConcurrentTasks)
|
||||
if (!Number.isInteger(concurrent) || concurrent < 0) {
|
||||
return res.status(400).json({ error: 'maxConcurrentTasks must be a non-negative integer' })
|
||||
}
|
||||
// 转换为数字类型
|
||||
mappedUpdates.maxConcurrentTasks = concurrent
|
||||
}
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (
|
||||
mappedUpdates.accountType &&
|
||||
!['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType)
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||
}
|
||||
|
||||
// 如果更新为分组类型,验证groupId
|
||||
if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) {
|
||||
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
|
||||
}
|
||||
|
||||
// 获取账户当前信息以处理分组变更
|
||||
const currentAccount = await claudeConsoleAccountService.getAccount(accountId)
|
||||
if (!currentAccount) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 规范化上游错误自动防护开关
|
||||
if (mappedUpdates.disableAutoProtection !== undefined) {
|
||||
mappedUpdates.disableAutoProtection =
|
||||
mappedUpdates.disableAutoProtection === true ||
|
||||
mappedUpdates.disableAutoProtection === 'true'
|
||||
}
|
||||
|
||||
// 处理分组的变更
|
||||
if (mappedUpdates.accountType !== undefined) {
|
||||
// 如果之前是分组类型,需要从所有分组中移除
|
||||
if (currentAccount.accountType === 'group') {
|
||||
const oldGroups = await accountGroupService.getAccountGroups(accountId)
|
||||
for (const oldGroup of oldGroups) {
|
||||
await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id)
|
||||
}
|
||||
}
|
||||
// 如果新类型是分组,处理多分组支持
|
||||
if (mappedUpdates.accountType === 'group') {
|
||||
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) {
|
||||
// 如果明确提供了 groupIds 参数(包括空数组)
|
||||
if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) {
|
||||
// 设置新的多分组
|
||||
await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude')
|
||||
} else {
|
||||
// groupIds 为空数组,从所有分组中移除
|
||||
await accountGroupService.removeAccountFromAllGroups(accountId)
|
||||
}
|
||||
} else if (mappedUpdates.groupId) {
|
||||
// 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑
|
||||
await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await claudeConsoleAccountService.updateAccount(accountId, mappedUpdates)
|
||||
|
||||
logger.success(`📝 Admin updated Claude Console account: ${accountId}`)
|
||||
return res.json({ success: true, message: 'Claude Console account updated successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update Claude Console account:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to update Claude Console account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 删除Claude Console账户
|
||||
router.delete('/claude-console-accounts/:accountId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
// 自动解绑所有绑定的 API Keys
|
||||
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'claude-console')
|
||||
|
||||
// 获取账户信息以检查是否在分组中
|
||||
const account = await claudeConsoleAccountService.getAccount(accountId)
|
||||
if (account && account.accountType === 'group') {
|
||||
const groups = await accountGroupService.getAccountGroups(accountId)
|
||||
for (const group of groups) {
|
||||
await accountGroupService.removeAccountFromGroup(accountId, group.id)
|
||||
}
|
||||
}
|
||||
|
||||
await claudeConsoleAccountService.deleteAccount(accountId)
|
||||
|
||||
let message = 'Claude Console账号已成功删除'
|
||||
if (unboundCount > 0) {
|
||||
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`🗑️ Admin deleted Claude Console account: ${accountId}, unbound ${unboundCount} keys`
|
||||
)
|
||||
return res.json({
|
||||
success: true,
|
||||
message,
|
||||
unboundKeys: unboundCount
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete Claude Console account:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to delete Claude Console account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 切换Claude Console账户状态
|
||||
router.put('/claude-console-accounts/:accountId/toggle', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const account = await claudeConsoleAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
const newStatus = !account.isActive
|
||||
await claudeConsoleAccountService.updateAccount(accountId, { isActive: newStatus })
|
||||
|
||||
logger.success(
|
||||
`🔄 Admin toggled Claude Console account status: ${accountId} -> ${
|
||||
newStatus ? 'active' : 'inactive'
|
||||
}`
|
||||
)
|
||||
return res.json({ success: true, isActive: newStatus })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to toggle Claude Console account status:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to toggle account status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 切换Claude Console账户调度状态
|
||||
router.put(
|
||||
'/claude-console-accounts/:accountId/toggle-schedulable',
|
||||
authenticateAdmin,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const account = await claudeConsoleAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
const newSchedulable = !account.schedulable
|
||||
await claudeConsoleAccountService.updateAccount(accountId, { schedulable: newSchedulable })
|
||||
|
||||
// 如果账号被禁用,发送webhook通知
|
||||
if (!newSchedulable) {
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId: account.id,
|
||||
accountName: account.name || 'Claude Console Account',
|
||||
platform: 'claude-console',
|
||||
status: 'disabled',
|
||||
errorCode: 'CLAUDE_CONSOLE_MANUALLY_DISABLED',
|
||||
reason: '账号已被管理员手动禁用调度',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`🔄 Admin toggled Claude Console account schedulable status: ${accountId} -> ${
|
||||
newSchedulable ? 'schedulable' : 'not schedulable'
|
||||
}`
|
||||
)
|
||||
return res.json({ success: true, schedulable: newSchedulable })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to toggle Claude Console account schedulable status:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to toggle schedulable status', message: error.message })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 获取Claude Console账户的使用统计
|
||||
router.get('/claude-console-accounts/:accountId/usage', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const usageStats = await claudeConsoleAccountService.getAccountUsageStats(accountId)
|
||||
|
||||
if (!usageStats) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
return res.json(usageStats)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Claude Console account usage stats:', error)
|
||||
return res.status(500).json({ error: 'Failed to get usage stats', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 手动重置Claude Console账户的每日使用量
|
||||
router.post(
|
||||
'/claude-console-accounts/:accountId/reset-usage',
|
||||
authenticateAdmin,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
await claudeConsoleAccountService.resetDailyUsage(accountId)
|
||||
|
||||
logger.success(`✅ Admin manually reset daily usage for Claude Console account: ${accountId}`)
|
||||
return res.json({ success: true, message: 'Daily usage reset successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset Claude Console account daily usage:', error)
|
||||
return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 重置Claude Console账户状态(清除所有异常状态)
|
||||
router.post(
|
||||
'/claude-console-accounts/:accountId/reset-status',
|
||||
authenticateAdmin,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const result = await claudeConsoleAccountService.resetAccountStatus(accountId)
|
||||
logger.success(`✅ Admin reset status for Claude Console account: ${accountId}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset Claude Console account status:', error)
|
||||
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 手动重置所有Claude Console账户的每日使用量
|
||||
router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
await claudeConsoleAccountService.resetAllDailyUsage()
|
||||
|
||||
logger.success('✅ Admin manually reset daily usage for all Claude Console accounts')
|
||||
return res.json({ success: true, message: 'All daily usage reset successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to reset all daily usage', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 测试Claude Console账户连通性(流式响应)- 复用 claudeConsoleRelayService
|
||||
router.post('/claude-console-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
|
||||
try {
|
||||
// 直接调用服务层的测试方法
|
||||
await claudeConsoleRelayService.testAccountConnection(accountId, res)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to test Claude Console account:`, error)
|
||||
// 错误已在服务层处理,这里仅做日志记录
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
239
src/routes/admin/claudeRelayConfig.js
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Claude 转发配置 API 路由
|
||||
* 管理全局 Claude Code 限制和会话绑定配置
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const claudeRelayConfigService = require('../../services/claudeRelayConfigService')
|
||||
const logger = require('../../utils/logger')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
/**
|
||||
* GET /admin/claude-relay-config
|
||||
* 获取 Claude 转发配置
|
||||
*/
|
||||
router.get('/claude-relay-config', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const config = await claudeRelayConfigService.getConfig()
|
||||
return res.json({
|
||||
success: true,
|
||||
config
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Claude relay config:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to get configuration',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* PUT /admin/claude-relay-config
|
||||
* 更新 Claude 转发配置
|
||||
*/
|
||||
router.put('/claude-relay-config', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
claudeCodeOnlyEnabled,
|
||||
globalSessionBindingEnabled,
|
||||
sessionBindingErrorMessage,
|
||||
sessionBindingTtlDays,
|
||||
userMessageQueueEnabled,
|
||||
userMessageQueueDelayMs,
|
||||
userMessageQueueTimeoutMs,
|
||||
concurrentRequestQueueEnabled,
|
||||
concurrentRequestQueueMaxSize,
|
||||
concurrentRequestQueueMaxSizeMultiplier,
|
||||
concurrentRequestQueueTimeoutMs
|
||||
} = req.body
|
||||
|
||||
// 验证输入
|
||||
if (claudeCodeOnlyEnabled !== undefined && typeof claudeCodeOnlyEnabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'claudeCodeOnlyEnabled must be a boolean' })
|
||||
}
|
||||
|
||||
if (
|
||||
globalSessionBindingEnabled !== undefined &&
|
||||
typeof globalSessionBindingEnabled !== 'boolean'
|
||||
) {
|
||||
return res.status(400).json({ error: 'globalSessionBindingEnabled must be a boolean' })
|
||||
}
|
||||
|
||||
if (sessionBindingErrorMessage !== undefined) {
|
||||
if (typeof sessionBindingErrorMessage !== 'string') {
|
||||
return res.status(400).json({ error: 'sessionBindingErrorMessage must be a string' })
|
||||
}
|
||||
if (sessionBindingErrorMessage.length > 500) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'sessionBindingErrorMessage must be less than 500 characters' })
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionBindingTtlDays !== undefined) {
|
||||
if (
|
||||
typeof sessionBindingTtlDays !== 'number' ||
|
||||
sessionBindingTtlDays < 1 ||
|
||||
sessionBindingTtlDays > 365
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'sessionBindingTtlDays must be a number between 1 and 365' })
|
||||
}
|
||||
}
|
||||
|
||||
// 验证用户消息队列配置
|
||||
if (userMessageQueueEnabled !== undefined && typeof userMessageQueueEnabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'userMessageQueueEnabled must be a boolean' })
|
||||
}
|
||||
|
||||
if (userMessageQueueDelayMs !== undefined) {
|
||||
if (
|
||||
typeof userMessageQueueDelayMs !== 'number' ||
|
||||
userMessageQueueDelayMs < 0 ||
|
||||
userMessageQueueDelayMs > 10000
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'userMessageQueueDelayMs must be a number between 0 and 10000' })
|
||||
}
|
||||
}
|
||||
|
||||
if (userMessageQueueTimeoutMs !== undefined) {
|
||||
if (
|
||||
typeof userMessageQueueTimeoutMs !== 'number' ||
|
||||
userMessageQueueTimeoutMs < 1000 ||
|
||||
userMessageQueueTimeoutMs > 300000
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'userMessageQueueTimeoutMs must be a number between 1000 and 300000' })
|
||||
}
|
||||
}
|
||||
|
||||
// 验证并发请求排队配置
|
||||
if (
|
||||
concurrentRequestQueueEnabled !== undefined &&
|
||||
typeof concurrentRequestQueueEnabled !== 'boolean'
|
||||
) {
|
||||
return res.status(400).json({ error: 'concurrentRequestQueueEnabled must be a boolean' })
|
||||
}
|
||||
|
||||
if (concurrentRequestQueueMaxSize !== undefined) {
|
||||
if (
|
||||
typeof concurrentRequestQueueMaxSize !== 'number' ||
|
||||
!Number.isInteger(concurrentRequestQueueMaxSize) ||
|
||||
concurrentRequestQueueMaxSize < 1 ||
|
||||
concurrentRequestQueueMaxSize > 100
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'concurrentRequestQueueMaxSize must be an integer between 1 and 100' })
|
||||
}
|
||||
}
|
||||
|
||||
if (concurrentRequestQueueMaxSizeMultiplier !== undefined) {
|
||||
// 使用 Number.isFinite() 同时排除 NaN、Infinity、-Infinity 和非数字类型
|
||||
if (
|
||||
!Number.isFinite(concurrentRequestQueueMaxSizeMultiplier) ||
|
||||
concurrentRequestQueueMaxSizeMultiplier < 0 ||
|
||||
concurrentRequestQueueMaxSizeMultiplier > 10
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: 'concurrentRequestQueueMaxSizeMultiplier must be a finite number between 0 and 10'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (concurrentRequestQueueTimeoutMs !== undefined) {
|
||||
if (
|
||||
typeof concurrentRequestQueueTimeoutMs !== 'number' ||
|
||||
!Number.isInteger(concurrentRequestQueueTimeoutMs) ||
|
||||
concurrentRequestQueueTimeoutMs < 5000 ||
|
||||
concurrentRequestQueueTimeoutMs > 300000
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
'concurrentRequestQueueTimeoutMs must be an integer between 5000 and 300000 (5 seconds to 5 minutes)'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = {}
|
||||
if (claudeCodeOnlyEnabled !== undefined) {
|
||||
updateData.claudeCodeOnlyEnabled = claudeCodeOnlyEnabled
|
||||
}
|
||||
if (globalSessionBindingEnabled !== undefined) {
|
||||
updateData.globalSessionBindingEnabled = globalSessionBindingEnabled
|
||||
}
|
||||
if (sessionBindingErrorMessage !== undefined) {
|
||||
updateData.sessionBindingErrorMessage = sessionBindingErrorMessage
|
||||
}
|
||||
if (sessionBindingTtlDays !== undefined) {
|
||||
updateData.sessionBindingTtlDays = sessionBindingTtlDays
|
||||
}
|
||||
if (userMessageQueueEnabled !== undefined) {
|
||||
updateData.userMessageQueueEnabled = userMessageQueueEnabled
|
||||
}
|
||||
if (userMessageQueueDelayMs !== undefined) {
|
||||
updateData.userMessageQueueDelayMs = userMessageQueueDelayMs
|
||||
}
|
||||
if (userMessageQueueTimeoutMs !== undefined) {
|
||||
updateData.userMessageQueueTimeoutMs = userMessageQueueTimeoutMs
|
||||
}
|
||||
if (concurrentRequestQueueEnabled !== undefined) {
|
||||
updateData.concurrentRequestQueueEnabled = concurrentRequestQueueEnabled
|
||||
}
|
||||
if (concurrentRequestQueueMaxSize !== undefined) {
|
||||
updateData.concurrentRequestQueueMaxSize = concurrentRequestQueueMaxSize
|
||||
}
|
||||
if (concurrentRequestQueueMaxSizeMultiplier !== undefined) {
|
||||
updateData.concurrentRequestQueueMaxSizeMultiplier = concurrentRequestQueueMaxSizeMultiplier
|
||||
}
|
||||
if (concurrentRequestQueueTimeoutMs !== undefined) {
|
||||
updateData.concurrentRequestQueueTimeoutMs = concurrentRequestQueueTimeoutMs
|
||||
}
|
||||
|
||||
const updatedConfig = await claudeRelayConfigService.updateConfig(
|
||||
updateData,
|
||||
req.admin?.username || 'unknown'
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Configuration updated successfully',
|
||||
config: updatedConfig
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update Claude relay config:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to update configuration',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /admin/claude-relay-config/session-bindings
|
||||
* 获取会话绑定统计
|
||||
*/
|
||||
router.get('/claude-relay-config/session-bindings', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const stats = await claudeRelayConfigService.getSessionBindingStats()
|
||||
return res.json({
|
||||
success: true,
|
||||
data: stats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get session binding stats:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to get session binding statistics',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
313
src/routes/admin/concurrency.js
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 并发管理 API 路由
|
||||
* 提供并发状态查看和手动清理功能
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const redis = require('../../models/redis')
|
||||
const logger = require('../../utils/logger')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const { calculateWaitTimeStats } = require('../../utils/statsHelper')
|
||||
|
||||
/**
|
||||
* GET /admin/concurrency
|
||||
* 获取所有并发状态
|
||||
*/
|
||||
router.get('/concurrency', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const status = await redis.getAllConcurrencyStatus()
|
||||
|
||||
// 为每个 API Key 获取排队计数
|
||||
const statusWithQueue = await Promise.all(
|
||||
status.map(async (s) => {
|
||||
const queueCount = await redis.getConcurrencyQueueCount(s.apiKeyId)
|
||||
return {
|
||||
...s,
|
||||
queueCount
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 计算汇总统计
|
||||
const summary = {
|
||||
totalKeys: statusWithQueue.length,
|
||||
totalActiveRequests: statusWithQueue.reduce((sum, s) => sum + s.activeCount, 0),
|
||||
totalExpiredRequests: statusWithQueue.reduce((sum, s) => sum + s.expiredCount, 0),
|
||||
totalQueuedRequests: statusWithQueue.reduce((sum, s) => sum + s.queueCount, 0)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
summary,
|
||||
concurrencyStatus: statusWithQueue
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get concurrency status:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get concurrency status',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /admin/concurrency-queue/stats
|
||||
* 获取排队统计信息
|
||||
*/
|
||||
router.get('/concurrency-queue/stats', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
// 获取所有有统计数据的 API Key
|
||||
const statsKeys = await redis.scanConcurrencyQueueStatsKeys()
|
||||
const queueKeys = await redis.scanConcurrencyQueueKeys()
|
||||
|
||||
// 合并所有相关的 API Key
|
||||
const allApiKeyIds = [...new Set([...statsKeys, ...queueKeys])]
|
||||
|
||||
// 获取各 API Key 的详细统计
|
||||
const perKeyStats = await Promise.all(
|
||||
allApiKeyIds.map(async (apiKeyId) => {
|
||||
const [queueCount, stats, waitTimes] = await Promise.all([
|
||||
redis.getConcurrencyQueueCount(apiKeyId),
|
||||
redis.getConcurrencyQueueStats(apiKeyId),
|
||||
redis.getQueueWaitTimes(apiKeyId)
|
||||
])
|
||||
|
||||
return {
|
||||
apiKeyId,
|
||||
currentQueueCount: queueCount,
|
||||
stats,
|
||||
waitTimeStats: calculateWaitTimeStats(waitTimes)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 获取全局等待时间统计
|
||||
const globalWaitTimes = await redis.getGlobalQueueWaitTimes()
|
||||
const globalWaitTimeStats = calculateWaitTimeStats(globalWaitTimes)
|
||||
|
||||
// 计算全局汇总
|
||||
const globalStats = {
|
||||
totalEntered: perKeyStats.reduce((sum, s) => sum + s.stats.entered, 0),
|
||||
totalSuccess: perKeyStats.reduce((sum, s) => sum + s.stats.success, 0),
|
||||
totalTimeout: perKeyStats.reduce((sum, s) => sum + s.stats.timeout, 0),
|
||||
totalCancelled: perKeyStats.reduce((sum, s) => sum + s.stats.cancelled, 0),
|
||||
totalSocketChanged: perKeyStats.reduce((sum, s) => sum + (s.stats.socket_changed || 0), 0),
|
||||
totalRejectedOverload: perKeyStats.reduce(
|
||||
(sum, s) => sum + (s.stats.rejected_overload || 0),
|
||||
0
|
||||
),
|
||||
currentTotalQueued: perKeyStats.reduce((sum, s) => sum + s.currentQueueCount, 0),
|
||||
// 队列资源利用率指标
|
||||
peakQueueSize:
|
||||
perKeyStats.length > 0 ? Math.max(...perKeyStats.map((s) => s.currentQueueCount)) : 0,
|
||||
avgQueueSize:
|
||||
perKeyStats.length > 0
|
||||
? Math.round(
|
||||
perKeyStats.reduce((sum, s) => sum + s.currentQueueCount, 0) / perKeyStats.length
|
||||
)
|
||||
: 0,
|
||||
activeApiKeys: perKeyStats.filter((s) => s.currentQueueCount > 0).length
|
||||
}
|
||||
|
||||
// 计算成功率
|
||||
if (globalStats.totalEntered > 0) {
|
||||
globalStats.successRate = Math.round(
|
||||
(globalStats.totalSuccess / globalStats.totalEntered) * 100
|
||||
)
|
||||
globalStats.timeoutRate = Math.round(
|
||||
(globalStats.totalTimeout / globalStats.totalEntered) * 100
|
||||
)
|
||||
globalStats.cancelledRate = Math.round(
|
||||
(globalStats.totalCancelled / globalStats.totalEntered) * 100
|
||||
)
|
||||
}
|
||||
|
||||
// 从全局等待时间统计中提取关键指标
|
||||
if (globalWaitTimeStats) {
|
||||
globalStats.avgWaitTimeMs = globalWaitTimeStats.avg
|
||||
globalStats.p50WaitTimeMs = globalWaitTimeStats.p50
|
||||
globalStats.p90WaitTimeMs = globalWaitTimeStats.p90
|
||||
globalStats.p99WaitTimeMs = globalWaitTimeStats.p99
|
||||
// 多实例采样策略标记(详见 design.md Decision 9)
|
||||
// 全局 P90 仅用于可视化和监控,不用于系统决策
|
||||
// 健康检查使用 API Key 级别的 P90(每 Key 独立采样)
|
||||
globalWaitTimeStats.globalP90ForVisualizationOnly = true
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
globalStats,
|
||||
globalWaitTimeStats,
|
||||
perKeyStats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get queue stats:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get queue stats',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* DELETE /admin/concurrency-queue/:apiKeyId
|
||||
* 清理特定 API Key 的排队计数
|
||||
*/
|
||||
router.delete('/concurrency-queue/:apiKeyId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { apiKeyId } = req.params
|
||||
await redis.clearConcurrencyQueue(apiKeyId)
|
||||
|
||||
logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} cleared queue for key ${apiKeyId}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Successfully cleared queue for API key ${apiKeyId}`
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to clear queue for ${req.params.apiKeyId}:`, error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to clear queue',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* DELETE /admin/concurrency-queue
|
||||
* 清理所有排队计数
|
||||
*/
|
||||
router.delete('/concurrency-queue', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const cleared = await redis.clearAllConcurrencyQueues()
|
||||
|
||||
logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} cleared ALL queues`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Successfully cleared all queues',
|
||||
cleared
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to clear all queues:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to clear all queues',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /admin/concurrency/:apiKeyId
|
||||
* 获取特定 API Key 的并发状态详情
|
||||
*/
|
||||
router.get('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { apiKeyId } = req.params
|
||||
const status = await redis.getConcurrencyStatus(apiKeyId)
|
||||
const queueCount = await redis.getConcurrencyQueueCount(apiKeyId)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
concurrencyStatus: {
|
||||
...status,
|
||||
queueCount
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to get concurrency status for ${req.params.apiKeyId}:`, error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get concurrency status',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* DELETE /admin/concurrency/:apiKeyId
|
||||
* 强制清理特定 API Key 的并发计数
|
||||
*/
|
||||
router.delete('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { apiKeyId } = req.params
|
||||
const result = await redis.forceClearConcurrency(apiKeyId)
|
||||
|
||||
logger.warn(
|
||||
`🧹 Admin ${req.admin?.username || 'unknown'} force cleared concurrency for key ${apiKeyId}`
|
||||
)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Successfully cleared concurrency for API key ${apiKeyId}`,
|
||||
result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to clear concurrency for ${req.params.apiKeyId}:`, error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to clear concurrency',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* DELETE /admin/concurrency
|
||||
* 强制清理所有并发计数
|
||||
*/
|
||||
router.delete('/concurrency', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const result = await redis.forceClearAllConcurrency()
|
||||
|
||||
logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} force cleared ALL concurrency`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Successfully cleared all concurrency',
|
||||
result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to clear all concurrency:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to clear all concurrency',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /admin/concurrency/cleanup
|
||||
* 清理过期的并发条目(不影响活跃请求)
|
||||
*/
|
||||
router.post('/concurrency/cleanup', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { apiKeyId } = req.body
|
||||
const result = await redis.cleanupExpiredConcurrency(apiKeyId || null)
|
||||
|
||||
logger.info(`🧹 Admin ${req.admin?.username || 'unknown'} cleaned up expired concurrency`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: apiKeyId
|
||||
? `Successfully cleaned up expired concurrency for API key ${apiKeyId}`
|
||||
: 'Successfully cleaned up all expired concurrency',
|
||||
result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to cleanup expired concurrency:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to cleanup expired concurrency',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
705
src/routes/admin/dashboard.js
Normal file
@@ -0,0 +1,705 @@
|
||||
const express = require('express')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||
const bedrockAccountService = require('../../services/bedrockAccountService')
|
||||
const ccrAccountService = require('../../services/ccrAccountService')
|
||||
const geminiAccountService = require('../../services/geminiAccountService')
|
||||
const droidAccountService = require('../../services/droidAccountService')
|
||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const CostCalculator = require('../../utils/costCalculator')
|
||||
const config = require('../../../config/config')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// 📊 系统统计
|
||||
|
||||
// 获取系统概览
|
||||
router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const [
|
||||
,
|
||||
apiKeys,
|
||||
claudeAccounts,
|
||||
claudeConsoleAccounts,
|
||||
geminiAccounts,
|
||||
bedrockAccountsResult,
|
||||
openaiAccounts,
|
||||
ccrAccounts,
|
||||
openaiResponsesAccounts,
|
||||
droidAccounts,
|
||||
todayStats,
|
||||
systemAverages,
|
||||
realtimeMetrics
|
||||
] = await Promise.all([
|
||||
redis.getSystemStats(),
|
||||
apiKeyService.getAllApiKeys(),
|
||||
claudeAccountService.getAllAccounts(),
|
||||
claudeConsoleAccountService.getAllAccounts(),
|
||||
geminiAccountService.getAllAccounts(),
|
||||
bedrockAccountService.getAllAccounts(),
|
||||
redis.getAllOpenAIAccounts(),
|
||||
ccrAccountService.getAllAccounts(),
|
||||
openaiResponsesAccountService.getAllAccounts(true),
|
||||
droidAccountService.getAllAccounts(),
|
||||
redis.getTodayStats(),
|
||||
redis.getSystemAverages(),
|
||||
redis.getRealtimeSystemMetrics()
|
||||
])
|
||||
|
||||
// 处理Bedrock账户数据
|
||||
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
|
||||
const normalizeBoolean = (value) => value === true || value === 'true'
|
||||
const isRateLimitedFlag = (status) => {
|
||||
if (!status) {
|
||||
return false
|
||||
}
|
||||
if (typeof status === 'string') {
|
||||
return status === 'limited'
|
||||
}
|
||||
if (typeof status === 'object') {
|
||||
return status.isRateLimited === true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const normalDroidAccounts = droidAccounts.filter(
|
||||
(acc) =>
|
||||
normalizeBoolean(acc.isActive) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
normalizeBoolean(acc.schedulable) &&
|
||||
!isRateLimitedFlag(acc.rateLimitStatus)
|
||||
).length
|
||||
const abnormalDroidAccounts = droidAccounts.filter(
|
||||
(acc) =>
|
||||
!normalizeBoolean(acc.isActive) || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedDroidAccounts = droidAccounts.filter(
|
||||
(acc) =>
|
||||
!normalizeBoolean(acc.schedulable) &&
|
||||
normalizeBoolean(acc.isActive) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedDroidAccounts = droidAccounts.filter((acc) =>
|
||||
isRateLimitedFlag(acc.rateLimitStatus)
|
||||
).length
|
||||
|
||||
// 计算使用统计(统一使用allTokens)
|
||||
const totalTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.allTokens || 0),
|
||||
0
|
||||
)
|
||||
const totalRequestsUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.requests || 0),
|
||||
0
|
||||
)
|
||||
const totalInputTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.inputTokens || 0),
|
||||
0
|
||||
)
|
||||
const totalOutputTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.outputTokens || 0),
|
||||
0
|
||||
)
|
||||
const totalCacheCreateTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.cacheCreateTokens || 0),
|
||||
0
|
||||
)
|
||||
const totalCacheReadTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.cacheReadTokens || 0),
|
||||
0
|
||||
)
|
||||
const totalAllTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.allTokens || 0),
|
||||
0
|
||||
)
|
||||
|
||||
const activeApiKeys = apiKeys.filter((key) => key.isActive).length
|
||||
|
||||
// Claude账户统计 - 根据账户管理页面的判断逻辑
|
||||
const normalClaudeAccounts = claudeAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalClaudeAccounts = claudeAccounts.filter(
|
||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedClaudeAccounts = claudeAccounts.filter(
|
||||
(acc) =>
|
||||
acc.schedulable === false &&
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedClaudeAccounts = claudeAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
|
||||
// Claude Console账户统计
|
||||
const normalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||
(acc) =>
|
||||
acc.schedulable === false &&
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
|
||||
// Gemini账户统计
|
||||
const normalGeminiAccounts = geminiAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(
|
||||
acc.rateLimitStatus === 'limited' ||
|
||||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
)
|
||||
).length
|
||||
const abnormalGeminiAccounts = geminiAccounts.filter(
|
||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedGeminiAccounts = geminiAccounts.filter(
|
||||
(acc) =>
|
||||
acc.schedulable === false &&
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedGeminiAccounts = geminiAccounts.filter(
|
||||
(acc) =>
|
||||
acc.rateLimitStatus === 'limited' ||
|
||||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
|
||||
// Bedrock账户统计
|
||||
const normalBedrockAccounts = bedrockAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalBedrockAccounts = bedrockAccounts.filter(
|
||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedBedrockAccounts = bedrockAccounts.filter(
|
||||
(acc) =>
|
||||
acc.schedulable === false &&
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedBedrockAccounts = bedrockAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
|
||||
// OpenAI账户统计
|
||||
// 注意:OpenAI账户的isActive和schedulable是字符串类型,默认值为'true'
|
||||
const normalOpenAIAccounts = openaiAccounts.filter(
|
||||
(acc) =>
|
||||
(acc.isActive === 'true' ||
|
||||
acc.isActive === true ||
|
||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== 'false' &&
|
||||
acc.schedulable !== false && // 包括'true'、true和undefined
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalOpenAIAccounts = openaiAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive === 'false' ||
|
||||
acc.isActive === false ||
|
||||
acc.status === 'blocked' ||
|
||||
acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedOpenAIAccounts = openaiAccounts.filter(
|
||||
(acc) =>
|
||||
(acc.schedulable === 'false' || acc.schedulable === false) &&
|
||||
(acc.isActive === 'true' ||
|
||||
acc.isActive === true ||
|
||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedOpenAIAccounts = openaiAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
|
||||
// CCR账户统计
|
||||
const normalCcrAccounts = ccrAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalCcrAccounts = ccrAccounts.filter(
|
||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedCcrAccounts = ccrAccounts.filter(
|
||||
(acc) =>
|
||||
acc.schedulable === false &&
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedCcrAccounts = ccrAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
|
||||
// OpenAI-Responses账户统计
|
||||
// 注意:OpenAI-Responses账户的isActive和schedulable也是字符串类型
|
||||
const normalOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
||||
(acc) =>
|
||||
(acc.isActive === 'true' ||
|
||||
acc.isActive === true ||
|
||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== 'false' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive === 'false' ||
|
||||
acc.isActive === false ||
|
||||
acc.status === 'blocked' ||
|
||||
acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
||||
(acc) =>
|
||||
(acc.schedulable === 'false' || acc.schedulable === false) &&
|
||||
(acc.isActive === 'true' ||
|
||||
acc.isActive === true ||
|
||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
|
||||
const dashboard = {
|
||||
overview: {
|
||||
totalApiKeys: apiKeys.length,
|
||||
activeApiKeys,
|
||||
// 总账户统计(所有平台)
|
||||
totalAccounts:
|
||||
claudeAccounts.length +
|
||||
claudeConsoleAccounts.length +
|
||||
geminiAccounts.length +
|
||||
bedrockAccounts.length +
|
||||
openaiAccounts.length +
|
||||
openaiResponsesAccounts.length +
|
||||
ccrAccounts.length,
|
||||
normalAccounts:
|
||||
normalClaudeAccounts +
|
||||
normalClaudeConsoleAccounts +
|
||||
normalGeminiAccounts +
|
||||
normalBedrockAccounts +
|
||||
normalOpenAIAccounts +
|
||||
normalOpenAIResponsesAccounts +
|
||||
normalCcrAccounts,
|
||||
abnormalAccounts:
|
||||
abnormalClaudeAccounts +
|
||||
abnormalClaudeConsoleAccounts +
|
||||
abnormalGeminiAccounts +
|
||||
abnormalBedrockAccounts +
|
||||
abnormalOpenAIAccounts +
|
||||
abnormalOpenAIResponsesAccounts +
|
||||
abnormalCcrAccounts +
|
||||
abnormalDroidAccounts,
|
||||
pausedAccounts:
|
||||
pausedClaudeAccounts +
|
||||
pausedClaudeConsoleAccounts +
|
||||
pausedGeminiAccounts +
|
||||
pausedBedrockAccounts +
|
||||
pausedOpenAIAccounts +
|
||||
pausedOpenAIResponsesAccounts +
|
||||
pausedCcrAccounts +
|
||||
pausedDroidAccounts,
|
||||
rateLimitedAccounts:
|
||||
rateLimitedClaudeAccounts +
|
||||
rateLimitedClaudeConsoleAccounts +
|
||||
rateLimitedGeminiAccounts +
|
||||
rateLimitedBedrockAccounts +
|
||||
rateLimitedOpenAIAccounts +
|
||||
rateLimitedOpenAIResponsesAccounts +
|
||||
rateLimitedCcrAccounts +
|
||||
rateLimitedDroidAccounts,
|
||||
// 各平台详细统计
|
||||
accountsByPlatform: {
|
||||
claude: {
|
||||
total: claudeAccounts.length,
|
||||
normal: normalClaudeAccounts,
|
||||
abnormal: abnormalClaudeAccounts,
|
||||
paused: pausedClaudeAccounts,
|
||||
rateLimited: rateLimitedClaudeAccounts
|
||||
},
|
||||
'claude-console': {
|
||||
total: claudeConsoleAccounts.length,
|
||||
normal: normalClaudeConsoleAccounts,
|
||||
abnormal: abnormalClaudeConsoleAccounts,
|
||||
paused: pausedClaudeConsoleAccounts,
|
||||
rateLimited: rateLimitedClaudeConsoleAccounts
|
||||
},
|
||||
gemini: {
|
||||
total: geminiAccounts.length,
|
||||
normal: normalGeminiAccounts,
|
||||
abnormal: abnormalGeminiAccounts,
|
||||
paused: pausedGeminiAccounts,
|
||||
rateLimited: rateLimitedGeminiAccounts
|
||||
},
|
||||
bedrock: {
|
||||
total: bedrockAccounts.length,
|
||||
normal: normalBedrockAccounts,
|
||||
abnormal: abnormalBedrockAccounts,
|
||||
paused: pausedBedrockAccounts,
|
||||
rateLimited: rateLimitedBedrockAccounts
|
||||
},
|
||||
openai: {
|
||||
total: openaiAccounts.length,
|
||||
normal: normalOpenAIAccounts,
|
||||
abnormal: abnormalOpenAIAccounts,
|
||||
paused: pausedOpenAIAccounts,
|
||||
rateLimited: rateLimitedOpenAIAccounts
|
||||
},
|
||||
ccr: {
|
||||
total: ccrAccounts.length,
|
||||
normal: normalCcrAccounts,
|
||||
abnormal: abnormalCcrAccounts,
|
||||
paused: pausedCcrAccounts,
|
||||
rateLimited: rateLimitedCcrAccounts
|
||||
},
|
||||
'openai-responses': {
|
||||
total: openaiResponsesAccounts.length,
|
||||
normal: normalOpenAIResponsesAccounts,
|
||||
abnormal: abnormalOpenAIResponsesAccounts,
|
||||
paused: pausedOpenAIResponsesAccounts,
|
||||
rateLimited: rateLimitedOpenAIResponsesAccounts
|
||||
},
|
||||
droid: {
|
||||
total: droidAccounts.length,
|
||||
normal: normalDroidAccounts,
|
||||
abnormal: abnormalDroidAccounts,
|
||||
paused: pausedDroidAccounts,
|
||||
rateLimited: rateLimitedDroidAccounts
|
||||
}
|
||||
},
|
||||
// 保留旧字段以兼容
|
||||
activeAccounts:
|
||||
normalClaudeAccounts +
|
||||
normalClaudeConsoleAccounts +
|
||||
normalGeminiAccounts +
|
||||
normalBedrockAccounts +
|
||||
normalOpenAIAccounts +
|
||||
normalOpenAIResponsesAccounts +
|
||||
normalCcrAccounts +
|
||||
normalDroidAccounts,
|
||||
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
|
||||
activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts,
|
||||
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts,
|
||||
totalGeminiAccounts: geminiAccounts.length,
|
||||
activeGeminiAccounts: normalGeminiAccounts,
|
||||
rateLimitedGeminiAccounts,
|
||||
totalTokensUsed,
|
||||
totalRequestsUsed,
|
||||
totalInputTokensUsed,
|
||||
totalOutputTokensUsed,
|
||||
totalCacheCreateTokensUsed,
|
||||
totalCacheReadTokensUsed,
|
||||
totalAllTokensUsed
|
||||
},
|
||||
recentActivity: {
|
||||
apiKeysCreatedToday: todayStats.apiKeysCreatedToday,
|
||||
requestsToday: todayStats.requestsToday,
|
||||
tokensToday: todayStats.tokensToday,
|
||||
inputTokensToday: todayStats.inputTokensToday,
|
||||
outputTokensToday: todayStats.outputTokensToday,
|
||||
cacheCreateTokensToday: todayStats.cacheCreateTokensToday || 0,
|
||||
cacheReadTokensToday: todayStats.cacheReadTokensToday || 0
|
||||
},
|
||||
systemAverages: {
|
||||
rpm: systemAverages.systemRPM,
|
||||
tpm: systemAverages.systemTPM
|
||||
},
|
||||
realtimeMetrics: {
|
||||
rpm: realtimeMetrics.realtimeRPM,
|
||||
tpm: realtimeMetrics.realtimeTPM,
|
||||
windowMinutes: realtimeMetrics.windowMinutes,
|
||||
isHistorical: realtimeMetrics.windowMinutes === 0 // 标识是否使用了历史数据
|
||||
},
|
||||
systemHealth: {
|
||||
redisConnected: redis.isConnected,
|
||||
claudeAccountsHealthy: normalClaudeAccounts + normalClaudeConsoleAccounts > 0,
|
||||
geminiAccountsHealthy: normalGeminiAccounts > 0,
|
||||
droidAccountsHealthy: normalDroidAccounts > 0,
|
||||
uptime: process.uptime()
|
||||
},
|
||||
systemTimezone: config.system.timezoneOffset || 8
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: dashboard })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get dashboard data:', error)
|
||||
return res.status(500).json({ error: 'Failed to get dashboard data', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 获取使用统计
|
||||
router.get('/usage-stats', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { period = 'daily' } = req.query // daily, monthly
|
||||
|
||||
// 获取基础API Key统计
|
||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||
|
||||
const stats = apiKeys.map((key) => ({
|
||||
keyId: key.id,
|
||||
keyName: key.name,
|
||||
usage: key.usage
|
||||
}))
|
||||
|
||||
return res.json({ success: true, data: { period, stats } })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get usage stats:', error)
|
||||
return res.status(500).json({ error: 'Failed to get usage stats', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 获取按模型的使用统计和费用
|
||||
router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { period = 'daily', startDate, endDate } = req.query // daily, monthly, 支持自定义时间范围
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const tzDate = redis.getDateInTimezone()
|
||||
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
|
||||
2,
|
||||
'0'
|
||||
)}`
|
||||
|
||||
logger.info(
|
||||
`📊 Getting global model stats, period: ${period}, startDate: ${startDate}, endDate: ${endDate}, today: ${today}, currentMonth: ${currentMonth}`
|
||||
)
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 获取所有模型的统计数据
|
||||
let searchPatterns = []
|
||||
|
||||
if (startDate && endDate) {
|
||||
// 自定义日期范围,生成多个日期的搜索模式
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
|
||||
// 确保日期范围有效
|
||||
if (start > end) {
|
||||
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
|
||||
}
|
||||
|
||||
// 限制最大范围为365天
|
||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||
if (daysDiff > 365) {
|
||||
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
|
||||
}
|
||||
|
||||
// 生成日期范围内所有日期的搜索模式
|
||||
const currentDate = new Date(start)
|
||||
while (currentDate <= end) {
|
||||
const dateStr = redis.getDateStringInTimezone(currentDate)
|
||||
searchPatterns.push(`usage:model:daily:*:${dateStr}`)
|
||||
currentDate.setDate(currentDate.getDate() + 1)
|
||||
}
|
||||
|
||||
logger.info(`📊 Generated ${searchPatterns.length} search patterns for date range`)
|
||||
} else {
|
||||
// 使用默认的period
|
||||
const pattern =
|
||||
period === 'daily'
|
||||
? `usage:model:daily:*:${today}`
|
||||
: `usage:model:monthly:*:${currentMonth}`
|
||||
searchPatterns = [pattern]
|
||||
}
|
||||
|
||||
logger.info('📊 Searching patterns:', searchPatterns)
|
||||
|
||||
// 获取所有匹配的keys
|
||||
const allKeys = []
|
||||
for (const pattern of searchPatterns) {
|
||||
const keys = await client.keys(pattern)
|
||||
allKeys.push(...keys)
|
||||
}
|
||||
|
||||
logger.info(`📊 Found ${allKeys.length} matching keys in total`)
|
||||
|
||||
// 模型名标准化函数(与redis.js保持一致)
|
||||
const normalizeModelName = (model) => {
|
||||
if (!model || model === 'unknown') {
|
||||
return model
|
||||
}
|
||||
|
||||
// 对于Bedrock模型,去掉区域前缀进行统一
|
||||
if (model.includes('.anthropic.') || model.includes('.claude')) {
|
||||
// 匹配所有AWS区域格式:region.anthropic.model-name-v1:0 -> claude-model-name
|
||||
// 支持所有AWS区域格式,如:us-east-1, eu-west-1, ap-southeast-1, ca-central-1等
|
||||
let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用)
|
||||
normalized = normalized.replace('anthropic.', '') // 去掉anthropic前缀
|
||||
normalized = normalized.replace(/-v\d+:\d+$/, '') // 去掉版本后缀(如-v1:0, -v2:1等)
|
||||
return normalized
|
||||
}
|
||||
|
||||
// 对于其他模型,去掉常见的版本后缀
|
||||
return model.replace(/-v\d+:\d+$|:latest$/, '')
|
||||
}
|
||||
|
||||
// 聚合相同模型的数据
|
||||
const modelStatsMap = new Map()
|
||||
|
||||
for (const key of allKeys) {
|
||||
const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/)
|
||||
|
||||
if (!match) {
|
||||
logger.warn(`📊 Pattern mismatch for key: ${key}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const rawModel = match[1]
|
||||
const normalizedModel = normalizeModelName(rawModel)
|
||||
const data = await client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const stats = modelStatsMap.get(normalizedModel) || {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0
|
||||
}
|
||||
|
||||
stats.requests += parseInt(data.requests) || 0
|
||||
stats.inputTokens += parseInt(data.inputTokens) || 0
|
||||
stats.outputTokens += parseInt(data.outputTokens) || 0
|
||||
stats.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
||||
stats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
||||
stats.allTokens += parseInt(data.allTokens) || 0
|
||||
|
||||
modelStatsMap.set(normalizedModel, stats)
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为数组并计算费用
|
||||
const modelStats = []
|
||||
|
||||
for (const [model, stats] of modelStatsMap) {
|
||||
const usage = {
|
||||
input_tokens: stats.inputTokens,
|
||||
output_tokens: stats.outputTokens,
|
||||
cache_creation_input_tokens: stats.cacheCreateTokens,
|
||||
cache_read_input_tokens: stats.cacheReadTokens
|
||||
}
|
||||
|
||||
// 计算费用
|
||||
const costData = CostCalculator.calculateCost(usage, model)
|
||||
|
||||
modelStats.push({
|
||||
model,
|
||||
period: startDate && endDate ? 'custom' : period,
|
||||
requests: stats.requests,
|
||||
inputTokens: usage.input_tokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
cacheCreateTokens: usage.cache_creation_input_tokens,
|
||||
cacheReadTokens: usage.cache_read_input_tokens,
|
||||
allTokens: stats.allTokens,
|
||||
usage: {
|
||||
requests: stats.requests,
|
||||
inputTokens: usage.input_tokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
cacheCreateTokens: usage.cache_creation_input_tokens,
|
||||
cacheReadTokens: usage.cache_read_input_tokens,
|
||||
totalTokens:
|
||||
usage.input_tokens +
|
||||
usage.output_tokens +
|
||||
usage.cache_creation_input_tokens +
|
||||
usage.cache_read_input_tokens
|
||||
},
|
||||
costs: costData.costs,
|
||||
formatted: costData.formatted,
|
||||
pricing: costData.pricing
|
||||
})
|
||||
}
|
||||
|
||||
// 按总费用排序
|
||||
modelStats.sort((a, b) => b.costs.total - a.costs.total)
|
||||
|
||||
logger.info(
|
||||
`📊 Returning ${modelStats.length} global model stats for period ${period}:`,
|
||||
modelStats
|
||||
)
|
||||
|
||||
return res.json({ success: true, data: modelStats })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get model stats:', error)
|
||||
return res.status(500).json({ error: 'Failed to get model stats', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 🔧 系统管理
|
||||
|
||||
// 清理过期数据
|
||||
router.post('/cleanup', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const [expiredKeys, errorAccounts] = await Promise.all([
|
||||
apiKeyService.cleanupExpiredKeys(),
|
||||
claudeAccountService.cleanupErrorAccounts()
|
||||
])
|
||||
|
||||
await redis.cleanup()
|
||||
|
||||
logger.success(
|
||||
`🧹 Admin triggered cleanup: ${expiredKeys} expired keys, ${errorAccounts} error accounts`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Cleanup completed',
|
||||
data: {
|
||||
expiredKeysRemoved: expiredKeys,
|
||||
errorAccountsReset: errorAccounts
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Cleanup failed:', error)
|
||||
return res.status(500).json({ error: 'Cleanup failed', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
527
src/routes/admin/droidAccounts.js
Normal file
@@ -0,0 +1,527 @@
|
||||
const express = require('express')
|
||||
const crypto = require('crypto')
|
||||
const droidAccountService = require('../../services/droidAccountService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const {
|
||||
startDeviceAuthorization,
|
||||
pollDeviceAuthorization,
|
||||
WorkOSDeviceAuthError
|
||||
} = require('../../utils/workosOAuthHelper')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// ==================== Droid 账户管理 API ====================
|
||||
|
||||
// 生成 Droid 设备码授权信息
|
||||
router.post('/droid-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { proxy } = req.body || {}
|
||||
const deviceAuth = await startDeviceAuthorization(proxy || null)
|
||||
|
||||
const sessionId = crypto.randomUUID()
|
||||
const expiresAt = new Date(Date.now() + deviceAuth.expiresIn * 1000).toISOString()
|
||||
|
||||
await redis.setOAuthSession(sessionId, {
|
||||
deviceCode: deviceAuth.deviceCode,
|
||||
userCode: deviceAuth.userCode,
|
||||
verificationUri: deviceAuth.verificationUri,
|
||||
verificationUriComplete: deviceAuth.verificationUriComplete,
|
||||
interval: deviceAuth.interval,
|
||||
proxy: proxy || null,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt
|
||||
})
|
||||
|
||||
logger.success('🤖 生成 Droid 设备码授权信息成功', { sessionId })
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
sessionId,
|
||||
userCode: deviceAuth.userCode,
|
||||
verificationUri: deviceAuth.verificationUri,
|
||||
verificationUriComplete: deviceAuth.verificationUriComplete,
|
||||
expiresIn: deviceAuth.expiresIn,
|
||||
interval: deviceAuth.interval,
|
||||
instructions: [
|
||||
'1. 使用下方验证码进入授权页面并确认访问权限。',
|
||||
'2. 在授权页面登录 Factory / Droid 账户并点击允许。',
|
||||
'3. 回到此处点击"完成授权"完成凭证获取。'
|
||||
]
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof WorkOSDeviceAuthError ? error.message : error.message || '未知错误'
|
||||
logger.error('❌ 生成 Droid 设备码授权失败:', message)
|
||||
return res.status(500).json({ error: 'Failed to start Droid device authorization', message })
|
||||
}
|
||||
})
|
||||
|
||||
// 交换 Droid 授权码
|
||||
router.post('/droid-accounts/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
const { sessionId, proxy } = req.body || {}
|
||||
try {
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: 'Session ID is required' })
|
||||
}
|
||||
|
||||
const oauthSession = await redis.getOAuthSession(sessionId)
|
||||
if (!oauthSession) {
|
||||
return res.status(400).json({ error: 'Invalid or expired OAuth session' })
|
||||
}
|
||||
|
||||
if (oauthSession.expiresAt && new Date() > new Date(oauthSession.expiresAt)) {
|
||||
await redis.deleteOAuthSession(sessionId)
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'OAuth session has expired, please generate a new authorization URL' })
|
||||
}
|
||||
|
||||
if (!oauthSession.deviceCode) {
|
||||
await redis.deleteOAuthSession(sessionId)
|
||||
return res.status(400).json({ error: 'OAuth session missing device code, please retry' })
|
||||
}
|
||||
|
||||
const proxyConfig = proxy || oauthSession.proxy || null
|
||||
const tokens = await pollDeviceAuthorization(oauthSession.deviceCode, proxyConfig)
|
||||
|
||||
await redis.deleteOAuthSession(sessionId)
|
||||
|
||||
logger.success('🤖 成功获取 Droid 访问令牌', { sessionId })
|
||||
return res.json({ success: true, data: { tokens } })
|
||||
} catch (error) {
|
||||
if (error instanceof WorkOSDeviceAuthError) {
|
||||
if (error.code === 'authorization_pending' || error.code === 'slow_down') {
|
||||
const oauthSession = await redis.getOAuthSession(sessionId)
|
||||
const expiresAt = oauthSession?.expiresAt ? new Date(oauthSession.expiresAt) : null
|
||||
const remainingSeconds =
|
||||
expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime())
|
||||
? Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000))
|
||||
: null
|
||||
|
||||
return res.json({
|
||||
success: false,
|
||||
pending: true,
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
retryAfter: error.retryAfter || Number(oauthSession?.interval) || 5,
|
||||
expiresIn: remainingSeconds
|
||||
})
|
||||
}
|
||||
|
||||
if (error.code === 'expired_token') {
|
||||
await redis.deleteOAuthSession(sessionId)
|
||||
return res.status(400).json({
|
||||
error: 'Device code expired',
|
||||
message: '授权已过期,请重新生成设备码并再次授权'
|
||||
})
|
||||
}
|
||||
|
||||
logger.error('❌ Droid 授权失败:', error.message)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to exchange Droid authorization code',
|
||||
message: error.message,
|
||||
errorCode: error.code
|
||||
})
|
||||
}
|
||||
|
||||
logger.error('❌ 交换 Droid 授权码失败:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to exchange Droid authorization code',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取所有 Droid 账户
|
||||
router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const accounts = await droidAccountService.getAllAccounts()
|
||||
const allApiKeys = await redis.getAllApiKeys()
|
||||
|
||||
// 添加使用统计
|
||||
const accountsWithStats = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
try {
|
||||
const usageStats = await redis.getAccountUsageStats(account.id, 'droid')
|
||||
let groupInfos = []
|
||||
try {
|
||||
groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
} catch (groupError) {
|
||||
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, groupError)
|
||||
groupInfos = []
|
||||
}
|
||||
|
||||
const groupIds = groupInfos.map((group) => group.id)
|
||||
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
|
||||
const binding = key.droidAccountId
|
||||
if (!binding) {
|
||||
return count
|
||||
}
|
||||
if (binding === account.id) {
|
||||
return count + 1
|
||||
}
|
||||
if (binding.startsWith('group:')) {
|
||||
const groupId = binding.substring('group:'.length)
|
||||
if (groupIds.includes(groupId)) {
|
||||
return count + 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}, 0)
|
||||
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
schedulable: account.schedulable === 'true',
|
||||
boundApiKeysCount,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
total: usageStats.total,
|
||||
averages: usageStats.averages
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message)
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
boundApiKeysCount: 0,
|
||||
groupInfos: [],
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0 },
|
||||
total: { tokens: 0, requests: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return res.json({ success: true, data: accountsWithStats })
|
||||
} catch (error) {
|
||||
logger.error('Failed to get Droid accounts:', error)
|
||||
return res.status(500).json({ error: 'Failed to get Droid accounts', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 创建 Droid 账户
|
||||
router.post('/droid-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountType: rawAccountType = 'shared', groupId, groupIds } = req.body
|
||||
|
||||
const normalizedAccountType = rawAccountType || 'shared'
|
||||
|
||||
if (!['shared', 'dedicated', 'group'].includes(normalizedAccountType)) {
|
||||
return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' })
|
||||
}
|
||||
|
||||
const normalizedGroupIds = Array.isArray(groupIds)
|
||||
? groupIds.filter((id) => typeof id === 'string' && id.trim())
|
||||
: []
|
||||
|
||||
if (
|
||||
normalizedAccountType === 'group' &&
|
||||
normalizedGroupIds.length === 0 &&
|
||||
(!groupId || typeof groupId !== 'string' || !groupId.trim())
|
||||
) {
|
||||
return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' })
|
||||
}
|
||||
|
||||
const accountPayload = {
|
||||
...req.body,
|
||||
accountType: normalizedAccountType
|
||||
}
|
||||
|
||||
delete accountPayload.groupId
|
||||
delete accountPayload.groupIds
|
||||
|
||||
const account = await droidAccountService.createAccount(accountPayload)
|
||||
|
||||
if (normalizedAccountType === 'group') {
|
||||
try {
|
||||
if (normalizedGroupIds.length > 0) {
|
||||
await accountGroupService.setAccountGroups(account.id, normalizedGroupIds, 'droid')
|
||||
} else if (typeof groupId === 'string' && groupId.trim()) {
|
||||
await accountGroupService.addAccountToGroup(account.id, groupId, 'droid')
|
||||
}
|
||||
} catch (groupError) {
|
||||
logger.error(`Failed to attach Droid account ${account.id} to groups:`, groupError)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to bind Droid account to groups',
|
||||
message: groupError.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`Created Droid account: ${account.name} (${account.id})`)
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return res.json({ success: true, data: formattedAccount })
|
||||
} catch (error) {
|
||||
logger.error('Failed to create Droid account:', error)
|
||||
return res.status(500).json({ error: 'Failed to create Droid account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 更新 Droid 账户
|
||||
router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const updates = { ...req.body }
|
||||
|
||||
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
|
||||
const mappedUpdates = mapExpiryField(updates, 'Droid', id)
|
||||
|
||||
const { accountType: rawAccountType, groupId, groupIds } = mappedUpdates
|
||||
|
||||
if (rawAccountType && !['shared', 'dedicated', 'group'].includes(rawAccountType)) {
|
||||
return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' })
|
||||
}
|
||||
|
||||
if (
|
||||
rawAccountType === 'group' &&
|
||||
(!groupId || typeof groupId !== 'string' || !groupId.trim()) &&
|
||||
(!Array.isArray(groupIds) || groupIds.length === 0)
|
||||
) {
|
||||
return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' })
|
||||
}
|
||||
|
||||
const currentAccount = await droidAccountService.getAccount(id)
|
||||
if (!currentAccount) {
|
||||
return res.status(404).json({ error: 'Droid account not found' })
|
||||
}
|
||||
|
||||
const normalizedGroupIds = Array.isArray(groupIds)
|
||||
? groupIds.filter((gid) => typeof gid === 'string' && gid.trim())
|
||||
: []
|
||||
const hasGroupIdsField = Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')
|
||||
const hasGroupIdField = Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupId')
|
||||
const targetAccountType = rawAccountType || currentAccount.accountType || 'shared'
|
||||
|
||||
delete mappedUpdates.groupId
|
||||
delete mappedUpdates.groupIds
|
||||
|
||||
if (rawAccountType) {
|
||||
mappedUpdates.accountType = targetAccountType
|
||||
}
|
||||
|
||||
const account = await droidAccountService.updateAccount(id, mappedUpdates)
|
||||
|
||||
try {
|
||||
if (currentAccount.accountType === 'group' && targetAccountType !== 'group') {
|
||||
await accountGroupService.removeAccountFromAllGroups(id)
|
||||
} else if (targetAccountType === 'group') {
|
||||
if (hasGroupIdsField) {
|
||||
if (normalizedGroupIds.length > 0) {
|
||||
await accountGroupService.setAccountGroups(id, normalizedGroupIds, 'droid')
|
||||
} else {
|
||||
await accountGroupService.removeAccountFromAllGroups(id)
|
||||
}
|
||||
} else if (hasGroupIdField && typeof groupId === 'string' && groupId.trim()) {
|
||||
await accountGroupService.setAccountGroups(id, [groupId], 'droid')
|
||||
}
|
||||
}
|
||||
} catch (groupError) {
|
||||
logger.error(`Failed to update Droid account ${id} groups:`, groupError)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to update Droid account groups',
|
||||
message: groupError.message
|
||||
})
|
||||
}
|
||||
|
||||
if (targetAccountType === 'group') {
|
||||
try {
|
||||
account.groupInfos = await accountGroupService.getAccountGroups(id)
|
||||
} catch (groupFetchError) {
|
||||
logger.debug(`Failed to fetch group infos for Droid account ${id}:`, groupFetchError)
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: account })
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update Droid account ${req.params.id}:`, error)
|
||||
return res.status(500).json({ error: 'Failed to update Droid account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 切换 Droid 账户调度状态
|
||||
router.put('/droid-accounts/:id/toggle-schedulable', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const account = await droidAccountService.getAccount(id)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Droid account not found' })
|
||||
}
|
||||
|
||||
const currentSchedulable = account.schedulable === true || account.schedulable === 'true'
|
||||
const newSchedulable = !currentSchedulable
|
||||
|
||||
await droidAccountService.updateAccount(id, { schedulable: newSchedulable ? 'true' : 'false' })
|
||||
|
||||
const updatedAccount = await droidAccountService.getAccount(id)
|
||||
const actualSchedulable = updatedAccount
|
||||
? updatedAccount.schedulable === true || updatedAccount.schedulable === 'true'
|
||||
: newSchedulable
|
||||
|
||||
if (!actualSchedulable) {
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId: account.id,
|
||||
accountName: account.name || 'Droid Account',
|
||||
platform: 'droid',
|
||||
status: 'disabled',
|
||||
errorCode: 'DROID_MANUALLY_DISABLED',
|
||||
reason: '账号已被管理员手动禁用调度',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`🔄 Admin toggled Droid account schedulable status: ${id} -> ${
|
||||
actualSchedulable ? 'schedulable' : 'not schedulable'
|
||||
}`
|
||||
)
|
||||
|
||||
return res.json({ success: true, schedulable: actualSchedulable })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to toggle Droid account schedulable status:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to toggle schedulable status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 获取单个 Droid 账户详细信息
|
||||
router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
// 获取账户基本信息
|
||||
const account = await droidAccountService.getAccount(id)
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
error: 'Not Found',
|
||||
message: 'Droid account not found'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取使用统计信息
|
||||
let usageStats
|
||||
try {
|
||||
usageStats = await redis.getAccountUsageStats(account.id, 'droid')
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to get usage stats for Droid account ${account.id}:`, error)
|
||||
usageStats = {
|
||||
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分组信息
|
||||
let groupInfos = []
|
||||
try {
|
||||
groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, error)
|
||||
groupInfos = []
|
||||
}
|
||||
|
||||
// 获取绑定的 API Key 数量
|
||||
const allApiKeys = await redis.getAllApiKeys()
|
||||
const groupIds = groupInfos.map((group) => group.id)
|
||||
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
|
||||
const binding = key.droidAccountId
|
||||
if (!binding) {
|
||||
return count
|
||||
}
|
||||
if (binding === account.id) {
|
||||
return count + 1
|
||||
}
|
||||
if (binding.startsWith('group:')) {
|
||||
const groupId = binding.substring('group:'.length)
|
||||
if (groupIds.includes(groupId)) {
|
||||
return count + 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}, 0)
|
||||
|
||||
// 获取解密的 API Keys(用于管理界面)
|
||||
let decryptedApiKeys = []
|
||||
try {
|
||||
decryptedApiKeys = await droidAccountService.getDecryptedApiKeyEntries(id)
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to get decrypted API keys for Droid account ${account.id}:`, error)
|
||||
decryptedApiKeys = []
|
||||
}
|
||||
|
||||
// 返回完整的账户信息,包含实际的 API Keys
|
||||
const accountDetails = {
|
||||
...account,
|
||||
// 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt
|
||||
expiresAt: account.subscriptionExpiresAt || null,
|
||||
schedulable: account.schedulable === 'true',
|
||||
boundApiKeysCount,
|
||||
groupInfos,
|
||||
// 包含实际的 API Keys(用于管理界面)
|
||||
apiKeys: decryptedApiKeys.map((entry) => ({
|
||||
key: entry.key,
|
||||
id: entry.id,
|
||||
usageCount: entry.usageCount || 0,
|
||||
lastUsedAt: entry.lastUsedAt || null,
|
||||
status: entry.status || 'active', // 使用实际的状态,默认为 active
|
||||
errorMessage: entry.errorMessage || '', // 包含错误信息
|
||||
createdAt: entry.createdAt || null
|
||||
})),
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
total: usageStats.total,
|
||||
averages: usageStats.averages
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: accountDetails
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get Droid account ${req.params.id}:`, error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to get Droid account',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 删除 Droid 账户
|
||||
router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
await droidAccountService.deleteAccount(id)
|
||||
return res.json({ success: true, message: 'Droid account deleted successfully' })
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete Droid account ${req.params.id}:`, error)
|
||||
return res.status(500).json({ error: 'Failed to delete Droid account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 刷新 Droid 账户 token
|
||||
router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const result = await droidAccountService.refreshAccessToken(id)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error(`Failed to refresh Droid account token ${req.params.id}:`, error)
|
||||
return res.status(500).json({ error: 'Failed to refresh token', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
509
src/routes/admin/geminiAccounts.js
Normal file
@@ -0,0 +1,509 @@
|
||||
const express = require('express')
|
||||
const geminiAccountService = require('../../services/geminiAccountService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// 🤖 Gemini OAuth 账户管理
|
||||
function getDefaultRedirectUri(oauthProvider) {
|
||||
if (oauthProvider === 'antigravity') {
|
||||
return process.env.ANTIGRAVITY_OAUTH_REDIRECT_URI || 'http://localhost:45462'
|
||||
}
|
||||
return process.env.GEMINI_OAUTH_REDIRECT_URI || 'https://codeassist.google.com/authcode'
|
||||
}
|
||||
|
||||
// 生成 Gemini OAuth 授权 URL
|
||||
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { state, proxy, oauthProvider } = req.body // 接收代理配置与OAuth Provider
|
||||
|
||||
const redirectUri = getDefaultRedirectUri(oauthProvider)
|
||||
|
||||
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`)
|
||||
|
||||
const {
|
||||
authUrl,
|
||||
state: authState,
|
||||
codeVerifier,
|
||||
redirectUri: finalRedirectUri,
|
||||
oauthProvider: resolvedOauthProvider
|
||||
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy, oauthProvider)
|
||||
|
||||
// 创建 OAuth 会话,包含 codeVerifier 和代理配置
|
||||
const sessionId = authState
|
||||
await redis.setOAuthSession(sessionId, {
|
||||
state: authState,
|
||||
type: 'gemini',
|
||||
redirectUri: finalRedirectUri,
|
||||
codeVerifier, // 保存 PKCE code verifier
|
||||
proxy: proxy || null, // 保存代理配置
|
||||
oauthProvider: resolvedOauthProvider,
|
||||
createdAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
logger.info(`Generated Gemini OAuth URL with session: ${sessionId}`)
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
authUrl,
|
||||
sessionId,
|
||||
oauthProvider: resolvedOauthProvider
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to generate Gemini auth URL:', error)
|
||||
return res.status(500).json({ error: 'Failed to generate auth URL', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 轮询 Gemini OAuth 授权状态
|
||||
router.post('/poll-auth-status', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.body
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: 'Session ID is required' })
|
||||
}
|
||||
|
||||
const result = await geminiAccountService.pollAuthorizationStatus(sessionId)
|
||||
|
||||
if (result.success) {
|
||||
logger.success(`✅ Gemini OAuth authorization successful for session: ${sessionId}`)
|
||||
return res.json({ success: true, data: { tokens: result.tokens } })
|
||||
} else {
|
||||
return res.json({ success: false, error: result.error })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to poll Gemini auth status:', error)
|
||||
return res.status(500).json({ error: 'Failed to poll auth status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 交换 Gemini 授权码
|
||||
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { code, sessionId, proxy: requestProxy, oauthProvider } = req.body
|
||||
let resolvedOauthProvider = oauthProvider
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Authorization code is required' })
|
||||
}
|
||||
|
||||
let redirectUri = getDefaultRedirectUri(resolvedOauthProvider)
|
||||
let codeVerifier = null
|
||||
let proxyConfig = null
|
||||
|
||||
// 如果提供了 sessionId,从 OAuth 会话中获取信息
|
||||
if (sessionId) {
|
||||
const sessionData = await redis.getOAuthSession(sessionId)
|
||||
if (sessionData) {
|
||||
const {
|
||||
redirectUri: sessionRedirectUri,
|
||||
codeVerifier: sessionCodeVerifier,
|
||||
proxy,
|
||||
oauthProvider: sessionOauthProvider
|
||||
} = sessionData
|
||||
redirectUri = sessionRedirectUri || redirectUri
|
||||
codeVerifier = sessionCodeVerifier
|
||||
proxyConfig = proxy // 获取代理配置
|
||||
if (!resolvedOauthProvider && sessionOauthProvider) {
|
||||
// 会话里保存的 provider 仅作为兜底
|
||||
resolvedOauthProvider = sessionOauthProvider
|
||||
}
|
||||
logger.info(
|
||||
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果请求体中直接提供了代理配置,优先使用它
|
||||
if (requestProxy) {
|
||||
proxyConfig = requestProxy
|
||||
logger.info(
|
||||
`Using proxy from request body: ${proxyConfig ? JSON.stringify(proxyConfig) : 'none'}`
|
||||
)
|
||||
}
|
||||
|
||||
const tokens = await geminiAccountService.exchangeCodeForTokens(
|
||||
code,
|
||||
redirectUri,
|
||||
codeVerifier,
|
||||
proxyConfig, // 传递代理配置
|
||||
resolvedOauthProvider
|
||||
)
|
||||
|
||||
// 清理 OAuth 会话
|
||||
if (sessionId) {
|
||||
await redis.deleteOAuthSession(sessionId)
|
||||
}
|
||||
|
||||
logger.success('✅ Successfully exchanged Gemini authorization code')
|
||||
return res.json({ success: true, data: { tokens, oauthProvider: resolvedOauthProvider } })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to exchange Gemini authorization code:', error)
|
||||
return res.status(500).json({ error: 'Failed to exchange code', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 获取所有 Gemini 账户
|
||||
router.get('/', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { platform, groupId } = req.query
|
||||
let accounts = await geminiAccountService.getAllAccounts()
|
||||
|
||||
// 根据查询参数进行筛选
|
||||
if (platform && platform !== 'all' && platform !== 'gemini') {
|
||||
// 如果指定了其他平台,返回空数组
|
||||
accounts = []
|
||||
}
|
||||
|
||||
// 如果指定了分组筛选
|
||||
if (groupId && groupId !== 'all') {
|
||||
if (groupId === 'ungrouped') {
|
||||
// 筛选未分组账户
|
||||
const filteredAccounts = []
|
||||
for (const account of accounts) {
|
||||
const groups = await accountGroupService.getAccountGroups(account.id)
|
||||
if (!groups || groups.length === 0) {
|
||||
filteredAccounts.push(account)
|
||||
}
|
||||
}
|
||||
accounts = filteredAccounts
|
||||
} else {
|
||||
// 筛选特定分组的账户
|
||||
const groupMembers = await accountGroupService.getGroupMembers(groupId)
|
||||
accounts = accounts.filter((account) => groupMembers.includes(account.id))
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个账户添加使用统计信息(与Claude账户相同的逻辑)
|
||||
const accountsWithStats = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
try {
|
||||
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
total: usageStats.total,
|
||||
averages: usageStats.averages
|
||||
}
|
||||
}
|
||||
} catch (statsError) {
|
||||
logger.warn(
|
||||
`⚠️ Failed to get usage stats for Gemini account ${account.id}:`,
|
||||
statsError.message
|
||||
)
|
||||
// 如果获取统计失败,返回空统计
|
||||
try {
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}
|
||||
} catch (groupError) {
|
||||
logger.warn(
|
||||
`⚠️ Failed to get group info for account ${account.id}:`,
|
||||
groupError.message
|
||||
)
|
||||
return {
|
||||
...account,
|
||||
groupInfos: [],
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return res.json({ success: true, data: accountsWithStats })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Gemini accounts:', error)
|
||||
return res.status(500).json({ error: 'Failed to get accounts', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 创建新的 Gemini 账户
|
||||
router.post('/', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const accountData = req.body
|
||||
|
||||
// 输入验证
|
||||
if (!accountData.name) {
|
||||
return res.status(400).json({ error: 'Account name is required' })
|
||||
}
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (
|
||||
accountData.accountType &&
|
||||
!['shared', 'dedicated', 'group'].includes(accountData.accountType)
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||
}
|
||||
|
||||
// 如果是分组类型,验证groupId或groupIds
|
||||
if (
|
||||
accountData.accountType === 'group' &&
|
||||
!accountData.groupId &&
|
||||
(!accountData.groupIds || accountData.groupIds.length === 0)
|
||||
) {
|
||||
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
|
||||
}
|
||||
|
||||
const newAccount = await geminiAccountService.createAccount(accountData)
|
||||
|
||||
// 如果是分组类型,处理分组绑定
|
||||
if (accountData.accountType === 'group') {
|
||||
if (accountData.groupIds && accountData.groupIds.length > 0) {
|
||||
// 多分组模式
|
||||
await accountGroupService.setAccountGroups(newAccount.id, accountData.groupIds, 'gemini')
|
||||
logger.info(
|
||||
`🏢 Added Gemini account ${newAccount.id} to groups: ${accountData.groupIds.join(', ')}`
|
||||
)
|
||||
} else if (accountData.groupId) {
|
||||
// 单分组模式(向后兼容)
|
||||
await accountGroupService.addAccountToGroup(newAccount.id, accountData.groupId, 'gemini')
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`)
|
||||
const formattedAccount = formatAccountExpiry(newAccount)
|
||||
return res.json({ success: true, data: formattedAccount })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create Gemini account:', error)
|
||||
return res.status(500).json({ error: 'Failed to create account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 更新 Gemini 账户
|
||||
router.put('/:accountId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const updates = req.body
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||
}
|
||||
|
||||
// 如果更新为分组类型,验证groupId或groupIds
|
||||
if (
|
||||
updates.accountType === 'group' &&
|
||||
!updates.groupId &&
|
||||
(!updates.groupIds || updates.groupIds.length === 0)
|
||||
) {
|
||||
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
|
||||
}
|
||||
|
||||
// 获取账户当前信息以处理分组变更
|
||||
const currentAccount = await geminiAccountService.getAccount(accountId)
|
||||
if (!currentAccount) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
|
||||
const mappedUpdates = mapExpiryField(updates, 'Gemini', accountId)
|
||||
|
||||
// 处理分组的变更
|
||||
if (mappedUpdates.accountType !== undefined) {
|
||||
// 如果之前是分组类型,需要从所有分组中移除
|
||||
if (currentAccount.accountType === 'group') {
|
||||
const oldGroups = await accountGroupService.getAccountGroups(accountId)
|
||||
for (const oldGroup of oldGroups) {
|
||||
await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id)
|
||||
}
|
||||
}
|
||||
// 如果新类型是分组,处理多分组支持
|
||||
if (mappedUpdates.accountType === 'group') {
|
||||
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) {
|
||||
// 如果明确提供了 groupIds 参数(包括空数组)
|
||||
if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) {
|
||||
// 设置新的多分组
|
||||
await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'gemini')
|
||||
} else {
|
||||
// groupIds 为空数组,从所有分组中移除
|
||||
await accountGroupService.removeAccountFromAllGroups(accountId)
|
||||
}
|
||||
} else if (mappedUpdates.groupId) {
|
||||
// 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑
|
||||
await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'gemini')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedAccount = await geminiAccountService.updateAccount(accountId, mappedUpdates)
|
||||
|
||||
logger.success(`📝 Admin updated Gemini account: ${accountId}`)
|
||||
return res.json({ success: true, data: updatedAccount })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update Gemini account:', error)
|
||||
return res.status(500).json({ error: 'Failed to update account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 删除 Gemini 账户
|
||||
router.delete('/:accountId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
// 自动解绑所有绑定的 API Keys
|
||||
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'gemini')
|
||||
|
||||
// 获取账户信息以检查是否在分组中
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
if (account && account.accountType === 'group') {
|
||||
const groups = await accountGroupService.getAccountGroups(accountId)
|
||||
for (const group of groups) {
|
||||
await accountGroupService.removeAccountFromGroup(accountId, group.id)
|
||||
}
|
||||
}
|
||||
|
||||
await geminiAccountService.deleteAccount(accountId)
|
||||
|
||||
let message = 'Gemini账号已成功删除'
|
||||
if (unboundCount > 0) {
|
||||
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||
}
|
||||
|
||||
logger.success(`🗑️ Admin deleted Gemini account: ${accountId}, unbound ${unboundCount} keys`)
|
||||
return res.json({
|
||||
success: true,
|
||||
message,
|
||||
unboundKeys: unboundCount
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete Gemini account:', error)
|
||||
return res.status(500).json({ error: 'Failed to delete account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 刷新 Gemini 账户 token
|
||||
router.post('/:accountId/refresh', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const result = await geminiAccountService.refreshAccountToken(accountId)
|
||||
|
||||
logger.success(`🔄 Admin refreshed token for Gemini account: ${accountId}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to refresh Gemini account token:', error)
|
||||
return res.status(500).json({ error: 'Failed to refresh token', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 切换 Gemini 账户调度状态
|
||||
router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 现在 account.schedulable 已经是布尔值了,直接取反即可
|
||||
const newSchedulable = !account.schedulable
|
||||
|
||||
await geminiAccountService.updateAccount(accountId, { schedulable: String(newSchedulable) })
|
||||
|
||||
// 验证更新是否成功,重新获取账户信息
|
||||
const updatedAccount = await geminiAccountService.getAccount(accountId)
|
||||
const actualSchedulable = updatedAccount ? updatedAccount.schedulable : newSchedulable
|
||||
|
||||
// 如果账号被禁用,发送webhook通知
|
||||
if (!actualSchedulable) {
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId: account.id,
|
||||
accountName: account.accountName || 'Gemini Account',
|
||||
platform: 'gemini',
|
||||
status: 'disabled',
|
||||
errorCode: 'GEMINI_MANUALLY_DISABLED',
|
||||
reason: '账号已被管理员手动禁用调度',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${
|
||||
actualSchedulable ? 'schedulable' : 'not schedulable'
|
||||
}`
|
||||
)
|
||||
|
||||
// 返回实际的数据库值,确保前端状态与后端一致
|
||||
return res.json({ success: true, schedulable: actualSchedulable })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to toggle Gemini account schedulable status:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to toggle schedulable status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 重置 Gemini OAuth 账户限流状态
|
||||
router.post('/:id/reset-rate-limit', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
await geminiAccountService.updateAccount(id, {
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: '',
|
||||
status: 'active',
|
||||
errorMessage: ''
|
||||
})
|
||||
|
||||
logger.info(`🔄 Admin manually reset rate limit for Gemini account ${id}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Rate limit reset successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset Gemini account rate limit:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 重置 Gemini OAuth 账户状态(清除所有异常状态)
|
||||
router.post('/:id/reset-status', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const result = await geminiAccountService.resetAccountStatus(id)
|
||||
|
||||
logger.success(`✅ Admin reset status for Gemini account: ${id}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset Gemini account status:', error)
|
||||
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
400
src/routes/admin/geminiApiAccounts.js
Normal file
@@ -0,0 +1,400 @@
|
||||
const express = require('express')
|
||||
const geminiApiAccountService = require('../../services/geminiApiAccountService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// 获取所有 Gemini-API 账户
|
||||
router.get('/gemini-api-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { platform, groupId } = req.query
|
||||
let accounts = await geminiApiAccountService.getAllAccounts(true)
|
||||
|
||||
// 根据查询参数进行筛选
|
||||
if (platform && platform !== 'gemini-api') {
|
||||
accounts = []
|
||||
}
|
||||
|
||||
// 根据分组ID筛选
|
||||
if (groupId) {
|
||||
const group = await accountGroupService.getGroup(groupId)
|
||||
if (group && group.platform === 'gemini') {
|
||||
const groupMembers = await accountGroupService.getGroupMembers(groupId)
|
||||
accounts = accounts.filter((account) => groupMembers.includes(account.id))
|
||||
} else {
|
||||
accounts = []
|
||||
}
|
||||
}
|
||||
|
||||
// 处理使用统计和绑定的 API Key 数量
|
||||
const accountsWithStats = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
// 检查并清除过期的限流状态
|
||||
await geminiApiAccountService.checkAndClearRateLimit(account.id)
|
||||
|
||||
// 获取使用统计信息
|
||||
let usageStats
|
||||
try {
|
||||
usageStats = await redis.getAccountUsageStats(account.id, 'gemini-api')
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to get usage stats for Gemini-API account ${account.id}:`, error)
|
||||
usageStats = {
|
||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// 计算绑定的API Key数量(支持 api: 前缀)
|
||||
const allKeys = await redis.getAllApiKeys()
|
||||
let boundCount = 0
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (key.geminiAccountId) {
|
||||
// 检查是否绑定了此 Gemini-API 账户(支持 api: 前缀)
|
||||
if (key.geminiAccountId === `api:${account.id}`) {
|
||||
boundCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分组信息
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
|
||||
return {
|
||||
...account,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
total: usageStats.total,
|
||||
averages: usageStats.averages || usageStats.monthly
|
||||
},
|
||||
boundApiKeys: boundCount
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
res.json({ success: true, data: accountsWithStats })
|
||||
} catch (error) {
|
||||
logger.error('Failed to get Gemini-API accounts:', error)
|
||||
res.status(500).json({ success: false, message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 创建 Gemini-API 账户
|
||||
router.post('/gemini-api-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountType, groupId, groupIds } = req.body
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid account type. Must be "shared", "dedicated" or "group"'
|
||||
})
|
||||
}
|
||||
|
||||
// 如果是分组类型,验证groupId或groupIds
|
||||
if (accountType === 'group' && !groupId && (!groupIds || groupIds.length === 0)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Group ID or Group IDs are required for group type accounts'
|
||||
})
|
||||
}
|
||||
|
||||
const account = await geminiApiAccountService.createAccount(req.body)
|
||||
|
||||
// 如果是分组类型,将账户添加到分组
|
||||
if (accountType === 'group') {
|
||||
if (groupIds && groupIds.length > 0) {
|
||||
// 使用多分组设置
|
||||
await accountGroupService.setAccountGroups(account.id, groupIds, 'gemini')
|
||||
} else if (groupId) {
|
||||
// 兼容单分组模式
|
||||
await accountGroupService.addAccountToGroup(account.id, groupId, 'gemini')
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`🏢 Admin created new Gemini-API account: ${account.name} (${accountType || 'shared'})`
|
||||
)
|
||||
|
||||
res.json({ success: true, data: account })
|
||||
} catch (error) {
|
||||
logger.error('Failed to create Gemini-API account:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取单个 Gemini-API 账户
|
||||
router.get('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const account = await geminiApiAccountService.getAccount(id)
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Account not found'
|
||||
})
|
||||
}
|
||||
|
||||
// 隐藏敏感信息
|
||||
account.apiKey = '***'
|
||||
|
||||
res.json({ success: true, data: account })
|
||||
} catch (error) {
|
||||
logger.error('Failed to get Gemini-API account:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 更新 Gemini-API 账户
|
||||
router.put('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const updates = req.body
|
||||
|
||||
// 验证priority的有效性(1-100)
|
||||
if (updates.priority !== undefined) {
|
||||
const priority = parseInt(updates.priority)
|
||||
if (isNaN(priority) || priority < 1 || priority > 100) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Priority must be a number between 1 and 100'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid account type. Must be "shared", "dedicated" or "group"'
|
||||
})
|
||||
}
|
||||
|
||||
// 如果更新为分组类型,验证groupId或groupIds
|
||||
if (
|
||||
updates.accountType === 'group' &&
|
||||
!updates.groupId &&
|
||||
(!updates.groupIds || updates.groupIds.length === 0)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Group ID or Group IDs are required for group type accounts'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取账户当前信息以处理分组变更
|
||||
const currentAccount = await geminiApiAccountService.getAccount(id)
|
||||
if (!currentAccount) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Account not found'
|
||||
})
|
||||
}
|
||||
|
||||
// 处理分组的变更
|
||||
if (updates.accountType !== undefined) {
|
||||
// 如果之前是分组类型,需要从所有分组中移除
|
||||
if (currentAccount.accountType === 'group') {
|
||||
await accountGroupService.removeAccountFromAllGroups(id)
|
||||
}
|
||||
|
||||
// 如果新类型是分组,添加到新分组
|
||||
if (updates.accountType === 'group') {
|
||||
// 处理多分组/单分组的兼容性
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'groupIds')) {
|
||||
if (updates.groupIds && updates.groupIds.length > 0) {
|
||||
// 使用多分组设置
|
||||
await accountGroupService.setAccountGroups(id, updates.groupIds, 'gemini')
|
||||
}
|
||||
} else if (updates.groupId) {
|
||||
// 兼容单分组模式
|
||||
await accountGroupService.addAccountToGroup(id, updates.groupId, 'gemini')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await geminiApiAccountService.updateAccount(id, updates)
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result)
|
||||
}
|
||||
|
||||
logger.success(`📝 Admin updated Gemini-API account: ${currentAccount.name}`)
|
||||
|
||||
res.json({ success: true, ...result })
|
||||
} catch (error) {
|
||||
logger.error('Failed to update Gemini-API account:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 删除 Gemini-API 账户
|
||||
router.delete('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const account = await geminiApiAccountService.getAccount(id)
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Account not found'
|
||||
})
|
||||
}
|
||||
|
||||
// 自动解绑所有绑定的 API Keys(支持 api: 前缀)
|
||||
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'gemini-api')
|
||||
|
||||
// 从所有分组中移除此账户
|
||||
if (account.accountType === 'group') {
|
||||
await accountGroupService.removeAccountFromAllGroups(id)
|
||||
logger.info(`Removed Gemini-API account ${id} from all groups`)
|
||||
}
|
||||
|
||||
const result = await geminiApiAccountService.deleteAccount(id)
|
||||
|
||||
let message = 'Gemini-API账号已成功删除'
|
||||
if (unboundCount > 0) {
|
||||
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||
}
|
||||
|
||||
logger.success(`✅ ${message}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...result,
|
||||
message,
|
||||
unboundKeys: unboundCount
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete Gemini-API account:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 切换 Gemini-API 账户调度状态
|
||||
router.put('/gemini-api-accounts/:id/toggle-schedulable', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const result = await geminiApiAccountService.toggleSchedulable(id)
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result)
|
||||
}
|
||||
|
||||
// 仅在停止调度时发送通知
|
||||
if (!result.schedulable) {
|
||||
await webhookNotifier.sendAccountEvent('account.status_changed', {
|
||||
accountId: id,
|
||||
platform: 'gemini-api',
|
||||
schedulable: result.schedulable,
|
||||
changedBy: 'admin',
|
||||
action: 'stopped_scheduling'
|
||||
})
|
||||
}
|
||||
|
||||
res.json(result)
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle Gemini-API account schedulable status:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 切换 Gemini-API 账户激活状态
|
||||
router.put('/gemini-api-accounts/:id/toggle', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const account = await geminiApiAccountService.getAccount(id)
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Account not found'
|
||||
})
|
||||
}
|
||||
|
||||
const newActiveStatus = account.isActive === 'true' ? 'false' : 'true'
|
||||
await geminiApiAccountService.updateAccount(id, {
|
||||
isActive: newActiveStatus
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
isActive: newActiveStatus === 'true'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle Gemini-API account status:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 重置 Gemini-API 账户限流状态
|
||||
router.post('/gemini-api-accounts/:id/reset-rate-limit', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
await geminiApiAccountService.updateAccount(id, {
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: '',
|
||||
status: 'active',
|
||||
errorMessage: ''
|
||||
})
|
||||
|
||||
logger.info(`🔄 Admin manually reset rate limit for Gemini-API account ${id}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Rate limit reset successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset Gemini-API account rate limit:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 重置 Gemini-API 账户状态(清除所有异常状态)
|
||||
router.post('/gemini-api-accounts/:id/reset-status', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const result = await geminiApiAccountService.resetAccountStatus(id)
|
||||
|
||||
logger.success(`✅ Admin reset status for Gemini-API account: ${id}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset Gemini-API account status:', error)
|
||||
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
54
src/routes/admin/index.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Admin Routes - 主入口文件
|
||||
* 导入并挂载所有子路由模块
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
|
||||
// 导入所有子路由
|
||||
const apiKeysRoutes = require('./apiKeys')
|
||||
const accountGroupsRoutes = require('./accountGroups')
|
||||
const claudeAccountsRoutes = require('./claudeAccounts')
|
||||
const claudeConsoleAccountsRoutes = require('./claudeConsoleAccounts')
|
||||
const ccrAccountsRoutes = require('./ccrAccounts')
|
||||
const bedrockAccountsRoutes = require('./bedrockAccounts')
|
||||
const geminiAccountsRoutes = require('./geminiAccounts')
|
||||
const geminiApiAccountsRoutes = require('./geminiApiAccounts')
|
||||
const openaiAccountsRoutes = require('./openaiAccounts')
|
||||
const azureOpenaiAccountsRoutes = require('./azureOpenaiAccounts')
|
||||
const openaiResponsesAccountsRoutes = require('./openaiResponsesAccounts')
|
||||
const droidAccountsRoutes = require('./droidAccounts')
|
||||
const dashboardRoutes = require('./dashboard')
|
||||
const usageStatsRoutes = require('./usageStats')
|
||||
const accountBalanceRoutes = require('./accountBalance')
|
||||
const systemRoutes = require('./system')
|
||||
const concurrencyRoutes = require('./concurrency')
|
||||
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||
const syncRoutes = require('./sync')
|
||||
|
||||
// 挂载所有子路由
|
||||
// 使用完整路径的模块(直接挂载到根路径)
|
||||
router.use('/', apiKeysRoutes)
|
||||
router.use('/', claudeAccountsRoutes)
|
||||
router.use('/', claudeConsoleAccountsRoutes)
|
||||
router.use('/', geminiApiAccountsRoutes)
|
||||
router.use('/', azureOpenaiAccountsRoutes)
|
||||
router.use('/', openaiResponsesAccountsRoutes)
|
||||
router.use('/', droidAccountsRoutes)
|
||||
router.use('/', dashboardRoutes)
|
||||
router.use('/', usageStatsRoutes)
|
||||
router.use('/', accountBalanceRoutes)
|
||||
router.use('/', systemRoutes)
|
||||
router.use('/', concurrencyRoutes)
|
||||
router.use('/', claudeRelayConfigRoutes)
|
||||
router.use('/', syncRoutes)
|
||||
|
||||
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||
router.use('/account-groups', accountGroupsRoutes)
|
||||
router.use('/ccr-accounts', ccrAccountsRoutes)
|
||||
router.use('/bedrock-accounts', bedrockAccountsRoutes)
|
||||
router.use('/gemini-accounts', geminiAccountsRoutes)
|
||||
router.use('/openai-accounts', openaiAccountsRoutes)
|
||||
|
||||
module.exports = router
|
||||
805
src/routes/admin/openaiAccounts.js
Normal file
@@ -0,0 +1,805 @@
|
||||
/**
|
||||
* Admin Routes - OpenAI 账户管理
|
||||
* 处理 OpenAI 账户的 CRUD 操作和 OAuth 授权流程
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
const crypto = require('crypto')
|
||||
const axios = require('axios')
|
||||
const openaiAccountService = require('../../services/openaiAccountService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const ProxyHelper = require('../../utils/proxyHelper')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// OpenAI OAuth 配置
|
||||
const OPENAI_CONFIG = {
|
||||
BASE_URL: 'https://auth.openai.com',
|
||||
CLIENT_ID: 'app_EMoamEEZ73f0CkXaXp7hrann',
|
||||
REDIRECT_URI: 'http://localhost:1455/auth/callback',
|
||||
SCOPE: 'openid profile email offline_access'
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 PKCE 参数
|
||||
* @returns {Object} 包含 codeVerifier 和 codeChallenge 的对象
|
||||
*/
|
||||
function generateOpenAIPKCE() {
|
||||
const codeVerifier = crypto.randomBytes(64).toString('hex')
|
||||
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')
|
||||
|
||||
return {
|
||||
codeVerifier,
|
||||
codeChallenge
|
||||
}
|
||||
}
|
||||
|
||||
// 生成 OpenAI OAuth 授权 URL
|
||||
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { proxy } = req.body
|
||||
|
||||
// 生成 PKCE 参数
|
||||
const pkce = generateOpenAIPKCE()
|
||||
|
||||
// 生成随机 state
|
||||
const state = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
// 创建会话 ID
|
||||
const sessionId = crypto.randomUUID()
|
||||
|
||||
// 将 PKCE 参数和代理配置存储到 Redis
|
||||
await redis.setOAuthSession(sessionId, {
|
||||
codeVerifier: pkce.codeVerifier,
|
||||
codeChallenge: pkce.codeChallenge,
|
||||
state,
|
||||
proxy: proxy || null,
|
||||
platform: 'openai',
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString()
|
||||
})
|
||||
|
||||
// 构建授权 URL 参数
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: OPENAI_CONFIG.CLIENT_ID,
|
||||
redirect_uri: OPENAI_CONFIG.REDIRECT_URI,
|
||||
scope: OPENAI_CONFIG.SCOPE,
|
||||
code_challenge: pkce.codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
id_token_add_organizations: 'true',
|
||||
codex_cli_simplified_flow: 'true'
|
||||
})
|
||||
|
||||
const authUrl = `${OPENAI_CONFIG.BASE_URL}/oauth/authorize?${params.toString()}`
|
||||
|
||||
logger.success('🔗 Generated OpenAI OAuth authorization URL')
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
authUrl,
|
||||
sessionId,
|
||||
instructions: [
|
||||
'1. 复制上面的链接到浏览器中打开',
|
||||
'2. 登录您的 OpenAI 账户',
|
||||
'3. 同意应用权限',
|
||||
'4. 复制浏览器地址栏中的完整 URL(包含 code 参数)',
|
||||
'5. 在添加账户表单中粘贴完整的回调 URL'
|
||||
]
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('生成 OpenAI OAuth URL 失败:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '生成授权链接失败',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 交换 OpenAI 授权码
|
||||
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { code, sessionId } = req.body
|
||||
|
||||
if (!code || !sessionId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '缺少必要参数'
|
||||
})
|
||||
}
|
||||
|
||||
// 从 Redis 获取会话数据
|
||||
const sessionData = await redis.getOAuthSession(sessionId)
|
||||
if (!sessionData) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '会话已过期或无效'
|
||||
})
|
||||
}
|
||||
|
||||
// 准备 token 交换请求
|
||||
const tokenData = {
|
||||
grant_type: 'authorization_code',
|
||||
code: code.trim(),
|
||||
redirect_uri: OPENAI_CONFIG.REDIRECT_URI,
|
||||
client_id: OPENAI_CONFIG.CLIENT_ID,
|
||||
code_verifier: sessionData.codeVerifier
|
||||
}
|
||||
|
||||
logger.info('Exchanging OpenAI authorization code:', {
|
||||
sessionId,
|
||||
codeLength: code.length,
|
||||
hasCodeVerifier: !!sessionData.codeVerifier
|
||||
})
|
||||
|
||||
// 配置代理(如果有)
|
||||
const axiosConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
}
|
||||
|
||||
// 配置代理(如果有)
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(sessionData.proxy)
|
||||
if (proxyAgent) {
|
||||
axiosConfig.httpAgent = proxyAgent
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
axiosConfig.proxy = false
|
||||
}
|
||||
|
||||
// 交换 authorization code 获取 tokens
|
||||
const tokenResponse = await axios.post(
|
||||
`${OPENAI_CONFIG.BASE_URL}/oauth/token`,
|
||||
new URLSearchParams(tokenData).toString(),
|
||||
axiosConfig
|
||||
)
|
||||
|
||||
const { id_token, access_token, refresh_token, expires_in } = tokenResponse.data
|
||||
|
||||
// 解析 ID token 获取用户信息
|
||||
const idTokenParts = id_token.split('.')
|
||||
if (idTokenParts.length !== 3) {
|
||||
throw new Error('Invalid ID token format')
|
||||
}
|
||||
|
||||
// 解码 JWT payload
|
||||
const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64url').toString())
|
||||
|
||||
// 获取 OpenAI 特定的声明
|
||||
const authClaims = payload['https://api.openai.com/auth'] || {}
|
||||
const accountId = authClaims.chatgpt_account_id || ''
|
||||
const chatgptUserId = authClaims.chatgpt_user_id || authClaims.user_id || ''
|
||||
const planType = authClaims.chatgpt_plan_type || ''
|
||||
|
||||
// 获取组织信息
|
||||
const organizations = authClaims.organizations || []
|
||||
const defaultOrg = organizations.find((org) => org.is_default) || organizations[0] || {}
|
||||
const organizationId = defaultOrg.id || ''
|
||||
const organizationRole = defaultOrg.role || ''
|
||||
const organizationTitle = defaultOrg.title || ''
|
||||
|
||||
// 清理 Redis 会话
|
||||
await redis.deleteOAuthSession(sessionId)
|
||||
|
||||
logger.success('✅ OpenAI OAuth token exchange successful')
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
tokens: {
|
||||
idToken: id_token,
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token,
|
||||
expires_in
|
||||
},
|
||||
accountInfo: {
|
||||
accountId,
|
||||
chatgptUserId,
|
||||
organizationId,
|
||||
organizationRole,
|
||||
organizationTitle,
|
||||
planType,
|
||||
email: payload.email || '',
|
||||
name: payload.name || '',
|
||||
emailVerified: payload.email_verified || false,
|
||||
organizations
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('OpenAI OAuth token exchange failed:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '交换授权码失败',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取所有 OpenAI 账户
|
||||
router.get('/', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { platform, groupId } = req.query
|
||||
let accounts = await openaiAccountService.getAllAccounts()
|
||||
|
||||
// 缓存账户所属分组,避免重复查询
|
||||
const accountGroupCache = new Map()
|
||||
const fetchAccountGroups = async (accountId) => {
|
||||
if (!accountGroupCache.has(accountId)) {
|
||||
const groups = await accountGroupService.getAccountGroups(accountId)
|
||||
accountGroupCache.set(accountId, groups || [])
|
||||
}
|
||||
return accountGroupCache.get(accountId)
|
||||
}
|
||||
|
||||
// 根据查询参数进行筛选
|
||||
if (platform && platform !== 'all' && platform !== 'openai') {
|
||||
// 如果指定了其他平台,返回空数组
|
||||
accounts = []
|
||||
}
|
||||
|
||||
// 如果指定了分组筛选
|
||||
if (groupId && groupId !== 'all') {
|
||||
if (groupId === 'ungrouped') {
|
||||
// 筛选未分组账户
|
||||
const filteredAccounts = []
|
||||
for (const account of accounts) {
|
||||
const groups = await fetchAccountGroups(account.id)
|
||||
if (!groups || groups.length === 0) {
|
||||
filteredAccounts.push(account)
|
||||
}
|
||||
}
|
||||
accounts = filteredAccounts
|
||||
} else {
|
||||
// 筛选特定分组的账户
|
||||
const groupMembers = await accountGroupService.getGroupMembers(groupId)
|
||||
accounts = accounts.filter((account) => groupMembers.includes(account.id))
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个账户添加使用统计信息
|
||||
const accountsWithStats = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
try {
|
||||
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
|
||||
const groupInfos = await fetchAccountGroups(account.id)
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
total: usageStats.total,
|
||||
monthly: usageStats.monthly
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to get usage stats for OpenAI account ${account.id}:`, error)
|
||||
const groupInfos = await fetchAccountGroups(account.id)
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
logger.info(`获取 OpenAI 账户列表: ${accountsWithStats.length} 个账户`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: accountsWithStats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取 OpenAI 账户列表失败:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '获取账户列表失败',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 创建 OpenAI 账户
|
||||
router.post('/', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
openaiOauth,
|
||||
accountInfo,
|
||||
proxy,
|
||||
accountType,
|
||||
groupId,
|
||||
rateLimitDuration,
|
||||
priority,
|
||||
needsImmediateRefresh, // 是否需要立即刷新
|
||||
requireRefreshSuccess // 是否必须刷新成功才能创建
|
||||
} = req.body
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '账户名称不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
// 准备账户数据
|
||||
const accountData = {
|
||||
name,
|
||||
description: description || '',
|
||||
accountType: accountType || 'shared',
|
||||
priority: priority || 50,
|
||||
rateLimitDuration:
|
||||
rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60,
|
||||
openaiOauth: openaiOauth || {},
|
||||
accountInfo: accountInfo || {},
|
||||
proxy: proxy || null,
|
||||
isActive: true,
|
||||
schedulable: true
|
||||
}
|
||||
|
||||
// 如果需要立即刷新且必须成功(OpenAI 手动模式)
|
||||
if (needsImmediateRefresh && requireRefreshSuccess) {
|
||||
// 先创建临时账户以测试刷新
|
||||
const tempAccount = await openaiAccountService.createAccount(accountData)
|
||||
|
||||
try {
|
||||
logger.info(`🔄 测试刷新 OpenAI 账户以获取完整 token 信息`)
|
||||
|
||||
// 尝试刷新 token(会自动使用账户配置的代理)
|
||||
await openaiAccountService.refreshAccountToken(tempAccount.id)
|
||||
|
||||
// 刷新成功,获取更新后的账户信息
|
||||
const refreshedAccount = await openaiAccountService.getAccount(tempAccount.id)
|
||||
|
||||
// 检查是否获取到了 ID Token
|
||||
if (!refreshedAccount.idToken || refreshedAccount.idToken === '') {
|
||||
// 没有获取到 ID Token,删除账户
|
||||
await openaiAccountService.deleteAccount(tempAccount.id)
|
||||
throw new Error('无法获取 ID Token,请检查 Refresh Token 是否有效')
|
||||
}
|
||||
|
||||
// 如果是分组类型,添加到分组
|
||||
if (accountType === 'group' && groupId) {
|
||||
await accountGroupService.addAccountToGroup(tempAccount.id, groupId, 'openai')
|
||||
}
|
||||
|
||||
// 清除敏感信息后返回
|
||||
delete refreshedAccount.idToken
|
||||
delete refreshedAccount.accessToken
|
||||
delete refreshedAccount.refreshToken
|
||||
|
||||
logger.success(`✅ 创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: refreshedAccount,
|
||||
message: '账户创建成功,并已获取完整 token 信息'
|
||||
})
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,删除临时创建的账户
|
||||
logger.warn(`❌ 刷新失败,删除临时账户: ${refreshError.message}`)
|
||||
await openaiAccountService.deleteAccount(tempAccount.id)
|
||||
|
||||
// 构建详细的错误信息
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
message: '账户创建失败',
|
||||
error: refreshError.message
|
||||
}
|
||||
|
||||
// 添加更详细的错误信息
|
||||
if (refreshError.status) {
|
||||
errorResponse.errorCode = refreshError.status
|
||||
}
|
||||
if (refreshError.details) {
|
||||
errorResponse.errorDetails = refreshError.details
|
||||
}
|
||||
if (refreshError.code) {
|
||||
errorResponse.networkError = refreshError.code
|
||||
}
|
||||
|
||||
// 提供更友好的错误提示
|
||||
if (refreshError.message.includes('Refresh Token 无效')) {
|
||||
errorResponse.suggestion = '请检查 Refresh Token 是否正确,或重新通过 OAuth 授权获取'
|
||||
} else if (refreshError.message.includes('代理')) {
|
||||
errorResponse.suggestion = '请检查代理配置是否正确,包括地址、端口和认证信息'
|
||||
} else if (refreshError.message.includes('过于频繁')) {
|
||||
errorResponse.suggestion = '请稍后再试,或更换代理 IP'
|
||||
} else if (refreshError.message.includes('连接')) {
|
||||
errorResponse.suggestion = '请检查网络连接和代理设置'
|
||||
}
|
||||
|
||||
return res.status(400).json(errorResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// 不需要强制刷新的情况(OAuth 模式或其他平台)
|
||||
const createdAccount = await openaiAccountService.createAccount(accountData)
|
||||
|
||||
// 如果是分组类型,添加到分组
|
||||
if (accountType === 'group' && groupId) {
|
||||
await accountGroupService.addAccountToGroup(createdAccount.id, groupId, 'openai')
|
||||
}
|
||||
|
||||
// 如果需要刷新但不强制成功(OAuth 模式可能已有完整信息)
|
||||
if (needsImmediateRefresh && !requireRefreshSuccess) {
|
||||
try {
|
||||
logger.info(`🔄 尝试刷新 OpenAI 账户 ${createdAccount.id}`)
|
||||
await openaiAccountService.refreshAccountToken(createdAccount.id)
|
||||
logger.info(`✅ 刷新成功`)
|
||||
} catch (refreshError) {
|
||||
logger.warn(`⚠️ 刷新失败,但账户已创建: ${refreshError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: createdAccount
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('创建 OpenAI 账户失败:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '创建账户失败',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 更新 OpenAI 账户
|
||||
router.put('/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const updates = req.body
|
||||
|
||||
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
|
||||
const mappedUpdates = mapExpiryField(updates, 'OpenAI', id)
|
||||
|
||||
const { needsImmediateRefresh, requireRefreshSuccess } = mappedUpdates
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (
|
||||
mappedUpdates.accountType &&
|
||||
!['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType)
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||
}
|
||||
|
||||
// 如果更新为分组类型,验证groupId
|
||||
if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) {
|
||||
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
|
||||
}
|
||||
|
||||
// 获取账户当前信息以处理分组变更
|
||||
const currentAccount = await openaiAccountService.getAccount(id)
|
||||
if (!currentAccount) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 如果更新了 Refresh Token,需要验证其有效性
|
||||
if (mappedUpdates.openaiOauth?.refreshToken && needsImmediateRefresh && requireRefreshSuccess) {
|
||||
// 先更新 token 信息
|
||||
const tempUpdateData = {}
|
||||
if (mappedUpdates.openaiOauth.refreshToken) {
|
||||
tempUpdateData.refreshToken = mappedUpdates.openaiOauth.refreshToken
|
||||
}
|
||||
if (mappedUpdates.openaiOauth.accessToken) {
|
||||
tempUpdateData.accessToken = mappedUpdates.openaiOauth.accessToken
|
||||
}
|
||||
// 更新代理配置(如果有)
|
||||
if (mappedUpdates.proxy !== undefined) {
|
||||
tempUpdateData.proxy = mappedUpdates.proxy
|
||||
}
|
||||
|
||||
// 临时更新账户以测试新的 token
|
||||
await openaiAccountService.updateAccount(id, tempUpdateData)
|
||||
|
||||
try {
|
||||
logger.info(`🔄 验证更新的 OpenAI token (账户: ${id})`)
|
||||
|
||||
// 尝试刷新 token(会使用账户配置的代理)
|
||||
await openaiAccountService.refreshAccountToken(id)
|
||||
|
||||
// 获取刷新后的账户信息
|
||||
const refreshedAccount = await openaiAccountService.getAccount(id)
|
||||
|
||||
// 检查是否获取到了 ID Token
|
||||
if (!refreshedAccount.idToken || refreshedAccount.idToken === '') {
|
||||
// 恢复原始 token
|
||||
await openaiAccountService.updateAccount(id, {
|
||||
refreshToken: currentAccount.refreshToken,
|
||||
accessToken: currentAccount.accessToken,
|
||||
idToken: currentAccount.idToken
|
||||
})
|
||||
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无法获取 ID Token,请检查 Refresh Token 是否有效',
|
||||
error: 'Invalid refresh token'
|
||||
})
|
||||
}
|
||||
|
||||
logger.success(`✅ Token 验证成功,继续更新账户信息`)
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,恢复原始 token
|
||||
logger.warn(`❌ Token 验证失败,恢复原始配置: ${refreshError.message}`)
|
||||
await openaiAccountService.updateAccount(id, {
|
||||
refreshToken: currentAccount.refreshToken,
|
||||
accessToken: currentAccount.accessToken,
|
||||
idToken: currentAccount.idToken,
|
||||
proxy: currentAccount.proxy
|
||||
})
|
||||
|
||||
// 构建详细的错误信息
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
message: '更新失败',
|
||||
error: refreshError.message
|
||||
}
|
||||
|
||||
// 添加更详细的错误信息
|
||||
if (refreshError.status) {
|
||||
errorResponse.errorCode = refreshError.status
|
||||
}
|
||||
if (refreshError.details) {
|
||||
errorResponse.errorDetails = refreshError.details
|
||||
}
|
||||
if (refreshError.code) {
|
||||
errorResponse.networkError = refreshError.code
|
||||
}
|
||||
|
||||
// 提供更友好的错误提示
|
||||
if (refreshError.message.includes('Refresh Token 无效')) {
|
||||
errorResponse.suggestion = '请检查 Refresh Token 是否正确,或重新通过 OAuth 授权获取'
|
||||
} else if (refreshError.message.includes('代理')) {
|
||||
errorResponse.suggestion = '请检查代理配置是否正确,包括地址、端口和认证信息'
|
||||
} else if (refreshError.message.includes('过于频繁')) {
|
||||
errorResponse.suggestion = '请稍后再试,或更换代理 IP'
|
||||
} else if (refreshError.message.includes('连接')) {
|
||||
errorResponse.suggestion = '请检查网络连接和代理设置'
|
||||
}
|
||||
|
||||
return res.status(400).json(errorResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理分组的变更
|
||||
if (mappedUpdates.accountType !== undefined) {
|
||||
// 如果之前是分组类型,需要从原分组中移除
|
||||
if (currentAccount.accountType === 'group') {
|
||||
const oldGroup = await accountGroupService.getAccountGroup(id)
|
||||
if (oldGroup) {
|
||||
await accountGroupService.removeAccountFromGroup(id, oldGroup.id)
|
||||
}
|
||||
}
|
||||
// 如果新类型是分组,添加到新分组
|
||||
if (mappedUpdates.accountType === 'group' && mappedUpdates.groupId) {
|
||||
await accountGroupService.addAccountToGroup(id, mappedUpdates.groupId, 'openai')
|
||||
}
|
||||
}
|
||||
|
||||
// 准备更新数据
|
||||
const updateData = { ...mappedUpdates }
|
||||
|
||||
// 处理敏感数据加密
|
||||
if (mappedUpdates.openaiOauth) {
|
||||
updateData.openaiOauth = mappedUpdates.openaiOauth
|
||||
// 编辑时不允许直接输入 ID Token,只能通过刷新获取
|
||||
if (mappedUpdates.openaiOauth.accessToken) {
|
||||
updateData.accessToken = mappedUpdates.openaiOauth.accessToken
|
||||
}
|
||||
if (mappedUpdates.openaiOauth.refreshToken) {
|
||||
updateData.refreshToken = mappedUpdates.openaiOauth.refreshToken
|
||||
}
|
||||
if (mappedUpdates.openaiOauth.expires_in) {
|
||||
updateData.expiresAt = new Date(
|
||||
Date.now() + mappedUpdates.openaiOauth.expires_in * 1000
|
||||
).toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 更新账户信息
|
||||
if (mappedUpdates.accountInfo) {
|
||||
updateData.accountId = mappedUpdates.accountInfo.accountId || currentAccount.accountId
|
||||
updateData.chatgptUserId =
|
||||
mappedUpdates.accountInfo.chatgptUserId || currentAccount.chatgptUserId
|
||||
updateData.organizationId =
|
||||
mappedUpdates.accountInfo.organizationId || currentAccount.organizationId
|
||||
updateData.organizationRole =
|
||||
mappedUpdates.accountInfo.organizationRole || currentAccount.organizationRole
|
||||
updateData.organizationTitle =
|
||||
mappedUpdates.accountInfo.organizationTitle || currentAccount.organizationTitle
|
||||
updateData.planType = mappedUpdates.accountInfo.planType || currentAccount.planType
|
||||
updateData.email = mappedUpdates.accountInfo.email || currentAccount.email
|
||||
updateData.emailVerified =
|
||||
mappedUpdates.accountInfo.emailVerified !== undefined
|
||||
? mappedUpdates.accountInfo.emailVerified
|
||||
: currentAccount.emailVerified
|
||||
}
|
||||
|
||||
const updatedAccount = await openaiAccountService.updateAccount(id, updateData)
|
||||
|
||||
// 如果需要刷新但不强制成功(非关键更新)
|
||||
if (needsImmediateRefresh && !requireRefreshSuccess) {
|
||||
try {
|
||||
logger.info(`🔄 尝试刷新 OpenAI 账户 ${id}`)
|
||||
await openaiAccountService.refreshAccountToken(id)
|
||||
logger.info(`✅ 刷新成功`)
|
||||
} catch (refreshError) {
|
||||
logger.warn(`⚠️ 刷新失败,但账户信息已更新: ${refreshError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`📝 Admin updated OpenAI account: ${id}`)
|
||||
return res.json({ success: true, data: updatedAccount })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update OpenAI account:', error)
|
||||
return res.status(500).json({ error: 'Failed to update account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 删除 OpenAI 账户
|
||||
router.delete('/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const account = await openaiAccountService.getAccount(id)
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '账户不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 自动解绑所有绑定的 API Keys
|
||||
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'openai')
|
||||
|
||||
// 如果账户在分组中,从分组中移除
|
||||
if (account.accountType === 'group') {
|
||||
const group = await accountGroupService.getAccountGroup(id)
|
||||
if (group) {
|
||||
await accountGroupService.removeAccountFromGroup(id, group.id)
|
||||
}
|
||||
}
|
||||
|
||||
await openaiAccountService.deleteAccount(id)
|
||||
|
||||
let message = 'OpenAI账号已成功删除'
|
||||
if (unboundCount > 0) {
|
||||
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ 删除 OpenAI 账户成功: ${account.name} (ID: ${id}), unbound ${unboundCount} keys`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message,
|
||||
unboundKeys: unboundCount
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('删除 OpenAI 账户失败:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '删除账户失败',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 切换 OpenAI 账户状态
|
||||
router.put('/:id/toggle', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const account = await redis.getOpenAiAccount(id)
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '账户不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 切换启用状态
|
||||
account.enabled = !account.enabled
|
||||
account.updatedAt = new Date().toISOString()
|
||||
|
||||
// TODO: 更新方法
|
||||
// await redis.updateOpenAiAccount(id, account)
|
||||
|
||||
logger.success(
|
||||
`✅ ${account.enabled ? '启用' : '禁用'} OpenAI 账户: ${account.name} (ID: ${id})`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: account
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('切换 OpenAI 账户状态失败:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '切换账户状态失败',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 重置 OpenAI 账户状态(清除所有异常状态)
|
||||
router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const result = await openaiAccountService.resetAccountStatus(accountId)
|
||||
|
||||
logger.success(`✅ Admin reset status for OpenAI account: ${accountId}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset OpenAI account status:', error)
|
||||
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 切换 OpenAI 账户调度状态
|
||||
router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const result = await openaiAccountService.toggleSchedulable(accountId)
|
||||
|
||||
// 如果账号被禁用,发送webhook通知
|
||||
if (!result.schedulable) {
|
||||
// 获取账号信息
|
||||
const account = await redis.getOpenAiAccount(accountId)
|
||||
if (account) {
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId: account.id,
|
||||
accountName: account.name || 'OpenAI Account',
|
||||
platform: 'openai',
|
||||
status: 'disabled',
|
||||
errorCode: 'OPENAI_MANUALLY_DISABLED',
|
||||
reason: '账号已被管理员手动禁用调度',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: result.success,
|
||||
schedulable: result.schedulable,
|
||||
message: result.schedulable ? '已启用调度' : '已禁用调度'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('切换 OpenAI 账户调度状态失败:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '切换调度状态失败',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
450
src/routes/admin/openaiResponsesAccounts.js
Normal file
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* Admin Routes - OpenAI-Responses 账户管理
|
||||
* 处理 OpenAI-Responses 账户的增删改查和状态管理
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// ==================== OpenAI-Responses 账户管理 API ====================
|
||||
|
||||
// 获取所有 OpenAI-Responses 账户
|
||||
router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { platform, groupId } = req.query
|
||||
let accounts = await openaiResponsesAccountService.getAllAccounts(true)
|
||||
|
||||
// 根据查询参数进行筛选
|
||||
if (platform && platform !== 'openai-responses') {
|
||||
accounts = []
|
||||
}
|
||||
|
||||
// 根据分组ID筛选
|
||||
if (groupId) {
|
||||
const group = await accountGroupService.getGroup(groupId)
|
||||
if (group && group.platform === 'openai') {
|
||||
const groupMembers = await accountGroupService.getGroupMembers(groupId)
|
||||
accounts = accounts.filter((account) => groupMembers.includes(account.id))
|
||||
} else {
|
||||
accounts = []
|
||||
}
|
||||
}
|
||||
|
||||
// 处理额度信息、使用统计和绑定的 API Key 数量
|
||||
const accountsWithStats = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
try {
|
||||
// 检查是否需要重置额度
|
||||
const today = redis.getDateStringInTimezone()
|
||||
if (account.lastResetDate !== today) {
|
||||
// 今天还没重置过,需要重置
|
||||
await openaiResponsesAccountService.updateAccount(account.id, {
|
||||
dailyUsage: '0',
|
||||
lastResetDate: today,
|
||||
quotaStoppedAt: ''
|
||||
})
|
||||
account.dailyUsage = '0'
|
||||
account.lastResetDate = today
|
||||
account.quotaStoppedAt = ''
|
||||
}
|
||||
|
||||
// 检查并清除过期的限流状态
|
||||
await openaiResponsesAccountService.checkAndClearRateLimit(account.id)
|
||||
|
||||
// 获取使用统计信息
|
||||
let usageStats
|
||||
try {
|
||||
usageStats = await redis.getAccountUsageStats(account.id, 'openai-responses')
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to get usage stats for OpenAI-Responses account ${account.id}:`,
|
||||
error
|
||||
)
|
||||
usageStats = {
|
||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// 计算绑定的API Key数量(支持 responses: 前缀)
|
||||
const allKeys = await redis.getAllApiKeys()
|
||||
let boundCount = 0
|
||||
|
||||
for (const key of allKeys) {
|
||||
// 检查是否绑定了该账户(包括 responses: 前缀)
|
||||
if (
|
||||
key.openaiAccountId === account.id ||
|
||||
key.openaiAccountId === `responses:${account.id}`
|
||||
) {
|
||||
boundCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 调试日志:检查绑定计数
|
||||
if (boundCount > 0) {
|
||||
logger.info(`OpenAI-Responses account ${account.id} has ${boundCount} bound API keys`)
|
||||
}
|
||||
|
||||
// 获取分组信息
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
groupInfos,
|
||||
boundApiKeysCount: boundCount,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
total: usageStats.total,
|
||||
monthly: usageStats.monthly
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to process OpenAI-Responses account ${account.id}:`, error)
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
groupInfos: [],
|
||||
boundApiKeysCount: 0,
|
||||
usage: {
|
||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
res.json({ success: true, data: accountsWithStats })
|
||||
} catch (error) {
|
||||
logger.error('Failed to get OpenAI-Responses accounts:', error)
|
||||
res.status(500).json({ success: false, message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 创建 OpenAI-Responses 账户
|
||||
router.post('/openai-responses-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const accountData = req.body
|
||||
|
||||
// 验证分组类型
|
||||
if (
|
||||
accountData.accountType === 'group' &&
|
||||
!accountData.groupId &&
|
||||
(!accountData.groupIds || accountData.groupIds.length === 0)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Group ID is required for group type accounts'
|
||||
})
|
||||
}
|
||||
|
||||
const account = await openaiResponsesAccountService.createAccount(accountData)
|
||||
|
||||
// 如果是分组类型,处理分组绑定
|
||||
if (accountData.accountType === 'group') {
|
||||
if (accountData.groupIds && accountData.groupIds.length > 0) {
|
||||
// 多分组模式
|
||||
await accountGroupService.setAccountGroups(account.id, accountData.groupIds, 'openai')
|
||||
logger.info(
|
||||
`🏢 Added OpenAI-Responses account ${account.id} to groups: ${accountData.groupIds.join(', ')}`
|
||||
)
|
||||
} else if (accountData.groupId) {
|
||||
// 单分组模式(向后兼容)
|
||||
await accountGroupService.addAccountToGroup(account.id, accountData.groupId, 'openai')
|
||||
logger.info(
|
||||
`🏢 Added OpenAI-Responses account ${account.id} to group: ${accountData.groupId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
res.json({ success: true, data: formattedAccount })
|
||||
} catch (error) {
|
||||
logger.error('Failed to create OpenAI-Responses account:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 更新 OpenAI-Responses 账户
|
||||
router.put('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const updates = req.body
|
||||
|
||||
// 获取当前账户信息
|
||||
const currentAccount = await openaiResponsesAccountService.getAccount(id)
|
||||
if (!currentAccount) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Account not found'
|
||||
})
|
||||
}
|
||||
|
||||
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
|
||||
const mappedUpdates = mapExpiryField(updates, 'OpenAI-Responses', id)
|
||||
|
||||
// 验证priority的有效性(1-100)
|
||||
if (mappedUpdates.priority !== undefined) {
|
||||
const priority = parseInt(mappedUpdates.priority)
|
||||
if (isNaN(priority) || priority < 1 || priority > 100) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Priority must be a number between 1 and 100'
|
||||
})
|
||||
}
|
||||
mappedUpdates.priority = priority.toString()
|
||||
}
|
||||
|
||||
// 处理分组变更
|
||||
if (mappedUpdates.accountType !== undefined) {
|
||||
// 如果之前是分组类型,需要从所有分组中移除
|
||||
if (currentAccount.accountType === 'group') {
|
||||
const oldGroups = await accountGroupService.getAccountGroups(id)
|
||||
for (const oldGroup of oldGroups) {
|
||||
await accountGroupService.removeAccountFromGroup(id, oldGroup.id)
|
||||
}
|
||||
logger.info(`📤 Removed OpenAI-Responses account ${id} from all groups`)
|
||||
}
|
||||
|
||||
// 如果新类型是分组,处理多分组支持
|
||||
if (mappedUpdates.accountType === 'group') {
|
||||
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) {
|
||||
if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) {
|
||||
// 设置新的多分组
|
||||
await accountGroupService.setAccountGroups(id, mappedUpdates.groupIds, 'openai')
|
||||
logger.info(
|
||||
`📥 Added OpenAI-Responses account ${id} to groups: ${mappedUpdates.groupIds.join(', ')}`
|
||||
)
|
||||
} else {
|
||||
// groupIds 为空数组,从所有分组中移除
|
||||
await accountGroupService.removeAccountFromAllGroups(id)
|
||||
logger.info(
|
||||
`📤 Removed OpenAI-Responses account ${id} from all groups (empty groupIds)`
|
||||
)
|
||||
}
|
||||
} else if (mappedUpdates.groupId) {
|
||||
// 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑
|
||||
await accountGroupService.addAccountToGroup(id, mappedUpdates.groupId, 'openai')
|
||||
logger.info(`📥 Added OpenAI-Responses account ${id} to group: ${mappedUpdates.groupId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await openaiResponsesAccountService.updateAccount(id, mappedUpdates)
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result)
|
||||
}
|
||||
|
||||
logger.success(`📝 Admin updated OpenAI-Responses account: ${id}`)
|
||||
res.json({ success: true, ...result })
|
||||
} catch (error) {
|
||||
logger.error('Failed to update OpenAI-Responses account:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 删除 OpenAI-Responses 账户
|
||||
router.delete('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const account = await openaiResponsesAccountService.getAccount(id)
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Account not found'
|
||||
})
|
||||
}
|
||||
|
||||
// 自动解绑所有绑定的 API Keys
|
||||
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'openai-responses')
|
||||
|
||||
// 从所有分组中移除此账户
|
||||
if (account.accountType === 'group') {
|
||||
await accountGroupService.removeAccountFromAllGroups(id)
|
||||
logger.info(`Removed OpenAI-Responses account ${id} from all groups`)
|
||||
}
|
||||
|
||||
const result = await openaiResponsesAccountService.deleteAccount(id)
|
||||
|
||||
let message = 'OpenAI-Responses账号已成功删除'
|
||||
if (unboundCount > 0) {
|
||||
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||
}
|
||||
|
||||
logger.success(`🗑️ Admin deleted OpenAI-Responses account: ${id}, unbound ${unboundCount} keys`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...result,
|
||||
message,
|
||||
unboundKeys: unboundCount
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete OpenAI-Responses account:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 切换 OpenAI-Responses 账户调度状态
|
||||
router.put(
|
||||
'/openai-responses-accounts/:id/toggle-schedulable',
|
||||
authenticateAdmin,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const result = await openaiResponsesAccountService.toggleSchedulable(id)
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result)
|
||||
}
|
||||
|
||||
// 仅在停止调度时发送通知
|
||||
if (!result.schedulable) {
|
||||
await webhookNotifier.sendAccountEvent('account.status_changed', {
|
||||
accountId: id,
|
||||
platform: 'openai-responses',
|
||||
schedulable: result.schedulable,
|
||||
changedBy: 'admin',
|
||||
action: 'stopped_scheduling'
|
||||
})
|
||||
}
|
||||
|
||||
res.json(result)
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle OpenAI-Responses account schedulable status:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 切换 OpenAI-Responses 账户激活状态
|
||||
router.put('/openai-responses-accounts/:id/toggle', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const account = await openaiResponsesAccountService.getAccount(id)
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Account not found'
|
||||
})
|
||||
}
|
||||
|
||||
const newActiveStatus = account.isActive === 'true' ? 'false' : 'true'
|
||||
await openaiResponsesAccountService.updateAccount(id, {
|
||||
isActive: newActiveStatus
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
isActive: newActiveStatus === 'true'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle OpenAI-Responses account status:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 重置 OpenAI-Responses 账户限流状态
|
||||
router.post(
|
||||
'/openai-responses-accounts/:id/reset-rate-limit',
|
||||
authenticateAdmin,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
await openaiResponsesAccountService.updateAccount(id, {
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: '',
|
||||
status: 'active',
|
||||
errorMessage: ''
|
||||
})
|
||||
|
||||
logger.info(`🔄 Admin manually reset rate limit for OpenAI-Responses account ${id}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Rate limit reset successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset OpenAI-Responses account rate limit:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 重置 OpenAI-Responses 账户状态(清除所有异常状态)
|
||||
router.post('/openai-responses-accounts/:id/reset-status', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const result = await openaiResponsesAccountService.resetAccountStatus(id)
|
||||
|
||||
logger.success(`✅ Admin reset status for OpenAI-Responses account: ${id}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset OpenAI-Responses account status:', error)
|
||||
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 手动重置 OpenAI-Responses 账户的每日使用量
|
||||
router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
await openaiResponsesAccountService.updateAccount(id, {
|
||||
dailyUsage: '0',
|
||||
lastResetDate: redis.getDateStringInTimezone(),
|
||||
quotaStoppedAt: ''
|
||||
})
|
||||
|
||||
logger.success(`✅ Admin manually reset daily usage for OpenAI-Responses account ${id}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Daily usage reset successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset OpenAI-Responses account usage:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
460
src/routes/admin/sync.js
Normal file
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* Admin Routes - Sync / Export (for migration)
|
||||
* Exports account data (including secrets) for safe server-to-server syncing.
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const redis = require('../../models/redis')
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||
const openaiAccountService = require('../../services/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||
const logger = require('../../utils/logger')
|
||||
|
||||
function toBool(value, defaultValue = false) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return defaultValue
|
||||
}
|
||||
if (value === true || value === 'true') {
|
||||
return true
|
||||
}
|
||||
if (value === false || value === 'false') {
|
||||
return false
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
function normalizeProxy(proxy) {
|
||||
if (!proxy || typeof proxy !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const protocol = proxy.protocol || proxy.type || proxy.scheme || ''
|
||||
const host = proxy.host || ''
|
||||
const port = Number(proxy.port || 0)
|
||||
|
||||
if (!protocol || !host || !Number.isFinite(port) || port <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
protocol: String(protocol),
|
||||
host: String(host),
|
||||
port,
|
||||
username: proxy.username ? String(proxy.username) : '',
|
||||
password: proxy.password ? String(proxy.password) : ''
|
||||
}
|
||||
}
|
||||
|
||||
function buildModelMappingFromSupportedModels(supportedModels) {
|
||||
if (!supportedModels) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(supportedModels)) {
|
||||
const mapping = {}
|
||||
for (const model of supportedModels) {
|
||||
if (typeof model === 'string' && model.trim()) {
|
||||
mapping[model.trim()] = model.trim()
|
||||
}
|
||||
}
|
||||
return Object.keys(mapping).length ? mapping : null
|
||||
}
|
||||
|
||||
if (typeof supportedModels === 'object') {
|
||||
const mapping = {}
|
||||
for (const [from, to] of Object.entries(supportedModels)) {
|
||||
if (typeof from === 'string' && typeof to === 'string' && from.trim() && to.trim()) {
|
||||
mapping[from.trim()] = to.trim()
|
||||
}
|
||||
}
|
||||
return Object.keys(mapping).length ? mapping : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function safeParseJson(raw, fallback = null) {
|
||||
if (!raw || typeof raw !== 'string') {
|
||||
return fallback
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch (_) {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Export accounts for migration (includes secrets).
|
||||
// GET /admin/sync/export-accounts?include_secrets=true
|
||||
router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const includeSecrets = toBool(req.query.include_secrets, false)
|
||||
if (!includeSecrets) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'include_secrets_required',
|
||||
message: 'Set include_secrets=true to export secrets'
|
||||
})
|
||||
}
|
||||
|
||||
// ===== Claude official OAuth / Setup Token accounts =====
|
||||
const rawClaudeAccounts = await redis.getAllClaudeAccounts()
|
||||
const claudeAccounts = rawClaudeAccounts.map((account) => {
|
||||
// Backward compatible extraction: prefer individual fields, fallback to claudeAiOauth JSON blob.
|
||||
let decryptedClaudeAiOauth = null
|
||||
if (account.claudeAiOauth) {
|
||||
try {
|
||||
const raw = claudeAccountService._decryptSensitiveData(account.claudeAiOauth)
|
||||
decryptedClaudeAiOauth = raw ? JSON.parse(raw) : null
|
||||
} catch (_) {
|
||||
decryptedClaudeAiOauth = null
|
||||
}
|
||||
}
|
||||
|
||||
const rawScopes =
|
||||
account.scopes && account.scopes.trim()
|
||||
? account.scopes
|
||||
: decryptedClaudeAiOauth?.scopes
|
||||
? decryptedClaudeAiOauth.scopes.join(' ')
|
||||
: ''
|
||||
|
||||
const scopes = rawScopes && rawScopes.trim() ? rawScopes.trim().split(' ') : []
|
||||
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
|
||||
const authType = isOAuth ? 'oauth' : 'setup-token'
|
||||
|
||||
const accessToken =
|
||||
account.accessToken && String(account.accessToken).trim()
|
||||
? claudeAccountService._decryptSensitiveData(account.accessToken)
|
||||
: decryptedClaudeAiOauth?.accessToken || ''
|
||||
|
||||
const refreshToken =
|
||||
account.refreshToken && String(account.refreshToken).trim()
|
||||
? claudeAccountService._decryptSensitiveData(account.refreshToken)
|
||||
: decryptedClaudeAiOauth?.refreshToken || ''
|
||||
|
||||
let expiresAt = null
|
||||
const expiresAtMs = Number.parseInt(account.expiresAt, 10)
|
||||
if (Number.isFinite(expiresAtMs) && expiresAtMs > 0) {
|
||||
expiresAt = new Date(expiresAtMs).toISOString()
|
||||
} else if (decryptedClaudeAiOauth?.expiresAt) {
|
||||
try {
|
||||
expiresAt = new Date(Number(decryptedClaudeAiOauth.expiresAt)).toISOString()
|
||||
} catch (_) {
|
||||
expiresAt = null
|
||||
}
|
||||
}
|
||||
|
||||
const proxy = account.proxy ? normalizeProxy(safeParseJson(account.proxy)) : null
|
||||
|
||||
// 🔧 Parse subscriptionInfo to extract org_uuid and account_uuid
|
||||
let orgUuid = null
|
||||
let accountUuid = null
|
||||
if (account.subscriptionInfo) {
|
||||
try {
|
||||
const subscriptionInfo = JSON.parse(account.subscriptionInfo)
|
||||
orgUuid = subscriptionInfo.organizationUuid || null
|
||||
accountUuid = subscriptionInfo.accountUuid || null
|
||||
} catch (_) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 Calculate expires_in from expires_at
|
||||
let expiresIn = null
|
||||
if (expiresAt) {
|
||||
try {
|
||||
const expiresAtTime = new Date(expiresAt).getTime()
|
||||
const nowTime = Date.now()
|
||||
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
|
||||
if (diffSeconds > 0) {
|
||||
expiresIn = diffSeconds
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore calculation errors
|
||||
}
|
||||
}
|
||||
// 🔧 Use default expires_in if calculation failed (Anthropic OAuth: 8 hours)
|
||||
if (!expiresIn && isOAuth) {
|
||||
expiresIn = 28800 // 8 hours
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken || undefined,
|
||||
expires_at: expiresAt || undefined,
|
||||
expires_in: expiresIn || undefined,
|
||||
scope: scopes.join(' ') || undefined,
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
// 🔧 Add auth info as top-level credentials fields
|
||||
if (orgUuid) {
|
||||
credentials.org_uuid = orgUuid
|
||||
}
|
||||
if (accountUuid) {
|
||||
credentials.account_uuid = accountUuid
|
||||
}
|
||||
|
||||
// 🔧 Store complete original CRS data in extra
|
||||
const extra = {
|
||||
crs_account_id: account.id,
|
||||
crs_kind: 'claude-account',
|
||||
crs_id: account.id,
|
||||
crs_name: account.name,
|
||||
crs_description: account.description || '',
|
||||
crs_platform: account.platform || 'claude',
|
||||
crs_auth_type: authType,
|
||||
crs_is_active: account.isActive === 'true',
|
||||
crs_schedulable: account.schedulable !== 'false',
|
||||
crs_priority: Number.parseInt(account.priority, 10) || 50,
|
||||
crs_status: account.status || 'active',
|
||||
crs_scopes: scopes,
|
||||
crs_subscription_info: account.subscriptionInfo || undefined
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'claude-account',
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
description: account.description || '',
|
||||
platform: account.platform || 'claude',
|
||||
authType,
|
||||
isActive: account.isActive === 'true',
|
||||
schedulable: account.schedulable !== 'false',
|
||||
priority: Number.parseInt(account.priority, 10) || 50,
|
||||
status: account.status || 'active',
|
||||
proxy,
|
||||
credentials,
|
||||
extra
|
||||
}
|
||||
})
|
||||
|
||||
// ===== Claude Console API Key accounts =====
|
||||
const claudeConsoleSummaries = await claudeConsoleAccountService.getAllAccounts()
|
||||
const claudeConsoleAccounts = []
|
||||
for (const summary of claudeConsoleSummaries) {
|
||||
const full = await claudeConsoleAccountService.getAccount(summary.id)
|
||||
if (!full) {
|
||||
continue
|
||||
}
|
||||
|
||||
const proxy = normalizeProxy(full.proxy)
|
||||
const modelMapping = buildModelMappingFromSupportedModels(full.supportedModels)
|
||||
|
||||
const credentials = {
|
||||
api_key: full.apiKey,
|
||||
base_url: full.apiUrl
|
||||
}
|
||||
|
||||
if (modelMapping) {
|
||||
credentials.model_mapping = modelMapping
|
||||
}
|
||||
|
||||
if (full.userAgent) {
|
||||
credentials.user_agent = full.userAgent
|
||||
}
|
||||
|
||||
claudeConsoleAccounts.push({
|
||||
kind: 'claude-console-account',
|
||||
id: full.id,
|
||||
name: full.name,
|
||||
description: full.description || '',
|
||||
platform: full.platform || 'claude-console',
|
||||
isActive: full.isActive === true,
|
||||
schedulable: full.schedulable !== false,
|
||||
priority: Number.parseInt(full.priority, 10) || 50,
|
||||
status: full.status || 'active',
|
||||
proxy,
|
||||
maxConcurrentTasks: Number.parseInt(full.maxConcurrentTasks, 10) || 0,
|
||||
credentials,
|
||||
extra: {
|
||||
crs_account_id: full.id,
|
||||
crs_kind: 'claude-console-account',
|
||||
crs_id: full.id,
|
||||
crs_name: full.name,
|
||||
crs_description: full.description || '',
|
||||
crs_platform: full.platform || 'claude-console',
|
||||
crs_is_active: full.isActive === true,
|
||||
crs_schedulable: full.schedulable !== false,
|
||||
crs_priority: Number.parseInt(full.priority, 10) || 50,
|
||||
crs_status: full.status || 'active'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===== OpenAI OAuth accounts =====
|
||||
const openaiOAuthAccounts = []
|
||||
{
|
||||
const client = redis.getClientSafe()
|
||||
const openaiKeys = await client.keys('openai:account:*')
|
||||
for (const key of openaiKeys) {
|
||||
const id = key.split(':').slice(2).join(':')
|
||||
const account = await openaiAccountService.getAccount(id)
|
||||
if (!account) {
|
||||
continue
|
||||
}
|
||||
|
||||
const accessToken = account.accessToken
|
||||
? openaiAccountService.decrypt(account.accessToken)
|
||||
: ''
|
||||
if (!accessToken) {
|
||||
// Skip broken/legacy records without decryptable token
|
||||
continue
|
||||
}
|
||||
|
||||
const scopes =
|
||||
account.scopes && typeof account.scopes === 'string' && account.scopes.trim()
|
||||
? account.scopes.trim().split(' ')
|
||||
: []
|
||||
|
||||
const proxy = normalizeProxy(account.proxy)
|
||||
|
||||
// 🔧 Calculate expires_in from expires_at
|
||||
let expiresIn = null
|
||||
if (account.expiresAt) {
|
||||
try {
|
||||
const expiresAtTime = new Date(account.expiresAt).getTime()
|
||||
const nowTime = Date.now()
|
||||
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
|
||||
if (diffSeconds > 0) {
|
||||
expiresIn = diffSeconds
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore calculation errors
|
||||
}
|
||||
}
|
||||
// 🔧 Use default expires_in if calculation failed (OpenAI OAuth: 10 days)
|
||||
if (!expiresIn) {
|
||||
expiresIn = 864000 // 10 days
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
access_token: accessToken,
|
||||
refresh_token: account.refreshToken || undefined,
|
||||
id_token: account.idToken || undefined,
|
||||
expires_at: account.expiresAt || undefined,
|
||||
expires_in: expiresIn || undefined,
|
||||
scope: scopes.join(' ') || undefined,
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
// 🔧 Add auth info as top-level credentials fields
|
||||
if (account.accountId) {
|
||||
credentials.chatgpt_account_id = account.accountId
|
||||
}
|
||||
if (account.chatgptUserId) {
|
||||
credentials.chatgpt_user_id = account.chatgptUserId
|
||||
}
|
||||
if (account.organizationId) {
|
||||
credentials.organization_id = account.organizationId
|
||||
}
|
||||
|
||||
// 🔧 Store complete original CRS data in extra
|
||||
const extra = {
|
||||
crs_account_id: account.id,
|
||||
crs_kind: 'openai-oauth-account',
|
||||
crs_id: account.id,
|
||||
crs_name: account.name,
|
||||
crs_description: account.description || '',
|
||||
crs_platform: account.platform || 'openai',
|
||||
crs_is_active: account.isActive === 'true',
|
||||
crs_schedulable: account.schedulable !== 'false',
|
||||
crs_priority: Number.parseInt(account.priority, 10) || 50,
|
||||
crs_status: account.status || 'active',
|
||||
crs_scopes: scopes,
|
||||
crs_email: account.email || undefined,
|
||||
crs_chatgpt_account_id: account.accountId || undefined,
|
||||
crs_chatgpt_user_id: account.chatgptUserId || undefined,
|
||||
crs_organization_id: account.organizationId || undefined
|
||||
}
|
||||
|
||||
openaiOAuthAccounts.push({
|
||||
kind: 'openai-oauth-account',
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
description: account.description || '',
|
||||
platform: account.platform || 'openai',
|
||||
authType: 'oauth',
|
||||
isActive: account.isActive === 'true',
|
||||
schedulable: account.schedulable !== 'false',
|
||||
priority: Number.parseInt(account.priority, 10) || 50,
|
||||
status: account.status || 'active',
|
||||
proxy,
|
||||
credentials,
|
||||
extra
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ===== OpenAI Responses API Key accounts =====
|
||||
const openaiResponsesAccounts = []
|
||||
const client = redis.getClientSafe()
|
||||
const openaiResponseKeys = await client.keys('openai_responses_account:*')
|
||||
for (const key of openaiResponseKeys) {
|
||||
const id = key.split(':').slice(1).join(':')
|
||||
const full = await openaiResponsesAccountService.getAccount(id)
|
||||
if (!full) {
|
||||
continue
|
||||
}
|
||||
|
||||
const proxy = normalizeProxy(full.proxy)
|
||||
|
||||
const credentials = {
|
||||
api_key: full.apiKey,
|
||||
base_url: full.baseApi
|
||||
}
|
||||
|
||||
if (full.userAgent) {
|
||||
credentials.user_agent = full.userAgent
|
||||
}
|
||||
|
||||
openaiResponsesAccounts.push({
|
||||
kind: 'openai-responses-account',
|
||||
id: full.id,
|
||||
name: full.name,
|
||||
description: full.description || '',
|
||||
platform: full.platform || 'openai-responses',
|
||||
isActive: full.isActive === 'true',
|
||||
schedulable: full.schedulable !== 'false',
|
||||
priority: Number.parseInt(full.priority, 10) || 50,
|
||||
status: full.status || 'active',
|
||||
proxy,
|
||||
credentials,
|
||||
extra: {
|
||||
crs_account_id: full.id,
|
||||
crs_kind: 'openai-responses-account',
|
||||
crs_id: full.id,
|
||||
crs_name: full.name,
|
||||
crs_description: full.description || '',
|
||||
crs_platform: full.platform || 'openai-responses',
|
||||
crs_is_active: full.isActive === 'true',
|
||||
crs_schedulable: full.schedulable !== 'false',
|
||||
crs_priority: Number.parseInt(full.priority, 10) || 50,
|
||||
crs_status: full.status || 'active'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
exportedAt: new Date().toISOString(),
|
||||
claudeAccounts,
|
||||
claudeConsoleAccounts,
|
||||
openaiOAuthAccounts,
|
||||
openaiResponsesAccounts
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to export accounts for sync:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'export_failed',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
401
src/routes/admin/system.js
Normal file
@@ -0,0 +1,401 @@
|
||||
const express = require('express')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const axios = require('axios')
|
||||
const claudeCodeHeadersService = require('../../services/claudeCodeHeadersService')
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const config = require('../../../config/config')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// ==================== Claude Code Headers 管理 ====================
|
||||
|
||||
// 获取所有 Claude Code headers
|
||||
router.get('/claude-code-headers', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const allHeaders = await claudeCodeHeadersService.getAllAccountHeaders()
|
||||
|
||||
// 获取所有 Claude 账号信息
|
||||
const accounts = await claudeAccountService.getAllAccounts()
|
||||
const accountMap = {}
|
||||
accounts.forEach((account) => {
|
||||
accountMap[account.id] = account.name
|
||||
})
|
||||
|
||||
// 格式化输出
|
||||
const formattedData = Object.entries(allHeaders).map(([accountId, data]) => ({
|
||||
accountId,
|
||||
accountName: accountMap[accountId] || 'Unknown',
|
||||
version: data.version,
|
||||
userAgent: data.headers['user-agent'],
|
||||
updatedAt: data.updatedAt,
|
||||
headers: data.headers
|
||||
}))
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: formattedData
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Claude Code headers:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to get Claude Code headers', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 🗑️ 清除指定账号的 Claude Code headers
|
||||
router.delete('/claude-code-headers/:accountId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
await claudeCodeHeadersService.clearAccountHeaders(accountId)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `Claude Code headers cleared for account ${accountId}`
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to clear Claude Code headers:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to clear Claude Code headers', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== 系统更新检查 ====================
|
||||
|
||||
// 版本比较函数
|
||||
function compareVersions(current, latest) {
|
||||
const parseVersion = (v) => {
|
||||
const parts = v.split('.').map(Number)
|
||||
return {
|
||||
major: parts[0] || 0,
|
||||
minor: parts[1] || 0,
|
||||
patch: parts[2] || 0
|
||||
}
|
||||
}
|
||||
|
||||
const currentV = parseVersion(current)
|
||||
const latestV = parseVersion(latest)
|
||||
|
||||
if (currentV.major !== latestV.major) {
|
||||
return currentV.major - latestV.major
|
||||
}
|
||||
if (currentV.minor !== latestV.minor) {
|
||||
return currentV.minor - latestV.minor
|
||||
}
|
||||
return currentV.patch - latestV.patch
|
||||
}
|
||||
|
||||
router.get('/check-updates', authenticateAdmin, async (req, res) => {
|
||||
// 读取当前版本
|
||||
const versionPath = path.join(__dirname, '../../../VERSION')
|
||||
let currentVersion = '1.0.0'
|
||||
try {
|
||||
currentVersion = fs.readFileSync(versionPath, 'utf8').trim()
|
||||
} catch (err) {
|
||||
logger.warn('⚠️ Could not read VERSION file:', err.message)
|
||||
}
|
||||
|
||||
try {
|
||||
// 从缓存获取
|
||||
const cacheKey = 'version_check_cache'
|
||||
const cached = await redis.getClient().get(cacheKey)
|
||||
|
||||
if (cached && !req.query.force) {
|
||||
const cachedData = JSON.parse(cached)
|
||||
const cacheAge = Date.now() - cachedData.timestamp
|
||||
|
||||
// 缓存有效期1小时
|
||||
if (cacheAge < 3600000) {
|
||||
// 实时计算 hasUpdate,不使用缓存的值
|
||||
const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
current: currentVersion,
|
||||
latest: cachedData.latest,
|
||||
hasUpdate, // 实时计算,不用缓存
|
||||
releaseInfo: cachedData.releaseInfo,
|
||||
cached: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 请求 GitHub API
|
||||
const githubRepo = 'wei-shaw/claude-relay-service'
|
||||
const response = await axios.get(`https://api.github.com/repos/${githubRepo}/releases/latest`, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'Claude-Relay-Service'
|
||||
},
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
const release = response.data
|
||||
const latestVersion = release.tag_name.replace(/^v/, '')
|
||||
|
||||
// 比较版本
|
||||
const hasUpdate = compareVersions(currentVersion, latestVersion) < 0
|
||||
|
||||
const releaseInfo = {
|
||||
name: release.name,
|
||||
body: release.body,
|
||||
publishedAt: release.published_at,
|
||||
htmlUrl: release.html_url
|
||||
}
|
||||
|
||||
// 缓存结果(不缓存 hasUpdate,因为它应该实时计算)
|
||||
await redis.getClient().set(
|
||||
cacheKey,
|
||||
JSON.stringify({
|
||||
latest: latestVersion,
|
||||
releaseInfo,
|
||||
timestamp: Date.now()
|
||||
}),
|
||||
'EX',
|
||||
3600
|
||||
) // 1小时过期
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
current: currentVersion,
|
||||
latest: latestVersion,
|
||||
hasUpdate,
|
||||
releaseInfo,
|
||||
cached: false
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// 改进错误日志记录
|
||||
const errorDetails = {
|
||||
message: error.message || 'Unknown error',
|
||||
code: error.code,
|
||||
response: error.response
|
||||
? {
|
||||
status: error.response.status,
|
||||
statusText: error.response.statusText,
|
||||
data: error.response.data
|
||||
}
|
||||
: null,
|
||||
request: error.request ? 'Request was made but no response received' : null
|
||||
}
|
||||
|
||||
logger.error('❌ Failed to check for updates:', errorDetails.message)
|
||||
|
||||
// 处理 404 错误 - 仓库或版本不存在
|
||||
if (error.response && error.response.status === 404) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
current: currentVersion,
|
||||
latest: currentVersion,
|
||||
hasUpdate: false,
|
||||
releaseInfo: {
|
||||
name: 'No releases found',
|
||||
body: 'The GitHub repository has no releases yet.',
|
||||
publishedAt: new Date().toISOString(),
|
||||
htmlUrl: '#'
|
||||
},
|
||||
warning: 'GitHub repository has no releases'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 如果是网络错误,尝试返回缓存的数据
|
||||
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
|
||||
const cacheKey = 'version_check_cache'
|
||||
const cached = await redis.getClient().get(cacheKey)
|
||||
|
||||
if (cached) {
|
||||
const cachedData = JSON.parse(cached)
|
||||
// 实时计算 hasUpdate
|
||||
const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
current: currentVersion,
|
||||
latest: cachedData.latest,
|
||||
hasUpdate, // 实时计算
|
||||
releaseInfo: cachedData.releaseInfo,
|
||||
cached: true,
|
||||
warning: 'Using cached data due to network error'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 其他错误返回当前版本信息
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
current: currentVersion,
|
||||
latest: currentVersion,
|
||||
hasUpdate: false,
|
||||
releaseInfo: {
|
||||
name: 'Update check failed',
|
||||
body: `Unable to check for updates: ${error.message || 'Unknown error'}`,
|
||||
publishedAt: new Date().toISOString(),
|
||||
htmlUrl: '#'
|
||||
},
|
||||
error: true,
|
||||
warning: error.message || 'Failed to check for updates'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== OEM 设置管理 ====================
|
||||
|
||||
// 获取OEM设置(公开接口,用于显示)
|
||||
// 注意:这个端点没有 authenticateAdmin 中间件,因为前端登录页也需要访问
|
||||
router.get('/oem-settings', async (req, res) => {
|
||||
try {
|
||||
const client = redis.getClient()
|
||||
const oemSettings = await client.get('oem:settings')
|
||||
|
||||
// 默认设置
|
||||
const defaultSettings = {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: '', // Base64编码的图标数据
|
||||
showAdminButton: true, // 是否显示管理后台按钮
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
let settings = defaultSettings
|
||||
if (oemSettings) {
|
||||
try {
|
||||
settings = { ...defaultSettings, ...JSON.parse(oemSettings) }
|
||||
} catch (err) {
|
||||
logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 LDAP 启用状态到响应中
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...settings,
|
||||
ldapEnabled: config.ldap && config.ldap.enabled === true
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get OEM settings:', error)
|
||||
return res.status(500).json({ error: 'Failed to get OEM settings', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 更新OEM设置
|
||||
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { siteName, siteIcon, siteIconData, showAdminButton } = req.body
|
||||
|
||||
// 验证输入
|
||||
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'Site name is required' })
|
||||
}
|
||||
|
||||
if (siteName.length > 100) {
|
||||
return res.status(400).json({ error: 'Site name must be less than 100 characters' })
|
||||
}
|
||||
|
||||
// 验证图标数据大小(如果是base64)
|
||||
if (siteIconData && siteIconData.length > 500000) {
|
||||
// 约375KB
|
||||
return res.status(400).json({ error: 'Icon file must be less than 350KB' })
|
||||
}
|
||||
|
||||
// 验证图标URL(如果提供)
|
||||
if (siteIcon && !siteIconData) {
|
||||
// 简单验证URL格式
|
||||
try {
|
||||
new URL(siteIcon)
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: 'Invalid icon URL format' })
|
||||
}
|
||||
}
|
||||
|
||||
const settings = {
|
||||
siteName: siteName.trim(),
|
||||
siteIcon: (siteIcon || '').trim(),
|
||||
siteIconData: (siteIconData || '').trim(), // Base64数据
|
||||
showAdminButton: showAdminButton !== false, // 默认为true
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
const client = redis.getClient()
|
||||
await client.set('oem:settings', JSON.stringify(settings))
|
||||
|
||||
logger.info(`✅ OEM settings updated: ${siteName}`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'OEM settings updated successfully',
|
||||
data: settings
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update OEM settings:', error)
|
||||
return res.status(500).json({ error: 'Failed to update OEM settings', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Claude Code 版本管理 ====================
|
||||
|
||||
router.get('/claude-code-version', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const CACHE_KEY = 'claude_code_user_agent:daily'
|
||||
|
||||
// 获取缓存的统一User-Agent
|
||||
const unifiedUserAgent = await redis.client.get(CACHE_KEY)
|
||||
const ttl = unifiedUserAgent ? await redis.client.ttl(CACHE_KEY) : 0
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
userAgent: unifiedUserAgent,
|
||||
isActive: !!unifiedUserAgent,
|
||||
ttlSeconds: ttl,
|
||||
lastUpdated: unifiedUserAgent ? new Date().toISOString() : null
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get unified Claude Code User-Agent error:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get User-Agent information',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🗑️ 清除统一Claude Code User-Agent缓存
|
||||
router.post('/claude-code-version/clear', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const CACHE_KEY = 'claude_code_user_agent:daily'
|
||||
|
||||
// 删除缓存的统一User-Agent
|
||||
await redis.client.del(CACHE_KEY)
|
||||
|
||||
logger.info(`🗑️ Admin manually cleared unified Claude Code User-Agent cache`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Unified User-Agent cache cleared successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Clear unified User-Agent cache error:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to clear cache',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
2530
src/routes/admin/usageStats.js
Normal file
78
src/routes/admin/utils.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Admin Routes - 共享工具函数
|
||||
* 供各个子路由模块导入使用
|
||||
*/
|
||||
|
||||
const logger = require('../../utils/logger')
|
||||
|
||||
/**
|
||||
* 处理可为空的时间字段
|
||||
* @param {*} value - 输入值
|
||||
* @returns {string|null} 规范化后的值
|
||||
*/
|
||||
function normalizeNullableDate(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return null
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
return trimmed === '' ? null : trimmed
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射前端的 expiresAt 字段到后端的 subscriptionExpiresAt 字段
|
||||
* @param {Object} updates - 更新对象
|
||||
* @param {string} accountType - 账户类型 (如 'Claude', 'OpenAI' 等)
|
||||
* @param {string} accountId - 账户 ID
|
||||
* @returns {Object} 映射后的更新对象
|
||||
*/
|
||||
function mapExpiryField(updates, accountType, accountId) {
|
||||
const mappedUpdates = { ...updates }
|
||||
if ('expiresAt' in mappedUpdates) {
|
||||
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
|
||||
delete mappedUpdates.expiresAt
|
||||
logger.info(
|
||||
`Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}`
|
||||
)
|
||||
}
|
||||
return mappedUpdates
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化账户数据,确保前端获取正确的过期时间字段
|
||||
* 将 subscriptionExpiresAt(订阅过期时间)映射到 expiresAt 供前端使用
|
||||
* 保留原始的 tokenExpiresAt(OAuth token过期时间)供内部使用
|
||||
* @param {Object} account - 账户对象
|
||||
* @returns {Object} 格式化后的账户对象
|
||||
*/
|
||||
function formatAccountExpiry(account) {
|
||||
if (!account || typeof account !== 'object') {
|
||||
return account
|
||||
}
|
||||
|
||||
const rawSubscription = Object.prototype.hasOwnProperty.call(account, 'subscriptionExpiresAt')
|
||||
? account.subscriptionExpiresAt
|
||||
: null
|
||||
|
||||
const rawToken = Object.prototype.hasOwnProperty.call(account, 'tokenExpiresAt')
|
||||
? account.tokenExpiresAt
|
||||
: account.expiresAt
|
||||
|
||||
const subscriptionExpiresAt = normalizeNullableDate(rawSubscription)
|
||||
const tokenExpiresAt = normalizeNullableDate(rawToken)
|
||||
|
||||
return {
|
||||
...account,
|
||||
subscriptionExpiresAt,
|
||||
tokenExpiresAt,
|
||||
expiresAt: subscriptionExpiresAt
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeNullableDate,
|
||||
mapExpiryField,
|
||||
formatAccountExpiry
|
||||
}
|
||||
1300
src/routes/api.js
@@ -3,6 +3,9 @@ const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const claudeAccountService = require('../services/claudeAccountService')
|
||||
const openaiAccountService = require('../services/openaiAccountService')
|
||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -31,8 +34,8 @@ router.post('/api/get-key-id', async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 验证API Key
|
||||
const validation = await apiKeyService.validateApiKey(apiKey)
|
||||
// 验证API Key(使用不触发激活的验证方法)
|
||||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||
|
||||
if (!validation.valid) {
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||
@@ -93,17 +96,21 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
|
||||
// 检查是否激活
|
||||
if (keyData.isActive !== 'true') {
|
||||
const keyName = keyData.name || 'Unknown'
|
||||
return res.status(403).json({
|
||||
error: 'API key is disabled',
|
||||
message: 'This API key has been disabled'
|
||||
message: `API Key "${keyName}" 已被禁用`,
|
||||
keyName
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) {
|
||||
const keyName = keyData.name || 'Unknown'
|
||||
return res.status(403).json({
|
||||
error: 'API key has expired',
|
||||
message: 'This API key has expired'
|
||||
message: `API Key "${keyName}" 已过期`,
|
||||
keyName
|
||||
})
|
||||
}
|
||||
|
||||
@@ -114,6 +121,7 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
|
||||
// 获取当日费用统计
|
||||
const dailyCost = await redis.getDailyCost(keyId)
|
||||
const costStats = await redis.getCostStats(keyId)
|
||||
|
||||
// 处理数据格式,与 validateApiKey 返回的格式保持一致
|
||||
// 解析限制模型数据
|
||||
@@ -140,12 +148,19 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
rateLimitWindow: parseInt(keyData.rateLimitWindow) || 0,
|
||||
rateLimitRequests: parseInt(keyData.rateLimitRequests) || 0,
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit) || 0,
|
||||
totalCostLimit: parseFloat(keyData.totalCostLimit) || 0,
|
||||
dailyCost: dailyCost || 0,
|
||||
totalCost: costStats.total || 0,
|
||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||
restrictedModels,
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients,
|
||||
permissions: keyData.permissions || 'all',
|
||||
permissions: keyData.permissions,
|
||||
// 添加激活相关字段
|
||||
expirationMode: keyData.expirationMode || 'fixed',
|
||||
isActivated: keyData.isActivated === 'true',
|
||||
activationDays: parseInt(keyData.activationDays || 0),
|
||||
activatedAt: keyData.activatedAt || null,
|
||||
usage // 使用完整的 usage 数据,而不是只有 total
|
||||
}
|
||||
} else if (apiKey) {
|
||||
@@ -158,8 +173,8 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 验证API Key(重用现有的验证逻辑)
|
||||
const validation = await apiKeyService.validateApiKey(apiKey)
|
||||
// 验证API Key(使用不触发激活的验证方法)
|
||||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||
|
||||
if (!validation.valid) {
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||
@@ -191,74 +206,85 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
// 获取验证结果中的完整keyData(包含isActive状态和cost信息)
|
||||
const fullKeyData = keyData
|
||||
|
||||
// 计算总费用 - 使用与模型统计相同的逻辑(按模型分别计算)
|
||||
// 🔧 FIX: 使用 allTimeCost 而不是扫描月度键
|
||||
// 计算总费用 - 优先使用持久化的总费用计数器
|
||||
let totalCost = 0
|
||||
let formattedCost = '$0.000000'
|
||||
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 获取所有月度模型统计(与model-stats接口相同的逻辑)
|
||||
const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`)
|
||||
const modelUsageMap = new Map()
|
||||
// 读取累积的总费用(没有 TTL 的持久键)
|
||||
const totalCostKey = `usage:cost:total:${keyId}`
|
||||
const allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
|
||||
|
||||
for (const key of allModelKeys) {
|
||||
const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
|
||||
if (!modelMatch) {
|
||||
continue
|
||||
}
|
||||
if (allTimeCost > 0) {
|
||||
totalCost = allTimeCost
|
||||
formattedCost = CostCalculator.formatCost(allTimeCost)
|
||||
logger.debug(`📊 使用 allTimeCost 计算用户统计: ${allTimeCost}`)
|
||||
} else {
|
||||
// Fallback: 如果 allTimeCost 为空(旧键),尝试月度键
|
||||
const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`)
|
||||
const modelUsageMap = new Map()
|
||||
|
||||
const model = modelMatch[1]
|
||||
const data = await client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
if (!modelUsageMap.has(model)) {
|
||||
modelUsageMap.set(model, {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0
|
||||
})
|
||||
for (const key of allModelKeys) {
|
||||
const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
|
||||
if (!modelMatch) {
|
||||
continue
|
||||
}
|
||||
|
||||
const modelUsage = modelUsageMap.get(model)
|
||||
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
|
||||
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
|
||||
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
||||
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
||||
}
|
||||
}
|
||||
const model = modelMatch[1]
|
||||
const data = await client.hgetall(key)
|
||||
|
||||
// 按模型计算费用并汇总
|
||||
for (const [model, usage] of modelUsageMap) {
|
||||
const usageData = {
|
||||
input_tokens: usage.inputTokens,
|
||||
output_tokens: usage.outputTokens,
|
||||
cache_creation_input_tokens: usage.cacheCreateTokens,
|
||||
cache_read_input_tokens: usage.cacheReadTokens
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
if (!modelUsageMap.has(model)) {
|
||||
modelUsageMap.set(model, {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0
|
||||
})
|
||||
}
|
||||
|
||||
const modelUsage = modelUsageMap.get(model)
|
||||
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
|
||||
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
|
||||
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
||||
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
||||
}
|
||||
}
|
||||
|
||||
const costResult = CostCalculator.calculateCost(usageData, model)
|
||||
totalCost += costResult.costs.total
|
||||
}
|
||||
// 按模型计算费用并汇总
|
||||
for (const [model, usage] of modelUsageMap) {
|
||||
const usageData = {
|
||||
input_tokens: usage.inputTokens,
|
||||
output_tokens: usage.outputTokens,
|
||||
cache_creation_input_tokens: usage.cacheCreateTokens,
|
||||
cache_read_input_tokens: usage.cacheReadTokens
|
||||
}
|
||||
|
||||
// 如果没有模型级别的详细数据,回退到总体数据计算
|
||||
if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) {
|
||||
const usage = fullKeyData.usage.total
|
||||
const costUsage = {
|
||||
input_tokens: usage.inputTokens || 0,
|
||||
output_tokens: usage.outputTokens || 0,
|
||||
cache_creation_input_tokens: usage.cacheCreateTokens || 0,
|
||||
cache_read_input_tokens: usage.cacheReadTokens || 0
|
||||
const costResult = CostCalculator.calculateCost(usageData, model)
|
||||
totalCost += costResult.costs.total
|
||||
}
|
||||
|
||||
const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022')
|
||||
totalCost = costResult.costs.total
|
||||
}
|
||||
// 如果没有模型级别的详细数据,回退到总体数据计算
|
||||
if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) {
|
||||
const usage = fullKeyData.usage.total
|
||||
const costUsage = {
|
||||
input_tokens: usage.inputTokens || 0,
|
||||
output_tokens: usage.outputTokens || 0,
|
||||
cache_creation_input_tokens: usage.cacheCreateTokens || 0,
|
||||
cache_read_input_tokens: usage.cacheReadTokens || 0
|
||||
}
|
||||
|
||||
formattedCost = CostCalculator.formatCost(totalCost)
|
||||
const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022')
|
||||
totalCost = costResult.costs.total
|
||||
}
|
||||
|
||||
formattedCost = CostCalculator.formatCost(totalCost)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to calculate detailed cost for key ${keyId}:`, error)
|
||||
logger.warn(`Failed to calculate cost for key ${keyId}:`, error)
|
||||
// 回退到简单计算
|
||||
if (fullKeyData.usage?.total?.allTokens > 0) {
|
||||
const usage = fullKeyData.usage.total
|
||||
@@ -278,21 +304,24 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
// 获取当前使用量
|
||||
let currentWindowRequests = 0
|
||||
let currentWindowTokens = 0
|
||||
let currentWindowCost = 0 // 新增:当前窗口费用
|
||||
let currentDailyCost = 0
|
||||
let windowStartTime = null
|
||||
let windowEndTime = null
|
||||
let windowRemainingSeconds = null
|
||||
|
||||
try {
|
||||
// 获取当前时间窗口的请求次数和Token使用量
|
||||
// 获取当前时间窗口的请求次数、Token使用量和费用
|
||||
if (fullKeyData.rateLimitWindow > 0) {
|
||||
const client = redis.getClientSafe()
|
||||
const requestCountKey = `rate_limit:requests:${keyId}`
|
||||
const tokenCountKey = `rate_limit:tokens:${keyId}`
|
||||
const costCountKey = `rate_limit:cost:${keyId}` // 新增:费用计数key
|
||||
const windowStartKey = `rate_limit:window_start:${keyId}`
|
||||
|
||||
currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0')
|
||||
currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0')
|
||||
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:获取当前窗口费用
|
||||
|
||||
// 获取窗口开始时间和计算剩余时间
|
||||
const windowStart = await client.get(windowStartKey)
|
||||
@@ -313,6 +342,7 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
// 重置计数为0,因为窗口已过期
|
||||
currentWindowRequests = 0
|
||||
currentWindowTokens = 0
|
||||
currentWindowCost = 0 // 新增:重置窗口费用
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,14 +353,63 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
logger.warn(`Failed to get current usage for key ${keyId}:`, error)
|
||||
}
|
||||
|
||||
const boundAccountDetails = {}
|
||||
|
||||
const accountDetailTasks = []
|
||||
|
||||
if (fullKeyData.claudeAccountId) {
|
||||
accountDetailTasks.push(
|
||||
(async () => {
|
||||
try {
|
||||
const overview = await claudeAccountService.getAccountOverview(
|
||||
fullKeyData.claudeAccountId
|
||||
)
|
||||
|
||||
if (overview && overview.accountType === 'dedicated') {
|
||||
boundAccountDetails.claude = overview
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ Failed to load Claude account overview for key ${keyId}:`, error)
|
||||
}
|
||||
})()
|
||||
)
|
||||
}
|
||||
|
||||
if (fullKeyData.openaiAccountId) {
|
||||
accountDetailTasks.push(
|
||||
(async () => {
|
||||
try {
|
||||
const overview = await openaiAccountService.getAccountOverview(
|
||||
fullKeyData.openaiAccountId
|
||||
)
|
||||
|
||||
if (overview && overview.accountType === 'dedicated') {
|
||||
boundAccountDetails.openai = overview
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ Failed to load OpenAI account overview for key ${keyId}:`, error)
|
||||
}
|
||||
})()
|
||||
)
|
||||
}
|
||||
|
||||
if (accountDetailTasks.length > 0) {
|
||||
await Promise.allSettled(accountDetailTasks)
|
||||
}
|
||||
|
||||
// 构建响应数据(只返回该API Key自己的信息,确保不泄露其他信息)
|
||||
const responseData = {
|
||||
id: keyId,
|
||||
name: fullKeyData.name,
|
||||
description: keyData.description || '',
|
||||
description: fullKeyData.description || keyData.description || '',
|
||||
isActive: true, // 如果能通过validateApiKey验证,说明一定是激活的
|
||||
createdAt: keyData.createdAt,
|
||||
expiresAt: keyData.expiresAt,
|
||||
createdAt: fullKeyData.createdAt || keyData.createdAt,
|
||||
expiresAt: fullKeyData.expiresAt || keyData.expiresAt,
|
||||
// 添加激活相关字段
|
||||
expirationMode: fullKeyData.expirationMode || 'fixed',
|
||||
isActivated: fullKeyData.isActivated === true || fullKeyData.isActivated === 'true',
|
||||
activationDays: parseInt(fullKeyData.activationDays || 0),
|
||||
activatedAt: fullKeyData.activatedAt || null,
|
||||
permissions: fullKeyData.permissions,
|
||||
|
||||
// 使用统计(使用验证结果中的完整数据)
|
||||
@@ -356,11 +435,17 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
concurrencyLimit: fullKeyData.concurrencyLimit || 0,
|
||||
rateLimitWindow: fullKeyData.rateLimitWindow || 0,
|
||||
rateLimitRequests: fullKeyData.rateLimitRequests || 0,
|
||||
rateLimitCost: parseFloat(fullKeyData.rateLimitCost) || 0, // 新增:费用限制
|
||||
dailyCostLimit: fullKeyData.dailyCostLimit || 0,
|
||||
totalCostLimit: fullKeyData.totalCostLimit || 0,
|
||||
weeklyOpusCostLimit: parseFloat(fullKeyData.weeklyOpusCostLimit) || 0, // Opus 周费用限制
|
||||
// 当前使用量
|
||||
currentWindowRequests,
|
||||
currentWindowTokens,
|
||||
currentWindowCost, // 新增:当前窗口费用
|
||||
currentDailyCost,
|
||||
currentTotalCost: totalCost,
|
||||
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyId)) || 0, // 当前 Opus 周费用
|
||||
// 时间窗口信息
|
||||
windowStartTime,
|
||||
windowEndTime,
|
||||
@@ -376,7 +461,12 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
geminiAccountId:
|
||||
fullKeyData.geminiAccountId && fullKeyData.geminiAccountId !== ''
|
||||
? fullKeyData.geminiAccountId
|
||||
: null
|
||||
: null,
|
||||
openaiAccountId:
|
||||
fullKeyData.openaiAccountId && fullKeyData.openaiAccountId !== ''
|
||||
? fullKeyData.openaiAccountId
|
||||
: null,
|
||||
details: Object.keys(boundAccountDetails).length > 0 ? boundAccountDetails : null
|
||||
},
|
||||
|
||||
// 模型和客户端限制信息
|
||||
@@ -401,6 +491,377 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 批量查询统计数据接口
|
||||
router.post('/api/batch-stats', async (req, res) => {
|
||||
try {
|
||||
const { apiIds } = req.body
|
||||
|
||||
// 验证输入
|
||||
if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid input',
|
||||
message: 'API IDs array is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 限制最多查询 30 个
|
||||
if (apiIds.length > 30) {
|
||||
return res.status(400).json({
|
||||
error: 'Too many keys',
|
||||
message: 'Maximum 30 API keys can be queried at once'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证所有 ID 格式
|
||||
const uuidRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
|
||||
const invalidIds = apiIds.filter((id) => !uuidRegex.test(id))
|
||||
if (invalidIds.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API ID format',
|
||||
message: `Invalid API IDs: ${invalidIds.join(', ')}`
|
||||
})
|
||||
}
|
||||
|
||||
const individualStats = []
|
||||
const aggregated = {
|
||||
totalKeys: apiIds.length,
|
||||
activeKeys: 0,
|
||||
usage: {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0,
|
||||
cost: 0,
|
||||
formattedCost: '$0.000000'
|
||||
},
|
||||
dailyUsage: {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0,
|
||||
cost: 0,
|
||||
formattedCost: '$0.000000'
|
||||
},
|
||||
monthlyUsage: {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0,
|
||||
cost: 0,
|
||||
formattedCost: '$0.000000'
|
||||
}
|
||||
}
|
||||
|
||||
// 并行查询所有 API Key 数据(复用单key查询逻辑)
|
||||
const results = await Promise.allSettled(
|
||||
apiIds.map(async (apiId) => {
|
||||
const keyData = await redis.getApiKey(apiId)
|
||||
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
return { error: 'Not found', apiId }
|
||||
}
|
||||
|
||||
// 检查是否激活
|
||||
if (keyData.isActive !== 'true') {
|
||||
return { error: 'Disabled', apiId }
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) {
|
||||
return { error: 'Expired', apiId }
|
||||
}
|
||||
|
||||
// 复用单key查询的逻辑:获取使用统计
|
||||
const usage = await redis.getUsageStats(apiId)
|
||||
|
||||
// 获取费用统计(与单key查询一致)
|
||||
const costStats = await redis.getCostStats(apiId)
|
||||
|
||||
return {
|
||||
apiId,
|
||||
name: keyData.name,
|
||||
description: keyData.description || '',
|
||||
isActive: true,
|
||||
createdAt: keyData.createdAt,
|
||||
usage: usage.total || {},
|
||||
dailyStats: {
|
||||
...usage.daily,
|
||||
cost: costStats.daily
|
||||
},
|
||||
monthlyStats: {
|
||||
...usage.monthly,
|
||||
cost: costStats.monthly
|
||||
},
|
||||
totalCost: costStats.total
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 处理结果并聚合
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled' && result.value && !result.value.error) {
|
||||
const stats = result.value
|
||||
aggregated.activeKeys++
|
||||
|
||||
// 聚合总使用量
|
||||
if (stats.usage) {
|
||||
aggregated.usage.requests += stats.usage.requests || 0
|
||||
aggregated.usage.inputTokens += stats.usage.inputTokens || 0
|
||||
aggregated.usage.outputTokens += stats.usage.outputTokens || 0
|
||||
aggregated.usage.cacheCreateTokens += stats.usage.cacheCreateTokens || 0
|
||||
aggregated.usage.cacheReadTokens += stats.usage.cacheReadTokens || 0
|
||||
aggregated.usage.allTokens += stats.usage.allTokens || 0
|
||||
}
|
||||
|
||||
// 聚合总费用
|
||||
aggregated.usage.cost += stats.totalCost || 0
|
||||
|
||||
// 聚合今日使用量
|
||||
aggregated.dailyUsage.requests += stats.dailyStats.requests || 0
|
||||
aggregated.dailyUsage.inputTokens += stats.dailyStats.inputTokens || 0
|
||||
aggregated.dailyUsage.outputTokens += stats.dailyStats.outputTokens || 0
|
||||
aggregated.dailyUsage.cacheCreateTokens += stats.dailyStats.cacheCreateTokens || 0
|
||||
aggregated.dailyUsage.cacheReadTokens += stats.dailyStats.cacheReadTokens || 0
|
||||
aggregated.dailyUsage.allTokens += stats.dailyStats.allTokens || 0
|
||||
aggregated.dailyUsage.cost += stats.dailyStats.cost || 0
|
||||
|
||||
// 聚合本月使用量
|
||||
aggregated.monthlyUsage.requests += stats.monthlyStats.requests || 0
|
||||
aggregated.monthlyUsage.inputTokens += stats.monthlyStats.inputTokens || 0
|
||||
aggregated.monthlyUsage.outputTokens += stats.monthlyStats.outputTokens || 0
|
||||
aggregated.monthlyUsage.cacheCreateTokens += stats.monthlyStats.cacheCreateTokens || 0
|
||||
aggregated.monthlyUsage.cacheReadTokens += stats.monthlyStats.cacheReadTokens || 0
|
||||
aggregated.monthlyUsage.allTokens += stats.monthlyStats.allTokens || 0
|
||||
aggregated.monthlyUsage.cost += stats.monthlyStats.cost || 0
|
||||
|
||||
// 添加到个体统计
|
||||
individualStats.push({
|
||||
apiId: stats.apiId,
|
||||
name: stats.name,
|
||||
isActive: true,
|
||||
usage: stats.usage,
|
||||
dailyUsage: {
|
||||
...stats.dailyStats,
|
||||
formattedCost: CostCalculator.formatCost(stats.dailyStats.cost || 0)
|
||||
},
|
||||
monthlyUsage: {
|
||||
...stats.monthlyStats,
|
||||
formattedCost: CostCalculator.formatCost(stats.monthlyStats.cost || 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化费用显示
|
||||
aggregated.usage.formattedCost = CostCalculator.formatCost(aggregated.usage.cost)
|
||||
aggregated.dailyUsage.formattedCost = CostCalculator.formatCost(aggregated.dailyUsage.cost)
|
||||
aggregated.monthlyUsage.formattedCost = CostCalculator.formatCost(aggregated.monthlyUsage.cost)
|
||||
|
||||
logger.api(`📊 Batch stats query for ${apiIds.length} keys from ${req.ip || 'unknown'}`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
aggregated,
|
||||
individual: individualStats
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to process batch stats query:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to retrieve batch statistics'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 批量模型统计查询接口
|
||||
router.post('/api/batch-model-stats', async (req, res) => {
|
||||
try {
|
||||
const { apiIds, period = 'daily' } = req.body
|
||||
|
||||
// 验证输入
|
||||
if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid input',
|
||||
message: 'API IDs array is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 限制最多查询 30 个
|
||||
if (apiIds.length > 30) {
|
||||
return res.status(400).json({
|
||||
error: 'Too many keys',
|
||||
message: 'Maximum 30 API keys can be queried at once'
|
||||
})
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
const tzDate = redis.getDateInTimezone()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
const modelUsageMap = new Map()
|
||||
|
||||
// 并行查询所有 API Key 的模型统计
|
||||
await Promise.all(
|
||||
apiIds.map(async (apiId) => {
|
||||
const pattern =
|
||||
period === 'daily'
|
||||
? `usage:${apiId}:model:daily:*:${today}`
|
||||
: `usage:${apiId}:model:monthly:*:${currentMonth}`
|
||||
|
||||
const keys = await client.keys(pattern)
|
||||
|
||||
for (const key of keys) {
|
||||
const match = key.match(
|
||||
period === 'daily'
|
||||
? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
||||
: /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
||||
)
|
||||
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
|
||||
const model = match[1]
|
||||
const data = await client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
if (!modelUsageMap.has(model)) {
|
||||
modelUsageMap.set(model, {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0
|
||||
})
|
||||
}
|
||||
|
||||
const modelUsage = modelUsageMap.get(model)
|
||||
modelUsage.requests += parseInt(data.requests) || 0
|
||||
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
|
||||
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
|
||||
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
||||
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
||||
modelUsage.allTokens += parseInt(data.allTokens) || 0
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 转换为数组并计算费用
|
||||
const modelStats = []
|
||||
for (const [model, usage] of modelUsageMap) {
|
||||
const usageData = {
|
||||
input_tokens: usage.inputTokens,
|
||||
output_tokens: usage.outputTokens,
|
||||
cache_creation_input_tokens: usage.cacheCreateTokens,
|
||||
cache_read_input_tokens: usage.cacheReadTokens
|
||||
}
|
||||
|
||||
const costData = CostCalculator.calculateCost(usageData, model)
|
||||
|
||||
modelStats.push({
|
||||
model,
|
||||
requests: usage.requests,
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cacheCreateTokens: usage.cacheCreateTokens,
|
||||
cacheReadTokens: usage.cacheReadTokens,
|
||||
allTokens: usage.allTokens,
|
||||
costs: costData.costs,
|
||||
formatted: costData.formatted,
|
||||
pricing: costData.pricing
|
||||
})
|
||||
}
|
||||
|
||||
// 按总 token 数降序排列
|
||||
modelStats.sort((a, b) => b.allTokens - a.allTokens)
|
||||
|
||||
logger.api(`📊 Batch model stats query for ${apiIds.length} keys, period: ${period}`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: modelStats,
|
||||
period
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to process batch model stats query:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to retrieve batch model statistics'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务
|
||||
router.post('/api-key/test', async (req, res) => {
|
||||
const config = require('../../config/config')
|
||||
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
|
||||
|
||||
try {
|
||||
const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({
|
||||
error: 'API Key is required',
|
||||
message: 'Please provide your API Key'
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API key format',
|
||||
message: 'API key format is invalid'
|
||||
})
|
||||
}
|
||||
|
||||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||
if (!validation.valid) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid API key',
|
||||
message: validation.error
|
||||
})
|
||||
}
|
||||
|
||||
logger.api(`🧪 API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`)
|
||||
|
||||
const port = config.server.port || 3000
|
||||
const apiUrl = `http://127.0.0.1:${port}/api/v1/messages?beta=true`
|
||||
|
||||
await sendStreamTestRequest({
|
||||
apiUrl,
|
||||
authorization: apiKey,
|
||||
responseStream: res,
|
||||
payload: createClaudeTestPayload(model, { stream: true }),
|
||||
timeout: 60000,
|
||||
extraHeaders: { 'x-api-key': apiKey }
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ API Key test failed:', error)
|
||||
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: 'Test failed',
|
||||
message: error.message || 'Internal server error'
|
||||
})
|
||||
}
|
||||
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 用户模型统计查询接口 - 安全的自查询接口
|
||||
router.post('/api/user-model-stats', async (req, res) => {
|
||||
try {
|
||||
@@ -434,9 +895,11 @@ router.post('/api/user-model-stats', async (req, res) => {
|
||||
|
||||
// 检查是否激活
|
||||
if (keyData.isActive !== 'true') {
|
||||
const keyName = keyData.name || 'Unknown'
|
||||
return res.status(403).json({
|
||||
error: 'API key is disabled',
|
||||
message: 'This API key has been disabled'
|
||||
message: `API Key "${keyName}" 已被禁用`,
|
||||
keyName
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,11 @@ const ALLOWED_MODELS = {
|
||||
'gpt-4-turbo',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'gpt-5',
|
||||
'gpt-5-mini',
|
||||
'gpt-35-turbo',
|
||||
'gpt-35-turbo-16k'
|
||||
'gpt-35-turbo-16k',
|
||||
'codex-mini'
|
||||
],
|
||||
EMBEDDING_MODELS: ['text-embedding-ada-002', 'text-embedding-3-small', 'text-embedding-3-large']
|
||||
}
|
||||
@@ -234,6 +237,99 @@ router.post('/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 处理响应请求 (gpt-5, gpt-5-mini, codex-mini models)
|
||||
router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
const requestId = `azure_resp_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`
|
||||
const sessionId = req.sessionId || req.headers['x-session-id'] || null
|
||||
|
||||
logger.info(`🚀 Azure OpenAI Responses Request ${requestId}`, {
|
||||
apiKeyId: req.apiKey?.id,
|
||||
sessionId,
|
||||
model: req.body.model,
|
||||
stream: req.body.stream || false,
|
||||
messages: req.body.messages?.length || 0
|
||||
})
|
||||
|
||||
try {
|
||||
// 获取绑定的 Azure OpenAI 账户
|
||||
let account = null
|
||||
if (req.apiKey?.azureOpenaiAccountId) {
|
||||
account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId)
|
||||
if (!account) {
|
||||
logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有绑定账户或账户不可用,选择一个可用账户
|
||||
if (!account || account.isActive !== 'true') {
|
||||
account = await azureOpenaiAccountService.selectAvailableAccount(sessionId)
|
||||
}
|
||||
|
||||
// 发送请求到 Azure OpenAI
|
||||
const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({
|
||||
account,
|
||||
requestBody: req.body,
|
||||
headers: req.headers,
|
||||
isStream: req.body.stream || false,
|
||||
endpoint: 'responses'
|
||||
})
|
||||
|
||||
// 处理流式响应
|
||||
if (req.body.stream) {
|
||||
await azureOpenaiRelayService.handleStreamResponse(response, res, {
|
||||
onEnd: async ({ usageData, actualModel }) => {
|
||||
if (usageData) {
|
||||
const modelToRecord = actualModel || req.body.model || 'unknown'
|
||||
await usageReporter.reportOnce(
|
||||
requestId,
|
||||
usageData,
|
||||
req.apiKey.id,
|
||||
modelToRecord,
|
||||
account.id
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error(`Stream error for request ${requestId}:`, error)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 处理非流式响应
|
||||
const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse(
|
||||
response,
|
||||
res
|
||||
)
|
||||
|
||||
if (usageData) {
|
||||
const modelToRecord = actualModel || req.body.model || 'unknown'
|
||||
await usageReporter.reportOnce(
|
||||
requestId,
|
||||
usageData,
|
||||
req.apiKey.id,
|
||||
modelToRecord,
|
||||
account.id
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Azure OpenAI responses request failed ${requestId}:`, error)
|
||||
|
||||
if (!res.headersSent) {
|
||||
const statusCode = error.response?.status || 500
|
||||
const errorMessage =
|
||||
error.response?.data?.error?.message || error.message || 'Internal server error'
|
||||
|
||||
res.status(statusCode).json({
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: 'azure_openai_error',
|
||||
code: error.code || 'unknown'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 处理嵌入请求
|
||||
router.post('/embeddings', authenticateApiKey, async (req, res) => {
|
||||
const requestId = `azure_embed_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`
|
||||
|
||||
196
src/routes/droidRoutes.js
Normal file
@@ -0,0 +1,196 @@
|
||||
const crypto = require('crypto')
|
||||
const express = require('express')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const droidRelayService = require('../services/droidRelayService')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const logger = require('../utils/logger')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function hasDroidPermission(apiKeyData) {
|
||||
return apiKeyService.hasPermission(apiKeyData?.permissions, 'droid')
|
||||
}
|
||||
|
||||
/**
|
||||
* Droid API 转发路由
|
||||
*
|
||||
* 支持的 Factory.ai 端点:
|
||||
* - /droid/claude - Anthropic (Claude) Messages API
|
||||
* - /droid/openai - OpenAI Responses API
|
||||
* - /droid/comm - OpenAI Chat Completions API
|
||||
*/
|
||||
|
||||
// Claude (Anthropic) 端点 - /v1/messages
|
||||
router.post('/claude/v1/messages', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
if (!hasDroidPermission(req.apiKey)) {
|
||||
logger.security(
|
||||
`🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}`
|
||||
)
|
||||
return res.status(403).json({
|
||||
error: 'permission_denied',
|
||||
message: '此 API Key 未启用 Droid 权限'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await droidRelayService.relayRequest(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
req,
|
||||
res,
|
||||
req.headers,
|
||||
{ endpointType: 'anthropic', sessionHash }
|
||||
)
|
||||
|
||||
// 如果是流式响应,已经在 relayService 中处理了
|
||||
if (result.streaming) {
|
||||
return
|
||||
}
|
||||
|
||||
// 非流式响应
|
||||
res.status(result.statusCode).set(result.headers).send(result.body)
|
||||
} catch (error) {
|
||||
logger.error('Droid Claude relay error:', error)
|
||||
res.status(500).json({
|
||||
error: 'internal_server_error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Comm 端点 - /v1/chat/completions(OpenAI Chat Completions 格式)
|
||||
router.post('/comm/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const sessionId =
|
||||
req.headers['session_id'] ||
|
||||
req.headers['x-session-id'] ||
|
||||
req.body?.session_id ||
|
||||
req.body?.conversation_id ||
|
||||
null
|
||||
|
||||
const sessionHash = sessionId
|
||||
? crypto.createHash('sha256').update(String(sessionId)).digest('hex')
|
||||
: null
|
||||
|
||||
if (!hasDroidPermission(req.apiKey)) {
|
||||
logger.security(
|
||||
`🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}`
|
||||
)
|
||||
return res.status(403).json({
|
||||
error: 'permission_denied',
|
||||
message: '此 API Key 未启用 Droid 权限'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await droidRelayService.relayRequest(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
req,
|
||||
res,
|
||||
req.headers,
|
||||
{ endpointType: 'comm', sessionHash }
|
||||
)
|
||||
|
||||
if (result.streaming) {
|
||||
return
|
||||
}
|
||||
|
||||
res.status(result.statusCode).set(result.headers).send(result.body)
|
||||
} catch (error) {
|
||||
logger.error('Droid Comm relay error:', error)
|
||||
res.status(500).json({
|
||||
error: 'internal_server_error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// OpenAI 端点 - /v1/responses
|
||||
router.post(['/openai/v1/responses', '/openai/responses'], authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const sessionId =
|
||||
req.headers['session_id'] ||
|
||||
req.headers['x-session-id'] ||
|
||||
req.body?.session_id ||
|
||||
req.body?.conversation_id ||
|
||||
null
|
||||
|
||||
const sessionHash = sessionId
|
||||
? crypto.createHash('sha256').update(String(sessionId)).digest('hex')
|
||||
: null
|
||||
|
||||
if (!hasDroidPermission(req.apiKey)) {
|
||||
logger.security(
|
||||
`🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}`
|
||||
)
|
||||
return res.status(403).json({
|
||||
error: 'permission_denied',
|
||||
message: '此 API Key 未启用 Droid 权限'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await droidRelayService.relayRequest(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
req,
|
||||
res,
|
||||
req.headers,
|
||||
{ endpointType: 'openai', sessionHash }
|
||||
)
|
||||
|
||||
if (result.streaming) {
|
||||
return
|
||||
}
|
||||
|
||||
res.status(result.statusCode).set(result.headers).send(result.body)
|
||||
} catch (error) {
|
||||
logger.error('Droid OpenAI relay error:', error)
|
||||
res.status(500).json({
|
||||
error: 'internal_server_error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 模型列表端点(兼容性)
|
||||
router.get('/*/v1/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
// 返回可用的模型列表
|
||||
const models = [
|
||||
{
|
||||
id: 'claude-opus-4-1-20250805',
|
||||
object: 'model',
|
||||
created: Date.now(),
|
||||
owned_by: 'anthropic'
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-5-20250929',
|
||||
object: 'model',
|
||||
created: Date.now(),
|
||||
owned_by: 'anthropic'
|
||||
},
|
||||
{
|
||||
id: 'gpt-5-2025-08-07',
|
||||
object: 'model',
|
||||
created: Date.now(),
|
||||
owned_by: 'openai'
|
||||
}
|
||||
]
|
||||
|
||||
res.json({
|
||||
object: 'list',
|
||||
data: models
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Droid models list error:', error)
|
||||
res.status(500).json({
|
||||
error: 'internal_server_error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -1,843 +1,115 @@
|
||||
/**
|
||||
* Gemini API 路由模块(精简版)
|
||||
*
|
||||
* 该模块只包含 geminiRoutes 独有的路由:
|
||||
* - /messages - OpenAI 兼容格式消息处理
|
||||
* - /models - 模型列表
|
||||
* - /usage - 使用统计
|
||||
* - /key-info - API Key 信息
|
||||
* - /v1internal:listExperiments - 实验列表
|
||||
* - /v1beta/models/:modelName:listExperiments - 带模型参数的实验列表
|
||||
*
|
||||
* 其他标准 Gemini API 路由由 standardGeminiRoutes.js 处理。
|
||||
* 所有处理函数都从 geminiHandlers.js 导入,以避免代码重复。
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const logger = require('../utils/logger')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const geminiAccountService = require('../services/geminiAccountService')
|
||||
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService')
|
||||
const crypto = require('crypto')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
// const { OAuth2Client } = require('google-auth-library'); // OAuth2Client is not used in this file
|
||||
|
||||
// 生成会话哈希
|
||||
function generateSessionHash(req) {
|
||||
const sessionData = [
|
||||
req.headers['user-agent'],
|
||||
req.ip,
|
||||
req.headers['x-api-key']?.substring(0, 10)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(':')
|
||||
|
||||
return crypto.createHash('sha256').update(sessionData).digest('hex')
|
||||
}
|
||||
|
||||
// 检查 API Key 权限
|
||||
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||
const permissions = apiKeyData.permissions || 'all'
|
||||
return permissions === 'all' || permissions === requiredPermission
|
||||
}
|
||||
|
||||
// Gemini 消息处理端点
|
||||
router.post('/messages', authenticateApiKey, async (req, res) => {
|
||||
const startTime = Date.now()
|
||||
let abortController = null
|
||||
|
||||
try {
|
||||
const apiKeyData = req.apiKey
|
||||
|
||||
// 检查权限
|
||||
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access Gemini',
|
||||
type: 'permission_denied'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 提取请求参数
|
||||
const {
|
||||
messages,
|
||||
model = 'gemini-2.0-flash-exp',
|
||||
temperature = 0.7,
|
||||
max_tokens = 4096,
|
||||
stream = false
|
||||
} = req.body
|
||||
|
||||
// 验证必需参数
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Messages array is required',
|
||||
type: 'invalid_request_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 生成会话哈希用于粘性会话
|
||||
const sessionHash = generateSessionHash(req)
|
||||
|
||||
// 使用统一调度选择可用的 Gemini 账户(传递请求的模型)
|
||||
let accountId
|
||||
try {
|
||||
const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
apiKeyData,
|
||||
sessionHash,
|
||||
model // 传递请求的模型进行过滤
|
||||
)
|
||||
const { accountId: selectedAccountId } = schedulerResult
|
||||
accountId = selectedAccountId
|
||||
} catch (error) {
|
||||
logger.error('Failed to select Gemini account:', error)
|
||||
return res.status(503).json({
|
||||
error: {
|
||||
message: error.message || 'No available Gemini accounts',
|
||||
type: 'service_unavailable'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取账户详情
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(503).json({
|
||||
error: {
|
||||
message: 'Selected account not found',
|
||||
type: 'service_unavailable'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`Using Gemini account: ${account.id} for API key: ${apiKeyData.id}`)
|
||||
|
||||
// 标记账户被使用
|
||||
await geminiAccountService.markAccountUsed(account.id)
|
||||
|
||||
// 创建中止控制器
|
||||
abortController = new AbortController()
|
||||
|
||||
// 处理客户端断开连接
|
||||
req.on('close', () => {
|
||||
if (abortController && !abortController.signal.aborted) {
|
||||
logger.info('Client disconnected, aborting Gemini request')
|
||||
abortController.abort()
|
||||
}
|
||||
})
|
||||
|
||||
// 发送请求到 Gemini
|
||||
const geminiResponse = await sendGeminiRequest({
|
||||
messages,
|
||||
model,
|
||||
temperature,
|
||||
maxTokens: max_tokens,
|
||||
stream,
|
||||
accessToken: account.accessToken,
|
||||
proxy: account.proxy,
|
||||
apiKeyId: apiKeyData.id,
|
||||
signal: abortController.signal,
|
||||
projectId: account.projectId,
|
||||
accountId: account.id
|
||||
})
|
||||
|
||||
if (stream) {
|
||||
// 设置流式响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.setHeader('X-Accel-Buffering', 'no')
|
||||
|
||||
// 流式传输响应
|
||||
for await (const chunk of geminiResponse) {
|
||||
if (abortController.signal.aborted) {
|
||||
break
|
||||
}
|
||||
res.write(chunk)
|
||||
}
|
||||
|
||||
res.end()
|
||||
} else {
|
||||
// 非流式响应
|
||||
res.json(geminiResponse)
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
logger.info(`Gemini request completed in ${duration}ms`)
|
||||
} catch (error) {
|
||||
logger.error('Gemini request error:', error)
|
||||
|
||||
// 处理速率限制
|
||||
if (error.status === 429) {
|
||||
if (req.apiKey && req.account) {
|
||||
await geminiAccountService.setAccountRateLimited(req.account.id, true)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回错误响应
|
||||
const status = error.status || 500
|
||||
const errorResponse = {
|
||||
error: error.error || {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
}
|
||||
|
||||
res.status(status).json(errorResponse)
|
||||
} finally {
|
||||
// 清理资源
|
||||
if (abortController) {
|
||||
abortController = null
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
// 获取可用模型列表
|
||||
router.get('/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const apiKeyData = req.apiKey
|
||||
|
||||
// 检查权限
|
||||
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access Gemini',
|
||||
type: 'permission_denied'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 选择账户获取模型列表
|
||||
let account = null
|
||||
try {
|
||||
const accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
apiKeyData,
|
||||
null,
|
||||
null
|
||||
)
|
||||
account = await geminiAccountService.getAccount(accountSelection.accountId)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to select Gemini account for models endpoint:', error)
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
// 返回默认模型列表
|
||||
return res.json({
|
||||
object: 'list',
|
||||
data: [
|
||||
{
|
||||
id: 'gemini-2.0-flash-exp',
|
||||
object: 'model',
|
||||
created: Date.now() / 1000,
|
||||
owned_by: 'google'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// 获取模型列表
|
||||
const models = await getAvailableModels(account.accessToken, account.proxy)
|
||||
|
||||
res.json({
|
||||
object: 'list',
|
||||
data: models
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get Gemini models:', error)
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve models',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
// 使用情况统计(与 Claude 共用)
|
||||
router.get('/usage', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const { usage } = req.apiKey
|
||||
|
||||
res.json({
|
||||
object: 'usage',
|
||||
total_tokens: usage.total.tokens,
|
||||
total_requests: usage.total.requests,
|
||||
daily_tokens: usage.daily.tokens,
|
||||
daily_requests: usage.daily.requests,
|
||||
monthly_tokens: usage.monthly.tokens,
|
||||
monthly_requests: usage.monthly.requests
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get usage stats:', error)
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve usage statistics',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// API Key 信息(与 Claude 共用)
|
||||
router.get('/key-info', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const keyData = req.apiKey
|
||||
|
||||
res.json({
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
permissions: keyData.permissions || 'all',
|
||||
token_limit: keyData.tokenLimit,
|
||||
tokens_used: keyData.usage.total.tokens,
|
||||
tokens_remaining:
|
||||
keyData.tokenLimit > 0
|
||||
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
|
||||
: null,
|
||||
rate_limit: {
|
||||
window: keyData.rateLimitWindow,
|
||||
requests: keyData.rateLimitRequests
|
||||
},
|
||||
concurrency_limit: keyData.concurrencyLimit,
|
||||
model_restrictions: {
|
||||
enabled: keyData.enableModelRestriction,
|
||||
models: keyData.restrictedModels
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get key info:', error)
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve API key information',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 共用的 loadCodeAssist 处理函数
|
||||
async function handleLoadCodeAssist(req, res) {
|
||||
try {
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 使用统一调度选择账号(传递请求的模型)
|
||||
const requestedModel = req.body.model
|
||||
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
requestedModel
|
||||
)
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
const { accessToken, refreshToken, projectId } = account
|
||||
|
||||
const { metadata, cloudaicompanionProject } = req.body
|
||||
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.info(`LoadCodeAssist request (${version})`, {
|
||||
metadata: metadata || {},
|
||||
requestedProject: cloudaicompanionProject || null,
|
||||
accountProject: projectId || null,
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
|
||||
|
||||
// 根据账户配置决定项目ID:
|
||||
// 1. 如果账户有项目ID -> 使用账户的项目ID(强制覆盖)
|
||||
// 2. 如果账户没有项目ID -> 传递 null(移除项目ID)
|
||||
let effectiveProjectId = null
|
||||
|
||||
if (projectId) {
|
||||
// 账户配置了项目ID,强制使用它
|
||||
effectiveProjectId = projectId
|
||||
logger.info('Using account project ID for loadCodeAssist:', effectiveProjectId)
|
||||
} else {
|
||||
// 账户没有配置项目ID,确保不传递项目ID
|
||||
effectiveProjectId = null
|
||||
logger.info('No project ID in account for loadCodeAssist, removing project parameter')
|
||||
}
|
||||
|
||||
const response = await geminiAccountService.loadCodeAssist(client, effectiveProjectId)
|
||||
|
||||
res.json(response)
|
||||
} catch (error) {
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.error(`Error in loadCodeAssist endpoint (${version})`, { error: error.message })
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 共用的 onboardUser 处理函数
|
||||
async function handleOnboardUser(req, res) {
|
||||
try {
|
||||
// 提取请求参数
|
||||
const { tierId, cloudaicompanionProject, metadata } = req.body
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 使用统一调度选择账号(传递请求的模型)
|
||||
const requestedModel = req.body.model
|
||||
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
requestedModel
|
||||
)
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
const { accessToken, refreshToken, projectId } = account
|
||||
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.info(`OnboardUser request (${version})`, {
|
||||
tierId: tierId || 'not provided',
|
||||
requestedProject: cloudaicompanionProject || null,
|
||||
accountProject: projectId || null,
|
||||
metadata: metadata || {},
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
|
||||
|
||||
// 根据账户配置决定项目ID:
|
||||
// 1. 如果账户有项目ID -> 使用账户的项目ID(强制覆盖)
|
||||
// 2. 如果账户没有项目ID -> 传递 null(移除项目ID)
|
||||
let effectiveProjectId = null
|
||||
|
||||
if (projectId) {
|
||||
// 账户配置了项目ID,强制使用它
|
||||
effectiveProjectId = projectId
|
||||
logger.info('Using account project ID:', effectiveProjectId)
|
||||
} else {
|
||||
// 账户没有配置项目ID,确保不传递项目ID(即使客户端传了也要移除)
|
||||
effectiveProjectId = null
|
||||
logger.info('No project ID in account, removing project parameter')
|
||||
}
|
||||
|
||||
// 如果提供了 tierId,直接调用 onboardUser
|
||||
if (tierId) {
|
||||
const response = await geminiAccountService.onboardUser(
|
||||
client,
|
||||
tierId,
|
||||
effectiveProjectId, // 使用处理后的项目ID
|
||||
metadata
|
||||
)
|
||||
|
||||
res.json(response)
|
||||
} else {
|
||||
// 否则执行完整的 setupUser 流程
|
||||
const response = await geminiAccountService.setupUser(
|
||||
client,
|
||||
effectiveProjectId, // 使用处理后的项目ID
|
||||
metadata
|
||||
)
|
||||
|
||||
res.json(response)
|
||||
}
|
||||
} catch (error) {
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.error(`Error in onboardUser endpoint (${version})`, { error: error.message })
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 共用的 countTokens 处理函数
|
||||
async function handleCountTokens(req, res) {
|
||||
try {
|
||||
// 处理请求体结构,支持直接 contents 或 request.contents
|
||||
const requestData = req.body.request || req.body
|
||||
const { contents, model = 'gemini-2.0-flash-exp' } = requestData
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 验证必需参数
|
||||
if (!contents || !Array.isArray(contents)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Contents array is required',
|
||||
type: 'invalid_request_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 使用统一调度选择账号
|
||||
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
model
|
||||
)
|
||||
const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId)
|
||||
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.info(`CountTokens request (${version})`, {
|
||||
model,
|
||||
contentsLength: contents.length,
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
|
||||
const response = await geminiAccountService.countTokens(client, contents, model)
|
||||
|
||||
res.json(response)
|
||||
} catch (error) {
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.error(`Error in countTokens endpoint (${version})`, { error: error.message })
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 共用的 generateContent 处理函数
|
||||
async function handleGenerateContent(req, res) {
|
||||
try {
|
||||
const { model, project, user_prompt_id, request: requestData } = req.body
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 处理不同格式的请求
|
||||
let actualRequestData = requestData
|
||||
if (!requestData) {
|
||||
if (req.body.messages) {
|
||||
// 这是 OpenAI 格式的请求,构建 Gemini 格式的 request 对象
|
||||
actualRequestData = {
|
||||
contents: req.body.messages.map((msg) => ({
|
||||
role: msg.role === 'assistant' ? 'model' : msg.role,
|
||||
parts: [{ text: msg.content }]
|
||||
})),
|
||||
generationConfig: {
|
||||
temperature: req.body.temperature !== undefined ? req.body.temperature : 0.7,
|
||||
maxOutputTokens: req.body.max_tokens !== undefined ? req.body.max_tokens : 4096,
|
||||
topP: req.body.top_p !== undefined ? req.body.top_p : 0.95,
|
||||
topK: req.body.top_k !== undefined ? req.body.top_k : 40
|
||||
}
|
||||
}
|
||||
} else if (req.body.contents) {
|
||||
// 直接的 Gemini 格式请求(没有 request 包装)
|
||||
actualRequestData = req.body
|
||||
}
|
||||
}
|
||||
|
||||
// 验证必需参数
|
||||
if (!actualRequestData || !actualRequestData.contents) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Request contents are required',
|
||||
type: 'invalid_request_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 使用统一调度选择账号
|
||||
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
model
|
||||
)
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
const { accessToken, refreshToken } = account
|
||||
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.info(`GenerateContent request (${version})`, {
|
||||
model,
|
||||
userPromptId: user_prompt_id,
|
||||
projectId: project || account.projectId,
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
|
||||
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
account.projectId, // 始终使用账户配置的项目ID,忽略请求中的project
|
||||
req.apiKey?.id, // 使用 API Key ID 作为 session ID
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
|
||||
// 记录使用统计
|
||||
if (response?.response?.usageMetadata) {
|
||||
try {
|
||||
const usage = response.response.usageMetadata
|
||||
await apiKeyService.recordUsage(
|
||||
req.apiKey.id,
|
||||
usage.promptTokenCount || 0,
|
||||
usage.candidatesTokenCount || 0,
|
||||
0, // cacheCreateTokens
|
||||
0, // cacheReadTokens
|
||||
model,
|
||||
account.id
|
||||
)
|
||||
logger.info(
|
||||
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to record Gemini usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
res.json(response)
|
||||
} catch (error) {
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
// 打印详细的错误信息
|
||||
logger.error(`Error in generateContent endpoint (${version})`, {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
requestUrl: error.config?.url,
|
||||
requestMethod: error.config?.method,
|
||||
stack: error.stack
|
||||
})
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 共用的 streamGenerateContent 处理函数
|
||||
async function handleStreamGenerateContent(req, res) {
|
||||
let abortController = null
|
||||
|
||||
try {
|
||||
const { model, project, user_prompt_id, request: requestData } = req.body
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 处理不同格式的请求
|
||||
let actualRequestData = requestData
|
||||
if (!requestData) {
|
||||
if (req.body.messages) {
|
||||
// 这是 OpenAI 格式的请求,构建 Gemini 格式的 request 对象
|
||||
actualRequestData = {
|
||||
contents: req.body.messages.map((msg) => ({
|
||||
role: msg.role === 'assistant' ? 'model' : msg.role,
|
||||
parts: [{ text: msg.content }]
|
||||
})),
|
||||
generationConfig: {
|
||||
temperature: req.body.temperature !== undefined ? req.body.temperature : 0.7,
|
||||
maxOutputTokens: req.body.max_tokens !== undefined ? req.body.max_tokens : 4096,
|
||||
topP: req.body.top_p !== undefined ? req.body.top_p : 0.95,
|
||||
topK: req.body.top_k !== undefined ? req.body.top_k : 40
|
||||
}
|
||||
}
|
||||
} else if (req.body.contents) {
|
||||
// 直接的 Gemini 格式请求(没有 request 包装)
|
||||
actualRequestData = req.body
|
||||
}
|
||||
}
|
||||
|
||||
// 验证必需参数
|
||||
if (!actualRequestData || !actualRequestData.contents) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Request contents are required',
|
||||
type: 'invalid_request_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 使用统一调度选择账号
|
||||
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
model
|
||||
)
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
const { accessToken, refreshToken } = account
|
||||
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.info(`StreamGenerateContent request (${version})`, {
|
||||
model,
|
||||
userPromptId: user_prompt_id,
|
||||
projectId: project || account.projectId,
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
// 创建中止控制器
|
||||
abortController = new AbortController()
|
||||
|
||||
// 处理客户端断开连接
|
||||
req.on('close', () => {
|
||||
if (abortController && !abortController.signal.aborted) {
|
||||
logger.info('Client disconnected, aborting stream request')
|
||||
abortController.abort()
|
||||
}
|
||||
})
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
|
||||
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const streamResponse = await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
account.projectId, // 始终使用账户配置的项目ID,忽略请求中的project
|
||||
req.apiKey?.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal, // 传递中止信号
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.setHeader('X-Accel-Buffering', 'no')
|
||||
|
||||
// 处理流式响应并捕获usage数据
|
||||
let buffer = ''
|
||||
let totalUsage = {
|
||||
promptTokenCount: 0,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: 0
|
||||
}
|
||||
const usageReported = false
|
||||
|
||||
streamResponse.on('data', (chunk) => {
|
||||
try {
|
||||
const chunkStr = chunk.toString()
|
||||
|
||||
// 直接转发数据到客户端
|
||||
if (!res.destroyed) {
|
||||
res.write(chunkStr)
|
||||
}
|
||||
|
||||
// 同时解析数据以捕获usage信息
|
||||
buffer += chunkStr
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ') && line.length > 6) {
|
||||
try {
|
||||
const jsonStr = line.slice(6)
|
||||
if (jsonStr && jsonStr !== '[DONE]') {
|
||||
const data = JSON.parse(jsonStr)
|
||||
|
||||
// 从响应中提取usage数据
|
||||
if (data.response?.usageMetadata) {
|
||||
totalUsage = data.response.usageMetadata
|
||||
logger.debug('📊 Captured Gemini usage data:', totalUsage)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing stream chunk:', error)
|
||||
}
|
||||
})
|
||||
|
||||
streamResponse.on('end', async () => {
|
||||
logger.info('Stream completed successfully')
|
||||
|
||||
// 记录使用统计
|
||||
if (!usageReported && totalUsage.totalTokenCount > 0) {
|
||||
try {
|
||||
await apiKeyService.recordUsage(
|
||||
req.apiKey.id,
|
||||
totalUsage.promptTokenCount || 0,
|
||||
totalUsage.candidatesTokenCount || 0,
|
||||
0, // cacheCreateTokens
|
||||
0, // cacheReadTokens
|
||||
model,
|
||||
account.id
|
||||
)
|
||||
logger.info(
|
||||
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to record Gemini usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
res.end()
|
||||
})
|
||||
|
||||
streamResponse.on('error', (error) => {
|
||||
logger.error('Stream error:', error)
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Stream error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
} else {
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
// 打印详细的错误信息
|
||||
logger.error(`Error in streamGenerateContent endpoint (${version})`, {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
requestUrl: error.config?.url,
|
||||
requestMethod: error.config?.method,
|
||||
stack: error.stack
|
||||
})
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
// 清理资源
|
||||
if (abortController) {
|
||||
abortController = null
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 注册所有路由端点
|
||||
// v1internal 版本的端点
|
||||
router.post('/v1internal\\:loadCodeAssist', authenticateApiKey, handleLoadCodeAssist)
|
||||
router.post('/v1internal\\:onboardUser', authenticateApiKey, handleOnboardUser)
|
||||
router.post('/v1internal\\:countTokens', authenticateApiKey, handleCountTokens)
|
||||
router.post('/v1internal\\:generateContent', authenticateApiKey, handleGenerateContent)
|
||||
router.post('/v1internal\\:streamGenerateContent', authenticateApiKey, handleStreamGenerateContent)
|
||||
|
||||
// v1beta 版本的端点 - 支持动态模型名称
|
||||
router.post('/v1beta/models/:modelName\\:loadCodeAssist', authenticateApiKey, handleLoadCodeAssist)
|
||||
router.post('/v1beta/models/:modelName\\:onboardUser', authenticateApiKey, handleOnboardUser)
|
||||
router.post('/v1beta/models/:modelName\\:countTokens', authenticateApiKey, handleCountTokens)
|
||||
// 从 handlers/geminiHandlers.js 导入所有处理函数
|
||||
const {
|
||||
handleMessages,
|
||||
handleModels,
|
||||
handleUsage,
|
||||
handleKeyInfo,
|
||||
handleSimpleEndpoint,
|
||||
// 以下函数需要导出供其他模块使用(如 unified.js)
|
||||
handleGenerateContent,
|
||||
handleStreamGenerateContent,
|
||||
handleLoadCodeAssist,
|
||||
handleOnboardUser,
|
||||
handleRetrieveUserQuota,
|
||||
handleCountTokens,
|
||||
handleStandardGenerateContent,
|
||||
handleStandardStreamGenerateContent,
|
||||
ensureGeminiPermissionMiddleware
|
||||
} = require('../handlers/geminiHandlers')
|
||||
|
||||
// ============================================================================
|
||||
// OpenAI 兼容格式路由
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* POST /messages
|
||||
* OpenAI 兼容格式的消息处理端点
|
||||
*/
|
||||
router.post('/messages', authenticateApiKey, handleMessages)
|
||||
|
||||
// ============================================================================
|
||||
// 模型和信息路由
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /models
|
||||
* 获取可用模型列表
|
||||
*/
|
||||
router.get('/models', authenticateApiKey, handleModels)
|
||||
|
||||
/**
|
||||
* GET /usage
|
||||
* 获取使用情况统计
|
||||
*/
|
||||
router.get('/usage', authenticateApiKey, handleUsage)
|
||||
|
||||
/**
|
||||
* GET /key-info
|
||||
* 获取 API Key 信息
|
||||
*/
|
||||
router.get('/key-info', authenticateApiKey, handleKeyInfo)
|
||||
|
||||
// ============================================================================
|
||||
// v1internal 独有路由
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* POST /v1internal:listExperiments
|
||||
* 列出实验(只有 geminiRoutes 定义此路由)
|
||||
*/
|
||||
router.post(
|
||||
'/v1beta/models/:modelName\\:generateContent',
|
||||
'/v1internal\\:listExperiments',
|
||||
authenticateApiKey,
|
||||
handleGenerateContent
|
||||
handleSimpleEndpoint('listExperiments')
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /v1internal:retrieveUserQuota
|
||||
* 获取用户配额信息(Gemini CLI 0.22.2+ 需要)
|
||||
*/
|
||||
router.post('/v1internal\\:retrieveUserQuota', authenticateApiKey, handleRetrieveUserQuota)
|
||||
|
||||
/**
|
||||
* POST /v1beta/models/:modelName:listExperiments
|
||||
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由)
|
||||
*/
|
||||
router.post(
|
||||
'/v1beta/models/:modelName\\:streamGenerateContent',
|
||||
'/v1beta/models/:modelName\\:listExperiments',
|
||||
authenticateApiKey,
|
||||
handleStreamGenerateContent
|
||||
handleSimpleEndpoint('listExperiments')
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 导出
|
||||
// ============================================================================
|
||||
|
||||
module.exports = router
|
||||
|
||||
// 导出处理函数供其他模块使用(如 unified.js、standardGeminiRoutes.js)
|
||||
module.exports.handleLoadCodeAssist = handleLoadCodeAssist
|
||||
module.exports.handleOnboardUser = handleOnboardUser
|
||||
module.exports.handleCountTokens = handleCountTokens
|
||||
module.exports.handleGenerateContent = handleGenerateContent
|
||||
module.exports.handleStreamGenerateContent = handleStreamGenerateContent
|
||||
module.exports.handleStandardGenerateContent = handleStandardGenerateContent
|
||||
module.exports.handleStandardStreamGenerateContent = handleStandardStreamGenerateContent
|
||||
module.exports.ensureGeminiPermissionMiddleware = ensureGeminiPermissionMiddleware
|
||||
|
||||
@@ -1,689 +0,0 @@
|
||||
const express = require('express')
|
||||
const ldapService = require('../services/ldapService')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
/**
|
||||
* 测试LDAP/AD连接
|
||||
*/
|
||||
router.get('/test-connection', async (req, res) => {
|
||||
try {
|
||||
logger.info('LDAP connection test requested')
|
||||
const result = await ldapService.testConnection()
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'LDAP/AD connection successful',
|
||||
data: result
|
||||
})
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'LDAP/AD connection failed',
|
||||
error: result.error,
|
||||
config: result.config
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('LDAP connection test error:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'LDAP connection test failed',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取LDAP配置信息
|
||||
*/
|
||||
router.get('/config', (req, res) => {
|
||||
try {
|
||||
const config = ldapService.getConfig()
|
||||
res.json({
|
||||
success: true,
|
||||
config
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Get LDAP config error:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get LDAP config',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 搜索用户
|
||||
*/
|
||||
router.post('/search-user', async (req, res) => {
|
||||
try {
|
||||
const { username } = req.body
|
||||
|
||||
if (!username) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username is required'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`Searching for user: ${username}`)
|
||||
|
||||
await ldapService.createConnection()
|
||||
await ldapService.bind()
|
||||
|
||||
const users = await ldapService.searchUser(username)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Found ${users.length} users`,
|
||||
users
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('User search error:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'User search failed',
|
||||
error: error.message
|
||||
})
|
||||
} finally {
|
||||
ldapService.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 列出所有用户(模拟Python代码的describe_ou功能)
|
||||
*/
|
||||
router.get('/list-users', async (req, res) => {
|
||||
try {
|
||||
const { limit = 20, type = 'human' } = req.query
|
||||
const limitNum = parseInt(limit)
|
||||
|
||||
logger.info(`Listing users with limit: ${limitNum}, type: ${type}`)
|
||||
|
||||
await ldapService.createConnection()
|
||||
await ldapService.bind()
|
||||
|
||||
const users = await ldapService.listAllUsers(limitNum, type)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Found ${users.length} users`,
|
||||
users,
|
||||
total: users.length,
|
||||
limit: limitNum,
|
||||
type
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('List users error:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'List users failed',
|
||||
error: error.message
|
||||
})
|
||||
} finally {
|
||||
ldapService.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 测试用户认证
|
||||
*/
|
||||
router.post('/test-auth', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username and password are required'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`Testing authentication for user: ${username}`)
|
||||
|
||||
const result = await ldapService.authenticateUser(username, password)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Authentication successful',
|
||||
user: result.user
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('User authentication test error:', error)
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: 'Authentication failed',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 列出所有OU
|
||||
*/
|
||||
router.get('/list-ous', async (req, res) => {
|
||||
try {
|
||||
logger.info('Listing all OUs in domain')
|
||||
|
||||
await ldapService.createConnection()
|
||||
await ldapService.bind()
|
||||
|
||||
const ous = await ldapService.listOUs()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Found ${ous.length} OUs`,
|
||||
ous
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('List OUs error:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'List OUs failed',
|
||||
error: error.message
|
||||
})
|
||||
} finally {
|
||||
ldapService.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 验证OU是否存在
|
||||
*/
|
||||
router.get('/verify-ou', async (req, res) => {
|
||||
try {
|
||||
const defaultOU = process.env.LDAP_DEFAULT_OU || 'YourOU'
|
||||
const { ou = defaultOU } = req.query
|
||||
// 使用配置的baseDN来构建测试DN,而不是硬编码域名
|
||||
const config = ldapService.getConfig()
|
||||
// 从baseDN中提取域部分,替换OU部分
|
||||
const baseDNParts = config.baseDN.split(',')
|
||||
const domainParts = baseDNParts.filter((part) => part.trim().startsWith('DC='))
|
||||
const testDN = `OU=${ou},${domainParts.join(',')}`
|
||||
|
||||
logger.info(`Verifying OU exists: ${testDN}`)
|
||||
|
||||
await ldapService.createConnection()
|
||||
await ldapService.bind()
|
||||
|
||||
const result = await ldapService.verifyOU(testDN)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'OU verification completed',
|
||||
testDN,
|
||||
result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('OU verification error:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'OU verification failed',
|
||||
error: error.message
|
||||
})
|
||||
} finally {
|
||||
ldapService.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* LDAP服务状态检查
|
||||
*/
|
||||
router.get('/status', async (req, res) => {
|
||||
try {
|
||||
const config = ldapService.getConfig()
|
||||
|
||||
// 简单的连接测试
|
||||
const connectionTest = await ldapService.testConnection()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
status: connectionTest.success ? 'connected' : 'disconnected',
|
||||
config,
|
||||
lastTest: new Date().toISOString(),
|
||||
testResult: connectionTest
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('LDAP status check error:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
status: 'error',
|
||||
message: 'Status check failed',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* AD用户登录认证
|
||||
*/
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '用户名和密码不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`AD用户登录尝试: ${username}`)
|
||||
|
||||
// 使用AD认证用户
|
||||
const authResult = await ldapService.authenticateUser(username, password)
|
||||
|
||||
// 生成用户会话token
|
||||
const jwt = require('jsonwebtoken')
|
||||
const config = require('../../config/config')
|
||||
|
||||
const userInfo = {
|
||||
type: 'ad_user',
|
||||
username: authResult.user.username || authResult.user.cn,
|
||||
displayName: authResult.user.displayName,
|
||||
email: authResult.user.email,
|
||||
groups: authResult.user.groups,
|
||||
loginTime: new Date().toISOString()
|
||||
}
|
||||
|
||||
const token = jwt.sign(userInfo, config.security.jwtSecret, {
|
||||
expiresIn: '8h' // 8小时过期
|
||||
})
|
||||
|
||||
logger.info(`AD用户登录成功: ${username}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '登录成功',
|
||||
token,
|
||||
user: userInfo
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('AD用户登录失败:', error)
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '用户名或密码错误',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* AD用户token验证
|
||||
*/
|
||||
router.get('/verify-token', (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '未提供有效的认证token'
|
||||
})
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7)
|
||||
const jwt = require('jsonwebtoken')
|
||||
const config = require('../../config/config')
|
||||
|
||||
const decoded = jwt.verify(token, config.security.jwtSecret)
|
||||
|
||||
if (decoded.type !== 'ad_user') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无效的用户类型'
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: decoded
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Token验证失败:', error)
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token无效或已过期'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* AD用户认证中间件
|
||||
*/
|
||||
const authenticateUser = (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '未提供有效的认证token'
|
||||
})
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7)
|
||||
const jwt = require('jsonwebtoken')
|
||||
const config = require('../../config/config')
|
||||
|
||||
const decoded = jwt.verify(token, config.security.jwtSecret)
|
||||
|
||||
if (decoded.type !== 'ad_user') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无效的用户类型'
|
||||
})
|
||||
}
|
||||
|
||||
req.user = decoded
|
||||
next()
|
||||
} catch (error) {
|
||||
logger.error('用户认证失败:', error)
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token无效或已过期'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的API Keys
|
||||
*
|
||||
* 自动关联逻辑说明:
|
||||
* 系统迁移过程中存在历史API Key,这些Key是在AD集成前手动创建的
|
||||
* 创建时使用的name字段恰好与AD用户的displayName一致
|
||||
* 例如: AD用户displayName为"测试用户",对应的API Key name也是"测试用户"
|
||||
* 为了避免用户重复创建Key,系统会自动关联这些历史Key
|
||||
* 关联规则:
|
||||
* 1. 优先匹配owner字段(新建的Key)
|
||||
* 2. 如果没有owner匹配,则尝试匹配name字段与displayName
|
||||
* 3. 找到匹配的历史Key后,自动将owner设置为当前用户,完成关联
|
||||
*/
|
||||
router.get('/user/api-keys', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const redis = require('../models/redis')
|
||||
const { username, displayName } = req.user
|
||||
|
||||
logger.info(`获取用户API Keys: ${username}, displayName: ${displayName}`)
|
||||
|
||||
// 使用与admin相同的API Key服务,获取所有API Keys的完整信息
|
||||
const allApiKeys = await apiKeyService.getAllApiKeys()
|
||||
|
||||
const userKeys = []
|
||||
let foundHistoricalKey = false
|
||||
|
||||
// 筛选属于该用户的API Keys,并处理自动关联
|
||||
for (const apiKey of allApiKeys) {
|
||||
logger.debug(
|
||||
`检查API Key: ${apiKey.id}, name: "${apiKey.name}", owner: "${apiKey.owner || '无'}", displayName: "${displayName}"`
|
||||
)
|
||||
|
||||
// 规则1: 直接owner匹配(已关联的Key)
|
||||
if (apiKey.owner === username) {
|
||||
logger.info(`找到已关联的API Key: ${apiKey.id}`)
|
||||
userKeys.push(apiKey)
|
||||
}
|
||||
// 规则2: 历史Key自动关联(name字段匹配displayName且无owner)
|
||||
else if (displayName && apiKey.name === displayName && !apiKey.owner) {
|
||||
logger.info(
|
||||
`🔗 发现历史API Key需要关联: id=${apiKey.id}, name="${apiKey.name}", displayName="${displayName}"`
|
||||
)
|
||||
|
||||
// 自动关联: 设置owner为当前用户
|
||||
await redis.getClient().hset(`apikey:${apiKey.id}`, 'owner', username)
|
||||
foundHistoricalKey = true
|
||||
|
||||
// 更新本地数据并添加到用户Key列表
|
||||
apiKey.owner = username
|
||||
userKeys.push(apiKey)
|
||||
|
||||
logger.info(`✅ 历史API Key关联成功: ${apiKey.id} -> ${username}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (foundHistoricalKey) {
|
||||
logger.info(`用户 ${username} 自动关联了历史API Key`)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
apiKeys: userKeys
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取用户API Keys失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取API Keys失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 创建用户API Key
|
||||
*/
|
||||
router.post('/user/api-keys', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { username } = req.user
|
||||
// 用户创建的API Key不需要任何输入参数,都使用默认值
|
||||
// const { limit } = req.body // 不再从请求体获取limit
|
||||
|
||||
// 检查用户是否已有API Key
|
||||
const redis = require('../models/redis')
|
||||
const allKeysPattern = 'apikey:*'
|
||||
const keys = await redis.getClient().keys(allKeysPattern)
|
||||
|
||||
let userKeyCount = 0
|
||||
for (const key of keys) {
|
||||
const apiKeyData = await redis.getClient().hgetall(key)
|
||||
if (apiKeyData && apiKeyData.owner === username) {
|
||||
userKeyCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (userKeyCount >= 1) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '每个用户只能创建一个API Key'
|
||||
})
|
||||
}
|
||||
|
||||
// 使用与admin相同的API Key生成服务,确保数据结构一致性
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
|
||||
// 获取用户的显示名称
|
||||
const { displayName } = req.user
|
||||
// 用户创建的API Key名称固定为displayName,不允许自定义
|
||||
const defaultName = displayName || username
|
||||
|
||||
const keyParams = {
|
||||
name: defaultName, // 使用displayName作为API Key名称
|
||||
tokenLimit: 0, // 固定为无限制
|
||||
description: `AD用户${username}创建的API Key`,
|
||||
// AD用户创建的Key添加owner信息以区分用户归属
|
||||
owner: username,
|
||||
ownerType: 'ad_user',
|
||||
// 确保用户创建的Key默认激活
|
||||
isActive: true,
|
||||
// 设置基本权限(与admin创建保持一致)
|
||||
permissions: 'all',
|
||||
// 设置合理的并发和速率限制(与admin创建保持一致)
|
||||
concurrencyLimit: 0,
|
||||
rateLimitWindow: 0,
|
||||
rateLimitRequests: 0,
|
||||
// 添加标签标识AD用户创建
|
||||
tags: ['ad-user', 'user-created']
|
||||
}
|
||||
|
||||
const newKey = await apiKeyService.generateApiKey(keyParams)
|
||||
|
||||
logger.info(`用户${username}创建API Key成功: ${newKey.id}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'API Key创建成功',
|
||||
apiKey: {
|
||||
id: newKey.id,
|
||||
key: newKey.apiKey, // 返回完整的API Key
|
||||
name: newKey.name,
|
||||
tokenLimit: newKey.tokenLimit || 0,
|
||||
used: 0,
|
||||
createdAt: newKey.createdAt,
|
||||
isActive: true,
|
||||
usage: {
|
||||
daily: { requests: 0, tokens: 0 },
|
||||
total: { requests: 0, tokens: 0 }
|
||||
},
|
||||
dailyCost: 0
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('创建用户API Key失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '创建API Key失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户API Key使用统计
|
||||
*/
|
||||
router.get('/user/usage-stats', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { username } = req.user
|
||||
const redis = require('../models/redis')
|
||||
|
||||
// 获取用户的API Keys
|
||||
const allKeysPattern = 'apikey:*'
|
||||
const keys = await redis.getClient().keys(allKeysPattern)
|
||||
|
||||
let totalUsage = 0
|
||||
let totalLimit = 0
|
||||
const userKeys = []
|
||||
|
||||
for (const key of keys) {
|
||||
const apiKeyData = await redis.getClient().hgetall(key)
|
||||
if (apiKeyData && apiKeyData.owner === username) {
|
||||
const used = parseInt(apiKeyData.used) || 0
|
||||
const limit = parseInt(apiKeyData.limit) || 0
|
||||
|
||||
totalUsage += used
|
||||
totalLimit += limit
|
||||
|
||||
userKeys.push({
|
||||
id: apiKeyData.id,
|
||||
name: apiKeyData.name,
|
||||
used,
|
||||
limit,
|
||||
percentage: limit > 0 ? Math.round((used / limit) * 100) : 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
stats: {
|
||||
totalUsage,
|
||||
totalLimit,
|
||||
percentage: totalLimit > 0 ? Math.round((totalUsage / totalLimit) * 100) : 0,
|
||||
keyCount: userKeys.length,
|
||||
keys: userKeys
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取用户使用统计失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取使用统计失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新用户API Key
|
||||
*/
|
||||
router.put('/user/api-keys/:keyId', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { username } = req.user
|
||||
const { keyId } = req.params
|
||||
const updates = req.body
|
||||
|
||||
// 验证用户只能编辑自己的API Key
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const allApiKeys = await apiKeyService.getAllApiKeys()
|
||||
const apiKey = allApiKeys.find((key) => key.id === keyId && key.owner === username)
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'API Key 不存在或无权限'
|
||||
})
|
||||
}
|
||||
|
||||
// 限制用户只能修改特定字段(不允许修改name)
|
||||
const allowedFields = ['description', 'isActive']
|
||||
const filteredUpdates = {}
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (allowedFields.includes(key)) {
|
||||
filteredUpdates[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
await apiKeyService.updateApiKey(keyId, filteredUpdates)
|
||||
|
||||
logger.info(`用户 ${username} 更新了 API Key: ${keyId}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'API Key 更新成功'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('更新用户API Key失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '更新 API Key 失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 删除用户API Key
|
||||
*/
|
||||
router.delete('/user/api-keys/:keyId', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { username } = req.user
|
||||
const { keyId } = req.params
|
||||
|
||||
// 验证用户只能删除自己的API Key
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const allApiKeys = await apiKeyService.getAllApiKeys()
|
||||
const apiKey = allApiKeys.find((key) => key.id === keyId && key.owner === username)
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'API Key 不存在或无权限'
|
||||
})
|
||||
}
|
||||
|
||||
await apiKeyService.deleteApiKey(keyId)
|
||||
|
||||
logger.info(`用户 ${username} 删除了 API Key: ${keyId}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'API Key 删除成功'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('删除用户API Key失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '删除 API Key 失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -5,32 +5,43 @@
|
||||
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const logger = require('../utils/logger')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const claudeRelayService = require('../services/claudeRelayService')
|
||||
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
|
||||
const openaiToClaude = require('../services/openaiToClaude')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
||||
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
|
||||
// 加载模型定价数据
|
||||
let modelPricingData = {}
|
||||
try {
|
||||
const pricingPath = path.join(__dirname, '../../data/model_pricing.json')
|
||||
const pricingContent = fs.readFileSync(pricingPath, 'utf8')
|
||||
modelPricingData = JSON.parse(pricingContent)
|
||||
logger.info('✅ Model pricing data loaded successfully')
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to load model pricing data:', error)
|
||||
}
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
const pricingService = require('../services/pricingService')
|
||||
const { getEffectiveModel } = require('../utils/modelHelper')
|
||||
|
||||
// 🔧 辅助函数:检查 API Key 权限
|
||||
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
||||
const permissions = apiKeyData.permissions || 'all'
|
||||
return permissions === 'all' || permissions === requiredPermission
|
||||
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
|
||||
}
|
||||
|
||||
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
||||
if (!rateLimitInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
const label = context ? ` (${context})` : ''
|
||||
|
||||
updateRateLimitCounters(rateLimitInfo, usageSummary, model)
|
||||
.then(({ totalTokens, totalCost }) => {
|
||||
if (totalTokens > 0) {
|
||||
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`)
|
||||
}
|
||||
if (typeof totalCost === 'number' && totalCost > 0) {
|
||||
logger.api(`💰 Updated rate limit cost count${label}: +$${totalCost.toFixed(6)}`)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(`❌ Failed to update rate limit counters${label}:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
// 📋 OpenAI 兼容的模型列表端点
|
||||
@@ -65,9 +76,9 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
]
|
||||
|
||||
// 如果启用了模型限制,过滤模型列表
|
||||
// 如果启用了模型限制,视为黑名单:过滤掉受限模型
|
||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||
models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id))
|
||||
models = models.filter((model) => !apiKeyData.restrictedModels.includes(model.id))
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -104,9 +115,9 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 检查模型限制
|
||||
// 模型限制(黑名单):命中则直接拒绝
|
||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||
if (!apiKeyData.restrictedModels.includes(modelId)) {
|
||||
if (apiKeyData.restrictedModels.includes(modelId)) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: `Model '${modelId}' not found`,
|
||||
@@ -118,7 +129,7 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
|
||||
// 从 model_pricing.json 获取模型信息
|
||||
const modelData = modelPricingData[modelId]
|
||||
const modelData = pricingService.getModelPricing(modelId)
|
||||
|
||||
// 构建标准 OpenAI 格式的模型响应
|
||||
let modelInfo
|
||||
@@ -189,9 +200,10 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
// 转换 OpenAI 请求为 Claude 格式
|
||||
const claudeRequest = openaiToClaude.convertRequest(req.body)
|
||||
|
||||
// 检查模型限制
|
||||
// 模型限制(黑名单):命中受限模型则拒绝
|
||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||
if (!apiKeyData.restrictedModels.includes(claudeRequest.model)) {
|
||||
const effectiveModel = getEffectiveModel(claudeRequest.model || '')
|
||||
if (apiKeyData.restrictedModels.includes(effectiveModel)) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: `Model ${req.body.model} is not allowed for this API key`,
|
||||
@@ -206,12 +218,24 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
const sessionHash = sessionHelper.generateSessionHash(claudeRequest)
|
||||
|
||||
// 选择可用的Claude账户
|
||||
const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||
apiKeyData,
|
||||
sessionHash,
|
||||
claudeRequest.model
|
||||
)
|
||||
const { accountId } = accountSelection
|
||||
let accountSelection
|
||||
try {
|
||||
accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||
apiKeyData,
|
||||
sessionHash,
|
||||
claudeRequest.model
|
||||
)
|
||||
} catch (error) {
|
||||
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
||||
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(error.rateLimitEndAt)
|
||||
return res.status(403).json({
|
||||
error: 'upstream_rate_limited',
|
||||
message: limitMessage
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const { accountId, accountType } = accountSelection
|
||||
|
||||
// 获取该账号存储的 Claude Code headers
|
||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
||||
@@ -241,54 +265,105 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
}
|
||||
})
|
||||
|
||||
// 使用转换后的响应流 (使用 OAuth-only beta header,添加 Claude Code 必需的 headers)
|
||||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
(usage) => {
|
||||
// 记录使用统计
|
||||
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
||||
const model = usage.model || claudeRequest.model
|
||||
// 使用转换后的响应流 (根据账户类型选择转发服务)
|
||||
// 创建 usage 回调函数
|
||||
const usageCallback = (usage) => {
|
||||
// 记录使用统计
|
||||
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
||||
const model = usage.model || claudeRequest.model
|
||||
const cacheCreateTokens =
|
||||
(usage.cache_creation && typeof usage.cache_creation === 'object'
|
||||
? (usage.cache_creation.ephemeral_5m_input_tokens || 0) +
|
||||
(usage.cache_creation.ephemeral_1h_input_tokens || 0)
|
||||
: usage.cache_creation_input_tokens || 0) || 0
|
||||
const cacheReadTokens = usage.cache_read_input_tokens || 0
|
||||
|
||||
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(
|
||||
apiKeyData.id,
|
||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||
model,
|
||||
accountId
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record usage:', error)
|
||||
})
|
||||
}
|
||||
},
|
||||
// 流转换器
|
||||
(() => {
|
||||
// 为每个请求创建独立的会话ID
|
||||
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
|
||||
return (chunk) => openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
|
||||
})(),
|
||||
{
|
||||
betaHeader:
|
||||
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
||||
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(
|
||||
apiKeyData.id,
|
||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||
model,
|
||||
accountId,
|
||||
accountType
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record usage:', error)
|
||||
})
|
||||
|
||||
queueRateLimitUpdate(
|
||||
req.rateLimitInfo,
|
||||
{
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens
|
||||
},
|
||||
model,
|
||||
`openai-${accountType}-stream`
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 创建流转换器
|
||||
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
|
||||
const streamTransformer = (chunk) =>
|
||||
openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
|
||||
|
||||
// 根据账户类型选择转发服务
|
||||
if (accountType === 'claude-console') {
|
||||
// Claude Console 账户使用 Console 转发服务
|
||||
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
usageCallback,
|
||||
accountId,
|
||||
streamTransformer
|
||||
)
|
||||
} else {
|
||||
// Claude Official 账户使用标准转发服务
|
||||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
usageCallback,
|
||||
streamTransformer,
|
||||
{
|
||||
betaHeader:
|
||||
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 非流式请求
|
||||
logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`)
|
||||
|
||||
// 发送请求到 Claude (使用 OAuth-only beta header,添加 Claude Code 必需的 headers)
|
||||
const claudeResponse = await claudeRelayService.relayRequest(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
req,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
{ betaHeader: 'oauth-2025-04-20' }
|
||||
)
|
||||
// 根据账户类型选择转发服务
|
||||
let claudeResponse
|
||||
if (accountType === 'claude-console') {
|
||||
// Claude Console 账户使用 Console 转发服务
|
||||
claudeResponse = await claudeConsoleRelayService.relayRequest(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
req,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
accountId
|
||||
)
|
||||
} else {
|
||||
// Claude Official 账户使用标准转发服务
|
||||
claudeResponse = await claudeRelayService.relayRequest(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
req,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
{ betaHeader: 'oauth-2025-04-20' }
|
||||
)
|
||||
}
|
||||
|
||||
// 解析 Claude 响应
|
||||
let claudeData
|
||||
@@ -322,17 +397,36 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
// 记录使用统计
|
||||
if (claudeData.usage) {
|
||||
const { usage } = claudeData
|
||||
const cacheCreateTokens =
|
||||
(usage.cache_creation && typeof usage.cache_creation === 'object'
|
||||
? (usage.cache_creation.ephemeral_5m_input_tokens || 0) +
|
||||
(usage.cache_creation.ephemeral_1h_input_tokens || 0)
|
||||
: usage.cache_creation_input_tokens || 0) || 0
|
||||
const cacheReadTokens = usage.cache_read_input_tokens || 0
|
||||
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(
|
||||
apiKeyData.id,
|
||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||
claudeRequest.model,
|
||||
accountId
|
||||
accountId,
|
||||
accountType
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record usage:', error)
|
||||
})
|
||||
|
||||
queueRateLimitUpdate(
|
||||
req.rateLimitInfo,
|
||||
{
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens
|
||||
},
|
||||
claudeRequest.model,
|
||||
`openai-${accountType}-non-stream`
|
||||
)
|
||||
}
|
||||
|
||||
// 返回 OpenAI 格式响应
|
||||
@@ -342,16 +436,29 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
const duration = Date.now() - startTime
|
||||
logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`)
|
||||
} catch (error) {
|
||||
logger.error('❌ OpenAI-Claude request error:', error)
|
||||
// 客户端主动断开连接是正常情况,使用 INFO 级别
|
||||
if (error.message === 'Client disconnected') {
|
||||
logger.info('🔌 OpenAI-Claude stream ended: Client disconnected')
|
||||
} else {
|
||||
logger.error('❌ OpenAI-Claude request error:', error)
|
||||
}
|
||||
|
||||
const status = error.status || 500
|
||||
res.status(status).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT
|
||||
if (!res.headersSent) {
|
||||
// 客户端断开使用 499 状态码 (Client Closed Request)
|
||||
if (error.message === 'Client disconnected') {
|
||||
res.status(499).end()
|
||||
} else {
|
||||
const status = error.status || 500
|
||||
res.status(status).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
// 清理资源
|
||||
if (abortController) {
|
||||
@@ -420,3 +527,4 @@ router.post('/v1/completions', authenticateApiKey, async (req, res) => {
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
module.exports.handleChatCompletion = handleChatCompletion
|
||||
|
||||
@@ -6,24 +6,33 @@ const geminiAccountService = require('../services/geminiAccountService')
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
const { getAvailableModels } = require('../services/geminiRelayService')
|
||||
const crypto = require('crypto')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
|
||||
// 生成会话哈希
|
||||
function generateSessionHash(req) {
|
||||
const sessionData = [
|
||||
req.headers['user-agent'],
|
||||
req.ip,
|
||||
req.headers['authorization']?.substring(0, 20)
|
||||
]
|
||||
const authSource =
|
||||
req.headers['authorization'] || req.headers['x-api-key'] || req.headers['x-goog-api-key']
|
||||
|
||||
const sessionData = [req.headers['user-agent'], req.ip, authSource?.substring(0, 20)]
|
||||
.filter(Boolean)
|
||||
.join(':')
|
||||
|
||||
return crypto.createHash('sha256').update(sessionData).digest('hex')
|
||||
}
|
||||
|
||||
function ensureAntigravityProjectId(account) {
|
||||
if (account.projectId) {
|
||||
return account.projectId
|
||||
}
|
||||
if (account.tempProjectId) {
|
||||
return account.tempProjectId
|
||||
}
|
||||
return `ag-${crypto.randomBytes(8).toString('hex')}`
|
||||
}
|
||||
|
||||
// 检查 API Key 权限
|
||||
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||
const permissions = apiKeyData.permissions || 'all'
|
||||
return permissions === 'all' || permissions === requiredPermission
|
||||
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
|
||||
}
|
||||
|
||||
// 转换 OpenAI 消息格式到 Gemini 格式
|
||||
@@ -311,6 +320,16 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
// 标记账户被使用
|
||||
await geminiAccountService.markAccountUsed(account.id)
|
||||
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建中止控制器
|
||||
abortController = new AbortController()
|
||||
|
||||
@@ -325,24 +344,49 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
// 获取OAuth客户端
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
account.accessToken,
|
||||
account.refreshToken
|
||||
account.refreshToken,
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
)
|
||||
if (actualStream) {
|
||||
// 流式响应
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
let { projectId } = account
|
||||
|
||||
if (oauthProvider === 'antigravity') {
|
||||
projectId = ensureAntigravityProjectId(account)
|
||||
if (!account.projectId && account.tempProjectId !== projectId) {
|
||||
await geminiAccountService.updateTempProjectId(account.id, projectId)
|
||||
account.tempProjectId = projectId
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('StreamGenerateContent request', {
|
||||
model,
|
||||
projectId: account.projectId,
|
||||
projectId,
|
||||
apiKeyId: apiKeyData.id
|
||||
})
|
||||
|
||||
const streamResponse = await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
account.projectId, // 使用有权限的项目ID
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal // 传递中止信号
|
||||
)
|
||||
const streamResponse =
|
||||
oauthProvider === 'antigravity'
|
||||
? await geminiAccountService.generateContentStreamAntigravity(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
projectId,
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal, // 传递中止信号
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
: await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
projectId, // 使用有权限的项目ID
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal, // 传递中止信号
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
|
||||
// 设置流式响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
@@ -375,7 +419,7 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: 0
|
||||
}
|
||||
const usageReported = false
|
||||
let usageReported = false // 修复:改为 let 以便后续修改
|
||||
|
||||
streamResponse.on('data', (chunk) => {
|
||||
try {
|
||||
@@ -488,7 +532,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
// 记录使用统计
|
||||
if (!usageReported && totalUsage.totalTokenCount > 0) {
|
||||
try {
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyData.id,
|
||||
totalUsage.promptTokenCount || 0,
|
||||
@@ -501,6 +544,9 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
logger.info(
|
||||
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
|
||||
)
|
||||
|
||||
// 修复:标记 usage 已上报,避免重复上报
|
||||
usageReported = true
|
||||
} catch (error) {
|
||||
logger.error('Failed to record Gemini usage:', error)
|
||||
}
|
||||
@@ -523,26 +569,63 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
})
|
||||
} else {
|
||||
// 如果已经开始发送流数据,发送错误事件
|
||||
res.write(`data: {"error": {"message": "${error.message || 'Stream error'}"}}\n\n`)
|
||||
res.write('data: [DONE]\n\n')
|
||||
// 修复:使用 JSON.stringify 避免字符串插值导致的格式错误
|
||||
if (!res.destroyed) {
|
||||
try {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
error: {
|
||||
message: error.message || 'Stream error',
|
||||
type: 'stream_error',
|
||||
code: error.code
|
||||
}
|
||||
})}\n\n`
|
||||
)
|
||||
res.write('data: [DONE]\n\n')
|
||||
} catch (writeError) {
|
||||
logger.error('Error sending error event:', writeError)
|
||||
}
|
||||
}
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 非流式响应
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
let { projectId } = account
|
||||
|
||||
if (oauthProvider === 'antigravity') {
|
||||
projectId = ensureAntigravityProjectId(account)
|
||||
if (!account.projectId && account.tempProjectId !== projectId) {
|
||||
await geminiAccountService.updateTempProjectId(account.id, projectId)
|
||||
account.tempProjectId = projectId
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('GenerateContent request', {
|
||||
model,
|
||||
projectId: account.projectId,
|
||||
projectId,
|
||||
apiKeyId: apiKeyData.id
|
||||
})
|
||||
|
||||
const response = await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
account.projectId, // 使用有权限的项目ID
|
||||
apiKeyData.id // 使用 API Key ID 作为 session ID
|
||||
)
|
||||
const response =
|
||||
oauthProvider === 'antigravity'
|
||||
? await geminiAccountService.generateContentAntigravity(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
projectId,
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
: await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
projectId, // 使用有权限的项目ID
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
|
||||
// 转换为 OpenAI 格式并返回
|
||||
const openaiResponse = convertGeminiResponseToOpenAI(response, model, false)
|
||||
@@ -550,7 +633,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
// 记录使用统计
|
||||
if (openaiResponse.usage) {
|
||||
try {
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyData.id,
|
||||
openaiResponse.usage.prompt_tokens || 0,
|
||||
@@ -574,7 +656,15 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
const duration = Date.now() - startTime
|
||||
logger.info(`OpenAI-Gemini request completed in ${duration}ms`)
|
||||
} catch (error) {
|
||||
logger.error('OpenAI-Gemini request error:', error)
|
||||
const statusForLog = error?.status || error?.response?.status
|
||||
logger.error('OpenAI-Gemini request error', {
|
||||
message: error?.message,
|
||||
status: statusForLog,
|
||||
code: error?.code,
|
||||
requestUrl: error?.config?.url,
|
||||
requestMethod: error?.config?.method,
|
||||
upstreamTraceId: error?.response?.headers?.['x-cloudaicompanion-trace-id']
|
||||
})
|
||||
|
||||
// 处理速率限制
|
||||
if (error.status === 429) {
|
||||
@@ -583,17 +673,24 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 返回 OpenAI 格式的错误响应
|
||||
const status = error.status || 500
|
||||
const errorResponse = {
|
||||
error: error.error || {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT
|
||||
if (!res.headersSent) {
|
||||
// 客户端断开使用 499 状态码 (Client Closed Request)
|
||||
if (error.message === 'Client disconnected') {
|
||||
res.status(499).end()
|
||||
} else {
|
||||
// 返回 OpenAI 格式的错误响应
|
||||
const status = error.status || 500
|
||||
const errorResponse = {
|
||||
error: error.error || {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
}
|
||||
}
|
||||
res.status(status).json(errorResponse)
|
||||
}
|
||||
}
|
||||
|
||||
res.status(status).json(errorResponse)
|
||||
} finally {
|
||||
// 清理资源
|
||||
if (abortController) {
|
||||
@@ -603,8 +700,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
// OpenAI 兼容的模型列表端点
|
||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
// 获取可用模型列表的共享处理器
|
||||
async function handleGetModels(req, res) {
|
||||
try {
|
||||
const apiKeyData = req.apiKey
|
||||
|
||||
@@ -635,8 +732,21 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
let models = []
|
||||
|
||||
if (account) {
|
||||
// 获取实际的模型列表
|
||||
models = await getAvailableModels(account.accessToken, account.proxy)
|
||||
// 获取实际的模型列表(失败时回退到默认列表,避免影响 /v1/models 可用性)
|
||||
try {
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
models =
|
||||
oauthProvider === 'antigravity'
|
||||
? await geminiAccountService.fetchAvailableModelsAntigravity(
|
||||
account.accessToken,
|
||||
account.proxy,
|
||||
account.refreshToken
|
||||
)
|
||||
: await getAvailableModels(account.accessToken, account.proxy)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to get Gemini models list from upstream, fallback to default:', error)
|
||||
models = []
|
||||
}
|
||||
} else {
|
||||
// 返回默认模型列表
|
||||
models = [
|
||||
@@ -649,6 +759,17 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
]
|
||||
}
|
||||
|
||||
if (!models || models.length === 0) {
|
||||
models = [
|
||||
{
|
||||
id: 'gemini-2.0-flash-exp',
|
||||
object: 'model',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: 'google'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 如果启用了模型限制,过滤模型列表
|
||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
||||
models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id))
|
||||
@@ -668,8 +789,13 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
// OpenAI 兼容的模型列表端点 (带 v1 版)
|
||||
router.get('/v1/models', authenticateApiKey, handleGetModels)
|
||||
|
||||
// OpenAI 兼容的模型列表端点 (根路径版,方便第三方加载)
|
||||
router.get('/models', authenticateApiKey, handleGetModels)
|
||||
|
||||
// OpenAI 兼容的模型详情端点
|
||||
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
||||
|
||||
264
src/routes/standardGeminiRoutes.js
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 标准 Gemini API 路由模块
|
||||
*
|
||||
* 该模块处理标准 Gemini API 格式的请求:
|
||||
* - v1beta/models/:modelName:generateContent
|
||||
* - v1beta/models/:modelName:streamGenerateContent
|
||||
* - v1beta/models/:modelName:countTokens
|
||||
* - v1beta/models/:modelName:loadCodeAssist
|
||||
* - v1beta/models/:modelName:onboardUser
|
||||
* - v1/models/:modelName:* (同上)
|
||||
* - v1internal:* (内部格式)
|
||||
* - v1beta/models, v1/models (模型列表)
|
||||
* - v1beta/models/:modelName, v1/models/:modelName (模型详情)
|
||||
*
|
||||
* 所有处理函数都从 geminiHandlers.js 导入,以避免代码重复。
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
// 从 handlers/geminiHandlers.js 导入所有处理函数
|
||||
const {
|
||||
ensureGeminiPermissionMiddleware,
|
||||
handleLoadCodeAssist,
|
||||
handleOnboardUser,
|
||||
handleCountTokens,
|
||||
handleGenerateContent,
|
||||
handleStreamGenerateContent,
|
||||
handleStandardGenerateContent,
|
||||
handleStandardStreamGenerateContent,
|
||||
handleModels,
|
||||
handleModelDetails
|
||||
} = require('../handlers/geminiHandlers')
|
||||
|
||||
// ============================================================================
|
||||
// v1beta 版本的标准路由 - 支持动态模型名称
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* POST /v1beta/models/:modelName:loadCodeAssist
|
||||
*/
|
||||
router.post(
|
||||
'/v1beta/models/:modelName\\:loadCodeAssist',
|
||||
authenticateApiKey,
|
||||
ensureGeminiPermissionMiddleware,
|
||||
(req, res, next) => {
|
||||
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
|
||||
handleLoadCodeAssist(req, res, next)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /v1beta/models/:modelName:onboardUser
|
||||
*/
|
||||
router.post(
|
||||
'/v1beta/models/:modelName\\:onboardUser',
|
||||
authenticateApiKey,
|
||||
ensureGeminiPermissionMiddleware,
|
||||
(req, res, next) => {
|
||||
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
|
||||
handleOnboardUser(req, res, next)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /v1beta/models/:modelName:countTokens
|
||||
*/
|
||||
router.post(
|
||||
'/v1beta/models/:modelName\\:countTokens',
|
||||
authenticateApiKey,
|
||||
ensureGeminiPermissionMiddleware,
|
||||
(req, res, next) => {
|
||||
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
|
||||
handleCountTokens(req, res, next)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /v1beta/models/:modelName:generateContent
|
||||
* 使用专门的标准 API 处理函数(支持 OAuth 和 API 账户)
|
||||
*/
|
||||
router.post(
|
||||
'/v1beta/models/:modelName\\:generateContent',
|
||||
authenticateApiKey,
|
||||
ensureGeminiPermissionMiddleware,
|
||||
handleStandardGenerateContent
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /v1beta/models/:modelName:streamGenerateContent
|
||||
* 使用专门的标准 API 流式处理函数(支持 OAuth 和 API 账户)
|
||||
*/
|
||||
router.post(
|
||||
'/v1beta/models/:modelName\\:streamGenerateContent',
|
||||
authenticateApiKey,
|
||||
ensureGeminiPermissionMiddleware,
|
||||
handleStandardStreamGenerateContent
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// v1 版本的标准路由(为了完整性,虽然 Gemini 主要使用 v1beta)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* POST /v1/models/:modelName:generateContent
|
||||
*/
|
||||
router.post(
|
||||
'/v1/models/:modelName\\:generateContent',
|
||||
authenticateApiKey,
|
||||
ensureGeminiPermissionMiddleware,
|
||||
handleStandardGenerateContent
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /v1/models/:modelName:streamGenerateContent
|
||||
*/
|
||||
router.post(
|
||||
'/v1/models/:modelName\\:streamGenerateContent',
|
||||
authenticateApiKey,
|
||||
ensureGeminiPermissionMiddleware,
|
||||
handleStandardStreamGenerateContent
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /v1/models/:modelName:countTokens
|
||||
*/
|
||||
router.post(
|
||||
'/v1/models/:modelName\\:countTokens',
|
||||
authenticateApiKey,
|
||||
ensureGeminiPermissionMiddleware,
|
||||
(req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1): ${req.method} ${req.originalUrl}`)
|
||||
handleCountTokens(req, res, next)
|
||||
}
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// v1internal 版本的标准路由(这些使用内部格式的处理函数)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* POST /v1internal:loadCodeAssist
|
||||
*/
|
||||
router.post(
|
||||
'/v1internal\\:loadCodeAssist',
|
||||
authenticateApiKey,
|
||||
ensureGeminiPermissionMiddleware,
|
||||
(req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleLoadCodeAssist(req, res, next)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /v1internal:onboardUser
|
||||
*/
|
||||
router.post(
|
||||
'/v1internal\\:onboardUser',
|
||||
authenticateApiKey,
|
||||
ensureGeminiPermissionMiddleware,
|
||||
(req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleOnboardUser(req, res, next)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /v1internal:countTokens
|
||||
*/
|
||||
router.post(
|
||||
'/v1internal\\:countTokens',
|
||||
authenticateApiKey,
|
||||
ensureGeminiPermissionMiddleware,
|
||||
(req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleCountTokens(req, res, next)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /v1internal:generateContent
|
||||
* v1internal 格式使用内部格式的处理函数
|
||||
*/
|
||||
router.post(
|
||||
'/v1internal\\:generateContent',
|
||||
authenticateApiKey,
|
||||
ensureGeminiPermissionMiddleware,
|
||||
(req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleGenerateContent(req, res, next)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /v1internal:streamGenerateContent
|
||||
* v1internal 格式使用内部格式的处理函数
|
||||
*/
|
||||
router.post(
|
||||
'/v1internal\\:streamGenerateContent',
|
||||
authenticateApiKey,
|
||||
ensureGeminiPermissionMiddleware,
|
||||
(req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleStreamGenerateContent(req, res, next)
|
||||
}
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 模型列表端点
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /v1beta/models
|
||||
* 获取模型列表(v1beta 版本)
|
||||
*/
|
||||
router.get('/v1beta/models', authenticateApiKey, ensureGeminiPermissionMiddleware, (req, res) => {
|
||||
logger.info('Standard Gemini API models request (v1beta)')
|
||||
handleModels(req, res)
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /v1/models
|
||||
* 获取模型列表(v1 版本)
|
||||
*/
|
||||
router.get('/v1/models', authenticateApiKey, ensureGeminiPermissionMiddleware, (req, res) => {
|
||||
logger.info('Standard Gemini API models request (v1)')
|
||||
handleModels(req, res)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 模型详情端点
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /v1beta/models/:modelName
|
||||
* 获取模型详情(v1beta 版本)
|
||||
*/
|
||||
router.get(
|
||||
'/v1beta/models/:modelName',
|
||||
authenticateApiKey,
|
||||
ensureGeminiPermissionMiddleware,
|
||||
handleModelDetails
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /v1/models/:modelName
|
||||
* 获取模型详情(v1 版本)
|
||||
*/
|
||||
router.get(
|
||||
'/v1/models/:modelName',
|
||||
authenticateApiKey,
|
||||
ensureGeminiPermissionMiddleware,
|
||||
handleModelDetails
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 初始化日志
|
||||
// ============================================================================
|
||||
|
||||
logger.info('Standard Gemini API routes initialized')
|
||||
|
||||
module.exports = router
|
||||
203
src/routes/unified.js
Normal file
@@ -0,0 +1,203 @@
|
||||
const express = require('express')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
const { handleChatCompletion } = require('./openaiClaudeRoutes')
|
||||
// 从 handlers/geminiHandlers.js 导入处理函数
|
||||
const {
|
||||
handleGenerateContent: geminiHandleGenerateContent,
|
||||
handleStreamGenerateContent: geminiHandleStreamGenerateContent
|
||||
} = require('../handlers/geminiHandlers')
|
||||
const openaiRoutes = require('./openaiRoutes')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// 🔍 根据模型名称检测后端类型
|
||||
function detectBackendFromModel(modelName) {
|
||||
if (!modelName) {
|
||||
return 'claude' // 默认 Claude
|
||||
}
|
||||
|
||||
const model = modelName.toLowerCase()
|
||||
|
||||
// Claude 模型
|
||||
if (model.startsWith('claude-')) {
|
||||
return 'claude'
|
||||
}
|
||||
|
||||
// Gemini 模型
|
||||
if (model.startsWith('gemini-')) {
|
||||
return 'gemini'
|
||||
}
|
||||
|
||||
// OpenAI 模型
|
||||
if (model.startsWith('gpt-')) {
|
||||
return 'openai'
|
||||
}
|
||||
|
||||
// 默认使用 Claude
|
||||
return 'claude'
|
||||
}
|
||||
|
||||
// 🚀 智能后端路由处理器
|
||||
async function routeToBackend(req, res, requestedModel) {
|
||||
const backend = detectBackendFromModel(requestedModel)
|
||||
|
||||
logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`)
|
||||
|
||||
// 检查权限
|
||||
const { permissions } = req.apiKey
|
||||
|
||||
if (backend === 'claude') {
|
||||
// Claude 后端:通过 OpenAI 兼容层
|
||||
if (!apiKeyService.hasPermission(permissions, 'claude')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access Claude',
|
||||
type: 'permission_denied',
|
||||
code: 'permission_denied'
|
||||
}
|
||||
})
|
||||
}
|
||||
await handleChatCompletion(req, res, req.apiKey)
|
||||
} else if (backend === 'openai') {
|
||||
// OpenAI 后端
|
||||
if (!apiKeyService.hasPermission(permissions, 'openai')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access OpenAI',
|
||||
type: 'permission_denied',
|
||||
code: 'permission_denied'
|
||||
}
|
||||
})
|
||||
}
|
||||
return await openaiRoutes.handleResponses(req, res)
|
||||
} else if (backend === 'gemini') {
|
||||
// Gemini 后端
|
||||
if (!apiKeyService.hasPermission(permissions, 'gemini')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access Gemini',
|
||||
type: 'permission_denied',
|
||||
code: 'permission_denied'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 转换为 Gemini 格式
|
||||
const geminiRequest = {
|
||||
model: requestedModel,
|
||||
messages: req.body.messages,
|
||||
temperature: req.body.temperature || 0.7,
|
||||
max_tokens: req.body.max_tokens || 4096,
|
||||
stream: req.body.stream || false
|
||||
}
|
||||
|
||||
req.body = geminiRequest
|
||||
|
||||
if (geminiRequest.stream) {
|
||||
return await geminiHandleStreamGenerateContent(req, res)
|
||||
} else {
|
||||
return await geminiHandleGenerateContent(req, res)
|
||||
}
|
||||
} else {
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: `Unsupported backend: ${backend}`,
|
||||
type: 'server_error',
|
||||
code: 'unsupported_backend'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 OpenAI 兼容的 chat/completions 端点(智能后端路由)
|
||||
router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
// 验证必需参数
|
||||
if (!req.body.messages || !Array.isArray(req.body.messages) || req.body.messages.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Messages array is required and cannot be empty',
|
||||
type: 'invalid_request_error',
|
||||
code: 'invalid_request'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const requestedModel = req.body.model || 'claude-3-5-sonnet-20241022'
|
||||
req.body.model = requestedModel // 确保模型已设置
|
||||
|
||||
// 使用统一的后端路由处理器
|
||||
await routeToBackend(req, res, requestedModel)
|
||||
} catch (error) {
|
||||
logger.error('❌ OpenAI chat/completions error:', error)
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Internal server error',
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 🔄 OpenAI 兼容的 completions 端点(传统格式,智能后端路由)
|
||||
router.post('/v1/completions', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
// 验证必需参数
|
||||
if (!req.body.prompt) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Prompt is required',
|
||||
type: 'invalid_request_error',
|
||||
code: 'invalid_request'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 将传统 completions 格式转换为 chat 格式
|
||||
const originalBody = req.body
|
||||
const requestedModel = originalBody.model || 'claude-3-5-sonnet-20241022'
|
||||
|
||||
req.body = {
|
||||
model: requestedModel,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: originalBody.prompt
|
||||
}
|
||||
],
|
||||
max_tokens: originalBody.max_tokens,
|
||||
temperature: originalBody.temperature,
|
||||
top_p: originalBody.top_p,
|
||||
stream: originalBody.stream,
|
||||
stop: originalBody.stop,
|
||||
n: originalBody.n || 1,
|
||||
presence_penalty: originalBody.presence_penalty,
|
||||
frequency_penalty: originalBody.frequency_penalty,
|
||||
logit_bias: originalBody.logit_bias,
|
||||
user: originalBody.user
|
||||
}
|
||||
|
||||
// 使用统一的后端路由处理器
|
||||
await routeToBackend(req, res, requestedModel)
|
||||
} catch (error) {
|
||||
logger.error('❌ OpenAI completions error:', error)
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to process completion request',
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
module.exports.detectBackendFromModel = detectBackendFromModel
|
||||
module.exports.routeToBackend = routeToBackend
|
||||
764
src/routes/userRoutes.js
Normal file
@@ -0,0 +1,764 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const ldapService = require('../services/ldapService')
|
||||
const userService = require('../services/userService')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const inputValidator = require('../utils/inputValidator')
|
||||
const { RateLimiterRedis } = require('rate-limiter-flexible')
|
||||
const redis = require('../models/redis')
|
||||
const { authenticateUser, authenticateUserOrAdmin, requireAdmin } = require('../middleware/auth')
|
||||
|
||||
// 🚦 配置登录速率限制
|
||||
// 只基于IP地址限制,避免攻击者恶意锁定特定账户
|
||||
|
||||
// 延迟初始化速率限制器,确保 Redis 已连接
|
||||
let ipRateLimiter = null
|
||||
let strictIpRateLimiter = null
|
||||
|
||||
// 初始化速率限制器函数
|
||||
function initRateLimiters() {
|
||||
if (!ipRateLimiter) {
|
||||
try {
|
||||
const redisClient = redis.getClientSafe()
|
||||
|
||||
// IP地址速率限制 - 正常限制
|
||||
ipRateLimiter = new RateLimiterRedis({
|
||||
storeClient: redisClient,
|
||||
keyPrefix: 'login_ip_limiter',
|
||||
points: 30, // 每个IP允许30次尝试
|
||||
duration: 900, // 15分钟窗口期
|
||||
blockDuration: 900 // 超限后封禁15分钟
|
||||
})
|
||||
|
||||
// IP地址速率限制 - 严格限制(用于检测暴力破解)
|
||||
strictIpRateLimiter = new RateLimiterRedis({
|
||||
storeClient: redisClient,
|
||||
keyPrefix: 'login_ip_strict',
|
||||
points: 100, // 每个IP允许100次尝试
|
||||
duration: 3600, // 1小时窗口期
|
||||
blockDuration: 3600 // 超限后封禁1小时
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ 初始化速率限制器失败:', error)
|
||||
// 速率限制器初始化失败时继续运行,但记录错误
|
||||
}
|
||||
}
|
||||
return { ipRateLimiter, strictIpRateLimiter }
|
||||
}
|
||||
|
||||
// 🔐 用户登录端点
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body
|
||||
const clientIp = req.ip || req.connection.remoteAddress || 'unknown'
|
||||
|
||||
// 初始化速率限制器(如果尚未初始化)
|
||||
const limiters = initRateLimiters()
|
||||
|
||||
// 检查IP速率限制 - 基础限制
|
||||
if (limiters.ipRateLimiter) {
|
||||
try {
|
||||
await limiters.ipRateLimiter.consume(clientIp)
|
||||
} catch (rateLimiterRes) {
|
||||
const retryAfter = Math.round(rateLimiterRes.msBeforeNext / 1000) || 900
|
||||
logger.security(`🚫 Login rate limit exceeded for IP: ${clientIp}`)
|
||||
res.set('Retry-After', String(retryAfter))
|
||||
return res.status(429).json({
|
||||
error: 'Too many requests',
|
||||
message: `Too many login attempts from this IP. Please try again later.`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 检查IP速率限制 - 严格限制(防止暴力破解)
|
||||
if (limiters.strictIpRateLimiter) {
|
||||
try {
|
||||
await limiters.strictIpRateLimiter.consume(clientIp)
|
||||
} catch (rateLimiterRes) {
|
||||
const retryAfter = Math.round(rateLimiterRes.msBeforeNext / 1000) || 3600
|
||||
logger.security(`🚫 Strict rate limit exceeded for IP: ${clientIp} - possible brute force`)
|
||||
res.set('Retry-After', String(retryAfter))
|
||||
return res.status(429).json({
|
||||
error: 'Too many requests',
|
||||
message: 'Too many login attempts detected. Access temporarily blocked.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing credentials',
|
||||
message: 'Username and password are required'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证输入格式
|
||||
let validatedUsername
|
||||
try {
|
||||
validatedUsername = inputValidator.validateUsername(username)
|
||||
inputValidator.validatePassword(password)
|
||||
} catch (validationError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid input',
|
||||
message: validationError.message
|
||||
})
|
||||
}
|
||||
|
||||
// 检查用户管理是否启用
|
||||
if (!config.userManagement.enabled) {
|
||||
return res.status(503).json({
|
||||
error: 'Service unavailable',
|
||||
message: 'User management is not enabled'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查LDAP是否启用
|
||||
if (!config.ldap || !config.ldap.enabled) {
|
||||
return res.status(503).json({
|
||||
error: 'Service unavailable',
|
||||
message: 'LDAP authentication is not enabled'
|
||||
})
|
||||
}
|
||||
|
||||
// 尝试LDAP认证
|
||||
const authResult = await ldapService.authenticateUserCredentials(validatedUsername, password)
|
||||
|
||||
if (!authResult.success) {
|
||||
// 登录失败
|
||||
logger.info(`🚫 Failed login attempt for user: ${validatedUsername} from IP: ${clientIp}`)
|
||||
return res.status(401).json({
|
||||
error: 'Authentication failed',
|
||||
message: authResult.message
|
||||
})
|
||||
}
|
||||
|
||||
// 登录成功
|
||||
logger.info(`✅ User login successful: ${validatedUsername} from IP: ${clientIp}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
user: {
|
||||
id: authResult.user.id,
|
||||
username: authResult.user.username,
|
||||
email: authResult.user.email,
|
||||
displayName: authResult.user.displayName,
|
||||
firstName: authResult.user.firstName,
|
||||
lastName: authResult.user.lastName,
|
||||
role: authResult.user.role
|
||||
},
|
||||
sessionToken: authResult.sessionToken
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ User login error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Login error',
|
||||
message: 'Internal server error during login'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🚪 用户登出端点
|
||||
router.post('/logout', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
await userService.invalidateUserSession(req.user.sessionToken)
|
||||
|
||||
logger.info(`👋 User logout: ${req.user.username}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Logout successful'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ User logout error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Logout error',
|
||||
message: 'Internal server error during logout'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 👤 获取当前用户信息
|
||||
router.get('/profile', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const user = await userService.getUserById(req.user.id)
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: 'User not found',
|
||||
message: 'User profile not found'
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
apiKeyCount: user.apiKeyCount,
|
||||
totalUsage: user.totalUsage
|
||||
},
|
||||
config: {
|
||||
maxApiKeysPerUser: config.userManagement.maxApiKeysPerUser,
|
||||
allowUserDeleteApiKeys: config.userManagement.allowUserDeleteApiKeys
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get user profile error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Profile error',
|
||||
message: 'Failed to retrieve user profile'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🔑 获取用户的API Keys
|
||||
router.get('/api-keys', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { includeDeleted = 'false' } = req.query
|
||||
const apiKeys = await apiKeyService.getUserApiKeys(req.user.id, includeDeleted === 'true')
|
||||
|
||||
// 移除敏感信息并格式化usage数据
|
||||
const safeApiKeys = apiKeys.map((key) => {
|
||||
// Flatten usage structure for frontend compatibility
|
||||
let flatUsage = {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost: 0
|
||||
}
|
||||
|
||||
if (key.usage && key.usage.total) {
|
||||
flatUsage = {
|
||||
requests: key.usage.total.requests || 0,
|
||||
inputTokens: key.usage.total.inputTokens || 0,
|
||||
outputTokens: key.usage.total.outputTokens || 0,
|
||||
totalCost: key.totalCost || 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
description: key.description,
|
||||
tokenLimit: key.tokenLimit,
|
||||
isActive: key.isActive,
|
||||
createdAt: key.createdAt,
|
||||
lastUsedAt: key.lastUsedAt,
|
||||
expiresAt: key.expiresAt,
|
||||
usage: flatUsage,
|
||||
dailyCost: key.dailyCost,
|
||||
dailyCostLimit: key.dailyCostLimit,
|
||||
totalCost: key.totalCost,
|
||||
totalCostLimit: key.totalCostLimit,
|
||||
// 不返回实际的key值,只返回前缀和后几位
|
||||
keyPreview: key.key
|
||||
? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}`
|
||||
: null,
|
||||
// Include deletion fields for deleted keys
|
||||
isDeleted: key.isDeleted,
|
||||
deletedAt: key.deletedAt,
|
||||
deletedBy: key.deletedBy,
|
||||
deletedByType: key.deletedByType
|
||||
}
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
apiKeys: safeApiKeys,
|
||||
total: safeApiKeys.length
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get user API keys error:', error)
|
||||
res.status(500).json({
|
||||
error: 'API Keys error',
|
||||
message: 'Failed to retrieve API keys'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🔑 创建新的API Key
|
||||
router.post('/api-keys', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { name, description, tokenLimit, expiresAt, dailyCostLimit, totalCostLimit } = req.body
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing name',
|
||||
message: 'API key name is required'
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
totalCostLimit !== undefined &&
|
||||
totalCostLimit !== null &&
|
||||
totalCostLimit !== '' &&
|
||||
(Number.isNaN(Number(totalCostLimit)) || Number(totalCostLimit) < 0)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid total cost limit',
|
||||
message: 'Total cost limit must be a non-negative number'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查用户API Key数量限制
|
||||
const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id)
|
||||
if (userApiKeys.length >= config.userManagement.maxApiKeysPerUser) {
|
||||
return res.status(400).json({
|
||||
error: 'API key limit exceeded',
|
||||
message: `You can only have up to ${config.userManagement.maxApiKeysPerUser} API keys`
|
||||
})
|
||||
}
|
||||
|
||||
// 创建API Key数据
|
||||
const apiKeyData = {
|
||||
name: name.trim(),
|
||||
description: description?.trim() || '',
|
||||
userId: req.user.id,
|
||||
userUsername: req.user.username,
|
||||
tokenLimit: tokenLimit || null,
|
||||
expiresAt: expiresAt || null,
|
||||
dailyCostLimit: dailyCostLimit || null,
|
||||
totalCostLimit: totalCostLimit || null,
|
||||
createdBy: 'user',
|
||||
// 设置服务权限为全部服务,确保前端显示“服务权限”为“全部服务”且具备完整访问权限
|
||||
permissions: 'all'
|
||||
}
|
||||
|
||||
const newApiKey = await apiKeyService.createApiKey(apiKeyData)
|
||||
|
||||
// 更新用户API Key数量
|
||||
await userService.updateUserApiKeyCount(req.user.id, userApiKeys.length + 1)
|
||||
|
||||
logger.info(`🔑 User ${req.user.username} created API key: ${name}`)
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'API key created successfully',
|
||||
apiKey: {
|
||||
id: newApiKey.id,
|
||||
name: newApiKey.name,
|
||||
description: newApiKey.description,
|
||||
key: newApiKey.apiKey, // 只在创建时返回完整key
|
||||
tokenLimit: newApiKey.tokenLimit,
|
||||
expiresAt: newApiKey.expiresAt,
|
||||
dailyCostLimit: newApiKey.dailyCostLimit,
|
||||
totalCostLimit: newApiKey.totalCostLimit,
|
||||
createdAt: newApiKey.createdAt
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Create user API key error:', error)
|
||||
res.status(500).json({
|
||||
error: 'API Key creation error',
|
||||
message: 'Failed to create API key'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🗑️ 删除API Key
|
||||
router.delete('/api-keys/:keyId', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { keyId } = req.params
|
||||
|
||||
// 检查是否允许用户删除自己的API Keys
|
||||
if (!config.userManagement.allowUserDeleteApiKeys) {
|
||||
return res.status(403).json({
|
||||
error: 'Operation not allowed',
|
||||
message:
|
||||
'Users are not allowed to delete their own API keys. Please contact an administrator.'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查API Key是否属于当前用户
|
||||
const existingKey = await apiKeyService.getApiKeyById(keyId)
|
||||
if (!existingKey || existingKey.userId !== req.user.id) {
|
||||
return res.status(404).json({
|
||||
error: 'API key not found',
|
||||
message: 'API key not found or you do not have permission to access it'
|
||||
})
|
||||
}
|
||||
|
||||
await apiKeyService.deleteApiKey(keyId, req.user.username, 'user')
|
||||
|
||||
// 更新用户API Key数量
|
||||
const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id)
|
||||
await userService.updateUserApiKeyCount(req.user.id, userApiKeys.length)
|
||||
|
||||
logger.info(`🗑️ User ${req.user.username} deleted API key: ${existingKey.name}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'API key deleted successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Delete user API key error:', error)
|
||||
res.status(500).json({
|
||||
error: 'API Key deletion error',
|
||||
message: 'Failed to delete API key'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 获取用户使用统计
|
||||
router.get('/usage-stats', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { period = 'week', model } = req.query
|
||||
|
||||
// 获取用户的API Keys (including deleted ones for complete usage stats)
|
||||
const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id, true)
|
||||
const apiKeyIds = userApiKeys.map((key) => key.id)
|
||||
|
||||
if (apiKeyIds.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
stats: {
|
||||
totalRequests: 0,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCost: 0,
|
||||
dailyStats: [],
|
||||
modelStats: []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取使用统计
|
||||
const stats = await apiKeyService.getAggregatedUsageStats(apiKeyIds, { period, model })
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
stats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get user usage stats error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Usage stats error',
|
||||
message: 'Failed to retrieve usage statistics'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// === 管理员用户管理端点 ===
|
||||
|
||||
// 📋 获取用户列表(管理员)
|
||||
router.get('/', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, role, isActive, search } = req.query
|
||||
|
||||
const options = {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
role,
|
||||
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined
|
||||
}
|
||||
|
||||
const result = await userService.getAllUsers(options)
|
||||
|
||||
// 如果有搜索条件,进行过滤
|
||||
let filteredUsers = result.users
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
filteredUsers = result.users.filter(
|
||||
(user) =>
|
||||
user.username.toLowerCase().includes(searchLower) ||
|
||||
user.displayName.toLowerCase().includes(searchLower) ||
|
||||
user.email.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
users: filteredUsers,
|
||||
pagination: {
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
totalPages: result.totalPages
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get users list error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Users list error',
|
||||
message: 'Failed to retrieve users list'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 👤 获取特定用户信息(管理员)
|
||||
router.get('/:userId', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params
|
||||
|
||||
const user = await userService.getUserById(userId)
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: 'User not found',
|
||||
message: 'User not found'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户的API Keys(包括已删除的以保留统计数据)
|
||||
const apiKeys = await apiKeyService.getUserApiKeys(userId, true)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
...user,
|
||||
apiKeys: apiKeys.map((key) => {
|
||||
// Flatten usage structure for frontend compatibility
|
||||
let flatUsage = {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost: 0
|
||||
}
|
||||
|
||||
if (key.usage && key.usage.total) {
|
||||
flatUsage = {
|
||||
requests: key.usage.total.requests || 0,
|
||||
inputTokens: key.usage.total.inputTokens || 0,
|
||||
outputTokens: key.usage.total.outputTokens || 0,
|
||||
totalCost: key.totalCost || 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
description: key.description,
|
||||
isActive: key.isActive,
|
||||
createdAt: key.createdAt,
|
||||
lastUsedAt: key.lastUsedAt,
|
||||
usage: flatUsage,
|
||||
keyPreview: key.key
|
||||
? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}`
|
||||
: null
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get user details error:', error)
|
||||
res.status(500).json({
|
||||
error: 'User details error',
|
||||
message: 'Failed to retrieve user details'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🔄 更新用户状态(管理员)
|
||||
router.patch('/:userId/status', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params
|
||||
const { isActive } = req.body
|
||||
|
||||
if (typeof isActive !== 'boolean') {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid status',
|
||||
message: 'isActive must be a boolean value'
|
||||
})
|
||||
}
|
||||
|
||||
const updatedUser = await userService.updateUserStatus(userId, isActive)
|
||||
|
||||
const adminUser = req.admin?.username || req.user?.username
|
||||
logger.info(
|
||||
`🔄 Admin ${adminUser} ${isActive ? 'enabled' : 'disabled'} user: ${updatedUser.username}`
|
||||
)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `User ${isActive ? 'enabled' : 'disabled'} successfully`,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
username: updatedUser.username,
|
||||
isActive: updatedUser.isActive,
|
||||
updatedAt: updatedUser.updatedAt
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Update user status error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Update status error',
|
||||
message: error.message || 'Failed to update user status'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🔄 更新用户角色(管理员)
|
||||
router.patch('/:userId/role', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params
|
||||
const { role } = req.body
|
||||
|
||||
const validRoles = ['user', 'admin']
|
||||
if (!role || !validRoles.includes(role)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid role',
|
||||
message: `Role must be one of: ${validRoles.join(', ')}`
|
||||
})
|
||||
}
|
||||
|
||||
const updatedUser = await userService.updateUserRole(userId, role)
|
||||
|
||||
const adminUser = req.admin?.username || req.user?.username
|
||||
logger.info(`🔄 Admin ${adminUser} changed user ${updatedUser.username} role to: ${role}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `User role updated to ${role} successfully`,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
username: updatedUser.username,
|
||||
role: updatedUser.role,
|
||||
updatedAt: updatedUser.updatedAt
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Update user role error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Update role error',
|
||||
message: error.message || 'Failed to update user role'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🔑 禁用用户的所有API Keys(管理员)
|
||||
router.post('/:userId/disable-keys', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params
|
||||
|
||||
const user = await userService.getUserById(userId)
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: 'User not found',
|
||||
message: 'User not found'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await apiKeyService.disableUserApiKeys(userId)
|
||||
|
||||
const adminUser = req.admin?.username || req.user?.username
|
||||
logger.info(`🔑 Admin ${adminUser} disabled all API keys for user: ${user.username}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Disabled ${result.count} API keys for user ${user.username}`,
|
||||
disabledCount: result.count
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Disable user API keys error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Disable keys error',
|
||||
message: 'Failed to disable user API keys'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 获取用户使用统计(管理员)
|
||||
router.get('/:userId/usage-stats', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params
|
||||
const { period = 'week', model } = req.query
|
||||
|
||||
const user = await userService.getUserById(userId)
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: 'User not found',
|
||||
message: 'User not found'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户的API Keys(包括已删除的以保留统计数据)
|
||||
const userApiKeys = await apiKeyService.getUserApiKeys(userId, true)
|
||||
const apiKeyIds = userApiKeys.map((key) => key.id)
|
||||
|
||||
if (apiKeyIds.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName
|
||||
},
|
||||
stats: {
|
||||
totalRequests: 0,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCost: 0,
|
||||
dailyStats: [],
|
||||
modelStats: []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取使用统计
|
||||
const stats = await apiKeyService.getAggregatedUsageStats(apiKeyIds, { period, model })
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName
|
||||
},
|
||||
stats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get user usage stats (admin) error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Usage stats error',
|
||||
message: 'Failed to retrieve user usage statistics'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 获取用户管理统计(管理员)
|
||||
router.get('/stats/overview', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const stats = await userService.getUserStats()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
stats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get user stats overview error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Stats error',
|
||||
message: 'Failed to retrieve user statistics'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🔧 测试LDAP连接(管理员)
|
||||
router.get('/admin/ldap-test', authenticateUserOrAdmin, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const testResult = await ldapService.testConnection()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
ldapTest: testResult,
|
||||
config: ldapService.getConfigInfo()
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ LDAP test error:', error)
|
||||
res.status(500).json({
|
||||
error: 'LDAP test error',
|
||||
message: 'Failed to test LDAP connection'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -164,13 +164,27 @@ router.post('/auth/change-password', async (req, res) => {
|
||||
|
||||
// 获取当前会话
|
||||
const sessionData = await redis.getSession(token)
|
||||
if (!sessionData) {
|
||||
|
||||
// 🔒 安全修复:检查空对象
|
||||
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid token',
|
||||
message: 'Session expired or invalid'
|
||||
})
|
||||
}
|
||||
|
||||
// 🔒 安全修复:验证会话完整性
|
||||
if (!sessionData.username || !sessionData.loginTime) {
|
||||
logger.security(
|
||||
`🔒 Invalid session structure in /auth/change-password from ${req.ip || 'unknown'}`
|
||||
)
|
||||
await redis.deleteSession(token)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
message: 'Session data corrupted or incomplete'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取当前管理员信息
|
||||
const adminData = await redis.getSession('admin_credentials')
|
||||
if (!adminData) {
|
||||
@@ -269,13 +283,25 @@ router.get('/auth/user', async (req, res) => {
|
||||
|
||||
// 获取当前会话
|
||||
const sessionData = await redis.getSession(token)
|
||||
if (!sessionData) {
|
||||
|
||||
// 🔒 安全修复:检查空对象
|
||||
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid token',
|
||||
message: 'Session expired or invalid'
|
||||
})
|
||||
}
|
||||
|
||||
// 🔒 安全修复:验证会话完整性
|
||||
if (!sessionData.username || !sessionData.loginTime) {
|
||||
logger.security(`🔒 Invalid session structure in /auth/user from ${req.ip || 'unknown'}`)
|
||||
await redis.deleteSession(token)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
message: 'Session data corrupted or incomplete'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取管理员信息
|
||||
const adminData = await redis.getSession('admin_credentials')
|
||||
if (!adminData) {
|
||||
@@ -316,13 +342,24 @@ router.post('/auth/refresh', async (req, res) => {
|
||||
|
||||
const sessionData = await redis.getSession(token)
|
||||
|
||||
if (!sessionData) {
|
||||
// 🔒 安全修复:检查空对象(hgetall 对不存在的 key 返回 {})
|
||||
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid token',
|
||||
message: 'Session expired or invalid'
|
||||
})
|
||||
}
|
||||
|
||||
// 🔒 安全修复:验证会话完整性(必须有 username 和 loginTime)
|
||||
if (!sessionData.username || !sessionData.loginTime) {
|
||||
logger.security(`🔒 Invalid session structure detected from ${req.ip || 'unknown'}`)
|
||||
await redis.deleteSession(token) // 清理无效/伪造的会话
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
message: 'Session data corrupted or incomplete'
|
||||
})
|
||||
}
|
||||
|
||||
// 更新最后活动时间
|
||||
sessionData.lastActivity = new Date().toISOString()
|
||||
await redis.setSession(token, sessionData, config.security.adminSessionTimeout)
|
||||
|
||||
@@ -4,6 +4,7 @@ const logger = require('../utils/logger')
|
||||
const webhookService = require('../services/webhookService')
|
||||
const webhookConfigService = require('../services/webhookConfigService')
|
||||
const { authenticateAdmin } = require('../middleware/auth')
|
||||
const { getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||
|
||||
// 获取webhook配置
|
||||
router.get('/config', authenticateAdmin, async (req, res) => {
|
||||
@@ -114,27 +115,153 @@ router.post('/platforms/:id/toggle', authenticateAdmin, async (req, res) => {
|
||||
// 测试Webhook连通性
|
||||
router.post('/test', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { url, type = 'custom', secret, enableSign } = req.body
|
||||
const {
|
||||
url,
|
||||
type = 'custom',
|
||||
secret,
|
||||
enableSign,
|
||||
deviceKey,
|
||||
serverUrl,
|
||||
level,
|
||||
sound,
|
||||
group,
|
||||
// SMTP 相关字段
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
user,
|
||||
pass,
|
||||
from,
|
||||
to,
|
||||
ignoreTLS,
|
||||
botToken,
|
||||
chatId,
|
||||
apiBaseUrl,
|
||||
proxyUrl
|
||||
} = req.body
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing webhook URL',
|
||||
message: '请提供webhook URL'
|
||||
})
|
||||
// Bark平台特殊处理
|
||||
if (type === 'bark') {
|
||||
if (!deviceKey) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing device key',
|
||||
message: '请提供Bark设备密钥'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证服务器URL(如果提供)
|
||||
if (serverUrl) {
|
||||
try {
|
||||
new URL(serverUrl)
|
||||
} catch (urlError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid server URL format',
|
||||
message: '请提供有效的Bark服务器URL'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`🧪 测试webhook: ${type} - Device Key: ${deviceKey.substring(0, 8)}...`)
|
||||
} else if (type === 'smtp') {
|
||||
// SMTP平台验证
|
||||
if (!host) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing SMTP host',
|
||||
message: '请提供SMTP服务器地址'
|
||||
})
|
||||
}
|
||||
if (!user) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing SMTP user',
|
||||
message: '请提供SMTP用户名'
|
||||
})
|
||||
}
|
||||
if (!pass) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing SMTP password',
|
||||
message: '请提供SMTP密码'
|
||||
})
|
||||
}
|
||||
if (!to) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing recipient email',
|
||||
message: '请提供收件人邮箱'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`🧪 测试webhook: ${type} - ${host}:${port || 587} -> ${to}`)
|
||||
} else if (type === 'telegram') {
|
||||
if (!botToken) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing Telegram bot token',
|
||||
message: '请提供 Telegram 机器人 Token'
|
||||
})
|
||||
}
|
||||
if (!chatId) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing Telegram chat id',
|
||||
message: '请提供 Telegram Chat ID'
|
||||
})
|
||||
}
|
||||
|
||||
if (apiBaseUrl) {
|
||||
try {
|
||||
const parsed = new URL(apiBaseUrl)
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid Telegram API base url protocol',
|
||||
message: 'Telegram API 基础地址仅支持 http 或 https'
|
||||
})
|
||||
}
|
||||
} catch (urlError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid Telegram API base url',
|
||||
message: '请提供有效的 Telegram API 基础地址'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (proxyUrl) {
|
||||
try {
|
||||
const parsed = new URL(proxyUrl)
|
||||
const supportedProtocols = ['http:', 'https:', 'socks4:', 'socks4a:', 'socks5:']
|
||||
if (!supportedProtocols.includes(parsed.protocol)) {
|
||||
return res.status(400).json({
|
||||
error: 'Unsupported proxy protocol',
|
||||
message: 'Telegram 代理仅支持 http/https/socks 协议'
|
||||
})
|
||||
}
|
||||
} catch (urlError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid proxy url',
|
||||
message: '请提供有效的代理地址'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`🧪 测试webhook: ${type} - Chat ID: ${chatId}`)
|
||||
} else {
|
||||
// 其他平台验证URL
|
||||
if (!url) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing webhook URL',
|
||||
message: '请提供webhook URL'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
try {
|
||||
new URL(url)
|
||||
} catch (urlError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid URL format',
|
||||
message: '请提供有效的webhook URL'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`🧪 测试webhook: ${type} - ${url}`)
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
try {
|
||||
new URL(url)
|
||||
} catch (urlError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid URL format',
|
||||
message: '请提供有效的webhook URL'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`🧪 测试webhook: ${type} - ${url}`)
|
||||
|
||||
// 创建临时平台配置
|
||||
const platform = {
|
||||
type,
|
||||
@@ -145,21 +272,61 @@ router.post('/test', authenticateAdmin, async (req, res) => {
|
||||
timeout: 10000
|
||||
}
|
||||
|
||||
// 添加Bark特有字段
|
||||
if (type === 'bark') {
|
||||
platform.deviceKey = deviceKey
|
||||
platform.serverUrl = serverUrl
|
||||
platform.level = level
|
||||
platform.sound = sound
|
||||
platform.group = group
|
||||
} else if (type === 'smtp') {
|
||||
// 添加SMTP特有字段
|
||||
platform.host = host
|
||||
platform.port = port || 587
|
||||
platform.secure = secure || false
|
||||
platform.user = user
|
||||
platform.pass = pass
|
||||
platform.from = from
|
||||
platform.to = to
|
||||
platform.ignoreTLS = ignoreTLS || false
|
||||
} else if (type === 'telegram') {
|
||||
platform.botToken = botToken
|
||||
platform.chatId = chatId
|
||||
platform.apiBaseUrl = apiBaseUrl
|
||||
platform.proxyUrl = proxyUrl
|
||||
}
|
||||
|
||||
const result = await webhookService.testWebhook(platform)
|
||||
|
||||
const identifier = (() => {
|
||||
if (type === 'bark') {
|
||||
return `Device: ${deviceKey.substring(0, 8)}...`
|
||||
}
|
||||
if (type === 'smtp') {
|
||||
const recipients = Array.isArray(to) ? to.join(', ') : to
|
||||
return `${host}:${port || 587} -> ${recipients}`
|
||||
}
|
||||
if (type === 'telegram') {
|
||||
return `Chat ID: ${chatId}`
|
||||
}
|
||||
return url
|
||||
})()
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`✅ Webhook测试成功: ${url}`)
|
||||
logger.info(`✅ Webhook测试成功: ${identifier}`)
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Webhook测试成功',
|
||||
url
|
||||
url: type === 'bark' ? undefined : url,
|
||||
deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined
|
||||
})
|
||||
} else {
|
||||
logger.warn(`❌ Webhook测试失败: ${url} - ${result.error}`)
|
||||
logger.warn(`❌ Webhook测试失败: ${identifier} - ${result.error}`)
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Webhook测试失败',
|
||||
url,
|
||||
url: type === 'bark' ? undefined : url,
|
||||
deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined,
|
||||
error: result.error
|
||||
})
|
||||
}
|
||||
@@ -218,7 +385,7 @@ router.post('/test-notification', authenticateAdmin, async (req, res) => {
|
||||
errorCode,
|
||||
reason,
|
||||
message,
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: getISOStringWithTimezone(new Date())
|
||||
}
|
||||
|
||||
const result = await webhookService.sendNotification(type, testData)
|
||||
|
||||
789
src/services/accountBalanceService.js
Normal file
@@ -0,0 +1,789 @@
|
||||
const redis = require('../models/redis')
|
||||
const balanceScriptService = require('./balanceScriptService')
|
||||
const logger = require('../utils/logger')
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
||||
|
||||
class AccountBalanceService {
|
||||
constructor(options = {}) {
|
||||
this.redis = options.redis || redis
|
||||
this.logger = options.logger || logger
|
||||
|
||||
this.providers = new Map()
|
||||
|
||||
this.CACHE_TTL_SECONDS = 3600
|
||||
this.LOCAL_TTL_SECONDS = 300
|
||||
|
||||
this.LOW_BALANCE_THRESHOLD = 10
|
||||
this.HIGH_USAGE_THRESHOLD_PERCENT = 90
|
||||
this.DEFAULT_CONCURRENCY = 10
|
||||
}
|
||||
|
||||
getSupportedPlatforms() {
|
||||
return [
|
||||
'claude',
|
||||
'claude-console',
|
||||
'gemini',
|
||||
'gemini-api',
|
||||
'openai',
|
||||
'openai-responses',
|
||||
'azure_openai',
|
||||
'bedrock',
|
||||
'droid',
|
||||
'ccr'
|
||||
]
|
||||
}
|
||||
|
||||
normalizePlatform(platform) {
|
||||
if (!platform) {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = String(platform).trim().toLowerCase()
|
||||
|
||||
// 兼容实施文档与历史命名
|
||||
if (value === 'claude-official') {
|
||||
return 'claude'
|
||||
}
|
||||
if (value === 'azure-openai') {
|
||||
return 'azure_openai'
|
||||
}
|
||||
|
||||
// 保持前端平台键一致
|
||||
return value
|
||||
}
|
||||
|
||||
registerProvider(platform, provider) {
|
||||
const normalized = this.normalizePlatform(platform)
|
||||
if (!normalized) {
|
||||
throw new Error('registerProvider: 缺少 platform')
|
||||
}
|
||||
if (!provider || typeof provider.queryBalance !== 'function') {
|
||||
throw new Error(`registerProvider: Provider 无效 (${normalized})`)
|
||||
}
|
||||
this.providers.set(normalized, provider)
|
||||
}
|
||||
|
||||
async getAccountBalance(accountId, platform, options = {}) {
|
||||
const normalizedPlatform = this.normalizePlatform(platform)
|
||||
const account = await this.getAccount(accountId, normalizedPlatform)
|
||||
if (!account) {
|
||||
return null
|
||||
}
|
||||
return await this._getAccountBalanceForAccount(account, normalizedPlatform, options)
|
||||
}
|
||||
|
||||
async refreshAccountBalance(accountId, platform) {
|
||||
const normalizedPlatform = this.normalizePlatform(platform)
|
||||
const account = await this.getAccount(accountId, normalizedPlatform)
|
||||
if (!account) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await this._getAccountBalanceForAccount(account, normalizedPlatform, {
|
||||
queryApi: true,
|
||||
useCache: false
|
||||
})
|
||||
}
|
||||
|
||||
async getAllAccountsBalance(platform, options = {}) {
|
||||
const normalizedPlatform = this.normalizePlatform(platform)
|
||||
const accounts = await this.getAllAccountsByPlatform(normalizedPlatform)
|
||||
const queryApi = this._parseBoolean(options.queryApi) || false
|
||||
const useCache = options.useCache !== false
|
||||
|
||||
const results = await this._mapWithConcurrency(
|
||||
accounts,
|
||||
this.DEFAULT_CONCURRENCY,
|
||||
async (acc) => {
|
||||
try {
|
||||
const balance = await this._getAccountBalanceForAccount(acc, normalizedPlatform, {
|
||||
queryApi,
|
||||
useCache
|
||||
})
|
||||
return { ...balance, name: acc.name || '' }
|
||||
} catch (error) {
|
||||
this.logger.error(`批量获取余额失败: ${normalizedPlatform}:${acc?.id}`, error)
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
accountId: acc?.id,
|
||||
platform: normalizedPlatform,
|
||||
balance: null,
|
||||
quota: null,
|
||||
statistics: {},
|
||||
source: 'local',
|
||||
lastRefreshAt: new Date().toISOString(),
|
||||
cacheExpiresAt: null,
|
||||
status: 'error',
|
||||
error: error.message || '批量查询失败'
|
||||
},
|
||||
name: acc?.name || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async getBalanceSummary() {
|
||||
const platforms = this.getSupportedPlatforms()
|
||||
|
||||
const summary = {
|
||||
totalBalance: 0,
|
||||
totalCost: 0,
|
||||
lowBalanceCount: 0,
|
||||
platforms: {}
|
||||
}
|
||||
|
||||
for (const platform of platforms) {
|
||||
const accounts = await this.getAllAccountsByPlatform(platform)
|
||||
const platformData = {
|
||||
count: accounts.length,
|
||||
totalBalance: 0,
|
||||
totalCost: 0,
|
||||
lowBalanceCount: 0,
|
||||
accounts: []
|
||||
}
|
||||
|
||||
const balances = await this._mapWithConcurrency(
|
||||
accounts,
|
||||
this.DEFAULT_CONCURRENCY,
|
||||
async (acc) => {
|
||||
const balance = await this._getAccountBalanceForAccount(acc, platform, {
|
||||
queryApi: false,
|
||||
useCache: true
|
||||
})
|
||||
return { ...balance, name: acc.name || '' }
|
||||
}
|
||||
)
|
||||
|
||||
for (const item of balances) {
|
||||
platformData.accounts.push(item)
|
||||
|
||||
const amount = item?.data?.balance?.amount
|
||||
const percentage = item?.data?.quota?.percentage
|
||||
const totalCost = Number(item?.data?.statistics?.totalCost || 0)
|
||||
|
||||
const hasAmount = typeof amount === 'number' && Number.isFinite(amount)
|
||||
const isLowBalance = hasAmount && amount < this.LOW_BALANCE_THRESHOLD
|
||||
const isHighUsage =
|
||||
typeof percentage === 'number' &&
|
||||
Number.isFinite(percentage) &&
|
||||
percentage > this.HIGH_USAGE_THRESHOLD_PERCENT
|
||||
|
||||
if (hasAmount) {
|
||||
platformData.totalBalance += amount
|
||||
}
|
||||
|
||||
if (isLowBalance || isHighUsage) {
|
||||
platformData.lowBalanceCount += 1
|
||||
summary.lowBalanceCount += 1
|
||||
}
|
||||
|
||||
platformData.totalCost += totalCost
|
||||
}
|
||||
|
||||
summary.platforms[platform] = platformData
|
||||
summary.totalBalance += platformData.totalBalance
|
||||
summary.totalCost += platformData.totalCost
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
async clearCache(accountId, platform) {
|
||||
const normalizedPlatform = this.normalizePlatform(platform)
|
||||
if (!normalizedPlatform) {
|
||||
throw new Error('缺少 platform 参数')
|
||||
}
|
||||
|
||||
await this.redis.deleteAccountBalance(normalizedPlatform, accountId)
|
||||
this.logger.info(`余额缓存已清除: ${normalizedPlatform}:${accountId}`)
|
||||
}
|
||||
|
||||
async getAccount(accountId, platform) {
|
||||
if (!accountId || !platform) {
|
||||
return null
|
||||
}
|
||||
|
||||
const serviceMap = {
|
||||
claude: require('./claudeAccountService'),
|
||||
'claude-console': require('./claudeConsoleAccountService'),
|
||||
gemini: require('./geminiAccountService'),
|
||||
'gemini-api': require('./geminiApiAccountService'),
|
||||
openai: require('./openaiAccountService'),
|
||||
'openai-responses': require('./openaiResponsesAccountService'),
|
||||
azure_openai: require('./azureOpenaiAccountService'),
|
||||
bedrock: require('./bedrockAccountService'),
|
||||
droid: require('./droidAccountService'),
|
||||
ccr: require('./ccrAccountService')
|
||||
}
|
||||
|
||||
const service = serviceMap[platform]
|
||||
if (!service || typeof service.getAccount !== 'function') {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = await service.getAccount(accountId)
|
||||
|
||||
// 处理不同服务返回格式的差异
|
||||
// Bedrock/CCR/Droid 等服务返回 { success, data } 格式
|
||||
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
|
||||
return result.success ? result.data : null
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async getAllAccountsByPlatform(platform) {
|
||||
if (!platform) {
|
||||
return []
|
||||
}
|
||||
|
||||
const serviceMap = {
|
||||
claude: require('./claudeAccountService'),
|
||||
'claude-console': require('./claudeConsoleAccountService'),
|
||||
gemini: require('./geminiAccountService'),
|
||||
'gemini-api': require('./geminiApiAccountService'),
|
||||
openai: require('./openaiAccountService'),
|
||||
'openai-responses': require('./openaiResponsesAccountService'),
|
||||
azure_openai: require('./azureOpenaiAccountService'),
|
||||
bedrock: require('./bedrockAccountService'),
|
||||
droid: require('./droidAccountService'),
|
||||
ccr: require('./ccrAccountService')
|
||||
}
|
||||
|
||||
const service = serviceMap[platform]
|
||||
if (!service) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Bedrock 特殊:返回 { success, data }
|
||||
if (platform === 'bedrock' && typeof service.getAllAccounts === 'function') {
|
||||
const result = await service.getAllAccounts()
|
||||
return result?.success ? result.data || [] : []
|
||||
}
|
||||
|
||||
if (platform === 'openai-responses') {
|
||||
return await service.getAllAccounts(true)
|
||||
}
|
||||
|
||||
if (typeof service.getAllAccounts !== 'function') {
|
||||
return []
|
||||
}
|
||||
|
||||
return await service.getAllAccounts()
|
||||
}
|
||||
|
||||
async _getAccountBalanceForAccount(account, platform, options = {}) {
|
||||
const queryMode = this._parseQueryMode(options.queryApi)
|
||||
const useCache = options.useCache !== false
|
||||
|
||||
const accountId = account?.id
|
||||
if (!accountId) {
|
||||
// 如果账户缺少 id,返回空响应而不是抛出错误,避免接口报错和UI错误
|
||||
this.logger.warn('账户缺少 id,返回空余额数据', { account, platform })
|
||||
return this._buildResponse(
|
||||
{
|
||||
status: 'error',
|
||||
errorMessage: '账户数据异常',
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
quota: null,
|
||||
statistics: {},
|
||||
lastRefreshAt: new Date().toISOString()
|
||||
},
|
||||
'unknown',
|
||||
platform,
|
||||
'local',
|
||||
null,
|
||||
{ scriptEnabled: false, scriptConfigured: false }
|
||||
)
|
||||
}
|
||||
|
||||
// 余额脚本配置状态(用于前端控制"刷新余额"按钮)
|
||||
let scriptConfig = null
|
||||
let scriptConfigured = false
|
||||
if (typeof this.redis?.getBalanceScriptConfig === 'function') {
|
||||
scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId)
|
||||
scriptConfigured = !!(
|
||||
scriptConfig &&
|
||||
scriptConfig.scriptBody &&
|
||||
String(scriptConfig.scriptBody).trim().length > 0
|
||||
)
|
||||
}
|
||||
const scriptEnabled = isBalanceScriptEnabled()
|
||||
const scriptMeta = { scriptEnabled, scriptConfigured }
|
||||
|
||||
const localBalance = await this._getBalanceFromLocal(accountId, platform)
|
||||
const localStatistics = localBalance.statistics || {}
|
||||
|
||||
const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics)
|
||||
|
||||
// 安全限制:queryApi=auto 仅用于 Antigravity(gemini + oauthProvider=antigravity)账户
|
||||
const effectiveQueryMode =
|
||||
queryMode === 'auto' && !(platform === 'gemini' && account?.oauthProvider === 'antigravity')
|
||||
? 'local'
|
||||
: queryMode
|
||||
|
||||
// local: 仅本地统计/缓存;auto: 优先缓存,无缓存则尝试远程 Provider(并缓存结果)
|
||||
if (effectiveQueryMode !== 'api') {
|
||||
if (useCache) {
|
||||
const cached = await this.redis.getAccountBalance(platform, accountId)
|
||||
if (cached && cached.status === 'success') {
|
||||
return this._buildResponse(
|
||||
{
|
||||
status: cached.status,
|
||||
errorMessage: cached.errorMessage,
|
||||
balance: quotaFromLocal.balance ?? cached.balance,
|
||||
currency: quotaFromLocal.currency || cached.currency || 'USD',
|
||||
quota: quotaFromLocal.quota || cached.quota || null,
|
||||
statistics: localStatistics,
|
||||
lastRefreshAt: cached.lastRefreshAt
|
||||
},
|
||||
accountId,
|
||||
platform,
|
||||
'cache',
|
||||
cached.ttlSeconds,
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveQueryMode === 'local') {
|
||||
return this._buildResponse(
|
||||
{
|
||||
status: 'success',
|
||||
errorMessage: null,
|
||||
balance: quotaFromLocal.balance,
|
||||
currency: quotaFromLocal.currency || 'USD',
|
||||
quota: quotaFromLocal.quota,
|
||||
statistics: localStatistics,
|
||||
lastRefreshAt: localBalance.lastCalculated
|
||||
},
|
||||
accountId,
|
||||
platform,
|
||||
'local',
|
||||
null,
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 强制查询:优先脚本(如启用且已配置),否则调用 Provider;失败自动降级到本地统计
|
||||
let providerResult
|
||||
|
||||
if (scriptEnabled && scriptConfigured) {
|
||||
providerResult = await this._getBalanceFromScript(scriptConfig, accountId, platform)
|
||||
} else {
|
||||
const provider = this.providers.get(platform)
|
||||
if (!provider) {
|
||||
return this._buildResponse(
|
||||
{
|
||||
status: 'error',
|
||||
errorMessage: `不支持的平台: ${platform}`,
|
||||
balance: quotaFromLocal.balance,
|
||||
currency: quotaFromLocal.currency || 'USD',
|
||||
quota: quotaFromLocal.quota,
|
||||
statistics: localStatistics,
|
||||
lastRefreshAt: new Date().toISOString()
|
||||
},
|
||||
accountId,
|
||||
platform,
|
||||
'local',
|
||||
null,
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
providerResult = await this._getBalanceFromProvider(provider, account)
|
||||
}
|
||||
|
||||
const isRemoteSuccess =
|
||||
providerResult.status === 'success' && ['api', 'script'].includes(providerResult.queryMethod)
|
||||
|
||||
// 仅缓存“真实远程查询成功”的结果,避免把字段/本地降级结果当作 API 结果缓存 1h
|
||||
if (isRemoteSuccess) {
|
||||
await this.redis.setAccountBalance(
|
||||
platform,
|
||||
accountId,
|
||||
providerResult,
|
||||
this.CACHE_TTL_SECONDS
|
||||
)
|
||||
}
|
||||
|
||||
const source = isRemoteSuccess ? 'api' : 'local'
|
||||
|
||||
return this._buildResponse(
|
||||
{
|
||||
status: providerResult.status,
|
||||
errorMessage: providerResult.errorMessage,
|
||||
balance: quotaFromLocal.balance ?? providerResult.balance,
|
||||
currency: quotaFromLocal.currency || providerResult.currency || 'USD',
|
||||
quota: quotaFromLocal.quota || providerResult.quota || null,
|
||||
statistics: localStatistics,
|
||||
lastRefreshAt: providerResult.lastRefreshAt
|
||||
},
|
||||
accountId,
|
||||
platform,
|
||||
source,
|
||||
null,
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
|
||||
async _getBalanceFromScript(scriptConfig, accountId, platform) {
|
||||
try {
|
||||
const result = await balanceScriptService.execute({
|
||||
scriptBody: scriptConfig.scriptBody,
|
||||
timeoutSeconds: scriptConfig.timeoutSeconds || 10,
|
||||
variables: {
|
||||
baseUrl: scriptConfig.baseUrl || '',
|
||||
apiKey: scriptConfig.apiKey || '',
|
||||
token: scriptConfig.token || '',
|
||||
accountId,
|
||||
platform,
|
||||
extra: scriptConfig.extra || ''
|
||||
}
|
||||
})
|
||||
|
||||
const mapped = result?.mapped || {}
|
||||
return {
|
||||
status: mapped.status || 'error',
|
||||
balance: typeof mapped.balance === 'number' ? mapped.balance : null,
|
||||
currency: mapped.currency || 'USD',
|
||||
quota: mapped.quota || null,
|
||||
queryMethod: 'api',
|
||||
rawData: mapped.rawData || result?.response?.data || null,
|
||||
lastRefreshAt: new Date().toISOString(),
|
||||
errorMessage: mapped.errorMessage || ''
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
quota: null,
|
||||
queryMethod: 'api',
|
||||
rawData: null,
|
||||
lastRefreshAt: new Date().toISOString(),
|
||||
errorMessage: error.message || '脚本执行失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _getBalanceFromProvider(provider, account) {
|
||||
try {
|
||||
const result = await provider.queryBalance(account)
|
||||
return {
|
||||
status: 'success',
|
||||
balance: typeof result?.balance === 'number' ? result.balance : null,
|
||||
currency: result?.currency || 'USD',
|
||||
quota: result?.quota || null,
|
||||
queryMethod: result?.queryMethod || 'api',
|
||||
rawData: result?.rawData || null,
|
||||
lastRefreshAt: new Date().toISOString(),
|
||||
errorMessage: ''
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
quota: null,
|
||||
queryMethod: 'api',
|
||||
rawData: null,
|
||||
lastRefreshAt: new Date().toISOString(),
|
||||
errorMessage: error.message || '查询失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _getBalanceFromLocal(accountId, platform) {
|
||||
const cached = await this.redis.getLocalBalance(platform, accountId)
|
||||
if (cached && cached.statistics) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const statistics = await this._computeLocalStatistics(accountId)
|
||||
const localBalance = {
|
||||
status: 'success',
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
statistics,
|
||||
queryMethod: 'local',
|
||||
lastCalculated: new Date().toISOString()
|
||||
}
|
||||
|
||||
await this.redis.setLocalBalance(platform, accountId, localBalance, this.LOCAL_TTL_SECONDS)
|
||||
return localBalance
|
||||
}
|
||||
|
||||
async _computeLocalStatistics(accountId) {
|
||||
const safeNumber = (value) => {
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : 0
|
||||
}
|
||||
|
||||
try {
|
||||
const usageStats = await this.redis.getAccountUsageStats(accountId)
|
||||
const dailyCost = safeNumber(usageStats?.daily?.cost || 0)
|
||||
const monthlyCost = await this._computeMonthlyCost(accountId)
|
||||
const totalCost = await this._computeTotalCost(accountId)
|
||||
|
||||
return {
|
||||
totalCost,
|
||||
dailyCost,
|
||||
monthlyCost,
|
||||
totalRequests: safeNumber(usageStats?.total?.requests || 0),
|
||||
dailyRequests: safeNumber(usageStats?.daily?.requests || 0),
|
||||
monthlyRequests: safeNumber(usageStats?.monthly?.requests || 0)
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.debug(`本地统计计算失败: ${accountId}`, error)
|
||||
return {
|
||||
totalCost: 0,
|
||||
dailyCost: 0,
|
||||
monthlyCost: 0,
|
||||
totalRequests: 0,
|
||||
dailyRequests: 0,
|
||||
monthlyRequests: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _computeMonthlyCost(accountId) {
|
||||
const tzDate = this.redis.getDateInTimezone(new Date())
|
||||
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
|
||||
2,
|
||||
'0'
|
||||
)}`
|
||||
|
||||
const pattern = `account_usage:model:monthly:${accountId}:*:${currentMonth}`
|
||||
return await this._sumModelCostsByKeysPattern(pattern)
|
||||
}
|
||||
|
||||
async _computeTotalCost(accountId) {
|
||||
const pattern = `account_usage:model:monthly:${accountId}:*:*`
|
||||
return await this._sumModelCostsByKeysPattern(pattern)
|
||||
}
|
||||
|
||||
async _sumModelCostsByKeysPattern(pattern) {
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
let totalCost = 0
|
||||
let cursor = '0'
|
||||
const scanCount = 200
|
||||
let iterations = 0
|
||||
const maxIterations = 2000
|
||||
|
||||
do {
|
||||
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', scanCount)
|
||||
cursor = nextCursor
|
||||
iterations += 1
|
||||
|
||||
if (!keys || keys.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
keys.forEach((key) => pipeline.hgetall(key))
|
||||
const results = await pipeline.exec()
|
||||
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
const [, data] = results[i] || []
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parts = String(keys[i]).split(':')
|
||||
const model = parts[4] || 'unknown'
|
||||
|
||||
const usage = {
|
||||
input_tokens: parseInt(data.inputTokens || 0),
|
||||
output_tokens: parseInt(data.outputTokens || 0),
|
||||
cache_creation_input_tokens: parseInt(data.cacheCreateTokens || 0),
|
||||
cache_read_input_tokens: parseInt(data.cacheReadTokens || 0)
|
||||
}
|
||||
|
||||
const costResult = CostCalculator.calculateCost(usage, model)
|
||||
totalCost += costResult.costs.total || 0
|
||||
}
|
||||
|
||||
if (iterations >= maxIterations) {
|
||||
this.logger.warn(`SCAN 次数超过上限,停止汇总:${pattern}`)
|
||||
break
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
return totalCost
|
||||
} catch (error) {
|
||||
this.logger.debug(`汇总模型费用失败: ${pattern}`, error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
_buildQuotaFromLocal(account, statistics) {
|
||||
if (!account || !Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
|
||||
return { balance: null, currency: null, quota: null }
|
||||
}
|
||||
|
||||
const dailyQuota = Number(account.dailyQuota || 0)
|
||||
const used = Number(statistics?.dailyCost || 0)
|
||||
|
||||
const resetAt = this._computeNextResetAt(account.quotaResetTime || '00:00')
|
||||
|
||||
// 不限制
|
||||
if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) {
|
||||
return {
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
quota: {
|
||||
daily: Infinity,
|
||||
used,
|
||||
remaining: Infinity,
|
||||
percentage: 0,
|
||||
unlimited: true,
|
||||
resetAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = Math.max(0, dailyQuota - used)
|
||||
const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0
|
||||
|
||||
return {
|
||||
balance: remaining,
|
||||
currency: 'USD',
|
||||
quota: {
|
||||
daily: dailyQuota,
|
||||
used,
|
||||
remaining,
|
||||
resetAt,
|
||||
percentage: Math.round(percentage * 100) / 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_computeNextResetAt(resetTime) {
|
||||
const now = new Date()
|
||||
const tzNow = this.redis.getDateInTimezone(now)
|
||||
const offsetMs = tzNow.getTime() - now.getTime()
|
||||
|
||||
const [h, m] = String(resetTime || '00:00')
|
||||
.split(':')
|
||||
.map((n) => parseInt(n, 10))
|
||||
|
||||
const resetHour = Number.isFinite(h) ? h : 0
|
||||
const resetMinute = Number.isFinite(m) ? m : 0
|
||||
|
||||
const year = tzNow.getUTCFullYear()
|
||||
const month = tzNow.getUTCMonth()
|
||||
const day = tzNow.getUTCDate()
|
||||
|
||||
let resetAtMs = Date.UTC(year, month, day, resetHour, resetMinute, 0, 0) - offsetMs
|
||||
if (resetAtMs <= now.getTime()) {
|
||||
resetAtMs += 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
return new Date(resetAtMs).toISOString()
|
||||
}
|
||||
|
||||
_buildResponse(balanceData, accountId, platform, source, ttlSeconds = null, extraData = {}) {
|
||||
const now = new Date()
|
||||
|
||||
const amount = typeof balanceData.balance === 'number' ? balanceData.balance : null
|
||||
const currency = balanceData.currency || 'USD'
|
||||
|
||||
let cacheExpiresAt = null
|
||||
if (source === 'cache') {
|
||||
const ttl =
|
||||
typeof ttlSeconds === 'number' && ttlSeconds > 0 ? ttlSeconds : this.CACHE_TTL_SECONDS
|
||||
cacheExpiresAt = new Date(Date.now() + ttl * 1000).toISOString()
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
platform,
|
||||
balance:
|
||||
typeof amount === 'number'
|
||||
? {
|
||||
amount,
|
||||
currency,
|
||||
formattedAmount: this._formatCurrency(amount, currency)
|
||||
}
|
||||
: null,
|
||||
quota: balanceData.quota || null,
|
||||
statistics: balanceData.statistics || {},
|
||||
source,
|
||||
lastRefreshAt: balanceData.lastRefreshAt || now.toISOString(),
|
||||
cacheExpiresAt,
|
||||
status: balanceData.status || 'success',
|
||||
error: balanceData.errorMessage || null,
|
||||
...(extraData && typeof extraData === 'object' ? extraData : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_formatCurrency(amount, currency = 'USD') {
|
||||
try {
|
||||
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
|
||||
return 'N/A'
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
|
||||
} catch (error) {
|
||||
return `$${amount.toFixed(2)}`
|
||||
}
|
||||
}
|
||||
|
||||
_parseBoolean(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
|
||||
return true
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
|
||||
return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
_parseQueryMode(value) {
|
||||
if (value === 'auto') {
|
||||
return 'auto'
|
||||
}
|
||||
const parsed = this._parseBoolean(value)
|
||||
return parsed ? 'api' : 'local'
|
||||
}
|
||||
|
||||
async _mapWithConcurrency(items, limit, mapper) {
|
||||
const concurrency = Math.max(1, Number(limit) || 1)
|
||||
const list = Array.isArray(items) ? items : []
|
||||
|
||||
const results = new Array(list.length)
|
||||
let nextIndex = 0
|
||||
|
||||
const workers = new Array(Math.min(concurrency, list.length)).fill(null).map(async () => {
|
||||
while (nextIndex < list.length) {
|
||||
const currentIndex = nextIndex
|
||||
nextIndex += 1
|
||||
results[currentIndex] = await mapper(list[currentIndex], currentIndex)
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(workers)
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
const accountBalanceService = new AccountBalanceService()
|
||||
module.exports = accountBalanceService
|
||||
module.exports.AccountBalanceService = AccountBalanceService
|
||||
@@ -13,7 +13,7 @@ class AccountGroupService {
|
||||
* 创建账户分组
|
||||
* @param {Object} groupData - 分组数据
|
||||
* @param {string} groupData.name - 分组名称
|
||||
* @param {string} groupData.platform - 平台类型 (claude/gemini)
|
||||
* @param {string} groupData.platform - 平台类型 (claude/gemini/openai)
|
||||
* @param {string} groupData.description - 分组描述
|
||||
* @returns {Object} 创建的分组
|
||||
*/
|
||||
@@ -27,8 +27,8 @@ class AccountGroupService {
|
||||
}
|
||||
|
||||
// 验证平台类型
|
||||
if (!['claude', 'gemini', 'openai'].includes(platform)) {
|
||||
throw new Error('平台类型必须是 claude、gemini 或 openai')
|
||||
if (!['claude', 'gemini', 'openai', 'droid'].includes(platform)) {
|
||||
throw new Error('平台类型必须是 claude、gemini、openai 或 droid')
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
@@ -311,7 +311,8 @@ class AccountGroupService {
|
||||
keyData &&
|
||||
(keyData.claudeAccountId === groupKey ||
|
||||
keyData.geminiAccountId === groupKey ||
|
||||
keyData.openaiAccountId === groupKey)
|
||||
keyData.openaiAccountId === groupKey ||
|
||||
keyData.droidAccountId === groupKey)
|
||||
) {
|
||||
boundApiKeys.push({
|
||||
id: keyId,
|
||||
@@ -327,12 +328,36 @@ class AccountGroupService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据账户ID获取其所属的分组(兼容性方法,返回单个分组)
|
||||
* @param {string} accountId - 账户ID
|
||||
* @returns {Object|null} 分组信息
|
||||
*/
|
||||
async getAccountGroup(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const allGroupIds = await client.smembers(this.GROUPS_KEY)
|
||||
|
||||
for (const groupId of allGroupIds) {
|
||||
const isMember = await client.sismember(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
||||
if (isMember) {
|
||||
return await this.getGroup(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('❌ 获取账户所属分组失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据账户ID获取其所属的所有分组
|
||||
* @param {string} accountId - 账户ID
|
||||
* @returns {Array} 分组信息数组
|
||||
*/
|
||||
async getAccountGroup(accountId) {
|
||||
async getAccountGroups(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const allGroupIds = await client.smembers(this.GROUPS_KEY)
|
||||
@@ -357,6 +382,49 @@ class AccountGroupService {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置账户的分组
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {Array} groupIds - 分组ID数组
|
||||
* @param {string} accountPlatform - 账户平台
|
||||
*/
|
||||
async setAccountGroups(accountId, groupIds, accountPlatform) {
|
||||
try {
|
||||
// 首先移除账户的所有现有分组
|
||||
await this.removeAccountFromAllGroups(accountId)
|
||||
|
||||
// 然后添加到新的分组中
|
||||
for (const groupId of groupIds) {
|
||||
await this.addAccountToGroup(accountId, groupId, accountPlatform)
|
||||
}
|
||||
|
||||
logger.success(`✅ 批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 批量设置账户分组失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从所有分组中移除账户
|
||||
* @param {string} accountId - 账户ID
|
||||
*/
|
||||
async removeAccountFromAllGroups(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const allGroupIds = await client.smembers(this.GROUPS_KEY)
|
||||
|
||||
for (const groupId of allGroupIds) {
|
||||
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
||||
}
|
||||
|
||||
logger.success(`✅ 从所有分组移除账户成功: ${accountId}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 从所有分组移除账户失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AccountGroupService()
|
||||
|
||||
286
src/services/accountNameCacheService.js
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* 账户名称缓存服务
|
||||
* 用于加速绑定账号搜索,避免每次搜索都查询所有账户
|
||||
*/
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
class AccountNameCacheService {
|
||||
constructor() {
|
||||
// 账户名称缓存:accountId -> { name, platform }
|
||||
this.accountCache = new Map()
|
||||
// 账户组名称缓存:groupId -> { name, platform }
|
||||
this.groupCache = new Map()
|
||||
// 缓存过期时间
|
||||
this.lastRefresh = 0
|
||||
this.refreshInterval = 5 * 60 * 1000 // 5分钟
|
||||
this.isRefreshing = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新缓存(如果过期)
|
||||
*/
|
||||
async refreshIfNeeded() {
|
||||
if (Date.now() - this.lastRefresh < this.refreshInterval) {
|
||||
return
|
||||
}
|
||||
if (this.isRefreshing) {
|
||||
// 等待正在进行的刷新完成
|
||||
let waitCount = 0
|
||||
while (this.isRefreshing && waitCount < 50) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
waitCount++
|
||||
}
|
||||
return
|
||||
}
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制刷新缓存
|
||||
*/
|
||||
async refresh() {
|
||||
if (this.isRefreshing) {
|
||||
return
|
||||
}
|
||||
this.isRefreshing = true
|
||||
|
||||
try {
|
||||
const newAccountCache = new Map()
|
||||
const newGroupCache = new Map()
|
||||
|
||||
// 延迟加载服务,避免循环依赖
|
||||
const claudeAccountService = require('./claudeAccountService')
|
||||
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
|
||||
const geminiAccountService = require('./geminiAccountService')
|
||||
const openaiAccountService = require('./openaiAccountService')
|
||||
const azureOpenaiAccountService = require('./azureOpenaiAccountService')
|
||||
const bedrockAccountService = require('./bedrockAccountService')
|
||||
const droidAccountService = require('./droidAccountService')
|
||||
const ccrAccountService = require('./ccrAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
|
||||
// 可选服务(可能不存在)
|
||||
let geminiApiAccountService = null
|
||||
let openaiResponsesAccountService = null
|
||||
try {
|
||||
geminiApiAccountService = require('./geminiApiAccountService')
|
||||
} catch (e) {
|
||||
// 服务不存在,忽略
|
||||
}
|
||||
try {
|
||||
openaiResponsesAccountService = require('./openaiResponsesAccountService')
|
||||
} catch (e) {
|
||||
// 服务不存在,忽略
|
||||
}
|
||||
|
||||
// 并行加载所有账户类型
|
||||
const results = await Promise.allSettled([
|
||||
claudeAccountService.getAllAccounts(),
|
||||
claudeConsoleAccountService.getAllAccounts(),
|
||||
geminiAccountService.getAllAccounts(),
|
||||
geminiApiAccountService?.getAllAccounts() || Promise.resolve([]),
|
||||
openaiAccountService.getAllAccounts(),
|
||||
openaiResponsesAccountService?.getAllAccounts() || Promise.resolve([]),
|
||||
azureOpenaiAccountService.getAllAccounts(),
|
||||
bedrockAccountService.getAllAccounts(),
|
||||
droidAccountService.getAllAccounts(),
|
||||
ccrAccountService.getAllAccounts(),
|
||||
accountGroupService.getAllGroups()
|
||||
])
|
||||
|
||||
// 提取结果
|
||||
const claudeAccounts = results[0].status === 'fulfilled' ? results[0].value : []
|
||||
const claudeConsoleAccounts = results[1].status === 'fulfilled' ? results[1].value : []
|
||||
const geminiAccounts = results[2].status === 'fulfilled' ? results[2].value : []
|
||||
const geminiApiAccounts = results[3].status === 'fulfilled' ? results[3].value : []
|
||||
const openaiAccounts = results[4].status === 'fulfilled' ? results[4].value : []
|
||||
const openaiResponsesAccounts = results[5].status === 'fulfilled' ? results[5].value : []
|
||||
const azureOpenaiAccounts = results[6].status === 'fulfilled' ? results[6].value : []
|
||||
const bedrockResult = results[7].status === 'fulfilled' ? results[7].value : { accounts: [] }
|
||||
const droidAccounts = results[8].status === 'fulfilled' ? results[8].value : []
|
||||
const ccrAccounts = results[9].status === 'fulfilled' ? results[9].value : []
|
||||
const groups = results[10].status === 'fulfilled' ? results[10].value : []
|
||||
|
||||
// Bedrock 返回格式特殊处理
|
||||
const bedrockAccounts = Array.isArray(bedrockResult)
|
||||
? bedrockResult
|
||||
: bedrockResult.accounts || []
|
||||
|
||||
// 填充账户缓存的辅助函数
|
||||
const addAccounts = (accounts, platform, prefix = '') => {
|
||||
if (!Array.isArray(accounts)) {
|
||||
return
|
||||
}
|
||||
for (const acc of accounts) {
|
||||
if (acc && acc.id && acc.name) {
|
||||
const key = prefix ? `${prefix}${acc.id}` : acc.id
|
||||
newAccountCache.set(key, { name: acc.name, platform })
|
||||
// 同时存储不带前缀的版本,方便查找
|
||||
if (prefix) {
|
||||
newAccountCache.set(acc.id, { name: acc.name, platform })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addAccounts(claudeAccounts, 'claude')
|
||||
addAccounts(claudeConsoleAccounts, 'claude-console')
|
||||
addAccounts(geminiAccounts, 'gemini')
|
||||
addAccounts(geminiApiAccounts, 'gemini-api', 'api:')
|
||||
addAccounts(openaiAccounts, 'openai')
|
||||
addAccounts(openaiResponsesAccounts, 'openai-responses', 'responses:')
|
||||
addAccounts(azureOpenaiAccounts, 'azure-openai')
|
||||
addAccounts(bedrockAccounts, 'bedrock')
|
||||
addAccounts(droidAccounts, 'droid')
|
||||
addAccounts(ccrAccounts, 'ccr')
|
||||
|
||||
// 填充账户组缓存
|
||||
if (Array.isArray(groups)) {
|
||||
for (const group of groups) {
|
||||
if (group && group.id && group.name) {
|
||||
newGroupCache.set(group.id, { name: group.name, platform: group.platform })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.accountCache = newAccountCache
|
||||
this.groupCache = newGroupCache
|
||||
this.lastRefresh = Date.now()
|
||||
|
||||
logger.debug(
|
||||
`账户名称缓存已刷新: ${newAccountCache.size} 个账户, ${newGroupCache.size} 个分组`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('刷新账户名称缓存失败:', error)
|
||||
} finally {
|
||||
this.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户显示名称
|
||||
* @param {string} accountId - 账户ID(可能带前缀)
|
||||
* @param {string} _fieldName - 字段名(如 claudeAccountId),保留用于将来扩展
|
||||
* @returns {string} 显示名称
|
||||
*/
|
||||
getAccountDisplayName(accountId, _fieldName) {
|
||||
if (!accountId) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 处理账户组
|
||||
if (accountId.startsWith('group:')) {
|
||||
const groupId = accountId.substring(6)
|
||||
const group = this.groupCache.get(groupId)
|
||||
if (group) {
|
||||
return `分组-${group.name}`
|
||||
}
|
||||
return `分组-${groupId.substring(0, 8)}`
|
||||
}
|
||||
|
||||
// 直接查找(包括带前缀的 api:xxx, responses:xxx)
|
||||
const cached = this.accountCache.get(accountId)
|
||||
if (cached) {
|
||||
return cached.name
|
||||
}
|
||||
|
||||
// 尝试去掉前缀查找
|
||||
let realId = accountId
|
||||
if (accountId.startsWith('api:')) {
|
||||
realId = accountId.substring(4)
|
||||
} else if (accountId.startsWith('responses:')) {
|
||||
realId = accountId.substring(10)
|
||||
}
|
||||
|
||||
if (realId !== accountId) {
|
||||
const cached2 = this.accountCache.get(realId)
|
||||
if (cached2) {
|
||||
return cached2.name
|
||||
}
|
||||
}
|
||||
|
||||
// 未找到,返回 ID 前缀
|
||||
return `${accountId.substring(0, 8)}...`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 API Key 的所有绑定账户显示名称
|
||||
* @param {Object} apiKey - API Key 对象
|
||||
* @returns {Array<{field: string, platform: string, name: string, accountId: string}>}
|
||||
*/
|
||||
getBindingDisplayNames(apiKey) {
|
||||
const bindings = []
|
||||
|
||||
const bindingFields = [
|
||||
{ field: 'claudeAccountId', platform: 'Claude' },
|
||||
{ field: 'claudeConsoleAccountId', platform: 'Claude Console' },
|
||||
{ field: 'geminiAccountId', platform: 'Gemini' },
|
||||
{ field: 'openaiAccountId', platform: 'OpenAI' },
|
||||
{ field: 'azureOpenaiAccountId', platform: 'Azure OpenAI' },
|
||||
{ field: 'bedrockAccountId', platform: 'Bedrock' },
|
||||
{ field: 'droidAccountId', platform: 'Droid' },
|
||||
{ field: 'ccrAccountId', platform: 'CCR' }
|
||||
]
|
||||
|
||||
for (const { field, platform } of bindingFields) {
|
||||
const accountId = apiKey[field]
|
||||
if (accountId) {
|
||||
const name = this.getAccountDisplayName(accountId, field)
|
||||
bindings.push({ field, platform, name, accountId })
|
||||
}
|
||||
}
|
||||
|
||||
return bindings
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索绑定账号
|
||||
* @param {Array} apiKeys - API Key 列表
|
||||
* @param {string} keyword - 搜索关键词
|
||||
* @returns {Array} 匹配的 API Key 列表
|
||||
*/
|
||||
searchByBindingAccount(apiKeys, keyword) {
|
||||
const lowerKeyword = keyword.toLowerCase().trim()
|
||||
if (!lowerKeyword) {
|
||||
return apiKeys
|
||||
}
|
||||
|
||||
return apiKeys.filter((key) => {
|
||||
const bindings = this.getBindingDisplayNames(key)
|
||||
|
||||
// 无绑定时,匹配"共享池"
|
||||
if (bindings.length === 0) {
|
||||
return '共享池'.includes(lowerKeyword) || 'shared'.includes(lowerKeyword)
|
||||
}
|
||||
|
||||
// 匹配任一绑定账户
|
||||
return bindings.some((binding) => {
|
||||
// 匹配账户名称
|
||||
if (binding.name && binding.name.toLowerCase().includes(lowerKeyword)) {
|
||||
return true
|
||||
}
|
||||
// 匹配平台名称
|
||||
if (binding.platform.toLowerCase().includes(lowerKeyword)) {
|
||||
return true
|
||||
}
|
||||
// 匹配账户 ID
|
||||
if (binding.accountId.toLowerCase().includes(lowerKeyword)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存(用于测试或强制刷新)
|
||||
*/
|
||||
clearCache() {
|
||||
this.accountCache.clear()
|
||||
this.groupCache.clear()
|
||||
this.lastRefresh = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 单例导出
|
||||
module.exports = new AccountNameCacheService()
|
||||
420
src/services/accountTestSchedulerService.js
Normal file
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* 账户定时测试调度服务
|
||||
* 使用 node-cron 支持 crontab 表达式,为每个账户创建独立的定时任务
|
||||
*/
|
||||
|
||||
const cron = require('node-cron')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
class AccountTestSchedulerService {
|
||||
constructor() {
|
||||
// 存储每个账户的 cron 任务: Map<string, { task: ScheduledTask, cronExpression: string }>
|
||||
this.scheduledTasks = new Map()
|
||||
// 定期刷新配置的间隔 (毫秒)
|
||||
this.refreshIntervalMs = 60 * 1000
|
||||
this.refreshInterval = null
|
||||
// 当前正在测试的账户
|
||||
this.testingAccounts = new Set()
|
||||
// 是否已启动
|
||||
this.isStarted = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 cron 表达式是否有效
|
||||
* @param {string} cronExpression - cron 表达式
|
||||
* @returns {boolean}
|
||||
*/
|
||||
validateCronExpression(cronExpression) {
|
||||
// 长度检查(防止 DoS)
|
||||
if (!cronExpression || cronExpression.length > 100) {
|
||||
return false
|
||||
}
|
||||
return cron.validate(cronExpression)
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动调度器
|
||||
*/
|
||||
async start() {
|
||||
if (this.isStarted) {
|
||||
logger.warn('⚠️ Account test scheduler is already running')
|
||||
return
|
||||
}
|
||||
|
||||
this.isStarted = true
|
||||
logger.info('🚀 Starting account test scheduler service (node-cron mode)')
|
||||
|
||||
// 初始化所有已配置账户的定时任务
|
||||
await this._refreshAllTasks()
|
||||
|
||||
// 定期刷新配置,以便动态添加/修改的配置能生效
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this._refreshAllTasks()
|
||||
}, this.refreshIntervalMs)
|
||||
|
||||
logger.info(
|
||||
`📅 Account test scheduler started (refreshing configs every ${this.refreshIntervalMs / 1000}s)`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止调度器
|
||||
*/
|
||||
stop() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval)
|
||||
this.refreshInterval = null
|
||||
}
|
||||
|
||||
// 停止所有 cron 任务
|
||||
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
|
||||
taskInfo.task.stop()
|
||||
logger.debug(`🛑 Stopped cron task for ${accountKey}`)
|
||||
}
|
||||
this.scheduledTasks.clear()
|
||||
|
||||
this.isStarted = false
|
||||
logger.info('🛑 Account test scheduler stopped')
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新所有账户的定时任务
|
||||
* @private
|
||||
*/
|
||||
async _refreshAllTasks() {
|
||||
try {
|
||||
const platforms = ['claude', 'gemini', 'openai']
|
||||
const activeAccountKeys = new Set()
|
||||
|
||||
// 并行加载所有平台的配置
|
||||
const allEnabledAccounts = await Promise.all(
|
||||
platforms.map((platform) =>
|
||||
redis
|
||||
.getEnabledTestAccounts(platform)
|
||||
.then((accounts) => accounts.map((acc) => ({ ...acc, platform })))
|
||||
.catch((error) => {
|
||||
logger.warn(`⚠️ Failed to load test accounts for platform ${platform}:`, error)
|
||||
return []
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// 展平平台数据
|
||||
const flatAccounts = allEnabledAccounts.flat()
|
||||
|
||||
for (const { accountId, cronExpression, model, platform } of flatAccounts) {
|
||||
if (!cronExpression) {
|
||||
logger.warn(
|
||||
`⚠️ Account ${accountId} (${platform}) has no valid cron expression, skipping`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const accountKey = `${platform}:${accountId}`
|
||||
activeAccountKeys.add(accountKey)
|
||||
|
||||
// 检查是否需要更新任务
|
||||
const existingTask = this.scheduledTasks.get(accountKey)
|
||||
if (existingTask) {
|
||||
// 如果 cron 表达式和模型都没变,不需要更新
|
||||
if (existingTask.cronExpression === cronExpression && existingTask.model === model) {
|
||||
continue
|
||||
}
|
||||
// 配置变了,停止旧任务
|
||||
existingTask.task.stop()
|
||||
logger.info(`🔄 Updating cron task for ${accountKey}: ${cronExpression}, model: ${model}`)
|
||||
} else {
|
||||
logger.info(`➕ Creating cron task for ${accountKey}: ${cronExpression}, model: ${model}`)
|
||||
}
|
||||
|
||||
// 创建新的 cron 任务
|
||||
this._createCronTask(accountId, platform, cronExpression, model)
|
||||
}
|
||||
|
||||
// 清理已删除或禁用的账户任务
|
||||
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
|
||||
if (!activeAccountKeys.has(accountKey)) {
|
||||
taskInfo.task.stop()
|
||||
this.scheduledTasks.delete(accountKey)
|
||||
logger.info(`➖ Removed cron task for ${accountKey} (disabled or deleted)`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Error refreshing account test tasks:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为单个账户创建 cron 任务
|
||||
* @param {string} accountId
|
||||
* @param {string} platform
|
||||
* @param {string} cronExpression
|
||||
* @param {string} model - 测试使用的模型
|
||||
* @private
|
||||
*/
|
||||
_createCronTask(accountId, platform, cronExpression, model) {
|
||||
const accountKey = `${platform}:${accountId}`
|
||||
|
||||
// 验证 cron 表达式
|
||||
if (!this.validateCronExpression(cronExpression)) {
|
||||
logger.error(`❌ Invalid cron expression for ${accountKey}: ${cronExpression}`)
|
||||
return
|
||||
}
|
||||
|
||||
const task = cron.schedule(
|
||||
cronExpression,
|
||||
async () => {
|
||||
await this._runAccountTest(accountId, platform, model)
|
||||
},
|
||||
{
|
||||
scheduled: true,
|
||||
timezone: process.env.TZ || 'Asia/Shanghai'
|
||||
}
|
||||
)
|
||||
|
||||
this.scheduledTasks.set(accountKey, {
|
||||
task,
|
||||
cronExpression,
|
||||
model,
|
||||
accountId,
|
||||
platform
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个账户测试
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} platform - 平台类型
|
||||
* @param {string} model - 测试使用的模型
|
||||
* @private
|
||||
*/
|
||||
async _runAccountTest(accountId, platform, model) {
|
||||
const accountKey = `${platform}:${accountId}`
|
||||
|
||||
// 避免重复测试
|
||||
if (this.testingAccounts.has(accountKey)) {
|
||||
logger.debug(`⏳ Account ${accountKey} is already being tested, skipping`)
|
||||
return
|
||||
}
|
||||
|
||||
this.testingAccounts.add(accountKey)
|
||||
|
||||
try {
|
||||
logger.info(
|
||||
`🧪 Running scheduled test for ${platform} account: ${accountId} (model: ${model})`
|
||||
)
|
||||
|
||||
let testResult
|
||||
|
||||
// 根据平台调用对应的测试方法
|
||||
switch (platform) {
|
||||
case 'claude':
|
||||
testResult = await this._testClaudeAccount(accountId, model)
|
||||
break
|
||||
case 'gemini':
|
||||
testResult = await this._testGeminiAccount(accountId, model)
|
||||
break
|
||||
case 'openai':
|
||||
testResult = await this._testOpenAIAccount(accountId, model)
|
||||
break
|
||||
default:
|
||||
testResult = {
|
||||
success: false,
|
||||
error: `Unsupported platform: ${platform}`,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 保存测试结果
|
||||
await redis.saveAccountTestResult(accountId, platform, testResult)
|
||||
|
||||
// 更新最后测试时间
|
||||
await redis.setAccountLastTestTime(accountId, platform)
|
||||
|
||||
// 记录日志
|
||||
if (testResult.success) {
|
||||
logger.info(
|
||||
`✅ Scheduled test passed for ${platform} account ${accountId} (${testResult.latencyMs}ms)`
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
`❌ Scheduled test failed for ${platform} account ${accountId}: ${testResult.error}`
|
||||
)
|
||||
}
|
||||
|
||||
return testResult
|
||||
} catch (error) {
|
||||
logger.error(`❌ Error testing ${platform} account ${accountId}:`, error)
|
||||
|
||||
const errorResult = {
|
||||
success: false,
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
|
||||
await redis.saveAccountTestResult(accountId, platform, errorResult)
|
||||
await redis.setAccountLastTestTime(accountId, platform)
|
||||
|
||||
return errorResult
|
||||
} finally {
|
||||
this.testingAccounts.delete(accountKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 Claude 账户
|
||||
* @param {string} accountId
|
||||
* @param {string} model - 测试使用的模型
|
||||
* @private
|
||||
*/
|
||||
async _testClaudeAccount(accountId, model) {
|
||||
const claudeRelayService = require('./claudeRelayService')
|
||||
return await claudeRelayService.testAccountConnectionSync(accountId, model)
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 Gemini 账户
|
||||
* @param {string} _accountId
|
||||
* @param {string} _model
|
||||
* @private
|
||||
*/
|
||||
async _testGeminiAccount(_accountId, _model) {
|
||||
// Gemini 测试暂时返回未实现
|
||||
return {
|
||||
success: false,
|
||||
error: 'Gemini scheduled test not implemented yet',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 OpenAI 账户
|
||||
* @param {string} _accountId
|
||||
* @param {string} _model
|
||||
* @private
|
||||
*/
|
||||
async _testOpenAIAccount(_accountId, _model) {
|
||||
// OpenAI 测试暂时返回未实现
|
||||
return {
|
||||
success: false,
|
||||
error: 'OpenAI scheduled test not implemented yet',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发账户测试
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} platform - 平台类型
|
||||
* @param {string} model - 测试使用的模型
|
||||
* @returns {Promise<Object>} 测试结果
|
||||
*/
|
||||
async triggerTest(accountId, platform, model = 'claude-sonnet-4-5-20250929') {
|
||||
logger.info(`🎯 Manual test triggered for ${platform} account: ${accountId} (model: ${model})`)
|
||||
return await this._runAccountTest(accountId, platform, model)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户测试历史
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} platform - 平台类型
|
||||
* @returns {Promise<Array>} 测试历史
|
||||
*/
|
||||
async getTestHistory(accountId, platform) {
|
||||
return await redis.getAccountTestHistory(accountId, platform)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户测试配置
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} platform - 平台类型
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async getTestConfig(accountId, platform) {
|
||||
return await redis.getAccountTestConfig(accountId, platform)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置账户测试配置
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} platform - 平台类型
|
||||
* @param {Object} testConfig - 测试配置 { enabled: boolean, cronExpression: string, model: string }
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setTestConfig(accountId, platform, testConfig) {
|
||||
// 验证 cron 表达式
|
||||
if (testConfig.cronExpression && !this.validateCronExpression(testConfig.cronExpression)) {
|
||||
throw new Error(`Invalid cron expression: ${testConfig.cronExpression}`)
|
||||
}
|
||||
|
||||
await redis.saveAccountTestConfig(accountId, platform, testConfig)
|
||||
logger.info(
|
||||
`📝 Test config updated for ${platform} account ${accountId}: enabled=${testConfig.enabled}, cronExpression=${testConfig.cronExpression}, model=${testConfig.model}`
|
||||
)
|
||||
|
||||
// 立即刷新任务,使配置立即生效
|
||||
if (this.isStarted) {
|
||||
await this._refreshAllTasks()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单个账户的定时任务(配置变更时调用)
|
||||
* @param {string} accountId
|
||||
* @param {string} platform
|
||||
*/
|
||||
async refreshAccountTask(accountId, platform) {
|
||||
if (!this.isStarted) {
|
||||
return
|
||||
}
|
||||
|
||||
const accountKey = `${platform}:${accountId}`
|
||||
const testConfig = await redis.getAccountTestConfig(accountId, platform)
|
||||
|
||||
// 停止现有任务
|
||||
const existingTask = this.scheduledTasks.get(accountKey)
|
||||
if (existingTask) {
|
||||
existingTask.task.stop()
|
||||
this.scheduledTasks.delete(accountKey)
|
||||
}
|
||||
|
||||
// 如果启用且有有效的 cron 表达式,创建新任务
|
||||
if (testConfig?.enabled && testConfig?.cronExpression) {
|
||||
this._createCronTask(accountId, platform, testConfig.cronExpression, testConfig.model)
|
||||
logger.info(
|
||||
`🔄 Refreshed cron task for ${accountKey}: ${testConfig.cronExpression}, model: ${testConfig.model}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取调度器状态
|
||||
* @returns {Object}
|
||||
*/
|
||||
getStatus() {
|
||||
const tasks = []
|
||||
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
|
||||
tasks.push({
|
||||
accountKey,
|
||||
accountId: taskInfo.accountId,
|
||||
platform: taskInfo.platform,
|
||||
cronExpression: taskInfo.cronExpression,
|
||||
model: taskInfo.model
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
running: this.isStarted,
|
||||
refreshIntervalMs: this.refreshIntervalMs,
|
||||
scheduledTasksCount: this.scheduledTasks.size,
|
||||
scheduledTasks: tasks,
|
||||
currentlyTesting: Array.from(this.testingAccounts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单例模式
|
||||
const accountTestSchedulerService = new AccountTestSchedulerService()
|
||||
|
||||
module.exports = accountTestSchedulerService
|
||||
3083
src/services/anthropicGeminiBridgeService.js
Normal file
595
src/services/antigravityClient.js
Normal file
@@ -0,0 +1,595 @@
|
||||
const axios = require('axios')
|
||||
const https = require('https')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const logger = require('../utils/logger')
|
||||
const {
|
||||
mapAntigravityUpstreamModel,
|
||||
normalizeAntigravityModelInput,
|
||||
getAntigravityModelMetadata
|
||||
} = require('../utils/antigravityModel')
|
||||
const { cleanJsonSchemaForGemini } = require('../utils/geminiSchemaCleaner')
|
||||
const { dumpAntigravityUpstreamRequest } = require('../utils/antigravityUpstreamDump')
|
||||
|
||||
const keepAliveAgent = new https.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30000,
|
||||
timeout: 120000,
|
||||
maxSockets: 100,
|
||||
maxFreeSockets: 10
|
||||
})
|
||||
|
||||
function getAntigravityApiUrl() {
|
||||
return process.env.ANTIGRAVITY_API_URL || 'https://daily-cloudcode-pa.sandbox.googleapis.com'
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url) {
|
||||
const str = String(url || '').trim()
|
||||
return str.endsWith('/') ? str.slice(0, -1) : str
|
||||
}
|
||||
|
||||
function getAntigravityApiUrlCandidates() {
|
||||
const configured = normalizeBaseUrl(getAntigravityApiUrl())
|
||||
const daily = 'https://daily-cloudcode-pa.sandbox.googleapis.com'
|
||||
const prod = 'https://cloudcode-pa.googleapis.com'
|
||||
|
||||
// 若显式配置了自定义 base url,则只使用该地址(不做 fallback,避免意外路由到别的环境)。
|
||||
if (process.env.ANTIGRAVITY_API_URL) {
|
||||
return [configured]
|
||||
}
|
||||
|
||||
// 默认行为:优先 daily(与旧逻辑一致),失败时再尝试 prod(对齐 CLIProxyAPI)。
|
||||
if (configured === normalizeBaseUrl(daily)) {
|
||||
return [configured, prod]
|
||||
}
|
||||
if (configured === normalizeBaseUrl(prod)) {
|
||||
return [configured, daily]
|
||||
}
|
||||
|
||||
return [configured, prod, daily].filter(Boolean)
|
||||
}
|
||||
|
||||
function getAntigravityHeaders(accessToken, baseUrl) {
|
||||
const resolvedBaseUrl = baseUrl || getAntigravityApiUrl()
|
||||
let host = 'daily-cloudcode-pa.sandbox.googleapis.com'
|
||||
try {
|
||||
host = new URL(resolvedBaseUrl).host || host
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {
|
||||
Host: host,
|
||||
'User-Agent': process.env.ANTIGRAVITY_USER_AGENT || 'antigravity/1.11.3 windows/amd64',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
requestType: 'agent'
|
||||
}
|
||||
}
|
||||
|
||||
function generateAntigravityProjectId() {
|
||||
return `ag-${uuidv4().replace(/-/g, '').slice(0, 16)}`
|
||||
}
|
||||
|
||||
function generateAntigravitySessionId() {
|
||||
return `sess-${uuidv4()}`
|
||||
}
|
||||
|
||||
function resolveAntigravityProjectId(projectId, requestData) {
|
||||
const candidate = projectId || requestData?.project || requestData?.projectId || null
|
||||
return candidate || generateAntigravityProjectId()
|
||||
}
|
||||
|
||||
function resolveAntigravitySessionId(sessionId, requestData) {
|
||||
const candidate =
|
||||
sessionId || requestData?.request?.sessionId || requestData?.request?.session_id || null
|
||||
return candidate || generateAntigravitySessionId()
|
||||
}
|
||||
|
||||
function buildAntigravityEnvelope({ requestData, projectId, sessionId, userPromptId }) {
|
||||
const model = mapAntigravityUpstreamModel(requestData?.model)
|
||||
const resolvedProjectId = resolveAntigravityProjectId(projectId, requestData)
|
||||
const resolvedSessionId = resolveAntigravitySessionId(sessionId, requestData)
|
||||
const requestPayload = {
|
||||
...(requestData?.request || {})
|
||||
}
|
||||
|
||||
if (requestPayload.session_id !== undefined) {
|
||||
delete requestPayload.session_id
|
||||
}
|
||||
requestPayload.sessionId = resolvedSessionId
|
||||
|
||||
const envelope = {
|
||||
project: resolvedProjectId,
|
||||
requestId: `req-${uuidv4()}`,
|
||||
model,
|
||||
userAgent: 'antigravity',
|
||||
request: {
|
||||
...requestPayload
|
||||
}
|
||||
}
|
||||
|
||||
if (userPromptId) {
|
||||
envelope.user_prompt_id = userPromptId
|
||||
envelope.userPromptId = userPromptId
|
||||
}
|
||||
|
||||
normalizeAntigravityEnvelope(envelope)
|
||||
return { model, envelope }
|
||||
}
|
||||
|
||||
function normalizeAntigravityThinking(model, requestPayload) {
|
||||
if (!requestPayload || typeof requestPayload !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const { generationConfig } = requestPayload
|
||||
if (!generationConfig || typeof generationConfig !== 'object') {
|
||||
return
|
||||
}
|
||||
const { thinkingConfig } = generationConfig
|
||||
if (!thinkingConfig || typeof thinkingConfig !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedModel = normalizeAntigravityModelInput(model)
|
||||
if (thinkingConfig.thinkingLevel && !normalizedModel.startsWith('gemini-3-')) {
|
||||
delete thinkingConfig.thinkingLevel
|
||||
}
|
||||
|
||||
const metadata = getAntigravityModelMetadata(normalizedModel)
|
||||
if (metadata && !metadata.thinking) {
|
||||
delete generationConfig.thinkingConfig
|
||||
return
|
||||
}
|
||||
if (!metadata || !metadata.thinking) {
|
||||
return
|
||||
}
|
||||
|
||||
const budgetRaw = Number(thinkingConfig.thinkingBudget)
|
||||
if (!Number.isFinite(budgetRaw)) {
|
||||
return
|
||||
}
|
||||
let budget = Math.trunc(budgetRaw)
|
||||
|
||||
const minBudget = Number.isFinite(metadata.thinking.min) ? metadata.thinking.min : null
|
||||
const maxBudget = Number.isFinite(metadata.thinking.max) ? metadata.thinking.max : null
|
||||
|
||||
if (maxBudget !== null && budget > maxBudget) {
|
||||
budget = maxBudget
|
||||
}
|
||||
|
||||
let effectiveMax = Number.isFinite(generationConfig.maxOutputTokens)
|
||||
? generationConfig.maxOutputTokens
|
||||
: null
|
||||
let setDefaultMax = false
|
||||
if (!effectiveMax && metadata.maxCompletionTokens) {
|
||||
effectiveMax = metadata.maxCompletionTokens
|
||||
setDefaultMax = true
|
||||
}
|
||||
|
||||
if (effectiveMax && budget >= effectiveMax) {
|
||||
budget = Math.max(0, effectiveMax - 1)
|
||||
}
|
||||
|
||||
if (minBudget !== null && budget >= 0 && budget < minBudget) {
|
||||
delete generationConfig.thinkingConfig
|
||||
return
|
||||
}
|
||||
|
||||
thinkingConfig.thinkingBudget = budget
|
||||
if (setDefaultMax) {
|
||||
generationConfig.maxOutputTokens = effectiveMax
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAntigravityEnvelope(envelope) {
|
||||
if (!envelope || typeof envelope !== 'object') {
|
||||
return
|
||||
}
|
||||
const model = String(envelope.model || '')
|
||||
const requestPayload = envelope.request
|
||||
if (!requestPayload || typeof requestPayload !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
if (requestPayload.safetySettings !== undefined) {
|
||||
delete requestPayload.safetySettings
|
||||
}
|
||||
|
||||
// 对齐 CLIProxyAPI:有 tools 时默认启用 VALIDATED(除非显式 NONE)
|
||||
if (Array.isArray(requestPayload.tools) && requestPayload.tools.length > 0) {
|
||||
const existing = requestPayload?.toolConfig?.functionCallingConfig || null
|
||||
if (existing?.mode !== 'NONE') {
|
||||
const nextCfg = { ...(existing || {}), mode: 'VALIDATED' }
|
||||
requestPayload.toolConfig = { functionCallingConfig: nextCfg }
|
||||
}
|
||||
}
|
||||
|
||||
// 对齐 CLIProxyAPI:非 Claude 模型移除 maxOutputTokens(Antigravity 环境不稳定)
|
||||
normalizeAntigravityThinking(model, requestPayload)
|
||||
if (!model.includes('claude')) {
|
||||
if (requestPayload.generationConfig && typeof requestPayload.generationConfig === 'object') {
|
||||
delete requestPayload.generationConfig.maxOutputTokens
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Claude 模型:parametersJsonSchema -> parameters + schema 清洗(避免 $schema / additionalProperties 等触发 400)
|
||||
if (!Array.isArray(requestPayload.tools)) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const tool of requestPayload.tools) {
|
||||
if (!tool || typeof tool !== 'object') {
|
||||
continue
|
||||
}
|
||||
const decls = Array.isArray(tool.functionDeclarations)
|
||||
? tool.functionDeclarations
|
||||
: Array.isArray(tool.function_declarations)
|
||||
? tool.function_declarations
|
||||
: null
|
||||
|
||||
if (!decls) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const decl of decls) {
|
||||
if (!decl || typeof decl !== 'object') {
|
||||
continue
|
||||
}
|
||||
let schema =
|
||||
decl.parametersJsonSchema !== undefined ? decl.parametersJsonSchema : decl.parameters
|
||||
if (typeof schema === 'string' && schema) {
|
||||
try {
|
||||
schema = JSON.parse(schema)
|
||||
} catch (_) {
|
||||
schema = null
|
||||
}
|
||||
}
|
||||
|
||||
decl.parameters = cleanJsonSchemaForGemini(schema)
|
||||
delete decl.parametersJsonSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function request({
|
||||
accessToken,
|
||||
proxyConfig = null,
|
||||
requestData,
|
||||
projectId = null,
|
||||
sessionId = null,
|
||||
userPromptId = null,
|
||||
stream = false,
|
||||
signal = null,
|
||||
params = null,
|
||||
timeoutMs = null
|
||||
}) {
|
||||
const { model, envelope } = buildAntigravityEnvelope({
|
||||
requestData,
|
||||
projectId,
|
||||
sessionId,
|
||||
userPromptId
|
||||
})
|
||||
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
let endpoints = getAntigravityApiUrlCandidates()
|
||||
|
||||
// Claude 模型在 sandbox(daily) 环境下对 tool_use/tool_result 的兼容性不稳定,优先走 prod。
|
||||
// 保持可配置优先:若用户显式设置了 ANTIGRAVITY_API_URL,则不改变顺序。
|
||||
if (!process.env.ANTIGRAVITY_API_URL && String(model).includes('claude')) {
|
||||
const prodHost = 'cloudcode-pa.googleapis.com'
|
||||
const dailyHost = 'daily-cloudcode-pa.sandbox.googleapis.com'
|
||||
const ordered = []
|
||||
for (const u of endpoints) {
|
||||
if (String(u).includes(prodHost)) {
|
||||
ordered.push(u)
|
||||
}
|
||||
}
|
||||
for (const u of endpoints) {
|
||||
if (!String(u).includes(prodHost)) {
|
||||
ordered.push(u)
|
||||
}
|
||||
}
|
||||
// 去重并保持 prod -> daily 的稳定顺序
|
||||
endpoints = Array.from(new Set(ordered)).sort((a, b) => {
|
||||
const av = String(a)
|
||||
const bv = String(b)
|
||||
const aScore = av.includes(prodHost) ? 0 : av.includes(dailyHost) ? 1 : 2
|
||||
const bScore = bv.includes(prodHost) ? 0 : bv.includes(dailyHost) ? 1 : 2
|
||||
return aScore - bScore
|
||||
})
|
||||
}
|
||||
|
||||
const isRetryable = (error) => {
|
||||
// 处理网络层面的连接重置或超时(常见于长请求被中间节点切断)
|
||||
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
|
||||
return true
|
||||
}
|
||||
|
||||
const status = error?.response?.status
|
||||
if (status === 429) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 400/404 的 “model unavailable / not found” 在不同环境间可能表现不同,允许 fallback。
|
||||
if (status === 400 || status === 404) {
|
||||
const data = error?.response?.data
|
||||
const safeToString = (value) => {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
// axios responseType=stream 时,data 可能是 stream(存在循环引用),不能 JSON.stringify
|
||||
if (typeof value === 'object' && typeof value.pipe === 'function') {
|
||||
return ''
|
||||
}
|
||||
if (Buffer.isBuffer(value)) {
|
||||
try {
|
||||
return value.toString('utf8')
|
||||
} catch (_) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (_) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const text = safeToString(data)
|
||||
const msg = (text || '').toLowerCase()
|
||||
return (
|
||||
msg.includes('requested model is currently unavailable') ||
|
||||
msg.includes('tool_use') ||
|
||||
msg.includes('tool_result') ||
|
||||
msg.includes('requested entity was not found') ||
|
||||
msg.includes('not found')
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
let lastError = null
|
||||
let retriedAfterDelay = false
|
||||
|
||||
const attemptRequest = async () => {
|
||||
for (let index = 0; index < endpoints.length; index += 1) {
|
||||
const baseUrl = endpoints[index]
|
||||
const url = `${baseUrl}/v1internal:${stream ? 'streamGenerateContent' : 'generateContent'}`
|
||||
|
||||
const axiosConfig = {
|
||||
url,
|
||||
method: 'POST',
|
||||
...(params ? { params } : {}),
|
||||
headers: getAntigravityHeaders(accessToken, baseUrl),
|
||||
data: envelope,
|
||||
timeout: stream ? 0 : timeoutMs || 600000,
|
||||
...(stream ? { responseType: 'stream' } : {})
|
||||
}
|
||||
|
||||
if (proxyAgent) {
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
axiosConfig.proxy = false
|
||||
if (index === 0) {
|
||||
logger.info(
|
||||
`🌐 Using proxy for Antigravity ${stream ? 'streamGenerateContent' : 'generateContent'}: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
axiosConfig.httpsAgent = keepAliveAgent
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
axiosConfig.signal = signal
|
||||
}
|
||||
|
||||
try {
|
||||
dumpAntigravityUpstreamRequest({
|
||||
requestId: envelope.requestId,
|
||||
model,
|
||||
stream,
|
||||
url,
|
||||
baseUrl,
|
||||
params: axiosConfig.params || null,
|
||||
headers: axiosConfig.headers,
|
||||
envelope
|
||||
}).catch(() => {})
|
||||
const response = await axios(axiosConfig)
|
||||
return { model, response }
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
const status = error?.response?.status || null
|
||||
|
||||
const hasNext = index + 1 < endpoints.length
|
||||
if (hasNext && isRetryable(error)) {
|
||||
logger.warn('⚠️ Antigravity upstream error, retrying with fallback baseUrl', {
|
||||
status,
|
||||
from: baseUrl,
|
||||
to: endpoints[index + 1],
|
||||
model
|
||||
})
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Antigravity request failed')
|
||||
}
|
||||
|
||||
try {
|
||||
return await attemptRequest()
|
||||
} catch (error) {
|
||||
// 如果是 429 RESOURCE_EXHAUSTED 且尚未重试过,等待 2 秒后重试一次
|
||||
const status = error?.response?.status
|
||||
if (status === 429 && !retriedAfterDelay && !signal?.aborted) {
|
||||
const data = error?.response?.data
|
||||
|
||||
// 安全地将 data 转为字符串,避免 stream 对象导致循环引用崩溃
|
||||
const safeDataToString = (value) => {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
// stream 对象存在循环引用,不能 JSON.stringify
|
||||
if (typeof value === 'object' && typeof value.pipe === 'function') {
|
||||
return ''
|
||||
}
|
||||
if (Buffer.isBuffer(value)) {
|
||||
try {
|
||||
return value.toString('utf8')
|
||||
} catch (_) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (_) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const msg = safeDataToString(data)
|
||||
if (
|
||||
msg.toLowerCase().includes('resource_exhausted') ||
|
||||
msg.toLowerCase().includes('no capacity')
|
||||
) {
|
||||
retriedAfterDelay = true
|
||||
logger.warn('⏳ Antigravity 429 RESOURCE_EXHAUSTED, waiting 2s before retry', { model })
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
return await attemptRequest()
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAvailableModels({ accessToken, proxyConfig = null, timeoutMs = 30000 }) {
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
const endpoints = getAntigravityApiUrlCandidates()
|
||||
|
||||
let lastError = null
|
||||
for (let index = 0; index < endpoints.length; index += 1) {
|
||||
const baseUrl = endpoints[index]
|
||||
const url = `${baseUrl}/v1internal:fetchAvailableModels`
|
||||
|
||||
const axiosConfig = {
|
||||
url,
|
||||
method: 'POST',
|
||||
headers: getAntigravityHeaders(accessToken, baseUrl),
|
||||
data: {},
|
||||
timeout: timeoutMs
|
||||
}
|
||||
|
||||
if (proxyAgent) {
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
axiosConfig.proxy = false
|
||||
if (index === 0) {
|
||||
logger.info(
|
||||
`🌐 Using proxy for Antigravity fetchAvailableModels: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
axiosConfig.httpsAgent = keepAliveAgent
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios(axiosConfig)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
const status = error?.response?.status
|
||||
const hasNext = index + 1 < endpoints.length
|
||||
if (hasNext && (status === 429 || status === 404)) {
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Antigravity fetchAvailableModels failed')
|
||||
}
|
||||
|
||||
async function countTokens({
|
||||
accessToken,
|
||||
proxyConfig = null,
|
||||
contents,
|
||||
model,
|
||||
timeoutMs = 30000
|
||||
}) {
|
||||
const upstreamModel = mapAntigravityUpstreamModel(model)
|
||||
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
const endpoints = getAntigravityApiUrlCandidates()
|
||||
|
||||
let lastError = null
|
||||
for (let index = 0; index < endpoints.length; index += 1) {
|
||||
const baseUrl = endpoints[index]
|
||||
const url = `${baseUrl}/v1internal:countTokens`
|
||||
const axiosConfig = {
|
||||
url,
|
||||
method: 'POST',
|
||||
headers: getAntigravityHeaders(accessToken, baseUrl),
|
||||
data: {
|
||||
request: {
|
||||
model: `models/${upstreamModel}`,
|
||||
contents
|
||||
}
|
||||
},
|
||||
timeout: timeoutMs
|
||||
}
|
||||
|
||||
if (proxyAgent) {
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
axiosConfig.proxy = false
|
||||
if (index === 0) {
|
||||
logger.info(
|
||||
`🌐 Using proxy for Antigravity countTokens: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
axiosConfig.httpsAgent = keepAliveAgent
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios(axiosConfig)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
const status = error?.response?.status
|
||||
const hasNext = index + 1 < endpoints.length
|
||||
if (hasNext && (status === 429 || status === 404)) {
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Antigravity countTokens failed')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAntigravityApiUrl,
|
||||
getAntigravityApiUrlCandidates,
|
||||
getAntigravityHeaders,
|
||||
buildAntigravityEnvelope,
|
||||
request,
|
||||
fetchAvailableModels,
|
||||
countTokens
|
||||
}
|
||||
170
src/services/antigravityRelayService.js
Normal file
@@ -0,0 +1,170 @@
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const { convertMessagesToGemini, convertGeminiResponse } = require('./geminiRelayService')
|
||||
const { normalizeAntigravityModelInput } = require('../utils/antigravityModel')
|
||||
const antigravityClient = require('./antigravityClient')
|
||||
|
||||
function buildRequestData({ messages, model, temperature, maxTokens, sessionId }) {
|
||||
const requestedModel = normalizeAntigravityModelInput(model)
|
||||
const { contents, systemInstruction } = convertMessagesToGemini(messages)
|
||||
|
||||
const requestData = {
|
||||
model: requestedModel,
|
||||
request: {
|
||||
contents,
|
||||
generationConfig: {
|
||||
temperature,
|
||||
maxOutputTokens: maxTokens,
|
||||
candidateCount: 1,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
},
|
||||
...(sessionId ? { sessionId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
if (systemInstruction) {
|
||||
requestData.request.systemInstruction = { parts: [{ text: systemInstruction }] }
|
||||
}
|
||||
|
||||
return requestData
|
||||
}
|
||||
|
||||
async function* handleStreamResponse(response, model, apiKeyId, accountId) {
|
||||
let buffer = ''
|
||||
let totalUsage = {
|
||||
promptTokenCount: 0,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: 0
|
||||
}
|
||||
let usageRecorded = false
|
||||
|
||||
try {
|
||||
for await (const chunk of response.data) {
|
||||
buffer += chunk.toString()
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
let jsonData = line
|
||||
if (line.startsWith('data: ')) {
|
||||
jsonData = line.substring(6).trim()
|
||||
}
|
||||
|
||||
if (!jsonData || jsonData === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonData)
|
||||
const payload = data?.response || data
|
||||
|
||||
if (payload?.usageMetadata) {
|
||||
totalUsage = payload.usageMetadata
|
||||
}
|
||||
|
||||
const openaiChunk = convertGeminiResponse(payload, model, true)
|
||||
if (openaiChunk) {
|
||||
yield `data: ${JSON.stringify(openaiChunk)}\n\n`
|
||||
const finishReason = openaiChunk.choices?.[0]?.finish_reason
|
||||
if (finishReason === 'stop') {
|
||||
yield 'data: [DONE]\n\n'
|
||||
|
||||
if (apiKeyId && totalUsage.totalTokenCount > 0) {
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyId,
|
||||
totalUsage.promptTokenCount || 0,
|
||||
totalUsage.candidatesTokenCount || 0,
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
accountId
|
||||
)
|
||||
usageRecorded = true
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore chunk parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!usageRecorded && apiKeyId && totalUsage.totalTokenCount > 0) {
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyId,
|
||||
totalUsage.promptTokenCount || 0,
|
||||
totalUsage.candidatesTokenCount || 0,
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
accountId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAntigravityRequest({
|
||||
messages,
|
||||
model,
|
||||
temperature = 0.7,
|
||||
maxTokens = 4096,
|
||||
stream = false,
|
||||
accessToken,
|
||||
proxy,
|
||||
apiKeyId,
|
||||
signal,
|
||||
projectId,
|
||||
accountId = null
|
||||
}) {
|
||||
const requestedModel = normalizeAntigravityModelInput(model)
|
||||
|
||||
const requestData = buildRequestData({
|
||||
messages,
|
||||
model: requestedModel,
|
||||
temperature,
|
||||
maxTokens,
|
||||
sessionId: apiKeyId
|
||||
})
|
||||
|
||||
const { response } = await antigravityClient.request({
|
||||
accessToken,
|
||||
proxyConfig: proxy,
|
||||
requestData,
|
||||
projectId,
|
||||
sessionId: apiKeyId,
|
||||
stream,
|
||||
signal,
|
||||
params: { alt: 'sse' }
|
||||
})
|
||||
|
||||
if (stream) {
|
||||
return handleStreamResponse(response, requestedModel, apiKeyId, accountId)
|
||||
}
|
||||
|
||||
const payload = response.data?.response || response.data
|
||||
const openaiResponse = convertGeminiResponse(payload, requestedModel, false)
|
||||
|
||||
if (apiKeyId && openaiResponse?.usage) {
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyId,
|
||||
openaiResponse.usage.prompt_tokens || 0,
|
||||
openaiResponse.usage.completion_tokens || 0,
|
||||
0,
|
||||
0,
|
||||
requestedModel,
|
||||
accountId
|
||||
)
|
||||
}
|
||||
|
||||
return openaiResponse
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendAntigravityRequest
|
||||
}
|
||||
@@ -129,6 +129,11 @@ async function createAccount(accountData) {
|
||||
supportedModels: JSON.stringify(
|
||||
accountData.supportedModels || ['gpt-4', 'gpt-4-turbo', 'gpt-35-turbo', 'gpt-35-turbo-16k']
|
||||
),
|
||||
|
||||
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||
// 注意:Azure OpenAI 使用 API Key 认证,没有 OAuth token,因此没有 expiresAt
|
||||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
||||
|
||||
// 状态字段
|
||||
isActive: accountData.isActive !== false ? 'true' : 'false',
|
||||
status: 'active',
|
||||
@@ -218,6 +223,12 @@ async function updateAccount(accountId, updates) {
|
||||
: JSON.stringify(updates.supportedModels)
|
||||
}
|
||||
|
||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||
// Azure OpenAI 使用 API Key,没有 token 刷新逻辑,不会覆盖此字段
|
||||
if (updates.subscriptionExpiresAt !== undefined) {
|
||||
// 直接保存,不做任何调整
|
||||
}
|
||||
|
||||
// 更新账户类型时处理共享账户集合
|
||||
const client = redisClient.getClientSafe()
|
||||
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
|
||||
@@ -249,6 +260,10 @@ async function updateAccount(accountId, updates) {
|
||||
|
||||
// 删除账户
|
||||
async function deleteAccount(accountId) {
|
||||
// 首先从所有分组中移除此账户
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
await accountGroupService.removeAccountFromAllGroups(accountId)
|
||||
|
||||
const client = redisClient.getClientSafe()
|
||||
const accountKey = `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
@@ -296,7 +311,15 @@ async function getAllAccounts() {
|
||||
}
|
||||
}
|
||||
|
||||
accounts.push(accountData)
|
||||
accounts.push({
|
||||
...accountData,
|
||||
isActive: accountData.isActive === 'true',
|
||||
schedulable: accountData.schedulable !== 'false',
|
||||
|
||||
// ✅ 前端显示订阅过期时间(业务字段)
|
||||
expiresAt: accountData.subscriptionExpiresAt || null,
|
||||
platform: 'azure-openai'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +346,19 @@ async function getSharedAccounts() {
|
||||
return accounts
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户订阅是否过期
|
||||
* @param {Object} account - 账户对象
|
||||
* @returns {boolean} - true: 已过期, false: 未过期
|
||||
*/
|
||||
function isSubscriptionExpired(account) {
|
||||
if (!account.subscriptionExpiresAt) {
|
||||
return false // 未设置视为永不过期
|
||||
}
|
||||
const expiryDate = new Date(account.subscriptionExpiresAt)
|
||||
return expiryDate <= new Date()
|
||||
}
|
||||
|
||||
// 选择可用账户
|
||||
async function selectAvailableAccount(sessionId = null) {
|
||||
// 如果有会话ID,尝试获取之前分配的账户
|
||||
@@ -344,9 +380,17 @@ async function selectAvailableAccount(sessionId = null) {
|
||||
const sharedAccounts = await getSharedAccounts()
|
||||
|
||||
// 过滤出可用的账户
|
||||
const availableAccounts = sharedAccounts.filter(
|
||||
(acc) => acc.isActive === 'true' && acc.schedulable === 'true'
|
||||
)
|
||||
const availableAccounts = sharedAccounts.filter((acc) => {
|
||||
// ✅ 检查账户订阅是否过期
|
||||
if (isSubscriptionExpired(acc)) {
|
||||
logger.debug(
|
||||
`⏰ Skipping expired Azure OpenAI account: ${acc.name}, expired at ${acc.subscriptionExpiresAt}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
return acc.isActive === 'true' && acc.schedulable === 'true'
|
||||
})
|
||||
|
||||
if (availableAccounts.length === 0) {
|
||||
throw new Error('No available Azure OpenAI accounts')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const axios = require('axios')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
|
||||
// 转换模型名称(去掉 azure/ 前缀)
|
||||
function normalizeModelName(model) {
|
||||
@@ -29,7 +30,7 @@ async function handleAzureOpenAIRequest({
|
||||
deploymentName = account.deploymentName || 'default'
|
||||
// Azure Responses API requires preview versions; fall back appropriately
|
||||
const apiVersion =
|
||||
account.apiVersion || (endpoint === 'responses' ? '2024-10-01-preview' : '2024-02-01')
|
||||
account.apiVersion || (endpoint === 'responses' ? '2025-04-01-preview' : '2024-02-01')
|
||||
if (endpoint === 'chat/completions') {
|
||||
requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}`
|
||||
} else if (endpoint === 'responses') {
|
||||
@@ -53,7 +54,9 @@ async function handleAzureOpenAIRequest({
|
||||
const processedBody = { ...requestBody }
|
||||
|
||||
// 标准化模型名称
|
||||
if (processedBody.model) {
|
||||
if (endpoint === 'responses') {
|
||||
processedBody.model = deploymentName
|
||||
} else if (processedBody.model) {
|
||||
processedBody.model = normalizeModelName(processedBody.model)
|
||||
} else {
|
||||
processedBody.model = 'gpt-4'
|
||||
@@ -68,7 +71,7 @@ async function handleAzureOpenAIRequest({
|
||||
url: requestUrl,
|
||||
headers: requestHeaders,
|
||||
data: processedBody,
|
||||
timeout: 600000, // 10 minutes for Azure OpenAI
|
||||
timeout: config.requestTimeout || 600000,
|
||||
validateStatus: () => true,
|
||||
// 添加连接保活选项
|
||||
keepAlive: true,
|
||||
@@ -79,7 +82,9 @@ async function handleAzureOpenAIRequest({
|
||||
|
||||
// 如果有代理,添加代理配置
|
||||
if (proxyAgent) {
|
||||
axiosConfig.httpAgent = proxyAgent
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
axiosConfig.proxy = false
|
||||
// 为代理添加额外的keep-alive设置
|
||||
if (proxyAgent.options) {
|
||||
proxyAgent.options.keepAlive = true
|
||||
@@ -273,6 +278,11 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
|
||||
let eventCount = 0
|
||||
const maxEvents = 10000 // 最大事件数量限制
|
||||
|
||||
// 专门用于保存最后几个chunks以提取usage数据
|
||||
let finalChunksBuffer = ''
|
||||
const FINAL_CHUNKS_SIZE = 32 * 1024 // 32KB保留最终chunks
|
||||
const allParsedEvents = [] // 存储所有解析的事件用于最终usage提取
|
||||
|
||||
// 设置响应头
|
||||
clientResponse.setHeader('Content-Type', 'text/event-stream')
|
||||
clientResponse.setHeader('Cache-Control', 'no-cache')
|
||||
@@ -297,8 +307,8 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
|
||||
clientResponse.flushHeaders()
|
||||
}
|
||||
|
||||
// 解析 SSE 事件以捕获 usage 数据
|
||||
const parseSSEForUsage = (data) => {
|
||||
// 强化的SSE事件解析,保存所有事件用于最终处理
|
||||
const parseSSEForUsage = (data, isFromFinalBuffer = false) => {
|
||||
const lines = data.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
@@ -310,34 +320,54 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
|
||||
}
|
||||
const eventData = JSON.parse(jsonStr)
|
||||
|
||||
// 保存所有成功解析的事件
|
||||
allParsedEvents.push(eventData)
|
||||
|
||||
// 获取模型信息
|
||||
if (eventData.model) {
|
||||
actualModel = eventData.model
|
||||
}
|
||||
|
||||
// 获取使用统计(Responses API: response.completed -> response.usage)
|
||||
if (eventData.type === 'response.completed' && eventData.response) {
|
||||
if (eventData.response.model) {
|
||||
actualModel = eventData.response.model
|
||||
}
|
||||
if (eventData.response.usage) {
|
||||
usageData = eventData.response.usage
|
||||
logger.debug('Captured Azure OpenAI nested usage (response.usage):', usageData)
|
||||
// 使用强化的usage提取函数
|
||||
const { usageData: extractedUsage, actualModel: extractedModel } =
|
||||
extractUsageDataRobust(
|
||||
eventData,
|
||||
`stream-event-${isFromFinalBuffer ? 'final' : 'normal'}`
|
||||
)
|
||||
|
||||
if (extractedUsage && !usageData) {
|
||||
usageData = extractedUsage
|
||||
if (extractedModel) {
|
||||
actualModel = extractedModel
|
||||
}
|
||||
logger.debug(`🎯 Stream usage captured via robust extraction`, {
|
||||
isFromFinalBuffer,
|
||||
usageData,
|
||||
actualModel
|
||||
})
|
||||
}
|
||||
|
||||
// 兼容 Chat Completions 风格(顶层 usage)
|
||||
if (!usageData && eventData.usage) {
|
||||
usageData = eventData.usage
|
||||
logger.debug('Captured Azure OpenAI usage (top-level):', usageData)
|
||||
}
|
||||
// 原有的简单提取作为备用
|
||||
if (!usageData) {
|
||||
// 获取使用统计(Responses API: response.completed -> response.usage)
|
||||
if (eventData.type === 'response.completed' && eventData.response) {
|
||||
if (eventData.response.model) {
|
||||
actualModel = eventData.response.model
|
||||
}
|
||||
if (eventData.response.usage) {
|
||||
usageData = eventData.response.usage
|
||||
logger.debug('🎯 Stream usage (backup method - response.usage):', usageData)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否是完成事件
|
||||
if (eventData.choices && eventData.choices[0] && eventData.choices[0].finish_reason) {
|
||||
// 这是最后一个 chunk
|
||||
// 兼容 Chat Completions 风格(顶层 usage)
|
||||
if (!usageData && eventData.usage) {
|
||||
usageData = eventData.usage
|
||||
logger.debug('🎯 Stream usage (backup method - top-level):', usageData)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
logger.debug('SSE parsing error (expected for incomplete chunks):', e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,10 +417,19 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
|
||||
// 同时解析数据以捕获 usage 信息,带缓冲区大小限制
|
||||
buffer += chunkStr
|
||||
|
||||
// 防止缓冲区过大
|
||||
// 保留最后的chunks用于最终usage提取(不被truncate影响)
|
||||
finalChunksBuffer += chunkStr
|
||||
if (finalChunksBuffer.length > FINAL_CHUNKS_SIZE) {
|
||||
finalChunksBuffer = finalChunksBuffer.slice(-FINAL_CHUNKS_SIZE)
|
||||
}
|
||||
|
||||
// 防止主缓冲区过大 - 但保持最后部分用于usage解析
|
||||
if (buffer.length > MAX_BUFFER_SIZE) {
|
||||
logger.warn(`Stream ${streamId} buffer exceeded limit, truncating`)
|
||||
buffer = buffer.slice(-MAX_BUFFER_SIZE / 2) // 保留后一半
|
||||
logger.warn(
|
||||
`Stream ${streamId} buffer exceeded limit, truncating main buffer but preserving final chunks`
|
||||
)
|
||||
// 保留最后1/4而不是1/2,为usage数据留更多空间
|
||||
buffer = buffer.slice(-MAX_BUFFER_SIZE / 4)
|
||||
}
|
||||
|
||||
// 处理完整的 SSE 事件
|
||||
@@ -426,9 +465,91 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
|
||||
hasEnded = true
|
||||
|
||||
try {
|
||||
// 处理剩余的 buffer
|
||||
if (buffer.trim() && buffer.length <= MAX_EVENT_SIZE) {
|
||||
parseSSEForUsage(buffer)
|
||||
logger.debug(`🔚 Stream ended, performing comprehensive usage extraction for ${streamId}`, {
|
||||
mainBufferSize: buffer.length,
|
||||
finalChunksBufferSize: finalChunksBuffer.length,
|
||||
parsedEventsCount: allParsedEvents.length,
|
||||
hasUsageData: !!usageData
|
||||
})
|
||||
|
||||
// 多层次的最终usage提取策略
|
||||
if (!usageData) {
|
||||
logger.debug('🔍 No usage found during stream, trying final extraction methods...')
|
||||
|
||||
// 方法1: 解析剩余的主buffer
|
||||
if (buffer.trim() && buffer.length <= MAX_EVENT_SIZE) {
|
||||
parseSSEForUsage(buffer, false)
|
||||
}
|
||||
|
||||
// 方法2: 解析保留的final chunks buffer
|
||||
if (!usageData && finalChunksBuffer.trim()) {
|
||||
logger.debug('🔍 Trying final chunks buffer for usage extraction...')
|
||||
parseSSEForUsage(finalChunksBuffer, true)
|
||||
}
|
||||
|
||||
// 方法3: 从所有解析的事件中重新搜索usage
|
||||
if (!usageData && allParsedEvents.length > 0) {
|
||||
logger.debug('🔍 Searching through all parsed events for usage...')
|
||||
|
||||
// 倒序查找,因为usage通常在最后
|
||||
for (let i = allParsedEvents.length - 1; i >= 0; i--) {
|
||||
const { usageData: foundUsage, actualModel: foundModel } = extractUsageDataRobust(
|
||||
allParsedEvents[i],
|
||||
`final-event-scan-${i}`
|
||||
)
|
||||
if (foundUsage) {
|
||||
usageData = foundUsage
|
||||
if (foundModel) {
|
||||
actualModel = foundModel
|
||||
}
|
||||
logger.debug(`🎯 Usage found in event ${i} during final scan!`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 方法4: 尝试合并所有事件并搜索
|
||||
if (!usageData && allParsedEvents.length > 0) {
|
||||
logger.debug('🔍 Trying combined events analysis...')
|
||||
const combinedData = {
|
||||
events: allParsedEvents,
|
||||
lastEvent: allParsedEvents[allParsedEvents.length - 1],
|
||||
eventCount: allParsedEvents.length
|
||||
}
|
||||
|
||||
const { usageData: combinedUsage } = extractUsageDataRobust(
|
||||
combinedData,
|
||||
'combined-events'
|
||||
)
|
||||
if (combinedUsage) {
|
||||
usageData = combinedUsage
|
||||
logger.debug('🎯 Usage found via combined events analysis!')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最终usage状态报告
|
||||
if (usageData) {
|
||||
logger.debug('✅ Final stream usage extraction SUCCESS', {
|
||||
streamId,
|
||||
usageData,
|
||||
actualModel,
|
||||
totalEvents: allParsedEvents.length,
|
||||
finalBufferSize: finalChunksBuffer.length
|
||||
})
|
||||
} else {
|
||||
logger.warn('❌ Final stream usage extraction FAILED', {
|
||||
streamId,
|
||||
totalEvents: allParsedEvents.length,
|
||||
finalBufferSize: finalChunksBuffer.length,
|
||||
mainBufferSize: buffer.length,
|
||||
lastFewEvents: allParsedEvents.slice(-3).map((e) => ({
|
||||
type: e.type,
|
||||
hasUsage: !!e.usage,
|
||||
hasResponse: !!e.response,
|
||||
keys: Object.keys(e)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
if (onEnd) {
|
||||
@@ -484,6 +605,120 @@ function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
// 强化的用量数据提取函数
|
||||
function extractUsageDataRobust(responseData, context = 'unknown') {
|
||||
logger.debug(`🔍 Attempting usage extraction for ${context}`, {
|
||||
responseDataKeys: Object.keys(responseData || {}),
|
||||
responseDataType: typeof responseData,
|
||||
hasUsage: !!responseData?.usage,
|
||||
hasResponse: !!responseData?.response
|
||||
})
|
||||
|
||||
let usageData = null
|
||||
let actualModel = null
|
||||
|
||||
try {
|
||||
// 策略 1: 顶层 usage (标准 Chat Completions)
|
||||
if (responseData?.usage) {
|
||||
usageData = responseData.usage
|
||||
actualModel = responseData.model
|
||||
logger.debug('✅ Usage extracted via Strategy 1 (top-level)', { usageData, actualModel })
|
||||
}
|
||||
|
||||
// 策略 2: response.usage (Responses API)
|
||||
else if (responseData?.response?.usage) {
|
||||
usageData = responseData.response.usage
|
||||
actualModel = responseData.response.model || responseData.model
|
||||
logger.debug('✅ Usage extracted via Strategy 2 (response.usage)', { usageData, actualModel })
|
||||
}
|
||||
|
||||
// 策略 3: 嵌套搜索 - 深度查找 usage 字段
|
||||
else {
|
||||
const findUsageRecursive = (obj, path = '') => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const currentPath = path ? `${path}.${key}` : key
|
||||
|
||||
if (key === 'usage' && value && typeof value === 'object') {
|
||||
logger.debug(`✅ Usage found at path: ${currentPath}`, value)
|
||||
return { usage: value, path: currentPath }
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const nested = findUsageRecursive(value, currentPath)
|
||||
if (nested) {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const found = findUsageRecursive(responseData)
|
||||
if (found) {
|
||||
usageData = found.usage
|
||||
// Try to find model in the same parent object
|
||||
const pathParts = found.path.split('.')
|
||||
pathParts.pop() // remove 'usage'
|
||||
let modelParent = responseData
|
||||
for (const part of pathParts) {
|
||||
modelParent = modelParent?.[part]
|
||||
}
|
||||
actualModel = modelParent?.model || responseData?.model
|
||||
logger.debug('✅ Usage extracted via Strategy 3 (recursive)', {
|
||||
usageData,
|
||||
actualModel,
|
||||
foundPath: found.path
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 策略 4: 特殊响应格式处理
|
||||
if (!usageData) {
|
||||
// 检查是否有 choices 数组,usage 可能在最后一个 choice 中
|
||||
if (responseData?.choices?.length > 0) {
|
||||
const lastChoice = responseData.choices[responseData.choices.length - 1]
|
||||
if (lastChoice?.usage) {
|
||||
usageData = lastChoice.usage
|
||||
actualModel = responseData.model || lastChoice.model
|
||||
logger.debug('✅ Usage extracted via Strategy 4 (choices)', { usageData, actualModel })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最终验证和记录
|
||||
if (usageData) {
|
||||
logger.debug('🎯 Final usage extraction result', {
|
||||
context,
|
||||
usageData,
|
||||
actualModel,
|
||||
inputTokens: usageData.prompt_tokens || usageData.input_tokens || 0,
|
||||
outputTokens: usageData.completion_tokens || usageData.output_tokens || 0,
|
||||
totalTokens: usageData.total_tokens || 0
|
||||
})
|
||||
} else {
|
||||
logger.warn('❌ Failed to extract usage data', {
|
||||
context,
|
||||
responseDataStructure: `${JSON.stringify(responseData, null, 2).substring(0, 1000)}...`,
|
||||
availableKeys: Object.keys(responseData || {}),
|
||||
responseSize: JSON.stringify(responseData || {}).length
|
||||
})
|
||||
}
|
||||
} catch (extractionError) {
|
||||
logger.error('🚨 Error during usage extraction', {
|
||||
context,
|
||||
error: extractionError.message,
|
||||
stack: extractionError.stack,
|
||||
responseDataType: typeof responseData
|
||||
})
|
||||
}
|
||||
|
||||
return { usageData, actualModel }
|
||||
}
|
||||
|
||||
// 处理非流式响应
|
||||
function handleNonStreamResponse(upstreamResponse, clientResponse) {
|
||||
try {
|
||||
@@ -510,9 +745,8 @@ function handleNonStreamResponse(upstreamResponse, clientResponse) {
|
||||
const responseData = upstreamResponse.data
|
||||
clientResponse.json(responseData)
|
||||
|
||||
// 提取 usage 数据
|
||||
const usageData = responseData.usage
|
||||
const actualModel = responseData.model
|
||||
// 使用强化的用量提取
|
||||
const { usageData, actualModel } = extractUsageDataRobust(responseData, 'non-stream')
|
||||
|
||||
return { usageData, actualModel, responseData }
|
||||
} catch (error) {
|
||||
|
||||
133
src/services/balanceProviders/baseBalanceProvider.js
Normal file
@@ -0,0 +1,133 @@
|
||||
const axios = require('axios')
|
||||
const logger = require('../../utils/logger')
|
||||
const ProxyHelper = require('../../utils/proxyHelper')
|
||||
|
||||
/**
|
||||
* Provider 抽象基类
|
||||
* 各平台 Provider 需继承并实现 queryBalance(account)
|
||||
*/
|
||||
class BaseBalanceProvider {
|
||||
constructor(platform) {
|
||||
this.platform = platform
|
||||
this.logger = logger
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询余额(抽象方法)
|
||||
* @param {object} account - 账户对象
|
||||
* @returns {Promise<object>}
|
||||
* 形如:
|
||||
* {
|
||||
* balance: number|null,
|
||||
* currency?: string,
|
||||
* quota?: { daily, used, remaining, resetAt, percentage, unlimited? },
|
||||
* queryMethod?: 'api'|'field'|'local',
|
||||
* rawData?: any
|
||||
* }
|
||||
*/
|
||||
async queryBalance(_account) {
|
||||
throw new Error('queryBalance 方法必须由子类实现')
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 HTTP 请求方法(支持代理)
|
||||
* @param {string} url
|
||||
* @param {object} options
|
||||
* @param {object} account
|
||||
*/
|
||||
async makeRequest(url, options = {}, account = {}) {
|
||||
const config = {
|
||||
url,
|
||||
method: options.method || 'GET',
|
||||
headers: options.headers || {},
|
||||
timeout: options.timeout || 15000,
|
||||
data: options.data,
|
||||
params: options.params,
|
||||
responseType: options.responseType
|
||||
}
|
||||
|
||||
const proxyConfig = account.proxyConfig || account.proxy
|
||||
if (proxyConfig) {
|
||||
const agent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
if (agent) {
|
||||
config.httpAgent = agent
|
||||
config.httpsAgent = agent
|
||||
config.proxy = false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios(config)
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
status: response.status,
|
||||
headers: response.headers
|
||||
}
|
||||
} catch (error) {
|
||||
const status = error.response?.status
|
||||
const message = error.response?.data?.message || error.message || '请求失败'
|
||||
this.logger.debug(`余额 Provider HTTP 请求失败: ${url} (${this.platform})`, {
|
||||
status,
|
||||
message
|
||||
})
|
||||
return { success: false, status, error: message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从账户字段读取 dailyQuota / dailyUsage(通用降级方案)
|
||||
* 注意:部分平台 dailyUsage 字段可能不是实时值,最终以 AccountBalanceService 的本地统计为准
|
||||
*/
|
||||
readQuotaFromFields(account) {
|
||||
const dailyQuota = Number(account?.dailyQuota || 0)
|
||||
const dailyUsage = Number(account?.dailyUsage || 0)
|
||||
|
||||
// 无限制
|
||||
if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) {
|
||||
return {
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
quota: {
|
||||
daily: Infinity,
|
||||
used: Number.isFinite(dailyUsage) ? dailyUsage : 0,
|
||||
remaining: Infinity,
|
||||
percentage: 0,
|
||||
unlimited: true
|
||||
},
|
||||
queryMethod: 'field'
|
||||
}
|
||||
}
|
||||
|
||||
const used = Number.isFinite(dailyUsage) ? dailyUsage : 0
|
||||
const remaining = Math.max(0, dailyQuota - used)
|
||||
const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0
|
||||
|
||||
return {
|
||||
balance: remaining,
|
||||
currency: 'USD',
|
||||
quota: {
|
||||
daily: dailyQuota,
|
||||
used,
|
||||
remaining,
|
||||
percentage: Math.round(percentage * 100) / 100
|
||||
},
|
||||
queryMethod: 'field'
|
||||
}
|
||||
}
|
||||
|
||||
parseCurrency(data) {
|
||||
return data?.currency || data?.Currency || 'USD'
|
||||
}
|
||||
|
||||
async safeExecute(fn, fallbackValue = null) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
this.logger.error(`余额 Provider 执行失败: ${this.platform}`, error)
|
||||
return fallbackValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BaseBalanceProvider
|
||||
30
src/services/balanceProviders/claudeBalanceProvider.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||
const claudeAccountService = require('../claudeAccountService')
|
||||
|
||||
class ClaudeBalanceProvider extends BaseBalanceProvider {
|
||||
constructor() {
|
||||
super('claude')
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude(OAuth):优先尝试获取 OAuth usage(用于配额/使用信息),不强行提供余额金额
|
||||
*/
|
||||
async queryBalance(account) {
|
||||
this.logger.debug(`查询 Claude 余额(OAuth usage): ${account?.id}`)
|
||||
|
||||
// 仅 OAuth 账户可用;失败时降级
|
||||
const usageData = await claudeAccountService.fetchOAuthUsage(account.id).catch(() => null)
|
||||
if (!usageData) {
|
||||
return { balance: null, currency: 'USD', queryMethod: 'local' }
|
||||
}
|
||||
|
||||
return {
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
queryMethod: 'api',
|
||||
rawData: usageData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ClaudeBalanceProvider
|
||||
@@ -0,0 +1,14 @@
|
||||
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||
|
||||
class ClaudeConsoleBalanceProvider extends BaseBalanceProvider {
|
||||
constructor() {
|
||||
super('claude-console')
|
||||
}
|
||||
|
||||
async queryBalance(account) {
|
||||
this.logger.debug(`查询 Claude Console 余额(字段): ${account?.id}`)
|
||||
return this.readQuotaFromFields(account)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ClaudeConsoleBalanceProvider
|
||||
250
src/services/balanceProviders/geminiBalanceProvider.js
Normal file
@@ -0,0 +1,250 @@
|
||||
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||
const antigravityClient = require('../antigravityClient')
|
||||
const geminiAccountService = require('../geminiAccountService')
|
||||
|
||||
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||
|
||||
function clamp01(value) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
if (value < 0) {
|
||||
return 0
|
||||
}
|
||||
if (value > 1) {
|
||||
return 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function round2(value) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
function normalizeQuotaCategory(displayName, modelId) {
|
||||
const name = String(displayName || '')
|
||||
const id = String(modelId || '')
|
||||
|
||||
if (name.includes('Gemini') && name.includes('Pro')) {
|
||||
return 'Gemini Pro'
|
||||
}
|
||||
if (name.includes('Gemini') && name.includes('Flash')) {
|
||||
return 'Gemini Flash'
|
||||
}
|
||||
if (name.includes('Gemini') && name.toLowerCase().includes('image')) {
|
||||
return 'Gemini Image'
|
||||
}
|
||||
|
||||
if (name.includes('Claude') || name.includes('GPT-OSS')) {
|
||||
return 'Claude'
|
||||
}
|
||||
|
||||
if (id.startsWith('gemini-3-pro-') || id.startsWith('gemini-2.5-pro')) {
|
||||
return 'Gemini Pro'
|
||||
}
|
||||
if (id.startsWith('gemini-3-flash') || id.startsWith('gemini-2.5-flash')) {
|
||||
return 'Gemini Flash'
|
||||
}
|
||||
if (id.includes('image')) {
|
||||
return 'Gemini Image'
|
||||
}
|
||||
if (id.includes('claude') || id.includes('gpt-oss')) {
|
||||
return 'Claude'
|
||||
}
|
||||
|
||||
return name || id || 'Unknown'
|
||||
}
|
||||
|
||||
function buildAntigravityQuota(modelsResponse) {
|
||||
const models = modelsResponse && typeof modelsResponse === 'object' ? modelsResponse.models : null
|
||||
|
||||
if (!models || typeof models !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const parseRemainingFraction = (quotaInfo) => {
|
||||
if (!quotaInfo || typeof quotaInfo !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const raw =
|
||||
quotaInfo.remainingFraction ??
|
||||
quotaInfo.remaining_fraction ??
|
||||
quotaInfo.remaining ??
|
||||
undefined
|
||||
|
||||
const num = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
|
||||
if (!Number.isFinite(num)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return clamp01(num)
|
||||
}
|
||||
|
||||
const allowedCategories = new Set(['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image'])
|
||||
const fixedOrder = ['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image']
|
||||
|
||||
const categoryMap = new Map()
|
||||
|
||||
for (const [modelId, modelDataRaw] of Object.entries(models)) {
|
||||
if (!modelDataRaw || typeof modelDataRaw !== 'object') {
|
||||
continue
|
||||
}
|
||||
|
||||
const displayName = modelDataRaw.displayName || modelDataRaw.display_name || modelId
|
||||
const quotaInfo = modelDataRaw.quotaInfo || modelDataRaw.quota_info || null
|
||||
|
||||
const remainingFraction = parseRemainingFraction(quotaInfo)
|
||||
if (remainingFraction === null) {
|
||||
continue
|
||||
}
|
||||
|
||||
const remainingPercent = round2(remainingFraction * 100)
|
||||
const usedPercent = round2(100 - remainingPercent)
|
||||
const resetAt = quotaInfo?.resetTime || quotaInfo?.reset_time || null
|
||||
|
||||
const category = normalizeQuotaCategory(displayName, modelId)
|
||||
if (!allowedCategories.has(category)) {
|
||||
continue
|
||||
}
|
||||
const entry = {
|
||||
category,
|
||||
modelId,
|
||||
displayName: String(displayName || modelId || category),
|
||||
remainingPercent,
|
||||
usedPercent,
|
||||
resetAt: typeof resetAt === 'string' && resetAt.trim() ? resetAt : null
|
||||
}
|
||||
|
||||
const existing = categoryMap.get(category)
|
||||
if (!existing || entry.remainingPercent < existing.remainingPercent) {
|
||||
categoryMap.set(category, entry)
|
||||
}
|
||||
}
|
||||
|
||||
const buckets = fixedOrder.map((category) => {
|
||||
const existing = categoryMap.get(category) || null
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
return {
|
||||
category,
|
||||
modelId: '',
|
||||
displayName: category,
|
||||
remainingPercent: null,
|
||||
usedPercent: null,
|
||||
resetAt: null
|
||||
}
|
||||
})
|
||||
|
||||
if (buckets.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const critical = buckets
|
||||
.filter((item) => item.remainingPercent !== null)
|
||||
.reduce((min, item) => {
|
||||
if (!min) {
|
||||
return item
|
||||
}
|
||||
return (item.remainingPercent ?? 0) < (min.remainingPercent ?? 0) ? item : min
|
||||
}, null)
|
||||
|
||||
if (!critical) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
quota: {
|
||||
type: 'antigravity',
|
||||
total: 100,
|
||||
used: critical.usedPercent,
|
||||
remaining: critical.remainingPercent,
|
||||
percentage: critical.usedPercent,
|
||||
resetAt: critical.resetAt,
|
||||
buckets: buckets.map((item) => ({
|
||||
category: item.category,
|
||||
remaining: item.remainingPercent,
|
||||
used: item.usedPercent,
|
||||
percentage: item.usedPercent,
|
||||
resetAt: item.resetAt
|
||||
}))
|
||||
},
|
||||
queryMethod: 'api',
|
||||
rawData: {
|
||||
modelsCount: Object.keys(models).length,
|
||||
bucketCount: buckets.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GeminiBalanceProvider extends BaseBalanceProvider {
|
||||
constructor() {
|
||||
super('gemini')
|
||||
}
|
||||
|
||||
async queryBalance(account) {
|
||||
const oauthProvider = account?.oauthProvider
|
||||
if (oauthProvider !== OAUTH_PROVIDER_ANTIGRAVITY) {
|
||||
if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
|
||||
return this.readQuotaFromFields(account)
|
||||
}
|
||||
return { balance: null, currency: 'USD', queryMethod: 'local' }
|
||||
}
|
||||
|
||||
const accessToken = String(account?.accessToken || '').trim()
|
||||
const refreshToken = String(account?.refreshToken || '').trim()
|
||||
const proxyConfig = account?.proxyConfig || account?.proxy || null
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('Antigravity 账户缺少 accessToken')
|
||||
}
|
||||
|
||||
const fetch = async (token) =>
|
||||
await antigravityClient.fetchAvailableModels({
|
||||
accessToken: token,
|
||||
proxyConfig
|
||||
})
|
||||
|
||||
let data
|
||||
try {
|
||||
data = await fetch(accessToken)
|
||||
} catch (error) {
|
||||
const status = error?.response?.status
|
||||
if ((status === 401 || status === 403) && refreshToken) {
|
||||
const refreshed = await geminiAccountService.refreshAccessToken(
|
||||
refreshToken,
|
||||
proxyConfig,
|
||||
OAUTH_PROVIDER_ANTIGRAVITY
|
||||
)
|
||||
const nextToken = String(refreshed?.access_token || '').trim()
|
||||
if (!nextToken) {
|
||||
throw error
|
||||
}
|
||||
data = await fetch(nextToken)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const mapped = buildAntigravityQuota(data)
|
||||
if (!mapped) {
|
||||
return {
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
quota: null,
|
||||
queryMethod: 'api',
|
||||
rawData: data || null
|
||||
}
|
||||
}
|
||||
|
||||
return mapped
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GeminiBalanceProvider
|
||||
23
src/services/balanceProviders/genericBalanceProvider.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||
|
||||
class GenericBalanceProvider extends BaseBalanceProvider {
|
||||
constructor(platform) {
|
||||
super(platform)
|
||||
}
|
||||
|
||||
async queryBalance(account) {
|
||||
this.logger.debug(`${this.platform} 暂无专用余额 API,实现降级策略`)
|
||||
|
||||
if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
|
||||
return this.readQuotaFromFields(account)
|
||||
}
|
||||
|
||||
return {
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
queryMethod: 'local'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GenericBalanceProvider
|
||||
25
src/services/balanceProviders/index.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const ClaudeBalanceProvider = require('./claudeBalanceProvider')
|
||||
const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider')
|
||||
const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider')
|
||||
const GenericBalanceProvider = require('./genericBalanceProvider')
|
||||
const GeminiBalanceProvider = require('./geminiBalanceProvider')
|
||||
|
||||
function registerAllProviders(balanceService) {
|
||||
// Claude
|
||||
balanceService.registerProvider('claude', new ClaudeBalanceProvider())
|
||||
balanceService.registerProvider('claude-console', new ClaudeConsoleBalanceProvider())
|
||||
|
||||
// OpenAI / Codex
|
||||
balanceService.registerProvider('openai-responses', new OpenAIResponsesBalanceProvider())
|
||||
balanceService.registerProvider('openai', new GenericBalanceProvider('openai'))
|
||||
balanceService.registerProvider('azure_openai', new GenericBalanceProvider('azure_openai'))
|
||||
|
||||
// 其他平台(降级)
|
||||
balanceService.registerProvider('gemini', new GeminiBalanceProvider())
|
||||
balanceService.registerProvider('gemini-api', new GenericBalanceProvider('gemini-api'))
|
||||
balanceService.registerProvider('bedrock', new GenericBalanceProvider('bedrock'))
|
||||
balanceService.registerProvider('droid', new GenericBalanceProvider('droid'))
|
||||
balanceService.registerProvider('ccr', new GenericBalanceProvider('ccr'))
|
||||
}
|
||||
|
||||
module.exports = { registerAllProviders }
|
||||
@@ -0,0 +1,54 @@
|
||||
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||
|
||||
class OpenAIResponsesBalanceProvider extends BaseBalanceProvider {
|
||||
constructor() {
|
||||
super('openai-responses')
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-Responses:
|
||||
* - 优先使用 dailyQuota 字段(如果配置了额度)
|
||||
* - 可选:尝试调用兼容 API(不同服务商实现不一,失败自动降级)
|
||||
*/
|
||||
async queryBalance(account) {
|
||||
this.logger.debug(`查询 OpenAI Responses 余额: ${account?.id}`)
|
||||
|
||||
// 配置了额度时直接返回(字段法)
|
||||
if (account?.dailyQuota && Number(account.dailyQuota) > 0) {
|
||||
return this.readQuotaFromFields(account)
|
||||
}
|
||||
|
||||
// 尝试调用 usage 接口(兼容性不保证)
|
||||
if (account?.apiKey && account?.baseApi) {
|
||||
const baseApi = String(account.baseApi).replace(/\/$/, '')
|
||||
const response = await this.makeRequest(
|
||||
`${baseApi}/v1/usage`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${account.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
},
|
||||
account
|
||||
)
|
||||
|
||||
if (response.success) {
|
||||
return {
|
||||
balance: null,
|
||||
currency: this.parseCurrency(response.data),
|
||||
queryMethod: 'api',
|
||||
rawData: response.data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
queryMethod: 'local'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OpenAIResponsesBalanceProvider
|
||||
210
src/services/balanceScriptService.js
Normal file
@@ -0,0 +1,210 @@
|
||||
const vm = require('vm')
|
||||
const axios = require('axios')
|
||||
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
||||
|
||||
/**
|
||||
* SSRF防护:检查URL是否访问内网或敏感地址
|
||||
* @param {string} url - 要检查的URL
|
||||
* @returns {boolean} - true表示URL安全
|
||||
*/
|
||||
function isUrlSafe(url) {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const hostname = parsed.hostname.toLowerCase()
|
||||
|
||||
// 禁止的协议
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁止访问localhost和私有IP
|
||||
const privatePatterns = [
|
||||
/^localhost$/i,
|
||||
/^127\./,
|
||||
/^10\./,
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
||||
/^192\.168\./,
|
||||
/^169\.254\./, // AWS metadata
|
||||
/^0\./, // 0.0.0.0
|
||||
/^::1$/,
|
||||
/^fc00:/i,
|
||||
/^fe80:/i,
|
||||
/\.local$/i,
|
||||
/\.internal$/i,
|
||||
/\.localhost$/i
|
||||
]
|
||||
|
||||
for (const pattern of privatePatterns) {
|
||||
if (pattern.test(hostname)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可配置脚本余额查询执行器
|
||||
* - 脚本格式:({ request: {...}, extractor: function(response){...} })
|
||||
* - 模板变量:{{baseUrl}}, {{apiKey}}, {{token}}, {{accountId}}, {{platform}}, {{extra}}
|
||||
*/
|
||||
class BalanceScriptService {
|
||||
/**
|
||||
* 执行脚本:返回标准余额结构 + 原始响应
|
||||
* @param {object} options
|
||||
* - scriptBody: string
|
||||
* - variables: Record<string,string>
|
||||
* - timeoutSeconds: number
|
||||
*/
|
||||
async execute(options = {}) {
|
||||
if (!isBalanceScriptEnabled()) {
|
||||
const error = new Error('余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)')
|
||||
error.code = 'BALANCE_SCRIPT_DISABLED'
|
||||
throw error
|
||||
}
|
||||
|
||||
const scriptBody = options.scriptBody?.trim()
|
||||
if (!scriptBody) {
|
||||
throw new Error('脚本内容为空')
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(1, (options.timeoutSeconds || 10) * 1000)
|
||||
const sandbox = {
|
||||
console,
|
||||
Math,
|
||||
Date
|
||||
}
|
||||
|
||||
let scriptResult
|
||||
try {
|
||||
const wrapped = scriptBody.startsWith('(') ? scriptBody : `(${scriptBody})`
|
||||
const script = new vm.Script(wrapped)
|
||||
scriptResult = script.runInNewContext(sandbox, { timeout: timeoutMs })
|
||||
} catch (error) {
|
||||
throw new Error(`脚本解析失败: ${error.message}`)
|
||||
}
|
||||
|
||||
if (!scriptResult || typeof scriptResult !== 'object') {
|
||||
throw new Error('脚本返回格式无效(需返回 { request, extractor })')
|
||||
}
|
||||
|
||||
const variables = options.variables || {}
|
||||
const request = this.applyTemplates(scriptResult.request || {}, variables)
|
||||
const { extractor } = scriptResult
|
||||
|
||||
if (!request?.url || typeof request.url !== 'string') {
|
||||
throw new Error('脚本 request.url 不能为空')
|
||||
}
|
||||
|
||||
// SSRF防护:验证URL安全性
|
||||
if (!isUrlSafe(request.url)) {
|
||||
throw new Error('脚本 request.url 不安全:禁止访问内网地址、localhost或使用非HTTP(S)协议')
|
||||
}
|
||||
|
||||
if (typeof extractor !== 'function') {
|
||||
throw new Error('脚本 extractor 必须是函数')
|
||||
}
|
||||
|
||||
const axiosConfig = {
|
||||
url: request.url,
|
||||
method: (request.method || 'GET').toUpperCase(),
|
||||
headers: request.headers || {},
|
||||
timeout: timeoutMs
|
||||
}
|
||||
|
||||
if (request.params) {
|
||||
axiosConfig.params = request.params
|
||||
}
|
||||
if (request.body || request.data) {
|
||||
axiosConfig.data = request.body || request.data
|
||||
}
|
||||
|
||||
let httpResponse
|
||||
try {
|
||||
httpResponse = await axios(axiosConfig)
|
||||
} catch (error) {
|
||||
const { response } = error || {}
|
||||
const { status, data } = response || {}
|
||||
throw new Error(
|
||||
`请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
const responseData = httpResponse?.data
|
||||
|
||||
let extracted = {}
|
||||
try {
|
||||
extracted = extractor(responseData) || {}
|
||||
} catch (error) {
|
||||
throw new Error(`extractor 执行失败: ${error.message}`)
|
||||
}
|
||||
|
||||
const mapped = this.mapExtractorResult(extracted, responseData)
|
||||
return {
|
||||
mapped,
|
||||
extracted,
|
||||
response: {
|
||||
status: httpResponse?.status,
|
||||
headers: httpResponse?.headers,
|
||||
data: responseData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyTemplates(value, variables) {
|
||||
if (typeof value === 'string') {
|
||||
return value.replace(/{{(\w+)}}/g, (_, key) => {
|
||||
const trimmed = key.trim()
|
||||
return variables[trimmed] !== undefined ? String(variables[trimmed]) : ''
|
||||
})
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.applyTemplates(item, variables))
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const result = {}
|
||||
Object.keys(value).forEach((k) => {
|
||||
result[k] = this.applyTemplates(value[k], variables)
|
||||
})
|
||||
return result
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
mapExtractorResult(result = {}, responseData) {
|
||||
const isValid = result.isValid !== false
|
||||
const remaining = Number(result.remaining)
|
||||
const total = Number(result.total)
|
||||
const used = Number(result.used)
|
||||
const currency = result.unit || 'USD'
|
||||
|
||||
const quota =
|
||||
Number.isFinite(total) || Number.isFinite(used)
|
||||
? {
|
||||
total: Number.isFinite(total) ? total : null,
|
||||
used: Number.isFinite(used) ? used : null,
|
||||
remaining: Number.isFinite(remaining) ? remaining : null,
|
||||
percentage:
|
||||
Number.isFinite(total) && total > 0 && Number.isFinite(used)
|
||||
? (used / total) * 100
|
||||
: null
|
||||
}
|
||||
: null
|
||||
|
||||
return {
|
||||
status: isValid ? 'success' : 'error',
|
||||
errorMessage: isValid ? '' : result.invalidMessage || '套餐无效',
|
||||
balance: Number.isFinite(remaining) ? remaining : null,
|
||||
currency,
|
||||
quota,
|
||||
planName: result.planName || null,
|
||||
extra: result.extra || null,
|
||||
rawData: responseData || result.raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new BalanceScriptService()
|
||||
@@ -35,12 +35,13 @@ class BedrockAccountService {
|
||||
description = '',
|
||||
region = process.env.AWS_REGION || 'us-east-1',
|
||||
awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken }
|
||||
bearerToken = null, // AWS Bearer Token for Bedrock API Keys
|
||||
defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||
isActive = true,
|
||||
accountType = 'shared', // 'dedicated' or 'shared'
|
||||
priority = 50, // 调度优先级 (1-100,数字越小优先级越高)
|
||||
schedulable = true, // 是否可被调度
|
||||
credentialType = 'default' // 'default', 'access_key', 'bearer_token'
|
||||
credentialType = 'access_key' // 'access_key', 'bearer_token'(默认为 access_key)
|
||||
} = options
|
||||
|
||||
const accountId = uuidv4()
|
||||
@@ -56,6 +57,11 @@ class BedrockAccountService {
|
||||
priority,
|
||||
schedulable,
|
||||
credentialType,
|
||||
|
||||
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||
// 注意:Bedrock 使用 AWS 凭证,没有 OAuth token,因此没有 expiresAt
|
||||
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
|
||||
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
type: 'bedrock' // 标识这是Bedrock账户
|
||||
@@ -66,6 +72,11 @@ class BedrockAccountService {
|
||||
accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials)
|
||||
}
|
||||
|
||||
// 加密存储 Bearer Token
|
||||
if (bearerToken) {
|
||||
accountData.bearerToken = this._encryptAwsCredentials({ token: bearerToken })
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData))
|
||||
|
||||
@@ -101,9 +112,85 @@ class BedrockAccountService {
|
||||
|
||||
const account = JSON.parse(accountData)
|
||||
|
||||
// 解密AWS凭证用于内部使用
|
||||
if (account.awsCredentials) {
|
||||
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||
// 根据凭证类型解密对应的凭证
|
||||
// 增强逻辑:优先按照 credentialType 解密,如果字段不存在则尝试解密实际存在的字段(兜底)
|
||||
try {
|
||||
let accessKeyDecrypted = false
|
||||
let bearerTokenDecrypted = false
|
||||
|
||||
// 第一步:按照 credentialType 尝试解密对应的凭证
|
||||
if (account.credentialType === 'access_key' && account.awsCredentials) {
|
||||
// Access Key 模式:解密 AWS 凭证
|
||||
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||
accessKeyDecrypted = true
|
||||
logger.debug(
|
||||
`🔓 解密 Access Key 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
|
||||
)
|
||||
} else if (account.credentialType === 'bearer_token' && account.bearerToken) {
|
||||
// Bearer Token 模式:解密 Bearer Token
|
||||
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||
account.bearerToken = decrypted.token
|
||||
bearerTokenDecrypted = true
|
||||
logger.debug(
|
||||
`🔓 解密 Bearer Token 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
|
||||
)
|
||||
} else if (!account.credentialType || account.credentialType === 'default') {
|
||||
// 向后兼容:旧版本账号可能没有 credentialType 字段,尝试解密所有存在的凭证
|
||||
if (account.awsCredentials) {
|
||||
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||
accessKeyDecrypted = true
|
||||
}
|
||||
if (account.bearerToken) {
|
||||
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||
account.bearerToken = decrypted.token
|
||||
bearerTokenDecrypted = true
|
||||
}
|
||||
logger.debug(
|
||||
`🔓 兼容模式解密 - ID: ${accountId}, Access Key: ${accessKeyDecrypted}, Bearer Token: ${bearerTokenDecrypted}`
|
||||
)
|
||||
}
|
||||
|
||||
// 第二步:兜底逻辑 - 如果按照 credentialType 没有解密到任何凭证,尝试解密实际存在的字段
|
||||
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
|
||||
logger.warn(
|
||||
`⚠️ credentialType="${account.credentialType}" 与实际字段不匹配,尝试兜底解密 - ID: ${accountId}`
|
||||
)
|
||||
if (account.awsCredentials) {
|
||||
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||
accessKeyDecrypted = true
|
||||
logger.warn(
|
||||
`🔓 兜底解密 Access Key 成功 - ID: ${accountId}, credentialType 应为 'access_key'`
|
||||
)
|
||||
}
|
||||
if (account.bearerToken) {
|
||||
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||
account.bearerToken = decrypted.token
|
||||
bearerTokenDecrypted = true
|
||||
logger.warn(
|
||||
`🔓 兜底解密 Bearer Token 成功 - ID: ${accountId}, credentialType 应为 'bearer_token'`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证至少解密了一种凭证
|
||||
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
|
||||
logger.error(
|
||||
`❌ 未找到任何凭证可解密 - ID: ${accountId}, credentialType: ${account.credentialType}, hasAwsCredentials: ${!!account.awsCredentials}, hasBearerToken: ${!!account.bearerToken}`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: 'No valid credentials found in account data'
|
||||
}
|
||||
}
|
||||
} catch (decryptError) {
|
||||
logger.error(
|
||||
`❌ 解密Bedrock凭证失败 - ID: ${accountId}, 类型: ${account.credentialType}`,
|
||||
decryptError
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: `Credentials decryption failed: ${decryptError.message}`
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
|
||||
@@ -142,10 +229,19 @@ class BedrockAccountService {
|
||||
priority: account.priority,
|
||||
schedulable: account.schedulable,
|
||||
credentialType: account.credentialType,
|
||||
|
||||
// ✅ 前端显示订阅过期时间(业务字段)
|
||||
expiresAt: account.subscriptionExpiresAt || null,
|
||||
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
type: 'bedrock',
|
||||
hasCredentials: !!account.awsCredentials
|
||||
platform: 'bedrock',
|
||||
// 根据凭证类型判断是否有凭证
|
||||
hasCredentials:
|
||||
account.credentialType === 'bearer_token'
|
||||
? !!account.bearerToken
|
||||
: !!account.awsCredentials
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -225,6 +321,21 @@ class BedrockAccountService {
|
||||
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
|
||||
}
|
||||
|
||||
// 更新 Bearer Token
|
||||
if (updates.bearerToken !== undefined) {
|
||||
if (updates.bearerToken) {
|
||||
account.bearerToken = this._encryptAwsCredentials({ token: updates.bearerToken })
|
||||
} else {
|
||||
delete account.bearerToken
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||
// Bedrock 没有 token 刷新逻辑,不会覆盖此字段
|
||||
if (updates.subscriptionExpiresAt !== undefined) {
|
||||
account.subscriptionExpiresAt = updates.subscriptionExpiresAt
|
||||
}
|
||||
|
||||
account.updatedAt = new Date().toISOString()
|
||||
|
||||
await client.set(`bedrock_account:${accountId}`, JSON.stringify(account))
|
||||
@@ -282,9 +393,17 @@ class BedrockAccountService {
|
||||
return { success: false, error: 'Failed to get accounts' }
|
||||
}
|
||||
|
||||
const availableAccounts = accountsResult.data.filter(
|
||||
(account) => account.isActive && account.schedulable
|
||||
)
|
||||
const availableAccounts = accountsResult.data.filter((account) => {
|
||||
// ✅ 检查账户订阅是否过期
|
||||
if (this.isSubscriptionExpired(account)) {
|
||||
logger.debug(
|
||||
`⏰ Skipping expired Bedrock account: ${account.name}, expired at ${account.subscriptionExpiresAt || account.expiresAt}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
return account.isActive && account.schedulable
|
||||
})
|
||||
|
||||
if (availableAccounts.length === 0) {
|
||||
return { success: false, error: 'No available Bedrock accounts' }
|
||||
@@ -321,13 +440,45 @@ class BedrockAccountService {
|
||||
|
||||
const account = accountResult.data
|
||||
|
||||
logger.info(`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}`)
|
||||
logger.info(
|
||||
`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}, 凭证类型: ${account.credentialType}`
|
||||
)
|
||||
|
||||
// 尝试获取模型列表来测试连接
|
||||
// 验证凭证是否已解密
|
||||
const hasValidCredentials =
|
||||
(account.credentialType === 'access_key' && account.awsCredentials) ||
|
||||
(account.credentialType === 'bearer_token' && account.bearerToken) ||
|
||||
(!account.credentialType && (account.awsCredentials || account.bearerToken))
|
||||
|
||||
if (!hasValidCredentials) {
|
||||
logger.error(
|
||||
`❌ 测试失败:账户没有有效凭证 - ID: ${accountId}, credentialType: ${account.credentialType}`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: 'No valid credentials found after decryption'
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试创建 Bedrock 客户端来验证凭证格式
|
||||
try {
|
||||
bedrockRelayService._getBedrockClient(account.region, account)
|
||||
logger.debug(`✅ Bedrock客户端创建成功 - ID: ${accountId}`)
|
||||
} catch (clientError) {
|
||||
logger.error(`❌ 创建Bedrock客户端失败 - ID: ${accountId}`, clientError)
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to create Bedrock client: ${clientError.message}`
|
||||
}
|
||||
}
|
||||
|
||||
// 获取可用模型列表(硬编码,但至少验证了凭证格式正确)
|
||||
const models = await bedrockRelayService.getAvailableModels(account)
|
||||
|
||||
if (models && models.length > 0) {
|
||||
logger.info(`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型`)
|
||||
logger.info(
|
||||
`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型, 凭证类型: ${account.credentialType}`
|
||||
)
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -352,6 +503,148 @@ class BedrockAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧪 测试 Bedrock 账户连接(SSE 流式返回,供前端测试页面使用)
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {Object} res - Express response 对象
|
||||
* @param {string} model - 测试使用的模型
|
||||
*/
|
||||
async testAccountConnection(accountId, res, model = null) {
|
||||
const { InvokeModelWithResponseStreamCommand } = require('@aws-sdk/client-bedrock-runtime')
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const accountResult = await this.getAccount(accountId)
|
||||
if (!accountResult.success) {
|
||||
throw new Error(accountResult.error || 'Account not found')
|
||||
}
|
||||
|
||||
const account = accountResult.data
|
||||
|
||||
// 根据账户类型选择合适的测试模型
|
||||
if (!model) {
|
||||
// Access Key 模式使用 Haiku(更快更便宜)
|
||||
model = account.defaultModel || 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🧪 Testing Bedrock account connection: ${account.name} (${accountId}), model: ${model}, credentialType: ${account.credentialType}`
|
||||
)
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.setHeader('X-Accel-Buffering', 'no')
|
||||
res.status(200)
|
||||
|
||||
// 发送 test_start 事件
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_start' })}\n\n`)
|
||||
|
||||
// 构造测试请求体(Bedrock 格式)
|
||||
const bedrockPayload = {
|
||||
anthropic_version: 'bedrock-2023-05-31',
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'Hello! Please respond with a simple greeting to confirm the connection is working. And tell me who are you?'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 获取 Bedrock 客户端
|
||||
const region = account.region || bedrockRelayService.defaultRegion
|
||||
const client = bedrockRelayService._getBedrockClient(region, account)
|
||||
|
||||
// 创建流式调用命令
|
||||
const command = new InvokeModelWithResponseStreamCommand({
|
||||
modelId: model,
|
||||
body: JSON.stringify(bedrockPayload),
|
||||
contentType: 'application/json',
|
||||
accept: 'application/json'
|
||||
})
|
||||
|
||||
logger.debug(`🌊 Bedrock test stream - model: ${model}, region: ${region}`)
|
||||
|
||||
const startTime = Date.now()
|
||||
const response = await client.send(command)
|
||||
|
||||
// 处理流式响应
|
||||
// let responseText = ''
|
||||
for await (const chunk of response.body) {
|
||||
if (chunk.chunk) {
|
||||
const chunkData = JSON.parse(new TextDecoder().decode(chunk.chunk.bytes))
|
||||
|
||||
// 提取文本内容
|
||||
if (chunkData.type === 'content_block_delta' && chunkData.delta?.text) {
|
||||
const { text } = chunkData.delta
|
||||
// responseText += text
|
||||
|
||||
// 发送 content 事件
|
||||
res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
|
||||
}
|
||||
|
||||
// 检测错误
|
||||
if (chunkData.type === 'error') {
|
||||
throw new Error(chunkData.error?.message || 'Bedrock API error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
logger.info(`✅ Bedrock test completed - model: ${model}, duration: ${duration}ms`)
|
||||
|
||||
// 发送 message_stop 事件(前端兼容)
|
||||
res.write(`data: ${JSON.stringify({ type: 'message_stop' })}\n\n`)
|
||||
|
||||
// 发送 test_complete 事件
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
|
||||
|
||||
// 结束响应
|
||||
res.end()
|
||||
|
||||
logger.info(`✅ Test request completed for Bedrock account: ${account.name}`)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Test Bedrock account connection failed:`, error)
|
||||
|
||||
// 发送错误事件给前端
|
||||
try {
|
||||
// 检查响应流是否仍然可写
|
||||
if (!res.writableEnded && !res.destroyed) {
|
||||
if (!res.headersSent) {
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.status(200)
|
||||
}
|
||||
const errorMsg = error.message || '测试失败'
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
|
||||
res.end()
|
||||
}
|
||||
} catch (writeError) {
|
||||
logger.error('Failed to write error to response stream:', writeError)
|
||||
}
|
||||
|
||||
// 不再重新抛出错误,避免路由层再次处理
|
||||
// throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户订阅是否过期
|
||||
* @param {Object} account - 账户对象
|
||||
* @returns {boolean} - true: 已过期, false: 未过期
|
||||
*/
|
||||
isSubscriptionExpired(account) {
|
||||
if (!account.subscriptionExpiresAt) {
|
||||
return false // 未设置视为永不过期
|
||||
}
|
||||
const expiryDate = new Date(account.subscriptionExpiresAt)
|
||||
return expiryDate <= new Date()
|
||||
}
|
||||
|
||||
// 🔑 生成加密密钥(缓存优化)
|
||||
_generateEncryptionKey() {
|
||||
if (!this._encryptionKeyCache) {
|
||||
|
||||