Compare commits
601 Commits
revert-424
...
v1.1.240
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
792ba51290 | ||
|
|
74d138a2fb | ||
|
|
b88698191e | ||
|
|
11c38b23d1 | ||
|
|
b2dfc2eb25 | ||
|
|
59ce0f091c | ||
|
|
67c20fa30e | ||
|
|
671451253f | ||
|
|
0173ab224b | ||
|
|
11fb77c8bd | ||
|
|
3d67f0b124 | ||
|
|
84f19b348b | ||
|
|
8ec8a59b07 | ||
|
|
00d8ac4bec | ||
|
|
5863816882 | ||
|
|
638d2ff189 | ||
|
|
fa2fc2fb16 | ||
|
|
6d56601550 | ||
|
|
dd8a0c95c3 | ||
|
|
126eee3712 | ||
|
|
26bfdd6892 | ||
|
|
cd3f51e9e2 | ||
|
|
9977245d59 | ||
|
|
09cf951cdc | ||
|
|
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 |
@@ -15,6 +15,7 @@ logs/
|
||||
# Data files
|
||||
data/
|
||||
temp/
|
||||
redis_data/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
|
||||
20
.env.example
@@ -37,15 +37,33 @@ CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-20
|
||||
# 启用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=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
|
||||
|
||||
@@ -60,6 +78,8 @@ TOKEN_USAGE_RETENTION=2592000000
|
||||
HEALTH_CHECK_INTERVAL=60000
|
||||
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
|
||||
|
||||
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
|
||||
1
.gitignore
vendored
@@ -26,6 +26,7 @@ redis_data/
|
||||
|
||||
# Logs directory
|
||||
logs/
|
||||
logs1/
|
||||
*.log
|
||||
startup.log
|
||||
app.log
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Ignore frontend directory as it has its own prettier config and plugins
|
||||
web/admin-spa/
|
||||
|
||||
# Ignore node_modules
|
||||
node_modules/
|
||||
|
||||
# Ignore build outputs
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
|
||||
# Ignore logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Ignore temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
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
|
||||
|
||||
410
README.md
@@ -11,16 +11,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 +49,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 +78,6 @@
|
||||
|
||||
## 🚀 核心功能
|
||||
|
||||
> 📸 **[点击查看界面预览](docs/preview.md)** - 查看Web管理界面的详细截图
|
||||
|
||||
### 基础功能
|
||||
|
||||
- ✅ **多账户管理**: 可以添加多个Claude账户自动轮换
|
||||
@@ -134,13 +126,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
|
||||
```
|
||||
|
||||
### 脚本功能
|
||||
@@ -298,60 +284,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
|
||||
```
|
||||
|
||||
@@ -364,7 +305,6 @@ docker-compose.yml 已包含:
|
||||
- ✅ Redis数据库
|
||||
- ✅ 健康检查
|
||||
- ✅ 自动重启
|
||||
- ✅ 所有配置通过环境变量管理
|
||||
|
||||
### 环境变量说明
|
||||
|
||||
@@ -449,19 +389,54 @@ docker-compose.yml 已包含:
|
||||
|
||||
**Claude Code 设置环境变量:**
|
||||
|
||||
默认使用标准 Claude 账号池:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
|
||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||
```
|
||||
|
||||
**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
|
||||
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"
|
||||
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
|
||||
@@ -476,11 +451,11 @@ gemini # 或其他 Gemini CLI 命令
|
||||
|
||||
**Codex 配置:**
|
||||
|
||||
在 `~/.codex/config.toml` 文件中添加以下配置:
|
||||
在 `~/.codex/config.toml` 文件**开头**添加以下配置:
|
||||
|
||||
```toml
|
||||
model_provider = "crs"
|
||||
model = "gpt-5"
|
||||
model = "gpt-5.1-codex-max"
|
||||
model_reasoning_effort = "high"
|
||||
disable_response_storage = true
|
||||
preferred_auth_method = "apikey"
|
||||
@@ -489,16 +464,71 @@ preferred_auth_method = "apikey"
|
||||
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密钥:
|
||||
在 `~/.codex/auth.json` 文件中配置API密钥为 null:
|
||||
|
||||
```json
|
||||
{
|
||||
"OPENAI_API_KEY": "你的后台创建的API密钥"
|
||||
"OPENAI_API_KEY": null
|
||||
}
|
||||
```
|
||||
|
||||
环境变量设置:
|
||||
|
||||
```bash
|
||||
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等)。
|
||||
@@ -511,23 +541,23 @@ Cherry Studio支持多种AI服务的接入,下面是不同账号类型的详
|
||||
|
||||
```
|
||||
# API地址
|
||||
http://你的服务器:3000/claude/
|
||||
http://你的服务器:3000/claude
|
||||
|
||||
# 模型ID示例
|
||||
claude-sonnet-4-20250514 # Claude Sonnet 4
|
||||
claude-sonnet-4-5-20250929 # Claude Sonnet 4.5
|
||||
claude-opus-4-20250514 # Claude Opus 4
|
||||
```
|
||||
|
||||
配置步骤:
|
||||
- 供应商类型选择"Anthropic"
|
||||
- API地址填入:`http://你的服务器:3000/claude/`
|
||||
- API地址填入:`http://你的服务器:3000/claude`
|
||||
- API Key填入:后台创建的API密钥(cr_开头)
|
||||
|
||||
**2. Gemini账号接入:**
|
||||
|
||||
```
|
||||
# API地址
|
||||
http://你的服务器:3000/gemini/
|
||||
http://你的服务器:3000/gemini
|
||||
|
||||
# 模型ID示例
|
||||
gemini-2.5-pro # Gemini 2.5 Pro
|
||||
@@ -535,14 +565,14 @@ gemini-2.5-pro # Gemini 2.5 Pro
|
||||
|
||||
配置步骤:
|
||||
- 供应商类型选择"Gemini"
|
||||
- API地址填入:`http://你的服务器:3000/gemini/`
|
||||
- API地址填入:`http://你的服务器:3000/gemini`
|
||||
- API Key填入:后台创建的API密钥(cr_开头)
|
||||
|
||||
**3. Codex接入:**
|
||||
|
||||
```
|
||||
# API地址
|
||||
http://你的服务器:3000/openai/
|
||||
http://你的服务器:3000/openai
|
||||
|
||||
# 模型ID(固定)
|
||||
gpt-5 # Codex使用固定模型ID
|
||||
@@ -550,10 +580,18 @@ gpt-5 # Codex使用固定模型ID
|
||||
|
||||
配置步骤:
|
||||
- 供应商类型选择"Openai-Response"
|
||||
- API地址填入:`http://你的服务器:3000/openai/`
|
||||
- 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 版本)
|
||||
|
||||
#### 其他第三方工具接入
|
||||
|
||||
**接入要点:**
|
||||
@@ -561,8 +599,10 @@ gpt-5 # Codex使用固定模型ID
|
||||
- 所有账号类型都使用相同的API密钥(在后台统一创建)
|
||||
- 根据不同的路由前缀自动识别账号类型
|
||||
- `/claude/` - 使用Claude账号池
|
||||
- `/droid/claude/` - 使用Droid类型Claude账号池(只建议api调用或Droid Cli中使用)
|
||||
- `/gemini/` - 使用Gemini账号池
|
||||
- `/openai/` - 使用Codex账号(只支持Openai-Response格式)
|
||||
- `/droid/openai/` - 使用Droid类型OpenAI兼容账号池(只建议api调用或Droid Cli中使用)
|
||||
- 支持所有标准API端点(messages、models等)
|
||||
|
||||
**重要说明:**
|
||||
@@ -655,23 +695,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
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 日志示例
|
||||
|
||||
@@ -718,13 +741,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
|
||||
@@ -740,23 +767,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
|
||||
@@ -774,42 +801,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 容器部署
|
||||
|
||||
---
|
||||
|
||||
上述两种方案均可用于生产部署。
|
||||
|
||||
---
|
||||
|
||||
@@ -847,6 +964,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)。
|
||||
|
||||
219
README_EN.md
@@ -9,7 +9,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 +30,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 +232,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 +374,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 +400,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 +435,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**.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -73,6 +73,30 @@ const config = {
|
||||
proxy: {
|
||||
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
|
||||
},
|
||||
@@ -113,38 +137,6 @@ 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
|
||||
}
|
||||
// 添加自定义客户端示例:
|
||||
// {
|
||||
// id: 'custom_client',
|
||||
// name: 'My Custom Client',
|
||||
// description: 'My custom API client',
|
||||
// userAgentPattern: /^MyClient\/[\d\.]+/i
|
||||
// }
|
||||
],
|
||||
// 是否允许自定义客户端(未来功能)
|
||||
allowCustomClients: process.env.ALLOW_CUSTOM_CLIENTS === 'true'
|
||||
},
|
||||
|
||||
// 🔐 LDAP 认证配置
|
||||
ldap: {
|
||||
enabled: process.env.LDAP_ENABLED === 'true',
|
||||
@@ -211,6 +203,15 @@ const config = {
|
||||
development: {
|
||||
debug: process.env.DEBUG === 'true',
|
||||
hotReload: process.env.HOT_RELOAD === 'true'
|
||||
},
|
||||
|
||||
// 📬 用户消息队列配置
|
||||
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 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 |
106
package-lock.json
generated
@@ -26,10 +26,12 @@
|
||||
"ioredis": "^5.3.2",
|
||||
"ldapjs": "^3.0.7",
|
||||
"morgan": "^1.10.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",
|
||||
@@ -43,6 +45,7 @@
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -889,6 +892,7 @@
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -2997,6 +3001,7 @@
|
||||
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3078,6 +3083,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3533,6 +3539,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001737",
|
||||
"electron-to-chromium": "^1.5.211",
|
||||
@@ -4420,6 +4427,7 @@
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -4476,6 +4484,7 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -7026,6 +7035,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
@@ -7574,6 +7592,7 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -7597,6 +7616,85 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-tailwindcss": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz",
|
||||
"integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "*",
|
||||
"@prettier/plugin-hermes": "*",
|
||||
"@prettier/plugin-oxc": "*",
|
||||
"@prettier/plugin-pug": "*",
|
||||
"@shopify/prettier-plugin-liquid": "*",
|
||||
"@trivago/prettier-plugin-sort-imports": "*",
|
||||
"@zackad/prettier-plugin-twig": "*",
|
||||
"prettier": "^3.0",
|
||||
"prettier-plugin-astro": "*",
|
||||
"prettier-plugin-css-order": "*",
|
||||
"prettier-plugin-jsdoc": "*",
|
||||
"prettier-plugin-marko": "*",
|
||||
"prettier-plugin-multiline-arrays": "*",
|
||||
"prettier-plugin-organize-attributes": "*",
|
||||
"prettier-plugin-organize-imports": "*",
|
||||
"prettier-plugin-sort-imports": "*",
|
||||
"prettier-plugin-svelte": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@ianvs/prettier-plugin-sort-imports": {
|
||||
"optional": true
|
||||
},
|
||||
"@prettier/plugin-hermes": {
|
||||
"optional": true
|
||||
},
|
||||
"@prettier/plugin-oxc": {
|
||||
"optional": true
|
||||
},
|
||||
"@prettier/plugin-pug": {
|
||||
"optional": true
|
||||
},
|
||||
"@shopify/prettier-plugin-liquid": {
|
||||
"optional": true
|
||||
},
|
||||
"@trivago/prettier-plugin-sort-imports": {
|
||||
"optional": true
|
||||
},
|
||||
"@zackad/prettier-plugin-twig": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-astro": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-css-order": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-jsdoc": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-marko": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-multiline-arrays": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-organize-attributes": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-organize-imports": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-sort-imports": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-svelte": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz",
|
||||
@@ -8425,6 +8523,13 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/string-similarity": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/string-similarity/-/string-similarity-4.0.4.tgz",
|
||||
"integrity": "sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
|
||||
@@ -9006,6 +9111,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
|
||||
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.2",
|
||||
|
||||
@@ -65,10 +65,12 @@
|
||||
"ioredis": "^5.3.2",
|
||||
"ldapjs": "^3.0.7",
|
||||
"morgan": "^1.10.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",
|
||||
@@ -82,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
|
||||
|
||||
@@ -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
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1631,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
|
||||
}
|
||||
|
||||
@@ -1754,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()
|
||||
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)
|
||||
|
||||
190
src/app.js
@@ -14,6 +14,7 @@ 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')
|
||||
@@ -22,6 +23,7 @@ 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')
|
||||
@@ -54,6 +56,11 @@ class Application {
|
||||
logger.info('🔄 Initializing pricing service...')
|
||||
await pricingService.initialize()
|
||||
|
||||
// 📋 初始化模型服务
|
||||
logger.info('🔄 Initializing model service...')
|
||||
const modelService = require('./services/modelService')
|
||||
await modelService.initialize()
|
||||
|
||||
// 📊 初始化缓存监控
|
||||
await this.initializeCacheMonitoring()
|
||||
|
||||
@@ -78,6 +85,11 @@ class Application {
|
||||
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') {
|
||||
@@ -250,6 +262,7 @@ class Application {
|
||||
|
||||
// 🛣️ 路由
|
||||
this.app.use('/api', apiRoutes)
|
||||
this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等)
|
||||
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
|
||||
this.app.use('/admin', adminRoutes)
|
||||
this.app.use('/users', userRoutes)
|
||||
@@ -261,7 +274,10 @@ class Application {
|
||||
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)
|
||||
|
||||
@@ -553,6 +569,127 @@ class Application {
|
||||
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() {
|
||||
@@ -571,6 +708,15 @@ 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')
|
||||
@@ -580,6 +726,48 @@ class Application {
|
||||
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')
|
||||
|
||||
2586
src/handlers/geminiHandlers.js
Normal file
@@ -7,12 +7,38 @@ const logger = require('../utils/logger')
|
||||
const browserFallbackMiddleware = (req, res, next) => {
|
||||
const userAgent = req.headers['user-agent'] || ''
|
||||
const origin = req.headers['origin'] || ''
|
||||
const authHeader = req.headers['authorization'] || req.headers['x-api-key'] || ''
|
||||
|
||||
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 = authHeader.startsWith('cr_') // 我们的API Key格式
|
||||
const hasApiKey = normalizedKey.startsWith('cr_') // 我们的API Key格式
|
||||
|
||||
if ((isChromeExtension || isBrowserRequest) && hasApiKey) {
|
||||
// 为Chrome插件请求添加特殊标记
|
||||
@@ -23,8 +49,8 @@ const browserFallbackMiddleware = (req, res, next) => {
|
||||
req.headers['user-agent'] = 'claude-cli/1.0.110 (external, cli, browser-fallback)'
|
||||
|
||||
// 确保设置正确的认证头
|
||||
if (!req.headers['authorization'] && req.headers['x-api-key']) {
|
||||
req.headers['authorization'] = `Bearer ${req.headers['x-api-key']}`
|
||||
if (!req.headers['authorization'] && apiKeyHeader) {
|
||||
req.headers['authorization'] = `Bearer ${apiKeyHeader}`
|
||||
}
|
||||
|
||||
// 添加必要的Anthropic头
|
||||
|
||||
1858
src/models/redis.js
7458
src/routes/admin.js
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
|
||||
2374
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
|
||||
371
src/routes/admin/bedrockAccounts.js
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* 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,
|
||||
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 && !['default', 'access_key', 'bearer_token'].includes(credentialType)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await bedrockAccountService.createAccount({
|
||||
name,
|
||||
description: description || '',
|
||||
region: region || 'us-east-1',
|
||||
awsCredentials,
|
||||
defaultModel,
|
||||
priority: priority || 50,
|
||||
accountType: accountType || 'shared',
|
||||
credentialType: credentialType || 'default'
|
||||
})
|
||||
|
||||
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 &&
|
||||
!['default', 'access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid credential type. Must be "default", "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账户连接
|
||||
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const result = await bedrockAccountService.testAccount(accountId)
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: 'Account test failed', message: result.error })
|
||||
}
|
||||
|
||||
logger.success(`🧪 Admin tested Bedrock account: ${accountId} - ${result.data.status}`)
|
||||
return res.json({ success: true, data: result.data })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to test Bedrock account:', error)
|
||||
return res.status(500).json({ error: 'Failed to test Bedrock account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
707
src/routes/admin/dashboard.js
Normal file
@@ -0,0 +1,707 @@
|
||||
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 openaiAccountService = require('../../services/openaiAccountService')
|
||||
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 pricingService = require('../../services/pricingService')
|
||||
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
|
||||
494
src/routes/admin/geminiAccounts.js
Normal file
@@ -0,0 +1,494 @@
|
||||
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 账户管理
|
||||
|
||||
// 生成 Gemini OAuth 授权 URL
|
||||
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { state, proxy } = req.body // 接收代理配置
|
||||
|
||||
// 使用新的 codeassist.google.com 回调地址
|
||||
const redirectUri = 'https://codeassist.google.com/authcode'
|
||||
|
||||
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`)
|
||||
|
||||
const {
|
||||
authUrl,
|
||||
state: authState,
|
||||
codeVerifier,
|
||||
redirectUri: finalRedirectUri
|
||||
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy)
|
||||
|
||||
// 创建 OAuth 会话,包含 codeVerifier 和代理配置
|
||||
const sessionId = authState
|
||||
await redis.setOAuthSession(sessionId, {
|
||||
state: authState,
|
||||
type: 'gemini',
|
||||
redirectUri: finalRedirectUri,
|
||||
codeVerifier, // 保存 PKCE code verifier
|
||||
proxy: proxy || null, // 保存代理配置
|
||||
createdAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
logger.info(`Generated Gemini OAuth URL with session: ${sessionId}`)
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
authUrl,
|
||||
sessionId
|
||||
}
|
||||
})
|
||||
} 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 } = req.body
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Authorization code is required' })
|
||||
}
|
||||
|
||||
let redirectUri = 'https://codeassist.google.com/authcode'
|
||||
let codeVerifier = null
|
||||
let proxyConfig = null
|
||||
|
||||
// 如果提供了 sessionId,从 OAuth 会话中获取信息
|
||||
if (sessionId) {
|
||||
const sessionData = await redis.getOAuthSession(sessionId)
|
||||
if (sessionData) {
|
||||
const {
|
||||
redirectUri: sessionRedirectUri,
|
||||
codeVerifier: sessionCodeVerifier,
|
||||
proxy
|
||||
} = sessionData
|
||||
redirectUri = sessionRedirectUri || redirectUri
|
||||
codeVerifier = sessionCodeVerifier
|
||||
proxyConfig = proxy // 获取代理配置
|
||||
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 // 传递代理配置
|
||||
)
|
||||
|
||||
// 清理 OAuth 会话
|
||||
if (sessionId) {
|
||||
await redis.deleteOAuthSession(sessionId)
|
||||
}
|
||||
|
||||
logger.success('✅ Successfully exchanged Gemini authorization code')
|
||||
return res.json({ success: true, data: { tokens } })
|
||||
} 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
|
||||
52
src/routes/admin/index.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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 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('/', 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
|
||||
2506
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
|
||||
}
|
||||
1109
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()
|
||||
|
||||
@@ -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,7 +148,9 @@ 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',
|
||||
@@ -196,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
|
||||
@@ -332,6 +353,50 @@ 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,
|
||||
@@ -372,11 +437,15 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
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,
|
||||
@@ -392,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
|
||||
},
|
||||
|
||||
// 模型和客户端限制信息
|
||||
@@ -728,6 +802,66 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 🧪 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 {
|
||||
@@ -761,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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
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 router = express.Router()
|
||||
|
||||
function hasDroidPermission(apiKeyData) {
|
||||
const permissions = apiKeyData?.permissions || 'all'
|
||||
return permissions === 'all' || 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
|
||||
@@ -5,8 +5,6 @@
|
||||
|
||||
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')
|
||||
@@ -15,17 +13,9 @@ 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') {
|
||||
@@ -33,6 +23,27 @@ function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
||||
return permissions === 'all' || 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 兼容的模型列表端点
|
||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
@@ -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,11 +218,23 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
const sessionHash = sessionHelper.generateSessionHash(claudeRequest)
|
||||
|
||||
// 选择可用的Claude账户
|
||||
const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||
apiKeyData,
|
||||
sessionHash,
|
||||
claudeRequest.model
|
||||
)
|
||||
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 } = accountSelection
|
||||
|
||||
// 获取该账号存储的 Claude Code headers
|
||||
@@ -251,6 +275,12 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
// 记录使用统计
|
||||
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
|
||||
@@ -263,6 +293,18 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
.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-claude-stream'
|
||||
)
|
||||
}
|
||||
},
|
||||
// 流转换器
|
||||
@@ -322,6 +364,12 @@ 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(
|
||||
@@ -333,6 +381,18 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
.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-claude-non-stream'
|
||||
)
|
||||
}
|
||||
|
||||
// 返回 OpenAI 格式响应
|
||||
@@ -420,3 +480,4 @@ router.post('/v1/completions', authenticateApiKey, async (req, res) => {
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
module.exports.handleChatCompletion = handleChatCompletion
|
||||
|
||||
@@ -9,11 +9,10 @@ const crypto = require('crypto')
|
||||
|
||||
// 生成会话哈希
|
||||
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(':')
|
||||
|
||||
@@ -387,7 +386,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 {
|
||||
@@ -513,6 +512,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)
|
||||
}
|
||||
@@ -535,8 +537,23 @@ 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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,637 +1,263 @@
|
||||
/**
|
||||
* 标准 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')
|
||||
const geminiAccountService = require('../services/geminiAccountService')
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
|
||||
// 导入 geminiRoutes 中导出的处理函数
|
||||
const { handleLoadCodeAssist, handleOnboardUser, handleCountTokens } = require('./geminiRoutes')
|
||||
|
||||
// 标准 Gemini API 路由处理器
|
||||
// 这些路由将挂载在 /gemini 路径下,处理标准 Gemini API 格式的请求
|
||||
// 标准格式: /gemini/v1beta/models/{model}:generateContent
|
||||
|
||||
// 专门处理标准 Gemini API 格式的 generateContent
|
||||
async function handleStandardGenerateContent(req, res) {
|
||||
try {
|
||||
// 从路径参数中获取模型名
|
||||
const model = req.params.modelName || 'gemini-2.0-flash-exp'
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 标准 Gemini API 请求体直接包含 contents 等字段
|
||||
const { contents, generationConfig, safetySettings, systemInstruction } = req.body
|
||||
|
||||
// 验证必需参数
|
||||
if (!contents || !Array.isArray(contents) || contents.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Contents array is required',
|
||||
type: 'invalid_request_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 构建内部 API 需要的请求格式
|
||||
const actualRequestData = {
|
||||
contents,
|
||||
generationConfig: generationConfig || {
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 4096,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
}
|
||||
}
|
||||
|
||||
// 只有在 safetySettings 存在且非空时才添加
|
||||
if (safetySettings && safetySettings.length > 0) {
|
||||
actualRequestData.safetySettings = safetySettings
|
||||
}
|
||||
|
||||
// 如果有 system instruction,修正格式并添加到请求体
|
||||
// Gemini CLI 的内部 API 需要 role: "user" 字段
|
||||
if (systemInstruction) {
|
||||
// 确保 systemInstruction 格式正确
|
||||
if (typeof systemInstruction === 'string' && systemInstruction.trim()) {
|
||||
actualRequestData.systemInstruction = {
|
||||
role: 'user', // Gemini CLI 内部 API 需要这个字段
|
||||
parts: [{ text: systemInstruction }]
|
||||
}
|
||||
} else if (systemInstruction.parts && systemInstruction.parts.length > 0) {
|
||||
// 检查是否有实际内容
|
||||
const hasContent = systemInstruction.parts.some(
|
||||
(part) => part.text && part.text.trim() !== ''
|
||||
)
|
||||
if (hasContent) {
|
||||
// 添加 role 字段(Gemini CLI 格式)
|
||||
actualRequestData.systemInstruction = {
|
||||
role: 'user', // Gemini CLI 内部 API 需要这个字段
|
||||
parts: systemInstruction.parts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用统一调度选择账号
|
||||
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' : 'v1'
|
||||
logger.info(`Standard Gemini API generateContent request (${version})`, {
|
||||
model,
|
||||
projectId: account.projectId,
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
// 解析账户的代理配置
|
||||
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 client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 使用账户的项目ID(如果有的话)
|
||||
const effectiveProjectId = account.projectId || null
|
||||
|
||||
logger.info('📋 Standard API 项目ID处理逻辑', {
|
||||
accountProjectId: account.projectId,
|
||||
effectiveProjectId,
|
||||
decision: account.projectId ? '使用账户配置' : '不使用项目ID'
|
||||
})
|
||||
|
||||
// 生成一个符合 Gemini CLI 格式的 user_prompt_id
|
||||
const userPromptId = `${require('crypto').randomUUID()}########0`
|
||||
|
||||
// 调用内部 API(cloudcode-pa)
|
||||
const response = await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId, // 使用生成的 user_prompt_id
|
||||
effectiveProjectId || 'oceanic-graph-cgcz4', // 如果没有项目ID,使用默认值
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回标准 Gemini API 格式的响应
|
||||
// 内部 API 返回的是 { response: {...} } 格式,需要提取并过滤
|
||||
if (response.response) {
|
||||
// 过滤掉 thought 部分(这是内部 API 特有的)
|
||||
const standardResponse = { ...response.response }
|
||||
if (standardResponse.candidates) {
|
||||
standardResponse.candidates = standardResponse.candidates.map((candidate) => {
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
// 过滤掉 thought: true 的 parts
|
||||
const filteredParts = candidate.content.parts.filter((part) => !part.thought)
|
||||
return {
|
||||
...candidate,
|
||||
content: {
|
||||
...candidate.content,
|
||||
parts: filteredParts
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidate
|
||||
})
|
||||
}
|
||||
res.json(standardResponse)
|
||||
} else {
|
||||
res.json(response)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in standard generateContent endpoint`, {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
stack: error.stack
|
||||
})
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 专门处理标准 Gemini API 格式的 streamGenerateContent
|
||||
async function handleStandardStreamGenerateContent(req, res) {
|
||||
let abortController = null
|
||||
|
||||
try {
|
||||
// 从路径参数中获取模型名
|
||||
const model = req.params.modelName || 'gemini-2.0-flash-exp'
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 标准 Gemini API 请求体直接包含 contents 等字段
|
||||
const { contents, generationConfig, safetySettings, systemInstruction } = req.body
|
||||
|
||||
// 验证必需参数
|
||||
if (!contents || !Array.isArray(contents) || contents.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Contents array is required',
|
||||
type: 'invalid_request_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 构建内部 API 需要的请求格式
|
||||
const actualRequestData = {
|
||||
contents,
|
||||
generationConfig: generationConfig || {
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 4096,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
}
|
||||
}
|
||||
|
||||
// 只有在 safetySettings 存在且非空时才添加
|
||||
if (safetySettings && safetySettings.length > 0) {
|
||||
actualRequestData.safetySettings = safetySettings
|
||||
}
|
||||
|
||||
// 如果有 system instruction,修正格式并添加到请求体
|
||||
// Gemini CLI 的内部 API 需要 role: "user" 字段
|
||||
if (systemInstruction) {
|
||||
// 确保 systemInstruction 格式正确
|
||||
if (typeof systemInstruction === 'string' && systemInstruction.trim()) {
|
||||
actualRequestData.systemInstruction = {
|
||||
role: 'user', // Gemini CLI 内部 API 需要这个字段
|
||||
parts: [{ text: systemInstruction }]
|
||||
}
|
||||
} else if (systemInstruction.parts && systemInstruction.parts.length > 0) {
|
||||
// 检查是否有实际内容
|
||||
const hasContent = systemInstruction.parts.some(
|
||||
(part) => part.text && part.text.trim() !== ''
|
||||
)
|
||||
if (hasContent) {
|
||||
// 添加 role 字段(Gemini CLI 格式)
|
||||
actualRequestData.systemInstruction = {
|
||||
role: 'user', // Gemini CLI 内部 API 需要这个字段
|
||||
parts: systemInstruction.parts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用统一调度选择账号
|
||||
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' : 'v1'
|
||||
logger.info(`Standard Gemini API streamGenerateContent request (${version})`, {
|
||||
model,
|
||||
projectId: 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()
|
||||
}
|
||||
})
|
||||
|
||||
// 解析账户的代理配置
|
||||
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 client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 使用账户的项目ID(如果有的话)
|
||||
const effectiveProjectId = account.projectId || null
|
||||
|
||||
logger.info('📋 Standard API 流式项目ID处理逻辑', {
|
||||
accountProjectId: account.projectId,
|
||||
effectiveProjectId,
|
||||
decision: account.projectId ? '使用账户配置' : '不使用项目ID'
|
||||
})
|
||||
|
||||
// 生成一个符合 Gemini CLI 格式的 user_prompt_id
|
||||
const userPromptId = `${require('crypto').randomUUID()}########0`
|
||||
|
||||
// 调用内部 API(cloudcode-pa)的流式接口
|
||||
const streamResponse = await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId, // 使用生成的 user_prompt_id
|
||||
effectiveProjectId || 'oceanic-graph-cgcz4', // 如果没有项目ID,使用默认值
|
||||
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 totalUsage = {
|
||||
promptTokenCount: 0,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: 0
|
||||
}
|
||||
|
||||
streamResponse.on('data', (chunk) => {
|
||||
try {
|
||||
if (!res.destroyed) {
|
||||
const chunkStr = chunk.toString()
|
||||
|
||||
// 处理 SSE 格式的数据
|
||||
const lines = chunkStr.split('\n')
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.substring(6).trim()
|
||||
if (jsonStr && jsonStr !== '[DONE]') {
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
|
||||
// 捕获 usage 数据
|
||||
if (data.response?.usageMetadata) {
|
||||
totalUsage = data.response.usageMetadata
|
||||
}
|
||||
|
||||
// 转换格式:移除 response 包装,直接返回标准 Gemini API 格式
|
||||
if (data.response) {
|
||||
// 过滤掉 thought 部分(这是内部 API 特有的)
|
||||
if (data.response.candidates) {
|
||||
const filteredCandidates = data.response.candidates
|
||||
.map((candidate) => {
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
// 过滤掉 thought: true 的 parts
|
||||
const filteredParts = candidate.content.parts.filter(
|
||||
(part) => !part.thought
|
||||
)
|
||||
if (filteredParts.length > 0) {
|
||||
return {
|
||||
...candidate,
|
||||
content: {
|
||||
...candidate.content,
|
||||
parts: filteredParts
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return candidate
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
// 只有当有有效内容时才发送
|
||||
if (filteredCandidates.length > 0 || data.response.usageMetadata) {
|
||||
const standardResponse = {
|
||||
candidates: filteredCandidates,
|
||||
...(data.response.usageMetadata && {
|
||||
usageMetadata: data.response.usageMetadata
|
||||
}),
|
||||
...(data.response.modelVersion && {
|
||||
modelVersion: data.response.modelVersion
|
||||
}),
|
||||
...(data.response.createTime && { createTime: data.response.createTime }),
|
||||
...(data.response.responseId && { responseId: data.response.responseId })
|
||||
}
|
||||
res.write(`data: ${JSON.stringify(standardResponse)}\n\n`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果没有 response 包装,直接发送
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
} else if (jsonStr === '[DONE]') {
|
||||
// 保持 [DONE] 标记
|
||||
res.write(`${line}\n\n`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing stream chunk:', error)
|
||||
}
|
||||
})
|
||||
|
||||
streamResponse.on('end', async () => {
|
||||
logger.info('Stream completed successfully')
|
||||
|
||||
// 记录使用统计
|
||||
if (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) {
|
||||
logger.error(`Error in standard streamGenerateContent endpoint`, {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
// 从 handlers/geminiHandlers.js 导入所有处理函数
|
||||
const {
|
||||
ensureGeminiPermissionMiddleware,
|
||||
handleLoadCodeAssist,
|
||||
handleOnboardUser,
|
||||
handleCountTokens,
|
||||
handleGenerateContent,
|
||||
handleStreamGenerateContent,
|
||||
handleStandardGenerateContent,
|
||||
handleStandardStreamGenerateContent,
|
||||
handleModels,
|
||||
handleModelDetails
|
||||
} = require('../handlers/geminiHandlers')
|
||||
|
||||
// ============================================================================
|
||||
// v1beta 版本的标准路由 - 支持动态模型名称
|
||||
router.post('/v1beta/models/:modelName\\:loadCodeAssist', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
|
||||
handleLoadCodeAssist(req, res, next)
|
||||
})
|
||||
// ============================================================================
|
||||
|
||||
router.post('/v1beta/models/:modelName\\:onboardUser', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
|
||||
handleOnboardUser(req, res, next)
|
||||
})
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
)
|
||||
|
||||
router.post('/v1beta/models/:modelName\\:countTokens', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
|
||||
handleCountTokens(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)
|
||||
}
|
||||
)
|
||||
|
||||
// 使用专门的处理函数处理标准 Gemini API 格式
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
|
||||
router.post('/v1/models/:modelName\\:countTokens', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1): ${req.method} ${req.originalUrl}`)
|
||||
handleCountTokens(req, res, next)
|
||||
})
|
||||
|
||||
// v1internal 版本的标准路由(这些使用原有的处理函数,因为格式不同)
|
||||
router.post('/v1internal\\:loadCodeAssist', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleLoadCodeAssist(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1internal\\:onboardUser', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleOnboardUser(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1internal\\:countTokens', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleCountTokens(req, res, next)
|
||||
})
|
||||
|
||||
// v1internal 使用不同的处理逻辑,因为它们不包含模型在 URL 中
|
||||
router.post('/v1internal\\:generateContent', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
// v1internal 格式不同,使用原有的处理函数
|
||||
const { handleGenerateContent } = require('./geminiRoutes')
|
||||
handleGenerateContent(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1internal\\:streamGenerateContent', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
// v1internal 格式不同,使用原有的处理函数
|
||||
const { handleStreamGenerateContent } = require('./geminiRoutes')
|
||||
handleStreamGenerateContent(req, res, next)
|
||||
})
|
||||
|
||||
// 添加标准 Gemini API 的模型列表端点
|
||||
router.get('/v1beta/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
logger.info('Standard Gemini API models request')
|
||||
// 直接调用 geminiRoutes 中的模型处理逻辑
|
||||
const geminiRoutes = require('./geminiRoutes')
|
||||
const modelHandler = geminiRoutes.stack.find(
|
||||
(layer) => layer.route && layer.route.path === '/models' && layer.route.methods.get
|
||||
)
|
||||
if (modelHandler && modelHandler.route.stack[1]) {
|
||||
// 调用处理函数(跳过第一个 authenticateApiKey 中间件)
|
||||
modelHandler.route.stack[1].handle(req, res)
|
||||
} else {
|
||||
res.status(500).json({ error: 'Models handler not found' })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in standard models endpoint:', error)
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve models',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
logger.info('Standard Gemini API models request (v1)')
|
||||
// 直接调用 geminiRoutes 中的模型处理逻辑
|
||||
const geminiRoutes = require('./geminiRoutes')
|
||||
const modelHandler = geminiRoutes.stack.find(
|
||||
(layer) => layer.route && layer.route.path === '/models' && layer.route.methods.get
|
||||
)
|
||||
if (modelHandler && modelHandler.route.stack[1]) {
|
||||
modelHandler.route.stack[1].handle(req, res)
|
||||
} else {
|
||||
res.status(500).json({ error: 'Models handler not found' })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in standard models endpoint (v1):', error)
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve models',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
// ============================================================================
|
||||
// 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)
|
||||
})
|
||||
|
||||
// 添加模型详情端点
|
||||
router.get('/v1beta/models/:modelName', authenticateApiKey, (req, res) => {
|
||||
const { modelName } = req.params
|
||||
logger.info(`Standard Gemini API model details request: ${modelName}`)
|
||||
|
||||
res.json({
|
||||
name: `models/${modelName}`,
|
||||
version: '001',
|
||||
displayName: modelName,
|
||||
description: `Gemini model: ${modelName}`,
|
||||
inputTokenLimit: 1048576,
|
||||
outputTokenLimit: 8192,
|
||||
supportedGenerationMethods: ['generateContent', 'streamGenerateContent', 'countTokens'],
|
||||
temperature: 1.0,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
})
|
||||
/**
|
||||
* GET /v1/models
|
||||
* 获取模型列表(v1 版本)
|
||||
*/
|
||||
router.get('/v1/models', authenticateApiKey, ensureGeminiPermissionMiddleware, (req, res) => {
|
||||
logger.info('Standard Gemini API models request (v1)')
|
||||
handleModels(req, res)
|
||||
})
|
||||
|
||||
router.get('/v1/models/:modelName', authenticateApiKey, (req, res) => {
|
||||
const { modelName } = req.params
|
||||
logger.info(`Standard Gemini API model details request (v1): ${modelName}`)
|
||||
// ============================================================================
|
||||
// 模型详情端点
|
||||
// ============================================================================
|
||||
|
||||
res.json({
|
||||
name: `models/${modelName}`,
|
||||
version: '001',
|
||||
displayName: modelName,
|
||||
description: `Gemini model: ${modelName}`,
|
||||
inputTokenLimit: 1048576,
|
||||
outputTokenLimit: 8192,
|
||||
supportedGenerationMethods: ['generateContent', 'streamGenerateContent', 'countTokens'],
|
||||
temperature: 1.0,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
})
|
||||
})
|
||||
/**
|
||||
* 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')
|
||||
|
||||
|
||||
202
src/routes/unified.js
Normal file
@@ -0,0 +1,202 @@
|
||||
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 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.permissions || 'all'
|
||||
|
||||
if (backend === 'claude') {
|
||||
// Claude 后端:通过 OpenAI 兼容层
|
||||
if (permissions !== 'all' && 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 (permissions !== 'all' && 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 (permissions !== 'all' && 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
|
||||
@@ -258,6 +258,8 @@ router.get('/api-keys', authenticateUser, async (req, res) => {
|
||||
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)}`
|
||||
@@ -287,7 +289,7 @@ router.get('/api-keys', authenticateUser, async (req, res) => {
|
||||
// 🔑 创建新的API Key
|
||||
router.post('/api-keys', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { name, description, tokenLimit, expiresAt, dailyCostLimit } = req.body
|
||||
const { name, description, tokenLimit, expiresAt, dailyCostLimit, totalCostLimit } = req.body
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({
|
||||
@@ -296,6 +298,18 @@ router.post('/api-keys', authenticateUser, async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -314,6 +328,7 @@ router.post('/api-keys', authenticateUser, async (req, res) => {
|
||||
tokenLimit: tokenLimit || null,
|
||||
expiresAt: expiresAt || null,
|
||||
dailyCostLimit: dailyCostLimit || null,
|
||||
totalCostLimit: totalCostLimit || null,
|
||||
createdBy: 'user',
|
||||
// 设置服务权限为全部服务,确保前端显示“服务权限”为“全部服务”且具备完整访问权限
|
||||
permissions: 'all'
|
||||
@@ -337,6 +352,7 @@ router.post('/api-keys', authenticateUser, async (req, res) => {
|
||||
tokenLimit: newApiKey.tokenLimit,
|
||||
expiresAt: newApiKey.expiresAt,
|
||||
dailyCostLimit: newApiKey.dailyCostLimit,
|
||||
totalCostLimit: newApiKey.totalCostLimit,
|
||||
createdAt: newApiKey.createdAt
|
||||
}
|
||||
})
|
||||
|
||||
@@ -133,7 +133,11 @@ router.post('/test', authenticateAdmin, async (req, res) => {
|
||||
pass,
|
||||
from,
|
||||
to,
|
||||
ignoreTLS
|
||||
ignoreTLS,
|
||||
botToken,
|
||||
chatId,
|
||||
apiBaseUrl,
|
||||
proxyUrl
|
||||
} = req.body
|
||||
|
||||
// Bark平台特殊处理
|
||||
@@ -186,6 +190,56 @@ router.post('/test', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -235,12 +289,30 @@ router.post('/test', authenticateAdmin, async (req, res) => {
|
||||
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) {
|
||||
const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url
|
||||
logger.info(`✅ Webhook测试成功: ${identifier}`)
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -249,7 +321,6 @@ router.post('/test', authenticateAdmin, async (req, res) => {
|
||||
deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined
|
||||
})
|
||||
} else {
|
||||
const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url
|
||||
logger.warn(`❌ Webhook测试失败: ${identifier} - ${result.error}`)
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
|
||||
@@ -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,
|
||||
|
||||
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
|
||||
@@ -4,6 +4,72 @@ const config = require('../../config/config')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
const ACCOUNT_TYPE_CONFIG = {
|
||||
claude: { prefix: 'claude:account:' },
|
||||
'claude-console': { prefix: 'claude_console_account:' },
|
||||
openai: { prefix: 'openai:account:' },
|
||||
'openai-responses': { prefix: 'openai_responses_account:' },
|
||||
'azure-openai': { prefix: 'azure_openai:account:' },
|
||||
gemini: { prefix: 'gemini_account:' },
|
||||
'gemini-api': { prefix: 'gemini_api_account:' },
|
||||
droid: { prefix: 'droid:account:' }
|
||||
}
|
||||
|
||||
const ACCOUNT_TYPE_PRIORITY = [
|
||||
'openai',
|
||||
'openai-responses',
|
||||
'azure-openai',
|
||||
'claude',
|
||||
'claude-console',
|
||||
'gemini',
|
||||
'gemini-api',
|
||||
'droid'
|
||||
]
|
||||
|
||||
const ACCOUNT_CATEGORY_MAP = {
|
||||
claude: 'claude',
|
||||
'claude-console': 'claude',
|
||||
openai: 'openai',
|
||||
'openai-responses': 'openai',
|
||||
'azure-openai': 'openai',
|
||||
gemini: 'gemini',
|
||||
'gemini-api': 'gemini',
|
||||
droid: 'droid'
|
||||
}
|
||||
|
||||
function normalizeAccountTypeKey(type) {
|
||||
if (!type) {
|
||||
return null
|
||||
}
|
||||
const lower = String(type).toLowerCase()
|
||||
if (lower === 'claude_console') {
|
||||
return 'claude-console'
|
||||
}
|
||||
if (lower === 'openai_responses' || lower === 'openai-response' || lower === 'openai-responses') {
|
||||
return 'openai-responses'
|
||||
}
|
||||
if (lower === 'azure_openai' || lower === 'azureopenai' || lower === 'azure-openai') {
|
||||
return 'azure-openai'
|
||||
}
|
||||
if (lower === 'gemini_api' || lower === 'gemini-api') {
|
||||
return 'gemini-api'
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
function sanitizeAccountIdForType(accountId, accountType) {
|
||||
if (!accountId || typeof accountId !== 'string') {
|
||||
return accountId
|
||||
}
|
||||
if (accountType === 'openai-responses') {
|
||||
return accountId.replace(/^responses:/, '')
|
||||
}
|
||||
if (accountType === 'gemini-api') {
|
||||
return accountId.replace(/^api:/, '')
|
||||
}
|
||||
return accountId
|
||||
}
|
||||
|
||||
class ApiKeyService {
|
||||
constructor() {
|
||||
this.prefix = config.security.apiKeyPrefix
|
||||
@@ -22,7 +88,8 @@ class ApiKeyService {
|
||||
openaiAccountId = null,
|
||||
azureOpenaiAccountId = null,
|
||||
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
|
||||
permissions = 'all', // 'claude', 'gemini', 'openai', 'all'
|
||||
droidAccountId = null,
|
||||
permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all'
|
||||
isActive = true,
|
||||
concurrencyLimit = 0,
|
||||
rateLimitWindow = null,
|
||||
@@ -33,9 +100,11 @@ class ApiKeyService {
|
||||
enableClientRestriction = false,
|
||||
allowedClients = [],
|
||||
dailyCostLimit = 0,
|
||||
totalCostLimit = 0,
|
||||
weeklyOpusCostLimit = 0,
|
||||
tags = [],
|
||||
activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能)
|
||||
activationUnit = 'days', // 新增:激活时间单位 'hours' 或 'days'
|
||||
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
|
||||
icon = '' // 新增:图标(base64编码)
|
||||
} = options
|
||||
@@ -62,15 +131,18 @@ class ApiKeyService {
|
||||
openaiAccountId: openaiAccountId || '',
|
||||
azureOpenaiAccountId: azureOpenaiAccountId || '',
|
||||
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
|
||||
droidAccountId: droidAccountId || '',
|
||||
permissions: permissions || 'all',
|
||||
enableModelRestriction: String(enableModelRestriction),
|
||||
restrictedModels: JSON.stringify(restrictedModels || []),
|
||||
enableClientRestriction: String(enableClientRestriction || false),
|
||||
allowedClients: JSON.stringify(allowedClients || []),
|
||||
dailyCostLimit: String(dailyCostLimit || 0),
|
||||
totalCostLimit: String(totalCostLimit || 0),
|
||||
weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0),
|
||||
tags: JSON.stringify(tags || []),
|
||||
activationDays: String(activationDays || 0), // 新增:激活后有效天数
|
||||
activationUnit: activationUnit || 'days', // 新增:激活时间单位
|
||||
expirationMode: expirationMode || 'fixed', // 新增:过期模式
|
||||
isActivated: expirationMode === 'fixed' ? 'true' : 'false', // 根据模式决定激活状态
|
||||
activatedAt: expirationMode === 'fixed' ? new Date().toISOString() : '', // 激活时间
|
||||
@@ -86,6 +158,14 @@ class ApiKeyService {
|
||||
// 保存API Key数据并建立哈希映射
|
||||
await redis.setApiKey(keyId, keyData, hashedKey)
|
||||
|
||||
// 同步添加到费用排序索引
|
||||
try {
|
||||
const costRankService = require('./costRankService')
|
||||
await costRankService.addKeyToIndexes(keyId)
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to add key ${keyId} to cost rank indexes:`, err.message)
|
||||
}
|
||||
|
||||
logger.success(`🔑 Generated new API key: ${name} (${keyId})`)
|
||||
|
||||
return {
|
||||
@@ -105,15 +185,18 @@ class ApiKeyService {
|
||||
openaiAccountId: keyData.openaiAccountId,
|
||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||
droidAccountId: keyData.droidAccountId,
|
||||
permissions: keyData.permissions,
|
||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||
restrictedModels: JSON.parse(keyData.restrictedModels),
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients: JSON.parse(keyData.allowedClients || '[]'),
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
|
||||
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
|
||||
tags: JSON.parse(keyData.tags || '[]'),
|
||||
activationDays: parseInt(keyData.activationDays || 0),
|
||||
activationUnit: keyData.activationUnit || 'days',
|
||||
expirationMode: keyData.expirationMode || 'fixed',
|
||||
isActivated: keyData.isActivated === 'true',
|
||||
activatedAt: keyData.activatedAt,
|
||||
@@ -137,6 +220,10 @@ class ApiKeyService {
|
||||
const keyData = await redis.findApiKeyByHash(hashedKey)
|
||||
|
||||
if (!keyData) {
|
||||
// ⚠️ 警告:映射表查找失败,可能是竞态条件或映射表损坏
|
||||
logger.warn(
|
||||
`⚠️ API key not found in hash map: ${hashedKey.substring(0, 16)}... (possible race condition or corrupted hash map)`
|
||||
)
|
||||
return { valid: false, error: 'API key not found' }
|
||||
}
|
||||
|
||||
@@ -149,8 +236,18 @@ class ApiKeyService {
|
||||
if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') {
|
||||
// 首次使用,需要激活
|
||||
const now = new Date()
|
||||
const activationDays = parseInt(keyData.activationDays || 30) // 默认30天
|
||||
const expiresAt = new Date(now.getTime() + activationDays * 24 * 60 * 60 * 1000)
|
||||
const activationPeriod = parseInt(keyData.activationDays || 30) // 默认30
|
||||
const activationUnit = keyData.activationUnit || 'days' // 默认天
|
||||
|
||||
// 根据单位计算过期时间
|
||||
let milliseconds
|
||||
if (activationUnit === 'hours') {
|
||||
milliseconds = activationPeriod * 60 * 60 * 1000 // 小时转毫秒
|
||||
} else {
|
||||
milliseconds = activationPeriod * 24 * 60 * 60 * 1000 // 天转毫秒
|
||||
}
|
||||
|
||||
const expiresAt = new Date(now.getTime() + milliseconds)
|
||||
|
||||
// 更新激活状态和过期时间
|
||||
keyData.isActivated = 'true'
|
||||
@@ -162,7 +259,9 @@ class ApiKeyService {
|
||||
await redis.setApiKey(keyData.id, keyData)
|
||||
|
||||
logger.success(
|
||||
`🔓 API key activated: ${keyData.id} (${keyData.name}), will expire in ${activationDays} days at ${expiresAt.toISOString()}`
|
||||
`🔓 API key activated: ${keyData.id} (${
|
||||
keyData.name
|
||||
}), will expire in ${activationPeriod} ${activationUnit} at ${expiresAt.toISOString()}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -188,8 +287,12 @@ class ApiKeyService {
|
||||
// 获取使用统计(供返回数据使用)
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
|
||||
// 获取当日费用统计
|
||||
const dailyCost = await redis.getDailyCost(keyData.id)
|
||||
// 获取费用统计
|
||||
const [dailyCost, costStats] = await Promise.all([
|
||||
redis.getDailyCost(keyData.id),
|
||||
redis.getCostStats(keyData.id)
|
||||
])
|
||||
const totalCost = costStats?.total || 0
|
||||
|
||||
// 更新最后使用时间(优化:只在实际API调用时更新,而不是验证时)
|
||||
// 注意:lastUsedAt的更新已移至recordUsage方法中
|
||||
@@ -234,6 +337,7 @@ class ApiKeyService {
|
||||
openaiAccountId: keyData.openaiAccountId,
|
||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||
droidAccountId: keyData.droidAccountId,
|
||||
permissions: keyData.permissions || 'all',
|
||||
tokenLimit: parseInt(keyData.tokenLimit),
|
||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||
@@ -245,8 +349,10 @@ class ApiKeyService {
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients,
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
|
||||
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
|
||||
dailyCost: dailyCost || 0,
|
||||
totalCost,
|
||||
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
|
||||
tags,
|
||||
usage
|
||||
@@ -277,7 +383,8 @@ class ApiKeyService {
|
||||
|
||||
// 检查是否激活
|
||||
if (keyData.isActive !== 'true') {
|
||||
return { valid: false, error: 'API key is disabled' }
|
||||
const keyName = keyData.name || 'Unknown'
|
||||
return { valid: false, error: `API Key "${keyName}" 已被禁用`, keyName }
|
||||
}
|
||||
|
||||
// 注意:这里不处理激活逻辑,保持 API Key 的未激活状态
|
||||
@@ -288,7 +395,8 @@ class ApiKeyService {
|
||||
keyData.expiresAt &&
|
||||
new Date() > new Date(keyData.expiresAt)
|
||||
) {
|
||||
return { valid: false, error: 'API key has expired' }
|
||||
const keyName = keyData.name || 'Unknown'
|
||||
return { valid: false, error: `API Key "${keyName}" 已过期`, keyName }
|
||||
}
|
||||
|
||||
// 如果API Key属于某个用户,检查用户是否被禁用
|
||||
@@ -306,7 +414,10 @@ class ApiKeyService {
|
||||
}
|
||||
|
||||
// 获取当日费用
|
||||
const dailyCost = (await redis.getDailyCost(keyData.id)) || 0
|
||||
const [dailyCost, costStats] = await Promise.all([
|
||||
redis.getDailyCost(keyData.id),
|
||||
redis.getCostStats(keyData.id)
|
||||
])
|
||||
|
||||
// 获取使用统计
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
@@ -347,6 +458,7 @@ class ApiKeyService {
|
||||
expirationMode: keyData.expirationMode || 'fixed',
|
||||
isActivated: keyData.isActivated === 'true',
|
||||
activationDays: parseInt(keyData.activationDays || 0),
|
||||
activationUnit: keyData.activationUnit || 'days',
|
||||
activatedAt: keyData.activatedAt || null,
|
||||
claudeAccountId: keyData.claudeAccountId,
|
||||
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
||||
@@ -354,6 +466,7 @@ class ApiKeyService {
|
||||
openaiAccountId: keyData.openaiAccountId,
|
||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||
bedrockAccountId: keyData.bedrockAccountId,
|
||||
droidAccountId: keyData.droidAccountId,
|
||||
permissions: keyData.permissions || 'all',
|
||||
tokenLimit: parseInt(keyData.tokenLimit),
|
||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||
@@ -365,8 +478,10 @@ class ApiKeyService {
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients,
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
|
||||
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
|
||||
dailyCost: dailyCost || 0,
|
||||
totalCost: costStats?.total || 0,
|
||||
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
|
||||
tags,
|
||||
usage
|
||||
@@ -383,6 +498,7 @@ class ApiKeyService {
|
||||
try {
|
||||
let apiKeys = await redis.getAllApiKeys()
|
||||
const client = redis.getClientSafe()
|
||||
const accountInfoCache = new Map()
|
||||
|
||||
// 默认过滤掉已删除的API Keys
|
||||
if (!includeDeleted) {
|
||||
@@ -411,10 +527,12 @@ class ApiKeyService {
|
||||
key.enableClientRestriction = key.enableClientRestriction === 'true'
|
||||
key.permissions = key.permissions || 'all' // 兼容旧数据
|
||||
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0)
|
||||
key.totalCostLimit = parseFloat(key.totalCostLimit || 0)
|
||||
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
|
||||
key.dailyCost = (await redis.getDailyCost(key.id)) || 0
|
||||
key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0
|
||||
key.activationDays = parseInt(key.activationDays || 0)
|
||||
key.activationUnit = key.activationUnit || 'days'
|
||||
key.expirationMode = key.expirationMode || 'fixed'
|
||||
key.isActivated = key.isActivated === 'true'
|
||||
key.activatedAt = key.activatedAt || null
|
||||
@@ -487,6 +605,48 @@ class ApiKeyService {
|
||||
if (Object.prototype.hasOwnProperty.call(key, 'ccrAccountId')) {
|
||||
delete key.ccrAccountId
|
||||
}
|
||||
|
||||
let lastUsageRecord = null
|
||||
try {
|
||||
const usageRecords = await redis.getUsageRecords(key.id, 1)
|
||||
if (Array.isArray(usageRecords) && usageRecords.length > 0) {
|
||||
lastUsageRecord = usageRecords[0]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`加载 API Key ${key.id} 的使用记录失败:`, error)
|
||||
}
|
||||
|
||||
if (lastUsageRecord && (lastUsageRecord.accountId || lastUsageRecord.accountType)) {
|
||||
const resolvedAccount = await this._resolveLastUsageAccount(
|
||||
key,
|
||||
lastUsageRecord,
|
||||
accountInfoCache,
|
||||
client
|
||||
)
|
||||
|
||||
if (resolvedAccount) {
|
||||
key.lastUsage = {
|
||||
accountId: resolvedAccount.accountId,
|
||||
rawAccountId: lastUsageRecord.accountId || resolvedAccount.accountId,
|
||||
accountType: resolvedAccount.accountType,
|
||||
accountCategory: resolvedAccount.accountCategory,
|
||||
accountName: resolvedAccount.accountName,
|
||||
recordedAt: lastUsageRecord.timestamp || key.lastUsedAt || null
|
||||
}
|
||||
} else {
|
||||
key.lastUsage = {
|
||||
accountId: null,
|
||||
rawAccountId: lastUsageRecord.accountId || null,
|
||||
accountType: 'deleted',
|
||||
accountCategory: 'deleted',
|
||||
accountName: '已删除',
|
||||
recordedAt: lastUsageRecord.timestamp || key.lastUsedAt || null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
key.lastUsage = null
|
||||
}
|
||||
|
||||
delete key.apiKey // 不返回哈希后的key
|
||||
}
|
||||
|
||||
@@ -521,9 +681,11 @@ class ApiKeyService {
|
||||
'openaiAccountId',
|
||||
'azureOpenaiAccountId',
|
||||
'bedrockAccountId', // 添加 Bedrock 账号ID
|
||||
'droidAccountId',
|
||||
'permissions',
|
||||
'expiresAt',
|
||||
'activationDays', // 新增:激活后有效天数
|
||||
'activationUnit', // 新增:激活时间单位
|
||||
'expirationMode', // 新增:过期模式
|
||||
'isActivated', // 新增:是否已激活
|
||||
'activatedAt', // 新增:激活时间
|
||||
@@ -532,6 +694,7 @@ class ApiKeyService {
|
||||
'enableClientRestriction',
|
||||
'allowedClients',
|
||||
'dailyCostLimit',
|
||||
'totalCostLimit',
|
||||
'weeklyOpusCostLimit',
|
||||
'tags',
|
||||
'userId', // 新增:用户ID(所有者变更)
|
||||
@@ -563,10 +726,11 @@ class ApiKeyService {
|
||||
|
||||
updatedData.updatedAt = new Date().toISOString()
|
||||
|
||||
// 更新时不需要重新建立哈希映射,因为API Key本身没有变化
|
||||
await redis.setApiKey(keyId, updatedData)
|
||||
// 传递hashedKey以确保映射表一致性
|
||||
// keyData.apiKey 存储的就是 hashedKey(见generateApiKey第123行)
|
||||
await redis.setApiKey(keyId, updatedData, keyData.apiKey)
|
||||
|
||||
logger.success(`📝 Updated API key: ${keyId}`)
|
||||
logger.success(`📝 Updated API key: ${keyId}, hashMap updated`)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
@@ -600,6 +764,14 @@ class ApiKeyService {
|
||||
await redis.deleteApiKeyHash(keyData.apiKey)
|
||||
}
|
||||
|
||||
// 从费用排序索引中移除
|
||||
try {
|
||||
const costRankService = require('./costRankService')
|
||||
await costRankService.removeKeyFromIndexes(keyId)
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to remove key ${keyId} from cost rank indexes:`, err.message)
|
||||
}
|
||||
|
||||
logger.success(`🗑️ Soft deleted API key: ${keyId} by ${deletedBy} (${deletedByType})`)
|
||||
|
||||
return { success: true }
|
||||
@@ -651,6 +823,14 @@ class ApiKeyService {
|
||||
})
|
||||
}
|
||||
|
||||
// 重新添加到费用排序索引
|
||||
try {
|
||||
const costRankService = require('./costRankService')
|
||||
await costRankService.addKeyToIndexes(keyId)
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to add restored key ${keyId} to cost rank indexes:`, err.message)
|
||||
}
|
||||
|
||||
logger.success(`✅ Restored API key: ${keyId} by ${restoredBy} (${restoredByType})`)
|
||||
|
||||
return { success: true, apiKey: updatedData }
|
||||
@@ -827,6 +1007,21 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 记录单次请求的使用详情
|
||||
const usageCost = costInfo && costInfo.costs ? costInfo.costs.total || 0 : 0
|
||||
await redis.addUsageRecord(keyId, {
|
||||
timestamp: new Date().toISOString(),
|
||||
model,
|
||||
accountId: accountId || null,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
totalTokens,
|
||||
cost: Number(usageCost.toFixed(6)),
|
||||
costBreakdown: costInfo && costInfo.costs ? costInfo.costs : undefined
|
||||
})
|
||||
|
||||
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`]
|
||||
if (cacheCreateTokens > 0) {
|
||||
logParts.push(`Cache Create: ${cacheCreateTokens}`)
|
||||
@@ -862,7 +1057,9 @@ class ApiKeyService {
|
||||
// 记录 Opus 周费用
|
||||
await redis.incrementWeeklyOpusCost(keyId, cost)
|
||||
logger.database(
|
||||
`💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed(6)}, model: ${model}, account type: ${accountType}`
|
||||
`💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed(
|
||||
6
|
||||
)}, model: ${model}, account type: ${accountType}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to record Opus cost:', error)
|
||||
@@ -896,9 +1093,46 @@ class ApiKeyService {
|
||||
await pricingService.initialize()
|
||||
}
|
||||
costInfo = pricingService.calculateCost(usageObject, model)
|
||||
|
||||
// 验证计算结果
|
||||
if (!costInfo || typeof costInfo.totalCost !== 'number') {
|
||||
logger.error(`❌ Invalid cost calculation result for model ${model}:`, costInfo)
|
||||
// 使用 CostCalculator 作为后备
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const fallbackCost = CostCalculator.calculateCost(usageObject, model)
|
||||
if (fallbackCost && fallbackCost.costs && fallbackCost.costs.total > 0) {
|
||||
logger.warn(
|
||||
`⚠️ Using fallback cost calculation for ${model}: $${fallbackCost.costs.total}`
|
||||
)
|
||||
costInfo = {
|
||||
totalCost: fallbackCost.costs.total,
|
||||
ephemeral5mCost: 0,
|
||||
ephemeral1hCost: 0
|
||||
}
|
||||
} else {
|
||||
costInfo = { totalCost: 0, ephemeral5mCost: 0, ephemeral1hCost: 0 }
|
||||
}
|
||||
}
|
||||
} catch (pricingError) {
|
||||
logger.error('❌ Failed to calculate cost:', pricingError)
|
||||
// 继续执行,不要因为费用计算失败而跳过统计记录
|
||||
logger.error(`❌ Failed to calculate cost for model ${model}:`, pricingError)
|
||||
logger.error(` Usage object:`, JSON.stringify(usageObject))
|
||||
// 使用 CostCalculator 作为后备
|
||||
try {
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const fallbackCost = CostCalculator.calculateCost(usageObject, model)
|
||||
if (fallbackCost && fallbackCost.costs && fallbackCost.costs.total > 0) {
|
||||
logger.warn(
|
||||
`⚠️ Using fallback cost calculation for ${model}: $${fallbackCost.costs.total}`
|
||||
)
|
||||
costInfo = {
|
||||
totalCost: fallbackCost.costs.total,
|
||||
ephemeral5mCost: 0,
|
||||
ephemeral1hCost: 0
|
||||
}
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
logger.error(`❌ Fallback cost calculation also failed:`, fallbackError)
|
||||
}
|
||||
}
|
||||
|
||||
// 提取详细的缓存创建数据
|
||||
@@ -937,11 +1171,21 @@ class ApiKeyService {
|
||||
// 记录详细的缓存费用(如果有)
|
||||
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {
|
||||
logger.database(
|
||||
`💰 Cache costs - 5m: $${costInfo.ephemeral5mCost.toFixed(6)}, 1h: $${costInfo.ephemeral1hCost.toFixed(6)}`
|
||||
`💰 Cache costs - 5m: $${costInfo.ephemeral5mCost.toFixed(
|
||||
6
|
||||
)}, 1h: $${costInfo.ephemeral1hCost.toFixed(6)}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`)
|
||||
// 如果有 token 使用但费用为 0,记录警告
|
||||
if (totalTokens > 0) {
|
||||
logger.warn(
|
||||
`⚠️ No cost recorded for ${keyId} - zero cost for model: ${model} (tokens: ${totalTokens})`
|
||||
)
|
||||
logger.warn(` This may indicate a pricing issue or model not found in pricing data`)
|
||||
} else {
|
||||
logger.debug(`💰 No cost recorded for ${keyId} - zero tokens for model: ${model}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取API Key数据以确定关联的账户
|
||||
@@ -973,6 +1217,32 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
const usageRecord = {
|
||||
timestamp: new Date().toISOString(),
|
||||
model,
|
||||
accountId: accountId || null,
|
||||
accountType: accountType || null,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
ephemeral5mTokens,
|
||||
ephemeral1hTokens,
|
||||
totalTokens,
|
||||
cost: Number((costInfo.totalCost || 0).toFixed(6)),
|
||||
costBreakdown: {
|
||||
input: costInfo.inputCost || 0,
|
||||
output: costInfo.outputCost || 0,
|
||||
cacheCreate: costInfo.cacheCreateCost || 0,
|
||||
cacheRead: costInfo.cacheReadCost || 0,
|
||||
ephemeral5m: costInfo.ephemeral5mCost || 0,
|
||||
ephemeral1h: costInfo.ephemeral1hCost || 0
|
||||
},
|
||||
isLongContext: costInfo.isLongContextRequest || false
|
||||
}
|
||||
|
||||
await redis.addUsageRecord(keyId, usageRecord)
|
||||
|
||||
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`]
|
||||
if (cacheCreateTokens > 0) {
|
||||
logParts.push(`Cache Create: ${cacheCreateTokens}`)
|
||||
@@ -995,11 +1265,180 @@ class ApiKeyService {
|
||||
logParts.push(`Total: ${totalTokens} tokens`)
|
||||
|
||||
logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`)
|
||||
|
||||
// 🔔 发布计费事件到消息队列(异步非阻塞)
|
||||
this._publishBillingEvent({
|
||||
keyId,
|
||||
keyName: keyData?.name,
|
||||
userId: keyData?.userId,
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
ephemeral5mTokens,
|
||||
ephemeral1hTokens,
|
||||
totalTokens,
|
||||
cost: costInfo.totalCost || 0,
|
||||
costBreakdown: {
|
||||
input: costInfo.inputCost || 0,
|
||||
output: costInfo.outputCost || 0,
|
||||
cacheCreate: costInfo.cacheCreateCost || 0,
|
||||
cacheRead: costInfo.cacheReadCost || 0,
|
||||
ephemeral5m: costInfo.ephemeral5mCost || 0,
|
||||
ephemeral1h: costInfo.ephemeral1hCost || 0
|
||||
},
|
||||
accountId,
|
||||
accountType,
|
||||
isLongContext: costInfo.isLongContextRequest || false,
|
||||
requestTimestamp: usageRecord.timestamp
|
||||
}).catch((err) => {
|
||||
// 发布失败不影响主流程,只记录错误
|
||||
logger.warn('⚠️ Failed to publish billing event:', err.message)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to record usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async _fetchAccountInfo(accountId, accountType, cache, client) {
|
||||
if (!client || !accountId || !accountType) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cacheKey = `${accountType}:${accountId}`
|
||||
if (cache.has(cacheKey)) {
|
||||
return cache.get(cacheKey)
|
||||
}
|
||||
|
||||
const accountConfig = ACCOUNT_TYPE_CONFIG[accountType]
|
||||
if (!accountConfig) {
|
||||
cache.set(cacheKey, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const redisKey = `${accountConfig.prefix}${accountId}`
|
||||
let accountData = null
|
||||
try {
|
||||
accountData = await client.hgetall(redisKey)
|
||||
} catch (error) {
|
||||
logger.debug(`加载账号信息失败 ${redisKey}:`, error)
|
||||
}
|
||||
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
const displayName =
|
||||
accountData.name ||
|
||||
accountData.displayName ||
|
||||
accountData.email ||
|
||||
accountData.username ||
|
||||
accountData.description ||
|
||||
accountId
|
||||
|
||||
const info = { id: accountId, name: displayName }
|
||||
cache.set(cacheKey, info)
|
||||
return info
|
||||
}
|
||||
|
||||
cache.set(cacheKey, null)
|
||||
return null
|
||||
}
|
||||
|
||||
async _resolveAccountByUsageRecord(usageRecord, cache, client) {
|
||||
if (!usageRecord || !client) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rawAccountId = usageRecord.accountId || null
|
||||
const rawAccountType = normalizeAccountTypeKey(usageRecord.accountType)
|
||||
const modelName = usageRecord.model || usageRecord.actualModel || usageRecord.service || null
|
||||
|
||||
if (!rawAccountId && !rawAccountType) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidateIds = new Set()
|
||||
if (rawAccountId) {
|
||||
candidateIds.add(rawAccountId)
|
||||
if (typeof rawAccountId === 'string' && rawAccountId.startsWith('responses:')) {
|
||||
candidateIds.add(rawAccountId.replace(/^responses:/, ''))
|
||||
}
|
||||
if (typeof rawAccountId === 'string' && rawAccountId.startsWith('api:')) {
|
||||
candidateIds.add(rawAccountId.replace(/^api:/, ''))
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateIds.size === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const typeCandidates = []
|
||||
const pushType = (type) => {
|
||||
const normalized = normalizeAccountTypeKey(type)
|
||||
if (normalized && ACCOUNT_TYPE_CONFIG[normalized] && !typeCandidates.includes(normalized)) {
|
||||
typeCandidates.push(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
pushType(rawAccountType)
|
||||
|
||||
if (modelName) {
|
||||
const lowerModel = modelName.toLowerCase()
|
||||
if (lowerModel.includes('gpt') || lowerModel.includes('openai')) {
|
||||
pushType('openai')
|
||||
pushType('openai-responses')
|
||||
pushType('azure-openai')
|
||||
} else if (lowerModel.includes('gemini')) {
|
||||
pushType('gemini')
|
||||
pushType('gemini-api')
|
||||
} else if (lowerModel.includes('claude') || lowerModel.includes('anthropic')) {
|
||||
pushType('claude')
|
||||
pushType('claude-console')
|
||||
} else if (lowerModel.includes('droid')) {
|
||||
pushType('droid')
|
||||
}
|
||||
}
|
||||
|
||||
ACCOUNT_TYPE_PRIORITY.forEach(pushType)
|
||||
|
||||
for (const type of typeCandidates) {
|
||||
const accountConfig = ACCOUNT_TYPE_CONFIG[type]
|
||||
if (!accountConfig) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const candidateId of candidateIds) {
|
||||
const normalizedId = sanitizeAccountIdForType(candidateId, type)
|
||||
const accountInfo = await this._fetchAccountInfo(normalizedId, type, cache, client)
|
||||
if (accountInfo) {
|
||||
return {
|
||||
accountId: normalizedId,
|
||||
accountName: accountInfo.name,
|
||||
accountType: type,
|
||||
accountCategory: ACCOUNT_CATEGORY_MAP[type] || 'other',
|
||||
rawAccountId: rawAccountId || normalizedId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async _resolveLastUsageAccount(apiKey, usageRecord, cache, client) {
|
||||
return await this._resolveAccountByUsageRecord(usageRecord, cache, client)
|
||||
}
|
||||
|
||||
// 🔔 发布计费事件(内部方法)
|
||||
async _publishBillingEvent(eventData) {
|
||||
try {
|
||||
const billingEventPublisher = require('./billingEventPublisher')
|
||||
await billingEventPublisher.publishBillingEvent(eventData)
|
||||
} catch (error) {
|
||||
// 静默失败,不影响主流程
|
||||
logger.debug('Failed to publish billing event:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔐 生成密钥
|
||||
_generateSecretKey() {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
@@ -1014,8 +1453,24 @@ class ApiKeyService {
|
||||
}
|
||||
|
||||
// 📈 获取使用统计
|
||||
async getUsageStats(keyId) {
|
||||
return await redis.getUsageStats(keyId)
|
||||
async getUsageStats(keyId, options = {}) {
|
||||
const usageStats = await redis.getUsageStats(keyId)
|
||||
|
||||
// options 可能是字符串(兼容旧接口),仅当为对象时才解析
|
||||
const optionObject =
|
||||
options && typeof options === 'object' && !Array.isArray(options) ? options : {}
|
||||
|
||||
if (optionObject.includeRecords === false) {
|
||||
return usageStats
|
||||
}
|
||||
|
||||
const recordLimit = optionObject.recordLimit || 20
|
||||
const recentRecords = await redis.getUsageRecords(keyId, recordLimit)
|
||||
|
||||
return {
|
||||
...usageStats,
|
||||
recentRecords
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 获取账户使用统计
|
||||
@@ -1067,9 +1522,11 @@ class ApiKeyService {
|
||||
dailyCost,
|
||||
totalCost: costStats.total,
|
||||
dailyCostLimit: parseFloat(key.dailyCostLimit || 0),
|
||||
totalCostLimit: parseFloat(key.totalCostLimit || 0),
|
||||
userId: key.userId,
|
||||
userUsername: key.userUsername,
|
||||
createdBy: key.createdBy,
|
||||
droidAccountId: key.droidAccountId,
|
||||
// Include deletion fields for deleted keys
|
||||
isDeleted: key.isDeleted,
|
||||
deletedAt: key.deletedAt,
|
||||
@@ -1112,7 +1569,17 @@ class ApiKeyService {
|
||||
userUsername: keyData.userUsername,
|
||||
createdBy: keyData.createdBy,
|
||||
permissions: keyData.permissions,
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0)
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
|
||||
// 所有平台账户绑定字段
|
||||
claudeAccountId: keyData.claudeAccountId,
|
||||
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
||||
geminiAccountId: keyData.geminiAccountId,
|
||||
openaiAccountId: keyData.openaiAccountId,
|
||||
bedrockAccountId: keyData.bedrockAccountId,
|
||||
droidAccountId: keyData.droidAccountId,
|
||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||
ccrAccountId: keyData.ccrAccountId
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get API key by ID:', error)
|
||||
@@ -1247,6 +1714,77 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔓 解绑账号从所有API Keys
|
||||
async unbindAccountFromAllKeys(accountId, accountType) {
|
||||
try {
|
||||
// 账号类型与字段的映射关系
|
||||
const fieldMap = {
|
||||
claude: 'claudeAccountId',
|
||||
'claude-console': 'claudeConsoleAccountId',
|
||||
gemini: 'geminiAccountId',
|
||||
'gemini-api': 'geminiAccountId', // 特殊处理,带 api: 前缀
|
||||
openai: 'openaiAccountId',
|
||||
'openai-responses': 'openaiAccountId', // 特殊处理,带 responses: 前缀
|
||||
azure_openai: 'azureOpenaiAccountId',
|
||||
bedrock: 'bedrockAccountId',
|
||||
droid: 'droidAccountId',
|
||||
ccr: null // CCR 账号没有对应的 API Key 字段
|
||||
}
|
||||
|
||||
const field = fieldMap[accountType]
|
||||
if (!field) {
|
||||
logger.info(`账号类型 ${accountType} 不需要解绑 API Key`)
|
||||
return 0
|
||||
}
|
||||
|
||||
// 获取所有API Keys
|
||||
const allKeys = await this.getAllApiKeys()
|
||||
|
||||
// 筛选绑定到此账号的 API Keys
|
||||
let boundKeys = []
|
||||
if (accountType === 'openai-responses') {
|
||||
// OpenAI-Responses 特殊处理:查找 openaiAccountId 字段中带 responses: 前缀的
|
||||
boundKeys = allKeys.filter((key) => key.openaiAccountId === `responses:${accountId}`)
|
||||
} else if (accountType === 'gemini-api') {
|
||||
// Gemini-API 特殊处理:查找 geminiAccountId 字段中带 api: 前缀的
|
||||
boundKeys = allKeys.filter((key) => key.geminiAccountId === `api:${accountId}`)
|
||||
} else {
|
||||
// 其他账号类型正常匹配
|
||||
boundKeys = allKeys.filter((key) => key[field] === accountId)
|
||||
}
|
||||
|
||||
// 批量解绑
|
||||
for (const key of boundKeys) {
|
||||
const updates = {}
|
||||
if (accountType === 'openai-responses') {
|
||||
updates.openaiAccountId = null
|
||||
} else if (accountType === 'gemini-api') {
|
||||
updates.geminiAccountId = null
|
||||
} else if (accountType === 'claude-console') {
|
||||
updates.claudeConsoleAccountId = null
|
||||
} else {
|
||||
updates[field] = null
|
||||
}
|
||||
|
||||
await this.updateApiKey(key.id, updates)
|
||||
logger.info(
|
||||
`✅ 自动解绑 API Key ${key.id} (${key.name}) 从 ${accountType} 账号 ${accountId}`
|
||||
)
|
||||
}
|
||||
|
||||
if (boundKeys.length > 0) {
|
||||
logger.success(
|
||||
`🔓 成功解绑 ${boundKeys.length} 个 API Key 从 ${accountType} 账号 ${accountId}`
|
||||
)
|
||||
}
|
||||
|
||||
return boundKeys.length
|
||||
} catch (error) {
|
||||
logger.error(`❌ 解绑 API Keys 失败 (${accountType} 账号 ${accountId}):`, error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 🧹 清理过期的API Keys
|
||||
async cleanupExpiredKeys() {
|
||||
try {
|
||||
|
||||
@@ -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) {
|
||||
@@ -303,7 +314,11 @@ async function getAllAccounts() {
|
||||
accounts.push({
|
||||
...accountData,
|
||||
isActive: accountData.isActive === 'true',
|
||||
schedulable: accountData.schedulable !== 'false'
|
||||
schedulable: accountData.schedulable !== 'false',
|
||||
|
||||
// ✅ 前端显示订阅过期时间(业务字段)
|
||||
expiresAt: accountData.subscriptionExpiresAt || null,
|
||||
platform: 'azure-openai'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -331,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,尝试获取之前分配的账户
|
||||
@@ -352,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')
|
||||
|
||||
@@ -82,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
|
||||
|
||||
@@ -56,6 +56,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账户
|
||||
@@ -142,9 +147,14 @@ class BedrockAccountService {
|
||||
priority: account.priority,
|
||||
schedulable: account.schedulable,
|
||||
credentialType: account.credentialType,
|
||||
|
||||
// ✅ 前端显示订阅过期时间(业务字段)
|
||||
expiresAt: account.subscriptionExpiresAt || null,
|
||||
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
type: 'bedrock',
|
||||
platform: 'bedrock',
|
||||
hasCredentials: !!account.awsCredentials
|
||||
})
|
||||
}
|
||||
@@ -225,6 +235,12 @@ class BedrockAccountService {
|
||||
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
|
||||
}
|
||||
|
||||
// ✅ 直接保存 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 +298,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' }
|
||||
@@ -352,6 +376,19 @@ class BedrockAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户订阅是否过期
|
||||
* @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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ const {
|
||||
const { fromEnv } = require('@aws-sdk/credential-providers')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const userMessageQueueService = require('./userMessageQueueService')
|
||||
|
||||
class BedrockRelayService {
|
||||
constructor() {
|
||||
@@ -69,7 +70,68 @@ class BedrockRelayService {
|
||||
|
||||
// 处理非流式请求
|
||||
async handleNonStreamRequest(requestBody, bedrockAccount = null) {
|
||||
const accountId = bedrockAccount?.id
|
||||
let queueLockAcquired = false
|
||||
let queueRequestId = null
|
||||
|
||||
try {
|
||||
// 📬 用户消息队列处理
|
||||
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||
// 校验 accountId 非空,避免空值污染队列锁键
|
||||
if (!accountId || accountId === '') {
|
||||
logger.error('❌ accountId missing for queue lock in Bedrock handleNonStreamRequest')
|
||||
throw new Error('accountId missing for queue lock')
|
||||
}
|
||||
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||
if (!queueResult.acquired && !queueResult.skipped) {
|
||||
// 区分 Redis 后端错误和队列超时
|
||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||
const errorMessage = isBackendError
|
||||
? 'Queue service temporarily unavailable, please retry later'
|
||||
: 'User message queue wait timeout, please retry later'
|
||||
const statusCode = isBackendError ? 500 : 503
|
||||
|
||||
// 结构化性能日志,用于后续统计
|
||||
logger.performance('user_message_queue_error', {
|
||||
errorType,
|
||||
errorCode,
|
||||
accountId,
|
||||
statusCode,
|
||||
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||
})
|
||||
|
||||
logger.warn(
|
||||
`📬 User message queue ${errorType} for Bedrock account ${accountId}`,
|
||||
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||
)
|
||||
return {
|
||||
statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-user-message-queue-error': errorType
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
code: errorCode,
|
||||
message: errorMessage
|
||||
}
|
||||
}),
|
||||
success: false
|
||||
}
|
||||
}
|
||||
if (queueResult.acquired && !queueResult.skipped) {
|
||||
queueLockAcquired = true
|
||||
queueRequestId = queueResult.requestId
|
||||
logger.debug(
|
||||
`📬 User message queue lock acquired for Bedrock account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const modelId = this._selectModel(requestBody, bedrockAccount)
|
||||
const region = this._selectRegion(modelId, bedrockAccount)
|
||||
const client = this._getBedrockClient(region, bedrockAccount)
|
||||
@@ -90,6 +152,23 @@ class BedrockRelayService {
|
||||
const response = await client.send(command)
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
|
||||
// 因为限流基于请求发送时刻计算(RPM),不是请求完成时刻
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||
logger.debug(
|
||||
`📬 User message queue lock released early for Bedrock account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock early for Bedrock account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
const responseBody = JSON.parse(new TextDecoder().decode(response.body))
|
||||
const claudeResponse = this._convertFromBedrockFormat(responseBody)
|
||||
@@ -106,12 +185,94 @@ class BedrockRelayService {
|
||||
} catch (error) {
|
||||
logger.error('❌ Bedrock非流式请求失败:', error)
|
||||
throw this._handleBedrockError(error)
|
||||
} finally {
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
logger.debug(
|
||||
`📬 User message queue lock released in finally for Bedrock account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock for Bedrock account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理流式请求
|
||||
async handleStreamRequest(requestBody, bedrockAccount = null, res) {
|
||||
const accountId = bedrockAccount?.id
|
||||
let queueLockAcquired = false
|
||||
let queueRequestId = null
|
||||
|
||||
try {
|
||||
// 📬 用户消息队列处理
|
||||
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||
// 校验 accountId 非空,避免空值污染队列锁键
|
||||
if (!accountId || accountId === '') {
|
||||
logger.error('❌ accountId missing for queue lock in Bedrock handleStreamRequest')
|
||||
throw new Error('accountId missing for queue lock')
|
||||
}
|
||||
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||
if (!queueResult.acquired && !queueResult.skipped) {
|
||||
// 区分 Redis 后端错误和队列超时
|
||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||
const errorMessage = isBackendError
|
||||
? 'Queue service temporarily unavailable, please retry later'
|
||||
: 'User message queue wait timeout, please retry later'
|
||||
const statusCode = isBackendError ? 500 : 503
|
||||
|
||||
// 结构化性能日志,用于后续统计
|
||||
logger.performance('user_message_queue_error', {
|
||||
errorType,
|
||||
errorCode,
|
||||
accountId,
|
||||
statusCode,
|
||||
stream: true,
|
||||
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||
})
|
||||
|
||||
logger.warn(
|
||||
`📬 User message queue ${errorType} for Bedrock account ${accountId} (stream)`,
|
||||
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||
)
|
||||
if (!res.headersSent) {
|
||||
const existingConnection = res.getHeader ? res.getHeader('Connection') : null
|
||||
res.writeHead(statusCode, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: existingConnection || 'keep-alive',
|
||||
'x-user-message-queue-error': errorType
|
||||
})
|
||||
}
|
||||
const errorEvent = `event: error\ndata: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
code: errorCode,
|
||||
message: errorMessage
|
||||
}
|
||||
})}\n\n`
|
||||
res.write(errorEvent)
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
return { success: false, error: errorType }
|
||||
}
|
||||
if (queueResult.acquired && !queueResult.skipped) {
|
||||
queueLockAcquired = true
|
||||
queueRequestId = queueResult.requestId
|
||||
logger.debug(
|
||||
`📬 User message queue lock acquired for Bedrock account ${accountId} (stream), requestId: ${queueRequestId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const modelId = this._selectModel(requestBody, bedrockAccount)
|
||||
const region = this._selectRegion(modelId, bedrockAccount)
|
||||
const client = this._getBedrockClient(region, bedrockAccount)
|
||||
@@ -131,11 +292,35 @@ class BedrockRelayService {
|
||||
const startTime = Date.now()
|
||||
const response = await client.send(command)
|
||||
|
||||
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
|
||||
// 因为限流基于请求发送时刻计算(RPM),不是请求完成时刻
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||
logger.debug(
|
||||
`📬 User message queue lock released early for Bedrock stream account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock early for Bedrock stream account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置SSE响应头
|
||||
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
|
||||
const existingConnection = res.getHeader ? res.getHeader('Connection') : null
|
||||
if (existingConnection) {
|
||||
logger.debug(
|
||||
`🔌 [Bedrock Stream] Preserving existing Connection header: ${existingConnection}`
|
||||
)
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
Connection: existingConnection || 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
||||
})
|
||||
@@ -191,6 +376,21 @@ class BedrockRelayService {
|
||||
res.end()
|
||||
|
||||
throw this._handleBedrockError(error)
|
||||
} finally {
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
logger.debug(
|
||||
`📬 User message queue lock released in finally for Bedrock stream account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock for Bedrock stream account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
224
src/services/billingEventPublisher.js
Normal file
@@ -0,0 +1,224 @@
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 计费事件发布器 - 使用 Redis Stream 解耦计费系统
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 异步非阻塞: 发布失败不影响主流程
|
||||
* 2. 结构化数据: 使用标准化的事件格式
|
||||
* 3. 可追溯性: 每个事件包含完整上下文
|
||||
*/
|
||||
class BillingEventPublisher {
|
||||
constructor() {
|
||||
this.streamKey = 'billing:events'
|
||||
this.maxLength = 100000 // 保留最近 10 万条事件
|
||||
this.enabled = process.env.BILLING_EVENTS_ENABLED !== 'false' // 默认开启
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布计费事件
|
||||
* @param {Object} eventData - 事件数据
|
||||
* @returns {Promise<string|null>} - 事件ID 或 null
|
||||
*/
|
||||
async publishBillingEvent(eventData) {
|
||||
if (!this.enabled) {
|
||||
logger.debug('📭 Billing events disabled, skipping publish')
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 构建标准化事件
|
||||
const event = {
|
||||
// 事件元数据
|
||||
eventId: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
eventType: 'usage.recorded',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
|
||||
// 核心计费数据
|
||||
apiKey: {
|
||||
id: eventData.keyId,
|
||||
name: eventData.keyName || null,
|
||||
userId: eventData.userId || null
|
||||
},
|
||||
|
||||
// 使用量详情
|
||||
usage: {
|
||||
model: eventData.model,
|
||||
inputTokens: eventData.inputTokens || 0,
|
||||
outputTokens: eventData.outputTokens || 0,
|
||||
cacheCreateTokens: eventData.cacheCreateTokens || 0,
|
||||
cacheReadTokens: eventData.cacheReadTokens || 0,
|
||||
ephemeral5mTokens: eventData.ephemeral5mTokens || 0,
|
||||
ephemeral1hTokens: eventData.ephemeral1hTokens || 0,
|
||||
totalTokens: eventData.totalTokens || 0
|
||||
},
|
||||
|
||||
// 费用详情
|
||||
cost: {
|
||||
total: eventData.cost || 0,
|
||||
currency: 'USD',
|
||||
breakdown: {
|
||||
input: eventData.costBreakdown?.input || 0,
|
||||
output: eventData.costBreakdown?.output || 0,
|
||||
cacheCreate: eventData.costBreakdown?.cacheCreate || 0,
|
||||
cacheRead: eventData.costBreakdown?.cacheRead || 0,
|
||||
ephemeral5m: eventData.costBreakdown?.ephemeral5m || 0,
|
||||
ephemeral1h: eventData.costBreakdown?.ephemeral1h || 0
|
||||
}
|
||||
},
|
||||
|
||||
// 账户信息
|
||||
account: {
|
||||
id: eventData.accountId || null,
|
||||
type: eventData.accountType || null
|
||||
},
|
||||
|
||||
// 请求上下文
|
||||
context: {
|
||||
isLongContext: eventData.isLongContext || false,
|
||||
requestTimestamp: eventData.requestTimestamp || new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 XADD 发布事件到 Stream
|
||||
// MAXLEN ~ 10000: 近似截断,保持性能
|
||||
const messageId = await client.xadd(
|
||||
this.streamKey,
|
||||
'MAXLEN',
|
||||
'~',
|
||||
this.maxLength,
|
||||
'*', // 自动生成消息ID
|
||||
'data',
|
||||
JSON.stringify(event)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
`📤 Published billing event: ${messageId} | Key: ${eventData.keyId} | Cost: $${event.cost.total.toFixed(6)}`
|
||||
)
|
||||
|
||||
return messageId
|
||||
} catch (error) {
|
||||
// ⚠️ 发布失败不影响主流程,只记录错误
|
||||
logger.error('❌ Failed to publish billing event:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发布计费事件(优化性能)
|
||||
* @param {Array<Object>} events - 事件数组
|
||||
* @returns {Promise<number>} - 成功发布的事件数
|
||||
*/
|
||||
async publishBatchBillingEvents(events) {
|
||||
if (!this.enabled || !events || events.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
events.forEach((eventData) => {
|
||||
const event = {
|
||||
eventId: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
eventType: 'usage.recorded',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
apiKey: {
|
||||
id: eventData.keyId,
|
||||
name: eventData.keyName || null
|
||||
},
|
||||
usage: {
|
||||
model: eventData.model,
|
||||
inputTokens: eventData.inputTokens || 0,
|
||||
outputTokens: eventData.outputTokens || 0,
|
||||
totalTokens: eventData.totalTokens || 0
|
||||
},
|
||||
cost: {
|
||||
total: eventData.cost || 0,
|
||||
currency: 'USD'
|
||||
}
|
||||
}
|
||||
|
||||
pipeline.xadd(
|
||||
this.streamKey,
|
||||
'MAXLEN',
|
||||
'~',
|
||||
this.maxLength,
|
||||
'*',
|
||||
'data',
|
||||
JSON.stringify(event)
|
||||
)
|
||||
})
|
||||
|
||||
const results = await pipeline.exec()
|
||||
const successCount = results.filter((r) => r[0] === null).length
|
||||
|
||||
logger.info(`📤 Batch published ${successCount}/${events.length} billing events`)
|
||||
return successCount
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to batch publish billing events:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Stream 信息(用于监控)
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getStreamInfo() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const info = await client.xinfo('STREAM', this.streamKey)
|
||||
|
||||
// 解析 Redis XINFO 返回的数组格式
|
||||
const result = {}
|
||||
for (let i = 0; i < info.length; i += 2) {
|
||||
result[info[i]] = info[i + 1]
|
||||
}
|
||||
|
||||
return {
|
||||
length: result.length || 0,
|
||||
firstEntry: result['first-entry'] || null,
|
||||
lastEntry: result['last-entry'] || null,
|
||||
groups: result.groups || 0
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('no such key')) {
|
||||
return { length: 0, groups: 0 }
|
||||
}
|
||||
logger.error('❌ Failed to get stream info:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建消费者组(供外部计费系统使用)
|
||||
* @param {string} groupName - 消费者组名称
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async createConsumerGroup(groupName = 'billing-system') {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// MKSTREAM: 如果 stream 不存在则创建
|
||||
await client.xgroup('CREATE', this.streamKey, groupName, '0', 'MKSTREAM')
|
||||
|
||||
logger.success(`✅ Created consumer group: ${groupName}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.message.includes('BUSYGROUP')) {
|
||||
logger.debug(`Consumer group ${groupName} already exists`)
|
||||
return true
|
||||
}
|
||||
logger.error(`❌ Failed to create consumer group ${groupName}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new BillingEventPublisher()
|
||||
@@ -76,6 +76,11 @@ class CcrAccountService {
|
||||
proxy: proxy ? JSON.stringify(proxy) : '',
|
||||
isActive: isActive.toString(),
|
||||
accountType,
|
||||
|
||||
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||
// 注意:CCR 使用 API Key 认证,没有 OAuth token,因此没有 expiresAt
|
||||
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
|
||||
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: '',
|
||||
status: 'active',
|
||||
@@ -165,6 +170,10 @@ class CcrAccountService {
|
||||
errorMessage: accountData.errorMessage,
|
||||
rateLimitInfo,
|
||||
schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度
|
||||
|
||||
// ✅ 前端显示订阅过期时间(业务字段)
|
||||
expiresAt: accountData.subscriptionExpiresAt || null,
|
||||
|
||||
// 额度管理相关
|
||||
dailyQuota: parseFloat(accountData.dailyQuota || '0'),
|
||||
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
|
||||
@@ -288,6 +297,12 @@ class CcrAccountService {
|
||||
updatedData.quotaResetTime = updates.quotaResetTime
|
||||
}
|
||||
|
||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||
// CCR 使用 API Key,没有 token 刷新逻辑,不会覆盖此字段
|
||||
if (updates.subscriptionExpiresAt !== undefined) {
|
||||
updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt
|
||||
}
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData)
|
||||
|
||||
// 处理共享账户集合变更
|
||||
@@ -548,8 +563,21 @@ class CcrAccountService {
|
||||
if (!modelMapping || Object.keys(modelMapping).length === 0) {
|
||||
return true
|
||||
}
|
||||
// 检查请求的模型是否在映射表的键中
|
||||
return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)
|
||||
|
||||
// 检查请求的模型是否在映射表的键中(精确匹配)
|
||||
if (Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 尝试大小写不敏感匹配
|
||||
const requestedModelLower = requestedModel.toLowerCase()
|
||||
for (const key of Object.keys(modelMapping)) {
|
||||
if (key.toLowerCase() === requestedModelLower) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 🔄 获取映射后的模型名称
|
||||
@@ -559,8 +587,21 @@ class CcrAccountService {
|
||||
return requestedModel
|
||||
}
|
||||
|
||||
// 返回映射后的模型名,如果不存在映射则返回原模型名
|
||||
return modelMapping[requestedModel] || requestedModel
|
||||
// 精确匹配
|
||||
if (modelMapping[requestedModel]) {
|
||||
return modelMapping[requestedModel]
|
||||
}
|
||||
|
||||
// 大小写不敏感匹配
|
||||
const requestedModelLower = requestedModel.toLowerCase()
|
||||
for (const [key, value] of Object.entries(modelMapping)) {
|
||||
if (key.toLowerCase() === requestedModelLower) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不存在映射则返回原模型名
|
||||
return requestedModel
|
||||
}
|
||||
|
||||
// 🔐 加密敏感数据
|
||||
@@ -898,6 +939,19 @@ class CcrAccountService {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CcrAccountService()
|
||||
|
||||
@@ -3,6 +3,8 @@ const ccrAccountService = require('./ccrAccountService')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||
const userMessageQueueService = require('./userMessageQueueService')
|
||||
const { isStreamWritable } = require('../utils/streamHelper')
|
||||
|
||||
class CcrRelayService {
|
||||
constructor() {
|
||||
@@ -21,8 +23,67 @@ class CcrRelayService {
|
||||
) {
|
||||
let abortController = null
|
||||
let account = null
|
||||
let queueLockAcquired = false
|
||||
let queueRequestId = null
|
||||
|
||||
try {
|
||||
// 📬 用户消息队列处理
|
||||
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||
// 校验 accountId 非空,避免空值污染队列锁键
|
||||
if (!accountId || accountId === '') {
|
||||
logger.error('❌ accountId missing for queue lock in CCR relayRequest')
|
||||
throw new Error('accountId missing for queue lock')
|
||||
}
|
||||
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||
if (!queueResult.acquired && !queueResult.skipped) {
|
||||
// 区分 Redis 后端错误和队列超时
|
||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||
const errorMessage = isBackendError
|
||||
? 'Queue service temporarily unavailable, please retry later'
|
||||
: 'User message queue wait timeout, please retry later'
|
||||
const statusCode = isBackendError ? 500 : 503
|
||||
|
||||
// 结构化性能日志,用于后续统计
|
||||
logger.performance('user_message_queue_error', {
|
||||
errorType,
|
||||
errorCode,
|
||||
accountId,
|
||||
statusCode,
|
||||
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||
})
|
||||
|
||||
logger.warn(
|
||||
`📬 User message queue ${errorType} for CCR account ${accountId}`,
|
||||
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||
)
|
||||
return {
|
||||
statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-user-message-queue-error': errorType
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
code: errorCode,
|
||||
message: errorMessage
|
||||
}
|
||||
}),
|
||||
accountId
|
||||
}
|
||||
}
|
||||
if (queueResult.acquired && !queueResult.skipped) {
|
||||
queueLockAcquired = true
|
||||
queueRequestId = queueResult.requestId
|
||||
logger.debug(
|
||||
`📬 User message queue lock acquired for CCR account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取账户信息
|
||||
account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
@@ -121,12 +182,17 @@ class CcrRelayService {
|
||||
'User-Agent': userAgent,
|
||||
...filteredHeaders
|
||||
},
|
||||
httpsAgent: proxyAgent,
|
||||
timeout: config.requestTimeout || 600000,
|
||||
signal: abortController.signal,
|
||||
validateStatus: () => true // 接受所有状态码
|
||||
}
|
||||
|
||||
if (proxyAgent) {
|
||||
requestConfig.httpAgent = proxyAgent
|
||||
requestConfig.httpsAgent = proxyAgent
|
||||
requestConfig.proxy = false
|
||||
}
|
||||
|
||||
// 根据 API Key 格式选择认证方式
|
||||
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
|
||||
// Anthropic 官方 API Key 使用 x-api-key
|
||||
@@ -157,6 +223,23 @@ class CcrRelayService {
|
||||
)
|
||||
const response = await axios(requestConfig)
|
||||
|
||||
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
|
||||
// 因为 Claude API 限流基于请求发送时刻计算(RPM),不是请求完成时刻
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||
logger.debug(
|
||||
`📬 User message queue lock released early for CCR account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock early for CCR account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 移除监听器(请求成功完成)
|
||||
if (clientRequest) {
|
||||
clientRequest.removeListener('close', handleClientDisconnect)
|
||||
@@ -228,6 +311,21 @@ class CcrRelayService {
|
||||
)
|
||||
|
||||
throw error
|
||||
} finally {
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
logger.debug(
|
||||
`📬 User message queue lock released in finally for CCR account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock for CCR account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +341,77 @@ class CcrRelayService {
|
||||
options = {}
|
||||
) {
|
||||
let account = null
|
||||
let queueLockAcquired = false
|
||||
let queueRequestId = null
|
||||
|
||||
try {
|
||||
// 📬 用户消息队列处理
|
||||
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||
// 校验 accountId 非空,避免空值污染队列锁键
|
||||
if (!accountId || accountId === '') {
|
||||
logger.error(
|
||||
'❌ accountId missing for queue lock in CCR relayStreamRequestWithUsageCapture'
|
||||
)
|
||||
throw new Error('accountId missing for queue lock')
|
||||
}
|
||||
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||
if (!queueResult.acquired && !queueResult.skipped) {
|
||||
// 区分 Redis 后端错误和队列超时
|
||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||
const errorMessage = isBackendError
|
||||
? 'Queue service temporarily unavailable, please retry later'
|
||||
: 'User message queue wait timeout, please retry later'
|
||||
const statusCode = isBackendError ? 500 : 503
|
||||
|
||||
// 结构化性能日志,用于后续<E5908E><E7BBAD>计
|
||||
logger.performance('user_message_queue_error', {
|
||||
errorType,
|
||||
errorCode,
|
||||
accountId,
|
||||
statusCode,
|
||||
stream: true,
|
||||
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||
})
|
||||
|
||||
logger.warn(
|
||||
`📬 User message queue ${errorType} for CCR account ${accountId} (stream)`,
|
||||
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||
)
|
||||
if (!responseStream.headersSent) {
|
||||
const existingConnection = responseStream.getHeader
|
||||
? responseStream.getHeader('Connection')
|
||||
: null
|
||||
responseStream.writeHead(statusCode, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: existingConnection || 'keep-alive',
|
||||
'x-user-message-queue-error': errorType
|
||||
})
|
||||
}
|
||||
const errorEvent = `event: error\ndata: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
code: errorCode,
|
||||
message: errorMessage
|
||||
}
|
||||
})}\n\n`
|
||||
responseStream.write(errorEvent)
|
||||
responseStream.write('data: [DONE]\n\n')
|
||||
responseStream.end()
|
||||
return
|
||||
}
|
||||
if (queueResult.acquired && !queueResult.skipped) {
|
||||
queueLockAcquired = true
|
||||
queueRequestId = queueResult.requestId
|
||||
logger.debug(
|
||||
`📬 User message queue lock acquired for CCR account ${accountId} (stream), requestId: ${queueRequestId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取账户信息
|
||||
account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
@@ -291,14 +459,53 @@ class CcrRelayService {
|
||||
accountId,
|
||||
usageCallback,
|
||||
streamTransformer,
|
||||
options
|
||||
options,
|
||||
// 📬 回调:在收到响应头时释放队列锁
|
||||
async () => {
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||
logger.debug(
|
||||
`📬 User message queue lock released early for CCR stream account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock early for CCR stream account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 更新最后使用时间
|
||||
await this._updateLastUsedTime(accountId)
|
||||
} catch (error) {
|
||||
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
|
||||
// 客户端主动断开连接是正常情况,使用 INFO 级别
|
||||
if (error.message === 'Client disconnected') {
|
||||
logger.info(
|
||||
`🔌 CCR stream relay ended: Client disconnected (Account: ${account?.name || accountId})`
|
||||
)
|
||||
} else {
|
||||
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放)
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
logger.debug(
|
||||
`📬 User message queue lock released in finally for CCR stream account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock for CCR stream account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +519,8 @@ class CcrRelayService {
|
||||
accountId,
|
||||
usageCallback,
|
||||
streamTransformer = null,
|
||||
requestOptions = {}
|
||||
requestOptions = {},
|
||||
onResponseHeaderReceived = null
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let aborted = false
|
||||
@@ -345,12 +553,17 @@ class CcrRelayService {
|
||||
'User-Agent': userAgent,
|
||||
...filteredHeaders
|
||||
},
|
||||
httpsAgent: proxyAgent,
|
||||
timeout: config.requestTimeout || 600000,
|
||||
responseType: 'stream',
|
||||
validateStatus: () => true // 接受所有状态码
|
||||
}
|
||||
|
||||
if (proxyAgent) {
|
||||
requestConfig.httpAgent = proxyAgent
|
||||
requestConfig.httpsAgent = proxyAgent
|
||||
requestConfig.proxy = false
|
||||
}
|
||||
|
||||
// 根据 API Key 格式选择认证方式
|
||||
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
|
||||
// Anthropic 官方 API Key 使用 x-api-key
|
||||
@@ -370,8 +583,11 @@ class CcrRelayService {
|
||||
// 发送请求
|
||||
const request = axios(requestConfig)
|
||||
|
||||
// 注意:使用 .then(async ...) 模式处理响应
|
||||
// - 内部的 releaseQueueLock 有独立的 try-catch,不会导致未捕获异常
|
||||
// - queueLockAcquired = false 的赋值会在 finally 执行前完成(JS 单线程保证)
|
||||
request
|
||||
.then((response) => {
|
||||
.then(async (response) => {
|
||||
logger.debug(`🌊 CCR stream response status: ${response.status}`)
|
||||
|
||||
// 错误响应处理
|
||||
@@ -394,10 +610,13 @@ class CcrRelayService {
|
||||
|
||||
// 设置错误响应的状态码和响应头
|
||||
if (!responseStream.headersSent) {
|
||||
const existingConnection = responseStream.getHeader
|
||||
? responseStream.getHeader('Connection')
|
||||
: null
|
||||
const errorHeaders = {
|
||||
'Content-Type': response.headers['content-type'] || 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive'
|
||||
Connection: existingConnection || 'keep-alive'
|
||||
}
|
||||
// 避免 Transfer-Encoding 冲突,让 Express 自动处理
|
||||
delete errorHeaders['Transfer-Encoding']
|
||||
@@ -407,13 +626,13 @@ class CcrRelayService {
|
||||
|
||||
// 直接透传错误数据,不进行包装
|
||||
response.data.on('data', (chunk) => {
|
||||
if (!responseStream.destroyed) {
|
||||
if (isStreamWritable(responseStream)) {
|
||||
responseStream.write(chunk)
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
if (!responseStream.destroyed) {
|
||||
if (isStreamWritable(responseStream)) {
|
||||
responseStream.end()
|
||||
}
|
||||
resolve() // 不抛出异常,正常完成流处理
|
||||
@@ -421,6 +640,19 @@ class CcrRelayService {
|
||||
return
|
||||
}
|
||||
|
||||
// 📬 收到成功响应头(HTTP 200),调用回调释放队列锁
|
||||
// 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成
|
||||
if (onResponseHeaderReceived && typeof onResponseHeaderReceived === 'function') {
|
||||
try {
|
||||
await onResponseHeaderReceived()
|
||||
} catch (callbackError) {
|
||||
logger.error(
|
||||
`❌ Failed to execute onResponseHeaderReceived callback for CCR stream account ${accountId}:`,
|
||||
callbackError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 成功响应,检查并移除错误状态
|
||||
ccrAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
|
||||
if (isRateLimited) {
|
||||
@@ -434,11 +666,20 @@ class CcrRelayService {
|
||||
})
|
||||
|
||||
// 设置响应头
|
||||
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
|
||||
if (!responseStream.headersSent) {
|
||||
const existingConnection = responseStream.getHeader
|
||||
? responseStream.getHeader('Connection')
|
||||
: null
|
||||
if (existingConnection) {
|
||||
logger.debug(
|
||||
`🔌 [CCR Stream] Preserving existing Connection header: ${existingConnection}`
|
||||
)
|
||||
}
|
||||
const headers = {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
Connection: existingConnection || 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||
}
|
||||
@@ -477,12 +718,17 @@ class CcrRelayService {
|
||||
}
|
||||
|
||||
// 写入到响应流
|
||||
if (outputLine && !responseStream.destroyed) {
|
||||
if (outputLine && isStreamWritable(responseStream)) {
|
||||
responseStream.write(`${outputLine}\n`)
|
||||
} else if (outputLine) {
|
||||
// 客户端连接已断开,记录警告
|
||||
logger.warn(
|
||||
`⚠️ [CCR] Client disconnected during stream, skipping data for account: ${accountId}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 空行也需要传递
|
||||
if (!responseStream.destroyed) {
|
||||
if (isStreamWritable(responseStream)) {
|
||||
responseStream.write('\n')
|
||||
}
|
||||
}
|
||||
@@ -493,10 +739,6 @@ class CcrRelayService {
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.end()
|
||||
}
|
||||
|
||||
// 如果收集到使用统计数据,调用回调
|
||||
if (usageCallback && Object.keys(collectedUsage).length > 0) {
|
||||
try {
|
||||
@@ -508,12 +750,26 @@ class CcrRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
resolve()
|
||||
if (isStreamWritable(responseStream)) {
|
||||
// 等待数据完全 flush 到客户端后再 resolve
|
||||
responseStream.end(() => {
|
||||
logger.debug(
|
||||
`🌊 CCR stream response completed and flushed | bytesWritten: ${responseStream.bytesWritten || 'unknown'}`
|
||||
)
|
||||
resolve()
|
||||
})
|
||||
} else {
|
||||
// 连接已断开,记录警告
|
||||
logger.warn(
|
||||
`⚠️ [CCR] Client disconnected before stream end, data may not have been received | account: ${accountId}`
|
||||
)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
logger.error('❌ Stream data error:', err)
|
||||
if (!responseStream.destroyed) {
|
||||
if (isStreamWritable(responseStream)) {
|
||||
responseStream.end()
|
||||
}
|
||||
reject(err)
|
||||
@@ -545,7 +801,7 @@ class CcrRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!responseStream.destroyed) {
|
||||
if (isStreamWritable(responseStream)) {
|
||||
responseStream.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
|
||||
responseStream.end()
|
||||
}
|
||||
|
||||
@@ -36,6 +36,20 @@ class ClaudeConsoleAccountService {
|
||||
)
|
||||
}
|
||||
|
||||
_getBlockedHandlingMinutes() {
|
||||
const raw = process.env.CLAUDE_CONSOLE_BLOCKED_HANDLING_MINUTES
|
||||
if (raw === undefined || raw === null || raw === '') {
|
||||
return 0
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
// 🏢 创建Claude Console账户
|
||||
async createAccount(options = {}) {
|
||||
const {
|
||||
@@ -52,7 +66,10 @@ class ClaudeConsoleAccountService {
|
||||
accountType = 'shared', // 'dedicated' or 'shared'
|
||||
schedulable = true, // 是否可被调度
|
||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||
quotaResetTime = '00:00' // 额度重置时间(HH:mm格式)
|
||||
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
||||
maxConcurrentTasks = 0, // 最大并发任务数,0表示无限制
|
||||
disableAutoProtection = false, // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
||||
interceptWarmup = false // 拦截预热请求(标题生成、Warmup等)
|
||||
} = options
|
||||
|
||||
// 验证必填字段
|
||||
@@ -83,6 +100,11 @@ class ClaudeConsoleAccountService {
|
||||
lastUsedAt: '',
|
||||
status: 'active',
|
||||
errorMessage: '',
|
||||
|
||||
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||
// 注意:Claude Console 没有 OAuth token,因此没有 expiresAt(token过期)
|
||||
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
|
||||
|
||||
// 限流相关
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: '',
|
||||
@@ -94,7 +116,10 @@ class ClaudeConsoleAccountService {
|
||||
// 使用与统计一致的时区日期,避免边界问题
|
||||
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
||||
quotaResetTime, // 额度重置时间
|
||||
quotaStoppedAt: '' // 因额度停用的时间
|
||||
quotaStoppedAt: '', // 因额度停用的时间
|
||||
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数,0表示无限制
|
||||
disableAutoProtection: disableAutoProtection.toString(), // 关闭自动防护
|
||||
interceptWarmup: interceptWarmup.toString() // 拦截预热请求
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
@@ -130,7 +155,11 @@ class ClaudeConsoleAccountService {
|
||||
dailyUsage: 0,
|
||||
lastResetDate: accountData.lastResetDate,
|
||||
quotaResetTime,
|
||||
quotaStoppedAt: null
|
||||
quotaStoppedAt: null,
|
||||
maxConcurrentTasks, // 新增:返回并发限制配置
|
||||
disableAutoProtection, // 新增:返回自动防护开关
|
||||
interceptWarmup, // 新增:返回预热请求拦截开关
|
||||
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,9 +173,18 @@ class ClaudeConsoleAccountService {
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
if (!accountData.id) {
|
||||
logger.warn(`⚠️ 检测到缺少ID的Claude Console账户数据,执行清理: ${key}`)
|
||||
await client.del(key)
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||
|
||||
// 获取实时并发计数
|
||||
const activeTaskCount = await redis.getConsoleAccountConcurrency(accountData.id)
|
||||
|
||||
accounts.push({
|
||||
id: accountData.id,
|
||||
platform: accountData.platform,
|
||||
@@ -168,12 +206,23 @@ class ClaudeConsoleAccountService {
|
||||
errorMessage: accountData.errorMessage,
|
||||
rateLimitInfo,
|
||||
schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度
|
||||
|
||||
// ✅ 前端显示订阅过期时间(业务字段)
|
||||
expiresAt: accountData.subscriptionExpiresAt || null,
|
||||
|
||||
// 额度管理相关
|
||||
dailyQuota: parseFloat(accountData.dailyQuota || '0'),
|
||||
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
|
||||
lastResetDate: accountData.lastResetDate || '',
|
||||
quotaResetTime: accountData.quotaResetTime || '00:00',
|
||||
quotaStoppedAt: accountData.quotaStoppedAt || null
|
||||
quotaStoppedAt: accountData.quotaStoppedAt || null,
|
||||
|
||||
// 并发控制相关
|
||||
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
|
||||
activeTaskCount,
|
||||
disableAutoProtection: accountData.disableAutoProtection === 'true',
|
||||
// 拦截预热请求
|
||||
interceptWarmup: accountData.interceptWarmup === 'true'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -219,11 +268,17 @@ class ClaudeConsoleAccountService {
|
||||
}
|
||||
accountData.isActive = accountData.isActive === 'true'
|
||||
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
|
||||
accountData.disableAutoProtection = accountData.disableAutoProtection === 'true'
|
||||
|
||||
if (accountData.proxy) {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
}
|
||||
|
||||
// 解析并发控制字段
|
||||
accountData.maxConcurrentTasks = parseInt(accountData.maxConcurrentTasks) || 0
|
||||
// 获取实时并发计数
|
||||
accountData.activeTaskCount = await redis.getConsoleAccountConcurrency(accountId)
|
||||
|
||||
logger.debug(
|
||||
`[DEBUG] Final account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}`
|
||||
)
|
||||
@@ -318,6 +373,23 @@ class ClaudeConsoleAccountService {
|
||||
updatedData.quotaStoppedAt = updates.quotaStoppedAt
|
||||
}
|
||||
|
||||
// 并发控制相关字段
|
||||
if (updates.maxConcurrentTasks !== undefined) {
|
||||
updatedData.maxConcurrentTasks = updates.maxConcurrentTasks.toString()
|
||||
}
|
||||
if (updates.disableAutoProtection !== undefined) {
|
||||
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
|
||||
}
|
||||
if (updates.interceptWarmup !== undefined) {
|
||||
updatedData.interceptWarmup = updates.interceptWarmup.toString()
|
||||
}
|
||||
|
||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
|
||||
if (updates.subscriptionExpiresAt !== undefined) {
|
||||
updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt
|
||||
}
|
||||
|
||||
// 处理账户类型变更
|
||||
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
|
||||
updatedData.accountType = updates.accountType
|
||||
@@ -490,20 +562,29 @@ class ClaudeConsoleAccountService {
|
||||
errorMessage: ''
|
||||
}
|
||||
|
||||
const hadAutoStop = accountData.rateLimitAutoStopped === 'true'
|
||||
|
||||
// 只恢复因限流而自动停止的账户
|
||||
if (accountData.rateLimitAutoStopped === 'true' && accountData.schedulable === 'false') {
|
||||
if (hadAutoStop && accountData.schedulable === 'false') {
|
||||
updateData.schedulable = 'true' // 恢复调度
|
||||
// 删除限流自动停止标记
|
||||
await client.hdel(accountKey, 'rateLimitAutoStopped')
|
||||
logger.info(
|
||||
`✅ Auto-resuming scheduling for Claude Console account ${accountId} after rate limit cleared`
|
||||
)
|
||||
}
|
||||
|
||||
if (hadAutoStop) {
|
||||
await client.hdel(accountKey, 'rateLimitAutoStopped')
|
||||
}
|
||||
|
||||
await client.hset(accountKey, updateData)
|
||||
logger.success(`✅ Rate limit removed and account re-enabled: ${accountId}`)
|
||||
}
|
||||
} else {
|
||||
if (await client.hdel(accountKey, 'rateLimitAutoStopped')) {
|
||||
logger.info(
|
||||
`ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during rate limit recovery`
|
||||
)
|
||||
}
|
||||
logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`)
|
||||
}
|
||||
|
||||
@@ -660,6 +741,183 @@ class ClaudeConsoleAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账号为临时封禁状态(400错误 - 账户临时禁用)
|
||||
async markConsoleAccountBlocked(accountId, errorDetails = '') {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const account = await this.getAccount(accountId)
|
||||
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const blockedMinutes = this._getBlockedHandlingMinutes()
|
||||
|
||||
if (blockedMinutes <= 0) {
|
||||
logger.info(
|
||||
`ℹ️ CLAUDE_CONSOLE_BLOCKED_HANDLING_MINUTES 未设置或为0,跳过账户封禁:${account.name} (${accountId})`
|
||||
)
|
||||
|
||||
if (account.blockedStatus === 'blocked') {
|
||||
try {
|
||||
await this.removeAccountBlocked(accountId)
|
||||
} catch (cleanupError) {
|
||||
logger.warn(`⚠️ 尝试移除账户封禁状态失败:${accountId}`, cleanupError)
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, skipped: true }
|
||||
}
|
||||
|
||||
const updates = {
|
||||
blockedAt: new Date().toISOString(),
|
||||
blockedStatus: 'blocked',
|
||||
isActive: 'false', // 禁用账户(与429保持一致)
|
||||
schedulable: 'false', // 停止调度(与429保持一致)
|
||||
status: 'account_blocked', // 设置状态(与429保持一致)
|
||||
errorMessage: '账户临时被禁用(400错误)',
|
||||
// 使用独立的封禁自动停止标记
|
||||
blockedAutoStopped: 'true'
|
||||
}
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
|
||||
|
||||
// 发送Webhook通知,包含完整错误详情
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || 'Claude Console Account',
|
||||
platform: 'claude-console',
|
||||
status: 'error',
|
||||
errorCode: 'CLAUDE_CONSOLE_BLOCKED',
|
||||
reason: `账户临时被禁用(400错误)。账户将在 ${blockedMinutes} 分钟后自动恢复。`,
|
||||
errorDetails: errorDetails || '无错误详情',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send blocked webhook notification:', webhookError)
|
||||
}
|
||||
|
||||
logger.warn(`🚫 Claude Console account temporarily blocked: ${account.name} (${accountId})`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark Claude Console account as blocked: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 移除账号的临时封禁状态
|
||||
async removeAccountBlocked(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// 获取账户当前状态和额度信息
|
||||
const [currentStatus, quotaStoppedAt] = await client.hmget(
|
||||
accountKey,
|
||||
'status',
|
||||
'quotaStoppedAt'
|
||||
)
|
||||
|
||||
// 删除封禁相关字段
|
||||
await client.hdel(accountKey, 'blockedAt', 'blockedStatus')
|
||||
|
||||
// 根据不同情况决定是否恢复账户
|
||||
if (currentStatus === 'account_blocked') {
|
||||
if (quotaStoppedAt) {
|
||||
// 还有额度限制,改为quota_exceeded状态
|
||||
await client.hset(accountKey, {
|
||||
status: 'quota_exceeded'
|
||||
// isActive保持false
|
||||
})
|
||||
logger.info(
|
||||
`⚠️ Blocked status removed but quota exceeded remains for account: ${accountId}`
|
||||
)
|
||||
} else {
|
||||
// 没有额度限制,完全恢复
|
||||
const accountData = await client.hgetall(accountKey)
|
||||
const updateData = {
|
||||
isActive: 'true',
|
||||
status: 'active',
|
||||
errorMessage: ''
|
||||
}
|
||||
|
||||
const hadAutoStop = accountData.blockedAutoStopped === 'true'
|
||||
|
||||
// 只恢复因封禁而自动停止的账户
|
||||
if (hadAutoStop && accountData.schedulable === 'false') {
|
||||
updateData.schedulable = 'true' // 恢复调度
|
||||
logger.info(
|
||||
`✅ Auto-resuming scheduling for Claude Console account ${accountId} after blocked status cleared`
|
||||
)
|
||||
}
|
||||
|
||||
if (hadAutoStop) {
|
||||
await client.hdel(accountKey, 'blockedAutoStopped')
|
||||
}
|
||||
|
||||
await client.hset(accountKey, updateData)
|
||||
logger.success(`✅ Blocked status removed and account re-enabled: ${accountId}`)
|
||||
}
|
||||
} else {
|
||||
if (await client.hdel(accountKey, 'blockedAutoStopped')) {
|
||||
logger.info(
|
||||
`ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during blocked status recovery`
|
||||
)
|
||||
}
|
||||
logger.success(`✅ Blocked status removed for Claude Console account: ${accountId}`)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Failed to remove blocked status for Claude Console account: ${accountId}`,
|
||||
error
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账号是否处于临时封禁状态
|
||||
async isAccountBlocked(accountId) {
|
||||
try {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (account.blockedStatus === 'blocked' && account.blockedAt) {
|
||||
const blockedDuration = this._getBlockedHandlingMinutes()
|
||||
|
||||
if (blockedDuration <= 0) {
|
||||
await this.removeAccountBlocked(accountId)
|
||||
return false
|
||||
}
|
||||
|
||||
const blockedAt = new Date(account.blockedAt)
|
||||
const now = new Date()
|
||||
const minutesSinceBlocked = (now - blockedAt) / (1000 * 60)
|
||||
|
||||
// 禁用时长过后自动恢复
|
||||
if (minutesSinceBlocked >= blockedDuration) {
|
||||
await this.removeAccountBlocked(accountId)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Failed to check blocked status for Claude Console account: ${accountId}`,
|
||||
error
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账号为过载状态(529错误)
|
||||
async markAccountOverloaded(accountId) {
|
||||
try {
|
||||
@@ -966,8 +1224,20 @@ class ClaudeConsoleAccountService {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查请求的模型是否在映射表的键中
|
||||
return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)
|
||||
// 检查请求的模型是否在映射表的键中(精确匹配)
|
||||
if (Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 尝试大小写不敏感匹配
|
||||
const requestedModelLower = requestedModel.toLowerCase()
|
||||
for (const key of Object.keys(modelMapping)) {
|
||||
if (key.toLowerCase() === requestedModelLower) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 🔄 获取映射后的模型名称
|
||||
@@ -977,8 +1247,21 @@ class ClaudeConsoleAccountService {
|
||||
return requestedModel
|
||||
}
|
||||
|
||||
// 返回映射后的模型,如果不存在则返回原模型
|
||||
return modelMapping[requestedModel] || requestedModel
|
||||
// 精确匹配
|
||||
if (modelMapping[requestedModel]) {
|
||||
return modelMapping[requestedModel]
|
||||
}
|
||||
|
||||
// 大小写不敏感匹配
|
||||
const requestedModelLower = requestedModel.toLowerCase()
|
||||
for (const [key, value] of Object.entries(modelMapping)) {
|
||||
if (key.toLowerCase() === requestedModelLower) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不存在则返回原模型
|
||||
return requestedModel
|
||||
}
|
||||
|
||||
// 💰 检查账户使用额度(基于实时统计数据)
|
||||
@@ -1230,6 +1513,84 @@ class ClaudeConsoleAccountService {
|
||||
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()
|
||||
}
|
||||
|
||||
// 🚫 标记账户的 count_tokens 端点不可用
|
||||
async markCountTokensUnavailable(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// 检查账户是否存在
|
||||
const exists = await client.exists(accountKey)
|
||||
if (!exists) {
|
||||
logger.warn(
|
||||
`⚠️ Cannot mark count_tokens unavailable for non-existent account: ${accountId}`
|
||||
)
|
||||
return { success: false, reason: 'Account not found' }
|
||||
}
|
||||
|
||||
await client.hset(accountKey, {
|
||||
countTokensUnavailable: 'true',
|
||||
countTokensUnavailableAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`🚫 Marked count_tokens endpoint as unavailable for Claude Console account: ${accountId}`
|
||||
)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark count_tokens unavailable for account ${accountId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 移除账户的 count_tokens 不可用标记
|
||||
async removeCountTokensUnavailable(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
await client.hdel(accountKey, 'countTokensUnavailable', 'countTokensUnavailableAt')
|
||||
|
||||
logger.info(
|
||||
`✅ Removed count_tokens unavailable mark for Claude Console account: ${accountId}`
|
||||
)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Failed to remove count_tokens unavailable mark for account ${accountId}:`,
|
||||
error
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账户的 count_tokens 端点是否不可用
|
||||
async isCountTokensUnavailable(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
const value = await client.hget(accountKey, 'countTokensUnavailable')
|
||||
return value === 'true'
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check count_tokens availability for account ${accountId}:`, error)
|
||||
return false // 出错时默认返回可用,避免误阻断
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeConsoleAccountService()
|
||||
|
||||
453
src/services/claudeRelayConfigService.js
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Claude 转发配置服务
|
||||
* 管理全局 Claude Code 限制和会话绑定配置
|
||||
*/
|
||||
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
const CONFIG_KEY = 'claude_relay_config'
|
||||
const SESSION_BINDING_PREFIX = 'original_session_binding:'
|
||||
|
||||
// 默认配置
|
||||
const DEFAULT_CONFIG = {
|
||||
claudeCodeOnlyEnabled: false,
|
||||
globalSessionBindingEnabled: false,
|
||||
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
||||
sessionBindingTtlDays: 30, // 会话绑定 TTL(天),默认30天
|
||||
// 用户消息队列配置
|
||||
userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭)
|
||||
userMessageQueueDelayMs: 200, // 请求间隔(毫秒)
|
||||
userMessageQueueTimeoutMs: 5000, // 队列等待超时(毫秒),优化后锁持有时间短无需长等待
|
||||
userMessageQueueLockTtlMs: 5000, // 锁TTL(毫秒),请求发送后立即释放无需长TTL
|
||||
// 并发请求排队配置
|
||||
concurrentRequestQueueEnabled: false, // 是否启用并发请求排队(默认关闭)
|
||||
concurrentRequestQueueMaxSize: 3, // 固定最小排队数(默认3)
|
||||
concurrentRequestQueueMaxSizeMultiplier: 0, // 并发数的倍数(默认0,仅使用固定值)
|
||||
concurrentRequestQueueTimeoutMs: 10000, // 排队超时(毫秒,默认10秒)
|
||||
concurrentRequestQueueMaxRedisFailCount: 5, // 连续 Redis 失败阈值(默认5次)
|
||||
// 排队健康检查配置
|
||||
concurrentRequestQueueHealthCheckEnabled: true, // 是否启用排队健康检查(默认开启)
|
||||
concurrentRequestQueueHealthThreshold: 0.8, // 健康检查阈值(P90 >= 超时 × 阈值时拒绝新请求)
|
||||
updatedAt: null,
|
||||
updatedBy: null
|
||||
}
|
||||
|
||||
// 内存缓存(避免频繁 Redis 查询)
|
||||
let configCache = null
|
||||
let configCacheTime = 0
|
||||
const CONFIG_CACHE_TTL = 60000 // 1分钟缓存
|
||||
|
||||
class ClaudeRelayConfigService {
|
||||
/**
|
||||
* 从 metadata.user_id 中提取原始 sessionId
|
||||
* 格式: user_{64位十六进制}_account__session_{uuid}
|
||||
* @param {Object} requestBody - 请求体
|
||||
* @returns {string|null} 原始 sessionId 或 null
|
||||
*/
|
||||
extractOriginalSessionId(requestBody) {
|
||||
if (!requestBody?.metadata?.user_id) {
|
||||
return null
|
||||
}
|
||||
|
||||
const userId = requestBody.metadata.user_id
|
||||
const match = userId.match(/session_([a-f0-9-]{36})$/i)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置(带缓存)
|
||||
* @returns {Promise<Object>} 配置对象
|
||||
*/
|
||||
async getConfig() {
|
||||
try {
|
||||
// 检查缓存
|
||||
if (configCache && Date.now() - configCacheTime < CONFIG_CACHE_TTL) {
|
||||
return configCache
|
||||
}
|
||||
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
logger.warn('⚠️ Redis not connected, using default config')
|
||||
return { ...DEFAULT_CONFIG }
|
||||
}
|
||||
|
||||
const data = await client.get(CONFIG_KEY)
|
||||
|
||||
if (data) {
|
||||
configCache = { ...DEFAULT_CONFIG, ...JSON.parse(data) }
|
||||
} else {
|
||||
configCache = { ...DEFAULT_CONFIG }
|
||||
}
|
||||
|
||||
configCacheTime = Date.now()
|
||||
return configCache
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Claude relay config:', error)
|
||||
return { ...DEFAULT_CONFIG }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
* @param {Object} newConfig - 新配置
|
||||
* @param {string} updatedBy - 更新者
|
||||
* @returns {Promise<Object>} 更新后的配置
|
||||
*/
|
||||
async updateConfig(newConfig, updatedBy) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const currentConfig = await this.getConfig()
|
||||
|
||||
const updatedConfig = {
|
||||
...currentConfig,
|
||||
...newConfig,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy
|
||||
}
|
||||
|
||||
await client.set(CONFIG_KEY, JSON.stringify(updatedConfig))
|
||||
|
||||
// 更新缓存
|
||||
configCache = updatedConfig
|
||||
configCacheTime = Date.now()
|
||||
|
||||
logger.info(`✅ Claude relay config updated by ${updatedBy}:`, {
|
||||
claudeCodeOnlyEnabled: updatedConfig.claudeCodeOnlyEnabled,
|
||||
globalSessionBindingEnabled: updatedConfig.globalSessionBindingEnabled,
|
||||
concurrentRequestQueueEnabled: updatedConfig.concurrentRequestQueueEnabled
|
||||
})
|
||||
|
||||
return updatedConfig
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update Claude relay config:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否启用全局 Claude Code 限制
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isClaudeCodeOnlyEnabled() {
|
||||
const cfg = await this.getConfig()
|
||||
return cfg.claudeCodeOnlyEnabled === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否启用全局会话绑定
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isGlobalSessionBindingEnabled() {
|
||||
const cfg = await this.getConfig()
|
||||
return cfg.globalSessionBindingEnabled === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话绑定错误信息
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async getSessionBindingErrorMessage() {
|
||||
const cfg = await this.getConfig()
|
||||
return cfg.sessionBindingErrorMessage || DEFAULT_CONFIG.sessionBindingErrorMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始会话绑定
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
* @returns {Promise<Object|null>} 绑定信息或 null
|
||||
*/
|
||||
async getOriginalSessionBinding(originalSessionId) {
|
||||
if (!originalSessionId) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
return null
|
||||
}
|
||||
|
||||
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||
const data = await client.get(key)
|
||||
|
||||
if (data) {
|
||||
return JSON.parse(data)
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to get session binding for ${originalSessionId}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置原始会话绑定
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} accountType - 账户类型
|
||||
* @returns {Promise<Object>} 绑定信息
|
||||
*/
|
||||
async setOriginalSessionBinding(originalSessionId, accountId, accountType) {
|
||||
if (!originalSessionId || !accountId || !accountType) {
|
||||
throw new Error('Invalid parameters for session binding')
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||
|
||||
const binding = {
|
||||
accountId,
|
||||
accountType,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
// 使用配置的 TTL(默认30天)
|
||||
const cfg = await this.getConfig()
|
||||
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
|
||||
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
|
||||
|
||||
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
|
||||
|
||||
logger.info(
|
||||
`🔗 Session binding created: ${originalSessionId} -> ${accountId} (${accountType})`
|
||||
)
|
||||
|
||||
return binding
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to set session binding for ${originalSessionId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话绑定的最后使用时间(续期)
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
*/
|
||||
async touchOriginalSessionBinding(originalSessionId) {
|
||||
if (!originalSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const binding = await this.getOriginalSessionBinding(originalSessionId)
|
||||
if (!binding) {
|
||||
return
|
||||
}
|
||||
|
||||
binding.lastUsedAt = new Date().toISOString()
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||
|
||||
// 使用配置的 TTL(默认30天)
|
||||
const cfg = await this.getConfig()
|
||||
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
|
||||
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
|
||||
|
||||
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ Failed to touch session binding for ${originalSessionId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查原始会话是否已绑定
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isOriginalSessionBound(originalSessionId) {
|
||||
const binding = await this.getOriginalSessionBinding(originalSessionId)
|
||||
return binding !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证绑定的账户是否可用
|
||||
* @param {Object} binding - 绑定信息
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async validateBoundAccount(binding) {
|
||||
if (!binding || !binding.accountId || !binding.accountType) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const { accountType } = binding
|
||||
const { accountId } = binding
|
||||
|
||||
let accountService
|
||||
switch (accountType) {
|
||||
case 'claude-official':
|
||||
accountService = require('./claudeAccountService')
|
||||
break
|
||||
case 'claude-console':
|
||||
accountService = require('./claudeConsoleAccountService')
|
||||
break
|
||||
case 'bedrock':
|
||||
accountService = require('./bedrockAccountService')
|
||||
break
|
||||
case 'ccr':
|
||||
accountService = require('./ccrAccountService')
|
||||
break
|
||||
default:
|
||||
logger.warn(`Unknown account type for validation: ${accountType}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const account = await accountService.getAccount(accountId)
|
||||
|
||||
// getAccount() 直接返回账户数据对象或 null,不是 { success, data } 格式
|
||||
if (!account) {
|
||||
logger.warn(`Session binding account not found: ${accountId} (${accountType})`)
|
||||
return false
|
||||
}
|
||||
|
||||
const accountData = account
|
||||
|
||||
// 检查账户是否激活
|
||||
if (accountData.isActive === false || accountData.isActive === 'false') {
|
||||
logger.warn(
|
||||
`Session binding account not active: ${accountId} (${accountType}), isActive: ${accountData.isActive}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查账户状态(如果存在)
|
||||
if (accountData.status && accountData.status === 'error') {
|
||||
logger.warn(
|
||||
`Session binding account has error status: ${accountId} (${accountType}), status: ${accountData.status}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to validate bound account ${binding.accountId}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证新会话请求
|
||||
* @param {Object} _requestBody - 请求体(预留参数,当前未使用)
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
* @returns {Promise<Object>} { valid: boolean, error?: string, binding?: object, isNewSession?: boolean }
|
||||
*/
|
||||
async validateNewSession(_requestBody, originalSessionId) {
|
||||
const cfg = await this.getConfig()
|
||||
|
||||
if (!cfg.globalSessionBindingEnabled) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// 如果没有 sessionId,跳过验证(可能是非 Claude Code 客户端)
|
||||
if (!originalSessionId) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const existingBinding = await this.getOriginalSessionBinding(originalSessionId)
|
||||
|
||||
// 如果会话已存在绑定
|
||||
if (existingBinding) {
|
||||
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
|
||||
// 其他类型(bedrock, ccr, claude-console等)忽略绑定,走正常调度
|
||||
if (existingBinding.accountType !== 'claude-official') {
|
||||
logger.info(
|
||||
`🔗 Session binding ignored for non-official account type: ${existingBinding.accountType}`
|
||||
)
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const accountValid = await this.validateBoundAccount(existingBinding)
|
||||
|
||||
if (!accountValid) {
|
||||
return {
|
||||
valid: false,
|
||||
error: cfg.sessionBindingErrorMessage,
|
||||
code: 'SESSION_BINDING_INVALID'
|
||||
}
|
||||
}
|
||||
|
||||
// 续期
|
||||
await this.touchOriginalSessionBinding(originalSessionId)
|
||||
|
||||
// 已有绑定,允许继续(这是正常的会话延续)
|
||||
return { valid: true, binding: existingBinding }
|
||||
}
|
||||
|
||||
// 没有绑定,是新会话
|
||||
// 注意:messages.length 检查在此处无法执行,因为我们不知道最终会调度到哪种账户类型
|
||||
// 绑定会在调度后创建,仅针对 claude-official 账户
|
||||
return { valid: true, isNewSession: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除原始会话绑定
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
*/
|
||||
async deleteOriginalSessionBinding(originalSessionId) {
|
||||
if (!originalSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||
await client.del(key)
|
||||
logger.info(`🗑️ Session binding deleted: ${originalSessionId}`)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to delete session binding for ${originalSessionId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话绑定统计
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getSessionBindingStats() {
|
||||
try {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
return { totalBindings: 0 }
|
||||
}
|
||||
|
||||
let cursor = '0'
|
||||
let count = 0
|
||||
|
||||
do {
|
||||
const [newCursor, keys] = await client.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
`${SESSION_BINDING_PREFIX}*`,
|
||||
'COUNT',
|
||||
100
|
||||
)
|
||||
cursor = newCursor
|
||||
count += keys.length
|
||||
} while (cursor !== '0')
|
||||
|
||||
return {
|
||||
totalBindings: count
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get session binding stats:', error)
|
||||
return { totalBindings: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除配置缓存(用于测试或强制刷新)
|
||||
*/
|
||||
clearCache() {
|
||||
configCache = null
|
||||
configCacheTime = 0
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeRelayConfigService()
|
||||
@@ -133,10 +133,34 @@ class CostInitService {
|
||||
totalCost += cost
|
||||
}
|
||||
|
||||
// 写入总费用
|
||||
// 写入总费用 - 修复:只在总费用不存在时初始化,避免覆盖现有累计值
|
||||
if (totalCost > 0) {
|
||||
const totalKey = `usage:cost:total:${apiKeyId}`
|
||||
promises.push(client.set(totalKey, totalCost.toString()))
|
||||
// 先检查总费用是否已存在
|
||||
const existingTotal = await client.get(totalKey)
|
||||
|
||||
if (!existingTotal || parseFloat(existingTotal) === 0) {
|
||||
// 仅在总费用不存在或为0时才初始化
|
||||
promises.push(client.set(totalKey, totalCost.toString()))
|
||||
logger.info(`💰 Initialized total cost for API Key ${apiKeyId}: $${totalCost.toFixed(6)}`)
|
||||
} else {
|
||||
// 如果总费用已存在,保持不变,避免覆盖累计值
|
||||
// 注意:这个逻辑防止因每日费用键过期(30天)导致的错误覆盖
|
||||
// 如果需要强制重新计算,请先手动删除 usage:cost:total:{keyId} 键
|
||||
const existing = parseFloat(existingTotal)
|
||||
const calculated = totalCost
|
||||
|
||||
if (calculated > existing * 1.1) {
|
||||
// 如果计算值比现有值大 10% 以上,记录警告(可能是数据不一致)
|
||||
logger.warn(
|
||||
`💰 Total cost mismatch for API Key ${apiKeyId}: existing=$${existing.toFixed(6)}, calculated=$${calculated.toFixed(6)} (from last 30 days). Keeping existing value to prevent data loss.`
|
||||
)
|
||||
} else {
|
||||
logger.debug(
|
||||
`💰 Skipping total cost initialization for API Key ${apiKeyId} - existing: $${existing.toFixed(6)}, calculated: $${calculated.toFixed(6)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
591
src/services/costRankService.js
Normal file
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* 费用排序索引服务
|
||||
*
|
||||
* 为 API Keys 提供按费用排序的功能,使用 Redis Sorted Set 预计算排序索引
|
||||
* 支持 today/7days/30days/all 四种固定时间范围的预计算索引
|
||||
* 支持 custom 时间范围的实时计算
|
||||
*
|
||||
* 设计原则:
|
||||
* - 只计算未删除的 API Key
|
||||
* - 使用原子操作避免竞态条件
|
||||
* - 提供增量更新接口供 API Key 创建/删除时调用
|
||||
*/
|
||||
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
// ============================================================================
|
||||
// 常量配置
|
||||
// ============================================================================
|
||||
|
||||
/** 时间范围更新间隔配置(省资源模式) */
|
||||
const UPDATE_INTERVALS = {
|
||||
today: 10 * 60 * 1000, // 10分钟
|
||||
'7days': 30 * 60 * 1000, // 30分钟
|
||||
'30days': 60 * 60 * 1000, // 1小时
|
||||
all: 2 * 60 * 60 * 1000 // 2小时
|
||||
}
|
||||
|
||||
/** 支持的时间范围列表 */
|
||||
const VALID_TIME_RANGES = ['today', '7days', '30days', 'all']
|
||||
|
||||
/** 分布式锁超时时间(秒) */
|
||||
const LOCK_TTL = 300
|
||||
|
||||
/** 批处理大小 */
|
||||
const BATCH_SIZE = 100
|
||||
|
||||
// ============================================================================
|
||||
// Redis Key 生成器(集中管理 key 格式)
|
||||
// ============================================================================
|
||||
|
||||
const RedisKeys = {
|
||||
/** 费用排序索引 Sorted Set */
|
||||
rankKey: (timeRange) => `cost_rank:${timeRange}`,
|
||||
|
||||
/** 临时索引 key(用于原子替换) */
|
||||
tempRankKey: (timeRange) => `cost_rank:${timeRange}:temp:${Date.now()}`,
|
||||
|
||||
/** 索引元数据 Hash */
|
||||
metaKey: (timeRange) => `cost_rank_meta:${timeRange}`,
|
||||
|
||||
/** 更新锁 */
|
||||
lockKey: (timeRange) => `cost_rank_lock:${timeRange}`,
|
||||
|
||||
/** 每日费用 */
|
||||
dailyCost: (keyId, date) => `usage:cost:daily:${keyId}:${date}`,
|
||||
|
||||
/** 总费用 */
|
||||
totalCost: (keyId) => `usage:cost:total:${keyId}`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CostRankService 类
|
||||
// ============================================================================
|
||||
|
||||
class CostRankService {
|
||||
constructor() {
|
||||
this.timers = {}
|
||||
this.isInitialized = false
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 生命周期管理
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 初始化服务:启动定时任务
|
||||
* 幂等设计:多次调用只会初始化一次
|
||||
*/
|
||||
async initialize() {
|
||||
// 先清理可能存在的旧定时器(支持热重载)
|
||||
this._clearAllTimers()
|
||||
|
||||
if (this.isInitialized) {
|
||||
logger.warn('CostRankService already initialized, re-initializing...')
|
||||
}
|
||||
|
||||
logger.info('🔄 Initializing CostRankService...')
|
||||
|
||||
try {
|
||||
// 启动时立即更新所有索引(异步,不阻塞启动)
|
||||
this.updateAllRanks().catch((err) => {
|
||||
logger.error('Failed to initialize cost ranks:', err)
|
||||
})
|
||||
|
||||
// 设置定时更新
|
||||
for (const [timeRange, interval] of Object.entries(UPDATE_INTERVALS)) {
|
||||
this.timers[timeRange] = setInterval(() => {
|
||||
this.updateRank(timeRange).catch((err) => {
|
||||
logger.error(`Failed to update cost rank for ${timeRange}:`, err)
|
||||
})
|
||||
}, interval)
|
||||
}
|
||||
|
||||
this.isInitialized = true
|
||||
logger.success('✅ CostRankService initialized')
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize CostRankService:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭服务:清理定时器
|
||||
*/
|
||||
shutdown() {
|
||||
this._clearAllTimers()
|
||||
this.isInitialized = false
|
||||
logger.info('CostRankService shutdown')
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有定时器
|
||||
* @private
|
||||
*/
|
||||
_clearAllTimers() {
|
||||
for (const timer of Object.values(this.timers)) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
this.timers = {}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 索引更新(全量)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 更新所有时间范围的索引
|
||||
*/
|
||||
async updateAllRanks() {
|
||||
for (const timeRange of VALID_TIME_RANGES) {
|
||||
try {
|
||||
await this.updateRank(timeRange)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update rank for ${timeRange}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新指定时间范围的排序索引
|
||||
* @param {string} timeRange - 时间范围
|
||||
*/
|
||||
async updateRank(timeRange) {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
logger.warn('Redis client not available, skipping cost rank update')
|
||||
return
|
||||
}
|
||||
|
||||
const lockKey = RedisKeys.lockKey(timeRange)
|
||||
const rankKey = RedisKeys.rankKey(timeRange)
|
||||
const metaKey = RedisKeys.metaKey(timeRange)
|
||||
|
||||
// 获取分布式锁
|
||||
const acquired = await client.set(lockKey, '1', 'NX', 'EX', LOCK_TTL)
|
||||
if (!acquired) {
|
||||
logger.debug(`Skipping ${timeRange} rank update - another update in progress`)
|
||||
return
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 标记为更新中
|
||||
await client.hset(metaKey, 'status', 'updating')
|
||||
|
||||
// 1. 获取所有未删除的 API Key IDs
|
||||
const keyIds = await this._getActiveApiKeyIds()
|
||||
|
||||
if (keyIds.length === 0) {
|
||||
// 无数据时清空索引
|
||||
await client.del(rankKey)
|
||||
await this._updateMeta(client, metaKey, startTime, 0)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 计算日期范围
|
||||
const dateRange = this._getDateRange(timeRange)
|
||||
|
||||
// 3. 分批计算费用
|
||||
const costs = await this._calculateCostsInBatches(keyIds, dateRange)
|
||||
|
||||
// 4. 原子更新索引(使用临时 key + RENAME 避免竞态条件)
|
||||
await this._atomicUpdateIndex(client, rankKey, costs)
|
||||
|
||||
// 5. 更新元数据
|
||||
await this._updateMeta(client, metaKey, startTime, keyIds.length)
|
||||
|
||||
logger.info(
|
||||
`📊 Updated cost rank for ${timeRange}: ${keyIds.length} keys in ${Date.now() - startTime}ms`
|
||||
)
|
||||
} catch (error) {
|
||||
await client.hset(metaKey, 'status', 'failed')
|
||||
logger.error(`Failed to update cost rank for ${timeRange}:`, error)
|
||||
throw error
|
||||
} finally {
|
||||
await client.del(lockKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子更新索引(避免竞态条件)
|
||||
* @private
|
||||
*/
|
||||
async _atomicUpdateIndex(client, rankKey, costs) {
|
||||
if (costs.size === 0) {
|
||||
await client.del(rankKey)
|
||||
return
|
||||
}
|
||||
|
||||
// 使用临时 key 构建新索引
|
||||
const tempKey = `${rankKey}:temp:${Date.now()}`
|
||||
|
||||
try {
|
||||
// 构建 ZADD 参数
|
||||
const members = []
|
||||
costs.forEach((cost, keyId) => {
|
||||
members.push(cost, keyId)
|
||||
})
|
||||
|
||||
// 写入临时 key
|
||||
await client.zadd(tempKey, ...members)
|
||||
|
||||
// 原子替换(RENAME 是原子操作)
|
||||
await client.rename(tempKey, rankKey)
|
||||
} catch (error) {
|
||||
// 清理临时 key
|
||||
await client.del(tempKey).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新元数据
|
||||
* @private
|
||||
*/
|
||||
async _updateMeta(client, metaKey, startTime, keyCount) {
|
||||
await client.hmset(metaKey, {
|
||||
lastUpdate: new Date().toISOString(),
|
||||
keyCount: keyCount.toString(),
|
||||
status: 'ready',
|
||||
updateDuration: (Date.now() - startTime).toString()
|
||||
})
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 索引增量更新(供外部调用)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 添加 API Key 到所有索引(创建 API Key 时调用)
|
||||
* @param {string} keyId - API Key ID
|
||||
*/
|
||||
async addKeyToIndexes(keyId) {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
// 将新 Key 添加到所有索引,初始分数为 0
|
||||
for (const timeRange of VALID_TIME_RANGES) {
|
||||
pipeline.zadd(RedisKeys.rankKey(timeRange), 0, keyId)
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
logger.debug(`Added key ${keyId} to cost rank indexes`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to add key ${keyId} to cost rank indexes:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从所有索引中移除 API Key(删除 API Key 时调用)
|
||||
* @param {string} keyId - API Key ID
|
||||
*/
|
||||
async removeKeyFromIndexes(keyId) {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
// 从所有索引中移除
|
||||
for (const timeRange of VALID_TIME_RANGES) {
|
||||
pipeline.zrem(RedisKeys.rankKey(timeRange), keyId)
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
logger.debug(`Removed key ${keyId} from cost rank indexes`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove key ${keyId} from cost rank indexes:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 查询接口
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取排序后的 keyId 列表
|
||||
* @param {string} timeRange - 时间范围
|
||||
* @param {string} sortOrder - 排序方向 'asc' | 'desc'
|
||||
* @param {number} offset - 偏移量
|
||||
* @param {number} limit - 限制数量,-1 表示全部
|
||||
* @returns {Promise<string[]>} keyId 列表
|
||||
*/
|
||||
async getSortedKeyIds(timeRange, sortOrder = 'desc', offset = 0, limit = -1) {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
throw new Error('Redis client not available')
|
||||
}
|
||||
|
||||
const rankKey = RedisKeys.rankKey(timeRange)
|
||||
const end = limit === -1 ? -1 : offset + limit - 1
|
||||
|
||||
if (sortOrder === 'desc') {
|
||||
return await client.zrevrange(rankKey, offset, end)
|
||||
} else {
|
||||
return await client.zrange(rankKey, offset, end)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Key 的费用分数
|
||||
* @param {string} timeRange - 时间范围
|
||||
* @param {string} keyId - API Key ID
|
||||
* @returns {Promise<number>} 费用
|
||||
*/
|
||||
async getKeyCost(timeRange, keyId) {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const score = await client.zscore(RedisKeys.rankKey(timeRange), keyId)
|
||||
return score ? parseFloat(score) : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取多个 Key 的费用分数
|
||||
* @param {string} timeRange - 时间范围
|
||||
* @param {string[]} keyIds - API Key ID 列表
|
||||
* @returns {Promise<Map<string, number>>} keyId -> cost
|
||||
*/
|
||||
async getBatchKeyCosts(timeRange, keyIds) {
|
||||
const client = redis.getClient()
|
||||
if (!client || keyIds.length === 0) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
const rankKey = RedisKeys.rankKey(timeRange)
|
||||
const costs = new Map()
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
keyIds.forEach((keyId) => {
|
||||
pipeline.zscore(rankKey, keyId)
|
||||
})
|
||||
const results = await pipeline.exec()
|
||||
|
||||
keyIds.forEach((keyId, index) => {
|
||||
const [err, score] = results[index]
|
||||
costs.set(keyId, err || !score ? 0 : parseFloat(score))
|
||||
})
|
||||
|
||||
return costs
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有排序索引的状态
|
||||
* @returns {Promise<Object>} 各时间范围的状态
|
||||
*/
|
||||
async getRankStatus() {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const status = {}
|
||||
|
||||
for (const timeRange of VALID_TIME_RANGES) {
|
||||
const meta = await client.hgetall(RedisKeys.metaKey(timeRange))
|
||||
status[timeRange] = {
|
||||
lastUpdate: meta.lastUpdate || null,
|
||||
keyCount: parseInt(meta.keyCount || 0),
|
||||
status: meta.status || 'unknown',
|
||||
updateDuration: parseInt(meta.updateDuration || 0)
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制刷新指定时间范围的索引
|
||||
* @param {string} timeRange - 时间范围,不传则刷新全部
|
||||
*/
|
||||
async forceRefresh(timeRange = null) {
|
||||
if (timeRange) {
|
||||
await this.updateRank(timeRange)
|
||||
} else {
|
||||
await this.updateAllRanks()
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Custom 时间范围实时计算
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 计算 custom 时间范围的费用(实时计算,排除已删除的 Key)
|
||||
* @param {string} startDate - 开始日期 YYYY-MM-DD
|
||||
* @param {string} endDate - 结束日期 YYYY-MM-DD
|
||||
* @returns {Promise<Map<string, number>>} keyId -> cost
|
||||
*/
|
||||
async calculateCustomRangeCosts(startDate, endDate) {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
throw new Error('Redis client not available')
|
||||
}
|
||||
|
||||
logger.info(`📊 Calculating custom range costs: ${startDate} to ${endDate}`)
|
||||
const startTime = Date.now()
|
||||
|
||||
// 1. 获取所有未删除的 API Key IDs
|
||||
const keyIds = await this._getActiveApiKeyIds()
|
||||
|
||||
if (keyIds.length === 0) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
// 2. 分批计算费用
|
||||
const costs = await this._calculateCostsInBatches(keyIds, { startDate, endDate })
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
logger.info(`📊 Custom range costs calculated: ${keyIds.length} keys in ${duration}ms`)
|
||||
|
||||
return costs
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 私有辅助方法
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取所有未删除的 API Key IDs
|
||||
* @private
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async _getActiveApiKeyIds() {
|
||||
// 使用现有的 scanApiKeyIds 获取所有 ID
|
||||
const allKeyIds = await redis.scanApiKeyIds()
|
||||
|
||||
if (allKeyIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 批量获取 API Key 数据,过滤已删除的
|
||||
const allKeys = await redis.batchGetApiKeys(allKeyIds)
|
||||
|
||||
return allKeys.filter((k) => !k.isDeleted).map((k) => k.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分批计算费用
|
||||
* @private
|
||||
*/
|
||||
async _calculateCostsInBatches(keyIds, dateRange) {
|
||||
const costs = new Map()
|
||||
|
||||
for (let i = 0; i < keyIds.length; i += BATCH_SIZE) {
|
||||
const batch = keyIds.slice(i, i + BATCH_SIZE)
|
||||
const batchCosts = await this._calculateBatchCosts(batch, dateRange)
|
||||
batchCosts.forEach((cost, keyId) => costs.set(keyId, cost))
|
||||
}
|
||||
|
||||
return costs
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量计算费用
|
||||
* @private
|
||||
*/
|
||||
async _calculateBatchCosts(keyIds, dateRange) {
|
||||
const client = redis.getClient()
|
||||
const costs = new Map()
|
||||
|
||||
if (dateRange.useTotal) {
|
||||
// 'all' 时间范围:直接读取 total cost
|
||||
const pipeline = client.pipeline()
|
||||
keyIds.forEach((keyId) => {
|
||||
pipeline.get(RedisKeys.totalCost(keyId))
|
||||
})
|
||||
const results = await pipeline.exec()
|
||||
|
||||
keyIds.forEach((keyId, index) => {
|
||||
const [err, value] = results[index]
|
||||
costs.set(keyId, err ? 0 : parseFloat(value || 0))
|
||||
})
|
||||
} else {
|
||||
// 特定日期范围:汇总每日费用
|
||||
const dates = this._getDatesBetween(dateRange.startDate, dateRange.endDate)
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
keyIds.forEach((keyId) => {
|
||||
dates.forEach((date) => {
|
||||
pipeline.get(RedisKeys.dailyCost(keyId, date))
|
||||
})
|
||||
})
|
||||
const results = await pipeline.exec()
|
||||
|
||||
let resultIndex = 0
|
||||
keyIds.forEach((keyId) => {
|
||||
let totalCost = 0
|
||||
dates.forEach(() => {
|
||||
const [err, value] = results[resultIndex++]
|
||||
if (!err && value) {
|
||||
totalCost += parseFloat(value)
|
||||
}
|
||||
})
|
||||
costs.set(keyId, totalCost)
|
||||
})
|
||||
}
|
||||
|
||||
return costs
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日期范围配置
|
||||
* @private
|
||||
*/
|
||||
_getDateRange(timeRange) {
|
||||
const now = new Date()
|
||||
const today = redis.getDateStringInTimezone(now)
|
||||
|
||||
switch (timeRange) {
|
||||
case 'today':
|
||||
return { startDate: today, endDate: today }
|
||||
case '7days': {
|
||||
const d7 = new Date(now)
|
||||
d7.setDate(d7.getDate() - 6)
|
||||
return { startDate: redis.getDateStringInTimezone(d7), endDate: today }
|
||||
}
|
||||
case '30days': {
|
||||
const d30 = new Date(now)
|
||||
d30.setDate(d30.getDate() - 29)
|
||||
return { startDate: redis.getDateStringInTimezone(d30), endDate: today }
|
||||
}
|
||||
case 'all':
|
||||
return { useTotal: true }
|
||||
default:
|
||||
throw new Error(`Invalid time range: ${timeRange}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取两个日期之间的所有日期
|
||||
* @private
|
||||
*/
|
||||
_getDatesBetween(startDate, endDate) {
|
||||
const dates = []
|
||||
const current = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
|
||||
while (current <= end) {
|
||||
dates.push(
|
||||
`${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, '0')}-${String(current.getDate()).padStart(2, '0')}`
|
||||
)
|
||||
current.setDate(current.getDate() + 1)
|
||||
}
|
||||
|
||||
return dates
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CostRankService()
|
||||
1581
src/services/droidAccountService.js
Normal file
1500
src/services/droidRelayService.js
Normal file
227
src/services/droidScheduler.js
Normal file
@@ -0,0 +1,227 @@
|
||||
const droidAccountService = require('./droidAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
class DroidScheduler {
|
||||
constructor() {
|
||||
this.STICKY_PREFIX = 'droid'
|
||||
}
|
||||
|
||||
_normalizeEndpointType(endpointType) {
|
||||
if (!endpointType) {
|
||||
return 'anthropic'
|
||||
}
|
||||
const normalized = String(endpointType).toLowerCase()
|
||||
if (normalized === 'openai') {
|
||||
return 'openai'
|
||||
}
|
||||
if (normalized === 'comm') {
|
||||
return 'comm'
|
||||
}
|
||||
if (normalized === 'anthropic') {
|
||||
return 'anthropic'
|
||||
}
|
||||
return 'anthropic'
|
||||
}
|
||||
|
||||
_isTruthy(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return false
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase() === 'true'
|
||||
}
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
_isAccountActive(account) {
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
const isActive = this._isTruthy(account.isActive)
|
||||
if (!isActive) {
|
||||
return false
|
||||
}
|
||||
|
||||
const status = (account.status || 'active').toLowerCase()
|
||||
const unhealthyStatuses = new Set(['error', 'unauthorized', 'blocked'])
|
||||
return !unhealthyStatuses.has(status)
|
||||
}
|
||||
|
||||
_isAccountSchedulable(account) {
|
||||
return this._isTruthy(account?.schedulable ?? true)
|
||||
}
|
||||
|
||||
_matchesEndpoint(account, endpointType) {
|
||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||
const accountEndpoint = this._normalizeEndpointType(account?.endpointType)
|
||||
if (normalizedEndpoint === accountEndpoint) {
|
||||
return true
|
||||
}
|
||||
|
||||
// comm 端点可以使用任何类型的账户
|
||||
if (normalizedEndpoint === 'comm') {
|
||||
return true
|
||||
}
|
||||
|
||||
const sharedEndpoints = new Set(['anthropic', 'openai'])
|
||||
return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint)
|
||||
}
|
||||
|
||||
_sortCandidates(candidates) {
|
||||
return [...candidates].sort((a, b) => {
|
||||
const priorityA = parseInt(a.priority, 10) || 50
|
||||
const priorityB = parseInt(b.priority, 10) || 50
|
||||
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB
|
||||
}
|
||||
|
||||
const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0
|
||||
const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0
|
||||
|
||||
if (lastUsedA !== lastUsedB) {
|
||||
return lastUsedA - lastUsedB
|
||||
}
|
||||
|
||||
const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
||||
const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||
return createdA - createdB
|
||||
})
|
||||
}
|
||||
|
||||
_composeStickySessionKey(endpointType, sessionHash, apiKeyId) {
|
||||
if (!sessionHash) {
|
||||
return null
|
||||
}
|
||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||
const apiKeyPart = apiKeyId || 'default'
|
||||
return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}`
|
||||
}
|
||||
|
||||
async _loadGroupAccounts(groupId) {
|
||||
const memberIds = await accountGroupService.getGroupMembers(groupId)
|
||||
if (!memberIds || memberIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const accounts = await Promise.all(
|
||||
memberIds.map(async (memberId) => {
|
||||
try {
|
||||
return await droidAccountService.getAccount(memberId)
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ 获取 Droid 分组成员账号失败: ${memberId}`, error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return accounts.filter(
|
||||
(account) => account && this._isAccountActive(account) && this._isAccountSchedulable(account)
|
||||
)
|
||||
}
|
||||
|
||||
async _ensureLastUsedUpdated(accountId) {
|
||||
try {
|
||||
await droidAccountService.touchLastUsedAt(accountId)
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ 更新 Droid 账号最后使用时间失败: ${accountId}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
async _cleanupStickyMapping(stickyKey) {
|
||||
if (!stickyKey) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await redis.deleteSessionAccountMapping(stickyKey)
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ 清理 Droid 粘性会话映射失败: ${stickyKey}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
async selectAccount(apiKeyData, endpointType, sessionHash) {
|
||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||
const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id)
|
||||
|
||||
let candidates = []
|
||||
let isDedicatedBinding = false
|
||||
|
||||
if (apiKeyData?.droidAccountId) {
|
||||
const binding = apiKeyData.droidAccountId
|
||||
if (binding.startsWith('group:')) {
|
||||
const groupId = binding.substring('group:'.length)
|
||||
logger.info(
|
||||
`🤖 API Key ${apiKeyData.name || apiKeyData.id} 绑定 Droid 分组 ${groupId},按分组调度`
|
||||
)
|
||||
candidates = await this._loadGroupAccounts(groupId, normalizedEndpoint)
|
||||
} else {
|
||||
const account = await droidAccountService.getAccount(binding)
|
||||
if (account) {
|
||||
candidates = [account]
|
||||
isDedicatedBinding = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidates || candidates.length === 0) {
|
||||
candidates = await droidAccountService.getSchedulableAccounts(normalizedEndpoint)
|
||||
}
|
||||
|
||||
const filtered = candidates.filter(
|
||||
(account) =>
|
||||
account &&
|
||||
this._isAccountActive(account) &&
|
||||
this._isAccountSchedulable(account) &&
|
||||
this._matchesEndpoint(account, normalizedEndpoint)
|
||||
)
|
||||
|
||||
if (filtered.length === 0) {
|
||||
throw new Error(
|
||||
`No available accounts for endpoint ${normalizedEndpoint}${apiKeyData?.droidAccountId ? ' (respecting binding)' : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
if (stickyKey && !isDedicatedBinding) {
|
||||
const mappedAccountId = await redis.getSessionAccountMapping(stickyKey)
|
||||
if (mappedAccountId) {
|
||||
const mappedAccount = filtered.find((account) => account.id === mappedAccountId)
|
||||
if (mappedAccount) {
|
||||
await redis.extendSessionAccountMappingTTL(stickyKey)
|
||||
logger.info(
|
||||
`🤖 命中 Droid 粘性会话: ${sessionHash} -> ${mappedAccount.name || mappedAccount.id}`
|
||||
)
|
||||
await this._ensureLastUsedUpdated(mappedAccount.id)
|
||||
return mappedAccount
|
||||
}
|
||||
|
||||
await this._cleanupStickyMapping(stickyKey)
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = this._sortCandidates(filtered)
|
||||
const selected = sorted[0]
|
||||
|
||||
if (!selected) {
|
||||
throw new Error(`No schedulable account available after sorting (${normalizedEndpoint})`)
|
||||
}
|
||||
|
||||
if (stickyKey && !isDedicatedBinding) {
|
||||
await redis.setSessionAccountMapping(stickyKey, selected.id)
|
||||
}
|
||||
|
||||
await this._ensureLastUsedUpdated(selected.id)
|
||||
|
||||
logger.info(
|
||||
`🤖 选择 Droid 账号 ${selected.name || selected.id}(endpoint: ${normalizedEndpoint}, priority: ${selected.priority || 50})`
|
||||
)
|
||||
|
||||
return selected
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new DroidScheduler()
|
||||
@@ -1,6 +1,7 @@
|
||||
const redisClient = require('../models/redis')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const https = require('https')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
const { OAuth2Client } = require('google-auth-library')
|
||||
@@ -21,6 +22,18 @@ const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.goog
|
||||
const OAUTH_CLIENT_SECRET = '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'
|
||||
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
||||
|
||||
// 🌐 TCP Keep-Alive Agent 配置
|
||||
// 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题
|
||||
const keepAliveAgent = new https.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30000, // 每30秒发送一次 keep-alive 探测
|
||||
timeout: 120000, // 120秒连接超时
|
||||
maxSockets: 100, // 最大并发连接数
|
||||
maxFreeSockets: 10 // 保持的空闲连接数
|
||||
})
|
||||
|
||||
logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support')
|
||||
|
||||
// 加密相关常量
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
const ENCRYPTION_SALT = 'gemini-account-salt'
|
||||
@@ -384,16 +397,22 @@ async function createAccount(accountData) {
|
||||
geminiOauth: geminiOauth ? encrypt(geminiOauth) : '',
|
||||
accessToken: accessToken ? encrypt(accessToken) : '',
|
||||
refreshToken: refreshToken ? encrypt(refreshToken) : '',
|
||||
expiresAt,
|
||||
expiresAt, // OAuth Token 过期时间(技术字段,自动刷新)
|
||||
// 只有OAuth方式才有scopes,手动添加的没有
|
||||
scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '',
|
||||
|
||||
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
||||
|
||||
// 代理设置
|
||||
proxy: accountData.proxy ? JSON.stringify(accountData.proxy) : '',
|
||||
|
||||
// 项目 ID(Google Cloud/Workspace 账号需要)
|
||||
projectId: accountData.projectId || '',
|
||||
|
||||
// 临时项目 ID(从 loadCodeAssist 接口自动获取)
|
||||
tempProjectId: accountData.tempProjectId || '',
|
||||
|
||||
// 支持的模型列表(可选)
|
||||
supportedModels: accountData.supportedModels || [], // 空数组表示支持所有模型
|
||||
|
||||
@@ -518,15 +537,23 @@ async function updateAccount(accountId, updates) {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果新增了 refresh token,更新过期时间为10分钟
|
||||
// ✅ 关键:如果新增了 refresh token,只更新 token 过期时间
|
||||
// 不要覆盖 subscriptionExpiresAt
|
||||
if (needUpdateExpiry) {
|
||||
const newExpiry = new Date(Date.now() + 10 * 60 * 1000).toISOString()
|
||||
updates.expiresAt = newExpiry
|
||||
updates.expiresAt = newExpiry // 只更新 OAuth Token 过期时间
|
||||
// ⚠️ 重要:不要修改 subscriptionExpiresAt
|
||||
logger.info(
|
||||
`🔄 New refresh token added for Gemini account ${accountId}, setting expiry to 10 minutes`
|
||||
`🔄 New refresh token added for Gemini account ${accountId}, setting token expiry to 10 minutes`
|
||||
)
|
||||
}
|
||||
|
||||
// ✅ 如果通过路由映射更新了 subscriptionExpiresAt,直接保存
|
||||
// subscriptionExpiresAt 是业务字段,与 token 刷新独立
|
||||
if (updates.subscriptionExpiresAt !== undefined) {
|
||||
// 直接保存,不做任何调整
|
||||
}
|
||||
|
||||
// 如果通过 geminiOauth 更新,也要检查是否新增了 refresh token
|
||||
if (updates.geminiOauth && !oldRefreshToken) {
|
||||
const oauthData =
|
||||
@@ -640,12 +667,25 @@ async function getAllAccounts() {
|
||||
// 转换 schedulable 字符串为布尔值(与 getAccount 保持一致)
|
||||
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true,只有明确设置为'false'才为false
|
||||
|
||||
const tokenExpiresAt = accountData.expiresAt || null
|
||||
const subscriptionExpiresAt =
|
||||
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
|
||||
? accountData.subscriptionExpiresAt
|
||||
: null
|
||||
|
||||
// 不解密敏感字段,只返回基本信息
|
||||
accounts.push({
|
||||
...accountData,
|
||||
geminiOauth: accountData.geminiOauth ? '[ENCRYPTED]' : '',
|
||||
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
|
||||
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
|
||||
|
||||
// ✅ 前端显示订阅过期时间(业务字段)
|
||||
// 注意:前端看到的 expiresAt 实际上是 subscriptionExpiresAt
|
||||
tokenExpiresAt,
|
||||
subscriptionExpiresAt,
|
||||
expiresAt: subscriptionExpiresAt,
|
||||
|
||||
// 添加 scopes 字段用于判断认证方式
|
||||
// 处理空字符串和默认值的情况
|
||||
scopes:
|
||||
@@ -724,8 +764,17 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||
|
||||
for (const accountId of sharedAccountIds) {
|
||||
const account = await getAccount(accountId)
|
||||
if (account && account.isActive === 'true' && !isRateLimited(account)) {
|
||||
if (
|
||||
account &&
|
||||
account.isActive === 'true' &&
|
||||
!isRateLimited(account) &&
|
||||
!isSubscriptionExpired(account)
|
||||
) {
|
||||
availableAccounts.push(account)
|
||||
} else if (account && isSubscriptionExpired(account)) {
|
||||
logger.debug(
|
||||
`⏰ Skipping expired Gemini account: ${account.name}, expired at ${account.subscriptionExpiresAt}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -780,6 +829,19 @@ function isTokenExpired(account) {
|
||||
return now >= expiryTime - buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户订阅是否过期
|
||||
* @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()
|
||||
}
|
||||
|
||||
// 检查账户是否被限流
|
||||
function isRateLimited(account) {
|
||||
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
|
||||
@@ -999,6 +1061,7 @@ async function getOauthClient(accessToken, refreshToken, proxyConfig = null) {
|
||||
|
||||
// 验证凭据本地有效性
|
||||
const { token } = await client.getAccessToken()
|
||||
|
||||
if (!token) {
|
||||
return false
|
||||
}
|
||||
@@ -1010,6 +1073,45 @@ async function getOauthClient(accessToken, refreshToken, proxyConfig = null) {
|
||||
return client
|
||||
}
|
||||
|
||||
// 通用的 Code Assist API 转发函数(用于简单的请求/响应端点)
|
||||
// 适用于:loadCodeAssist, onboardUser, countTokens, listExperiments 等不需要特殊处理的端点
|
||||
async function forwardToCodeAssist(client, apiMethod, requestBody, proxyConfig = null) {
|
||||
const axios = require('axios')
|
||||
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'
|
||||
const CODE_ASSIST_API_VERSION = 'v1internal'
|
||||
|
||||
const { token } = await client.getAccessToken()
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
|
||||
logger.info(`📡 ${apiMethod} API调用开始`)
|
||||
|
||||
const axiosConfig = {
|
||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${apiMethod}`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: requestBody,
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 添加代理配置
|
||||
if (proxyAgent) {
|
||||
// 只设置 httpsAgent,因为目标 URL 是 HTTPS (cloudcode-pa.googleapis.com)
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
axiosConfig.proxy = false
|
||||
logger.info(`🌐 Using proxy for ${apiMethod}: ${ProxyHelper.getProxyDescription(proxyConfig)}`)
|
||||
} else {
|
||||
logger.debug(`🌐 No proxy configured for ${apiMethod}`)
|
||||
}
|
||||
|
||||
const response = await axios(axiosConfig)
|
||||
|
||||
logger.info(`✅ ${apiMethod} API调用成功`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 调用 Google Code Assist API 的 loadCodeAssist 方法(支持代理)
|
||||
async function loadCodeAssist(client, projectId = null, proxyConfig = null) {
|
||||
const axios = require('axios')
|
||||
@@ -1017,6 +1119,57 @@ async function loadCodeAssist(client, projectId = null, proxyConfig = null) {
|
||||
const CODE_ASSIST_API_VERSION = 'v1internal'
|
||||
|
||||
const { token } = await client.getAccessToken()
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
// 🔍 只有个人账户(无 projectId)才需要调用 tokeninfo/userinfo
|
||||
// 这些调用有助于 Google 获取临时 projectId
|
||||
if (!projectId) {
|
||||
const tokenInfoConfig = {
|
||||
url: 'https://oauth2.googleapis.com/tokeninfo',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
data: new URLSearchParams({ access_token: token }).toString(),
|
||||
timeout: 15000
|
||||
}
|
||||
|
||||
if (proxyAgent) {
|
||||
tokenInfoConfig.httpAgent = proxyAgent
|
||||
tokenInfoConfig.httpsAgent = proxyAgent
|
||||
tokenInfoConfig.proxy = false
|
||||
}
|
||||
|
||||
try {
|
||||
await axios(tokenInfoConfig)
|
||||
logger.info('📋 tokeninfo 接口验证成功')
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ tokeninfo 接口调用失败:', error.message)
|
||||
}
|
||||
|
||||
const userInfoConfig = {
|
||||
url: 'https://www.googleapis.com/oauth2/v2/userinfo',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: '*/*'
|
||||
},
|
||||
timeout: 15000
|
||||
}
|
||||
|
||||
if (proxyAgent) {
|
||||
userInfoConfig.httpAgent = proxyAgent
|
||||
userInfoConfig.httpsAgent = proxyAgent
|
||||
userInfoConfig.proxy = false
|
||||
}
|
||||
|
||||
try {
|
||||
await axios(userInfoConfig)
|
||||
logger.info('📋 userinfo 接口获取成功')
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ userinfo 接口调用失败:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建ClientMetadata
|
||||
const clientMetadata = {
|
||||
@@ -1051,9 +1204,10 @@ async function loadCodeAssist(client, projectId = null, proxyConfig = null) {
|
||||
}
|
||||
|
||||
// 添加代理配置
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
if (proxyAgent) {
|
||||
// 只设置 httpsAgent,因为目标 URL 是 HTTPS (cloudcode-pa.googleapis.com)
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
axiosConfig.proxy = false
|
||||
logger.info(
|
||||
`🌐 Using proxy for Gemini loadCodeAssist: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||
)
|
||||
@@ -1127,7 +1281,9 @@ async function onboardUser(client, tierId, projectId, clientMetadata, proxyConfi
|
||||
// 添加代理配置
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
if (proxyAgent) {
|
||||
baseAxiosConfig.httpAgent = proxyAgent
|
||||
baseAxiosConfig.httpsAgent = proxyAgent
|
||||
baseAxiosConfig.proxy = false
|
||||
logger.info(
|
||||
`🌐 Using proxy for Gemini onboardUser: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||
)
|
||||
@@ -1258,7 +1414,9 @@ async function countTokens(client, contents, model = 'gemini-2.0-flash-exp', pro
|
||||
// 添加代理配置
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
if (proxyAgent) {
|
||||
// 只设置 httpsAgent,因为目标 URL 是 HTTPS (cloudcode-pa.googleapis.com)
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
axiosConfig.proxy = false
|
||||
logger.info(
|
||||
`🌐 Using proxy for Gemini countTokens: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||
)
|
||||
@@ -1327,18 +1485,22 @@ async function generateContent(
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: request,
|
||||
timeout: 60000 // 生成内容可能需要更长时间
|
||||
timeout: 600000 // 生成内容可能需要更长时间
|
||||
}
|
||||
|
||||
// 添加代理配置
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
if (proxyAgent) {
|
||||
// 只设置 httpsAgent,因为目标 URL 是 HTTPS (cloudcode-pa.googleapis.com)
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
axiosConfig.proxy = false
|
||||
logger.info(
|
||||
`🌐 Using proxy for Gemini generateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||
)
|
||||
} else {
|
||||
logger.debug('🌐 No proxy configured for Gemini generateContent')
|
||||
// 没有代理时,使用 keepAlive agent 防止长时间请求被中断
|
||||
axiosConfig.httpsAgent = keepAliveAgent
|
||||
logger.debug('🌐 Using keepAlive agent for Gemini generateContent')
|
||||
}
|
||||
|
||||
const response = await axios(axiosConfig)
|
||||
@@ -1401,18 +1563,23 @@ async function generateContentStream(
|
||||
},
|
||||
data: request,
|
||||
responseType: 'stream',
|
||||
timeout: 60000
|
||||
timeout: 0 // 流式请求不设置超时限制,由 keepAlive 和 AbortSignal 控制
|
||||
}
|
||||
|
||||
// 添加代理配置
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
if (proxyAgent) {
|
||||
// 只设置 httpsAgent,因为目标 URL 是 HTTPS (cloudcode-pa.googleapis.com)
|
||||
// 同时设置 httpAgent 和 httpsAgent 可能导致 axios/follow-redirects 选择错误的协议
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
axiosConfig.proxy = false
|
||||
logger.info(
|
||||
`🌐 Using proxy for Gemini streamGenerateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||
)
|
||||
} else {
|
||||
logger.debug('🌐 No proxy configured for Gemini streamGenerateContent')
|
||||
// 没有代理时,使用 keepAlive agent 防止长时间流式请求被中断
|
||||
axiosConfig.httpsAgent = keepAliveAgent
|
||||
logger.debug('🌐 Using keepAlive agent for Gemini streamGenerateContent')
|
||||
}
|
||||
|
||||
// 如果提供了中止信号,添加到配置中
|
||||
@@ -1426,6 +1593,73 @@ async function generateContentStream(
|
||||
return response.data // 返回流对象
|
||||
}
|
||||
|
||||
// 更新账户的临时项目 ID
|
||||
async function updateTempProjectId(accountId, tempProjectId) {
|
||||
if (!tempProjectId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const account = await getAccount(accountId)
|
||||
if (!account) {
|
||||
logger.warn(`Account ${accountId} not found when updating tempProjectId`)
|
||||
return
|
||||
}
|
||||
|
||||
// 只有在没有固定项目 ID 的情况下才更新临时项目 ID
|
||||
if (!account.projectId && tempProjectId !== account.tempProjectId) {
|
||||
await updateAccount(accountId, { tempProjectId })
|
||||
logger.info(`Updated tempProjectId for account ${accountId}: ${tempProjectId}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update tempProjectId for account ${accountId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置账户状态(清除所有异常状态)
|
||||
async function resetAccountStatus(accountId) {
|
||||
const account = await getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const updates = {
|
||||
// 根据是否有有效的 refreshToken 来设置 status
|
||||
status: account.refreshToken ? 'active' : 'created',
|
||||
// 恢复可调度状态
|
||||
schedulable: 'true',
|
||||
// 清除错误相关字段
|
||||
errorMessage: '',
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: ''
|
||||
}
|
||||
|
||||
await updateAccount(accountId, updates)
|
||||
logger.info(`✅ Reset all error status for Gemini account ${accountId}`)
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
platform: 'gemini',
|
||||
status: 'recovered',
|
||||
errorCode: 'STATUS_RESET',
|
||||
reason: 'Account status manually reset',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
logger.info(`📢 Webhook notification sent for Gemini account ${account.name} status reset`)
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send status reset webhook notification:', webhookError)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Account status reset successfully'
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateAuthUrl,
|
||||
pollAuthorizationStatus,
|
||||
@@ -1443,6 +1677,7 @@ module.exports = {
|
||||
getAccountRateLimitInfo,
|
||||
isTokenExpired,
|
||||
getOauthClient,
|
||||
forwardToCodeAssist, // 通用转发函数
|
||||
loadCodeAssist,
|
||||
getOnboardTier,
|
||||
onboardUser,
|
||||
@@ -1454,6 +1689,8 @@ module.exports = {
|
||||
countTokens,
|
||||
generateContent,
|
||||
generateContentStream,
|
||||
updateTempProjectId,
|
||||
resetAccountStatus,
|
||||
OAUTH_CLIENT_ID,
|
||||
OAUTH_SCOPES
|
||||
}
|
||||
|
||||
586
src/services/geminiApiAccountService.js
Normal file
@@ -0,0 +1,586 @@
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
|
||||
class GeminiApiAccountService {
|
||||
constructor() {
|
||||
// 加密相关常量
|
||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
||||
this.ENCRYPTION_SALT = 'gemini-api-salt'
|
||||
|
||||
// Redis 键前缀
|
||||
this.ACCOUNT_KEY_PREFIX = 'gemini_api_account:'
|
||||
this.SHARED_ACCOUNTS_KEY = 'shared_gemini_api_accounts'
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
this._encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
this._decryptCache = new LRUCache(500)
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
this._decryptCache.cleanup()
|
||||
logger.info('🧹 Gemini-API decrypt cache cleanup completed', this._decryptCache.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
}
|
||||
|
||||
// 创建账户
|
||||
async createAccount(options = {}) {
|
||||
const {
|
||||
name = 'Gemini API Account',
|
||||
description = '',
|
||||
apiKey = '', // 必填:Google AI Studio API Key
|
||||
baseUrl = 'https://generativelanguage.googleapis.com', // 默认 Gemini API 基础 URL
|
||||
proxy = null,
|
||||
priority = 50, // 调度优先级 (1-100)
|
||||
isActive = true,
|
||||
accountType = 'shared', // 'dedicated' or 'shared'
|
||||
schedulable = true, // 是否可被调度
|
||||
supportedModels = [], // 支持的模型列表
|
||||
rateLimitDuration = 60 // 限流时间(分钟)
|
||||
} = options
|
||||
|
||||
// 验证必填字段
|
||||
if (!apiKey) {
|
||||
throw new Error('API Key is required for Gemini-API account')
|
||||
}
|
||||
|
||||
// 规范化 baseUrl(确保不以 / 结尾)
|
||||
const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
|
||||
|
||||
const accountId = uuidv4()
|
||||
|
||||
const accountData = {
|
||||
id: accountId,
|
||||
platform: 'gemini-api',
|
||||
name,
|
||||
description,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
apiKey: this._encryptSensitiveData(apiKey),
|
||||
priority: priority.toString(),
|
||||
proxy: proxy ? JSON.stringify(proxy) : '',
|
||||
isActive: isActive.toString(),
|
||||
accountType,
|
||||
schedulable: schedulable.toString(),
|
||||
supportedModels: JSON.stringify(supportedModels),
|
||||
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: '',
|
||||
status: 'active',
|
||||
errorMessage: '',
|
||||
|
||||
// 限流相关
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: '',
|
||||
rateLimitDuration: rateLimitDuration.toString()
|
||||
}
|
||||
|
||||
// 保存到 Redis
|
||||
await this._saveAccount(accountId, accountData)
|
||||
|
||||
logger.success(`🚀 Created Gemini-API account: ${name} (${accountId})`)
|
||||
|
||||
return {
|
||||
...accountData,
|
||||
apiKey: '***' // 返回时隐藏敏感信息
|
||||
}
|
||||
}
|
||||
|
||||
// 获取账户
|
||||
async getAccount(accountId) {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
const accountData = await client.hgetall(key)
|
||||
|
||||
if (!accountData || !accountData.id) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 解密敏感数据
|
||||
accountData.apiKey = this._decryptSensitiveData(accountData.apiKey)
|
||||
|
||||
// 解析 JSON 字段
|
||||
if (accountData.proxy) {
|
||||
try {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
} catch (e) {
|
||||
accountData.proxy = null
|
||||
}
|
||||
}
|
||||
|
||||
if (accountData.supportedModels) {
|
||||
try {
|
||||
accountData.supportedModels = JSON.parse(accountData.supportedModels)
|
||||
} catch (e) {
|
||||
accountData.supportedModels = []
|
||||
}
|
||||
}
|
||||
|
||||
return accountData
|
||||
}
|
||||
|
||||
// 更新账户
|
||||
async updateAccount(accountId, updates) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
// 处理敏感字段加密
|
||||
if (updates.apiKey) {
|
||||
updates.apiKey = this._encryptSensitiveData(updates.apiKey)
|
||||
}
|
||||
|
||||
// 处理 JSON 字段
|
||||
if (updates.proxy !== undefined) {
|
||||
updates.proxy = updates.proxy ? JSON.stringify(updates.proxy) : ''
|
||||
}
|
||||
|
||||
if (updates.supportedModels !== undefined) {
|
||||
updates.supportedModels = JSON.stringify(updates.supportedModels)
|
||||
}
|
||||
|
||||
// 规范化 baseUrl
|
||||
if (updates.baseUrl) {
|
||||
updates.baseUrl = updates.baseUrl.endsWith('/')
|
||||
? updates.baseUrl.slice(0, -1)
|
||||
: updates.baseUrl
|
||||
}
|
||||
|
||||
// 更新 Redis
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
await client.hset(key, updates)
|
||||
|
||||
logger.info(`📝 Updated Gemini-API account: ${account.name}`)
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// 删除账户
|
||||
async deleteAccount(accountId) {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// 从共享账户列表中移除
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
// 删除账户数据
|
||||
await client.del(key)
|
||||
|
||||
logger.info(`🗑️ Deleted Gemini-API account: ${accountId}`)
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// 获取所有账户
|
||||
async getAllAccounts(includeInactive = false) {
|
||||
const client = redis.getClientSafe()
|
||||
const accountIds = await client.smembers(this.SHARED_ACCOUNTS_KEY)
|
||||
const accounts = []
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (account) {
|
||||
// 过滤非活跃账户
|
||||
if (includeInactive || account.isActive === 'true') {
|
||||
// 隐藏敏感信息
|
||||
account.apiKey = '***'
|
||||
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = this._getRateLimitInfo(account)
|
||||
|
||||
// 格式化 rateLimitStatus 为对象
|
||||
account.rateLimitStatus = rateLimitInfo.isRateLimited
|
||||
? {
|
||||
isRateLimited: true,
|
||||
rateLimitedAt: account.rateLimitedAt || null,
|
||||
minutesRemaining: rateLimitInfo.remainingMinutes || 0
|
||||
}
|
||||
: {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
minutesRemaining: 0
|
||||
}
|
||||
|
||||
// 转换 schedulable 字段为布尔值
|
||||
account.schedulable = account.schedulable !== 'false'
|
||||
// 转换 isActive 字段为布尔值
|
||||
account.isActive = account.isActive === 'true'
|
||||
|
||||
account.platform = account.platform || 'gemini-api'
|
||||
|
||||
accounts.push(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 直接从 Redis 获取所有账户(包括非共享账户)
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
for (const key of keys) {
|
||||
const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '')
|
||||
if (!accountIds.includes(accountId)) {
|
||||
const accountData = await client.hgetall(key)
|
||||
if (accountData && accountData.id) {
|
||||
// 过滤非活跃账户
|
||||
if (includeInactive || accountData.isActive === 'true') {
|
||||
// 隐藏敏感信息
|
||||
accountData.apiKey = '***'
|
||||
|
||||
// 解析 JSON 字段
|
||||
if (accountData.proxy) {
|
||||
try {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
} catch (e) {
|
||||
accountData.proxy = null
|
||||
}
|
||||
}
|
||||
|
||||
if (accountData.supportedModels) {
|
||||
try {
|
||||
accountData.supportedModels = JSON.parse(accountData.supportedModels)
|
||||
} catch (e) {
|
||||
accountData.supportedModels = []
|
||||
}
|
||||
}
|
||||
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||
|
||||
// 格式化 rateLimitStatus 为对象
|
||||
accountData.rateLimitStatus = rateLimitInfo.isRateLimited
|
||||
? {
|
||||
isRateLimited: true,
|
||||
rateLimitedAt: accountData.rateLimitedAt || null,
|
||||
minutesRemaining: rateLimitInfo.remainingMinutes || 0
|
||||
}
|
||||
: {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
minutesRemaining: 0
|
||||
}
|
||||
|
||||
// 转换 schedulable 字段为布尔值
|
||||
accountData.schedulable = accountData.schedulable !== 'false'
|
||||
// 转换 isActive 字段为布尔值
|
||||
accountData.isActive = accountData.isActive === 'true'
|
||||
|
||||
accountData.platform = accountData.platform || 'gemini-api'
|
||||
|
||||
accounts.push(accountData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accounts
|
||||
}
|
||||
|
||||
// 标记账户已使用
|
||||
async markAccountUsed(accountId) {
|
||||
await this.updateAccount(accountId, {
|
||||
lastUsedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// 标记账户限流
|
||||
async setAccountRateLimited(accountId, isLimited, duration = null) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isLimited) {
|
||||
const rateLimitDuration = duration || parseInt(account.rateLimitDuration) || 60
|
||||
const now = new Date()
|
||||
const resetAt = new Date(now.getTime() + rateLimitDuration * 60000)
|
||||
|
||||
await this.updateAccount(accountId, {
|
||||
rateLimitedAt: now.toISOString(),
|
||||
rateLimitStatus: 'limited',
|
||||
rateLimitResetAt: resetAt.toISOString(),
|
||||
rateLimitDuration: rateLimitDuration.toString(),
|
||||
status: 'rateLimited',
|
||||
schedulable: 'false', // 防止被调度
|
||||
errorMessage: `Rate limited until ${resetAt.toISOString()}`
|
||||
})
|
||||
|
||||
logger.warn(
|
||||
`⏳ Gemini-API account ${account.name} marked as rate limited for ${rateLimitDuration} minutes (until ${resetAt.toISOString()})`
|
||||
)
|
||||
} else {
|
||||
// 清除限流状态
|
||||
await this.updateAccount(accountId, {
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: '',
|
||||
rateLimitResetAt: '',
|
||||
status: 'active',
|
||||
schedulable: 'true',
|
||||
errorMessage: ''
|
||||
})
|
||||
|
||||
logger.info(`✅ Rate limit cleared for Gemini-API account ${account.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为未授权状态(401错误)
|
||||
async markAccountUnauthorized(accountId, reason = 'Gemini API账号认证失败(401错误)') {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const currentCount = parseInt(account.unauthorizedCount || '0', 10)
|
||||
const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1
|
||||
|
||||
await this.updateAccount(accountId, {
|
||||
status: 'unauthorized',
|
||||
schedulable: 'false',
|
||||
errorMessage: reason,
|
||||
unauthorizedAt: now,
|
||||
unauthorizedCount: unauthorizedCount.toString()
|
||||
})
|
||||
|
||||
logger.warn(
|
||||
`🚫 Gemini-API account ${account.name || accountId} marked as unauthorized due to 401 error`
|
||||
)
|
||||
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
platform: 'gemini-api',
|
||||
status: 'unauthorized',
|
||||
errorCode: 'GEMINI_API_UNAUTHORIZED',
|
||||
reason,
|
||||
timestamp: now
|
||||
})
|
||||
logger.info(
|
||||
`📢 Webhook notification sent for Gemini-API account ${account.name || accountId} unauthorized state`
|
||||
)
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send unauthorized webhook notification:', webhookError)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查并清除过期的限流状态
|
||||
async checkAndClearRateLimit(accountId) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account || account.rateLimitStatus !== 'limited') {
|
||||
return false
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
let shouldClear = false
|
||||
|
||||
// 优先使用 rateLimitResetAt 字段
|
||||
if (account.rateLimitResetAt) {
|
||||
const resetAt = new Date(account.rateLimitResetAt)
|
||||
shouldClear = now >= resetAt
|
||||
} else {
|
||||
// 如果没有 rateLimitResetAt,使用旧的逻辑
|
||||
const rateLimitedAt = new Date(account.rateLimitedAt)
|
||||
const rateLimitDuration = parseInt(account.rateLimitDuration) || 60
|
||||
shouldClear = now - rateLimitedAt > rateLimitDuration * 60000
|
||||
}
|
||||
|
||||
if (shouldClear) {
|
||||
// 限流已过期,清除状态
|
||||
await this.setAccountRateLimited(accountId, false)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 切换调度状态
|
||||
async toggleSchedulable(accountId) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const newSchedulableStatus = account.schedulable === 'true' ? 'false' : 'true'
|
||||
await this.updateAccount(accountId, {
|
||||
schedulable: newSchedulableStatus
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`🔄 Toggled schedulable status for Gemini-API account ${account.name}: ${newSchedulableStatus}`
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
schedulable: newSchedulableStatus === 'true'
|
||||
}
|
||||
}
|
||||
|
||||
// 重置账户状态(清除所有异常状态)
|
||||
async resetAccountStatus(accountId) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const updates = {
|
||||
// 根据是否有有效的 apiKey 来设置 status
|
||||
status: account.apiKey ? 'active' : 'created',
|
||||
// 恢复可调度状态
|
||||
schedulable: 'true',
|
||||
// 清除错误相关字段
|
||||
errorMessage: '',
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: '',
|
||||
rateLimitResetAt: '',
|
||||
rateLimitDuration: ''
|
||||
}
|
||||
|
||||
await this.updateAccount(accountId, updates)
|
||||
logger.info(`✅ Reset all error status for Gemini-API account ${accountId}`)
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
platform: 'gemini-api',
|
||||
status: 'recovered',
|
||||
errorCode: 'STATUS_RESET',
|
||||
reason: 'Account status manually reset',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
logger.info(
|
||||
`📢 Webhook notification sent for Gemini-API account ${account.name} status reset`
|
||||
)
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send status reset webhook notification:', webhookError)
|
||||
}
|
||||
|
||||
return { success: true, message: 'Account status reset successfully' }
|
||||
}
|
||||
|
||||
// API Key 不会过期
|
||||
isTokenExpired(_account) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取限流信息
|
||||
_getRateLimitInfo(accountData) {
|
||||
if (accountData.rateLimitStatus !== 'limited') {
|
||||
return { isRateLimited: false }
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
let willBeAvailableAt
|
||||
let remainingMinutes
|
||||
|
||||
// 优先使用 rateLimitResetAt 字段
|
||||
if (accountData.rateLimitResetAt) {
|
||||
willBeAvailableAt = new Date(accountData.rateLimitResetAt)
|
||||
remainingMinutes = Math.max(0, Math.ceil((willBeAvailableAt - now) / 60000))
|
||||
} else {
|
||||
// 如果没有 rateLimitResetAt,使用旧的逻辑
|
||||
const rateLimitedAt = new Date(accountData.rateLimitedAt)
|
||||
const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60
|
||||
const elapsedMinutes = Math.floor((now - rateLimitedAt) / 60000)
|
||||
remainingMinutes = Math.max(0, rateLimitDuration - elapsedMinutes)
|
||||
willBeAvailableAt = new Date(rateLimitedAt.getTime() + rateLimitDuration * 60000)
|
||||
}
|
||||
|
||||
return {
|
||||
isRateLimited: remainingMinutes > 0,
|
||||
remainingMinutes,
|
||||
willBeAvailableAt
|
||||
}
|
||||
}
|
||||
|
||||
// 加密敏感数据
|
||||
_encryptSensitiveData(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const key = this._getEncryptionKey()
|
||||
const iv = crypto.randomBytes(16)
|
||||
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
|
||||
let encrypted = cipher.update(text)
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()])
|
||||
|
||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
|
||||
}
|
||||
|
||||
// 解密敏感数据
|
||||
_decryptSensitiveData(text) {
|
||||
if (!text || text === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
|
||||
const cached = this._decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const key = this._getEncryptionKey()
|
||||
const [ivHex, encryptedHex] = text.split(':')
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const encryptedText = Buffer.from(encryptedHex, 'hex')
|
||||
|
||||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encryptedText)
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()])
|
||||
|
||||
const result = decrypted.toString()
|
||||
|
||||
// 存入缓存(5分钟过期)
|
||||
this._decryptCache.set(cacheKey, result, 5 * 60 * 1000)
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('Decryption error:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 获取加密密钥
|
||||
_getEncryptionKey() {
|
||||
if (!this._encryptionKeyCache) {
|
||||
this._encryptionKeyCache = crypto.scryptSync(
|
||||
config.security.encryptionKey,
|
||||
this.ENCRYPTION_SALT,
|
||||
32
|
||||
)
|
||||
}
|
||||
return this._encryptionKeyCache
|
||||
}
|
||||
|
||||
// 保存账户到 Redis
|
||||
async _saveAccount(accountId, accountData) {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// 保存账户数据
|
||||
await client.hset(key, accountData)
|
||||
|
||||
// 添加到共享账户列表
|
||||
if (accountData.accountType === 'shared') {
|
||||
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new GeminiApiAccountService()
|
||||
@@ -279,7 +279,9 @@ async function sendGeminiRequest({
|
||||
// 添加代理配置
|
||||
const proxyAgent = createProxyAgent(proxy)
|
||||
if (proxyAgent) {
|
||||
// 只设置 httpsAgent,因为目标 URL 是 HTTPS (cloudcode.googleapis.com)
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
axiosConfig.proxy = false
|
||||
logger.info(`🌐 Using proxy for Gemini API request: ${ProxyHelper.getProxyDescription(proxy)}`)
|
||||
} else {
|
||||
logger.debug('🌐 No proxy configured for Gemini API request')
|
||||
@@ -387,7 +389,9 @@ async function getAvailableModels(accessToken, proxy, projectId, location = 'us-
|
||||
|
||||
const proxyAgent = createProxyAgent(proxy)
|
||||
if (proxyAgent) {
|
||||
// 只设置 httpsAgent,因为目标 URL 是 HTTPS (cloudcode.googleapis.com)
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
axiosConfig.proxy = false
|
||||
logger.info(
|
||||
`🌐 Using proxy for Gemini models request: ${ProxyHelper.getProxyDescription(proxy)}`
|
||||
)
|
||||
@@ -488,7 +492,9 @@ async function countTokens({
|
||||
// 添加代理配置
|
||||
const proxyAgent = createProxyAgent(proxy)
|
||||
if (proxyAgent) {
|
||||
// 只设置 httpsAgent,因为目标 URL 是 HTTPS (cloudcode.googleapis.com)
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
axiosConfig.proxy = false
|
||||
logger.info(
|
||||
`🌐 Using proxy for Gemini countTokens request: ${ProxyHelper.getProxyDescription(proxy)}`
|
||||
)
|
||||
|
||||
145
src/services/modelService.js
Normal file
@@ -0,0 +1,145 @@
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 模型服务
|
||||
* 管理系统支持的 AI 模型列表
|
||||
* 与 pricingService 独立,专注于"支持哪些模型"而不是"如何计费"
|
||||
*/
|
||||
class ModelService {
|
||||
constructor() {
|
||||
this.supportedModels = this.getDefaultModels()
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化模型服务
|
||||
*/
|
||||
async initialize() {
|
||||
const totalModels = Object.values(this.supportedModels).reduce(
|
||||
(sum, config) => sum + config.models.length,
|
||||
0
|
||||
)
|
||||
logger.success(`✅ Model service initialized with ${totalModels} models`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支持的模型配置
|
||||
*/
|
||||
getDefaultModels() {
|
||||
return {
|
||||
claude: {
|
||||
provider: 'anthropic',
|
||||
description: 'Claude models from Anthropic',
|
||||
models: [
|
||||
'claude-opus-4-5-20251101',
|
||||
'claude-haiku-4-5-20251001',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-opus-4-1-20250805',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-3-7-sonnet-20250219',
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'claude-3-5-haiku-20241022',
|
||||
'claude-3-opus-20240229',
|
||||
'claude-3-haiku-20240307'
|
||||
]
|
||||
},
|
||||
openai: {
|
||||
provider: 'openai',
|
||||
description: 'OpenAI GPT models',
|
||||
models: [
|
||||
'gpt-5.1-2025-11-13',
|
||||
'gpt-5.1-codex-mini',
|
||||
'gpt-5.1-codex',
|
||||
'gpt-5.1-codex-max',
|
||||
'gpt-5-2025-08-07',
|
||||
'gpt-5-codex'
|
||||
]
|
||||
},
|
||||
gemini: {
|
||||
provider: 'google',
|
||||
description: 'Google Gemini models',
|
||||
models: ['gemini-2.5-pro', 'gemini-3-pro-preview', 'gemini-2.5-flash']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有支持的模型(OpenAI API 格式)
|
||||
*/
|
||||
getAllModels() {
|
||||
const models = []
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
for (const [_service, config] of Object.entries(this.supportedModels)) {
|
||||
for (const modelId of config.models) {
|
||||
models.push({
|
||||
id: modelId,
|
||||
object: 'model',
|
||||
created: now,
|
||||
owned_by: config.provider
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return models.sort((a, b) => {
|
||||
// 先按 provider 排序,再按 model id 排序
|
||||
if (a.owned_by !== b.owned_by) {
|
||||
return a.owned_by.localeCompare(b.owned_by)
|
||||
}
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 provider 获取模型
|
||||
* @param {string} provider - 'anthropic', 'openai', 'google' 等
|
||||
*/
|
||||
getModelsByProvider(provider) {
|
||||
return this.getAllModels().filter((m) => m.owned_by === provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模型是否被支持
|
||||
* @param {string} modelId - 模型 ID
|
||||
*/
|
||||
isModelSupported(modelId) {
|
||||
if (!modelId) {
|
||||
return false
|
||||
}
|
||||
return this.getAllModels().some((m) => m.id === modelId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型的 provider
|
||||
* @param {string} modelId - 模型 ID
|
||||
*/
|
||||
getModelProvider(modelId) {
|
||||
const model = this.getAllModels().find((m) => m.id === modelId)
|
||||
return model ? model.owned_by : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务状态
|
||||
*/
|
||||
getStatus() {
|
||||
const totalModels = Object.values(this.supportedModels).reduce(
|
||||
(sum, config) => sum + config.models.length,
|
||||
0
|
||||
)
|
||||
|
||||
return {
|
||||
initialized: true,
|
||||
totalModels,
|
||||
providers: Object.keys(this.supportedModels)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源(保留接口兼容性)
|
||||
*/
|
||||
cleanup() {
|
||||
logger.debug('📋 Model service cleanup (no-op)')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ModelService()
|
||||
@@ -115,6 +115,85 @@ setInterval(
|
||||
10 * 60 * 1000
|
||||
)
|
||||
|
||||
function toNumberOrNull(value) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
function computeResetMeta(updatedAt, resetAfterSeconds) {
|
||||
if (!updatedAt || resetAfterSeconds === null || resetAfterSeconds === undefined) {
|
||||
return {
|
||||
resetAt: null,
|
||||
remainingSeconds: null
|
||||
}
|
||||
}
|
||||
|
||||
const updatedMs = Date.parse(updatedAt)
|
||||
if (Number.isNaN(updatedMs)) {
|
||||
return {
|
||||
resetAt: null,
|
||||
remainingSeconds: null
|
||||
}
|
||||
}
|
||||
|
||||
const resetMs = updatedMs + resetAfterSeconds * 1000
|
||||
return {
|
||||
resetAt: new Date(resetMs).toISOString(),
|
||||
remainingSeconds: Math.max(0, Math.round((resetMs - Date.now()) / 1000))
|
||||
}
|
||||
}
|
||||
|
||||
function buildCodexUsageSnapshot(accountData) {
|
||||
const updatedAt = accountData.codexUsageUpdatedAt
|
||||
|
||||
const primaryUsedPercent = toNumberOrNull(accountData.codexPrimaryUsedPercent)
|
||||
const primaryResetAfterSeconds = toNumberOrNull(accountData.codexPrimaryResetAfterSeconds)
|
||||
const primaryWindowMinutes = toNumberOrNull(accountData.codexPrimaryWindowMinutes)
|
||||
const secondaryUsedPercent = toNumberOrNull(accountData.codexSecondaryUsedPercent)
|
||||
const secondaryResetAfterSeconds = toNumberOrNull(accountData.codexSecondaryResetAfterSeconds)
|
||||
const secondaryWindowMinutes = toNumberOrNull(accountData.codexSecondaryWindowMinutes)
|
||||
const overSecondaryPercent = toNumberOrNull(accountData.codexPrimaryOverSecondaryLimitPercent)
|
||||
|
||||
const hasPrimaryData =
|
||||
primaryUsedPercent !== null ||
|
||||
primaryResetAfterSeconds !== null ||
|
||||
primaryWindowMinutes !== null
|
||||
const hasSecondaryData =
|
||||
secondaryUsedPercent !== null ||
|
||||
secondaryResetAfterSeconds !== null ||
|
||||
secondaryWindowMinutes !== null
|
||||
|
||||
if (!updatedAt && !hasPrimaryData && !hasSecondaryData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const primaryMeta = computeResetMeta(updatedAt, primaryResetAfterSeconds)
|
||||
const secondaryMeta = computeResetMeta(updatedAt, secondaryResetAfterSeconds)
|
||||
|
||||
return {
|
||||
updatedAt,
|
||||
primary: {
|
||||
usedPercent: primaryUsedPercent,
|
||||
resetAfterSeconds: primaryResetAfterSeconds,
|
||||
windowMinutes: primaryWindowMinutes,
|
||||
resetAt: primaryMeta.resetAt,
|
||||
remainingSeconds: primaryMeta.remainingSeconds
|
||||
},
|
||||
secondary: {
|
||||
usedPercent: secondaryUsedPercent,
|
||||
resetAfterSeconds: secondaryResetAfterSeconds,
|
||||
windowMinutes: secondaryWindowMinutes,
|
||||
resetAt: secondaryMeta.resetAt,
|
||||
remainingSeconds: secondaryMeta.remainingSeconds
|
||||
},
|
||||
primaryOverSecondaryPercent: overSecondaryPercent
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新访问令牌
|
||||
async function refreshAccessToken(refreshToken, proxy = null) {
|
||||
try {
|
||||
@@ -144,8 +223,9 @@ async function refreshAccessToken(refreshToken, proxy = null) {
|
||||
// 配置代理(如果有)
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxy)
|
||||
if (proxyAgent) {
|
||||
requestOptions.httpAgent = proxyAgent
|
||||
requestOptions.httpsAgent = proxyAgent
|
||||
requestOptions.proxy = false // 重要:禁用 axios 的默认代理,强制使用我们的 httpsAgent
|
||||
requestOptions.proxy = false
|
||||
logger.info(
|
||||
`🌐 Using proxy for OpenAI token refresh: ${ProxyHelper.getProxyDescription(proxy)}`
|
||||
)
|
||||
@@ -255,6 +335,19 @@ function isTokenExpired(account) {
|
||||
return new Date(account.expiresAt) <= new Date()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户订阅是否过期
|
||||
* @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()
|
||||
}
|
||||
|
||||
// 刷新账户的 access token(带分布式锁)
|
||||
async function refreshAccountToken(accountId) {
|
||||
let lockAcquired = false
|
||||
@@ -325,15 +418,15 @@ async function refreshAccountToken(accountId) {
|
||||
throw new Error('Failed to refresh token')
|
||||
}
|
||||
|
||||
// 准备更新数据
|
||||
// 准备更新数据 - 不要在这里加密,让 updateAccount 统一处理
|
||||
const updates = {
|
||||
accessToken: encrypt(newTokens.access_token),
|
||||
accessToken: newTokens.access_token, // 不加密,让 updateAccount 处理
|
||||
expiresAt: new Date(newTokens.expiry_date).toISOString()
|
||||
}
|
||||
|
||||
// 如果有新的 ID token,也更新它(这对于首次未提供 ID Token 的账户特别重要)
|
||||
if (newTokens.id_token) {
|
||||
updates.idToken = encrypt(newTokens.id_token)
|
||||
updates.idToken = newTokens.id_token // 不加密,让 updateAccount 处理
|
||||
|
||||
// 如果之前没有 ID Token,尝试解析并更新用户信息
|
||||
if (!account.idToken || account.idToken === '') {
|
||||
@@ -364,7 +457,7 @@ async function refreshAccountToken(accountId) {
|
||||
updates.organizationTitle = authClaims.organizations[0].title
|
||||
}
|
||||
if (payload.email) {
|
||||
updates.email = encrypt(payload.email)
|
||||
updates.email = payload.email // 不加密,让 updateAccount 处理
|
||||
}
|
||||
if (payload.email_verified !== undefined) {
|
||||
updates.emailVerified = payload.email_verified
|
||||
@@ -380,14 +473,14 @@ async function refreshAccountToken(accountId) {
|
||||
|
||||
// 如果返回了新的 refresh token,更新它
|
||||
if (newTokens.refresh_token && newTokens.refresh_token !== refreshToken) {
|
||||
updates.refreshToken = encrypt(newTokens.refresh_token)
|
||||
updates.refreshToken = newTokens.refresh_token // 不加密,让 updateAccount 处理
|
||||
logger.info(`Updated refresh token for account ${accountId}`)
|
||||
}
|
||||
|
||||
// 更新账户信息
|
||||
await updateAccount(accountId, updates)
|
||||
|
||||
logRefreshSuccess(accountId, accountName, 'openai', newTokens.expiry_date)
|
||||
logRefreshSuccess(accountId, accountName, 'openai', newTokens) // 传入完整的 newTokens 对象
|
||||
return newTokens
|
||||
} catch (error) {
|
||||
logRefreshError(accountId, account?.name || accountName, 'openai', error.message)
|
||||
@@ -476,7 +569,11 @@ async function createAccount(accountData) {
|
||||
// 过期时间
|
||||
expiresAt: oauthData.expires_in
|
||||
? new Date(Date.now() + oauthData.expires_in * 1000).toISOString()
|
||||
: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), // 默认1年
|
||||
: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), // OAuth Token 过期时间(技术字段)
|
||||
|
||||
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
||||
|
||||
// 状态字段
|
||||
isActive: accountData.isActive !== false ? 'true' : 'false',
|
||||
status: 'active',
|
||||
@@ -583,6 +680,12 @@ async function updateAccount(accountId, updates) {
|
||||
typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy)
|
||||
}
|
||||
|
||||
// ✅ 如果通过路由映射更新了 subscriptionExpiresAt,直接保存
|
||||
// subscriptionExpiresAt 是业务字段,与 token 刷新独立
|
||||
if (updates.subscriptionExpiresAt !== undefined) {
|
||||
// 直接保存,不做任何调整
|
||||
}
|
||||
|
||||
// 更新账户类型时处理共享账户集合
|
||||
const client = redisClient.getClientSafe()
|
||||
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
|
||||
@@ -650,6 +753,8 @@ async function getAllAccounts() {
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
const codexUsage = buildCodexUsageSnapshot(accountData)
|
||||
|
||||
// 解密敏感数据(但不返回给前端)
|
||||
if (accountData.email) {
|
||||
accountData.email = decrypt(accountData.email)
|
||||
@@ -657,12 +762,24 @@ async function getAllAccounts() {
|
||||
|
||||
// 先保存 refreshToken 是否存在的标记
|
||||
const hasRefreshTokenFlag = !!accountData.refreshToken
|
||||
const maskedAccessToken = accountData.accessToken ? '[ENCRYPTED]' : ''
|
||||
const maskedRefreshToken = accountData.refreshToken ? '[ENCRYPTED]' : ''
|
||||
const maskedOauth = accountData.openaiOauth ? '[ENCRYPTED]' : ''
|
||||
|
||||
// 屏蔽敏感信息(token等不应该返回给前端)
|
||||
delete accountData.idToken
|
||||
delete accountData.accessToken
|
||||
delete accountData.refreshToken
|
||||
delete accountData.openaiOauth
|
||||
delete accountData.codexPrimaryUsedPercent
|
||||
delete accountData.codexPrimaryResetAfterSeconds
|
||||
delete accountData.codexPrimaryWindowMinutes
|
||||
delete accountData.codexSecondaryUsedPercent
|
||||
delete accountData.codexSecondaryResetAfterSeconds
|
||||
delete accountData.codexSecondaryWindowMinutes
|
||||
delete accountData.codexPrimaryOverSecondaryLimitPercent
|
||||
// 时间戳改由 codexUsage.updatedAt 暴露
|
||||
delete accountData.codexUsageUpdatedAt
|
||||
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = await getAccountRateLimitInfo(accountData.id)
|
||||
@@ -671,24 +788,32 @@ async function getAllAccounts() {
|
||||
if (accountData.proxy) {
|
||||
try {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
// 屏蔽代理密码
|
||||
if (accountData.proxy && accountData.proxy.password) {
|
||||
accountData.proxy.password = '******'
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果解析失败,设置为null
|
||||
accountData.proxy = null
|
||||
}
|
||||
}
|
||||
|
||||
const tokenExpiresAt = accountData.expiresAt || null
|
||||
const subscriptionExpiresAt =
|
||||
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
|
||||
? accountData.subscriptionExpiresAt
|
||||
: null
|
||||
|
||||
// 不解密敏感字段,只返回基本信息
|
||||
accounts.push({
|
||||
...accountData,
|
||||
isActive: accountData.isActive === 'true',
|
||||
schedulable: accountData.schedulable !== 'false',
|
||||
openaiOauth: accountData.openaiOauth ? '[ENCRYPTED]' : '',
|
||||
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
|
||||
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
|
||||
openaiOauth: maskedOauth,
|
||||
accessToken: maskedAccessToken,
|
||||
refreshToken: maskedRefreshToken,
|
||||
|
||||
// ✅ 前端显示订阅过期时间(业务字段)
|
||||
tokenExpiresAt,
|
||||
subscriptionExpiresAt,
|
||||
expiresAt: subscriptionExpiresAt,
|
||||
|
||||
// 添加 scopes 字段用于判断认证方式
|
||||
// 处理空字符串的情况
|
||||
scopes:
|
||||
@@ -698,15 +823,20 @@ async function getAllAccounts() {
|
||||
// 添加限流状态信息(统一格式)
|
||||
rateLimitStatus: rateLimitInfo
|
||||
? {
|
||||
status: rateLimitInfo.status,
|
||||
isRateLimited: rateLimitInfo.isRateLimited,
|
||||
rateLimitedAt: rateLimitInfo.rateLimitedAt,
|
||||
rateLimitResetAt: rateLimitInfo.rateLimitResetAt,
|
||||
minutesRemaining: rateLimitInfo.minutesRemaining
|
||||
}
|
||||
: {
|
||||
status: 'normal',
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
rateLimitResetAt: null,
|
||||
minutesRemaining: 0
|
||||
}
|
||||
},
|
||||
codexUsage
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -714,6 +844,47 @@ async function getAllAccounts() {
|
||||
return accounts
|
||||
}
|
||||
|
||||
// 获取单个账户的概要信息(用于外部展示基本状态)
|
||||
async function getAccountOverview(accountId) {
|
||||
const client = redisClient.getClientSafe()
|
||||
const accountData = await client.hgetall(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const codexUsage = buildCodexUsageSnapshot(accountData)
|
||||
const rateLimitInfo = await getAccountRateLimitInfo(accountId)
|
||||
|
||||
if (accountData.proxy) {
|
||||
try {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
} catch (error) {
|
||||
accountData.proxy = null
|
||||
}
|
||||
}
|
||||
|
||||
const scopes =
|
||||
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : []
|
||||
|
||||
return {
|
||||
id: accountData.id,
|
||||
accountType: accountData.accountType || 'shared',
|
||||
platform: accountData.platform || 'openai',
|
||||
isActive: accountData.isActive === 'true',
|
||||
schedulable: accountData.schedulable !== 'false',
|
||||
rateLimitStatus: rateLimitInfo || {
|
||||
status: 'normal',
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
rateLimitResetAt: null,
|
||||
minutesRemaining: 0
|
||||
},
|
||||
codexUsage,
|
||||
scopes
|
||||
}
|
||||
}
|
||||
|
||||
// 选择可用账户(支持专属和共享账户)
|
||||
async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||
// 首先检查是否有粘性会话
|
||||
@@ -767,8 +938,17 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||
|
||||
for (const accountId of sharedAccountIds) {
|
||||
const account = await getAccount(accountId)
|
||||
if (account && account.isActive === 'true' && !isRateLimited(account)) {
|
||||
if (
|
||||
account &&
|
||||
account.isActive === 'true' &&
|
||||
!isRateLimited(account) &&
|
||||
!isSubscriptionExpired(account)
|
||||
) {
|
||||
availableAccounts.push(account)
|
||||
} else if (account && isSubscriptionExpired(account)) {
|
||||
logger.debug(
|
||||
`⏰ Skipping expired OpenAI account: ${account.name}, expired at ${account.subscriptionExpiresAt}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -869,6 +1049,49 @@ async function setAccountRateLimited(accountId, isLimited, resetsInSeconds = nul
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为未授权状态(401错误)
|
||||
async function markAccountUnauthorized(accountId, reason = 'OpenAI账号认证失败(401错误)') {
|
||||
const account = await getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const currentCount = parseInt(account.unauthorizedCount || '0', 10)
|
||||
const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1
|
||||
|
||||
const updates = {
|
||||
status: 'unauthorized',
|
||||
schedulable: 'false',
|
||||
errorMessage: reason,
|
||||
unauthorizedAt: now,
|
||||
unauthorizedCount: unauthorizedCount.toString()
|
||||
}
|
||||
|
||||
await updateAccount(accountId, updates)
|
||||
logger.warn(
|
||||
`🚫 Marked OpenAI account ${account.name || accountId} as unauthorized due to 401 error`
|
||||
)
|
||||
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
platform: 'openai',
|
||||
status: 'unauthorized',
|
||||
errorCode: 'OPENAI_UNAUTHORIZED',
|
||||
reason,
|
||||
timestamp: now
|
||||
})
|
||||
logger.info(
|
||||
`📢 Webhook notification sent for OpenAI account ${account.name} unauthorized state`
|
||||
)
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send unauthorized webhook notification:', webhookError)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重置账户所有异常状态
|
||||
async function resetAccountStatus(accountId) {
|
||||
const account = await getAccount(accountId)
|
||||
@@ -940,34 +1163,39 @@ async function getAccountRateLimitInfo(accountId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (account.rateLimitStatus === 'limited') {
|
||||
const status = account.rateLimitStatus || 'normal'
|
||||
const rateLimitedAt = account.rateLimitedAt || null
|
||||
const rateLimitResetAt = account.rateLimitResetAt || null
|
||||
|
||||
if (status === 'limited') {
|
||||
const now = Date.now()
|
||||
let remainingTime = 0
|
||||
|
||||
// 优先使用 rateLimitResetAt 字段(精确的重置时间)
|
||||
if (account.rateLimitResetAt) {
|
||||
const resetAt = new Date(account.rateLimitResetAt).getTime()
|
||||
if (rateLimitResetAt) {
|
||||
const resetAt = new Date(rateLimitResetAt).getTime()
|
||||
remainingTime = Math.max(0, resetAt - now)
|
||||
}
|
||||
// 回退到使用 rateLimitedAt + 默认1小时
|
||||
else if (account.rateLimitedAt) {
|
||||
const limitedAt = new Date(account.rateLimitedAt).getTime()
|
||||
} else if (rateLimitedAt) {
|
||||
const limitedAt = new Date(rateLimitedAt).getTime()
|
||||
const limitDuration = 60 * 60 * 1000 // 默认1小时
|
||||
remainingTime = Math.max(0, limitedAt + limitDuration - now)
|
||||
}
|
||||
|
||||
const minutesRemaining = remainingTime > 0 ? Math.ceil(remainingTime / (60 * 1000)) : 0
|
||||
|
||||
return {
|
||||
isRateLimited: remainingTime > 0,
|
||||
rateLimitedAt: account.rateLimitedAt,
|
||||
rateLimitResetAt: account.rateLimitResetAt,
|
||||
minutesRemaining: Math.ceil(remainingTime / (60 * 1000))
|
||||
status,
|
||||
isRateLimited: minutesRemaining > 0,
|
||||
rateLimitedAt,
|
||||
rateLimitResetAt,
|
||||
minutesRemaining
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
rateLimitResetAt: null,
|
||||
rateLimitedAt,
|
||||
rateLimitResetAt,
|
||||
minutesRemaining: 0
|
||||
}
|
||||
}
|
||||
@@ -995,9 +1223,45 @@ async function updateAccountUsage(accountId, tokens = 0) {
|
||||
// 为了兼容性,保留recordUsage作为updateAccountUsage的别名
|
||||
const recordUsage = updateAccountUsage
|
||||
|
||||
async function updateCodexUsageSnapshot(accountId, usageSnapshot) {
|
||||
if (!usageSnapshot || typeof usageSnapshot !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const fieldMap = {
|
||||
primaryUsedPercent: 'codexPrimaryUsedPercent',
|
||||
primaryResetAfterSeconds: 'codexPrimaryResetAfterSeconds',
|
||||
primaryWindowMinutes: 'codexPrimaryWindowMinutes',
|
||||
secondaryUsedPercent: 'codexSecondaryUsedPercent',
|
||||
secondaryResetAfterSeconds: 'codexSecondaryResetAfterSeconds',
|
||||
secondaryWindowMinutes: 'codexSecondaryWindowMinutes',
|
||||
primaryOverSecondaryPercent: 'codexPrimaryOverSecondaryLimitPercent'
|
||||
}
|
||||
|
||||
const updates = {}
|
||||
let hasPayload = false
|
||||
|
||||
for (const [key, field] of Object.entries(fieldMap)) {
|
||||
if (usageSnapshot[key] !== undefined && usageSnapshot[key] !== null) {
|
||||
updates[field] = String(usageSnapshot[key])
|
||||
hasPayload = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasPayload) {
|
||||
return
|
||||
}
|
||||
|
||||
updates.codexUsageUpdatedAt = new Date().toISOString()
|
||||
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createAccount,
|
||||
getAccount,
|
||||
getAccountOverview,
|
||||
updateAccount,
|
||||
deleteAccount,
|
||||
getAllAccounts,
|
||||
@@ -1005,11 +1269,13 @@ module.exports = {
|
||||
refreshAccountToken,
|
||||
isTokenExpired,
|
||||
setAccountRateLimited,
|
||||
markAccountUnauthorized,
|
||||
resetAccountStatus,
|
||||
toggleSchedulable,
|
||||
getAccountRateLimitInfo,
|
||||
updateAccountUsage,
|
||||
recordUsage, // 别名,指向updateAccountUsage
|
||||
updateCodexUsageSnapshot,
|
||||
encrypt,
|
||||
decrypt,
|
||||
generateEncryptionKey,
|
||||
|
||||
@@ -75,6 +75,11 @@ class OpenAIResponsesAccountService {
|
||||
isActive: isActive.toString(),
|
||||
accountType,
|
||||
schedulable: schedulable.toString(),
|
||||
|
||||
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||
// 注意:OpenAI-Responses 使用 API Key 认证,没有 OAuth token,因此没有 expiresAt
|
||||
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
|
||||
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: '',
|
||||
status: 'active',
|
||||
@@ -151,6 +156,12 @@ class OpenAIResponsesAccountService {
|
||||
: updates.baseApi
|
||||
}
|
||||
|
||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||
// OpenAI-Responses 使用 API Key,没有 token 刷新逻辑,不会覆盖此字段
|
||||
if (updates.subscriptionExpiresAt !== undefined) {
|
||||
// 直接保存,不做任何调整
|
||||
}
|
||||
|
||||
// 更新 Redis
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
@@ -212,6 +223,10 @@ class OpenAIResponsesAccountService {
|
||||
// 转换 isActive 字段为布尔值
|
||||
account.isActive = account.isActive === 'true'
|
||||
|
||||
// ✅ 前端显示订阅过期时间(业务字段)
|
||||
account.expiresAt = account.subscriptionExpiresAt || null
|
||||
account.platform = account.platform || 'openai-responses'
|
||||
|
||||
accounts.push(account)
|
||||
}
|
||||
}
|
||||
@@ -258,6 +273,10 @@ class OpenAIResponsesAccountService {
|
||||
// 转换 isActive 字段为布尔值
|
||||
accountData.isActive = accountData.isActive === 'true'
|
||||
|
||||
// ✅ 前端显示订阅过期时间(业务字段)
|
||||
accountData.expiresAt = accountData.subscriptionExpiresAt || null
|
||||
accountData.platform = accountData.platform || 'openai-responses'
|
||||
|
||||
accounts.push(accountData)
|
||||
}
|
||||
}
|
||||
@@ -293,6 +312,48 @@ class OpenAIResponsesAccountService {
|
||||
)
|
||||
}
|
||||
|
||||
// 🚫 标记账户为未授权状态(401错误)
|
||||
async markAccountUnauthorized(accountId, reason = 'OpenAI Responses账号认证失败(401错误)') {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const currentCount = parseInt(account.unauthorizedCount || '0', 10)
|
||||
const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1
|
||||
|
||||
await this.updateAccount(accountId, {
|
||||
status: 'unauthorized',
|
||||
schedulable: 'false',
|
||||
errorMessage: reason,
|
||||
unauthorizedAt: now,
|
||||
unauthorizedCount: unauthorizedCount.toString()
|
||||
})
|
||||
|
||||
logger.warn(
|
||||
`🚫 OpenAI-Responses account ${account.name || accountId} marked as unauthorized due to 401 error`
|
||||
)
|
||||
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
platform: 'openai',
|
||||
status: 'unauthorized',
|
||||
errorCode: 'OPENAI_UNAUTHORIZED',
|
||||
reason,
|
||||
timestamp: now
|
||||
})
|
||||
logger.info(
|
||||
`📢 Webhook notification sent for OpenAI-Responses account ${account.name || accountId} unauthorized state`
|
||||
)
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send unauthorized webhook notification:', webhookError)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查并清除过期的限流状态
|
||||
async checkAndClearRateLimit(accountId) {
|
||||
const account = await this.getAccount(accountId)
|
||||
@@ -462,6 +523,25 @@ class OpenAIResponsesAccountService {
|
||||
return { success: true, message: 'Account status reset successfully' }
|
||||
}
|
||||
|
||||
// ⏰ 检查账户订阅是否已过期
|
||||
isSubscriptionExpired(account) {
|
||||
if (!account.subscriptionExpiresAt) {
|
||||
return false // 未设置过期时间,视为永不过期
|
||||
}
|
||||
|
||||
const expiryDate = new Date(account.subscriptionExpiresAt)
|
||||
const now = new Date()
|
||||
|
||||
if (expiryDate <= now) {
|
||||
logger.debug(
|
||||
`⏰ OpenAI-Responses Account ${account.name} (${account.id}) subscription expired at ${account.subscriptionExpiresAt}`
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取限流信息
|
||||
_getRateLimitInfo(accountData) {
|
||||
if (accountData.rateLimitStatus !== 'limited') {
|
||||
|
||||