mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-04 21:58:19 +00:00
Compare commits
1345 Commits
refactor_e
...
logger
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13ff448049 | ||
|
|
3dc4d6c39e | ||
|
|
019412c27a | ||
|
|
96a2b81aaa | ||
|
|
fb610e62a0 | ||
|
|
736f7b55b7 | ||
|
|
2fd33ea294 | ||
|
|
53123aaf94 | ||
|
|
f8f5d26600 | ||
|
|
c86bc94d9d | ||
|
|
50e8639a40 | ||
|
|
424325162e | ||
|
|
a9a8676f7c | ||
|
|
14295f0035 | ||
|
|
29e70acc55 | ||
|
|
8599b348c0 | ||
|
|
6a761c2dba | ||
|
|
df2ee649ab | ||
|
|
00782aae88 | ||
|
|
70f8a59a65 | ||
|
|
a4cf9bb6fe | ||
|
|
ab30f584cc | ||
|
|
9629c8a771 | ||
|
|
fc56f45628 | ||
|
|
8f862152e8 | ||
|
|
ab6dc79600 | ||
|
|
52d9b8cc78 | ||
|
|
6a96ddea76 | ||
|
|
f1ac5606ee | ||
|
|
b2d9771a57 | ||
|
|
c4ca9d7c3b | ||
|
|
303feafc3c | ||
|
|
b2de5e229c | ||
|
|
8297723d91 | ||
|
|
90f1dafb55 | ||
|
|
cba21eb8c7 | ||
|
|
1f419a3c71 | ||
|
|
74e5e640c5 | ||
|
|
c4ea095ae0 | ||
|
|
1ded19795a | ||
|
|
158b46eb4b | ||
|
|
2581bea93e | ||
|
|
c3ed6a689e | ||
|
|
f60896a838 | ||
|
|
36c603f3b2 | ||
|
|
1c582cde31 | ||
|
|
94a6f3eb57 | ||
|
|
3058fae145 | ||
|
|
687e455051 | ||
|
|
39a4c9ac02 | ||
|
|
47bfea1eae | ||
|
|
d7b6d0cd34 | ||
|
|
2b70095b47 | ||
|
|
45ebcd4f11 | ||
|
|
3dbe0c2067 | ||
|
|
0654718b34 | ||
|
|
6791eb72ba | ||
|
|
cb3537f529 | ||
|
|
471fd3a3b2 | ||
|
|
810641a264 | ||
|
|
b7896585fd | ||
|
|
179697ba61 | ||
|
|
032f159509 | ||
|
|
95a2d02df9 | ||
|
|
3ac9ff6028 | ||
|
|
fcf0f952b1 | ||
|
|
b99099fcbe | ||
|
|
bf66bbe5fa | ||
|
|
e80b442dd6 | ||
|
|
431b3a84f6 | ||
|
|
098e6e7f2b | ||
|
|
afcbff6644 | ||
|
|
ce1fde8500 | ||
|
|
4661399639 | ||
|
|
ac3baacec7 | ||
|
|
78d8d458ca | ||
|
|
e20a287c4b | ||
|
|
c7ab0f4f3d | ||
|
|
0d1057830b | ||
|
|
dd1cac3f2e | ||
|
|
5c792263ba | ||
|
|
37776c5083 | ||
|
|
fa81fe9396 | ||
|
|
f0a727ccb8 | ||
|
|
b77bf11b02 | ||
|
|
cdbc7a9510 | ||
|
|
c693bfee5e | ||
|
|
7156bf2382 | ||
|
|
c216527f23 | ||
|
|
b1de0f49df | ||
|
|
525ca09f2c | ||
|
|
92fc973bc3 | ||
|
|
22ff8e2cbe | ||
|
|
1ec664a348 | ||
|
|
6a24c37c0e | ||
|
|
8965fc49c9 | ||
|
|
735386c0b9 | ||
|
|
58c4da0ddf | ||
|
|
fe68488b1c | ||
|
|
25af6e6f77 | ||
|
|
e2d3b46a3a | ||
|
|
dd775167ab | ||
|
|
43f2a8ac06 | ||
|
|
bcf93a2c05 | ||
|
|
09ff878d88 | ||
|
|
d4749ba388 | ||
|
|
1f2bdb1402 | ||
|
|
64a97092c9 | ||
|
|
69b87b5d8e | ||
|
|
bd4160793e | ||
|
|
82e21972ec | ||
|
|
dce00141ce | ||
|
|
b2a057723a | ||
|
|
f023efdbfc | ||
|
|
8b65623726 | ||
|
|
aa35d8db69 | ||
|
|
64ed7dce4d | ||
|
|
67c321c4fb | ||
|
|
b3f50e9dd0 | ||
|
|
ea870a7846 | ||
|
|
fa21599fc8 | ||
|
|
e6c42bfbda | ||
|
|
7d480d5ff3 | ||
|
|
86c63ea4a7 | ||
|
|
2624c48113 | ||
|
|
384cba92cf | ||
|
|
7222265fee | ||
|
|
fdbc31eb9a | ||
|
|
3172c956f7 | ||
|
|
8b9188c584 | ||
|
|
5fc9152499 | ||
|
|
18b945b9c5 | ||
|
|
826ef2e5a6 | ||
|
|
7311c18d52 | ||
|
|
4a4238d830 | ||
|
|
9805b0f3b0 | ||
|
|
dfca9681c8 | ||
|
|
a6e6897f63 | ||
|
|
ec0633bdfb | ||
|
|
2d1534dc77 | ||
|
|
eebd7ca0f3 | ||
|
|
98e3e5ca2c | ||
|
|
e5dde67272 | ||
|
|
d2546cf9ec | ||
|
|
ede47ef014 | ||
|
|
6c7795238f | ||
|
|
0baacb2686 | ||
|
|
c5aaee9f2f | ||
|
|
1987c7e16c | ||
|
|
c13bb67360 | ||
|
|
77f8e51b56 | ||
|
|
e08f799994 | ||
|
|
cc41ac63bf | ||
|
|
4e0f4b207d | ||
|
|
8a7033e5a3 | ||
|
|
7cb60c7d83 | ||
|
|
e1c7a4f41f | ||
|
|
2d3fd5634a | ||
|
|
5cfa64838c | ||
|
|
55afd5cbdf | ||
|
|
65920983cc | ||
|
|
ced72951e4 | ||
|
|
0ff98b1dc1 | ||
|
|
5d4a0757f7 | ||
|
|
07b099006c | ||
|
|
5fbf860020 | ||
|
|
eab768b4a0 | ||
|
|
1031f1ddf0 | ||
|
|
63c01016e4 | ||
|
|
5810c05dab | ||
|
|
c29eef9b15 | ||
|
|
b472f9c5b5 | ||
|
|
a34d8f586e | ||
|
|
c7661167cf | ||
|
|
5f36e32821 | ||
|
|
11e8e4e7a6 | ||
|
|
35422b316d | ||
|
|
df0ae9294d | ||
|
|
57e5d67f86 | ||
|
|
7351480365 | ||
|
|
e19e904179 | ||
|
|
a54baf4998 | ||
|
|
721357b4a4 | ||
|
|
ff9f9fbbc9 | ||
|
|
9b551d978d | ||
|
|
76ab8a480a | ||
|
|
f091f663c2 | ||
|
|
e8966c7374 | ||
|
|
5a7f498629 | ||
|
|
4c1f138c0a | ||
|
|
f4d7bde20b | ||
|
|
0c181395b4 | ||
|
|
6897a9ffd8 | ||
|
|
77130dfb87 | ||
|
|
614abc3441 | ||
|
|
2479da4986 | ||
|
|
7b732ec4b7 | ||
|
|
0fed791ad9 | ||
|
|
7de02991a1 | ||
|
|
3c57cfbf71 | ||
|
|
fe9b305232 | ||
|
|
17dafa3b03 | ||
|
|
5f5b9425df | ||
|
|
b880094296 | ||
|
|
9c37b63f2e | ||
|
|
9f4a2d64a3 | ||
|
|
e24f13a277 | ||
|
|
d67c57eaa5 | ||
|
|
60dc910a27 | ||
|
|
629a534798 | ||
|
|
15a7edf6d6 | ||
|
|
cdd2eb517e | ||
|
|
1a398bbc40 | ||
|
|
581c51f312 | ||
|
|
8f00af181b | ||
|
|
0c417e8ec6 | ||
|
|
f930cdbb51 | ||
|
|
4d0a9d9494 | ||
|
|
6891057647 | ||
|
|
a610ef48e4 | ||
|
|
ddf5c85b81 | ||
|
|
ec590d1075 | ||
|
|
a8c9b24c7e | ||
|
|
2389dbafc5 | ||
|
|
6ef95c97cc | ||
|
|
2397ec8075 | ||
|
|
c24608730b | ||
|
|
ca9ee54fba | ||
|
|
bb0ed4dddf | ||
|
|
407da544fe | ||
|
|
98261ec9fa | ||
|
|
74f93d41f3 | ||
|
|
021892b17d | ||
|
|
9f44116260 | ||
|
|
8a56795bd8 | ||
|
|
1154077eea | ||
|
|
42861bc5fb | ||
|
|
7074ea2ed6 | ||
|
|
414be64d33 | ||
|
|
c1137027e6 | ||
|
|
ff77ba1157 | ||
|
|
3da7cebec6 | ||
|
|
7dc5f8c92d | ||
|
|
7763f11da7 | ||
|
|
7437b671ef | ||
|
|
55d19df029 | ||
|
|
731e9f4ca9 | ||
|
|
cc6fcebda1 | ||
|
|
72a12e3747 | ||
|
|
0adfcf9d27 | ||
|
|
51d71a6e1a | ||
|
|
8026e5142b | ||
|
|
9e33c83351 | ||
|
|
d2492d2af9 | ||
|
|
c9abe1d769 | ||
|
|
a8bfa7ad29 | ||
|
|
93e30703d4 | ||
|
|
b39885be1e | ||
|
|
f24feed775 | ||
|
|
6b75bc0016 | ||
|
|
937d931442 | ||
|
|
5c6e6032ef | ||
|
|
d5e01a3eab | ||
|
|
69e1542fc9 | ||
|
|
3199e2e8cd | ||
|
|
66d0764fc1 | ||
|
|
01bcbf09c6 | ||
|
|
0682a15971 | ||
|
|
19bbb7d7c7 | ||
|
|
ace855ed36 | ||
|
|
36ed41ad7a | ||
|
|
df19a8de5d | ||
|
|
0074085b13 | ||
|
|
f473d20a09 | ||
|
|
9061411ec7 | ||
|
|
6ee01d75a6 | ||
|
|
b0b275b236 | ||
|
|
81a66be721 | ||
|
|
01469aa01c | ||
|
|
0769184b9b | ||
|
|
15b21c075f | ||
|
|
4137120d69 | ||
|
|
3d1433dd70 | ||
|
|
acbfc9d3b3 | ||
|
|
4cdad47695 | ||
|
|
6a1de0ebdc | ||
|
|
92a4e88ceb | ||
|
|
e9043590a9 | ||
|
|
9e6828653b | ||
|
|
dae661bb53 | ||
|
|
649a5205c9 | ||
|
|
26a563da54 | ||
|
|
c1492be131 | ||
|
|
7ca65a5e8e | ||
|
|
b244a06ca1 | ||
|
|
c320410c84 | ||
|
|
2938246f2e | ||
|
|
0e9ad4a15f | ||
|
|
2200bb9166 | ||
|
|
d6db10b4bc | ||
|
|
85ff8b1422 | ||
|
|
1428338546 | ||
|
|
ec76b0f5e2 | ||
|
|
96b172e93b | ||
|
|
70263e96ab | ||
|
|
15db5c0062 | ||
|
|
f5a774f22c | ||
|
|
0735b0c604 | ||
|
|
0b91e45197 | ||
|
|
6bc3e62fd5 | ||
|
|
922ecef31e | ||
|
|
3ba2aaee32 | ||
|
|
0a6f39e60b | ||
|
|
573b5c3e3b | ||
|
|
1bd791d603 | ||
|
|
fcc6172b43 | ||
|
|
c4e0fc1837 | ||
|
|
7533ffc3ee | ||
|
|
e71407ee62 | ||
|
|
d026edc1b3 | ||
|
|
ab166649bc | ||
|
|
9f20e49100 | ||
|
|
013a575541 | ||
|
|
4c13666f26 | ||
|
|
e8425addf0 | ||
|
|
aab82f22fa | ||
|
|
8e10af82b1 | ||
|
|
595e3fed91 | ||
|
|
933ab4340b | ||
|
|
3b306bb5d3 | ||
|
|
8118424039 | ||
|
|
7d7ffc05ad | ||
|
|
31544405f4 | ||
|
|
30cb3b8bc2 | ||
|
|
d7db30a23e | ||
|
|
39a868faea | ||
|
|
fa45cb5279 | ||
|
|
25a3896e5c | ||
|
|
76180b1df4 | ||
|
|
84cdd24116 | ||
|
|
83b2b071fd | ||
|
|
4bb4b64184 | ||
|
|
6c0a79dab8 | ||
|
|
d249532473 | ||
|
|
fc2d9922f8 | ||
|
|
1b627ddb5e | ||
|
|
8c5b6654cb | ||
|
|
9f989fc7ef | ||
|
|
ca0eaa7697 | ||
|
|
dcf4336c75 | ||
|
|
c7a52370fc | ||
|
|
c3660938e0 | ||
|
|
6983d9f91f | ||
|
|
c2bbfd7fe7 | ||
|
|
d0a850468d | ||
|
|
e71df436e2 | ||
|
|
5840de1df8 | ||
|
|
9c2082f41c | ||
|
|
e647878031 | ||
|
|
4c2979bb67 | ||
|
|
09e5e5d68c | ||
|
|
7c7f9abd04 | ||
|
|
050e0221c7 | ||
|
|
a57a36a739 | ||
|
|
0046282fb8 | ||
|
|
a91f3e7556 | ||
|
|
bf9a5f5b52 | ||
|
|
7d49ce6da7 | ||
|
|
a2b5efb6bd | ||
|
|
d916456801 | ||
|
|
9a1ef8b957 | ||
|
|
723eefe9d8 | ||
|
|
f71bf9e82f | ||
|
|
e2798fa62f | ||
|
|
b08f1889e8 | ||
|
|
045ba23566 | ||
|
|
7fe969c2ce | ||
|
|
b91eb8a5ac | ||
|
|
6e6a96d19f | ||
|
|
f6be18eca4 | ||
|
|
bdefed7b0a | ||
|
|
3ed0ae83f1 | ||
|
|
ee7ce5a476 | ||
|
|
6659a8a569 | ||
|
|
fade73d970 | ||
|
|
466d19c33d | ||
|
|
486c828df0 | ||
|
|
c68fd36ee1 | ||
|
|
6ccb404c94 | ||
|
|
74122e4175 | ||
|
|
2e4405e2bd | ||
|
|
f354e5de23 | ||
|
|
95cb7fc862 | ||
|
|
79a252fc57 | ||
|
|
72177c2c50 | ||
|
|
3e941fd4fa | ||
|
|
25dfc0af22 | ||
|
|
2a0ecf3a1f | ||
|
|
6736762713 | ||
|
|
266f5784d7 | ||
|
|
923308a899 | ||
|
|
e4efa34e6a | ||
|
|
143a2def24 | ||
|
|
ffc077490c | ||
|
|
476cf10495 | ||
|
|
b294ff5e96 | ||
|
|
096141bfef | ||
|
|
9e8b9995a6 | ||
|
|
a498da7ab2 | ||
|
|
ad72500941 | ||
|
|
79859a3fc6 | ||
|
|
5197d874d7 | ||
|
|
e9e9708d1e | ||
|
|
e0c6900195 | ||
|
|
bf99ead4a4 | ||
|
|
474db61e56 | ||
|
|
406be515db | ||
|
|
7794788b1e | ||
|
|
2f74cc077b | ||
|
|
01925858ec | ||
|
|
0c01fd406d | ||
|
|
a97dbdf95c | ||
|
|
7e46c43f6f | ||
|
|
25a8473e85 | ||
|
|
c25f487c8f | ||
|
|
4f05c8eafb | ||
|
|
f4d95bf1c4 | ||
|
|
391d4514c0 | ||
|
|
c89c8a7396 | ||
|
|
d2defa1253 | ||
|
|
127029d62d | ||
|
|
6c5181977d | ||
|
|
6992fd2b66 | ||
|
|
92895ebe5a | ||
|
|
c0fb3bf95f | ||
|
|
abe31f216f | ||
|
|
44bc65691e | ||
|
|
b69245212a | ||
|
|
2a54e989b4 | ||
|
|
7c27558de9 | ||
|
|
51ef19a3fb | ||
|
|
8e7301b79a | ||
|
|
ec98a21933 | ||
|
|
1dd59f5d08 | ||
|
|
2ffdf738bd | ||
|
|
ea084e775e | ||
|
|
b4a6721948 | ||
|
|
41be436c04 | ||
|
|
b73b16e102 | ||
|
|
8f9960bcc7 | ||
|
|
3c70617060 | ||
|
|
ec9903e640 | ||
|
|
3a98ae3f70 | ||
|
|
1894ddc786 | ||
|
|
f23be16e98 | ||
|
|
b882dfa8f6 | ||
|
|
d491cbd3d2 | ||
|
|
334ba555fc | ||
|
|
ba632d0b4d | ||
|
|
b5d3e87ea2 | ||
|
|
8d92ce38ed | ||
|
|
6c0b1681f9 | ||
|
|
f22ea6e0a8 | ||
|
|
9f1ab16aa5 | ||
|
|
0dd475d2ff | ||
|
|
dd374cdd9b | ||
|
|
daf3ef9848 | ||
|
|
23ee0fc3b4 | ||
|
|
08638b18ce | ||
|
|
d331f0fb2a | ||
|
|
4b98fceb6e | ||
|
|
ef63416098 | ||
|
|
50a432180d | ||
|
|
2ea7634549 | ||
|
|
10da082412 | ||
|
|
31c8ead1d4 | ||
|
|
00f4594062 | ||
|
|
467e584359 | ||
|
|
f635fc3ae6 | ||
|
|
168ebb1cd4 | ||
|
|
b7bc609a7a | ||
|
|
a7d6a8b0d0 | ||
|
|
4b98773e9a | ||
|
|
046c8b27b6 | ||
|
|
4be61d00e4 | ||
|
|
f2e9fd7afb | ||
|
|
4ac7d94026 | ||
|
|
9af71caf73 | ||
|
|
91e57a4c69 | ||
|
|
45a6a779e5 | ||
|
|
49c7a0dee5 | ||
|
|
956244c742 | ||
|
|
752dc11dd4 | ||
|
|
17be7c3b45 | ||
|
|
11cf70e60d | ||
|
|
f19b5b8680 | ||
|
|
69a88a0563 | ||
|
|
1dd78b83b7 | ||
|
|
62549717e0 | ||
|
|
4eeca081fe | ||
|
|
9d952e0d78 | ||
|
|
f7d393fc72 | ||
|
|
176fd6eda1 | ||
|
|
7d6ba52d85 | ||
|
|
fc38c480a1 | ||
|
|
51c4cd9ab5 | ||
|
|
dfa27f3412 | ||
|
|
e34b5def60 | ||
|
|
63f94e7669 | ||
|
|
18a385f817 | ||
|
|
8e95d338b5 | ||
|
|
f236785ed5 | ||
|
|
f3e220b196 | ||
|
|
33bf267ce8 | ||
|
|
274872b8e5 | ||
|
|
cab562276d | ||
|
|
05c2dde38f | ||
|
|
0ee5670be6 | ||
|
|
9790e2c4f6 | ||
|
|
4f760a8d40 | ||
|
|
8563eafc57 | ||
|
|
72d5b35d3f | ||
|
|
7d71f467d9 | ||
|
|
aea732ab92 | ||
|
|
da6f24a3d4 | ||
|
|
28ed42130c | ||
|
|
96215c9fd5 | ||
|
|
6628fd9181 | ||
|
|
a3b8a1998a | ||
|
|
6a34d365ec | ||
|
|
406a3e4dca | ||
|
|
c1d7ecdeec | ||
|
|
6451158680 | ||
|
|
0bd4b34046 | ||
|
|
f14b06ec3a | ||
|
|
6ed775be8f | ||
|
|
b712279b2a | ||
|
|
1bffe3081d | ||
|
|
cfebe80822 | ||
|
|
17e697af8f | ||
|
|
01b35bb667 | ||
|
|
d8410d2f11 | ||
|
|
e68eed3d40 | ||
|
|
04cc668430 | ||
|
|
5d76e16324 | ||
|
|
b6c547ae98 | ||
|
|
93adcd57d7 | ||
|
|
e813da59cc | ||
|
|
b25ac0bfb6 | ||
|
|
465830945b | ||
|
|
70c27bc662 | ||
|
|
db6a788e0d | ||
|
|
e3bc40f11b | ||
|
|
3e9be07db4 | ||
|
|
684caa3673 | ||
|
|
47aaa695b2 | ||
|
|
cda73a2ec5 | ||
|
|
a12ed5709e | ||
|
|
78b0f8905b | ||
|
|
42d29756a0 | ||
|
|
27a0a447d0 | ||
|
|
fcdfd027cd | ||
|
|
3f9698bb47 | ||
|
|
99a8b5eef0 | ||
|
|
041782c49e | ||
|
|
18077b6e87 | ||
|
|
c40a4f5444 | ||
|
|
028f0220dd | ||
|
|
dc6fbffa96 | ||
|
|
99b9a34e19 | ||
|
|
23e4249ebe | ||
|
|
511489db09 | ||
|
|
a616aa3c89 | ||
|
|
1c12c73496 | ||
|
|
b29efbde52 | ||
|
|
b7527eb80e | ||
|
|
d05974fa3d | ||
|
|
a77a88308a | ||
|
|
e5a5d2de7c | ||
|
|
c0187d50ff | ||
|
|
3d0bf36981 | ||
|
|
e61c1dc738 | ||
|
|
91a627ddfc | ||
|
|
3064ff093a | ||
|
|
e2f736bd2d | ||
|
|
c6cf1b98f8 | ||
|
|
56fc3441da | ||
|
|
ebaaecb9d9 | ||
|
|
fa7ba4a390 | ||
|
|
29983e434f | ||
|
|
8c65264474 | ||
|
|
cd4b75f492 | ||
|
|
faad6bcd0c | ||
|
|
265a9ea78c | ||
|
|
aeab08099b | ||
|
|
d9f37d16f7 | ||
|
|
203abf4430 | ||
|
|
17024490e9 | ||
|
|
f7ae3621f4 | ||
|
|
5cbd9da3f5 | ||
|
|
daffba3641 | ||
|
|
860ab51434 | ||
|
|
1442666cc0 | ||
|
|
5ac9ebdebb | ||
|
|
a47a37d315 | ||
|
|
fbc19abd28 | ||
|
|
1f111a163a | ||
|
|
b601d8fd7c | ||
|
|
e98ca000f2 | ||
|
|
5351c28af8 | ||
|
|
e174861b96 | ||
|
|
247e029159 | ||
|
|
5cfc133413 | ||
|
|
c6f53e4cc8 | ||
|
|
c8acbdb363 | ||
|
|
3a3be21366 | ||
|
|
274da13a19 | ||
|
|
153994fe45 | ||
|
|
cdef6da9e9 | ||
|
|
9127449a7a | ||
|
|
8809c44443 | ||
|
|
d15718a87e | ||
|
|
da5aace109 | ||
|
|
6a87808612 | ||
|
|
105b86c660 | ||
|
|
b8b66c3900 | ||
|
|
bc5b9a5506 | ||
|
|
9c798dcd16 | ||
|
|
f5b8abc3f3 | ||
|
|
09cc127121 | ||
|
|
ac67d50616 | ||
|
|
86964bb426 | ||
|
|
c05dc07666 | ||
|
|
af94e11c7d | ||
|
|
0f86c4df9e | ||
|
|
5f0db18d3a | ||
|
|
919e6937ee | ||
|
|
64e23f02f7 | ||
|
|
fbe7f35a25 | ||
|
|
8cd0150a75 | ||
|
|
839aa401f0 | ||
|
|
4055777110 | ||
|
|
b3a99a2625 | ||
|
|
872f7a9648 | ||
|
|
b0c703935f | ||
|
|
621d2b0b6a | ||
|
|
e69520b7fb | ||
|
|
4b968d03a1 | ||
|
|
edc6679140 | ||
|
|
e732c58426 | ||
|
|
81e29aaa3d | ||
|
|
c5a1cbe755 | ||
|
|
35218609d9 | ||
|
|
7629ad553a | ||
|
|
7ddf3a112c | ||
|
|
034094c2d2 | ||
|
|
65ed6d9d5b | ||
|
|
4524f90ebd | ||
|
|
33dd326007 | ||
|
|
95b487c51e | ||
|
|
fcb03392d1 | ||
|
|
64a6168092 | ||
|
|
6a6edaa7cf | ||
|
|
a95d70cf93 | ||
|
|
3e01dc81ec | ||
|
|
e087c9fe9e | ||
|
|
33d601db82 | ||
|
|
eef73e3699 | ||
|
|
1cc07546cb | ||
|
|
e23f01f8d5 | ||
|
|
a3c2b28d6a | ||
|
|
289ed24899 | ||
|
|
98db907680 | ||
|
|
b1cc9050ff | ||
|
|
dc4f5750af | ||
|
|
d374a22b70 | ||
|
|
595ed6b40e | ||
|
|
c9f5b1de1a | ||
|
|
522f2d920b | ||
|
|
bef59929db | ||
|
|
b27b9a1098 | ||
|
|
70de3819e8 | ||
|
|
af18dec46b | ||
|
|
43efc2161a | ||
|
|
caaa988c87 | ||
|
|
ee6dd9179b | ||
|
|
f96a733430 | ||
|
|
de23ccd234 | ||
|
|
da516af837 | ||
|
|
7fbf9c4851 | ||
|
|
808f5c481e | ||
|
|
6dcf954bfe | ||
|
|
cb6fa7d46d | ||
|
|
1e3621833f | ||
|
|
eedb57b2c6 | ||
|
|
524f6d6af5 | ||
|
|
53f7a7993e | ||
|
|
abcb353793 | ||
|
|
d7c2a9f1b8 | ||
|
|
7969df3926 | ||
|
|
97c52a6991 | ||
|
|
a50288c186 | ||
|
|
f246c12959 | ||
|
|
5d7ab194e2 | ||
|
|
8a329f6522 | ||
|
|
4200edb983 | ||
|
|
93ce48aca8 | ||
|
|
df1ec4832c | ||
|
|
e3a38d27f5 | ||
|
|
754498a012 | ||
|
|
4226746675 | ||
|
|
94536be9be | ||
|
|
2c6a9245ee | ||
|
|
fc18a3c89e | ||
|
|
4f23e53002 | ||
|
|
005e9659e1 | ||
|
|
43c6bbb3ad | ||
|
|
def4d16c73 | ||
|
|
61ae19ac82 | ||
|
|
08add538a0 | ||
|
|
bd166b2f77 | ||
|
|
8b7384e47f | ||
|
|
60dc032cb8 | ||
|
|
d47190f1fd | ||
|
|
e581422810 | ||
|
|
ad151bb919 | ||
|
|
b5040e0182 | ||
|
|
c826d06d2c | ||
|
|
7c058bfee3 | ||
|
|
3133e91d8e | ||
|
|
b5e55c81d4 | ||
|
|
0837747428 | ||
|
|
518763cd08 | ||
|
|
2b862f65a2 | ||
|
|
cb53adef62 | ||
|
|
c3481f5a67 | ||
|
|
ba50b6fcc0 | ||
|
|
003246f113 | ||
|
|
13aee98d4a | ||
|
|
6c94573323 | ||
|
|
03a257bddb | ||
|
|
e02e1e8d4a | ||
|
|
57f1015197 | ||
|
|
974b93a8be | ||
|
|
652d71d799 | ||
|
|
f6d4c586eb | ||
|
|
adc7fbd424 | ||
|
|
cfc6bc8e5e | ||
|
|
da802ece3b | ||
|
|
1074f8acb1 | ||
|
|
a0e6a72b69 | ||
|
|
795cfd471a | ||
|
|
0a053ee633 | ||
|
|
85f81df2f8 | ||
|
|
94d9607447 | ||
|
|
2be4489d18 | ||
|
|
d34e4f1f28 | ||
|
|
11a81c25ef | ||
|
|
c18414cbe4 | ||
|
|
998305fd00 | ||
|
|
49ab1a3b38 | ||
|
|
c123ea3179 | ||
|
|
a6ad49dba0 | ||
|
|
3749be3e09 | ||
|
|
b67a42e0a8 | ||
|
|
9805d35a5d | ||
|
|
e3473e3c39 | ||
|
|
a1cab158ea | ||
|
|
9934cdc5bd | ||
|
|
c834694992 | ||
|
|
aa1f5c6e4e | ||
|
|
2d28fb3a73 | ||
|
|
206ed55db4 | ||
|
|
9b0913343c | ||
|
|
5696a62c27 | ||
|
|
11a7ac9b10 | ||
|
|
cbce487362 | ||
|
|
f8ca8d7cea | ||
|
|
732e5d2661 | ||
|
|
5d6fac69c4 | ||
|
|
5654d08086 | ||
|
|
73a7b33864 | ||
|
|
64a752a3b4 | ||
|
|
0ad918c21d | ||
|
|
5829bc69ca | ||
|
|
b591b4ebdf | ||
|
|
dd497d5bd8 | ||
|
|
f70cac54d1 | ||
|
|
f6a48434c1 | ||
|
|
c63b6b3ef8 | ||
|
|
28bd31a30b | ||
|
|
491013e27a | ||
|
|
0bb43aa464 | ||
|
|
0edc707657 | ||
|
|
68b7badb80 | ||
|
|
b57e97d2a1 | ||
|
|
eeb421513b | ||
|
|
ef1e380bbc | ||
|
|
2579b3c0ba | ||
|
|
d646a922ee | ||
|
|
8b2afcec90 | ||
|
|
726f1632b0 | ||
|
|
5a2dad2e16 | ||
|
|
039b00d695 | ||
|
|
1cb63063f7 | ||
|
|
5671503c28 | ||
|
|
50dafeaa0b | ||
|
|
1d4850e47a | ||
|
|
cc4f73dc7e | ||
|
|
067be3727e | ||
|
|
edeb4791c9 | ||
|
|
2f25e44e60 | ||
|
|
5fe1ce89ec | ||
|
|
03fc89da00 | ||
|
|
44e9b02b3f | ||
|
|
7f1f368065 | ||
|
|
89caccd4e0 | ||
|
|
6748b006b7 | ||
|
|
baf086d5b3 | ||
|
|
e2037ad756 | ||
|
|
d75e198304 | ||
|
|
223f0d0850 | ||
|
|
01cd279f9f | ||
|
|
3b26810c17 | ||
|
|
196e2a0abb | ||
|
|
63b9457b6c | ||
|
|
0082b87f61 | ||
|
|
936b1f8d09 | ||
|
|
c1a545ac23 | ||
|
|
33925dd313 | ||
|
|
f5abbeb353 | ||
|
|
c13683e982 | ||
|
|
17bab355e4 | ||
|
|
e77effaf8b | ||
|
|
2e39323782 | ||
|
|
8db5356caf | ||
|
|
fa2edd9d3f | ||
|
|
7997a04a68 | ||
|
|
2c30b4cf60 | ||
|
|
38c3349a6a | ||
|
|
41cb01bac9 | ||
|
|
c2ef4c8e54 | ||
|
|
a7cd44e536 | ||
|
|
dc12ec6dfd | ||
|
|
6eec8851eb | ||
|
|
39c966efdd | ||
|
|
981023154b | ||
|
|
14a9a99e2d | ||
|
|
e74c6f5de7 | ||
|
|
d3170310ff | ||
|
|
03cfc05afd | ||
|
|
fa686207ed | ||
|
|
e863be7ec3 | ||
|
|
2d4edb3eca | ||
|
|
ba5333a092 | ||
|
|
53fa7255ec | ||
|
|
dddf772f19 | ||
|
|
3768fc37da | ||
|
|
9d6d580cbd | ||
|
|
c3696cd857 | ||
|
|
c7498b768c | ||
|
|
da17bdb688 | ||
|
|
c87a741fc9 | ||
|
|
4ad8eefaec | ||
|
|
e64b13c925 | ||
|
|
b97a683bfd | ||
|
|
6ea19b0ae2 | ||
|
|
dd9d2a150d | ||
|
|
195be56c46 | ||
|
|
78662e8194 | ||
|
|
c8f7aa76e7 | ||
|
|
543e7b0b6b | ||
|
|
42d2394585 | ||
|
|
94bd44d0f2 | ||
|
|
92022360de | ||
|
|
7f462a084c | ||
|
|
b77d64bc9f | ||
|
|
d1d945eaa0 | ||
|
|
ef0780c096 | ||
|
|
28fdb8af37 | ||
|
|
dbde044213 | ||
|
|
870132a5cb | ||
|
|
ffa898c52d | ||
|
|
cdf27d60be | ||
|
|
6cf84b118b | ||
|
|
1cc81deb69 | ||
|
|
1d578b73ce | ||
|
|
fdb6a3ce16 | ||
|
|
ca1f3c6e4c | ||
|
|
f942361f7b | ||
|
|
02fd80b703 | ||
|
|
d6b03d4760 | ||
|
|
cb75e25a1a | ||
|
|
9572e16dcb | ||
|
|
459fce196f | ||
|
|
ada434fb20 | ||
|
|
71ba3fa310 | ||
|
|
0727353afa | ||
|
|
fd2ff2a973 | ||
|
|
50f9195f2d | ||
|
|
a47fc5a76b | ||
|
|
72ffe61ad1 | ||
|
|
ea8cac7c10 | ||
|
|
8639699d49 | ||
|
|
f242220132 | ||
|
|
55dbdba636 | ||
|
|
03b670971b | ||
|
|
24860fdc05 | ||
|
|
229dd3a123 | ||
|
|
919eacd907 | ||
|
|
4cec55c9a4 | ||
|
|
aa8ec92976 | ||
|
|
44da9c9a28 | ||
|
|
c776a1edff | ||
|
|
a5cbef1a61 | ||
|
|
ae22ba593a | ||
|
|
4ad4ad7088 | ||
|
|
8bccda5649 | ||
|
|
2a804b6c02 | ||
|
|
3b61617cb1 | ||
|
|
ec28671aed | ||
|
|
c7c7229b8b | ||
|
|
2efc133997 | ||
|
|
df72ac1215 | ||
|
|
2fc0d7b2a7 | ||
|
|
3a9e394814 | ||
|
|
3d9d3da1ae | ||
|
|
8abd764eca | ||
|
|
7a31e481a6 | ||
|
|
b70d2655ed | ||
|
|
15cb2f1a9e | ||
|
|
2471367c92 | ||
|
|
962c40c1a7 | ||
|
|
f6c7828160 | ||
|
|
8b57da9a2b | ||
|
|
daa7a13505 | ||
|
|
cda4790219 | ||
|
|
c6bb1dcc0e | ||
|
|
f8e1b084cd | ||
|
|
7d869c9af1 | ||
|
|
1690b05629 | ||
|
|
563825492e | ||
|
|
eee37017e1 | ||
|
|
29ec328f46 | ||
|
|
b843bb8286 | ||
|
|
77975529fe | ||
|
|
9de65184ab | ||
|
|
4912b1e632 | ||
|
|
cf91cf1b14 | ||
|
|
d0fb54fbfe | ||
|
|
346b869d60 | ||
|
|
ac158e227e | ||
|
|
d96f846648 | ||
|
|
473f3b6f3e | ||
|
|
7f1a471751 | ||
|
|
bbac342f3a | ||
|
|
4b3702987f | ||
|
|
6341847203 | ||
|
|
4e75a9b3b3 | ||
|
|
26f44b8d4b | ||
|
|
8fba0017c7 | ||
|
|
7f4056abc9 | ||
|
|
0257918571 | ||
|
|
1d4e746c4f | ||
|
|
677a02c632 | ||
|
|
177b891905 | ||
|
|
c4dcc6df9c | ||
|
|
7ddd314015 | ||
|
|
ba7325c884 | ||
|
|
3c4b1ef127 | ||
|
|
18c630e5e4 | ||
|
|
0ea0a432bf | ||
|
|
8a964efbed | ||
|
|
865bb7aad8 | ||
|
|
d9c1fb5244 | ||
|
|
71c39c9893 | ||
|
|
38067f1ddc | ||
|
|
7cfeb6e87c | ||
|
|
0a231a8acc | ||
|
|
1cea7a0314 | ||
|
|
ed95a9f2b2 | ||
|
|
76d71a032a | ||
|
|
38bff1a0e0 | ||
|
|
0c0caad827 | ||
|
|
4445e5891f | ||
|
|
f46cefbd39 | ||
|
|
feef022303 | ||
|
|
6a80c18189 | ||
|
|
6616bb4048 | ||
|
|
ac5f51c3d5 | ||
|
|
587888a688 | ||
|
|
7370b4fbcd | ||
|
|
94506bee99 | ||
|
|
7c814a5fd9 | ||
|
|
24aa29598a | ||
|
|
d61a862fa2 | ||
|
|
e29c6b44c7 | ||
|
|
327a0ca323 | ||
|
|
a746309a8e | ||
|
|
d247f90571 | ||
|
|
edbe18b157 | ||
|
|
d951485431 | ||
|
|
306a1a3f57 | ||
|
|
2431de78fa | ||
|
|
49abd6aaf3 | ||
|
|
f3a1f98add | ||
|
|
1ccc728e5d | ||
|
|
11ee80d377 | ||
|
|
512850e83d | ||
|
|
0e9c3cde7c | ||
|
|
43263a3bc8 | ||
|
|
8cce3cc84a | ||
|
|
faaa5a2949 | ||
|
|
c00f5a17c8 | ||
|
|
9c079d04a8 | ||
|
|
c9d4cdc57e | ||
|
|
12b4e80d4b | ||
|
|
6e2a04f374 | ||
|
|
3feeca627c | ||
|
|
8357b15fec | ||
|
|
ecdd9d1ccb | ||
|
|
fc69f4f757 | ||
|
|
5e70274003 | ||
|
|
57b194c63f | ||
|
|
10b04416c1 | ||
|
|
9f6027325c | ||
|
|
b64c8ea56b | ||
|
|
e74d3f4a8f | ||
|
|
8a2aebf845 | ||
|
|
984c8ee477 | ||
|
|
398ae7156b | ||
|
|
d85eeabf11 | ||
|
|
6a62654759 | ||
|
|
c056a7ad7c | ||
|
|
c784a70277 | ||
|
|
e6c87907d5 | ||
|
|
71e9290142 | ||
|
|
74ec34da67 | ||
|
|
7188749cb3 | ||
|
|
c28add55db | ||
|
|
78f34a8245 | ||
|
|
97d6f10f15 | ||
|
|
afefc4caca | ||
|
|
6abbd036f8 | ||
|
|
ef0db0f914 | ||
|
|
e01986fdd4 | ||
|
|
a0c6ebe2d8 | ||
|
|
d2183af23f | ||
|
|
953f1bdc3c | ||
|
|
e2429f20f8 | ||
|
|
f0945da4fb | ||
|
|
8df3de9ae5 | ||
|
|
277cc1cac8 | ||
|
|
07a92293e4 | ||
|
|
9730b9ba2d | ||
|
|
508799c452 | ||
|
|
5e81ef4a44 | ||
|
|
eb42eb6f27 | ||
|
|
232612898b | ||
|
|
6a37efb871 | ||
|
|
af59b61f8a | ||
|
|
f995e31d04 | ||
|
|
9758a9e60d | ||
|
|
6f56696af2 | ||
|
|
345fbdf3d2 | ||
|
|
ce031f7d15 | ||
|
|
bd6b811183 | ||
|
|
196bafff03 | ||
|
|
82bf149ade | ||
|
|
f20b558e22 | ||
|
|
54447bf227 | ||
|
|
fc09051d8b | ||
|
|
1f5ef24ecd | ||
|
|
b1faf42529 | ||
|
|
6a85206e32 | ||
|
|
e3d3e697d3 | ||
|
|
db9b333930 | ||
|
|
f7b284ad73 | ||
|
|
e1970e8a66 | ||
|
|
0cd93d67ff | ||
|
|
6e806e21bd | ||
|
|
a8462c1b70 | ||
|
|
706ea8b649 | ||
|
|
95d46d1dfc | ||
|
|
010f27678d | ||
|
|
1c1e3386f8 | ||
|
|
d87117a2cf | ||
|
|
4ed92a94a1 | ||
|
|
821ea34a3c | ||
|
|
ecb3d01376 | ||
|
|
e322ed4f05 | ||
|
|
a385c8a6f8 | ||
|
|
bcf7e78665 | ||
|
|
4cc76f2deb | ||
|
|
0cb2bb2ea7 | ||
|
|
b41c24d653 | ||
|
|
c5d97597c4 | ||
|
|
fe9acb6c59 | ||
|
|
75548c449b | ||
|
|
bca78beb1b | ||
|
|
9110611489 | ||
|
|
a8a42cbfa8 | ||
|
|
19df2ac234 | ||
|
|
e7524c85c2 | ||
|
|
a4356727e9 | ||
|
|
f15a53fae4 | ||
|
|
8e3cf2eaab | ||
|
|
c51ec3135b | ||
|
|
2469c439b1 | ||
|
|
1297addfb1 | ||
|
|
d6cbf43373 | ||
|
|
0b1a1ca064 | ||
|
|
df647e7b42 | ||
|
|
52a9cee0e1 | ||
|
|
fe16d05fbb | ||
|
|
1430c05b6c | ||
|
|
b25841e50d | ||
|
|
34d45bb3b8 | ||
|
|
9b73696a98 | ||
|
|
aecdbfacf3 | ||
|
|
1c25e29999 | ||
|
|
5ceb898676 | ||
|
|
2fe3706ef0 | ||
|
|
1880164e29 | ||
|
|
b704fc9254 | ||
|
|
352da66bd1 | ||
|
|
8205ad2cd0 | ||
|
|
e417c269eb | ||
|
|
59a76b3970 | ||
|
|
53be79a00e | ||
|
|
c4b69b341a | ||
|
|
e162b9c169 | ||
|
|
77e3502028 | ||
|
|
ae0461692c | ||
|
|
13bdb80958 | ||
|
|
6f74e7b738 | ||
|
|
eaee89f77a | ||
|
|
756a8c50d6 | ||
|
|
a99dbc78c9 | ||
|
|
8a54512037 | ||
|
|
3f96bd9509 | ||
|
|
6d06cb8fb3 | ||
|
|
4247883173 | ||
|
|
bf491d6fe7 | ||
|
|
c15e753a0a | ||
|
|
902aee4e6b | ||
|
|
b964f755ec | ||
|
|
a044070e1d | ||
|
|
e0b859dbbe | ||
|
|
07b64ff1a4 | ||
|
|
7bc9192f3f | ||
|
|
057e551059 | ||
|
|
2f80c814aa | ||
|
|
136a029bb4 | ||
|
|
d4b32a403b | ||
|
|
722b187f83 | ||
|
|
0c5c5823bf | ||
|
|
f5a6b7d1f0 | ||
|
|
bcd236286c | ||
|
|
6c4ada5098 | ||
|
|
2402715492 | ||
|
|
f32cf02714 | ||
|
|
e224ee5498 | ||
|
|
90011aa0c9 | ||
|
|
d0589468c1 | ||
|
|
6ef5acbfe5 | ||
|
|
efe894cad6 | ||
|
|
2a366c176d | ||
|
|
8e280a6a24 | ||
|
|
f144518e0e | ||
|
|
fcc006ecd3 | ||
|
|
5fbadc6b21 | ||
|
|
7902570855 | ||
|
|
55898780f1 | ||
|
|
d16cb90c2f | ||
|
|
66dd514c56 | ||
|
|
ba40748118 | ||
|
|
3538cefe68 | ||
|
|
f77aef82d2 | ||
|
|
4d0037a40c | ||
|
|
fd7a4461cc | ||
|
|
8bc6ddbca8 | ||
|
|
7d50e432b5 | ||
|
|
6103888610 | ||
|
|
4d8189f21b | ||
|
|
cddb778577 | ||
|
|
fa506ec04f | ||
|
|
0eaeef5723 | ||
|
|
f87054895e | ||
|
|
d74a5bd507 | ||
|
|
b5d4535db6 | ||
|
|
4d7562fd79 | ||
|
|
5b869376ab | ||
|
|
19c522d9bc | ||
|
|
1d4ecad134 | ||
|
|
805464e406 | ||
|
|
c674c3561a | ||
|
|
7aa2972c3f | ||
|
|
986558fea7 | ||
|
|
818e34682c | ||
|
|
252fddf3de | ||
|
|
39079e7aff | ||
|
|
2488e6ab66 | ||
|
|
1fa4518bb9 | ||
|
|
1b739e87ae | ||
|
|
e944983567 | ||
|
|
4fccaf3284 | ||
|
|
0a79dc9ecc | ||
|
|
847a8c8c4d | ||
|
|
a1018c5823 | ||
|
|
323417182a | ||
|
|
f3bcf570f4 | ||
|
|
a3059597fb | ||
|
|
d19a6914f9 | ||
|
|
4313ede132 | ||
|
|
f3b7ac508d | ||
|
|
635bfd4aba | ||
|
|
38e72e1af7 | ||
|
|
26644bfd1e | ||
|
|
6a827fc7b9 | ||
|
|
3b3ae9c0dd | ||
|
|
301909e3e5 | ||
|
|
97a9c8627c | ||
|
|
56c1fbecea | ||
|
|
de9d18a2fe | ||
|
|
be16ad26b5 | ||
|
|
d762da9141 | ||
|
|
c05d6f7cdf | ||
|
|
7af3fb5ae4 | ||
|
|
3ac54b2178 | ||
|
|
42a26f076a | ||
|
|
3b67759730 | ||
|
|
5407a8345f | ||
|
|
3fe509757b | ||
|
|
952b679ca3 | ||
|
|
6799daacd1 | ||
|
|
fa02b5150c | ||
|
|
63a1904242 | ||
|
|
1e3450fdcb | ||
|
|
5541026b86 | ||
|
|
c36c920b34 | ||
|
|
514fea65c4 | ||
|
|
e269b3bfdd | ||
|
|
0862a9bfa7 | ||
|
|
f43c695527 | ||
|
|
ead43f081c | ||
|
|
4e2a3d61dc | ||
|
|
218ad6bbe0 | ||
|
|
b485f2e42e | ||
|
|
16e32c3f67 | ||
|
|
15f65bb558 | ||
|
|
b161d6831f | ||
|
|
969953039f | ||
|
|
f1506ed5da | ||
|
|
9a239d9e13 | ||
|
|
a5da09dfb9 | ||
|
|
6f81f2d143 | ||
|
|
0b877ca8a3 | ||
|
|
2911b9cd04 | ||
|
|
6b3f1ab0e4 | ||
|
|
2c15655b08 | ||
|
|
afa9c650fe | ||
|
|
28d8d82ded | ||
|
|
a100baf57f | ||
|
|
5621755655 | ||
|
|
d892bfc278 | ||
|
|
4369b18fbf | ||
|
|
fb9b5d31e8 | ||
|
|
3bf0748389 | ||
|
|
cf46b89814 | ||
|
|
3360b34af9 | ||
|
|
4558eb41fc | ||
|
|
bbc5584f80 | ||
|
|
8604c9f9d5 | ||
|
|
747e02ee0d | ||
|
|
8b0334309b | ||
|
|
48afa821e4 | ||
|
|
42a8d3e3dc | ||
|
|
a44fc51007 | ||
|
|
961bc874d2 | ||
|
|
b2b018ab93 | ||
|
|
77da33de4f | ||
|
|
06ad5e3f8c | ||
|
|
9326bf96fc | ||
|
|
bed73102b4 | ||
|
|
eb59f9c75d | ||
|
|
f3bd2ed472 | ||
|
|
456475d593 | ||
|
|
a36ce199ba | ||
|
|
b7c3ad0867 | ||
|
|
ea3545cc7e | ||
|
|
232ba46b16 | ||
|
|
5f011502d1 | ||
|
|
93b6f1066b | ||
|
|
52fe92ed7f | ||
|
|
0d005df463 | ||
|
|
e3ef3ace29 | ||
|
|
a203e98689 | ||
|
|
27f99a0f38 | ||
|
|
d1e48d02bd | ||
|
|
4f06a1df50 | ||
|
|
2d7ae1180f | ||
|
|
75b486b467 | ||
|
|
5b5f10fe93 | ||
|
|
5f654e76e2 | ||
|
|
aa8d112c58 | ||
|
|
e82dc0e841 | ||
|
|
dd741fc38a | ||
|
|
120e4ee92f | ||
|
|
9d2a56bff4 | ||
|
|
31d82a3169 | ||
|
|
d22ee5d451 | ||
|
|
203edaed50 | ||
|
|
93b5638a9c | ||
|
|
52a5e58f0c | ||
|
|
20607b0b5c | ||
|
|
6bebfe9e54 | ||
|
|
50b76f4466 | ||
|
|
23e4e25e9a | ||
|
|
5b83d478d6 | ||
|
|
dca38d01d6 | ||
|
|
0a434d3b3a | ||
|
|
7c4b83a430 | ||
|
|
b7f24b428b | ||
|
|
22a0ed0ee2 | ||
|
|
cf711d55a5 | ||
|
|
26ea562fdb | ||
|
|
efce0c6c57 | ||
|
|
a3768dae97 | ||
|
|
85efea3fb8 | ||
|
|
c820fda26d | ||
|
|
4740293640 | ||
|
|
8be8813cd8 | ||
|
|
8cc747ef22 | ||
|
|
d6ed2ab3e0 | ||
|
|
e8ae980104 | ||
|
|
cd8c23c0ab | ||
|
|
3568042cd9 | ||
|
|
7443129e18 | ||
|
|
4196a3db5a | ||
|
|
cd7594f623 | ||
|
|
b887db474e | ||
|
|
6c4242ad2a | ||
|
|
da98972dda | ||
|
|
530af5e358 |
@@ -4,4 +4,6 @@
|
|||||||
.vscode
|
.vscode
|
||||||
.gitignore
|
.gitignore
|
||||||
Makefile
|
Makefile
|
||||||
docs
|
docs
|
||||||
|
.eslintcache
|
||||||
|
.gocache
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
# 所有请求超时时间,单位秒,默认为0,表示不限制
|
# 所有请求超时时间,单位秒,默认为0,表示不限制
|
||||||
# RELAY_TIMEOUT=0
|
# RELAY_TIMEOUT=0
|
||||||
# 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值
|
# 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值
|
||||||
# STREAMING_TIMEOUT=120
|
# STREAMING_TIMEOUT=300
|
||||||
|
|
||||||
# Gemini 识别图片 最大图片数量
|
# Gemini 识别图片 最大图片数量
|
||||||
# GEMINI_VISION_MAX_IMAGE_NUM=16
|
# GEMINI_VISION_MAX_IMAGE_NUM=16
|
||||||
@@ -56,8 +56,6 @@
|
|||||||
# SESSION_SECRET=random_string
|
# SESSION_SECRET=random_string
|
||||||
|
|
||||||
# 其他配置
|
# 其他配置
|
||||||
# 渠道测试频率(单位:秒)
|
|
||||||
# CHANNEL_TEST_FREQUENCY=10
|
|
||||||
# 生成默认token
|
# 生成默认token
|
||||||
# GENERATE_DEFAULT_TOKEN=false
|
# GENERATE_DEFAULT_TOKEN=false
|
||||||
# Cohere 安全设置
|
# Cohere 安全设置
|
||||||
|
|||||||
26
.github/ISSUE_TEMPLATE/bug_report_en.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/bug_report_en.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Describe the issue you encountered with clear and detailed language
|
||||||
|
title: ''
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Routine Checks**
|
||||||
|
|
||||||
|
[//]: # (Remove the space in the box and fill with an x)
|
||||||
|
+ [ ] I have confirmed there are no similar issues currently
|
||||||
|
+ [ ] I have confirmed I have upgraded to the latest version
|
||||||
|
+ [ ] I have thoroughly read the project README, especially the FAQ section
|
||||||
|
+ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback
|
||||||
|
+ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly**
|
||||||
|
|
||||||
|
**Issue Description**
|
||||||
|
|
||||||
|
**Steps to Reproduce**
|
||||||
|
|
||||||
|
**Expected Result**
|
||||||
|
|
||||||
|
**Related Screenshots**
|
||||||
|
If none, please delete this section.
|
||||||
22
.github/ISSUE_TEMPLATE/feature_request_en.md
vendored
Normal file
22
.github/ISSUE_TEMPLATE/feature_request_en.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Describe the new feature you would like to add with clear and detailed language
|
||||||
|
title: ''
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Routine Checks**
|
||||||
|
|
||||||
|
[//]: # (Remove the space in the box and fill with an x)
|
||||||
|
+ [ ] I have confirmed there are no similar issues currently
|
||||||
|
+ [ ] I have confirmed I have upgraded to the latest version
|
||||||
|
+ [ ] I have thoroughly read the project README and confirmed the current version cannot meet my needs
|
||||||
|
+ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback
|
||||||
|
+ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly**
|
||||||
|
|
||||||
|
**Feature Description**
|
||||||
|
|
||||||
|
**Use Case**
|
||||||
|
|
||||||
@@ -13,7 +13,3 @@
|
|||||||
### PR 描述
|
### PR 描述
|
||||||
|
|
||||||
**请在下方详细描述您的 PR,包括目的、实现细节等。**
|
**请在下方详细描述您的 PR,包括目的、实现细节等。**
|
||||||
|
|
||||||
### **重要提示**
|
|
||||||
|
|
||||||
**所有 PR 都必须提交到 `alpha` 分支。请确保您的 PR 目标分支是 `alpha`。**
|
|
||||||
|
|||||||
127
.github/workflows/docker-image-alpha.yml
vendored
127
.github/workflows/docker-image-alpha.yml
vendored
@@ -11,19 +11,42 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
push_to_registries:
|
build_single_arch:
|
||||||
name: Push Docker image to multiple registries
|
name: Build & push (${{ matrix.arch }}) [native]
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- arch: arm64
|
||||||
|
platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out (shallow)
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Save version info
|
- name: Determine alpha version
|
||||||
|
id: version
|
||||||
run: |
|
run: |
|
||||||
echo "alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" > VERSION
|
VERSION="alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)"
|
||||||
|
echo "$VERSION" > VERSION
|
||||||
|
echo "value=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
|
echo "Publishing version: $VERSION for ${{ matrix.arch }}"
|
||||||
|
|
||||||
|
- name: Normalize GHCR repository
|
||||||
|
run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -31,32 +54,98 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to the Container registry
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Extract metadata (labels)
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
calciumion/new-api
|
calciumion/new-api
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ env.GHCR_REPOSITORY }}
|
||||||
tags: |
|
|
||||||
type=raw,value=alpha
|
|
||||||
type=raw,value=alpha-{{date 'YYYYMMDD'}}-{{sha}}
|
|
||||||
|
|
||||||
- name: Build and push Docker images
|
- name: Build & push single-arch (to both registries)
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: |
|
||||||
|
calciumion/new-api:alpha-${{ matrix.arch }}
|
||||||
|
calciumion/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }}
|
||||||
|
ghcr.io/${{ env.GHCR_REPOSITORY }}:alpha-${{ matrix.arch }}
|
||||||
|
ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ steps.version.outputs.value }}-${{ matrix.arch }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
|
||||||
|
create_manifests:
|
||||||
|
name: Create multi-arch manifests (Docker Hub + GHCR)
|
||||||
|
needs: [build_single_arch]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Check out (shallow)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Normalize GHCR repository
|
||||||
|
run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Determine alpha version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION="alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)"
|
||||||
|
echo "value=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create & push manifest (Docker Hub - alpha)
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t calciumion/new-api:alpha \
|
||||||
|
calciumion/new-api:alpha-amd64 \
|
||||||
|
calciumion/new-api:alpha-arm64
|
||||||
|
|
||||||
|
- name: Create & push manifest (Docker Hub - versioned alpha)
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t calciumion/new-api:${VERSION} \
|
||||||
|
calciumion/new-api:${VERSION}-amd64 \
|
||||||
|
calciumion/new-api:${VERSION}-arm64
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create & push manifest (GHCR - alpha)
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t ghcr.io/${GHCR_REPOSITORY}:alpha \
|
||||||
|
ghcr.io/${GHCR_REPOSITORY}:alpha-amd64 \
|
||||||
|
ghcr.io/${GHCR_REPOSITORY}:alpha-arm64
|
||||||
|
|
||||||
|
- name: Create & push manifest (GHCR - versioned alpha)
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t ghcr.io/${GHCR_REPOSITORY}:${VERSION} \
|
||||||
|
ghcr.io/${GHCR_REPOSITORY}:${VERSION}-amd64 \
|
||||||
|
ghcr.io/${GHCR_REPOSITORY}:${VERSION}-arm64
|
||||||
|
|||||||
126
.github/workflows/docker-image-arm64.yml
vendored
126
.github/workflows/docker-image-arm64.yml
vendored
@@ -1,26 +1,46 @@
|
|||||||
name: Publish Docker image (Multi Registries)
|
name: Publish Docker image (Multi Registries, native amd64+arm64)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*'
|
- '*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
push_to_registries:
|
build_single_arch:
|
||||||
name: Push Docker image to multiple registries
|
name: Build & push (${{ matrix.arch }}) [native]
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- arch: arm64
|
||||||
|
platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out (shallow)
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Save version info
|
- name: Resolve tag & write VERSION
|
||||||
run: |
|
run: |
|
||||||
git describe --tags > VERSION
|
git fetch --tags --force --depth=1
|
||||||
|
TAG=${GITHUB_REF#refs/tags/}
|
||||||
|
echo "TAG=$TAG" >> $GITHUB_ENV
|
||||||
|
echo "$TAG" > VERSION
|
||||||
|
echo "Building tag: $TAG for ${{ matrix.arch }}"
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
# - name: Normalize GHCR repository
|
||||||
|
# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -31,26 +51,88 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to the Container registry
|
# - name: Log in to GHCR
|
||||||
uses: docker/login-action@v3
|
# uses: docker/login-action@v3
|
||||||
with:
|
# with:
|
||||||
registry: ghcr.io
|
# registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
# username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (labels)
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
calciumion/new-api
|
calciumion/new-api
|
||||||
ghcr.io/${{ github.repository }}
|
# ghcr.io/${{ env.GHCR_REPOSITORY }}
|
||||||
|
|
||||||
- name: Build and push Docker images
|
- name: Build & push single-arch (to both registries)
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: |
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }}
|
||||||
|
calciumion/new-api:latest-${{ matrix.arch }}
|
||||||
|
# ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.TAG }}-${{ matrix.arch }}
|
||||||
|
# ghcr.io/${{ env.GHCR_REPOSITORY }}:latest-${{ matrix.arch }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
|
||||||
|
create_manifests:
|
||||||
|
name: Create multi-arch manifests (Docker Hub)
|
||||||
|
needs: [build_single_arch]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
steps:
|
||||||
|
- name: Extract tag
|
||||||
|
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
#
|
||||||
|
# - name: Normalize GHCR repository
|
||||||
|
# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create & push manifest (Docker Hub - version)
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t calciumion/new-api:${TAG} \
|
||||||
|
calciumion/new-api:${TAG}-amd64 \
|
||||||
|
calciumion/new-api:${TAG}-arm64
|
||||||
|
|
||||||
|
- name: Create & push manifest (Docker Hub - latest)
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t calciumion/new-api:latest \
|
||||||
|
calciumion/new-api:latest-amd64 \
|
||||||
|
calciumion/new-api:latest-arm64
|
||||||
|
|
||||||
|
# ---- GHCR ----
|
||||||
|
# - name: Log in to GHCR
|
||||||
|
# uses: docker/login-action@v3
|
||||||
|
# with:
|
||||||
|
# registry: ghcr.io
|
||||||
|
# username: ${{ github.actor }}
|
||||||
|
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# - name: Create & push manifest (GHCR - version)
|
||||||
|
# run: |
|
||||||
|
# docker buildx imagetools create \
|
||||||
|
# -t ghcr.io/${GHCR_REPOSITORY}:${TAG} \
|
||||||
|
# ghcr.io/${GHCR_REPOSITORY}:${TAG}-amd64 \
|
||||||
|
# ghcr.io/${GHCR_REPOSITORY}:${TAG}-arm64
|
||||||
|
#
|
||||||
|
# - name: Create & push manifest (GHCR - latest)
|
||||||
|
# run: |
|
||||||
|
# docker buildx imagetools create \
|
||||||
|
# -t ghcr.io/${GHCR_REPOSITORY}:latest \
|
||||||
|
# ghcr.io/${GHCR_REPOSITORY}:latest-amd64 \
|
||||||
|
# ghcr.io/${GHCR_REPOSITORY}:latest-arm64
|
||||||
|
|||||||
141
.github/workflows/electron-build.yml
vendored
Normal file
141
.github/workflows/electron-build.yml
vendored
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
name: Build Electron App
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*' # Triggers on version tags like v1.0.0
|
||||||
|
- '!*-*' # Ignore pre-release tags like v1.0.0-beta
|
||||||
|
- '!*-alpha*' # Ignore alpha tags like v1.0.0-alpha
|
||||||
|
workflow_dispatch: # Allows manual triggering
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
# os: [macos-latest, windows-latest]
|
||||||
|
os: [windows-latest]
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '>=1.25.1'
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
env:
|
||||||
|
CI: ""
|
||||||
|
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||||
|
run: |
|
||||||
|
cd web
|
||||||
|
bun install
|
||||||
|
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# - name: Build Go binary (macos/Linux)
|
||||||
|
# if: runner.os != 'Windows'
|
||||||
|
# run: |
|
||||||
|
# go mod download
|
||||||
|
# go build -ldflags "-s -w -X 'new-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api
|
||||||
|
|
||||||
|
- name: Build Go binary (Windows)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
run: |
|
||||||
|
go mod download
|
||||||
|
go build -ldflags "-s -w -X 'new-api/common.Version=$(git describe --tags)'" -o new-api.exe
|
||||||
|
|
||||||
|
- name: Update Electron version
|
||||||
|
run: |
|
||||||
|
cd electron
|
||||||
|
VERSION=$(git describe --tags)
|
||||||
|
VERSION=${VERSION#v} # Remove 'v' prefix if present
|
||||||
|
# Convert to valid semver: take first 3 components and convert rest to prerelease format
|
||||||
|
# e.g., 0.9.3-patch.1 -> 0.9.3-patch.1
|
||||||
|
if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(.*)$ ]]; then
|
||||||
|
MAJOR=${BASH_REMATCH[1]}
|
||||||
|
MINOR=${BASH_REMATCH[2]}
|
||||||
|
PATCH=${BASH_REMATCH[3]}
|
||||||
|
REST=${BASH_REMATCH[4]}
|
||||||
|
|
||||||
|
VERSION="$MAJOR.$MINOR.$PATCH"
|
||||||
|
|
||||||
|
# If there's extra content, append it without adding -dev
|
||||||
|
if [[ -n "$REST" ]]; then
|
||||||
|
VERSION="$VERSION$REST"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Install Electron dependencies
|
||||||
|
run: |
|
||||||
|
cd electron
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# - name: Build Electron app (macOS)
|
||||||
|
# if: runner.os == 'macOS'
|
||||||
|
# run: |
|
||||||
|
# cd electron
|
||||||
|
# npm run build:mac
|
||||||
|
# env:
|
||||||
|
# CSC_IDENTITY_AUTO_DISCOVERY: false # Skip code signing
|
||||||
|
|
||||||
|
- name: Build Electron app (Windows)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
run: |
|
||||||
|
cd electron
|
||||||
|
npm run build:win
|
||||||
|
|
||||||
|
# - name: Upload artifacts (macOS)
|
||||||
|
# if: runner.os == 'macOS'
|
||||||
|
# uses: actions/upload-artifact@v4
|
||||||
|
# with:
|
||||||
|
# name: macos-build
|
||||||
|
# path: |
|
||||||
|
# electron/dist/*.dmg
|
||||||
|
# electron/dist/*.zip
|
||||||
|
|
||||||
|
- name: Upload artifacts (Windows)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-build
|
||||||
|
path: |
|
||||||
|
electron/dist/*.exe
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
|
||||||
|
- name: Upload to Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
windows-build/*
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
59
.github/workflows/linux-release.yml
vendored
59
.github/workflows/linux-release.yml
vendored
@@ -1,59 +0,0 @@
|
|||||||
name: Linux Release
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
name:
|
|
||||||
description: 'reason'
|
|
||||||
required: false
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
- '!*-alpha*'
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
- name: Build Frontend
|
|
||||||
env:
|
|
||||||
CI: ""
|
|
||||||
run: |
|
|
||||||
cd web
|
|
||||||
bun install
|
|
||||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
|
||||||
cd ..
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v3
|
|
||||||
with:
|
|
||||||
go-version: '>=1.18.0'
|
|
||||||
- name: Build Backend (amd64)
|
|
||||||
run: |
|
|
||||||
go mod download
|
|
||||||
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api
|
|
||||||
|
|
||||||
- name: Build Backend (arm64)
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
|
|
||||||
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api-arm64
|
|
||||||
|
|
||||||
- name: Release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
one-api
|
|
||||||
one-api-arm64
|
|
||||||
draft: true
|
|
||||||
generate_release_notes: true
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
51
.github/workflows/macos-release.yml
vendored
51
.github/workflows/macos-release.yml
vendored
@@ -1,51 +0,0 @@
|
|||||||
name: macOS Release
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
name:
|
|
||||||
description: 'reason'
|
|
||||||
required: false
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
- '!*-alpha*'
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: macos-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
- name: Build Frontend
|
|
||||||
env:
|
|
||||||
CI: ""
|
|
||||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
|
||||||
run: |
|
|
||||||
cd web
|
|
||||||
bun install
|
|
||||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
|
||||||
cd ..
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v3
|
|
||||||
with:
|
|
||||||
go-version: '>=1.18.0'
|
|
||||||
- name: Build Backend
|
|
||||||
run: |
|
|
||||||
go mod download
|
|
||||||
go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o one-api-macos
|
|
||||||
- name: Release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
with:
|
|
||||||
files: one-api-macos
|
|
||||||
draft: true
|
|
||||||
generate_release_notes: true
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
21
.github/workflows/pr-target-branch-check.yml
vendored
21
.github/workflows/pr-target-branch-check.yml
vendored
@@ -1,21 +0,0 @@
|
|||||||
name: Check PR Branching Strategy
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, reopened, edited]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-branching-strategy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Enforce branching strategy
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.base_ref }}" == "main" ]]; then
|
|
||||||
if [[ "${{ github.head_ref }}" != "alpha" ]]; then
|
|
||||||
echo "Error: Pull requests to 'main' are only allowed from the 'alpha' branch."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
elif [[ "${{ github.base_ref }}" != "alpha" ]]; then
|
|
||||||
echo "Error: Pull requests must be targeted to the 'alpha' or 'main' branch."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Branching strategy check passed."
|
|
||||||
136
.github/workflows/release.yml
vendored
Normal file
136
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
name: Release (Linux, macOS, Windows)
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
name:
|
||||||
|
description: 'reason'
|
||||||
|
required: false
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
- '!*-alpha*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
linux:
|
||||||
|
name: Linux Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
- name: Build Frontend
|
||||||
|
env:
|
||||||
|
CI: ""
|
||||||
|
run: |
|
||||||
|
cd web
|
||||||
|
bun install
|
||||||
|
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
||||||
|
cd ..
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: '>=1.25.1'
|
||||||
|
- name: Build Backend (amd64)
|
||||||
|
run: |
|
||||||
|
go mod download
|
||||||
|
VERSION=$(git describe --tags)
|
||||||
|
go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION
|
||||||
|
- name: Build Backend (arm64)
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||||
|
VERSION=$(git describe --tags)
|
||||||
|
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION
|
||||||
|
- name: Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
new-api-*
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
macos:
|
||||||
|
name: macOS Release
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
- name: Build Frontend
|
||||||
|
env:
|
||||||
|
CI: ""
|
||||||
|
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||||
|
run: |
|
||||||
|
cd web
|
||||||
|
bun install
|
||||||
|
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
||||||
|
cd ..
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: '>=1.25.1'
|
||||||
|
- name: Build Backend
|
||||||
|
run: |
|
||||||
|
go mod download
|
||||||
|
VERSION=$(git describe --tags)
|
||||||
|
go build -ldflags "-X 'new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION
|
||||||
|
- name: Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
with:
|
||||||
|
files: new-api-macos-*
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
windows:
|
||||||
|
name: Windows Release
|
||||||
|
runs-on: windows-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
- name: Build Frontend
|
||||||
|
env:
|
||||||
|
CI: ""
|
||||||
|
run: |
|
||||||
|
cd web
|
||||||
|
bun install
|
||||||
|
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
||||||
|
cd ..
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: '>=1.25.1'
|
||||||
|
- name: Build Backend
|
||||||
|
run: |
|
||||||
|
go mod download
|
||||||
|
VERSION=$(git describe --tags)
|
||||||
|
go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe
|
||||||
|
- name: Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
with:
|
||||||
|
files: new-api-*.exe
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
|
||||||
91
.github/workflows/sync-to-gitee.yml
vendored
Normal file
91
.github/workflows/sync-to-gitee.yml
vendored
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
name: Sync Release to Gitee
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag_name:
|
||||||
|
description: 'Release Tag to sync (e.g. v1.0.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
# 配置你的 Gitee 仓库信息
|
||||||
|
env:
|
||||||
|
GITEE_OWNER: 'QuantumNous' # 修改为你的 Gitee 用户名
|
||||||
|
GITEE_REPO: 'new-api' # 修改为你的 Gitee 仓库名
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-to-gitee:
|
||||||
|
runs-on: sync
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Get Release Info
|
||||||
|
id: release_info
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG_NAME: ${{ github.event.inputs.tag_name }}
|
||||||
|
run: |
|
||||||
|
# 获取 release 信息
|
||||||
|
RELEASE_INFO=$(gh release view "$TAG_NAME" --json name,body,tagName,targetCommitish)
|
||||||
|
|
||||||
|
RELEASE_NAME=$(echo "$RELEASE_INFO" | jq -r '.name')
|
||||||
|
TARGET_COMMITISH=$(echo "$RELEASE_INFO" | jq -r '.targetCommitish')
|
||||||
|
|
||||||
|
# 使用多行字符串输出
|
||||||
|
{
|
||||||
|
echo "release_name=$RELEASE_NAME"
|
||||||
|
echo "target_commitish=$TARGET_COMMITISH"
|
||||||
|
echo "release_body<<EOF"
|
||||||
|
echo "$RELEASE_INFO" | jq -r '.body'
|
||||||
|
echo "EOF"
|
||||||
|
} >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# 下载 release 的所有附件
|
||||||
|
gh release download "$TAG_NAME" --dir ./release_assets || echo "No assets to download"
|
||||||
|
|
||||||
|
# 列出下载的文件
|
||||||
|
ls -la ./release_assets/ || echo "No assets directory"
|
||||||
|
|
||||||
|
- name: Create Gitee Release
|
||||||
|
id: create_release
|
||||||
|
uses: nICEnnnnnnnLee/action-gitee-release@v2.0.0
|
||||||
|
with:
|
||||||
|
gitee_action: create_release
|
||||||
|
gitee_owner: ${{ env.GITEE_OWNER }}
|
||||||
|
gitee_repo: ${{ env.GITEE_REPO }}
|
||||||
|
gitee_token: ${{ secrets.GITEE_TOKEN }}
|
||||||
|
gitee_tag_name: ${{ github.event.inputs.tag_name }}
|
||||||
|
gitee_release_name: ${{ steps.release_info.outputs.release_name }}
|
||||||
|
gitee_release_body: ${{ steps.release_info.outputs.release_body }}
|
||||||
|
gitee_target_commitish: ${{ steps.release_info.outputs.target_commitish }}
|
||||||
|
|
||||||
|
- name: Upload Assets to Gitee
|
||||||
|
if: hashFiles('release_assets/*') != ''
|
||||||
|
uses: nICEnnnnnnnLee/action-gitee-release@v2.0.0
|
||||||
|
with:
|
||||||
|
gitee_action: upload_asset
|
||||||
|
gitee_owner: ${{ env.GITEE_OWNER }}
|
||||||
|
gitee_repo: ${{ env.GITEE_REPO }}
|
||||||
|
gitee_token: ${{ secrets.GITEE_TOKEN }}
|
||||||
|
gitee_release_id: ${{ steps.create_release.outputs.release-id }}
|
||||||
|
gitee_upload_retry_times: 3
|
||||||
|
gitee_files: |
|
||||||
|
release_assets/*
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
rm -rf release_assets/
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
echo "✅ Successfully synced release ${{ github.event.inputs.tag_name }} to Gitee!"
|
||||||
|
echo "🔗 Gitee Release URL: https://gitee.com/${{ env.GITEE_OWNER }}/${{ env.GITEE_REPO }}/releases/tag/${{ github.event.inputs.tag_name }}"
|
||||||
|
|
||||||
53
.github/workflows/windows-release.yml
vendored
53
.github/workflows/windows-release.yml
vendored
@@ -1,53 +0,0 @@
|
|||||||
name: Windows Release
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
name:
|
|
||||||
description: 'reason'
|
|
||||||
required: false
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
- '!*-alpha*'
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: windows-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
- name: Build Frontend
|
|
||||||
env:
|
|
||||||
CI: ""
|
|
||||||
run: |
|
|
||||||
cd web
|
|
||||||
bun install
|
|
||||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
|
||||||
cd ..
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v3
|
|
||||||
with:
|
|
||||||
go-version: '>=1.18.0'
|
|
||||||
- name: Build Backend
|
|
||||||
run: |
|
|
||||||
go mod download
|
|
||||||
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o one-api.exe
|
|
||||||
- name: Release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
with:
|
|
||||||
files: one-api.exe
|
|
||||||
draft: true
|
|
||||||
generate_release_notes: true
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
.zed
|
||||||
upload
|
upload
|
||||||
*.exe
|
*.exe
|
||||||
*.db
|
*.db
|
||||||
@@ -9,5 +10,12 @@ logs
|
|||||||
web/dist
|
web/dist
|
||||||
.env
|
.env
|
||||||
one-api
|
one-api
|
||||||
|
new-api
|
||||||
|
/__debug_bin*
|
||||||
.DS_Store
|
.DS_Store
|
||||||
tiktoken_cache
|
tiktoken_cache
|
||||||
|
.eslintcache
|
||||||
|
.gocache
|
||||||
|
|
||||||
|
electron/node_modules
|
||||||
|
electron/dist
|
||||||
|
|||||||
17
Dockerfile
17
Dockerfile
@@ -2,16 +2,19 @@ FROM oven/bun:latest AS builder
|
|||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY web/package.json .
|
COPY web/package.json .
|
||||||
|
COPY web/bun.lock .
|
||||||
RUN bun install
|
RUN bun install
|
||||||
COPY ./web .
|
COPY ./web .
|
||||||
COPY ./VERSION .
|
COPY ./VERSION .
|
||||||
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
||||||
|
|
||||||
FROM golang:alpine AS builder2
|
FROM golang:alpine AS builder2
|
||||||
|
ENV GO111MODULE=on CGO_ENABLED=0
|
||||||
|
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64}
|
||||||
|
|
||||||
ENV GO111MODULE=on \
|
|
||||||
CGO_ENABLED=0 \
|
|
||||||
GOOS=linux
|
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@@ -20,15 +23,15 @@ RUN go mod download
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=builder /build/dist ./web/dist
|
COPY --from=builder /build/dist ./web/dist
|
||||||
RUN go build -ldflags "-s -w -X 'one-api/common.Version=$(cat VERSION)'" -o one-api
|
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||||
|
|
||||||
FROM alpine
|
FROM alpine
|
||||||
|
|
||||||
RUN apk upgrade --no-cache \
|
RUN apk upgrade --no-cache \
|
||||||
&& apk add --no-cache ca-certificates tzdata ffmpeg \
|
&& apk add --no-cache ca-certificates tzdata \
|
||||||
&& update-ca-certificates
|
&& update-ca-certificates
|
||||||
|
|
||||||
COPY --from=builder2 /build/one-api /
|
COPY --from=builder2 /build/new-api /
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
ENTRYPOINT ["/one-api"]
|
ENTRYPOINT ["/new-api"]
|
||||||
|
|||||||
240
LICENSE
240
LICENSE
@@ -1,201 +1,103 @@
|
|||||||
Apache License
|
# **New API 许可协议 (Licensing)**
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
本项目采用**基于使用场景的双重许可 (Usage-Based Dual Licensing)** 模式。
|
||||||
|
|
||||||
1. Definitions.
|
**核心原则:**
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
- **默认许可:** 本项目默认在 **GNU Affero 通用公共许可证 v3.0 (AGPLv3)** 下提供。任何用户在遵守 AGPLv3 条款和下述附加限制的前提下,均可免费使用。
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
- **商业许可:** 在特定商业场景下,或当您希望获得 AGPLv3 之外的权利时,**必须**获取**商业许可证 (Commercial License)**。
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
---
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
## **1. 开源许可证 (Open Source License): AGPLv3 - 适用于基础使用**
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
- 在遵守 **AGPLv3** 条款的前提下,您可以自由地使用、修改和分发 New API。AGPLv3 的完整文本可以访问 [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html) 获取。
|
||||||
exercising permissions granted by this License.
|
- **核心义务:** AGPLv3 的一个关键要求是,如果您修改了 New API 并通过网络提供服务 (SaaS),或者分发了修改后的版本,您必须以 AGPLv3 许可证向所有用户提供相应的**完整源代码**。
|
||||||
|
- **附加限制 (重要):** 在仅使用 AGPLv3 开源许可证的情况下,您**必须**完整保留项目代码中原有的品牌标识、LOGO 及版权声明信息。**禁止以任何形式修改、移除或遮盖**这些信息。如需移除,必须获取商业许可证。
|
||||||
|
- 使用前请务必仔细阅读并理解 AGPLv3 的所有条款及上述附加限制。
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
## **2. 商业许可证 (Commercial License) - 适用于高级场景及闭源需求**
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
在以下任一情况下,您**必须**联系我们获取并签署一份商业许可证,才能合法使用 New API:
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
- **场景一:移除品牌和版权信息**
|
||||||
Object form, made available under the License, as indicated by a
|
您希望在您的产品或服务中移除 New API 的 LOGO、UI界面中的版权声明或其他品牌标识。
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
- **场景二:规避 AGPLv3 开源义务**
|
||||||
form, that is based on (or derived from) the Work and for which the
|
您基于 New API 进行了修改,并希望:
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
- 通过网络提供服务(SaaS),但**不希望**向您的服务用户公开您修改后的源代码。
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
- 分发一个集成了 New API 的软件产品,但**不希望**以 AGPLv3 许可证发布您的产品或公开源代码。
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
- **场景三:企业政策与集成需求**
|
||||||
the original version of the Work and any modifications or additions
|
- 您所在公司的政策、客户合同或项目要求不允许使用 AGPLv3 许可的软件。
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
- 您需要进行 OEM 集成,将 New API 作为您闭源商业产品的一部分进行再分发。
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
- **场景四:需要商业支持与保障**
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
您需要 AGPLv3 未提供的商业保障,如官方技术支持等。
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
**获取商业许可:**
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
请通过电子邮件 **support@quantumnous.com** 联系 New API 团队洽谈商业授权事宜。
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
## **3. 贡献 (Contributions)**
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
- 我们欢迎社区对 New API 的贡献。所有向本项目提交的贡献(例如通过 Pull Request)都将被视为在 **AGPLv3** 许可证下提供。
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
- 通过向本项目提交贡献,即表示您同意您的代码以 AGPLv3 许可证授权给本项目及所有后续使用者(无论这些使用者最终遵循 AGPLv3 还是商业许可)。
|
||||||
modifications, and in Source or Object form, provided that You
|
- 您也理解并同意,您的贡献可能会被包含在根据商业许可证分发的 New API 版本中。
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
## **4. 其他条款 (Other Terms)**
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
- 关于商业许可证的具体条款、条件和价格,以双方签署的正式商业许可协议为准。
|
||||||
stating that You changed the files; and
|
- 项目维护者保留根据需要更新本许可政策的权利。相关更新将通过项目官方渠道(如代码仓库、官方网站)进行通知。
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
---
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
# **New API Licensing**
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
This project uses a **Usage-Based Dual Licensing** model.
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
**Core Principles:**
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
- **Default License:** This project is available by default under the **GNU Affero General Public License v3.0 (AGPLv3)**. Any user may use it free of charge, provided they comply with both the AGPLv3 terms and the additional restrictions listed below.
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
- **Commercial License:** For specific commercial scenarios, or if you require rights beyond those granted by AGPLv3, you **must** obtain a **Commercial License**.
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
---
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
## **1. Open Source License: AGPLv3 – For Basic Usage**
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
- Under the terms of the **AGPLv3**, you are free to use, modify, and distribute New API. The complete AGPLv3 license text can be viewed at [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
- **Core Obligation:** A key AGPLv3 requirement is that if you modify New API and provide it as a network service (SaaS), or distribute a modified version, you must make the **complete corresponding source code** available to all users under the AGPLv3 license.
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
- **Additional Restriction (Important):** When using only the AGPLv3 open-source license, you **must** retain all original branding, logos, and copyright statements within the project’s code. **You are strictly prohibited from modifying, removing, or concealing** any such information. If you wish to remove this, you must obtain a Commercial License.
|
||||||
or other liability obligations and/or rights consistent with this
|
- Please read and ensure that you fully understand all AGPLv3 terms and the above additional restriction before use.
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
## **2. Commercial License – For Advanced Scenarios & Closed Source Needs**
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
You **must** contact us to obtain and sign a Commercial License in any of the following scenarios in order to legally use New API:
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
- **Scenario 1: Removal of Branding and Copyright**
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
You wish to remove the New API logo, copyright statement, or other branding elements from your product or service.
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
- **Scenario 2: Avoidance of AGPLv3 Open Source Obligations**
|
||||||
|
You have modified New API and wish to:
|
||||||
|
- Offer it as a network service (SaaS) **without** disclosing your modifications' source code to your users.
|
||||||
|
- Distribute a software product integrated with New API **without** releasing your product under AGPLv3 or open-sourcing the code.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
- **Scenario 3: Enterprise Policy & Integration Needs**
|
||||||
you may not use this file except in compliance with the License.
|
- Your organization’s policies, client contracts, or project requirements prohibit the use of AGPLv3-licensed software.
|
||||||
You may obtain a copy of the License at
|
- You require OEM integration and need to redistribute New API as part of your closed-source commercial product.
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
- **Scenario 4: Commercial Support and Assurances**
|
||||||
|
You require commercial assurances not provided by AGPLv3, such as official technical support.
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
**Obtaining a Commercial License:**
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
Please contact the New API team via email at **support@quantumnous.com** to discuss commercial licensing.
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
## **3. Contributions**
|
||||||
limitations under the License.
|
|
||||||
|
- We welcome community contributions to New API. All contributions (e.g., via Pull Request) are deemed to be provided under the **AGPLv3** license.
|
||||||
|
- By submitting a contribution, you agree that your code is licensed to this project and all downstream users under the AGPLv3 license (regardless of whether those users ultimately operate under AGPLv3 or a Commercial License).
|
||||||
|
- You also acknowledge and agree that your contribution may be included in New API releases distributed under a Commercial License.
|
||||||
|
|
||||||
|
## **4. Other Terms**
|
||||||
|
|
||||||
|
- The specific terms, conditions, and pricing of the Commercial License are governed by the formal commercial license agreement executed by both parties.
|
||||||
|
- Project maintainers reserve the right to update this licensing policy as needed. Updates will be communicated via official project channels (e.g., repository, official website).
|
||||||
|
|||||||
260
LOGGING.md
Normal file
260
LOGGING.md
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
# 日志系统说明
|
||||||
|
|
||||||
|
本项目使用 Go 标准库的 `log/slog` 实现结构化日志记录。
|
||||||
|
|
||||||
|
## 📋 功能特性
|
||||||
|
|
||||||
|
### 1. 标准的文件存储结构
|
||||||
|
|
||||||
|
- **当前日志文件**: `oneapi.log` - 实时写入的日志文件
|
||||||
|
- **归档日志文件**: `oneapi.2024-01-02-153045.log` - 自动轮转后的历史日志
|
||||||
|
|
||||||
|
### 2. 自动日志轮转
|
||||||
|
|
||||||
|
日志文件会在以下情况自动轮转:
|
||||||
|
|
||||||
|
- **按大小轮转**: 当日志文件超过指定大小时(默认 100MB)
|
||||||
|
- **启动时日期检查**: 程序启动时如果检测到日志文件是旧日期创建的,会自动轮转
|
||||||
|
- **自动清理**: 只保留最近 N 个日志文件(默认 7 个)
|
||||||
|
|
||||||
|
### 3. 结构化日志
|
||||||
|
|
||||||
|
所有日志都包含以下结构化字段:
|
||||||
|
|
||||||
|
```
|
||||||
|
time=2024-01-02T15:30:45 level=INFO msg="user logged in" request_id=abc123 user_id=1001
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 多种输出格式
|
||||||
|
|
||||||
|
- **Text 格式** (默认): 人类可读的文本格式
|
||||||
|
- **JSON 格式**: 便于日志分析工具解析
|
||||||
|
|
||||||
|
### 5. 灵活的日志级别
|
||||||
|
|
||||||
|
支持四个日志级别:
|
||||||
|
- `DEBUG`: 调试信息
|
||||||
|
- `INFO`: 一般信息
|
||||||
|
- `WARN`: 警告信息
|
||||||
|
- `ERROR`: 错误信息
|
||||||
|
|
||||||
|
## ⚙️ 配置方式
|
||||||
|
|
||||||
|
### 环境变量配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 日志目录(必需,否则只输出到控制台)
|
||||||
|
--log-dir=./logs
|
||||||
|
|
||||||
|
# 日志级别(可选,默认: INFO,DEBUG 模式除外)
|
||||||
|
export LOG_LEVEL=DEBUG # 可选值: DEBUG, INFO, WARN, ERROR
|
||||||
|
|
||||||
|
# 日志格式(可选,默认: text)
|
||||||
|
export LOG_FORMAT=json # 可选值: text, json
|
||||||
|
|
||||||
|
# 单个日志文件最大大小(可选,默认: 100,单位: MB)
|
||||||
|
export LOG_MAX_SIZE_MB=200
|
||||||
|
|
||||||
|
# 保留的日志文件数量(可选,默认: 7)
|
||||||
|
export LOG_MAX_FILES=14
|
||||||
|
|
||||||
|
# 启用调试模式(会自动将日志级别设为 DEBUG)
|
||||||
|
export DEBUG=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 命令行参数
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动时指定日志目录
|
||||||
|
./new-api --log-dir=./logs
|
||||||
|
|
||||||
|
# 如果不指定日志目录,日志只输出到控制台
|
||||||
|
./new-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 使用示例
|
||||||
|
|
||||||
|
### 基础使用
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/QuantumNous/new-api/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 记录信息日志
|
||||||
|
logger.LogInfo(ctx, "user registered successfully")
|
||||||
|
|
||||||
|
// 记录警告日志
|
||||||
|
logger.LogWarn(ctx, "API rate limit approaching")
|
||||||
|
|
||||||
|
// 记录错误日志
|
||||||
|
logger.LogError(ctx, "failed to connect to database")
|
||||||
|
|
||||||
|
// 记录调试日志(只在 DEBUG 模式下输出)
|
||||||
|
logger.LogDebug(ctx, "processing request with params: %v", params)
|
||||||
|
|
||||||
|
// 记录系统日志(无 context)
|
||||||
|
logger.LogSystemInfo("application started")
|
||||||
|
logger.LogSystemError("critical system error")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日志输出示例
|
||||||
|
|
||||||
|
**Text 格式** (易读格式):
|
||||||
|
```
|
||||||
|
[INFO] 2024/01/02 - 15:30:45 | SYSTEM | application started
|
||||||
|
[INFO] 2024/01/02 - 15:30:46 | abc123 | user registered successfully
|
||||||
|
[WARN] 2024/01/02 - 15:30:47 | def456 | API rate limit approaching | remaining=10, limit=100
|
||||||
|
[ERROR] 2024/01/02 - 15:30:48 | ghi789 | failed to connect to database | error="connection timeout"
|
||||||
|
```
|
||||||
|
|
||||||
|
格式说明:`[级别] 时间 | 请求ID/组件 | 消息 | 额外属性(如有)`
|
||||||
|
|
||||||
|
**JSON 格式**:
|
||||||
|
```json
|
||||||
|
{"time":"2024-01-02 15:30:45","level":"INFO","msg":"application started","request_id":"SYSTEM"}
|
||||||
|
{"time":"2024-01-02 15:30:46","level":"INFO","msg":"user registered successfully","request_id":"abc123"}
|
||||||
|
{"time":"2024-01-02 15:30:47","level":"WARN","msg":"API rate limit approaching","request_id":"def456"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📂 日志文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
logs/
|
||||||
|
├── oneapi.log # 当前活动日志文件
|
||||||
|
├── oneapi.2024-01-01-090000.log # 昨天的日志
|
||||||
|
├── oneapi.2024-01-01-150000.log # 昨天下午的日志(如果超过大小限制)
|
||||||
|
├── oneapi.2023-12-31-090000.log # 更早的日志
|
||||||
|
└── ... # 最多保留配置数量的历史文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 日志轮转机制
|
||||||
|
|
||||||
|
### 轮转触发条件
|
||||||
|
|
||||||
|
1. **文件大小检查**: 每写入 1000 条日志后检查一次文件大小
|
||||||
|
2. **启动时日期检查**: 程序启动时检查日志文件的修改日期,如果不是今天则轮转
|
||||||
|
3. **自动清理**: 轮转时自动删除超过保留数量的旧日志文件
|
||||||
|
|
||||||
|
> **注意**: 日志不会在运行时动态检查日期变化。如果需要每天自动轮转日志,建议:
|
||||||
|
> - 使用定时任务(如 cron)每天重启服务
|
||||||
|
> - 或者配置较小的日志文件大小,让它自动按大小轮转
|
||||||
|
|
||||||
|
### 轮转流程
|
||||||
|
|
||||||
|
1. 检测到需要轮转时,关闭当前日志文件
|
||||||
|
2. 将 `oneapi.log` 重命名为 `oneapi.YYYY-MM-DD-HHmmss.log`
|
||||||
|
3. 创建新的 `oneapi.log` 文件
|
||||||
|
4. 异步清理超过数量限制的旧日志文件
|
||||||
|
5. 记录轮转事件到新日志文件
|
||||||
|
|
||||||
|
## 🎯 最佳实践
|
||||||
|
|
||||||
|
### 1. 生产环境配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 INFO 级别,避免过多调试信息
|
||||||
|
export LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# 使用 JSON 格式,便于日志分析工具处理
|
||||||
|
export LOG_FORMAT=json
|
||||||
|
|
||||||
|
# 设置合适的文件大小和保留数量
|
||||||
|
export LOG_MAX_SIZE_MB=500
|
||||||
|
export LOG_MAX_FILES=30
|
||||||
|
|
||||||
|
# 指定日志目录
|
||||||
|
./new-api --log-dir=/var/log/oneapi
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 开发环境配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 DEBUG 级别查看详细信息
|
||||||
|
export DEBUG=true
|
||||||
|
|
||||||
|
# 使用 Text 格式,便于阅读
|
||||||
|
export LOG_FORMAT=text
|
||||||
|
|
||||||
|
# 较小的文件大小和保留数量
|
||||||
|
export LOG_MAX_SIZE_MB=50
|
||||||
|
export LOG_MAX_FILES=7
|
||||||
|
|
||||||
|
./new-api --log-dir=./logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 容器环境配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 只输出到标准输出,由容器运行时管理日志
|
||||||
|
./new-api
|
||||||
|
|
||||||
|
# 或者使用 JSON 格式便于日志收集系统处理
|
||||||
|
export LOG_FORMAT=json
|
||||||
|
./new-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 日志分析
|
||||||
|
|
||||||
|
### 使用 grep 分析文本日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查找错误日志
|
||||||
|
grep '\[ERROR\]' logs/oneapi.log
|
||||||
|
|
||||||
|
# 查找特定请求的所有日志
|
||||||
|
grep 'abc123' logs/*.log
|
||||||
|
|
||||||
|
# 查看最近的警告和错误
|
||||||
|
tail -f logs/oneapi.log | grep -E '\[(WARN|ERROR)\]'
|
||||||
|
|
||||||
|
# 查找包含特定关键词的日志
|
||||||
|
grep 'database' logs/oneapi.log
|
||||||
|
|
||||||
|
# 查看今天的所有错误
|
||||||
|
grep "\[ERROR\] $(date +%Y/%m/%d)" logs/oneapi.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 jq 分析 JSON 日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 提取所有错误日志
|
||||||
|
cat logs/oneapi.log | jq 'select(.level=="ERROR")'
|
||||||
|
|
||||||
|
# 统计各级别日志数量
|
||||||
|
cat logs/oneapi.log | jq -r '.level' | sort | uniq -c
|
||||||
|
|
||||||
|
# 查找特定时间范围的日志
|
||||||
|
cat logs/oneapi.log | jq 'select(.time >= "2024-01-02 15:00:00" and .time <= "2024-01-02 16:00:00")'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 性能优化
|
||||||
|
|
||||||
|
1. **异步日志轮转**: 轮转操作在后台 goroutine 中执行,不阻塞主程序
|
||||||
|
2. **批量写入检查**: 每 1000 次写入才检查一次轮转条件,减少 I/O 开销
|
||||||
|
3. **读写锁**: 使用 `sync.RWMutex` 保护日志器,提高并发性能
|
||||||
|
4. **零分配**: `slog` 库在大多数情况下实现零内存分配
|
||||||
|
|
||||||
|
## 🚨 故障排查
|
||||||
|
|
||||||
|
### 日志文件未创建
|
||||||
|
|
||||||
|
- 检查日志目录是否存在且有写入权限
|
||||||
|
- 确认启动时指定了 `--log-dir` 参数
|
||||||
|
|
||||||
|
### 日志文件过多
|
||||||
|
|
||||||
|
- 调整 `LOG_MAX_FILES` 环境变量
|
||||||
|
- 手动清理不需要的旧日志文件
|
||||||
|
|
||||||
|
### 日志级别不正确
|
||||||
|
|
||||||
|
- 检查 `LOG_LEVEL` 环境变量是否正确设置
|
||||||
|
- 确认 `DEBUG` 环境变量的值(会覆盖 LOG_LEVEL)
|
||||||
|
|
||||||
|
## 📖 相关文档
|
||||||
|
|
||||||
|
- [Go slog 官方文档](https://pkg.go.dev/log/slog)
|
||||||
|
- [结构化日志最佳实践](https://go.dev/blog/slog)
|
||||||
|
|
||||||
73
README.en.md
73
README.en.md
@@ -1,6 +1,10 @@
|
|||||||
<p align="right">
|
<p align="right">
|
||||||
<a href="./README.md">中文</a> | <strong>English</strong>
|
<a href="./README.md">中文</a> | <strong>English</strong> | <a href="./README.fr.md">Français</a> | <a href="./README.ja.md">日本語</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **MT (Machine Translation)**: This document is machine translated. For the most accurate information, please refer to the [Chinese version](./README.md).
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||
@@ -40,6 +44,28 @@
|
|||||||
> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes.
|
> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes.
|
||||||
> - According to the [《Interim Measures for the Management of Generative Artificial Intelligence Services》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), please do not provide any unregistered generative AI services to the public in China.
|
> - According to the [《Interim Measures for the Management of Generative Artificial Intelligence Services》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), please do not provide any unregistered generative AI services to the public in China.
|
||||||
|
|
||||||
|
<h2>🤝 Trusted Partners</h2>
|
||||||
|
<p id="premium-sponsors"> </p>
|
||||||
|
<p align="center"><strong>No particular order</strong></p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.cherry-ai.com/" target=_blank><img
|
||||||
|
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://bda.pku.edu.cn/" target=_blank><img
|
||||||
|
src="./docs/images/pku.png" alt="Peking University" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
|
||||||
|
src="./docs/images/ucloud.png" alt="UCloud" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://www.aliyun.com/" target=_blank><img
|
||||||
|
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://io.net/" target=_blank><img
|
||||||
|
src="./docs/images/io-net.png" alt="IO.NET" height="120"
|
||||||
|
/></a>
|
||||||
|
</p>
|
||||||
|
<p> </p>
|
||||||
|
|
||||||
## 📚 Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
For detailed documentation, please visit our official Wiki: [https://docs.newapi.pro/](https://docs.newapi.pro/)
|
For detailed documentation, please visit our official Wiki: [https://docs.newapi.pro/](https://docs.newapi.pro/)
|
||||||
@@ -53,7 +79,7 @@ New API offers a wide range of features, please refer to [Features Introduction]
|
|||||||
|
|
||||||
1. 🎨 Brand new UI interface
|
1. 🎨 Brand new UI interface
|
||||||
2. 🌍 Multi-language support
|
2. 🌍 Multi-language support
|
||||||
3. 💰 Online recharge functionality (YiPay)
|
3. 💰 Online recharge functionality, currently supports EPay and Stripe
|
||||||
4. 🔍 Support for querying usage quotas with keys (works with [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
4. 🔍 Support for querying usage quotas with keys (works with [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||||
5. 🔄 Compatible with the original One API database
|
5. 🔄 Compatible with the original One API database
|
||||||
6. 💵 Support for pay-per-use model pricing
|
6. 💵 Support for pay-per-use model pricing
|
||||||
@@ -63,18 +89,23 @@ New API offers a wide range of features, please refer to [Features Introduction]
|
|||||||
10. 🤖 Support for more authorization login methods (LinuxDO, Telegram, OIDC)
|
10. 🤖 Support for more authorization login methods (LinuxDO, Telegram, OIDC)
|
||||||
11. 🔄 Support for Rerank models (Cohere and Jina), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
|
11. 🔄 Support for Rerank models (Cohere and Jina), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
|
||||||
12. ⚡ Support for OpenAI Realtime API (including Azure channels), [API Documentation](https://docs.newapi.pro/api/openai-realtime)
|
12. ⚡ Support for OpenAI Realtime API (including Azure channels), [API Documentation](https://docs.newapi.pro/api/openai-realtime)
|
||||||
13. ⚡ Support for Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
|
13. ⚡ Support for **OpenAI Responses** format, [API Documentation](https://docs.newapi.pro/api/openai-responses)
|
||||||
14. Support for entering chat interface via /chat2link route
|
14. ⚡ Support for **Claude Messages** format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
|
||||||
15. 🧠 Support for setting reasoning effort through model name suffixes:
|
15. ⚡ Support for **Google Gemini** format, [API Documentation](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||||
|
16. 🧠 Support for setting reasoning effort through model name suffixes:
|
||||||
1. OpenAI o-series models
|
1. OpenAI o-series models
|
||||||
- Add `-high` suffix for high reasoning effort (e.g.: `o3-mini-high`)
|
- Add `-high` suffix for high reasoning effort (e.g.: `o3-mini-high`)
|
||||||
- Add `-medium` suffix for medium reasoning effort (e.g.: `o3-mini-medium`)
|
- Add `-medium` suffix for medium reasoning effort (e.g.: `o3-mini-medium`)
|
||||||
- Add `-low` suffix for low reasoning effort (e.g.: `o3-mini-low`)
|
- Add `-low` suffix for low reasoning effort (e.g.: `o3-mini-low`)
|
||||||
2. Claude thinking models
|
2. Claude thinking models
|
||||||
- Add `-thinking` suffix to enable thinking mode (e.g.: `claude-3-7-sonnet-20250219-thinking`)
|
- Add `-thinking` suffix to enable thinking mode (e.g.: `claude-3-7-sonnet-20250219-thinking`)
|
||||||
16. 🔄 Thinking-to-content functionality
|
17. 🔄 Thinking-to-content functionality
|
||||||
17. 🔄 Model rate limiting for users
|
18. 🔄 Model rate limiting for users
|
||||||
18. 💰 Cache billing support, which allows billing at a set ratio when cache is hit:
|
19. 🔄 Request format conversion functionality, supporting the following three format conversions:
|
||||||
|
1. OpenAI Chat Completions => Claude Messages
|
||||||
|
2. Claude Messages => OpenAI Chat Completions (can be used for Claude Code to call third-party models)
|
||||||
|
3. OpenAI Chat Completions => Gemini Chat
|
||||||
|
20. 💰 Cache billing support, which allows billing at a set ratio when cache is hit:
|
||||||
1. Set the `Prompt Cache Ratio` option in `System Settings-Operation Settings`
|
1. Set the `Prompt Cache Ratio` option in `System Settings-Operation Settings`
|
||||||
2. Set `Prompt Cache Ratio` in the channel, range 0-1, e.g., setting to 0.5 means billing at 50% when cache is hit
|
2. Set `Prompt Cache Ratio` in the channel, range 0-1, e.g., setting to 0.5 means billing at 50% when cache is hit
|
||||||
3. Supported channels:
|
3. Supported channels:
|
||||||
@@ -93,23 +124,23 @@ This version supports multiple models, please refer to [API Documentation-Relay
|
|||||||
4. Custom channels, supporting full call address input
|
4. Custom channels, supporting full call address input
|
||||||
5. Rerank models ([Cohere](https://cohere.ai/) and [Jina](https://jina.ai/)), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
|
5. Rerank models ([Cohere](https://cohere.ai/) and [Jina](https://jina.ai/)), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
|
||||||
6. Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
|
6. Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
|
||||||
7. Dify, currently only supports chatflow
|
7. Google Gemini format, [API Documentation](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||||
|
8. Dify, currently only supports chatflow
|
||||||
|
9. For more interfaces, please refer to [API Documentation](https://docs.newapi.pro/api)
|
||||||
|
|
||||||
## Environment Variable Configuration
|
## Environment Variable Configuration
|
||||||
|
|
||||||
For detailed configuration instructions, please refer to [Installation Guide-Environment Variables Configuration](https://docs.newapi.pro/installation/environment-variables):
|
For detailed configuration instructions, please refer to [Installation Guide-Environment Variables Configuration](https://docs.newapi.pro/installation/environment-variables):
|
||||||
|
|
||||||
- `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false`
|
- `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false`
|
||||||
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 120 seconds
|
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 300 seconds
|
||||||
- `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true`
|
- `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true`
|
||||||
- `FORCE_STREAM_OPTION`: Whether to override client stream_options parameter, default is `true`
|
|
||||||
- `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true`
|
- `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true`
|
||||||
- `GET_MEDIA_TOKEN_NOT_STREAM`: Whether to count image tokens in non-streaming cases, default is `true`
|
- `GET_MEDIA_TOKEN_NOT_STREAM`: Whether to count image tokens in non-streaming cases, default is `true`
|
||||||
- `UPDATE_TASK`: Whether to update asynchronous tasks (Midjourney, Suno), default is `true`
|
- `UPDATE_TASK`: Whether to update asynchronous tasks (Midjourney, Suno), default is `true`
|
||||||
- `COHERE_SAFETY_SETTING`: Cohere model safety settings, options are `NONE`, `CONTEXTUAL`, `STRICT`, default is `NONE`
|
|
||||||
- `GEMINI_VISION_MAX_IMAGE_NUM`: Maximum number of images for Gemini models, default is `16`
|
- `GEMINI_VISION_MAX_IMAGE_NUM`: Maximum number of images for Gemini models, default is `16`
|
||||||
- `MAX_FILE_DOWNLOAD_MB`: Maximum file download size in MB, default is `20`
|
- `MAX_FILE_DOWNLOAD_MB`: Maximum file download size in MB, default is `20`
|
||||||
- `CRYPTO_SECRET`: Encryption key used for encrypting database content
|
- `CRYPTO_SECRET`: Encryption key used for encrypting Redis database content
|
||||||
- `AZURE_DEFAULT_API_VERSION`: Azure channel default API version, default is `2025-04-01-preview`
|
- `AZURE_DEFAULT_API_VERSION`: Azure channel default API version, default is `2025-04-01-preview`
|
||||||
- `NOTIFICATION_LIMIT_DURATION_MINUTE`: Notification limit duration, default is `10` minutes
|
- `NOTIFICATION_LIMIT_DURATION_MINUTE`: Notification limit duration, default is `10` minutes
|
||||||
- `NOTIFY_LIMIT_COUNT`: Maximum number of user notifications within the specified duration, default is `2`
|
- `NOTIFY_LIMIT_COUNT`: Maximum number of user notifications within the specified duration, default is `2`
|
||||||
@@ -156,7 +187,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Channel Retry and Cache
|
## Channel Retry and Cache
|
||||||
Channel retry functionality has been implemented, you can set the number of retries in `Settings->Operation Settings->General Settings`. It is **recommended to enable caching**.
|
Channel retry functionality has been implemented, you can set the number of retries in `Settings->Operation Settings->General Settings->Failure Retry Count`, **recommended to enable caching** functionality.
|
||||||
|
|
||||||
### Cache Configuration Method
|
### Cache Configuration Method
|
||||||
1. `REDIS_CONN_STRING`: Set Redis as cache
|
1. `REDIS_CONN_STRING`: Set Redis as cache
|
||||||
@@ -166,21 +197,21 @@ Channel retry functionality has been implemented, you can set the number of retr
|
|||||||
|
|
||||||
For detailed API documentation, please refer to [API Documentation](https://docs.newapi.pro/api):
|
For detailed API documentation, please refer to [API Documentation](https://docs.newapi.pro/api):
|
||||||
|
|
||||||
- [Chat API](https://docs.newapi.pro/api/openai-chat)
|
- [Chat API (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||||
- [Image API](https://docs.newapi.pro/api/openai-image)
|
- [Response API (Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||||
- [Rerank API](https://docs.newapi.pro/api/jinaai-rerank)
|
- [Image API (Image)](https://docs.newapi.pro/api/openai-image)
|
||||||
- [Realtime API](https://docs.newapi.pro/api/openai-realtime)
|
- [Rerank API (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||||
- [Claude Chat API (messages)](https://docs.newapi.pro/api/anthropic-chat)
|
- [Realtime Chat API (Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||||
|
- [Claude Chat API](https://docs.newapi.pro/api/anthropic-chat)
|
||||||
|
- [Google Gemini Chat API](https://docs.newapi.pro/api/google-gemini-chat)
|
||||||
|
|
||||||
## Related Projects
|
## Related Projects
|
||||||
- [One API](https://github.com/songquanpeng/one-api): Original project
|
- [One API](https://github.com/songquanpeng/one-api): Original project
|
||||||
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy): Midjourney interface support
|
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy): Midjourney interface support
|
||||||
- [chatnio](https://github.com/Deeptrain-Community/chatnio): Next-generation AI one-stop B/C-end solution
|
|
||||||
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool): Query usage quota with key
|
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool): Query usage quota with key
|
||||||
|
|
||||||
Other projects based on New API:
|
Other projects based on New API:
|
||||||
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon): High-performance optimized version of New API
|
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon): High-performance optimized version of New API
|
||||||
- [VoAPI](https://github.com/VoAPI/VoAPI): Frontend beautified version based on New API
|
|
||||||
|
|
||||||
## Help and Support
|
## Help and Support
|
||||||
|
|
||||||
|
|||||||
225
README.fr.md
Normal file
225
README.fr.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<p align="right">
|
||||||
|
<a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <strong>Français</strong> | <a href="./README.ja.md">日本語</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **MT (Traduction Automatique)**: Ce document est traduit automatiquement. Pour les informations les plus précises, veuillez vous référer à la [version chinoise](./README.md).
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# New API
|
||||||
|
|
||||||
|
🍥 Passerelle de modèles étendus de nouvelle génération et système de gestion d'actifs d'IA
|
||||||
|
|
||||||
|
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
|
||||||
|
<img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="licence">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/Calcium-Ion/new-api/releases/latest">
|
||||||
|
<img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="version">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
|
||||||
|
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
|
||||||
|
</a>
|
||||||
|
<a href="https://hub.docker.com/r/CalciumIon/new-api">
|
||||||
|
<img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
|
||||||
|
</a>
|
||||||
|
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
|
||||||
|
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 📝 Description du projet
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Il s'agit d'un projet open-source développé sur la base de [One API](https://github.com/songquanpeng/one-api)
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> - Ce projet est uniquement destiné à des fins d'apprentissage personnel, sans garantie de stabilité ni de support technique.
|
||||||
|
> - Les utilisateurs doivent se conformer aux [Conditions d'utilisation](https://openai.com/policies/terms-of-use) d'OpenAI et aux **lois et réglementations applicables**, et ne doivent pas l'utiliser à des fins illégales.
|
||||||
|
> - Conformément aux [《Mesures provisoires pour la gestion des services d'intelligence artificielle générative》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), veuillez ne fournir aucun service d'IA générative non enregistré au public en Chine.
|
||||||
|
|
||||||
|
<h2>🤝 Partenaires de confiance</h2>
|
||||||
|
<p id="premium-sponsors"> </p>
|
||||||
|
<p align="center"><strong>Sans ordre particulier</strong></p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.cherry-ai.com/" target=_blank><img
|
||||||
|
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://bda.pku.edu.cn/" target=_blank><img
|
||||||
|
src="./docs/images/pku.png" alt="Université de Pékin" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
|
||||||
|
src="./docs/images/ucloud.png" alt="UCloud" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://www.aliyun.com/" target=_blank><img
|
||||||
|
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://io.net/" target=_blank><img
|
||||||
|
src="./docs/images/io-net.png" alt="IO.NET" height="120"
|
||||||
|
/></a>
|
||||||
|
</p>
|
||||||
|
<p> </p>
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
Pour une documentation détaillée, veuillez consulter notre Wiki officiel : [https://docs.newapi.pro/](https://docs.newapi.pro/)
|
||||||
|
|
||||||
|
Vous pouvez également accéder au DeepWiki généré par l'IA :
|
||||||
|
[](https://deepwiki.com/QuantumNous/new-api)
|
||||||
|
|
||||||
|
## ✨ Fonctionnalités clés
|
||||||
|
|
||||||
|
New API offre un large éventail de fonctionnalités, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/wiki/features-introduction) pour plus de détails :
|
||||||
|
|
||||||
|
1. 🎨 Nouvelle interface utilisateur
|
||||||
|
2. 🌍 Prise en charge multilingue
|
||||||
|
3. 💰 Fonctionnalité de recharge en ligne, prend actuellement en charge EPay et Stripe
|
||||||
|
4. 🔍 Prise en charge de la recherche de quotas d'utilisation avec des clés (fonctionne avec [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||||
|
5. 🔄 Compatible avec la base de données originale de One API
|
||||||
|
6. 💵 Prise en charge de la tarification des modèles de paiement à l'utilisation
|
||||||
|
7. ⚖️ Prise en charge de la sélection aléatoire pondérée des canaux
|
||||||
|
8. 📈 Tableau de bord des données (console)
|
||||||
|
9. 🔒 Regroupement de jetons et restrictions de modèles
|
||||||
|
10. 🤖 Prise en charge de plus de méthodes de connexion par autorisation (LinuxDO, Telegram, OIDC)
|
||||||
|
11. 🔄 Prise en charge des modèles Rerank (Cohere et Jina), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
|
||||||
|
12. ⚡ Prise en charge de l'API OpenAI Realtime (y compris les canaux Azure), [Documentation de l'API](https://docs.newapi.pro/api/openai-realtime)
|
||||||
|
13. ⚡ Prise en charge du format **OpenAI Responses**, [Documentation de l'API](https://docs.newapi.pro/api/openai-responses)
|
||||||
|
14. ⚡ Prise en charge du format **Claude Messages**, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
|
||||||
|
15. ⚡ Prise en charge du format **Google Gemini**, [Documentation de l'API](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||||
|
16. 🧠 Prise en charge de la définition de l'effort de raisonnement via les suffixes de nom de modèle :
|
||||||
|
1. Modèles de la série o d'OpenAI
|
||||||
|
- Ajouter le suffixe `-high` pour un effort de raisonnement élevé (par exemple : `o3-mini-high`)
|
||||||
|
- Ajouter le suffixe `-medium` pour un effort de raisonnement moyen (par exemple : `o3-mini-medium`)
|
||||||
|
- Ajouter le suffixe `-low` pour un effort de raisonnement faible (par exemple : `o3-mini-low`)
|
||||||
|
2. Modèles de pensée de Claude
|
||||||
|
- Ajouter le suffixe `-thinking` pour activer le mode de pensée (par exemple : `claude-3-7-sonnet-20250219-thinking`)
|
||||||
|
17. 🔄 Fonctionnalité de la pensée au contenu
|
||||||
|
18. 🔄 Limitation du débit du modèle pour les utilisateurs
|
||||||
|
19. 🔄 Fonctionnalité de conversion de format de requête, prenant en charge les trois conversions de format suivantes :
|
||||||
|
1. OpenAI Chat Completions => Claude Messages
|
||||||
|
2. Claude Messages => OpenAI Chat Completions (peut être utilisé pour Claude Code pour appeler des modèles tiers)
|
||||||
|
3. OpenAI Chat Completions => Gemini Chat
|
||||||
|
20. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint :
|
||||||
|
1. Définir l'option `Ratio de cache d'invite` dans `Paramètres système->Paramètres de fonctionnement`
|
||||||
|
2. Définir le `Ratio de cache d'invite` dans le canal, plage de 0 à 1, par exemple, le définir sur 0,5 signifie facturer à 50 % lorsque le cache est atteint
|
||||||
|
3. Canaux pris en charge :
|
||||||
|
- [x] OpenAI
|
||||||
|
- [x] Azure
|
||||||
|
- [x] DeepSeek
|
||||||
|
- [x] Claude
|
||||||
|
|
||||||
|
## Prise en charge des modèles
|
||||||
|
|
||||||
|
Cette version prend en charge plusieurs modèles, veuillez vous référer à [Documentation de l'API-Interface de relais](https://docs.newapi.pro/api) pour plus de détails :
|
||||||
|
|
||||||
|
1. Modèles tiers **gpts** (gpt-4-gizmo-*)
|
||||||
|
2. Canal tiers [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy), [Documentation de l'API](https://docs.newapi.pro/api/midjourney-proxy-image)
|
||||||
|
3. Canal tiers [Suno API](https://github.com/Suno-API/Suno-API), [Documentation de l'API](https://docs.newapi.pro/api/suno-music)
|
||||||
|
4. Canaux personnalisés, prenant en charge la saisie complète de l'adresse d'appel
|
||||||
|
5. Modèles Rerank ([Cohere](https://cohere.ai/) et [Jina](https://jina.ai/)), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
|
||||||
|
6. Format de messages Claude, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
|
||||||
|
7. Format Google Gemini, [Documentation de l'API](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||||
|
8. Dify, ne prend actuellement en charge que chatflow
|
||||||
|
9. Pour plus d'interfaces, veuillez vous référer à la [Documentation de l'API](https://docs.newapi.pro/api)
|
||||||
|
|
||||||
|
## Configuration des variables d'environnement
|
||||||
|
|
||||||
|
Pour des instructions de configuration détaillées, veuillez vous référer à [Guide d'installation-Configuration des variables d'environnement](https://docs.newapi.pro/installation/environment-variables) :
|
||||||
|
|
||||||
|
- `GENERATE_DEFAULT_TOKEN` : S'il faut générer des jetons initiaux pour les utilisateurs nouvellement enregistrés, la valeur par défaut est `false`
|
||||||
|
- `STREAMING_TIMEOUT` : Délai d'expiration de la réponse en streaming, la valeur par défaut est de 300 secondes
|
||||||
|
- `DIFY_DEBUG` : S'il faut afficher les informations sur le flux de travail et les nœuds pour les canaux Dify, la valeur par défaut est `true`
|
||||||
|
- `GET_MEDIA_TOKEN` : S'il faut compter les jetons d'image, la valeur par défaut est `true`
|
||||||
|
- `GET_MEDIA_TOKEN_NOT_STREAM` : S'il faut compter les jetons d'image dans les cas sans streaming, la valeur par défaut est `true`
|
||||||
|
- `UPDATE_TASK` : S'il faut mettre à jour les tâches asynchrones (Midjourney, Suno), la valeur par défaut est `true`
|
||||||
|
- `GEMINI_VISION_MAX_IMAGE_NUM` : Nombre maximum d'images pour les modèles Gemini, la valeur par défaut est `16`
|
||||||
|
- `MAX_FILE_DOWNLOAD_MB` : Taille maximale de téléchargement de fichier en Mo, la valeur par défaut est `20`
|
||||||
|
- `CRYPTO_SECRET` : Clé de chiffrement utilisée pour chiffrer le contenu de la base de données Redis
|
||||||
|
- `AZURE_DEFAULT_API_VERSION` : Version de l'API par défaut du canal Azure, la valeur par défaut est `2025-04-01-preview`
|
||||||
|
- `NOTIFICATION_LIMIT_DURATION_MINUTE` : Durée de la limite de notification, la valeur par défaut est de `10` minutes
|
||||||
|
- `NOTIFY_LIMIT_COUNT` : Nombre maximal de notifications utilisateur dans la durée spécifiée, la valeur par défaut est `2`
|
||||||
|
- `ERROR_LOG_ENABLED=true` : S'il faut enregistrer et afficher les journaux d'erreurs, la valeur par défaut est `false`
|
||||||
|
|
||||||
|
## Déploiement
|
||||||
|
|
||||||
|
Pour des guides de déploiement détaillés, veuillez vous référer à [Guide d'installation-Méthodes de déploiement](https://docs.newapi.pro/installation) :
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Dernière image Docker : `calciumion/new-api:latest`
|
||||||
|
|
||||||
|
### Considérations sur le déploiement multi-machines
|
||||||
|
- La variable d'environnement `SESSION_SECRET` doit être définie, sinon l'état de connexion sera incohérent sur plusieurs machines
|
||||||
|
- Si vous partagez Redis, `CRYPTO_SECRET` doit être défini, sinon le contenu de Redis ne pourra pas être consulté sur plusieurs machines
|
||||||
|
|
||||||
|
### Exigences de déploiement
|
||||||
|
- Base de données locale (par défaut) : SQLite (le déploiement Docker doit monter le répertoire `/data`)
|
||||||
|
- Base de données distante : MySQL version >= 5.7.8, PgSQL version >= 9.6
|
||||||
|
|
||||||
|
### Méthodes de déploiement
|
||||||
|
|
||||||
|
#### Utilisation de la fonctionnalité Docker du panneau BaoTa
|
||||||
|
Installez le panneau BaoTa (version **9.2.0** ou supérieure), recherchez **New-API** dans le magasin d'applications et installez-le.
|
||||||
|
[Tutoriel avec des images](./docs/BT.md)
|
||||||
|
|
||||||
|
#### Utilisation de Docker Compose (recommandé)
|
||||||
|
```shell
|
||||||
|
# Télécharger le projet
|
||||||
|
git clone https://github.com/Calcium-Ion/new-api.git
|
||||||
|
cd new-api
|
||||||
|
# Modifier docker-compose.yml si nécessaire
|
||||||
|
# Démarrer
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Utilisation directe de l'image Docker
|
||||||
|
```shell
|
||||||
|
# Utilisation de SQLite
|
||||||
|
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
||||||
|
|
||||||
|
# Utilisation de MySQL
|
||||||
|
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nouvelle tentative de canal et cache
|
||||||
|
La fonctionnalité de nouvelle tentative de canal a été implémentée, vous pouvez définir le nombre de tentatives dans `Paramètres->Paramètres de fonctionnement->Paramètres généraux->Nombre de tentatives en cas d'échec`, **recommandé d'activer la fonctionnalité de mise en cache**.
|
||||||
|
|
||||||
|
### Méthode de configuration du cache
|
||||||
|
1. `REDIS_CONN_STRING` : Définir Redis comme cache
|
||||||
|
2. `MEMORY_CACHE_ENABLED` : Activer le cache mémoire (pas besoin de le définir manuellement si Redis est défini)
|
||||||
|
|
||||||
|
## Documentation de l'API
|
||||||
|
|
||||||
|
Pour une documentation détaillée de l'API, veuillez vous référer à [Documentation de l'API](https://docs.newapi.pro/api) :
|
||||||
|
|
||||||
|
- [API de discussion (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||||
|
- [API de réponse (Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||||
|
- [API d'image (Image)](https://docs.newapi.pro/api/openai-image)
|
||||||
|
- [API de rerank (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||||
|
- [API de discussion en temps réel (Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||||
|
- [API de discussion Claude](https://docs.newapi.pro/api/anthropic-chat)
|
||||||
|
- [API de discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat)
|
||||||
|
|
||||||
|
## Projets connexes
|
||||||
|
- [One API](https://github.com/songquanpeng/one-api) : Projet original
|
||||||
|
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) : Prise en charge de l'interface Midjourney
|
||||||
|
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) : Interroger le quota d'utilisation avec une clé
|
||||||
|
|
||||||
|
Autres projets basés sur New API :
|
||||||
|
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) : Version optimisée hautes performances de New API
|
||||||
|
|
||||||
|
## Aide et support
|
||||||
|
|
||||||
|
Si vous avez des questions, veuillez vous référer à [Aide et support](https://docs.newapi.pro/support) :
|
||||||
|
- [Interaction avec la communauté](https://docs.newapi.pro/support/community-interaction)
|
||||||
|
- [Commentaires sur les problèmes](https://docs.newapi.pro/support/feedback-issues)
|
||||||
|
- [FAQ](https://docs.newapi.pro/support/faq)
|
||||||
|
|
||||||
|
## 🌟 Historique des étoiles
|
||||||
|
|
||||||
|
[](https://star-history.com/#Calcium-Ion/new-api&Date)
|
||||||
226
README.ja.md
Normal file
226
README.ja.md
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<p align="right">
|
||||||
|
<a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a> | <strong>日本語</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **MT(機械翻訳)**: この文書は機械翻訳されています。最も正確な情報については、[中国語版](./README.md)を参照してください。
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# New API
|
||||||
|
|
||||||
|
🍥次世代大規模モデルゲートウェイとAI資産管理システム
|
||||||
|
|
||||||
|
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
|
||||||
|
<img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="license">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/Calcium-Ion/new-api/releases/latest">
|
||||||
|
<img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="release">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
|
||||||
|
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
|
||||||
|
</a>
|
||||||
|
<a href="https://hub.docker.com/r/CalciumIon/new-api">
|
||||||
|
<img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
|
||||||
|
</a>
|
||||||
|
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
|
||||||
|
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 📝 プロジェクト説明
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 本プロジェクトは、[One API](https://github.com/songquanpeng/one-api)をベースに二次開発されたオープンソースプロジェクトです
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> - 本プロジェクトは個人学習用のみであり、安定性の保証や技術サポートは提供しません。
|
||||||
|
> - ユーザーは、OpenAIの[利用規約](https://openai.com/policies/terms-of-use)および**法律法規**を遵守する必要があり、違法な目的で使用してはいけません。
|
||||||
|
> - [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)の要求に従い、中国地域の公衆に未登録の生成式AI サービスを提供しないでください。
|
||||||
|
|
||||||
|
<h2>🤝 信頼できるパートナー</h2>
|
||||||
|
<p id="premium-sponsors"> </p>
|
||||||
|
<p align="center"><strong>順不同</strong></p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.cherry-ai.com/" target=_blank><img
|
||||||
|
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://bda.pku.edu.cn/" target=_blank><img
|
||||||
|
src="./docs/images/pku.png" alt="北京大学" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
|
||||||
|
src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://www.aliyun.com/" target=_blank><img
|
||||||
|
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://io.net/" target=_blank><img
|
||||||
|
src="./docs/images/io-net.png" alt="IO.NET" height="120"
|
||||||
|
/></a>
|
||||||
|
</p>
|
||||||
|
<p> </p>
|
||||||
|
|
||||||
|
## 📚 ドキュメント
|
||||||
|
|
||||||
|
詳細なドキュメントは公式Wikiをご覧ください:[https://docs.newapi.pro/](https://docs.newapi.pro/)
|
||||||
|
|
||||||
|
AIが生成したDeepWikiにもアクセスできます:
|
||||||
|
[](https://deepwiki.com/QuantumNous/new-api)
|
||||||
|
|
||||||
|
## ✨ 主な機能
|
||||||
|
|
||||||
|
New APIは豊富な機能を提供しています。詳細な機能については[機能説明](https://docs.newapi.pro/wiki/features-introduction)を参照してください:
|
||||||
|
|
||||||
|
1. 🎨 全く新しいUIインターフェース
|
||||||
|
2. 🌍 多言語サポート
|
||||||
|
3. 💰 オンラインチャージ機能をサポート、現在EPayとStripeをサポート
|
||||||
|
4. 🔍 キーによる使用量クォータの照会をサポート([neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)と連携)
|
||||||
|
5. 🔄 オリジナルのOne APIデータベースと互換性あり
|
||||||
|
6. 💵 モデルの従量課金をサポート
|
||||||
|
7. ⚖️ チャネルの重み付けランダムをサポート
|
||||||
|
8. 📈 データダッシュボード(コンソール)
|
||||||
|
9. 🔒 トークングループ化、モデル制限
|
||||||
|
10. 🤖 より多くの認証ログイン方法をサポート(LinuxDO、Telegram、OIDC)
|
||||||
|
11. 🔄 Rerankモデルをサポート(CohereとJina)、[API ドキュメント](https://docs.newapi.pro/api/jinaai-rerank)
|
||||||
|
12. ⚡ OpenAI Realtime APIをサポート(Azureチャネルを含む)、[APIドキュメント](https://docs.newapi.pro/api/openai-realtime)
|
||||||
|
13. ⚡ **OpenAI Responses**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/openai-responses)
|
||||||
|
14. ⚡ **Claude Messages**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat)
|
||||||
|
15. ⚡ **Google Gemini**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||||
|
16. 🧠 モデル名のサフィックスを通じてreasoning effortを設定することをサポート:
|
||||||
|
1. OpenAI oシリーズモデル
|
||||||
|
- `-high`サフィックスを追加してhigh reasoning effortに設定(例:`o3-mini-high`)
|
||||||
|
- `-medium`サフィックスを追加してmedium reasoning effortに設定(例:`o3-mini-medium`)
|
||||||
|
- `-low`サフィックスを追加してlow reasoning effortに設定(例:`o3-mini-low`)
|
||||||
|
2. Claude思考モデル
|
||||||
|
- `-thinking`サフィックスを追加して思考モードを有効にする(例:`claude-3-7-sonnet-20250219-thinking`)
|
||||||
|
17. 🔄 思考からコンテンツへの機能
|
||||||
|
18. 🔄 ユーザーに対するモデルレート制限機能
|
||||||
|
19. 🔄 リクエストフォーマット変換機能、以下の3つのフォーマット変換をサポート:
|
||||||
|
1. OpenAI Chat Completions => Claude Messages
|
||||||
|
2. Claude Messages => OpenAI Chat Completions(Claude Codeがサードパーティモデルを呼び出す際に使用可能)
|
||||||
|
3. OpenAI Chat Completions => Gemini Chat
|
||||||
|
20. 💰 キャッシュ課金サポート、有効にするとキャッシュがヒットした際に設定された比率で課金できます:
|
||||||
|
1. `システム設定-運営設定`で`プロンプトキャッシュ倍率`オプションを設定
|
||||||
|
2. チャネルで`プロンプトキャッシュ倍率`を設定、範囲は0-1、例えば0.5に設定するとキャッシュがヒットした際に50%で課金
|
||||||
|
3. サポートされているチャネル:
|
||||||
|
- [x] OpenAI
|
||||||
|
- [x] Azure
|
||||||
|
- [x] DeepSeek
|
||||||
|
- [x] Claude
|
||||||
|
|
||||||
|
## モデルサポート
|
||||||
|
|
||||||
|
このバージョンは複数のモデルをサポートしています。詳細は[APIドキュメント-中継インターフェース](https://docs.newapi.pro/api)を参照してください:
|
||||||
|
|
||||||
|
1. サードパーティモデル **gpts**(gpt-4-gizmo-*)
|
||||||
|
2. サードパーティチャネル[Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)インターフェース、[APIドキュメント](https://docs.newapi.pro/api/midjourney-proxy-image)
|
||||||
|
3. サードパーティチャネル[Suno API](https://github.com/Suno-API/Suno-API)インターフェース、[APIドキュメント](https://docs.newapi.pro/api/suno-music)
|
||||||
|
4. カスタムチャネル、完全な呼び出しアドレスの入力をサポート
|
||||||
|
5. Rerankモデル([Cohere](https://cohere.ai/)と[Jina](https://jina.ai/))、[APIドキュメント](https://docs.newapi.pro/api/jinaai-rerank)
|
||||||
|
6. Claude Messages形式、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat)
|
||||||
|
7. Google Gemini形式、[APIドキュメント](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||||
|
8. Dify、現在はchatflowのみをサポート
|
||||||
|
9. その他のインターフェースについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください
|
||||||
|
|
||||||
|
## 環境変数設定
|
||||||
|
|
||||||
|
詳細な設定説明については[インストールガイド-環境変数設定](https://docs.newapi.pro/installation/environment-variables)を参照してください:
|
||||||
|
|
||||||
|
- `GENERATE_DEFAULT_TOKEN`:新規登録ユーザーに初期トークンを生成するかどうか、デフォルトは`false`
|
||||||
|
- `STREAMING_TIMEOUT`:ストリーミング応答のタイムアウト時間、デフォルトは300秒
|
||||||
|
- `DIFY_DEBUG`:Difyチャネルがワークフローとノード情報を出力するかどうか、デフォルトは`true`
|
||||||
|
- `GET_MEDIA_TOKEN`:画像トークンを統計するかどうか、デフォルトは`true`
|
||||||
|
- `GET_MEDIA_TOKEN_NOT_STREAM`:非ストリーミングの場合に画像トークンを統計するかどうか、デフォルトは`true`
|
||||||
|
- `UPDATE_TASK`:非同期タスク(Midjourney、Suno)を更新するかどうか、デフォルトは`true`
|
||||||
|
- `GEMINI_VISION_MAX_IMAGE_NUM`:Geminiモデルの最大画像数、デフォルトは`16`
|
||||||
|
- `MAX_FILE_DOWNLOAD_MB`: 最大ファイルダウンロードサイズ、単位MB、デフォルトは`20`
|
||||||
|
- `CRYPTO_SECRET`:暗号化キー、Redisデータベースの内容を暗号化するために使用
|
||||||
|
- `AZURE_DEFAULT_API_VERSION`:Azureチャネルのデフォルトのバージョン、デフォルトは`2025-04-01-preview`
|
||||||
|
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:メールなどの通知制限の継続時間、デフォルトは`10`分
|
||||||
|
- `NOTIFY_LIMIT_COUNT`:指定された継続時間内のユーザー通知の最大数、デフォルトは`2`
|
||||||
|
- `ERROR_LOG_ENABLED=true`: エラーログを記録して表示するかどうか、デフォルトは`false`
|
||||||
|
|
||||||
|
## デプロイ
|
||||||
|
|
||||||
|
詳細なデプロイガイドについては[インストールガイド-デプロイ方法](https://docs.newapi.pro/installation)を参照してください:
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 最新のDockerイメージ:`calciumion/new-api:latest`
|
||||||
|
|
||||||
|
### マルチマシンデプロイの注意事項
|
||||||
|
- 環境変数`SESSION_SECRET`を設定する必要があります。そうしないとマルチマシンデプロイ時にログイン状態が不一致になります
|
||||||
|
- Redisを共有する場合、`CRYPTO_SECRET`を設定する必要があります。そうしないとマルチマシンデプロイ時にRedisの内容を取得できません
|
||||||
|
|
||||||
|
### デプロイ要件
|
||||||
|
- ローカルデータベース(デフォルト):SQLite(Dockerデプロイの場合は`/data`ディレクトリをマウントする必要があります)
|
||||||
|
- リモートデータベース:MySQLバージョン >= 5.7.8、PgSQLバージョン >= 9.6
|
||||||
|
|
||||||
|
### デプロイ方法
|
||||||
|
|
||||||
|
#### 宝塔パネルのDocker機能を使用してデプロイ
|
||||||
|
宝塔パネル(**9.2.0バージョン**以上)をインストールし、アプリケーションストアで**New-API**を見つけてインストールします。
|
||||||
|
[画像付きチュートリアル](./docs/BT.md)
|
||||||
|
|
||||||
|
#### Docker Composeを使用してデプロイ(推奨)
|
||||||
|
```shell
|
||||||
|
# プロジェクトをダウンロード
|
||||||
|
git clone https://github.com/Calcium-Ion/new-api.git
|
||||||
|
cd new-api
|
||||||
|
# 必要に応じてdocker-compose.ymlを編集
|
||||||
|
# 起動
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Dockerイメージを直接使用
|
||||||
|
```shell
|
||||||
|
# SQLiteを使用
|
||||||
|
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
||||||
|
|
||||||
|
# MySQLを使用
|
||||||
|
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## チャネルリトライとキャッシュ
|
||||||
|
チャネルリトライ機能はすでに実装されており、`設定->運営設定->一般設定->失敗リトライ回数`でリトライ回数を設定できます。**キャッシュ機能を有効にすることを推奨します**。
|
||||||
|
|
||||||
|
### キャッシュ設定方法
|
||||||
|
1. `REDIS_CONN_STRING`:Redisをキャッシュとして設定
|
||||||
|
2. `MEMORY_CACHE_ENABLED`:メモリキャッシュを有効にする(Redisを設定した場合は手動設定不要)
|
||||||
|
|
||||||
|
## APIドキュメント
|
||||||
|
|
||||||
|
詳細なAPIドキュメントについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください:
|
||||||
|
|
||||||
|
- [チャットインターフェース(Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||||
|
- [レスポンスインターフェース(Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||||
|
- [画像インターフェース(Image)](https://docs.newapi.pro/api/openai-image)
|
||||||
|
- [再ランク付けインターフェース(Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||||
|
- [リアルタイム対話インターフェース(Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||||
|
- [Claudeチャットインターフェース](https://docs.newapi.pro/api/anthropic-chat)
|
||||||
|
- [Google Geminiチャットインターフェース](https://docs.newapi.pro/api/google-gemini-chat)
|
||||||
|
|
||||||
|
## 関連プロジェクト
|
||||||
|
- [One API](https://github.com/songquanpeng/one-api):オリジナルプロジェクト
|
||||||
|
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy):Midjourneyインターフェースサポート
|
||||||
|
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):キーを使用して使用量クォータを照会
|
||||||
|
|
||||||
|
New APIベースのその他のプロジェクト:
|
||||||
|
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon):New API高性能最適化版
|
||||||
|
|
||||||
|
## ヘルプサポート
|
||||||
|
|
||||||
|
問題がある場合は、[ヘルプサポート](https://docs.newapi.pro/support)を参照してください:
|
||||||
|
- [コミュニティ交流](https://docs.newapi.pro/support/community-interaction)
|
||||||
|
- [問題のフィードバック](https://docs.newapi.pro/support/feedback-issues)
|
||||||
|
- [よくある質問](https://docs.newapi.pro/support/faq)
|
||||||
|
|
||||||
|
## 🌟 Star History
|
||||||
|
|
||||||
|
[](https://star-history.com/#Calcium-Ion/new-api&Date)
|
||||||
|
|
||||||
81
README.md
81
README.md
@@ -1,5 +1,5 @@
|
|||||||
<p align="right">
|
<p align="right">
|
||||||
<strong>中文</strong> | <a href="./README.en.md">English</a>
|
<strong>中文</strong> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a> | <a href="./README.ja.md">日本語</a>
|
||||||
</p>
|
</p>
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -40,6 +40,28 @@
|
|||||||
> - 使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。
|
> - 使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。
|
||||||
> - 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务。
|
> - 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务。
|
||||||
|
|
||||||
|
<h2>🤝 我们信任的合作伙伴</h2>
|
||||||
|
<p id="premium-sponsors"> </p>
|
||||||
|
<p align="center"><strong>排名不分先后</strong></p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.cherry-ai.com/" target=_blank><img
|
||||||
|
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://bda.pku.edu.cn/" target=_blank><img
|
||||||
|
src="./docs/images/pku.png" alt="北京大学" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
|
||||||
|
src="./docs/images/ucloud.png" alt="UCloud 优刻得" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://www.aliyun.com/" target=_blank><img
|
||||||
|
src="./docs/images/aliyun.png" alt="阿里云" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://io.net/" target=_blank><img
|
||||||
|
src="./docs/images/io-net.png" alt="IO.NET" height="120"
|
||||||
|
/></a>
|
||||||
|
</p>
|
||||||
|
<p> </p>
|
||||||
|
|
||||||
## 📚 文档
|
## 📚 文档
|
||||||
|
|
||||||
详细文档请访问我们的官方Wiki:[https://docs.newapi.pro/](https://docs.newapi.pro/)
|
详细文档请访问我们的官方Wiki:[https://docs.newapi.pro/](https://docs.newapi.pro/)
|
||||||
@@ -53,7 +75,7 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
|
|||||||
|
|
||||||
1. 🎨 全新的UI界面
|
1. 🎨 全新的UI界面
|
||||||
2. 🌍 多语言支持
|
2. 🌍 多语言支持
|
||||||
3. 💰 支持在线充值功能(易支付)
|
3. 💰 支持在线充值功能,当前支持易支付和Stripe
|
||||||
4. 🔍 支持用key查询使用额度(配合[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
4. 🔍 支持用key查询使用额度(配合[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||||
5. 🔄 兼容原版One API的数据库
|
5. 🔄 兼容原版One API的数据库
|
||||||
6. 💵 支持模型按次数收费
|
6. 💵 支持模型按次数收费
|
||||||
@@ -63,18 +85,23 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
|
|||||||
10. 🤖 支持更多授权登陆方式(LinuxDO,Telegram、OIDC)
|
10. 🤖 支持更多授权登陆方式(LinuxDO,Telegram、OIDC)
|
||||||
11. 🔄 支持Rerank模型(Cohere和Jina),[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
|
11. 🔄 支持Rerank模型(Cohere和Jina),[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
|
||||||
12. ⚡ 支持OpenAI Realtime API(包括Azure渠道),[接口文档](https://docs.newapi.pro/api/openai-realtime)
|
12. ⚡ 支持OpenAI Realtime API(包括Azure渠道),[接口文档](https://docs.newapi.pro/api/openai-realtime)
|
||||||
13. ⚡ 支持Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
|
13. ⚡ 支持 **OpenAI Responses** 格式,[接口文档](https://docs.newapi.pro/api/openai-responses)
|
||||||
14. 支持使用路由/chat2link进入聊天界面
|
14. ⚡ 支持 **Claude Messages** 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
|
||||||
15. 🧠 支持通过模型名称后缀设置 reasoning effort:
|
15. ⚡ 支持 **Google Gemini** 格式,[接口文档](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||||
|
16. 🧠 支持通过模型名称后缀设置 reasoning effort:
|
||||||
1. OpenAI o系列模型
|
1. OpenAI o系列模型
|
||||||
- 添加后缀 `-high` 设置为 high reasoning effort (例如: `o3-mini-high`)
|
- 添加后缀 `-high` 设置为 high reasoning effort (例如: `o3-mini-high`)
|
||||||
- 添加后缀 `-medium` 设置为 medium reasoning effort (例如: `o3-mini-medium`)
|
- 添加后缀 `-medium` 设置为 medium reasoning effort (例如: `o3-mini-medium`)
|
||||||
- 添加后缀 `-low` 设置为 low reasoning effort (例如: `o3-mini-low`)
|
- 添加后缀 `-low` 设置为 low reasoning effort (例如: `o3-mini-low`)
|
||||||
2. Claude 思考模型
|
2. Claude 思考模型
|
||||||
- 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`)
|
- 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`)
|
||||||
16. 🔄 思考转内容功能
|
17. 🔄 思考转内容功能
|
||||||
17. 🔄 针对用户的模型限流功能
|
18. 🔄 针对用户的模型限流功能
|
||||||
18. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
|
19. 🔄 请求格式转换功能,支持以下三种格式转换:
|
||||||
|
1. OpenAI Chat Completions => Claude Messages (OpenAI格式调用Claude模型)
|
||||||
|
2. Clade Messages => OpenAI Chat Completions (可用于Claude Code调用第三方模型)
|
||||||
|
3. OpenAI Chat Completions => Gemini Chat (OpenAI格式调用Gemini模型)
|
||||||
|
20. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
|
||||||
1. 在 `系统设置-运营设置` 中设置 `提示缓存倍率` 选项
|
1. 在 `系统设置-运营设置` 中设置 `提示缓存倍率` 选项
|
||||||
2. 在渠道中设置 `提示缓存倍率`,范围 0-1,例如设置为 0.5 表示缓存命中时按照 50% 计费
|
2. 在渠道中设置 `提示缓存倍率`,范围 0-1,例如设置为 0.5 表示缓存命中时按照 50% 计费
|
||||||
3. 支持的渠道:
|
3. 支持的渠道:
|
||||||
@@ -93,27 +120,28 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
|
|||||||
4. 自定义渠道,支持填入完整调用地址
|
4. 自定义渠道,支持填入完整调用地址
|
||||||
5. Rerank模型([Cohere](https://cohere.ai/)和[Jina](https://jina.ai/)),[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
|
5. Rerank模型([Cohere](https://cohere.ai/)和[Jina](https://jina.ai/)),[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
|
||||||
6. Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
|
6. Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
|
||||||
7. Dify,当前仅支持chatflow
|
7. Google Gemini格式,[接口文档](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||||
|
8. Dify,当前仅支持chatflow
|
||||||
|
9. 更多接口请参考[接口文档](https://docs.newapi.pro/api)
|
||||||
|
|
||||||
## 环境变量配置
|
## 环境变量配置
|
||||||
|
|
||||||
详细配置说明请参考[安装指南-环境变量配置](https://docs.newapi.pro/installation/environment-variables):
|
详细配置说明请参考[安装指南-环境变量配置](https://docs.newapi.pro/installation/environment-variables):
|
||||||
|
|
||||||
- `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false`
|
- `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false`
|
||||||
- `STREAMING_TIMEOUT`:流式回复超时时间,默认120秒
|
- `STREAMING_TIMEOUT`:流式回复超时时间,默认300秒
|
||||||
- `DIFY_DEBUG`:Dify渠道是否输出工作流和节点信息,默认 `true`
|
- `DIFY_DEBUG`:Dify渠道是否输出工作流和节点信息,默认 `true`
|
||||||
- `FORCE_STREAM_OPTION`:是否覆盖客户端stream_options参数,默认 `true`
|
|
||||||
- `GET_MEDIA_TOKEN`:是否统计图片token,默认 `true`
|
- `GET_MEDIA_TOKEN`:是否统计图片token,默认 `true`
|
||||||
- `GET_MEDIA_TOKEN_NOT_STREAM`:非流情况下是否统计图片token,默认 `true`
|
- `GET_MEDIA_TOKEN_NOT_STREAM`:非流情况下是否统计图片token,默认 `true`
|
||||||
- `UPDATE_TASK`:是否更新异步任务(Midjourney、Suno),默认 `true`
|
- `UPDATE_TASK`:是否更新异步任务(Midjourney、Suno),默认 `true`
|
||||||
- `COHERE_SAFETY_SETTING`:Cohere模型安全设置,可选值为 `NONE`, `CONTEXTUAL`, `STRICT`,默认 `NONE`
|
|
||||||
- `GEMINI_VISION_MAX_IMAGE_NUM`:Gemini模型最大图片数量,默认 `16`
|
- `GEMINI_VISION_MAX_IMAGE_NUM`:Gemini模型最大图片数量,默认 `16`
|
||||||
- `MAX_FILE_DOWNLOAD_MB`: 最大文件下载大小,单位MB,默认 `20`
|
- `MAX_FILE_DOWNLOAD_MB`: 最大文件下载大小,单位MB,默认 `20`
|
||||||
- `CRYPTO_SECRET`:加密密钥,用于加密数据库内容
|
- `CRYPTO_SECRET`:加密密钥,用于加密Redis数据库内容
|
||||||
- `AZURE_DEFAULT_API_VERSION`:Azure渠道默认API版本,默认 `2025-04-01-preview`
|
- `AZURE_DEFAULT_API_VERSION`:Azure渠道默认API版本,默认 `2025-04-01-preview`
|
||||||
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:通知限制持续时间,默认 `10`分钟
|
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:邮件等通知限制持续时间,默认 `10`分钟
|
||||||
- `NOTIFY_LIMIT_COUNT`:用户通知在指定持续时间内的最大数量,默认 `2`
|
- `NOTIFY_LIMIT_COUNT`:用户通知在指定持续时间内的最大数量,默认 `2`
|
||||||
- `ERROR_LOG_ENABLED=true`: 是否记录并显示错误日志,默认`false`
|
- `ERROR_LOG_ENABLED=true`: 是否记录并显示错误日志,默认`false`
|
||||||
|
- `TASK_PRICE_PATCH=sora-2-all,sora-2-pro-all`: 异步任务设置某些模型按次计费,多个模型用逗号分隔,例如`sora-2-all,sora-2-pro-all`,表示sora-2-all和sora-2-pro-all模型异步任务仅按次计费,不按秒等计费。
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
@@ -138,12 +166,18 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
|
|||||||
|
|
||||||
#### 使用Docker Compose部署(推荐)
|
#### 使用Docker Compose部署(推荐)
|
||||||
```shell
|
```shell
|
||||||
# 下载项目
|
# 下载项目源码
|
||||||
git clone https://github.com/Calcium-Ion/new-api.git
|
git clone https://github.com/QuantumNous/new-api.git
|
||||||
|
|
||||||
|
# 进入项目目录
|
||||||
cd new-api
|
cd new-api
|
||||||
# 按需编辑docker-compose.yml
|
|
||||||
# 启动
|
# 根据需要编辑 docker-compose.yml 文件
|
||||||
docker-compose up -d
|
# 使用nano编辑器
|
||||||
|
nano docker-compose.yml
|
||||||
|
# 或使用vim编辑器
|
||||||
|
# vim docker-compose.yml
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 直接使用Docker镜像
|
#### 直接使用Docker镜像
|
||||||
@@ -156,7 +190,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
|
|||||||
```
|
```
|
||||||
|
|
||||||
## 渠道重试与缓存
|
## 渠道重试与缓存
|
||||||
渠道重试功能已经实现,可以在`设置->运营设置->通用设置`设置重试次数,**建议开启缓存**功能。
|
渠道重试功能已经实现,可以在`设置->运营设置->通用设置->失败重试次数`设置重试次数,**建议开启缓存**功能。
|
||||||
|
|
||||||
### 缓存设置方法
|
### 缓存设置方法
|
||||||
1. `REDIS_CONN_STRING`:设置Redis作为缓存
|
1. `REDIS_CONN_STRING`:设置Redis作为缓存
|
||||||
@@ -166,16 +200,17 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
|
|||||||
|
|
||||||
详细接口文档请参考[接口文档](https://docs.newapi.pro/api):
|
详细接口文档请参考[接口文档](https://docs.newapi.pro/api):
|
||||||
|
|
||||||
- [聊天接口(Chat)](https://docs.newapi.pro/api/openai-chat)
|
- [聊天接口(Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||||
|
- [响应接口 (Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||||
- [图像接口(Image)](https://docs.newapi.pro/api/openai-image)
|
- [图像接口(Image)](https://docs.newapi.pro/api/openai-image)
|
||||||
- [重排序接口(Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
- [重排序接口(Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||||
- [实时对话接口(Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
- [实时对话接口(Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||||
- [Claude聊天接口(messages)](https://docs.newapi.pro/api/anthropic-chat)
|
- [Claude聊天接口](https://docs.newapi.pro/api/anthropic-chat)
|
||||||
|
- [Google Gemini聊天接口](https://docs.newapi.pro/api/google-gemini-chat)
|
||||||
|
|
||||||
## 相关项目
|
## 相关项目
|
||||||
- [One API](https://github.com/songquanpeng/one-api):原版项目
|
- [One API](https://github.com/songquanpeng/one-api):原版项目
|
||||||
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy):Midjourney接口支持
|
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy):Midjourney接口支持
|
||||||
- [chatnio](https://github.com/Deeptrain-Community/chatnio):下一代AI一站式B/C端解决方案
|
|
||||||
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):用key查询使用额度
|
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):用key查询使用额度
|
||||||
|
|
||||||
其他基于New API的项目:
|
其他基于New API的项目:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import "one-api/constant"
|
import "github.com/QuantumNous/new-api/constant"
|
||||||
|
|
||||||
func ChannelType2APIType(channelType int) (int, bool) {
|
func ChannelType2APIType(channelType int) (int, bool) {
|
||||||
apiType := -1
|
apiType := -1
|
||||||
@@ -63,6 +63,14 @@ func ChannelType2APIType(channelType int) (int, bool) {
|
|||||||
apiType = constant.APITypeXai
|
apiType = constant.APITypeXai
|
||||||
case constant.ChannelTypeCoze:
|
case constant.ChannelTypeCoze:
|
||||||
apiType = constant.APITypeCoze
|
apiType = constant.APITypeCoze
|
||||||
|
case constant.ChannelTypeJimeng:
|
||||||
|
apiType = constant.APITypeJimeng
|
||||||
|
case constant.ChannelTypeMoonshot:
|
||||||
|
apiType = constant.APITypeMoonshot
|
||||||
|
case constant.ChannelTypeSubmodel:
|
||||||
|
apiType = constant.APITypeSubmodel
|
||||||
|
case constant.ChannelTypeMiniMax:
|
||||||
|
apiType = constant.APITypeMiniMax
|
||||||
}
|
}
|
||||||
if apiType == -1 {
|
if apiType == -1 {
|
||||||
return constant.APITypeOpenAI, false
|
return constant.APITypeOpenAI, false
|
||||||
|
|||||||
296
common/audio.go
Normal file
296
common/audio.go
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/abema/go-mp4"
|
||||||
|
"github.com/go-audio/aiff"
|
||||||
|
"github.com/go-audio/wav"
|
||||||
|
"github.com/jfreymuth/oggvorbis"
|
||||||
|
"github.com/mewkiz/flac"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/tcolgate/mp3"
|
||||||
|
"github.com/yapingcat/gomedia/go-codec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetAudioDuration 使用纯 Go 库获取音频文件的时长(秒)。
|
||||||
|
// 它不再依赖外部的 ffmpeg 或 ffprobe 程序。
|
||||||
|
func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duration float64, err error) {
|
||||||
|
SysLog(fmt.Sprintf("GetAudioDuration: ext=%s", ext))
|
||||||
|
// 根据文件扩展名选择解析器
|
||||||
|
switch ext {
|
||||||
|
case ".mp3":
|
||||||
|
duration, err = getMP3Duration(f)
|
||||||
|
case ".wav":
|
||||||
|
duration, err = getWAVDuration(f)
|
||||||
|
case ".flac":
|
||||||
|
duration, err = getFLACDuration(f)
|
||||||
|
case ".m4a", ".mp4":
|
||||||
|
duration, err = getM4ADuration(f)
|
||||||
|
case ".ogg", ".oga", ".opus":
|
||||||
|
duration, err = getOGGDuration(f)
|
||||||
|
if err != nil {
|
||||||
|
duration, err = getOpusDuration(f)
|
||||||
|
}
|
||||||
|
case ".aiff", ".aif", ".aifc":
|
||||||
|
duration, err = getAIFFDuration(f)
|
||||||
|
case ".webm":
|
||||||
|
duration, err = getWebMDuration(f)
|
||||||
|
case ".aac":
|
||||||
|
duration, err = getAACDuration(f)
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unsupported audio format: %s", ext)
|
||||||
|
}
|
||||||
|
SysLog(fmt.Sprintf("GetAudioDuration: duration=%f", duration))
|
||||||
|
return duration, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMP3Duration 解析 MP3 文件以获取时长。
|
||||||
|
// 注意:对于 VBR (Variable Bitrate) MP3,这个估算可能不完全精确,但通常足够好。
|
||||||
|
// FFmpeg 在这种情况下会扫描整个文件来获得精确值,但这里的库提供了快速估算。
|
||||||
|
func getMP3Duration(r io.Reader) (float64, error) {
|
||||||
|
d := mp3.NewDecoder(r)
|
||||||
|
var f mp3.Frame
|
||||||
|
skipped := 0
|
||||||
|
duration := 0.0
|
||||||
|
|
||||||
|
for {
|
||||||
|
if err := d.Decode(&f, &skipped); err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return 0, errors.Wrap(err, "failed to decode mp3 frame")
|
||||||
|
}
|
||||||
|
duration += f.Duration().Seconds()
|
||||||
|
}
|
||||||
|
return duration, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getWAVDuration 解析 WAV 文件头以获取时长。
|
||||||
|
func getWAVDuration(r io.ReadSeeker) (float64, error) {
|
||||||
|
dec := wav.NewDecoder(r)
|
||||||
|
if !dec.IsValidFile() {
|
||||||
|
return 0, errors.New("invalid wav file")
|
||||||
|
}
|
||||||
|
d, err := dec.Duration()
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "failed to get wav duration")
|
||||||
|
}
|
||||||
|
return d.Seconds(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFLACDuration 解析 FLAC 文件的 STREAMINFO 块。
|
||||||
|
func getFLACDuration(r io.Reader) (float64, error) {
|
||||||
|
stream, err := flac.Parse(r)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "failed to parse flac stream")
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
// 时长 = 总采样数 / 采样率
|
||||||
|
duration := float64(stream.Info.NSamples) / float64(stream.Info.SampleRate)
|
||||||
|
return duration, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getM4ADuration 解析 M4A/MP4 文件的 'mvhd' box。
|
||||||
|
func getM4ADuration(r io.ReadSeeker) (float64, error) {
|
||||||
|
// go-mp4 库需要 ReadSeeker 接口
|
||||||
|
info, err := mp4.Probe(r)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "failed to probe m4a/mp4 file")
|
||||||
|
}
|
||||||
|
// 时长 = Duration / Timescale
|
||||||
|
return float64(info.Duration) / float64(info.Timescale), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOGGDuration 解析 OGG/Vorbis 文件以获取时长。
|
||||||
|
func getOGGDuration(r io.ReadSeeker) (float64, error) {
|
||||||
|
// 重置 reader 到开头
|
||||||
|
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return 0, errors.Wrap(err, "failed to seek ogg file")
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := oggvorbis.NewReader(r)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "failed to create ogg vorbis reader")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算时长 = 总采样数 / 采样率
|
||||||
|
// 需要读取整个文件来获取总采样数
|
||||||
|
channels := reader.Channels()
|
||||||
|
sampleRate := reader.SampleRate()
|
||||||
|
|
||||||
|
// 估算方法:读取到文件结尾
|
||||||
|
var totalSamples int64
|
||||||
|
buf := make([]float32, 4096*channels)
|
||||||
|
for {
|
||||||
|
n, err := reader.Read(buf)
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "failed to read ogg samples")
|
||||||
|
}
|
||||||
|
totalSamples += int64(n / channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := float64(totalSamples) / float64(sampleRate)
|
||||||
|
return duration, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOpusDuration 解析 Opus 文件(在 OGG 容器中)以获取时长。
|
||||||
|
func getOpusDuration(r io.ReadSeeker) (float64, error) {
|
||||||
|
// Opus 通常封装在 OGG 容器中
|
||||||
|
// 我们需要解析 OGG 页面来获取时长信息
|
||||||
|
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return 0, errors.Wrap(err, "failed to seek opus file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 OGG 页面头部
|
||||||
|
var totalGranulePos int64
|
||||||
|
buf := make([]byte, 27) // OGG 页面头部最小大小
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, err := r.Read(buf)
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "failed to read opus/ogg page")
|
||||||
|
}
|
||||||
|
if n < 27 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 OGG 页面标识 "OggS"
|
||||||
|
if string(buf[0:4]) != "OggS" {
|
||||||
|
// 跳过一些字节继续寻找
|
||||||
|
if _, err := r.Seek(-26, io.SeekCurrent); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 granule position (字节 6-13, 小端序)
|
||||||
|
granulePos := int64(binary.LittleEndian.Uint64(buf[6:14]))
|
||||||
|
if granulePos > totalGranulePos {
|
||||||
|
totalGranulePos = granulePos
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取段表大小
|
||||||
|
numSegments := int(buf[26])
|
||||||
|
segmentTable := make([]byte, numSegments)
|
||||||
|
if _, err := io.ReadFull(r, segmentTable); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算页面数据大小并跳过
|
||||||
|
var pageSize int
|
||||||
|
for _, segSize := range segmentTable {
|
||||||
|
pageSize += int(segSize)
|
||||||
|
}
|
||||||
|
if _, err := r.Seek(int64(pageSize), io.SeekCurrent); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opus 的采样率固定为 48000 Hz
|
||||||
|
duration := float64(totalGranulePos) / 48000.0
|
||||||
|
return duration, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAIFFDuration 解析 AIFF 文件头以获取时长。
|
||||||
|
func getAIFFDuration(r io.ReadSeeker) (float64, error) {
|
||||||
|
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return 0, errors.Wrap(err, "failed to seek aiff file")
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := aiff.NewDecoder(r)
|
||||||
|
if !dec.IsValidFile() {
|
||||||
|
return 0, errors.New("invalid aiff file")
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := dec.Duration()
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "failed to get aiff duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.Seconds(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getWebMDuration 解析 WebM 文件以获取时长。
|
||||||
|
// WebM 使用 Matroska 容器格式
|
||||||
|
func getWebMDuration(r io.ReadSeeker) (float64, error) {
|
||||||
|
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return 0, errors.Wrap(err, "failed to seek webm file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebM/Matroska 文件的解析比较复杂
|
||||||
|
// 这里提供一个简化的实现,读取 EBML 头部
|
||||||
|
// 对于完整的 WebM 解析,可能需要使用专门的库
|
||||||
|
|
||||||
|
// 简单实现:查找 Duration 元素
|
||||||
|
// WebM Duration 的 Element ID 是 0x4489
|
||||||
|
// 这是一个简化版本,可能不适用于所有 WebM 文件
|
||||||
|
buf := make([]byte, 8192)
|
||||||
|
n, err := r.Read(buf)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return 0, errors.Wrap(err, "failed to read webm file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试查找 Duration 元素(这是一个简化的方法)
|
||||||
|
// 实际的 WebM 解析需要完整的 EBML 解析器
|
||||||
|
// 这里返回错误,建议使用专门的库
|
||||||
|
if n > 0 {
|
||||||
|
// 检查 EBML 标识
|
||||||
|
if len(buf) >= 4 && binary.BigEndian.Uint32(buf[0:4]) == 0x1A45DFA3 {
|
||||||
|
// 这是一个有效的 EBML 文件
|
||||||
|
// 但完整解析需要更复杂的逻辑
|
||||||
|
return 0, errors.New("webm duration parsing requires full EBML parser (consider using ffprobe for webm files)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, errors.New("failed to parse webm file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAACDuration 解析 AAC (ADTS格式) 文件以获取时长。
|
||||||
|
// 使用 gomedia 库来解析 AAC ADTS 帧
|
||||||
|
func getAACDuration(r io.ReadSeeker) (float64, error) {
|
||||||
|
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return 0, errors.Wrap(err, "failed to seek aac file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取整个文件内容
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "failed to read aac file")
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalFrames int64
|
||||||
|
var sampleRate int
|
||||||
|
|
||||||
|
// 使用 gomedia 的 SplitAACFrame 函数来分割 AAC 帧
|
||||||
|
codec.SplitAACFrame(data, func(aac []byte) {
|
||||||
|
// 解析 ADTS 头部以获取采样率信息
|
||||||
|
if len(aac) >= 7 {
|
||||||
|
// 使用 ConvertADTSToASC 来获取音频配置信息
|
||||||
|
asc, err := codec.ConvertADTSToASC(aac)
|
||||||
|
if err == nil && sampleRate == 0 {
|
||||||
|
sampleRate = codec.AACSampleIdxToSample(int(asc.Sample_freq_index))
|
||||||
|
}
|
||||||
|
totalFrames++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if sampleRate == 0 || totalFrames == 0 {
|
||||||
|
return 0, errors.New("no valid aac frames found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每个 AAC ADTS 帧包含 1024 个采样
|
||||||
|
totalSamples := totalFrames * 1024
|
||||||
|
duration := float64(totalSamples) / float64(sampleRate)
|
||||||
|
return duration, nil
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ var TopUpLink = ""
|
|||||||
// var ChatLink = ""
|
// var ChatLink = ""
|
||||||
// var ChatLink2 = ""
|
// var ChatLink2 = ""
|
||||||
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
|
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
|
||||||
|
// 保留旧变量以兼容历史逻辑,实际展示由 general_setting.quota_display_type 控制
|
||||||
var DisplayInCurrencyEnabled = true
|
var DisplayInCurrencyEnabled = true
|
||||||
var DisplayTokenStatEnabled = true
|
var DisplayTokenStatEnabled = true
|
||||||
var DrawingEnabled = true
|
var DrawingEnabled = true
|
||||||
@@ -83,6 +84,7 @@ var GitHubClientId = ""
|
|||||||
var GitHubClientSecret = ""
|
var GitHubClientSecret = ""
|
||||||
var LinuxDOClientId = ""
|
var LinuxDOClientId = ""
|
||||||
var LinuxDOClientSecret = ""
|
var LinuxDOClientSecret = ""
|
||||||
|
var LinuxDOMinimumTrustLevel = 0
|
||||||
|
|
||||||
var WeChatServerAddress = ""
|
var WeChatServerAddress = ""
|
||||||
var WeChatServerToken = ""
|
var WeChatServerToken = ""
|
||||||
@@ -157,14 +159,15 @@ var (
|
|||||||
GlobalWebRateLimitNum int
|
GlobalWebRateLimitNum int
|
||||||
GlobalWebRateLimitDuration int64
|
GlobalWebRateLimitDuration int64
|
||||||
|
|
||||||
|
CriticalRateLimitEnable bool
|
||||||
|
CriticalRateLimitNum = 20
|
||||||
|
CriticalRateLimitDuration int64 = 20 * 60
|
||||||
|
|
||||||
UploadRateLimitNum = 10
|
UploadRateLimitNum = 10
|
||||||
UploadRateLimitDuration int64 = 60
|
UploadRateLimitDuration int64 = 60
|
||||||
|
|
||||||
DownloadRateLimitNum = 10
|
DownloadRateLimitNum = 10
|
||||||
DownloadRateLimitDuration int64 = 60
|
DownloadRateLimitDuration int64 = 60
|
||||||
|
|
||||||
CriticalRateLimitNum = 20
|
|
||||||
CriticalRateLimitDuration int64 = 20 * 60
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var RateLimitKeyExpirationDuration = 20 * time.Minute
|
var RateLimitKeyExpirationDuration = 20 * time.Minute
|
||||||
@@ -193,3 +196,9 @@ const (
|
|||||||
ChannelStatusManuallyDisabled = 2 // also don't use 0
|
ChannelStatusManuallyDisabled = 2 // also don't use 0
|
||||||
ChannelStatusAutoDisabled = 3
|
ChannelStatusAutoDisabled = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TopUpStatusPending = "pending"
|
||||||
|
TopUpStatusSuccess = "success"
|
||||||
|
TopUpStatusExpired = "expired"
|
||||||
|
)
|
||||||
|
|||||||
19
common/copy.go
Normal file
19
common/copy.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jinzhu/copier"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DeepCopy[T any](src *T) (*T, error) {
|
||||||
|
if src == nil {
|
||||||
|
return nil, fmt.Errorf("copy source cannot be nil")
|
||||||
|
}
|
||||||
|
var dst T
|
||||||
|
err := copier.CopyWithOption(&dst, src, copier.Option{DeepCopy: true, IgnoreEmpty: true})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &dst, nil
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stringWriter interface {
|
type stringWriter interface {
|
||||||
@@ -52,6 +53,8 @@ type CustomEvent struct {
|
|||||||
Id string
|
Id string
|
||||||
Retry uint
|
Retry uint
|
||||||
Data interface{}
|
Data interface{}
|
||||||
|
|
||||||
|
Mutex sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode(writer io.Writer, event CustomEvent) error {
|
func encode(writer io.Writer, event CustomEvent) error {
|
||||||
@@ -73,6 +76,8 @@ func (r CustomEvent) Render(w http.ResponseWriter) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r CustomEvent) WriteContentType(w http.ResponseWriter) {
|
func (r CustomEvent) WriteContentType(w http.ResponseWriter) {
|
||||||
|
r.Mutex.Lock()
|
||||||
|
defer r.Mutex.Unlock()
|
||||||
header := w.Header()
|
header := w.Header()
|
||||||
header["Content-Type"] = contentType
|
header["Content-Type"] = contentType
|
||||||
|
|
||||||
|
|||||||
@@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
|
|||||||
var UsingMySQL = false
|
var UsingMySQL = false
|
||||||
var UsingClickHouse = false
|
var UsingClickHouse = false
|
||||||
|
|
||||||
var SQLitePath = "one-api.db?_busy_timeout=5000"
|
var SQLitePath = "one-api.db?_busy_timeout=30000"
|
||||||
|
|||||||
@@ -86,5 +86,8 @@ func SendEmail(subject string, receiver string, content string) error {
|
|||||||
} else {
|
} else {
|
||||||
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
|
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
SysError(fmt.Sprintf("failed to send email to %s: %v", receiver, err))
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ package common
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"github.com/gin-contrib/static"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/static"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Credit: https://github.com/gin-contrib/static/issues/19
|
// Credit: https://github.com/gin-contrib/static/issues/19
|
||||||
|
|||||||
33
common/endpoint_defaults.go
Normal file
33
common/endpoint_defaults.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import "github.com/QuantumNous/new-api/constant"
|
||||||
|
|
||||||
|
// EndpointInfo 描述单个端点的默认请求信息
|
||||||
|
// path: 上游路径
|
||||||
|
// method: HTTP 请求方式,例如 POST/GET
|
||||||
|
// 目前均为 POST,后续可扩展
|
||||||
|
//
|
||||||
|
// json 标签用于直接序列化到 API 输出
|
||||||
|
// 例如:{"path":"/v1/chat/completions","method":"POST"}
|
||||||
|
|
||||||
|
type EndpointInfo struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method
|
||||||
|
var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
|
||||||
|
constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"},
|
||||||
|
constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"},
|
||||||
|
constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
|
||||||
|
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
|
||||||
|
constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"},
|
||||||
|
constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
|
||||||
|
constant.EndpointTypeEmbeddings: {Path: "/v1/embeddings", Method: "POST"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在
|
||||||
|
func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) {
|
||||||
|
info, ok := defaultEndpointInfoMap[et]
|
||||||
|
return info, ok
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import "one-api/constant"
|
import "github.com/QuantumNous/new-api/constant"
|
||||||
|
|
||||||
// GetEndpointTypesByChannelType 获取渠道最优先端点类型(所有的渠道都支持 OpenAI 端点)
|
// GetEndpointTypesByChannelType 获取渠道最优先端点类型(所有的渠道都支持 OpenAI 端点)
|
||||||
func GetEndpointTypesByChannelType(channelType int, modelName string) []constant.EndpointType {
|
func GetEndpointTypesByChannelType(channelType int, modelName string) []constant.EndpointType {
|
||||||
@@ -26,6 +26,8 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant
|
|||||||
endpointTypes = []constant.EndpointType{constant.EndpointTypeGemini, constant.EndpointTypeOpenAI}
|
endpointTypes = []constant.EndpointType{constant.EndpointTypeGemini, constant.EndpointTypeOpenAI}
|
||||||
case constant.ChannelTypeOpenRouter: // OpenRouter 只支持 OpenAI 端点
|
case constant.ChannelTypeOpenRouter: // OpenRouter 只支持 OpenAI 端点
|
||||||
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
|
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
|
||||||
|
case constant.ChannelTypeSora:
|
||||||
|
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
|
||||||
default:
|
default:
|
||||||
if IsOpenAIResponseOnlyModel(modelName) {
|
if IsOpenAIResponseOnlyModel(modelName) {
|
||||||
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIResponse}
|
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIResponse}
|
||||||
|
|||||||
123
common/gin.go
123
common/gin.go
@@ -2,11 +2,16 @@ package common
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"io"
|
"io"
|
||||||
"one-api/constant"
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
const KeyRequestBody = "key_request_body"
|
const KeyRequestBody = "key_request_body"
|
||||||
@@ -30,9 +35,16 @@ func UnmarshalBodyReusable(c *gin.Context, v any) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
//if DebugEnabled {
|
||||||
|
// println("UnmarshalBodyReusable request body:", string(requestBody))
|
||||||
|
//}
|
||||||
contentType := c.Request.Header.Get("Content-Type")
|
contentType := c.Request.Header.Get("Content-Type")
|
||||||
if strings.HasPrefix(contentType, "application/json") {
|
if strings.HasPrefix(contentType, "application/json") {
|
||||||
err = Unmarshal(requestBody, &v)
|
err = Unmarshal(requestBody, v)
|
||||||
|
} else if strings.Contains(contentType, gin.MIMEPOSTForm) {
|
||||||
|
err = parseFormData(requestBody, v)
|
||||||
|
} else if strings.Contains(contentType, gin.MIMEMultipartPOSTForm) {
|
||||||
|
err = parseMultipartFormData(c, requestBody, v)
|
||||||
} else {
|
} else {
|
||||||
// skip for now
|
// skip for now
|
||||||
// TODO: someday non json request have variant model, we will need to implementation this
|
// TODO: someday non json request have variant model, we will need to implementation this
|
||||||
@@ -86,3 +98,108 @@ func GetContextKeyType[T any](c *gin.Context, key constant.ContextKey) (T, bool)
|
|||||||
var t T
|
var t T
|
||||||
return t, false
|
return t, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ApiError(c *gin.Context, err error) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApiErrorMsg(c *gin.Context, msg string) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApiSuccess(c *gin.Context, data any) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseMultipartFormReusable(c *gin.Context) (*multipart.Form, error) {
|
||||||
|
requestBody, err := GetRequestBody(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := c.Request.Header.Get("Content-Type")
|
||||||
|
boundary := ""
|
||||||
|
if idx := strings.Index(contentType, "boundary="); idx != -1 {
|
||||||
|
boundary = contentType[idx+9:]
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := multipart.NewReader(bytes.NewReader(requestBody), boundary)
|
||||||
|
form, err := reader.ReadForm(32 << 20) // 32 MB max memory
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset request body
|
||||||
|
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
||||||
|
return form, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processFormMap(formMap map[string]any, v any) error {
|
||||||
|
jsonData, err := Marshal(formMap)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = Unmarshal(jsonData, v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFormData(data []byte, v any) error {
|
||||||
|
values, err := url.ParseQuery(string(data))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
formMap := make(map[string]any)
|
||||||
|
for key, vals := range values {
|
||||||
|
if len(vals) == 1 {
|
||||||
|
formMap[key] = vals[0]
|
||||||
|
} else {
|
||||||
|
formMap[key] = vals
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return processFormMap(formMap, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMultipartFormData(c *gin.Context, data []byte, v any) error {
|
||||||
|
contentType := c.Request.Header.Get("Content-Type")
|
||||||
|
boundary := ""
|
||||||
|
if idx := strings.Index(contentType, "boundary="); idx != -1 {
|
||||||
|
boundary = contentType[idx+9:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if boundary == "" {
|
||||||
|
return Unmarshal(data, v) // Fallback to JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := multipart.NewReader(bytes.NewReader(data), boundary)
|
||||||
|
form, err := reader.ReadForm(32 << 20) // 32 MB max memory
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer form.RemoveAll()
|
||||||
|
formMap := make(map[string]any)
|
||||||
|
for key, vals := range form.Value {
|
||||||
|
if len(vals) == 1 {
|
||||||
|
formMap[key] = vals[0]
|
||||||
|
} else {
|
||||||
|
formMap[key] = vals
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return processFormMap(formMap, v)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ package common
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/bytedance/gopkg/util/gopool"
|
|
||||||
"math"
|
"math"
|
||||||
|
|
||||||
|
"github.com/bytedance/gopkg/util/gopool"
|
||||||
)
|
)
|
||||||
|
|
||||||
var relayGoPool gopool.Pool
|
var relayGoPool gopool.Pool
|
||||||
|
|||||||
34
common/hash.go
Normal file
34
common/hash.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Sha256Raw(data []byte) []byte {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write(data)
|
||||||
|
return h.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Sha1Raw(data []byte) []byte {
|
||||||
|
h := sha1.New()
|
||||||
|
h.Write(data)
|
||||||
|
return h.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Sha1(data []byte) string {
|
||||||
|
return hex.EncodeToString(Sha1Raw(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func HmacSha256Raw(message, key []byte) []byte {
|
||||||
|
h := hmac.New(sha256.New, key)
|
||||||
|
h.Write(message)
|
||||||
|
return h.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HmacSha256(message, key string) string {
|
||||||
|
return hex.EncodeToString(HmacSha256Raw([]byte(message), []byte(key)))
|
||||||
|
}
|
||||||
@@ -3,12 +3,14 @@ package common
|
|||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"one-api/constant"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -19,10 +21,10 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func printHelp() {
|
func printHelp() {
|
||||||
fmt.Println("New API " + Version + " - All in one API service for OpenAI API.")
|
fmt.Println("NewAPI(Based OneAPI) " + Version + " - The next-generation LLM gateway and AI asset management system supports multiple languages.")
|
||||||
fmt.Println("Copyright (C) 2023 JustSong. All rights reserved.")
|
fmt.Println("Original Project: OneAPI by JustSong - https://github.com/songquanpeng/one-api")
|
||||||
fmt.Println("GitHub: https://github.com/songquanpeng/one-api")
|
fmt.Println("Maintainer: QuantumNous - https://github.com/QuantumNous/new-api")
|
||||||
fmt.Println("Usage: one-api [--port <port>] [--log-dir <log directory>] [--version] [--help]")
|
fmt.Println("Usage: newapi [--port <port>] [--log-dir <log directory>] [--version] [--help]")
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitEnv() {
|
func InitEnv() {
|
||||||
@@ -41,9 +43,10 @@ func InitEnv() {
|
|||||||
if os.Getenv("SESSION_SECRET") != "" {
|
if os.Getenv("SESSION_SECRET") != "" {
|
||||||
ss := os.Getenv("SESSION_SECRET")
|
ss := os.Getenv("SESSION_SECRET")
|
||||||
if ss == "random_string" {
|
if ss == "random_string" {
|
||||||
log.Println("WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string.")
|
slog.Warn("SESSION_SECRET is set to the default value 'random_string', please change it to a random string.")
|
||||||
log.Println("警告:SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。")
|
slog.Warn("警告:SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。")
|
||||||
log.Fatal("Please set SESSION_SECRET to a random string.")
|
slog.Error("Please set SESSION_SECRET to a random string.")
|
||||||
|
os.Exit(1)
|
||||||
} else {
|
} else {
|
||||||
SessionSecret = ss
|
SessionSecret = ss
|
||||||
}
|
}
|
||||||
@@ -60,12 +63,14 @@ func InitEnv() {
|
|||||||
var err error
|
var err error
|
||||||
*LogDir, err = filepath.Abs(*LogDir)
|
*LogDir, err = filepath.Abs(*LogDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
slog.Error("failed to get absolute path for log directory", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(*LogDir); os.IsNotExist(err) {
|
if _, err := os.Stat(*LogDir); os.IsNotExist(err) {
|
||||||
err = os.Mkdir(*LogDir, 0777)
|
err = os.Mkdir(*LogDir, 0777)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
slog.Error("failed to create log directory", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,11 +102,14 @@ func InitEnv() {
|
|||||||
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 60)
|
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 60)
|
||||||
GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
|
GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
|
||||||
|
|
||||||
|
CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
|
||||||
|
CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20)
|
||||||
|
CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60))
|
||||||
initConstantEnv()
|
initConstantEnv()
|
||||||
}
|
}
|
||||||
|
|
||||||
func initConstantEnv() {
|
func initConstantEnv() {
|
||||||
constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 120)
|
constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300)
|
||||||
constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
|
constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
|
||||||
constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20)
|
constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20)
|
||||||
// ForceStreamOption 覆盖请求参数,强制返回usage信息
|
// ForceStreamOption 覆盖请求参数,强制返回usage信息
|
||||||
@@ -117,4 +125,17 @@ func initConstantEnv() {
|
|||||||
constant.GenerateDefaultToken = GetEnvOrDefaultBool("GENERATE_DEFAULT_TOKEN", false)
|
constant.GenerateDefaultToken = GetEnvOrDefaultBool("GENERATE_DEFAULT_TOKEN", false)
|
||||||
// 是否启用错误日志
|
// 是否启用错误日志
|
||||||
constant.ErrorLogEnabled = GetEnvOrDefaultBool("ERROR_LOG_ENABLED", false)
|
constant.ErrorLogEnabled = GetEnvOrDefaultBool("ERROR_LOG_ENABLED", false)
|
||||||
|
|
||||||
|
soraPatchStr := GetEnvOrDefaultString("TASK_PRICE_PATCH", "")
|
||||||
|
if soraPatchStr != "" {
|
||||||
|
var taskPricePatches []string
|
||||||
|
soraPatches := strings.Split(soraPatchStr, ",")
|
||||||
|
for _, patch := range soraPatches {
|
||||||
|
trimmedPatch := strings.TrimSpace(patch)
|
||||||
|
if trimmedPatch != "" {
|
||||||
|
taskPricePatches = append(taskPricePatches, trimmedPatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
constant.TaskPricePatches = taskPricePatches
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
common/ip.go
Normal file
22
common/ip.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import "net"
|
||||||
|
|
||||||
|
func IsPrivateIP(ip net.IP) bool {
|
||||||
|
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private := []net.IPNet{
|
||||||
|
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)},
|
||||||
|
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)},
|
||||||
|
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, privateNet := range private {
|
||||||
|
if privateNet.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package common
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Unmarshal(data []byte, v any) error {
|
func Unmarshal(data []byte, v any) error {
|
||||||
@@ -13,10 +14,32 @@ func UnmarshalJsonStr(data string, v any) error {
|
|||||||
return json.Unmarshal(StringToByteSlice(data), v)
|
return json.Unmarshal(StringToByteSlice(data), v)
|
||||||
}
|
}
|
||||||
|
|
||||||
func DecodeJson(reader *bytes.Reader, v any) error {
|
func DecodeJson(reader io.Reader, v any) error {
|
||||||
return json.NewDecoder(reader).Decode(v)
|
return json.NewDecoder(reader).Decode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Marshal(v any) ([]byte, error) {
|
func Marshal(v any) ([]byte, error) {
|
||||||
return json.Marshal(v)
|
return json.Marshal(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetJsonType(data json.RawMessage) string {
|
||||||
|
data = bytes.TrimSpace(data)
|
||||||
|
if len(data) == 0 {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
firstChar := bytes.TrimSpace(data)[0]
|
||||||
|
switch firstChar {
|
||||||
|
case '{':
|
||||||
|
return "object"
|
||||||
|
case '[':
|
||||||
|
return "array"
|
||||||
|
case '"':
|
||||||
|
return "string"
|
||||||
|
case 't', 'f':
|
||||||
|
return "boolean"
|
||||||
|
case 'n':
|
||||||
|
return "null"
|
||||||
|
default:
|
||||||
|
return "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/go-redis/redis/v8"
|
|
||||||
"one-api/common"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed lua/rate_limit.lua
|
//go:embed lua/rate_limit.lua
|
||||||
|
|||||||
120
common/logger.go
120
common/logger.go
@@ -1,120 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"github.com/bytedance/gopkg/util/gopool"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
loggerINFO = "INFO"
|
|
||||||
loggerWarn = "WARN"
|
|
||||||
loggerError = "ERR"
|
|
||||||
)
|
|
||||||
|
|
||||||
const maxLogCount = 1000000
|
|
||||||
|
|
||||||
var logCount int
|
|
||||||
var setupLogLock sync.Mutex
|
|
||||||
var setupLogWorking bool
|
|
||||||
|
|
||||||
func SetupLogger() {
|
|
||||||
if *LogDir != "" {
|
|
||||||
ok := setupLogLock.TryLock()
|
|
||||||
if !ok {
|
|
||||||
log.Println("setup log is already working")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
setupLogLock.Unlock()
|
|
||||||
setupLogWorking = false
|
|
||||||
}()
|
|
||||||
logPath := filepath.Join(*LogDir, fmt.Sprintf("oneapi-%s.log", time.Now().Format("20060102150405")))
|
|
||||||
fd, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("failed to open log file")
|
|
||||||
}
|
|
||||||
gin.DefaultWriter = io.MultiWriter(os.Stdout, fd)
|
|
||||||
gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, fd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func SysLog(s string) {
|
|
||||||
t := time.Now()
|
|
||||||
_, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func SysError(s string) {
|
|
||||||
t := time.Now()
|
|
||||||
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func LogInfo(ctx context.Context, msg string) {
|
|
||||||
logHelper(ctx, loggerINFO, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func LogWarn(ctx context.Context, msg string) {
|
|
||||||
logHelper(ctx, loggerWarn, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func LogError(ctx context.Context, msg string) {
|
|
||||||
logHelper(ctx, loggerError, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func logHelper(ctx context.Context, level string, msg string) {
|
|
||||||
writer := gin.DefaultErrorWriter
|
|
||||||
if level == loggerINFO {
|
|
||||||
writer = gin.DefaultWriter
|
|
||||||
}
|
|
||||||
id := ctx.Value(RequestIdKey)
|
|
||||||
now := time.Now()
|
|
||||||
_, _ = fmt.Fprintf(writer, "[%s] %v | %s | %s \n", level, now.Format("2006/01/02 - 15:04:05"), id, msg)
|
|
||||||
logCount++ // we don't need accurate count, so no lock here
|
|
||||||
if logCount > maxLogCount && !setupLogWorking {
|
|
||||||
logCount = 0
|
|
||||||
setupLogWorking = true
|
|
||||||
gopool.Go(func() {
|
|
||||||
SetupLogger()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func FatalLog(v ...any) {
|
|
||||||
t := time.Now()
|
|
||||||
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func LogQuota(quota int) string {
|
|
||||||
if DisplayInCurrencyEnabled {
|
|
||||||
return fmt.Sprintf("$%.6f 额度", float64(quota)/QuotaPerUnit)
|
|
||||||
} else {
|
|
||||||
return fmt.Sprintf("%d 点额度", quota)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func FormatQuota(quota int) string {
|
|
||||||
if DisplayInCurrencyEnabled {
|
|
||||||
return fmt.Sprintf("$%.6f", float64(quota)/QuotaPerUnit)
|
|
||||||
} else {
|
|
||||||
return fmt.Sprintf("%d", quota)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogJson 仅供测试使用 only for test
|
|
||||||
func LogJson(ctx context.Context, msg string, obj any) {
|
|
||||||
jsonStr, err := json.Marshal(obj)
|
|
||||||
if err != nil {
|
|
||||||
LogError(ctx, fmt.Sprintf("json marshal failed: %s", err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
LogInfo(ctx, fmt.Sprintf("%s | %s", msg, string(jsonStr)))
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PageInfo struct {
|
type PageInfo struct {
|
||||||
Page int `json:"page"` // page num 页码
|
Page int `json:"page"` // page num 页码
|
||||||
PageSize int `json:"page_size"` // page size 页大小
|
PageSize int `json:"page_size"` // page size 页大小
|
||||||
StartTimestamp int64 `json:"start_timestamp"` // 秒级
|
|
||||||
EndTimestamp int64 `json:"end_timestamp"` // 秒级
|
|
||||||
|
|
||||||
Total int `json:"total"` // 总条数,后设置
|
Total int `json:"total"` // 总条数,后设置
|
||||||
Items any `json:"items"` // 数据,后设置
|
Items any `json:"items"` // 数据,后设置
|
||||||
@@ -39,11 +38,14 @@ func (p *PageInfo) SetItems(items any) {
|
|||||||
p.Items = items
|
p.Items = items
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetPageQuery(c *gin.Context) (*PageInfo, error) {
|
func GetPageQuery(c *gin.Context) *PageInfo {
|
||||||
pageInfo := &PageInfo{}
|
pageInfo := &PageInfo{}
|
||||||
err := c.BindQuery(pageInfo)
|
// 手动获取并处理每个参数
|
||||||
if err != nil {
|
if page, err := strconv.Atoi(c.Query("p")); err == nil {
|
||||||
return nil, err
|
pageInfo.Page = page
|
||||||
|
}
|
||||||
|
if pageSize, err := strconv.Atoi(c.Query("page_size")); err == nil {
|
||||||
|
pageInfo.PageSize = pageSize
|
||||||
}
|
}
|
||||||
if pageInfo.Page < 1 {
|
if pageInfo.Page < 1 {
|
||||||
// 兼容
|
// 兼容
|
||||||
@@ -56,7 +58,25 @@ func GetPageQuery(c *gin.Context) (*PageInfo, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if pageInfo.PageSize == 0 {
|
if pageInfo.PageSize == 0 {
|
||||||
pageInfo.PageSize = ItemsPerPage
|
// 兼容
|
||||||
|
pageSize, _ := strconv.Atoi(c.Query("ps"))
|
||||||
|
if pageSize != 0 {
|
||||||
|
pageInfo.PageSize = pageSize
|
||||||
|
}
|
||||||
|
if pageInfo.PageSize == 0 {
|
||||||
|
pageSize, _ = strconv.Atoi(c.Query("size")) // token page
|
||||||
|
if pageSize != 0 {
|
||||||
|
pageInfo.PageSize = pageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pageInfo.PageSize == 0 {
|
||||||
|
pageInfo.PageSize = ItemsPerPage
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return pageInfo, nil
|
|
||||||
|
if pageInfo.PageSize > 100 {
|
||||||
|
pageInfo.PageSize = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageInfo
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package common
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/shirou/gopsutil/cpu"
|
|
||||||
"os"
|
"os"
|
||||||
"runtime/pprof"
|
"runtime/pprof"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/cpu"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Monitor 定时监控cpu使用率,超过阈值输出pprof文件
|
// Monitor 定时监控cpu使用率,超过阈值输出pprof文件
|
||||||
|
|||||||
5
common/quota.go
Normal file
5
common/quota.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
func GetTrustQuota() int {
|
||||||
|
return int(10 * QuotaPerUnit)
|
||||||
|
}
|
||||||
327
common/ssrf_protection.go
Normal file
327
common/ssrf_protection.go
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSRFProtection SSRF防护配置
|
||||||
|
type SSRFProtection struct {
|
||||||
|
AllowPrivateIp bool
|
||||||
|
DomainFilterMode bool // true: 白名单, false: 黑名单
|
||||||
|
DomainList []string // domain format, e.g. example.com, *.example.com
|
||||||
|
IpFilterMode bool // true: 白名单, false: 黑名单
|
||||||
|
IpList []string // CIDR or single IP
|
||||||
|
AllowedPorts []int // 允许的端口范围
|
||||||
|
ApplyIPFilterForDomain bool // 对域名启用IP过滤
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultSSRFProtection 默认SSRF防护配置
|
||||||
|
var DefaultSSRFProtection = &SSRFProtection{
|
||||||
|
AllowPrivateIp: false,
|
||||||
|
DomainFilterMode: true,
|
||||||
|
DomainList: []string{},
|
||||||
|
IpFilterMode: true,
|
||||||
|
IpList: []string{},
|
||||||
|
AllowedPorts: []int{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPrivateIP 检查IP是否为私有地址
|
||||||
|
func isPrivateIP(ip net.IP) bool {
|
||||||
|
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查私有网段
|
||||||
|
private := []net.IPNet{
|
||||||
|
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8
|
||||||
|
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12
|
||||||
|
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
|
||||||
|
{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8
|
||||||
|
{IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地)
|
||||||
|
{IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 224.0.0.0/4 (组播)
|
||||||
|
{IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 240.0.0.0/4 (保留)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, privateNet := range private {
|
||||||
|
if privateNet.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查IPv6私有地址
|
||||||
|
if ip.To4() == nil {
|
||||||
|
// IPv6 loopback
|
||||||
|
if ip.Equal(net.IPv6loopback) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// IPv6 link-local
|
||||||
|
if strings.HasPrefix(ip.String(), "fe80:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// IPv6 unique local
|
||||||
|
if strings.HasPrefix(ip.String(), "fc") || strings.HasPrefix(ip.String(), "fd") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePortRanges 解析端口范围配置
|
||||||
|
// 支持格式: "80", "443", "8000-9000"
|
||||||
|
func parsePortRanges(portConfigs []string) ([]int, error) {
|
||||||
|
var ports []int
|
||||||
|
|
||||||
|
for _, config := range portConfigs {
|
||||||
|
config = strings.TrimSpace(config)
|
||||||
|
if config == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(config, "-") {
|
||||||
|
// 处理端口范围 "8000-9000"
|
||||||
|
parts := strings.Split(config, "-")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return nil, fmt.Errorf("invalid port range format: %s", config)
|
||||||
|
}
|
||||||
|
|
||||||
|
startPort, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid start port in range %s: %v", config, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endPort, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid end port in range %s: %v", config, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if startPort > endPort {
|
||||||
|
return nil, fmt.Errorf("invalid port range %s: start port cannot be greater than end port", config)
|
||||||
|
}
|
||||||
|
|
||||||
|
if startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535 {
|
||||||
|
return nil, fmt.Errorf("port range %s contains invalid port numbers (must be 1-65535)", config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加范围内的所有端口
|
||||||
|
for port := startPort; port <= endPort; port++ {
|
||||||
|
ports = append(ports, port)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 处理单个端口 "80"
|
||||||
|
port, err := strconv.Atoi(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid port number: %s", config)
|
||||||
|
}
|
||||||
|
|
||||||
|
if port < 1 || port > 65535 {
|
||||||
|
return nil, fmt.Errorf("invalid port number %d (must be 1-65535)", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
ports = append(ports, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ports, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowedPort 检查端口是否被允许
|
||||||
|
func (p *SSRFProtection) isAllowedPort(port int) bool {
|
||||||
|
if len(p.AllowedPorts) == 0 {
|
||||||
|
return true // 如果没有配置端口限制,则允许所有端口
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, allowedPort := range p.AllowedPorts {
|
||||||
|
if port == allowedPort {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDomainWhitelisted 检查域名是否在白名单中
|
||||||
|
func isDomainListed(domain string, list []string) bool {
|
||||||
|
if len(list) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
domain = strings.ToLower(domain)
|
||||||
|
for _, item := range list {
|
||||||
|
item = strings.ToLower(strings.TrimSpace(item))
|
||||||
|
if item == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 精确匹配
|
||||||
|
if domain == item {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 通配符匹配 (*.example.com)
|
||||||
|
if strings.HasPrefix(item, "*.") {
|
||||||
|
suffix := strings.TrimPrefix(item, "*.")
|
||||||
|
if strings.HasSuffix(domain, "."+suffix) || domain == suffix {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SSRFProtection) isDomainAllowed(domain string) bool {
|
||||||
|
listed := isDomainListed(domain, p.DomainList)
|
||||||
|
if p.DomainFilterMode { // 白名单
|
||||||
|
return listed
|
||||||
|
}
|
||||||
|
// 黑名单
|
||||||
|
return !listed
|
||||||
|
}
|
||||||
|
|
||||||
|
// isIPWhitelisted 检查IP是否在白名单中
|
||||||
|
|
||||||
|
func isIPListed(ip net.IP, list []string) bool {
|
||||||
|
if len(list) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, whitelistCIDR := range list {
|
||||||
|
_, network, err := net.ParseCIDR(whitelistCIDR)
|
||||||
|
if err != nil {
|
||||||
|
// 尝试作为单个IP处理
|
||||||
|
if whitelistIP := net.ParseIP(whitelistCIDR); whitelistIP != nil {
|
||||||
|
if ip.Equal(whitelistIP) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if network.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsIPAccessAllowed 检查IP是否允许访问
|
||||||
|
func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool {
|
||||||
|
// 私有IP限制
|
||||||
|
if isPrivateIP(ip) && !p.AllowPrivateIp {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
listed := isIPListed(ip, p.IpList)
|
||||||
|
if p.IpFilterMode { // 白名单
|
||||||
|
return listed
|
||||||
|
}
|
||||||
|
// 黑名单
|
||||||
|
return !listed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateURL 验证URL是否安全
|
||||||
|
func (p *SSRFProtection) ValidateURL(urlStr string) error {
|
||||||
|
// 解析URL
|
||||||
|
u, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid URL format: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只允许HTTP/HTTPS协议
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析主机和端口
|
||||||
|
host, portStr, err := net.SplitHostPort(u.Host)
|
||||||
|
if err != nil {
|
||||||
|
// 没有端口,使用默认端口
|
||||||
|
host = u.Hostname()
|
||||||
|
if u.Scheme == "https" {
|
||||||
|
portStr = "443"
|
||||||
|
} else {
|
||||||
|
portStr = "80"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证端口
|
||||||
|
port, err := strconv.Atoi(portStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid port: %s", portStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.isAllowedPort(port) {
|
||||||
|
return fmt.Errorf("port %d is not allowed", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 host 是 IP,则跳过域名检查
|
||||||
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
|
if !p.IsIPAccessAllowed(ip) {
|
||||||
|
if isPrivateIP(ip) {
|
||||||
|
return fmt.Errorf("private IP address not allowed: %s", ip.String())
|
||||||
|
}
|
||||||
|
if p.IpFilterMode {
|
||||||
|
return fmt.Errorf("ip not in whitelist: %s", ip.String())
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ip in blacklist: %s", ip.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先进行域名过滤
|
||||||
|
if !p.isDomainAllowed(host) {
|
||||||
|
if p.DomainFilterMode {
|
||||||
|
return fmt.Errorf("domain not in whitelist: %s", host)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("domain in blacklist: %s", host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若未启用对域名应用IP过滤,则到此通过
|
||||||
|
if !p.ApplyIPFilterForDomain {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析域名对应IP并检查
|
||||||
|
ips, err := net.LookupIP(host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("DNS resolution failed for %s: %v", host, err)
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
if !p.IsIPAccessAllowed(ip) {
|
||||||
|
if isPrivateIP(ip) && !p.AllowPrivateIp {
|
||||||
|
return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String())
|
||||||
|
}
|
||||||
|
if p.IpFilterMode {
|
||||||
|
return fmt.Errorf("ip not in whitelist: %s resolves to %s", host, ip.String())
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ip in blacklist: %s resolves to %s", host, ip.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL
|
||||||
|
func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string, applyIPFilterForDomain bool) error {
|
||||||
|
// 如果SSRF防护被禁用,直接返回成功
|
||||||
|
if !enableSSRFProtection {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析端口范围配置
|
||||||
|
allowedPortInts, err := parsePortRanges(allowedPorts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("request reject - invalid port configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
protection := &SSRFProtection{
|
||||||
|
AllowPrivateIp: allowPrivateIp,
|
||||||
|
DomainFilterMode: domainFilterMode,
|
||||||
|
DomainList: domainList,
|
||||||
|
IpFilterMode: ipFilterMode,
|
||||||
|
IpList: ipList,
|
||||||
|
AllowedPorts: allowedPortInts,
|
||||||
|
ApplyIPFilterForDomain: applyIPFilterForDomain,
|
||||||
|
}
|
||||||
|
return protection.ValidateURL(urlStr)
|
||||||
|
}
|
||||||
140
common/str.go
140
common/str.go
@@ -4,7 +4,10 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -95,3 +98,140 @@ func GetJsonString(data any) string {
|
|||||||
b, _ := json.Marshal(data)
|
b, _ := json.Marshal(data)
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MaskEmail masks a user email to prevent PII leakage in logs
|
||||||
|
// Returns "***masked***" if email is empty, otherwise shows only the domain part
|
||||||
|
func MaskEmail(email string) string {
|
||||||
|
if email == "" {
|
||||||
|
return "***masked***"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the @ symbol
|
||||||
|
atIndex := strings.Index(email, "@")
|
||||||
|
if atIndex == -1 {
|
||||||
|
// No @ symbol found, return masked
|
||||||
|
return "***masked***"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return only the domain part with @ symbol
|
||||||
|
return "***@" + email[atIndex+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskHostTail returns the tail parts of a domain/host that should be preserved.
|
||||||
|
// It keeps 2 parts for likely country-code TLDs (e.g., co.uk, com.cn), otherwise keeps only the TLD.
|
||||||
|
func maskHostTail(parts []string) []string {
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
lastPart := parts[len(parts)-1]
|
||||||
|
secondLastPart := parts[len(parts)-2]
|
||||||
|
if len(lastPart) == 2 && len(secondLastPart) <= 3 {
|
||||||
|
// Likely country code TLD like co.uk, com.cn
|
||||||
|
return []string{secondLastPart, lastPart}
|
||||||
|
}
|
||||||
|
return []string{lastPart}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskHostForURL collapses subdomains and keeps only masked prefix + preserved tail.
|
||||||
|
// Example: api.openai.com -> ***.com, sub.domain.co.uk -> ***.co.uk
|
||||||
|
func maskHostForURL(host string) string {
|
||||||
|
parts := strings.Split(host, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
tail := maskHostTail(parts)
|
||||||
|
return "***." + strings.Join(tail, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskHostForPlainDomain masks a plain domain and reflects subdomain depth with multiple ***.
|
||||||
|
// Example: openai.com -> ***.com, api.openai.com -> ***.***.com, sub.domain.co.uk -> ***.***.co.uk
|
||||||
|
func maskHostForPlainDomain(domain string) string {
|
||||||
|
parts := strings.Split(domain, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
tail := maskHostTail(parts)
|
||||||
|
numStars := len(parts) - len(tail)
|
||||||
|
if numStars < 1 {
|
||||||
|
numStars = 1
|
||||||
|
}
|
||||||
|
stars := strings.TrimSuffix(strings.Repeat("***.", numStars), ".")
|
||||||
|
return stars + "." + strings.Join(tail, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskSensitiveInfo masks sensitive information like URLs, IPs, and domain names in a string
|
||||||
|
// Example:
|
||||||
|
// http://example.com -> http://***.com
|
||||||
|
// https://api.test.org/v1/users/123?key=secret -> https://***.org/***/***/?key=***
|
||||||
|
// https://sub.domain.co.uk/path/to/resource -> https://***.co.uk/***/***
|
||||||
|
// 192.168.1.1 -> ***.***.***.***
|
||||||
|
// openai.com -> ***.com
|
||||||
|
// www.openai.com -> ***.***.com
|
||||||
|
// api.openai.com -> ***.***.com
|
||||||
|
func MaskSensitiveInfo(str string) string {
|
||||||
|
// Mask URLs
|
||||||
|
urlPattern := regexp.MustCompile(`(http|https)://[^\s/$.?#].[^\s]*`)
|
||||||
|
str = urlPattern.ReplaceAllStringFunc(str, func(urlStr string) string {
|
||||||
|
u, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return urlStr
|
||||||
|
}
|
||||||
|
|
||||||
|
host := u.Host
|
||||||
|
if host == "" {
|
||||||
|
return urlStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mask host with unified logic
|
||||||
|
maskedHost := maskHostForURL(host)
|
||||||
|
|
||||||
|
result := u.Scheme + "://" + maskedHost
|
||||||
|
|
||||||
|
// Mask path
|
||||||
|
if u.Path != "" && u.Path != "/" {
|
||||||
|
pathParts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||||
|
maskedPathParts := make([]string, len(pathParts))
|
||||||
|
for i := range pathParts {
|
||||||
|
if pathParts[i] != "" {
|
||||||
|
maskedPathParts[i] = "***"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(maskedPathParts) > 0 {
|
||||||
|
result += "/" + strings.Join(maskedPathParts, "/")
|
||||||
|
}
|
||||||
|
} else if u.Path == "/" {
|
||||||
|
result += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mask query parameters
|
||||||
|
if u.RawQuery != "" {
|
||||||
|
values, err := url.ParseQuery(u.RawQuery)
|
||||||
|
if err != nil {
|
||||||
|
// If can't parse query, just mask the whole query string
|
||||||
|
result += "?***"
|
||||||
|
} else {
|
||||||
|
maskedParams := make([]string, 0, len(values))
|
||||||
|
for key := range values {
|
||||||
|
maskedParams = append(maskedParams, key+"=***")
|
||||||
|
}
|
||||||
|
if len(maskedParams) > 0 {
|
||||||
|
result += "?" + strings.Join(maskedParams, "&")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mask domain names without protocol (like openai.com, www.openai.com)
|
||||||
|
domainPattern := regexp.MustCompile(`\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b`)
|
||||||
|
str = domainPattern.ReplaceAllStringFunc(str, func(domain string) string {
|
||||||
|
return maskHostForPlainDomain(domain)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mask IP addresses
|
||||||
|
ipPattern := regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
|
||||||
|
str = ipPattern.ReplaceAllString(str, "***.***.***.***")
|
||||||
|
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|||||||
54
common/sys_log.go
Normal file
54
common/sys_log.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SysLog(s string) {
|
||||||
|
slog.Info(s, "component", "system")
|
||||||
|
}
|
||||||
|
|
||||||
|
func SysError(s string) {
|
||||||
|
slog.Error(s, "component", "system")
|
||||||
|
}
|
||||||
|
|
||||||
|
func FatalLog(v ...any) {
|
||||||
|
msg := fmt.Sprint(v...)
|
||||||
|
slog.Error(msg, "component", "system", "level", "fatal")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogStartupSuccess(startTime time.Time, port string) {
|
||||||
|
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
durationMs := duration.Milliseconds()
|
||||||
|
|
||||||
|
// Get network IPs
|
||||||
|
networkIps := GetNetworkIps()
|
||||||
|
|
||||||
|
// Print blank line for spacing
|
||||||
|
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||||
|
|
||||||
|
// Print the main success message
|
||||||
|
fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m ready in %d ms\n", SystemName, Version, durationMs)
|
||||||
|
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||||
|
|
||||||
|
// Skip fancy startup message in container environments
|
||||||
|
if !IsRunningInContainer() {
|
||||||
|
// Print local URL
|
||||||
|
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mLocal:\033[0m http://localhost:%s/\n", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print network URLs
|
||||||
|
for _, ip := range networkIps {
|
||||||
|
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print blank line for spacing
|
||||||
|
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||||
|
}
|
||||||
150
common/totp.go
Normal file
150
common/totp.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pquerna/otp"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// 备用码配置
|
||||||
|
BackupCodeLength = 8 // 备用码长度
|
||||||
|
BackupCodeCount = 4 // 生成备用码数量
|
||||||
|
|
||||||
|
// 限制配置
|
||||||
|
MaxFailAttempts = 5 // 最大失败尝试次数
|
||||||
|
LockoutDuration = 300 // 锁定时间(秒)
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateTOTPSecret 生成TOTP密钥和配置
|
||||||
|
func GenerateTOTPSecret(accountName string) (*otp.Key, error) {
|
||||||
|
issuer := Get2FAIssuer()
|
||||||
|
return totp.Generate(totp.GenerateOpts{
|
||||||
|
Issuer: issuer,
|
||||||
|
AccountName: accountName,
|
||||||
|
Period: 30,
|
||||||
|
Digits: otp.DigitsSix,
|
||||||
|
Algorithm: otp.AlgorithmSHA1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateTOTPCode 验证TOTP验证码
|
||||||
|
func ValidateTOTPCode(secret, code string) bool {
|
||||||
|
// 清理验证码格式
|
||||||
|
cleanCode := strings.ReplaceAll(code, " ", "")
|
||||||
|
if len(cleanCode) != 6 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证验证码
|
||||||
|
return totp.Validate(cleanCode, secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateBackupCodes 生成备用恢复码
|
||||||
|
func GenerateBackupCodes() ([]string, error) {
|
||||||
|
codes := make([]string, BackupCodeCount)
|
||||||
|
|
||||||
|
for i := 0; i < BackupCodeCount; i++ {
|
||||||
|
code, err := generateRandomBackupCode()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
codes[i] = code
|
||||||
|
}
|
||||||
|
|
||||||
|
return codes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRandomBackupCode 生成单个备用码
|
||||||
|
func generateRandomBackupCode() (string, error) {
|
||||||
|
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
code := make([]byte, BackupCodeLength)
|
||||||
|
|
||||||
|
for i := range code {
|
||||||
|
randomBytes := make([]byte, 1)
|
||||||
|
_, err := rand.Read(randomBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
code[i] = charset[int(randomBytes[0])%len(charset)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化为 XXXX-XXXX 格式
|
||||||
|
return fmt.Sprintf("%s-%s", string(code[:4]), string(code[4:])), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateBackupCode 验证备用码格式
|
||||||
|
func ValidateBackupCode(code string) bool {
|
||||||
|
// 移除所有分隔符并转为大写
|
||||||
|
cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", ""))
|
||||||
|
if len(cleanCode) != BackupCodeLength {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查字符是否合法
|
||||||
|
for _, char := range cleanCode {
|
||||||
|
if !((char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeBackupCode 标准化备用码格式
|
||||||
|
func NormalizeBackupCode(code string) string {
|
||||||
|
cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", ""))
|
||||||
|
if len(cleanCode) == BackupCodeLength {
|
||||||
|
return fmt.Sprintf("%s-%s", cleanCode[:4], cleanCode[4:])
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashBackupCode 对备用码进行哈希
|
||||||
|
func HashBackupCode(code string) (string, error) {
|
||||||
|
normalizedCode := NormalizeBackupCode(code)
|
||||||
|
return Password2Hash(normalizedCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get2FAIssuer 获取2FA发行者名称
|
||||||
|
func Get2FAIssuer() string {
|
||||||
|
return SystemName
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnvOrDefault 获取环境变量或默认值
|
||||||
|
func getEnvOrDefault(key, defaultValue string) string {
|
||||||
|
if value, exists := os.LookupEnv(key); exists {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateNumericCode 验证数字验证码格式
|
||||||
|
func ValidateNumericCode(code string) (string, error) {
|
||||||
|
// 移除空格
|
||||||
|
code = strings.ReplaceAll(code, " ", "")
|
||||||
|
|
||||||
|
if len(code) != 6 {
|
||||||
|
return "", fmt.Errorf("验证码必须是6位数字")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为纯数字
|
||||||
|
if _, err := strconv.Atoi(code); err != nil {
|
||||||
|
return "", fmt.Errorf("验证码只能包含数字")
|
||||||
|
}
|
||||||
|
|
||||||
|
return code, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateQRCodeData 生成二维码数据
|
||||||
|
func GenerateQRCodeData(secret, username string) string {
|
||||||
|
issuer := Get2FAIssuer()
|
||||||
|
accountName := fmt.Sprintf("%s (%s)", username, issuer)
|
||||||
|
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=6&period=30",
|
||||||
|
issuer, accountName, secret, issuer)
|
||||||
|
}
|
||||||
125
common/utils.go
125
common/utils.go
@@ -1,8 +1,6 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
crand "crypto/rand"
|
crand "crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -68,6 +66,78 @@ func GetIp() (ip string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetNetworkIps() []string {
|
||||||
|
var networkIps []string
|
||||||
|
ips, err := net.InterfaceAddrs()
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return networkIps
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, a := range ips {
|
||||||
|
if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
|
||||||
|
if ipNet.IP.To4() != nil {
|
||||||
|
ip := ipNet.IP.String()
|
||||||
|
// Include common private network ranges
|
||||||
|
if strings.HasPrefix(ip, "10.") ||
|
||||||
|
strings.HasPrefix(ip, "172.") ||
|
||||||
|
strings.HasPrefix(ip, "192.168.") {
|
||||||
|
networkIps = append(networkIps, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return networkIps
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunningInContainer detects if the application is running inside a container
|
||||||
|
func IsRunningInContainer() bool {
|
||||||
|
// Method 1: Check for .dockerenv file (Docker containers)
|
||||||
|
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Check cgroup for container indicators
|
||||||
|
if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
|
||||||
|
content := string(data)
|
||||||
|
if strings.Contains(content, "docker") ||
|
||||||
|
strings.Contains(content, "containerd") ||
|
||||||
|
strings.Contains(content, "kubepods") ||
|
||||||
|
strings.Contains(content, "/lxc/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: Check environment variables commonly set by container runtimes
|
||||||
|
containerEnvVars := []string{
|
||||||
|
"KUBERNETES_SERVICE_HOST",
|
||||||
|
"DOCKER_CONTAINER",
|
||||||
|
"container",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, envVar := range containerEnvVars {
|
||||||
|
if os.Getenv(envVar) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 4: Check if init process is not the traditional init
|
||||||
|
if data, err := os.ReadFile("/proc/1/comm"); err == nil {
|
||||||
|
comm := strings.TrimSpace(string(data))
|
||||||
|
// In containers, process 1 is often not "init" or "systemd"
|
||||||
|
if comm != "init" && comm != "systemd" {
|
||||||
|
// Additional check: if it's a common container entrypoint
|
||||||
|
if strings.Contains(comm, "docker") ||
|
||||||
|
strings.Contains(comm, "containerd") ||
|
||||||
|
strings.Contains(comm, "runc") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
var sizeKB = 1024
|
var sizeKB = 1024
|
||||||
var sizeMB = sizeKB * 1024
|
var sizeMB = sizeKB * 1024
|
||||||
var sizeGB = sizeMB * 1024
|
var sizeGB = sizeMB * 1024
|
||||||
@@ -123,8 +193,16 @@ func Interface2String(inter interface{}) string {
|
|||||||
return fmt.Sprintf("%d", inter.(int))
|
return fmt.Sprintf("%d", inter.(int))
|
||||||
case float64:
|
case float64:
|
||||||
return fmt.Sprintf("%f", inter.(float64))
|
return fmt.Sprintf("%f", inter.(float64))
|
||||||
|
case bool:
|
||||||
|
if inter.(bool) {
|
||||||
|
return "true"
|
||||||
|
} else {
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
return "Not Implemented"
|
return fmt.Sprintf("%v", inter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func UnescapeHTML(x string) interface{} {
|
func UnescapeHTML(x string) interface{} {
|
||||||
@@ -152,10 +230,6 @@ func GetUUID() string {
|
|||||||
|
|
||||||
const keyChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
const keyChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
|
||||||
func init() {
|
|
||||||
rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func GenerateRandomCharsKey(length int) (string, error) {
|
func GenerateRandomCharsKey(length int) (string, error) {
|
||||||
b := make([]byte, length)
|
b := make([]byte, length)
|
||||||
maxI := big.NewInt(int64(len(keyChars)))
|
maxI := big.NewInt(int64(len(keyChars)))
|
||||||
@@ -249,43 +323,6 @@ func SaveTmpFile(filename string, data io.Reader) (string, error) {
|
|||||||
return f.Name(), nil
|
return f.Name(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAudioDuration returns the duration of an audio file in seconds.
|
|
||||||
func GetAudioDuration(ctx context.Context, filename string, ext string) (float64, error) {
|
|
||||||
// ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {{input}}
|
|
||||||
c := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filename)
|
|
||||||
output, err := c.Output()
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.Wrap(err, "failed to get audio duration")
|
|
||||||
}
|
|
||||||
durationStr := string(bytes.TrimSpace(output))
|
|
||||||
if durationStr == "N/A" {
|
|
||||||
// Create a temporary output file name
|
|
||||||
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.Wrap(err, "failed to create temporary file")
|
|
||||||
}
|
|
||||||
tmpName := tmpFp.Name()
|
|
||||||
// Close immediately so ffmpeg can open the file on Windows.
|
|
||||||
_ = tmpFp.Close()
|
|
||||||
defer os.Remove(tmpName)
|
|
||||||
|
|
||||||
// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
|
|
||||||
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
|
|
||||||
if err := ffmpegCmd.Run(); err != nil {
|
|
||||||
return 0, errors.Wrap(err, "failed to run ffmpeg")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recalculate the duration of the new file
|
|
||||||
c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
|
|
||||||
output, err := c.Output()
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
|
|
||||||
}
|
|
||||||
durationStr = string(bytes.TrimSpace(output))
|
|
||||||
}
|
|
||||||
return strconv.ParseFloat(durationStr, 64)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildURL concatenates base and endpoint, returns the complete url string
|
// BuildURL concatenates base and endpoint, returns the complete url string
|
||||||
func BuildURL(base string, endpoint string) string {
|
func BuildURL(base string, endpoint string) string {
|
||||||
u, err := url.Parse(base)
|
u, err := url.Parse(base)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/google/uuid"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type verificationValue struct {
|
type verificationValue struct {
|
||||||
|
|||||||
@@ -30,5 +30,9 @@ const (
|
|||||||
APITypeXinference
|
APITypeXinference
|
||||||
APITypeXai
|
APITypeXai
|
||||||
APITypeCoze
|
APITypeCoze
|
||||||
|
APITypeJimeng
|
||||||
|
APITypeMoonshot
|
||||||
|
APITypeSubmodel
|
||||||
|
APITypeMiniMax
|
||||||
APITypeDummy // this one is only for count, do not add any channel after this
|
APITypeDummy // this one is only for count, do not add any channel after this
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ const (
|
|||||||
ChannelTypeCoze = 49
|
ChannelTypeCoze = 49
|
||||||
ChannelTypeKling = 50
|
ChannelTypeKling = 50
|
||||||
ChannelTypeJimeng = 51
|
ChannelTypeJimeng = 51
|
||||||
|
ChannelTypeVidu = 52
|
||||||
|
ChannelTypeSubmodel = 53
|
||||||
|
ChannelTypeDoubaoVideo = 54
|
||||||
|
ChannelTypeSora = 55
|
||||||
ChannelTypeDummy // this one is only for count, do not add any channel after this
|
ChannelTypeDummy // this one is only for count, do not add any channel after this
|
||||||
|
|
||||||
)
|
)
|
||||||
@@ -106,4 +110,70 @@ var ChannelBaseURLs = []string{
|
|||||||
"https://api.coze.cn", //49
|
"https://api.coze.cn", //49
|
||||||
"https://api.klingai.com", //50
|
"https://api.klingai.com", //50
|
||||||
"https://visual.volcengineapi.com", //51
|
"https://visual.volcengineapi.com", //51
|
||||||
|
"https://api.vidu.cn", //52
|
||||||
|
"https://llm.submodel.ai", //53
|
||||||
|
"https://ark.cn-beijing.volces.com", //54
|
||||||
|
"https://api.openai.com", //55
|
||||||
|
}
|
||||||
|
|
||||||
|
var ChannelTypeNames = map[int]string{
|
||||||
|
ChannelTypeUnknown: "Unknown",
|
||||||
|
ChannelTypeOpenAI: "OpenAI",
|
||||||
|
ChannelTypeMidjourney: "Midjourney",
|
||||||
|
ChannelTypeAzure: "Azure",
|
||||||
|
ChannelTypeOllama: "Ollama",
|
||||||
|
ChannelTypeMidjourneyPlus: "MidjourneyPlus",
|
||||||
|
ChannelTypeOpenAIMax: "OpenAIMax",
|
||||||
|
ChannelTypeOhMyGPT: "OhMyGPT",
|
||||||
|
ChannelTypeCustom: "Custom",
|
||||||
|
ChannelTypeAILS: "AILS",
|
||||||
|
ChannelTypeAIProxy: "AIProxy",
|
||||||
|
ChannelTypePaLM: "PaLM",
|
||||||
|
ChannelTypeAPI2GPT: "API2GPT",
|
||||||
|
ChannelTypeAIGC2D: "AIGC2D",
|
||||||
|
ChannelTypeAnthropic: "Anthropic",
|
||||||
|
ChannelTypeBaidu: "Baidu",
|
||||||
|
ChannelTypeZhipu: "Zhipu",
|
||||||
|
ChannelTypeAli: "Ali",
|
||||||
|
ChannelTypeXunfei: "Xunfei",
|
||||||
|
ChannelType360: "360",
|
||||||
|
ChannelTypeOpenRouter: "OpenRouter",
|
||||||
|
ChannelTypeAIProxyLibrary: "AIProxyLibrary",
|
||||||
|
ChannelTypeFastGPT: "FastGPT",
|
||||||
|
ChannelTypeTencent: "Tencent",
|
||||||
|
ChannelTypeGemini: "Gemini",
|
||||||
|
ChannelTypeMoonshot: "Moonshot",
|
||||||
|
ChannelTypeZhipu_v4: "ZhipuV4",
|
||||||
|
ChannelTypePerplexity: "Perplexity",
|
||||||
|
ChannelTypeLingYiWanWu: "LingYiWanWu",
|
||||||
|
ChannelTypeAws: "AWS",
|
||||||
|
ChannelTypeCohere: "Cohere",
|
||||||
|
ChannelTypeMiniMax: "MiniMax",
|
||||||
|
ChannelTypeSunoAPI: "SunoAPI",
|
||||||
|
ChannelTypeDify: "Dify",
|
||||||
|
ChannelTypeJina: "Jina",
|
||||||
|
ChannelCloudflare: "Cloudflare",
|
||||||
|
ChannelTypeSiliconFlow: "SiliconFlow",
|
||||||
|
ChannelTypeVertexAi: "VertexAI",
|
||||||
|
ChannelTypeMistral: "Mistral",
|
||||||
|
ChannelTypeDeepSeek: "DeepSeek",
|
||||||
|
ChannelTypeMokaAI: "MokaAI",
|
||||||
|
ChannelTypeVolcEngine: "VolcEngine",
|
||||||
|
ChannelTypeBaiduV2: "BaiduV2",
|
||||||
|
ChannelTypeXinference: "Xinference",
|
||||||
|
ChannelTypeXai: "xAI",
|
||||||
|
ChannelTypeCoze: "Coze",
|
||||||
|
ChannelTypeKling: "Kling",
|
||||||
|
ChannelTypeJimeng: "Jimeng",
|
||||||
|
ChannelTypeVidu: "Vidu",
|
||||||
|
ChannelTypeSubmodel: "Submodel",
|
||||||
|
ChannelTypeDoubaoVideo: "DoubaoVideo",
|
||||||
|
ChannelTypeSora: "Sora",
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetChannelTypeName(channelType int) string {
|
||||||
|
if name, ok := ChannelTypeNames[channelType]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package constant
|
|||||||
type ContextKey string
|
type ContextKey string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
ContextKeyTokenCountMeta ContextKey = "token_count_meta"
|
||||||
|
ContextKeyPromptTokens ContextKey = "prompt_tokens"
|
||||||
|
|
||||||
ContextKeyOriginalModel ContextKey = "original_model"
|
ContextKeyOriginalModel ContextKey = "original_model"
|
||||||
ContextKeyRequestStartTime ContextKey = "request_start_time"
|
ContextKeyRequestStartTime ContextKey = "request_start_time"
|
||||||
|
|
||||||
@@ -11,7 +14,6 @@ const (
|
|||||||
ContextKeyTokenKey ContextKey = "token_key"
|
ContextKeyTokenKey ContextKey = "token_key"
|
||||||
ContextKeyTokenId ContextKey = "token_id"
|
ContextKeyTokenId ContextKey = "token_id"
|
||||||
ContextKeyTokenGroup ContextKey = "token_group"
|
ContextKeyTokenGroup ContextKey = "token_group"
|
||||||
ContextKeyTokenAllowIps ContextKey = "allow_ips"
|
|
||||||
ContextKeyTokenSpecificChannelId ContextKey = "specific_channel_id"
|
ContextKeyTokenSpecificChannelId ContextKey = "specific_channel_id"
|
||||||
ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled"
|
ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled"
|
||||||
ContextKeyTokenModelLimit ContextKey = "token_model_limit"
|
ContextKeyTokenModelLimit ContextKey = "token_model_limit"
|
||||||
@@ -19,16 +21,20 @@ const (
|
|||||||
/* channel related keys */
|
/* channel related keys */
|
||||||
ContextKeyChannelId ContextKey = "channel_id"
|
ContextKeyChannelId ContextKey = "channel_id"
|
||||||
ContextKeyChannelName ContextKey = "channel_name"
|
ContextKeyChannelName ContextKey = "channel_name"
|
||||||
ContextKeyChannelCreateTime ContextKey = "channel_create_name"
|
ContextKeyChannelCreateTime ContextKey = "channel_create_time"
|
||||||
ContextKeyChannelBaseUrl ContextKey = "base_url"
|
ContextKeyChannelBaseUrl ContextKey = "base_url"
|
||||||
ContextKeyChannelType ContextKey = "channel_type"
|
ContextKeyChannelType ContextKey = "channel_type"
|
||||||
ContextKeyChannelSetting ContextKey = "channel_setting"
|
ContextKeyChannelSetting ContextKey = "channel_setting"
|
||||||
|
ContextKeyChannelOtherSetting ContextKey = "channel_other_setting"
|
||||||
ContextKeyChannelParamOverride ContextKey = "param_override"
|
ContextKeyChannelParamOverride ContextKey = "param_override"
|
||||||
|
ContextKeyChannelHeaderOverride ContextKey = "header_override"
|
||||||
ContextKeyChannelOrganization ContextKey = "channel_organization"
|
ContextKeyChannelOrganization ContextKey = "channel_organization"
|
||||||
ContextKeyChannelAutoBan ContextKey = "auto_ban"
|
ContextKeyChannelAutoBan ContextKey = "auto_ban"
|
||||||
ContextKeyChannelModelMapping ContextKey = "model_mapping"
|
ContextKeyChannelModelMapping ContextKey = "model_mapping"
|
||||||
ContextKeyChannelStatusCodeMapping ContextKey = "status_code_mapping"
|
ContextKeyChannelStatusCodeMapping ContextKey = "status_code_mapping"
|
||||||
ContextKeyChannelIsMultiKey ContextKey = "channel_is_multi_key"
|
ContextKeyChannelIsMultiKey ContextKey = "channel_is_multi_key"
|
||||||
|
ContextKeyChannelMultiKeyIndex ContextKey = "channel_multi_key_index"
|
||||||
|
ContextKeyChannelKey ContextKey = "channel_key"
|
||||||
|
|
||||||
/* user related keys */
|
/* user related keys */
|
||||||
ContextKeyUserId ContextKey = "id"
|
ContextKeyUserId ContextKey = "id"
|
||||||
@@ -39,4 +45,6 @@ const (
|
|||||||
ContextKeyUserGroup ContextKey = "user_group"
|
ContextKeyUserGroup ContextKey = "user_group"
|
||||||
ContextKeyUsingGroup ContextKey = "group"
|
ContextKeyUsingGroup ContextKey = "group"
|
||||||
ContextKeyUserName ContextKey = "username"
|
ContextKeyUserName ContextKey = "username"
|
||||||
|
|
||||||
|
ContextKeySystemPromptOverride ContextKey = "system_prompt_override"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const (
|
|||||||
EndpointTypeGemini EndpointType = "gemini"
|
EndpointTypeGemini EndpointType = "gemini"
|
||||||
EndpointTypeJinaRerank EndpointType = "jina-rerank"
|
EndpointTypeJinaRerank EndpointType = "jina-rerank"
|
||||||
EndpointTypeImageGeneration EndpointType = "image-generation"
|
EndpointTypeImageGeneration EndpointType = "image-generation"
|
||||||
|
EndpointTypeEmbeddings EndpointType = "embeddings"
|
||||||
|
EndpointTypeOpenAIVideo EndpointType = "openai-video"
|
||||||
//EndpointTypeMidjourney EndpointType = "midjourney-proxy"
|
//EndpointTypeMidjourney EndpointType = "midjourney-proxy"
|
||||||
//EndpointTypeSuno EndpointType = "suno-proxy"
|
//EndpointTypeSuno EndpointType = "suno-proxy"
|
||||||
//EndpointTypeKling EndpointType = "kling"
|
//EndpointTypeKling EndpointType = "kling"
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ var NotifyLimitCount int
|
|||||||
var NotificationLimitDurationMinute int
|
var NotificationLimitDurationMinute int
|
||||||
var GenerateDefaultToken bool
|
var GenerateDefaultToken bool
|
||||||
var ErrorLogEnabled bool
|
var ErrorLogEnabled bool
|
||||||
|
|
||||||
|
// temporary variable for sora patch, will be removed in future
|
||||||
|
var TaskPricePatches []string
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ type TaskPlatform string
|
|||||||
const (
|
const (
|
||||||
TaskPlatformSuno TaskPlatform = "suno"
|
TaskPlatformSuno TaskPlatform = "suno"
|
||||||
TaskPlatformMidjourney = "mj"
|
TaskPlatformMidjourney = "mj"
|
||||||
TaskPlatformKling TaskPlatform = "kling"
|
|
||||||
TaskPlatformJimeng TaskPlatform = "jimeng"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SunoActionMusic = "MUSIC"
|
SunoActionMusic = "MUSIC"
|
||||||
SunoActionLyrics = "LYRICS"
|
SunoActionLyrics = "LYRICS"
|
||||||
|
|
||||||
TaskActionGenerate = "generate"
|
TaskActionGenerate = "generate"
|
||||||
TaskActionTextGenerate = "textGenerate"
|
TaskActionTextGenerate = "textGenerate"
|
||||||
|
TaskActionFirstTailGenerate = "firstTailGenerate"
|
||||||
|
TaskActionReferenceGenerate = "referenceGenerate"
|
||||||
)
|
)
|
||||||
|
|
||||||
var SunoModel2Action = map[string]string{
|
var SunoModel2Action = map[string]string{
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"one-api/common"
|
|
||||||
"one-api/dto"
|
|
||||||
"one-api/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetSubscription(c *gin.Context) {
|
func GetSubscription(c *gin.Context) {
|
||||||
@@ -39,8 +40,18 @@ func GetSubscription(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
quota := remainQuota + usedQuota
|
quota := remainQuota + usedQuota
|
||||||
amount := float64(quota)
|
amount := float64(quota)
|
||||||
if common.DisplayInCurrencyEnabled {
|
// OpenAI 兼容接口中的 *_USD 字段含义保持“额度单位”对应值:
|
||||||
amount /= common.QuotaPerUnit
|
// 我们将其解释为以“站点展示类型”为准:
|
||||||
|
// - USD: 直接除以 QuotaPerUnit
|
||||||
|
// - CNY: 先转 USD 再乘汇率
|
||||||
|
// - TOKENS: 直接使用 tokens 数量
|
||||||
|
switch operation_setting.GetQuotaDisplayType() {
|
||||||
|
case operation_setting.QuotaDisplayTypeCNY:
|
||||||
|
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
|
||||||
|
case operation_setting.QuotaDisplayTypeTokens:
|
||||||
|
// amount 保持 tokens 数值
|
||||||
|
default:
|
||||||
|
amount = amount / common.QuotaPerUnit
|
||||||
}
|
}
|
||||||
if token != nil && token.UnlimitedQuota {
|
if token != nil && token.UnlimitedQuota {
|
||||||
amount = 100000000
|
amount = 100000000
|
||||||
@@ -80,8 +91,13 @@ func GetUsage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
amount := float64(quota)
|
amount := float64(quota)
|
||||||
if common.DisplayInCurrencyEnabled {
|
switch operation_setting.GetQuotaDisplayType() {
|
||||||
amount /= common.QuotaPerUnit
|
case operation_setting.QuotaDisplayTypeCNY:
|
||||||
|
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
|
||||||
|
case operation_setting.QuotaDisplayTypeTokens:
|
||||||
|
// tokens 保持原值
|
||||||
|
default:
|
||||||
|
amount = amount / common.QuotaPerUnit
|
||||||
}
|
}
|
||||||
usage := OpenAIUsageResponse{
|
usage := OpenAIUsageResponse{
|
||||||
Object: "list",
|
Object: "list",
|
||||||
|
|||||||
@@ -4,17 +4,20 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/common"
|
|
||||||
"one-api/constant"
|
|
||||||
"one-api/model"
|
|
||||||
"one-api/service"
|
|
||||||
"one-api/setting"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/service"
|
||||||
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -125,6 +128,14 @@ func GetAuthHeader(token string) http.Header {
|
|||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetClaudeAuthHeader get claude auth header
|
||||||
|
func GetClaudeAuthHeader(token string) http.Header {
|
||||||
|
h := http.Header{}
|
||||||
|
h.Add("x-api-key", token)
|
||||||
|
h.Add("anthropic-version", "2023-06-01")
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
func GetResponseBody(method, url string, channel *model.Channel, headers http.Header) ([]byte, error) {
|
func GetResponseBody(method, url string, channel *model.Channel, headers http.Header) ([]byte, error) {
|
||||||
req, err := http.NewRequest(method, url, nil)
|
req, err := http.NewRequest(method, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -133,7 +144,11 @@ func GetResponseBody(method, url string, channel *model.Channel, headers http.He
|
|||||||
for k := range headers {
|
for k := range headers {
|
||||||
req.Header.Add(k, headers.Get(k))
|
req.Header.Add(k, headers.Get(k))
|
||||||
}
|
}
|
||||||
res, err := service.GetHttpClient().Do(req)
|
client, err := service.NewProxyHttpClient(channel.GetSetting().Proxy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -336,7 +351,7 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
|
|||||||
return 0, fmt.Errorf("failed to update moonshot balance, status: %v, code: %d, scode: %s", response.Status, response.Code, response.Scode)
|
return 0, fmt.Errorf("failed to update moonshot balance, status: %v, code: %d, scode: %s", response.Status, response.Code, response.Scode)
|
||||||
}
|
}
|
||||||
availableBalanceCny := response.Data.AvailableBalance
|
availableBalanceCny := response.Data.AvailableBalance
|
||||||
availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(setting.Price)).InexactFloat64()
|
availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(operation_setting.Price)).InexactFloat64()
|
||||||
channel.UpdateBalance(availableBalanceUsd)
|
channel.UpdateBalance(availableBalanceUsd)
|
||||||
return availableBalanceUsd, nil
|
return availableBalanceUsd, nil
|
||||||
}
|
}
|
||||||
@@ -409,26 +424,24 @@ func updateChannelBalance(channel *model.Channel) (float64, error) {
|
|||||||
func UpdateChannelBalance(c *gin.Context) {
|
func UpdateChannelBalance(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
channel, err := model.GetChannelById(id, true)
|
channel, err := model.CacheGetChannel(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if channel.ChannelInfo.IsMultiKey {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": err.Error(),
|
"message": "多密钥渠道不支持余额查询",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
balance, err := updateChannelBalance(channel)
|
balance, err := updateChannelBalance(channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -436,7 +449,6 @@ func UpdateChannelBalance(c *gin.Context) {
|
|||||||
"message": "",
|
"message": "",
|
||||||
"balance": balance,
|
"balance": balance,
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateAllChannelsBalance() error {
|
func updateAllChannelsBalance() error {
|
||||||
@@ -448,6 +460,9 @@ func updateAllChannelsBalance() error {
|
|||||||
if channel.Status != common.ChannelStatusEnabled {
|
if channel.Status != common.ChannelStatusEnabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if channel.ChannelInfo.IsMultiKey {
|
||||||
|
continue // skip multi-key channels
|
||||||
|
}
|
||||||
// TODO: support Azure
|
// TODO: support Azure
|
||||||
//if channel.Type != common.ChannelTypeOpenAI && channel.Type != common.ChannelTypeCustom {
|
//if channel.Type != common.ChannelTypeOpenAI && channel.Type != common.ChannelTypeCustom {
|
||||||
// continue
|
// continue
|
||||||
@@ -458,7 +473,7 @@ func updateAllChannelsBalance() error {
|
|||||||
} else {
|
} else {
|
||||||
// err is nil & balance <= 0 means quota is used up
|
// err is nil & balance <= 0 means quota is used up
|
||||||
if balance <= 0 {
|
if balance <= 0 {
|
||||||
service.DisableChannel(channel.Id, channel.Name, "余额不足")
|
service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "余额不足")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
time.Sleep(common.RequestInterval)
|
time.Sleep(common.RequestInterval)
|
||||||
@@ -470,10 +485,7 @@ func UpdateAllChannelsBalance(c *gin.Context) {
|
|||||||
// TODO: make it async
|
// TODO: make it async
|
||||||
err := updateAllChannelsBalance()
|
err := updateAllChannelsBalance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
|||||||
@@ -10,55 +10,93 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"one-api/common"
|
|
||||||
"one-api/constant"
|
|
||||||
"one-api/dto"
|
|
||||||
"one-api/middleware"
|
|
||||||
"one-api/model"
|
|
||||||
"one-api/relay"
|
|
||||||
relaycommon "one-api/relay/common"
|
|
||||||
"one-api/relay/helper"
|
|
||||||
"one-api/service"
|
|
||||||
"one-api/types"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/QuantumNous/new-api/middleware"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/relay"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||||
|
"github.com/QuantumNous/new-api/relay/helper"
|
||||||
|
"github.com/QuantumNous/new-api/service"
|
||||||
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
|
||||||
"github.com/bytedance/gopkg/util/gopool"
|
"github.com/bytedance/gopkg/util/gopool"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testChannel(channel *model.Channel, testModel string) (err error, newAPIError *types.NewAPIError) {
|
type testResult struct {
|
||||||
|
context *gin.Context
|
||||||
|
localErr error
|
||||||
|
newAPIError *types.NewAPIError
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChannel(channel *model.Channel, testModel string, endpointType string) testResult {
|
||||||
tik := time.Now()
|
tik := time.Now()
|
||||||
if channel.Type == constant.ChannelTypeMidjourney {
|
var unsupportedTestChannelTypes = []int{
|
||||||
return errors.New("midjourney channel test is not supported"), nil
|
constant.ChannelTypeMidjourney,
|
||||||
|
constant.ChannelTypeMidjourneyPlus,
|
||||||
|
constant.ChannelTypeSunoAPI,
|
||||||
|
constant.ChannelTypeKling,
|
||||||
|
constant.ChannelTypeJimeng,
|
||||||
|
constant.ChannelTypeDoubaoVideo,
|
||||||
|
constant.ChannelTypeVidu,
|
||||||
}
|
}
|
||||||
if channel.Type == constant.ChannelTypeMidjourneyPlus {
|
if lo.Contains(unsupportedTestChannelTypes, channel.Type) {
|
||||||
return errors.New("midjourney plus channel test is not supported"), nil
|
channelTypeName := constant.GetChannelTypeName(channel.Type)
|
||||||
}
|
return testResult{
|
||||||
if channel.Type == constant.ChannelTypeSunoAPI {
|
localErr: fmt.Errorf("%s channel test is not supported", channelTypeName),
|
||||||
return errors.New("suno channel test is not supported"), nil
|
}
|
||||||
}
|
|
||||||
if channel.Type == constant.ChannelTypeKling {
|
|
||||||
return errors.New("kling channel test is not supported"), nil
|
|
||||||
}
|
|
||||||
if channel.Type == constant.ChannelTypeJimeng {
|
|
||||||
return errors.New("jimeng channel test is not supported"), nil
|
|
||||||
}
|
}
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
|
||||||
|
testModel = strings.TrimSpace(testModel)
|
||||||
|
if testModel == "" {
|
||||||
|
if channel.TestModel != nil && *channel.TestModel != "" {
|
||||||
|
testModel = strings.TrimSpace(*channel.TestModel)
|
||||||
|
} else {
|
||||||
|
models := channel.GetModels()
|
||||||
|
if len(models) > 0 {
|
||||||
|
testModel = strings.TrimSpace(models[0])
|
||||||
|
}
|
||||||
|
if testModel == "" {
|
||||||
|
testModel = "gpt-4o-mini"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
requestPath := "/v1/chat/completions"
|
requestPath := "/v1/chat/completions"
|
||||||
|
|
||||||
// 先判断是否为 Embedding 模型
|
// 如果指定了端点类型,使用指定的端点类型
|
||||||
if strings.Contains(strings.ToLower(testModel), "embedding") ||
|
if endpointType != "" {
|
||||||
strings.HasPrefix(testModel, "m3e") || // m3e 系列模型
|
if endpointInfo, ok := common.GetDefaultEndpointInfo(constant.EndpointType(endpointType)); ok {
|
||||||
strings.Contains(testModel, "bge-") || // bge 系列模型
|
requestPath = endpointInfo.Path
|
||||||
strings.Contains(testModel, "embed") ||
|
}
|
||||||
channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型
|
} else {
|
||||||
requestPath = "/v1/embeddings" // 修改请求路径
|
// 如果没有指定端点类型,使用原有的自动检测逻辑
|
||||||
|
// 先判断是否为 Embedding 模型
|
||||||
|
if strings.Contains(strings.ToLower(testModel), "embedding") ||
|
||||||
|
strings.HasPrefix(testModel, "m3e") || // m3e 系列模型
|
||||||
|
strings.Contains(testModel, "bge-") || // bge 系列模型
|
||||||
|
strings.Contains(testModel, "embed") ||
|
||||||
|
channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型
|
||||||
|
requestPath = "/v1/embeddings" // 修改请求路径
|
||||||
|
}
|
||||||
|
|
||||||
|
// VolcEngine 图像生成模型
|
||||||
|
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
|
||||||
|
requestPath = "/v1/images/generations"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Request = &http.Request{
|
c.Request = &http.Request{
|
||||||
@@ -68,94 +106,250 @@ func testChannel(channel *model.Channel, testModel string) (err error, newAPIErr
|
|||||||
Header: make(http.Header),
|
Header: make(http.Header),
|
||||||
}
|
}
|
||||||
|
|
||||||
if testModel == "" {
|
|
||||||
if channel.TestModel != nil && *channel.TestModel != "" {
|
|
||||||
testModel = *channel.TestModel
|
|
||||||
} else {
|
|
||||||
if len(channel.GetModels()) > 0 {
|
|
||||||
testModel = channel.GetModels()[0]
|
|
||||||
} else {
|
|
||||||
testModel = "gpt-4o-mini"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cache, err := model.GetUserCache(1)
|
cache, err := model.GetUserCache(1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err, nil
|
return testResult{
|
||||||
|
localErr: err,
|
||||||
|
newAPIError: nil,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cache.WriteContext(c)
|
cache.WriteContext(c)
|
||||||
|
|
||||||
c.Request.Header.Set("Authorization", "Bearer "+channel.Key)
|
//c.Request.Header.Set("Authorization", "Bearer "+channel.Key)
|
||||||
c.Request.Header.Set("Content-Type", "application/json")
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
c.Set("channel", channel.Type)
|
c.Set("channel", channel.Type)
|
||||||
c.Set("base_url", channel.GetBaseURL())
|
c.Set("base_url", channel.GetBaseURL())
|
||||||
group, _ := model.GetUserGroup(1, false)
|
group, _ := model.GetUserGroup(1, false)
|
||||||
c.Set("group", group)
|
c.Set("group", group)
|
||||||
|
|
||||||
middleware.SetupContextForSelectedChannel(c, channel, testModel)
|
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, testModel)
|
||||||
|
if newAPIError != nil {
|
||||||
info := relaycommon.GenRelayInfo(c)
|
return testResult{
|
||||||
|
context: c,
|
||||||
err = helper.ModelMappedHelper(c, info, nil)
|
localErr: newAPIError,
|
||||||
if err != nil {
|
newAPIError: newAPIError,
|
||||||
return err, types.NewError(err, types.ErrorCodeChannelModelMappedError)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine relay format based on endpoint type or request path
|
||||||
|
var relayFormat types.RelayFormat
|
||||||
|
if endpointType != "" {
|
||||||
|
// 根据指定的端点类型设置 relayFormat
|
||||||
|
switch constant.EndpointType(endpointType) {
|
||||||
|
case constant.EndpointTypeOpenAI:
|
||||||
|
relayFormat = types.RelayFormatOpenAI
|
||||||
|
case constant.EndpointTypeOpenAIResponse:
|
||||||
|
relayFormat = types.RelayFormatOpenAIResponses
|
||||||
|
case constant.EndpointTypeAnthropic:
|
||||||
|
relayFormat = types.RelayFormatClaude
|
||||||
|
case constant.EndpointTypeGemini:
|
||||||
|
relayFormat = types.RelayFormatGemini
|
||||||
|
case constant.EndpointTypeJinaRerank:
|
||||||
|
relayFormat = types.RelayFormatRerank
|
||||||
|
case constant.EndpointTypeImageGeneration:
|
||||||
|
relayFormat = types.RelayFormatOpenAIImage
|
||||||
|
case constant.EndpointTypeEmbeddings:
|
||||||
|
relayFormat = types.RelayFormatEmbedding
|
||||||
|
default:
|
||||||
|
relayFormat = types.RelayFormatOpenAI
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 根据请求路径自动检测
|
||||||
|
relayFormat = types.RelayFormatOpenAI
|
||||||
|
if c.Request.URL.Path == "/v1/embeddings" {
|
||||||
|
relayFormat = types.RelayFormatEmbedding
|
||||||
|
}
|
||||||
|
if c.Request.URL.Path == "/v1/images/generations" {
|
||||||
|
relayFormat = types.RelayFormatOpenAIImage
|
||||||
|
}
|
||||||
|
if c.Request.URL.Path == "/v1/messages" {
|
||||||
|
relayFormat = types.RelayFormatClaude
|
||||||
|
}
|
||||||
|
if strings.Contains(c.Request.URL.Path, "/v1beta/models") {
|
||||||
|
relayFormat = types.RelayFormatGemini
|
||||||
|
}
|
||||||
|
if c.Request.URL.Path == "/v1/rerank" || c.Request.URL.Path == "/rerank" {
|
||||||
|
relayFormat = types.RelayFormatRerank
|
||||||
|
}
|
||||||
|
if c.Request.URL.Path == "/v1/responses" {
|
||||||
|
relayFormat = types.RelayFormatOpenAIResponses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request := buildTestRequest(testModel, endpointType)
|
||||||
|
|
||||||
|
info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: err,
|
||||||
|
newAPIError: types.NewError(err, types.ErrorCodeGenRelayInfoFailed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info.InitChannelMeta(c)
|
||||||
|
|
||||||
|
err = helper.ModelMappedHelper(c, info, request)
|
||||||
|
if err != nil {
|
||||||
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: err,
|
||||||
|
newAPIError: types.NewError(err, types.ErrorCodeChannelModelMappedError),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
testModel = info.UpstreamModelName
|
testModel = info.UpstreamModelName
|
||||||
|
// 更新请求中的模型名称
|
||||||
|
request.SetModelName(testModel)
|
||||||
|
|
||||||
apiType, _ := common.ChannelType2APIType(channel.Type)
|
apiType, _ := common.ChannelType2APIType(channel.Type)
|
||||||
adaptor := relay.GetAdaptor(apiType)
|
adaptor := relay.GetAdaptor(apiType)
|
||||||
if adaptor == nil {
|
if adaptor == nil {
|
||||||
return fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), types.NewError(fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), types.ErrorCodeInvalidApiType)
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: fmt.Errorf("invalid api type: %d, adaptor is nil", apiType),
|
||||||
|
newAPIError: types.NewError(fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), types.ErrorCodeInvalidApiType),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
request := buildTestRequest(testModel)
|
//// 创建一个用于日志的 info 副本,移除 ApiKey
|
||||||
// 创建一个用于日志的 info 副本,移除 ApiKey
|
//logInfo := info
|
||||||
logInfo := *info
|
//logInfo.ApiKey = ""
|
||||||
logInfo.ApiKey = ""
|
common.SysLog(fmt.Sprintf("testing channel %d with model %s , info %+v ", channel.Id, testModel, info.ToString()))
|
||||||
common.SysLog(fmt.Sprintf("testing channel %d with model %s , info %+v ", channel.Id, testModel, logInfo))
|
|
||||||
|
|
||||||
priceData, err := helper.ModelPriceHelper(c, info, 0, int(request.MaxTokens))
|
priceData, err := helper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err, types.NewError(err, types.ErrorCodeModelPriceError)
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: err,
|
||||||
|
newAPIError: types.NewError(err, types.ErrorCodeModelPriceError),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
adaptor.Init(info)
|
adaptor.Init(info)
|
||||||
|
|
||||||
convertedRequest, err := adaptor.ConvertOpenAIRequest(c, info, request)
|
var convertedRequest any
|
||||||
|
// 根据 RelayMode 选择正确的转换函数
|
||||||
|
switch info.RelayMode {
|
||||||
|
case relayconstant.RelayModeEmbeddings:
|
||||||
|
// Embedding 请求 - request 已经是正确的类型
|
||||||
|
if embeddingReq, ok := request.(*dto.EmbeddingRequest); ok {
|
||||||
|
convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, *embeddingReq)
|
||||||
|
} else {
|
||||||
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: errors.New("invalid embedding request type"),
|
||||||
|
newAPIError: types.NewError(errors.New("invalid embedding request type"), types.ErrorCodeConvertRequestFailed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case relayconstant.RelayModeImagesGenerations:
|
||||||
|
// 图像生成请求 - request 已经是正确的类型
|
||||||
|
if imageReq, ok := request.(*dto.ImageRequest); ok {
|
||||||
|
convertedRequest, err = adaptor.ConvertImageRequest(c, info, *imageReq)
|
||||||
|
} else {
|
||||||
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: errors.New("invalid image request type"),
|
||||||
|
newAPIError: types.NewError(errors.New("invalid image request type"), types.ErrorCodeConvertRequestFailed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case relayconstant.RelayModeRerank:
|
||||||
|
// Rerank 请求 - request 已经是正确的类型
|
||||||
|
if rerankReq, ok := request.(*dto.RerankRequest); ok {
|
||||||
|
convertedRequest, err = adaptor.ConvertRerankRequest(c, info.RelayMode, *rerankReq)
|
||||||
|
} else {
|
||||||
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: errors.New("invalid rerank request type"),
|
||||||
|
newAPIError: types.NewError(errors.New("invalid rerank request type"), types.ErrorCodeConvertRequestFailed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case relayconstant.RelayModeResponses:
|
||||||
|
// Response 请求 - request 已经是正确的类型
|
||||||
|
if responseReq, ok := request.(*dto.OpenAIResponsesRequest); ok {
|
||||||
|
convertedRequest, err = adaptor.ConvertOpenAIResponsesRequest(c, info, *responseReq)
|
||||||
|
} else {
|
||||||
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: errors.New("invalid response request type"),
|
||||||
|
newAPIError: types.NewError(errors.New("invalid response request type"), types.ErrorCodeConvertRequestFailed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Chat/Completion 等其他请求类型
|
||||||
|
if generalReq, ok := request.(*dto.GeneralOpenAIRequest); ok {
|
||||||
|
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, generalReq)
|
||||||
|
} else {
|
||||||
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: errors.New("invalid general request type"),
|
||||||
|
newAPIError: types.NewError(errors.New("invalid general request type"), types.ErrorCodeConvertRequestFailed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err, types.NewError(err, types.ErrorCodeConvertRequestFailed)
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: err,
|
||||||
|
newAPIError: types.NewError(err, types.ErrorCodeConvertRequestFailed),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
jsonData, err := json.Marshal(convertedRequest)
|
jsonData, err := json.Marshal(convertedRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err, types.NewError(err, types.ErrorCodeJsonMarshalFailed)
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: err,
|
||||||
|
newAPIError: types.NewError(err, types.ErrorCodeJsonMarshalFailed),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
requestBody := bytes.NewBuffer(jsonData)
|
requestBody := bytes.NewBuffer(jsonData)
|
||||||
c.Request.Body = io.NopCloser(requestBody)
|
c.Request.Body = io.NopCloser(requestBody)
|
||||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err, types.NewError(err, types.ErrorCodeDoRequestFailed)
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: err,
|
||||||
|
newAPIError: types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var httpResp *http.Response
|
var httpResp *http.Response
|
||||||
if resp != nil {
|
if resp != nil {
|
||||||
httpResp = resp.(*http.Response)
|
httpResp = resp.(*http.Response)
|
||||||
if httpResp.StatusCode != http.StatusOK {
|
if httpResp.StatusCode != http.StatusOK {
|
||||||
err := service.RelayErrorHandler(httpResp, true)
|
err := service.RelayErrorHandler(c.Request.Context(), httpResp, true)
|
||||||
return err, types.NewError(err, types.ErrorCodeBadResponse)
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: err,
|
||||||
|
newAPIError: types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
usageA, respErr := adaptor.DoResponse(c, httpResp, info)
|
usageA, respErr := adaptor.DoResponse(c, httpResp, info)
|
||||||
if respErr != nil {
|
if respErr != nil {
|
||||||
return respErr, respErr
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: respErr,
|
||||||
|
newAPIError: respErr,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if usageA == nil {
|
if usageA == nil {
|
||||||
return errors.New("usage is nil"), types.NewError(errors.New("usage is nil"), types.ErrorCodeBadResponseBody)
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: errors.New("usage is nil"),
|
||||||
|
newAPIError: types.NewOpenAIError(errors.New("usage is nil"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
usage := usageA.(*dto.Usage)
|
usage := usageA.(*dto.Usage)
|
||||||
result := w.Result()
|
result := w.Result()
|
||||||
respBody, err := io.ReadAll(result.Body)
|
respBody, err := io.ReadAll(result.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err, types.NewError(err, types.ErrorCodeReadResponseBodyFailed)
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: err,
|
||||||
|
newAPIError: types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
info.PromptTokens = usage.PromptTokens
|
info.PromptTokens = usage.PromptTokens
|
||||||
|
|
||||||
@@ -183,30 +377,94 @@ func testChannel(channel *model.Channel, testModel string) (err error, newAPIErr
|
|||||||
Quota: quota,
|
Quota: quota,
|
||||||
Content: "模型测试",
|
Content: "模型测试",
|
||||||
UseTimeSeconds: int(consumedTime),
|
UseTimeSeconds: int(consumedTime),
|
||||||
IsStream: false,
|
IsStream: info.IsStream,
|
||||||
Group: info.UsingGroup,
|
Group: info.UsingGroup,
|
||||||
Other: other,
|
Other: other,
|
||||||
})
|
})
|
||||||
common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
|
common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
|
||||||
return nil, nil
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: nil,
|
||||||
|
newAPIError: nil,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
|
func buildTestRequest(model string, endpointType string) dto.Request {
|
||||||
testRequest := &dto.GeneralOpenAIRequest{
|
// 根据端点类型构建不同的测试请求
|
||||||
Model: "", // this will be set later
|
if endpointType != "" {
|
||||||
Stream: false,
|
switch constant.EndpointType(endpointType) {
|
||||||
|
case constant.EndpointTypeEmbeddings:
|
||||||
|
// 返回 EmbeddingRequest
|
||||||
|
return &dto.EmbeddingRequest{
|
||||||
|
Model: model,
|
||||||
|
Input: []any{"hello world"},
|
||||||
|
}
|
||||||
|
case constant.EndpointTypeImageGeneration:
|
||||||
|
// 返回 ImageRequest
|
||||||
|
return &dto.ImageRequest{
|
||||||
|
Model: model,
|
||||||
|
Prompt: "a cute cat",
|
||||||
|
N: 1,
|
||||||
|
Size: "1024x1024",
|
||||||
|
}
|
||||||
|
case constant.EndpointTypeJinaRerank:
|
||||||
|
// 返回 RerankRequest
|
||||||
|
return &dto.RerankRequest{
|
||||||
|
Model: model,
|
||||||
|
Query: "What is Deep Learning?",
|
||||||
|
Documents: []any{"Deep Learning is a subset of machine learning.", "Machine learning is a field of artificial intelligence."},
|
||||||
|
TopN: 2,
|
||||||
|
}
|
||||||
|
case constant.EndpointTypeOpenAIResponse:
|
||||||
|
// 返回 OpenAIResponsesRequest
|
||||||
|
return &dto.OpenAIResponsesRequest{
|
||||||
|
Model: model,
|
||||||
|
Input: json.RawMessage("\"hi\""),
|
||||||
|
}
|
||||||
|
case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAI:
|
||||||
|
// 返回 GeneralOpenAIRequest
|
||||||
|
maxTokens := uint(10)
|
||||||
|
if constant.EndpointType(endpointType) == constant.EndpointTypeGemini {
|
||||||
|
maxTokens = 3000
|
||||||
|
}
|
||||||
|
return &dto.GeneralOpenAIRequest{
|
||||||
|
Model: model,
|
||||||
|
Stream: false,
|
||||||
|
Messages: []dto.Message{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: "hi",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MaxTokens: maxTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自动检测逻辑(保持原有行为)
|
||||||
// 先判断是否为 Embedding 模型
|
// 先判断是否为 Embedding 模型
|
||||||
if strings.Contains(strings.ToLower(model), "embedding") || // 其他 embedding 模型
|
if strings.Contains(strings.ToLower(model), "embedding") ||
|
||||||
strings.HasPrefix(model, "m3e") || // m3e 系列模型
|
strings.HasPrefix(model, "m3e") ||
|
||||||
strings.Contains(model, "bge-") {
|
strings.Contains(model, "bge-") {
|
||||||
testRequest.Model = model
|
// 返回 EmbeddingRequest
|
||||||
// Embedding 请求
|
return &dto.EmbeddingRequest{
|
||||||
testRequest.Input = []string{"hello world"}
|
Model: model,
|
||||||
return testRequest
|
Input: []any{"hello world"},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 并非Embedding 模型
|
|
||||||
|
// Chat/Completion 请求 - 返回 GeneralOpenAIRequest
|
||||||
|
testRequest := &dto.GeneralOpenAIRequest{
|
||||||
|
Model: model,
|
||||||
|
Stream: false,
|
||||||
|
Messages: []dto.Message{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: "hi",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(model, "o") {
|
if strings.HasPrefix(model, "o") {
|
||||||
testRequest.MaxCompletionTokens = 10
|
testRequest.MaxCompletionTokens = 10
|
||||||
} else if strings.Contains(model, "thinking") {
|
} else if strings.Contains(model, "thinking") {
|
||||||
@@ -219,43 +477,48 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
|
|||||||
testRequest.MaxTokens = 10
|
testRequest.MaxTokens = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
testMessage := dto.Message{
|
|
||||||
Role: "user",
|
|
||||||
Content: "hi",
|
|
||||||
}
|
|
||||||
testRequest.Model = model
|
|
||||||
testRequest.Messages = append(testRequest.Messages, testMessage)
|
|
||||||
return testRequest
|
return testRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestChannel(c *gin.Context) {
|
func TestChannel(c *gin.Context) {
|
||||||
channelId, err := strconv.Atoi(c.Param("id"))
|
channelId, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
channel, err := model.GetChannelById(channelId, true)
|
channel, err := model.CacheGetChannel(channelId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
channel, err = model.GetChannelById(channelId, true)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//defer func() {
|
||||||
|
// if channel.ChannelInfo.IsMultiKey {
|
||||||
|
// go func() { _ = channel.SaveChannelInfo() }()
|
||||||
|
// }
|
||||||
|
//}()
|
||||||
|
testModel := c.Query("model")
|
||||||
|
endpointType := c.Query("endpoint_type")
|
||||||
|
tik := time.Now()
|
||||||
|
result := testChannel(channel, testModel, endpointType)
|
||||||
|
if result.localErr != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": err.Error(),
|
"message": result.localErr.Error(),
|
||||||
|
"time": 0.0,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
testModel := c.Query("model")
|
|
||||||
tik := time.Now()
|
|
||||||
_, newAPIError := testChannel(channel, testModel)
|
|
||||||
tok := time.Now()
|
tok := time.Now()
|
||||||
milliseconds := tok.Sub(tik).Milliseconds()
|
milliseconds := tok.Sub(tik).Milliseconds()
|
||||||
go channel.UpdateResponseTime(milliseconds)
|
go channel.UpdateResponseTime(milliseconds)
|
||||||
consumedTime := float64(milliseconds) / 1000.0
|
consumedTime := float64(milliseconds) / 1000.0
|
||||||
if newAPIError != nil {
|
if result.newAPIError != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": newAPIError.Error(),
|
"message": result.newAPIError.Error(),
|
||||||
"time": consumedTime,
|
"time": consumedTime,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -265,7 +528,6 @@ func TestChannel(c *gin.Context) {
|
|||||||
"message": "",
|
"message": "",
|
||||||
"time": consumedTime,
|
"time": consumedTime,
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var testAllChannelsLock sync.Mutex
|
var testAllChannelsLock sync.Mutex
|
||||||
@@ -280,9 +542,9 @@ func testAllChannels(notify bool) error {
|
|||||||
}
|
}
|
||||||
testAllChannelsRunning = true
|
testAllChannelsRunning = true
|
||||||
testAllChannelsLock.Unlock()
|
testAllChannelsLock.Unlock()
|
||||||
channels, err := model.GetAllChannels(0, 0, true, false)
|
channels, getChannelErr := model.GetAllChannels(0, 0, true, false)
|
||||||
if err != nil {
|
if getChannelErr != nil {
|
||||||
return err
|
return getChannelErr
|
||||||
}
|
}
|
||||||
var disableThreshold = int64(common.ChannelDisableThreshold * 1000)
|
var disableThreshold = int64(common.ChannelDisableThreshold * 1000)
|
||||||
if disableThreshold == 0 {
|
if disableThreshold == 0 {
|
||||||
@@ -299,30 +561,34 @@ func testAllChannels(notify bool) error {
|
|||||||
for _, channel := range channels {
|
for _, channel := range channels {
|
||||||
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
|
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
|
||||||
tik := time.Now()
|
tik := time.Now()
|
||||||
err, newAPIError := testChannel(channel, "")
|
result := testChannel(channel, "", "")
|
||||||
tok := time.Now()
|
tok := time.Now()
|
||||||
milliseconds := tok.Sub(tik).Milliseconds()
|
milliseconds := tok.Sub(tik).Milliseconds()
|
||||||
|
|
||||||
shouldBanChannel := false
|
shouldBanChannel := false
|
||||||
|
newAPIError := result.newAPIError
|
||||||
// request error disables the channel
|
// request error disables the channel
|
||||||
if err != nil {
|
if newAPIError != nil {
|
||||||
shouldBanChannel = service.ShouldDisableChannel(channel.Type, newAPIError)
|
shouldBanChannel = service.ShouldDisableChannel(channel.Type, result.newAPIError)
|
||||||
}
|
}
|
||||||
|
|
||||||
if milliseconds > disableThreshold {
|
// 当错误检查通过,才检查响应时间
|
||||||
err = errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0))
|
if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
|
||||||
shouldBanChannel = true
|
if milliseconds > disableThreshold {
|
||||||
|
err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)
|
||||||
|
newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
|
||||||
|
shouldBanChannel = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// disable channel
|
// disable channel
|
||||||
if isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
|
if isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
|
||||||
service.DisableChannel(channel.Id, channel.Name, err.Error())
|
processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// enable channel
|
// enable channel
|
||||||
if !isChannelEnabled && service.ShouldEnableChannel(err, newAPIError, channel.Status) {
|
if !isChannelEnabled && service.ShouldEnableChannel(newAPIError, channel.Status) {
|
||||||
service.EnableChannel(channel.Id, channel.Name)
|
service.EnableChannel(channel.Id, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
channel.UpdateResponseTime(milliseconds)
|
channel.UpdateResponseTime(milliseconds)
|
||||||
@@ -339,28 +605,39 @@ func testAllChannels(notify bool) error {
|
|||||||
func TestAllChannels(c *gin.Context) {
|
func TestAllChannels(c *gin.Context) {
|
||||||
err := testAllChannels(true)
|
err := testAllChannels(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": "",
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func AutomaticallyTestChannels(frequency int) {
|
var autoTestChannelsOnce sync.Once
|
||||||
if frequency <= 0 {
|
|
||||||
common.SysLog("CHANNEL_TEST_FREQUENCY is not set or invalid, skipping automatic channel test")
|
func AutomaticallyTestChannels() {
|
||||||
|
// 只在Master节点定时测试渠道
|
||||||
|
if !common.IsMasterNode {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for {
|
autoTestChannelsOnce.Do(func() {
|
||||||
time.Sleep(time.Duration(frequency) * time.Minute)
|
for {
|
||||||
common.SysLog("testing all channels")
|
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
|
||||||
_ = testAllChannels(false)
|
time.Sleep(1 * time.Minute)
|
||||||
common.SysLog("channel test finished")
|
continue
|
||||||
}
|
}
|
||||||
|
for {
|
||||||
|
frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
|
||||||
|
time.Sleep(time.Duration(int(math.Round(frequency))) * time.Minute)
|
||||||
|
common.SysLog(fmt.Sprintf("automatically test channels with interval %f minutes", frequency))
|
||||||
|
common.SysLog("automatically testing all channels")
|
||||||
|
_ = testAllChannels(false)
|
||||||
|
common.SysLog("automatically channel test finished")
|
||||||
|
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,101 +3,103 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/common"
|
|
||||||
"one-api/model"
|
"github.com/QuantumNous/new-api/common"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MigrateConsoleSetting 迁移旧的控制台相关配置到 console_setting.*
|
// MigrateConsoleSetting 迁移旧的控制台相关配置到 console_setting.*
|
||||||
func MigrateConsoleSetting(c *gin.Context) {
|
func MigrateConsoleSetting(c *gin.Context) {
|
||||||
// 读取全部 option
|
// 读取全部 option
|
||||||
opts, err := model.AllOption()
|
opts, err := model.AllOption()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 建立 map
|
// 建立 map
|
||||||
valMap := map[string]string{}
|
valMap := map[string]string{}
|
||||||
for _, o := range opts {
|
for _, o := range opts {
|
||||||
valMap[o.Key] = o.Value
|
valMap[o.Key] = o.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 APIInfo
|
// 处理 APIInfo
|
||||||
if v := valMap["ApiInfo"]; v != "" {
|
if v := valMap["ApiInfo"]; v != "" {
|
||||||
var arr []map[string]interface{}
|
var arr []map[string]interface{}
|
||||||
if err := json.Unmarshal([]byte(v), &arr); err == nil {
|
if err := json.Unmarshal([]byte(v), &arr); err == nil {
|
||||||
if len(arr) > 50 {
|
if len(arr) > 50 {
|
||||||
arr = arr[:50]
|
arr = arr[:50]
|
||||||
}
|
}
|
||||||
bytes, _ := json.Marshal(arr)
|
bytes, _ := json.Marshal(arr)
|
||||||
model.UpdateOption("console_setting.api_info", string(bytes))
|
model.UpdateOption("console_setting.api_info", string(bytes))
|
||||||
}
|
}
|
||||||
model.UpdateOption("ApiInfo", "")
|
model.UpdateOption("ApiInfo", "")
|
||||||
}
|
}
|
||||||
// Announcements 直接搬
|
// Announcements 直接搬
|
||||||
if v := valMap["Announcements"]; v != "" {
|
if v := valMap["Announcements"]; v != "" {
|
||||||
model.UpdateOption("console_setting.announcements", v)
|
model.UpdateOption("console_setting.announcements", v)
|
||||||
model.UpdateOption("Announcements", "")
|
model.UpdateOption("Announcements", "")
|
||||||
}
|
}
|
||||||
// FAQ 转换
|
// FAQ 转换
|
||||||
if v := valMap["FAQ"]; v != "" {
|
if v := valMap["FAQ"]; v != "" {
|
||||||
var arr []map[string]interface{}
|
var arr []map[string]interface{}
|
||||||
if err := json.Unmarshal([]byte(v), &arr); err == nil {
|
if err := json.Unmarshal([]byte(v), &arr); err == nil {
|
||||||
out := []map[string]interface{}{}
|
out := []map[string]interface{}{}
|
||||||
for _, item := range arr {
|
for _, item := range arr {
|
||||||
q, _ := item["question"].(string)
|
q, _ := item["question"].(string)
|
||||||
if q == "" {
|
if q == "" {
|
||||||
q, _ = item["title"].(string)
|
q, _ = item["title"].(string)
|
||||||
}
|
}
|
||||||
a, _ := item["answer"].(string)
|
a, _ := item["answer"].(string)
|
||||||
if a == "" {
|
if a == "" {
|
||||||
a, _ = item["content"].(string)
|
a, _ = item["content"].(string)
|
||||||
}
|
}
|
||||||
if q != "" && a != "" {
|
if q != "" && a != "" {
|
||||||
out = append(out, map[string]interface{}{"question": q, "answer": a})
|
out = append(out, map[string]interface{}{"question": q, "answer": a})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(out) > 50 {
|
if len(out) > 50 {
|
||||||
out = out[:50]
|
out = out[:50]
|
||||||
}
|
}
|
||||||
bytes, _ := json.Marshal(out)
|
bytes, _ := json.Marshal(out)
|
||||||
model.UpdateOption("console_setting.faq", string(bytes))
|
model.UpdateOption("console_setting.faq", string(bytes))
|
||||||
}
|
}
|
||||||
model.UpdateOption("FAQ", "")
|
model.UpdateOption("FAQ", "")
|
||||||
}
|
}
|
||||||
// Uptime Kuma 迁移到新的 groups 结构(console_setting.uptime_kuma_groups)
|
// Uptime Kuma 迁移到新的 groups 结构(console_setting.uptime_kuma_groups)
|
||||||
url := valMap["UptimeKumaUrl"]
|
url := valMap["UptimeKumaUrl"]
|
||||||
slug := valMap["UptimeKumaSlug"]
|
slug := valMap["UptimeKumaSlug"]
|
||||||
if url != "" && slug != "" {
|
if url != "" && slug != "" {
|
||||||
// 仅当同时存在 URL 与 Slug 时才进行迁移
|
// 仅当同时存在 URL 与 Slug 时才进行迁移
|
||||||
groups := []map[string]interface{}{
|
groups := []map[string]interface{}{
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"categoryName": "old",
|
"categoryName": "old",
|
||||||
"url": url,
|
"url": url,
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
"description": "",
|
"description": "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
bytes, _ := json.Marshal(groups)
|
bytes, _ := json.Marshal(groups)
|
||||||
model.UpdateOption("console_setting.uptime_kuma_groups", string(bytes))
|
model.UpdateOption("console_setting.uptime_kuma_groups", string(bytes))
|
||||||
}
|
}
|
||||||
// 清空旧键内容
|
// 清空旧键内容
|
||||||
if url != "" {
|
if url != "" {
|
||||||
model.UpdateOption("UptimeKumaUrl", "")
|
model.UpdateOption("UptimeKumaUrl", "")
|
||||||
}
|
}
|
||||||
if slug != "" {
|
if slug != "" {
|
||||||
model.UpdateOption("UptimeKumaSlug", "")
|
model.UpdateOption("UptimeKumaSlug", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除旧键记录
|
// 删除旧键记录
|
||||||
oldKeys := []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"}
|
oldKeys := []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"}
|
||||||
model.DB.Where("key IN ?", oldKeys).Delete(&model.Option{})
|
model.DB.Where("key IN ?", oldKeys).Delete(&model.Option{})
|
||||||
|
|
||||||
// 重新加载 OptionMap
|
// 重新加载 OptionMap
|
||||||
model.InitOptionMap()
|
model.InitOptionMap()
|
||||||
common.SysLog("console setting migrated")
|
common.SysLog("console setting migrated")
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "migrated"})
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "migrated"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gin-contrib/sessions"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/common"
|
|
||||||
"one-api/model"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GitHubOAuthResponse struct {
|
type GitHubOAuthResponse struct {
|
||||||
@@ -103,10 +105,7 @@ func GitHubOAuth(c *gin.Context) {
|
|||||||
code := c.Query("code")
|
code := c.Query("code")
|
||||||
githubUser, err := getGitHubUserInfoByCode(code)
|
githubUser, err := getGitHubUserInfoByCode(code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user := model.User{
|
user := model.User{
|
||||||
@@ -185,10 +184,7 @@ func GitHubBind(c *gin.Context) {
|
|||||||
code := c.Query("code")
|
code := c.Query("code")
|
||||||
githubUser, err := getGitHubUserInfoByCode(code)
|
githubUser, err := getGitHubUserInfoByCode(code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user := model.User{
|
user := model.User{
|
||||||
@@ -207,19 +203,13 @@ func GitHubBind(c *gin.Context) {
|
|||||||
user.Id = id.(int)
|
user.Id = id.(int)
|
||||||
err = user.FillUserById()
|
err = user.FillUserById()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user.GitHubId = githubUser.Login
|
user.GitHubId = githubUser.Login
|
||||||
err = user.Update(false)
|
err = user.Update(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -239,10 +229,7 @@ func GenerateOAuthCode(c *gin.Context) {
|
|||||||
session.Set("oauth_state", state)
|
session.Set("oauth_state", state)
|
||||||
err := session.Save()
|
err := session.Save()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/model"
|
|
||||||
"one-api/setting"
|
"github.com/QuantumNous/new-api/model"
|
||||||
"one-api/setting/ratio_setting"
|
"github.com/QuantumNous/new-api/service"
|
||||||
|
"github.com/QuantumNous/new-api/setting"
|
||||||
|
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -26,17 +28,17 @@ func GetUserGroups(c *gin.Context) {
|
|||||||
userGroup := ""
|
userGroup := ""
|
||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
userGroup, _ = model.GetUserGroup(userId, false)
|
userGroup, _ = model.GetUserGroup(userId, false)
|
||||||
for groupName, ratio := range ratio_setting.GetGroupRatioCopy() {
|
userUsableGroups := service.GetUserUsableGroups(userGroup)
|
||||||
|
for groupName, _ := range ratio_setting.GetGroupRatioCopy() {
|
||||||
// UserUsableGroups contains the groups that the user can use
|
// UserUsableGroups contains the groups that the user can use
|
||||||
userUsableGroups := setting.GetUserUsableGroups(userGroup)
|
|
||||||
if desc, ok := userUsableGroups[groupName]; ok {
|
if desc, ok := userUsableGroups[groupName]; ok {
|
||||||
usableGroups[groupName] = map[string]interface{}{
|
usableGroups[groupName] = map[string]interface{}{
|
||||||
"ratio": ratio,
|
"ratio": service.GetUserGroupRatio(userGroup, groupName),
|
||||||
"desc": desc,
|
"desc": desc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if setting.GroupInUserUsableGroups("auto") {
|
if _, ok := userUsableGroups["auto"]; ok {
|
||||||
usableGroups["auto"] = map[string]interface{}{
|
usableGroups["auto"] = map[string]interface{}{
|
||||||
"ratio": "自动",
|
"ratio": "自动",
|
||||||
"desc": setting.GetUsableGroupDescription("auto"),
|
"desc": setting.GetUsableGroupDescription("auto"),
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"one-api/common"
|
|
||||||
"one-api/model"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -38,10 +39,7 @@ func LinuxDoBind(c *gin.Context) {
|
|||||||
code := c.Query("code")
|
code := c.Query("code")
|
||||||
linuxdoUser, err := getLinuxdoUserInfoByCode(code, c)
|
linuxdoUser, err := getLinuxdoUserInfoByCode(code, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,20 +61,14 @@ func LinuxDoBind(c *gin.Context) {
|
|||||||
|
|
||||||
err = user.FillUserById()
|
err = user.FillUserById()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user.LinuxDOId = strconv.Itoa(linuxdoUser.Id)
|
user.LinuxDOId = strconv.Itoa(linuxdoUser.Id)
|
||||||
err = user.Update(false)
|
err = user.Update(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,10 +194,7 @@ func LinuxdoOAuth(c *gin.Context) {
|
|||||||
code := c.Query("code")
|
code := c.Query("code")
|
||||||
linuxdoUser, err := getLinuxdoUserInfoByCode(code, c)
|
linuxdoUser, err := getLinuxdoUserInfoByCode(code, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,21 +221,29 @@ func LinuxdoOAuth(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if common.RegisterEnabled {
|
if common.RegisterEnabled {
|
||||||
user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1)
|
if linuxdoUser.TrustLevel >= common.LinuxDOMinimumTrustLevel {
|
||||||
user.DisplayName = linuxdoUser.Name
|
user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1)
|
||||||
user.Role = common.RoleCommonUser
|
user.DisplayName = linuxdoUser.Name
|
||||||
user.Status = common.UserStatusEnabled
|
user.Role = common.RoleCommonUser
|
||||||
|
user.Status = common.UserStatusEnabled
|
||||||
|
|
||||||
affCode := session.Get("aff")
|
affCode := session.Get("aff")
|
||||||
inviterId := 0
|
inviterId := 0
|
||||||
if affCode != nil {
|
if affCode != nil {
|
||||||
inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
|
inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := user.Insert(inviterId); err != nil {
|
if err := user.Insert(inviterId); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": err.Error(),
|
"message": "Linux DO 信任等级未达到管理员设置的最低信任等级",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,16 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/common"
|
|
||||||
"one-api/model"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetAllLogs(c *gin.Context) {
|
func GetAllLogs(c *gin.Context) {
|
||||||
p, _ := strconv.Atoi(c.Query("p"))
|
pageInfo := common.GetPageQuery(c)
|
||||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
|
||||||
if p < 1 {
|
|
||||||
p = 1
|
|
||||||
}
|
|
||||||
if pageSize < 0 {
|
|
||||||
pageSize = common.ItemsPerPage
|
|
||||||
}
|
|
||||||
logType, _ := strconv.Atoi(c.Query("type"))
|
logType, _ := strconv.Atoi(c.Query("type"))
|
||||||
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
|
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
|
||||||
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
|
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
|
||||||
@@ -26,38 +20,19 @@ func GetAllLogs(c *gin.Context) {
|
|||||||
modelName := c.Query("model_name")
|
modelName := c.Query("model_name")
|
||||||
channel, _ := strconv.Atoi(c.Query("channel"))
|
channel, _ := strconv.Atoi(c.Query("channel"))
|
||||||
group := c.Query("group")
|
group := c.Query("group")
|
||||||
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, (p-1)*pageSize, pageSize, channel, group)
|
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
pageInfo.SetTotal(int(total))
|
||||||
"success": true,
|
pageInfo.SetItems(logs)
|
||||||
"message": "",
|
common.ApiSuccess(c, pageInfo)
|
||||||
"data": map[string]any{
|
return
|
||||||
"items": logs,
|
|
||||||
"total": total,
|
|
||||||
"page": p,
|
|
||||||
"page_size": pageSize,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUserLogs(c *gin.Context) {
|
func GetUserLogs(c *gin.Context) {
|
||||||
p, _ := strconv.Atoi(c.Query("p"))
|
pageInfo := common.GetPageQuery(c)
|
||||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
|
||||||
if p < 1 {
|
|
||||||
p = 1
|
|
||||||
}
|
|
||||||
if pageSize < 0 {
|
|
||||||
pageSize = common.ItemsPerPage
|
|
||||||
}
|
|
||||||
if pageSize > 100 {
|
|
||||||
pageSize = 100
|
|
||||||
}
|
|
||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
logType, _ := strconv.Atoi(c.Query("type"))
|
logType, _ := strconv.Atoi(c.Query("type"))
|
||||||
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
|
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
|
||||||
@@ -65,24 +40,14 @@ func GetUserLogs(c *gin.Context) {
|
|||||||
tokenName := c.Query("token_name")
|
tokenName := c.Query("token_name")
|
||||||
modelName := c.Query("model_name")
|
modelName := c.Query("model_name")
|
||||||
group := c.Query("group")
|
group := c.Query("group")
|
||||||
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, (p-1)*pageSize, pageSize, group)
|
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
pageInfo.SetTotal(int(total))
|
||||||
"success": true,
|
pageInfo.SetItems(logs)
|
||||||
"message": "",
|
common.ApiSuccess(c, pageInfo)
|
||||||
"data": map[string]any{
|
|
||||||
"items": logs,
|
|
||||||
"total": total,
|
|
||||||
"page": p,
|
|
||||||
"page_size": pageSize,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,10 +55,7 @@ func SearchAllLogs(c *gin.Context) {
|
|||||||
keyword := c.Query("keyword")
|
keyword := c.Query("keyword")
|
||||||
logs, err := model.SearchAllLogs(keyword)
|
logs, err := model.SearchAllLogs(keyword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -109,10 +71,7 @@ func SearchUserLogs(c *gin.Context) {
|
|||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
logs, err := model.SearchUserLogs(userId, keyword)
|
logs, err := model.SearchUserLogs(userId, keyword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -198,10 +157,7 @@ func DeleteHistoryLogs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
count, err := model.DeleteOldLog(c.Request.Context(), targetTimestamp, 100)
|
count, err := model.DeleteOldLog(c.Request.Context(), targetTimestamp, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/common"
|
|
||||||
"one-api/dto"
|
|
||||||
"one-api/model"
|
|
||||||
"one-api/service"
|
|
||||||
"one-api/setting"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/QuantumNous/new-api/logger"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/service"
|
||||||
|
"github.com/QuantumNous/new-api/setting"
|
||||||
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func UpdateMidjourneyTaskBulk() {
|
func UpdateMidjourneyTaskBulk() {
|
||||||
@@ -28,7 +31,7 @@ func UpdateMidjourneyTaskBulk() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
common.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
|
logger.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
|
||||||
taskChannelM := make(map[int][]string)
|
taskChannelM := make(map[int][]string)
|
||||||
taskM := make(map[string]*model.Midjourney)
|
taskM := make(map[string]*model.Midjourney)
|
||||||
nullTaskIds := make([]int, 0)
|
nullTaskIds := make([]int, 0)
|
||||||
@@ -47,9 +50,9 @@ func UpdateMidjourneyTaskBulk() {
|
|||||||
"progress": "100%",
|
"progress": "100%",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err))
|
logger.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err))
|
||||||
} else {
|
} else {
|
||||||
common.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds))
|
logger.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(taskChannelM) == 0 {
|
if len(taskChannelM) == 0 {
|
||||||
@@ -57,20 +60,20 @@ func UpdateMidjourneyTaskBulk() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for channelId, taskIds := range taskChannelM {
|
for channelId, taskIds := range taskChannelM {
|
||||||
common.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
|
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
|
||||||
if len(taskIds) == 0 {
|
if len(taskIds) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
midjourneyChannel, err := model.CacheGetChannel(channelId)
|
midjourneyChannel, err := model.CacheGetChannel(channelId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogError(ctx, fmt.Sprintf("CacheGetChannel: %v", err))
|
logger.LogError(ctx, fmt.Sprintf("CacheGetChannel: %v", err))
|
||||||
err := model.MjBulkUpdate(taskIds, map[string]any{
|
err := model.MjBulkUpdate(taskIds, map[string]any{
|
||||||
"fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId),
|
"fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId),
|
||||||
"status": "FAILURE",
|
"status": "FAILURE",
|
||||||
"progress": "100%",
|
"progress": "100%",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err))
|
logger.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -81,7 +84,7 @@ func UpdateMidjourneyTaskBulk() {
|
|||||||
})
|
})
|
||||||
req, err := http.NewRequest("POST", requestUrl, bytes.NewBuffer(body))
|
req, err := http.NewRequest("POST", requestUrl, bytes.NewBuffer(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogError(ctx, fmt.Sprintf("Get Task error: %v", err))
|
logger.LogError(ctx, fmt.Sprintf("Get Task error: %v", err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 设置超时时间
|
// 设置超时时间
|
||||||
@@ -93,22 +96,22 @@ func UpdateMidjourneyTaskBulk() {
|
|||||||
req.Header.Set("mj-api-secret", midjourneyChannel.Key)
|
req.Header.Set("mj-api-secret", midjourneyChannel.Key)
|
||||||
resp, err := service.GetHttpClient().Do(req)
|
resp, err := service.GetHttpClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err))
|
logger.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
common.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
|
logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
responseBody, err := io.ReadAll(resp.Body)
|
responseBody, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogError(ctx, fmt.Sprintf("Get Task parse body error: %v", err))
|
logger.LogError(ctx, fmt.Sprintf("Get Task parse body error: %v", err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var responseItems []dto.MidjourneyDto
|
var responseItems []dto.MidjourneyDto
|
||||||
err = json.Unmarshal(responseBody, &responseItems)
|
err = json.Unmarshal(responseBody, &responseItems)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
|
logger.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -145,9 +148,25 @@ func UpdateMidjourneyTaskBulk() {
|
|||||||
buttonStr, _ := json.Marshal(responseItem.Buttons)
|
buttonStr, _ := json.Marshal(responseItem.Buttons)
|
||||||
task.Buttons = string(buttonStr)
|
task.Buttons = string(buttonStr)
|
||||||
}
|
}
|
||||||
|
// 映射 VideoUrl
|
||||||
|
task.VideoUrl = responseItem.VideoUrl
|
||||||
|
|
||||||
|
// 映射 VideoUrls - 将数组序列化为 JSON 字符串
|
||||||
|
if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 {
|
||||||
|
videoUrlsStr, err := json.Marshal(responseItem.VideoUrls)
|
||||||
|
if err != nil {
|
||||||
|
logger.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err))
|
||||||
|
task.VideoUrls = "[]" // 失败时设置为空数组
|
||||||
|
} else {
|
||||||
|
task.VideoUrls = string(videoUrlsStr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
task.VideoUrls = "" // 空值时清空字段
|
||||||
|
}
|
||||||
|
|
||||||
shouldReturnQuota := false
|
shouldReturnQuota := false
|
||||||
if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
|
if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
|
||||||
common.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
|
logger.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
|
||||||
task.Progress = "100%"
|
task.Progress = "100%"
|
||||||
if task.Quota != 0 {
|
if task.Quota != 0 {
|
||||||
shouldReturnQuota = true
|
shouldReturnQuota = true
|
||||||
@@ -155,14 +174,14 @@ func UpdateMidjourneyTaskBulk() {
|
|||||||
}
|
}
|
||||||
err = task.Update()
|
err = task.Update()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error())
|
logger.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error())
|
||||||
} else {
|
} else {
|
||||||
if shouldReturnQuota {
|
if shouldReturnQuota {
|
||||||
err = model.IncreaseUserQuota(task.UserId, task.Quota, false)
|
err = model.IncreaseUserQuota(task.UserId, task.Quota, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogError(ctx, "fail to increase user quota: "+err.Error())
|
logger.LogError(ctx, "fail to increase user quota: "+err.Error())
|
||||||
}
|
}
|
||||||
logContent := fmt.Sprintf("构图失败 %s,补偿 %s", task.MjId, common.LogQuota(task.Quota))
|
logContent := fmt.Sprintf("构图失败 %s,补偿 %s", task.MjId, logger.LogQuota(task.Quota))
|
||||||
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
|
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,19 +227,26 @@ func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto)
|
|||||||
if oldTask.Progress != "100%" && newTask.FailReason != "" {
|
if oldTask.Progress != "100%" && newTask.FailReason != "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// 检查 VideoUrl 是否需要更新
|
||||||
|
if oldTask.VideoUrl != newTask.VideoUrl {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 检查 VideoUrls 是否需要更新
|
||||||
|
if newTask.VideoUrls != nil && len(newTask.VideoUrls) > 0 {
|
||||||
|
newVideoUrlsStr, _ := json.Marshal(newTask.VideoUrls)
|
||||||
|
if oldTask.VideoUrls != string(newVideoUrlsStr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else if oldTask.VideoUrls != "" {
|
||||||
|
// 如果新数据没有 VideoUrls 但旧数据有,需要更新(清空)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAllMidjourney(c *gin.Context) {
|
func GetAllMidjourney(c *gin.Context) {
|
||||||
p, _ := strconv.Atoi(c.Query("p"))
|
pageInfo := common.GetPageQuery(c)
|
||||||
if p < 1 {
|
|
||||||
p = 1
|
|
||||||
}
|
|
||||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
|
||||||
if pageSize <= 0 {
|
|
||||||
pageSize = common.ItemsPerPage
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析其他查询参数
|
// 解析其他查询参数
|
||||||
queryParams := model.TaskQueryParams{
|
queryParams := model.TaskQueryParams{
|
||||||
@@ -230,36 +256,22 @@ func GetAllMidjourney(c *gin.Context) {
|
|||||||
EndTimestamp: c.Query("end_timestamp"),
|
EndTimestamp: c.Query("end_timestamp"),
|
||||||
}
|
}
|
||||||
|
|
||||||
items := model.GetAllTasks((p-1)*pageSize, pageSize, queryParams)
|
items := model.GetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
|
||||||
total := model.CountAllTasks(queryParams)
|
total := model.CountAllTasks(queryParams)
|
||||||
|
|
||||||
if setting.MjForwardUrlEnabled {
|
if setting.MjForwardUrlEnabled {
|
||||||
for i, midjourney := range items {
|
for i, midjourney := range items {
|
||||||
midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
|
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
|
||||||
items[i] = midjourney
|
items[i] = midjourney
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.JSON(200, gin.H{
|
pageInfo.SetTotal(int(total))
|
||||||
"success": true,
|
pageInfo.SetItems(items)
|
||||||
"message": "",
|
common.ApiSuccess(c, pageInfo)
|
||||||
"data": gin.H{
|
|
||||||
"items": items,
|
|
||||||
"total": total,
|
|
||||||
"page": p,
|
|
||||||
"page_size": pageSize,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUserMidjourney(c *gin.Context) {
|
func GetUserMidjourney(c *gin.Context) {
|
||||||
p, _ := strconv.Atoi(c.Query("p"))
|
pageInfo := common.GetPageQuery(c)
|
||||||
if p < 1 {
|
|
||||||
p = 1
|
|
||||||
}
|
|
||||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
|
||||||
if pageSize <= 0 {
|
|
||||||
pageSize = common.ItemsPerPage
|
|
||||||
}
|
|
||||||
|
|
||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
|
|
||||||
@@ -269,23 +281,16 @@ func GetUserMidjourney(c *gin.Context) {
|
|||||||
EndTimestamp: c.Query("end_timestamp"),
|
EndTimestamp: c.Query("end_timestamp"),
|
||||||
}
|
}
|
||||||
|
|
||||||
items := model.GetAllUserTask(userId, (p-1)*pageSize, pageSize, queryParams)
|
items := model.GetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
|
||||||
total := model.CountAllUserTask(userId, queryParams)
|
total := model.CountAllUserTask(userId, queryParams)
|
||||||
|
|
||||||
if setting.MjForwardUrlEnabled {
|
if setting.MjForwardUrlEnabled {
|
||||||
for i, midjourney := range items {
|
for i, midjourney := range items {
|
||||||
midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
|
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
|
||||||
items[i] = midjourney
|
items[i] = midjourney
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.JSON(200, gin.H{
|
pageInfo.SetTotal(int(total))
|
||||||
"success": true,
|
pageInfo.SetItems(items)
|
||||||
"message": "",
|
common.ApiSuccess(c, pageInfo)
|
||||||
"data": gin.H{
|
|
||||||
"items": items,
|
|
||||||
"total": total,
|
|
||||||
"page": p,
|
|
||||||
"page_size": pageSize,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/common"
|
|
||||||
"one-api/constant"
|
|
||||||
"one-api/middleware"
|
|
||||||
"one-api/model"
|
|
||||||
"one-api/setting"
|
|
||||||
"one-api/setting/console_setting"
|
|
||||||
"one-api/setting/operation_setting"
|
|
||||||
"one-api/setting/system_setting"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/middleware"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/setting"
|
||||||
|
"github.com/QuantumNous/new-api/setting/console_setting"
|
||||||
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||||
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,44 +40,54 @@ func TestStatus(c *gin.Context) {
|
|||||||
func GetStatus(c *gin.Context) {
|
func GetStatus(c *gin.Context) {
|
||||||
|
|
||||||
cs := console_setting.GetConsoleSetting()
|
cs := console_setting.GetConsoleSetting()
|
||||||
|
common.OptionMapRWMutex.RLock()
|
||||||
|
defer common.OptionMapRWMutex.RUnlock()
|
||||||
|
|
||||||
|
passkeySetting := system_setting.GetPasskeySettings()
|
||||||
|
legalSetting := system_setting.GetLegalSettings()
|
||||||
|
|
||||||
data := gin.H{
|
data := gin.H{
|
||||||
"version": common.Version,
|
"version": common.Version,
|
||||||
"start_time": common.StartTime,
|
"start_time": common.StartTime,
|
||||||
"email_verification": common.EmailVerificationEnabled,
|
"email_verification": common.EmailVerificationEnabled,
|
||||||
"github_oauth": common.GitHubOAuthEnabled,
|
"github_oauth": common.GitHubOAuthEnabled,
|
||||||
"github_client_id": common.GitHubClientId,
|
"github_client_id": common.GitHubClientId,
|
||||||
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
|
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
|
||||||
"linuxdo_client_id": common.LinuxDOClientId,
|
"linuxdo_client_id": common.LinuxDOClientId,
|
||||||
"telegram_oauth": common.TelegramOAuthEnabled,
|
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
|
||||||
"telegram_bot_name": common.TelegramBotName,
|
"telegram_oauth": common.TelegramOAuthEnabled,
|
||||||
"system_name": common.SystemName,
|
"telegram_bot_name": common.TelegramBotName,
|
||||||
"logo": common.Logo,
|
"system_name": common.SystemName,
|
||||||
"footer_html": common.Footer,
|
"logo": common.Logo,
|
||||||
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
|
"footer_html": common.Footer,
|
||||||
"wechat_login": common.WeChatAuthEnabled,
|
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
|
||||||
"server_address": setting.ServerAddress,
|
"wechat_login": common.WeChatAuthEnabled,
|
||||||
"price": setting.Price,
|
"server_address": system_setting.ServerAddress,
|
||||||
"min_topup": setting.MinTopUp,
|
"turnstile_check": common.TurnstileCheckEnabled,
|
||||||
"turnstile_check": common.TurnstileCheckEnabled,
|
"turnstile_site_key": common.TurnstileSiteKey,
|
||||||
"turnstile_site_key": common.TurnstileSiteKey,
|
"top_up_link": common.TopUpLink,
|
||||||
"top_up_link": common.TopUpLink,
|
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
|
||||||
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
|
"quota_per_unit": common.QuotaPerUnit,
|
||||||
"quota_per_unit": common.QuotaPerUnit,
|
// 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type
|
||||||
"display_in_currency": common.DisplayInCurrencyEnabled,
|
"display_in_currency": operation_setting.IsCurrencyDisplay(),
|
||||||
"enable_batch_update": common.BatchUpdateEnabled,
|
"quota_display_type": operation_setting.GetQuotaDisplayType(),
|
||||||
"enable_drawing": common.DrawingEnabled,
|
"custom_currency_symbol": operation_setting.GetGeneralSetting().CustomCurrencySymbol,
|
||||||
"enable_task": common.TaskEnabled,
|
"custom_currency_exchange_rate": operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate,
|
||||||
"enable_data_export": common.DataExportEnabled,
|
"enable_batch_update": common.BatchUpdateEnabled,
|
||||||
"data_export_default_time": common.DataExportDefaultTime,
|
"enable_drawing": common.DrawingEnabled,
|
||||||
"default_collapse_sidebar": common.DefaultCollapseSidebar,
|
"enable_task": common.TaskEnabled,
|
||||||
"enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
|
"enable_data_export": common.DataExportEnabled,
|
||||||
"mj_notify_enabled": setting.MjNotifyEnabled,
|
"data_export_default_time": common.DataExportDefaultTime,
|
||||||
"chats": setting.Chats,
|
"default_collapse_sidebar": common.DefaultCollapseSidebar,
|
||||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
"mj_notify_enabled": setting.MjNotifyEnabled,
|
||||||
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
"chats": setting.Chats,
|
||||||
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||||
"pay_methods": setting.PayMethods,
|
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
||||||
|
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
||||||
|
|
||||||
|
"usd_exchange_rate": operation_setting.USDExchangeRate,
|
||||||
|
"price": operation_setting.Price,
|
||||||
|
"stripe_unit_price": setting.StripeUnitPrice,
|
||||||
|
|
||||||
// 面板启用开关
|
// 面板启用开关
|
||||||
"api_info_enabled": cs.ApiInfoEnabled,
|
"api_info_enabled": cs.ApiInfoEnabled,
|
||||||
@@ -84,10 +95,23 @@ func GetStatus(c *gin.Context) {
|
|||||||
"announcements_enabled": cs.AnnouncementsEnabled,
|
"announcements_enabled": cs.AnnouncementsEnabled,
|
||||||
"faq_enabled": cs.FAQEnabled,
|
"faq_enabled": cs.FAQEnabled,
|
||||||
|
|
||||||
|
// 模块管理配置
|
||||||
|
"HeaderNavModules": common.OptionMap["HeaderNavModules"],
|
||||||
|
"SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"],
|
||||||
|
|
||||||
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
|
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
|
||||||
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
|
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
|
||||||
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
|
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
|
||||||
|
"passkey_login": passkeySetting.Enabled,
|
||||||
|
"passkey_display_name": passkeySetting.RPDisplayName,
|
||||||
|
"passkey_rp_id": passkeySetting.RPID,
|
||||||
|
"passkey_origins": passkeySetting.Origins,
|
||||||
|
"passkey_allow_insecure": passkeySetting.AllowInsecureOrigin,
|
||||||
|
"passkey_user_verification": passkeySetting.UserVerification,
|
||||||
|
"passkey_attachment": passkeySetting.AttachmentPreference,
|
||||||
"setup": constant.Setup,
|
"setup": constant.Setup,
|
||||||
|
"user_agreement_enabled": legalSetting.UserAgreement != "",
|
||||||
|
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据启用状态注入可选内容
|
// 根据启用状态注入可选内容
|
||||||
@@ -131,6 +155,24 @@ func GetAbout(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetUserAgreement(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": system_setting.GetLegalSettings().UserAgreement,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPrivacyPolicy(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": system_setting.GetLegalSettings().PrivacyPolicy,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func GetMidjourney(c *gin.Context) {
|
func GetMidjourney(c *gin.Context) {
|
||||||
common.OptionMapRWMutex.RLock()
|
common.OptionMapRWMutex.RLock()
|
||||||
defer common.OptionMapRWMutex.RUnlock()
|
defer common.OptionMapRWMutex.RUnlock()
|
||||||
@@ -214,10 +256,7 @@ func SendEmailVerification(c *gin.Context) {
|
|||||||
"<p>验证码 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, code, common.VerificationValidMinutes)
|
"<p>验证码 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, code, common.VerificationValidMinutes)
|
||||||
err := common.SendEmail(subject, email, content)
|
err := common.SendEmail(subject, email, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -245,7 +284,7 @@ func SendPasswordResetEmail(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
code := common.GenerateVerificationCode(0)
|
code := common.GenerateVerificationCode(0)
|
||||||
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
|
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
|
||||||
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", setting.ServerAddress, email, code)
|
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
|
||||||
subject := fmt.Sprintf("%s密码重置", common.SystemName)
|
subject := fmt.Sprintf("%s密码重置", common.SystemName)
|
||||||
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
|
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
|
||||||
"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+
|
"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+
|
||||||
@@ -253,10 +292,7 @@ func SendPasswordResetEmail(c *gin.Context) {
|
|||||||
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes)
|
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes)
|
||||||
err := common.SendEmail(subject, email, content)
|
err := common.SendEmail(subject, email, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -291,10 +327,7 @@ func ResetPassword(c *gin.Context) {
|
|||||||
password := common.GenerateVerificationCode(12)
|
password := common.GenerateVerificationCode(12)
|
||||||
err = model.ResetUserPasswordByEmail(req.Email, password)
|
err = model.ResetUserPasswordByEmail(req.Email, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
common.DeleteKey(req.Email, common.PasswordResetPurpose)
|
common.DeleteKey(req.Email, common.PasswordResetPurpose)
|
||||||
|
|||||||
28
controller/missing_models.go
Normal file
28
controller/missing_models.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetMissingModels returns the list of model names that are referenced by channels
|
||||||
|
// but do not have corresponding records in the models meta table.
|
||||||
|
// This helps administrators quickly discover models that need configuration.
|
||||||
|
func GetMissingModels(c *gin.Context) {
|
||||||
|
missing, err := model.GetMissingModels()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": missing,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,20 +2,22 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/relay"
|
||||||
|
"github.com/QuantumNous/new-api/relay/channel/ai360"
|
||||||
|
"github.com/QuantumNous/new-api/relay/channel/lingyiwanwu"
|
||||||
|
"github.com/QuantumNous/new-api/relay/channel/minimax"
|
||||||
|
"github.com/QuantumNous/new-api/relay/channel/moonshot"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
"github.com/QuantumNous/new-api/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
"net/http"
|
|
||||||
"one-api/common"
|
|
||||||
"one-api/constant"
|
|
||||||
"one-api/dto"
|
|
||||||
"one-api/model"
|
|
||||||
"one-api/relay"
|
|
||||||
"one-api/relay/channel/ai360"
|
|
||||||
"one-api/relay/channel/lingyiwanwu"
|
|
||||||
"one-api/relay/channel/minimax"
|
|
||||||
"one-api/relay/channel/moonshot"
|
|
||||||
relaycommon "one-api/relay/common"
|
|
||||||
"one-api/setting"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// https://platform.openai.com/docs/api-reference/models/list
|
// https://platform.openai.com/docs/api-reference/models/list
|
||||||
@@ -92,7 +94,9 @@ func init() {
|
|||||||
if !success || apiType == constant.APITypeAIProxyLibrary {
|
if !success || apiType == constant.APITypeAIProxyLibrary {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
meta := &relaycommon.RelayInfo{ChannelType: i}
|
meta := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
|
ChannelType: i,
|
||||||
|
}}
|
||||||
adaptor := relay.GetAdaptor(apiType)
|
adaptor := relay.GetAdaptor(apiType)
|
||||||
adaptor.Init(meta)
|
adaptor.Init(meta)
|
||||||
channelId2Models[i] = adaptor.GetModelList()
|
channelId2Models[i] = adaptor.GetModelList()
|
||||||
@@ -102,7 +106,7 @@ func init() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListModels(c *gin.Context) {
|
func ListModels(c *gin.Context, modelType int) {
|
||||||
userOpenAiModels := make([]dto.OpenAIModels, 0)
|
userOpenAiModels := make([]dto.OpenAIModels, 0)
|
||||||
|
|
||||||
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
|
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
|
||||||
@@ -145,7 +149,7 @@ func ListModels(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var models []string
|
var models []string
|
||||||
if tokenGroup == "auto" {
|
if tokenGroup == "auto" {
|
||||||
for _, autoGroup := range setting.AutoGroups {
|
for _, autoGroup := range service.GetUserAutoGroup(userGroup) {
|
||||||
groupModels := model.GetGroupEnabledModels(autoGroup)
|
groupModels := model.GetGroupEnabledModels(autoGroup)
|
||||||
for _, g := range groupModels {
|
for _, g := range groupModels {
|
||||||
if !common.StringsContains(models, g) {
|
if !common.StringsContains(models, g) {
|
||||||
@@ -171,10 +175,42 @@ func ListModels(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.JSON(200, gin.H{
|
switch modelType {
|
||||||
"success": true,
|
case constant.ChannelTypeAnthropic:
|
||||||
"data": userOpenAiModels,
|
useranthropicModels := make([]dto.AnthropicModel, len(userOpenAiModels))
|
||||||
})
|
for i, model := range userOpenAiModels {
|
||||||
|
useranthropicModels[i] = dto.AnthropicModel{
|
||||||
|
ID: model.Id,
|
||||||
|
CreatedAt: time.Unix(int64(model.Created), 0).UTC().Format(time.RFC3339),
|
||||||
|
DisplayName: model.Id,
|
||||||
|
Type: "model",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"data": useranthropicModels,
|
||||||
|
"first_id": useranthropicModels[0].ID,
|
||||||
|
"has_more": false,
|
||||||
|
"last_id": useranthropicModels[len(useranthropicModels)-1].ID,
|
||||||
|
})
|
||||||
|
case constant.ChannelTypeGemini:
|
||||||
|
userGeminiModels := make([]dto.GeminiModel, len(userOpenAiModels))
|
||||||
|
for i, model := range userOpenAiModels {
|
||||||
|
userGeminiModels[i] = dto.GeminiModel{
|
||||||
|
Name: model.Id,
|
||||||
|
DisplayName: model.Id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"models": userGeminiModels,
|
||||||
|
"nextPageToken": nil,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": userOpenAiModels,
|
||||||
|
"object": "list",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ChannelListModels(c *gin.Context) {
|
func ChannelListModels(c *gin.Context) {
|
||||||
@@ -198,10 +234,20 @@ func EnabledListModels(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func RetrieveModel(c *gin.Context) {
|
func RetrieveModel(c *gin.Context, modelType int) {
|
||||||
modelId := c.Param("model")
|
modelId := c.Param("model")
|
||||||
if aiModel, ok := openAIModelsMap[modelId]; ok {
|
if aiModel, ok := openAIModelsMap[modelId]; ok {
|
||||||
c.JSON(200, aiModel)
|
switch modelType {
|
||||||
|
case constant.ChannelTypeAnthropic:
|
||||||
|
c.JSON(200, dto.AnthropicModel{
|
||||||
|
ID: aiModel.Id,
|
||||||
|
CreatedAt: time.Unix(int64(aiModel.Created), 0).UTC().Format(time.RFC3339),
|
||||||
|
DisplayName: aiModel.Id,
|
||||||
|
Type: "model",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
c.JSON(200, aiModel)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
openAIError := dto.OpenAIError{
|
openAIError := dto.OpenAIError{
|
||||||
Message: fmt.Sprintf("The model '%s' does not exist", modelId),
|
Message: fmt.Sprintf("The model '%s' does not exist", modelId),
|
||||||
|
|||||||
330
controller/model_meta.go
Normal file
330
controller/model_meta.go
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetAllModelsMeta 获取模型列表(分页)
|
||||||
|
func GetAllModelsMeta(c *gin.Context) {
|
||||||
|
|
||||||
|
pageInfo := common.GetPageQuery(c)
|
||||||
|
modelsMeta, err := model.GetAllModels(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 批量填充附加字段,提升列表接口性能
|
||||||
|
enrichModels(modelsMeta)
|
||||||
|
var total int64
|
||||||
|
model.DB.Model(&model.Model{}).Count(&total)
|
||||||
|
|
||||||
|
// 统计供应商计数(全部数据,不受分页影响)
|
||||||
|
vendorCounts, _ := model.GetVendorModelCounts()
|
||||||
|
|
||||||
|
pageInfo.SetTotal(int(total))
|
||||||
|
pageInfo.SetItems(modelsMeta)
|
||||||
|
common.ApiSuccess(c, gin.H{
|
||||||
|
"items": modelsMeta,
|
||||||
|
"total": total,
|
||||||
|
"page": pageInfo.GetPage(),
|
||||||
|
"page_size": pageInfo.GetPageSize(),
|
||||||
|
"vendor_counts": vendorCounts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchModelsMeta 搜索模型列表
|
||||||
|
func SearchModelsMeta(c *gin.Context) {
|
||||||
|
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
vendor := c.Query("vendor")
|
||||||
|
pageInfo := common.GetPageQuery(c)
|
||||||
|
|
||||||
|
modelsMeta, total, err := model.SearchModels(keyword, vendor, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 批量填充附加字段,提升列表接口性能
|
||||||
|
enrichModels(modelsMeta)
|
||||||
|
pageInfo.SetTotal(int(total))
|
||||||
|
pageInfo.SetItems(modelsMeta)
|
||||||
|
common.ApiSuccess(c, pageInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModelMeta 根据 ID 获取单条模型信息
|
||||||
|
func GetModelMeta(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var m model.Model
|
||||||
|
if err := model.DB.First(&m, id).Error; err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enrichModels([]*model.Model{&m})
|
||||||
|
common.ApiSuccess(c, &m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateModelMeta 新建模型
|
||||||
|
func CreateModelMeta(c *gin.Context) {
|
||||||
|
var m model.Model
|
||||||
|
if err := c.ShouldBindJSON(&m); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.ModelName == "" {
|
||||||
|
common.ApiErrorMsg(c, "模型名称不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 名称冲突检查
|
||||||
|
if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
} else if dup {
|
||||||
|
common.ApiErrorMsg(c, "模型名称已存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.Insert(); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
model.RefreshPricing()
|
||||||
|
common.ApiSuccess(c, &m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateModelMeta 更新模型
|
||||||
|
func UpdateModelMeta(c *gin.Context) {
|
||||||
|
statusOnly := c.Query("status_only") == "true"
|
||||||
|
|
||||||
|
var m model.Model
|
||||||
|
if err := c.ShouldBindJSON(&m); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.Id == 0 {
|
||||||
|
common.ApiErrorMsg(c, "缺少模型 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusOnly {
|
||||||
|
// 只更新状态,防止误清空其他字段
|
||||||
|
if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 名称冲突检查
|
||||||
|
if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
} else if dup {
|
||||||
|
common.ApiErrorMsg(c, "模型名称已存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.Update(); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model.RefreshPricing()
|
||||||
|
common.ApiSuccess(c, &m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteModelMeta 删除模型
|
||||||
|
func DeleteModelMeta(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := model.DB.Delete(&model.Model{}, id).Error; err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
model.RefreshPricing()
|
||||||
|
common.ApiSuccess(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrichModels 批量填充附加信息:端点、渠道、分组、计费类型,避免 N+1 查询
|
||||||
|
func enrichModels(models []*model.Model) {
|
||||||
|
if len(models) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 拆分精确与规则匹配
|
||||||
|
exactNames := make([]string, 0)
|
||||||
|
exactIdx := make(map[string][]int) // modelName -> indices in models
|
||||||
|
ruleIndices := make([]int, 0)
|
||||||
|
for i, m := range models {
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.NameRule == model.NameRuleExact {
|
||||||
|
exactNames = append(exactNames, m.ModelName)
|
||||||
|
exactIdx[m.ModelName] = append(exactIdx[m.ModelName], i)
|
||||||
|
} else {
|
||||||
|
ruleIndices = append(ruleIndices, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 批量查询精确模型的绑定渠道
|
||||||
|
channelsByModel, _ := model.GetBoundChannelsByModelsMap(exactNames)
|
||||||
|
|
||||||
|
// 3) 精确模型:端点从缓存、渠道批量映射、分组/计费类型从缓存
|
||||||
|
for name, indices := range exactIdx {
|
||||||
|
chs := channelsByModel[name]
|
||||||
|
for _, idx := range indices {
|
||||||
|
mm := models[idx]
|
||||||
|
if mm.Endpoints == "" {
|
||||||
|
eps := model.GetModelSupportEndpointTypes(mm.ModelName)
|
||||||
|
if b, err := json.Marshal(eps); err == nil {
|
||||||
|
mm.Endpoints = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mm.BoundChannels = chs
|
||||||
|
mm.EnableGroups = model.GetModelEnableGroups(mm.ModelName)
|
||||||
|
mm.QuotaTypes = model.GetModelQuotaTypes(mm.ModelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ruleIndices) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 一次性读取定价缓存,内存匹配所有规则模型
|
||||||
|
pricings := model.GetPricing()
|
||||||
|
|
||||||
|
// 为全部规则模型收集匹配名集合、端点并集、分组并集、配额集合
|
||||||
|
matchedNamesByIdx := make(map[int][]string)
|
||||||
|
endpointSetByIdx := make(map[int]map[constant.EndpointType]struct{})
|
||||||
|
groupSetByIdx := make(map[int]map[string]struct{})
|
||||||
|
quotaSetByIdx := make(map[int]map[int]struct{})
|
||||||
|
|
||||||
|
for _, p := range pricings {
|
||||||
|
for _, idx := range ruleIndices {
|
||||||
|
mm := models[idx]
|
||||||
|
var matched bool
|
||||||
|
switch mm.NameRule {
|
||||||
|
case model.NameRulePrefix:
|
||||||
|
matched = strings.HasPrefix(p.ModelName, mm.ModelName)
|
||||||
|
case model.NameRuleSuffix:
|
||||||
|
matched = strings.HasSuffix(p.ModelName, mm.ModelName)
|
||||||
|
case model.NameRuleContains:
|
||||||
|
matched = strings.Contains(p.ModelName, mm.ModelName)
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matchedNamesByIdx[idx] = append(matchedNamesByIdx[idx], p.ModelName)
|
||||||
|
|
||||||
|
es := endpointSetByIdx[idx]
|
||||||
|
if es == nil {
|
||||||
|
es = make(map[constant.EndpointType]struct{})
|
||||||
|
endpointSetByIdx[idx] = es
|
||||||
|
}
|
||||||
|
for _, et := range p.SupportedEndpointTypes {
|
||||||
|
es[et] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
gs := groupSetByIdx[idx]
|
||||||
|
if gs == nil {
|
||||||
|
gs = make(map[string]struct{})
|
||||||
|
groupSetByIdx[idx] = gs
|
||||||
|
}
|
||||||
|
for _, g := range p.EnableGroup {
|
||||||
|
gs[g] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
qs := quotaSetByIdx[idx]
|
||||||
|
if qs == nil {
|
||||||
|
qs = make(map[int]struct{})
|
||||||
|
quotaSetByIdx[idx] = qs
|
||||||
|
}
|
||||||
|
qs[p.QuotaType] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) 汇总所有匹配到的模型名称,批量查询一次渠道
|
||||||
|
allMatchedSet := make(map[string]struct{})
|
||||||
|
for _, names := range matchedNamesByIdx {
|
||||||
|
for _, n := range names {
|
||||||
|
allMatchedSet[n] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allMatched := make([]string, 0, len(allMatchedSet))
|
||||||
|
for n := range allMatchedSet {
|
||||||
|
allMatched = append(allMatched, n)
|
||||||
|
}
|
||||||
|
matchedChannelsByModel, _ := model.GetBoundChannelsByModelsMap(allMatched)
|
||||||
|
|
||||||
|
// 6) 回填每个规则模型的并集信息
|
||||||
|
for _, idx := range ruleIndices {
|
||||||
|
mm := models[idx]
|
||||||
|
|
||||||
|
// 端点并集 -> 序列化
|
||||||
|
if es, ok := endpointSetByIdx[idx]; ok && mm.Endpoints == "" {
|
||||||
|
eps := make([]constant.EndpointType, 0, len(es))
|
||||||
|
for et := range es {
|
||||||
|
eps = append(eps, et)
|
||||||
|
}
|
||||||
|
if b, err := json.Marshal(eps); err == nil {
|
||||||
|
mm.Endpoints = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分组并集
|
||||||
|
if gs, ok := groupSetByIdx[idx]; ok {
|
||||||
|
groups := make([]string, 0, len(gs))
|
||||||
|
for g := range gs {
|
||||||
|
groups = append(groups, g)
|
||||||
|
}
|
||||||
|
mm.EnableGroups = groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配额类型集合(保持去重并排序)
|
||||||
|
if qs, ok := quotaSetByIdx[idx]; ok {
|
||||||
|
arr := make([]int, 0, len(qs))
|
||||||
|
for k := range qs {
|
||||||
|
arr = append(arr, k)
|
||||||
|
}
|
||||||
|
sort.Ints(arr)
|
||||||
|
mm.QuotaTypes = arr
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渠道并集
|
||||||
|
names := matchedNamesByIdx[idx]
|
||||||
|
channelSet := make(map[string]model.BoundChannel)
|
||||||
|
for _, n := range names {
|
||||||
|
for _, ch := range matchedChannelsByModel[n] {
|
||||||
|
key := ch.Name + "_" + strconv.Itoa(ch.Type)
|
||||||
|
channelSet[key] = ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(channelSet) > 0 {
|
||||||
|
chs := make([]model.BoundChannel, 0, len(channelSet))
|
||||||
|
for _, ch := range channelSet {
|
||||||
|
chs = append(chs, ch)
|
||||||
|
}
|
||||||
|
mm.BoundChannels = chs
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配信息
|
||||||
|
mm.MatchedModels = names
|
||||||
|
mm.MatchedCount = len(names)
|
||||||
|
}
|
||||||
|
}
|
||||||
604
controller/model_sync.go
Normal file
604
controller/model_sync.go
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 上游地址
|
||||||
|
const (
|
||||||
|
upstreamModelsURL = "https://basellm.github.io/llm-metadata/api/newapi/models.json"
|
||||||
|
upstreamVendorsURL = "https://basellm.github.io/llm-metadata/api/newapi/vendors.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizeLocale(locale string) (string, bool) {
|
||||||
|
l := strings.ToLower(strings.TrimSpace(locale))
|
||||||
|
switch l {
|
||||||
|
case "en", "zh", "ja":
|
||||||
|
return l, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUpstreamBase() string {
|
||||||
|
return common.GetEnvOrDefaultString("SYNC_UPSTREAM_BASE", "https://basellm.github.io/llm-metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUpstreamURLs(locale string) (modelsURL, vendorsURL string) {
|
||||||
|
base := strings.TrimRight(getUpstreamBase(), "/")
|
||||||
|
if l, ok := normalizeLocale(locale); ok && l != "" {
|
||||||
|
return fmt.Sprintf("%s/api/i18n/%s/newapi/models.json", base, l),
|
||||||
|
fmt.Sprintf("%s/api/i18n/%s/newapi/vendors.json", base, l)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s/api/newapi/models.json", base), fmt.Sprintf("%s/api/newapi/vendors.json", base)
|
||||||
|
}
|
||||||
|
|
||||||
|
type upstreamEnvelope[T any] struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data []T `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type upstreamModel struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
Endpoints json.RawMessage `json:"endpoints"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
ModelName string `json:"model_name"`
|
||||||
|
NameRule int `json:"name_rule"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
VendorName string `json:"vendor_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type upstreamVendor struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
etagCache = make(map[string]string)
|
||||||
|
bodyCache = make(map[string][]byte)
|
||||||
|
cacheMutex sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
type overwriteField struct {
|
||||||
|
ModelName string `json:"model_name"`
|
||||||
|
Fields []string `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type syncRequest struct {
|
||||||
|
Overwrite []overwriteField `json:"overwrite"`
|
||||||
|
Locale string `json:"locale"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPClient() *http.Client {
|
||||||
|
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 10)
|
||||||
|
dialer := &net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second}
|
||||||
|
transport := &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: time.Duration(timeoutSec) * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
ResponseHeaderTimeout: time.Duration(timeoutSec) * time.Second,
|
||||||
|
}
|
||||||
|
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
host = addr
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(host, "github.io") {
|
||||||
|
if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
return dialer.DialContext(ctx, "tcp6", addr)
|
||||||
|
}
|
||||||
|
return dialer.DialContext(ctx, network, addr)
|
||||||
|
}
|
||||||
|
return &http.Client{Transport: transport}
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpClient = newHTTPClient()
|
||||||
|
|
||||||
|
func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error {
|
||||||
|
var lastErr error
|
||||||
|
attempts := common.GetEnvOrDefault("SYNC_HTTP_RETRY", 3)
|
||||||
|
if attempts < 1 {
|
||||||
|
attempts = 1
|
||||||
|
}
|
||||||
|
baseDelay := 200 * time.Millisecond
|
||||||
|
maxMB := common.GetEnvOrDefault("SYNC_HTTP_MAX_MB", 10)
|
||||||
|
maxBytes := int64(maxMB) << 20
|
||||||
|
for attempt := 0; attempt < attempts; attempt++ {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// ETag conditional request
|
||||||
|
cacheMutex.RLock()
|
||||||
|
if et := etagCache[url]; et != "" {
|
||||||
|
req.Header.Set("If-None-Match", et)
|
||||||
|
}
|
||||||
|
cacheMutex.RUnlock()
|
||||||
|
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
// backoff with jitter
|
||||||
|
sleep := baseDelay * time.Duration(1<<attempt)
|
||||||
|
jitter := time.Duration(rand.Intn(150)) * time.Millisecond
|
||||||
|
time.Sleep(sleep + jitter)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
func() {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusOK:
|
||||||
|
// read body into buffer for caching and flexible decode
|
||||||
|
limited := io.LimitReader(resp.Body, maxBytes)
|
||||||
|
buf, err := io.ReadAll(limited)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// cache body and ETag
|
||||||
|
cacheMutex.Lock()
|
||||||
|
if et := resp.Header.Get("ETag"); et != "" {
|
||||||
|
etagCache[url] = et
|
||||||
|
}
|
||||||
|
bodyCache[url] = buf
|
||||||
|
cacheMutex.Unlock()
|
||||||
|
|
||||||
|
// Try decode as envelope first
|
||||||
|
if err := json.Unmarshal(buf, out); err != nil {
|
||||||
|
// Try decode as pure array
|
||||||
|
var arr []T
|
||||||
|
if err2 := json.Unmarshal(buf, &arr); err2 != nil {
|
||||||
|
lastErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.Success = true
|
||||||
|
out.Data = arr
|
||||||
|
out.Message = ""
|
||||||
|
} else {
|
||||||
|
if !out.Success && len(out.Data) == 0 && out.Message == "" {
|
||||||
|
out.Success = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastErr = nil
|
||||||
|
case http.StatusNotModified:
|
||||||
|
// use cache
|
||||||
|
cacheMutex.RLock()
|
||||||
|
buf := bodyCache[url]
|
||||||
|
cacheMutex.RUnlock()
|
||||||
|
if len(buf) == 0 {
|
||||||
|
lastErr = errors.New("cache miss for 304 response")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(buf, out); err != nil {
|
||||||
|
var arr []T
|
||||||
|
if err2 := json.Unmarshal(buf, &arr); err2 != nil {
|
||||||
|
lastErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.Success = true
|
||||||
|
out.Data = arr
|
||||||
|
out.Message = ""
|
||||||
|
} else {
|
||||||
|
if !out.Success && len(out.Data) == 0 && out.Message == "" {
|
||||||
|
out.Success = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastErr = nil
|
||||||
|
default:
|
||||||
|
lastErr = errors.New(resp.Status)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if lastErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sleep := baseDelay * time.Duration(1<<attempt)
|
||||||
|
jitter := time.Duration(rand.Intn(150)) * time.Millisecond
|
||||||
|
time.Sleep(sleep + jitter)
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureVendorID(vendorName string, vendorByName map[string]upstreamVendor, vendorIDCache map[string]int, createdVendors *int) int {
|
||||||
|
if vendorName == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if id, ok := vendorIDCache[vendorName]; ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
var existing model.Vendor
|
||||||
|
if err := model.DB.Where("name = ?", vendorName).First(&existing).Error; err == nil {
|
||||||
|
vendorIDCache[vendorName] = existing.Id
|
||||||
|
return existing.Id
|
||||||
|
}
|
||||||
|
uv := vendorByName[vendorName]
|
||||||
|
v := &model.Vendor{
|
||||||
|
Name: vendorName,
|
||||||
|
Description: uv.Description,
|
||||||
|
Icon: coalesce(uv.Icon, ""),
|
||||||
|
Status: chooseStatus(uv.Status, 1),
|
||||||
|
}
|
||||||
|
if err := v.Insert(); err == nil {
|
||||||
|
*createdVendors++
|
||||||
|
vendorIDCache[vendorName] = v.Id
|
||||||
|
return v.Id
|
||||||
|
}
|
||||||
|
vendorIDCache[vendorName] = 0
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncUpstreamModels 同步上游模型与供应商,仅对「未配置模型」生效
|
||||||
|
func SyncUpstreamModels(c *gin.Context) {
|
||||||
|
var req syncRequest
|
||||||
|
// 允许空体
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
// 1) 获取未配置模型列表
|
||||||
|
missing, err := model.GetMissingModels()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(missing) == 0 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||||
|
"created_models": 0,
|
||||||
|
"created_vendors": 0,
|
||||||
|
"skipped_models": []string{},
|
||||||
|
}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 拉取上游 vendors 与 models
|
||||||
|
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15)
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
modelsURL, vendorsURL := getUpstreamURLs(req.Locale)
|
||||||
|
var vendorsEnv upstreamEnvelope[upstreamVendor]
|
||||||
|
var modelsEnv upstreamEnvelope[upstreamModel]
|
||||||
|
var fetchErr error
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
// vendor 失败不拦截
|
||||||
|
_ = fetchJSON(ctx, vendorsURL, &vendorsEnv)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil {
|
||||||
|
fetchErr = err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
if fetchErr != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": req.Locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 建立映射
|
||||||
|
vendorByName := make(map[string]upstreamVendor)
|
||||||
|
for _, v := range vendorsEnv.Data {
|
||||||
|
if v.Name != "" {
|
||||||
|
vendorByName[v.Name] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modelByName := make(map[string]upstreamModel)
|
||||||
|
for _, m := range modelsEnv.Data {
|
||||||
|
if m.ModelName != "" {
|
||||||
|
modelByName[m.ModelName] = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 执行同步:仅创建缺失模型;若上游缺失该模型则跳过
|
||||||
|
createdModels := 0
|
||||||
|
createdVendors := 0
|
||||||
|
updatedModels := 0
|
||||||
|
var skipped []string
|
||||||
|
var createdList []string
|
||||||
|
var updatedList []string
|
||||||
|
|
||||||
|
// 本地缓存:vendorName -> id
|
||||||
|
vendorIDCache := make(map[string]int)
|
||||||
|
|
||||||
|
for _, name := range missing {
|
||||||
|
up, ok := modelByName[name]
|
||||||
|
if !ok {
|
||||||
|
skipped = append(skipped, name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若本地已存在且设置为不同步,则跳过(极端情况:缺失列表与本地状态不同步时)
|
||||||
|
var existing model.Model
|
||||||
|
if err := model.DB.Where("model_name = ?", name).First(&existing).Error; err == nil {
|
||||||
|
if existing.SyncOfficial == 0 {
|
||||||
|
skipped = append(skipped, name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 vendor 存在
|
||||||
|
vendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors)
|
||||||
|
|
||||||
|
// 创建模型
|
||||||
|
mi := &model.Model{
|
||||||
|
ModelName: name,
|
||||||
|
Description: up.Description,
|
||||||
|
Icon: up.Icon,
|
||||||
|
Tags: up.Tags,
|
||||||
|
VendorID: vendorID,
|
||||||
|
Status: chooseStatus(up.Status, 1),
|
||||||
|
NameRule: up.NameRule,
|
||||||
|
}
|
||||||
|
if err := mi.Insert(); err == nil {
|
||||||
|
createdModels++
|
||||||
|
createdList = append(createdList, name)
|
||||||
|
} else {
|
||||||
|
skipped = append(skipped, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 处理可选覆盖(更新本地已有模型的差异字段)
|
||||||
|
if len(req.Overwrite) > 0 {
|
||||||
|
// vendorIDCache 已用于创建阶段,可复用
|
||||||
|
for _, ow := range req.Overwrite {
|
||||||
|
up, ok := modelByName[ow.ModelName]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var local model.Model
|
||||||
|
if err := model.DB.Where("model_name = ?", ow.ModelName).First(&local).Error; err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳过被禁用官方同步的模型
|
||||||
|
if local.SyncOfficial == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 映射 vendor
|
||||||
|
newVendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors)
|
||||||
|
|
||||||
|
// 应用字段覆盖(事务)
|
||||||
|
_ = model.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
needUpdate := false
|
||||||
|
if containsField(ow.Fields, "description") {
|
||||||
|
local.Description = up.Description
|
||||||
|
needUpdate = true
|
||||||
|
}
|
||||||
|
if containsField(ow.Fields, "icon") {
|
||||||
|
local.Icon = up.Icon
|
||||||
|
needUpdate = true
|
||||||
|
}
|
||||||
|
if containsField(ow.Fields, "tags") {
|
||||||
|
local.Tags = up.Tags
|
||||||
|
needUpdate = true
|
||||||
|
}
|
||||||
|
if containsField(ow.Fields, "vendor") {
|
||||||
|
local.VendorID = newVendorID
|
||||||
|
needUpdate = true
|
||||||
|
}
|
||||||
|
if containsField(ow.Fields, "name_rule") {
|
||||||
|
local.NameRule = up.NameRule
|
||||||
|
needUpdate = true
|
||||||
|
}
|
||||||
|
if containsField(ow.Fields, "status") {
|
||||||
|
local.Status = chooseStatus(up.Status, local.Status)
|
||||||
|
needUpdate = true
|
||||||
|
}
|
||||||
|
if !needUpdate {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := tx.Save(&local).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updatedModels++
|
||||||
|
updatedList = append(updatedList, ow.ModelName)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": gin.H{
|
||||||
|
"created_models": createdModels,
|
||||||
|
"created_vendors": createdVendors,
|
||||||
|
"updated_models": updatedModels,
|
||||||
|
"skipped_models": skipped,
|
||||||
|
"created_list": createdList,
|
||||||
|
"updated_list": updatedList,
|
||||||
|
"source": gin.H{
|
||||||
|
"locale": req.Locale,
|
||||||
|
"models_url": modelsURL,
|
||||||
|
"vendors_url": vendorsURL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsField(fields []string, key string) bool {
|
||||||
|
key = strings.ToLower(strings.TrimSpace(key))
|
||||||
|
for _, f := range fields {
|
||||||
|
if strings.ToLower(strings.TrimSpace(f)) == key {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func coalesce(a, b string) string {
|
||||||
|
if strings.TrimSpace(a) != "" {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func chooseStatus(primary, fallback int) int {
|
||||||
|
if primary == 0 && fallback != 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if primary != 0 {
|
||||||
|
return primary
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncUpstreamPreview 预览上游与本地的差异(仅用于弹窗选择)
|
||||||
|
func SyncUpstreamPreview(c *gin.Context) {
|
||||||
|
// 1) 拉取上游数据
|
||||||
|
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15)
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
locale := c.Query("locale")
|
||||||
|
modelsURL, vendorsURL := getUpstreamURLs(locale)
|
||||||
|
|
||||||
|
var vendorsEnv upstreamEnvelope[upstreamVendor]
|
||||||
|
var modelsEnv upstreamEnvelope[upstreamModel]
|
||||||
|
var fetchErr error
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = fetchJSON(ctx, vendorsURL, &vendorsEnv)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil {
|
||||||
|
fetchErr = err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
if fetchErr != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vendorByName := make(map[string]upstreamVendor)
|
||||||
|
for _, v := range vendorsEnv.Data {
|
||||||
|
if v.Name != "" {
|
||||||
|
vendorByName[v.Name] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modelByName := make(map[string]upstreamModel)
|
||||||
|
upstreamNames := make([]string, 0, len(modelsEnv.Data))
|
||||||
|
for _, m := range modelsEnv.Data {
|
||||||
|
if m.ModelName != "" {
|
||||||
|
modelByName[m.ModelName] = m
|
||||||
|
upstreamNames = append(upstreamNames, m.ModelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 本地已有模型
|
||||||
|
var locals []model.Model
|
||||||
|
if len(upstreamNames) > 0 {
|
||||||
|
_ = model.DB.Where("model_name IN ? AND sync_official <> 0", upstreamNames).Find(&locals).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地 vendor 名称映射
|
||||||
|
vendorIdSet := make(map[int]struct{})
|
||||||
|
for _, m := range locals {
|
||||||
|
if m.VendorID != 0 {
|
||||||
|
vendorIdSet[m.VendorID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vendorIDs := make([]int, 0, len(vendorIdSet))
|
||||||
|
for id := range vendorIdSet {
|
||||||
|
vendorIDs = append(vendorIDs, id)
|
||||||
|
}
|
||||||
|
idToVendorName := make(map[int]string)
|
||||||
|
if len(vendorIDs) > 0 {
|
||||||
|
var dbVendors []model.Vendor
|
||||||
|
_ = model.DB.Where("id IN ?", vendorIDs).Find(&dbVendors).Error
|
||||||
|
for _, v := range dbVendors {
|
||||||
|
idToVendorName[v.Id] = v.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 缺失且上游存在的模型
|
||||||
|
missingList, _ := model.GetMissingModels()
|
||||||
|
var missing []string
|
||||||
|
for _, name := range missingList {
|
||||||
|
if _, ok := modelByName[name]; ok {
|
||||||
|
missing = append(missing, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 计算冲突字段
|
||||||
|
type conflictField struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
Local interface{} `json:"local"`
|
||||||
|
Upstream interface{} `json:"upstream"`
|
||||||
|
}
|
||||||
|
type conflictItem struct {
|
||||||
|
ModelName string `json:"model_name"`
|
||||||
|
Fields []conflictField `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflicts []conflictItem
|
||||||
|
for _, local := range locals {
|
||||||
|
up, ok := modelByName[local.ModelName]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := make([]conflictField, 0, 6)
|
||||||
|
if strings.TrimSpace(local.Description) != strings.TrimSpace(up.Description) {
|
||||||
|
fields = append(fields, conflictField{Field: "description", Local: local.Description, Upstream: up.Description})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(local.Icon) != strings.TrimSpace(up.Icon) {
|
||||||
|
fields = append(fields, conflictField{Field: "icon", Local: local.Icon, Upstream: up.Icon})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(local.Tags) != strings.TrimSpace(up.Tags) {
|
||||||
|
fields = append(fields, conflictField{Field: "tags", Local: local.Tags, Upstream: up.Tags})
|
||||||
|
}
|
||||||
|
// vendor 对比使用名称
|
||||||
|
localVendor := idToVendorName[local.VendorID]
|
||||||
|
if strings.TrimSpace(localVendor) != strings.TrimSpace(up.VendorName) {
|
||||||
|
fields = append(fields, conflictField{Field: "vendor", Local: localVendor, Upstream: up.VendorName})
|
||||||
|
}
|
||||||
|
if local.NameRule != up.NameRule {
|
||||||
|
fields = append(fields, conflictField{Field: "name_rule", Local: local.NameRule, Upstream: up.NameRule})
|
||||||
|
}
|
||||||
|
if local.Status != chooseStatus(up.Status, local.Status) {
|
||||||
|
fields = append(fields, conflictField{Field: "status", Local: local.Status, Upstream: up.Status})
|
||||||
|
}
|
||||||
|
if len(fields) > 0 {
|
||||||
|
conflicts = append(conflicts, conflictItem{ModelName: local.ModelName, Fields: fields})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": gin.H{
|
||||||
|
"missing": missing,
|
||||||
|
"conflicts": conflicts,
|
||||||
|
"source": gin.H{
|
||||||
|
"locale": locale,
|
||||||
|
"models_url": modelsURL,
|
||||||
|
"vendors_url": vendorsURL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,14 +6,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"one-api/common"
|
|
||||||
"one-api/model"
|
|
||||||
"one-api/setting"
|
|
||||||
"one-api/setting/system_setting"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -45,7 +45,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
|
|||||||
values.Set("client_secret", system_setting.GetOIDCSettings().ClientSecret)
|
values.Set("client_secret", system_setting.GetOIDCSettings().ClientSecret)
|
||||||
values.Set("code", code)
|
values.Set("code", code)
|
||||||
values.Set("grant_type", "authorization_code")
|
values.Set("grant_type", "authorization_code")
|
||||||
values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", setting.ServerAddress))
|
values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", system_setting.ServerAddress))
|
||||||
formData := values.Encode()
|
formData := values.Encode()
|
||||||
req, err := http.NewRequest("POST", system_setting.GetOIDCSettings().TokenEndpoint, strings.NewReader(formData))
|
req, err := http.NewRequest("POST", system_setting.GetOIDCSettings().TokenEndpoint, strings.NewReader(formData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -69,7 +69,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if oidcResponse.AccessToken == "" {
|
if oidcResponse.AccessToken == "" {
|
||||||
common.SysError("OIDC 获取 Token 失败,请检查设置!")
|
common.SysLog("OIDC 获取 Token 失败,请检查设置!")
|
||||||
return nil, errors.New("OIDC 获取 Token 失败,请检查设置!")
|
return nil, errors.New("OIDC 获取 Token 失败,请检查设置!")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
|
|||||||
}
|
}
|
||||||
defer res2.Body.Close()
|
defer res2.Body.Close()
|
||||||
if res2.StatusCode != http.StatusOK {
|
if res2.StatusCode != http.StatusOK {
|
||||||
common.SysError("OIDC 获取用户信息失败!请检查设置!")
|
common.SysLog("OIDC 获取用户信息失败!请检查设置!")
|
||||||
return nil, errors.New("OIDC 获取用户信息失败!请检查设置!")
|
return nil, errors.New("OIDC 获取用户信息失败!请检查设置!")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if oidcUser.OpenID == "" || oidcUser.Email == "" {
|
if oidcUser.OpenID == "" || oidcUser.Email == "" {
|
||||||
common.SysError("OIDC 获取用户信息为空!请检查设置!")
|
common.SysLog("OIDC 获取用户信息为空!请检查设置!")
|
||||||
return nil, errors.New("OIDC 获取用户信息为空!请检查设置!")
|
return nil, errors.New("OIDC 获取用户信息为空!请检查设置!")
|
||||||
}
|
}
|
||||||
return &oidcUser, nil
|
return &oidcUser, nil
|
||||||
@@ -126,10 +126,7 @@ func OidcAuth(c *gin.Context) {
|
|||||||
code := c.Query("code")
|
code := c.Query("code")
|
||||||
oidcUser, err := getOidcUserInfoByCode(code)
|
oidcUser, err := getOidcUserInfoByCode(code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user := model.User{
|
user := model.User{
|
||||||
@@ -195,10 +192,7 @@ func OidcBind(c *gin.Context) {
|
|||||||
code := c.Query("code")
|
code := c.Query("code")
|
||||||
oidcUser, err := getOidcUserInfoByCode(code)
|
oidcUser, err := getOidcUserInfoByCode(code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user := model.User{
|
user := model.User{
|
||||||
@@ -217,19 +211,13 @@ func OidcBind(c *gin.Context) {
|
|||||||
user.Id = id.(int)
|
user.Id = id.(int)
|
||||||
err = user.FillUserById()
|
err = user.FillUserById()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user.OidcId = oidcUser.OpenID
|
user.OidcId = oidcUser.OpenID
|
||||||
err = user.Update(false)
|
err = user.Update(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/common"
|
|
||||||
"one-api/model"
|
|
||||||
"one-api/setting"
|
|
||||||
"one-api/setting/console_setting"
|
|
||||||
"one-api/setting/ratio_setting"
|
|
||||||
"one-api/setting/system_setting"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/setting"
|
||||||
|
"github.com/QuantumNous/new-api/setting/console_setting"
|
||||||
|
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||||
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,8 +37,13 @@ func GetOptions(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OptionUpdateRequest struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value any `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
func UpdateOption(c *gin.Context) {
|
func UpdateOption(c *gin.Context) {
|
||||||
var option model.Option
|
var option OptionUpdateRequest
|
||||||
err := json.NewDecoder(c.Request.Body).Decode(&option)
|
err := json.NewDecoder(c.Request.Body).Decode(&option)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
@@ -45,6 +52,16 @@ func UpdateOption(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
switch option.Value.(type) {
|
||||||
|
case bool:
|
||||||
|
option.Value = common.Interface2String(option.Value.(bool))
|
||||||
|
case float64:
|
||||||
|
option.Value = common.Interface2String(option.Value.(float64))
|
||||||
|
case int:
|
||||||
|
option.Value = common.Interface2String(option.Value.(int))
|
||||||
|
default:
|
||||||
|
option.Value = fmt.Sprintf("%v", option.Value)
|
||||||
|
}
|
||||||
switch option.Key {
|
switch option.Key {
|
||||||
case "GitHubOAuthEnabled":
|
case "GitHubOAuthEnabled":
|
||||||
if option.Value == "true" && common.GitHubClientId == "" {
|
if option.Value == "true" && common.GitHubClientId == "" {
|
||||||
@@ -104,7 +121,7 @@ func UpdateOption(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "GroupRatio":
|
case "GroupRatio":
|
||||||
err = ratio_setting.CheckGroupRatio(option.Value)
|
err = ratio_setting.CheckGroupRatio(option.Value.(string))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -112,8 +129,35 @@ func UpdateOption(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case "ImageRatio":
|
||||||
|
err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "图片倍率设置失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "AudioRatio":
|
||||||
|
err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "音频倍率设置失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "AudioCompletionRatio":
|
||||||
|
err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "音频补全倍率设置失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
case "ModelRequestRateLimitGroup":
|
case "ModelRequestRateLimitGroup":
|
||||||
err = setting.CheckModelRequestRateLimitGroup(option.Value)
|
err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -122,7 +166,7 @@ func UpdateOption(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "console_setting.api_info":
|
case "console_setting.api_info":
|
||||||
err = console_setting.ValidateConsoleSettings(option.Value, "ApiInfo")
|
err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -131,7 +175,7 @@ func UpdateOption(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "console_setting.announcements":
|
case "console_setting.announcements":
|
||||||
err = console_setting.ValidateConsoleSettings(option.Value, "Announcements")
|
err = console_setting.ValidateConsoleSettings(option.Value.(string), "Announcements")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -140,7 +184,7 @@ func UpdateOption(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "console_setting.faq":
|
case "console_setting.faq":
|
||||||
err = console_setting.ValidateConsoleSettings(option.Value, "FAQ")
|
err = console_setting.ValidateConsoleSettings(option.Value.(string), "FAQ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -149,7 +193,7 @@ func UpdateOption(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "console_setting.uptime_kuma_groups":
|
case "console_setting.uptime_kuma_groups":
|
||||||
err = console_setting.ValidateConsoleSettings(option.Value, "UptimeKumaGroups")
|
err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -158,12 +202,9 @@ func UpdateOption(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = model.UpdateOption(option.Key, option.Value)
|
err = model.UpdateOption(option.Key, option.Value.(string))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
|||||||
497
controller/passkey.go
Normal file
497
controller/passkey.go
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
|
||||||
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
webauthnlib "github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func PasskeyRegisterBegin(c *gin.Context) {
|
||||||
|
if !system_setting.GetPasskeySettings().Enabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未启用 Passkey 登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := getSessionUser(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, err := model.GetPasskeyByUserID(user.Id)
|
||||||
|
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, model.ErrPasskeyNotFound) {
|
||||||
|
credential = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
waUser := passkeysvc.NewWebAuthnUser(user, credential)
|
||||||
|
var options []webauthnlib.RegistrationOption
|
||||||
|
if credential != nil {
|
||||||
|
descriptor := credential.ToWebAuthnCredential().Descriptor()
|
||||||
|
options = append(options, webauthnlib.WithExclusions([]protocol.CredentialDescriptor{descriptor}))
|
||||||
|
}
|
||||||
|
|
||||||
|
creation, sessionData, err := wa.BeginRegistration(waUser, options...)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := passkeysvc.SaveSessionData(c, passkeysvc.RegistrationSessionKey, sessionData); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": gin.H{
|
||||||
|
"options": creation,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyRegisterFinish(c *gin.Context) {
|
||||||
|
if !system_setting.GetPasskeySettings().Enabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未启用 Passkey 登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := getSessionUser(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credentialRecord, err := model.GetPasskeyByUserID(user.Id)
|
||||||
|
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, model.ErrPasskeyNotFound) {
|
||||||
|
credentialRecord = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.RegistrationSessionKey)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
waUser := passkeysvc.NewWebAuthnUser(user, credentialRecord)
|
||||||
|
credential, err := wa.FinishRegistration(waUser, *sessionData, c.Request)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passkeyCredential := model.NewPasskeyCredentialFromWebAuthn(user.Id, credential)
|
||||||
|
if passkeyCredential == nil {
|
||||||
|
common.ApiErrorMsg(c, "无法创建 Passkey 凭证")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.UpsertPasskeyCredential(passkeyCredential); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "Passkey 注册成功",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyDelete(c *gin.Context) {
|
||||||
|
user, err := getSessionUser(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.DeletePasskeyByUserID(user.Id); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "Passkey 已解绑",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyStatus(c *gin.Context) {
|
||||||
|
user, err := getSessionUser(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, err := model.GetPasskeyByUserID(user.Id)
|
||||||
|
if errors.Is(err, model.ErrPasskeyNotFound) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": gin.H{
|
||||||
|
"enabled": false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := gin.H{
|
||||||
|
"enabled": true,
|
||||||
|
"last_used_at": credential.LastUsedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyLoginBegin(c *gin.Context) {
|
||||||
|
if !system_setting.GetPasskeySettings().Enabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未启用 Passkey 登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assertion, sessionData, err := wa.BeginDiscoverableLogin()
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := passkeysvc.SaveSessionData(c, passkeysvc.LoginSessionKey, sessionData); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": gin.H{
|
||||||
|
"options": assertion,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyLoginFinish(c *gin.Context) {
|
||||||
|
if !system_setting.GetPasskeySettings().Enabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未启用 Passkey 登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.LoginSessionKey)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := func(rawID, userHandle []byte) (webauthnlib.User, error) {
|
||||||
|
// 首先通过凭证ID查找用户
|
||||||
|
credential, err := model.GetPasskeyByCredentialID(rawID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("未找到 Passkey 凭证: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过凭证获取用户
|
||||||
|
user := &model.User{Id: credential.UserID}
|
||||||
|
if err := user.FillUserById(); err != nil {
|
||||||
|
return nil, fmt.Errorf("用户信息获取失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Status != common.UserStatusEnabled {
|
||||||
|
return nil, errors.New("该用户已被禁用")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(userHandle) > 0 {
|
||||||
|
userID, parseErr := strconv.Atoi(string(userHandle))
|
||||||
|
if parseErr != nil {
|
||||||
|
// 记录异常但继续验证,因为某些客户端可能使用非数字格式
|
||||||
|
common.SysLog(fmt.Sprintf("PasskeyLogin: userHandle parse error for credential, length: %d", len(userHandle)))
|
||||||
|
} else if userID != user.Id {
|
||||||
|
return nil, errors.New("用户句柄与凭证不匹配")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return passkeysvc.NewWebAuthnUser(user, credential), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
waUser, credential, err := wa.FinishPasskeyLogin(handler, *sessionData, c.Request)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userWrapper, ok := waUser.(*passkeysvc.WebAuthnUser)
|
||||||
|
if !ok {
|
||||||
|
common.ApiErrorMsg(c, "Passkey 登录状态异常")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modelUser := userWrapper.ModelUser()
|
||||||
|
if modelUser == nil {
|
||||||
|
common.ApiErrorMsg(c, "Passkey 登录状态异常")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if modelUser.Status != common.UserStatusEnabled {
|
||||||
|
common.ApiErrorMsg(c, "该用户已被禁用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新凭证信息
|
||||||
|
updatedCredential := model.NewPasskeyCredentialFromWebAuthn(modelUser.Id, credential)
|
||||||
|
if updatedCredential == nil {
|
||||||
|
common.ApiErrorMsg(c, "Passkey 凭证更新失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
updatedCredential.LastUsedAt = &now
|
||||||
|
if err := model.UpsertPasskeyCredential(updatedCredential); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setupLogin(modelUser, c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminResetPasskey(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
common.ApiErrorMsg(c, "无效的用户 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &model.User{Id: id}
|
||||||
|
if err := user.FillUserById(); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := model.GetPasskeyByUserID(user.Id); err != nil {
|
||||||
|
if errors.Is(err, model.ErrPasskeyNotFound) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "该用户尚未绑定 Passkey",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.DeletePasskeyByUserID(user.Id); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "Passkey 已重置",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyVerifyBegin(c *gin.Context) {
|
||||||
|
if !system_setting.GetPasskeySettings().Enabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未启用 Passkey 登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := getSessionUser(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, err := model.GetPasskeyByUserID(user.Id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "该用户尚未绑定 Passkey",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
waUser := passkeysvc.NewWebAuthnUser(user, credential)
|
||||||
|
assertion, sessionData, err := wa.BeginLogin(waUser)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := passkeysvc.SaveSessionData(c, passkeysvc.VerifySessionKey, sessionData); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": gin.H{
|
||||||
|
"options": assertion,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyVerifyFinish(c *gin.Context) {
|
||||||
|
if !system_setting.GetPasskeySettings().Enabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未启用 Passkey 登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := getSessionUser(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, err := model.GetPasskeyByUserID(user.Id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "该用户尚未绑定 Passkey",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
waUser := passkeysvc.NewWebAuthnUser(user, credential)
|
||||||
|
_, err = wa.FinishLogin(waUser, *sessionData, c.Request)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新凭证的最后使用时间
|
||||||
|
now := time.Now()
|
||||||
|
credential.LastUsedAt = &now
|
||||||
|
if err := model.UpsertPasskeyCredential(credential); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "Passkey 验证成功",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSessionUser(c *gin.Context) (*model.User, error) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
idRaw := session.Get("id")
|
||||||
|
if idRaw == nil {
|
||||||
|
return nil, errors.New("未登录")
|
||||||
|
}
|
||||||
|
id, ok := idRaw.(int)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("无效的会话信息")
|
||||||
|
}
|
||||||
|
user := &model.User{Id: id}
|
||||||
|
if err := user.FillUserById(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user.Status != common.UserStatusEnabled {
|
||||||
|
return nil, errors.New("该用户已被禁用")
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
@@ -3,15 +3,14 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"one-api/common"
|
|
||||||
"one-api/constant"
|
|
||||||
"one-api/dto"
|
|
||||||
"one-api/middleware"
|
|
||||||
"one-api/model"
|
|
||||||
"one-api/setting"
|
|
||||||
"one-api/types"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/middleware"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,57 +27,35 @@ func Playground(c *gin.Context) {
|
|||||||
|
|
||||||
useAccessToken := c.GetBool("use_access_token")
|
useAccessToken := c.GetBool("use_access_token")
|
||||||
if useAccessToken {
|
if useAccessToken {
|
||||||
newAPIError = types.NewError(errors.New("暂不支持使用 access token"), types.ErrorCodeAccessDenied)
|
newAPIError = types.NewError(errors.New("暂不支持使用 access token"), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
playgroundRequest := &dto.PlayGroundRequest{}
|
group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
|
||||||
err := common.UnmarshalBodyReusable(c, playgroundRequest)
|
modelName := c.GetString("original_model")
|
||||||
if err != nil {
|
|
||||||
newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if playgroundRequest.Model == "" {
|
|
||||||
newAPIError = types.NewError(errors.New("请选择模型"), types.ErrorCodeInvalidRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Set("original_model", playgroundRequest.Model)
|
|
||||||
group := playgroundRequest.Group
|
|
||||||
userGroup := c.GetString("group")
|
|
||||||
|
|
||||||
if group == "" {
|
|
||||||
group = userGroup
|
|
||||||
} else {
|
|
||||||
if !setting.GroupInUserUsableGroups(group) && group != userGroup {
|
|
||||||
newAPIError = types.NewError(errors.New("无权访问该分组"), types.ErrorCodeAccessDenied)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Set("group", group)
|
|
||||||
}
|
|
||||||
|
|
||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
//c.Set("token_name", "playground-"+group)
|
|
||||||
|
// Write user context to ensure acceptUnsetRatio is available
|
||||||
|
userCache, err := model.GetUserCache(userId)
|
||||||
|
if err != nil {
|
||||||
|
newAPIError = types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userCache.WriteContext(c)
|
||||||
|
|
||||||
tempToken := &model.Token{
|
tempToken := &model.Token{
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
Name: fmt.Sprintf("playground-%s", group),
|
Name: fmt.Sprintf("playground-%s", group),
|
||||||
Group: group,
|
Group: group,
|
||||||
}
|
}
|
||||||
_ = middleware.SetupContextForToken(c, tempToken)
|
_ = middleware.SetupContextForToken(c, tempToken)
|
||||||
_, err = getChannel(c, group, playgroundRequest.Model, 0)
|
_, newAPIError = getChannel(c, group, modelName, 0)
|
||||||
if err != nil {
|
if newAPIError != nil {
|
||||||
newAPIError = types.NewError(err, types.ErrorCodeGetChannelFailed)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
//middleware.SetupContextForSelectedChannel(c, channel, playgroundRequest.Model)
|
//middleware.SetupContextForSelectedChannel(c, channel, playgroundRequest.Model)
|
||||||
common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now())
|
common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now())
|
||||||
|
|
||||||
// Write user context to ensure acceptUnsetRatio is available
|
Relay(c, types.RelayFormatOpenAI)
|
||||||
userCache, err := model.GetUserCache(userId)
|
|
||||||
if err != nil {
|
|
||||||
newAPIError = types.NewError(err, types.ErrorCodeQueryDataError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userCache.WriteContext(c)
|
|
||||||
Relay(c)
|
|
||||||
}
|
}
|
||||||
|
|||||||
90
controller/prefill_group.go
Normal file
90
controller/prefill_group.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetPrefillGroups 获取预填组列表,可通过 ?type=xxx 过滤
|
||||||
|
func GetPrefillGroups(c *gin.Context) {
|
||||||
|
groupType := c.Query("type")
|
||||||
|
groups, err := model.GetAllPrefillGroups(groupType)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePrefillGroup 创建新的预填组
|
||||||
|
func CreatePrefillGroup(c *gin.Context) {
|
||||||
|
var g model.PrefillGroup
|
||||||
|
if err := c.ShouldBindJSON(&g); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if g.Name == "" || g.Type == "" {
|
||||||
|
common.ApiErrorMsg(c, "组名称和类型不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 创建前检查名称
|
||||||
|
if dup, err := model.IsPrefillGroupNameDuplicated(0, g.Name); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
} else if dup {
|
||||||
|
common.ApiErrorMsg(c, "组名称已存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Insert(); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, &g)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePrefillGroup 更新预填组
|
||||||
|
func UpdatePrefillGroup(c *gin.Context) {
|
||||||
|
var g model.PrefillGroup
|
||||||
|
if err := c.ShouldBindJSON(&g); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if g.Id == 0 {
|
||||||
|
common.ApiErrorMsg(c, "缺少组 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 名称冲突检查
|
||||||
|
if dup, err := model.IsPrefillGroupNameDuplicated(g.Id, g.Name); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
} else if dup {
|
||||||
|
common.ApiErrorMsg(c, "组名称已存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Update(); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, &g)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePrefillGroup 删除预填组
|
||||||
|
func DeletePrefillGroup(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := model.DeletePrefillGroupByID(id); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, nil)
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"one-api/model"
|
"github.com/QuantumNous/new-api/model"
|
||||||
"one-api/setting"
|
"github.com/QuantumNous/new-api/service"
|
||||||
"one-api/setting/ratio_setting"
|
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -30,7 +30,7 @@ func GetPricing(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
usableGroup = setting.GetUserUsableGroups(group)
|
usableGroup = service.GetUserUsableGroups(group)
|
||||||
// check groupRatio contains usableGroup
|
// check groupRatio contains usableGroup
|
||||||
for group := range ratio_setting.GetGroupRatioCopy() {
|
for group := range ratio_setting.GetGroupRatioCopy() {
|
||||||
if _, ok := usableGroup[group]; !ok {
|
if _, ok := usableGroup[group]; !ok {
|
||||||
@@ -39,10 +39,13 @@ func GetPricing(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": pricing,
|
"data": pricing,
|
||||||
"group_ratio": groupRatio,
|
"vendors": model.GetVendors(),
|
||||||
"usable_group": usableGroup,
|
"group_ratio": groupRatio,
|
||||||
|
"usable_group": usableGroup,
|
||||||
|
"supported_endpoint": model.GetSupportedEndpointMap(),
|
||||||
|
"auto_groups": service.GetUserAutoGroup(group),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/setting/ratio_setting"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetRatioConfig(c *gin.Context) {
|
func GetRatioConfig(c *gin.Context) {
|
||||||
if !ratio_setting.IsExposeRatioEnabled() {
|
if !ratio_setting.IsExposeRatioEnabled() {
|
||||||
c.JSON(http.StatusForbidden, gin.H{
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "倍率配置接口未启用",
|
"message": "倍率配置接口未启用",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": "",
|
||||||
"data": ratio_setting.GetExposedData(),
|
"data": ratio_setting.GetExposedData(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,474 +1,540 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"io"
|
||||||
"strings"
|
"net"
|
||||||
"sync"
|
"net/http"
|
||||||
"time"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"one-api/common"
|
"github.com/QuantumNous/new-api/logger"
|
||||||
"one-api/dto"
|
|
||||||
"one-api/model"
|
|
||||||
"one-api/setting/ratio_setting"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultTimeoutSeconds = 10
|
defaultTimeoutSeconds = 10
|
||||||
defaultEndpoint = "/api/ratio_config"
|
defaultEndpoint = "/api/ratio_config"
|
||||||
maxConcurrentFetches = 8
|
maxConcurrentFetches = 8
|
||||||
|
maxRatioConfigBytes = 10 << 20 // 10MB
|
||||||
|
floatEpsilon = 1e-9
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func nearlyEqual(a, b float64) bool {
|
||||||
|
if a > b {
|
||||||
|
return a-b < floatEpsilon
|
||||||
|
}
|
||||||
|
return b-a < floatEpsilon
|
||||||
|
}
|
||||||
|
|
||||||
|
func valuesEqual(a, b interface{}) bool {
|
||||||
|
af, aok := a.(float64)
|
||||||
|
bf, bok := b.(float64)
|
||||||
|
if aok && bok {
|
||||||
|
return nearlyEqual(af, bf)
|
||||||
|
}
|
||||||
|
return a == b
|
||||||
|
}
|
||||||
|
|
||||||
var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"}
|
var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"}
|
||||||
|
|
||||||
type upstreamResult struct {
|
type upstreamResult struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Data map[string]any `json:"data,omitempty"`
|
Data map[string]any `json:"data,omitempty"`
|
||||||
Err string `json:"err,omitempty"`
|
Err string `json:"err,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchUpstreamRatios(c *gin.Context) {
|
func FetchUpstreamRatios(c *gin.Context) {
|
||||||
var req dto.UpstreamRequest
|
var req dto.UpstreamRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Timeout <= 0 {
|
if req.Timeout <= 0 {
|
||||||
req.Timeout = defaultTimeoutSeconds
|
req.Timeout = defaultTimeoutSeconds
|
||||||
}
|
}
|
||||||
|
|
||||||
var upstreams []dto.UpstreamDTO
|
var upstreams []dto.UpstreamDTO
|
||||||
|
|
||||||
if len(req.Upstreams) > 0 {
|
if len(req.Upstreams) > 0 {
|
||||||
for _, u := range req.Upstreams {
|
for _, u := range req.Upstreams {
|
||||||
if strings.HasPrefix(u.BaseURL, "http") {
|
if strings.HasPrefix(u.BaseURL, "http") {
|
||||||
if u.Endpoint == "" {
|
if u.Endpoint == "" {
|
||||||
u.Endpoint = defaultEndpoint
|
u.Endpoint = defaultEndpoint
|
||||||
}
|
}
|
||||||
u.BaseURL = strings.TrimRight(u.BaseURL, "/")
|
u.BaseURL = strings.TrimRight(u.BaseURL, "/")
|
||||||
upstreams = append(upstreams, u)
|
upstreams = append(upstreams, u)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if len(req.ChannelIDs) > 0 {
|
} else if len(req.ChannelIDs) > 0 {
|
||||||
intIds := make([]int, 0, len(req.ChannelIDs))
|
intIds := make([]int, 0, len(req.ChannelIDs))
|
||||||
for _, id64 := range req.ChannelIDs {
|
for _, id64 := range req.ChannelIDs {
|
||||||
intIds = append(intIds, int(id64))
|
intIds = append(intIds, int(id64))
|
||||||
}
|
}
|
||||||
dbChannels, err := model.GetChannelsByIds(intIds)
|
dbChannels, err := model.GetChannelsByIds(intIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogError(c.Request.Context(), "failed to query channels: "+err.Error())
|
logger.LogError(c.Request.Context(), "failed to query channels: "+err.Error())
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询渠道失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询渠道失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, ch := range dbChannels {
|
for _, ch := range dbChannels {
|
||||||
if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") {
|
if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") {
|
||||||
upstreams = append(upstreams, dto.UpstreamDTO{
|
upstreams = append(upstreams, dto.UpstreamDTO{
|
||||||
ID: ch.Id,
|
ID: ch.Id,
|
||||||
Name: ch.Name,
|
Name: ch.Name,
|
||||||
BaseURL: strings.TrimRight(base, "/"),
|
BaseURL: strings.TrimRight(base, "/"),
|
||||||
Endpoint: "",
|
Endpoint: "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(upstreams) == 0 {
|
if len(upstreams) == 0 {
|
||||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "无有效上游渠道"})
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "无有效上游渠道"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
ch := make(chan upstreamResult, len(upstreams))
|
ch := make(chan upstreamResult, len(upstreams))
|
||||||
|
|
||||||
sem := make(chan struct{}, maxConcurrentFetches)
|
sem := make(chan struct{}, maxConcurrentFetches)
|
||||||
|
|
||||||
client := &http.Client{Transport: &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second}}
|
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||||
|
transport := &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second}
|
||||||
|
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
host = addr
|
||||||
|
}
|
||||||
|
// 对 github.io 优先尝试 IPv4,失败则回退 IPv6
|
||||||
|
if strings.HasSuffix(host, "github.io") {
|
||||||
|
if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
return dialer.DialContext(ctx, "tcp6", addr)
|
||||||
|
}
|
||||||
|
return dialer.DialContext(ctx, network, addr)
|
||||||
|
}
|
||||||
|
client := &http.Client{Transport: transport}
|
||||||
|
|
||||||
for _, chn := range upstreams {
|
for _, chn := range upstreams {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(chItem dto.UpstreamDTO) {
|
go func(chItem dto.UpstreamDTO) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
sem <- struct{}{}
|
sem <- struct{}{}
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
|
|
||||||
endpoint := chItem.Endpoint
|
endpoint := chItem.Endpoint
|
||||||
if endpoint == "" {
|
var fullURL string
|
||||||
endpoint = defaultEndpoint
|
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
|
||||||
} else if !strings.HasPrefix(endpoint, "/") {
|
fullURL = endpoint
|
||||||
endpoint = "/" + endpoint
|
} else {
|
||||||
}
|
if endpoint == "" {
|
||||||
fullURL := chItem.BaseURL + endpoint
|
endpoint = defaultEndpoint
|
||||||
|
} else if !strings.HasPrefix(endpoint, "/") {
|
||||||
|
endpoint = "/" + endpoint
|
||||||
|
}
|
||||||
|
fullURL = chItem.BaseURL + endpoint
|
||||||
|
}
|
||||||
|
|
||||||
uniqueName := chItem.Name
|
uniqueName := chItem.Name
|
||||||
if chItem.ID != 0 {
|
if chItem.ID != 0 {
|
||||||
uniqueName = fmt.Sprintf("%s(%d)", chItem.Name, chItem.ID)
|
uniqueName = fmt.Sprintf("%s(%d)", chItem.Name, chItem.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second)
|
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogWarn(c.Request.Context(), "build request failed: "+err.Error())
|
logger.LogWarn(c.Request.Context(), "build request failed: "+err.Error())
|
||||||
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
// 简单重试:最多 3 次,指数退避
|
||||||
if err != nil {
|
var resp *http.Response
|
||||||
common.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error())
|
var lastErr error
|
||||||
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
return
|
resp, lastErr = client.Do(httpReq)
|
||||||
}
|
if lastErr == nil {
|
||||||
defer resp.Body.Close()
|
break
|
||||||
if resp.StatusCode != http.StatusOK {
|
}
|
||||||
common.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status)
|
time.Sleep(time.Duration(200*(1<<attempt)) * time.Millisecond)
|
||||||
ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
|
}
|
||||||
return
|
if lastErr != nil {
|
||||||
}
|
logger.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+lastErr.Error())
|
||||||
// 兼容两种上游接口格式:
|
ch <- upstreamResult{Name: uniqueName, Err: lastErr.Error()}
|
||||||
// type1: /api/ratio_config -> data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price
|
return
|
||||||
// type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式
|
}
|
||||||
var body struct {
|
defer resp.Body.Close()
|
||||||
Success bool `json:"success"`
|
if resp.StatusCode != http.StatusOK {
|
||||||
Data json.RawMessage `json:"data"`
|
logger.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status)
|
||||||
Message string `json:"message"`
|
ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
// Content-Type 和响应体大小校验
|
||||||
common.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
|
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "application/json") {
|
||||||
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
logger.LogWarn(c.Request.Context(), "unexpected content-type from "+chItem.Name+": "+ct)
|
||||||
return
|
}
|
||||||
}
|
limited := io.LimitReader(resp.Body, maxRatioConfigBytes)
|
||||||
|
// 兼容两种上游接口格式:
|
||||||
|
// type1: /api/ratio_config -> data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price
|
||||||
|
// type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式
|
||||||
|
var body struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
if !body.Success {
|
if err := json.NewDecoder(limited).Decode(&body); err != nil {
|
||||||
ch <- upstreamResult{Name: uniqueName, Err: body.Message}
|
logger.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
|
||||||
return
|
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试按 type1 解析
|
if !body.Success {
|
||||||
var type1Data map[string]any
|
ch <- upstreamResult{Name: uniqueName, Err: body.Message}
|
||||||
if err := json.Unmarshal(body.Data, &type1Data); err == nil {
|
return
|
||||||
// 如果包含至少一个 ratioTypes 字段,则认为是 type1
|
}
|
||||||
isType1 := false
|
|
||||||
for _, rt := range ratioTypes {
|
|
||||||
if _, ok := type1Data[rt]; ok {
|
|
||||||
isType1 = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if isType1 {
|
|
||||||
ch <- upstreamResult{Name: uniqueName, Data: type1Data}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果不是 type1,则尝试按 type2 (/api/pricing) 解析
|
// 若 Data 为空,将继续按 type1 尝试解析(与多数静态 ratio_config 兼容)
|
||||||
var pricingItems []struct {
|
|
||||||
ModelName string `json:"model_name"`
|
|
||||||
QuotaType int `json:"quota_type"`
|
|
||||||
ModelRatio float64 `json:"model_ratio"`
|
|
||||||
ModelPrice float64 `json:"model_price"`
|
|
||||||
CompletionRatio float64 `json:"completion_ratio"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body.Data, &pricingItems); err != nil {
|
|
||||||
common.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error())
|
|
||||||
ch <- upstreamResult{Name: uniqueName, Err: "无法解析上游返回数据"}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
modelRatioMap := make(map[string]float64)
|
// 尝试按 type1 解析
|
||||||
completionRatioMap := make(map[string]float64)
|
var type1Data map[string]any
|
||||||
modelPriceMap := make(map[string]float64)
|
if err := json.Unmarshal(body.Data, &type1Data); err == nil {
|
||||||
|
// 如果包含至少一个 ratioTypes 字段,则认为是 type1
|
||||||
|
isType1 := false
|
||||||
|
for _, rt := range ratioTypes {
|
||||||
|
if _, ok := type1Data[rt]; ok {
|
||||||
|
isType1 = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isType1 {
|
||||||
|
ch <- upstreamResult{Name: uniqueName, Data: type1Data}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, item := range pricingItems {
|
// 如果不是 type1,则尝试按 type2 (/api/pricing) 解析
|
||||||
if item.QuotaType == 1 {
|
var pricingItems []struct {
|
||||||
modelPriceMap[item.ModelName] = item.ModelPrice
|
ModelName string `json:"model_name"`
|
||||||
} else {
|
QuotaType int `json:"quota_type"`
|
||||||
modelRatioMap[item.ModelName] = item.ModelRatio
|
ModelRatio float64 `json:"model_ratio"`
|
||||||
// completionRatio 可能为 0,此时也直接赋值,保持与上游一致
|
ModelPrice float64 `json:"model_price"`
|
||||||
completionRatioMap[item.ModelName] = item.CompletionRatio
|
CompletionRatio float64 `json:"completion_ratio"`
|
||||||
}
|
}
|
||||||
}
|
if err := json.Unmarshal(body.Data, &pricingItems); err != nil {
|
||||||
|
logger.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error())
|
||||||
|
ch <- upstreamResult{Name: uniqueName, Err: "无法解析上游返回数据"}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
converted := make(map[string]any)
|
modelRatioMap := make(map[string]float64)
|
||||||
|
completionRatioMap := make(map[string]float64)
|
||||||
|
modelPriceMap := make(map[string]float64)
|
||||||
|
|
||||||
if len(modelRatioMap) > 0 {
|
for _, item := range pricingItems {
|
||||||
ratioAny := make(map[string]any, len(modelRatioMap))
|
if item.QuotaType == 1 {
|
||||||
for k, v := range modelRatioMap {
|
modelPriceMap[item.ModelName] = item.ModelPrice
|
||||||
ratioAny[k] = v
|
} else {
|
||||||
}
|
modelRatioMap[item.ModelName] = item.ModelRatio
|
||||||
converted["model_ratio"] = ratioAny
|
// completionRatio 可能为 0,此时也直接赋值,保持与上游一致
|
||||||
}
|
completionRatioMap[item.ModelName] = item.CompletionRatio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(completionRatioMap) > 0 {
|
converted := make(map[string]any)
|
||||||
compAny := make(map[string]any, len(completionRatioMap))
|
|
||||||
for k, v := range completionRatioMap {
|
|
||||||
compAny[k] = v
|
|
||||||
}
|
|
||||||
converted["completion_ratio"] = compAny
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(modelPriceMap) > 0 {
|
if len(modelRatioMap) > 0 {
|
||||||
priceAny := make(map[string]any, len(modelPriceMap))
|
ratioAny := make(map[string]any, len(modelRatioMap))
|
||||||
for k, v := range modelPriceMap {
|
for k, v := range modelRatioMap {
|
||||||
priceAny[k] = v
|
ratioAny[k] = v
|
||||||
}
|
}
|
||||||
converted["model_price"] = priceAny
|
converted["model_ratio"] = ratioAny
|
||||||
}
|
}
|
||||||
|
|
||||||
ch <- upstreamResult{Name: uniqueName, Data: converted}
|
if len(completionRatioMap) > 0 {
|
||||||
}(chn)
|
compAny := make(map[string]any, len(completionRatioMap))
|
||||||
}
|
for k, v := range completionRatioMap {
|
||||||
|
compAny[k] = v
|
||||||
|
}
|
||||||
|
converted["completion_ratio"] = compAny
|
||||||
|
}
|
||||||
|
|
||||||
wg.Wait()
|
if len(modelPriceMap) > 0 {
|
||||||
close(ch)
|
priceAny := make(map[string]any, len(modelPriceMap))
|
||||||
|
for k, v := range modelPriceMap {
|
||||||
|
priceAny[k] = v
|
||||||
|
}
|
||||||
|
converted["model_price"] = priceAny
|
||||||
|
}
|
||||||
|
|
||||||
localData := ratio_setting.GetExposedData()
|
ch <- upstreamResult{Name: uniqueName, Data: converted}
|
||||||
|
}(chn)
|
||||||
|
}
|
||||||
|
|
||||||
var testResults []dto.TestResult
|
wg.Wait()
|
||||||
var successfulChannels []struct {
|
close(ch)
|
||||||
name string
|
|
||||||
data map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
for r := range ch {
|
localData := ratio_setting.GetExposedData()
|
||||||
if r.Err != "" {
|
|
||||||
testResults = append(testResults, dto.TestResult{
|
|
||||||
Name: r.Name,
|
|
||||||
Status: "error",
|
|
||||||
Error: r.Err,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
testResults = append(testResults, dto.TestResult{
|
|
||||||
Name: r.Name,
|
|
||||||
Status: "success",
|
|
||||||
})
|
|
||||||
successfulChannels = append(successfulChannels, struct {
|
|
||||||
name string
|
|
||||||
data map[string]any
|
|
||||||
}{name: r.Name, data: r.Data})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
differences := buildDifferences(localData, successfulChannels)
|
var testResults []dto.TestResult
|
||||||
|
var successfulChannels []struct {
|
||||||
|
name string
|
||||||
|
data map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
for r := range ch {
|
||||||
"success": true,
|
if r.Err != "" {
|
||||||
"data": gin.H{
|
testResults = append(testResults, dto.TestResult{
|
||||||
"differences": differences,
|
Name: r.Name,
|
||||||
"test_results": testResults,
|
Status: "error",
|
||||||
},
|
Error: r.Err,
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
testResults = append(testResults, dto.TestResult{
|
||||||
|
Name: r.Name,
|
||||||
|
Status: "success",
|
||||||
|
})
|
||||||
|
successfulChannels = append(successfulChannels, struct {
|
||||||
|
name string
|
||||||
|
data map[string]any
|
||||||
|
}{name: r.Name, data: r.Data})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
differences := buildDifferences(localData, successfulChannels)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": gin.H{
|
||||||
|
"differences": differences,
|
||||||
|
"test_results": testResults,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDifferences(localData map[string]any, successfulChannels []struct {
|
func buildDifferences(localData map[string]any, successfulChannels []struct {
|
||||||
name string
|
name string
|
||||||
data map[string]any
|
data map[string]any
|
||||||
}) map[string]map[string]dto.DifferenceItem {
|
}) map[string]map[string]dto.DifferenceItem {
|
||||||
differences := make(map[string]map[string]dto.DifferenceItem)
|
differences := make(map[string]map[string]dto.DifferenceItem)
|
||||||
|
|
||||||
allModels := make(map[string]struct{})
|
allModels := make(map[string]struct{})
|
||||||
|
|
||||||
for _, ratioType := range ratioTypes {
|
|
||||||
if localRatioAny, ok := localData[ratioType]; ok {
|
|
||||||
if localRatio, ok := localRatioAny.(map[string]float64); ok {
|
|
||||||
for modelName := range localRatio {
|
|
||||||
allModels[modelName] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, channel := range successfulChannels {
|
|
||||||
for _, ratioType := range ratioTypes {
|
|
||||||
if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
|
|
||||||
for modelName := range upstreamRatio {
|
|
||||||
allModels[modelName] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
confidenceMap := make(map[string]map[string]bool)
|
for _, ratioType := range ratioTypes {
|
||||||
|
if localRatioAny, ok := localData[ratioType]; ok {
|
||||||
// 预处理阶段:检查pricing接口的可信度
|
if localRatio, ok := localRatioAny.(map[string]float64); ok {
|
||||||
for _, channel := range successfulChannels {
|
for modelName := range localRatio {
|
||||||
confidenceMap[channel.name] = make(map[string]bool)
|
allModels[modelName] = struct{}{}
|
||||||
|
}
|
||||||
modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any)
|
}
|
||||||
completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any)
|
}
|
||||||
|
}
|
||||||
if hasModelRatio && hasCompletionRatio {
|
|
||||||
// 遍历所有模型,检查是否满足不可信条件
|
|
||||||
for modelName := range allModels {
|
|
||||||
// 默认为可信
|
|
||||||
confidenceMap[channel.name][modelName] = true
|
|
||||||
|
|
||||||
// 检查是否满足不可信条件:model_ratio为37.5且completion_ratio为1
|
|
||||||
if modelRatioVal, ok := modelRatios[modelName]; ok {
|
|
||||||
if completionRatioVal, ok := completionRatios[modelName]; ok {
|
|
||||||
// 转换为float64进行比较
|
|
||||||
if modelRatioFloat, ok := modelRatioVal.(float64); ok {
|
|
||||||
if completionRatioFloat, ok := completionRatioVal.(float64); ok {
|
|
||||||
if modelRatioFloat == 37.5 && completionRatioFloat == 1.0 {
|
|
||||||
confidenceMap[channel.name][modelName] = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果不是从pricing接口获取的数据,则全部标记为可信
|
|
||||||
for modelName := range allModels {
|
|
||||||
confidenceMap[channel.name][modelName] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for modelName := range allModels {
|
for _, channel := range successfulChannels {
|
||||||
for _, ratioType := range ratioTypes {
|
for _, ratioType := range ratioTypes {
|
||||||
var localValue interface{} = nil
|
if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
|
||||||
if localRatioAny, ok := localData[ratioType]; ok {
|
for modelName := range upstreamRatio {
|
||||||
if localRatio, ok := localRatioAny.(map[string]float64); ok {
|
allModels[modelName] = struct{}{}
|
||||||
if val, exists := localRatio[modelName]; exists {
|
}
|
||||||
localValue = val
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
upstreamValues := make(map[string]interface{})
|
confidenceMap := make(map[string]map[string]bool)
|
||||||
confidenceValues := make(map[string]bool)
|
|
||||||
hasUpstreamValue := false
|
|
||||||
hasDifference := false
|
|
||||||
|
|
||||||
for _, channel := range successfulChannels {
|
// 预处理阶段:检查pricing接口的可信度
|
||||||
var upstreamValue interface{} = nil
|
for _, channel := range successfulChannels {
|
||||||
|
confidenceMap[channel.name] = make(map[string]bool)
|
||||||
if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
|
|
||||||
if val, exists := upstreamRatio[modelName]; exists {
|
|
||||||
upstreamValue = val
|
|
||||||
hasUpstreamValue = true
|
|
||||||
|
|
||||||
if localValue != nil && localValue != val {
|
|
||||||
hasDifference = true
|
|
||||||
} else if localValue == val {
|
|
||||||
upstreamValue = "same"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if upstreamValue == nil && localValue == nil {
|
|
||||||
upstreamValue = "same"
|
|
||||||
}
|
|
||||||
|
|
||||||
if localValue == nil && upstreamValue != nil && upstreamValue != "same" {
|
|
||||||
hasDifference = true
|
|
||||||
}
|
|
||||||
|
|
||||||
upstreamValues[channel.name] = upstreamValue
|
|
||||||
|
|
||||||
confidenceValues[channel.name] = confidenceMap[channel.name][modelName]
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldInclude := false
|
modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any)
|
||||||
|
completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any)
|
||||||
if localValue != nil {
|
|
||||||
if hasDifference {
|
|
||||||
shouldInclude = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if hasUpstreamValue {
|
|
||||||
shouldInclude = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if shouldInclude {
|
if hasModelRatio && hasCompletionRatio {
|
||||||
if differences[modelName] == nil {
|
// 遍历所有模型,检查是否满足不可信条件
|
||||||
differences[modelName] = make(map[string]dto.DifferenceItem)
|
for modelName := range allModels {
|
||||||
}
|
// 默认为可信
|
||||||
differences[modelName][ratioType] = dto.DifferenceItem{
|
confidenceMap[channel.name][modelName] = true
|
||||||
Current: localValue,
|
|
||||||
Upstreams: upstreamValues,
|
|
||||||
Confidence: confidenceValues,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
channelHasDiff := make(map[string]bool)
|
// 检查是否满足不可信条件:model_ratio为37.5且completion_ratio为1
|
||||||
for _, ratioMap := range differences {
|
if modelRatioVal, ok := modelRatios[modelName]; ok {
|
||||||
for _, item := range ratioMap {
|
if completionRatioVal, ok := completionRatios[modelName]; ok {
|
||||||
for chName, val := range item.Upstreams {
|
// 转换为float64进行比较
|
||||||
if val != nil && val != "same" {
|
if modelRatioFloat, ok := modelRatioVal.(float64); ok {
|
||||||
channelHasDiff[chName] = true
|
if completionRatioFloat, ok := completionRatioVal.(float64); ok {
|
||||||
}
|
if modelRatioFloat == 37.5 && completionRatioFloat == 1.0 {
|
||||||
}
|
confidenceMap[channel.name][modelName] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果不是从pricing接口获取的数据,则全部标记为可信
|
||||||
|
for modelName := range allModels {
|
||||||
|
confidenceMap[channel.name][modelName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for modelName, ratioMap := range differences {
|
for modelName := range allModels {
|
||||||
for ratioType, item := range ratioMap {
|
for _, ratioType := range ratioTypes {
|
||||||
for chName := range item.Upstreams {
|
var localValue interface{} = nil
|
||||||
if !channelHasDiff[chName] {
|
if localRatioAny, ok := localData[ratioType]; ok {
|
||||||
delete(item.Upstreams, chName)
|
if localRatio, ok := localRatioAny.(map[string]float64); ok {
|
||||||
delete(item.Confidence, chName)
|
if val, exists := localRatio[modelName]; exists {
|
||||||
}
|
localValue = val
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
allSame := true
|
upstreamValues := make(map[string]interface{})
|
||||||
for _, v := range item.Upstreams {
|
confidenceValues := make(map[string]bool)
|
||||||
if v != "same" {
|
hasUpstreamValue := false
|
||||||
allSame = false
|
hasDifference := false
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(item.Upstreams) == 0 || allSame {
|
|
||||||
delete(ratioMap, ratioType)
|
|
||||||
} else {
|
|
||||||
differences[modelName][ratioType] = item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(ratioMap) == 0 {
|
for _, channel := range successfulChannels {
|
||||||
delete(differences, modelName)
|
var upstreamValue interface{} = nil
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return differences
|
if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
|
||||||
|
if val, exists := upstreamRatio[modelName]; exists {
|
||||||
|
upstreamValue = val
|
||||||
|
hasUpstreamValue = true
|
||||||
|
|
||||||
|
if localValue != nil && !valuesEqual(localValue, val) {
|
||||||
|
hasDifference = true
|
||||||
|
} else if valuesEqual(localValue, val) {
|
||||||
|
upstreamValue = "same"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if upstreamValue == nil && localValue == nil {
|
||||||
|
upstreamValue = "same"
|
||||||
|
}
|
||||||
|
|
||||||
|
if localValue == nil && upstreamValue != nil && upstreamValue != "same" {
|
||||||
|
hasDifference = true
|
||||||
|
}
|
||||||
|
|
||||||
|
upstreamValues[channel.name] = upstreamValue
|
||||||
|
|
||||||
|
confidenceValues[channel.name] = confidenceMap[channel.name][modelName]
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldInclude := false
|
||||||
|
|
||||||
|
if localValue != nil {
|
||||||
|
if hasDifference {
|
||||||
|
shouldInclude = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if hasUpstreamValue {
|
||||||
|
shouldInclude = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldInclude {
|
||||||
|
if differences[modelName] == nil {
|
||||||
|
differences[modelName] = make(map[string]dto.DifferenceItem)
|
||||||
|
}
|
||||||
|
differences[modelName][ratioType] = dto.DifferenceItem{
|
||||||
|
Current: localValue,
|
||||||
|
Upstreams: upstreamValues,
|
||||||
|
Confidence: confidenceValues,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channelHasDiff := make(map[string]bool)
|
||||||
|
for _, ratioMap := range differences {
|
||||||
|
for _, item := range ratioMap {
|
||||||
|
for chName, val := range item.Upstreams {
|
||||||
|
if val != nil && val != "same" {
|
||||||
|
channelHasDiff[chName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for modelName, ratioMap := range differences {
|
||||||
|
for ratioType, item := range ratioMap {
|
||||||
|
for chName := range item.Upstreams {
|
||||||
|
if !channelHasDiff[chName] {
|
||||||
|
delete(item.Upstreams, chName)
|
||||||
|
delete(item.Confidence, chName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allSame := true
|
||||||
|
for _, v := range item.Upstreams {
|
||||||
|
if v != "same" {
|
||||||
|
allSame = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(item.Upstreams) == 0 || allSame {
|
||||||
|
delete(ratioMap, ratioType)
|
||||||
|
} else {
|
||||||
|
differences[modelName][ratioType] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ratioMap) == 0 {
|
||||||
|
delete(differences, modelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return differences
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetSyncableChannels(c *gin.Context) {
|
func GetSyncableChannels(c *gin.Context) {
|
||||||
channels, err := model.GetAllChannels(0, 0, true, false)
|
channels, err := model.GetAllChannels(0, 0, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": err.Error(),
|
"message": err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var syncableChannels []dto.SyncableChannel
|
var syncableChannels []dto.SyncableChannel
|
||||||
for _, channel := range channels {
|
for _, channel := range channels {
|
||||||
if channel.GetBaseURL() != "" {
|
if channel.GetBaseURL() != "" {
|
||||||
syncableChannels = append(syncableChannels, dto.SyncableChannel{
|
syncableChannels = append(syncableChannels, dto.SyncableChannel{
|
||||||
ID: channel.Id,
|
ID: channel.Id,
|
||||||
Name: channel.Name,
|
Name: channel.Name,
|
||||||
BaseURL: channel.GetBaseURL(),
|
BaseURL: channel.GetBaseURL(),
|
||||||
Status: channel.Status,
|
Status: channel.Status,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
syncableChannels = append(syncableChannels, dto.SyncableChannel{
|
||||||
"success": true,
|
ID: -100,
|
||||||
"message": "",
|
Name: "官方倍率预设",
|
||||||
"data": syncableChannels,
|
BaseURL: "https://basellm.github.io",
|
||||||
})
|
Status: 1,
|
||||||
}
|
})
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": syncableChannels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,91 +1,53 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
|
||||||
"one-api/common"
|
|
||||||
"one-api/model"
|
|
||||||
"strconv"
|
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetAllRedemptions(c *gin.Context) {
|
func GetAllRedemptions(c *gin.Context) {
|
||||||
p, _ := strconv.Atoi(c.Query("p"))
|
pageInfo := common.GetPageQuery(c)
|
||||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
redemptions, total, err := model.GetAllRedemptions(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||||
if p < 0 {
|
|
||||||
p = 0
|
|
||||||
}
|
|
||||||
if pageSize < 1 {
|
|
||||||
pageSize = common.ItemsPerPage
|
|
||||||
}
|
|
||||||
redemptions, total, err := model.GetAllRedemptions((p-1)*pageSize, pageSize)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
pageInfo.SetTotal(int(total))
|
||||||
"success": true,
|
pageInfo.SetItems(redemptions)
|
||||||
"message": "",
|
common.ApiSuccess(c, pageInfo)
|
||||||
"data": gin.H{
|
|
||||||
"items": redemptions,
|
|
||||||
"total": total,
|
|
||||||
"page": p,
|
|
||||||
"page_size": pageSize,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func SearchRedemptions(c *gin.Context) {
|
func SearchRedemptions(c *gin.Context) {
|
||||||
keyword := c.Query("keyword")
|
keyword := c.Query("keyword")
|
||||||
p, _ := strconv.Atoi(c.Query("p"))
|
pageInfo := common.GetPageQuery(c)
|
||||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
redemptions, total, err := model.SearchRedemptions(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||||
if p < 0 {
|
|
||||||
p = 0
|
|
||||||
}
|
|
||||||
if pageSize < 1 {
|
|
||||||
pageSize = common.ItemsPerPage
|
|
||||||
}
|
|
||||||
redemptions, total, err := model.SearchRedemptions(keyword, (p-1)*pageSize, pageSize)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
pageInfo.SetTotal(int(total))
|
||||||
"success": true,
|
pageInfo.SetItems(redemptions)
|
||||||
"message": "",
|
common.ApiSuccess(c, pageInfo)
|
||||||
"data": gin.H{
|
|
||||||
"items": redemptions,
|
|
||||||
"total": total,
|
|
||||||
"page": p,
|
|
||||||
"page_size": pageSize,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRedemption(c *gin.Context) {
|
func GetRedemption(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
redemption, err := model.GetRedemptionById(id)
|
redemption, err := model.GetRedemptionById(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -100,13 +62,10 @@ func AddRedemption(c *gin.Context) {
|
|||||||
redemption := model.Redemption{}
|
redemption := model.Redemption{}
|
||||||
err := c.ShouldBindJSON(&redemption)
|
err := c.ShouldBindJSON(&redemption)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(redemption.Name) == 0 || len(redemption.Name) > 20 {
|
if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "兑换码名称长度必须在1-20之间",
|
"message": "兑换码名称长度必须在1-20之间",
|
||||||
@@ -165,10 +124,7 @@ func DeleteRedemption(c *gin.Context) {
|
|||||||
id, _ := strconv.Atoi(c.Param("id"))
|
id, _ := strconv.Atoi(c.Param("id"))
|
||||||
err := model.DeleteRedemptionById(id)
|
err := model.DeleteRedemptionById(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -183,18 +139,12 @@ func UpdateRedemption(c *gin.Context) {
|
|||||||
redemption := model.Redemption{}
|
redemption := model.Redemption{}
|
||||||
err := c.ShouldBindJSON(&redemption)
|
err := c.ShouldBindJSON(&redemption)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cleanRedemption, err := model.GetRedemptionById(redemption.Id)
|
cleanRedemption, err := model.GetRedemptionById(redemption.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if statusOnly == "" {
|
if statusOnly == "" {
|
||||||
@@ -212,10 +162,7 @@ func UpdateRedemption(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
err = cleanRedemption.Update()
|
err = cleanRedemption.Update()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -229,16 +176,13 @@ func UpdateRedemption(c *gin.Context) {
|
|||||||
func DeleteInvalidRedemption(c *gin.Context) {
|
func DeleteInvalidRedemption(c *gin.Context) {
|
||||||
rows, err := model.DeleteInvalidRedemptions()
|
rows, err := model.DeleteInvalidRedemptions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": "",
|
||||||
"data": rows,
|
"data": rows,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,115 +2,199 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/common"
|
|
||||||
"one-api/constant"
|
|
||||||
constant2 "one-api/constant"
|
|
||||||
"one-api/dto"
|
|
||||||
"one-api/middleware"
|
|
||||||
"one-api/model"
|
|
||||||
"one-api/relay"
|
|
||||||
relayconstant "one-api/relay/constant"
|
|
||||||
"one-api/relay/helper"
|
|
||||||
"one-api/service"
|
|
||||||
"one-api/types"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/QuantumNous/new-api/logger"
|
||||||
|
"github.com/QuantumNous/new-api/middleware"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/relay"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||||
|
"github.com/QuantumNous/new-api/relay/helper"
|
||||||
|
"github.com/QuantumNous/new-api/service"
|
||||||
|
"github.com/QuantumNous/new-api/setting"
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
|
||||||
|
"github.com/bytedance/gopkg/util/gopool"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
func relayHandler(c *gin.Context, relayMode int) *types.NewAPIError {
|
func relayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError {
|
||||||
var err *types.NewAPIError
|
var err *types.NewAPIError
|
||||||
switch relayMode {
|
switch info.RelayMode {
|
||||||
case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits:
|
case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits:
|
||||||
err = relay.ImageHelper(c)
|
err = relay.ImageHelper(c, info)
|
||||||
case relayconstant.RelayModeAudioSpeech:
|
case relayconstant.RelayModeAudioSpeech:
|
||||||
fallthrough
|
fallthrough
|
||||||
case relayconstant.RelayModeAudioTranslation:
|
case relayconstant.RelayModeAudioTranslation:
|
||||||
fallthrough
|
fallthrough
|
||||||
case relayconstant.RelayModeAudioTranscription:
|
case relayconstant.RelayModeAudioTranscription:
|
||||||
err = relay.AudioHelper(c)
|
err = relay.AudioHelper(c, info)
|
||||||
case relayconstant.RelayModeRerank:
|
case relayconstant.RelayModeRerank:
|
||||||
err = relay.RerankHelper(c, relayMode)
|
err = relay.RerankHelper(c, info)
|
||||||
case relayconstant.RelayModeEmbeddings:
|
case relayconstant.RelayModeEmbeddings:
|
||||||
err = relay.EmbeddingHelper(c)
|
err = relay.EmbeddingHelper(c, info)
|
||||||
case relayconstant.RelayModeResponses:
|
case relayconstant.RelayModeResponses:
|
||||||
err = relay.ResponsesHelper(c)
|
err = relay.ResponsesHelper(c, info)
|
||||||
case relayconstant.RelayModeGemini:
|
|
||||||
err = relay.GeminiHelper(c)
|
|
||||||
default:
|
default:
|
||||||
err = relay.TextHelper(c)
|
err = relay.TextHelper(c, info)
|
||||||
}
|
}
|
||||||
|
|
||||||
if constant2.ErrorLogEnabled && err != nil {
|
|
||||||
// 保存错误日志到mysql中
|
|
||||||
userId := c.GetInt("id")
|
|
||||||
tokenName := c.GetString("token_name")
|
|
||||||
modelName := c.GetString("original_model")
|
|
||||||
tokenId := c.GetInt("token_id")
|
|
||||||
userGroup := c.GetString("group")
|
|
||||||
channelId := c.GetInt("channel_id")
|
|
||||||
other := make(map[string]interface{})
|
|
||||||
other["error_type"] = err.ErrorType
|
|
||||||
other["error_code"] = err.GetErrorCode()
|
|
||||||
other["status_code"] = err.StatusCode
|
|
||||||
other["channel_id"] = channelId
|
|
||||||
other["channel_name"] = c.GetString("channel_name")
|
|
||||||
other["channel_type"] = c.GetInt("channel_type")
|
|
||||||
|
|
||||||
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.Error(), tokenId, 0, false, userGroup, other)
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func Relay(c *gin.Context) {
|
func geminiRelayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError {
|
||||||
relayMode := relayconstant.Path2RelayMode(c.Request.URL.Path)
|
var err *types.NewAPIError
|
||||||
|
if strings.Contains(c.Request.URL.Path, "embed") {
|
||||||
|
err = relay.GeminiEmbeddingHandler(c, info)
|
||||||
|
} else {
|
||||||
|
err = relay.GeminiHelper(c, info)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||||
|
|
||||||
requestId := c.GetString(common.RequestIdKey)
|
requestId := c.GetString(common.RequestIdKey)
|
||||||
group := c.GetString("group")
|
group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
|
||||||
originalModel := c.GetString("original_model")
|
originalModel := common.GetContextKeyString(c, constant.ContextKeyOriginalModel)
|
||||||
var newAPIError *types.NewAPIError
|
|
||||||
|
var (
|
||||||
|
newAPIError *types.NewAPIError
|
||||||
|
ws *websocket.Conn
|
||||||
|
)
|
||||||
|
|
||||||
|
if relayFormat == types.RelayFormatOpenAIRealtime {
|
||||||
|
var err error
|
||||||
|
ws, err = upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
helper.WssError(c, ws, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()).ToOpenAIError())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ws.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if newAPIError != nil {
|
||||||
|
logger.LogError(c, fmt.Sprintf("relay error: %s", newAPIError.Error()))
|
||||||
|
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
|
||||||
|
switch relayFormat {
|
||||||
|
case types.RelayFormatOpenAIRealtime:
|
||||||
|
helper.WssError(c, ws, newAPIError.ToOpenAIError())
|
||||||
|
case types.RelayFormatClaude:
|
||||||
|
c.JSON(newAPIError.StatusCode, gin.H{
|
||||||
|
"type": "error",
|
||||||
|
"error": newAPIError.ToClaudeError(),
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
c.JSON(newAPIError.StatusCode, gin.H{
|
||||||
|
"error": newAPIError.ToOpenAIError(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
request, err := helper.GetAndValidateRequest(c, relayFormat)
|
||||||
|
if err != nil {
|
||||||
|
newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
relayInfo, err := relaycommon.GenRelayInfo(c, relayFormat, request, ws)
|
||||||
|
if err != nil {
|
||||||
|
newAPIError = types.NewError(err, types.ErrorCodeGenRelayInfoFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := request.GetTokenCountMeta()
|
||||||
|
|
||||||
|
if setting.ShouldCheckPromptSensitive() {
|
||||||
|
contains, words := service.CheckSensitiveText(meta.CombineText)
|
||||||
|
if contains {
|
||||||
|
logger.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ", ")))
|
||||||
|
newAPIError = types.NewError(err, types.ErrorCodeSensitiveWordsDetected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := service.CountRequestToken(c, meta, relayInfo)
|
||||||
|
if err != nil {
|
||||||
|
newAPIError = types.NewError(err, types.ErrorCodeCountTokenFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
relayInfo.SetPromptTokens(tokens)
|
||||||
|
|
||||||
|
priceData, err := helper.ModelPriceHelper(c, relayInfo, tokens, meta)
|
||||||
|
if err != nil {
|
||||||
|
newAPIError = types.NewError(err, types.ErrorCodeModelPriceError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// common.SetContextKey(c, constant.ContextKeyTokenCountMeta, meta)
|
||||||
|
|
||||||
|
if priceData.FreeModel {
|
||||||
|
logger.LogInfo(c, fmt.Sprintf("模型 %s 免费,跳过预扣费", relayInfo.OriginModelName))
|
||||||
|
} else {
|
||||||
|
newAPIError = service.PreConsumeQuota(c, priceData.QuotaToPreConsume, relayInfo)
|
||||||
|
if newAPIError != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
// Only return quota if downstream failed and quota was actually pre-consumed
|
||||||
|
if newAPIError != nil && relayInfo.FinalPreConsumedQuota != 0 {
|
||||||
|
service.ReturnPreConsumedQuota(c, relayInfo)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
for i := 0; i <= common.RetryTimes; i++ {
|
for i := 0; i <= common.RetryTimes; i++ {
|
||||||
channel, err := getChannel(c, group, originalModel, i)
|
channel, err := getChannel(c, group, originalModel, i)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogError(c, err.Error())
|
logger.LogError(c, err.Error())
|
||||||
newAPIError = types.NewError(err, types.ErrorCodeGetChannelFailed)
|
newAPIError = err
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
newAPIError = relayRequest(c, relayMode, channel)
|
addUsedChannel(c, channel.Id)
|
||||||
|
requestBody, _ := common.GetRequestBody(c)
|
||||||
|
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
||||||
|
|
||||||
if newAPIError == nil {
|
switch relayFormat {
|
||||||
return // 成功处理请求,直接返回
|
case types.RelayFormatOpenAIRealtime:
|
||||||
|
newAPIError = relay.WssHelper(c, relayInfo)
|
||||||
|
case types.RelayFormatClaude:
|
||||||
|
newAPIError = relay.ClaudeHelper(c, relayInfo)
|
||||||
|
case types.RelayFormatGemini:
|
||||||
|
newAPIError = geminiRelayHandler(c, relayInfo)
|
||||||
|
default:
|
||||||
|
newAPIError = relayHandler(c, relayInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
go processChannelError(c, channel.Id, channel.Type, channel.Name, channel.GetAutoBan(), newAPIError)
|
if newAPIError == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
|
||||||
|
|
||||||
if !shouldRetry(c, newAPIError, common.RetryTimes-i) {
|
if !shouldRetry(c, newAPIError, common.RetryTimes-i) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useChannel := c.GetStringSlice("use_channel")
|
useChannel := c.GetStringSlice("use_channel")
|
||||||
if len(useChannel) > 1 {
|
if len(useChannel) > 1 {
|
||||||
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
|
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
|
||||||
common.LogInfo(c, retryLogStr)
|
logger.LogInfo(c, retryLogStr)
|
||||||
}
|
|
||||||
|
|
||||||
if newAPIError != nil {
|
|
||||||
if newAPIError.StatusCode == http.StatusTooManyRequests {
|
|
||||||
common.LogError(c, fmt.Sprintf("origin 429 error: %s", newAPIError.Error()))
|
|
||||||
newAPIError.SetMessage("当前分组上游负载已饱和,请稍后再试")
|
|
||||||
}
|
|
||||||
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
|
|
||||||
c.JSON(newAPIError.StatusCode, gin.H{
|
|
||||||
"error": newAPIError.ToOpenAIError(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,129 +205,13 @@ var upgrader = websocket.Upgrader{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func WssRelay(c *gin.Context) {
|
|
||||||
// 将 HTTP 连接升级为 WebSocket 连接
|
|
||||||
|
|
||||||
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
||||||
defer ws.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
helper.WssError(c, ws, types.NewError(err, types.ErrorCodeGetChannelFailed).ToOpenAIError())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
relayMode := relayconstant.Path2RelayMode(c.Request.URL.Path)
|
|
||||||
requestId := c.GetString(common.RequestIdKey)
|
|
||||||
group := c.GetString("group")
|
|
||||||
//wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01
|
|
||||||
originalModel := c.GetString("original_model")
|
|
||||||
var newAPIError *types.NewAPIError
|
|
||||||
|
|
||||||
for i := 0; i <= common.RetryTimes; i++ {
|
|
||||||
channel, err := getChannel(c, group, originalModel, i)
|
|
||||||
if err != nil {
|
|
||||||
common.LogError(c, err.Error())
|
|
||||||
newAPIError = types.NewError(err, types.ErrorCodeGetChannelFailed)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
newAPIError = wssRequest(c, ws, relayMode, channel)
|
|
||||||
|
|
||||||
if newAPIError == nil {
|
|
||||||
return // 成功处理请求,直接返回
|
|
||||||
}
|
|
||||||
|
|
||||||
go processChannelError(c, channel.Id, channel.Type, channel.Name, channel.GetAutoBan(), newAPIError)
|
|
||||||
|
|
||||||
if !shouldRetry(c, newAPIError, common.RetryTimes-i) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
useChannel := c.GetStringSlice("use_channel")
|
|
||||||
if len(useChannel) > 1 {
|
|
||||||
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
|
|
||||||
common.LogInfo(c, retryLogStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if newAPIError != nil {
|
|
||||||
if newAPIError.StatusCode == http.StatusTooManyRequests {
|
|
||||||
newAPIError.SetMessage("当前分组上游负载已饱和,请稍后再试")
|
|
||||||
}
|
|
||||||
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
|
|
||||||
helper.WssError(c, ws, newAPIError.ToOpenAIError())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func RelayClaude(c *gin.Context) {
|
|
||||||
//relayMode := constant.Path2RelayMode(c.Request.URL.Path)
|
|
||||||
requestId := c.GetString(common.RequestIdKey)
|
|
||||||
group := c.GetString("group")
|
|
||||||
originalModel := c.GetString("original_model")
|
|
||||||
var newAPIError *types.NewAPIError
|
|
||||||
|
|
||||||
for i := 0; i <= common.RetryTimes; i++ {
|
|
||||||
channel, err := getChannel(c, group, originalModel, i)
|
|
||||||
if err != nil {
|
|
||||||
common.LogError(c, err.Error())
|
|
||||||
newAPIError = types.NewError(err, types.ErrorCodeGetChannelFailed)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
newAPIError = claudeRequest(c, channel)
|
|
||||||
|
|
||||||
if newAPIError == nil {
|
|
||||||
return // 成功处理请求,直接返回
|
|
||||||
}
|
|
||||||
|
|
||||||
go processChannelError(c, channel.Id, channel.Type, channel.Name, channel.GetAutoBan(), newAPIError)
|
|
||||||
|
|
||||||
if !shouldRetry(c, newAPIError, common.RetryTimes-i) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
useChannel := c.GetStringSlice("use_channel")
|
|
||||||
if len(useChannel) > 1 {
|
|
||||||
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
|
|
||||||
common.LogInfo(c, retryLogStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if newAPIError != nil {
|
|
||||||
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
|
|
||||||
c.JSON(newAPIError.StatusCode, gin.H{
|
|
||||||
"type": "error",
|
|
||||||
"error": newAPIError.ToClaudeError(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func relayRequest(c *gin.Context, relayMode int, channel *model.Channel) *types.NewAPIError {
|
|
||||||
addUsedChannel(c, channel.Id)
|
|
||||||
requestBody, _ := common.GetRequestBody(c)
|
|
||||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
|
||||||
return relayHandler(c, relayMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func wssRequest(c *gin.Context, ws *websocket.Conn, relayMode int, channel *model.Channel) *types.NewAPIError {
|
|
||||||
addUsedChannel(c, channel.Id)
|
|
||||||
requestBody, _ := common.GetRequestBody(c)
|
|
||||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
|
||||||
return relay.WssHelper(c, ws)
|
|
||||||
}
|
|
||||||
|
|
||||||
func claudeRequest(c *gin.Context, channel *model.Channel) *types.NewAPIError {
|
|
||||||
addUsedChannel(c, channel.Id)
|
|
||||||
requestBody, _ := common.GetRequestBody(c)
|
|
||||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
|
||||||
return relay.ClaudeHelper(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
func addUsedChannel(c *gin.Context, channelId int) {
|
func addUsedChannel(c *gin.Context, channelId int) {
|
||||||
useChannel := c.GetStringSlice("use_channel")
|
useChannel := c.GetStringSlice("use_channel")
|
||||||
useChannel = append(useChannel, fmt.Sprintf("%d", channelId))
|
useChannel = append(useChannel, fmt.Sprintf("%d", channelId))
|
||||||
c.Set("use_channel", useChannel)
|
c.Set("use_channel", useChannel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*model.Channel, error) {
|
func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*model.Channel, *types.NewAPIError) {
|
||||||
if retryCount == 0 {
|
if retryCount == 0 {
|
||||||
autoBan := c.GetBool("auto_ban")
|
autoBan := c.GetBool("auto_ban")
|
||||||
autoBanInt := 1
|
autoBanInt := 1
|
||||||
@@ -257,14 +225,17 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m
|
|||||||
AutoBan: &autoBanInt,
|
AutoBan: &autoBanInt,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
channel, selectGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount)
|
channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if group == "auto" {
|
return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, originalModel, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
|
||||||
return nil, errors.New(fmt.Sprintf("获取自动分组下模型 %s 的可用渠道失败: %s", originalModel, err.Error()))
|
}
|
||||||
}
|
if channel == nil {
|
||||||
return nil, errors.New(fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败: %s", selectGroup, originalModel, err.Error()))
|
return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(retry)", selectGroup, originalModel), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
|
||||||
|
}
|
||||||
|
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, originalModel)
|
||||||
|
if newAPIError != nil {
|
||||||
|
return nil, newAPIError
|
||||||
}
|
}
|
||||||
middleware.SetupContextForSelectedChannel(c, channel, originalModel)
|
|
||||||
return channel, nil
|
return channel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +246,7 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
|
|||||||
if types.IsChannelError(openaiErr) {
|
if types.IsChannelError(openaiErr) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if types.IsLocalError(openaiErr) {
|
if types.IsSkipRetryError(openaiErr) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if retryTimes <= 0 {
|
if retryTimes <= 0 {
|
||||||
@@ -298,10 +269,6 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if openaiErr.StatusCode == http.StatusBadRequest {
|
if openaiErr.StatusCode == http.StatusBadRequest {
|
||||||
channelType := c.GetInt("channel_type")
|
|
||||||
if channelType == constant.ChannelTypeAnthropic {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if openaiErr.StatusCode == 408 {
|
if openaiErr.StatusCode == 408 {
|
||||||
@@ -314,45 +281,87 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func processChannelError(c *gin.Context, channelId int, channelType int, channelName string, autoBan bool, err *types.NewAPIError) {
|
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
|
||||||
|
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
|
||||||
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
|
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
|
||||||
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
|
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
|
||||||
common.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelId, err.StatusCode, err.Error()))
|
if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan {
|
||||||
if service.ShouldDisableChannel(channelType, err) && autoBan {
|
gopool.Go(func() {
|
||||||
service.DisableChannel(channelId, channelName, err.Error())
|
service.DisableChannel(channelError, err.Error())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if constant.ErrorLogEnabled && types.IsRecordErrorLog(err) {
|
||||||
|
// 保存错误日志到mysql中
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
tokenName := c.GetString("token_name")
|
||||||
|
modelName := c.GetString("original_model")
|
||||||
|
tokenId := c.GetInt("token_id")
|
||||||
|
userGroup := c.GetString("group")
|
||||||
|
channelId := c.GetInt("channel_id")
|
||||||
|
other := make(map[string]interface{})
|
||||||
|
if c.Request != nil && c.Request.URL != nil {
|
||||||
|
other["request_path"] = c.Request.URL.Path
|
||||||
|
}
|
||||||
|
other["error_type"] = err.GetErrorType()
|
||||||
|
other["error_code"] = err.GetErrorCode()
|
||||||
|
other["status_code"] = err.StatusCode
|
||||||
|
other["channel_id"] = channelId
|
||||||
|
other["channel_name"] = c.GetString("channel_name")
|
||||||
|
other["channel_type"] = c.GetInt("channel_type")
|
||||||
|
adminInfo := make(map[string]interface{})
|
||||||
|
adminInfo["use_channel"] = c.GetStringSlice("use_channel")
|
||||||
|
isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey)
|
||||||
|
if isMultiKey {
|
||||||
|
adminInfo["is_multi_key"] = true
|
||||||
|
adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex)
|
||||||
|
}
|
||||||
|
other["admin_info"] = adminInfo
|
||||||
|
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveError(), tokenId, 0, false, userGroup, other)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func RelayMidjourney(c *gin.Context) {
|
func RelayMidjourney(c *gin.Context) {
|
||||||
relayMode := c.GetInt("relay_mode")
|
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatMjProxy, nil, nil)
|
||||||
var err *dto.MidjourneyResponse
|
|
||||||
switch relayMode {
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"description": fmt.Sprintf("failed to generate relay info: %s", err.Error()),
|
||||||
|
"type": "upstream_error",
|
||||||
|
"code": 4,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var mjErr *dto.MidjourneyResponse
|
||||||
|
switch relayInfo.RelayMode {
|
||||||
case relayconstant.RelayModeMidjourneyNotify:
|
case relayconstant.RelayModeMidjourneyNotify:
|
||||||
err = relay.RelayMidjourneyNotify(c)
|
mjErr = relay.RelayMidjourneyNotify(c)
|
||||||
case relayconstant.RelayModeMidjourneyTaskFetch, relayconstant.RelayModeMidjourneyTaskFetchByCondition:
|
case relayconstant.RelayModeMidjourneyTaskFetch, relayconstant.RelayModeMidjourneyTaskFetchByCondition:
|
||||||
err = relay.RelayMidjourneyTask(c, relayMode)
|
mjErr = relay.RelayMidjourneyTask(c, relayInfo.RelayMode)
|
||||||
case relayconstant.RelayModeMidjourneyTaskImageSeed:
|
case relayconstant.RelayModeMidjourneyTaskImageSeed:
|
||||||
err = relay.RelayMidjourneyTaskImageSeed(c)
|
mjErr = relay.RelayMidjourneyTaskImageSeed(c)
|
||||||
case relayconstant.RelayModeSwapFace:
|
case relayconstant.RelayModeSwapFace:
|
||||||
err = relay.RelaySwapFace(c)
|
mjErr = relay.RelaySwapFace(c, relayInfo)
|
||||||
default:
|
default:
|
||||||
err = relay.RelayMidjourneySubmit(c, relayMode)
|
mjErr = relay.RelayMidjourneySubmit(c, relayInfo)
|
||||||
}
|
}
|
||||||
//err = relayMidjourneySubmit(c, relayMode)
|
//err = relayMidjourneySubmit(c, relayMode)
|
||||||
log.Println(err)
|
log.Println(mjErr)
|
||||||
if err != nil {
|
if mjErr != nil {
|
||||||
statusCode := http.StatusBadRequest
|
statusCode := http.StatusBadRequest
|
||||||
if err.Code == 30 {
|
if mjErr.Code == 30 {
|
||||||
err.Result = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
|
mjErr.Result = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
|
||||||
statusCode = http.StatusTooManyRequests
|
statusCode = http.StatusTooManyRequests
|
||||||
}
|
}
|
||||||
c.JSON(statusCode, gin.H{
|
c.JSON(statusCode, gin.H{
|
||||||
"description": fmt.Sprintf("%s %s", err.Description, err.Result),
|
"description": fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result),
|
||||||
"type": "upstream_error",
|
"type": "upstream_error",
|
||||||
"code": err.Code,
|
"code": mjErr.Code,
|
||||||
})
|
})
|
||||||
channelId := c.GetInt("channel_id")
|
channelId := c.GetInt("channel_id")
|
||||||
common.LogError(c, fmt.Sprintf("relay error (channel #%d, status code %d): %s", channelId, statusCode, fmt.Sprintf("%s %s", err.Description, err.Result)))
|
logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code %d): %s", channelId, statusCode, fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,36 +392,39 @@ func RelayNotFound(c *gin.Context) {
|
|||||||
func RelayTask(c *gin.Context) {
|
func RelayTask(c *gin.Context) {
|
||||||
retryTimes := common.RetryTimes
|
retryTimes := common.RetryTimes
|
||||||
channelId := c.GetInt("channel_id")
|
channelId := c.GetInt("channel_id")
|
||||||
relayMode := c.GetInt("relay_mode")
|
|
||||||
group := c.GetString("group")
|
group := c.GetString("group")
|
||||||
originalModel := c.GetString("original_model")
|
originalModel := c.GetString("original_model")
|
||||||
c.Set("use_channel", []string{fmt.Sprintf("%d", channelId)})
|
c.Set("use_channel", []string{fmt.Sprintf("%d", channelId)})
|
||||||
taskErr := taskRelayHandler(c, relayMode)
|
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskErr := taskRelayHandler(c, relayInfo)
|
||||||
if taskErr == nil {
|
if taskErr == nil {
|
||||||
retryTimes = 0
|
retryTimes = 0
|
||||||
}
|
}
|
||||||
for i := 0; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && i < retryTimes; i++ {
|
for i := 0; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && i < retryTimes; i++ {
|
||||||
channel, err := getChannel(c, group, originalModel, i)
|
channel, newAPIError := getChannel(c, group, originalModel, i)
|
||||||
if err != nil {
|
if newAPIError != nil {
|
||||||
common.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", err.Error()))
|
logger.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", newAPIError.Error()))
|
||||||
taskErr = service.TaskErrorWrapperLocal(err, "get_channel_failed", http.StatusInternalServerError)
|
taskErr = service.TaskErrorWrapperLocal(newAPIError.Err, "get_channel_failed", http.StatusInternalServerError)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
channelId = channel.Id
|
channelId = channel.Id
|
||||||
useChannel := c.GetStringSlice("use_channel")
|
useChannel := c.GetStringSlice("use_channel")
|
||||||
useChannel = append(useChannel, fmt.Sprintf("%d", channelId))
|
useChannel = append(useChannel, fmt.Sprintf("%d", channelId))
|
||||||
c.Set("use_channel", useChannel)
|
c.Set("use_channel", useChannel)
|
||||||
common.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, i))
|
logger.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, i))
|
||||||
//middleware.SetupContextForSelectedChannel(c, channel, originalModel)
|
//middleware.SetupContextForSelectedChannel(c, channel, originalModel)
|
||||||
|
|
||||||
requestBody, err := common.GetRequestBody(c)
|
requestBody, _ := common.GetRequestBody(c)
|
||||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
||||||
taskErr = taskRelayHandler(c, relayMode)
|
taskErr = taskRelayHandler(c, relayInfo)
|
||||||
}
|
}
|
||||||
useChannel := c.GetStringSlice("use_channel")
|
useChannel := c.GetStringSlice("use_channel")
|
||||||
if len(useChannel) > 1 {
|
if len(useChannel) > 1 {
|
||||||
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
|
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
|
||||||
common.LogInfo(c, retryLogStr)
|
logger.LogInfo(c, retryLogStr)
|
||||||
}
|
}
|
||||||
if taskErr != nil {
|
if taskErr != nil {
|
||||||
if taskErr.StatusCode == http.StatusTooManyRequests {
|
if taskErr.StatusCode == http.StatusTooManyRequests {
|
||||||
@@ -422,13 +434,13 @@ func RelayTask(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func taskRelayHandler(c *gin.Context, relayMode int) *dto.TaskError {
|
func taskRelayHandler(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dto.TaskError {
|
||||||
var err *dto.TaskError
|
var err *dto.TaskError
|
||||||
switch relayMode {
|
switch relayInfo.RelayMode {
|
||||||
case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeKlingFetchByID:
|
case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeVideoFetchByID:
|
||||||
err = relay.RelayTaskFetch(c, relayMode)
|
err = relay.RelayTaskFetch(c, relayInfo.RelayMode)
|
||||||
default:
|
default:
|
||||||
err = relay.RelayTaskSubmit(c, relayMode)
|
err = relay.RelayTaskSubmit(c, relayInfo)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
314
controller/secure_verification.go
Normal file
314
controller/secure_verification.go
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
|
||||||
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SecureVerificationSessionKey 安全验证的 session key
|
||||||
|
SecureVerificationSessionKey = "secure_verified_at"
|
||||||
|
// SecureVerificationTimeout 验证有效期(秒)
|
||||||
|
SecureVerificationTimeout = 300 // 5分钟
|
||||||
|
)
|
||||||
|
|
||||||
|
type UniversalVerifyRequest struct {
|
||||||
|
Method string `json:"method"` // "2fa" 或 "passkey"
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerificationStatusResponse struct {
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UniversalVerify 通用验证接口
|
||||||
|
// 支持 2FA 和 Passkey 验证,验证成功后在 session 中记录时间戳
|
||||||
|
func UniversalVerify(c *gin.Context) {
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
if userId == 0 {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "未登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UniversalVerifyRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.ApiError(c, fmt.Errorf("参数错误: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
user := &model.User{Id: userId}
|
||||||
|
if err := user.FillUserById(); err != nil {
|
||||||
|
common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Status != common.UserStatusEnabled {
|
||||||
|
common.ApiError(c, fmt.Errorf("该用户已被禁用"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户的验证方式
|
||||||
|
twoFA, _ := model.GetTwoFAByUserId(userId)
|
||||||
|
has2FA := twoFA != nil && twoFA.IsEnabled
|
||||||
|
|
||||||
|
passkey, passkeyErr := model.GetPasskeyByUserID(userId)
|
||||||
|
hasPasskey := passkeyErr == nil && passkey != nil
|
||||||
|
|
||||||
|
if !has2FA && !hasPasskey {
|
||||||
|
common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据验证方式进行验证
|
||||||
|
var verified bool
|
||||||
|
var verifyMethod string
|
||||||
|
|
||||||
|
switch req.Method {
|
||||||
|
case "2fa":
|
||||||
|
if !has2FA {
|
||||||
|
common.ApiError(c, fmt.Errorf("用户未启用2FA"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Code == "" {
|
||||||
|
common.ApiError(c, fmt.Errorf("验证码不能为空"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
verified = validateTwoFactorAuth(twoFA, req.Code)
|
||||||
|
verifyMethod = "2FA"
|
||||||
|
|
||||||
|
case "passkey":
|
||||||
|
if !hasPasskey {
|
||||||
|
common.ApiError(c, fmt.Errorf("用户未启用Passkey"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Passkey 验证需要先调用 PasskeyVerifyBegin 和 PasskeyVerifyFinish
|
||||||
|
// 这里只是验证 Passkey 验证流程是否已经完成
|
||||||
|
// 实际上,前端应该先调用这两个接口,然后再调用本接口
|
||||||
|
verified = true // Passkey 验证逻辑已在 PasskeyVerifyFinish 中完成
|
||||||
|
verifyMethod = "Passkey"
|
||||||
|
|
||||||
|
default:
|
||||||
|
common.ApiError(c, fmt.Errorf("不支持的验证方式: %s", req.Method))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verified {
|
||||||
|
common.ApiError(c, fmt.Errorf("验证失败,请检查验证码"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证成功,在 session 中记录时间戳
|
||||||
|
session := sessions.Default(c)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
session.Set(SecureVerificationSessionKey, now)
|
||||||
|
if err := session.Save(); err != nil {
|
||||||
|
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录日志
|
||||||
|
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("通用安全验证成功 (验证方式: %s)", verifyMethod))
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "验证成功",
|
||||||
|
"data": gin.H{
|
||||||
|
"verified": true,
|
||||||
|
"expires_at": now + SecureVerificationTimeout,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVerificationStatus 获取验证状态
|
||||||
|
func GetVerificationStatus(c *gin.Context) {
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
if userId == 0 {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "未登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session := sessions.Default(c)
|
||||||
|
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
|
||||||
|
|
||||||
|
if verifiedAtRaw == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": VerificationStatusResponse{
|
||||||
|
Verified: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
verifiedAt, ok := verifiedAtRaw.(int64)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": VerificationStatusResponse{
|
||||||
|
Verified: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Now().Unix() - verifiedAt
|
||||||
|
if elapsed >= SecureVerificationTimeout {
|
||||||
|
// 验证已过期
|
||||||
|
session.Delete(SecureVerificationSessionKey)
|
||||||
|
_ = session.Save()
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": VerificationStatusResponse{
|
||||||
|
Verified: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": VerificationStatusResponse{
|
||||||
|
Verified: true,
|
||||||
|
ExpiresAt: verifiedAt + SecureVerificationTimeout,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckSecureVerification 检查是否已通过安全验证
|
||||||
|
// 返回 true 表示验证有效,false 表示需要重新验证
|
||||||
|
func CheckSecureVerification(c *gin.Context) bool {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
|
||||||
|
|
||||||
|
if verifiedAtRaw == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
verifiedAt, ok := verifiedAtRaw.(int64)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Now().Unix() - verifiedAt
|
||||||
|
if elapsed >= SecureVerificationTimeout {
|
||||||
|
// 验证已过期,清除 session
|
||||||
|
session.Delete(SecureVerificationSessionKey)
|
||||||
|
_ = session.Save()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyVerifyAndSetSession Passkey 验证完成后设置 session
|
||||||
|
// 这是一个辅助函数,供 PasskeyVerifyFinish 调用
|
||||||
|
func PasskeyVerifyAndSetSession(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
session.Set(SecureVerificationSessionKey, now)
|
||||||
|
_ = session.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyVerifyForSecure 用于安全验证的 Passkey 验证流程
|
||||||
|
// 整合了 begin 和 finish 流程
|
||||||
|
func PasskeyVerifyForSecure(c *gin.Context) {
|
||||||
|
if !system_setting.GetPasskeySettings().Enabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未启用 Passkey 登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
if userId == 0 {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "未登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &model.User{Id: userId}
|
||||||
|
if err := user.FillUserById(); err != nil {
|
||||||
|
common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Status != common.UserStatusEnabled {
|
||||||
|
common.ApiError(c, fmt.Errorf("该用户已被禁用"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, err := model.GetPasskeyByUserID(userId)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "该用户尚未绑定 Passkey",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
waUser := passkeysvc.NewWebAuthnUser(user, credential)
|
||||||
|
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = wa.FinishLogin(waUser, *sessionData, c.Request)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新凭证的最后使用时间
|
||||||
|
now := time.Now()
|
||||||
|
credential.LastUsedAt = &now
|
||||||
|
if err := model.UpsertPasskeyCredential(credential); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证成功,设置 session
|
||||||
|
PasskeyVerifyAndSetSession(c)
|
||||||
|
|
||||||
|
// 记录日志
|
||||||
|
model.RecordLog(userId, model.LogTypeSystem, "Passkey 安全验证成功")
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "Passkey 验证成功",
|
||||||
|
"data": gin.H{
|
||||||
|
"verified": true,
|
||||||
|
"expires_at": time.Now().Unix() + SecureVerificationTimeout,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"one-api/common"
|
|
||||||
"one-api/constant"
|
|
||||||
"one-api/model"
|
|
||||||
"one-api/setting/operation_setting"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Setup struct {
|
type Setup struct {
|
||||||
@@ -53,7 +54,7 @@ func GetSetup(c *gin.Context) {
|
|||||||
func PostSetup(c *gin.Context) {
|
func PostSetup(c *gin.Context) {
|
||||||
// Check if setup is already completed
|
// Check if setup is already completed
|
||||||
if constant.Setup {
|
if constant.Setup {
|
||||||
c.JSON(400, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "系统已经初始化完成",
|
"message": "系统已经初始化完成",
|
||||||
})
|
})
|
||||||
@@ -66,7 +67,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
var req SetupRequest
|
var req SetupRequest
|
||||||
err := c.ShouldBindJSON(&req)
|
err := c.ShouldBindJSON(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(400, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "请求参数有误",
|
"message": "请求参数有误",
|
||||||
})
|
})
|
||||||
@@ -77,7 +78,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
if !rootExists {
|
if !rootExists {
|
||||||
// Validate username length: max 12 characters to align with model.User validation
|
// Validate username length: max 12 characters to align with model.User validation
|
||||||
if len(req.Username) > 12 {
|
if len(req.Username) > 12 {
|
||||||
c.JSON(400, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "用户名长度不能超过12个字符",
|
"message": "用户名长度不能超过12个字符",
|
||||||
})
|
})
|
||||||
@@ -85,7 +86,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
// Validate password
|
// Validate password
|
||||||
if req.Password != req.ConfirmPassword {
|
if req.Password != req.ConfirmPassword {
|
||||||
c.JSON(400, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "两次输入的密码不一致",
|
"message": "两次输入的密码不一致",
|
||||||
})
|
})
|
||||||
@@ -93,7 +94,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(req.Password) < 8 {
|
if len(req.Password) < 8 {
|
||||||
c.JSON(400, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "密码长度至少为8个字符",
|
"message": "密码长度至少为8个字符",
|
||||||
})
|
})
|
||||||
@@ -103,7 +104,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
// Create root user
|
// Create root user
|
||||||
hashedPassword, err := common.Password2Hash(req.Password)
|
hashedPassword, err := common.Password2Hash(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "系统错误: " + err.Error(),
|
"message": "系统错误: " + err.Error(),
|
||||||
})
|
})
|
||||||
@@ -120,7 +121,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
err = model.DB.Create(&rootUser).Error
|
err = model.DB.Create(&rootUser).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "创建管理员账号失败: " + err.Error(),
|
"message": "创建管理员账号失败: " + err.Error(),
|
||||||
})
|
})
|
||||||
@@ -135,7 +136,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
// Save operation modes to database for persistence
|
// Save operation modes to database for persistence
|
||||||
err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled))
|
err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "保存自用模式设置失败: " + err.Error(),
|
"message": "保存自用模式设置失败: " + err.Error(),
|
||||||
})
|
})
|
||||||
@@ -144,7 +145,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
|
|
||||||
err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled))
|
err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "保存演示站点模式设置失败: " + err.Error(),
|
"message": "保存演示站点模式设置失败: " + err.Error(),
|
||||||
})
|
})
|
||||||
@@ -160,7 +161,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
err = model.DB.Create(&setup).Error
|
err = model.DB.Create(&setup).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "系统初始化失败: " + err.Error(),
|
"message": "系统初始化失败: " + err.Error(),
|
||||||
})
|
})
|
||||||
|
|||||||
136
controller/swag_video.go
Normal file
136
controller/swag_video.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VideoGenerations
|
||||||
|
// @Summary 生成视频
|
||||||
|
// @Description 调用视频生成接口生成视频
|
||||||
|
// @Description 支持多种视频生成服务:
|
||||||
|
// @Description - 可灵AI (Kling): https://app.klingai.com/cn/dev/document-api/apiReference/commonInfo
|
||||||
|
// @Description - 即梦 (Jimeng): https://www.volcengine.com/docs/85621/1538636
|
||||||
|
// @Tags Video
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param Authorization header string true "用户认证令牌 (Aeess-Token: sk-xxxx)"
|
||||||
|
// @Param request body dto.VideoRequest true "视频生成请求参数"
|
||||||
|
// @Failure 400 {object} dto.OpenAIError "请求参数错误"
|
||||||
|
// @Failure 401 {object} dto.OpenAIError "未授权"
|
||||||
|
// @Failure 403 {object} dto.OpenAIError "无权限"
|
||||||
|
// @Failure 500 {object} dto.OpenAIError "服务器内部错误"
|
||||||
|
// @Router /v1/video/generations [post]
|
||||||
|
func VideoGenerations(c *gin.Context) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// VideoGenerationsTaskId
|
||||||
|
// @Summary 查询视频
|
||||||
|
// @Description 根据任务ID查询视频生成任务的状态和结果
|
||||||
|
// @Tags Video
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param task_id path string true "Task ID"
|
||||||
|
// @Success 200 {object} dto.VideoTaskResponse "任务状态和结果"
|
||||||
|
// @Failure 400 {object} dto.OpenAIError "请求参数错误"
|
||||||
|
// @Failure 401 {object} dto.OpenAIError "未授权"
|
||||||
|
// @Failure 403 {object} dto.OpenAIError "无权限"
|
||||||
|
// @Failure 500 {object} dto.OpenAIError "服务器内部错误"
|
||||||
|
// @Router /v1/video/generations/{task_id} [get]
|
||||||
|
func VideoGenerationsTaskId(c *gin.Context) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// KlingText2VideoGenerations
|
||||||
|
// @Summary 可灵文生视频
|
||||||
|
// @Description 调用可灵AI文生视频接口,生成视频内容
|
||||||
|
// @Tags Video
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param Authorization header string true "用户认证令牌 (Aeess-Token: sk-xxxx)"
|
||||||
|
// @Param request body KlingText2VideoRequest true "视频生成请求参数"
|
||||||
|
// @Success 200 {object} dto.VideoTaskResponse "任务状态和结果"
|
||||||
|
// @Failure 400 {object} dto.OpenAIError "请求参数错误"
|
||||||
|
// @Failure 401 {object} dto.OpenAIError "未授权"
|
||||||
|
// @Failure 403 {object} dto.OpenAIError "无权限"
|
||||||
|
// @Failure 500 {object} dto.OpenAIError "服务器内部错误"
|
||||||
|
// @Router /kling/v1/videos/text2video [post]
|
||||||
|
func KlingText2VideoGenerations(c *gin.Context) {
|
||||||
|
}
|
||||||
|
|
||||||
|
type KlingText2VideoRequest struct {
|
||||||
|
ModelName string `json:"model_name,omitempty" example:"kling-v1"`
|
||||||
|
Prompt string `json:"prompt" binding:"required" example:"A cat playing piano in the garden"`
|
||||||
|
NegativePrompt string `json:"negative_prompt,omitempty" example:"blurry, low quality"`
|
||||||
|
CfgScale float64 `json:"cfg_scale,omitempty" example:"0.7"`
|
||||||
|
Mode string `json:"mode,omitempty" example:"std"`
|
||||||
|
CameraControl *KlingCameraControl `json:"camera_control,omitempty"`
|
||||||
|
AspectRatio string `json:"aspect_ratio,omitempty" example:"16:9"`
|
||||||
|
Duration string `json:"duration,omitempty" example:"5"`
|
||||||
|
CallbackURL string `json:"callback_url,omitempty" example:"https://your.domain/callback"`
|
||||||
|
ExternalTaskId string `json:"external_task_id,omitempty" example:"custom-task-001"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KlingCameraControl struct {
|
||||||
|
Type string `json:"type,omitempty" example:"simple"`
|
||||||
|
Config *KlingCameraConfig `json:"config,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KlingCameraConfig struct {
|
||||||
|
Horizontal float64 `json:"horizontal,omitempty" example:"2.5"`
|
||||||
|
Vertical float64 `json:"vertical,omitempty" example:"0"`
|
||||||
|
Pan float64 `json:"pan,omitempty" example:"0"`
|
||||||
|
Tilt float64 `json:"tilt,omitempty" example:"0"`
|
||||||
|
Roll float64 `json:"roll,omitempty" example:"0"`
|
||||||
|
Zoom float64 `json:"zoom,omitempty" example:"0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KlingImage2VideoGenerations
|
||||||
|
// @Summary 可灵官方-图生视频
|
||||||
|
// @Description 调用可灵AI图生视频接口,生成视频内容
|
||||||
|
// @Tags Video
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param Authorization header string true "用户认证令牌 (Aeess-Token: sk-xxxx)"
|
||||||
|
// @Param request body KlingImage2VideoRequest true "图生视频请求参数"
|
||||||
|
// @Success 200 {object} dto.VideoTaskResponse "任务状态和结果"
|
||||||
|
// @Failure 400 {object} dto.OpenAIError "请求参数错误"
|
||||||
|
// @Failure 401 {object} dto.OpenAIError "未授权"
|
||||||
|
// @Failure 403 {object} dto.OpenAIError "无权限"
|
||||||
|
// @Failure 500 {object} dto.OpenAIError "服务器内部错误"
|
||||||
|
// @Router /kling/v1/videos/image2video [post]
|
||||||
|
func KlingImage2VideoGenerations(c *gin.Context) {
|
||||||
|
}
|
||||||
|
|
||||||
|
type KlingImage2VideoRequest struct {
|
||||||
|
ModelName string `json:"model_name,omitempty" example:"kling-v2-master"`
|
||||||
|
Image string `json:"image" binding:"required" example:"https://h2.inkwai.com/bs2/upload-ylab-stunt/se/ai_portal_queue_mmu_image_upscale_aiweb/3214b798-e1b4-4b00-b7af-72b5b0417420_raw_image_0.jpg"`
|
||||||
|
Prompt string `json:"prompt,omitempty" example:"A cat playing piano in the garden"`
|
||||||
|
NegativePrompt string `json:"negative_prompt,omitempty" example:"blurry, low quality"`
|
||||||
|
CfgScale float64 `json:"cfg_scale,omitempty" example:"0.7"`
|
||||||
|
Mode string `json:"mode,omitempty" example:"std"`
|
||||||
|
CameraControl *KlingCameraControl `json:"camera_control,omitempty"`
|
||||||
|
AspectRatio string `json:"aspect_ratio,omitempty" example:"16:9"`
|
||||||
|
Duration string `json:"duration,omitempty" example:"5"`
|
||||||
|
CallbackURL string `json:"callback_url,omitempty" example:"https://your.domain/callback"`
|
||||||
|
ExternalTaskId string `json:"external_task_id,omitempty" example:"custom-task-002"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KlingImage2videoTaskId godoc
|
||||||
|
// @Summary 可灵任务查询--图生视频
|
||||||
|
// @Description Query the status and result of a Kling video generation task by task ID
|
||||||
|
// @Tags Origin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param task_id path string true "Task ID"
|
||||||
|
// @Router /kling/v1/videos/image2video/{task_id} [get]
|
||||||
|
func KlingImage2videoTaskId(c *gin.Context) {}
|
||||||
|
|
||||||
|
// KlingText2videoTaskId godoc
|
||||||
|
// @Summary 可灵任务查询--文生视频
|
||||||
|
// @Description Query the status and result of a Kling text-to-video generation task by task ID
|
||||||
|
// @Tags Origin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param task_id path string true "Task ID"
|
||||||
|
// @Router /kling/v1/videos/text2video/{task_id} [get]
|
||||||
|
func KlingText2videoTaskId(c *gin.Context) {}
|
||||||
@@ -5,18 +5,21 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/samber/lo"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/common"
|
|
||||||
"one-api/constant"
|
|
||||||
"one-api/dto"
|
|
||||||
"one-api/model"
|
|
||||||
"one-api/relay"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/QuantumNous/new-api/logger"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/relay"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/samber/lo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func UpdateTaskBulk() {
|
func UpdateTaskBulk() {
|
||||||
@@ -53,9 +56,9 @@ func UpdateTaskBulk() {
|
|||||||
"progress": "100%",
|
"progress": "100%",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogError(ctx, fmt.Sprintf("Fix null task_id task error: %v", err))
|
logger.LogError(ctx, fmt.Sprintf("Fix null task_id task error: %v", err))
|
||||||
} else {
|
} else {
|
||||||
common.LogInfo(ctx, fmt.Sprintf("Fix null task_id task success: %v", nullTaskIds))
|
logger.LogInfo(ctx, fmt.Sprintf("Fix null task_id task success: %v", nullTaskIds))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(taskChannelM) == 0 {
|
if len(taskChannelM) == 0 {
|
||||||
@@ -74,10 +77,10 @@ func UpdateTaskByPlatform(platform constant.TaskPlatform, taskChannelM map[int][
|
|||||||
//_ = UpdateMidjourneyTaskAll(context.Background(), tasks)
|
//_ = UpdateMidjourneyTaskAll(context.Background(), tasks)
|
||||||
case constant.TaskPlatformSuno:
|
case constant.TaskPlatformSuno:
|
||||||
_ = UpdateSunoTaskAll(context.Background(), taskChannelM, taskM)
|
_ = UpdateSunoTaskAll(context.Background(), taskChannelM, taskM)
|
||||||
case constant.TaskPlatformKling, constant.TaskPlatformJimeng:
|
|
||||||
_ = UpdateVideoTaskAll(context.Background(), platform, taskChannelM, taskM)
|
|
||||||
default:
|
default:
|
||||||
common.SysLog("未知平台")
|
if err := UpdateVideoTaskAll(context.Background(), platform, taskChannelM, taskM); err != nil {
|
||||||
|
common.SysLog(fmt.Sprintf("UpdateVideoTaskAll fail: %s", err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,14 +88,14 @@ func UpdateSunoTaskAll(ctx context.Context, taskChannelM map[int][]string, taskM
|
|||||||
for channelId, taskIds := range taskChannelM {
|
for channelId, taskIds := range taskChannelM {
|
||||||
err := updateSunoTaskAll(ctx, channelId, taskIds, taskM)
|
err := updateSunoTaskAll(ctx, channelId, taskIds, taskM)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %d", channelId, err.Error()))
|
logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %d", channelId, err.Error()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, taskM map[string]*model.Task) error {
|
func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, taskM map[string]*model.Task) error {
|
||||||
common.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
|
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
|
||||||
if len(taskIds) == 0 {
|
if len(taskIds) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -105,7 +108,7 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
|
|||||||
"progress": "100%",
|
"progress": "100%",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.SysError(fmt.Sprintf("UpdateMidjourneyTask error2: %v", err))
|
common.SysLog(fmt.Sprintf("UpdateMidjourneyTask error2: %v", err))
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -117,23 +120,23 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
|
|||||||
"ids": taskIds,
|
"ids": taskIds,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.SysError(fmt.Sprintf("Get Task Do req error: %v", err))
|
common.SysLog(fmt.Sprintf("Get Task Do req error: %v", err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
common.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
|
logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
|
||||||
return errors.New(fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
|
return errors.New(fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
responseBody, err := io.ReadAll(resp.Body)
|
responseBody, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.SysError(fmt.Sprintf("Get Task parse body error: %v", err))
|
common.SysLog(fmt.Sprintf("Get Task parse body error: %v", err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var responseItems dto.TaskResponse[[]dto.SunoDataResponse]
|
var responseItems dto.TaskResponse[[]dto.SunoDataResponse]
|
||||||
err = json.Unmarshal(responseBody, &responseItems)
|
err = json.Unmarshal(responseBody, &responseItems)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
|
logger.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !responseItems.IsSuccess() {
|
if !responseItems.IsSuccess() {
|
||||||
@@ -153,19 +156,19 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
|
|||||||
task.StartTime = lo.If(responseItem.StartTime != 0, responseItem.StartTime).Else(task.StartTime)
|
task.StartTime = lo.If(responseItem.StartTime != 0, responseItem.StartTime).Else(task.StartTime)
|
||||||
task.FinishTime = lo.If(responseItem.FinishTime != 0, responseItem.FinishTime).Else(task.FinishTime)
|
task.FinishTime = lo.If(responseItem.FinishTime != 0, responseItem.FinishTime).Else(task.FinishTime)
|
||||||
if responseItem.FailReason != "" || task.Status == model.TaskStatusFailure {
|
if responseItem.FailReason != "" || task.Status == model.TaskStatusFailure {
|
||||||
common.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason)
|
logger.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason)
|
||||||
task.Progress = "100%"
|
task.Progress = "100%"
|
||||||
//err = model.CacheUpdateUserQuota(task.UserId) ?
|
//err = model.CacheUpdateUserQuota(task.UserId) ?
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogError(ctx, "error update user quota cache: "+err.Error())
|
logger.LogError(ctx, "error update user quota cache: "+err.Error())
|
||||||
} else {
|
} else {
|
||||||
quota := task.Quota
|
quota := task.Quota
|
||||||
if quota != 0 {
|
if quota != 0 {
|
||||||
err = model.IncreaseUserQuota(task.UserId, quota, false)
|
err = model.IncreaseUserQuota(task.UserId, quota, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogError(ctx, "fail to increase user quota: "+err.Error())
|
logger.LogError(ctx, "fail to increase user quota: "+err.Error())
|
||||||
}
|
}
|
||||||
logContent := fmt.Sprintf("异步任务执行失败 %s,补偿 %s", task.TaskID, common.LogQuota(quota))
|
logContent := fmt.Sprintf("异步任务执行失败 %s,补偿 %s", task.TaskID, logger.LogQuota(quota))
|
||||||
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
|
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,7 +180,7 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
|
|||||||
|
|
||||||
err = task.Update()
|
err = task.Update()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.SysError("UpdateMidjourneyTask task error: " + err.Error())
|
common.SysLog("UpdateMidjourneyTask task error: " + err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -225,14 +228,7 @@ func checkTaskNeedUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetAllTask(c *gin.Context) {
|
func GetAllTask(c *gin.Context) {
|
||||||
p, _ := strconv.Atoi(c.Query("p"))
|
pageInfo := common.GetPageQuery(c)
|
||||||
if p < 1 {
|
|
||||||
p = 1
|
|
||||||
}
|
|
||||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
|
||||||
if pageSize <= 0 {
|
|
||||||
pageSize = common.ItemsPerPage
|
|
||||||
}
|
|
||||||
|
|
||||||
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
|
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
|
||||||
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
|
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
|
||||||
@@ -247,30 +243,15 @@ func GetAllTask(c *gin.Context) {
|
|||||||
ChannelID: c.Query("channel_id"),
|
ChannelID: c.Query("channel_id"),
|
||||||
}
|
}
|
||||||
|
|
||||||
items := model.TaskGetAllTasks((p-1)*pageSize, pageSize, queryParams)
|
items := model.TaskGetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
|
||||||
total := model.TaskCountAllTasks(queryParams)
|
total := model.TaskCountAllTasks(queryParams)
|
||||||
|
pageInfo.SetTotal(int(total))
|
||||||
c.JSON(200, gin.H{
|
pageInfo.SetItems(items)
|
||||||
"success": true,
|
common.ApiSuccess(c, pageInfo)
|
||||||
"message": "",
|
|
||||||
"data": gin.H{
|
|
||||||
"items": items,
|
|
||||||
"total": total,
|
|
||||||
"page": p,
|
|
||||||
"page_size": pageSize,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUserTask(c *gin.Context) {
|
func GetUserTask(c *gin.Context) {
|
||||||
p, _ := strconv.Atoi(c.Query("p"))
|
pageInfo := common.GetPageQuery(c)
|
||||||
if p < 1 {
|
|
||||||
p = 1
|
|
||||||
}
|
|
||||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
|
||||||
if pageSize <= 0 {
|
|
||||||
pageSize = common.ItemsPerPage
|
|
||||||
}
|
|
||||||
|
|
||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
|
|
||||||
@@ -286,17 +267,9 @@ func GetUserTask(c *gin.Context) {
|
|||||||
EndTimestamp: endTimestamp,
|
EndTimestamp: endTimestamp,
|
||||||
}
|
}
|
||||||
|
|
||||||
items := model.TaskGetAllUserTask(userId, (p-1)*pageSize, pageSize, queryParams)
|
items := model.TaskGetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
|
||||||
total := model.TaskCountAllUserTask(userId, queryParams)
|
total := model.TaskCountAllUserTask(userId, queryParams)
|
||||||
|
pageInfo.SetTotal(int(total))
|
||||||
c.JSON(200, gin.H{
|
pageInfo.SetItems(items)
|
||||||
"success": true,
|
common.ApiSuccess(c, pageInfo)
|
||||||
"message": "",
|
|
||||||
"data": gin.H{
|
|
||||||
"items": items,
|
|
||||||
"total": total,
|
|
||||||
"page": p,
|
|
||||||
"page_size": pageSize,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,27 +2,33 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"one-api/common"
|
|
||||||
"one-api/constant"
|
|
||||||
"one-api/model"
|
|
||||||
"one-api/relay"
|
|
||||||
"one-api/relay/channel"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/QuantumNous/new-api/logger"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/relay"
|
||||||
|
"github.com/QuantumNous/new-api/relay/channel"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
func UpdateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
|
func UpdateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
|
||||||
for channelId, taskIds := range taskChannelM {
|
for channelId, taskIds := range taskChannelM {
|
||||||
if err := updateVideoTaskAll(ctx, platform, channelId, taskIds, taskM); err != nil {
|
if err := updateVideoTaskAll(ctx, platform, channelId, taskIds, taskM); err != nil {
|
||||||
common.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
|
logger.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error {
|
func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error {
|
||||||
common.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
|
logger.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
|
||||||
if len(taskIds) == 0 {
|
if len(taskIds) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -34,7 +40,7 @@ func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, cha
|
|||||||
"progress": "100%",
|
"progress": "100%",
|
||||||
})
|
})
|
||||||
if errUpdate != nil {
|
if errUpdate != nil {
|
||||||
common.SysError(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate))
|
common.SysLog(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate))
|
||||||
}
|
}
|
||||||
return fmt.Errorf("CacheGetChannel failed: %w", err)
|
return fmt.Errorf("CacheGetChannel failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -42,9 +48,14 @@ func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, cha
|
|||||||
if adaptor == nil {
|
if adaptor == nil {
|
||||||
return fmt.Errorf("video adaptor not found")
|
return fmt.Errorf("video adaptor not found")
|
||||||
}
|
}
|
||||||
|
info := &relaycommon.RelayInfo{}
|
||||||
|
info.ChannelMeta = &relaycommon.ChannelMeta{
|
||||||
|
ChannelBaseUrl: cacheGetChannel.GetBaseURL(),
|
||||||
|
}
|
||||||
|
adaptor.Init(info)
|
||||||
for _, taskId := range taskIds {
|
for _, taskId := range taskIds {
|
||||||
if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
|
if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
|
||||||
common.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
|
logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -58,7 +69,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
|
|||||||
|
|
||||||
task := taskM[taskId]
|
task := taskM[taskId]
|
||||||
if task == nil {
|
if task == nil {
|
||||||
common.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId))
|
logger.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId))
|
||||||
return fmt.Errorf("task %s not found", taskId)
|
return fmt.Errorf("task %s not found", taskId)
|
||||||
}
|
}
|
||||||
resp, err := adaptor.FetchTask(baseURL, channel.Key, map[string]any{
|
resp, err := adaptor.FetchTask(baseURL, channel.Key, map[string]any{
|
||||||
@@ -77,18 +88,39 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
|
|||||||
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
|
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
taskResult, err := adaptor.ParseTaskResult(responseBody)
|
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask response: %s", string(responseBody)))
|
||||||
if err != nil {
|
|
||||||
|
taskResult := &relaycommon.TaskInfo{}
|
||||||
|
// try parse as New API response format
|
||||||
|
var responseItems dto.TaskResponse[model.Task]
|
||||||
|
if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
|
||||||
|
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask parsed as new api response format: %+v", responseItems))
|
||||||
|
t := responseItems.Data
|
||||||
|
taskResult.TaskID = t.TaskID
|
||||||
|
taskResult.Status = string(t.Status)
|
||||||
|
taskResult.Url = t.FailReason
|
||||||
|
taskResult.Progress = t.Progress
|
||||||
|
taskResult.Reason = t.FailReason
|
||||||
|
task.Data = t.Data
|
||||||
|
} else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil {
|
||||||
return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
|
return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
|
||||||
|
} else {
|
||||||
|
task.Data = redactVideoResponseBody(responseBody)
|
||||||
}
|
}
|
||||||
//if taskResult.Code != 0 {
|
|
||||||
// return fmt.Errorf("video task fetch failed for task %s", taskId)
|
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask taskResult: %+v", taskResult))
|
||||||
//}
|
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
if taskResult.Status == "" {
|
if taskResult.Status == "" {
|
||||||
return fmt.Errorf("task %s status is empty", taskId)
|
//return fmt.Errorf("task %s status is empty", taskId)
|
||||||
|
taskResult = relaycommon.FailTaskInfo("upstream returned empty status")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录原本的状态,防止重复退款
|
||||||
|
shouldRefund := false
|
||||||
|
quota := task.Quota
|
||||||
|
preStatus := task.Status
|
||||||
|
|
||||||
task.Status = model.TaskStatus(taskResult.Status)
|
task.Status = model.TaskStatus(taskResult.Status)
|
||||||
switch taskResult.Status {
|
switch taskResult.Status {
|
||||||
case model.TaskStatusSubmitted:
|
case model.TaskStatusSubmitted:
|
||||||
@@ -105,22 +137,115 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
|
|||||||
if task.FinishTime == 0 {
|
if task.FinishTime == 0 {
|
||||||
task.FinishTime = now
|
task.FinishTime = now
|
||||||
}
|
}
|
||||||
task.FailReason = taskResult.Url
|
if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") {
|
||||||
|
task.FailReason = taskResult.Url
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果返回了 total_tokens 并且配置了模型倍率(非固定价格),则重新计费
|
||||||
|
if taskResult.TotalTokens > 0 {
|
||||||
|
// 获取模型名称
|
||||||
|
var taskData map[string]interface{}
|
||||||
|
if err := json.Unmarshal(task.Data, &taskData); err == nil {
|
||||||
|
if modelName, ok := taskData["model"].(string); ok && modelName != "" {
|
||||||
|
// 获取模型价格和倍率
|
||||||
|
modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName)
|
||||||
|
// 只有配置了倍率(非固定价格)时才按 token 重新计费
|
||||||
|
if hasRatioSetting && modelRatio > 0 {
|
||||||
|
// 获取用户和组的倍率信息
|
||||||
|
group := task.Group
|
||||||
|
if group == "" {
|
||||||
|
user, err := model.GetUserById(task.UserId, false)
|
||||||
|
if err == nil {
|
||||||
|
group = user.Group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if group != "" {
|
||||||
|
groupRatio := ratio_setting.GetGroupRatio(group)
|
||||||
|
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(group, group)
|
||||||
|
|
||||||
|
var finalGroupRatio float64
|
||||||
|
if hasUserGroupRatio {
|
||||||
|
finalGroupRatio = userGroupRatio
|
||||||
|
} else {
|
||||||
|
finalGroupRatio = groupRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
|
||||||
|
actualQuota := int(float64(taskResult.TotalTokens) * modelRatio * finalGroupRatio)
|
||||||
|
|
||||||
|
// 计算差额
|
||||||
|
preConsumedQuota := task.Quota
|
||||||
|
quotaDelta := actualQuota - preConsumedQuota
|
||||||
|
|
||||||
|
if quotaDelta > 0 {
|
||||||
|
// 需要补扣费
|
||||||
|
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后补扣费:%s(实际消耗:%s,预扣费:%s,tokens:%d)",
|
||||||
|
task.TaskID,
|
||||||
|
logger.LogQuota(quotaDelta),
|
||||||
|
logger.LogQuota(actualQuota),
|
||||||
|
logger.LogQuota(preConsumedQuota),
|
||||||
|
taskResult.TotalTokens,
|
||||||
|
))
|
||||||
|
if err := model.DecreaseUserQuota(task.UserId, quotaDelta); err != nil {
|
||||||
|
logger.LogError(ctx, fmt.Sprintf("补扣费失败: %s", err.Error()))
|
||||||
|
} else {
|
||||||
|
model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta)
|
||||||
|
model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
|
||||||
|
task.Quota = actualQuota // 更新任务记录的实际扣费额度
|
||||||
|
|
||||||
|
// 记录消费日志
|
||||||
|
logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,补扣费 %s",
|
||||||
|
modelRatio, finalGroupRatio, taskResult.TotalTokens,
|
||||||
|
logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(quotaDelta))
|
||||||
|
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
|
||||||
|
}
|
||||||
|
} else if quotaDelta < 0 {
|
||||||
|
// 需要退还多扣的费用
|
||||||
|
refundQuota := -quotaDelta
|
||||||
|
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后返还:%s(实际消耗:%s,预扣费:%s,tokens:%d)",
|
||||||
|
task.TaskID,
|
||||||
|
logger.LogQuota(refundQuota),
|
||||||
|
logger.LogQuota(actualQuota),
|
||||||
|
logger.LogQuota(preConsumedQuota),
|
||||||
|
taskResult.TotalTokens,
|
||||||
|
))
|
||||||
|
if err := model.IncreaseUserQuota(task.UserId, refundQuota, false); err != nil {
|
||||||
|
logger.LogError(ctx, fmt.Sprintf("退还预扣费失败: %s", err.Error()))
|
||||||
|
} else {
|
||||||
|
task.Quota = actualQuota // 更新任务记录的实际扣费额度
|
||||||
|
|
||||||
|
// 记录退款日志
|
||||||
|
logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,退还 %s",
|
||||||
|
modelRatio, finalGroupRatio, taskResult.TotalTokens,
|
||||||
|
logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(refundQuota))
|
||||||
|
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// quotaDelta == 0, 预扣费刚好准确
|
||||||
|
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费准确(%s,tokens:%d)",
|
||||||
|
task.TaskID, logger.LogQuota(actualQuota), taskResult.TotalTokens))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
case model.TaskStatusFailure:
|
case model.TaskStatusFailure:
|
||||||
|
logger.LogJson(ctx, fmt.Sprintf("Task %s failed", taskId), task)
|
||||||
task.Status = model.TaskStatusFailure
|
task.Status = model.TaskStatusFailure
|
||||||
task.Progress = "100%"
|
task.Progress = "100%"
|
||||||
if task.FinishTime == 0 {
|
if task.FinishTime == 0 {
|
||||||
task.FinishTime = now
|
task.FinishTime = now
|
||||||
}
|
}
|
||||||
task.FailReason = taskResult.Reason
|
task.FailReason = taskResult.Reason
|
||||||
common.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
|
logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
|
||||||
quota := task.Quota
|
taskResult.Progress = "100%"
|
||||||
if quota != 0 {
|
if quota != 0 {
|
||||||
if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil {
|
if preStatus != model.TaskStatusFailure {
|
||||||
common.LogError(ctx, "Failed to increase user quota: "+err.Error())
|
shouldRefund = true
|
||||||
|
} else {
|
||||||
|
logger.LogWarn(ctx, fmt.Sprintf("Task %s already in failure status, skip refund", task.TaskID))
|
||||||
}
|
}
|
||||||
logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, common.LogQuota(quota))
|
|
||||||
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
|
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, taskId)
|
return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, taskId)
|
||||||
@@ -128,11 +253,53 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
|
|||||||
if taskResult.Progress != "" {
|
if taskResult.Progress != "" {
|
||||||
task.Progress = taskResult.Progress
|
task.Progress = taskResult.Progress
|
||||||
}
|
}
|
||||||
|
|
||||||
task.Data = responseBody
|
|
||||||
if err := task.Update(); err != nil {
|
if err := task.Update(); err != nil {
|
||||||
common.SysError("UpdateVideoTask task error: " + err.Error())
|
common.SysLog("UpdateVideoTask task error: " + err.Error())
|
||||||
|
shouldRefund = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldRefund {
|
||||||
|
// 任务失败且之前状态不是失败才退还额度,防止重复退还
|
||||||
|
if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil {
|
||||||
|
logger.LogWarn(ctx, "Failed to increase user quota: "+err.Error())
|
||||||
|
}
|
||||||
|
logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, logger.LogQuota(quota))
|
||||||
|
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func redactVideoResponseBody(body []byte) []byte {
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(body, &m); err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
resp, _ := m["response"].(map[string]any)
|
||||||
|
if resp != nil {
|
||||||
|
delete(resp, "bytesBase64Encoded")
|
||||||
|
if v, ok := resp["video"].(string); ok {
|
||||||
|
resp["video"] = truncateBase64(v)
|
||||||
|
}
|
||||||
|
if vs, ok := resp["videos"].([]any); ok {
|
||||||
|
for i := range vs {
|
||||||
|
if vm, ok := vs[i].(map[string]any); ok {
|
||||||
|
delete(vm, "bytesBase64Encoded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateBase64(s string) string {
|
||||||
|
const maxKeep = 256
|
||||||
|
if len(s) <= maxKeep {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxKeep] + "..."
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/common"
|
|
||||||
"one-api/model"
|
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -65,7 +66,7 @@ func TelegramBind(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Redirect(302, "/setting")
|
c.Redirect(302, "/console/personal")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TelegramLogin(c *gin.Context) {
|
func TelegramLogin(c *gin.Context) {
|
||||||
|
|||||||
@@ -1,46 +1,28 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/common"
|
|
||||||
"one-api/model"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetAllTokens(c *gin.Context) {
|
func GetAllTokens(c *gin.Context) {
|
||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
p, _ := strconv.Atoi(c.Query("p"))
|
pageInfo := common.GetPageQuery(c)
|
||||||
size, _ := strconv.Atoi(c.Query("size"))
|
tokens, err := model.GetAllUserTokens(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||||
if p < 1 {
|
|
||||||
p = 1
|
|
||||||
}
|
|
||||||
if size <= 0 {
|
|
||||||
size = common.ItemsPerPage
|
|
||||||
} else if size > 100 {
|
|
||||||
size = 100
|
|
||||||
}
|
|
||||||
tokens, err := model.GetAllUserTokens(userId, (p-1)*size, size)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Get total count for pagination
|
|
||||||
total, _ := model.CountUserTokens(userId)
|
total, _ := model.CountUserTokens(userId)
|
||||||
|
pageInfo.SetTotal(int(total))
|
||||||
c.JSON(http.StatusOK, gin.H{
|
pageInfo.SetItems(tokens)
|
||||||
"success": true,
|
common.ApiSuccess(c, pageInfo)
|
||||||
"message": "",
|
|
||||||
"data": gin.H{
|
|
||||||
"items": tokens,
|
|
||||||
"total": total,
|
|
||||||
"page": p,
|
|
||||||
"page_size": size,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,10 +32,7 @@ func SearchTokens(c *gin.Context) {
|
|||||||
token := c.Query("token")
|
token := c.Query("token")
|
||||||
tokens, err := model.SearchUserTokens(userId, keyword, token)
|
tokens, err := model.SearchUserTokens(userId, keyword, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -68,18 +47,12 @@ func GetToken(c *gin.Context) {
|
|||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, err := model.GetTokenByIds(id, userId)
|
token, err := model.GetTokenByIds(id, userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -95,10 +68,7 @@ func GetTokenStatus(c *gin.Context) {
|
|||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
token, err := model.GetTokenByIds(tokenId, userId)
|
token, err := model.GetTokenByIds(tokenId, userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
expiredAt := token.ExpiredTime
|
expiredAt := token.ExpiredTime
|
||||||
@@ -114,9 +84,27 @@ func GetTokenStatus(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddToken(c *gin.Context) {
|
func GetTokenUsage(c *gin.Context) {
|
||||||
token := model.Token{}
|
authHeader := c.GetHeader("Authorization")
|
||||||
err := c.ShouldBindJSON(&token)
|
if authHeader == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "No Authorization header",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(authHeader, " ")
|
||||||
|
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "Invalid Bearer token",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenKey := parts[1]
|
||||||
|
|
||||||
|
token, err := model.GetTokenByKey(strings.TrimPrefix(tokenKey, "sk-"), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -124,6 +112,36 @@ func AddToken(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expiredAt := token.ExpiredTime
|
||||||
|
if expiredAt == -1 {
|
||||||
|
expiredAt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": true,
|
||||||
|
"message": "ok",
|
||||||
|
"data": gin.H{
|
||||||
|
"object": "token_usage",
|
||||||
|
"name": token.Name,
|
||||||
|
"total_granted": token.RemainQuota + token.UsedQuota,
|
||||||
|
"total_used": token.UsedQuota,
|
||||||
|
"total_available": token.RemainQuota,
|
||||||
|
"unlimited_quota": token.UnlimitedQuota,
|
||||||
|
"model_limits": token.GetModelLimitsMap(),
|
||||||
|
"model_limits_enabled": token.ModelLimitsEnabled,
|
||||||
|
"expires_at": expiredAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddToken(c *gin.Context) {
|
||||||
|
token := model.Token{}
|
||||||
|
err := c.ShouldBindJSON(&token)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(token.Name) > 30 {
|
if len(token.Name) > 30 {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -137,7 +155,7 @@ func AddToken(c *gin.Context) {
|
|||||||
"success": false,
|
"success": false,
|
||||||
"message": "生成令牌失败",
|
"message": "生成令牌失败",
|
||||||
})
|
})
|
||||||
common.SysError("failed to generate token key: " + err.Error())
|
common.SysLog("failed to generate token key: " + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cleanToken := model.Token{
|
cleanToken := model.Token{
|
||||||
@@ -156,10 +174,7 @@ func AddToken(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
err = cleanToken.Insert()
|
err = cleanToken.Insert()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -174,10 +189,7 @@ func DeleteToken(c *gin.Context) {
|
|||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
err := model.DeleteTokenById(id, userId)
|
err := model.DeleteTokenById(id, userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -193,10 +205,7 @@ func UpdateToken(c *gin.Context) {
|
|||||||
token := model.Token{}
|
token := model.Token{}
|
||||||
err := c.ShouldBindJSON(&token)
|
err := c.ShouldBindJSON(&token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(token.Name) > 30 {
|
if len(token.Name) > 30 {
|
||||||
@@ -208,10 +217,7 @@ func UpdateToken(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
cleanToken, err := model.GetTokenByIds(token.Id, userId)
|
cleanToken, err := model.GetTokenByIds(token.Id, userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if token.Status == common.TokenStatusEnabled {
|
if token.Status == common.TokenStatusEnabled {
|
||||||
@@ -245,10 +251,7 @@ func UpdateToken(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
err = cleanToken.Update()
|
err = cleanToken.Update()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -275,10 +278,7 @@ func DeleteTokenBatch(c *gin.Context) {
|
|||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
count, err := model.BatchDeleteTokens(tokenBatch.Ids, userId)
|
count, err := model.BatchDeleteTokens(tokenBatch.Ids, userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
|||||||
@@ -4,20 +4,64 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
"one-api/common"
|
|
||||||
"one-api/model"
|
|
||||||
"one-api/service"
|
|
||||||
"one-api/setting"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/logger"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/service"
|
||||||
|
"github.com/QuantumNous/new-api/setting"
|
||||||
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||||
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||||
|
|
||||||
"github.com/Calcium-Ion/go-epay/epay"
|
"github.com/Calcium-Ion/go-epay/epay"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func GetTopUpInfo(c *gin.Context) {
|
||||||
|
// 获取支付方式
|
||||||
|
payMethods := operation_setting.PayMethods
|
||||||
|
|
||||||
|
// 如果启用了 Stripe 支付,添加到支付方法列表
|
||||||
|
if setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "" {
|
||||||
|
// 检查是否已经包含 Stripe
|
||||||
|
hasStripe := false
|
||||||
|
for _, method := range payMethods {
|
||||||
|
if method["type"] == "stripe" {
|
||||||
|
hasStripe = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasStripe {
|
||||||
|
stripeMethod := map[string]string{
|
||||||
|
"name": "Stripe",
|
||||||
|
"type": "stripe",
|
||||||
|
"color": "rgba(var(--semi-purple-5), 1)",
|
||||||
|
"min_topup": strconv.Itoa(setting.StripeMinTopUp),
|
||||||
|
}
|
||||||
|
payMethods = append(payMethods, stripeMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := gin.H{
|
||||||
|
"enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "",
|
||||||
|
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
|
||||||
|
"enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]",
|
||||||
|
"creem_products": setting.CreemProducts,
|
||||||
|
"pay_methods": payMethods,
|
||||||
|
"min_topup": operation_setting.MinTopUp,
|
||||||
|
"stripe_min_topup": setting.StripeMinTopUp,
|
||||||
|
"amount_options": operation_setting.GetPaymentSetting().AmountOptions,
|
||||||
|
"discount": operation_setting.GetPaymentSetting().AmountDiscount,
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
type EpayRequest struct {
|
type EpayRequest struct {
|
||||||
Amount int64 `json:"amount"`
|
Amount int64 `json:"amount"`
|
||||||
PaymentMethod string `json:"payment_method"`
|
PaymentMethod string `json:"payment_method"`
|
||||||
@@ -30,13 +74,13 @@ type AmountRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetEpayClient() *epay.Client {
|
func GetEpayClient() *epay.Client {
|
||||||
if setting.PayAddress == "" || setting.EpayId == "" || setting.EpayKey == "" {
|
if operation_setting.PayAddress == "" || operation_setting.EpayId == "" || operation_setting.EpayKey == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
withUrl, err := epay.NewClient(&epay.Config{
|
withUrl, err := epay.NewClient(&epay.Config{
|
||||||
PartnerID: setting.EpayId,
|
PartnerID: operation_setting.EpayId,
|
||||||
Key: setting.EpayKey,
|
Key: operation_setting.EpayKey,
|
||||||
}, setting.PayAddress)
|
}, operation_setting.PayAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -45,8 +89,9 @@ func GetEpayClient() *epay.Client {
|
|||||||
|
|
||||||
func getPayMoney(amount int64, group string) float64 {
|
func getPayMoney(amount int64, group string) float64 {
|
||||||
dAmount := decimal.NewFromInt(amount)
|
dAmount := decimal.NewFromInt(amount)
|
||||||
|
// 充值金额以“展示类型”为准:
|
||||||
if !common.DisplayInCurrencyEnabled {
|
// - USD/CNY: 前端传 amount 为金额单位;TOKENS: 前端传 tokens,需要换成 USD 金额
|
||||||
|
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||||
dAmount = dAmount.Div(dQuotaPerUnit)
|
dAmount = dAmount.Div(dQuotaPerUnit)
|
||||||
}
|
}
|
||||||
@@ -57,16 +102,24 @@ func getPayMoney(amount int64, group string) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dTopupGroupRatio := decimal.NewFromFloat(topupGroupRatio)
|
dTopupGroupRatio := decimal.NewFromFloat(topupGroupRatio)
|
||||||
dPrice := decimal.NewFromFloat(setting.Price)
|
dPrice := decimal.NewFromFloat(operation_setting.Price)
|
||||||
|
// apply optional preset discount by the original request amount (if configured), default 1.0
|
||||||
|
discount := 1.0
|
||||||
|
if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok {
|
||||||
|
if ds > 0 {
|
||||||
|
discount = ds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dDiscount := decimal.NewFromFloat(discount)
|
||||||
|
|
||||||
payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio)
|
payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio).Mul(dDiscount)
|
||||||
|
|
||||||
return payMoney.InexactFloat64()
|
return payMoney.InexactFloat64()
|
||||||
}
|
}
|
||||||
|
|
||||||
func getMinTopup() int64 {
|
func getMinTopup() int64 {
|
||||||
minTopup := setting.MinTopUp
|
minTopup := operation_setting.MinTopUp
|
||||||
if !common.DisplayInCurrencyEnabled {
|
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||||
dMinTopup := decimal.NewFromInt(int64(minTopup))
|
dMinTopup := decimal.NewFromInt(int64(minTopup))
|
||||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||||
minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart())
|
minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart())
|
||||||
@@ -98,13 +151,13 @@ func RequestEpay(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !setting.ContainsPayMethod(req.PaymentMethod) {
|
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
|
||||||
c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"})
|
c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
callBackAddress := service.GetCallbackAddress()
|
callBackAddress := service.GetCallbackAddress()
|
||||||
returnUrl, _ := url.Parse(setting.ServerAddress + "/console/log")
|
returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log")
|
||||||
notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
|
notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
|
||||||
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
|
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
|
||||||
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
|
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
|
||||||
@@ -127,18 +180,19 @@ func RequestEpay(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
amount := req.Amount
|
amount := req.Amount
|
||||||
if !common.DisplayInCurrencyEnabled {
|
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||||
dAmount := decimal.NewFromInt(int64(amount))
|
dAmount := decimal.NewFromInt(int64(amount))
|
||||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||||
amount = dAmount.Div(dQuotaPerUnit).IntPart()
|
amount = dAmount.Div(dQuotaPerUnit).IntPart()
|
||||||
}
|
}
|
||||||
topUp := &model.TopUp{
|
topUp := &model.TopUp{
|
||||||
UserId: id,
|
UserId: id,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
Money: payMoney,
|
Money: payMoney,
|
||||||
TradeNo: tradeNo,
|
TradeNo: tradeNo,
|
||||||
CreateTime: time.Now().Unix(),
|
PaymentMethod: req.PaymentMethod,
|
||||||
Status: "pending",
|
CreateTime: time.Now().Unix(),
|
||||||
|
Status: "pending",
|
||||||
}
|
}
|
||||||
err = topUp.Insert()
|
err = topUp.Insert()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -186,8 +240,8 @@ func EpayNotify(c *gin.Context) {
|
|||||||
_, err := c.Writer.Write([]byte("fail"))
|
_, err := c.Writer.Write([]byte("fail"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("易支付回调写入失败")
|
log.Println("易支付回调写入失败")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
verifyInfo, err := client.Verify(params)
|
verifyInfo, err := client.Verify(params)
|
||||||
if err == nil && verifyInfo.VerifyStatus {
|
if err == nil && verifyInfo.VerifyStatus {
|
||||||
@@ -231,7 +285,7 @@ func EpayNotify(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("易支付回调更新用户成功 %v", topUp)
|
log.Printf("易支付回调更新用户成功 %v", topUp)
|
||||||
model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", common.LogQuota(quotaToAdd), topUp.Money))
|
model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Printf("易支付异常回调: %v", verifyInfo)
|
log.Printf("易支付异常回调: %v", verifyInfo)
|
||||||
@@ -263,3 +317,76 @@ func RequestAmount(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
|
c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetUserTopUps(c *gin.Context) {
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
pageInfo := common.GetPageQuery(c)
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
|
||||||
|
var (
|
||||||
|
topups []*model.TopUp
|
||||||
|
total int64
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if keyword != "" {
|
||||||
|
topups, total, err = model.SearchUserTopUps(userId, keyword, pageInfo)
|
||||||
|
} else {
|
||||||
|
topups, total, err = model.GetUserTopUps(userId, pageInfo)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pageInfo.SetTotal(int(total))
|
||||||
|
pageInfo.SetItems(topups)
|
||||||
|
common.ApiSuccess(c, pageInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllTopUps 管理员获取全平台充值记录
|
||||||
|
func GetAllTopUps(c *gin.Context) {
|
||||||
|
pageInfo := common.GetPageQuery(c)
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
|
||||||
|
var (
|
||||||
|
topups []*model.TopUp
|
||||||
|
total int64
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if keyword != "" {
|
||||||
|
topups, total, err = model.SearchAllTopUps(keyword, pageInfo)
|
||||||
|
} else {
|
||||||
|
topups, total, err = model.GetAllTopUps(pageInfo)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pageInfo.SetTotal(int(total))
|
||||||
|
pageInfo.SetItems(topups)
|
||||||
|
common.ApiSuccess(c, pageInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminCompleteTopupRequest struct {
|
||||||
|
TradeNo string `json:"trade_no"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCompleteTopUp 管理员补单接口
|
||||||
|
func AdminCompleteTopUp(c *gin.Context) {
|
||||||
|
var req AdminCompleteTopupRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.TradeNo == "" {
|
||||||
|
common.ApiErrorMsg(c, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订单级互斥,防止并发补单
|
||||||
|
LockOrder(req.TradeNo)
|
||||||
|
defer UnlockOrder(req.TradeNo)
|
||||||
|
|
||||||
|
if err := model.ManualCompleteTopUp(req.TradeNo); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, nil)
|
||||||
|
}
|
||||||
|
|||||||
461
controller/topup_creem.go
Normal file
461
controller/topup_creem.go
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/setting"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/thanhpk/randstr"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PaymentMethodCreem = "creem"
|
||||||
|
CreemSignatureHeader = "creem-signature"
|
||||||
|
)
|
||||||
|
|
||||||
|
var creemAdaptor = &CreemAdaptor{}
|
||||||
|
|
||||||
|
// 生成HMAC-SHA256签名
|
||||||
|
func generateCreemSignature(payload string, secret string) string {
|
||||||
|
h := hmac.New(sha256.New, []byte(secret))
|
||||||
|
h.Write([]byte(payload))
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证Creem webhook签名
|
||||||
|
func verifyCreemSignature(payload string, signature string, secret string) bool {
|
||||||
|
if secret == "" {
|
||||||
|
log.Printf("Creem webhook secret not set")
|
||||||
|
if setting.CreemTestMode {
|
||||||
|
log.Printf("Skip Creem webhook sign verify in test mode")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedSignature := generateCreemSignature(payload, secret)
|
||||||
|
return hmac.Equal([]byte(signature), []byte(expectedSignature))
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreemPayRequest struct {
|
||||||
|
ProductId string `json:"product_id"`
|
||||||
|
PaymentMethod string `json:"payment_method"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreemProduct struct {
|
||||||
|
ProductId string `json:"productId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Quota int64 `json:"quota"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreemAdaptor struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
|
||||||
|
if req.PaymentMethod != PaymentMethodCreem {
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ProductId == "" {
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "请选择产品"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析产品列表
|
||||||
|
var products []CreemProduct
|
||||||
|
err := json.Unmarshal([]byte(setting.CreemProducts), &products)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("解析Creem产品列表失败", err)
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "产品配置错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找对应的产品
|
||||||
|
var selectedProduct *CreemProduct
|
||||||
|
for _, product := range products {
|
||||||
|
if product.ProductId == req.ProductId {
|
||||||
|
selectedProduct = &product
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedProduct == nil {
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "产品不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := c.GetInt("id")
|
||||||
|
user, _ := model.GetUserById(id, false)
|
||||||
|
|
||||||
|
// 生成唯一的订单引用ID
|
||||||
|
reference := fmt.Sprintf("creem-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
|
||||||
|
referenceId := "ref_" + common.Sha1([]byte(reference))
|
||||||
|
|
||||||
|
// 先创建订单记录,使用产品配置的金额和充值额度
|
||||||
|
topUp := &model.TopUp{
|
||||||
|
UserId: id,
|
||||||
|
Amount: selectedProduct.Quota, // 充值额度
|
||||||
|
Money: selectedProduct.Price, // 支付金额
|
||||||
|
TradeNo: referenceId,
|
||||||
|
CreateTime: time.Now().Unix(),
|
||||||
|
Status: common.TopUpStatusPending,
|
||||||
|
}
|
||||||
|
err = topUp.Insert()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("创建Creem订单失败: %v", err)
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建支付链接,传入用户邮箱
|
||||||
|
checkoutUrl, err := genCreemLink(referenceId, selectedProduct, user.Email, user.Username)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("获取Creem支付链接失败: %v", err)
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Creem订单创建成功 - 用户ID: %d, 订单号: %s, 产品: %s, 充值额度: %d, 支付金额: %.2f",
|
||||||
|
id, referenceId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price)
|
||||||
|
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"message": "success",
|
||||||
|
"data": gin.H{
|
||||||
|
"checkout_url": checkoutUrl,
|
||||||
|
"order_id": referenceId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequestCreemPay(c *gin.Context) {
|
||||||
|
var req CreemPayRequest
|
||||||
|
|
||||||
|
// 读取body内容用于打印,同时保留原始数据供后续使用
|
||||||
|
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("read creem pay req body err: %v", err)
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "read query error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印body内容
|
||||||
|
log.Printf("creem pay request body: %s", string(bodyBytes))
|
||||||
|
|
||||||
|
// 重新设置body供后续的ShouldBindJSON使用
|
||||||
|
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
|
|
||||||
|
err = c.ShouldBindJSON(&req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creemAdaptor.RequestPay(c, &req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新的Creem Webhook结构体,匹配实际的webhook数据格式
|
||||||
|
type CreemWebhookEvent struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
EventType string `json:"eventType"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
Object struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
RequestId string `json:"request_id"`
|
||||||
|
Order struct {
|
||||||
|
Object string `json:"object"`
|
||||||
|
Id string `json:"id"`
|
||||||
|
Customer string `json:"customer"`
|
||||||
|
Product string `json:"product"`
|
||||||
|
Amount int `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
SubTotal int `json:"sub_total"`
|
||||||
|
TaxAmount int `json:"tax_amount"`
|
||||||
|
AmountDue int `json:"amount_due"`
|
||||||
|
AmountPaid int `json:"amount_paid"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Transaction string `json:"transaction"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
} `json:"order"`
|
||||||
|
Product struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Price int `json:"price"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
BillingType string `json:"billing_type"`
|
||||||
|
BillingPeriod string `json:"billing_period"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
TaxMode string `json:"tax_mode"`
|
||||||
|
TaxCategory string `json:"tax_category"`
|
||||||
|
DefaultSuccessUrl *string `json:"default_success_url"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
} `json:"product"`
|
||||||
|
Units int `json:"units"`
|
||||||
|
Customer struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
} `json:"customer"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
} `json:"object"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留旧的结构体作为兼容
|
||||||
|
type CreemWebhookData struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Data struct {
|
||||||
|
RequestId string `json:"request_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreemWebhook(c *gin.Context) {
|
||||||
|
// 读取body内容用于打印,同时保留原始数据供后续使用
|
||||||
|
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("读取Creem Webhook请求body失败: %v", err)
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取签名头
|
||||||
|
signature := c.GetHeader(CreemSignatureHeader)
|
||||||
|
|
||||||
|
// 打印关键信息(避免输出完整敏感payload)
|
||||||
|
log.Printf("Creem Webhook - URI: %s", c.Request.RequestURI)
|
||||||
|
if setting.CreemTestMode {
|
||||||
|
log.Printf("Creem Webhook - Signature: %s , Body: %s", signature, bodyBytes)
|
||||||
|
} else if signature == "" {
|
||||||
|
log.Printf("Creem Webhook缺少签名头")
|
||||||
|
c.AbortWithStatus(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证签名
|
||||||
|
if !verifyCreemSignature(string(bodyBytes), signature, setting.CreemWebhookSecret) {
|
||||||
|
log.Printf("Creem Webhook签名验证失败")
|
||||||
|
c.AbortWithStatus(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Creem Webhook签名验证成功")
|
||||||
|
|
||||||
|
// 重新设置body供后续的ShouldBindJSON使用
|
||||||
|
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
|
|
||||||
|
// 解析新格式的webhook数据
|
||||||
|
var webhookEvent CreemWebhookEvent
|
||||||
|
if err := c.ShouldBindJSON(&webhookEvent); err != nil {
|
||||||
|
log.Printf("解析Creem Webhook参数失败: %v", err)
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Creem Webhook解析成功 - EventType: %s, EventId: %s", webhookEvent.EventType, webhookEvent.Id)
|
||||||
|
|
||||||
|
// 根据事件类型处理不同的webhook
|
||||||
|
switch webhookEvent.EventType {
|
||||||
|
case "checkout.completed":
|
||||||
|
handleCheckoutCompleted(c, &webhookEvent)
|
||||||
|
default:
|
||||||
|
log.Printf("忽略Creem Webhook事件类型: %s", webhookEvent.EventType)
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理支付完成事件
|
||||||
|
func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
||||||
|
// 验证订单状态
|
||||||
|
if event.Object.Order.Status != "paid" {
|
||||||
|
log.Printf("订单状态不是已支付: %s, 跳过处理", event.Object.Order.Status)
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取引用ID(这是我们创建订单时传递的request_id)
|
||||||
|
referenceId := event.Object.RequestId
|
||||||
|
if referenceId == "" {
|
||||||
|
log.Println("Creem Webhook缺少request_id字段")
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证订单类型,目前只处理一次性付款
|
||||||
|
if event.Object.Order.Type != "onetime" {
|
||||||
|
log.Printf("暂不支持的订单类型: %s, 跳过处理", event.Object.Order.Type)
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录详细的支付信息
|
||||||
|
log.Printf("处理Creem支付完成 - 订单号: %s, Creem订单ID: %s, 支付金额: %d %s, 客户邮箱: <redacted>, 产品: %s",
|
||||||
|
referenceId,
|
||||||
|
event.Object.Order.Id,
|
||||||
|
event.Object.Order.AmountPaid,
|
||||||
|
event.Object.Order.Currency,
|
||||||
|
event.Object.Product.Name)
|
||||||
|
|
||||||
|
// 查询本地订单确认存在
|
||||||
|
topUp := model.GetTopUpByTradeNo(referenceId)
|
||||||
|
if topUp == nil {
|
||||||
|
log.Printf("Creem充值订单不存在: %s", referenceId)
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if topUp.Status != common.TopUpStatusPending {
|
||||||
|
log.Printf("Creem充值订单状态错误: %s, 当前状态: %s", referenceId, topUp.Status)
|
||||||
|
c.Status(http.StatusOK) // 已处理过的订单,返回成功避免重复处理
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理充值,传入客户邮箱和姓名信息
|
||||||
|
customerEmail := event.Object.Customer.Email
|
||||||
|
customerName := event.Object.Customer.Name
|
||||||
|
|
||||||
|
// 防护性检查,确保邮箱和姓名不为空字符串
|
||||||
|
if customerEmail == "" {
|
||||||
|
log.Printf("警告:Creem回调中客户邮箱为空 - 订单号: %s", referenceId)
|
||||||
|
}
|
||||||
|
if customerName == "" {
|
||||||
|
log.Printf("警告:Creem回调中客户姓名为空 - 订单号: %s", referenceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := model.RechargeCreem(referenceId, customerEmail, customerName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Creem充值处理失败: %s, 订单号: %s", err.Error(), referenceId)
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Creem充值成功 - 订单号: %s, 充值额度: %d, 支付金额: %.2f",
|
||||||
|
referenceId, topUp.Amount, topUp.Money)
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreemCheckoutRequest struct {
|
||||||
|
ProductId string `json:"product_id"`
|
||||||
|
RequestId string `json:"request_id"`
|
||||||
|
Customer struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
} `json:"customer"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreemCheckoutResponse struct {
|
||||||
|
CheckoutUrl string `json:"checkout_url"`
|
||||||
|
Id string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func genCreemLink(referenceId string, product *CreemProduct, email string, username string) (string, error) {
|
||||||
|
if setting.CreemApiKey == "" {
|
||||||
|
return "", fmt.Errorf("未配置Creem API密钥")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据测试模式选择 API 端点
|
||||||
|
apiUrl := "https://api.creem.io/v1/checkouts"
|
||||||
|
if setting.CreemTestMode {
|
||||||
|
apiUrl = "https://test-api.creem.io/v1/checkouts"
|
||||||
|
log.Printf("使用Creem测试环境: %s", apiUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建请求数据,确保包含用户邮箱
|
||||||
|
requestData := CreemCheckoutRequest{
|
||||||
|
ProductId: product.ProductId,
|
||||||
|
RequestId: referenceId, // 这个作为订单ID传递给Creem
|
||||||
|
Customer: struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}{
|
||||||
|
Email: email, // 用户邮箱会在支付页面预填充
|
||||||
|
},
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"username": username,
|
||||||
|
"reference_id": referenceId,
|
||||||
|
"product_name": product.Name,
|
||||||
|
"quota": fmt.Sprintf("%d", product.Quota),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列化请求数据
|
||||||
|
jsonData, err := json.Marshal(requestData)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("序列化请求数据失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 HTTP 请求
|
||||||
|
req, err := http.NewRequest("POST", apiUrl, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("创建HTTP请求失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置请求头
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("x-api-key", setting.CreemApiKey)
|
||||||
|
|
||||||
|
log.Printf("发送Creem支付请求 - URL: %s, 产品ID: %s, 用户邮箱: %s, 订单号: %s",
|
||||||
|
apiUrl, product.ProductId, email, referenceId)
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("发送HTTP请求失败: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// 读取响应
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("读取响应失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Creem API resp - status code: %d, resp: %s", resp.StatusCode, string(body))
|
||||||
|
|
||||||
|
// 检查响应状态
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return "", fmt.Errorf("Creem API http status %d ", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 解析响应
|
||||||
|
var checkoutResp CreemCheckoutResponse
|
||||||
|
err = json.Unmarshal(body, &checkoutResp)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("解析响应失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkoutResp.CheckoutUrl == "" {
|
||||||
|
return "", fmt.Errorf("Creem API resp no checkout url ")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Creem 支付链接创建成功 - 订单号: %s, 支付链接: %s", referenceId, checkoutResp.CheckoutUrl)
|
||||||
|
return checkoutResp.CheckoutUrl, nil
|
||||||
|
}
|
||||||
288
controller/topup_stripe.go
Normal file
288
controller/topup_stripe.go
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/setting"
|
||||||
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||||
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stripe/stripe-go/v81"
|
||||||
|
"github.com/stripe/stripe-go/v81/checkout/session"
|
||||||
|
"github.com/stripe/stripe-go/v81/webhook"
|
||||||
|
"github.com/thanhpk/randstr"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PaymentMethodStripe = "stripe"
|
||||||
|
)
|
||||||
|
|
||||||
|
var stripeAdaptor = &StripeAdaptor{}
|
||||||
|
|
||||||
|
type StripePayRequest struct {
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
PaymentMethod string `json:"payment_method"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StripeAdaptor struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) {
|
||||||
|
if req.Amount < getStripeMinTopup() {
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := c.GetInt("id")
|
||||||
|
group, err := model.GetUserGroup(id, true)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payMoney := getStripePayMoney(float64(req.Amount), group)
|
||||||
|
if payMoney <= 0.01 {
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
|
||||||
|
if req.PaymentMethod != PaymentMethodStripe {
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Amount < getStripeMinTopup() {
|
||||||
|
c.JSON(200, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup()), "data": 10})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Amount > 10000 {
|
||||||
|
c.JSON(200, gin.H{"message": "充值数量不能大于 10000", "data": 10})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := c.GetInt("id")
|
||||||
|
user, _ := model.GetUserById(id, false)
|
||||||
|
chargedMoney := GetChargedAmount(float64(req.Amount), *user)
|
||||||
|
|
||||||
|
reference := fmt.Sprintf("new-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
|
||||||
|
referenceId := "ref_" + common.Sha1([]byte(reference))
|
||||||
|
|
||||||
|
payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, req.Amount)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("获取Stripe Checkout支付链接失败", err)
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp := &model.TopUp{
|
||||||
|
UserId: id,
|
||||||
|
Amount: req.Amount,
|
||||||
|
Money: chargedMoney,
|
||||||
|
TradeNo: referenceId,
|
||||||
|
PaymentMethod: PaymentMethodStripe,
|
||||||
|
CreateTime: time.Now().Unix(),
|
||||||
|
Status: common.TopUpStatusPending,
|
||||||
|
}
|
||||||
|
err = topUp.Insert()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"message": "success",
|
||||||
|
"data": gin.H{
|
||||||
|
"pay_link": payLink,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequestStripeAmount(c *gin.Context) {
|
||||||
|
var req StripePayRequest
|
||||||
|
err := c.ShouldBindJSON(&req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stripeAdaptor.RequestAmount(c, &req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequestStripePay(c *gin.Context) {
|
||||||
|
var req StripePayRequest
|
||||||
|
err := c.ShouldBindJSON(&req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stripeAdaptor.RequestPay(c, &req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func StripeWebhook(c *gin.Context) {
|
||||||
|
payload, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("解析Stripe Webhook参数失败: %v\n", err)
|
||||||
|
c.AbortWithStatus(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
signature := c.GetHeader("Stripe-Signature")
|
||||||
|
endpointSecret := setting.StripeWebhookSecret
|
||||||
|
event, err := webhook.ConstructEventWithOptions(payload, signature, endpointSecret, webhook.ConstructEventOptions{
|
||||||
|
IgnoreAPIVersionMismatch: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Stripe Webhook验签失败: %v\n", err)
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch event.Type {
|
||||||
|
case stripe.EventTypeCheckoutSessionCompleted:
|
||||||
|
sessionCompleted(event)
|
||||||
|
case stripe.EventTypeCheckoutSessionExpired:
|
||||||
|
sessionExpired(event)
|
||||||
|
default:
|
||||||
|
log.Printf("不支持的Stripe Webhook事件类型: %s\n", event.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionCompleted(event stripe.Event) {
|
||||||
|
customerId := event.GetObjectValue("customer")
|
||||||
|
referenceId := event.GetObjectValue("client_reference_id")
|
||||||
|
status := event.GetObjectValue("status")
|
||||||
|
if "complete" != status {
|
||||||
|
log.Println("错误的Stripe Checkout完成状态:", status, ",", referenceId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := model.Recharge(referenceId, customerId)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err.Error(), referenceId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
total, _ := strconv.ParseFloat(event.GetObjectValue("amount_total"), 64)
|
||||||
|
currency := strings.ToUpper(event.GetObjectValue("currency"))
|
||||||
|
log.Printf("收到款项:%s, %.2f(%s)", referenceId, total/100, currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionExpired(event stripe.Event) {
|
||||||
|
referenceId := event.GetObjectValue("client_reference_id")
|
||||||
|
status := event.GetObjectValue("status")
|
||||||
|
if "expired" != status {
|
||||||
|
log.Println("错误的Stripe Checkout过期状态:", status, ",", referenceId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(referenceId) == 0 {
|
||||||
|
log.Println("未提供支付单号")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp := model.GetTopUpByTradeNo(referenceId)
|
||||||
|
if topUp == nil {
|
||||||
|
log.Println("充值订单不存在", referenceId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if topUp.Status != common.TopUpStatusPending {
|
||||||
|
log.Println("充值订单状态错误", referenceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp.Status = common.TopUpStatusExpired
|
||||||
|
err := topUp.Update()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("过期充值订单失败", referenceId, ", err:", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("充值订单已过期", referenceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func genStripeLink(referenceId string, customerId string, email string, amount int64) (string, error) {
|
||||||
|
if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") {
|
||||||
|
return "", fmt.Errorf("无效的Stripe API密钥")
|
||||||
|
}
|
||||||
|
|
||||||
|
stripe.Key = setting.StripeApiSecret
|
||||||
|
|
||||||
|
params := &stripe.CheckoutSessionParams{
|
||||||
|
ClientReferenceID: stripe.String(referenceId),
|
||||||
|
SuccessURL: stripe.String(system_setting.ServerAddress + "/console/log"),
|
||||||
|
CancelURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
|
||||||
|
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||||
|
{
|
||||||
|
Price: stripe.String(setting.StripePriceId),
|
||||||
|
Quantity: stripe.Int64(amount),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
|
||||||
|
AllowPromotionCodes: stripe.Bool(setting.StripePromotionCodesEnabled),
|
||||||
|
}
|
||||||
|
|
||||||
|
if "" == customerId {
|
||||||
|
if "" != email {
|
||||||
|
params.CustomerEmail = stripe.String(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
params.CustomerCreation = stripe.String(string(stripe.CheckoutSessionCustomerCreationAlways))
|
||||||
|
} else {
|
||||||
|
params.Customer = stripe.String(customerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := session.New(params)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.URL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetChargedAmount(count float64, user model.User) float64 {
|
||||||
|
topUpGroupRatio := common.GetTopupGroupRatio(user.Group)
|
||||||
|
if topUpGroupRatio == 0 {
|
||||||
|
topUpGroupRatio = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return count * topUpGroupRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStripePayMoney(amount float64, group string) float64 {
|
||||||
|
originalAmount := amount
|
||||||
|
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||||
|
amount = amount / common.QuotaPerUnit
|
||||||
|
}
|
||||||
|
// Using float64 for monetary calculations is acceptable here due to the small amounts involved
|
||||||
|
topupGroupRatio := common.GetTopupGroupRatio(group)
|
||||||
|
if topupGroupRatio == 0 {
|
||||||
|
topupGroupRatio = 1
|
||||||
|
}
|
||||||
|
// apply optional preset discount by the original request amount (if configured), default 1.0
|
||||||
|
discount := 1.0
|
||||||
|
if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok {
|
||||||
|
if ds > 0 {
|
||||||
|
discount = ds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payMoney := amount * setting.StripeUnitPrice * topupGroupRatio * discount
|
||||||
|
return payMoney
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStripeMinTopup() int64 {
|
||||||
|
minTopup := setting.StripeMinTopUp
|
||||||
|
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||||
|
minTopup = minTopup * int(common.QuotaPerUnit)
|
||||||
|
}
|
||||||
|
return int64(minTopup)
|
||||||
|
}
|
||||||
554
controller/twofa.go
Normal file
554
controller/twofa.go
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Setup2FARequest 设置2FA请求结构
|
||||||
|
type Setup2FARequest struct {
|
||||||
|
Code string `json:"code" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify2FARequest 验证2FA请求结构
|
||||||
|
type Verify2FARequest struct {
|
||||||
|
Code string `json:"code" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup2FAResponse 设置2FA响应结构
|
||||||
|
type Setup2FAResponse struct {
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
QRCodeData string `json:"qr_code_data"`
|
||||||
|
BackupCodes []string `json:"backup_codes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup2FA 初始化2FA设置
|
||||||
|
func Setup2FA(c *gin.Context) {
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
|
||||||
|
// 检查用户是否已经启用2FA
|
||||||
|
existing, err := model.GetTwoFAByUserId(userId)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing != nil && existing.IsEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户已启用2FA,请先禁用后重新设置",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果存在已禁用的2FA记录,先删除它
|
||||||
|
if existing != nil && !existing.IsEnabled {
|
||||||
|
if err := existing.Delete(); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing = nil // 重置为nil,后续将创建新记录
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
user, err := model.GetUserById(userId, false)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成TOTP密钥
|
||||||
|
key, err := common.GenerateTOTPSecret(user.Username)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "生成2FA密钥失败",
|
||||||
|
})
|
||||||
|
common.SysLog("生成TOTP密钥失败: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成备用码
|
||||||
|
backupCodes, err := common.GenerateBackupCodes()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "生成备用码失败",
|
||||||
|
})
|
||||||
|
common.SysLog("生成备用码失败: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成二维码数据
|
||||||
|
qrCodeData := common.GenerateQRCodeData(key.Secret(), user.Username)
|
||||||
|
|
||||||
|
// 创建或更新2FA记录(暂未启用)
|
||||||
|
twoFA := &model.TwoFA{
|
||||||
|
UserId: userId,
|
||||||
|
Secret: key.Secret(),
|
||||||
|
IsEnabled: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing != nil {
|
||||||
|
// 更新现有记录
|
||||||
|
twoFA.Id = existing.Id
|
||||||
|
err = twoFA.Update()
|
||||||
|
} else {
|
||||||
|
// 创建新记录
|
||||||
|
err = twoFA.Create()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建备用码记录
|
||||||
|
if err := model.CreateBackupCodes(userId, backupCodes); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "保存备用码失败",
|
||||||
|
})
|
||||||
|
common.SysLog("保存备用码失败: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
model.RecordLog(userId, model.LogTypeSystem, "开始设置两步验证")
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "2FA设置初始化成功,请使用认证器扫描二维码并输入验证码完成设置",
|
||||||
|
"data": Setup2FAResponse{
|
||||||
|
Secret: key.Secret(),
|
||||||
|
QRCodeData: qrCodeData,
|
||||||
|
BackupCodes: backupCodes,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable2FA 启用2FA
|
||||||
|
func Enable2FA(c *gin.Context) {
|
||||||
|
var req Setup2FARequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "参数错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
|
||||||
|
// 获取2FA记录
|
||||||
|
twoFA, err := model.GetTwoFAByUserId(userId)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if twoFA == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "请先完成2FA初始化设置",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if twoFA.IsEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "2FA已经启用",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证TOTP验证码
|
||||||
|
cleanCode, err := common.ValidateNumericCode(req.Code)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !common.ValidateTOTPCode(twoFA.Secret, cleanCode) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "验证码或备用码错误,请重试",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用2FA
|
||||||
|
if err := twoFA.Enable(); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
model.RecordLog(userId, model.LogTypeSystem, "成功启用两步验证")
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "两步验证启用成功",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable2FA 禁用2FA
|
||||||
|
func Disable2FA(c *gin.Context) {
|
||||||
|
var req Verify2FARequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "参数错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
|
||||||
|
// 获取2FA记录
|
||||||
|
twoFA, err := model.GetTwoFAByUserId(userId)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if twoFA == nil || !twoFA.IsEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户未启用2FA",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证TOTP验证码或备用码
|
||||||
|
cleanCode, err := common.ValidateNumericCode(req.Code)
|
||||||
|
isValidTOTP := false
|
||||||
|
isValidBackup := false
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// 尝试验证TOTP
|
||||||
|
isValidTOTP, _ = twoFA.ValidateTOTPAndUpdateUsage(cleanCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidTOTP {
|
||||||
|
// 尝试验证备用码
|
||||||
|
isValidBackup, err = twoFA.ValidateBackupCodeAndUpdateUsage(req.Code)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidTOTP && !isValidBackup {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "验证码或备用码错误,请重试",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁用2FA
|
||||||
|
if err := model.DisableTwoFA(userId); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
model.RecordLog(userId, model.LogTypeSystem, "禁用两步验证")
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "两步验证已禁用",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get2FAStatus 获取用户2FA状态
|
||||||
|
func Get2FAStatus(c *gin.Context) {
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
|
||||||
|
twoFA, err := model.GetTwoFAByUserId(userId)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := map[string]interface{}{
|
||||||
|
"enabled": false,
|
||||||
|
"locked": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if twoFA != nil {
|
||||||
|
status["enabled"] = twoFA.IsEnabled
|
||||||
|
status["locked"] = twoFA.IsLocked()
|
||||||
|
if twoFA.IsEnabled {
|
||||||
|
// 获取剩余备用码数量
|
||||||
|
backupCount, err := model.GetUnusedBackupCodeCount(userId)
|
||||||
|
if err != nil {
|
||||||
|
common.SysLog("获取备用码数量失败: " + err.Error())
|
||||||
|
} else {
|
||||||
|
status["backup_codes_remaining"] = backupCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegenerateBackupCodes 重新生成备用码
|
||||||
|
func RegenerateBackupCodes(c *gin.Context) {
|
||||||
|
var req Verify2FARequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "参数错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
|
||||||
|
// 获取2FA记录
|
||||||
|
twoFA, err := model.GetTwoFAByUserId(userId)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if twoFA == nil || !twoFA.IsEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户未启用2FA",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证TOTP验证码
|
||||||
|
cleanCode, err := common.ValidateNumericCode(req.Code)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, err := twoFA.ValidateTOTPAndUpdateUsage(cleanCode)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "验证码或备用码错误,请重试",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成新的备用码
|
||||||
|
backupCodes, err := common.GenerateBackupCodes()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "生成备用码失败",
|
||||||
|
})
|
||||||
|
common.SysLog("生成备用码失败: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存新的备用码
|
||||||
|
if err := model.CreateBackupCodes(userId, backupCodes); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "保存备用码失败",
|
||||||
|
})
|
||||||
|
common.SysLog("保存备用码失败: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
model.RecordLog(userId, model.LogTypeSystem, "重新生成两步验证备用码")
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "备用码重新生成成功",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"backup_codes": backupCodes,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify2FALogin 登录时验证2FA
|
||||||
|
func Verify2FALogin(c *gin.Context) {
|
||||||
|
var req Verify2FARequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "参数错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从会话中获取pending用户信息
|
||||||
|
session := sessions.Default(c)
|
||||||
|
pendingUserId := session.Get("pending_user_id")
|
||||||
|
if pendingUserId == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "会话已过期,请重新登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userId, ok := pendingUserId.(int)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "会话数据无效,请重新登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 获取用户信息
|
||||||
|
user, err := model.GetUserById(userId, false)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户不存在",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取2FA记录
|
||||||
|
twoFA, err := model.GetTwoFAByUserId(user.Id)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if twoFA == nil || !twoFA.IsEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户未启用2FA",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证TOTP验证码或备用码
|
||||||
|
cleanCode, err := common.ValidateNumericCode(req.Code)
|
||||||
|
isValidTOTP := false
|
||||||
|
isValidBackup := false
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// 尝试验证TOTP
|
||||||
|
isValidTOTP, _ = twoFA.ValidateTOTPAndUpdateUsage(cleanCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidTOTP {
|
||||||
|
// 尝试验证备用码
|
||||||
|
isValidBackup, err = twoFA.ValidateBackupCodeAndUpdateUsage(req.Code)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidTOTP && !isValidBackup {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "验证码或备用码错误,请重试",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2FA验证成功,清理pending会话信息并完成登录
|
||||||
|
session.Delete("pending_username")
|
||||||
|
session.Delete("pending_user_id")
|
||||||
|
session.Save()
|
||||||
|
|
||||||
|
setupLogin(user, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin2FAStats 管理员获取2FA统计信息
|
||||||
|
func Admin2FAStats(c *gin.Context) {
|
||||||
|
stats, err := model.GetTwoFAStats()
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDisable2FA 管理员强制禁用用户2FA
|
||||||
|
func AdminDisable2FA(c *gin.Context) {
|
||||||
|
userIdStr := c.Param("id")
|
||||||
|
userId, err := strconv.Atoi(userIdStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户ID格式错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查目标用户权限
|
||||||
|
targetUser, err := model.GetUserById(userId, false)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
myRole := c.GetInt("role")
|
||||||
|
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无权操作同级或更高级用户的2FA设置",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁用2FA
|
||||||
|
if err := model.DisableTwoFA(userId); err != nil {
|
||||||
|
if errors.Is(err, model.ErrTwoFANotEnabled) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户未启用2FA",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
adminId := c.GetInt("id")
|
||||||
|
model.RecordLog(userId, model.LogTypeManage,
|
||||||
|
fmt.Sprintf("管理员(ID:%d)强制禁用了用户的两步验证", adminId))
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "用户2FA已被强制禁用",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,11 +5,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/setting/console_setting"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/setting/console_setting"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
)
|
)
|
||||||
@@ -31,7 +32,7 @@ type Monitor struct {
|
|||||||
|
|
||||||
type UptimeGroupResult struct {
|
type UptimeGroupResult struct {
|
||||||
CategoryName string `json:"categoryName"`
|
CategoryName string `json:"categoryName"`
|
||||||
Monitors []Monitor `json:"monitors"`
|
Monitors []Monitor `json:"monitors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
|
func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
|
||||||
@@ -57,29 +58,29 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
|
|||||||
url, _ := groupConfig["url"].(string)
|
url, _ := groupConfig["url"].(string)
|
||||||
slug, _ := groupConfig["slug"].(string)
|
slug, _ := groupConfig["slug"].(string)
|
||||||
categoryName, _ := groupConfig["categoryName"].(string)
|
categoryName, _ := groupConfig["categoryName"].(string)
|
||||||
|
|
||||||
result := UptimeGroupResult{
|
result := UptimeGroupResult{
|
||||||
CategoryName: categoryName,
|
CategoryName: categoryName,
|
||||||
Monitors: []Monitor{},
|
Monitors: []Monitor{},
|
||||||
}
|
}
|
||||||
|
|
||||||
if url == "" || slug == "" {
|
if url == "" || slug == "" {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
baseURL := strings.TrimSuffix(url, "/")
|
baseURL := strings.TrimSuffix(url, "/")
|
||||||
|
|
||||||
var statusData struct {
|
var statusData struct {
|
||||||
PublicGroupList []struct {
|
PublicGroupList []struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
MonitorList []struct {
|
MonitorList []struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"monitorList"`
|
} `json:"monitorList"`
|
||||||
} `json:"publicGroupList"`
|
} `json:"publicGroupList"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var heartbeatData struct {
|
var heartbeatData struct {
|
||||||
HeartbeatList map[string][]struct {
|
HeartbeatList map[string][]struct {
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
@@ -88,11 +89,11 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
|
|||||||
}
|
}
|
||||||
|
|
||||||
g, gCtx := errgroup.WithContext(ctx)
|
g, gCtx := errgroup.WithContext(ctx)
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData)
|
return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData)
|
||||||
})
|
})
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
|
return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
|
||||||
})
|
})
|
||||||
|
|
||||||
if g.Wait() != nil {
|
if g.Wait() != nil {
|
||||||
@@ -139,7 +140,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
|
|||||||
|
|
||||||
client := &http.Client{Timeout: httpTimeout}
|
client := &http.Client{Timeout: httpTimeout}
|
||||||
results := make([]UptimeGroupResult, len(groups))
|
results := make([]UptimeGroupResult, len(groups))
|
||||||
|
|
||||||
g, gCtx := errgroup.WithContext(ctx)
|
g, gCtx := errgroup.WithContext(ctx)
|
||||||
for i, group := range groups {
|
for i, group := range groups {
|
||||||
i, group := i, group
|
i, group := i, group
|
||||||
@@ -148,7 +149,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
g.Wait()
|
g.Wait()
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results})
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/model"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetAllQuotaDates(c *gin.Context) {
|
func GetAllQuotaDates(c *gin.Context) {
|
||||||
@@ -13,10 +16,7 @@ func GetAllQuotaDates(c *gin.Context) {
|
|||||||
username := c.Query("username")
|
username := c.Query("username")
|
||||||
dates, err := model.GetAllQuotaDates(startTimestamp, endTimestamp, username)
|
dates, err := model.GetAllQuotaDates(startTimestamp, endTimestamp, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -41,10 +41,7 @@ func GetUserQuotaDates(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
dates, err := model.GetQuotaDataByUserId(userId, startTimestamp, endTimestamp)
|
dates, err := model.GetQuotaDataByUserId(userId, startTimestamp, endTimestamp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"one-api/common"
|
|
||||||
"one-api/dto"
|
|
||||||
"one-api/model"
|
|
||||||
"one-api/setting"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"one-api/constant"
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/QuantumNous/new-api/logger"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/service"
|
||||||
|
"github.com/QuantumNous/new-api/setting"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -62,6 +65,32 @@ func Login(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否启用2FA
|
||||||
|
if model.IsTwoFAEnabled(user.Id) {
|
||||||
|
// 设置pending session,等待2FA验证
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Set("pending_username", user.Username)
|
||||||
|
session.Set("pending_user_id", user.Id)
|
||||||
|
err := session.Save()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "无法保存会话信息,请重试",
|
||||||
|
"success": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "请输入两步验证码",
|
||||||
|
"success": true,
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"require_2fa": true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setupLogin(&user, c)
|
setupLogin(&user, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +195,7 @@ func Register(c *gin.Context) {
|
|||||||
"success": false,
|
"success": false,
|
||||||
"message": "数据库错误,请稍后重试",
|
"message": "数据库错误,请稍后重试",
|
||||||
})
|
})
|
||||||
common.SysError(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err))
|
common.SysLog(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if exist {
|
if exist {
|
||||||
@@ -183,15 +212,13 @@ func Register(c *gin.Context) {
|
|||||||
Password: user.Password,
|
Password: user.Password,
|
||||||
DisplayName: user.Username,
|
DisplayName: user.Username,
|
||||||
InviterId: inviterId,
|
InviterId: inviterId,
|
||||||
|
Role: common.RoleCommonUser, // 明确设置角色为普通用户
|
||||||
}
|
}
|
||||||
if common.EmailVerificationEnabled {
|
if common.EmailVerificationEnabled {
|
||||||
cleanUser.Email = user.Email
|
cleanUser.Email = user.Email
|
||||||
}
|
}
|
||||||
if err := cleanUser.Insert(inviterId); err != nil {
|
if err := cleanUser.Insert(inviterId); err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +239,7 @@ func Register(c *gin.Context) {
|
|||||||
"success": false,
|
"success": false,
|
||||||
"message": "生成默认令牌失败",
|
"message": "生成默认令牌失败",
|
||||||
})
|
})
|
||||||
common.SysError("failed to generate token key: " + err.Error())
|
common.SysLog("failed to generate token key: " + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 生成默认令牌
|
// 生成默认令牌
|
||||||
@@ -247,81 +274,45 @@ func Register(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetAllUsers(c *gin.Context) {
|
func GetAllUsers(c *gin.Context) {
|
||||||
pageInfo, err := common.GetPageQuery(c)
|
pageInfo := common.GetPageQuery(c)
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"message": "parse page query failed",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
users, total, err := model.GetAllUsers(pageInfo)
|
users, total, err := model.GetAllUsers(pageInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pageInfo.SetTotal(int(total))
|
pageInfo.SetTotal(int(total))
|
||||||
pageInfo.SetItems(users)
|
pageInfo.SetItems(users)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": true,
|
common.ApiSuccess(c, pageInfo)
|
||||||
"message": "",
|
|
||||||
"data": pageInfo,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func SearchUsers(c *gin.Context) {
|
func SearchUsers(c *gin.Context) {
|
||||||
keyword := c.Query("keyword")
|
keyword := c.Query("keyword")
|
||||||
group := c.Query("group")
|
group := c.Query("group")
|
||||||
p, _ := strconv.Atoi(c.Query("p"))
|
pageInfo := common.GetPageQuery(c)
|
||||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
users, total, err := model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||||
if p < 1 {
|
|
||||||
p = 1
|
|
||||||
}
|
|
||||||
if pageSize < 0 {
|
|
||||||
pageSize = common.ItemsPerPage
|
|
||||||
}
|
|
||||||
startIdx := (p - 1) * pageSize
|
|
||||||
users, total, err := model.SearchUsers(keyword, group, startIdx, pageSize)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": true,
|
pageInfo.SetTotal(int(total))
|
||||||
"message": "",
|
pageInfo.SetItems(users)
|
||||||
"data": gin.H{
|
common.ApiSuccess(c, pageInfo)
|
||||||
"items": users,
|
|
||||||
"total": total,
|
|
||||||
"page": p,
|
|
||||||
"page_size": pageSize,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUser(c *gin.Context) {
|
func GetUser(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, err := model.GetUserById(id, false)
|
user, err := model.GetUserById(id, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
myRole := c.GetInt("role")
|
myRole := c.GetInt("role")
|
||||||
@@ -344,10 +335,7 @@ func GenerateAccessToken(c *gin.Context) {
|
|||||||
id := c.GetInt("id")
|
id := c.GetInt("id")
|
||||||
user, err := model.GetUserById(id, true)
|
user, err := model.GetUserById(id, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// get rand int 28-32
|
// get rand int 28-32
|
||||||
@@ -358,7 +346,7 @@ func GenerateAccessToken(c *gin.Context) {
|
|||||||
"success": false,
|
"success": false,
|
||||||
"message": "生成失败",
|
"message": "生成失败",
|
||||||
})
|
})
|
||||||
common.SysError("failed to generate key: " + err.Error())
|
common.SysLog("failed to generate key: " + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user.SetAccessToken(key)
|
user.SetAccessToken(key)
|
||||||
@@ -372,10 +360,7 @@ func GenerateAccessToken(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := user.Update(false); err != nil {
|
if err := user.Update(false); err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,18 +380,12 @@ func TransferAffQuota(c *gin.Context) {
|
|||||||
id := c.GetInt("id")
|
id := c.GetInt("id")
|
||||||
user, err := model.GetUserById(id, true)
|
user, err := model.GetUserById(id, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tran := TransferAffQuotaRequest{}
|
tran := TransferAffQuotaRequest{}
|
||||||
if err := c.ShouldBindJSON(&tran); err != nil {
|
if err := c.ShouldBindJSON(&tran); err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = user.TransferAffQuotaToQuota(tran.Quota)
|
err = user.TransferAffQuotaToQuota(tran.Quota)
|
||||||
@@ -427,10 +406,7 @@ func GetAffCode(c *gin.Context) {
|
|||||||
id := c.GetInt("id")
|
id := c.GetInt("id")
|
||||||
user, err := model.GetUserById(id, true)
|
user, err := model.GetUserById(id, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if user.AffCode == "" {
|
if user.AffCode == "" {
|
||||||
@@ -453,25 +429,147 @@ func GetAffCode(c *gin.Context) {
|
|||||||
|
|
||||||
func GetSelf(c *gin.Context) {
|
func GetSelf(c *gin.Context) {
|
||||||
id := c.GetInt("id")
|
id := c.GetInt("id")
|
||||||
|
userRole := c.GetInt("role")
|
||||||
user, err := model.GetUserById(id, false)
|
user, err := model.GetUserById(id, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
|
// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
|
||||||
user.Remark = ""
|
user.Remark = ""
|
||||||
|
|
||||||
|
// 计算用户权限信息
|
||||||
|
permissions := calculateUserPermissions(userRole)
|
||||||
|
|
||||||
|
// 获取用户设置并提取sidebar_modules
|
||||||
|
userSetting := user.GetSetting()
|
||||||
|
|
||||||
|
// 构建响应数据,包含用户信息和权限
|
||||||
|
responseData := map[string]interface{}{
|
||||||
|
"id": user.Id,
|
||||||
|
"username": user.Username,
|
||||||
|
"display_name": user.DisplayName,
|
||||||
|
"role": user.Role,
|
||||||
|
"status": user.Status,
|
||||||
|
"email": user.Email,
|
||||||
|
"github_id": user.GitHubId,
|
||||||
|
"oidc_id": user.OidcId,
|
||||||
|
"wechat_id": user.WeChatId,
|
||||||
|
"telegram_id": user.TelegramId,
|
||||||
|
"group": user.Group,
|
||||||
|
"quota": user.Quota,
|
||||||
|
"used_quota": user.UsedQuota,
|
||||||
|
"request_count": user.RequestCount,
|
||||||
|
"aff_code": user.AffCode,
|
||||||
|
"aff_count": user.AffCount,
|
||||||
|
"aff_quota": user.AffQuota,
|
||||||
|
"aff_history_quota": user.AffHistoryQuota,
|
||||||
|
"inviter_id": user.InviterId,
|
||||||
|
"linux_do_id": user.LinuxDOId,
|
||||||
|
"setting": user.Setting,
|
||||||
|
"stripe_customer": user.StripeCustomer,
|
||||||
|
"sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段
|
||||||
|
"permissions": permissions, // 新增权限字段
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": "",
|
||||||
"data": user,
|
"data": responseData,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算用户权限的辅助函数
|
||||||
|
func calculateUserPermissions(userRole int) map[string]interface{} {
|
||||||
|
permissions := map[string]interface{}{}
|
||||||
|
|
||||||
|
// 根据用户角色计算权限
|
||||||
|
if userRole == common.RoleRootUser {
|
||||||
|
// 超级管理员不需要边栏设置功能
|
||||||
|
permissions["sidebar_settings"] = false
|
||||||
|
permissions["sidebar_modules"] = map[string]interface{}{}
|
||||||
|
} else if userRole == common.RoleAdminUser {
|
||||||
|
// 管理员可以设置边栏,但不包含系统设置功能
|
||||||
|
permissions["sidebar_settings"] = true
|
||||||
|
permissions["sidebar_modules"] = map[string]interface{}{
|
||||||
|
"admin": map[string]interface{}{
|
||||||
|
"setting": false, // 管理员不能访问系统设置
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 普通用户只能设置个人功能,不包含管理员区域
|
||||||
|
permissions["sidebar_settings"] = true
|
||||||
|
permissions["sidebar_modules"] = map[string]interface{}{
|
||||||
|
"admin": false, // 普通用户不能访问管理员区域
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据用户角色生成默认的边栏配置
|
||||||
|
func generateDefaultSidebarConfig(userRole int) string {
|
||||||
|
defaultConfig := map[string]interface{}{}
|
||||||
|
|
||||||
|
// 聊天区域 - 所有用户都可以访问
|
||||||
|
defaultConfig["chat"] = map[string]interface{}{
|
||||||
|
"enabled": true,
|
||||||
|
"playground": true,
|
||||||
|
"chat": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 控制台区域 - 所有用户都可以访问
|
||||||
|
defaultConfig["console"] = map[string]interface{}{
|
||||||
|
"enabled": true,
|
||||||
|
"detail": true,
|
||||||
|
"token": true,
|
||||||
|
"log": true,
|
||||||
|
"midjourney": true,
|
||||||
|
"task": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 个人中心区域 - 所有用户都可以访问
|
||||||
|
defaultConfig["personal"] = map[string]interface{}{
|
||||||
|
"enabled": true,
|
||||||
|
"topup": true,
|
||||||
|
"personal": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员区域 - 根据角色决定
|
||||||
|
if userRole == common.RoleAdminUser {
|
||||||
|
// 管理员可以访问管理员区域,但不能访问系统设置
|
||||||
|
defaultConfig["admin"] = map[string]interface{}{
|
||||||
|
"enabled": true,
|
||||||
|
"channel": true,
|
||||||
|
"models": true,
|
||||||
|
"redemption": true,
|
||||||
|
"user": true,
|
||||||
|
"setting": false, // 管理员不能访问系统设置
|
||||||
|
}
|
||||||
|
} else if userRole == common.RoleRootUser {
|
||||||
|
// 超级管理员可以访问所有功能
|
||||||
|
defaultConfig["admin"] = map[string]interface{}{
|
||||||
|
"enabled": true,
|
||||||
|
"channel": true,
|
||||||
|
"models": true,
|
||||||
|
"redemption": true,
|
||||||
|
"user": true,
|
||||||
|
"setting": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 普通用户不包含admin区域
|
||||||
|
|
||||||
|
// 转换为JSON字符串
|
||||||
|
configBytes, err := json.Marshal(defaultConfig)
|
||||||
|
if err != nil {
|
||||||
|
common.SysLog("生成默认边栏配置失败: " + err.Error())
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(configBytes)
|
||||||
|
}
|
||||||
|
|
||||||
func GetUserModels(c *gin.Context) {
|
func GetUserModels(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -479,13 +577,10 @@ func GetUserModels(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
user, err := model.GetUserCache(id)
|
user, err := model.GetUserCache(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
groups := setting.GetUserUsableGroups(user.Group)
|
groups := service.GetUserUsableGroups(user.Group)
|
||||||
var models []string
|
var models []string
|
||||||
for group := range groups {
|
for group := range groups {
|
||||||
for _, g := range model.GetGroupEnabledModels(group) {
|
for _, g := range model.GetGroupEnabledModels(group) {
|
||||||
@@ -524,10 +619,7 @@ func UpdateUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
originUser, err := model.GetUserById(updatedUser.Id, false)
|
originUser, err := model.GetUserById(updatedUser.Id, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
myRole := c.GetInt("role")
|
myRole := c.GetInt("role")
|
||||||
@@ -550,14 +642,11 @@ func UpdateUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
updatePassword := updatedUser.Password != ""
|
updatePassword := updatedUser.Password != ""
|
||||||
if err := updatedUser.Edit(updatePassword); err != nil {
|
if err := updatedUser.Edit(updatePassword); err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if originUser.Quota != updatedUser.Quota {
|
if originUser.Quota != updatedUser.Quota {
|
||||||
model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", common.LogQuota(originUser.Quota), common.LogQuota(updatedUser.Quota)))
|
model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", logger.LogQuota(originUser.Quota), logger.LogQuota(updatedUser.Quota)))
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -567,8 +656,8 @@ func UpdateUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func UpdateSelf(c *gin.Context) {
|
func UpdateSelf(c *gin.Context) {
|
||||||
var user model.User
|
var requestData map[string]interface{}
|
||||||
err := json.NewDecoder(c.Request.Body).Decode(&user)
|
err := json.NewDecoder(c.Request.Body).Decode(&requestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -576,6 +665,60 @@ func UpdateSelf(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否是sidebar_modules更新请求
|
||||||
|
if sidebarModules, exists := requestData["sidebar_modules"]; exists {
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
user, err := model.GetUserById(userId, false)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前用户设置
|
||||||
|
currentSetting := user.GetSetting()
|
||||||
|
|
||||||
|
// 更新sidebar_modules字段
|
||||||
|
if sidebarModulesStr, ok := sidebarModules.(string); ok {
|
||||||
|
currentSetting.SidebarModules = sidebarModulesStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存更新后的设置
|
||||||
|
user.SetSetting(currentSetting)
|
||||||
|
if err := user.Update(false); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "更新设置失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "设置更新成功",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原有的用户信息更新逻辑
|
||||||
|
var user model.User
|
||||||
|
requestDataBytes, err := json.Marshal(requestData)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无效的参数",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(requestDataBytes, &user)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无效的参数",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if user.Password == "" {
|
if user.Password == "" {
|
||||||
user.Password = "$I_LOVE_U" // make Validator happy :)
|
user.Password = "$I_LOVE_U" // make Validator happy :)
|
||||||
}
|
}
|
||||||
@@ -599,17 +742,11 @@ func UpdateSelf(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id)
|
updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := cleanUser.Update(updatePassword); err != nil {
|
if err := cleanUser.Update(updatePassword); err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,18 +777,12 @@ func checkUpdatePassword(originalPassword string, newPassword string, userId int
|
|||||||
func DeleteUser(c *gin.Context) {
|
func DeleteUser(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
originUser, err := model.GetUserById(id, false)
|
originUser, err := model.GetUserById(id, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
myRole := c.GetInt("role")
|
myRole := c.GetInt("role")
|
||||||
@@ -686,10 +817,7 @@ func DeleteSelf(c *gin.Context) {
|
|||||||
|
|
||||||
err := model.DeleteUserById(id)
|
err := model.DeleteUserById(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -733,12 +861,10 @@ func CreateUser(c *gin.Context) {
|
|||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Password: user.Password,
|
Password: user.Password,
|
||||||
DisplayName: user.DisplayName,
|
DisplayName: user.DisplayName,
|
||||||
|
Role: user.Role, // 保持管理员设置的角色
|
||||||
}
|
}
|
||||||
if err := cleanUser.Insert(0); err != nil {
|
if err := cleanUser.Insert(0); err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -848,10 +974,7 @@ func ManageUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := user.Update(false); err != nil {
|
if err := user.Update(false); err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
clearUser := model.User{
|
clearUser := model.User{
|
||||||
@@ -883,20 +1006,14 @@ func EmailBind(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
err := user.FillUserById()
|
err := user.FillUserById()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user.Email = email
|
user.Email = email
|
||||||
// no need to check if this email already taken, because we have used verification code to check it
|
// no need to check if this email already taken, because we have used verification code to check it
|
||||||
err = user.Update(false)
|
err = user.Update(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -910,27 +1027,67 @@ type topUpRequest struct {
|
|||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var topUpLock = sync.Mutex{}
|
var topUpLocks sync.Map
|
||||||
|
var topUpCreateLock sync.Mutex
|
||||||
|
|
||||||
|
type topUpTryLock struct {
|
||||||
|
ch chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTopUpTryLock() *topUpTryLock {
|
||||||
|
return &topUpTryLock{ch: make(chan struct{}, 1)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *topUpTryLock) TryLock() bool {
|
||||||
|
select {
|
||||||
|
case l.ch <- struct{}{}:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *topUpTryLock) Unlock() {
|
||||||
|
select {
|
||||||
|
case <-l.ch:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTopUpLock(userID int) *topUpTryLock {
|
||||||
|
if v, ok := topUpLocks.Load(userID); ok {
|
||||||
|
return v.(*topUpTryLock)
|
||||||
|
}
|
||||||
|
topUpCreateLock.Lock()
|
||||||
|
defer topUpCreateLock.Unlock()
|
||||||
|
if v, ok := topUpLocks.Load(userID); ok {
|
||||||
|
return v.(*topUpTryLock)
|
||||||
|
}
|
||||||
|
l := newTopUpTryLock()
|
||||||
|
topUpLocks.Store(userID, l)
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
func TopUp(c *gin.Context) {
|
func TopUp(c *gin.Context) {
|
||||||
topUpLock.Lock()
|
id := c.GetInt("id")
|
||||||
defer topUpLock.Unlock()
|
lock := getTopUpLock(id)
|
||||||
req := topUpRequest{}
|
if !lock.TryLock() {
|
||||||
err := c.ShouldBindJSON(&req)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": err.Error(),
|
"message": "充值处理中,请稍后重试",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := c.GetInt("id")
|
defer lock.Unlock()
|
||||||
|
req := topUpRequest{}
|
||||||
|
err := c.ShouldBindJSON(&req)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
quota, err := model.Redeem(req.Key, id)
|
quota, err := model.Redeem(req.Key, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -938,7 +1095,6 @@ func TopUp(c *gin.Context) {
|
|||||||
"message": "",
|
"message": "",
|
||||||
"data": quota,
|
"data": quota,
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserSettingRequest struct {
|
type UpdateUserSettingRequest struct {
|
||||||
@@ -947,6 +1103,10 @@ type UpdateUserSettingRequest struct {
|
|||||||
WebhookUrl string `json:"webhook_url,omitempty"`
|
WebhookUrl string `json:"webhook_url,omitempty"`
|
||||||
WebhookSecret string `json:"webhook_secret,omitempty"`
|
WebhookSecret string `json:"webhook_secret,omitempty"`
|
||||||
NotificationEmail string `json:"notification_email,omitempty"`
|
NotificationEmail string `json:"notification_email,omitempty"`
|
||||||
|
BarkUrl string `json:"bark_url,omitempty"`
|
||||||
|
GotifyUrl string `json:"gotify_url,omitempty"`
|
||||||
|
GotifyToken string `json:"gotify_token,omitempty"`
|
||||||
|
GotifyPriority int `json:"gotify_priority,omitempty"`
|
||||||
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
|
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
|
||||||
RecordIpLog bool `json:"record_ip_log"`
|
RecordIpLog bool `json:"record_ip_log"`
|
||||||
}
|
}
|
||||||
@@ -962,7 +1122,7 @@ func UpdateUserSetting(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证预警类型
|
// 验证预警类型
|
||||||
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook {
|
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "无效的预警类型",
|
"message": "无效的预警类型",
|
||||||
@@ -1010,13 +1170,71 @@ func UpdateUserSetting(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果是Bark类型,验证Bark URL
|
||||||
|
if req.QuotaWarningType == dto.NotifyTypeBark {
|
||||||
|
if req.BarkUrl == "" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "Bark推送URL不能为空",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 验证URL格式
|
||||||
|
if _, err := url.ParseRequestURI(req.BarkUrl); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无效的Bark推送URL",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 检查是否是HTTP或HTTPS
|
||||||
|
if !strings.HasPrefix(req.BarkUrl, "https://") && !strings.HasPrefix(req.BarkUrl, "http://") {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "Bark推送URL必须以http://或https://开头",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是Gotify类型,验证Gotify URL和Token
|
||||||
|
if req.QuotaWarningType == dto.NotifyTypeGotify {
|
||||||
|
if req.GotifyUrl == "" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "Gotify服务器地址不能为空",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.GotifyToken == "" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "Gotify令牌不能为空",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 验证URL格式
|
||||||
|
if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无效的Gotify服务器地址",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 检查是否是HTTP或HTTPS
|
||||||
|
if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "Gotify服务器地址必须以http://或https://开头",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
user, err := model.GetUserById(userId, true)
|
user, err := model.GetUserById(userId, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1041,6 +1259,23 @@ func UpdateUserSetting(c *gin.Context) {
|
|||||||
settings.NotificationEmail = req.NotificationEmail
|
settings.NotificationEmail = req.NotificationEmail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果是Bark类型,添加Bark URL到设置中
|
||||||
|
if req.QuotaWarningType == dto.NotifyTypeBark {
|
||||||
|
settings.BarkUrl = req.BarkUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是Gotify类型,添加Gotify配置到设置中
|
||||||
|
if req.QuotaWarningType == dto.NotifyTypeGotify {
|
||||||
|
settings.GotifyUrl = req.GotifyUrl
|
||||||
|
settings.GotifyToken = req.GotifyToken
|
||||||
|
// Gotify优先级范围0-10,超出范围则使用默认值5
|
||||||
|
if req.GotifyPriority < 0 || req.GotifyPriority > 10 {
|
||||||
|
settings.GotifyPriority = 5
|
||||||
|
} else {
|
||||||
|
settings.GotifyPriority = req.GotifyPriority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 更新用户设置
|
// 更新用户设置
|
||||||
user.SetSetting(settings)
|
user.SetSetting(settings)
|
||||||
if err := user.Update(false); err != nil {
|
if err := user.Update(false); err != nil {
|
||||||
|
|||||||
124
controller/vendor_meta.go
Normal file
124
controller/vendor_meta.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetAllVendors 获取供应商列表(分页)
|
||||||
|
func GetAllVendors(c *gin.Context) {
|
||||||
|
pageInfo := common.GetPageQuery(c)
|
||||||
|
vendors, err := model.GetAllVendors(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
model.DB.Model(&model.Vendor{}).Count(&total)
|
||||||
|
pageInfo.SetTotal(int(total))
|
||||||
|
pageInfo.SetItems(vendors)
|
||||||
|
common.ApiSuccess(c, pageInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchVendors 搜索供应商
|
||||||
|
func SearchVendors(c *gin.Context) {
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
pageInfo := common.GetPageQuery(c)
|
||||||
|
vendors, total, err := model.SearchVendors(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageInfo.SetTotal(int(total))
|
||||||
|
pageInfo.SetItems(vendors)
|
||||||
|
common.ApiSuccess(c, pageInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVendorMeta 根据 ID 获取供应商
|
||||||
|
func GetVendorMeta(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := model.GetVendorByID(id)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateVendorMeta 新建供应商
|
||||||
|
func CreateVendorMeta(c *gin.Context) {
|
||||||
|
var v model.Vendor
|
||||||
|
if err := c.ShouldBindJSON(&v); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.Name == "" {
|
||||||
|
common.ApiErrorMsg(c, "供应商名称不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 创建前先检查名称
|
||||||
|
if dup, err := model.IsVendorNameDuplicated(0, v.Name); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
} else if dup {
|
||||||
|
common.ApiErrorMsg(c, "供应商名称已存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := v.Insert(); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, &v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateVendorMeta 更新供应商
|
||||||
|
func UpdateVendorMeta(c *gin.Context) {
|
||||||
|
var v model.Vendor
|
||||||
|
if err := c.ShouldBindJSON(&v); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.Id == 0 {
|
||||||
|
common.ApiErrorMsg(c, "缺少供应商 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 名称冲突检查
|
||||||
|
if dup, err := model.IsVendorNameDuplicated(v.Id, v.Name); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
} else if dup {
|
||||||
|
common.ApiErrorMsg(c, "供应商名称已存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := v.Update(); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, &v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteVendorMeta 删除供应商
|
||||||
|
func DeleteVendorMeta(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := model.DB.Delete(&model.Vendor{}, id).Error; err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, nil)
|
||||||
|
}
|
||||||
177
controller/video_proxy.go
Normal file
177
controller/video_proxy.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/logger"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func VideoProxy(c *gin.Context) {
|
||||||
|
taskID := c.Param("task_id")
|
||||||
|
if taskID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": gin.H{
|
||||||
|
"message": "task_id is required",
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, exists, err := model.GetByOnlyTaskId(taskID)
|
||||||
|
if err != nil {
|
||||||
|
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to query task %s: %s", taskID, err.Error()))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": gin.H{
|
||||||
|
"message": "Failed to query task",
|
||||||
|
"type": "server_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists || task == nil {
|
||||||
|
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: %v", taskID, err))
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"error": gin.H{
|
||||||
|
"message": "Task not found",
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.Status != model.TaskStatusSuccess {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": gin.H{
|
||||||
|
"message": fmt.Sprintf("Task is not completed yet, current status: %s", task.Status),
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channel, err := model.CacheGetChannel(task.ChannelId)
|
||||||
|
if err != nil {
|
||||||
|
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: not found", taskID))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": gin.H{
|
||||||
|
"message": "Failed to retrieve channel information",
|
||||||
|
"type": "server_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
baseURL := channel.GetBaseURL()
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.openai.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
var videoURL string
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to create request: %s", err.Error()))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": gin.H{
|
||||||
|
"message": "Failed to create proxy request",
|
||||||
|
"type": "server_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch channel.Type {
|
||||||
|
case constant.ChannelTypeGemini:
|
||||||
|
apiKey := task.PrivateData.Key
|
||||||
|
if apiKey == "" {
|
||||||
|
logger.LogError(c.Request.Context(), fmt.Sprintf("Missing stored API key for Gemini task %s", taskID))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": gin.H{
|
||||||
|
"message": "API key not stored for task",
|
||||||
|
"type": "server_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
videoURL, err = getGeminiVideoURL(channel, task, apiKey)
|
||||||
|
if err != nil {
|
||||||
|
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to resolve Gemini video URL for task %s: %s", taskID, err.Error()))
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{
|
||||||
|
"error": gin.H{
|
||||||
|
"message": "Failed to resolve Gemini video URL",
|
||||||
|
"type": "server_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("x-goog-api-key", apiKey)
|
||||||
|
case constant.ChannelTypeAli:
|
||||||
|
// Video URL is directly in task.FailReason
|
||||||
|
videoURL = task.FailReason
|
||||||
|
default:
|
||||||
|
// Default (Sora, etc.): Use original logic
|
||||||
|
videoURL = fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.TaskID)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+channel.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.URL, err = url.Parse(videoURL)
|
||||||
|
if err != nil {
|
||||||
|
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to parse URL %s: %s", videoURL, err.Error()))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": gin.H{
|
||||||
|
"message": "Failed to create proxy request",
|
||||||
|
"type": "server_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to fetch video from %s: %s", videoURL, err.Error()))
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{
|
||||||
|
"error": gin.H{
|
||||||
|
"message": "Failed to fetch video content",
|
||||||
|
"type": "server_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
logger.LogError(c.Request.Context(), fmt.Sprintf("Upstream returned status %d for %s", resp.StatusCode, videoURL))
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{
|
||||||
|
"error": gin.H{
|
||||||
|
"message": fmt.Sprintf("Upstream service returned status %d", resp.StatusCode),
|
||||||
|
"type": "server_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, values := range resp.Header {
|
||||||
|
for _, value := range values {
|
||||||
|
c.Writer.Header().Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Writer.Header().Set("Cache-Control", "public, max-age=86400") // Cache for 24 hours
|
||||||
|
c.Writer.WriteHeader(resp.StatusCode)
|
||||||
|
_, err = io.Copy(c.Writer, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to stream video content: %s", err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
158
controller/video_proxy_gemini.go
Normal file
158
controller/video_proxy_gemini.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/relay"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getGeminiVideoURL(channel *model.Channel, task *model.Task, apiKey string) (string, error) {
|
||||||
|
if channel == nil || task == nil {
|
||||||
|
return "", fmt.Errorf("invalid channel or task")
|
||||||
|
}
|
||||||
|
|
||||||
|
if url := extractGeminiVideoURLFromTaskData(task); url != "" {
|
||||||
|
return ensureAPIKey(url, apiKey), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := constant.ChannelBaseURLs[channel.Type]
|
||||||
|
if channel.GetBaseURL() != "" {
|
||||||
|
baseURL = channel.GetBaseURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
adaptor := relay.GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channel.Type)))
|
||||||
|
if adaptor == nil {
|
||||||
|
return "", fmt.Errorf("gemini task adaptor not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiKey == "" {
|
||||||
|
return "", fmt.Errorf("api key not available for task")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := adaptor.FetchTask(baseURL, apiKey, map[string]any{
|
||||||
|
"task_id": task.TaskID,
|
||||||
|
"action": task.Action,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("fetch task failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read task response failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
taskInfo, parseErr := adaptor.ParseTaskResult(body)
|
||||||
|
if parseErr == nil && taskInfo != nil && taskInfo.RemoteUrl != "" {
|
||||||
|
return ensureAPIKey(taskInfo.RemoteUrl, apiKey), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if url := extractGeminiVideoURLFromPayload(body); url != "" {
|
||||||
|
return ensureAPIKey(url, apiKey), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if parseErr != nil {
|
||||||
|
return "", fmt.Errorf("parse task result failed: %w", parseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("gemini video url not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractGeminiVideoURLFromTaskData(task *model.Task) string {
|
||||||
|
if task == nil || len(task.Data) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(task.Data, &payload); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return extractGeminiVideoURLFromMap(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractGeminiVideoURLFromPayload(body []byte) string {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return extractGeminiVideoURLFromMap(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractGeminiVideoURLFromMap(payload map[string]any) string {
|
||||||
|
if payload == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if uri, ok := payload["uri"].(string); ok && uri != "" {
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
if resp, ok := payload["response"].(map[string]any); ok {
|
||||||
|
if uri := extractGeminiVideoURLFromResponse(resp); uri != "" {
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractGeminiVideoURLFromResponse(resp map[string]any) string {
|
||||||
|
if resp == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if gvr, ok := resp["generateVideoResponse"].(map[string]any); ok {
|
||||||
|
if uri := extractGeminiVideoURLFromGeneratedSamples(gvr); uri != "" {
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if videos, ok := resp["videos"].([]any); ok {
|
||||||
|
for _, video := range videos {
|
||||||
|
if vm, ok := video.(map[string]any); ok {
|
||||||
|
if uri, ok := vm["uri"].(string); ok && uri != "" {
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if uri, ok := resp["video"].(string); ok && uri != "" {
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
if uri, ok := resp["uri"].(string); ok && uri != "" {
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractGeminiVideoURLFromGeneratedSamples(gvr map[string]any) string {
|
||||||
|
if gvr == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if samples, ok := gvr["generatedSamples"].([]any); ok {
|
||||||
|
for _, sample := range samples {
|
||||||
|
if sm, ok := sample.(map[string]any); ok {
|
||||||
|
if video, ok := sm["video"].(map[string]any); ok {
|
||||||
|
if uri, ok := video["uri"].(string); ok && uri != "" {
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureAPIKey(uri, key string) string {
|
||||||
|
if key == "" || uri == "" {
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
if strings.Contains(uri, "key=") {
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
if strings.Contains(uri, "?") {
|
||||||
|
return fmt.Sprintf("%s&key=%s", uri, key)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s?key=%s", uri, key)
|
||||||
|
}
|
||||||
@@ -4,13 +4,15 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gin-contrib/sessions"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/common"
|
|
||||||
"one-api/model"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type wechatLoginResponse struct {
|
type wechatLoginResponse struct {
|
||||||
@@ -150,19 +152,13 @@ func WeChatBind(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
err = user.FillUserById()
|
err = user.FillUserById()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user.WeChatId = wechatId
|
user.WeChatId = wechatId
|
||||||
err = user.Update(false)
|
err = user.Update(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.ApiError(c, err)
|
||||||
"success": false,
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
version: '3.4'
|
# New-API Docker Compose Configuration
|
||||||
|
#
|
||||||
|
# Quick Start:
|
||||||
|
# 1. docker-compose up -d
|
||||||
|
# 2. Access at http://localhost:3000
|
||||||
|
#
|
||||||
|
# Using MySQL instead of PostgreSQL:
|
||||||
|
# 1. Comment out the postgres service and SQL_DSN line 15
|
||||||
|
# 2. Uncomment the mysql service and SQL_DSN line 16
|
||||||
|
# 3. Uncomment mysql in depends_on (line 28)
|
||||||
|
# 4. Uncomment mysql_data in volumes section (line 64)
|
||||||
|
#
|
||||||
|
# ⚠️ IMPORTANT: Change all default passwords before deploying to production!
|
||||||
|
|
||||||
|
version: '3.4' # For compatibility with older Docker versions
|
||||||
|
|
||||||
services:
|
services:
|
||||||
new-api:
|
new-api:
|
||||||
@@ -12,21 +26,25 @@ services:
|
|||||||
- ./data:/data
|
- ./data:/data
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
environment:
|
environment:
|
||||||
- SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service
|
- SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production!
|
||||||
|
# - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL
|
||||||
- REDIS_CONN_STRING=redis://redis
|
- REDIS_CONN_STRING=redis://redis
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
- ERROR_LOG_ENABLED=true # 是否启用错误日志记录
|
- ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording)
|
||||||
# - STREAMING_TIMEOUT=120 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值
|
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update)
|
||||||
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!!!!!!!
|
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions)
|
||||||
# - NODE_TYPE=slave # Uncomment for slave node in multi-node deployment
|
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!)
|
||||||
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
|
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
|
||||||
# - FRONTEND_BASE_URL=https://openai.justsong.cn # Uncomment for multi-node deployment with front-end URL
|
# - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID)
|
||||||
|
# - UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Umami 网站 ID (Umami Website ID)
|
||||||
|
# - UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js # Umami 脚本 URL,默认为官方地址 (Umami Script URL, defaults to official URL)
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- mysql
|
- postgres
|
||||||
|
# - mysql # Uncomment if using MySQL
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk -F: '{print $$2}'"]
|
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -36,17 +54,31 @@ services:
|
|||||||
container_name: redis
|
container_name: redis
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
mysql:
|
postgres:
|
||||||
image: mysql:8.2
|
image: postgres:15
|
||||||
container_name: mysql
|
container_name: postgres
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: 123456 # Ensure this matches the password in SQL_DSN
|
POSTGRES_USER: root
|
||||||
MYSQL_DATABASE: new-api
|
POSTGRES_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production!
|
||||||
|
POSTGRES_DB: new-api
|
||||||
volumes:
|
volumes:
|
||||||
- mysql_data:/var/lib/mysql
|
- pg_data:/var/lib/postgresql/data
|
||||||
# ports:
|
# ports:
|
||||||
# - "3306:3306" # If you want to access MySQL from outside Docker, uncomment
|
# - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker
|
||||||
|
|
||||||
|
# mysql:
|
||||||
|
# image: mysql:8.2
|
||||||
|
# container_name: mysql
|
||||||
|
# restart: always
|
||||||
|
# environment:
|
||||||
|
# MYSQL_ROOT_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production!
|
||||||
|
# MYSQL_DATABASE: new-api
|
||||||
|
# volumes:
|
||||||
|
# - mysql_data:/var/lib/mysql
|
||||||
|
# ports:
|
||||||
|
# - "3306:3306" # Uncomment if you need to access MySQL from outside Docker
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
pg_data:
|
||||||
|
# mysql_data:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user