mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab828650e6 | ||
|
|
4c7e4e08f6 | ||
|
|
16ae8c7afa | ||
|
|
956f627f68 | ||
|
|
f3076e2338 | ||
|
|
7f320797a0 | ||
|
|
7bcc207055 | ||
|
|
fba8941068 | ||
|
|
ede456877b | ||
|
|
e6587b6ca2 | ||
|
|
4d1ed7917d | ||
|
|
5fe892de82 | ||
|
|
3328a4356a | ||
|
|
77b0c29354 | ||
|
|
c872bc7881 | ||
|
|
487b2e87e2 | ||
|
|
eb8a01541a | ||
|
|
0acf114fa7 | ||
|
|
5d941008fc | ||
|
|
b44a790777 | ||
|
|
06e94ef69b | ||
|
|
0f2e0b18a9 | ||
|
|
71e26f44b7 | ||
|
|
8232e19bc0 | ||
|
|
dcc61915e9 | ||
|
|
47d08560a7 | ||
|
|
590c4a163b | ||
|
|
9cfcd371d6 | ||
|
|
223021630e | ||
|
|
42c2b249c2 | ||
|
|
0b3ebc23f5 | ||
|
|
65bb9ab9cf | ||
|
|
50d6f7444d | ||
|
|
bcfea792e8 | ||
|
|
1e7ea8d46f | ||
|
|
bd61e0391a | ||
|
|
499b43043e | ||
|
|
ae2da31380 | ||
|
|
1e6d69a349 | ||
|
|
dac57a81c6 | ||
|
|
d162f9b7f6 | ||
|
|
866f71e3fb | ||
|
|
47ab48b4cf | ||
|
|
e3cf4ab8be | ||
|
|
4fb10f33f1 | ||
|
|
c9318c54cd | ||
|
|
8d9aaf0bbd | ||
|
|
52d24d29c7 | ||
|
|
3fbd447d94 | ||
|
|
5a5cdbdcde | ||
|
|
cca89f2f96 | ||
|
|
e25aad2231 | ||
|
|
84382761fc | ||
|
|
0a09c1572f | ||
|
|
90deabdfe3 | ||
|
|
b1c24e276b | ||
|
|
c8fdc2b5cd | ||
|
|
dfabf0f9dd | ||
|
|
817b0f2d6d | ||
|
|
a975868046 | ||
|
|
cc2dfab19a | ||
|
|
040a1e82a6 | ||
|
|
4f39bfc5a8 | ||
|
|
3e3616863d | ||
|
|
1bbae67040 | ||
|
|
fc07ac0803 | ||
|
|
c7823a7821 | ||
|
|
61d1bcb405 | ||
|
|
21a436b76a | ||
|
|
849aa73a28 | ||
|
|
fb89bda177 | ||
|
|
9e6c8c9734 | ||
|
|
3f69bc861c | ||
|
|
cea4e1d8e9 | ||
|
|
539764f7eb | ||
|
|
c9be17778d | ||
|
|
d8bc91efce | ||
|
|
e8e2f18927 | ||
|
|
0977de3713 | ||
|
|
eaa37d9faa | ||
|
|
4e588ffa1d | ||
|
|
b9e919f510 | ||
|
|
32f9f3675d | ||
|
|
221d557d5e | ||
|
|
4105c1ece6 | ||
|
|
4d59687cbc | ||
|
|
8621e24e7c | ||
|
|
3bebccac5d | ||
|
|
98287195ca | ||
|
|
fd1b159f71 | ||
|
|
7b03fdc8ab | ||
|
|
851a50962d | ||
|
|
b6e17db0c6 | ||
|
|
b98c2f01a5 | ||
|
|
14e7967e1c | ||
|
|
a4efb77223 | ||
|
|
5041d6ba26 | ||
|
|
322b602154 | ||
|
|
de15722ee1 | ||
|
|
380f04bace | ||
|
|
194cc0e0c9 | ||
|
|
d3acb42d37 | ||
|
|
bfca754714 | ||
|
|
881e78975e | ||
|
|
eb3baef3aa | ||
|
|
5205c07d60 | ||
|
|
60eb08cb96 | ||
|
|
fd4a49e64f | ||
|
|
be2ee7f3b8 | ||
|
|
757abdb4d1 | ||
|
|
cb5e0ba4c0 | ||
|
|
d2b418f8cc | ||
|
|
2390783900 | ||
|
|
c75f0e7fd9 | ||
|
|
d5e9360ed7 | ||
|
|
3fcb8e5f2d | ||
|
|
8cdaf8b2e6 | ||
|
|
68f05a4481 | ||
|
|
bb4d178b5d | ||
|
|
73487fd98b | ||
|
|
66fd09eba6 | ||
|
|
bd152dec00 | ||
|
|
7090dca502 | ||
|
|
029f3124bf | ||
|
|
840707e5a1 | ||
|
|
704b652f86 | ||
|
|
2b98839c29 | ||
|
|
90dd5b5a57 | ||
|
|
5ad9378b33 | ||
|
|
4a413f5bb7 | ||
|
|
0bab566d04 | ||
|
|
9b19cf083a | ||
|
|
8b22788746 | ||
|
|
e973fc516e | ||
|
|
12ecbf574a | ||
|
|
443bd6ad17 | ||
|
|
c795098a87 | ||
|
|
1f0009341d | ||
|
|
1359eaa894 | ||
|
|
74318af570 | ||
|
|
ddc10d7c1a | ||
|
|
6bc2189e6f | ||
|
|
b206ee8271 | ||
|
|
70ff923033 | ||
|
|
a2fbc073a4 | ||
|
|
9944041dc8 | ||
|
|
18a04f1213 | ||
|
|
35988d0eb7 | ||
|
|
6673b6bd1e | ||
|
|
2a7fd08e9e | ||
|
|
7f69e81176 | ||
|
|
93dc417b79 | ||
|
|
f905e4b3fb | ||
|
|
ed3e23887b | ||
|
|
91f35d88cf | ||
|
|
f465a337e8 | ||
|
|
37465e2872 | ||
|
|
70c18dadd6 | ||
|
|
881a0eaef6 | ||
|
|
95f7a5a636 | ||
|
|
871406ab24 | ||
|
|
38a1cc6933 | ||
|
|
d38e0d4296 | ||
|
|
d12d75a037 | ||
|
|
a2922b410e | ||
|
|
d303efc6f7 | ||
|
|
dd17332c31 | ||
|
|
47d08786ad | ||
|
|
3434ad5e72 | ||
|
|
e91ec88a1f | ||
|
|
6f7a50b1b6 | ||
|
|
db0c294e83 | ||
|
|
d8181e8e91 | ||
|
|
af134cf488 | ||
|
|
d918149c2f | ||
|
|
aa6bf6497f | ||
|
|
074c07b5ca | ||
|
|
f70f46e87e | ||
|
|
c21633655e | ||
|
|
1bafbb3864 | ||
|
|
353dccedc4 | ||
|
|
697f1870a0 | ||
|
|
a404c2e09e | ||
|
|
a6cfd124b0 | ||
|
|
4441b577c3 | ||
|
|
a704fbc3f3 | ||
|
|
dab221cdb9 | ||
|
|
4f434ec525 | ||
|
|
2585d4cd7d | ||
|
|
93a34b0f56 | ||
|
|
aa3a07209a | ||
|
|
c2332bc8d0 | ||
|
|
8f242e2875 | ||
|
|
51e9981bee | ||
|
|
65fbbcb3ff | ||
|
|
bd086a7e3c | ||
|
|
3554ba13db | ||
|
|
bcccebc9fa | ||
|
|
d67b46a9e2 | ||
|
|
20c6d33cf4 | ||
|
|
cf0e84c548 | ||
|
|
2332af9a08 | ||
|
|
024fc9fcf0 | ||
|
|
4220f4e095 | ||
|
|
a709acb149 | ||
|
|
466842acd8 | ||
|
|
b91f28f95b | ||
|
|
87164db3da | ||
|
|
ad16dec3db | ||
|
|
a0da0f4713 | ||
|
|
65e5cde60d | ||
|
|
0f0b464f4e | ||
|
|
bd223f6540 | ||
|
|
168be0ca68 | ||
|
|
f2e91bbdaa | ||
|
|
d0c9248478 | ||
|
|
22c697bbff | ||
|
|
08d518324a | ||
|
|
dfacb20e43 | ||
|
|
55c9291c35 | ||
|
|
6e8e787b82 | ||
|
|
2a0a50adc5 | ||
|
|
5e13b6af6a | ||
|
|
bd2454a5b6 | ||
|
|
2640a03219 | ||
|
|
7d0e9a0a20 | ||
|
|
09982d7221 | ||
|
|
c4db1bb244 | ||
|
|
e7c2f025cc | ||
|
|
4ee294b208 | ||
|
|
93f1a82d3f | ||
|
|
c46f98cd2b | ||
|
|
5c69b1b082 | ||
|
|
6a78fb9232 | ||
|
|
02a03df79a | ||
|
|
2aca8fb79b | ||
|
|
891b227b69 | ||
|
|
04096dddb7 | ||
|
|
3a9b1c479f | ||
|
|
944d3f299e | ||
|
|
56451601fb | ||
|
|
4d53ef6068 | ||
|
|
a62bb196f7 | ||
|
|
7ad0ca48c5 | ||
|
|
e25639c11b | ||
|
|
8abac080a2 | ||
|
|
37cd059531 | ||
|
|
c46910df22 | ||
|
|
8cfd7f53be | ||
|
|
75e22d7a5b | ||
|
|
6b2d335bc5 | ||
|
|
270b1540cb | ||
|
|
660214d9a5 | ||
|
|
2231fee768 | ||
|
|
a2e60c1c6e | ||
|
|
fc388bb799 | ||
|
|
62a35c3960 | ||
|
|
82653544ac | ||
|
|
f10f825404 | ||
|
|
a268f5ca66 | ||
|
|
832e2e8bdd | ||
|
|
be703a0c5f | ||
|
|
c2c86928c5 | ||
|
|
bda21ccc61 | ||
|
|
72c56680b9 | ||
|
|
a6808499f0 | ||
|
|
fdb954b3d1 | ||
|
|
a069001f01 | ||
|
|
d3a87d11ad | ||
|
|
d7275dfd08 | ||
|
|
3396f0880f | ||
|
|
d5fa6c4921 | ||
|
|
d4855cca9c | ||
|
|
1d862922db | ||
|
|
4830ef093b | ||
|
|
ad20064d15 | ||
|
|
515487baee | ||
|
|
a38c328975 | ||
|
|
97d95d25f1 | ||
|
|
abfd24267b | ||
|
|
a363682929 | ||
|
|
57422275bb | ||
|
|
cb7f69850b | ||
|
|
5ea3740de3 | ||
|
|
3920624862 | ||
|
|
d804bca495 | ||
|
|
ca527d5833 | ||
|
|
ae4eb49afc | ||
|
|
5d97086511 | ||
|
|
0e6da1e731 | ||
|
|
5d0922b9d1 | ||
|
|
ed3d622554 | ||
|
|
0f71cf6df2 | ||
|
|
a1d3eba874 | ||
|
|
d22c1d4ab9 | ||
|
|
cc8dfe3dd8 | ||
|
|
f25edadd5f | ||
|
|
4f50c67332 | ||
|
|
75f9d2a290 | ||
|
|
b93b8859f9 | ||
|
|
3510a45742 | ||
|
|
85eb283b40 | ||
|
|
b8e1c5077d | ||
|
|
8422240967 | ||
|
|
fbd922117e | ||
|
|
941c4d57fa | ||
|
|
cf046ad9a4 | ||
|
|
7699a954ac | ||
|
|
2c31708d1b | ||
|
|
80f338ce01 | ||
|
|
299088a6e7 | ||
|
|
a23bfca848 | ||
|
|
8951a64a05 | ||
|
|
eb20561893 | ||
|
|
a33637a99e | ||
|
|
97cd7a7097 | ||
|
|
b0fe132736 | ||
|
|
e2c75d1054 | ||
|
|
94f298927d | ||
|
|
3be827204f | ||
|
|
227a1174b9 | ||
|
|
c0defcb6df | ||
|
|
53739ebe5c | ||
|
|
7f420257a6 | ||
|
|
4f85209dc9 | ||
|
|
cd89e2e8fc | ||
|
|
0cb5a29699 | ||
|
|
61788804b4 | ||
|
|
2751ea6aa4 | ||
|
|
58fe66789c | ||
|
|
573b6b26db | ||
|
|
d425428ebe | ||
|
|
b26d3b386a | ||
|
|
cf0b14c5fd | ||
|
|
056561de41 | ||
|
|
f2215df72e | ||
|
|
72f8957163 | ||
|
|
73f0c15790 | ||
|
|
0558693949 | ||
|
|
2a9cb02cc9 | ||
|
|
5ce36931a8 | ||
|
|
0d826d9eba | ||
|
|
304242957a | ||
|
|
ef37ae8d9e | ||
|
|
fc13d4f2b6 | ||
|
|
78f9c0c800 | ||
|
|
a3bc7bc445 | ||
|
|
0132114285 | ||
|
|
5970efbb7c | ||
|
|
7bf28b571d | ||
|
|
bc3bd6bc43 | ||
|
|
9acfab0a86 | ||
|
|
3e5a84d2a0 | ||
|
|
92948f51a9 | ||
|
|
776637fccb | ||
|
|
82acb08d26 | ||
|
|
7744e1b0b4 | ||
|
|
0b8a1add18 | ||
|
|
2dd60c08b6 | ||
|
|
cce9880937 | ||
|
|
b8ea1cd076 | ||
|
|
4117840f75 | ||
|
|
f642764f34 | ||
|
|
482e192e2d | ||
|
|
ee51a07de4 | ||
|
|
bf23658aaf | ||
|
|
bc4728332e | ||
|
|
2cb5cda077 | ||
|
|
9d90dedb81 | ||
|
|
6074cff68c | ||
|
|
6ab8e7d0a6 | ||
|
|
17f9ef8915 | ||
|
|
15e0a28bd0 | ||
|
|
58bc438992 | ||
|
|
b04616f59f | ||
|
|
82ba4fcbba | ||
|
|
819f428ac3 | ||
|
|
35f28e8aab | ||
|
|
65645c7403 | ||
|
|
71159d5633 | ||
|
|
f2ebb09e34 | ||
|
|
07acc67ce6 | ||
|
|
01ad8bba8a | ||
|
|
986a128d15 | ||
|
|
6cad2bb6a1 | ||
|
|
2b8b3de0e4 | ||
|
|
4127886584 | ||
|
|
ee3e5db6c2 | ||
|
|
153cb67b9c | ||
|
|
f370d355b6 | ||
|
|
a31dad992c | ||
|
|
55a88f5f6b | ||
|
|
5d90a92582 | ||
|
|
93abe8efcd | ||
|
|
f4b75c42ed | ||
|
|
36ff038c78 | ||
|
|
545009590c | ||
|
|
5b7b9b21ee | ||
|
|
44a2a87b69 | ||
|
|
184e88083b | ||
|
|
53b40aea46 | ||
|
|
aa328b6dbf | ||
|
|
64231ff403 | ||
|
|
d7b80a2066 | ||
|
|
08d63ea3f3 | ||
|
|
ac9c4c85ad | ||
|
|
492c401599 | ||
|
|
a063258a40 | ||
|
|
dc590f2b5f | ||
|
|
ca52e2bfd9 | ||
|
|
43604e36dc | ||
|
|
a4a997e351 | ||
|
|
73ca463924 | ||
|
|
9eeec2c78c | ||
|
|
90890be2b4 | ||
|
|
2e4bab17a8 | ||
|
|
b052e79a16 | ||
|
|
9775451e83 | ||
|
|
c22a2ad144 | ||
|
|
7bd362d544 | ||
|
|
e7036a616f | ||
|
|
98652d4c68 | ||
|
|
d0dc7ef92b | ||
|
|
194fb20d5f | ||
|
|
30f528f084 | ||
|
|
28d024ceda | ||
|
|
6b9c98c59f | ||
|
|
1294e50b76 | ||
|
|
a0e540642b | ||
|
|
14d6a7de31 | ||
|
|
cd7b60a3ba | ||
|
|
ebbed22f36 | ||
|
|
f6e5d8f673 | ||
|
|
bf1c6211b8 | ||
|
|
d388fa344a | ||
|
|
eabb61b08c | ||
|
|
a5c3438927 | ||
|
|
b6948542bb | ||
|
|
0242bd3193 | ||
|
|
229ef48470 | ||
|
|
19c35ba901 | ||
|
|
3c06ae97e8 | ||
|
|
f34966e4c7 | ||
|
|
e9ebf45640 | ||
|
|
c00e5bd243 | ||
|
|
ab5f18a437 | ||
|
|
330b222359 | ||
|
|
d81cb2d4f0 | ||
|
|
8e2edb680d | ||
|
|
00879be47d | ||
|
|
34656a3f07 | ||
|
|
27cf037309 | ||
|
|
2198fa69ab | ||
|
|
95f6434443 | ||
|
|
8734b0d3d5 | ||
|
|
790d0d9628 | ||
|
|
3c562563c5 | ||
|
|
f19785d0e1 | ||
|
|
a3d098a87c | ||
|
|
449d7c4218 | ||
|
|
d199141a91 | ||
|
|
df83ffdafb | ||
|
|
cf03c56cfa | ||
|
|
3d1e33558a | ||
|
|
f314ebb3a2 | ||
|
|
31d6ef9585 | ||
|
|
fdfb77c4a2 | ||
|
|
a93e3853f7 | ||
|
|
34da9d7cc8 | ||
|
|
b3dd6bbe46 | ||
|
|
9ab3e45288 | ||
|
|
d255a86644 | ||
|
|
f3f50a818f | ||
|
|
1ff520659f | ||
|
|
33381fc472 | ||
|
|
e3d767bf54 | ||
|
|
8f82d0e100 | ||
|
|
3085e5aabe | ||
|
|
36dce639a9 | ||
|
|
6e15757150 | ||
|
|
365d389a14 | ||
|
|
e79d57eaf9 | ||
|
|
8d5dfea6de | ||
|
|
e02e5b186e | ||
|
|
2dc670875a | ||
|
|
9768921ff7 | ||
|
|
db90f0631a | ||
|
|
8a0a8f9af4 | ||
|
|
8ca2c68e90 | ||
|
|
d26fc44fe6 | ||
|
|
7ed2a6668a | ||
|
|
3c4ac99ae4 | ||
|
|
63d276767e | ||
|
|
60dcc5b0db | ||
|
|
0d2c648422 | ||
|
|
56f7ddd1dc | ||
|
|
930e311f51 | ||
|
|
5df8c71b89 | ||
|
|
dfedb5069d | ||
|
|
7016c8f401 | ||
|
|
5a14b74ff2 | ||
|
|
00bab8d390 | ||
|
|
f6e364ca33 | ||
|
|
4cd98889a7 | ||
|
|
c9b613659c | ||
|
|
01b4029998 | ||
|
|
f583e50e88 | ||
|
|
a7ea957843 | ||
|
|
c92c95a733 | ||
|
|
ae7608440e | ||
|
|
5c9da8a39d | ||
|
|
e012f24ef2 | ||
|
|
756c9c7158 | ||
|
|
ab4bf75d0e | ||
|
|
7a9715ed41 | ||
|
|
c353a6159e | ||
|
|
f5dca4848a | ||
|
|
36321a9af4 | ||
|
|
b485b8bd4a | ||
|
|
cae2c8656a | ||
|
|
acf574129f | ||
|
|
6426e0d3de | ||
|
|
eedc39b33a | ||
|
|
9672a2e000 | ||
|
|
0fffd2c550 | ||
|
|
5671a57dc8 | ||
|
|
166e8754a2 | ||
|
|
fbaf6c1bdd | ||
|
|
7cdc2dba6f | ||
|
|
a321ccf737 | ||
|
|
4533ae8850 | ||
|
|
7542ed365e | ||
|
|
bf4da7a3e7 | ||
|
|
0fe354b083 | ||
|
|
6d0c583599 | ||
|
|
2e861c03c4 | ||
|
|
d5fc778ea7 | ||
|
|
404cd41ff4 | ||
|
|
851f473b88 | ||
|
|
e97da4f551 | ||
|
|
5544b0fb29 | ||
|
|
428e744710 | ||
|
|
7b406765d6 | ||
|
|
92ae7df115 | ||
|
|
cc42e78334 | ||
|
|
0c90ed30fb | ||
|
|
5a81856510 | ||
|
|
3110b71512 | ||
|
|
eac1b2c39e | ||
|
|
337bb80230 | ||
|
|
82f2a0da06 | ||
|
|
f0d8dad71e | ||
|
|
f5c1d773d7 | ||
|
|
9148e6b11e | ||
|
|
a7d8c3d3df | ||
|
|
997eb86aa0 | ||
|
|
3866acc269 | ||
|
|
38477f4328 | ||
|
|
5168f6a924 | ||
|
|
3a407c635a | ||
|
|
6901e0ed45 | ||
|
|
9758c420fa | ||
|
|
6d2bf06ac7 | ||
|
|
b8faaf250f | ||
|
|
40137b1205 | ||
|
|
c67cde4e90 | ||
|
|
2e9213a784 | ||
|
|
87496b5656 | ||
|
|
c856e4306f | ||
|
|
92090f28bf | ||
|
|
12c2b612de | ||
|
|
e8af0b9c66 | ||
|
|
80776e8e03 | ||
|
|
b296c795ac | ||
|
|
9fc162a82f | ||
|
|
9532bd438b | ||
|
|
73319f1943 | ||
|
|
ee56d6a1d2 | ||
|
|
5d29ddfffc | ||
|
|
45903b684b | ||
|
|
a580874697 | ||
|
|
885369f19f | ||
|
|
24ff339c97 | ||
|
|
e582eb73d2 | ||
|
|
ec252b3b46 | ||
|
|
6503f36750 | ||
|
|
2366f66f33 | ||
|
|
cc0303c65e | ||
|
|
b11eeac29e | ||
|
|
ad5f3ba997 | ||
|
|
15455ca619 | ||
|
|
904315c209 | ||
|
|
95d720dd86 | ||
|
|
74f59cc4f8 | ||
|
|
e979454457 | ||
|
|
0af8610a48 | ||
|
|
8226089ade | ||
|
|
1bd40ffa67 | ||
|
|
34a6ccce43 | ||
|
|
c275119f12 | ||
|
|
9322b7aeb8 | ||
|
|
c6444b2bf1 | ||
|
|
55075677eb | ||
|
|
733aa5f88a | ||
|
|
d704910857 | ||
|
|
ad6c21392f | ||
|
|
55e87c3b16 | ||
|
|
0c4da6c838 | ||
|
|
225c1166c6 | ||
|
|
97b91d3409 | ||
|
|
c953f6f44d | ||
|
|
d6b9fedbc7 | ||
|
|
720eb0c227 | ||
|
|
495c19b5e4 | ||
|
|
237ba96233 | ||
|
|
ae46d89de6 | ||
|
|
69f73e8c5d | ||
|
|
245f87ef35 | ||
|
|
81e51db0b6 | ||
|
|
4634496638 | ||
|
|
4e2f0a9346 | ||
|
|
304b3d4772 | ||
|
|
74629f3ef2 | ||
|
|
c9d48188af | ||
|
|
7e7b88a59e | ||
|
|
5c66dc198d | ||
|
|
47efce7a90 | ||
|
|
ea4d3f0ba7 | ||
|
|
2d03e27dcf | ||
|
|
cf7ff80f71 | ||
|
|
93ab75e42b | ||
|
|
449dc7da79 | ||
|
|
3b129a483a | ||
|
|
3e0d60bb1f | ||
|
|
1d3645fc26 | ||
|
|
18bff86214 | ||
|
|
114084107c | ||
|
|
d60f7238da | ||
|
|
ea684dceeb | ||
|
|
dfd496b41a | ||
|
|
6125b10276 | ||
|
|
071271f003 | ||
|
|
13a9df2e07 | ||
|
|
37a5a6702b | ||
|
|
afb68e97d6 | ||
|
|
93b9741af2 | ||
|
|
013d592454 | ||
|
|
0f56e9b80c | ||
|
|
6ec5ecb9ac | ||
|
|
460d439351 | ||
|
|
f39406dd80 | ||
|
|
9de8e75238 | ||
|
|
fee34b6bd1 | ||
|
|
cbe8699322 | ||
|
|
066e5551f8 | ||
|
|
1d73b736a9 | ||
|
|
8ae6635b9c | ||
|
|
574f0d580e | ||
|
|
0879515ee8 | ||
|
|
be40783454 |
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: dev-guide
|
||||
description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关、Vue 管理端、账号与出站代理、接口协议、部署、CI、发布或文档时使用,定位权威规范和验证入口。Use for development in the repository containing this skill; not for generic Rust/Vue questions in unrelated repositories.
|
||||
---
|
||||
|
||||
# 开发指南
|
||||
|
||||
先读根目录的 [AGENTS.md](../../../AGENTS.md) 和 [CONTRIBUTING.md](../../../CONTRIBUTING.md)。
|
||||
本技能只组织工作入口,架构、协议和视觉规则从下列权威文档读取,不维护第二份副本。
|
||||
|
||||
## 按任务读取
|
||||
|
||||
| 任务 | 技能与文档入口 |
|
||||
| --- | --- |
|
||||
| 用户能力、快速开始 | [README](../../../README.md) |
|
||||
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
|
||||
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
|
||||
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
|
||||
| 前端开发、管理端组件、主题 Token、视觉验证 | 加载并使用 `$frontend-design`,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
|
||||
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
|
||||
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
|
||||
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
|
||||
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
|
||||
| 创建或审查 PR | [github-pr](../github-pr/SKILL.md) |
|
||||
| 整理或提交 Issue | [github-issue](../github-issue/SKILL.md) |
|
||||
| 发布说明、版本规划与发版 | [release](../release/SKILL.md) |
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
|
||||
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
|
||||
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
|
||||
优先依据适用的官方源码确定方案。结合上表读取所需文档,先找可复用的同类实现,再修改所属模块。
|
||||
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
|
||||
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
|
||||
5. 交付前按文档职责逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
|
||||
是否与其他位置重复,脱离本次变更背景能否独立理解。仅解释实现经过、前后对比或验证结果的内容移到
|
||||
任务报告或 PR;仍影响升级、恢复的说明保留适用条件与操作。没有文档差异时,只核对行为变化是否使现有说明失真。
|
||||
6. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
|
||||
|
||||
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "dev-guide"
|
||||
short_description: "从仓库规范与架构文档定位实现、验证和交付的正确入口"
|
||||
default_prompt: "使用 $dev-guide 按当前仓库规范与架构完成开发、验证或发布。"
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: github-issue
|
||||
description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用于“提个 Issue”“把这个问题反馈到仓库”“帮我报个 bug”“整理成 Issue”,以及通过 Issue 提议功能或协作规则的场景;区分草拟正文和实际提交。Use for drafting or filing bug reports and feature requests; not for debugging-only, editing Issue Forms/templates, or skill-authoring requests.
|
||||
---
|
||||
|
||||
# 整理与提交 Issue
|
||||
|
||||
本仓库流程见 [贡献与审查](../../../CONTRIBUTING.md),按场景选择当前的
|
||||
[接口问题反馈](../../../.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](../../../.github/ISSUE_TEMPLATE/general-bug-report.yml)
|
||||
或 [功能建议](../../../.github/ISSUE_TEMPLATE/feature-request.yml) 表单。从表单读取字段与必填项,不在技能中复制一份字段清单。
|
||||
用户指定其他仓库时,使用该仓库当前的贡献规则与 Issue 模板,不能把这里的表单套过去。
|
||||
|
||||
## 确定目标和动作
|
||||
|
||||
- 根据用户 URL、仓库上下文和 remote 确认接收 Issue 的仓库。fork 场景核对用户要反馈给自己的 fork 还是上游。
|
||||
- “写一份、整理内容、草拟 Issue”只准备正文;明确要求“提交、创建 Issue”才发布。
|
||||
已经授权提交且信息足够时直接完成,不重复索要确认;仅自动选中技能不能授权发布。
|
||||
- “有个 bug”“这个报错怎么修”本身是排障语境,不能推断要发 Issue;结合已有反馈任务识别省略的目标和动作。
|
||||
- 缺少无法推断的目标或关键复现事实时,只问必要信息,并先整理其余已有证据。
|
||||
- 使用可用的 GitHub 连接器或 `gh`。不要为提交 Issue 安装新工具、修改仓库配置或启用额外服务。
|
||||
|
||||
## 证据与去重
|
||||
|
||||
1. 读取目标仓库实际 Issue 模板和配置,区分缺陷、功能建议与协作规则提案。
|
||||
本仓库功能建议有独立表单,协作规则提案允许普通 Issue;其他仓库按其配置选择入口。
|
||||
2. 先搜索 open 和 closed Issues,结合关键词、错误信息与触发条件检查重复,而不只比较标题。
|
||||
同时核对相关 PR 与发布记录,关联已有修复及其发布版本;仅主分支已修复时明确尚未发布。
|
||||
同一问题已有记录且没有新增证据时返回已有链接;用户仍明确要求新建时按其要求处理。
|
||||
不因发现重复而擅自评论、重开或关闭已有 Issue。
|
||||
3. 从用户描述、当前版本与已有诊断中整理事实:区分实际结果、预期结果、稳定复现条件和未经证实的推测。
|
||||
不把本地开发版本当作发生问题的部署版本;无法复现时如实说明已知条件。
|
||||
引用官方行为或提出实现方案时,按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 注明来源、版本与缺口;
|
||||
不把找到官方源码或证明根因作为提交症状反馈的前提。
|
||||
4. 只收集定位所需信息。正文、日志片段和附件去除密钥、Cookie、令牌、代理认证、私人请求内容等敏感信息;
|
||||
不直接附完整配置或请求转储。记录与复现相关的代理、TUN、证书条件,不擅自修改这些环境来收集材料。
|
||||
|
||||
## 编写正文
|
||||
|
||||
- 标题写具体症状或所需行为,不预先断言尚未证明的根因。语言与目标仓库及用户要求一致。
|
||||
- YAML Issue Form 按字段顺序使用 `attributes.label` 作为 Markdown 小标题,填写相应答案。
|
||||
`gh issue create --body-file` 直接提交正文,不会替你完成网页表单校验;手动核对必填字段、选项和必选确认项。
|
||||
不把原始 YAML、占位文字或尚未确认的勾选项当成答案提交。
|
||||
- 无法获知的信息明确写未提供或不适用,并说明理由;如果该事实关系到能否理解或复现问题,发布前补齐。
|
||||
功能或规则提案说明当前场景、缺少的能力、建议结果和必要取舍,不强行填写缺陷复现步骤。
|
||||
- 附件只使用用户授权发布且实际可访问的地址;本机 `/tmp/...` 路径不能作为 GitHub 附件。
|
||||
没有可用附件时保留操作描述并说明缺口,不声称截图已上传。
|
||||
- 使用模板规定或用户要求的 labels、assignees;先确认它们在目标仓库存在,不自行猜测。
|
||||
|
||||
## 提交与收尾
|
||||
|
||||
把核对后的 UTF-8 正文写入临时文件,或直接传给连接器的结构化字段。CLI 变量先从已确认的目标与正文赋值:
|
||||
|
||||
```bash
|
||||
gh issue create --repo "$issue_repo" --title "$issue_title" --body-file "$issue_body_file"
|
||||
```
|
||||
|
||||
发布前快速复核目标、必填内容、脱敏及重复记录。网络超时或返回不明确时先查询远端是否已经创建,避免重复提交。
|
||||
发布后读取新 Issue 确认仓库、标题和正文,返回链接及必要的信息缺口。
|
||||
只准备草稿或发布失败时提供可直接使用的标题与正文,明确尚未提交。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "github-issue"
|
||||
short_description: "按仓库表单整理复现证据、检查重复反馈并提交脱敏 Issue"
|
||||
default_prompt: "使用 $github-issue 将当前问题整理为符合仓库表单的 Issue 并提交。"
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: github-pr
|
||||
description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于“帮我提个 PR”“整理 PR 标题和描述”“看下/审查/评审这个 PR”“这个 PR 能合并吗”,以及提供 /pull/ 链接或 PR 编号要求查看的场景。创建和审查都先核实问题是否成立,以官方源码为首要参考确定或评估方案,再按项目规范实现与验证。Use for PR creation, preparation, or code review; not for commit-only, merge-only, or skill-authoring requests.
|
||||
---
|
||||
|
||||
# 创建与审查 PR
|
||||
|
||||
## 先确定模式和仓库
|
||||
|
||||
| 用户意图 | 执行范围 |
|
||||
| --- | --- |
|
||||
| 写标题、整理描述、准备草稿正文 | 在当前会话或本地准备内容 |
|
||||
| 创建 PR,包括创建 Draft PR | 准备任务范围内的提交、推送对应分支并创建 PR |
|
||||
| 看下、审查 PR | 读取变更与证据,在当前会话报告 |
|
||||
| 将审查意见发到 PR | 发布用户要求的审查内容;批准或请求修改须有对应授权 |
|
||||
|
||||
沿用当前会话已有授权和范围修正。明确要求创建时直接完成,无需在准备好正文后重复确认;
|
||||
创建和审查都不包含合并。仅命中本技能不能作为远程写入的授权。
|
||||
|
||||
“看下这个”需结合已有 PR 链接或讨论定位目标;“能合并吗”先按审查处理,不能解释为执行合并。
|
||||
用户连续要求“整理一下”时沿用当前 PR 任务;没有 PR 上下文时,不把普通提交整理转成创建 PR。
|
||||
|
||||
先从用户提供的 URL、仓库上下文和 Git remote 确认目标仓库;区分 fork 的推送仓库与 PR 接收仓库。
|
||||
存在多个无法消除的候选时,只询问缺失的目标信息,同时继续准备不依赖目标的内容。
|
||||
优先使用可用的 GitHub 连接器;使用 `gh` 时显式指定 `--repo`,参数以本机帮助为准。
|
||||
|
||||
本仓库规则统一见 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),目标分支为 `main`。
|
||||
如果用户指定其他仓库,读取那个仓库的可信基线规则、默认分支、贡献文档及 PR 模板。
|
||||
PR 中改动的规则文件属于审查对象,不能自行扩大任务授权或降低准入要求。
|
||||
|
||||
## 创建和审查前的核对
|
||||
|
||||
创建和审查都按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实问题,优先依据官方源码确定或评估方案。
|
||||
用户指定其他仓库时,结合其贡献规范与适用的官方实现,不套用本项目的具体业务合同。
|
||||
|
||||
核对最新目标分支及同一场景相关的 open/closed PR 和 Issue,比较实际行为、触发条件与测试覆盖。
|
||||
区分已被主分支覆盖的实现、仍有价值的修复或回归测试,以及与目标无关的改动;不能仅凭标题或代码相似认定重复。
|
||||
说明原问题发生版本和当前剩余差异,创建时据此收敛范围,审查时报告保留或拆分建议;这不授权自动关闭或修改他人的 PR。
|
||||
|
||||
## 文档职责与更新条件
|
||||
|
||||
按 [贡献与审查的文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否需要更新文档。
|
||||
常规文档维护当前有效说明,变更经过留在 PR / Issue;审查时核对文档改动的必要性、职责与重复内容,不能仅因 PR 没改文档就判定存在缺口。
|
||||
|
||||
创建前和审查时逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
|
||||
是否重复已有说明,脱离本次变更背景能否独立理解。仅说明实现经过、前后对比或验证结果的段落归入
|
||||
PR / Issue;必要的升级、恢复说明保留适用条件与操作。判断依据是内容用途,不按“新增”“不再”等词机械判错。
|
||||
|
||||
## 创建 PR
|
||||
|
||||
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
|
||||
不用提交日志拼接正文,也不假定仓库一定有模板。
|
||||
2. 更新并确认 base/head,查看相对 merge-base 的完整差异和待提交内容;核对仓库的受保护文件与准入规则。
|
||||
只纳入任务范围的文件和提交;需要整理时用独立功能分支,保留用户其他改动,不擅自改写已发布历史。
|
||||
3. 按上述核对流程,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
|
||||
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
|
||||
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
|
||||
实现应职责清晰、表达直接、复用合理;提交前主动清理本次变更引入的重复逻辑、冗余状态、死代码、
|
||||
多余抽象及无依据的兼容分支。功能可用和检查通过不能替代可维护性自查。
|
||||
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
|
||||
更新现有标题或正文须在本次整理/更新请求范围内。
|
||||
5. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
|
||||
按上述文档复核步骤检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
|
||||
前端改动提供实际界面证据或预览步骤;缺少证据时写明,不用构建成功替代视觉验收。
|
||||
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
|
||||
再给必要的设计取舍、验证与遗留事项。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
|
||||
本次实现与它的关系及必要的偏离原因;区分源码推断和实际复现,不用检查通过替代方案论证。按规模压缩,不强填无关章节。
|
||||
7. 明确要求草稿或仍有阻塞项时创建 Draft PR,并解释原因;具备审查条件且未要求草稿时创建普通 PR。
|
||||
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
|
||||
8. 创建后核对链接、base、head、草稿状态和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
|
||||
|
||||
CLI 示例中的变量必须先根据上述检查赋值;`pr_body_file` 是精确写入 UTF-8 正文的临时文件:
|
||||
|
||||
```bash
|
||||
gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
|
||||
--title "$pr_title" --body-file "$pr_body_file"
|
||||
```
|
||||
|
||||
按需添加 `--draft`。不要用 `gh pr create --dry-run` 做只读预览,它仍可能推送 Git 变更。
|
||||
多行正文通过文件或连接器的结构化字段传递,不把未转义正文拼进 shell 命令。
|
||||
创建完成返回链接、目标分支和验证摘要;不要称为已合并或已获维护者批准。
|
||||
|
||||
## 审查 PR
|
||||
|
||||
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
|
||||
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
|
||||
2. 先核实 PR 要解决的问题是否成立:提取触发场景、预期行为和声称的原因,在目标分支基线检查实际行为。
|
||||
按上述核对流程收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
|
||||
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
|
||||
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
|
||||
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
|
||||
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
|
||||
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
|
||||
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
|
||||
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
|
||||
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
|
||||
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
|
||||
5. UI 改动同时对照主题、共用组件、数据展示逻辑和同类页面。浏览器可用时检查相关交互及状态;
|
||||
未运行的页面检查列为验证缺口,不从截图或源码推断所有状态都正确。
|
||||
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
|
||||
按上述文档复核步骤检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
|
||||
工具不可用、集成测试跳过或环境不足时具体说明影响范围。
|
||||
7. 按 [审查标准](../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
|
||||
再列出实现问题与验证缺口,结论附具体依据。
|
||||
实现问题区分必须修复项、可选建议和验证缺口;没有的类别可省略。
|
||||
每个问题给变更中的文件位置、触发条件或规范依据、影响和最小修复方向。
|
||||
复用建议指出具体已有实现,不把个人偏好写成缺陷;未发现问题时直接说明并交代覆盖范围。
|
||||
8. 结束前核对 head 是否变化。发生变化时检查新增差异并更新结论,或明确结论只适用于原 SHA。
|
||||
|
||||
审查本身不修改源码、不发表评论、不提交 GitHub 的批准或请求修改状态。
|
||||
用户明确要求发布时,先复核当前 head 和重复评论,再通过结构化字段或 `--body-file` 发布对应内容;
|
||||
需要绑定具体提交时使用支持 commit ID 的审查接口。不要在旧提交结论上批准新的 head。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "github-pr"
|
||||
short_description: "创建与审查拉取请求,先核实问题,以官方源码为首要依据确定或评估实现方案"
|
||||
default_prompt: "使用 $github-pr 按当前请求创建或审查 PR,先核实问题是否成立,以官方源码为首要参考确定或评估解决方案,说明源码依据及必要的偏离原因,并按仓库规范实现与验证或报告审查结论。"
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: release
|
||||
description: 准备或执行当前仓库的稳定版、实验版发版,维护中文发布说明。用于“发版”“发布实验版/exp 版本”“准备 vX.Y.Z”“推荐版本号”“整理发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing stable or experimental releases; not for deploying/restarting an instance or authoring a release skill.
|
||||
---
|
||||
|
||||
# 发版
|
||||
|
||||
## 识别任务范围
|
||||
|
||||
| 用户意图 | 执行范围 |
|
||||
| --- | --- |
|
||||
| 推荐版本号 | 根据本次变更与已有版本给出建议及理由 |
|
||||
| 整理更新日志、写 note、准备发版 | 核对变更、编写说明、检查发布条件;未要求提交时保留本地改动 |
|
||||
| 正式发布、发布指定版本、打版本 tag | 在已授权范围内执行对应步骤;仅打 tag 的请求不自动扩展为完整发版 |
|
||||
| 发布实验版、exp 版本 | 从目标实验分支发布,按下方实验版流程确定版本、文案和渠道隔离 |
|
||||
| 重试失败的发版 | 核对上次执行状态,从未完成的步骤恢复 |
|
||||
|
||||
沿用当前会话明确的版本、范围与授权;用户已授权发布目标版本且条件满足时直接执行,不重复确认。
|
||||
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
|
||||
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
|
||||
|
||||
稳定版从 `main` 发布。实验版从对应的 `experimental/<用途>` 分支发布,版本与 tag 采用 `vX.Y.Z-exp.N`,
|
||||
展示标题包含分支用途名;先读 [实验版发布](references/experimental.md) 确定标题与编号,再复用本文的准备、执行与验收步骤。
|
||||
实验功能是否合入 `main` 是单独的维护决策,发布实验版不包含合并功能的授权。
|
||||
|
||||
## 读取当前流程
|
||||
|
||||
先读 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),再按任务检查:
|
||||
|
||||
| 事实 | 权威入口 |
|
||||
| --- | --- |
|
||||
| 当前版本 | [release/version.yaml](../../../release/version.yaml) |
|
||||
| 本次发布说明及语言约定 | [release/notes.md](../../../release/notes.md)、贡献文档的项目约定 |
|
||||
| 版本提交、tag、推送与工作流触发 | [release/publish](../../../release/publish) |
|
||||
| 构建、检查、GitHub Release 和镜像发布 | [发布工作流](../../../.github/workflows/release.yml) |
|
||||
| 平台与产物清单 | [release/platforms.yaml](../../../release/platforms.yaml) |
|
||||
|
||||
命令从仓库根目录执行;每次以当前脚本和工作流为准,不另写发布脚本或绕过已有质量检查。
|
||||
用户明确指定其他仓库时先读取其发布流程,不能直接套用这里的路径和命令。
|
||||
|
||||
## 准备版本与说明
|
||||
|
||||
1. 确认发布类型、对应分支、upstream、目标仓库、工作区和完整待推送范围;
|
||||
未提交或未跟踪内容分别核对,不因脚本只检查已跟踪文件就认定所有内容都已纳入发布。
|
||||
2. 读取版本文件、已有 tag 和实际发行记录,确认上一发布基线与本次目标提交。
|
||||
根据基线到目标的提交和最终差异归纳变更,剔除回退、重复和未进入发布范围的内容。
|
||||
不能只用当前版本文件或最近几条提交判断“本次更新”。
|
||||
3. 使用用户指定或当前会话已确定的目标版本;需要建议时按兼容性、能力新增和修复范围说明理由。
|
||||
目标 tag 必须未被占用;遇到已有同名版本先判断是重试还是新发版,不自动递增版本掩盖冲突。
|
||||
4. 按贡献文档维护 `release/notes.md`。正文写中文,保留版本号和必要技术标识;
|
||||
按用户可感知的最终行为归并,不逐条翻译 commit,也不把排查过程或未验证承诺写入说明。
|
||||
可按新增功能、问题修复、体验优化、升级说明组织,省略没有内容的章节。
|
||||
5. 替换为本次版本说明,首行与目标 tag 一致且正文非空。升级事项应能追溯到实际迁移、配置或兼容变化;
|
||||
敏感信息脱敏,未合入功能不宣称已发布。已有准确说明仅需翻译时保持其事实和范围。
|
||||
6. 按贡献规范检查变更与现有 CI 证据,明确通过、失败、跳过和未执行。
|
||||
涉及数据库迁移时,对照同一大版本的上一正式 Release 检查已发布迁移是否保持不变,并验证升级路径;
|
||||
跨大版本按项目部署约定判断,不默认支持在线升级。区分未发布迁移的整理、已执行这些迁移的源码部署实例,
|
||||
以及 CI 实际采用的冻结基线。未发版不代表迁移从未被执行,CI 冻结检查失败也不等于正式版数据库升级失败;
|
||||
按各自证据报告兼容性与检查结果,不因未发版而自动放宽现有冻结规则。
|
||||
只修改说明时无需重建前后端;实际发版仍要跟踪发布工作流针对目标提交的检查与产物。
|
||||
|
||||
## 执行发版
|
||||
|
||||
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
|
||||
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
|
||||
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
|
||||
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
|
||||
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
|
||||
|
||||
在准备完成且用户已授权发布后,将 `release_version` 设为已确定的版本,执行:
|
||||
|
||||
```bash
|
||||
release/publish "$release_version"
|
||||
```
|
||||
|
||||
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
|
||||
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
|
||||
|
||||
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
|
||||
保持已有 tag、源码提交、附件和预发行状态,不重新执行发布入口。
|
||||
|
||||
## 恢复与验收
|
||||
|
||||
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
|
||||
若本地已创建版本提交或 tag,不直接重跑入口,也不通过删 tag、回退版本或强推来消除报错。
|
||||
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
|
||||
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
|
||||
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
|
||||
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
|
||||
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
|
||||
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
|
||||
Release 和镜像已生成也不表示用户的运行实例已经升级。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "release"
|
||||
short_description: "按仓库流程准备稳定版或实验版的中文说明,完成发行与渠道验证"
|
||||
default_prompt: "使用 $release 按指定的稳定版或实验版渠道准备中文发布说明,并核对发版条件。"
|
||||
@@ -0,0 +1,64 @@
|
||||
# 实验版发布
|
||||
|
||||
适用于独立于主版本维护、供用户主动选择体验的实验功能。发布入口仍是仓库的 `release/publish`,
|
||||
构建复用目标实验分支的 `.github/workflows/release.yml` 与 `release/platforms.yaml`。
|
||||
|
||||
## 分支与版本
|
||||
|
||||
- 从用户指定或会话已确定的 `experimental/<用途>` 分支发布,核对它的 upstream 和实验功能范围。
|
||||
使用独立 worktree 时保留其他工作区内容;不把实验代码合入 `main`,也不顺带升级运行实例。
|
||||
- 版本采用 `vX.Y.Z-exp.N`:`X.Y.Z` 表示本轮采用的正式版基线,`N` 从 1 开始递增。
|
||||
编号在仓库内唯一;多个实验分支共享 Git tag 命名空间,不能分别占用相同版本号。
|
||||
后续只有实际同步了新的正式版基线,才调整 `X.Y.Z`。
|
||||
- 核对已有 tag、Release 与目标分支源码,先区分重试和新发行。已发布的旧命名版本保留,
|
||||
不为统一名称移动 tag 或替换原有产物。
|
||||
- GitHub Release 展示标题采用 `vX.Y.Z-<用途>.M`,`<用途>` 取实验分支名去掉 `experimental/` 前缀后的部分。
|
||||
发布前通过 `gh api` 核对相同正式版基线与用途的已有 Release 标题和旧命名 tag,新发行取已有最大编号加 1,首次从 1 开始。
|
||||
展示编号 `M` 独立于实际 tag 的 `exp.N`,旧命名发行也计入,不能因改用 `exp.N` 而重置;同一发行重试沿用已确定编号。
|
||||
例如已有 `v3.10.0-codex-anti-degradation.1`,下一次 tag 即使是 `v3.10.0-exp.1`,标题也应为 `v3.10.0-codex-anti-degradation.2`。
|
||||
- tag 和产物继续使用 `exp.N`:镜像版本去掉 tag 开头的 `v`,安装包沿用项目的版本、系统和架构命名。
|
||||
发布说明首行仍匹配实际 tag;关联 Issue 和试验目的写在正文中。新标题规则不自动改名已有 Release。
|
||||
|
||||
## 先核对发布能力
|
||||
|
||||
在目标实验分支读取实际工作流,确认以下行为;新实验分支若缺少支持,先在既有流程中补齐并验证:
|
||||
|
||||
- `exp.N` 产生 `experimental` 构建类型,所有平台的二进制与镜像使用同一版本和源码提交。
|
||||
本项目实验构建禁用应用内一键更新,用户按部署说明手动升级。
|
||||
- GitHub Release 标为 Pre-release,且不设为 Latest;Docker 发布版本标签,不覆盖稳定版 `latest`。
|
||||
默认 `stable` 更新渠道忽略实验版;主动配置为 `preview` 的用户可能看到预发行。
|
||||
- 复用正式版平台清单、质量检查、完整安装包与多架构镜像,以及部署文件、校验和和现有产物证明。
|
||||
对照本次清单确认平台,不以“实验版”为由仅发布单架构镜像。
|
||||
- 部署说明交代与稳定实例并存的目录、配置、端口和数据库要求;需要 Compose 隔离配置时,
|
||||
核对发布附件确实包含它,不能只在文案中承诺。
|
||||
- 实验版正文使用下方完整文案,不再由工作流重复追加安装章节或自动生成混入正式版基线的提交列表。
|
||||
- Release 的 `name` 使用上述展示标题。工作流若默认以 tag 为标题,发布成功后用 `gh release edit --title` 设置标题,
|
||||
再通过 `gh api` 回读核对;仅修改 `name`,保留 tag、产物、正文和预发行状态。
|
||||
|
||||
## 中文发布说明
|
||||
|
||||
在目标分支的 `release/notes.md` 编写,首行为 `# vX.Y.Z-exp.N`。后续内容按实际需要组织:
|
||||
|
||||
1. **实验说明**:开头简述正式版基线、验证的问题与适用人群。效果尚未验证时明确说明,
|
||||
不把假设写成已解决的问题,不承诺实验功能一定进入主版本。
|
||||
2. **本次变化**:首次发行概括相对正式版基线新增的实验能力,关联 PR;后续发行只列相对上个实验版的变化。
|
||||
同步主分支修复时说明它对实验用户的影响,不复制整份正式版更新记录。
|
||||
3. **安装与使用**:给出本版本真实镜像命令、支持平台、部署文件及实验部署文档链接。
|
||||
交代功能是否默认启用和必要配置;保留工作流实际提供的校验与证明说明。
|
||||
4. **使用须知**:只写本次适用的已知限制、数据隔离、迁移与回退边界,以及手动更新方式。
|
||||
5. **反馈与致谢**:链接关联 Issue 或已有反馈入口,感谢实际贡献者。没有相应内容的章节可以省略。
|
||||
|
||||
用户要求先审核文案时,先展示完整正文;已批准的同一版本文案无需重复确认。
|
||||
正常新发行把说明纳入版本提交并从 tag 构建,不先创建公开空 Release 再回填附件。
|
||||
|
||||
## 执行与验收
|
||||
|
||||
按主技能准备说明和其他变更后,在实验分支执行 `release/publish <X.Y.Z-exp.N>`。
|
||||
入口创建版本提交和 tag,并从该分支 dispatch 发布工作流;不要从 `main` 发布实验代码。
|
||||
|
||||
发布前记录 GitHub Latest 的版本与 Docker `latest` 摘要;发布后核对二者仍对应稳定版。
|
||||
检查展示标题的分支用途与历史续号、它所对应的实际 tag,以及目标 tag 的源码提交、完整产物、校验和、多架构平台和 Pre-release 标记。
|
||||
同基线的 `X.Y.Z-exp.N` 按 SemVer 排序低于正式 `X.Y.Z`;渠道隔离仍以更新器过滤和发布标签规则为准。
|
||||
|
||||
失败时按主技能从未完成步骤恢复,不通过换版本号或移动 tag 掩盖失败。
|
||||
仅维护已有页面文案时不重新构建;报告实际完成状态、发布链接及验证缺口。
|
||||
@@ -0,0 +1,33 @@
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
欢迎提问。提交前可以搜索已有讨论,并查看[部署文档](https://github.com/zyycn/codex-proxy-rs/blob/main/deploy/README.md)。
|
||||
明确的程序故障请使用 [Issue 模板](https://github.com/zyycn/codex-proxy-rs/issues/new/choose);不确定是不是 Bug,也可以先在这里交流。
|
||||
请移除密钥、令牌、Cookie、代理认证和私人内容,不要上传完整配置或请求转储。解决后请标记帮助到你的答案。
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: 想解决什么问题
|
||||
description: 说明希望完成的任务、当前遇到的困难,标题尽量具体。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 版本与部署方式
|
||||
description: 涉及具体实例时,填写完整版本号或提交号,以及 Docker、二进制或源码部署方式。
|
||||
|
||||
- type: textarea
|
||||
id: attempts
|
||||
attributes:
|
||||
label: 已尝试的方法与结果
|
||||
description: 可以附看过的文档、相关讨论、脱敏错误片段或截图;未尝试可留空。
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: 相关环境与补充信息
|
||||
description: 按问题需要补充操作系统、客户端、反向代理或中转链路,无需提供无关信息。
|
||||
@@ -0,0 +1,44 @@
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
欢迎分享实验版本的使用体验,包括有效、无明显变化或遇到限制的情况。
|
||||
请先查看对应[预发行页面](https://github.com/zyycn/codex-proxy-rs/releases)的部署与数据隔离说明,并搜索已有反馈。
|
||||
明确的程序缺陷请使用 [Issue 模板](https://github.com/zyycn/codex-proxy-rs/issues/new/choose),在两处互相附链接即可,无需重复粘贴。
|
||||
请移除密钥、令牌、Cookie、代理认证和私人内容,不要上传完整配置或请求转储。
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 实验版本或提交号
|
||||
description: 填写实际运行的完整版本号或提交号,不要只写 exp 或 latest。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: scenario
|
||||
attributes:
|
||||
label: 使用场景与环境
|
||||
description: 说明启用的实验功能、部署方式、客户端及与结果相关的条件,不需要提供账号身份信息。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: observation
|
||||
attributes:
|
||||
label: 观察到的效果或限制
|
||||
description: 描述实际现象、出现频率和观察时长;请区分观察结果与原因推测。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: comparison
|
||||
attributes:
|
||||
label: 对照与复现信息
|
||||
description: 如已对比启用前后或不同配置,请说明条件和结果;没有对照也可以反馈,无需额外测试。
|
||||
|
||||
- type: textarea
|
||||
id: references
|
||||
attributes:
|
||||
label: 相关链接与补充材料
|
||||
description: 可附已有 Issue、讨论、脱敏日志片段或截图。
|
||||
@@ -0,0 +1,29 @@
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
这里适合交流尚在探索的想法、方案取舍和使用场景,不要求先给出完整实现。
|
||||
已有明确场景与预期行为时,可以直接提交[功能建议 Issue](https://github.com/zyycn/codex-proxy-rs/issues/new?template=feature-request.yml),无需先开讨论。
|
||||
请先搜索相关讨论,每个主题围绕一个问题展开。
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: 使用场景与当前不便
|
||||
description: 希望完成什么任务,目前的做法有什么不足?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: 想法与希望讨论的方向
|
||||
description: 可以说明期望体验、考虑过的替代方法,以及还不确定的地方。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: references
|
||||
attributes:
|
||||
label: 参考资料
|
||||
description: 可附相关 Issue、讨论或其他项目的具体行为;示例和截图请先脱敏。
|
||||
@@ -0,0 +1,174 @@
|
||||
name: 接口问题反馈
|
||||
description: 客户端调用模型、搜索等 API 时的报错、断流、超时或响应异常
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
管理端页面、账号导入或服务部署的问题请选择“常规问题反馈”;新增能力请选择“功能建议”。
|
||||
请提供最小复现信息。请求、日志和截图发布前请移除密钥、令牌、Cookie、代理认证及私人内容。
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 说明发生了什么、实际结果以及出现的频率;已有正常账号、模型或客户端的对照结果也可说明。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 服务版本
|
||||
description: 填写实际运行的完整版本号,不要只写“最新”或 latest;源码运行填写提交号。
|
||||
placeholder: 完整版本号或提交号
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: 部署方式
|
||||
options:
|
||||
- Docker Compose
|
||||
- 其他容器部署
|
||||
- 二进制运行
|
||||
- 源码运行
|
||||
- 其他方式(请在环境信息中说明)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: 环境信息
|
||||
description: 填写服务端与客户端的操作系统及版本;性能或超时问题可补充 CPU、内存和容器资源限制。
|
||||
placeholder: |
|
||||
服务端操作系统与版本:
|
||||
客户端操作系统与版本:
|
||||
相关资源限制(如有):
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: client-name
|
||||
attributes:
|
||||
label: 客户端名称
|
||||
description: 区分 CLI、桌面端、编辑器扩展、SDK 或自编脚本。
|
||||
placeholder: 例如 Codex CLI、Codex Desktop 或某个编辑器扩展
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: client-version
|
||||
attributes:
|
||||
label: 客户端版本
|
||||
description: 填写完整版本号;Codex CLI 可填写 codex --version 的输出。无法取得时说明原因。
|
||||
placeholder: 完整版本号或相关 SDK 版本
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: client-source
|
||||
attributes:
|
||||
label: 客户端安装来源与启动方式
|
||||
description: 说明安装来源,以及是否通过包装脚本、启动器或第三方修改版运行;不确定时填写已知部分。
|
||||
placeholder: 例如通过 npm 安装 @openai/codex,直接执行 codex
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: endpoint
|
||||
attributes:
|
||||
label: 请求方法与端点
|
||||
description: 填写发生异常的请求方法与路径,无需真实域名;不清楚端点时说明客户端当时执行的具体操作。
|
||||
placeholder: 例如 POST /v1/responses、POST /v1/alpha/search 或 WebSocket /v1/responses
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: intermediary
|
||||
attributes:
|
||||
label: 是否经过中转或反向代理
|
||||
description: |
|
||||
指客户端到本服务之间的链路。API 中转包括 New API、One API、CCH 等;反向代理或 CDN 包括 Nginx、Caddy、Cloudflare Tunnel 等。
|
||||
如有中转,请在下方填写链路详情;本服务到上游的出站代理请在补充信息中说明。
|
||||
options:
|
||||
- 否,直接连接本服务
|
||||
- 是,仅经过 API 中转服务
|
||||
- 是,仅经过反向代理或 CDN
|
||||
- 是,同时经过 API 中转和反向代理或 CDN
|
||||
- 不确定
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: request-chain
|
||||
attributes:
|
||||
label: 请求链路
|
||||
description: |
|
||||
按调用顺序填写中间服务的名称与版本,例如:Codex → New API → Nginx → 本服务。不确定时填写已知部分。
|
||||
如果已测试过直连本服务,请说明是否仍能复现;未测试可注明,不要求先完成对照测试。
|
||||
placeholder: |
|
||||
调用顺序与各中间服务版本:
|
||||
是否启用请求参数或请求头改写(如已知):
|
||||
直连本服务的对照结果(如已测试):
|
||||
|
||||
- type: textarea
|
||||
id: account-model
|
||||
attributes:
|
||||
label: 平台、账号类型与模型
|
||||
description: 填写与失败请求相关的信息,不要提供账号凭据;不涉及模型或无法确认的信息请注明。
|
||||
placeholder: |
|
||||
平台与认证类型:
|
||||
账号套餐(如相关):
|
||||
请求模型与映射后的上游模型(如已知):
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 描述触发问题的操作;无法稳定复现时,说明已知触发条件。
|
||||
placeholder: |
|
||||
1. 使用什么配置或输入
|
||||
2. 执行什么操作
|
||||
3. 出现什么结果
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: 预期行为
|
||||
description: 说明原本期望得到的结果。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: request-response
|
||||
attributes:
|
||||
label: 请求、响应与诊断附件
|
||||
description: |
|
||||
提供脱敏后的最小请求、网关实际状态/响应片段和客户端显示,区分上游、中转、网关与客户端的错误来源。
|
||||
可在此直接上传管理端默认诊断包并检查其缺失说明;原始错误与日志片段需另行审阅脱敏,粘贴时请使用 Markdown 代码块。
|
||||
按部署文档的“请求错误排查”检查关联 ID、错误详情与 application 日志,不默认开启完整 dump;无法取得的信息请注明。
|
||||
placeholder: |
|
||||
发生时间与时区:
|
||||
传输(HTTP JSON / SSE / WebSocket):
|
||||
脱敏后的最小请求:
|
||||
网关关联 ID(x-gateway-request-id;配置的入口头请单独注明):
|
||||
上游关联 ID(x-request-id / x-oai-request-id,注明来源):
|
||||
网关实际状态、Content-Type 与响应片段:
|
||||
中转服务的状态与错误(如有):
|
||||
客户端显示的错误:
|
||||
诊断包或无法取得的信息:
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 最近有升级时填写升级前后版本及前后端是否同步更新;网络问题补充出站代理、系统代理、TUN 和自定义证书等相关环境。
|
||||
placeholder: |
|
||||
服务或客户端升级前版本 → 当前版本(如有):
|
||||
服务前后端是否同步更新(如有升级):
|
||||
最近的配置变更:
|
||||
其他相关环境:
|
||||
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 使用问答
|
||||
url: https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答
|
||||
about: 安装部署、配置和日常使用求助;明确的 Bug 请选择上方问题反馈模板。
|
||||
- name: 想法与方案讨论
|
||||
url: https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论
|
||||
about: 探索尚未明确的需求与方案;具体功能需求也可以直接提交功能建议。
|
||||
- name: 实验版体验反馈
|
||||
url: https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈
|
||||
about: 分享实验版本的使用效果、适用场景和限制。
|
||||
@@ -0,0 +1,39 @@
|
||||
name: 功能建议
|
||||
description: 说明使用场景、当前不便和期望行为,提出新能力或体验改进
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
如果已有功能无法正常使用,请选择对应的故障模板。提交前可搜索已有 Issues,相关讨论请附链接。
|
||||
每个 Issue 尽量围绕一个使用场景;截图和示例请移除密钥、令牌、Cookie、代理认证及私人内容。
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: 使用场景与当前不便
|
||||
description: 说明你希望完成什么任务、目前如何操作,以及现有能力有什么不足。
|
||||
placeholder: |
|
||||
希望完成的任务:
|
||||
当前做法与不便:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: 期望行为
|
||||
description: 描述改进后希望如何使用,最好给出一个具体例子;不要求先设计技术方案。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 已尝试的替代方案
|
||||
description: 如果已有配置、操作方式或其他工具能部分满足需求,说明仍缺少什么;未尝试可留空。
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 可附当前使用的具体版本、相关 Issue、截图或参考链接;引用其他项目时说明希望借鉴的具体行为。
|
||||
@@ -0,0 +1,143 @@
|
||||
name: 常规问题反馈
|
||||
description: 管理端页面、账号导入、安装部署、升级或其他非客户端 API 问题
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
客户端调用模型、搜索等 API 的问题请选择“接口问题反馈”;新增能力或体验改进请选择“功能建议”。
|
||||
请提供最小复现信息。请求、日志和截图发布前请移除密钥、令牌、Cookie、代理认证及私人内容。
|
||||
|
||||
- type: dropdown
|
||||
id: issue-type
|
||||
attributes:
|
||||
label: 问题类型
|
||||
options:
|
||||
- 页面或交互异常
|
||||
- 配置或部署异常
|
||||
- 升级异常
|
||||
- 其他问题
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 说明发生了什么、实际结果以及出现的频率。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 服务版本
|
||||
description: 填写实际运行的完整版本号,不要只写“最新”或 latest;源码运行填写提交号。服务无法启动时填写准备运行的版本。
|
||||
placeholder: 完整版本号或提交号
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: 部署方式
|
||||
options:
|
||||
- Docker Compose
|
||||
- 其他容器部署
|
||||
- 二进制运行
|
||||
- 源码运行
|
||||
- 其他方式(请在环境信息中说明)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: 环境信息
|
||||
description: 填写操作系统与版本,并补充与问题相关的浏览器、访问方式或资源信息。
|
||||
placeholder: |
|
||||
操作系统与版本:
|
||||
浏览器与版本(页面问题):
|
||||
页面访问方式(HTTP / HTTPS,localhost / IP / 域名,无需真实地址):
|
||||
CPU 核数与内存、容器资源限制(部署或性能问题):
|
||||
Docker / Compose 版本(容器部署问题):
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: intermediary
|
||||
attributes:
|
||||
label: 是否经过中转或反向代理
|
||||
description: |
|
||||
指客户端或浏览器到本服务之间的链路。API 中转包括 New API、One API、CCH 等;反向代理或 CDN 包括 Nginx、Caddy、Cloudflare Tunnel 等。
|
||||
如有中转,请在下方填写链路详情;本服务到上游的出站代理请在补充信息中说明。
|
||||
options:
|
||||
- 否,直接连接本服务
|
||||
- 是,仅经过 API 中转服务
|
||||
- 是,仅经过反向代理或 CDN
|
||||
- 是,同时经过 API 中转和反向代理或 CDN
|
||||
- 不确定
|
||||
- 不适用(例如服务尚未启动)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: request-chain
|
||||
attributes:
|
||||
label: 请求链路
|
||||
description: |
|
||||
按调用顺序填写中间服务的名称与版本,例如:浏览器 → Cloudflare → Nginx → 本服务。不确定时填写已知部分。
|
||||
如果已测试过直连本服务,请说明是否仍能复现;未测试可注明,不要求先完成对照测试。
|
||||
placeholder: |
|
||||
调用顺序与各中间服务版本:
|
||||
是否启用请求参数或请求头改写(如已知):
|
||||
直连本服务的对照结果(如已测试):
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 描述触发问题的操作;批量操作补充数量,升级问题说明升级方式。无法稳定复现时,说明已知触发条件。
|
||||
placeholder: |
|
||||
1. 使用什么配置或输入
|
||||
2. 执行什么操作
|
||||
3. 出现什么结果
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: 预期行为
|
||||
description: 说明原本期望得到的结果。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 错误信息与日志
|
||||
description: |
|
||||
提供脱敏后的页面提示、浏览器控制台错误或相关启动、升级日志;如有失败请求,补充状态码与响应片段。
|
||||
可以在此上传诊断包或日志附件;粘贴日志时请使用 Markdown 代码块。不要上传完整配置或请求转储。
|
||||
placeholder: |
|
||||
发生时间与时区:
|
||||
页面提示或终端错误:
|
||||
浏览器控制台 / 服务日志:
|
||||
失败请求的状态码与响应片段(如有):
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: 截图或录屏
|
||||
description: 页面或交互问题可在此拖入相关截图、录屏,并说明对应的操作。
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 最近有升级时填写升级前后版本及前后端是否同步更新;网络问题补充出站代理、系统代理、TUN 和自定义证书等相关环境。
|
||||
placeholder: |
|
||||
升级前版本 → 当前版本(如有):
|
||||
前后端是否同步更新(如有升级):
|
||||
最近的配置变更:
|
||||
其他相关环境:
|
||||
@@ -0,0 +1,242 @@
|
||||
name: Verify container
|
||||
description: Scan and validate a built image with the deployment Compose configuration
|
||||
inputs:
|
||||
image:
|
||||
description: Local image tag or immutable registry digest to validate
|
||||
required: true
|
||||
platform:
|
||||
description: Native runner image platform
|
||||
required: true
|
||||
artifact-name:
|
||||
description: Unique diagnostics artifact name within the workflow run
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Define isolated runtime values
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ inputs.image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
admin_password="$(openssl rand -hex 24)"
|
||||
postgres_password="$(openssl rand -hex 24)"
|
||||
redis_password="$(openssl rand -hex 24)"
|
||||
echo "::add-mask::$admin_password"
|
||||
echo "::add-mask::$postgres_password"
|
||||
echo "::add-mask::$redis_password"
|
||||
{
|
||||
echo "CPR_IMAGE=$IMAGE"
|
||||
echo "COMPOSE_PROJECT_NAME=cpr-check-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${RUNNER_ARCH,,}"
|
||||
echo "DIAGNOSTICS_DIR=$RUNNER_TEMP/codex-proxy-rs-diagnostics"
|
||||
echo "CI_ADMIN_PASSWORD=$admin_password"
|
||||
echo "CI_POSTGRES_PASSWORD=$postgres_password"
|
||||
echo "CI_REDIS_PASSWORD=$redis_password"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Scan container image
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
env:
|
||||
TRIVY_PLATFORM: ${{ inputs.platform }}
|
||||
TRIVY_QUIET: 'true'
|
||||
TRIVY_SKIP_VERSION_CHECK: 'true'
|
||||
with:
|
||||
image-ref: ${{ inputs.image }}
|
||||
version: v0.72.0
|
||||
format: json
|
||||
output: trivy-results.json
|
||||
severity: HIGH,CRITICAL
|
||||
vuln-type: os,library
|
||||
scanners: vuln
|
||||
ignore-unfixed: true
|
||||
exit-code: '0'
|
||||
|
||||
- name: Generate Trivy SARIF
|
||||
shell: bash
|
||||
run: trivy convert --format sarif --output trivy-results.sarif trivy-results.json
|
||||
|
||||
- name: Upload Trivy SARIF
|
||||
if: >-
|
||||
${{ always() && hashFiles('trivy-results.sarif') != '' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: runtime-container-${{ inputs.platform }}
|
||||
|
||||
- name: Enforce Trivy vulnerability gate
|
||||
shell: bash
|
||||
run: trivy convert --format table --severity HIGH,CRITICAL --exit-code 1 trivy-results.json
|
||||
|
||||
- name: Prepare isolated runtime
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 0077
|
||||
# Bind roots hand off ownership to the image-specific database users.
|
||||
# Sticky directories keep that initialization compatible with user-namespaced runners.
|
||||
install -d -m 1777 .runtime/postgres .runtime/redis
|
||||
mkdir -p "$DIAGNOSTICS_DIR"
|
||||
sudo install -d -m 0770 -o "$(id -u)" -g 10001 \
|
||||
.runtime/data .runtime/logs
|
||||
ruby <<'RUBY'
|
||||
path = "deploy/config.example.yaml"
|
||||
content = File.read(path)
|
||||
replacements = {
|
||||
"password: &postgres_password ''" => "password: &postgres_password '#{ENV.fetch("CI_POSTGRES_PASSWORD")}'",
|
||||
"password: &redis_password ''" => "password: &redis_password '#{ENV.fetch("CI_REDIS_PASSWORD")}'",
|
||||
"default_password: ''" => "default_password: '#{ENV.fetch("CI_ADMIN_PASSWORD")}'",
|
||||
}
|
||||
replacements.each do |placeholder, configured|
|
||||
raise "missing config placeholder: #{placeholder}" unless content.sub!(placeholder, configured)
|
||||
end
|
||||
File.write("deploy/config.yaml", content)
|
||||
RUBY
|
||||
sudo chown "$(id -u):10001" deploy/config.yaml
|
||||
chmod 0640 deploy/config.yaml
|
||||
|
||||
- name: Validate Docker Compose
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml config --quiet
|
||||
|
||||
- name: Configure Redis host memory overcommit
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo sysctl -w vm.overcommit_memory=1
|
||||
test "$(sysctl -n vm.overcommit_memory)" = 1
|
||||
|
||||
- name: Verify PostgreSQL and Redis volume persistence
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" up -d --no-build --wait postgres redis
|
||||
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -At -U codex_proxy -d codex_proxy -c "SHOW TimeZone;" | tr -d '\r')" = Asia/Shanghai
|
||||
"${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
|
||||
"CREATE TABLE cpr_ci_persistence_probe (value text PRIMARY KEY); INSERT INTO cpr_ci_persistence_probe VALUES ('persisted');"
|
||||
"${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
|
||||
redis-cli SET cpr:ci:persistence-probe persisted
|
||||
"${compose[@]}" up -d --no-build --wait --force-recreate postgres redis
|
||||
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -At -U codex_proxy -d codex_proxy -c \
|
||||
"SELECT value FROM cpr_ci_persistence_probe;")" = persisted
|
||||
test "$("${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
|
||||
redis-cli --raw GET cpr:ci:persistence-probe | tr -d '\r')" = persisted
|
||||
"${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
|
||||
"DROP TABLE cpr_ci_persistence_probe;"
|
||||
"${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
|
||||
redis-cli DEL cpr:ci:persistence-probe
|
||||
|
||||
- name: Start and verify application
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" up -d --no-build --wait codex-proxy-rs
|
||||
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
|
||||
"http://127.0.0.1:8080/healthz")"
|
||||
test "$status" = 204
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs id -u | tr -d '\r')" = 10001
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs id -g | tr -d '\r')" = 10001
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs date +%z | tr -d '\r')" = +0800
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs cat /etc/timezone | tr -d '\r')" = Asia/Shanghai
|
||||
"${compose[@]}" exec -T codex-proxy-rs sh -c \
|
||||
'touch /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write && rm /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write'
|
||||
|
||||
- name: Verify clean logs and graceful shutdown
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" stop --timeout 30 codex-proxy-rs
|
||||
container_id="$("${compose[@]}" ps -a -q codex-proxy-rs)"
|
||||
test -n "$container_id"
|
||||
test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = 0
|
||||
"${compose[@]}" logs --no-color --timestamps codex-proxy-rs \
|
||||
> "$DIAGNOSTICS_DIR/application.log"
|
||||
"${compose[@]}" logs --no-color --timestamps postgres \
|
||||
> "$DIAGNOSTICS_DIR/postgres.log"
|
||||
"${compose[@]}" logs --no-color --timestamps redis \
|
||||
> "$DIAGNOSTICS_DIR/redis.log"
|
||||
if grep --extended-regexp --ignore-case \
|
||||
'"level"[[:space:]]*:[[:space:]]*"(warn|error|fatal|panic)"|(^|[^[:alnum:]_])panic([^[:alnum:]_]|$)' \
|
||||
"$DIAGNOSTICS_DIR/application.log" \
|
||||
|| grep --extended-regexp --ignore-case \
|
||||
'(^|[[:space:]])(warning|error|fatal|panic):' \
|
||||
"$DIAGNOSTICS_DIR/postgres.log" \
|
||||
|| grep --extended-regexp --ignore-case \
|
||||
'[0-9]+:[A-Z][[:space:]].*[[:space:]]#[[:space:]]|(^|[|[:space:]])(warning|error|fatal|panic)(:|[[:space:]])' \
|
||||
"$DIAGNOSTICS_DIR/redis.log"; then
|
||||
echo "container logs contain a warning or error severity" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Collect container diagnostics
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p "$DIAGNOSTICS_DIR"
|
||||
raw_dir="$(mktemp -d)"
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" config > "$raw_dir/compose-config.yaml" 2>&1
|
||||
"${compose[@]}" ps -a > "$raw_dir/compose-ps.txt" 2>&1
|
||||
"${compose[@]}" logs --no-color --timestamps > "$raw_dir/compose.log" 2>&1
|
||||
mapfile -t container_ids < <("${compose[@]}" ps -a -q)
|
||||
if [ "${#container_ids[@]}" -gt 0 ]; then
|
||||
docker inspect "${container_ids[@]}" > "$raw_dir/docker-inspect.json" 2>&1
|
||||
fi
|
||||
RAW_DIR="$raw_dir" OUTPUT_DIR="$DIAGNOSTICS_DIR" ruby <<'RUBY'
|
||||
raw_dir = ENV.fetch("RAW_DIR")
|
||||
output_dir = ENV.fetch("OUTPUT_DIR")
|
||||
secrets = [
|
||||
ENV.fetch("CI_ADMIN_PASSWORD"),
|
||||
ENV.fetch("CI_POSTGRES_PASSWORD"),
|
||||
ENV.fetch("CI_REDIS_PASSWORD"),
|
||||
]
|
||||
encoded_secrets = secrets.map do |secret|
|
||||
secret.encode("UTF-8").bytes.map do |byte|
|
||||
character = byte.chr
|
||||
character.match?(/[A-Za-z0-9]/) ? character : format("%%%02X", byte)
|
||||
end.join
|
||||
end
|
||||
|
||||
Dir.children(raw_dir).each do |name|
|
||||
content = File.binread(File.join(raw_dir, name))
|
||||
(secrets + encoded_secrets).each do |secret|
|
||||
content = content.gsub(secret, "<redacted>")
|
||||
end
|
||||
File.binwrite(File.join(output_dir, name), content)
|
||||
end
|
||||
RUBY
|
||||
rm -rf "$raw_dir"
|
||||
cp trivy-results.json trivy-results.sarif "$DIAGNOSTICS_DIR/" 2>/dev/null || true
|
||||
|
||||
- name: Upload container diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ inputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/codex-proxy-rs-diagnostics
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Remove isolated runtime
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml down --remove-orphans || true
|
||||
sudo rm -rf "$GITHUB_WORKSPACE/.runtime" "$GITHUB_WORKSPACE/deploy/config.yaml"
|
||||
@@ -21,6 +21,27 @@ updates:
|
||||
day: monday
|
||||
time: '09:10'
|
||||
timezone: Asia/Shanghai
|
||||
# 按官方 Codex 锁文件、协议指纹或已审计 fork 固定;完成对应核对与实测后再手动升级。
|
||||
ignore:
|
||||
- dependency-name: aws-lc-rs
|
||||
- dependency-name: hyper
|
||||
- dependency-name: hyper-rustls
|
||||
- dependency-name: hyper-tls
|
||||
- dependency-name: hyper-util
|
||||
- dependency-name: native-tls
|
||||
- dependency-name: openssl
|
||||
- dependency-name: openssl-src
|
||||
- dependency-name: openssl-sys
|
||||
- dependency-name: reqwest
|
||||
- dependency-name: rustls
|
||||
- dependency-name: rustls-native-certs
|
||||
- dependency-name: rustls-pki-types
|
||||
- dependency-name: tokio
|
||||
- dependency-name: tokio-native-tls
|
||||
- dependency-name: tokio-rustls
|
||||
- dependency-name: tokio-tungstenite
|
||||
- dependency-name: tungstenite
|
||||
- dependency-name: zstd
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
|
||||
@@ -17,35 +17,43 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: cpr-ci-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
CPR_IMAGE: codex-proxy-rs:ci-${{ inputs.ref }}
|
||||
TRIVY_QUIET: 'true'
|
||||
TRIVY_SKIP_VERSION_CHECK: 'true'
|
||||
steps:
|
||||
- name: Define isolated runtime values
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
admin_password="$(openssl rand -hex 24)"
|
||||
postgres_password="$(openssl rand -hex 24)"
|
||||
redis_password="$(openssl rand -hex 24)"
|
||||
echo "::add-mask::$admin_password"
|
||||
echo "::add-mask::$postgres_password"
|
||||
echo "::add-mask::$redis_password"
|
||||
{
|
||||
echo "DIAGNOSTICS_DIR=$RUNNER_TEMP/codex-proxy-rs-diagnostics"
|
||||
echo "CI_ADMIN_PASSWORD=$admin_password"
|
||||
echo "CI_POSTGRES_PASSWORD=$postgres_password"
|
||||
echo "CI_REDIS_PASSWORD=$redis_password"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Cache Cargo mounts
|
||||
id: cargo-cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ runner.temp }}/container-cargo
|
||||
key: container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/**/Cargo.toml', 'deploy/Dockerfile') }}-${{ inputs.ref }}
|
||||
restore-keys: |
|
||||
container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/**/Cargo.toml', 'deploy/Dockerfile') }}-
|
||||
container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
# The GHA layer exporter does not persist RUN --mount=type=cache contents.
|
||||
# Extraction runs before actions/cache saves its directories in post steps.
|
||||
- name: Restore Cargo mounts to BuildKit
|
||||
uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
scratch-dir: ${{ runner.temp }}/container-cache-dance
|
||||
cache-map: |
|
||||
{
|
||||
"${{ runner.temp }}/container-cargo/registry": "/usr/local/cargo/registry",
|
||||
"${{ runner.temp }}/container-cargo/git": "/usr/local/cargo/git",
|
||||
"${{ runner.temp }}/container-cargo/target": "/app/backend/target"
|
||||
}
|
||||
skip-extraction: ${{ steps.cargo-cache.outputs.cache-hit }}
|
||||
|
||||
- name: Build runtime image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
@@ -55,212 +63,15 @@ jobs:
|
||||
target: runtime
|
||||
load: true
|
||||
tags: ${{ env.CPR_IMAGE }}
|
||||
no-cache-filters: |
|
||||
runtime-base
|
||||
runtime
|
||||
cache-from: type=gha,scope=runtime-ci
|
||||
cache-to: type=gha,mode=max,scope=runtime-ci
|
||||
|
||||
- name: Generate Trivy SARIF
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
- name: Verify runtime image
|
||||
uses: ./.github/actions/verify-container
|
||||
with:
|
||||
image-ref: ${{ env.CPR_IMAGE }}
|
||||
version: v0.72.0
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
severity: HIGH,CRITICAL
|
||||
vuln-type: os,library
|
||||
scanners: vuln
|
||||
ignore-unfixed: true
|
||||
exit-code: '0'
|
||||
|
||||
- name: Upload Trivy SARIF
|
||||
if: >-
|
||||
${{ hashFiles('trivy-results.sarif') != '' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: runtime-container
|
||||
|
||||
- name: Enforce Trivy vulnerability gate
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ env.CPR_IMAGE }}
|
||||
version: v0.72.0
|
||||
format: table
|
||||
severity: HIGH,CRITICAL
|
||||
vuln-type: os,library
|
||||
scanners: vuln
|
||||
ignore-unfixed: true
|
||||
exit-code: '1'
|
||||
|
||||
- name: Prepare isolated runtime
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 0077
|
||||
# Bind roots hand off ownership to the image-specific database users.
|
||||
# Sticky directories keep that initialization compatible with user-namespaced runners.
|
||||
install -d -m 1777 .runtime/postgres .runtime/redis
|
||||
mkdir -p "$DIAGNOSTICS_DIR"
|
||||
sudo install -d -m 0770 -o "$(id -u)" -g 10001 \
|
||||
.runtime/data .runtime/logs
|
||||
ruby <<'RUBY'
|
||||
path = "deploy/config.example.yaml"
|
||||
content = File.read(path)
|
||||
replacements = {
|
||||
"password: &postgres_password ''" => "password: &postgres_password '#{ENV.fetch("CI_POSTGRES_PASSWORD")}'",
|
||||
"password: &redis_password ''" => "password: &redis_password '#{ENV.fetch("CI_REDIS_PASSWORD")}'",
|
||||
"default_password: ''" => "default_password: '#{ENV.fetch("CI_ADMIN_PASSWORD")}'",
|
||||
}
|
||||
replacements.each do |placeholder, configured|
|
||||
raise "missing config placeholder: #{placeholder}" unless content.sub!(placeholder, configured)
|
||||
end
|
||||
File.write("deploy/config.yaml", content)
|
||||
RUBY
|
||||
sudo chown "$(id -u):10001" deploy/config.yaml
|
||||
chmod 0640 deploy/config.yaml
|
||||
|
||||
- name: Validate Docker Compose
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml config --quiet
|
||||
|
||||
- name: Configure Redis host memory overcommit
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo sysctl -w vm.overcommit_memory=1
|
||||
test "$(sysctl -n vm.overcommit_memory)" = 1
|
||||
|
||||
- name: Verify PostgreSQL and Redis volume persistence
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" up -d --no-build --wait postgres redis
|
||||
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -At -U codex_proxy -d codex_proxy -c "SHOW TimeZone;" | tr -d '\r')" = Asia/Shanghai
|
||||
"${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
|
||||
"CREATE TABLE cpr_ci_persistence_probe (value text PRIMARY KEY); INSERT INTO cpr_ci_persistence_probe VALUES ('persisted');"
|
||||
"${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
|
||||
redis-cli SET cpr:ci:persistence-probe persisted
|
||||
"${compose[@]}" up -d --no-build --wait --force-recreate postgres redis
|
||||
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -At -U codex_proxy -d codex_proxy -c \
|
||||
"SELECT value FROM cpr_ci_persistence_probe;")" = persisted
|
||||
test "$("${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
|
||||
redis-cli --raw GET cpr:ci:persistence-probe | tr -d '\r')" = persisted
|
||||
"${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
|
||||
"DROP TABLE cpr_ci_persistence_probe;"
|
||||
"${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
|
||||
redis-cli DEL cpr:ci:persistence-probe
|
||||
|
||||
- name: Start and verify application
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" up -d --no-build --wait codex-proxy-rs
|
||||
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
|
||||
"http://127.0.0.1:8080/healthz")"
|
||||
test "$status" = 204
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs id -u | tr -d '\r')" = 10001
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs id -g | tr -d '\r')" = 10001
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs date +%z | tr -d '\r')" = +0800
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs cat /etc/timezone | tr -d '\r')" = Asia/Shanghai
|
||||
"${compose[@]}" exec -T codex-proxy-rs sh -c \
|
||||
'touch /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write && rm /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write'
|
||||
|
||||
- name: Verify clean logs and graceful shutdown
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" stop --timeout 30 codex-proxy-rs
|
||||
container_id="$("${compose[@]}" ps -a -q codex-proxy-rs)"
|
||||
test -n "$container_id"
|
||||
test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = 0
|
||||
"${compose[@]}" logs --no-color --timestamps codex-proxy-rs \
|
||||
> "$DIAGNOSTICS_DIR/application.log"
|
||||
"${compose[@]}" logs --no-color --timestamps postgres \
|
||||
> "$DIAGNOSTICS_DIR/postgres.log"
|
||||
"${compose[@]}" logs --no-color --timestamps redis \
|
||||
> "$DIAGNOSTICS_DIR/redis.log"
|
||||
if grep --extended-regexp --ignore-case \
|
||||
'"level"[[:space:]]*:[[:space:]]*"(warn|error|fatal|panic)"|(^|[^[:alnum:]_])panic([^[:alnum:]_]|$)' \
|
||||
"$DIAGNOSTICS_DIR/application.log" \
|
||||
|| grep --extended-regexp --ignore-case \
|
||||
'(^|[[:space:]])(warning|error|fatal|panic):' \
|
||||
"$DIAGNOSTICS_DIR/postgres.log" \
|
||||
|| grep --extended-regexp --ignore-case \
|
||||
'[0-9]+:[A-Z][[:space:]].*[[:space:]]#[[:space:]]|(^|[|[:space:]])(warning|error|fatal|panic)(:|[[:space:]])' \
|
||||
"$DIAGNOSTICS_DIR/redis.log"; then
|
||||
echo "container logs contain a warning or error severity" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Collect container diagnostics
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p "$DIAGNOSTICS_DIR"
|
||||
raw_dir="$(mktemp -d)"
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" config > "$raw_dir/compose-config.yaml" 2>&1
|
||||
"${compose[@]}" ps -a > "$raw_dir/compose-ps.txt" 2>&1
|
||||
"${compose[@]}" logs --no-color --timestamps > "$raw_dir/compose.log" 2>&1
|
||||
mapfile -t container_ids < <("${compose[@]}" ps -a -q)
|
||||
if [ "${#container_ids[@]}" -gt 0 ]; then
|
||||
docker inspect "${container_ids[@]}" > "$raw_dir/docker-inspect.json" 2>&1
|
||||
fi
|
||||
RAW_DIR="$raw_dir" OUTPUT_DIR="$DIAGNOSTICS_DIR" ruby <<'RUBY'
|
||||
raw_dir = ENV.fetch("RAW_DIR")
|
||||
output_dir = ENV.fetch("OUTPUT_DIR")
|
||||
secrets = [
|
||||
ENV.fetch("CI_ADMIN_PASSWORD"),
|
||||
ENV.fetch("CI_POSTGRES_PASSWORD"),
|
||||
ENV.fetch("CI_REDIS_PASSWORD"),
|
||||
]
|
||||
encoded_secrets = secrets.map do |secret|
|
||||
secret.encode("UTF-8").bytes.map do |byte|
|
||||
character = byte.chr
|
||||
character.match?(/[A-Za-z0-9]/) ? character : format("%%%02X", byte)
|
||||
end.join
|
||||
end
|
||||
|
||||
Dir.children(raw_dir).each do |name|
|
||||
content = File.binread(File.join(raw_dir, name))
|
||||
(secrets + encoded_secrets).each do |secret|
|
||||
content = content.gsub(secret, "<redacted>")
|
||||
end
|
||||
File.binwrite(File.join(output_dir, name), content)
|
||||
end
|
||||
RUBY
|
||||
rm -rf "$raw_dir"
|
||||
cp trivy-results.sarif "$DIAGNOSTICS_DIR/" 2>/dev/null || true
|
||||
|
||||
- name: Upload container diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/codex-proxy-rs-diagnostics
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Remove isolated runtime
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml down --remove-orphans || true
|
||||
sudo rm -rf "$GITHUB_WORKSPACE/.runtime" "$GITHUB_WORKSPACE/deploy/config.yaml"
|
||||
image: ${{ env.CPR_IMAGE }}
|
||||
platform: linux/amd64
|
||||
artifact-name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-amd64
|
||||
|
||||
@@ -33,6 +33,8 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Rust 1.97 编译 AWS SDK 调试信息需要更大的 codegen 线程栈。
|
||||
RUST_MIN_STACK: 16777216
|
||||
CPR_TEST_DATABASE_URL: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy
|
||||
CPR_TEST_REDIS_URL: redis://127.0.0.1:6379
|
||||
services:
|
||||
@@ -61,9 +63,44 @@ jobs:
|
||||
--health-retries 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
# PR 场景需要 base 提交做冻结清单的 append-only 比对。
|
||||
fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }}
|
||||
|
||||
# 已应用迁移的字节被 sqlx 启动校验钉死:改动已发布迁移会让所有
|
||||
# 存量部署重启即失败。冻结清单保证旧文件不被改、新文件必须入册。
|
||||
- name: Migration freeze check
|
||||
working-directory: backend/migrations
|
||||
shell: bash
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha256sum --check --strict .frozen-sha256
|
||||
for file in *.sql; do
|
||||
if ! awk '{print $2}' .frozen-sha256 | grep -Fxq "$file"; then
|
||||
echo "::error file=backend/migrations/$file::migration $file is not frozen; append its sha256 to backend/migrations/.frozen-sha256"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# 清单自证不足以防「改迁移 + 同 PR 刷新清单行」:校验对新哈希通过。
|
||||
# 对 PR,断言 .frozen-sha256 相对 base 只增不改——base 里的每一行
|
||||
# 必须原样保留(改哈希=删行,删迁移=删行,都会被挡)。
|
||||
if [ -n "${BASE_SHA:-}" ]; then
|
||||
base_manifest="$(git show "${BASE_SHA}:backend/migrations/.frozen-sha256" 2>/dev/null || true)"
|
||||
if [ -n "$base_manifest" ]; then
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && continue
|
||||
if ! grep -Fxq "$line" .frozen-sha256; then
|
||||
file="$(printf '%s' "$line" | awk '{print $2}')"
|
||||
echo "::error file=backend/migrations/.frozen-sha256::frozen migration $file was modified or removed; released migrations are append-only — add a new numbered migration instead"
|
||||
exit 1
|
||||
fi
|
||||
done <<< "$base_manifest"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
|
||||
@@ -71,7 +108,7 @@ jobs:
|
||||
components: rustfmt,clippy
|
||||
|
||||
- name: Cache Rust
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
|
||||
@@ -117,12 +154,12 @@ jobs:
|
||||
CI: 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11.7.0
|
||||
|
||||
@@ -156,7 +193,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
@@ -168,3 +205,6 @@ jobs:
|
||||
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
|
||||
-color
|
||||
.github/workflows/*.yml
|
||||
|
||||
- name: Check release script syntax
|
||||
run: bash -n release/publish
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Agent Instructions Guard
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- edited
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: agents-md-guard-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# 只查询 PR 元数据,禁止检出或执行贡献分支中的代码。
|
||||
- name: Enforce agent instructions policy
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const params = { owner, repo, pull_number: pr.number };
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
...params,
|
||||
per_page: 100,
|
||||
});
|
||||
const isInstructions = path => typeof path === 'string'
|
||||
&& (path === 'AGENTS.md' || path.endsWith('/AGENTS.md'));
|
||||
|
||||
// 同时检查重命名前后的路径,避免通过移走规则文件绕过检查。
|
||||
if (!files.some(file => isInstructions(file.filename) || isInstructions(file.previous_filename))) {
|
||||
core.info('No agent instruction changes detected.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 旧事件不处理后续提交,也不重复处理已关闭的 PR。
|
||||
const { data: current } = await github.rest.pulls.get(params);
|
||||
if (current.state !== 'open' || current.head.sha !== pr.head.sha) {
|
||||
core.info('Pull request state has changed; skipping this event.');
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = '<!-- agents-md-guard -->';
|
||||
const body = [
|
||||
marker,
|
||||
'此 PR 修改了 `AGENTS.md`,已按贡献规则关闭。',
|
||||
'',
|
||||
'PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。',
|
||||
'请撤销相关变更后重新打开 PR;如需调整协作规则,请先提交 Issue 说明原因和建议,由维护者处理。',
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: pr.number, per_page: 100,
|
||||
});
|
||||
const notified = comments.some(comment => comment.user?.login === 'github-actions[bot]'
|
||||
&& comment.body?.includes(marker));
|
||||
if (!notified) {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Unable to post policy explanation: ${error.message}`);
|
||||
}
|
||||
|
||||
await github.rest.pulls.update({ ...params, state: 'closed' });
|
||||
core.setFailed('Pull requests must not change AGENTS.md.');
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- 'release/version.yaml'
|
||||
- 'release/notes.md'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
@@ -25,14 +28,14 @@ jobs:
|
||||
container: ${{ steps.filter.outputs.container }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect affected checks
|
||||
id: filter
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
with:
|
||||
filters: |
|
||||
backend:
|
||||
@@ -48,6 +51,7 @@ jobs:
|
||||
- 'deploy/**'
|
||||
- '.dockerignore'
|
||||
- 'release/version.yaml'
|
||||
- '.github/actions/**'
|
||||
- '.github/workflows/_container.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
|
||||
|
||||
+137
-75
@@ -1,9 +1,6 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -15,7 +12,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
||||
group: release-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
@@ -29,6 +26,7 @@ jobs:
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
docker_version: ${{ steps.version.outputs.docker_version }}
|
||||
build_time: ${{ steps.version.outputs.build_time }}
|
||||
git_sha: ${{ steps.version.outputs.git_sha }}
|
||||
platform_matrix: ${{ steps.version.outputs.platform_matrix }}
|
||||
@@ -40,16 +38,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
|
||||
ref: ${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Read release metadata
|
||||
id: version
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
ruby <<'RUBY' >> "$GITHUB_OUTPUT"
|
||||
require "json"
|
||||
@@ -90,6 +88,13 @@ jobs:
|
||||
fail("Release tag #{release_tag} does not match #{version_path} tag #{expected_tag}") unless release_tag == expected_tag
|
||||
fail("#{version_path}: version #{version} is not a valid semver") unless version.match?(SEMVER)
|
||||
|
||||
notes_path = "release/notes.md"
|
||||
fail("#{notes_path}: release notes are required") unless File.file?(notes_path)
|
||||
notes = File.readlines(notes_path)
|
||||
unless notes.first&.strip == "# #{release_tag}" && !notes.drop(1).join.strip.empty?
|
||||
fail("#{notes_path}: expected non-empty notes starting with '# #{release_tag}'")
|
||||
end
|
||||
|
||||
platforms = platforms_data["platforms"]
|
||||
fail("#{platforms_path}: platforms must be a non-empty array") unless platforms.is_a?(Array) && !platforms.empty?
|
||||
|
||||
@@ -104,7 +109,7 @@ jobs:
|
||||
require_string(platform, key, "#{platforms_path}: platforms[#{index}]")
|
||||
end
|
||||
archive = platform["archive"]
|
||||
fail("#{platforms_path}: platforms[#{index}].archive must be tar.gz or zip") unless ["tar.gz", "zip"].include?(archive)
|
||||
fail("#{platforms_path}: platforms[#{index}].archive must be tar.gz") unless archive == "tar.gz"
|
||||
platform_key = "#{platform["os"]}-#{platform["arch"]}"
|
||||
fail("#{platforms_path}: duplicate platform #{platform_key}") if seen_platforms[platform_key]
|
||||
seen_platforms[platform_key] = true
|
||||
@@ -146,6 +151,8 @@ jobs:
|
||||
|
||||
puts "tag=#{release_tag}"
|
||||
puts "version=#{version}"
|
||||
# Docker tags forbid "+", so semver build metadata maps to "-" in image tags.
|
||||
puts "docker_version=#{version.tr("+", "-")}"
|
||||
puts "build_time=#{Time.now.utc.iso8601}"
|
||||
puts "git_sha=#{git_sha.strip}"
|
||||
puts "platform_matrix=#{matrix}"
|
||||
@@ -174,7 +181,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
@@ -185,9 +192,10 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
key: cargo-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock') }}
|
||||
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}-${{ needs.release-info.outputs.git_sha }}
|
||||
restore-keys: |
|
||||
cargo-${{ runner.os }}-${{ matrix.target }}-
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
|
||||
|
||||
- name: Build binary
|
||||
shell: bash
|
||||
@@ -234,7 +242,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
@@ -250,9 +258,10 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
key: cargo-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock') }}
|
||||
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}-${{ needs.release-info.outputs.git_sha }}
|
||||
restore-keys: |
|
||||
cargo-${{ runner.os }}-${{ matrix.target }}-
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
|
||||
|
||||
- name: Build binary
|
||||
shell: bash
|
||||
@@ -286,7 +295,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
@@ -306,6 +315,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ruby <<'RUBY' > release-platforms.json
|
||||
@@ -321,7 +331,6 @@ jobs:
|
||||
import pathlib
|
||||
import shutil
|
||||
import tarfile
|
||||
import zipfile
|
||||
|
||||
APP_NAME = "codex-proxy-rs"
|
||||
|
||||
@@ -341,7 +350,18 @@ jobs:
|
||||
|
||||
shutil.rmtree(release_dir, ignore_errors=True)
|
||||
release_dir.mkdir(parents=True)
|
||||
archives = []
|
||||
config_template = pathlib.Path("deploy/config.example.yaml")
|
||||
shutil.copy2(config_template, release_dir / config_template.name)
|
||||
compose = pathlib.Path("deploy/compose.yaml").read_text()
|
||||
image = f"{os.environ['REGISTRY']}/{os.environ['IMAGE_NAME']}"
|
||||
default_image = "${CPR_IMAGE:-" + image + ":latest}"
|
||||
if compose.count(default_image) != 1:
|
||||
raise SystemExit("Expected exactly one default application image in deploy/compose.yaml")
|
||||
# 发布附件固定镜像版本,防止后续 pull 与随包配置模板脱节。
|
||||
versioned_image = "${CPR_IMAGE:-" + f"{image}:{os.environ['DOCKER_VERSION']}" + "}"
|
||||
compose = compose.replace(default_image, versioned_image)
|
||||
(release_dir / "compose.yaml").write_text(compose)
|
||||
assets = [release_dir / config_template.name, release_dir / "compose.yaml"]
|
||||
|
||||
for platform in platforms:
|
||||
platform_name = f"{platform['os']}-{platform['arch']}"
|
||||
@@ -357,32 +377,29 @@ jobs:
|
||||
|
||||
binary_dest = package_root / platform["binary"]
|
||||
shutil.copy2(binary_src, binary_dest)
|
||||
if platform["os"] != "windows":
|
||||
binary_dest.chmod(0o755)
|
||||
binary_dest.chmod(0o755)
|
||||
shutil.copytree(frontend_dir, web_dist, dirs_exist_ok=True)
|
||||
deploy_dir = package_root / "deploy"
|
||||
deploy_dir.mkdir()
|
||||
shutil.copy2(config_template, deploy_dir / config_template.name)
|
||||
|
||||
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
|
||||
archive_path = release_dir / archive_name
|
||||
if platform["archive"] == "tar.gz":
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
archive.add(package_root, arcname=APP_NAME)
|
||||
elif platform["archive"] == "zip":
|
||||
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for path in sorted(package_root.rglob("*")):
|
||||
if path.is_file():
|
||||
archive.write(path, path.relative_to(package_dir))
|
||||
else:
|
||||
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
|
||||
archives.append(archive_path)
|
||||
assets.append(archive_path)
|
||||
|
||||
checksum_path = release_dir / "checksums.txt"
|
||||
with checksum_path.open("w", encoding="utf-8") as file:
|
||||
for archive in archives:
|
||||
file.write(f"{sha256(archive)} {archive.name}\n")
|
||||
for asset in assets:
|
||||
file.write(f"{sha256(asset)} {asset.name}\n")
|
||||
PY
|
||||
|
||||
- name: Generate release asset provenance
|
||||
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: dist/release/*
|
||||
|
||||
@@ -406,13 +423,14 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
security-events: write
|
||||
env:
|
||||
TRIVY_QUIET: 'true'
|
||||
TRIVY_SKIP_VERSION_CHECK: 'true'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
@@ -455,10 +473,10 @@ jobs:
|
||||
PY
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -475,8 +493,11 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
CPR_PREBUILT_DIR=dist/docker
|
||||
cache-from: type=gha,scope=runtime-prebuilt-${{ matrix.os }}-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=runtime-prebuilt-${{ matrix.os }}-${{ matrix.arch }}
|
||||
no-cache-filters: |
|
||||
runtime-base
|
||||
runtime-prebuilt
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }}
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }},mode=min
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
labels: |
|
||||
@@ -486,19 +507,31 @@ jobs:
|
||||
org.opencontainers.image.source=https://github.com/zyycn/codex-proxy-rs
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ needs.release-info.outputs.git_sha }}-${{ matrix.arch }}
|
||||
|
||||
- name: Scan exact app image digest
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
env:
|
||||
TRIVY_PLATFORM: ${{ matrix.dockerPlatform }}
|
||||
- name: Verify exact app image digest
|
||||
uses: ./.github/actions/verify-container
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
||||
version: v0.72.0
|
||||
format: table
|
||||
severity: HIGH,CRITICAL
|
||||
vuln-type: os,library
|
||||
scanners: vuln
|
||||
ignore-unfixed: true
|
||||
exit-code: '1'
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
||||
platform: ${{ matrix.dockerPlatform }}
|
||||
artifact-name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.arch }}
|
||||
|
||||
- name: Record verified image digest
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
IMAGE_ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
|
||||
mkdir -p dist/digests
|
||||
printf '%s\n' "$IMAGE_DIGEST" > "dist/digests/linux-${IMAGE_ARCH}.txt"
|
||||
|
||||
- name: Upload verified image digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: image-digest-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/digests/*.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
needs: [release-info, package-assets, build-docker-image]
|
||||
@@ -512,7 +545,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
@@ -522,14 +555,21 @@ jobs:
|
||||
name: release-assets
|
||||
path: dist/release
|
||||
|
||||
- name: Download verified image digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: image-digest-*
|
||||
path: dist/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -539,7 +579,7 @@ jobs:
|
||||
id: manifest
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
||||
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
DOCKER_PLATFORMS: ${{ needs.release-info.outputs.docker_platforms }}
|
||||
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
|
||||
@@ -553,7 +593,12 @@ jobs:
|
||||
platform="${platform//[[:space:]]/}"
|
||||
[ -n "${platform}" ] || continue
|
||||
arch="${platform##*/}"
|
||||
sources+=("${image}:sha-${GIT_SHA}-${arch}")
|
||||
digest="$(cat "dist/digests/linux-${arch}.txt")"
|
||||
if [[ ! "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]; then
|
||||
echo "invalid verified image digest for ${platform}" >&2
|
||||
exit 1
|
||||
fi
|
||||
sources+=("${image}@${digest}")
|
||||
done
|
||||
|
||||
if [ "${#sources[@]}" -eq 0 ]; then
|
||||
@@ -562,7 +607,7 @@ jobs:
|
||||
fi
|
||||
|
||||
target_args=(
|
||||
-t "${image}:${VERSION}"
|
||||
-t "${image}:${DOCKER_VERSION}"
|
||||
-t "${image}:sha-${GIT_SHA}"
|
||||
)
|
||||
if [ "$PRERELEASE" != true ]; then
|
||||
@@ -570,7 +615,7 @@ jobs:
|
||||
fi
|
||||
docker buildx imagetools create "${target_args[@]}" "${sources[@]}"
|
||||
|
||||
inspect_output="$(docker buildx imagetools inspect "${image}:${VERSION}")"
|
||||
inspect_output="$(docker buildx imagetools inspect "${image}:${DOCKER_VERSION}")"
|
||||
platform_list="$(awk '$1 == "Platform:" { print $2 }' <<< "${inspect_output}")"
|
||||
for platform in "${platforms[@]}"; do
|
||||
if ! grep -Eq "^${platform}(/|$)" <<< "${platform_list}"; then
|
||||
@@ -580,7 +625,7 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
digest="$(docker buildx imagetools inspect "${image}:${VERSION}" --format '{{ .Manifest.Digest }}')"
|
||||
digest="$(docker buildx imagetools inspect "${image}:${DOCKER_VERSION}" --format '{{ .Manifest.Digest }}')"
|
||||
if [ -z "${digest}" ]; then
|
||||
echo "missing image digest"
|
||||
exit 1
|
||||
@@ -588,7 +633,7 @@ jobs:
|
||||
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate container provenance attestation
|
||||
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.manifest.outputs.digest }}
|
||||
@@ -598,7 +643,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
DIGEST: ${{ steps.manifest.outputs.digest }}
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
||||
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
|
||||
run: |
|
||||
@@ -609,7 +654,7 @@ jobs:
|
||||
fi
|
||||
|
||||
image="${REGISTRY}/${IMAGE_NAME}"
|
||||
tags=("${image}:${VERSION}" "${image}:sha-${GIT_SHA}")
|
||||
tags=("${image}:${DOCKER_VERSION}" "${image}:sha-${GIT_SHA}")
|
||||
if [ "$PRERELEASE" != true ]; then
|
||||
tags+=("${image}:latest")
|
||||
fi
|
||||
@@ -620,32 +665,49 @@ jobs:
|
||||
|
||||
cosign sign --yes "${images[@]}"
|
||||
|
||||
- name: Prepare release notes
|
||||
shell: bash
|
||||
env:
|
||||
INSTALLATION_NOTES: |
|
||||
## 📥 安装
|
||||
|
||||
**Docker 部署:**
|
||||
```bash
|
||||
# 从 GitHub 容器镜像仓库拉取
|
||||
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.docker_version }}
|
||||
```
|
||||
|
||||
请从本版本附件下载 `compose.yaml` 和 `config.example.yaml`。Compose 默认固定到本版本镜像;升级前请对比模板与已有配置。
|
||||
|
||||
**手动下载:**
|
||||
请从下方附件中下载适合当前平台的归档包。
|
||||
|
||||
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
|
||||
|
||||
## 📚 文档
|
||||
|
||||
- [GitHub 仓库](https://github.com/${{ github.repository }})
|
||||
- [部署指南](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md)
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
notes = "\n".join(Path("release/notes.md").read_text().splitlines()[1:]).strip()
|
||||
installation = os.environ["INSTALLATION_NOTES"].strip()
|
||||
output = Path(os.environ["RUNNER_TEMP"]) / "release-notes.md"
|
||||
output.write_text(f"{notes}\n\n{installation}\n")
|
||||
PY
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
with:
|
||||
tag_name: ${{ needs.release-info.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
previous_tag: ${{ needs.release-info.outputs.previous_tag }}
|
||||
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
|
||||
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
|
||||
body: |
|
||||
## 📥 Installation
|
||||
|
||||
**Docker:**
|
||||
```bash
|
||||
# GitHub Container Registry
|
||||
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.version }}
|
||||
```
|
||||
|
||||
**Manual download:**
|
||||
Download the appropriate archive for your platform from the assets below.
|
||||
|
||||
Release archives are covered by GitHub artifact attestations. Docker images are published with SBOM/provenance attestations and keyless cosign signatures.
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- [GitHub Repository](https://github.com/${{ github.repository }})
|
||||
- [Installation Guide](https://github.com/${{ github.repository }}/blob/main/deploy/README.md)
|
||||
body_path: ${{ runner.temp }}/release-notes.md
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
dist/release/*
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
|
||||
|
||||
- name: Cache Rust
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
|
||||
@@ -40,19 +40,28 @@ jobs:
|
||||
|
||||
- name: Run cargo audit
|
||||
working-directory: backend
|
||||
run: cargo audit
|
||||
shell: bash
|
||||
run: |
|
||||
# 临时接受此中危风险以保持 Codex 0.154.0 的 TLS 指纹;直接升级会新增 ML-DSA 签名算法。
|
||||
# 仅豁免本条公告,2026-09-21 UTC 起恢复阻断;兼容升级验证完成后应提前删除豁免。
|
||||
# https://github.com/rustls/rustls/security/advisories/GHSA-2mjx-qc3c-rqvc
|
||||
audit_advisory_args=()
|
||||
if [[ "$(date -u +%F)" < "2026-09-21" ]]; then
|
||||
audit_advisory_args+=(--ignore RUSTSEC-2026-0285)
|
||||
fi
|
||||
cargo audit "${audit_advisory_args[@]}"
|
||||
|
||||
frontend-security:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11.7.0
|
||||
|
||||
|
||||
+1
-1
@@ -33,8 +33,8 @@ Thumbs.db
|
||||
# Local tools and worktrees
|
||||
/.codegraph/
|
||||
/.codex-ws-audit/
|
||||
/.agents/
|
||||
/.reasonix/
|
||||
/.claude
|
||||
/.worktrees/
|
||||
/worktrees/
|
||||
/.cargo/
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 项目协作约定
|
||||
|
||||
开发和审查先阅读 [CONTRIBUTING.md](CONTRIBUTING.md),使用其中的协作流程和审查标准。
|
||||
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
|
||||
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则。
|
||||
|
||||
<!-- CODEGRAPH_START -->
|
||||
## CodeGraph
|
||||
|
||||
In repositories indexed by CodeGraph (a `.codegraph/` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code:
|
||||
|
||||
- **MCP tools** (when available): `codegraph_explore` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them. `codegraph_node` returns one symbol's source + callers, or reads a whole file with line numbers. If the tools are listed but deferred, load them by name via tool search.
|
||||
- **Shell** (always works): `codegraph explore "<symbol names or question>"` and `codegraph node <symbol-or-file>` print the same output.
|
||||
|
||||
If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is the user's decision.
|
||||
<!-- CODEGRAPH_END -->
|
||||
|
||||
## 工作方式
|
||||
|
||||
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题,以官方源码为首要参考确定或评估方案。
|
||||
- 先定位变更所属模块、同类实现和上下游调用关系,再选择最小合理改动。复用已有能力,也避免为尚不存在的需求增加抽象。
|
||||
- 代码注释使用中文,解释原因与边界;提交信息使用英文,沿用历史中的 Conventional Commits 格式。
|
||||
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
|
||||
- 审查请求默认只读。修复、提交、推送和合并按用户当前授权执行;“本地验证通过”不等于用户已经审阅批准。
|
||||
- 不读取或输出无关凭据,不把真实密钥、代理认证、账号令牌或请求转储放入提交、截图及审查报告。
|
||||
|
||||
## Code Review Rules
|
||||
|
||||
### 审查范围
|
||||
|
||||
- 确认目标分支与当前变更范围,结合调用方、被调用方及已有测试审查;结论对应实际审查的提交或工作区状态。
|
||||
- 按 CONTRIBUTING.md 的维度检查设计、正确性、安全、复杂度、性能、可读性、验证、界面和文档,深度与变更风险相称。
|
||||
- 重点核对现有架构和数据合同:共享业务事实是否重复解释,异步与并发操作是否破坏状态,权限和敏感数据是否越界,协议适配是否影响兼容性。
|
||||
- 修改既有特殊分支时先查场景和历史;放宽检查、修改测试或新增例外需要业务依据,不能仅以让检查通过为理由。
|
||||
|
||||
### 代码与界面一致性
|
||||
|
||||
- 对重复逻辑、冗余状态和多余抽象给出具体位置及影响;建议复用时指出已有实现,不能仅凭行数或语法相似判定质量。
|
||||
- UI 变更对照项目基础组件、主题和同类页面,检查交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
|
||||
- 核对截图、预览和实际交互证据;未检查的状态明确列为验证缺口,不推断视觉验收已经通过。
|
||||
|
||||
### 审查输出
|
||||
|
||||
- 用中文先回答原问题是否成立、解决方向是否合理,依据与判断标准见 [贡献与审查](CONTRIBUTING.md#审查标准),再报告实现问题和验证缺口。
|
||||
- 实现问题聚焦本次变更新引入或加重的、可定位的问题,包含文件位置、触发场景或规范依据、影响和最小修复方向。
|
||||
- 区分必须修复的问题、可选建议和验证缺口。个人偏好标为建议,不作为必须修复项;没有问题时不要凑数。
|
||||
- 严重程度按实际影响判断,不为绕过工具的优先级过滤而升级普通规范问题。说明审查和验证的实际覆盖范围,AI 未报告问题不等于维护者批准。
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
# 贡献与审查
|
||||
|
||||
贡献应解决明确的问题,并保持或改善项目的可维护性。提交者、自动检查和审查者使用同一套标准,让重复问题在提交和初审阶段得到处理。
|
||||
|
||||
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制。
|
||||
|
||||
## 交流与反馈
|
||||
|
||||
[讨论区](https://github.com/zyycn/codex-proxy-rs/discussions) 用于使用问答、想法讨论、实验反馈和经验分享。
|
||||
提问时按需补充版本、部署方式、已尝试的方法及脱敏信息;问题解决后,请在「使用问答」中标记有帮助的答案。
|
||||
|
||||
明确的 Bug、具体功能需求或协作规则建议使用 Issue,无需先开讨论。
|
||||
讨论形成可执行事项后,可以新建 Issue 并互相附链接;已有 Issue 的后续信息继续补充在原帖,避免重复记录。
|
||||
围绕问题友善交流,欢迎不同使用场景和方案观点。
|
||||
|
||||
### 提交 Issue
|
||||
|
||||
按场景选择 [接口问题反馈](.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](.github/ISSUE_TEMPLATE/general-bug-report.yml) 或 [功能建议](.github/ISSUE_TEMPLATE/feature-request.yml) 表单,按对应提示提供信息。故障反馈填写实际运行版本和中转链路;接口问题区分客户端显示与网关实际响应,页面问题附相关截图或录屏。
|
||||
|
||||
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 Issue,说明原因和建议方案。
|
||||
|
||||
## 问题与方案依据
|
||||
|
||||
开发、创建 PR 和审查都先核实待解决的问题、触发条件与预期行为,再确定或评估实现。区分问题发生版本、目标分支现状和本次变更后的行为,不把需求描述、PR 描述或第三方项目的做法直接当成事实。目标分支已修复的问题,应说明当前仍需补齐的差异,不能据此否定旧版本中的问题。
|
||||
|
||||
涉及上游协议、客户端行为或兼容性时,以对应版本的官方源码为首要资料。优先读取用户提供或本机已有的官方源码,核对仓库来源、版本或提交;本地资料不足时查官方仓库的对应版本源码,再结合官方测试、文档、发布说明和运行证据。第三方资料只作线索或补充。引用具体文件、符号或行号,对外引用优先使用固定提交的源码链接;源码不可得或版本不匹配时说明证据缺口,不把最新实现当成当前部署行为。无上游依赖的内部变更,以本项目代码、合同和运行证据为依据。
|
||||
|
||||
官方已有同一场景的处理方式时,优先沿用其协议语义、职责归属和解决路径,检查是否只需复用已有能力或正确传递官方合同。新增覆盖配置、补偿逻辑或采用第三方兼容方案,应说明官方路径为何不足;偏离官方方案需有本项目的实际约束和验证依据,不机械照搬源码,也不默认接受已有提案。
|
||||
|
||||
反馈 Issue 时区分观察到的症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。能说明问题的反馈可以先提交,不要求报告者先找到官方源码或证明根因;引用官方行为或提出实现方案时,按上述原则注明依据与缺口。
|
||||
|
||||
## PR 流程
|
||||
|
||||
1. **准备变更。** 按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改与自查。涉及架构、公开合同或较大交互变化时,先说明方案与取舍。
|
||||
2. **发起 PR。** 目标分支使用 `main`。说明问题成立的依据、触发场景、改动后的行为和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。界面改动提供截图、录屏或可复现的预览方式。一个 PR 围绕一个明确目标,必要重构说明关联原因。
|
||||
3. **自动检查与初审。** CI 执行适用的格式、静态分析、测试和构建检查;启用 AI 审查后,由它按本规范辅助检查语义、复用和一致性。提交者先处理明确问题,维护者重点审阅方案、风险与用户体验。
|
||||
4. **修改与合并。** 更新代码后重新执行受影响的检查,确认审查意见已处理,验证证据对应当前变更。维护者审阅批准后再合并;需要升级说明的变更同步相关文档。
|
||||
|
||||
AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下文中要求“审查 PR #编号”时,应按下列标准执行,无需再次列举要求。
|
||||
|
||||
### 项目技能
|
||||
|
||||
仓库技能位于 `.agents/skills/`,随代码共享。技能提供执行流程,协作与审查标准仍以本文和 `AGENTS.md` 为准。
|
||||
|
||||
| 技能 | 用途 | 调用示例 |
|
||||
| --- | --- | --- |
|
||||
| [dev-guide](.agents/skills/dev-guide/SKILL.md) | 项目开发与文档导航 | `$dev-guide 修复当前问题` |
|
||||
| [github-pr](.agents/skills/github-pr/SKILL.md) | 创建或审查 PR | `$github-pr 创建当前分支的 PR` / `$github-pr 审查 PR #编号` |
|
||||
| [github-issue](.agents/skills/github-issue/SKILL.md) | 整理或提交 Issue | `$github-issue 提交当前问题` |
|
||||
| [release](.agents/skills/release/SKILL.md) | 准备发布说明或正式发版 | `$release 整理本次发布说明` / `$release 发布 vX.Y.Z` |
|
||||
|
||||
审查请求默认在当前会话报告;明确要求创建 PR、提交 Issue 或正式发版时才执行对应远程操作。技能安装不等于启用 GitHub 自动审查。
|
||||
|
||||
### 规则文件保护
|
||||
|
||||
PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/agents-md-guard.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查。
|
||||
|
||||
源码变更遵守现有架构边界与迁移冻结规则,由对应 CI 检查执行;不额外设立整块业务目录的 PR 禁改清单。构建、测试和安全扫描复用现有工作流。
|
||||
|
||||
## 审查标准
|
||||
|
||||
按改动涉及的范围选择检查深度,无需为每个 PR 填写所有维度的报告。
|
||||
|
||||
结论先回答原问题是否成立(成立、部分成立、不成立或证据不足),说明触发条件和依据;再判断解决方向是否合理,交代适用的官方方案、本次实现与它的关系及必要调整,最后报告实现问题与验证缺口。原问题是否成立与 PR 实现是否正确分别判断。源码推断、实际复现和检查结果分别说明,CI 通过不能代替问题与方案的论证。
|
||||
|
||||
| 维度 | 检查重点 |
|
||||
| --- | --- |
|
||||
| 设计与架构 | 职责归属、依赖方向、公开接口是否合理;是否融入已有体系,有无绕过边界的旁路 |
|
||||
| 正确性与兼容性 | 正常、异常和边界输入;并发、取消、重试与副作用;是否改变既有 API、协议或数据合同 |
|
||||
| 安全与数据 | 权限、输入校验、敏感信息、状态隔离和持久化一致性;日志与诊断是否符合脱敏边界 |
|
||||
| 简洁性与复用 | 重复业务规则、冗余状态、无效代码和过早抽象;能否用更直接的实现解决当前问题 |
|
||||
| 性能与资源 | 实际数据规模下的查询、请求、内存与连接成本;资源释放、背压和异常时的生命周期 |
|
||||
| 可读性与风格 | 命名是否准确,控制流是否易懂,格式是否一致;注释解释约束与原因而非复述代码 |
|
||||
| 测试与验证 | 是否覆盖真实触发条件和结果,能否发现回归;证据是否对应本次变更,有无跳过或缺失项 |
|
||||
| 界面与交互 | 共用组件和主题、信息层级、状态反馈、响应式、键盘操作及可访问性;是否有实际页面证据 |
|
||||
| 文档与升级 | 行为、配置、API 和部署说明是否同步;数据迁移和兼容影响是否交代清楚 |
|
||||
|
||||
审查意见区分三类:
|
||||
|
||||
- **必须修复:** 有具体触发条件的缺陷、违反现有合同或明确项目规范的问题。说明位置、依据、影响和最小修复方向。
|
||||
- **建议:** 不影响合同或规范的替代实现、命名偏好和局部优化。个人偏好不能单独阻止合并,也不应强求与本次目标无关的重构。
|
||||
- **验证缺口:** 尚未检查的行为、状态或运行环境。说明缺少什么证据,由维护者评估是否需要补齐,不能把未验证当作已通过或已发现缺陷。
|
||||
|
||||
复杂度不按行数判断,复用不只看语法相似。建议合并重复逻辑时应指出共享的业务含义;建议保留特殊分支时应能说明它服务的场景。例外应有依据,不能仅为绕过检查而放宽规则。
|
||||
|
||||
## 项目约定
|
||||
|
||||
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
|
||||
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式。
|
||||
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层。
|
||||
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`。
|
||||
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项。
|
||||
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。
|
||||
|
||||
## 文档职责与更新条件
|
||||
|
||||
常规文档说明当前有效的用法、接口合同、模块职责和维护约束,不承担变更历史。
|
||||
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
|
||||
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
|
||||
|
||||
- 按 [开发指南的文档入口](.agents/skills/dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
|
||||
不按每次变更追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
|
||||
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
|
||||
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
|
||||
- 改动原因、前后对比、排查经过和本次验证结果放在 PR / Issue 或任务报告。版本变更与升级影响按维护者的发版安排维护
|
||||
`release/notes.md`,不为普通开发任务或 PR 自动追加发布说明。
|
||||
- 仍影响实际升级、恢复或兼容使用的限制与操作应保留,并注明适用条件;不能仅因涉及旧版就删除。
|
||||
审查文档改动时核对必要性、所属职责与重复内容,区分当前使用说明和仅解释此次修复的内容。
|
||||
|
||||
## 界面验证
|
||||
|
||||
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有组件为依据,在任务范围内改进布局、视觉与交互。
|
||||
|
||||
开发前查找同类页面及 `components/base` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
|
||||
|
||||
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
|
||||
|
||||
提交者在浏览器中验证受影响路径,提供前后截图、录屏或预览步骤,并标明实际覆盖的状态。按变更选择浅色/深色、窄窗口、空/加载/错误状态、大量数据及键盘操作;共用主题或基础组件变更按主题文档扩大验证范围。模拟数据与预览材料不包含真实凭据。
|
||||
|
||||
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收。
|
||||
|
||||
## 验证
|
||||
|
||||
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言。
|
||||
|
||||
| 范围 | 现有检查 | 仍需提供的证据 |
|
||||
| --- | --- | --- |
|
||||
| Rust | Rustfmt、严格 Clippy、后端测试、架构边界检查 | 相关失败场景和集成行为 |
|
||||
| 前端 | ESLint、类型检查与构建 | 页面一致性、交互和实际数据状态 |
|
||||
| 数据库迁移 | 冻结清单校验与 PR 基线比对 | 升级行为及兼容说明 |
|
||||
| 依赖安全 | Cargo Audit、pnpm Audit | 本次变更的权限、数据与信任边界 |
|
||||
| 工作流与容器 | Actionlint、相关容器检查 | 改动涉及的运行行为 |
|
||||
|
||||
前端从仓库根目录执行:
|
||||
|
||||
```bash
|
||||
pnpm --dir frontend format:check
|
||||
pnpm --dir frontend build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
`build` 已包含类型检查;不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)。
|
||||
|
||||
依赖 PostgreSQL / Redis 的测试使用 [专用测试环境](backend/migrations/README.md#本地测试库),未配置导致跳过时明确记录。CI 中已有对应服务和检查,见 [质量工作流](.github/workflows/_quality.yml) 与 [安全扫描](.github/workflows/security-scan.yml)。
|
||||
|
||||
集成变更应验证实际业务路径及结果,不能只凭健康检查或探测成功认定全链路正常。协议和网络改动按需核对认证、证书、代理、流式交付及客户端结果;记录影响复现的环境条件。缺少运行条件时说明缺口,不用模拟结果代替真实集成结论。
|
||||
|
||||
## 自动检查与合并条件
|
||||
|
||||
[CI](.github/workflows/ci.yml) 已按变更范围调用现有质量检查。新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试。
|
||||
|
||||
AI 自动审查需要在所用工具中单独启用。其支持的检查范围和优先级过滤以工具说明为准;即使能读取 `AGENTS.md`,也不能保证所有可维护性和视觉问题都会被报告。配置参考 [Codex GitHub 审查文档](https://learn.chatgpt.com/docs/third-party/github)。
|
||||
|
||||
要由 GitHub 强制执行合并条件,维护者需配置对应分支的必需状态检查、审阅批准和讨论解决要求,并按需要设置新提交后的重新审阅。配置前核对实际检查名称与触发范围,见 [GitHub 分支保护](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)。规则文档本身不会启用 AI 服务或更改分支设置。
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"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
|
||||
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
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
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
|
||||
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
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
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
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
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
|
||||
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,
|
||||
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
|
||||
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
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
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
|
||||
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
|
||||
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,
|
||||
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
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
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,
|
||||
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
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
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
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
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]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -5,180 +5,128 @@
|
||||
|
||||
# Codex Proxy RS
|
||||
|
||||
**基于 Rust 的多 Provider 透明 AI 网关**
|
||||
面向 Codex 的自托管多账号 AI 网关
|
||||
|
||||
[](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml)
|
||||
[](https://github.com/zyycn/codex-proxy-rs/releases)
|
||||
[](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
|
||||
[](https://opensource.org/license/mit)
|
||||
[](LICENSE)
|
||||
|
||||
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运维](#运维) · [部署](deploy/README.md) · [接口](docs/api.md) · [架构](docs/architecture.md)
|
||||
[快速预览](#快速预览) · [快速开始](#快速开始) · [客户端接入](#客户端接入) · [文档](#文档) · [社区](#社区) · [许可证](#许可证)
|
||||
|
||||
</div>
|
||||
|
||||
> [!NOTE]
|
||||
> 只支持 OpenAI Responses API,不提供 `/v1/chat/completions`。
|
||||
> 本项目提供 Responses API,不支持 `/v1/chat/completions`。接入前请确认客户端支持 Responses 协议。
|
||||
|
||||
## 能力
|
||||
## 快速预览
|
||||
|
||||
| 领域 | 实现 |
|
||||
| -------- | --------------------------------------------------------------------------- |
|
||||
| 协议 | OpenAI Responses JSON、SSE、WebSocket 与模型目录 |
|
||||
| Provider | 固定 OpenAI、xAI 两个 Provider;各自管理 OAuth、账号、额度与模型目录 |
|
||||
| 路由 | Client Key 绑定 Provider、全局模型映射、会话亲和与同 Provider 安全 fallback |
|
||||
| 透明边界 | OpenAI 保持 Responses 语义透明;xAI 的协议适配只驻留在 `providers/xai` |
|
||||
| 延续 | OpenAI native/replay continuation;xAI 使用客户端提交的完整历史 |
|
||||
| 管理 | Client Key、账号、模型目录、设置、观测、系统更新与 OAuth |
|
||||
| 计量 | 模型请求 Token、费用、延迟、账号与 Provider 归因 |
|
||||
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计。
|
||||
|
||||
| 登录信息 | 值 |
|
||||
| --- | --- |
|
||||
| 地址 | <https://codex-proxy-rs.ainz.cc> |
|
||||
| 登录身份 | 管理员 |
|
||||
| 账号 | `admin@cpr.local` |
|
||||
| 密码 | `039c18de2aeac46d23ead7766bb07bbbe3747233c66a128e` |
|
||||
|
||||
预览服务运行已发布版本,展示的账号、代理与使用记录均为模拟数据,每天北京时间 `00:00` 自动生成当天数据。
|
||||
这是公开共享的功能预览环境,不提供真实模型调用;请勿导入真实账号、密钥或其他敏感信息。
|
||||
|
||||
## 快速开始
|
||||
|
||||
需要 Docker Engine 与 Docker Compose Plugin。
|
||||
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
|
||||
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
|
||||
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
|
||||
|
||||
### 1. 准备
|
||||
### 一键安装
|
||||
|
||||
请确保当前用户能访问 Docker,并可通过 `sudo` 或 root 设置目录权限。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zyycn/codex-proxy-rs.git
|
||||
cd codex-proxy-rs
|
||||
|
||||
mkdir -p .runtime/data .runtime/logs
|
||||
install -d -m 0750 .runtime/postgres .runtime/redis
|
||||
cp deploy/config.example.yaml deploy/config.yaml
|
||||
sudo chown "$(id -u):10001" deploy/config.yaml
|
||||
chmod 0640 deploy/config.yaml
|
||||
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/install.sh -o install.sh && bash install.sh
|
||||
```
|
||||
|
||||
分别生成 PostgreSQL 与 Redis 密码:
|
||||
[安装脚本](deploy/install.sh) 默认安装到当前目录下的 `codex-proxy-rs/`,下载同一正式 Release 的部署文件,
|
||||
自动生成密码、设置目录权限并启动服务。完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
|
||||
[添加账号与客户端密钥](#添加账号与客户端密钥)。
|
||||
|
||||
可在执行 `bash install.sh` 时传入环境变量:
|
||||
|
||||
| 变量 | 用途 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `INSTALL_DIR` | 安装目录,建议使用绝对路径 | 当前目录下的 `codex-proxy-rs/` |
|
||||
| `CPR_RELEASE_TAG` | 指定发布标签 | 最新正式版本 |
|
||||
| `ADMIN_PASSWORD` | 管理员初始密码,至少 12 位,不能包含 `$`,不能使用常见弱口令 | 随机生成 |
|
||||
|
||||
例如,自定义安装目录:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 24
|
||||
openssl rand -hex 24
|
||||
INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
|
||||
```
|
||||
|
||||
把两个结果写入 `deploy/config.yaml`,并单独设置管理员初始密码:
|
||||
重复运行时请使用同一安装目录。检测到 `deploy/config.yaml` 后,脚本保留现有配置和部署文件,
|
||||
忽略传入的管理员密码,也不执行版本升级。
|
||||
|
||||
| 配置 | 约束 |
|
||||
| -------------------------- | ---------------------------- |
|
||||
| `store.database.password` | 48 位十六进制 |
|
||||
| `store.redis.password` | 48 位十六进制 |
|
||||
| `admin.default_password` | 至少 12 位,不能包含 `$` |
|
||||
### 手动安装
|
||||
|
||||
PostgreSQL、Redis 容器和应用通过同一份 `config.yaml` 使用这两个基础设施密码,不需要额外
|
||||
导出环境变量。管理员密码只在首次创建管理员时生效。
|
||||
自行下载部署文件、配置密码和启动服务的完整步骤见 [部署文档](deploy/README.md#手动安装)。
|
||||
|
||||
Linux 需要允许容器组写入运行目录:
|
||||
### 登录管理端
|
||||
|
||||
```bash
|
||||
sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs
|
||||
chmod 0770 .runtime/data .runtime/logs
|
||||
```
|
||||
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录。
|
||||
API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、趋势、请求日志、额度与健康时间线;不能访问管理员页面。
|
||||
|
||||
### 2. 启动
|
||||
默认地址只能在服务器本机访问。从其他设备使用时,需要配置
|
||||
[HTTPS 反向代理](deploy/README.md#公网访问)。
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/compose.yaml config --quiet
|
||||
docker compose -f deploy/compose.yaml pull
|
||||
docker compose -f deploy/compose.yaml up -d --no-build
|
||||
curl -i http://127.0.0.1:8080/healthz
|
||||
```
|
||||
### 添加账号与客户端密钥
|
||||
|
||||
`204 No Content` 表示应用、PostgreSQL 与 Redis 均可用。管理端地址:
|
||||
`http://127.0.0.1:8080`。
|
||||
|
||||
### 3. 初始化
|
||||
|
||||
1. 使用 `admin@cpr.local` 与初始密码登录。
|
||||
2. 在账号页选择对应 Provider:OpenAI 支持 OAuth、账号文件与 Agent Identity;xAI 支持 OAuth
|
||||
和兼容账号文件导入。
|
||||
3. 按需配置客户端模型到上游模型的全局映射;未命中映射的模型名原样透传。
|
||||
4. 创建 `sk_...` Client Key,绑定 `openai` 或 `xai`,并设置模型范围、速率与并发限制。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> xAI 使用 OAuth session,不支持把 xAI API Key 作为上游 credential。
|
||||
|
||||
> [!NOTE]
|
||||
> `admin.default_password` 只在首次创建管理员时生效。
|
||||
|
||||
完整部署、权限和密码轮换规则见 [deploy/README.md](deploy/README.md)。
|
||||
1. 在「账号」中添加账号,完成授权或导入。
|
||||
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
|
||||
3. 打开密钥的「使用密钥」,复制客户端配置。
|
||||
|
||||
## 客户端接入
|
||||
|
||||
| 配置 | 值 |
|
||||
| -------- | ---------------------------------------- |
|
||||
| Base URL | `http://127.0.0.1:8080/v1` |
|
||||
| API Key | 管理端创建的 `sk_...` |
|
||||
| 鉴权 | `Authorization: Bearer <client-api-key>` |
|
||||
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入。
|
||||
合并到客户端配置后重启 Codex。完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)。
|
||||
|
||||
**其他 Responses API 客户端**:填写以下信息。
|
||||
|
||||
| 配置 | 值 |
|
||||
| --- | --- |
|
||||
| Base URL | `http://127.0.0.1:8080/v1`;远程接入使用服务器的 HTTPS 地址 |
|
||||
| API Key | 管理端创建的客户端密钥 |
|
||||
|
||||
可用模型以该密钥查询到的模型列表为准:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8080/v1/models \
|
||||
-H 'Authorization: Bearer <client-api-key>'
|
||||
|
||||
curl http://127.0.0.1:8080/v1/responses \
|
||||
-H 'Authorization: Bearer <client-api-key>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "<model-id>",
|
||||
"input": "Reply with pong.",
|
||||
"stream": false
|
||||
}'
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>API 路由</summary>
|
||||
|
||||
| 路由 | 用途 |
|
||||
| ---------------------------------- | ------------------------------ |
|
||||
| `POST /v1/responses` | JSON 响应或 SSE Responses 流 |
|
||||
| `GET /v1/responses` | Responses WebSocket 升级 |
|
||||
| `POST /v1/responses/review` | review 子代理请求 |
|
||||
| `GET /v1/models` | 当前 Client Key 可用的模型列表 |
|
||||
| `GET /v1/models/catalog` | Codex 客户端展示用模型目录 |
|
||||
| `GET /v1/models/{model_id}/info` | Codex 客户端展示用模型详情 |
|
||||
| `GET /v1/models/{model_id}` | OpenAI 兼容模型详情 |
|
||||
|
||||
所有 `/v1/*` 路由都需要客户端 API Key。
|
||||
|
||||
</details>
|
||||
|
||||
完整的客户端与管理端路由、鉴权和 mutation 规则见 [接口文档](docs/api.md)。
|
||||
|
||||
## 运维
|
||||
|
||||
```bash
|
||||
# 升级
|
||||
docker compose -f deploy/compose.yaml pull codex-proxy-rs
|
||||
docker compose -f deploy/compose.yaml up -d --no-build
|
||||
|
||||
# 日志
|
||||
docker compose -f deploy/compose.yaml logs -f codex-proxy-rs
|
||||
|
||||
# 从源码构建
|
||||
docker compose -f deploy/compose.yaml build codex-proxy-rs
|
||||
docker compose -f deploy/compose.yaml up -d
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点和自更新状态。删除该目录会永久清除
|
||||
> 运行状态。
|
||||
|
||||
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket
|
||||
upgrade 与真实客户端 IP;不要暴露 PostgreSQL 或 Redis。
|
||||
|
||||
## 维护与发布
|
||||
|
||||
发布仓库版本必须从干净、已同步上游的发布分支运行:
|
||||
|
||||
```bash
|
||||
release/publish <version>
|
||||
```
|
||||
|
||||
脚本会更新 `release/version.yaml`、创建约定提交与带注释的 `v<version>` tag,并原子推送分支和 tag。
|
||||
不要手工修改版本后单独打 tag。
|
||||
|
||||
## 文档
|
||||
|
||||
- [部署](deploy/README.md)
|
||||
- [客户端与管理端接口](docs/api.md)
|
||||
- [架构与数据边界](docs/architecture.md)
|
||||
- [Release](https://github.com/zyycn/codex-proxy-rs/releases)
|
||||
- [容器镜像](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
|
||||
- [客户端接入与生图](deploy/README.md#客户端配置)
|
||||
- [部署、备份与恢复](deploy/README.md)
|
||||
- [API 参考](docs/api.md)
|
||||
- [模型定价与手动同步](docs/api.md#模型定价)
|
||||
- [系统架构](docs/architecture.md)
|
||||
- [管理端主题](docs/theme.md)
|
||||
- [数据库迁移](backend/migrations/README.md)
|
||||
- [贡献与审查](CONTRIBUTING.md)
|
||||
|
||||
## 社区
|
||||
|
||||
欢迎到 [Discussions 讨论区](https://github.com/zyycn/codex-proxy-rs/discussions)交流:
|
||||
[使用问答](https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答)、
|
||||
[想法讨论](https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论)、
|
||||
[实验反馈](https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈)与
|
||||
[经验分享](https://github.com/zyycn/codex-proxy-rs/discussions/categories/经验分享)。
|
||||
明确的 Bug 或功能需求请使用 [Issue 模板](https://github.com/zyycn/codex-proxy-rs/issues/new/choose),无需先发讨论。
|
||||
|
||||
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 [Apache License 2.0](LICENSE) 开源。
|
||||
|
||||
Generated
+1359
-474
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -15,7 +15,7 @@ resolver = "3"
|
||||
[workspace.package]
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
license = "Apache-2.0"
|
||||
publish = false
|
||||
rust-version = "1.97"
|
||||
|
||||
@@ -38,3 +38,7 @@ strip = "symbols"
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[patch.crates-io]
|
||||
# Upstream SOCKS IPv6 fix; remove once a release includes hyperium/hyper-util#302.
|
||||
hyper-util = { git = "https://github.com/hyperium/hyper-util", rev = "191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6" }
|
||||
|
||||
@@ -25,8 +25,10 @@ tokio = { version = "1.52.3", default-features = false, features = ["rt-multi-th
|
||||
|
||||
[dev-dependencies]
|
||||
config = { version = "0.15.23", default-features = false, features = ["yaml"] }
|
||||
syn = { version = "2.0.118", features = ["full"] }
|
||||
serde_json = "1"
|
||||
syn = { version = "3.0.3", features = ["full", "visit"] }
|
||||
tempfile = "3.27.0"
|
||||
tokio-rustls = { version = "=0.26.4", default-features = false }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -7,37 +7,44 @@ use serde::Deserialize;
|
||||
const CONFIG_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// 顶层配置只组合各包拥有的配置段,不解释任何业务字段。
|
||||
/// 各启动配置忽略未知字段,允许升级时保留旧配置;已知字段仍按类型和业务约束校验。
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GatewayConfig {
|
||||
schema_version: u32,
|
||||
host: HostConfig,
|
||||
store: gateway_store::StoreConfig,
|
||||
admin: gateway_admin::AdminConfig,
|
||||
#[serde(default)]
|
||||
client: gateway_admin::ClientConfig,
|
||||
api: gateway_api::ApiConfig,
|
||||
openai: provider_openai::OpenAiConfig,
|
||||
xai: provider_xai::XaiConfig,
|
||||
#[serde(default, rename = "services")]
|
||||
_compose_services: Option<serde::de::IgnoredAny>,
|
||||
}
|
||||
|
||||
impl LoadableConfig for GatewayConfig {
|
||||
const EXTERNAL_SECTIONS: &'static [&'static str] = &["services"];
|
||||
|
||||
fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> {
|
||||
if self.schema_version != CONFIG_SCHEMA_VERSION {
|
||||
return Err(ConfigError::InvalidField("schema_version"));
|
||||
}
|
||||
self.host.resolve_and_validate(source_dir)?;
|
||||
self.store
|
||||
self.api
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("api"))?;
|
||||
self.host
|
||||
.resolve_and_validate(source_dir, &self.api.asset_directory)?;
|
||||
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
|
||||
self.store
|
||||
.resolve_and_validate(&runtime_data_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("store"))?;
|
||||
self.admin
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("admin"))?;
|
||||
self.api
|
||||
self.client
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("api"))?;
|
||||
.map_err(|_| ConfigError::InvalidField("client"))?;
|
||||
self.openai
|
||||
.resolve_and_validate(source_dir)
|
||||
.resolve_and_validate(&runtime_data_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("openai"))?;
|
||||
self.xai
|
||||
.resolve_and_validate(source_dir)
|
||||
@@ -54,10 +61,10 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
host,
|
||||
store,
|
||||
admin,
|
||||
client,
|
||||
api,
|
||||
openai,
|
||||
xai,
|
||||
_compose_services: _,
|
||||
} = config;
|
||||
|
||||
let host = gateway_host::initialize(host).await?;
|
||||
@@ -72,19 +79,27 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
let providers = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
|
||||
let mut core = gateway_core::initialize(store.core_ports(), providers).await?;
|
||||
host.report_startup_ready("Core");
|
||||
let admin = gateway_admin::initialize(
|
||||
let mut admin = gateway_admin::initialize(
|
||||
admin,
|
||||
client,
|
||||
store.admin_ports(),
|
||||
vec![openai.admin_provider(), xai.admin_provider()],
|
||||
core.snapshot_control(),
|
||||
core.account_probe(),
|
||||
host.system_operations(),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
|
||||
providers: vec![openai.admin_provider(), xai.admin_provider()],
|
||||
snapshot: core.snapshot_control(),
|
||||
account_probe: core.account_probe(),
|
||||
proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
|
||||
client_distribution: host.client_distribution_resolver(),
|
||||
system: host.system_operations(),
|
||||
client_key_verifier: core.client_key_verifier(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
host.report_startup_ready("Admin");
|
||||
|
||||
let mut probes = store.health_probes();
|
||||
probes.extend(core.health_probes());
|
||||
probes.push(host.logging_health_probe());
|
||||
let api = gateway_api::initialize(
|
||||
api,
|
||||
core.execution_service(),
|
||||
@@ -99,6 +114,7 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
plan.extend(core.take_worker_contributions());
|
||||
plan.extend(openai.take_worker_contributions());
|
||||
plan.extend(xai.take_worker_contributions());
|
||||
plan.extend(admin.take_worker_contributions());
|
||||
host.start_workers(plan, store.worker_leader_lease())?;
|
||||
host.report_startup_ready("Workers");
|
||||
host.serve(api.router()).await?;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::{env, error::Error, io};
|
||||
|
||||
use provider_openai::ensure_rustls_provider;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
ensure_rustls_provider();
|
||||
let mut arguments = env::args_os().skip(1);
|
||||
match arguments.next().as_deref().and_then(|value| value.to_str()) {
|
||||
None | Some("serve") => {
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
//! docs/architecture.md 依赖 DAG 与生产源码纪律的 workspace 级机器校验。
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use syn::{Item, visit::Visit};
|
||||
|
||||
/// workspace 成员冻结清单;新增 crate 必须同步扩展本文件的依赖规则。
|
||||
pub(super) const WORKSPACE_MEMBERS: &[&str] = &[
|
||||
"apps/gateway",
|
||||
"crates/gateway-admin",
|
||||
"crates/gateway-api",
|
||||
"crates/gateway-core",
|
||||
"crates/gateway-host",
|
||||
"crates/gateway-protocol",
|
||||
"crates/gateway-store",
|
||||
"crates/providers/openai",
|
||||
"crates/providers/xai",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn workspace_member_list_matches_the_frozen_dag_scope() {
|
||||
let manifest =
|
||||
fs::read_to_string(backend_root().join("Cargo.toml")).expect("read workspace manifest");
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
assert!(
|
||||
manifest.contains(&format!("\"{member}\"")),
|
||||
"workspace must include {member}"
|
||||
);
|
||||
}
|
||||
let member_count = manifest
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| line.starts_with("\"apps/") || line.starts_with("\"crates/"))
|
||||
.count();
|
||||
assert_eq!(
|
||||
member_count,
|
||||
WORKSPACE_MEMBERS.len(),
|
||||
"new workspace members must extend the dependency DAG rules"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_core_depends_on_no_http_db_redis_or_provider_crate() {
|
||||
assert_no_dependency(
|
||||
"crates/gateway-core",
|
||||
&[
|
||||
"axum",
|
||||
"hyper",
|
||||
"reqwest",
|
||||
"sqlx",
|
||||
"redis",
|
||||
"gateway-host",
|
||||
"gateway-store",
|
||||
"provider-openai",
|
||||
"provider-xai",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_value_owners_do_not_depend_on_execution_or_routing() {
|
||||
let root = backend_root().join("crates/gateway-core/src");
|
||||
for relative in super::rust_files(&root) {
|
||||
let allowed: Option<&[&str]> = match relative.to_str().expect("source path") {
|
||||
"validation.rs" => Some(&[]),
|
||||
"identity.rs" => Some(&["validation"]),
|
||||
"upstream.rs" => Some(&["validation"]),
|
||||
"event.rs" => Some(&["metering", "operation", "upstream", "validation"]),
|
||||
"account/selection.rs" => Some(&["account", "concurrency", "identity", "validation"]),
|
||||
"concurrency.rs" => Some(&["error"]),
|
||||
"account/store.rs" => Some(&["account", "error", "identity", "validation"]),
|
||||
path if path.starts_with("policy/") => Some(&["account", "policy", "validation"]),
|
||||
path if path.starts_with("account/") => Some(&["account", "identity", "validation"]),
|
||||
_ => None,
|
||||
};
|
||||
let Some(allowed) = allowed else { continue };
|
||||
let source = fs::read_to_string(root.join(&relative)).expect("read core source");
|
||||
let syntax = syn::parse_file(&source).expect("parse core source");
|
||||
let mut references = CrateReferences::default();
|
||||
references.visit_file(&syntax);
|
||||
for dependency in references.0 {
|
||||
assert!(
|
||||
allowed.contains(&dependency.as_str()),
|
||||
"{} must not depend on crate::{dependency}",
|
||||
relative.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CrateReferences(BTreeSet<String>);
|
||||
|
||||
impl<'ast> Visit<'ast> for CrateReferences {
|
||||
fn visit_path(&mut self, path: &'ast syn::Path) {
|
||||
let mut segments = path.segments.iter();
|
||||
if segments
|
||||
.next()
|
||||
.is_some_and(|segment| segment.ident == "crate")
|
||||
&& let Some(owner) = segments.next()
|
||||
{
|
||||
self.0.insert(owner.ident.to_string());
|
||||
}
|
||||
syn::visit::visit_path(self, path);
|
||||
}
|
||||
|
||||
fn visit_item_use(&mut self, item: &'ast syn::ItemUse) {
|
||||
self.use_tree(&item.tree, false);
|
||||
}
|
||||
}
|
||||
|
||||
impl CrateReferences {
|
||||
fn use_tree(&mut self, tree: &syn::UseTree, crate_root: bool) {
|
||||
match tree {
|
||||
syn::UseTree::Path(path) if crate_root => {
|
||||
self.0.insert(path.ident.to_string());
|
||||
}
|
||||
syn::UseTree::Name(name) if crate_root => {
|
||||
self.0.insert(name.ident.to_string());
|
||||
}
|
||||
syn::UseTree::Rename(rename) if crate_root => {
|
||||
self.0.insert(rename.ident.to_string());
|
||||
}
|
||||
syn::UseTree::Path(path) => self.use_tree(&path.tree, path.ident == "crate"),
|
||||
syn::UseTree::Group(group) => {
|
||||
for item in &group.items {
|
||||
self.use_tree(item, crate_root);
|
||||
}
|
||||
}
|
||||
syn::UseTree::Glob(_) if crate_root => {
|
||||
self.0.insert("*".to_owned());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_protocol_has_no_workspace_dependencies() {
|
||||
for name in dependency_names("crates/gateway-protocol") {
|
||||
assert!(
|
||||
!name.starts_with("gateway-") && !name.starts_with("provider-"),
|
||||
"gateway-protocol must not depend on workspace crate `{name}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_crates_do_not_depend_on_each_other() {
|
||||
assert_no_dependency("crates/providers/openai", &["provider-xai"]);
|
||||
assert_no_dependency("crates/providers/xai", &["provider-openai"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_admin_stays_free_of_infrastructure_dependencies() {
|
||||
assert_no_dependency(
|
||||
"crates/gateway-admin",
|
||||
&["axum", "sqlx", "redis", "reqwest"],
|
||||
);
|
||||
}
|
||||
|
||||
/// workspace 包名到冻结成员路径的映射。
|
||||
const PACKAGE_TO_MEMBER: &[(&str, &str)] = &[
|
||||
("codex-proxy-rs", "apps/gateway"),
|
||||
("gateway-admin", "crates/gateway-admin"),
|
||||
("gateway-api", "crates/gateway-api"),
|
||||
("gateway-core", "crates/gateway-core"),
|
||||
("gateway-host", "crates/gateway-host"),
|
||||
("gateway-protocol", "crates/gateway-protocol"),
|
||||
("gateway-store", "crates/gateway-store"),
|
||||
("provider-openai", "crates/providers/openai"),
|
||||
("provider-xai", "crates/providers/xai"),
|
||||
];
|
||||
|
||||
/// Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计。
|
||||
const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
|
||||
("crates/gateway-api", &["admin", "auth", "openai"]),
|
||||
(
|
||||
"crates/gateway-host",
|
||||
&[
|
||||
"client_distribution",
|
||||
"config",
|
||||
"pricing",
|
||||
"proxy_probe",
|
||||
"serve",
|
||||
"system_update",
|
||||
"workers",
|
||||
],
|
||||
),
|
||||
("crates/gateway-store", &["backup", "postgres", "redis"]),
|
||||
(
|
||||
"crates/providers/openai",
|
||||
&["config", "credential", "transport"],
|
||||
),
|
||||
(
|
||||
"crates/providers/xai",
|
||||
&["config", "credential", "transport"],
|
||||
),
|
||||
];
|
||||
|
||||
/// 不对应单一生产模块、而是校验 crate/workspace 整体契约的根级测试场景。
|
||||
const ROOT_TEST_SCENARIOS: &[(&str, &[&str])] = &[
|
||||
("apps/gateway", &["architecture"]),
|
||||
("crates/gateway-api", &["architecture"]),
|
||||
];
|
||||
|
||||
/// 冻结的 workspace 内部运行时依赖边;新增/删除任何边都必须同步本表。
|
||||
const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
|
||||
("codex-proxy-rs", "gateway-admin"),
|
||||
("codex-proxy-rs", "gateway-api"),
|
||||
("codex-proxy-rs", "gateway-core"),
|
||||
("codex-proxy-rs", "gateway-host"),
|
||||
("codex-proxy-rs", "gateway-store"),
|
||||
("codex-proxy-rs", "provider-openai"),
|
||||
("codex-proxy-rs", "provider-xai"),
|
||||
("gateway-admin", "gateway-core"),
|
||||
("gateway-api", "gateway-admin"),
|
||||
("gateway-api", "gateway-core"),
|
||||
("gateway-api", "gateway-protocol"),
|
||||
("gateway-host", "gateway-admin"),
|
||||
("gateway-host", "gateway-core"),
|
||||
("gateway-store", "gateway-admin"),
|
||||
("gateway-store", "gateway-core"),
|
||||
("provider-openai", "gateway-admin"),
|
||||
("provider-openai", "gateway-core"),
|
||||
("provider-openai", "gateway-protocol"),
|
||||
("provider-xai", "gateway-admin"),
|
||||
("provider-xai", "gateway-core"),
|
||||
("provider-xai", "gateway-protocol"),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn workspace_internal_dependency_edges_match_frozen_dag() {
|
||||
let metadata = cargo_metadata_json();
|
||||
let actual = workspace_runtime_dependency_edges(&metadata);
|
||||
let mut expected: Vec<(String, String)> = ALLOWED_INTERNAL_EDGES
|
||||
.iter()
|
||||
.map(|(from, to)| ((*from).to_owned(), (*to).to_owned()))
|
||||
.collect();
|
||||
expected.sort();
|
||||
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"workspace internal dependency edges diverged from the frozen DAG"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_dependency_edges_exclude_dev_build_and_external_dependencies() {
|
||||
let metadata = serde_json::json!({
|
||||
"packages": [
|
||||
{
|
||||
"name": "gateway-api",
|
||||
"dependencies": [
|
||||
{ "name": "gateway-core", "kind": null },
|
||||
{ "name": "gateway-store", "kind": "dev" },
|
||||
{ "name": "gateway-host", "kind": "build" },
|
||||
{ "name": "serde", "kind": null }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "external-package",
|
||||
"dependencies": [{ "name": "gateway-core", "kind": null }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
workspace_runtime_dependency_edges(&metadata),
|
||||
vec![("gateway-api".to_owned(), "gateway-core".to_owned())]
|
||||
);
|
||||
}
|
||||
|
||||
fn workspace_runtime_dependency_edges(metadata: &serde_json::Value) -> Vec<(String, String)> {
|
||||
let packages = metadata["packages"]
|
||||
.as_array()
|
||||
.expect("cargo metadata packages");
|
||||
let internal_names: std::collections::BTreeSet<&str> =
|
||||
PACKAGE_TO_MEMBER.iter().map(|(name, _)| *name).collect();
|
||||
|
||||
let mut actual: Vec<(String, String)> = Vec::new();
|
||||
for package in packages {
|
||||
let name = package["name"].as_str().expect("package name");
|
||||
if !internal_names.contains(name) {
|
||||
continue;
|
||||
}
|
||||
for dependency in package["dependencies"].as_array().into_iter().flatten() {
|
||||
let dep_name = dependency["name"].as_str().expect("dependency name");
|
||||
if !internal_names.contains(dep_name) {
|
||||
continue;
|
||||
}
|
||||
if dependency.get("kind").expect("dependency kind").is_null() {
|
||||
actual.push((name.to_owned(), dep_name.to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actual.sort();
|
||||
actual
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_public_module_surfaces_match_allowlist() {
|
||||
for (member, allowed) in ADAPTER_PUBLIC_MODULES {
|
||||
let root = backend_root().join(member).join("src/lib.rs");
|
||||
let source = fs::read_to_string(&root).expect("read adapter crate root");
|
||||
let syntax = syn::parse_file(&source).expect("parse adapter crate root");
|
||||
let actual = syntax
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Item::Mod(module) if matches!(module.vis, syn::Visibility::Public(_)) => {
|
||||
Some(module.ident.to_string())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
let expected = allowed
|
||||
.iter()
|
||||
.map(|module| (*module).to_owned())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert_eq!(
|
||||
actual,
|
||||
expected,
|
||||
"{} public module surface diverged from its allowlist",
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_sources_do_not_host_tests() {
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
let src = backend_root().join(member).join("src");
|
||||
let sources = super::rust_files(&src);
|
||||
assert!(!sources.is_empty(), "{member} has no production sources");
|
||||
for relative in sources {
|
||||
let path = src.join(relative);
|
||||
let source = fs::read_to_string(&path).expect("read production source");
|
||||
assert!(
|
||||
!source.contains("#[cfg(test)]"),
|
||||
"{} hosts tests in production src",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_modules_follow_conventional_file_layout() {
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
let member_root = backend_root().join(member);
|
||||
assert_module_tree(&member_root.join("src"), &["lib.rs", "main.rs"]);
|
||||
|
||||
let tests = member_root.join("tests");
|
||||
if tests.is_dir() {
|
||||
assert_module_tree(&tests, &["main.rs"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ModuleLayout {
|
||||
File,
|
||||
Directory,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_tests_mirror_production_module_tree() {
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
let member_root = backend_root().join(member);
|
||||
let tests_root = member_root.join("tests");
|
||||
if !tests_root.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let production = module_layouts(&member_root.join("src"), &["lib.rs", "main.rs"]);
|
||||
let tests = module_layouts(&tests_root, &["main.rs"]);
|
||||
for (module, test_layout) in tests {
|
||||
if module == Path::new("support") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(production_layout) = production.get(&module) {
|
||||
assert_eq!(
|
||||
test_layout,
|
||||
*production_layout,
|
||||
"{} must mirror the production module layout for {}",
|
||||
tests_root.join(module.with_extension("rs")).display(),
|
||||
member_root.join("src").join(&module).display(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_production_owner = module
|
||||
.parent()
|
||||
.into_iter()
|
||||
.flat_map(Path::ancestors)
|
||||
.take_while(|ancestor| !ancestor.as_os_str().is_empty())
|
||||
.any(|ancestor| production.contains_key(ancestor));
|
||||
assert!(
|
||||
has_production_owner || root_test_scenario_allowed(member, &module),
|
||||
"{} has no mirrored production module owner",
|
||||
tests_root.join(module.with_extension("rs")).display(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn module_layouts(root: &Path, crate_roots: &[&str]) -> BTreeMap<PathBuf, ModuleLayout> {
|
||||
super::rust_files(root)
|
||||
.into_iter()
|
||||
.filter(|relative| {
|
||||
!crate_roots
|
||||
.iter()
|
||||
.any(|candidate| relative == Path::new(candidate))
|
||||
})
|
||||
.map(|relative| {
|
||||
if relative.file_name().and_then(|value| value.to_str()) == Some("mod.rs") {
|
||||
(
|
||||
relative.parent().expect("mod.rs parent").to_path_buf(),
|
||||
ModuleLayout::Directory,
|
||||
)
|
||||
} else {
|
||||
(relative.with_extension(""), ModuleLayout::File)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn root_test_scenario_allowed(member: &str, module: &Path) -> bool {
|
||||
let Some(module) = module.to_str() else {
|
||||
return false;
|
||||
};
|
||||
ROOT_TEST_SCENARIOS
|
||||
.iter()
|
||||
.any(|(candidate, scenarios)| *candidate == member && scenarios.contains(&module))
|
||||
}
|
||||
|
||||
fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
|
||||
let files = super::rust_files(root);
|
||||
for relative in &files {
|
||||
if crate_roots
|
||||
.iter()
|
||||
.any(|candidate| relative == Path::new(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_name = relative
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.expect("Rust source file name");
|
||||
let parent = relative.parent().expect("Rust source parent");
|
||||
let module_name = if file_name == "mod.rs" {
|
||||
parent
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.expect("mod.rs module name")
|
||||
} else {
|
||||
relative
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.expect("Rust source module name")
|
||||
};
|
||||
|
||||
if file_name != "mod.rs" {
|
||||
assert_ne!(
|
||||
parent.file_name().and_then(|value| value.to_str()),
|
||||
Some(module_name),
|
||||
"{} repeats its parent module name",
|
||||
root.join(relative).display()
|
||||
);
|
||||
let child_directory = relative.with_extension("");
|
||||
assert!(
|
||||
!files
|
||||
.iter()
|
||||
.any(|candidate| candidate.starts_with(&child_directory)),
|
||||
"{} mixes a leaf module with a same-name module directory",
|
||||
root.join(relative).display()
|
||||
);
|
||||
}
|
||||
|
||||
let declaration_parents = if parent.as_os_str().is_empty()
|
||||
|| (file_name == "mod.rs"
|
||||
&& parent
|
||||
.parent()
|
||||
.is_some_and(|ancestor| ancestor.as_os_str().is_empty()))
|
||||
{
|
||||
crate_roots
|
||||
.iter()
|
||||
.map(|candidate| root.join(candidate))
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
let declaration_directory = if file_name == "mod.rs" {
|
||||
parent.parent().expect("nested mod.rs parent")
|
||||
} else {
|
||||
parent
|
||||
};
|
||||
vec![root.join(declaration_directory).join("mod.rs")]
|
||||
};
|
||||
let declaration_count = declaration_parents
|
||||
.iter()
|
||||
.map(|path| external_module_declaration_count(path, module_name))
|
||||
.sum::<usize>();
|
||||
assert_eq!(
|
||||
declaration_count,
|
||||
1,
|
||||
"{} must be declared exactly once by its parent module",
|
||||
root.join(relative).display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn external_module_declaration_count(path: &Path, module_name: &str) -> usize {
|
||||
if !path.is_file() {
|
||||
return 0;
|
||||
}
|
||||
let source = fs::read_to_string(path).expect("read parent module source");
|
||||
let syntax = syn::parse_file(&source).expect("parse parent module source");
|
||||
syntax
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
matches!(
|
||||
item,
|
||||
Item::Mod(module)
|
||||
if module.content.is_none() && module.ident == module_name
|
||||
)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
pub(super) fn backend_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.expect("backend workspace root")
|
||||
}
|
||||
|
||||
fn cargo_metadata_json() -> serde_json::Value {
|
||||
let output = std::process::Command::new("cargo")
|
||||
.args(["metadata", "--format-version", "1", "--no-deps"])
|
||||
.current_dir(backend_root())
|
||||
.output()
|
||||
.expect("run cargo metadata");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"cargo metadata failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).expect("parse cargo metadata")
|
||||
}
|
||||
|
||||
/// 提取成员 `[dependencies]` 段内声明的依赖名;段落以下一个 `[` 表头结束。
|
||||
fn dependency_names(member: &str) -> Vec<String> {
|
||||
let manifest = fs::read_to_string(backend_root().join(member).join("Cargo.toml"))
|
||||
.expect("read member manifest");
|
||||
let mut in_dependencies = false;
|
||||
let mut names = Vec::new();
|
||||
for line in manifest.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('[') {
|
||||
in_dependencies = line == "[dependencies]";
|
||||
continue;
|
||||
}
|
||||
if !in_dependencies || line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((name, _)) = line.split_once('=') {
|
||||
names.push(name.trim().to_owned());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
fn assert_no_dependency(member: &str, forbidden: &[&str]) {
|
||||
for name in dependency_names(member) {
|
||||
assert!(
|
||||
!forbidden.contains(&name.as_str()),
|
||||
"{member} must not depend on `{name}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,151 @@ const REDIS_PASSWORD: &str = "222222222222222222222222222222222222222222222222";
|
||||
const ADMIN_PASSWORD: &str = "test-admin-password";
|
||||
const TOPOLOGY_CHILD_ENV: &str = "CPR_TEST_TOPOLOGY_CHILD";
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
|
||||
use gateway_admin::ports::proxy::ProxyProbe;
|
||||
use gateway_core::account::OutboundProxy;
|
||||
use gateway_host::proxy_probe::HttpProxyProbe;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
|
||||
use tokio_rustls::{
|
||||
TlsAcceptor,
|
||||
rustls::{
|
||||
ServerConfig,
|
||||
pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _},
|
||||
},
|
||||
};
|
||||
|
||||
const CHILD_ENV: &str = "CPR_TEST_PROXY_TLS_DIRECTORY";
|
||||
let Ok(directory) = std::env::var(CHILD_ENV) else {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
generate_proxy_test_certificates(directory.path());
|
||||
// 使用子进程隔离环境变量,避免并行测试读取到临时 CA 配置。
|
||||
for ca_env in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
|
||||
let mut child = Command::new(std::env::current_exe().unwrap());
|
||||
child
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::proxy_probe_should_use_provider_custom_ca_for_https_proxies",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ENV, directory.path())
|
||||
.env_remove("CODEX_CA_CERTIFICATE")
|
||||
.env_remove("SSL_CERT_FILE")
|
||||
.env(ca_env, directory.path().join("ca.pem"));
|
||||
if ca_env == "CODEX_CA_CERTIFICATE" {
|
||||
child.env(
|
||||
"SSL_CERT_FILE",
|
||||
directory.path().join("missing-fallback.pem"),
|
||||
);
|
||||
}
|
||||
let output = child.output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{ca_env}: {}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
provider_openai::ensure_rustls_provider();
|
||||
let directory = std::path::Path::new(&directory);
|
||||
let certificate = CertificateDer::from_pem_file(directory.join("server.pem")).unwrap();
|
||||
let key = PrivateKeyDer::from_pem_file(directory.join("server.key")).unwrap();
|
||||
let acceptor = TlsAcceptor::from(Arc::new(
|
||||
ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![certificate], key)
|
||||
.unwrap(),
|
||||
));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let proxy =
|
||||
OutboundProxy::parse(&format!("https://{}", listener.local_addr().unwrap())).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
let mut stream = acceptor.accept(socket).await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
while !request.ends_with(b"\r\n\r\n") {
|
||||
assert!(request.len() < 8192);
|
||||
request.push(stream.read_u8().await.unwrap());
|
||||
}
|
||||
assert!(request.starts_with(b"GET http://unresolvable.invalid/ip "));
|
||||
let body = "{\"ip\":\"203.0.113.8\"}";
|
||||
stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
|
||||
stream.shutdown().await.unwrap();
|
||||
});
|
||||
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
|
||||
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
|
||||
let result = probe.test(&proxy).await;
|
||||
assert!(result.success, "{}", result.message);
|
||||
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
|
||||
tokio::time::timeout(Duration::from_secs(5), server)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn generate_proxy_test_certificates(directory: &std::path::Path) {
|
||||
let openssl = |args: &[&str]| {
|
||||
let output = Command::new("openssl")
|
||||
.args(args)
|
||||
.current_dir(directory)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
};
|
||||
openssl(&[
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
"ca.key",
|
||||
"-out",
|
||||
"ca.pem",
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=Proxy Test CA",
|
||||
]);
|
||||
openssl(&[
|
||||
"req",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
"server.key",
|
||||
"-out",
|
||||
"server.csr",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
]);
|
||||
fs::write(directory.join("extensions"), "subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n").unwrap();
|
||||
openssl(&[
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
"server.csr",
|
||||
"-CA",
|
||||
"ca.pem",
|
||||
"-CAkey",
|
||||
"ca.key",
|
||||
"-CAcreateserial",
|
||||
"-out",
|
||||
"server.pem",
|
||||
"-days",
|
||||
"1",
|
||||
"-extfile",
|
||||
"extensions",
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_load_complete_terminal_example() {
|
||||
parse_config(&valid_config()).expect("terminal config example");
|
||||
@@ -18,10 +163,62 @@ fn config_loader_should_load_complete_terminal_example() {
|
||||
fn config_loader_should_resolve_paths_relative_to_config_file() {
|
||||
let (config, _directory) = parse_config(&valid_config()).expect("resolved config");
|
||||
let debug = format!("{config:?}");
|
||||
assert!(debug.contains(".runtime/data"));
|
||||
assert!(debug.contains(".runtime/logs"));
|
||||
assert!(debug.contains("frontend/dist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_share_resolved_assets_with_system_update() {
|
||||
const CHILD_ENV: &str = "CPR_TEST_UPDATE_ASSETS_CHILD";
|
||||
let Ok(case) = std::env::var(CHILD_ENV) else {
|
||||
// 使用子进程覆盖无环境变量、Docker 路径和相对路径,避免污染并行测试。
|
||||
for (case, web_dist) in [
|
||||
("binary", None),
|
||||
("docker", Some("/app/web/dist")),
|
||||
("relative", Some("../custom/dist")),
|
||||
] {
|
||||
let mut child = Command::new(std::env::current_exe().expect("test executable"));
|
||||
child
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::config_loader_should_share_resolved_assets_with_system_update",
|
||||
])
|
||||
.env(CHILD_ENV, case)
|
||||
.env_remove("CPR_WEB_DIST_DIR");
|
||||
if let Some(web_dist) = web_dist {
|
||||
child.env("CPR_WEB_DIST_DIR", web_dist);
|
||||
}
|
||||
let output = child.output().expect("isolated configuration test");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{case}: {}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let mut document = valid_config_document();
|
||||
document["api"]["asset_directory"] = serde_json::json!("../web/dist");
|
||||
let (config, directory) = parse_config(&document.to_string()).expect("binary configuration");
|
||||
let assets = match case.as_str() {
|
||||
"docker" => std::path::PathBuf::from("/app/web/dist"),
|
||||
"relative" => directory.path().join("deploy/../custom/dist"),
|
||||
_ => directory.path().join("deploy/../web/dist"),
|
||||
};
|
||||
let debug = format!("{config:?}");
|
||||
assert!(debug.contains(&format!("asset_directory: {assets:?}")));
|
||||
assert!(debug.contains(&format!("web_dist_dir: Some({assets:?})")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_missing_runtime_data_dir() {
|
||||
let config = valid_config().replace(" runtime_data_dir: '../.runtime/data'\n", "");
|
||||
|
||||
assert!(parse_config(&config).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_inject_connection_passwords_into_urls() {
|
||||
parse_config(&valid_config()).expect("Store validates password injection into both URLs");
|
||||
@@ -75,16 +272,107 @@ fn bootstrap_config_debug_should_redact_all_passwords() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_unknown_fields() {
|
||||
assert_rejected(format!(
|
||||
"{}\nunknown_terminal_field: true\n",
|
||||
valid_config()
|
||||
));
|
||||
fn config_loader_should_ignore_unknown_fields_in_configuration_sections() {
|
||||
let mut document = valid_config_document();
|
||||
document["host"]["system_update"] = serde_json::json!({});
|
||||
document["store"]["pool"] = serde_json::json!({});
|
||||
for path in [
|
||||
"",
|
||||
"/host",
|
||||
"/host/listen",
|
||||
"/host/logging",
|
||||
"/host/logging/file",
|
||||
"/host/system_update",
|
||||
"/store",
|
||||
"/store/database",
|
||||
"/store/redis",
|
||||
"/store/pool",
|
||||
"/admin",
|
||||
"/client",
|
||||
"/api",
|
||||
"/openai",
|
||||
"/openai/wire_profile",
|
||||
"/xai",
|
||||
"/xai/wire_profile",
|
||||
] {
|
||||
let mut extended = document.clone();
|
||||
extended.pointer_mut(path).expect("configuration section")["unused_setting"] =
|
||||
serde_json::json!({"nested": [true, null, "ignored"]});
|
||||
parse_config(&extended.to_string())
|
||||
.unwrap_or_else(|error| panic!("unknown field in {path}: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_removed_tls_section() {
|
||||
assert_rejected(valid_config().replace("openai:\n", "openai:\n tls: {}\n"));
|
||||
fn config_loader_should_ignore_removed_tls_and_fingerprint_sections() {
|
||||
let mut document = valid_config_document();
|
||||
document["openai"]["tls"] = serde_json::json!({});
|
||||
document["openai"]["fingerprint"] = serde_json::json!({"browser": "removed"});
|
||||
parse_config(&document.to_string()).expect("removed provider settings are ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_known_fields_alongside_unknown_fields() {
|
||||
for port in [serde_json::json!(0), serde_json::json!("not-a-port")] {
|
||||
let mut document = valid_config_document();
|
||||
document["host"]["listen"]["unused_setting"] = serde_json::json!(true);
|
||||
document["host"]["listen"]["port"] = port;
|
||||
assert_rejected(document.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
const CHILD_ENV: &str = "CPR_TEST_CONFIG_DIAGNOSTICS_CHILD";
|
||||
const UNUSED_SECRET: &str = "unused-value-must-not-appear-in-diagnostics";
|
||||
if std::env::var_os(CHILD_ENV).is_some() {
|
||||
gateway_host::load_config::<GatewayConfig>().expect("startup configuration");
|
||||
return;
|
||||
}
|
||||
for case in ["normal", "unused", "missing"] {
|
||||
let mut document = valid_config_document();
|
||||
if case == "unused" {
|
||||
document["openai"]["wire_profile"]["location"] = serde_json::json!(UNUSED_SECRET);
|
||||
} else if case == "missing" {
|
||||
document["host"]["listen"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("port");
|
||||
}
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(directory.path().join("deploy")).unwrap();
|
||||
fs::write(
|
||||
directory.path().join("deploy/config.yaml"),
|
||||
document.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let output = Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::config_loader_should_report_startup_configuration_diagnostics",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ENV, "1")
|
||||
.current_dir(directory.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(
|
||||
output.status.success(),
|
||||
case != "missing",
|
||||
"{case}: {stderr}"
|
||||
);
|
||||
match case {
|
||||
"unused" => assert!(
|
||||
stderr.contains("openai.wire_profile.location") && stderr.contains("已忽略"),
|
||||
"{stderr}"
|
||||
),
|
||||
"missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"),
|
||||
_ => assert!(!stderr.contains("警告"), "{stderr}"),
|
||||
}
|
||||
assert!(!stderr.contains(UNUSED_SECRET), "{stderr}");
|
||||
assert!(!stderr.contains("services"), "{stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -126,16 +414,43 @@ fn config_loader_should_reject_admin_password_with_compose_interpolation() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_removed_fingerprint_section() {
|
||||
assert_rejected(valid_config().replace(
|
||||
"openai:\n",
|
||||
"openai:\n fingerprint:\n browser: removed\n",
|
||||
));
|
||||
fn config_loader_should_reject_zero_client_session_ttl() {
|
||||
let mut config = valid_config_document();
|
||||
*config
|
||||
.pointer_mut("/client/session_ttl_minutes")
|
||||
.expect("example client session TTL") = serde_json::json!(0);
|
||||
assert_rejected(config.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_codex_core_version() {
|
||||
assert_rejected(valid_config().replace("codex_version: '0.145.0'", "codex_version: 'latest'"));
|
||||
fn config_loader_should_default_missing_client_section() {
|
||||
let mut config = valid_config_document();
|
||||
config
|
||||
.as_object_mut()
|
||||
.expect("example config mapping")
|
||||
.remove("client")
|
||||
.expect("example client section");
|
||||
parse_config(&config.to_string()).expect("client defaults when the section is omitted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_missing_client_session_ttl() {
|
||||
let mut config = valid_config_document();
|
||||
config["client"]
|
||||
.as_object_mut()
|
||||
.expect("example client mapping")
|
||||
.remove("session_ttl_minutes")
|
||||
.expect("example client session TTL");
|
||||
assert_rejected(config.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_codex_cli_version() {
|
||||
assert_rejected(valid_config().replacen(
|
||||
" residency: us",
|
||||
" residency: us\n codex_version: 'latest'",
|
||||
1,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -154,7 +469,30 @@ fn config_loader_should_reject_zero_server_port() {
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_desktop_profile_fields() {
|
||||
assert_rejected(valid_config().replace("desktop_build: '5828'", "desktop_build: 'build'"));
|
||||
assert_rejected(valid_config().replacen(
|
||||
" residency: us",
|
||||
" residency: us\n desktop_build: 'build'",
|
||||
1,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_ignore_removed_request_location_setting() {
|
||||
let original = valid_config();
|
||||
parse_config(&original).expect("location belongs to runtime settings");
|
||||
for value in [
|
||||
"null",
|
||||
"{}",
|
||||
"{ country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }",
|
||||
] {
|
||||
let legacy = original.replacen(
|
||||
" residency: us",
|
||||
&format!(" residency: us\n location: {value}"),
|
||||
1,
|
||||
);
|
||||
assert_ne!(legacy, original);
|
||||
parse_config(&legacy).expect("removed request location does not prevent startup");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_rejected(config: String) {
|
||||
@@ -179,6 +517,18 @@ fn valid_config() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn valid_config_document() -> serde_json::Value {
|
||||
// 按字段修改样例,避免注释或排版变化让测试输入悄悄失效;JSON 仍可由 YAML 文件入口加载。
|
||||
config::Config::builder()
|
||||
.add_source(config::File::from_str(
|
||||
&valid_config(),
|
||||
config::FileFormat::Yaml,
|
||||
))
|
||||
.build()
|
||||
.and_then(config::Config::try_deserialize)
|
||||
.expect("example config document")
|
||||
}
|
||||
|
||||
fn parse_config(config: &str) -> Result<(GatewayConfig, tempfile::TempDir), String> {
|
||||
let directory = tempfile::tempdir().map_err(|error| error.to_string())?;
|
||||
let deploy = directory.path().join("deploy");
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
mod architecture;
|
||||
mod bootstrap;
|
||||
|
||||
// 组合根是否混入业务策略由代码审查判断,行数和标识符/注释关键词不能证明职责越界。
|
||||
// 依赖 DAG、公开模块面和模块镜像继续由 architecture 检查,源码纪律由下方测试维护。
|
||||
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
fs,
|
||||
@@ -21,7 +25,11 @@ fn app_tree_matches_frozen_terminal_manifest() {
|
||||
);
|
||||
assert_eq!(
|
||||
rust_files(&root.join("tests")),
|
||||
BTreeSet::from([PathBuf::from("bootstrap.rs"), PathBuf::from("main.rs")]),
|
||||
BTreeSet::from([
|
||||
PathBuf::from("architecture.rs"),
|
||||
PathBuf::from("bootstrap.rs"),
|
||||
PathBuf::from("main.rs"),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,58 +46,38 @@ fn cargo_library_root_is_conventional_lib() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_files_have_no_hidden_modules_or_test_hooks() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
for relative in ["bootstrap.rs", "lib.rs", "main.rs"] {
|
||||
let path = root.join(relative);
|
||||
let source = fs::read_to_string(&path).expect("read production source");
|
||||
assert!(
|
||||
!source.contains("include!("),
|
||||
"{} uses include!",
|
||||
path.display()
|
||||
);
|
||||
let syntax = syn::parse_file(&source).expect("parse production source");
|
||||
for item in &syntax.items {
|
||||
if let Item::Mod(module) = item {
|
||||
assert!(
|
||||
module.content.is_none(),
|
||||
"{} has an inline module",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
for attribute in item_attrs(item) {
|
||||
assert!(
|
||||
!is_path_or_test_cfg(attribute),
|
||||
"{} has a production test/path hook",
|
||||
path.display(),
|
||||
);
|
||||
fn workspace_production_files_have_no_hidden_modules_or_test_hooks() {
|
||||
for member in architecture::WORKSPACE_MEMBERS {
|
||||
let root = architecture::backend_root().join(member).join("src");
|
||||
for relative in rust_files(&root) {
|
||||
let path = root.join(relative);
|
||||
let source = fs::read_to_string(&path).expect("read production source");
|
||||
assert!(
|
||||
!source.contains("include!("),
|
||||
"{} uses include!",
|
||||
path.display()
|
||||
);
|
||||
let syntax = syn::parse_file(&source).expect("parse production source");
|
||||
for item in &syntax.items {
|
||||
if let Item::Mod(module) = item {
|
||||
assert!(
|
||||
module.content.is_none(),
|
||||
"{} has an inline module",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
for attribute in item_attrs(item) {
|
||||
assert!(
|
||||
!is_path_or_test_cfg(attribute),
|
||||
"{} has a production test/path hook",
|
||||
path.display(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_owns_only_bundle_wiring() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let bootstrap = fs::read_to_string(root.join("bootstrap.rs")).expect("read bootstrap");
|
||||
for forbidden in [
|
||||
"sqlx::",
|
||||
"redis::",
|
||||
"Repository",
|
||||
"impl Provider",
|
||||
"impl ExecutionService",
|
||||
"tokio::spawn",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
] {
|
||||
assert!(
|
||||
!bootstrap.contains(forbidden),
|
||||
"bootstrap owns `{forbidden}`"
|
||||
);
|
||||
}
|
||||
assert!(bootstrap.lines().count() <= 300);
|
||||
}
|
||||
|
||||
fn rust_files(root: &Path) -> BTreeSet<PathBuf> {
|
||||
let mut result = BTreeSet::new();
|
||||
collect_rust_files(root, root, &mut result);
|
||||
|
||||
@@ -12,22 +12,31 @@ name = "main"
|
||||
path = "tests/main.rs"
|
||||
|
||||
[dependencies]
|
||||
argon2 = "0.5.3"
|
||||
argon2 = "0.6.0"
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
base64 = "0.23.0"
|
||||
bytes = "1.11.1"
|
||||
chrono = "0.4.45"
|
||||
futures = "0.3.32"
|
||||
gateway-core = { path = "../gateway-core" }
|
||||
heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
rand_core = { version = "0.6.4", features = ["getrandom"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
secrecy = { version = "0.10.3", features = ["serde"] }
|
||||
sha2 = "0.11"
|
||||
subtle = "2.6.1"
|
||||
thiserror = "2.0.18"
|
||||
tracing = "0.1.44"
|
||||
uuid = { version = "1.23.3", features = ["v7"] }
|
||||
cron = "0.17"
|
||||
chrono-tz = "0.10"
|
||||
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt"] }
|
||||
tempfile = "3"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "test-util"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! 备份纯策略与 Worker 任务。
|
||||
|
||||
pub mod policy;
|
||||
pub mod task;
|
||||
@@ -0,0 +1,161 @@
|
||||
//! 备份纯策略:Cron 推进与保留候选。
|
||||
//!
|
||||
//! 本模块不持有任何端口或对象,是 `BackupService` 与 `BackupTask` 共用的纯函数集。
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, Duration, TimeZone as _, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use cron::Schedule;
|
||||
|
||||
use crate::model::backup::{BackupError, BackupRecord, BackupStatus, code};
|
||||
|
||||
/// 向历史回溯的最大范围(分钟)。覆盖 Feb 29 + 特定星期等罕见组合的最长间隔。
|
||||
const MAX_BACKDATE_MINUTES: i64 = 40 * 366 * 24 * 60;
|
||||
|
||||
/// 已解析并校验的备份计划。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackupSchedule {
|
||||
schedule: Schedule,
|
||||
timezone: Tz,
|
||||
}
|
||||
|
||||
impl BackupSchedule {
|
||||
/// 解析并校验 5 段 Cron 与 IANA 时区。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Cron 非法或时区不是 IANA 名称时返回 [`BackupError`]。
|
||||
pub fn parse(cron_expression: &str, schedule_timezone: &str) -> Result<Self, BackupError> {
|
||||
let expression = format!("0 {cron_expression}");
|
||||
let schedule = Schedule::from_str(&expression).map_err(|_| {
|
||||
BackupError::new(code::INVALID_CRON, "Cron 表达式必须是 5 段格式".to_owned())
|
||||
})?;
|
||||
let timezone = schedule_timezone.parse::<Tz>().map_err(|_| {
|
||||
BackupError::new(code::INVALID_TIMEZONE, "时区必须是 IANA 名称".to_owned())
|
||||
})?;
|
||||
Ok(Self { schedule, timezone })
|
||||
}
|
||||
|
||||
/// 返回最接近 `at`(含 `at` 所在分钟)的最近一次触发时间。
|
||||
///
|
||||
/// 若过去 `MAX_BACKDATE_MINUTES` 内没有任何触发(极罕见的稀疏计划或超长停机),
|
||||
/// 返回 `None`,调用方不应为历史点补任务。
|
||||
#[must_use]
|
||||
pub fn last_firing_at_or_before(&self, at: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
||||
let high_min = at.timestamp().div_euclid(60);
|
||||
let low_min = high_min.saturating_sub(MAX_BACKDATE_MINUTES);
|
||||
|
||||
// 检查窗口内是否存在触发;不存在则直接返回 None。
|
||||
let low_instant = instant(low_min, self.timezone)?;
|
||||
if self
|
||||
.schedule
|
||||
.after(&low_instant)
|
||||
.next()
|
||||
.is_none_or(|firing| firing.timestamp() > high_min * 60 + 59)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// 二分查找最小的 u(分钟)使 after(u) > high;prev = after(u - 1 分钟)。
|
||||
let mut lo = low_min;
|
||||
let mut hi = high_min;
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if self
|
||||
.firing_after(mid)
|
||||
.is_some_and(|firing| firing > high_min)
|
||||
{
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
self.firing_after(lo - 1)
|
||||
.filter(|&firing| firing <= high_min)
|
||||
.and_then(|firing| Utc.timestamp_opt(firing * 60, 0).single())
|
||||
}
|
||||
|
||||
/// 返回第一个严格晚于 `at` 的触发时间。
|
||||
#[must_use]
|
||||
pub fn next_after(&self, at: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
||||
let local = at.with_timezone(&self.timezone);
|
||||
self.schedule
|
||||
.after(&local)
|
||||
.next()
|
||||
.map(|firing| firing.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
/// `after(u 分钟)` 返回的第一个触发时间(分钟索引),用于二分搜索。
|
||||
fn firing_after(&self, minute_index: i64) -> Option<i64> {
|
||||
let instant = instant(minute_index, self.timezone)?;
|
||||
self.schedule
|
||||
.after(&instant)
|
||||
.next()
|
||||
.map(|firing| firing.timestamp().div_euclid(60))
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造指定分钟索引(UTC epoch 分钟)在目标时区下的 `DateTime`。
|
||||
fn instant(minute_index: i64, timezone: Tz) -> Option<DateTime<Tz>> {
|
||||
timezone.timestamp_opt(minute_index * 60, 0).single()
|
||||
}
|
||||
|
||||
/// 一条记录被纳入删除流程的原因。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RetentionReason {
|
||||
/// 超过 `retention_days`。
|
||||
Days,
|
||||
/// 超过 `retention_count`。
|
||||
Count,
|
||||
}
|
||||
|
||||
/// 保留决策。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RetentionDecision {
|
||||
pub record_id: String,
|
||||
pub reason: RetentionReason,
|
||||
}
|
||||
|
||||
/// 从按完成时间倒序的 completed 计划备份中决定纳入删除流程的记录。
|
||||
///
|
||||
/// 自动保留规则:
|
||||
/// - `retentionDays = 0` 时不按天数清理;`retentionCount = 0` 时不按份数清理。
|
||||
/// - 自动计划备份满足任一启用阈值即可进入删除。
|
||||
/// - 无论阈值如何,至少保留最近一个成功的计划备份(倒序下标 0)。
|
||||
/// - 手动备份不进入本规则;手工/计划记录各自的 `expires_at` 由到期扫描处理。
|
||||
#[must_use]
|
||||
pub fn decide_retention(
|
||||
retention_days: u32,
|
||||
retention_count: u32,
|
||||
now: DateTime<Utc>,
|
||||
scheduled_completed_desc: &[BackupRecord],
|
||||
) -> Vec<RetentionDecision> {
|
||||
let mut decisions = Vec::new();
|
||||
for (index, record) in scheduled_completed_desc.iter().enumerate() {
|
||||
if record.status != BackupStatus::Completed {
|
||||
continue;
|
||||
}
|
||||
// 最近一个成功的计划备份永远保留。
|
||||
if index == 0 {
|
||||
continue;
|
||||
}
|
||||
let expired_by_days = retention_days > 0
|
||||
&& record.completed_at.is_some_and(|completed_at| {
|
||||
completed_at <= now - Duration::days(i64::from(retention_days))
|
||||
});
|
||||
let exceeded_count =
|
||||
retention_count > 0 && index >= usize::try_from(retention_count).unwrap_or(usize::MAX);
|
||||
if expired_by_days || exceeded_count {
|
||||
decisions.push(RetentionDecision {
|
||||
record_id: record.id.clone(),
|
||||
reason: if expired_by_days {
|
||||
RetentionReason::Days
|
||||
} else {
|
||||
RetentionReason::Count
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
decisions
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
//! Backup Worker 贡献:单个可取消 `DaemonTask` 承担调度、执行、删除收敛与保留清理。
|
||||
//!
|
||||
//! 当前部署边界是单副本,因此不需要 Redis lease、fencing token 或 heartbeat。长时间
|
||||
//! `pg_dump` 与上传由本 Daemon 自身持有;Host 只负责 panic 后重启、健康与关闭。
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::{
|
||||
lifecycle::CancellationToken,
|
||||
task::{DaemonTask, WorkerTaskError},
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
model::backup::{
|
||||
BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStorageConfig,
|
||||
BackupTriggerKind, build_backup_seed, code,
|
||||
},
|
||||
ports::backup::{
|
||||
BackupObjectStorePort, BackupRepository, DatabaseDumpPort, DumpRequest,
|
||||
StatusTransitionUpdate, UploadObjectRequest,
|
||||
},
|
||||
};
|
||||
|
||||
impl From<BackupError> for WorkerTaskError {
|
||||
fn from(error: BackupError) -> Self {
|
||||
WorkerTaskError::safe(error.message())
|
||||
}
|
||||
}
|
||||
|
||||
use super::policy::{BackupSchedule, decide_retention};
|
||||
|
||||
/// 循环间隔:管理员修改 Cron 后该时长内生效;无工作时可取消等待。
|
||||
const BACKUP_LOOP_INTERVAL: Duration = Duration::from_secs(30);
|
||||
/// 每个循环最多执行的保留删除数量。
|
||||
const RETENTION_BATCH_SIZE: usize = 10;
|
||||
/// 保留扫描一次读取的 completed 计划备份上限。
|
||||
const RETENTION_SCAN_LIMIT: u32 = 1000;
|
||||
/// 每个循环最多完成的待删除记录数量。
|
||||
const PENDING_DELETION_BATCH: u32 = 20;
|
||||
|
||||
/// 备份 Daemon 任务。
|
||||
pub struct BackupTask {
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
dump: Arc<dyn DatabaseDumpPort>,
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
}
|
||||
|
||||
impl BackupTask {
|
||||
/// 组合仓储、导出器与对象存储适配器。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
dump: Arc<dyn DatabaseDumpPort>,
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
dump,
|
||||
object_store,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DaemonTask for BackupTask {
|
||||
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move { self.run_daemon(cancellation).await })
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupTask {
|
||||
async fn run_daemon(&self, cancellation: CancellationToken) -> Result<(), WorkerTaskError> {
|
||||
loop {
|
||||
if cancellation.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
self.run_cycle(&cancellation).await?;
|
||||
|
||||
tokio::select! {
|
||||
_ = cancellation.cancelled() => return Ok(()),
|
||||
_ = tokio::time::sleep(BACKUP_LOOP_INTERVAL) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行一个周期:推进计划、恢复中间态、删除收敛、领取执行一个任务、保留清理。
|
||||
///
|
||||
/// 对外暴露以便集成测试逐周期驱动;daemon 循环内部复用。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 仓储或基础设施不可用、任务执行失败时返回 [`WorkerTaskError`]。
|
||||
pub async fn run_cycle(&self, cancellation: &CancellationToken) -> Result<(), WorkerTaskError> {
|
||||
if cancellation.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
self.advance_schedule(Utc::now()).await?;
|
||||
self.recover_intermediate(cancellation).await?;
|
||||
self.finish_pending_deletions().await?;
|
||||
self.process_next_task(cancellation).await?;
|
||||
self.run_retention_batch(Utc::now()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 推进到期计划并幂等插入 scheduled 任务。
|
||||
async fn advance_schedule(&self, now: DateTime<Utc>) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
if !settings.schedule_enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let cron = settings.cron_expression.as_deref().unwrap_or_default();
|
||||
let timezone = settings.schedule_timezone.as_deref().unwrap_or_default();
|
||||
if cron.is_empty() || timezone.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let schedule = BackupSchedule::parse(cron, timezone)
|
||||
.map_err(|_| WorkerTaskError::safe("backup schedule is invalid"))?;
|
||||
|
||||
let due = match settings.next_run_at {
|
||||
Some(next_run_at) if next_run_at <= now => true,
|
||||
Some(_) => false,
|
||||
None => {
|
||||
// 刚启用计划但尚无游标:直接初始化。
|
||||
if let Some(next_run_at) = schedule.next_after(now) {
|
||||
let _ = self
|
||||
.repository
|
||||
.advance_schedule_cursor(next_run_at, cron, timezone)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if !due {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let scheduled_at = match schedule.last_firing_at_or_before(now) {
|
||||
Some(scheduled_at) => scheduled_at,
|
||||
None => {
|
||||
// 无法从计划推导最近到期时间:推进游标避免反复触发。
|
||||
if let Some(next_run_at) = schedule.next_after(now) {
|
||||
let _ = self
|
||||
.repository
|
||||
.advance_schedule_cursor(next_run_at, cron, timezone)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let prefix = settings.prefix.as_deref().unwrap_or_default();
|
||||
let expires_at = if settings.retention_days > 0 {
|
||||
Some(now + chrono::Duration::days(i64::from(settings.retention_days)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let seed = build_backup_seed(
|
||||
BackupTriggerKind::Scheduled,
|
||||
Some(scheduled_at),
|
||||
prefix,
|
||||
now,
|
||||
expires_at,
|
||||
)
|
||||
.map_err(infra_error)?;
|
||||
let inserted = self
|
||||
.repository
|
||||
.insert_scheduled_record(seed)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
if inserted {
|
||||
info!(scheduled_at = %scheduled_at, "计划备份任务已创建");
|
||||
} else {
|
||||
warn!(scheduled_at = %scheduled_at, "计划时间点冲突,跳过并推进游标");
|
||||
}
|
||||
if let Some(next_run_at) = schedule.next_after(now) {
|
||||
let _ = self
|
||||
.repository
|
||||
.advance_schedule_cursor(next_run_at, cron, timezone)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 恢复中间状态(dumping/uploading/deleting)。
|
||||
async fn recover_intermediate(
|
||||
&self,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<(), WorkerTaskError> {
|
||||
let records = self
|
||||
.repository
|
||||
.list_intermediate_records()
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
for record in records {
|
||||
match record.status {
|
||||
BackupStatus::Dumping => self.recover_dumping(&record, cancellation).await?,
|
||||
BackupStatus::Uploading => self.recover_uploading(&record, cancellation).await?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// dumping:有完整暂存则继续上传,否则清理暂存并标记失败。
|
||||
async fn recover_dumping(
|
||||
&self,
|
||||
record: &BackupRecord,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<(), WorkerTaskError> {
|
||||
match self.dump.inspect_staging(&record.id).await? {
|
||||
Some(artifact) => {
|
||||
let now = Utc::now();
|
||||
let update = StatusTransitionUpdate {
|
||||
size_bytes: Some(artifact.size_bytes),
|
||||
sha256: Some(artifact.sha256.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let Some(uploading_record) = self
|
||||
.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
BackupStatus::Dumping,
|
||||
BackupStatus::Uploading,
|
||||
update,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
self.upload_and_verify(
|
||||
&uploading_record,
|
||||
&artifact.path,
|
||||
artifact.size_bytes,
|
||||
&artifact.sha256,
|
||||
now,
|
||||
cancellation,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
let _ = self.dump.cleanup_staging(&record.id).await;
|
||||
self.fail_task(record, code::PG_DUMP_FAILED, "导出进程中断,暂存归档不完整")
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// uploading:远端匹配则补记完成,有完整暂存则重试上传,否则失败。
|
||||
async fn recover_uploading(
|
||||
&self,
|
||||
record: &BackupRecord,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
let Some(config) = BackupStorageConfig::from_settings(&settings) else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(remote) = self
|
||||
.object_store
|
||||
.head_object(&config, &record.object_key)
|
||||
.await?
|
||||
&& record.size_bytes == Some(remote.size_bytes)
|
||||
&& record.sha256.as_deref() == Some(remote.sha256.as_str())
|
||||
{
|
||||
let now = Utc::now();
|
||||
self.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
BackupStatus::Uploading,
|
||||
BackupStatus::Completed,
|
||||
StatusTransitionUpdate::default(),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
return Ok(());
|
||||
}
|
||||
match self.dump.inspect_staging(&record.id).await? {
|
||||
Some(artifact) => {
|
||||
self.upload_and_verify(
|
||||
record,
|
||||
&artifact.path,
|
||||
artifact.size_bytes,
|
||||
&artifact.sha256,
|
||||
Utc::now(),
|
||||
cancellation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.fail_task(
|
||||
record,
|
||||
code::S3_UPLOAD_FAILED,
|
||||
"远端对象缺失且本地无完整暂存",
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成待删除记录(deleting → DeleteObject → 硬删除)。
|
||||
async fn finish_pending_deletions(&self) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
let Some(config) = BackupStorageConfig::from_settings(&settings) else {
|
||||
return Ok(());
|
||||
};
|
||||
let records = self
|
||||
.repository
|
||||
.list_pending_deletions(PENDING_DELETION_BATCH)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
for record in records {
|
||||
if self
|
||||
.object_store
|
||||
.delete_object(&config, &record.object_key)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
self.repository
|
||||
.delete_record(&record.id)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 领取一个 queued 任务并执行导出、上传与远端校验。
|
||||
async fn process_next_task(
|
||||
&self,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<(), WorkerTaskError> {
|
||||
let now = Utc::now();
|
||||
let Some(record) = self
|
||||
.repository
|
||||
.claim_next_queued(now)
|
||||
.await
|
||||
.map_err(repo_error)?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
info!(
|
||||
backup_id = %record.id,
|
||||
attempt = record.attempt_count,
|
||||
"领取备份任务"
|
||||
);
|
||||
if let Err(error) = self.execute_task(&record, cancellation).await {
|
||||
warn!(backup_id = %record.id, error = %error, "备份任务执行失败");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 执行单个任务;内部处理全部状态迁移。
|
||||
async fn execute_task(
|
||||
&self,
|
||||
record: &BackupRecord,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
if BackupStorageConfig::from_settings(&settings).is_none() {
|
||||
return self
|
||||
.fail_task(record, code::INVALID_CONFIG, "备份存储配置不完整")
|
||||
.await;
|
||||
}
|
||||
if !settings.storage_verified() {
|
||||
return self
|
||||
.fail_task(record, code::INVALID_CONFIG, "备份存储配置尚未通过连接测试")
|
||||
.await;
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let artifact = match self
|
||||
.dump
|
||||
.dump(DumpRequest {
|
||||
backup_id: record.id.clone(),
|
||||
cancellation: cancellation.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(artifact) => artifact,
|
||||
Err(error) => {
|
||||
let (error_code, message) = classify_dump_error(error);
|
||||
return self.fail_task(record, error_code, message).await;
|
||||
}
|
||||
};
|
||||
|
||||
// dumping → uploading,持久化 size/sha256。
|
||||
let update = StatusTransitionUpdate {
|
||||
size_bytes: Some(artifact.size_bytes),
|
||||
sha256: Some(artifact.sha256.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let Some(uploading_record) = self
|
||||
.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
BackupStatus::Dumping,
|
||||
BackupStatus::Uploading,
|
||||
update,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
self.upload_and_verify(
|
||||
&uploading_record,
|
||||
&artifact.path,
|
||||
artifact.size_bytes,
|
||||
&artifact.sha256,
|
||||
now,
|
||||
cancellation,
|
||||
)
|
||||
.await?;
|
||||
let _ = self.dump.cleanup_staging(&record.id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 上传 + 远端校验,内部完成终态迁移。
|
||||
async fn upload_and_verify(
|
||||
&self,
|
||||
record: &BackupRecord,
|
||||
source: &std::path::Path,
|
||||
size_bytes: u64,
|
||||
sha256: &str,
|
||||
now: DateTime<Utc>,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
let Some(config) = BackupStorageConfig::from_settings(&settings) else {
|
||||
return Ok(());
|
||||
};
|
||||
let store = self.object_store.clone();
|
||||
let metadata =
|
||||
BackupObjectMetadata::new(record.id.clone(), sha256.to_owned(), now, size_bytes);
|
||||
if let Err(error) = store
|
||||
.upload_file(
|
||||
&config,
|
||||
UploadObjectRequest {
|
||||
object_key: record.object_key.clone(),
|
||||
source: source.to_path_buf(),
|
||||
metadata,
|
||||
cancellation: cancellation.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = store.delete_object(&config, &record.object_key).await;
|
||||
self.fail_task(
|
||||
record,
|
||||
error.code(),
|
||||
format!("上传到对象存储失败:{}", error.message()),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match store.head_object(&config, &record.object_key).await {
|
||||
Ok(Some(remote)) if remote.size_bytes == size_bytes && remote.sha256 == sha256 => {
|
||||
if self
|
||||
.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
BackupStatus::Uploading,
|
||||
BackupStatus::Completed,
|
||||
StatusTransitionUpdate::default(),
|
||||
Utc::now(),
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?
|
||||
.is_some()
|
||||
{
|
||||
info!(
|
||||
backup_id = %record.id,
|
||||
size_bytes,
|
||||
"备份完成"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Some(remote)) => {
|
||||
let size_matches = remote.size_bytes == size_bytes;
|
||||
let sha256_matches = remote.sha256 == sha256;
|
||||
warn!(
|
||||
backup_id = %record.id,
|
||||
expected_size_bytes = size_bytes,
|
||||
remote_size_bytes = remote.size_bytes,
|
||||
sha256_matches,
|
||||
"备份远端对象校验不一致"
|
||||
);
|
||||
let _ = store.delete_object(&config, &record.object_key).await;
|
||||
self.fail_task(
|
||||
record,
|
||||
code::REMOTE_VERIFICATION_FAILED,
|
||||
match (size_matches, sha256_matches) {
|
||||
(false, true) => "远端对象大小不一致",
|
||||
(true, false) => "远端对象 sha256 校验值不一致",
|
||||
(false, false) => "远端对象大小和 sha256 校验值均不一致",
|
||||
(true, true) => "远端对象校验状态异常",
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(None) => {
|
||||
warn!(backup_id = %record.id, "上传完成后未找到远端对象");
|
||||
self.fail_task(
|
||||
record,
|
||||
code::REMOTE_VERIFICATION_FAILED,
|
||||
"上传完成后未找到远端对象",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = store.delete_object(&config, &record.object_key).await;
|
||||
self.fail_task(
|
||||
record,
|
||||
error.code(),
|
||||
format!("远端对象校验失败:{}", error.message()),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把活跃任务写入失败终态并清理暂存。
|
||||
async fn fail_task(
|
||||
&self,
|
||||
record: &BackupRecord,
|
||||
error_code: &'static str,
|
||||
message: impl Into<String>,
|
||||
) -> Result<(), WorkerTaskError> {
|
||||
let message = message.into();
|
||||
warn!(
|
||||
backup_id = %record.id,
|
||||
error_code,
|
||||
error_message = %message,
|
||||
"备份任务失败"
|
||||
);
|
||||
let now = Utc::now();
|
||||
let update = StatusTransitionUpdate {
|
||||
error_code: Some(error_code.to_string()),
|
||||
error_message: Some(message),
|
||||
completed_at: Some(now),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = self
|
||||
.repository
|
||||
.transition_status(&record.id, record.status, BackupStatus::Failed, update, now)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
let _ = self.dump.cleanup_staging(&record.id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 执行一小批到期保留清理:先处理 `expires_at` 已到期的记录,再按天数/份数清理计划备份。
|
||||
async fn run_retention_batch(&self, now: DateTime<Utc>) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
|
||||
// 1. expires_at 已到期的记录(手动或计划)无条件进入删除。
|
||||
let expired = self
|
||||
.repository
|
||||
.list_expired_records(RETENTION_BATCH_SIZE as u32)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
for record in expired {
|
||||
let Some(record) = self
|
||||
.repository
|
||||
.transition_to_deleting(&record.id, now)
|
||||
.await
|
||||
.map_err(repo_error)?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
self.finalize_deletion(&record).await?;
|
||||
}
|
||||
|
||||
// 2. 计划备份按 retentionDays / retentionCount 清理。
|
||||
let records = self
|
||||
.repository
|
||||
.list_scheduled_completed_desc(RETENTION_SCAN_LIMIT)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
let decisions = decide_retention(
|
||||
settings.retention_days,
|
||||
settings.retention_count,
|
||||
now,
|
||||
&records,
|
||||
);
|
||||
for decision in decisions.into_iter().take(RETENTION_BATCH_SIZE) {
|
||||
let Some(record) = self
|
||||
.repository
|
||||
.transition_to_deleting(&decision.record_id, now)
|
||||
.await
|
||||
.map_err(repo_error)?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
self.finalize_deletion(&record).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 对已进入 deleting 的记录执行对象删除并硬删除记录行。
|
||||
async fn finalize_deletion(&self, record: &BackupRecord) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
let Some(config) = BackupStorageConfig::from_settings(&settings) else {
|
||||
return Ok(());
|
||||
};
|
||||
if self
|
||||
.object_store
|
||||
.delete_object(&config, &record.object_key)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
self.repository
|
||||
.delete_record(&record.id)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_dump_error(error: BackupError) -> (&'static str, &'static str) {
|
||||
match error.code() {
|
||||
code::CANCELLED => (code::CANCELLED, "备份已取消"),
|
||||
code::STAGING_SPACE_EXHAUSTED => (code::STAGING_SPACE_EXHAUSTED, "暂存磁盘空间不足"),
|
||||
_ => (code::PG_DUMP_FAILED, "数据库导出失败"),
|
||||
}
|
||||
}
|
||||
|
||||
fn repo_error(error: crate::ports::store::AdminStoreError) -> WorkerTaskError {
|
||||
WorkerTaskError::safe(format!("backup store failed: {error}"))
|
||||
}
|
||||
|
||||
fn infra_error(error: BackupError) -> WorkerTaskError {
|
||||
WorkerTaskError::safe(error.message())
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! 容量熔断冻结的恢复编排:到期探测、失败顺延与自适应并发下调。
|
||||
//!
|
||||
//! 冻结事实保存在可丢失的 Redis 冷却中,本服务只组合 Admin 端口:
|
||||
//! 观测(`AccountRuntimeStore`)、探测(`AccountsService`)与写回
|
||||
//! (原子降低并发 / 按冻结代次结束探测)。旧观测不会覆盖管理员操作。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::StreamExt as _;
|
||||
use gateway_core::account::{AccountConcurrencyLimit, ProviderAccountId};
|
||||
use gateway_core::routing::UpstreamModelId;
|
||||
use gateway_core::task::{ScheduledTask, WorkerCycleContext, WorkerTaskError};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::model::accounts::{
|
||||
AccountConnectionTestEvent, AccountFreeze, AccountPageItem, AccountRuntimeSnapshot,
|
||||
};
|
||||
use crate::model::{MutationActor, MutationContext};
|
||||
use crate::ports::store::{AccountRuntimeStore, AccountStore, SettingsStore};
|
||||
use crate::use_case::accounts::AccountsService;
|
||||
|
||||
/// 自适应并发下调保留系数:下调到观测在途峰值的 80%。
|
||||
const ADAPTIVE_CONCURRENCY_FACTOR: f64 = 0.8;
|
||||
/// 自适应并发下限:过低的并发让账号几乎不可用,宁可保持冻结。
|
||||
const ADAPTIVE_CONCURRENCY_FLOOR: u32 = 2;
|
||||
/// worker 周期;冷却观测与探测都依赖该节奏。
|
||||
pub const FREEZE_RECOVERY_INTERVAL: Duration = Duration::from_secs(30);
|
||||
pub const FREEZE_RECOVERY_WORKER_OWNER: &str = "account-freeze-recovery";
|
||||
pub const WORKER_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
||||
pub const WORKER_MAXIMUM_BACKOFF: Duration = Duration::from_secs(60);
|
||||
pub const WORKER_LEASE_TTL: Duration = Duration::from_secs(15 * 60);
|
||||
pub const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
|
||||
/// 系统审计上下文的固定请求 ID;账号并发更新审计由此溯源到自动冻结编排。
|
||||
const SYSTEM_REQUEST_ID: &str = "account-freeze-recovery";
|
||||
|
||||
/// 冻结恢复所需的管理端口组合。
|
||||
#[derive(Clone)]
|
||||
pub struct FreezeRecoveryDeps {
|
||||
pub accounts: Arc<dyn AccountsService>,
|
||||
pub store: Arc<dyn AccountStore>,
|
||||
pub runtime: Arc<dyn AccountRuntimeStore>,
|
||||
pub settings: Arc<dyn SettingsStore>,
|
||||
}
|
||||
|
||||
pub struct FreezeRecoveryTask {
|
||||
deps: FreezeRecoveryDeps,
|
||||
}
|
||||
|
||||
impl FreezeRecoveryTask {
|
||||
pub fn new(deps: FreezeRecoveryDeps) -> Self {
|
||||
Self { deps }
|
||||
}
|
||||
|
||||
fn system_context() -> MutationContext {
|
||||
MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: SYSTEM_REQUEST_ID.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取冻结策略;读取或校验失败跳过本轮,不能把未知配置当作允许解冻。
|
||||
async fn freeze_policy(&self) -> Option<gateway_core::provider_ports::ProviderFreezePolicy> {
|
||||
let settings = self.deps.settings.load_runtime_settings().await.ok()?;
|
||||
gateway_core::provider_ports::ProviderFreezePolicy::try_new(
|
||||
settings.account_auto_freeze_enabled,
|
||||
settings.account_auto_freeze_threshold,
|
||||
settings.account_auto_freeze_window_seconds,
|
||||
settings.account_auto_freeze_duration_seconds,
|
||||
settings.account_auto_freeze_probe_enabled,
|
||||
settings.account_auto_freeze_probe_model,
|
||||
settings.account_auto_freeze_adaptive_concurrency,
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
async fn active_freezes(&self) -> BTreeMap<String, AccountFreeze> {
|
||||
self.deps.runtime.active_freezes().await.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn run_cycle_inner(&self) {
|
||||
let Some(policy) = self.freeze_policy().await else {
|
||||
return;
|
||||
};
|
||||
let freezes = self.active_freezes().await;
|
||||
if freezes.is_empty() {
|
||||
return;
|
||||
}
|
||||
if policy.enabled() && policy.adaptive_concurrency() {
|
||||
self.adapt_concurrency_limits(&freezes, &policy).await;
|
||||
}
|
||||
self.recover_due_freezes(&freezes, &policy).await;
|
||||
}
|
||||
|
||||
/// 对冻结中的账号执行自适应并发下调:目标为观测在途峰值的 80%(下限 2),
|
||||
/// 只降不升;未观测到峰值证据的账号保持现状。
|
||||
async fn adapt_concurrency_limits(
|
||||
&self,
|
||||
freezes: &BTreeMap<String, AccountFreeze>,
|
||||
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
|
||||
) {
|
||||
let account_ids = freezes.keys().cloned().collect::<Vec<_>>();
|
||||
let Ok(peaks) = self.deps.runtime.capacity_peaks(&account_ids).await else {
|
||||
return;
|
||||
};
|
||||
for (account_id, peak) in peaks {
|
||||
let Some(target) = adaptive_target(peak) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(account) = ProviderAccountId::new(account_id.clone()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(limit) = AccountConcurrencyLimit::new(target) else {
|
||||
continue;
|
||||
};
|
||||
match self
|
||||
.deps
|
||||
.accounts
|
||||
.lower_concurrency_limit(&Self::system_context(), account, limit)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => {
|
||||
tracing::info!(
|
||||
account_id,
|
||||
adapted_limit = target,
|
||||
freeze_seconds = policy.freeze_duration().as_secs(),
|
||||
"容量熔断:按观测在途峰值下调账号并发上限",
|
||||
);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
warn!(account_id, error = %error, "自适应并发下调失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_account(&self, account_id: &str) -> Option<Option<AccountPageItem>> {
|
||||
let account_id = ProviderAccountId::new(account_id.to_owned()).ok()?;
|
||||
self.deps
|
||||
.store
|
||||
.load_account(account_id.as_str(), AccountRuntimeSnapshot::default())
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// 到达探测时间后才恢复;关闭自动冻结或探测时,已有冻结仍等待原冷却结束。
|
||||
async fn recover_due_freezes(
|
||||
&self,
|
||||
freezes: &BTreeMap<String, AccountFreeze>,
|
||||
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
|
||||
) {
|
||||
for (account_id, freeze) in freezes {
|
||||
if freeze.until > Utc::now() {
|
||||
continue;
|
||||
}
|
||||
if policy.enabled() && policy.probe_enabled() && freeze.requires_probe {
|
||||
self.probe_and_recover(account_id, freeze, policy).await;
|
||||
} else if let Err(error) = self
|
||||
.deps
|
||||
.runtime
|
||||
.finish_freeze(account_id, freeze, None)
|
||||
.await
|
||||
{
|
||||
warn!(account_id, error = %error, "解除到期冻结失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_and_recover(
|
||||
&self,
|
||||
account_id: &str,
|
||||
freeze: &AccountFreeze,
|
||||
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
|
||||
) {
|
||||
let Some(Some(item)) = self.load_account(account_id).await else {
|
||||
return;
|
||||
};
|
||||
if !item.account.enabled {
|
||||
// 停用账号没有自动恢复意义;解冻交给管理员手动恢复。
|
||||
return;
|
||||
}
|
||||
let Ok(account) = ProviderAccountId::new(account_id.to_owned()) else {
|
||||
return;
|
||||
};
|
||||
let Some(model) = self.resolve_probe_model(&account, policy).await else {
|
||||
// 没有可用探测模型时按失败处理,顺延冻结等待下一轮。
|
||||
self.postpone(&account, freeze, policy).await;
|
||||
return;
|
||||
};
|
||||
let probe_succeeded = match self
|
||||
.deps
|
||||
.accounts
|
||||
.test_connection(account.clone(), model)
|
||||
.await
|
||||
{
|
||||
Ok(events) => drain_probe(events).await,
|
||||
Err(error) => {
|
||||
tracing::debug!(account_id, error = %error, "冻结恢复探测发起失败");
|
||||
false
|
||||
}
|
||||
};
|
||||
if probe_succeeded {
|
||||
match self
|
||||
.deps
|
||||
.runtime
|
||||
.finish_freeze(account_id, freeze, None)
|
||||
.await
|
||||
{
|
||||
Ok(true) => tracing::info!(account_id, "冻结恢复探测成功:账号已解冻"),
|
||||
Ok(false) => {}
|
||||
Err(error) => warn!(account_id, error = %error, "解除冻结失败"),
|
||||
}
|
||||
} else {
|
||||
self.postpone(&account, freeze, policy).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测失败只顺延本次仍存在的冻结,旧探测不能重新冻结已手动恢复的账号。
|
||||
async fn postpone(
|
||||
&self,
|
||||
account: &ProviderAccountId,
|
||||
freeze: &AccountFreeze,
|
||||
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
|
||||
) {
|
||||
let Some(until) = SystemTime::now().checked_add(policy.freeze_duration()) else {
|
||||
return;
|
||||
};
|
||||
let until = DateTime::<Utc>::from(until);
|
||||
match self
|
||||
.deps
|
||||
.runtime
|
||||
.finish_freeze(account.as_str(), freeze, Some(until))
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
tracing::info!(
|
||||
account_id = account.as_str(),
|
||||
postpone_seconds = policy.freeze_duration().as_secs(),
|
||||
"冻结恢复探测失败:账号冻结顺延",
|
||||
);
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
account_id = account.as_str(),
|
||||
error = %error,
|
||||
"冻结顺延失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_probe_model(
|
||||
&self,
|
||||
account: &ProviderAccountId,
|
||||
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
|
||||
) -> Option<UpstreamModelId> {
|
||||
if let Some(model) = policy.probe_model() {
|
||||
return UpstreamModelId::new(model.to_owned()).ok();
|
||||
}
|
||||
let models = self.deps.accounts.models(account, false).await.ok()?;
|
||||
models.models.into_iter().next().map(|model| model.id)
|
||||
}
|
||||
}
|
||||
|
||||
impl ScheduledTask for FreezeRecoveryTask {
|
||||
fn run_cycle(
|
||||
&self,
|
||||
context: WorkerCycleContext,
|
||||
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
if context.cancellation().is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
self.run_cycle_inner().await;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 自适应目标并发:观测峰值 × 0.8 向下取整,下限 2;无证据(峰值为 0)不调整。
|
||||
fn adaptive_target(peak_in_flight: u32) -> Option<u32> {
|
||||
if peak_in_flight == 0 {
|
||||
return None;
|
||||
}
|
||||
let scaled = (f64::from(peak_in_flight) * ADAPTIVE_CONCURRENCY_FACTOR).floor() as u32;
|
||||
Some(scaled.max(ADAPTIVE_CONCURRENCY_FLOOR))
|
||||
}
|
||||
|
||||
/// 消耗连接测试事件流并返回是否以 `Completed` 终止。
|
||||
async fn drain_probe(mut events: crate::model::accounts::AccountConnectionTestEventStream) -> bool {
|
||||
let mut completed = false;
|
||||
while let Some(event) = events.next().await {
|
||||
match event {
|
||||
AccountConnectionTestEvent::Completed => completed = true,
|
||||
AccountConnectionTestEvent::Failed { .. } => return false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
completed
|
||||
}
|
||||
@@ -2,42 +2,56 @@
|
||||
//!
|
||||
//! 本 crate 不包含 HTTP wire、数据库实现或具体 Provider 实现。
|
||||
|
||||
use std::{fmt, path::Path, sync::Arc};
|
||||
use std::{fmt, path::Path, sync::Arc, time::Duration};
|
||||
|
||||
use gateway_core::{
|
||||
engine::execution::ClientKeyVerifier,
|
||||
engine::probe::AccountProbe,
|
||||
routing::{ProviderKind, snapshot::SnapshotControl},
|
||||
routing::ProviderKind,
|
||||
runtime::SnapshotControl,
|
||||
task::{
|
||||
DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerLeaseRequest,
|
||||
WorkerRegistration, WorkerRunnable, WorkerSchedule,
|
||||
},
|
||||
};
|
||||
use secrecy::{ExposeSecret as _, SecretString};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub mod backup;
|
||||
pub mod freeze_recovery;
|
||||
pub mod model;
|
||||
pub mod ports;
|
||||
mod use_case;
|
||||
|
||||
pub use use_case::key_usage::KeyUsageService;
|
||||
|
||||
pub use use_case::{
|
||||
accounts::AccountsService, auth::AuthService, client_keys::ClientKeyService,
|
||||
observability::ObservabilityService, openai::OpenAiService, settings::SettingsService,
|
||||
system::SystemService, xai::XaiService,
|
||||
account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService,
|
||||
backup::BackupService, client_distribution::ClientDistributionService,
|
||||
client_keys::ClientKeyService, import_tasks::ImportTasksService,
|
||||
observability::ObservabilityService, openai::OpenAiService, proxies::ProxiesService,
|
||||
settings::SettingsService, system::SystemService, xai::XaiService,
|
||||
};
|
||||
|
||||
use model::{AdminError, AdminErrorKind};
|
||||
use ports::{
|
||||
client_distribution::ClientDistributionResolver,
|
||||
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry},
|
||||
store::AdminStorePorts,
|
||||
system::SystemOperations,
|
||||
};
|
||||
use use_case::{
|
||||
accounts::DefaultAccountsService, auth::DefaultAuthService,
|
||||
client_keys::DefaultClientKeyService, observability::DefaultObservabilityService,
|
||||
openai::DefaultOpenAiService, settings::DefaultSettingsService, system::DefaultSystemService,
|
||||
xai::DefaultXaiService,
|
||||
account_groups::DefaultAccountGroupService, accounts::DefaultAccountsService,
|
||||
auth::DefaultAuthService, backup::DefaultBackupService,
|
||||
client_distribution::DefaultClientDistributionService, client_keys::DefaultClientKeyService,
|
||||
observability::DefaultObservabilityService, openai::DefaultOpenAiService,
|
||||
settings::DefaultSettingsService, system::DefaultSystemService, xai::DefaultXaiService,
|
||||
};
|
||||
|
||||
const OPENAI_PROVIDER_KIND: &str = "openai";
|
||||
const XAI_PROVIDER_KIND: &str = "xai";
|
||||
const MINIMUM_INITIAL_PASSWORD_BYTES: usize = 12;
|
||||
const WEAK_INITIAL_PASSWORDS: &[&str] = &[
|
||||
const WEAK_ADMIN_PASSWORDS: &[&str] = &[
|
||||
"",
|
||||
"admin",
|
||||
"123456",
|
||||
@@ -48,6 +62,9 @@ const WEAK_INITIAL_PASSWORDS: &[&str] = &[
|
||||
"codex-proxy-rs",
|
||||
];
|
||||
|
||||
const BACKUP_WORKER_OWNER: &str = "backup";
|
||||
const DEFAULT_CLIENT_SESSION_TTL_MINUTES: u64 = 24 * 60;
|
||||
|
||||
/// 只用于首次幂等创建默认管理员的启动密码。
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
@@ -80,13 +97,40 @@ impl fmt::Debug for InitialAdminPassword {
|
||||
|
||||
/// 管理控制面的启动配置。
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AdminConfig {
|
||||
pub session_ttl_minutes: u64,
|
||||
pub default_username: String,
|
||||
pub default_password: InitialAdminPassword,
|
||||
}
|
||||
|
||||
/// Client 登录域的通用启动配置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct ClientConfig {
|
||||
pub session_ttl_minutes: u64,
|
||||
}
|
||||
|
||||
impl Default for ClientConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
session_ttl_minutes: DEFAULT_CLIENT_SESSION_TTL_MINUTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientConfig {
|
||||
/// 校验 Client session TTL;当前配置不含相对路径。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 会话有效期为零或无法安全换算时返回错误。
|
||||
pub fn resolve_and_validate(&mut self, _source_dir: &Path) -> Result<(), AdminConfigError> {
|
||||
if self.session_ttl_minutes == 0 || i64::try_from(self.session_ttl_minutes).is_err() {
|
||||
return Err(AdminConfigError::InvalidField("client.session_ttl_minutes"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminConfig {
|
||||
/// 校验 Admin-owned 字段;当前配置不含相对路径。
|
||||
///
|
||||
@@ -105,7 +149,7 @@ impl AdminConfig {
|
||||
let password = self.default_password.expose().trim();
|
||||
if password.len() < MINIMUM_INITIAL_PASSWORD_BYTES
|
||||
|| password.contains('$')
|
||||
|| WEAK_INITIAL_PASSWORDS.contains(&password.to_ascii_lowercase().as_str())
|
||||
|| WEAK_ADMIN_PASSWORDS.contains(&password.to_ascii_lowercase().as_str())
|
||||
{
|
||||
return Err(AdminConfigError::WeakInitialPassword);
|
||||
}
|
||||
@@ -127,9 +171,9 @@ impl fmt::Debug for AdminConfig {
|
||||
/// Admin-owned 启动配置错误;不回显任何配置值。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum AdminConfigError {
|
||||
#[error("configuration field `{0}` is invalid")]
|
||||
#[error("配置字段 `{0}` 不合法")]
|
||||
InvalidField(&'static str),
|
||||
#[error("admin.default_password does not meet the initial password policy")]
|
||||
#[error("admin.default_password 不符合初始密码策略")]
|
||||
WeakInitialPassword,
|
||||
}
|
||||
|
||||
@@ -138,17 +182,44 @@ pub enum AdminConfigError {
|
||||
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
|
||||
#[derive(Clone)]
|
||||
pub struct AdminServices {
|
||||
proxies: Arc<dyn ProxiesService>,
|
||||
auth: Arc<dyn AuthService>,
|
||||
key_usage: Arc<dyn KeyUsageService>,
|
||||
accounts: Arc<dyn AccountsService>,
|
||||
account_groups: Arc<dyn AccountGroupService>,
|
||||
client_keys: Arc<dyn ClientKeyService>,
|
||||
client_distribution: Arc<dyn ClientDistributionService>,
|
||||
observability: Arc<dyn ObservabilityService>,
|
||||
settings: Arc<dyn SettingsService>,
|
||||
system: Arc<dyn SystemService>,
|
||||
openai: Arc<dyn OpenAiService>,
|
||||
xai: Arc<dyn XaiService>,
|
||||
backups: Arc<dyn BackupService>,
|
||||
import_tasks: Arc<dyn ImportTasksService>,
|
||||
}
|
||||
|
||||
impl AdminServices {
|
||||
#[must_use]
|
||||
pub fn import_tasks(&self) -> &dyn ImportTasksService {
|
||||
self.import_tasks.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn key_usage(&self) -> &dyn KeyUsageService {
|
||||
self.key_usage.as_ref()
|
||||
}
|
||||
|
||||
/// 取得账号服务的共享句柄;后台编排(冻结恢复 worker)需要持有 Arc。
|
||||
#[must_use]
|
||||
pub fn accounts_handle(&self) -> Arc<dyn AccountsService> {
|
||||
Arc::clone(&self.accounts)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn proxies(&self) -> &dyn ProxiesService {
|
||||
self.proxies.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn auth(&self) -> &dyn AuthService {
|
||||
self.auth.as_ref()
|
||||
@@ -159,11 +230,21 @@ impl AdminServices {
|
||||
self.accounts.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn account_groups(&self) -> &dyn AccountGroupService {
|
||||
self.account_groups.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn client_keys(&self) -> &dyn ClientKeyService {
|
||||
self.client_keys.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn client_distribution(&self) -> &dyn ClientDistributionService {
|
||||
self.client_distribution.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn observability(&self) -> &dyn ObservabilityService {
|
||||
self.observability.as_ref()
|
||||
@@ -188,11 +269,17 @@ impl AdminServices {
|
||||
pub fn xai(&self) -> &dyn XaiService {
|
||||
self.xai.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn backups(&self) -> &dyn BackupService {
|
||||
self.backups.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin 初始化完成后的封闭能力包。
|
||||
pub struct AdminBundle {
|
||||
services: AdminServices,
|
||||
worker_contributions: Vec<WorkerContribution>,
|
||||
}
|
||||
|
||||
impl AdminBundle {
|
||||
@@ -200,6 +287,23 @@ impl AdminBundle {
|
||||
pub fn services(&self) -> AdminServices {
|
||||
self.services.clone()
|
||||
}
|
||||
|
||||
/// 取出 Admin Worker 贡献;只能调用一次,与其它 Bundle 的贡献一并交给 Host。
|
||||
pub fn take_worker_contributions(&mut self) -> Vec<WorkerContribution> {
|
||||
std::mem::take(&mut self.worker_contributions)
|
||||
}
|
||||
}
|
||||
|
||||
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
|
||||
pub struct AdminRuntimePorts {
|
||||
pub pricing_source: Arc<dyn ports::pricing::PricingSource>,
|
||||
pub providers: Vec<Arc<dyn ProviderAdmin>>,
|
||||
pub snapshot: Arc<dyn SnapshotControl>,
|
||||
pub account_probe: Arc<dyn AccountProbe>,
|
||||
pub proxy_probe: Arc<dyn ports::proxy::ProxyProbe>,
|
||||
pub client_distribution: Arc<dyn ClientDistributionResolver>,
|
||||
pub system: Arc<dyn SystemOperations>,
|
||||
pub client_key_verifier: Arc<dyn ClientKeyVerifier>,
|
||||
}
|
||||
|
||||
/// 校验配置、建立动态 Provider 注册表并完成默认管理员幂等初始化。
|
||||
@@ -209,15 +313,26 @@ impl AdminBundle {
|
||||
/// 配置非法、Provider 注册冲突/缺失或默认管理员初始化失败时返回错误。
|
||||
pub async fn initialize(
|
||||
mut config: AdminConfig,
|
||||
mut client_config: ClientConfig,
|
||||
store: AdminStorePorts,
|
||||
providers: Vec<Arc<dyn ProviderAdmin>>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
system: Arc<dyn SystemOperations>,
|
||||
runtime: AdminRuntimePorts,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
let AdminRuntimePorts {
|
||||
pricing_source,
|
||||
providers,
|
||||
snapshot,
|
||||
account_probe: probe,
|
||||
proxy_probe,
|
||||
client_distribution,
|
||||
system,
|
||||
client_key_verifier,
|
||||
} = runtime;
|
||||
config
|
||||
.resolve_and_validate(Path::new("."))
|
||||
.map_err(|error| AdminError::invalid(error.to_string()))?;
|
||||
client_config
|
||||
.resolve_and_validate(Path::new("."))
|
||||
.map_err(|error| AdminError::invalid(error.to_string()))?;
|
||||
let registry = ProviderAdminRegistry::new(providers).map_err(map_provider_registry_error)?;
|
||||
let openai = registry
|
||||
.require(&provider_kind(OPENAI_PROVIDER_KIND)?)
|
||||
@@ -229,51 +344,173 @@ pub async fn initialize(
|
||||
let auth = Arc::new(DefaultAuthService::new(
|
||||
config.default_username,
|
||||
config.session_ttl_minutes,
|
||||
client_config.session_ttl_minutes,
|
||||
store.auth(),
|
||||
client_key_verifier,
|
||||
));
|
||||
auth.ensure_default_admin(config.default_password.expose())
|
||||
.await?;
|
||||
|
||||
let accounts = Arc::new(DefaultAccountsService::new(
|
||||
store.accounts(),
|
||||
store.settings(),
|
||||
store.account_runtime(),
|
||||
registry.clone(),
|
||||
snapshot.clone(),
|
||||
probe.clone(),
|
||||
));
|
||||
let backup_ports = store.backup();
|
||||
let backups = Arc::new(DefaultBackupService::new(
|
||||
backup_ports.repository(),
|
||||
backup_ports.object_store(),
|
||||
store.auth(),
|
||||
snapshot.clone(),
|
||||
));
|
||||
let backup_task = backup::task::BackupTask::new(
|
||||
backup_ports.repository(),
|
||||
backup_ports.dump(),
|
||||
backup_ports.object_store(),
|
||||
);
|
||||
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
|
||||
auth.clone(),
|
||||
store.client_keys(),
|
||||
store.observability(),
|
||||
));
|
||||
let profile_provider = Arc::clone(&openai);
|
||||
let openai = Arc::new(DefaultOpenAiService::new(
|
||||
openai,
|
||||
store.accounts(),
|
||||
store.proxies(),
|
||||
snapshot.clone(),
|
||||
));
|
||||
let xai = Arc::new(DefaultXaiService::new(
|
||||
xai,
|
||||
store.accounts(),
|
||||
store.proxies(),
|
||||
snapshot.clone(),
|
||||
));
|
||||
let import_tasks =
|
||||
use_case::import_tasks::DefaultImportTasksService::new(openai.clone(), xai.clone());
|
||||
let import_task = use_case::import_tasks::ImportTaskWorker(import_tasks.clone());
|
||||
let services = AdminServices {
|
||||
key_usage,
|
||||
proxies: Arc::new(use_case::proxies::DefaultProxiesService::new(
|
||||
store.proxies(),
|
||||
proxy_probe,
|
||||
snapshot.clone(),
|
||||
registry.clone(),
|
||||
)),
|
||||
auth,
|
||||
accounts,
|
||||
accounts: accounts.clone(),
|
||||
account_groups: Arc::new(DefaultAccountGroupService::new(
|
||||
store.account_groups(),
|
||||
store.account_runtime(),
|
||||
snapshot.clone(),
|
||||
)),
|
||||
client_keys: Arc::new(DefaultClientKeyService::new(
|
||||
store.client_keys(),
|
||||
snapshot.clone(),
|
||||
Arc::clone(&profile_provider),
|
||||
)),
|
||||
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
|
||||
observability: Arc::new(DefaultObservabilityService::new(
|
||||
store.observability(),
|
||||
store.accounts(),
|
||||
store.settings(),
|
||||
registry,
|
||||
registry.clone(),
|
||||
)),
|
||||
settings: Arc::new(DefaultSettingsService::new(
|
||||
store.settings(),
|
||||
snapshot.clone(),
|
||||
profile_provider,
|
||||
registry,
|
||||
pricing_source,
|
||||
)),
|
||||
system: Arc::new(DefaultSystemService::new(system)),
|
||||
openai: Arc::new(DefaultOpenAiService::new(
|
||||
openai,
|
||||
store.accounts(),
|
||||
snapshot.clone(),
|
||||
)),
|
||||
xai: Arc::new(DefaultXaiService::new(
|
||||
xai,
|
||||
store.accounts(),
|
||||
snapshot.clone(),
|
||||
)),
|
||||
openai,
|
||||
xai,
|
||||
import_tasks,
|
||||
backups,
|
||||
};
|
||||
Ok(AdminBundle { services })
|
||||
let freeze_recovery =
|
||||
freeze_recovery::FreezeRecoveryTask::new(freeze_recovery::FreezeRecoveryDeps {
|
||||
accounts: Arc::clone(&accounts) as Arc<dyn AccountsService>,
|
||||
store: store.accounts(),
|
||||
runtime: store.account_runtime(),
|
||||
settings: store.settings(),
|
||||
});
|
||||
let mut worker_contributions = backup_worker_contribution(backup_task)?;
|
||||
let id = WorkerId::try_new(WorkerKind::AccountImport, "admin")
|
||||
.map_err(|_| AdminError::internal("导入 Worker ID 不合法"))?;
|
||||
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
|
||||
.map_err(|_| AdminError::internal("导入 Worker 重启策略不合法"))?;
|
||||
let registration = WorkerRegistration::try_new(
|
||||
id,
|
||||
WorkerRunnable::Daemon {
|
||||
restart,
|
||||
task: Box::new(import_task),
|
||||
},
|
||||
)
|
||||
.map_err(|_| AdminError::internal("导入 Worker 注册信息不合法"))?;
|
||||
worker_contributions.push(WorkerContribution::Registration(registration));
|
||||
worker_contributions.extend(freeze_recovery_worker_contribution(freeze_recovery)?);
|
||||
Ok(AdminBundle {
|
||||
services,
|
||||
worker_contributions,
|
||||
})
|
||||
}
|
||||
|
||||
/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`。
|
||||
fn backup_worker_contribution(
|
||||
task: backup::task::BackupTask,
|
||||
) -> Result<Vec<WorkerContribution>, AdminError> {
|
||||
let id = WorkerId::try_new(WorkerKind::Backup, BACKUP_WORKER_OWNER)
|
||||
.map_err(|_| AdminError::internal("备份 Worker ID 不合法"))?;
|
||||
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
|
||||
.map_err(|_| AdminError::internal("备份 Worker 重启策略不合法"))?;
|
||||
let registration = WorkerRegistration::try_new(
|
||||
id,
|
||||
WorkerRunnable::Daemon {
|
||||
restart,
|
||||
task: Box::new(task),
|
||||
},
|
||||
)
|
||||
.map_err(|_| AdminError::internal("备份 Worker 注册信息不合法"))?;
|
||||
Ok(vec![WorkerContribution::Registration(registration)])
|
||||
}
|
||||
|
||||
/// 冻结恢复 Worker 注册:按固定周期扫描活跃冻结,owner 固定。
|
||||
fn freeze_recovery_worker_contribution(
|
||||
task: freeze_recovery::FreezeRecoveryTask,
|
||||
) -> Result<Vec<WorkerContribution>, AdminError> {
|
||||
let id = WorkerId::try_new(
|
||||
WorkerKind::AccountFreezeRecovery,
|
||||
freeze_recovery::FREEZE_RECOVERY_WORKER_OWNER,
|
||||
)
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker ID 不合法"))?;
|
||||
let schedule = WorkerSchedule::try_new(
|
||||
freeze_recovery::FREEZE_RECOVERY_INTERVAL,
|
||||
freeze_recovery::WORKER_INITIAL_BACKOFF,
|
||||
freeze_recovery::WORKER_MAXIMUM_BACKOFF,
|
||||
freeze_recovery::WORKER_LEASE_TTL,
|
||||
freeze_recovery::WORKER_LEASE_RENEWAL,
|
||||
)
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker 调度配置不合法"))?;
|
||||
let lease = WorkerLeaseRequest::try_new(id.clone(), freeze_recovery::WORKER_LEASE_TTL)
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker 租约配置不合法"))?;
|
||||
let registration = WorkerRegistration::try_new(
|
||||
id,
|
||||
WorkerRunnable::Scheduled {
|
||||
schedule,
|
||||
lease: Some(lease),
|
||||
task: Box::new(task),
|
||||
},
|
||||
)
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?;
|
||||
Ok(vec![WorkerContribution::Registration(registration)])
|
||||
}
|
||||
|
||||
fn provider_kind(value: &'static str) -> Result<ProviderKind, AdminError> {
|
||||
ProviderKind::new(value).map_err(|_| AdminError::internal("Built-in Provider kind is invalid"))
|
||||
ProviderKind::new(value).map_err(|_| AdminError::internal("内置 Provider 类型不合法"))
|
||||
}
|
||||
|
||||
fn map_provider_registry_error(error: ProviderAdminError) -> AdminError {
|
||||
@@ -283,8 +520,12 @@ fn map_provider_registry_error(error: ProviderAdminError) -> AdminError {
|
||||
}
|
||||
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
|
||||
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
|
||||
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
|
||||
ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown,
|
||||
ProviderAdminErrorKind::Unavailable | ProviderAdminErrorKind::CredentialRefreshRequired => {
|
||||
AdminErrorKind::Unavailable
|
||||
}
|
||||
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
|
||||
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
|
||||
};
|
||||
AdminError::new(kind, "Provider registry initialization failed")
|
||||
AdminError::new(kind, "Provider 注册表初始化失败")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Provider-neutral account group commands and projections.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::{account::AccountStatusFacts, routing::AccountGroupId};
|
||||
|
||||
use super::{PageSize, Revision, observability::DecimalAmount};
|
||||
|
||||
/// Canonical `#RRGGBBAA` color persisted with an account group.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupColor(String);
|
||||
|
||||
impl AccountGroupColor {
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
(value.len() == 9
|
||||
&& value.starts_with('#')
|
||||
&& value[1..].bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||
.then(|| Self(value.to_ascii_uppercase()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Group member availability at the current observation time.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountGroupAccountSummary {
|
||||
pub available: u64,
|
||||
pub limited: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// Group scheduling slots derived from available accounts and runtime leases.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountGroupCapacity {
|
||||
pub used_slots: Option<u64>,
|
||||
pub total_slots: u64,
|
||||
}
|
||||
|
||||
/// Successful, downstream-committed USD request costs for group accounts.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupUsage {
|
||||
pub today_usd: DecimalAmount,
|
||||
/// 当前 usage retention 窗口内、按请求发生时 routing group 快照归属的累计成本。
|
||||
pub retained_total_usd: DecimalAmount,
|
||||
}
|
||||
|
||||
/// 当前页账号组 membership 对应的持久账号事实;运行态在 Admin query service 合并。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupMemberFact {
|
||||
pub group_id: AccountGroupId,
|
||||
pub account_id: String,
|
||||
pub status: AccountStatusFacts,
|
||||
pub total_slots: u64,
|
||||
}
|
||||
|
||||
/// Lightweight group reference embedded in account and client-key views.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupRef {
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
pub color: AccountGroupColor,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Account group list query.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupListQuery {
|
||||
pub page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub search: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// Complete account group summary.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupRecord {
|
||||
pub disable_fast: bool,
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub color: AccountGroupColor,
|
||||
pub enabled: bool,
|
||||
pub member_count: u64,
|
||||
pub provider_counts: BTreeMap<String, u64>,
|
||||
pub client_key_count: u64,
|
||||
pub account_summary: AccountGroupAccountSummary,
|
||||
pub capacity: AccountGroupCapacity,
|
||||
pub usage: AccountGroupUsage,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Paginated account groups.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupPage {
|
||||
pub config_revision: Revision,
|
||||
pub items: Vec<AccountGroupRecord>,
|
||||
pub total: u64,
|
||||
pub page: u32,
|
||||
pub page_size: u16,
|
||||
}
|
||||
|
||||
/// Create an account group.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateAccountGroup {
|
||||
pub disable_fast: bool,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub color: AccountGroupColor,
|
||||
}
|
||||
|
||||
/// Store-ready create command with a generated stable ID.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NewAccountGroup {
|
||||
pub disable_fast: bool,
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub color: AccountGroupColor,
|
||||
}
|
||||
|
||||
/// Update an account group's descriptive fields.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateAccountGroup {
|
||||
pub disable_fast: Option<bool>,
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub color: AccountGroupColor,
|
||||
}
|
||||
|
||||
/// Enable or disable an account group.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetAccountGroupEnabled {
|
||||
pub id: AccountGroupId,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Delete an account group that is not referenced by a client key.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteAccountGroup {
|
||||
pub id: AccountGroupId,
|
||||
}
|
||||
|
||||
/// Result of any account-group mutation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupMutation {
|
||||
pub config_revision: Revision,
|
||||
pub id: AccountGroupId,
|
||||
pub record: Option<AccountGroupRecord>,
|
||||
}
|
||||
@@ -1,35 +1,32 @@
|
||||
//! 多 Provider 账号目录与连接测试的公共事实。
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::{collections::BTreeMap, pin::Pin};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::Stream;
|
||||
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use gateway_core::{
|
||||
engine::probe::AccountProbeErrorSource, error::GatewayErrorKind, routing::ProviderKind,
|
||||
upstream::UpstreamSendState,
|
||||
};
|
||||
|
||||
use super::{PageSize, Revision, observability::TimeRange};
|
||||
use super::{PageSize, Revision, account_groups::AccountGroupRef, observability::TimeRange};
|
||||
|
||||
/// Provider 账号当前可用性。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccountAvailability {
|
||||
Unknown,
|
||||
Ready,
|
||||
Cooldown,
|
||||
QuotaExhausted,
|
||||
Expired,
|
||||
Banned,
|
||||
Invalid,
|
||||
}
|
||||
pub use gateway_core::account::{
|
||||
AccountConcurrencyLimit, AccountErrorReason, AccountStatus, AccountStatusFacts,
|
||||
AccountStatusProjection, AccountWeight, CredentialState, QuotaAccessState, QuotaEvidence,
|
||||
QuotaState, resolve_account_status,
|
||||
};
|
||||
|
||||
/// 管理页使用的归一化账号状态。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccountStatus {
|
||||
Active,
|
||||
Expired,
|
||||
QuotaExhausted,
|
||||
Disabled,
|
||||
Banned,
|
||||
Attention,
|
||||
/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountImportSettings {
|
||||
pub notes: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
}
|
||||
|
||||
/// 账号列表排序字段。
|
||||
@@ -63,19 +60,46 @@ pub struct AccountListQuery {
|
||||
pub page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub provider_kind: Option<ProviderKind>,
|
||||
pub group_filter: Option<AccountGroupFilter>,
|
||||
pub search: Option<String>,
|
||||
pub status: Option<AccountStatus>,
|
||||
pub sort: Option<AccountSort>,
|
||||
}
|
||||
|
||||
/// Admin query service 从运行态存储取得的当前账号冷却快照。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AccountRuntimeSnapshot {
|
||||
pub cooldown: BTreeMap<String, gateway_core::account::AccountCooldown>,
|
||||
/// `None` 表示实时 lease 存储不可用;`Some` 中未出现的账号当前使用量为零。
|
||||
pub in_flight: Option<BTreeMap<String, u64>>,
|
||||
}
|
||||
|
||||
/// 恢复任务读取的冻结快照;generation 将异步结果绑定到本次冻结。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountFreeze {
|
||||
pub credential_revision: Revision,
|
||||
pub until: DateTime<Utc>,
|
||||
pub generation: String,
|
||||
pub requires_probe: bool,
|
||||
}
|
||||
|
||||
/// Optional account membership filter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AccountGroupFilter {
|
||||
Group(gateway_core::routing::AccountGroupId),
|
||||
Ungrouped,
|
||||
}
|
||||
|
||||
/// 账号公共存储投影;Provider 专属字段不进入此结构。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountRecord {
|
||||
pub id: String,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub groups: Vec<AccountGroupRef>,
|
||||
pub name: String,
|
||||
pub notes: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub upstream_user_id: String,
|
||||
pub upstream_user_id: Option<String>,
|
||||
pub upstream_account_id: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
pub authentication_kind: String,
|
||||
@@ -84,11 +108,15 @@ pub struct AccountRecord {
|
||||
pub access_token_expires_at: Option<DateTime<Utc>>,
|
||||
pub next_refresh_at: Option<DateTime<Utc>>,
|
||||
pub enabled: bool,
|
||||
pub availability: AccountAvailability,
|
||||
pub availability_reason: Option<String>,
|
||||
pub cooldown_until: Option<DateTime<Utc>>,
|
||||
pub availability_observed_at: DateTime<Utc>,
|
||||
pub quota_observed_at: Option<DateTime<Utc>>,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: gateway_core::account::AccountModelAccess,
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub credential_state: CredentialState,
|
||||
pub credential_observed_at: DateTime<Utc>,
|
||||
pub quota: QuotaState,
|
||||
pub last_error_reason: Option<AccountErrorReason>,
|
||||
pub last_error_message: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -173,32 +201,74 @@ pub struct AccountUsageWindowResult {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountPage {
|
||||
pub config_revision: Revision,
|
||||
pub items: Vec<AccountRecord>,
|
||||
pub items: Vec<AccountPageItem>,
|
||||
pub total: u64,
|
||||
pub summary: AccountSummary,
|
||||
}
|
||||
|
||||
/// 同一状态快照下的账号事实与唯一状态投影。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountPageItem {
|
||||
pub account: AccountRecord,
|
||||
pub projection: AccountStatusProjection,
|
||||
}
|
||||
|
||||
/// 统一账号目录的全局状态计数,不受当前筛选和分页影响。
|
||||
///
|
||||
/// 计数与 [`AccountStatus`] 一一对应,由 store 按派生状态聚合。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountSummary {
|
||||
pub total: u64,
|
||||
pub active: u64,
|
||||
pub normal: u64,
|
||||
pub quota_exhausted: u64,
|
||||
pub attention: u64,
|
||||
pub rate_limited: u64,
|
||||
pub disabled: u64,
|
||||
pub error: u64,
|
||||
}
|
||||
|
||||
/// 账号启停写入命令。
|
||||
/// 账号可编辑事实的一次性替换命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetAccountEnabled {
|
||||
pub expected_config_revision: Revision,
|
||||
pub struct UpdateAccount {
|
||||
pub account_id: String,
|
||||
/// 缺省保留备注;空字符串清空备注。
|
||||
pub notes: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
/// 账号更新结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountUpdateResult {
|
||||
pub config_revision: Revision,
|
||||
pub account_id: gateway_core::account::ProviderAccountId,
|
||||
}
|
||||
|
||||
/// 仅修改显式字段的批量账号设置命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BatchUpdateAccounts {
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub concurrency_limit: Option<Option<AccountConcurrencyLimit>>,
|
||||
pub weight: Option<AccountWeight>,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Option<Vec<gateway_core::routing::AccountGroupId>>,
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
/// 批量账号更新结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountsUpdateResult {
|
||||
pub config_revision: Revision,
|
||||
pub account_ids: Vec<gateway_core::account::ProviderAccountId>,
|
||||
}
|
||||
|
||||
/// 账号批量删除命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteAccounts {
|
||||
pub expected_config_revision: Revision,
|
||||
pub account_ids: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -217,12 +287,17 @@ pub enum AccountConnectionTestEvent {
|
||||
Content {
|
||||
text: String,
|
||||
},
|
||||
Completed {
|
||||
account_status: AccountStatus,
|
||||
},
|
||||
Completed,
|
||||
Failed {
|
||||
source: AccountProbeErrorSource,
|
||||
gateway_error_code: GatewayErrorKind,
|
||||
send_state: Option<UpstreamSendState>,
|
||||
message: String,
|
||||
account_status: AccountStatus,
|
||||
provider_error_code: Option<String>,
|
||||
provider_error_type: Option<String>,
|
||||
upstream_status: Option<u16>,
|
||||
upstream_content_type: Option<String>,
|
||||
upstream_body: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
//! 管理员认证、会话和安全审计事实。
|
||||
//! 控制面认证、统一会话和管理员安全审计事实。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::{engine::execution::ClientAuthenticationError, policy::ClientApiKeyId};
|
||||
|
||||
use super::{MutationActor, MutationContext, Revision};
|
||||
|
||||
/// 会话审计使用的稳定管理员标识。
|
||||
#[must_use]
|
||||
pub fn admin_session_actor_ref(admin_user_id: &str) -> String {
|
||||
format!("admin:{admin_user_id}")
|
||||
}
|
||||
|
||||
/// 已认证的管理主体。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AdminPrincipal {
|
||||
@@ -34,47 +41,99 @@ impl AdminRequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理员登录命令。
|
||||
/// 登录方式只决定凭据验证流程,不能直接授予会话权限。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct LoginCommand {
|
||||
pub username: Option<String>,
|
||||
pub password: String,
|
||||
pub source: String,
|
||||
pub enum LoginCommand {
|
||||
Admin {
|
||||
username: Option<String>,
|
||||
password: String,
|
||||
},
|
||||
Key {
|
||||
api_key: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LoginCommand {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("LoginCommand")
|
||||
.field("username", &self.username)
|
||||
.field("password", &"[REDACTED]")
|
||||
.field("source", &self.source)
|
||||
.finish()
|
||||
match self {
|
||||
Self::Admin { username, .. } => formatter
|
||||
.debug_struct("AdminLogin")
|
||||
.field("username", username)
|
||||
.field("password", &"[REDACTED]")
|
||||
.finish(),
|
||||
Self::Key { .. } => formatter
|
||||
.debug_struct("KeyLogin")
|
||||
.field("api_key", &"[REDACTED]")
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录成功后返回的会话事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct LoginResult {
|
||||
pub session_id: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub session: AuthSession,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LoginResult {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("LoginResult")
|
||||
.field("session_id", &"[REDACTED]")
|
||||
.field("session", &self.session)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录状态机可被 API 精确映射的失败类型。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum LoginError {
|
||||
#[error("invalid administrator credentials")]
|
||||
#[error("invalid login credentials")]
|
||||
InvalidCredentials,
|
||||
#[error("administrator login is throttled")]
|
||||
Throttled,
|
||||
#[error("administrator authentication is unavailable")]
|
||||
#[error("too many login attempts")]
|
||||
TooManyAttempts { retry_after_seconds: u64 },
|
||||
#[error("authentication is unavailable")]
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Redis 中可恢复的管理员会话。
|
||||
impl From<ClientAuthenticationError> for LoginError {
|
||||
fn from(error: ClientAuthenticationError) -> Self {
|
||||
match error {
|
||||
ClientAuthenticationError::InvalidKey => Self::InvalidCredentials,
|
||||
ClientAuthenticationError::SnapshotUnavailable => Self::Unavailable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 服务端已验证的身份绑定,不保存密码或原始 API Key。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdminSession {
|
||||
pub admin_user_id: String,
|
||||
pub enum SessionSubject {
|
||||
Admin {
|
||||
admin_user_id: String,
|
||||
credential_fingerprint: String,
|
||||
},
|
||||
Key {
|
||||
client_key_id: ClientApiKeyId,
|
||||
},
|
||||
}
|
||||
|
||||
/// 仅已登录管理员可以修改自己的密码。
|
||||
pub struct ChangePassword {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ChangePassword {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("ChangePassword([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
/// 两种登录方式共用的固定有效期会话。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthSession {
|
||||
pub subject: SessionSubject,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//! 备份领域的稳定错误码。
|
||||
|
||||
/// 配置或参数非法。
|
||||
pub const INVALID_CONFIG: &str = "backup.invalid_config";
|
||||
/// Cron 表达式非法。
|
||||
pub const INVALID_CRON: &str = "backup.invalid_cron";
|
||||
/// IANA 时区非法。
|
||||
pub const INVALID_TIMEZONE: &str = "backup.invalid_timezone";
|
||||
/// 记录不存在。
|
||||
pub const RECORD_NOT_FOUND: &str = "backup.record_not_found";
|
||||
/// 已存在活跃任务,拒绝创建。
|
||||
pub const ACTIVE_TASK_CONFLICT: &str = "backup.active_task_conflict";
|
||||
/// 记录状态与期望不符,无法迁移。
|
||||
pub const STATE_CONFLICT: &str = "backup.state_conflict";
|
||||
/// 已有记录时禁止切换存储目标。
|
||||
pub const STORAGE_IDENTITY_LOCKED: &str = "backup.storage_identity_locked";
|
||||
/// `pg_dump` 非零退出或失败。
|
||||
pub const PG_DUMP_FAILED: &str = "backup.pg_dump_failed";
|
||||
/// 暂存磁盘空间不足。
|
||||
pub const STAGING_SPACE_EXHAUSTED: &str = "backup.staging_space_exhausted";
|
||||
/// S3 认证失败。
|
||||
pub const S3_AUTH_FAILED: &str = "backup.s3_auth_failed";
|
||||
/// S3 权限拒绝。
|
||||
pub const S3_PERMISSION_DENIED: &str = "backup.s3_permission_denied";
|
||||
/// S3 上传失败。
|
||||
pub const S3_UPLOAD_FAILED: &str = "backup.s3_upload_failed";
|
||||
/// 远端对象校验失败。
|
||||
pub const REMOTE_VERIFICATION_FAILED: &str = "backup.remote_verification_failed";
|
||||
/// 任务被取消。
|
||||
pub const CANCELLED: &str = "backup.cancelled";
|
||||
/// 仓储或对象存储暂不可用。
|
||||
pub const STORE_UNAVAILABLE: &str = "backup.store_unavailable";
|
||||
/// 上游 S3 服务返回无效或失败响应。
|
||||
pub const UPSTREAM_FAILURE: &str = "backup.upstream_failure";
|
||||
@@ -0,0 +1,533 @@
|
||||
//! S3 备份领域模型、状态机与稳定错误。
|
||||
//!
|
||||
//! 领域类型只携带事实,不承载基础设施细节。Secret 使用 [`secrecy::SecretString`],
|
||||
//! 全流程不实现 `Serialize`,避免被错误地写入 wire 或日志。管理员身份等审计事实
|
||||
//! 只存在于 `admin_audit_events`,本模型不重复保存。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use secrecy::SecretString;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::PageSize;
|
||||
|
||||
/// 备份触发来源。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BackupTriggerKind {
|
||||
Manual,
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
impl BackupTriggerKind {
|
||||
/// 稳定持久化值。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Manual => "manual",
|
||||
Self::Scheduled => "scheduled",
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析持久化值。
|
||||
#[must_use]
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"manual" => Some(Self::Manual),
|
||||
"scheduled" => Some(Self::Scheduled),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for BackupTriggerKind {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// 备份任务状态机(六状态)。
|
||||
///
|
||||
/// 迁移 `backup_records_lifecycle_ck` 与字段空值语义共同定义合法持久化行;
|
||||
/// [`Self::allows_transition_to`] 只约束领域内的显式状态迁移,两层都必须满足。
|
||||
/// 删除成功后记录行被硬删除,因此不存在 `deleted` 终态。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum BackupStatus {
|
||||
Queued,
|
||||
Dumping,
|
||||
Uploading,
|
||||
Completed,
|
||||
Failed,
|
||||
Deleting,
|
||||
}
|
||||
|
||||
impl BackupStatus {
|
||||
/// 稳定持久化值。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Queued => "queued",
|
||||
Self::Dumping => "dumping",
|
||||
Self::Uploading => "uploading",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
Self::Deleting => "deleting",
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析持久化值。
|
||||
#[must_use]
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"queued" => Some(Self::Queued),
|
||||
"dumping" => Some(Self::Dumping),
|
||||
"uploading" => Some(Self::Uploading),
|
||||
"completed" => Some(Self::Completed),
|
||||
"failed" => Some(Self::Failed),
|
||||
"deleting" => Some(Self::Deleting),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 该状态是否消耗全局活跃名额。
|
||||
#[must_use]
|
||||
pub const fn is_active(self) -> bool {
|
||||
matches!(self, Self::Queued | Self::Dumping | Self::Uploading)
|
||||
}
|
||||
|
||||
/// 该状态是否允许进入删除流程。
|
||||
#[must_use]
|
||||
pub const fn can_be_deleted(self) -> bool {
|
||||
matches!(self, Self::Completed | Self::Failed)
|
||||
}
|
||||
|
||||
/// 领域内允许的显式迁移。
|
||||
#[must_use]
|
||||
pub fn allows_transition_to(self, target: BackupStatus) -> bool {
|
||||
matches!(
|
||||
(self, target),
|
||||
(Self::Queued, Self::Dumping)
|
||||
| (Self::Queued, Self::Failed)
|
||||
| (Self::Dumping, Self::Uploading)
|
||||
| (Self::Dumping, Self::Failed)
|
||||
| (Self::Uploading, Self::Completed)
|
||||
| (Self::Uploading, Self::Failed)
|
||||
| (Self::Completed, Self::Deleting)
|
||||
| (Self::Failed, Self::Deleting)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for BackupStatus {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// S3 存储配置更新命令;`secret_access_key` 为 `None` 表示保留旧值。
|
||||
#[derive(Clone)]
|
||||
pub struct UpdateBackupStorageCommand {
|
||||
pub endpoint: String,
|
||||
pub region: String,
|
||||
pub bucket: String,
|
||||
pub access_key_id: String,
|
||||
pub secret_access_key: Option<SecretString>,
|
||||
pub prefix: String,
|
||||
pub force_path_style: bool,
|
||||
}
|
||||
|
||||
impl fmt::Debug for UpdateBackupStorageCommand {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("UpdateBackupStorageCommand")
|
||||
.field("endpoint", &self.endpoint)
|
||||
.field("region", &self.region)
|
||||
.field("bucket", &self.bucket)
|
||||
.field("access_key_id", &self.access_key_id)
|
||||
.field("secret_access_key", &self.secret_access_key.is_some())
|
||||
.field("prefix", &self.prefix)
|
||||
.field("force_path_style", &self.force_path_style)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 调度配置更新命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateBackupScheduleCommand {
|
||||
pub schedule_enabled: bool,
|
||||
pub cron_expression: String,
|
||||
pub schedule_timezone: String,
|
||||
pub retention_days: u32,
|
||||
pub retention_count: u32,
|
||||
}
|
||||
|
||||
/// 完整保存的备份设置(含 Secret)。
|
||||
///
|
||||
/// 只从仓储读取;不实现 `Serialize`,防止 Secret 进入 wire 或日志。
|
||||
#[derive(Clone)]
|
||||
pub struct BackupSettings {
|
||||
pub storage_revision: u64,
|
||||
pub endpoint: Option<String>,
|
||||
pub region: Option<String>,
|
||||
pub bucket: Option<String>,
|
||||
pub access_key_id: Option<String>,
|
||||
pub secret_access_key: Option<SecretString>,
|
||||
pub prefix: Option<String>,
|
||||
pub force_path_style: bool,
|
||||
pub schedule_enabled: bool,
|
||||
pub cron_expression: Option<String>,
|
||||
pub schedule_timezone: Option<String>,
|
||||
pub retention_days: u32,
|
||||
pub retention_count: u32,
|
||||
pub next_run_at: Option<DateTime<Utc>>,
|
||||
pub last_verified_at: Option<DateTime<Utc>>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for BackupSettings {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("BackupSettings")
|
||||
.field("storage_revision", &self.storage_revision)
|
||||
.field("endpoint", &self.endpoint)
|
||||
.field("region", &self.region)
|
||||
.field("bucket", &self.bucket)
|
||||
.field("access_key_id", &self.access_key_id)
|
||||
.field("secret_access_key", &self.secret_access_key.is_some())
|
||||
.field("prefix", &self.prefix)
|
||||
.field("force_path_style", &self.force_path_style)
|
||||
.field("schedule_enabled", &self.schedule_enabled)
|
||||
.field("cron_expression", &self.cron_expression)
|
||||
.field("schedule_timezone", &self.schedule_timezone)
|
||||
.field("retention_days", &self.retention_days)
|
||||
.field("retention_count", &self.retention_count)
|
||||
.field("next_run_at", &self.next_run_at)
|
||||
.field("last_verified_at", &self.last_verified_at)
|
||||
.field("updated_at", &self.updated_at)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupSettings {
|
||||
/// S3 存储配置是否完整(bucket/region/密钥/前缀齐全)。
|
||||
#[must_use]
|
||||
pub fn storage_configured(&self) -> bool {
|
||||
self.endpoint.is_some()
|
||||
&& self.region.is_some()
|
||||
&& self.bucket.is_some()
|
||||
&& self.access_key_id.is_some()
|
||||
&& self.secret_access_key.is_some()
|
||||
&& self.prefix.is_some()
|
||||
}
|
||||
|
||||
/// 探测是否已对当前 revision 成功;`last_verified_at` 非空即表示当前配置已通过探针。
|
||||
#[must_use]
|
||||
pub fn storage_verified(&self) -> bool {
|
||||
self.last_verified_at.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// 已经校验并可用于对象存储操作的存储配置快照。
|
||||
#[derive(Clone)]
|
||||
pub struct BackupStorageConfig {
|
||||
pub storage_revision: u64,
|
||||
pub endpoint: String,
|
||||
pub region: String,
|
||||
pub bucket: String,
|
||||
pub access_key_id: String,
|
||||
pub secret_access_key: SecretString,
|
||||
pub prefix: String,
|
||||
pub force_path_style: bool,
|
||||
}
|
||||
|
||||
impl fmt::Debug for BackupStorageConfig {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("BackupStorageConfig")
|
||||
.field("storage_revision", &self.storage_revision)
|
||||
.field("endpoint", &self.endpoint)
|
||||
.field("region", &self.region)
|
||||
.field("bucket", &self.bucket)
|
||||
.field("access_key_id", &self.access_key_id)
|
||||
.field("secret_access_key", &"[REDACTED]")
|
||||
.field("prefix", &self.prefix)
|
||||
.field("force_path_style", &self.force_path_style)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupStorageConfig {
|
||||
/// 从完整设置推导对象存储配置;设置不完整时返回 `None`。
|
||||
#[must_use]
|
||||
pub fn from_settings(settings: &BackupSettings) -> Option<Self> {
|
||||
Some(Self {
|
||||
storage_revision: settings.storage_revision,
|
||||
endpoint: settings.endpoint.clone()?,
|
||||
region: settings.region.clone()?,
|
||||
bucket: settings.bucket.clone()?,
|
||||
access_key_id: settings.access_key_id.clone()?,
|
||||
secret_access_key: settings.secret_access_key.clone()?,
|
||||
prefix: settings.prefix.clone()?,
|
||||
force_path_style: settings.force_path_style,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条备份记录。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackupRecord {
|
||||
pub id: String,
|
||||
pub trigger_kind: BackupTriggerKind,
|
||||
pub status: BackupStatus,
|
||||
pub scheduled_at: Option<DateTime<Utc>>,
|
||||
pub object_key: String,
|
||||
pub size_bytes: Option<u64>,
|
||||
pub sha256: Option<String>,
|
||||
pub attempt_count: u32,
|
||||
pub error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
/// 创建时确定的过期时间;手工和计划备份均可设置,到期自动清理。
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 备份记录分页查询。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackupRecordListQuery {
|
||||
pub page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub status: Option<BackupStatus>,
|
||||
pub trigger: Option<BackupTriggerKind>,
|
||||
}
|
||||
|
||||
/// 备份记录分页结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackupRecordPage {
|
||||
pub items: Vec<BackupRecord>,
|
||||
pub total: u64,
|
||||
pub page: u32,
|
||||
pub page_size: PageSize,
|
||||
}
|
||||
|
||||
impl BackupRecordPage {
|
||||
/// 计算总页数;空集合视为一页。
|
||||
#[must_use]
|
||||
pub fn total_pages(&self) -> u32 {
|
||||
let size = u64::from(self.page_size.get());
|
||||
if self.total == 0 {
|
||||
1
|
||||
} else {
|
||||
self.total.div_ceil(size).min(u64::from(u32::MAX)) as u32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建备份任务的持久化数据;id 与对象 key 由领域生成。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackupRecordSeed {
|
||||
pub id: String,
|
||||
pub trigger_kind: BackupTriggerKind,
|
||||
pub scheduled_at: Option<DateTime<Utc>>,
|
||||
pub object_key: String,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 连接测试结果;只携带稳定阶段与脱敏消息。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConnectionTestResult {
|
||||
pub ok: bool,
|
||||
pub stage: &'static str,
|
||||
pub code: Option<&'static str>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 对象存储探测阶段。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConnectionTestStage {
|
||||
PutObject,
|
||||
HeadObject,
|
||||
GetObject,
|
||||
DeleteObject,
|
||||
}
|
||||
|
||||
impl ConnectionTestStage {
|
||||
/// 稳定 wire 值。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::PutObject => "putObject",
|
||||
Self::HeadObject => "headObject",
|
||||
Self::GetObject => "getObject",
|
||||
Self::DeleteObject => "deleteObject",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传到对象存储并期望远端校验的归档 metadata。
|
||||
///
|
||||
/// `size_bytes` 在上传时不写入 metadata,但 `HeadObject` 会返回远端大小,
|
||||
/// 用于与本地归档校验一致。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackupObjectMetadata {
|
||||
pub backup_id: String,
|
||||
pub sha256: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
impl BackupObjectMetadata {
|
||||
/// 构造上传 metadata。
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
backup_id: String,
|
||||
sha256: String,
|
||||
created_at: DateTime<Utc>,
|
||||
size_bytes: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
backup_id,
|
||||
sha256,
|
||||
created_at,
|
||||
size_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 短时下载地址结果。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct DownloadUrlResult {
|
||||
pub url: String,
|
||||
pub file_name: String,
|
||||
pub expires_in: std::time::Duration,
|
||||
}
|
||||
|
||||
impl fmt::Debug for DownloadUrlResult {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("DownloadUrlResult")
|
||||
.field("url", &"[REDACTED]")
|
||||
.field("file_name", &self.file_name)
|
||||
.field("expires_in", &self.expires_in)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 备份基础设施错误:只携带稳定错误码与脱敏消息。
|
||||
///
|
||||
/// 该类型不构成第二套 kind 枚举;`gateway-admin` 边界统一映射为既有
|
||||
/// `AdminErrorKind`。稳定 `error_code` 同时用于任务记录、日志与 UI。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{message}")]
|
||||
pub struct BackupError {
|
||||
code: &'static str,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl BackupError {
|
||||
/// 构造基础设施错误。
|
||||
#[must_use]
|
||||
pub const fn new(code: &'static str, message: String) -> Self {
|
||||
Self { code, message }
|
||||
}
|
||||
|
||||
/// 稳定错误码,例如 `backup.s3_auth_failed`。
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> &'static str {
|
||||
self.code
|
||||
}
|
||||
|
||||
/// 脱敏后的可操作消息。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造稳定错误码常量。
|
||||
pub mod code;
|
||||
|
||||
/// 构建对象 key:`{prefix}/YYYY/MM/DD/codex-proxy-rs_{UTC seconds}_{backup id}.dump`。
|
||||
///
|
||||
/// 规范化规则:prefix 不得为空、不得以前导 `/` 开头、不得包含 `..`、反斜杠或控制字符;
|
||||
/// 输出统一使用 `/` 分隔且不含空段。
|
||||
pub fn build_object_key(
|
||||
prefix: &str,
|
||||
backup_id: &str,
|
||||
at: DateTime<Utc>,
|
||||
) -> Result<String, BackupError> {
|
||||
let prefix = normalize_prefix(prefix)?;
|
||||
Ok(format!(
|
||||
"{prefix}/{}/{:02}/{:02}/codex-proxy-rs_{}_{}.dump",
|
||||
at.format("%Y"),
|
||||
at.format("%m"),
|
||||
at.format("%d"),
|
||||
at.format("%Y%m%d_%H%M%S"),
|
||||
short_backup_id(backup_id),
|
||||
))
|
||||
}
|
||||
|
||||
/// 下载文件名:`codex-proxy-rs_{短 id}.dump`,与对象 key 的文件名一致。
|
||||
#[must_use]
|
||||
pub fn build_download_file_name(backup_id: &str) -> String {
|
||||
format!("codex-proxy-rs_{}.dump", short_backup_id(backup_id))
|
||||
}
|
||||
|
||||
/// 截取备份 id 的可读短标识:去掉 `backup_` 前缀,最多保留 8 位 hex。
|
||||
#[must_use]
|
||||
fn short_backup_id(backup_id: &str) -> String {
|
||||
let hex = backup_id.strip_prefix("backup_").unwrap_or(backup_id);
|
||||
hex.get(..8).unwrap_or(hex).to_owned()
|
||||
}
|
||||
|
||||
/// 规范化并校验对象 prefix。
|
||||
///
|
||||
/// 返回去掉尾部 `/` 的规范 prefix;空串、前导 `/`、`..` 段、反斜杠和控制字符均拒绝。
|
||||
pub fn normalize_prefix(prefix: &str) -> Result<String, BackupError> {
|
||||
if prefix.is_empty() || prefix.starts_with('/') {
|
||||
return Err(BackupError::new(
|
||||
code::INVALID_CONFIG,
|
||||
"存储对象前缀必须非空且不能以前导 / 开头".to_owned(),
|
||||
));
|
||||
}
|
||||
if prefix.bytes().any(|byte| byte.is_ascii_control()) || prefix.contains('\\') {
|
||||
return Err(BackupError::new(
|
||||
code::INVALID_CONFIG,
|
||||
"存储对象前缀包含非法字符".to_owned(),
|
||||
));
|
||||
}
|
||||
if prefix.split('/').any(|segment| segment == "..") {
|
||||
return Err(BackupError::new(
|
||||
code::INVALID_CONFIG,
|
||||
"存储对象前缀不能包含 .. 路径段".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(prefix.trim_end_matches('/').to_owned())
|
||||
}
|
||||
|
||||
/// 生成新的备份记录 id。
|
||||
#[must_use]
|
||||
pub fn new_backup_id() -> String {
|
||||
format!("backup_{}", Uuid::now_v7().simple())
|
||||
}
|
||||
|
||||
/// 从触发类型与计划时间构造可持久化 seed;id 与对象 key 一次生成保持一致。
|
||||
pub fn build_backup_seed(
|
||||
trigger_kind: BackupTriggerKind,
|
||||
scheduled_at: Option<DateTime<Utc>>,
|
||||
prefix: &str,
|
||||
at: DateTime<Utc>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<BackupRecordSeed, BackupError> {
|
||||
let id = new_backup_id();
|
||||
let object_key = build_object_key(prefix, &id, at)?;
|
||||
Ok(BackupRecordSeed {
|
||||
id,
|
||||
trigger_kind,
|
||||
scheduled_at,
|
||||
object_key,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! 管理设置页使用的 Codex 客户端下载信息。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Windows 安装包架构。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ClientArchitecture {
|
||||
X64,
|
||||
Arm64,
|
||||
}
|
||||
|
||||
impl ClientArchitecture {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::X64 => "x64",
|
||||
Self::Arm64 => "arm64",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载地址的可信来源。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClientDownloadSource {
|
||||
MicrosoftStore,
|
||||
OfficialOpenAi,
|
||||
}
|
||||
|
||||
impl ClientDownloadSource {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::MicrosoftStore => "microsoft_store",
|
||||
Self::OfficialOpenAi => "official_openai",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个已经过 Host 校验、可以直接交给浏览器下载的安装包。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientDownloadPackage {
|
||||
pub architecture: ClientArchitecture,
|
||||
pub source: ClientDownloadSource,
|
||||
pub version: Option<String>,
|
||||
pub file_name: String,
|
||||
pub size_bytes: Option<u64>,
|
||||
pub download_url: String,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Codex Desktop Windows 下载解析结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexDesktopWindowsDownloads {
|
||||
pub resolved_at: DateTime<Utc>,
|
||||
pub cached: bool,
|
||||
pub warning: Option<String>,
|
||||
pub packages: Vec<ClientDownloadPackage>,
|
||||
}
|
||||
@@ -5,11 +5,12 @@ use std::{fmt, num::NonZeroU16};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use gateway_core::{
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
routing::ProviderKind,
|
||||
engine::budget::{ClientBudgetLimits, ClientBudgetStatus},
|
||||
policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits},
|
||||
routing::{AccountGroupId, ProviderKind},
|
||||
};
|
||||
|
||||
use super::{AdminModelError, Revision};
|
||||
use super::{AdminModelError, Revision, account_groups::AccountGroupRef};
|
||||
|
||||
/// Client Key 列表保持旧 HTTP 合同允许的完整非零 `u16` 页大小。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
@@ -85,13 +86,16 @@ pub struct ClientKeyListQuery {
|
||||
/// 不含完整明文 Key 的管理投影。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyRecord {
|
||||
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub groups: Vec<AccountGroupRef>,
|
||||
pub provider_kinds: Vec<ProviderKind>,
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub limits: RateLimits,
|
||||
pub budget: ClientBudgetStatus,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
@@ -141,22 +145,25 @@ impl fmt::Debug for ClientKeySecret {
|
||||
/// API 提交的 Client Key 创建命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateClientKey {
|
||||
pub expected_config_revision: Revision,
|
||||
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub custom_key: Option<PlaintextClientApiKey>,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub group_ids: Vec<AccountGroupId>,
|
||||
pub limits: RateLimits,
|
||||
pub budget: ClientBudgetLimits,
|
||||
}
|
||||
|
||||
/// 管理用例生成 ID 与明文后的持久化命令。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct NewClientKey {
|
||||
pub expected_config_revision: Revision,
|
||||
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub group_ids: Vec<AccountGroupId>,
|
||||
pub limits: RateLimits,
|
||||
pub budget: ClientBudgetLimits,
|
||||
pub plaintext: String,
|
||||
}
|
||||
|
||||
@@ -166,7 +173,7 @@ impl fmt::Debug for NewClientKey {
|
||||
.debug_struct("NewClientKey")
|
||||
.field("id", &self.id)
|
||||
.field("name", &self.name)
|
||||
.field("provider_kind", &self.provider_kind)
|
||||
.field("group_ids", &self.group_ids)
|
||||
.field("plaintext", &"[REDACTED]")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
@@ -175,26 +182,41 @@ impl fmt::Debug for NewClientKey {
|
||||
/// 修改 Client Key 的公开策略字段。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateClientKey {
|
||||
pub expected_config_revision: Revision,
|
||||
pub openai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub group_ids: Vec<AccountGroupId>,
|
||||
pub limits: RateLimits,
|
||||
pub daily_limit_usd: Option<gateway_core::metering::Decimal>,
|
||||
pub weekly_limit_usd: Option<gateway_core::metering::Decimal>,
|
||||
}
|
||||
|
||||
/// 修改 Client Key 启用状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetClientKeyEnabled {
|
||||
pub expected_config_revision: Revision,
|
||||
pub id: ClientApiKeyId,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// 管理员选择的金额重置范围。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClientKeyBudgetPeriod {
|
||||
Daily,
|
||||
Weekly,
|
||||
All,
|
||||
}
|
||||
|
||||
/// 清零所选窗口已用金额,保留限额、到期时间与历史费用。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResetClientKeyBudget {
|
||||
pub id: ClientApiKeyId,
|
||||
pub period: ClientKeyBudgetPeriod,
|
||||
}
|
||||
|
||||
/// 删除 Client Key。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteClientKey {
|
||||
pub expected_config_revision: Revision,
|
||||
pub id: ClientApiKeyId,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! 进程内账号导入任务;只保存执行所需输入与可公开的结果。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{MutationContext, provider_credentials::ImportCredentials};
|
||||
|
||||
pub const MAX_IMPORT_TASK_ITEMS: usize = 200;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportTaskInput {
|
||||
pub provider: ProviderKind,
|
||||
pub command: ImportCredentials,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SubmitImportTask {
|
||||
pub submission_id: Uuid,
|
||||
/// API 对完整输入计算摘要;用于区分丢失响应后的重试与修改后的新提交。
|
||||
pub fingerprint: [u8; 32],
|
||||
pub context: MutationContext,
|
||||
pub items: Vec<ImportTaskInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ImportItemStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
Unknown,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
impl ImportItemStatus {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Running => "running",
|
||||
Self::Succeeded => "succeeded",
|
||||
Self::Failed => "failed",
|
||||
Self::Unknown => "unknown",
|
||||
Self::Skipped => "skipped",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImportTaskItem {
|
||||
pub index: usize,
|
||||
pub provider: ProviderKind,
|
||||
pub status: ImportItemStatus,
|
||||
pub account_ids: Vec<ProviderAccountId>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ImportTaskCounts {
|
||||
pub pending: usize,
|
||||
pub running: usize,
|
||||
pub succeeded: usize,
|
||||
pub failed: usize,
|
||||
pub unknown: usize,
|
||||
pub skipped: usize,
|
||||
pub imported_accounts: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImportTaskSummary {
|
||||
pub task_id: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub finished_at: Option<DateTime<Utc>>,
|
||||
pub stop_requested: bool,
|
||||
pub total: usize,
|
||||
pub counts: ImportTaskCounts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImportTaskDetail {
|
||||
pub summary: ImportTaskSummary,
|
||||
pub items: Vec<ImportTaskItem>,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! 单页 Key 用量的查询合同;身份范围不由调用方提供。
|
||||
|
||||
use super::{
|
||||
PageSize,
|
||||
client_keys::ClientKeyRecord,
|
||||
observability::{
|
||||
HealthTimeline, OpsErrorPage, RequestMetricPoint, TimeRange, UsageOverview, UsagePage,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyUsageQuery {
|
||||
pub range: TimeRange,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KeyUsageRecordKind {
|
||||
Success,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyUsageRecordsQuery {
|
||||
pub usage: KeyUsageQuery,
|
||||
pub kind: KeyUsageRecordKind,
|
||||
pub current_page: u32,
|
||||
pub page_size: PageSize,
|
||||
}
|
||||
|
||||
pub struct KeyUsageOverview {
|
||||
pub key: ClientKeyRecord,
|
||||
pub overview: UsageOverview,
|
||||
pub trend: Vec<RequestMetricPoint>,
|
||||
pub health_timeline: HealthTimeline,
|
||||
}
|
||||
|
||||
pub enum KeyUsageRecords {
|
||||
Success(UsagePage),
|
||||
Error(OpsErrorPage),
|
||||
}
|
||||
@@ -2,11 +2,20 @@
|
||||
|
||||
use std::num::{NonZeroU16, NonZeroU64};
|
||||
|
||||
pub mod account_groups;
|
||||
pub mod accounts;
|
||||
pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod client_keys;
|
||||
pub mod import_tasks;
|
||||
pub mod key_usage;
|
||||
pub mod observability;
|
||||
pub mod pricing;
|
||||
pub mod provider_credentials;
|
||||
pub mod proxies;
|
||||
pub mod quota_forecast;
|
||||
pub mod quota_forecast_sampling;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
|
||||
@@ -15,15 +24,17 @@ pub mod system;
|
||||
pub enum AdminErrorKind {
|
||||
Invalid,
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
Conflict,
|
||||
RateLimited,
|
||||
BadGateway,
|
||||
UpstreamResultUnknown,
|
||||
Unavailable,
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// 不携带基础设施细节的管理用例错误。
|
||||
/// 不携带基础设施细节、可安全返回给管理员的管理用例错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{message}")]
|
||||
pub struct AdminError {
|
||||
@@ -45,11 +56,22 @@ impl AdminError {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// 返回已脱敏、可公开给管理员的文案。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn invalid(message: impl Into<String>) -> Self {
|
||||
Self::new(AdminErrorKind::Invalid, message)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::new(AdminErrorKind::Invalid, message)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new(AdminErrorKind::NotFound, message)
|
||||
@@ -60,6 +82,16 @@ impl AdminError {
|
||||
Self::new(AdminErrorKind::Conflict, message)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn bad_gateway(message: impl Into<String>) -> Self {
|
||||
Self::new(AdminErrorKind::BadGateway, message)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn upstream_result_unknown(message: impl Into<String>) -> Self {
|
||||
Self::new(AdminErrorKind::UpstreamResultUnknown, message)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn unavailable(message: impl Into<String>) -> Self {
|
||||
Self::new(AdminErrorKind::Unavailable, message)
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
//! 用量、成本、健康与错误诊断的 UTC 语义事实。
|
||||
|
||||
use std::{num::NonZeroU32, str::FromStr};
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use chrono::{DateTime, TimeDelta, Timelike as _, Utc};
|
||||
|
||||
use super::{AdminModelError, PageSize};
|
||||
|
||||
/// 观测日界所用的东八区(UTC+8)固定偏移秒数。
|
||||
const CHINA_OFFSET_SECONDS: i64 = 8 * 60 * 60;
|
||||
|
||||
/// 将 UTC 时刻截断到东八区(UTC+8)当日零点,返回值仍为 UTC。
|
||||
#[must_use]
|
||||
pub fn china_day_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = (value.timestamp() + CHINA_OFFSET_SECONDS).rem_euclid(24 * 60 * 60);
|
||||
value - TimeDelta::seconds(elapsed) - TimeDelta::nanoseconds(i64::from(value.nanosecond()))
|
||||
}
|
||||
|
||||
/// 外部观测查询的 UTC 时间范围。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TimeRange {
|
||||
@@ -28,29 +38,6 @@ impl TimeRange {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从一开始的观测页码。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PageNumber(NonZeroU32);
|
||||
|
||||
impl PageNumber {
|
||||
#[must_use]
|
||||
pub const fn new(value: NonZeroU32) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn get(self) -> u32 {
|
||||
self.0.get()
|
||||
}
|
||||
}
|
||||
|
||||
/// 观测记录的稳定键集游标。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ObservabilityCursor {
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub stable_id: String,
|
||||
}
|
||||
|
||||
/// 请求结果状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RequestOutcome {
|
||||
@@ -136,8 +123,7 @@ pub struct UsageFilter {
|
||||
pub struct UsageQuery {
|
||||
pub range: TimeRange,
|
||||
pub filter: UsageFilter,
|
||||
pub cursor: Option<ObservabilityCursor>,
|
||||
pub page: PageNumber,
|
||||
pub current_page: u32,
|
||||
pub page_size: PageSize,
|
||||
}
|
||||
|
||||
@@ -154,7 +140,6 @@ pub struct OpsErrorFilter {
|
||||
pub attempt_index: Option<u32>,
|
||||
pub response_id: Option<String>,
|
||||
pub upstream_request_id: Option<String>,
|
||||
pub failure_kind: Option<String>,
|
||||
pub status_code: Option<u16>,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
@@ -164,8 +149,7 @@ pub struct OpsErrorFilter {
|
||||
pub struct OpsErrorQuery {
|
||||
pub range: TimeRange,
|
||||
pub filter: OpsErrorFilter,
|
||||
pub cursor: Option<ObservabilityCursor>,
|
||||
pub page: PageNumber,
|
||||
pub current_page: u32,
|
||||
pub page_size: PageSize,
|
||||
}
|
||||
|
||||
@@ -194,42 +178,22 @@ impl DecimalAmount {
|
||||
/// 精确相加两个 `numeric(20,10)` 金额。
|
||||
#[must_use]
|
||||
pub fn checked_add(&self, other: &Self) -> Option<Self> {
|
||||
let sum = scaled_amount(&self.0)?.checked_add(scaled_amount(&other.0)?)?;
|
||||
Self::from_scaled(sum)
|
||||
Self::from_decimal(self.to_decimal()?.checked_add(other.to_decimal()?)?)
|
||||
}
|
||||
|
||||
/// 将金额按非零请求数均分,保留最多十位小数。
|
||||
#[must_use]
|
||||
pub fn checked_div_u64(&self, divisor: u64) -> Option<Self> {
|
||||
let divisor = u128::from(divisor);
|
||||
(divisor != 0)
|
||||
.then(|| scaled_amount(&self.0)?.checked_div(divisor))
|
||||
.flatten()
|
||||
.and_then(Self::from_scaled)
|
||||
Self::from_decimal(self.to_decimal()?.checked_div_u64(divisor)?)
|
||||
}
|
||||
|
||||
fn from_scaled(value: u128) -> Option<Self> {
|
||||
if value >= 10_u128.pow(20) {
|
||||
return None;
|
||||
}
|
||||
let whole = value / 10_u128.pow(10);
|
||||
let fraction = value % 10_u128.pow(10);
|
||||
let value = if fraction == 0 {
|
||||
whole.to_string()
|
||||
} else {
|
||||
format!("{whole}.{fraction:010}")
|
||||
.trim_end_matches('0')
|
||||
.to_owned()
|
||||
};
|
||||
Some(Self(value))
|
||||
fn to_decimal(&self) -> Option<gateway_core::metering::Decimal> {
|
||||
self.0.parse().ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn scaled_amount(value: &str) -> Option<u128> {
|
||||
let (whole, fraction) = value.split_once('.').unwrap_or((value, ""));
|
||||
let whole = whole.parse::<u128>().ok()?;
|
||||
let fraction = format!("{fraction:0<10}").parse::<u128>().ok()?;
|
||||
whole.checked_mul(10_u128.pow(10))?.checked_add(fraction)
|
||||
fn from_decimal(value: gateway_core::metering::Decimal) -> Option<Self> {
|
||||
Some(Self(value.canonical()))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for DecimalAmount {
|
||||
@@ -320,6 +284,7 @@ pub struct LatencyPercentiles {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderBillingInput {
|
||||
pub upstream_model_id: String,
|
||||
pub service_tier: Option<String>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
@@ -332,9 +297,11 @@ pub struct ProviderBillingInput {
|
||||
/// 控制面仅保留通用事实,具体 Provider 负责校验已持久化总额并恢复标准费用。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageCalculatedBillingFact {
|
||||
pub breakdown: Option<CalculatedBillingBreakdown>,
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub provider_kind: String,
|
||||
pub upstream_model_id: String,
|
||||
pub service_tier: Option<String>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
@@ -345,6 +312,8 @@ pub struct UsageCalculatedBillingFact {
|
||||
/// Provider 已确认的逐项费用与单价。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CalculatedBillingBreakdown {
|
||||
pub image: Option<ImageBillingBreakdown>,
|
||||
pub custom_multiplier_bps: u32,
|
||||
pub input_amount: CurrencyCost,
|
||||
pub output_amount: CurrencyCost,
|
||||
pub cache_read_amount: CurrencyCost,
|
||||
@@ -359,6 +328,16 @@ pub struct CalculatedBillingBreakdown {
|
||||
pub multiplier_percent: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImageBillingBreakdown {
|
||||
pub input_tokens: u64,
|
||||
pub cached_tokens: u64,
|
||||
pub input_amount: CurrencyCost,
|
||||
pub cache_read_amount: CurrencyCost,
|
||||
pub input_price_per_million: CurrencyCost,
|
||||
pub cache_read_price_per_million: CurrencyCost,
|
||||
}
|
||||
|
||||
/// 单次请求的费用语义。
|
||||
///
|
||||
/// Provider 上报费用或无法恢复逐项价格时保留总额;Provider 验证成功后升级为完整分解。
|
||||
@@ -410,6 +389,16 @@ pub struct RequestMetrics {
|
||||
pub max_latency_ms: Option<u64>,
|
||||
pub latency_percentiles: LatencyPercentiles,
|
||||
pub first_token_latency_percentiles: LatencyPercentiles,
|
||||
pub admission_decision_count: u64,
|
||||
pub admission_decision_percentiles: LatencyPercentiles,
|
||||
pub account_selection_wait_count: u64,
|
||||
pub account_selection_wait_percentiles: LatencyPercentiles,
|
||||
pub output_throughput_p10: Option<u64>,
|
||||
pub output_throughput_p50: Option<u64>,
|
||||
pub output_throughput_p90: Option<u64>,
|
||||
pub capacity_sample_count: u64,
|
||||
pub capacity_utilization_avg_basis_points: Option<u64>,
|
||||
pub capacity_utilization_p95_basis_points: Option<u64>,
|
||||
pub cache_eligible_request_count: u64,
|
||||
pub cache_hit_request_count: u64,
|
||||
}
|
||||
@@ -447,18 +436,15 @@ pub struct RequestMetricPoint {
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
}
|
||||
|
||||
/// 账号池容量统计。
|
||||
/// 账号池的统一五态统计。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AccountPoolMetrics {
|
||||
pub total: u64,
|
||||
pub enabled: u64,
|
||||
pub unavailable: u64,
|
||||
pub active: u64,
|
||||
pub expired: u64,
|
||||
pub normal: u64,
|
||||
pub quota_exhausted: u64,
|
||||
pub refreshing: Option<u64>,
|
||||
pub rate_limited: u64,
|
||||
pub disabled: u64,
|
||||
pub banned: u64,
|
||||
pub error: u64,
|
||||
}
|
||||
|
||||
/// Dashboard 中一个账号的模型级用量事实。
|
||||
@@ -489,7 +475,7 @@ pub struct DashboardAccountRequestBucket {
|
||||
pub request_count: u64,
|
||||
}
|
||||
|
||||
/// Dashboard 中一个账号的完整用量与公共状态事实。
|
||||
/// Dashboard 中一个账号的完整用量事实。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DashboardAccountUsage {
|
||||
pub account_id: String,
|
||||
@@ -498,8 +484,8 @@ pub struct DashboardAccountUsage {
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub availability: String,
|
||||
/// Admin 按 Provider 补齐的套餐展示名称。
|
||||
pub plan_type_display: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub input_tokens: Option<u64>,
|
||||
@@ -520,28 +506,91 @@ pub struct DashboardAccountUsage {
|
||||
///
|
||||
/// `None` 表示上游未提供可比较的百分比,不应伪造为零。
|
||||
pub quota_used_percent: Option<f64>,
|
||||
/// Provider 返回的代表窗口;缺失时保留未知语义。
|
||||
pub quota_window: Option<super::provider_credentials::ProviderQuotaWindow>,
|
||||
pub models: Vec<DashboardAccountModelUsage>,
|
||||
}
|
||||
|
||||
/// Dashboard 当前账号池的可重建运行时槽位事实。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DashboardRuntimeSlots {
|
||||
/// 与持久账号池指标相同谓词下可参与调度的账号数。
|
||||
pub active_accounts: u64,
|
||||
/// 继承运行参数默认并发上限的可调度账号数。
|
||||
pub inherited_accounts: u64,
|
||||
/// 已配置账号级并发上限的可调度账号槽位总数。
|
||||
pub overridden_slots: u64,
|
||||
/// Redis 可用时的实时 in-flight 槽位总数;不可用时为 `None`。
|
||||
pub used_slots: Option<u64>,
|
||||
}
|
||||
|
||||
/// 仪表盘所需的公共观测事实。
|
||||
/// 仪表盘卡片脚注展示的全历史累计。
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct DashboardTotals {
|
||||
pub request_count: u64,
|
||||
pub input_tokens: u64,
|
||||
pub cached_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub billing_usd: Option<DecimalAmount>,
|
||||
}
|
||||
|
||||
/// 仪表盘所需的当前区间事实及全历史累计。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DashboardObservation {
|
||||
pub range: TimeRange,
|
||||
pub requests: RequestMetrics,
|
||||
pub attempts: AttemptMetrics,
|
||||
pub totals: DashboardTotals,
|
||||
pub provider_accounts: AccountPoolMetrics,
|
||||
pub trend: Vec<RequestMetricPoint>,
|
||||
pub account_usage: Vec<DashboardAccountUsage>,
|
||||
pub recent_requests: Vec<UsageRecord>,
|
||||
pub recent_requests: Vec<UsageListRecord>,
|
||||
}
|
||||
|
||||
/// 使用记录表格的窄读模型。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageListRecord {
|
||||
pub id: String,
|
||||
pub endpoint: String,
|
||||
pub client_transport: String,
|
||||
pub requested_model_id: Option<String>,
|
||||
pub provider_kind: Option<String>,
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
pub provider_account_email: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
pub upstream_response_model: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub cost_source: String,
|
||||
pub cost_amount: Option<DecimalAmount>,
|
||||
pub cost_currency: Option<String>,
|
||||
pub billing: Option<UsageBilling>,
|
||||
pub transport_decision_wait_ms: Option<u64>,
|
||||
pub connect_ms: Option<u64>,
|
||||
pub headers_ms: Option<u64>,
|
||||
pub first_event_ms: Option<u64>,
|
||||
pub first_reasoning_ms: Option<u64>,
|
||||
pub first_text_ms: Option<u64>,
|
||||
pub first_token_ms: Option<u64>,
|
||||
pub provider_processing_ms: Option<u64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub admission_decision_ms: Option<u64>,
|
||||
pub account_selection_wait_ms: Option<u64>,
|
||||
pub capacity_used_slots: Option<u64>,
|
||||
pub capacity_total_slots: Option<u64>,
|
||||
pub client_ip: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub reasoning_preset: Option<String>,
|
||||
pub subagent_kind: Option<String>,
|
||||
pub compact: bool,
|
||||
pub started_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 一次完整模型请求的公共观测记录。
|
||||
@@ -550,11 +599,14 @@ pub struct UsageRecord {
|
||||
pub id: String,
|
||||
pub client_api_key_ref: String,
|
||||
pub config_revision: u64,
|
||||
pub routing_scope: String,
|
||||
pub routing_group_refs: Vec<String>,
|
||||
pub routing_group_names_snapshot: Vec<String>,
|
||||
pub protocol: String,
|
||||
pub operation: String,
|
||||
pub endpoint: String,
|
||||
pub client_transport: String,
|
||||
pub requested_model_id: String,
|
||||
pub requested_model_id: Option<String>,
|
||||
pub provider_kind: Option<String>,
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
@@ -564,6 +616,8 @@ pub struct UsageRecord {
|
||||
pub upstream_transport: Option<String>,
|
||||
pub http_version: Option<String>,
|
||||
pub websocket_pool: Option<String>,
|
||||
pub upstream_response_model: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
/// Provider 已筛选的专有观测 JSON;管理领域保持不透明。
|
||||
pub provider_metadata_json: Option<String>,
|
||||
pub attempt_count: u32,
|
||||
@@ -600,6 +654,10 @@ pub struct UsageRecord {
|
||||
pub first_token_ms: Option<u64>,
|
||||
pub provider_processing_ms: Option<u64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub admission_decision_ms: Option<u64>,
|
||||
pub account_selection_wait_ms: Option<u64>,
|
||||
pub capacity_used_slots: Option<u64>,
|
||||
pub capacity_total_slots: Option<u64>,
|
||||
pub client_ip: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
@@ -617,9 +675,10 @@ pub struct UsageRecord {
|
||||
/// 用量分页结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsagePage {
|
||||
pub items: Vec<UsageRecord>,
|
||||
pub items: Vec<UsageListRecord>,
|
||||
pub current_page: u32,
|
||||
pub page_size: u16,
|
||||
pub total: u64,
|
||||
pub next_cursor: Option<ObservabilityCursor>,
|
||||
}
|
||||
|
||||
/// 请求中的一次上游尝试或运维事件。
|
||||
@@ -632,6 +691,9 @@ pub struct UsageAttempt {
|
||||
pub operation: String,
|
||||
pub provider_kind: Option<String>,
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
pub provider_account_email: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
pub upstream_send_state: Option<String>,
|
||||
@@ -659,6 +721,8 @@ pub struct UsageAttempt {
|
||||
/// 一条请求及其全部尝试。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageDetail {
|
||||
pub trace: Option<serde_json::Value>,
|
||||
pub related_requests: Vec<serde_json::Value>,
|
||||
pub request: UsageRecord,
|
||||
pub attempts: Vec<UsageAttempt>,
|
||||
}
|
||||
@@ -692,6 +756,7 @@ pub struct UsageSummary {
|
||||
/// 单个诊断维度值的聚合结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiagnosticObservation {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
@@ -699,6 +764,10 @@ pub struct DiagnosticObservation {
|
||||
pub attempt_count: u64,
|
||||
pub total_tokens: u64,
|
||||
pub average_latency_ms: Option<u64>,
|
||||
pub latency_p95_ms: Option<u64>,
|
||||
pub first_token_p95_ms: Option<u64>,
|
||||
pub non_completion_count: u64,
|
||||
pub retry_count: u64,
|
||||
pub cost_coverage: CostCoverage,
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
}
|
||||
@@ -713,18 +782,47 @@ pub struct OpsError {
|
||||
pub client_api_key_ref: Option<String>,
|
||||
pub component: String,
|
||||
pub operation: String,
|
||||
pub protocol: Option<String>,
|
||||
pub client_transport: Option<String>,
|
||||
pub requested_model_id: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub endpoint: Option<String>,
|
||||
pub provider_kind: Option<String>,
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
pub provider_account_email: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
pub failure_kind: String,
|
||||
pub status_code: Option<u16>,
|
||||
pub upstream_send_state: Option<String>,
|
||||
pub client_status_code: Option<u16>,
|
||||
pub upstream_status_code: Option<u16>,
|
||||
pub provider_error_code: Option<String>,
|
||||
pub client_response_id: Option<String>,
|
||||
pub upstream_request_id: Option<String>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub message: String,
|
||||
pub occurrence_count: u32,
|
||||
pub raw_upstream_error: Option<String>,
|
||||
pub client_ip: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub reasoning_preset: Option<String>,
|
||||
pub request_kind: Option<String>,
|
||||
pub subagent_kind: Option<String>,
|
||||
pub compact: Option<bool>,
|
||||
pub continuation_affinity_hash: Option<String>,
|
||||
pub continuation_previous_response_id_hash: Option<String>,
|
||||
pub continuation_unavailable_reason: Option<String>,
|
||||
pub upstream_connection_id: Option<String>,
|
||||
pub upstream_connection_exit_reason: Option<String>,
|
||||
pub upstream_connection_age_ms: Option<u64>,
|
||||
pub upstream_connection_idle_ms: Option<u64>,
|
||||
pub recovery_request_id: Option<String>,
|
||||
pub recovered_at: Option<DateTime<Utc>>,
|
||||
pub recovery_attempt_count: u32,
|
||||
pub recovery_retry_delay_ms: Option<u64>,
|
||||
pub recovery_total_latency_ms: Option<u64>,
|
||||
pub occurred_at: DateTime<Utc>,
|
||||
pub stable_sort_id: String,
|
||||
}
|
||||
@@ -733,8 +831,9 @@ pub struct OpsError {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpsErrorPage {
|
||||
pub items: Vec<OpsError>,
|
||||
pub current_page: u32,
|
||||
pub page_size: u16,
|
||||
pub total: u64,
|
||||
pub next_cursor: Option<ObservabilityCursor>,
|
||||
}
|
||||
|
||||
/// 仪表盘趋势指标。
|
||||
@@ -773,6 +872,9 @@ pub struct TrendSummary {
|
||||
pub average_latency_ms: Option<u64>,
|
||||
pub max_latency_ms: Option<u64>,
|
||||
pub min_latency_ms: Option<u64>,
|
||||
pub peak_first_token_p95_ms: Option<f64>,
|
||||
pub peak_latency_p95_ms: Option<f64>,
|
||||
pub minimum_output_throughput_p50: Option<u64>,
|
||||
pub success_rate: Option<f64>,
|
||||
pub cache_hit_request_rate: Option<f64>,
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
@@ -921,6 +1023,7 @@ pub struct DashboardResult {
|
||||
/// 洞察健康时间点的语义结果。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageInsightsHealthPoint {
|
||||
pub total_requests: u64,
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub success_requests: u64,
|
||||
pub failed_requests: u64,
|
||||
@@ -940,6 +1043,7 @@ pub struct UsageInsightsHealth {
|
||||
pub incomplete_requests: u64,
|
||||
pub caller_error_requests: u64,
|
||||
pub success_rate: f64,
|
||||
pub completion_rate: f64,
|
||||
pub points: Vec<UsageInsightsHealthPoint>,
|
||||
}
|
||||
|
||||
@@ -949,6 +1053,13 @@ pub struct UsageInsightsPerformancePoint {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub latency_percentiles: LatencyPercentiles,
|
||||
pub first_token_latency_percentiles: LatencyPercentiles,
|
||||
pub admission_decision_percentiles: LatencyPercentiles,
|
||||
pub account_selection_wait_percentiles: LatencyPercentiles,
|
||||
pub output_throughput_p10: Option<u64>,
|
||||
pub output_throughput_p50: Option<u64>,
|
||||
pub output_throughput_p90: Option<u64>,
|
||||
pub capacity_utilization_avg_basis_points: Option<u64>,
|
||||
pub capacity_utilization_p95_basis_points: Option<u64>,
|
||||
}
|
||||
|
||||
/// 洞察性能汇总与可观测覆盖率。
|
||||
@@ -958,6 +1069,16 @@ pub struct UsageInsightsPerformance {
|
||||
pub first_token_latency_percentiles: LatencyPercentiles,
|
||||
pub latency_coverage: f64,
|
||||
pub first_token_coverage: f64,
|
||||
pub admission_decision_percentiles: LatencyPercentiles,
|
||||
pub account_selection_wait_percentiles: LatencyPercentiles,
|
||||
pub admission_decision_coverage: f64,
|
||||
pub account_selection_wait_coverage: f64,
|
||||
pub output_throughput_p10: Option<u64>,
|
||||
pub output_throughput_p50: Option<u64>,
|
||||
pub output_throughput_p90: Option<u64>,
|
||||
pub capacity_utilization_avg_basis_points: Option<u64>,
|
||||
pub capacity_utilization_p95_basis_points: Option<u64>,
|
||||
pub capacity_coverage: f64,
|
||||
pub points: Vec<UsageInsightsPerformancePoint>,
|
||||
}
|
||||
|
||||
@@ -971,6 +1092,8 @@ pub struct UsageInsightsCostPoint {
|
||||
pub total_tokens: u64,
|
||||
pub estimated_cost: Option<DecimalAmount>,
|
||||
pub standard_cost: Option<DecimalAmount>,
|
||||
pub no_cache_cost: Option<DecimalAmount>,
|
||||
pub cache_savings: Option<DecimalAmount>,
|
||||
pub cached_token_rate: f64,
|
||||
pub cache_hit_request_rate: Option<f64>,
|
||||
}
|
||||
@@ -980,7 +1103,11 @@ pub struct UsageInsightsCostPoint {
|
||||
pub struct UsageInsightsCost {
|
||||
pub estimated_cost: Option<DecimalAmount>,
|
||||
pub standard_cost: Option<DecimalAmount>,
|
||||
pub no_cache_cost: Option<DecimalAmount>,
|
||||
pub cache_savings: Option<DecimalAmount>,
|
||||
pub tier_premium: Option<DecimalAmount>,
|
||||
pub cost_per_request: Option<DecimalAmount>,
|
||||
pub cost_per_successful_request: Option<DecimalAmount>,
|
||||
pub tokens_per_request: f64,
|
||||
pub cached_token_rate: f64,
|
||||
pub cache_hit_request_rate: Option<f64>,
|
||||
@@ -1007,6 +1134,7 @@ pub struct UsageInsights {
|
||||
/// 已完成分母计算的诊断项。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DiagnosticsItem {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
@@ -1014,6 +1142,13 @@ pub struct DiagnosticsItem {
|
||||
pub error_rate: f64,
|
||||
pub request_share: f64,
|
||||
pub average_latency_ms: Option<u64>,
|
||||
pub latency_p95_ms: Option<u64>,
|
||||
pub first_token_p95_ms: Option<u64>,
|
||||
pub non_completion_count: u64,
|
||||
pub non_completion_rate: f64,
|
||||
pub retry_count: u64,
|
||||
pub retry_rate: f64,
|
||||
pub impact_score: f64,
|
||||
pub estimated_cost: Option<DecimalAmount>,
|
||||
pub attempt_count: u64,
|
||||
pub total_tokens: u64,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! 全局模型定价的管理合同。
|
||||
|
||||
use gateway_core::metering::{ModelPriceOverride, PricingOverrides};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PricingCatalog {
|
||||
pub defaults: PricingOverrides,
|
||||
pub overrides: PricingOverrides,
|
||||
pub synced: PricingOverrides,
|
||||
pub synced_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct StoredPricing {
|
||||
pub overrides: PricingOverrides,
|
||||
pub synced: PricingOverrides,
|
||||
pub synced_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PricingSyncPreview {
|
||||
pub prices: PricingOverrides,
|
||||
pub skipped: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SyncPricing {
|
||||
pub preview: PricingSyncPreview,
|
||||
pub models: BTreeMap<String, BTreeSet<String>>,
|
||||
}
|
||||
|
||||
pub type PricingSyncChanges = BTreeMap<String, BTreeMap<String, Option<ModelPriceOverride>>>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PricingChange {
|
||||
Replace(ModelPriceOverride),
|
||||
Multiplier(u32),
|
||||
Reset,
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdatePricing {
|
||||
pub provider: String,
|
||||
pub models: Vec<String>,
|
||||
pub change: PricingChange,
|
||||
}
|
||||
|
||||
pub type ProviderPricingCatalog = BTreeMap<String, ModelPriceOverride>;
|
||||
@@ -1,16 +1,24 @@
|
||||
//! Provider 管理能力交换的中立 Command 与 Result。
|
||||
|
||||
use std::fmt;
|
||||
use std::{fmt, pin::Pin};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use futures::Stream;
|
||||
use gateway_core::{
|
||||
engine::credential::{OpaqueProviderData, ProviderAccountId},
|
||||
account::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
|
||||
routing::{ProviderKind, UpstreamModelId},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
MutationActor, MutationContext, PageSize, Revision,
|
||||
accounts::{AccountAvailability, AccountRecord, AccountStatus, AccountSummary, AccountUsage},
|
||||
AdminError, MutationActor, MutationContext, Revision,
|
||||
accounts::{
|
||||
AccountImportSettings, AccountRecord, AccountSummary, AccountUsage, CredentialState,
|
||||
},
|
||||
};
|
||||
|
||||
/// Provider-owned JSON;公共层只搬运且 Debug 不输出值。
|
||||
@@ -41,56 +49,6 @@ impl fmt::Debug for ProviderDocument {
|
||||
}
|
||||
}
|
||||
|
||||
/// Credential 列表稳定游标。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialCursor {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub account_id: ProviderAccountId,
|
||||
}
|
||||
|
||||
/// Provider credential 列表查询。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialListQuery {
|
||||
pub availability: Option<CredentialAvailabilityFilter>,
|
||||
pub enabled: Option<bool>,
|
||||
pub window: CredentialListWindow,
|
||||
}
|
||||
|
||||
/// Credential 可用性筛选;支持一个 wire 值对应多个持久状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CredentialAvailabilityFilter {
|
||||
Exact(AccountAvailability),
|
||||
AnyOf(Vec<AccountAvailability>),
|
||||
}
|
||||
|
||||
impl CredentialAvailabilityFilter {
|
||||
#[must_use]
|
||||
pub fn matches(&self, availability: AccountAvailability) -> bool {
|
||||
match self {
|
||||
Self::Exact(expected) => *expected == availability,
|
||||
Self::AnyOf(expected) => expected.contains(&availability),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Credential 目录的集合窗口;完整列表与游标分页互斥。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CredentialListWindow {
|
||||
All,
|
||||
Page {
|
||||
cursor: Option<CredentialCursor>,
|
||||
page_size: PageSize,
|
||||
},
|
||||
}
|
||||
|
||||
/// Provider credential 列表。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialPage {
|
||||
pub config_revision: Revision,
|
||||
pub items: Vec<AccountRecord>,
|
||||
pub next_cursor: Option<CredentialCursor>,
|
||||
}
|
||||
|
||||
/// Provider credential 详情。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialDetails {
|
||||
@@ -100,8 +58,9 @@ pub struct CredentialDetails {
|
||||
|
||||
/// Provider 正式文档批量导入命令。
|
||||
pub struct ImportCredentials {
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
pub context: MutationContext,
|
||||
pub expected_config_revision: Revision,
|
||||
pub document: ProviderDocument,
|
||||
}
|
||||
|
||||
@@ -110,7 +69,6 @@ impl fmt::Debug for ImportCredentials {
|
||||
formatter
|
||||
.debug_struct("ImportCredentials")
|
||||
.field("context", &self.context)
|
||||
.field("expected_config_revision", &self.expected_config_revision)
|
||||
.field("document", &self.document)
|
||||
.finish()
|
||||
}
|
||||
@@ -125,6 +83,7 @@ pub struct CredentialImportResult {
|
||||
|
||||
/// Provider 解析导入文档时只接收不透明文档,不接触 revision 或审计上下文。
|
||||
pub struct PrepareCredentialImport {
|
||||
pub default_outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub document: ProviderDocument,
|
||||
}
|
||||
|
||||
@@ -140,11 +99,13 @@ impl fmt::Debug for PrepareCredentialImport {
|
||||
/// Provider 已验证、可由 Store 原子创建的一份 credential。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PreparedCredentialCreate {
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub account_id: ProviderAccountId,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub upstream_user_id: String,
|
||||
pub upstream_user_id: Option<String>,
|
||||
pub upstream_account_id: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
pub authentication_kind: String,
|
||||
@@ -153,10 +114,8 @@ pub struct PreparedCredentialCreate {
|
||||
pub access_token_expires_at: Option<DateTime<Utc>>,
|
||||
pub next_refresh_at: Option<DateTime<Utc>>,
|
||||
pub enabled: bool,
|
||||
pub availability: AccountAvailability,
|
||||
pub availability_reason: Option<String>,
|
||||
pub cooldown_until: Option<DateTime<Utc>>,
|
||||
pub availability_observed_at: DateTime<Utc>,
|
||||
pub credential_state: CredentialState,
|
||||
pub credential_observed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Provider 对一份导入文档的完整验证结果。
|
||||
@@ -169,17 +128,11 @@ pub struct PreparedCredentialImport {
|
||||
/// Admin 交给 Store 的导入事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CredentialImportCommit {
|
||||
pub expected_config_revision: Revision,
|
||||
pub outbound_proxy: Option<super::proxies::ImportProxyBinding>,
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
pub prepared: PreparedCredentialImport,
|
||||
}
|
||||
|
||||
/// 可选的重新授权目标。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReauthorizationTarget {
|
||||
pub account_id: ProviderAccountId,
|
||||
pub credential_revision: Revision,
|
||||
}
|
||||
|
||||
/// OAuth pending owner 的中立身份;不编码具体 Provider 的 Redis key 或 JSON。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum AuthorizationOwner {
|
||||
@@ -240,47 +193,41 @@ impl fmt::Debug for AuthorizationOwnerBinding {
|
||||
}
|
||||
|
||||
/// OAuth 完成时应创建新账号还是 CAS 更新既有 credential。
|
||||
///
|
||||
/// 重新授权只绑定稳定的账号身份:credential revision 由服务端在临近写入时读取,
|
||||
/// 长流程期间的后台刷新不得让恢复操作失效。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthorizationMutationTarget {
|
||||
Create {
|
||||
name: String,
|
||||
},
|
||||
Reauthorize {
|
||||
account_id: ProviderAccountId,
|
||||
expected_credential_revision: Revision,
|
||||
},
|
||||
Create { name: String },
|
||||
Reauthorize { account_id: ProviderAccountId },
|
||||
}
|
||||
|
||||
/// 必须完整进入 Provider opaque pending payload、并在 complete 后原样恢复的事务信封。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PendingAuthorizationMutation {
|
||||
expected_config_revision: Revision,
|
||||
provider_kind: ProviderKind,
|
||||
target: AuthorizationMutationTarget,
|
||||
owner_binding: AuthorizationOwnerBinding,
|
||||
outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
outbound_proxy_id: Option<String>,
|
||||
}
|
||||
|
||||
impl PendingAuthorizationMutation {
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
expected_config_revision: Revision,
|
||||
provider_kind: ProviderKind,
|
||||
target: AuthorizationMutationTarget,
|
||||
owner_binding: AuthorizationOwnerBinding,
|
||||
) -> Self {
|
||||
Self {
|
||||
expected_config_revision,
|
||||
provider_kind,
|
||||
target,
|
||||
owner_binding,
|
||||
outbound_proxy: None,
|
||||
outbound_proxy_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn expected_config_revision(&self) -> Revision {
|
||||
self.expected_config_revision
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn provider_kind(&self) -> &ProviderKind {
|
||||
&self.provider_kind
|
||||
@@ -295,15 +242,155 @@ impl PendingAuthorizationMutation {
|
||||
pub const fn owner_binding(&self) -> &AuthorizationOwnerBinding {
|
||||
&self.owner_binding
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_outbound_proxy(
|
||||
mut self,
|
||||
proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
) -> Self {
|
||||
self.outbound_proxy = proxy;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn outbound_proxy(&self) -> Option<&gateway_core::account::OutboundProxy> {
|
||||
self.outbound_proxy.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_outbound_proxy_id(mut self, id: Option<String>) -> Self {
|
||||
self.outbound_proxy_id = id;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn outbound_proxy_id(&self) -> Option<&str> {
|
||||
self.outbound_proxy_id.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl PendingAuthorizationMutation {
|
||||
/// 编码 v1 中立事务字段;版本号的位置和 Provider 私有外层文档由 adapter 保持。
|
||||
#[must_use]
|
||||
pub fn to_storage_v1(&self) -> Map<String, Value> {
|
||||
let target = match &self.target {
|
||||
AuthorizationMutationTarget::Create { name } => {
|
||||
serde_json::json!({"kind": "create", "name": name})
|
||||
}
|
||||
AuthorizationMutationTarget::Reauthorize { account_id } => {
|
||||
serde_json::json!({"kind": "reauthorize", "account_id": account_id.to_string()})
|
||||
}
|
||||
};
|
||||
let owner = match self.owner_binding.owner() {
|
||||
AuthorizationOwner::AdminSession { admin_user_id } => {
|
||||
serde_json::json!({"kind": "admin_session", "admin_user_id": admin_user_id})
|
||||
}
|
||||
AuthorizationOwner::AdminApiKey => serde_json::json!({"kind": "admin_api_key"}),
|
||||
AuthorizationOwner::System => serde_json::json!({"kind": "system"}),
|
||||
};
|
||||
let mut document = Map::from_iter([
|
||||
(
|
||||
"provider_kind".to_owned(),
|
||||
Value::String(self.provider_kind.as_str().to_owned()),
|
||||
),
|
||||
("target".to_owned(), target),
|
||||
("owner".to_owned(), owner),
|
||||
(
|
||||
"started_request_id".to_owned(),
|
||||
Value::String(self.owner_binding.started_request_id().to_owned()),
|
||||
),
|
||||
]);
|
||||
if let Some(proxy) = &self.outbound_proxy {
|
||||
document.insert(
|
||||
"outbound_proxy_url".to_owned(),
|
||||
Value::String(proxy.expose_url().to_owned()),
|
||||
);
|
||||
}
|
||||
if let Some(id) = &self.outbound_proxy_id {
|
||||
document.insert("outbound_proxy_id".to_owned(), Value::String(id.clone()));
|
||||
}
|
||||
document
|
||||
}
|
||||
|
||||
/// 恢复 adapter 已确认版本为 v1 的中立事务字段。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 文档字段、Provider kind 或账号身份非法时返回错误,不携带原始 pending 内容。
|
||||
pub fn from_storage_v1(value: Value) -> Result<Self, AdminError> {
|
||||
let invalid = || AdminError::invalid("invalid pending authorization mutation");
|
||||
let document: StoredAuthorizationMutationV1 =
|
||||
serde_json::from_value(value).map_err(|_| invalid())?;
|
||||
let provider_kind = ProviderKind::new(document.provider_kind).map_err(|_| invalid())?;
|
||||
let target = match document.target {
|
||||
StoredAuthorizationTargetV1::Create { name } => {
|
||||
AuthorizationMutationTarget::Create { name }
|
||||
}
|
||||
StoredAuthorizationTargetV1::Reauthorize { account_id } => {
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id: ProviderAccountId::new(account_id).map_err(|_| invalid())?,
|
||||
}
|
||||
}
|
||||
};
|
||||
let owner = match document.owner {
|
||||
StoredAuthorizationOwnerV1::AdminSession { admin_user_id } => {
|
||||
AuthorizationOwner::AdminSession { admin_user_id }
|
||||
}
|
||||
StoredAuthorizationOwnerV1::AdminApiKey => AuthorizationOwner::AdminApiKey,
|
||||
StoredAuthorizationOwnerV1::System => AuthorizationOwner::System,
|
||||
};
|
||||
let proxy = document
|
||||
.outbound_proxy_url
|
||||
.as_deref()
|
||||
.map(gateway_core::account::OutboundProxy::parse)
|
||||
.transpose()
|
||||
.map_err(|_| invalid())?;
|
||||
Ok(Self::new(
|
||||
provider_kind,
|
||||
target,
|
||||
AuthorizationOwnerBinding {
|
||||
owner,
|
||||
started_request_id: document.started_request_id,
|
||||
},
|
||||
)
|
||||
.with_outbound_proxy(proxy)
|
||||
.with_outbound_proxy_id(document.outbound_proxy_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct StoredAuthorizationMutationV1 {
|
||||
provider_kind: String,
|
||||
target: StoredAuthorizationTargetV1,
|
||||
owner: StoredAuthorizationOwnerV1,
|
||||
started_request_id: String,
|
||||
#[serde(default)]
|
||||
outbound_proxy_url: Option<String>,
|
||||
#[serde(default)]
|
||||
outbound_proxy_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
enum StoredAuthorizationTargetV1 {
|
||||
Create { name: String },
|
||||
Reauthorize { account_id: String },
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
enum StoredAuthorizationOwnerV1 {
|
||||
AdminSession { admin_user_id: String },
|
||||
AdminApiKey,
|
||||
System,
|
||||
}
|
||||
|
||||
/// 启动 Provider OAuth Authorization Code 流程。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StartAuthorization {
|
||||
pub context: MutationContext,
|
||||
pub expected_config_revision: Revision,
|
||||
pub name: String,
|
||||
pub reauthorization: Option<ReauthorizationTarget>,
|
||||
pub reauthorization: Option<ProviderAccountId>,
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
/// OAuth 流程启动结果。
|
||||
@@ -322,6 +409,20 @@ pub trait CredentialCommitGuard: Send + 'static {
|
||||
fn finish(self: Box<Self>);
|
||||
}
|
||||
|
||||
/// OAuth pending claim 在 Store 事务结果确定后执行的显式结算动作。
|
||||
///
|
||||
/// OAuth provider 必须在 credential 准备阶段持有 claim:Store 提交成功后消费 flow,提交或
|
||||
/// 校验失败后释放 flow。异步结算不能依赖 `Drop`,否则请求提前返回会让同一授权流无谓地
|
||||
/// 进入短暂的不可重试状态。
|
||||
#[async_trait]
|
||||
pub trait AuthorizationCommitGuard: Send + 'static {
|
||||
/// Store 已提交 OAuth credential 后消费对应的一次性 flow。
|
||||
async fn commit(self: Box<Self>) -> Result<(), AdminError>;
|
||||
|
||||
/// Store 未提交 OAuth credential 时释放 claim,允许同一 flow 重试。
|
||||
async fn abort(self: Box<Self>) -> Result<(), AdminError>;
|
||||
}
|
||||
|
||||
/// OAuth complete 后由 Provider 返回的准备结果;Store 仍是唯一提交者。
|
||||
pub enum PreparedAuthorizationCredential {
|
||||
Create(PreparedCredentialCreate),
|
||||
@@ -332,6 +433,7 @@ pub enum PreparedAuthorizationCredential {
|
||||
pub struct PreparedAuthorizationCommit {
|
||||
pub pending: PendingAuthorizationMutation,
|
||||
pub credential: PreparedAuthorizationCredential,
|
||||
authorization_guard: Option<Box<dyn AuthorizationCommitGuard>>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for PreparedAuthorizationCommit {
|
||||
@@ -354,13 +456,45 @@ pub enum AuthorizationCredentialCommit {
|
||||
/// Admin 交给 Store 的 OAuth 原子事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AuthorizationCommit {
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
pub pending: PendingAuthorizationMutation,
|
||||
pub credential: AuthorizationCredentialCommit,
|
||||
}
|
||||
|
||||
/// OAuth 准备结果拆解后的 Store 命令与两个结算 guard。
|
||||
///
|
||||
/// Store 成功时先结束 credential guard,再消费 OAuth claim;失败时丢弃 credential guard 并
|
||||
/// 释放 OAuth claim,使同一授权流可以重试。
|
||||
pub(crate) struct AuthorizationCommitSettlement {
|
||||
pub(crate) command: AuthorizationCommit,
|
||||
pub(crate) credential_guard: Option<Box<dyn CredentialCommitGuard>>,
|
||||
pub(crate) authorization_guard: Option<Box<dyn AuthorizationCommitGuard>>,
|
||||
}
|
||||
|
||||
impl PreparedAuthorizationCommit {
|
||||
#[must_use]
|
||||
pub fn into_commit(self) -> (AuthorizationCommit, Option<Box<dyn CredentialCommitGuard>>) {
|
||||
pub fn new(
|
||||
pending: PendingAuthorizationMutation,
|
||||
credential: PreparedAuthorizationCredential,
|
||||
) -> Self {
|
||||
Self {
|
||||
pending,
|
||||
credential,
|
||||
authorization_guard: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 让 OAuth claim 覆盖准备完成到 Store 事务结算之间的全部窗口。
|
||||
#[must_use]
|
||||
pub fn with_authorization_guard(mut self, guard: Box<dyn AuthorizationCommitGuard>) -> Self {
|
||||
self.authorization_guard = Some(guard);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn into_commit(
|
||||
self,
|
||||
settings: Option<AccountImportSettings>,
|
||||
) -> AuthorizationCommitSettlement {
|
||||
let (credential, guard) = match self.credential {
|
||||
PreparedAuthorizationCredential::Create(credential) => {
|
||||
(AuthorizationCredentialCommit::Create(credential), None)
|
||||
@@ -373,19 +507,35 @@ impl PreparedAuthorizationCommit {
|
||||
)
|
||||
}
|
||||
};
|
||||
(
|
||||
AuthorizationCommit {
|
||||
AuthorizationCommitSettlement {
|
||||
command: AuthorizationCommit {
|
||||
settings,
|
||||
pending: self.pending,
|
||||
credential,
|
||||
},
|
||||
guard,
|
||||
)
|
||||
credential_guard: guard,
|
||||
authorization_guard: self.authorization_guard,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn abort(self) -> Result<(), AdminError> {
|
||||
let AuthorizationCommitSettlement {
|
||||
credential_guard,
|
||||
authorization_guard,
|
||||
..
|
||||
} = self.into_commit(None);
|
||||
drop(credential_guard);
|
||||
if let Some(guard) = authorization_guard {
|
||||
guard.abort().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成 Provider OAuth 流程。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct CompleteAuthorization {
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
pub context: MutationContext,
|
||||
pub flow_id: String,
|
||||
pub callback_url: String,
|
||||
@@ -406,7 +556,6 @@ impl fmt::Debug for CompleteAuthorization {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialMutation {
|
||||
pub context: MutationContext,
|
||||
pub expected_config_revision: Revision,
|
||||
pub account_id: ProviderAccountId,
|
||||
}
|
||||
|
||||
@@ -422,7 +571,6 @@ pub struct CredentialMutationResult {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialDeletion {
|
||||
pub context: MutationContext,
|
||||
pub expected_config_revision: Revision,
|
||||
pub account_ids: Vec<ProviderAccountId>,
|
||||
}
|
||||
|
||||
@@ -436,14 +584,13 @@ pub struct CredentialDeletionResult {
|
||||
/// Provider-owned token 轮换命令。
|
||||
pub struct RotateCredential {
|
||||
pub mutation: CredentialMutation,
|
||||
pub expected_credential_revision: Revision,
|
||||
pub provider_material: ProviderDocument,
|
||||
pub settings: Option<super::accounts::UpdateAccount>,
|
||||
}
|
||||
|
||||
/// Provider 校验手工轮换材料时所需的非事务输入。
|
||||
pub struct PrepareCredentialRotation {
|
||||
pub account: AccountRecord,
|
||||
pub expected_credential_revision: Revision,
|
||||
pub provider_material: ProviderDocument,
|
||||
}
|
||||
|
||||
@@ -452,10 +599,6 @@ impl fmt::Debug for PrepareCredentialRotation {
|
||||
formatter
|
||||
.debug_struct("PrepareCredentialRotation")
|
||||
.field("account", &self.account)
|
||||
.field(
|
||||
"expected_credential_revision",
|
||||
&self.expected_credential_revision,
|
||||
)
|
||||
.field("provider_material", &self.provider_material)
|
||||
.finish()
|
||||
}
|
||||
@@ -467,9 +610,12 @@ pub struct PreparedCredentialRotationFacts {
|
||||
pub account_id: ProviderAccountId,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub expected_credential_revision: Revision,
|
||||
pub replacement_identity: Option<ProviderAccountIdentity>,
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
/// Token 刷新保留提交时的资料,不以准备阶段的副本覆盖新套餐。
|
||||
pub preserve_profile: bool,
|
||||
pub provider_material: ProviderDocument,
|
||||
pub has_refresh_token: bool,
|
||||
pub access_token_expires_at: Option<DateTime<Utc>>,
|
||||
@@ -520,8 +666,8 @@ impl fmt::Debug for PreparedCredentialRotation {
|
||||
/// Admin 交给 Store 的轮换或 refresh 事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CredentialRotationCommit {
|
||||
pub expected_config_revision: Revision,
|
||||
pub prepared: PreparedCredentialRotationFacts,
|
||||
pub settings: Option<super::accounts::UpdateAccount>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for RotateCredential {
|
||||
@@ -529,29 +675,65 @@ impl fmt::Debug for RotateCredential {
|
||||
formatter
|
||||
.debug_struct("RotateCredential")
|
||||
.field("mutation", &self.mutation)
|
||||
.field(
|
||||
"expected_credential_revision",
|
||||
&self.expected_credential_revision,
|
||||
)
|
||||
.field("provider_material", &self.provider_material)
|
||||
.field("settings", &self.settings)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用账号用量能否可靠归属到一个 Provider quota 窗口。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum QuotaLocalUsageAttribution {
|
||||
/// 窗口覆盖账号的全部请求,可按账号与时间范围聚合。
|
||||
AccountWide,
|
||||
/// 窗口需要 Provider / 模型级归属,通用账号聚合不可用。
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Provider quota bucket 中窗口的官方位置语义。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderQuotaWindowRole {
|
||||
Primary,
|
||||
Secondary,
|
||||
Monthly,
|
||||
}
|
||||
|
||||
impl ProviderQuotaWindowRole {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Primary => "primary",
|
||||
Self::Secondary => "secondary",
|
||||
Self::Monthly => "monthly",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个 Provider quota 窗口的公共投影。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderQuotaWindow {
|
||||
pub key: String,
|
||||
pub group: String,
|
||||
pub label: String,
|
||||
pub source: Option<String>,
|
||||
pub limit_id: Option<String>,
|
||||
pub limit_name: Option<String>,
|
||||
pub role: Option<ProviderQuotaWindowRole>,
|
||||
pub local_usage_attribution: QuotaLocalUsageAttribution,
|
||||
pub window_seconds: Option<u64>,
|
||||
pub used_percent: Option<f64>,
|
||||
pub reset_at: Option<DateTime<Utc>>,
|
||||
pub limit_reached: bool,
|
||||
pub local_usage: Option<AccountUsage>,
|
||||
pub provider_data: Option<ProviderDocument>,
|
||||
}
|
||||
|
||||
/// 账号用量统计周期,按周优先、月次之选择;短期限流窗口不参与统计面板。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum AccountUsagePeriod {
|
||||
Weekly,
|
||||
Monthly,
|
||||
}
|
||||
|
||||
/// Provider 解释 quota 所需的公共请求事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderQuotaRequest {
|
||||
@@ -563,48 +745,300 @@ pub struct ProviderQuotaRequest {
|
||||
/// Provider 已解析的 quota 结果及其不透明差异字段。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderQuota {
|
||||
/// 上游额度响应明确提供的套餐,可用于补全账号展示。
|
||||
pub plan_type: Option<String>,
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub refresh_token_expires_at: Option<DateTime<Utc>>,
|
||||
pub windows: Vec<ProviderQuotaWindow>,
|
||||
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号五态派生。
|
||||
pub limit_reached: bool,
|
||||
pub provider_data: Option<ProviderDocument>,
|
||||
}
|
||||
|
||||
/// 空值和 `unknown` 代表未提供套餐;新的套餐标识仍按明确值保留。
|
||||
pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
|
||||
value.filter(|value| {
|
||||
let value = value.trim();
|
||||
!value.is_empty() && !value.eq_ignore_ascii_case("unknown")
|
||||
})
|
||||
}
|
||||
|
||||
/// 按需查询的订阅周期,不参与额度或账号可用性判断。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderSubscription {
|
||||
pub starts_at: Option<DateTime<Utc>>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub will_renew: Option<bool>,
|
||||
pub billing_period: Option<String>,
|
||||
pub billing_currency: Option<String>,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 按需汇聚的个人信息;资料查询失败不丢弃可用的订阅结果。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountPersonalInfo {
|
||||
pub profile: Result<ProviderProfileStatistics, AdminError>,
|
||||
pub subscription: Option<ProviderSubscription>,
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料中的累计摘要。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderProfileStatisticsSummary {
|
||||
pub total_text_tokens: Option<u64>,
|
||||
pub peak_tokens: Option<u64>,
|
||||
pub longest_task_duration_ms: Option<u64>,
|
||||
pub current_streak_days: Option<u64>,
|
||||
pub longest_streak_days: Option<u64>,
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料中的单日 Token bucket。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderProfileDailyUsage {
|
||||
pub date: NaiveDate,
|
||||
pub tokens: u64,
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料中的插件或 Skill 调用排行。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderProfileInvocation {
|
||||
pub invocation_type: String,
|
||||
pub plugin_id: Option<String>,
|
||||
pub plugin_name: Option<String>,
|
||||
pub skill_id: Option<String>,
|
||||
pub skill_name: Option<String>,
|
||||
pub usage_count: Option<u64>,
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料中的活动洞察。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderProfileActivityInsights {
|
||||
pub fast_mode_percent: Option<f64>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub reasoning_effort_percent: Option<f64>,
|
||||
pub skills_explored: Option<u64>,
|
||||
pub total_skills_used: Option<u64>,
|
||||
pub total_threads: Option<u64>,
|
||||
pub invocations: Option<Vec<ProviderProfileInvocation>>,
|
||||
}
|
||||
|
||||
/// Provider 已解释的官方个人资料统计。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderProfileStatistics {
|
||||
pub display_name: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub image_url: Option<String>,
|
||||
pub has_stats_error: bool,
|
||||
pub summary: ProviderProfileStatisticsSummary,
|
||||
pub daily_usage: Option<Vec<ProviderProfileDailyUsage>>,
|
||||
pub activity_insights: ProviderProfileActivityInsights,
|
||||
}
|
||||
|
||||
/// Provider 头像正文的中立字节流;公共层不解释 MIME,也不限制总字节数。
|
||||
pub type ProviderProfileAvatarStream =
|
||||
Pin<Box<dyn Stream<Item = Result<Bytes, ProviderProfileAvatarStreamError>> + Send + 'static>>;
|
||||
|
||||
/// Provider 头像流在响应开始后的通用失败,不携带上游 URL 或正文。
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("provider profile avatar stream failed")]
|
||||
pub struct ProviderProfileAvatarStreamError;
|
||||
|
||||
/// Provider 已校验并打开的官方头像响应。
|
||||
pub struct ProviderProfileAvatar {
|
||||
pub content_type: Option<String>,
|
||||
pub content_length: Option<u64>,
|
||||
pub etag: Option<String>,
|
||||
pub body: ProviderProfileAvatarStream,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ProviderProfileAvatar {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ProviderProfileAvatar")
|
||||
.field("content_type", &self.content_type)
|
||||
.field("content_length", &self.content_length)
|
||||
.field("etag", &self.etag)
|
||||
.field("body", &"<stream>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 返回的一张安全主动额度重置卡。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderResetCredit {
|
||||
pub id: String,
|
||||
pub status: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub reset_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Provider 主动额度重置卡列表。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderResetCredits {
|
||||
pub available_count: u64,
|
||||
pub credits: Vec<ProviderResetCredit>,
|
||||
}
|
||||
|
||||
/// 一次主动额度重置卡消费命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConsumeProviderResetCredit {
|
||||
pub account_id: ProviderAccountId,
|
||||
pub credit_id: Option<String>,
|
||||
pub redeem_request_id: Uuid,
|
||||
}
|
||||
|
||||
/// Provider 返回的主动额度重置卡消费结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderResetCreditResult {
|
||||
pub code: String,
|
||||
pub credit: Option<ProviderResetCredit>,
|
||||
}
|
||||
|
||||
impl ProviderQuota {
|
||||
/// 保留账号已有的套餐子类型,仅在缺失时使用上游额度快照补全。
|
||||
pub(crate) fn fill_missing_plan_type(&self, account_plan_type: &mut Option<String>) {
|
||||
if explicit_plan_type(account_plan_type.as_deref()).is_none() {
|
||||
*account_plan_type = explicit_plan_type(self.plan_type.as_deref()).map(str::to_owned);
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择账号用量统计的周/月窗口,同时返回展示所需的周期事实。
|
||||
///
|
||||
/// 只使用边界完整、可归属到整个账号的窗口;周优先于月,同周期保持
|
||||
/// Provider 投影顺序。没有符合条件的窗口时不回退到短期或历史累计。
|
||||
#[must_use]
|
||||
pub fn usage_window(&self) -> Option<(&ProviderQuotaWindow, AccountUsagePeriod)> {
|
||||
self.usage_windows()
|
||||
.filter(|(window, _)| window.local_usage.is_some())
|
||||
.min_by_key(|(_, period)| *period)
|
||||
}
|
||||
|
||||
/// 统计与预测共用窗口归属规则,避免把模型专属桶或短期限流当作账号容量。
|
||||
pub(crate) fn usage_windows(
|
||||
&self,
|
||||
) -> impl Iterator<Item = (&ProviderQuotaWindow, AccountUsagePeriod)> {
|
||||
self.windows.iter().filter_map(|window| {
|
||||
if window.local_usage_attribution != QuotaLocalUsageAttribution::AccountWide
|
||||
|| window.reset_at.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let seconds = window.window_seconds?;
|
||||
let period = if is_week_window(seconds) {
|
||||
AccountUsagePeriod::Weekly
|
||||
} else if window.group == "monthly" && seconds >= 7 * 24 * 60 * 60 {
|
||||
AccountUsagePeriod::Monthly
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some((window, period))
|
||||
})
|
||||
}
|
||||
|
||||
/// 返回 Dashboard 使用的代表性额度比例。
|
||||
///
|
||||
/// 优先使用短周期窗口,再依次使用周、月和其它窗口;同一优先级取较高的已用比例。
|
||||
/// 这里只解释跨 Provider 共享的窗口语义,绝不读取 Provider 私有 JSON。
|
||||
/// 优先使用覆盖账号全部请求的窗口;同一归属范围内再依次使用短周期、周、月
|
||||
/// 和其它窗口,同一优先级取较高的已用比例。这里只解释跨 Provider 共享的窗口
|
||||
/// 语义,绝不读取 Provider 私有 JSON。
|
||||
#[must_use]
|
||||
pub fn representative_used_percent(&self) -> Option<f64> {
|
||||
if self.limit_reached {
|
||||
return Some(100.0);
|
||||
}
|
||||
self.representative_used_window()
|
||||
.map(|(_, used_percent)| used_percent)
|
||||
}
|
||||
|
||||
/// 选择 Dashboard 展示的真实窗口;没有百分比时仍保留 Provider 的滚动窗口语义。
|
||||
#[must_use]
|
||||
pub fn representative_window(&self) -> Option<&ProviderQuotaWindow> {
|
||||
self.representative_used_window()
|
||||
.map(|(index, _)| &self.windows[index])
|
||||
.or_else(|| {
|
||||
self.windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
|
||||
.map(|(_, window)| window)
|
||||
})
|
||||
}
|
||||
|
||||
/// 将已确认的账号级额度耗尽事实投影到展示窗口。
|
||||
///
|
||||
/// Provider 已指出具体触顶窗口时只归一化这些窗口;否则归一化 Dashboard
|
||||
/// 同样会选择的代表窗口,避免把多个独立额度窗口全部伪造成已用尽。
|
||||
pub fn apply_limit_reached_display(&mut self) {
|
||||
if !self.limit_reached {
|
||||
return;
|
||||
}
|
||||
let reached_windows = self
|
||||
.windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, window)| window.limit_reached.then_some(index))
|
||||
.collect::<Vec<_>>();
|
||||
if !reached_windows.is_empty() {
|
||||
for index in reached_windows {
|
||||
self.windows[index].used_percent = Some(100.0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let representative = self
|
||||
.representative_used_window()
|
||||
.map(|(index, _)| index)
|
||||
.or_else(|| {
|
||||
self.windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
|
||||
.map(|(index, _)| index)
|
||||
});
|
||||
if let Some(index) = representative {
|
||||
self.windows[index].used_percent = Some(100.0);
|
||||
self.windows[index].limit_reached = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn representative_used_window(&self) -> Option<(usize, f64)> {
|
||||
self.windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|window| {
|
||||
let (index, window) = window;
|
||||
let used_percent = window
|
||||
.used_percent
|
||||
.filter(|value| value.is_finite())
|
||||
.map(|value| value.clamp(0.0, 100.0))?;
|
||||
Some((quota_usage_priority(window), used_percent))
|
||||
Some((index, quota_usage_priority(window), used_percent))
|
||||
})
|
||||
.fold(None::<(u8, f64)>, |selected, candidate| match selected {
|
||||
Some(current)
|
||||
if current.0 < candidate.0
|
||||
|| (current.0 == candidate.0 && current.1 >= candidate.1) =>
|
||||
{
|
||||
.fold(
|
||||
None::<(usize, (u8, u8), f64)>,
|
||||
|selected, candidate| match selected {
|
||||
Some(current)
|
||||
}
|
||||
_ => Some(candidate),
|
||||
})
|
||||
.map(|(_, used_percent)| used_percent)
|
||||
if current.1 < candidate.1
|
||||
|| (current.1 == candidate.1 && current.2 >= candidate.2) =>
|
||||
{
|
||||
Some(current)
|
||||
}
|
||||
_ => Some(candidate),
|
||||
},
|
||||
)
|
||||
.map(|(index, _, used_percent)| (index, used_percent))
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_usage_priority(window: &ProviderQuotaWindow) -> u8 {
|
||||
match window.group.as_str() {
|
||||
fn quota_usage_priority(window: &ProviderQuotaWindow) -> (u8, u8) {
|
||||
let attribution = match window.local_usage_attribution {
|
||||
QuotaLocalUsageAttribution::AccountWide => 0,
|
||||
QuotaLocalUsageAttribution::Unavailable => 1,
|
||||
};
|
||||
let duration = match window.group.as_str() {
|
||||
"shortTerm" if window.window_seconds.is_some_and(is_week_window) => 1,
|
||||
"shortTerm" => 0,
|
||||
"monthly" => 2,
|
||||
_ => 3,
|
||||
}
|
||||
};
|
||||
(attribution, duration)
|
||||
}
|
||||
|
||||
fn is_week_window(seconds: u64) -> bool {
|
||||
@@ -661,7 +1095,9 @@ impl fmt::Debug for ProviderExport {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountDirectoryItem {
|
||||
pub account: AccountRecord,
|
||||
pub status: AccountStatus,
|
||||
/// Provider 提供的套餐展示名称;未识别到套餐时为空。
|
||||
pub plan_type_display: Option<String>,
|
||||
pub projection: gateway_core::account::AccountStatusProjection,
|
||||
pub usage: Option<super::accounts::AccountUsage>,
|
||||
pub quota: ProviderQuota,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! 可复用的账号出口配置,以及脱敏后的连通性测试结果。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::account::OutboundProxy;
|
||||
|
||||
use super::{PageSize, Revision, account_groups::AccountGroupRef};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AccountProxySelection {
|
||||
Direct,
|
||||
Url(OutboundProxy),
|
||||
Saved(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportProxyBinding {
|
||||
pub id: String,
|
||||
pub proxy: OutboundProxy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyListQuery {
|
||||
pub page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub search: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProxyAccountRef {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub provider_kind: String,
|
||||
pub authentication_kind: String,
|
||||
pub plan_type: Option<String>,
|
||||
pub plan_type_display: Option<String>,
|
||||
pub groups: Vec<AccountGroupRef>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// 按代理查询关联账号,分页与搜索均在存储层执行。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyAccountListQuery {
|
||||
pub proxy_id: String,
|
||||
pub page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub search: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyAccountPage {
|
||||
pub items: Vec<ProxyAccountRef>,
|
||||
pub total: u64,
|
||||
pub page: u32,
|
||||
pub page_size: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProxyTestResult {
|
||||
pub success: bool,
|
||||
pub latency_ms: u64,
|
||||
pub exit_ip: Option<std::net::IpAddr>,
|
||||
pub exit_ipv4: Option<std::net::Ipv4Addr>,
|
||||
pub exit_ipv6: Option<std::net::Ipv6Addr>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyRecord {
|
||||
pub location: Option<gateway_core::account::RequestLocation>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub proxy: OutboundProxy,
|
||||
pub revision: Revision,
|
||||
pub account_count: u64,
|
||||
pub last_test_at: Option<DateTime<Utc>>,
|
||||
pub last_test: Option<ProxyTestResult>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyPage {
|
||||
pub items: Vec<ProxyRecord>,
|
||||
pub total: u64,
|
||||
pub page: u32,
|
||||
pub page_size: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewProxy {
|
||||
pub location: Option<gateway_core::account::RequestLocation>,
|
||||
pub name: String,
|
||||
pub proxy: OutboundProxy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateProxy {
|
||||
/// 外层为空保留配置,内层为空恢复全局继承。
|
||||
pub location: Option<Option<gateway_core::account::RequestLocation>>,
|
||||
pub id: String,
|
||||
pub revision: Revision,
|
||||
pub name: String,
|
||||
pub proxy: Option<OutboundProxy>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyMutation {
|
||||
pub config_revision: Revision,
|
||||
pub record: ProxyRecord,
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! 账号额度的只读容量估算,不参与金额结算或账号调度。
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
|
||||
use super::provider_credentials::{AccountUsagePeriod, ProviderQuota, ProviderQuotaWindow};
|
||||
use super::quota_forecast_sampling::{QuotaForecastMethod, QuotaForecastSample};
|
||||
|
||||
const DAY_SECONDS: u64 = 86_400;
|
||||
const MIN_USED_PERCENT: f64 = 5.0;
|
||||
const LOW_SAMPLE_PERCENT: f64 = 10.0;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountQuotaForecastReport {
|
||||
pub account_id: String,
|
||||
pub generated_at: DateTime<Utc>,
|
||||
pub forecasts: [AccountQuotaForecast; 2],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountQuotaForecast {
|
||||
pub period: AccountUsagePeriod,
|
||||
pub target_seconds: u64,
|
||||
pub extrapolated: bool,
|
||||
pub source: Option<QuotaForecastSource>,
|
||||
pub unavailable_reason: Option<&'static str>,
|
||||
pub low_sample: bool,
|
||||
pub incomplete_cost: bool,
|
||||
pub incomplete_tokens: bool,
|
||||
pub estimated_tokens: Option<u64>,
|
||||
pub estimated_usd: Option<f64>,
|
||||
/// 剩余估算始终属于源窗口,不随目标周期折算。
|
||||
pub remaining_tokens: Option<u64>,
|
||||
pub remaining_usd: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct QuotaForecastSource {
|
||||
pub label: String,
|
||||
pub used_percent: Option<f64>,
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub reset_at: DateTime<Utc>,
|
||||
pub tokens: Option<u64>,
|
||||
pub usd: Option<f64>,
|
||||
}
|
||||
|
||||
/// 优先预测真实的对应窗口;缺少对应周期时只给出明确标识的 7/30 天容量折算。
|
||||
/// 本地日志不能证明站外消耗或完整留存,因此即使样本充足也不声称官方额度。
|
||||
#[must_use]
|
||||
pub fn account_quota_forecasts(
|
||||
quota: &ProviderQuota,
|
||||
account_added_at: DateTime<Utc>,
|
||||
now: DateTime<Utc>,
|
||||
samples: &[QuotaForecastSample],
|
||||
) -> [AccountQuotaForecast; 2] {
|
||||
[AccountUsagePeriod::Weekly, AccountUsagePeriod::Monthly].map(|period| {
|
||||
let selected = quota
|
||||
.usage_windows()
|
||||
.find(|(_, source_period)| *source_period == period)
|
||||
.or_else(|| quota.usage_windows().min_by_key(|(_, period)| *period));
|
||||
let mut forecast = AccountQuotaForecast {
|
||||
period,
|
||||
target_seconds: match period {
|
||||
AccountUsagePeriod::Weekly => 7 * DAY_SECONDS,
|
||||
AccountUsagePeriod::Monthly => 30 * DAY_SECONDS,
|
||||
},
|
||||
extrapolated: false,
|
||||
source: None,
|
||||
unavailable_reason: Some("没有可统计的周/月额度窗口,请先刷新账号额度。"),
|
||||
low_sample: false,
|
||||
incomplete_cost: false,
|
||||
incomplete_tokens: false,
|
||||
estimated_tokens: None,
|
||||
estimated_usd: None,
|
||||
remaining_tokens: None,
|
||||
remaining_usd: None,
|
||||
};
|
||||
if let Some((window, source_period)) = selected {
|
||||
forecast.project(
|
||||
window,
|
||||
source_period,
|
||||
quota.observed_at,
|
||||
account_added_at,
|
||||
now,
|
||||
samples.iter().find(|sample| sample.key == window.key),
|
||||
);
|
||||
}
|
||||
forecast
|
||||
})
|
||||
}
|
||||
|
||||
impl AccountQuotaForecast {
|
||||
fn project(
|
||||
&mut self,
|
||||
window: &ProviderQuotaWindow,
|
||||
source_period: AccountUsagePeriod,
|
||||
observed_at: Option<DateTime<Utc>>,
|
||||
account_added_at: DateTime<Utc>,
|
||||
now: DateTime<Utc>,
|
||||
sample: Option<&QuotaForecastSample>,
|
||||
) {
|
||||
let (Some(seconds), Some(reset_at)) = (window.window_seconds, window.reset_at) else {
|
||||
return;
|
||||
};
|
||||
self.extrapolated = source_period != self.period;
|
||||
if !self.extrapolated {
|
||||
self.target_seconds = seconds;
|
||||
}
|
||||
let percent = window
|
||||
.used_percent
|
||||
.filter(|p| p.is_finite() && (0.0..=100.0).contains(p));
|
||||
let usage = sample.map(|sample| &sample.usage);
|
||||
let usd = usage
|
||||
.filter(|usage| usage.known_cost_count > 0)
|
||||
.map(|usage| usage.usd)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0);
|
||||
let start = i64::try_from(seconds)
|
||||
.ok()
|
||||
.and_then(Duration::try_seconds)
|
||||
.and_then(|duration| reset_at.checked_sub_signed(duration));
|
||||
self.source = Some(QuotaForecastSource {
|
||||
label: window.label.clone(),
|
||||
used_percent: percent,
|
||||
observed_at,
|
||||
reset_at,
|
||||
tokens: usage.map(|usage| usage.tokens),
|
||||
usd,
|
||||
});
|
||||
self.incomplete_cost = usage.is_none_or(|usage| {
|
||||
usage.unavailable_cost_count > 0
|
||||
|| usage.known_cost_count == 0
|
||||
|| usage.known_cost_count != usage.request_count
|
||||
});
|
||||
self.incomplete_tokens = usage.is_some_and(|usage| usage.missing_token_count > 0);
|
||||
let method = sample.map_or(QuotaForecastMethod::Cumulative, |sample| sample.method);
|
||||
let Some(start) = start.filter(|start| *start <= now && now < reset_at) else {
|
||||
self.unavailable_reason = Some("额度窗口已过期或边界无效,请刷新账号额度后重试。");
|
||||
return;
|
||||
};
|
||||
if !observed_at.is_some_and(|observed| start <= observed && observed <= now) {
|
||||
self.unavailable_reason = Some("缺少本周期的额度快照,请先刷新账号额度。");
|
||||
return;
|
||||
}
|
||||
if sample.is_some_and(|sample| sample.discontinuous) {
|
||||
self.unavailable_reason =
|
||||
Some("额度观测出现回落或累计记录不连续,正在重新积累配对样本。");
|
||||
return;
|
||||
}
|
||||
if account_added_at > start && method == QuotaForecastMethod::Cumulative {
|
||||
self.unavailable_reason = Some(
|
||||
"本周期开始时的记录不完整,正在积累至少 5 个百分点的配对观测,无需等待下次重置。",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(percent) = percent else {
|
||||
self.unavailable_reason = Some("已用比例未知,请刷新额度后查看预测。");
|
||||
return;
|
||||
};
|
||||
let Some(usage) = usage.filter(|usage| usage.request_count > 0) else {
|
||||
self.unavailable_reason = Some("本周期没有网关用量记录,暂时无法预测额度。");
|
||||
return;
|
||||
};
|
||||
let Some(sample) = sample.filter(|sample| {
|
||||
sample.end_at == observed_at.unwrap_or(now)
|
||||
&& start <= sample.start_at
|
||||
&& sample.start_at <= sample.end_at
|
||||
&& sample.sampled_percent.is_finite()
|
||||
&& sample.sampled_percent >= MIN_USED_PERCENT
|
||||
&& sample.sampled_percent <= percent
|
||||
}) else {
|
||||
self.unavailable_reason =
|
||||
Some("有效额度进度不足 5 个百分点或采样边界无效,请继续积累用量。");
|
||||
return;
|
||||
};
|
||||
self.low_sample = sample.sampled_percent < LOW_SAMPLE_PERCENT
|
||||
|| (method == QuotaForecastMethod::Incremental && sample.block_count < 2);
|
||||
// 漏记和个别缺失只影响精度,仍按已记录数值估算,不按请求数补齐未知消耗。
|
||||
// 预测是近似展示值;不复用为账单金额,也不把月折算当成自然月或额外余额。
|
||||
let capacity_factor = 100.0 / sample.sampled_percent;
|
||||
let factor = capacity_factor * self.target_seconds as f64 / seconds as f64;
|
||||
let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0);
|
||||
self.estimated_tokens = tokens.and_then(|value| estimate_tokens(value, factor));
|
||||
self.estimated_usd = usd.and_then(|value| estimate(value, factor));
|
||||
let remaining_factor = (100.0 - percent) / sample.sampled_percent;
|
||||
self.remaining_tokens = tokens.and_then(|value| estimate_tokens(value, remaining_factor));
|
||||
self.remaining_usd = usd.and_then(|value| estimate(value, remaining_factor));
|
||||
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
|
||||
{
|
||||
Some("本周期暂无可用于估算的 Token 或费用数据,请积累用量后重试。")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn estimate(value: f64, factor: f64) -> Option<f64> {
|
||||
let estimate = value * factor;
|
||||
(estimate.is_finite() && estimate >= 0.0).then_some(estimate)
|
||||
}
|
||||
|
||||
fn estimate_tokens(value: u64, factor: f64) -> Option<u64> {
|
||||
estimate(value as f64, factor)
|
||||
.filter(|value| value.round() < u64::MAX as f64)
|
||||
.map(|value| value.round() as u64)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! 预测专用的配对采样;不改变完整交付用量或费用账本的统计口径。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::provider_credentials::ProviderDocument;
|
||||
|
||||
/// 限制带 Provider 文档的历史点数量;累计用量仍覆盖整个查询区间。
|
||||
pub const MAX_FORECAST_HISTORY_POINTS: usize = 128;
|
||||
const MIN_BLOCK_PERCENT: f64 = 5.0;
|
||||
const RECENT_BLOCKS: usize = 3;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct QuotaForecastUsage {
|
||||
pub request_count: u64,
|
||||
pub tokens: u64,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cached_tokens: u64,
|
||||
pub missing_token_count: u64,
|
||||
pub known_cost_count: u64,
|
||||
pub unavailable_cost_count: u64,
|
||||
pub usd: f64,
|
||||
pub excluded_request_count: u64,
|
||||
}
|
||||
|
||||
impl QuotaForecastUsage {
|
||||
/// 累计计数倒退表示不能配对,不用饱和减法掩盖数据断点。
|
||||
fn difference(&self, baseline: &Self) -> Option<Self> {
|
||||
let usd = self.usd - baseline.usd;
|
||||
if !usd.is_finite() || usd < -1e-9 {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
request_count: self.request_count.checked_sub(baseline.request_count)?,
|
||||
tokens: self.tokens.checked_sub(baseline.tokens)?,
|
||||
input_tokens: self.input_tokens.checked_sub(baseline.input_tokens)?,
|
||||
output_tokens: self.output_tokens.checked_sub(baseline.output_tokens)?,
|
||||
cached_tokens: self.cached_tokens.checked_sub(baseline.cached_tokens)?,
|
||||
missing_token_count: self
|
||||
.missing_token_count
|
||||
.checked_sub(baseline.missing_token_count)?,
|
||||
known_cost_count: self
|
||||
.known_cost_count
|
||||
.checked_sub(baseline.known_cost_count)?,
|
||||
unavailable_cost_count: self
|
||||
.unavailable_cost_count
|
||||
.checked_sub(baseline.unavailable_cost_count)?,
|
||||
usd: usd.max(0.0),
|
||||
excluded_request_count: self
|
||||
.excluded_request_count
|
||||
.checked_sub(baseline.excluded_request_count)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QuotaForecastHistoryPoint {
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub completed_at: DateTime<Utc>,
|
||||
pub usage: QuotaForecastUsage,
|
||||
pub provider_observation: ProviderDocument,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct QuotaForecastHistory {
|
||||
pub points: Vec<QuotaForecastHistoryPoint>,
|
||||
pub usage: QuotaForecastUsage,
|
||||
pub pending_request_count: u64,
|
||||
}
|
||||
|
||||
/// 具体 Provider 从自己的历史文档解释出的额度事实。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct QuotaForecastObservation {
|
||||
pub used_percent: f64,
|
||||
pub reset_at: DateTime<Utc>,
|
||||
pub plan_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QuotaForecastPoint {
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub used_percent: f64,
|
||||
pub usage: QuotaForecastUsage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum QuotaForecastMethod {
|
||||
Cumulative,
|
||||
Incremental,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QuotaForecastSample {
|
||||
pub key: String,
|
||||
pub method: QuotaForecastMethod,
|
||||
pub start_at: DateTime<Utc>,
|
||||
pub end_at: DateTime<Utc>,
|
||||
pub baseline_percent: f64,
|
||||
pub sampled_percent: f64,
|
||||
pub block_count: usize,
|
||||
pub observation_count: usize,
|
||||
pub pending_request_count: u64,
|
||||
pub discontinuous: bool,
|
||||
pub usage: QuotaForecastUsage,
|
||||
}
|
||||
|
||||
/// 对同一额度段的累计点形成至少 5 个百分点的连续块,使用最近三个完整块及尾部。
|
||||
/// 不平均逐请求比值;重复百分比不增加块数,5/10 个百分点仅是量化噪声门槛。
|
||||
#[must_use]
|
||||
pub fn select_forecast_sample(
|
||||
key: String,
|
||||
start_at: DateTime<Utc>,
|
||||
current: QuotaForecastPoint,
|
||||
mut history: Vec<QuotaForecastPoint>,
|
||||
pending_request_count: u64,
|
||||
) -> QuotaForecastSample {
|
||||
history.retain(|point| {
|
||||
start_at <= point.observed_at
|
||||
&& point.observed_at < current.observed_at
|
||||
&& point.used_percent.is_finite()
|
||||
&& (0.0..100.0).contains(&point.used_percent)
|
||||
});
|
||||
history.sort_by_key(|point| point.observed_at);
|
||||
let mut sample = QuotaForecastSample {
|
||||
key,
|
||||
method: QuotaForecastMethod::Cumulative,
|
||||
start_at,
|
||||
end_at: current.observed_at,
|
||||
baseline_percent: 0.0,
|
||||
sampled_percent: current.used_percent,
|
||||
block_count: 0,
|
||||
observation_count: history.len(),
|
||||
pending_request_count,
|
||||
discontinuous: false,
|
||||
usage: current.usage.clone(),
|
||||
};
|
||||
history.push(current);
|
||||
let mut anchors: Vec<&QuotaForecastPoint> = Vec::new();
|
||||
let mut high_water: Option<&QuotaForecastPoint> = None;
|
||||
for point in &history {
|
||||
if let Some(previous) = high_water {
|
||||
if point.observed_at <= previous.observed_at {
|
||||
continue;
|
||||
}
|
||||
if point.used_percent < previous.used_percent {
|
||||
// 一个百分点内的回落可能是量化/并发乱序;更大的回落不能跨越。
|
||||
if previous.used_percent - point.used_percent <= 1.0 {
|
||||
continue;
|
||||
}
|
||||
sample.discontinuous = true;
|
||||
anchors.clear();
|
||||
}
|
||||
}
|
||||
high_water = Some(point);
|
||||
if anchors
|
||||
.last()
|
||||
.is_none_or(|anchor| point.used_percent - anchor.used_percent >= MIN_BLOCK_PERCENT)
|
||||
{
|
||||
anchors.push(point);
|
||||
}
|
||||
}
|
||||
let Some(current) = history.last() else {
|
||||
return sample;
|
||||
};
|
||||
if anchors.len() < 2 {
|
||||
return sample;
|
||||
}
|
||||
let baseline_index = anchors.len().saturating_sub(RECENT_BLOCKS + 1);
|
||||
let baseline = anchors[baseline_index];
|
||||
let progress = current.used_percent - baseline.used_percent;
|
||||
if !progress.is_finite() || progress < MIN_BLOCK_PERCENT {
|
||||
return sample;
|
||||
}
|
||||
let Some(usage) = current.usage.difference(&baseline.usage) else {
|
||||
sample.discontinuous = true;
|
||||
return sample;
|
||||
};
|
||||
if usage.request_count == 0 {
|
||||
return sample;
|
||||
}
|
||||
sample.method = QuotaForecastMethod::Incremental;
|
||||
sample.start_at = baseline.observed_at;
|
||||
sample.baseline_percent = baseline.used_percent;
|
||||
sample.sampled_percent = progress;
|
||||
sample.block_count = anchors.len() - baseline_index - 1;
|
||||
sample.usage = usage;
|
||||
// 断点后的新基线已有足够进度时,只消费新段;旧段从不参与本次估算。
|
||||
sample.discontinuous = false;
|
||||
sample
|
||||
}
|
||||
@@ -11,44 +11,69 @@ use super::Revision;
|
||||
/// 客户端模型到上游模型的全局精确映射。
|
||||
pub type ModelMappings = BTreeMap<PublicModelId, UpstreamModelId>;
|
||||
|
||||
/// 账号调度策略。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RotationStrategy {
|
||||
Smart,
|
||||
QuotaResetPriority,
|
||||
RoundRobin,
|
||||
Sticky,
|
||||
}
|
||||
/// 账号调度策略;由 Core 拥有稳定值与 wire 映射。
|
||||
pub use gateway_core::account::RotationStrategy;
|
||||
|
||||
/// 完整运行设置事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeSettings {
|
||||
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub config_revision: Revision,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u32,
|
||||
pub max_concurrent_per_account: u32,
|
||||
pub request_interval_ms: u64,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub responses_max_decompressed_body_bytes: u64,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
pub usage_retention_days: u32,
|
||||
pub ops_event_retention_days: u32,
|
||||
pub audit_retention_days: u32,
|
||||
pub account_auto_freeze_enabled: bool,
|
||||
pub account_auto_freeze_threshold: u32,
|
||||
pub account_auto_freeze_window_seconds: u64,
|
||||
pub account_auto_freeze_duration_seconds: u64,
|
||||
pub account_auto_freeze_probe_enabled: bool,
|
||||
pub account_auto_freeze_probe_model: Option<String>,
|
||||
pub account_auto_freeze_adaptive_concurrency: bool,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 原子替换运行设置的命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReplaceRuntimeSettings {
|
||||
pub expected_config_revision: Revision,
|
||||
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u32,
|
||||
pub max_concurrent_per_account: u32,
|
||||
pub request_interval_ms: u64,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub responses_max_decompressed_body_bytes: u64,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
pub usage_retention_days: u32,
|
||||
pub ops_event_retention_days: u32,
|
||||
pub audit_retention_days: u32,
|
||||
pub account_auto_freeze_enabled: bool,
|
||||
pub account_auto_freeze_threshold: u32,
|
||||
pub account_auto_freeze_window_seconds: u64,
|
||||
pub account_auto_freeze_duration_seconds: u64,
|
||||
pub account_auto_freeze_probe_enabled: bool,
|
||||
pub account_auto_freeze_probe_model: Option<String>,
|
||||
pub account_auto_freeze_adaptive_concurrency: bool,
|
||||
}
|
||||
|
||||
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏。
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
//! 备份控制面所需的持久化与基础设施能力。
|
||||
//!
|
||||
//! 端口方法只接受/返回领域类型与既有错误体系:`BackupRepository` 复用
|
||||
//! `AdminStoreError`;`DatabaseDumpPort` 与 `BackupObjectStorePort` 返回只携带稳定
|
||||
//! 错误码的 [`BackupError`]。`gateway-store` 实现全部端口;`gateway-admin` 拥有
|
||||
//! 状态机、Cron、保留策略与 BackupTask 算法。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::model::Revision;
|
||||
use crate::model::{
|
||||
MutationContext,
|
||||
backup::{
|
||||
BackupError, BackupObjectMetadata, BackupRecord, BackupRecordListQuery, BackupRecordPage,
|
||||
BackupRecordSeed, BackupSettings, BackupStatus, BackupStorageConfig, ConnectionTestResult,
|
||||
UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
|
||||
},
|
||||
};
|
||||
use crate::ports::store::AdminStoreResult;
|
||||
|
||||
/// Worker 状态迁移携带的字段更新;`None` 字段不写入。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct StatusTransitionUpdate {
|
||||
pub size_bytes: Option<u64>,
|
||||
pub sha256: Option<String>,
|
||||
pub error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 备份配置、任务与计划游标的 PostgreSQL 仓储端口。
|
||||
#[async_trait]
|
||||
pub trait BackupRepository: Send + Sync {
|
||||
/// 读取完整设置(含 Secret)。
|
||||
async fn load_settings(&self) -> AdminStoreResult<BackupSettings>;
|
||||
|
||||
/// 同事务更新 S3 存储配置、递增 revision、清空验证状态并写审计事件。
|
||||
///
|
||||
/// 已有记录时禁止切换 endpoint/region/bucket/path-style(存储身份锁定)。
|
||||
/// 返回更新后的设置与新的全局 `config_revision`。
|
||||
async fn update_storage_settings(
|
||||
&self,
|
||||
command: UpdateBackupStorageCommand,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<(BackupSettings, Revision)>;
|
||||
|
||||
/// 同事务更新调度配置、推进计划游标并写审计事件。
|
||||
async fn update_schedule_settings(
|
||||
&self,
|
||||
command: UpdateBackupScheduleCommand,
|
||||
next_run_at: Option<DateTime<Utc>>,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<BackupSettings>;
|
||||
|
||||
/// CAS 记录探针成功;`storage_revision` 与当前配置不一致时丢弃结果(返回 `false`)。
|
||||
async fn record_verification(
|
||||
&self,
|
||||
storage_revision: u64,
|
||||
at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 插入 queued 任务;存在活跃任务时返回 `backup.active_task_conflict`。
|
||||
async fn insert_backup_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<BackupRecord>;
|
||||
|
||||
/// 幂等插入 scheduled 任务;同一时间点或活跃冲突时返回 `false`(调用方推进游标即可)。
|
||||
async fn insert_scheduled_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 分页查询记录。
|
||||
async fn list_backup_records(
|
||||
&self,
|
||||
query: BackupRecordListQuery,
|
||||
) -> AdminStoreResult<BackupRecordPage>;
|
||||
|
||||
/// 读取单条记录。
|
||||
async fn load_backup_record(&self, id: &str) -> AdminStoreResult<Option<BackupRecord>>;
|
||||
|
||||
/// 读取中间状态记录(dumping/uploading),供启动恢复。
|
||||
async fn list_intermediate_records(&self) -> AdminStoreResult<Vec<BackupRecord>>;
|
||||
|
||||
/// 读取等待完成墓碑的记录(status = deleting)。
|
||||
async fn list_pending_deletions(&self, limit: u32) -> AdminStoreResult<Vec<BackupRecord>>;
|
||||
|
||||
/// 读取 `expires_at` 已到期且非活跃的记录(completed/failed),供保留清理。
|
||||
async fn list_expired_records(&self, limit: u32) -> AdminStoreResult<Vec<BackupRecord>>;
|
||||
|
||||
/// 原子领取最旧的 queued 任务并迁移到 dumping;无任务返回 `None`。
|
||||
async fn claim_next_queued(&self, now: DateTime<Utc>)
|
||||
-> AdminStoreResult<Option<BackupRecord>>;
|
||||
|
||||
/// 条件迁移状态:要求当前状态一致;不匹配返回 `None`。
|
||||
async fn transition_status(
|
||||
&self,
|
||||
id: &str,
|
||||
from: BackupStatus,
|
||||
to: BackupStatus,
|
||||
update: StatusTransitionUpdate,
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>>;
|
||||
|
||||
/// 迁移到删除流程(completed/failed → deleting);供管理员与保留策略调用。
|
||||
async fn transition_to_deleting(
|
||||
&self,
|
||||
id: &str,
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>>;
|
||||
|
||||
/// 远端删除成功后硬删除记录行。
|
||||
async fn delete_record(&self, id: &str) -> AdminStoreResult<()>;
|
||||
|
||||
/// 条件推进计划游标;仅当计划表达式和时区仍是 `expected_*` 时才写入,返回是否推进。
|
||||
async fn advance_schedule_cursor(
|
||||
&self,
|
||||
next_run_at: DateTime<Utc>,
|
||||
expected_cron: &str,
|
||||
expected_timezone: &str,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 按完成时间倒序读取 completed 计划备份,供保留策略扫描(最多 `limit` 条)。
|
||||
async fn list_scheduled_completed_desc(
|
||||
&self,
|
||||
limit: u32,
|
||||
) -> AdminStoreResult<Vec<BackupRecord>>;
|
||||
}
|
||||
|
||||
/// `pg_dump` 导出与本地暂存端口。
|
||||
#[async_trait]
|
||||
pub trait DatabaseDumpPort: Send + Sync {
|
||||
/// 创建归档;完成后原子落盘。取消时终止子进程并清理部分文件。
|
||||
async fn dump(&self, request: DumpRequest) -> Result<DumpArtifact, BackupError>;
|
||||
|
||||
/// 检查暂存目录是否存在完整归档。
|
||||
async fn inspect_staging(&self, backup_id: &str)
|
||||
-> Result<Option<StagedArtifact>, BackupError>;
|
||||
|
||||
/// 清理暂存目录中该备份的全部文件。
|
||||
async fn cleanup_staging(&self, backup_id: &str) -> Result<(), BackupError>;
|
||||
}
|
||||
|
||||
/// 一次 `pg_dump` 请求。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DumpRequest {
|
||||
pub backup_id: String,
|
||||
pub cancellation: gateway_core::lifecycle::CancellationToken,
|
||||
}
|
||||
|
||||
/// 已完成并落盘的归档事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DumpArtifact {
|
||||
pub path: PathBuf,
|
||||
pub size_bytes: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// 暂存目录中已存在的完整归档。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StagedArtifact {
|
||||
pub path: PathBuf,
|
||||
pub size_bytes: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// S3 兼容对象存储适配器端口。
|
||||
///
|
||||
/// 方法接受已经校验的存储配置;SDK client 的构造与缓存属于 adapter 实现细节。
|
||||
#[async_trait]
|
||||
pub trait BackupObjectStorePort: Send + Sync {
|
||||
/// 对专用探针对象执行 Put/Head/Get/Delete。
|
||||
async fn test_connection(
|
||||
&self,
|
||||
config: &BackupStorageConfig,
|
||||
) -> Result<ConnectionTestResult, BackupError>;
|
||||
|
||||
/// 分片流式上传本地归档;取消时中止 multipart 并返回 `backup.cancelled`。
|
||||
async fn upload_file(
|
||||
&self,
|
||||
config: &BackupStorageConfig,
|
||||
request: UploadObjectRequest,
|
||||
) -> Result<(), BackupError>;
|
||||
|
||||
/// 读取对象大小与 metadata;对象不存在返回 `Ok(None)`。
|
||||
async fn head_object(
|
||||
&self,
|
||||
config: &BackupStorageConfig,
|
||||
object_key: &str,
|
||||
) -> Result<Option<BackupObjectMetadata>, BackupError>;
|
||||
|
||||
/// 删除对象;对象不存在视为成功。
|
||||
async fn delete_object(
|
||||
&self,
|
||||
config: &BackupStorageConfig,
|
||||
object_key: &str,
|
||||
) -> Result<(), BackupError>;
|
||||
|
||||
/// 创建短时预签名下载 URL。
|
||||
async fn presigned_download(
|
||||
&self,
|
||||
config: &BackupStorageConfig,
|
||||
object_key: &str,
|
||||
file_name: &str,
|
||||
ttl: Duration,
|
||||
) -> Result<String, BackupError>;
|
||||
}
|
||||
|
||||
/// 一次对象上传请求。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UploadObjectRequest {
|
||||
pub object_key: String,
|
||||
pub source: PathBuf,
|
||||
pub metadata: BackupObjectMetadata,
|
||||
pub cancellation: gateway_core::lifecycle::CancellationToken,
|
||||
}
|
||||
|
||||
/// 备份控制面所需的全部 Store 能力集合。
|
||||
#[derive(Clone)]
|
||||
pub struct BackupStorePorts {
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
dump: Arc<dyn DatabaseDumpPort>,
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
}
|
||||
|
||||
impl BackupStorePorts {
|
||||
/// 组合备份端口。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
dump: Arc<dyn DatabaseDumpPort>,
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
dump,
|
||||
object_store,
|
||||
}
|
||||
}
|
||||
|
||||
/// 备份任务/配置仓储。
|
||||
#[must_use]
|
||||
pub fn repository(&self) -> Arc<dyn BackupRepository> {
|
||||
self.repository.clone()
|
||||
}
|
||||
|
||||
/// `pg_dump` 导出与暂存。
|
||||
#[must_use]
|
||||
pub fn dump(&self) -> Arc<dyn DatabaseDumpPort> {
|
||||
self.dump.clone()
|
||||
}
|
||||
|
||||
/// S3/R2 对象存储适配器。
|
||||
#[must_use]
|
||||
pub fn object_store(&self) -> Arc<dyn BackupObjectStorePort> {
|
||||
self.object_store.clone()
|
||||
}
|
||||
|
||||
/// 测试与不可用路径使用的空实现;任何调用都返回不可用错误。
|
||||
#[doc(hidden)]
|
||||
#[must_use]
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
repository: Arc::new(UnavailableBackupRepository),
|
||||
dump: Arc::new(UnavailableDumpPort),
|
||||
object_store: Arc::new(UnavailableObjectStore),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UnavailableBackupRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl BackupRepository for UnavailableBackupRepository {
|
||||
async fn load_settings(&self) -> AdminStoreResult<BackupSettings> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn update_storage_settings(
|
||||
&self,
|
||||
_command: UpdateBackupStorageCommand,
|
||||
_context: &MutationContext,
|
||||
) -> AdminStoreResult<(BackupSettings, Revision)> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn update_schedule_settings(
|
||||
&self,
|
||||
_command: UpdateBackupScheduleCommand,
|
||||
_next_run_at: Option<DateTime<Utc>>,
|
||||
_context: &MutationContext,
|
||||
) -> AdminStoreResult<BackupSettings> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn record_verification(
|
||||
&self,
|
||||
_storage_revision: u64,
|
||||
_at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn insert_backup_record(
|
||||
&self,
|
||||
_seed: BackupRecordSeed,
|
||||
) -> AdminStoreResult<BackupRecord> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn insert_scheduled_record(&self, _seed: BackupRecordSeed) -> AdminStoreResult<bool> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn list_backup_records(
|
||||
&self,
|
||||
_query: BackupRecordListQuery,
|
||||
) -> AdminStoreResult<BackupRecordPage> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn load_backup_record(&self, _id: &str) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn list_intermediate_records(&self) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn list_pending_deletions(&self, _limit: u32) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn list_expired_records(&self, _limit: u32) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn claim_next_queued(
|
||||
&self,
|
||||
_now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn transition_status(
|
||||
&self,
|
||||
_id: &str,
|
||||
_from: BackupStatus,
|
||||
_to: BackupStatus,
|
||||
_update: StatusTransitionUpdate,
|
||||
_now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn transition_to_deleting(
|
||||
&self,
|
||||
_id: &str,
|
||||
_now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn delete_record(&self, _id: &str) -> AdminStoreResult<()> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn advance_schedule_cursor(
|
||||
&self,
|
||||
_next_run_at: DateTime<Utc>,
|
||||
_expected_cron: &str,
|
||||
_expected_timezone: &str,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn list_scheduled_completed_desc(
|
||||
&self,
|
||||
_limit: u32,
|
||||
) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
}
|
||||
|
||||
struct UnavailableDumpPort;
|
||||
|
||||
#[async_trait]
|
||||
impl DatabaseDumpPort for UnavailableDumpPort {
|
||||
async fn dump(&self, _request: DumpRequest) -> Result<DumpArtifact, BackupError> {
|
||||
Err(disabled_infra())
|
||||
}
|
||||
async fn inspect_staging(
|
||||
&self,
|
||||
_backup_id: &str,
|
||||
) -> Result<Option<StagedArtifact>, BackupError> {
|
||||
Err(disabled_infra())
|
||||
}
|
||||
async fn cleanup_staging(&self, _backup_id: &str) -> Result<(), BackupError> {
|
||||
Err(disabled_infra())
|
||||
}
|
||||
}
|
||||
|
||||
struct UnavailableObjectStore;
|
||||
|
||||
#[async_trait]
|
||||
impl BackupObjectStorePort for UnavailableObjectStore {
|
||||
async fn test_connection(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
) -> Result<ConnectionTestResult, BackupError> {
|
||||
Err(disabled_infra())
|
||||
}
|
||||
async fn upload_file(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
_request: UploadObjectRequest,
|
||||
) -> Result<(), BackupError> {
|
||||
Err(disabled_infra())
|
||||
}
|
||||
async fn head_object(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
_object_key: &str,
|
||||
) -> Result<Option<BackupObjectMetadata>, BackupError> {
|
||||
Err(disabled_infra())
|
||||
}
|
||||
async fn delete_object(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
_object_key: &str,
|
||||
) -> Result<(), BackupError> {
|
||||
Err(disabled_infra())
|
||||
}
|
||||
async fn presigned_download(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
_object_key: &str,
|
||||
_file_name: &str,
|
||||
_ttl: std::time::Duration,
|
||||
) -> Result<String, BackupError> {
|
||||
Err(disabled_infra())
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled_store() -> crate::ports::store::AdminStoreError {
|
||||
crate::ports::store::AdminStoreError::new(
|
||||
crate::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"backup",
|
||||
"backup store is disabled in this configuration",
|
||||
)
|
||||
}
|
||||
|
||||
fn disabled_infra() -> BackupError {
|
||||
BackupError::new(
|
||||
crate::model::backup::code::STORE_UNAVAILABLE,
|
||||
"backup infrastructure is disabled in this configuration".to_owned(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! 客户端安装包解析的外部能力端口。
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::model::client_distribution::CodexDesktopWindowsDownloads;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ClientDistributionResolver: Send + Sync {
|
||||
async fn resolve_codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
//! 管理用例依赖的外部能力端口。
|
||||
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod pricing;
|
||||
pub mod provider;
|
||||
pub mod proxy;
|
||||
pub mod store;
|
||||
pub mod system;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
//! 手动同步的外部价目来源;网络和外部文档解析由 Host 实现。
|
||||
|
||||
use crate::model::{AdminError, pricing::PricingSyncPreview};
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[async_trait]
|
||||
pub trait PricingSource: Send + Sync {
|
||||
async fn fetch(&self) -> Result<PricingSyncPreview, AdminError>;
|
||||
}
|
||||
@@ -4,20 +4,26 @@ use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{
|
||||
engine::credential::ProviderAccountId,
|
||||
account::ProviderAccountId,
|
||||
operation::Operation,
|
||||
routing::{ProviderKind, UpstreamModelId},
|
||||
};
|
||||
use heck::ToUpperCamelCase;
|
||||
|
||||
use crate::model::observability::{
|
||||
CalculatedBillingBreakdown, DashboardWireProfile, ProviderBillingInput,
|
||||
};
|
||||
use crate::model::provider_credentials::{
|
||||
AuthorizationStarted, CompleteAuthorization, PendingAuthorizationMutation,
|
||||
PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation,
|
||||
PreparedAuthorizationCommit, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
|
||||
ProviderQuotaRequest,
|
||||
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
|
||||
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type,
|
||||
};
|
||||
use crate::model::{
|
||||
provider_credentials::{ProviderDocument, ProviderQuotaWindow},
|
||||
quota_forecast_sampling::QuotaForecastObservation,
|
||||
};
|
||||
|
||||
/// Provider 管理失败的稳定分类。
|
||||
@@ -27,27 +33,75 @@ pub enum ProviderAdminErrorKind {
|
||||
Unsupported,
|
||||
NotFound,
|
||||
Conflict,
|
||||
/// Provider 已发起不可逆操作,但无法确认最终执行结果。
|
||||
Ambiguous,
|
||||
Unavailable,
|
||||
CredentialRefreshRequired,
|
||||
BadGateway,
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// 不泄漏 OAuth material 与 Provider 响应体的管理错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
/// 不携带 OAuth 请求材料的管理错误。
|
||||
///
|
||||
/// `message` 是 Provider 局部诊断,可能包含原始上游正文;通用管理用例不得自动把它作为公开文案。
|
||||
/// 只有明确拥有原始诊断合同的调用方才能读取,`Debug` 始终只记录是否存在。
|
||||
/// `public_message` 则是明确标记为可公开的静态提示,不允许携带动态上游材料。
|
||||
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("provider admin operation failed: {kind:?}")]
|
||||
pub struct ProviderAdminError {
|
||||
kind: ProviderAdminErrorKind,
|
||||
message: Option<String>,
|
||||
public_message: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProviderAdminError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ProviderAdminError")
|
||||
.field("kind", &self.kind)
|
||||
.field("message", &self.message.as_ref().map(|_| "<redacted>"))
|
||||
.field("public_message", &self.public_message)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderAdminError {
|
||||
#[must_use]
|
||||
pub const fn new(kind: ProviderAdminErrorKind) -> Self {
|
||||
Self { kind }
|
||||
Self {
|
||||
kind,
|
||||
message: None,
|
||||
public_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_message(mut self, message: impl Into<String>) -> Self {
|
||||
self.message = Some(message.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Provider 明确允许公开的静态提示;不得承载上游响应或凭据材料。
|
||||
#[must_use]
|
||||
pub const fn with_public_message(mut self, message: &'static str) -> Self {
|
||||
self.public_message = Some(message);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn public_message(&self) -> Option<&'static str> {
|
||||
self.public_message
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> ProviderAdminErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn message(&self) -> Option<&str> {
|
||||
self.message.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个具体 Provider 对管理控制面提供的解析、验证、上游交互与运行时资源回收能力。
|
||||
@@ -56,14 +110,45 @@ impl ProviderAdminError {
|
||||
/// 全部由 [`crate::ports::store::AccountStore`] 提交。运行时资源通知只在事务成功后发生。
|
||||
#[async_trait]
|
||||
pub trait ProviderAdmin: Send + Sync {
|
||||
/// 只读内置价目;没有本地计价能力的 Provider 返回空目录。
|
||||
fn pricing_catalog(&self) -> crate::model::pricing::ProviderPricingCatalog {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn provider_kind(&self) -> &ProviderKind;
|
||||
|
||||
/// 提供该 Provider 的可选客户端身份;通用管理层不解释内部字段。
|
||||
fn client_profile_options(
|
||||
&self,
|
||||
) -> Result<gateway_core::account::OpaqueProviderData, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 校验并投影客户端身份,结果不含认证或账号材料。
|
||||
fn preview_client_profile(
|
||||
&self,
|
||||
_configuration: &gateway_core::account::OpaqueProviderData,
|
||||
) -> Result<gateway_core::account::OpaqueProviderData, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 将原始套餐值投影为展示名称;默认保留未知 Provider 的原始名称。
|
||||
fn plan_type_display(&self, plan_type: &str) -> String {
|
||||
plan_type.to_owned()
|
||||
}
|
||||
|
||||
/// 账号已经由控制面提交为不可调度状态,释放 Provider 持有的账号级运行时资源。
|
||||
///
|
||||
/// 无账号级运行时资源的 Provider 不需要执行额外操作。该通知发生在 Store 事务
|
||||
/// 成功之后,不参与事务成败,也不得恢复或改写已经提交的账号状态。
|
||||
async fn account_unavailable(&self, account_id: &ProviderAccountId);
|
||||
|
||||
/// 账号资格事实已经由控制面提交,失效 Provider 持有的可重建派生状态。
|
||||
///
|
||||
/// 该通知发生在 Store 事务成功之后、下一份 RuntimeSnapshot 编译之前;通知
|
||||
/// 不参与已提交事务成败。没有账号派生状态的 Provider 可使用默认空实现。
|
||||
async fn account_facts_changed(&self, _account_ids: &[ProviderAccountId]) {}
|
||||
|
||||
/// 生成一次连接测试所需的 Provider-owned operation;Core 负责实际执行与落账。
|
||||
fn connection_test_operation(
|
||||
&self,
|
||||
@@ -74,6 +159,13 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
/// 返回该 Provider 实际持有的 Dashboard 上游身份画像。
|
||||
fn dashboard_wire_profile(&self) -> Option<DashboardWireProfile>;
|
||||
|
||||
fn configured_wire_profile(
|
||||
&self,
|
||||
_configuration: &gateway_core::account::OpaqueProviderData,
|
||||
) -> Option<DashboardWireProfile> {
|
||||
self.dashboard_wire_profile()
|
||||
}
|
||||
|
||||
/// 使用 Provider-owned 价格规则恢复持久请求的逐项费用。
|
||||
fn calculated_billing(
|
||||
&self,
|
||||
@@ -105,11 +197,68 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
command: PrepareCredentialRefresh,
|
||||
) -> Result<PreparedCredentialRotation, ProviderAdminError>;
|
||||
|
||||
/// 可公开的账号连接设置;实现只能显式投影非敏感字段,不能返回凭据原文。
|
||||
async fn account_configuration(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<Option<ProviderDocument>, ProviderAdminError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn quota(
|
||||
&self,
|
||||
request: ProviderQuotaRequest,
|
||||
) -> Result<ProviderQuota, ProviderAdminError>;
|
||||
|
||||
/// 历史观测的协议字段仅由具体 Provider 解释;不支持时保留累计估算。
|
||||
fn quota_forecast_observation(
|
||||
&self,
|
||||
_document: &ProviderDocument,
|
||||
_window: &ProviderQuotaWindow,
|
||||
) -> Option<QuotaForecastObservation> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝。
|
||||
/// 只由个人信息显式查询,不在额度刷新或后台任务中预取。
|
||||
async fn subscription(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
async fn profile_statistics(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileStatistics, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 打开 Provider 官方头像字节流;不支持该能力的 Provider 使用默认拒绝。
|
||||
async fn profile_avatar(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileAvatar, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 查询 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
|
||||
async fn reset_credits(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderResetCredits, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 消费 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
|
||||
async fn consume_reset_credit(
|
||||
&self,
|
||||
_command: ConsumeProviderResetCredit,
|
||||
) -> Result<ProviderResetCreditResult, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
async fn models(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -129,6 +278,14 @@ pub struct ProviderAdminRegistry {
|
||||
}
|
||||
|
||||
impl ProviderAdminRegistry {
|
||||
#[must_use]
|
||||
pub fn pricing_catalog(&self) -> gateway_core::metering::PricingOverrides {
|
||||
self.providers
|
||||
.iter()
|
||||
.map(|(kind, provider)| (kind.as_str().to_owned(), provider.pricing_catalog()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 创建无重复 ProviderKind 的注册表。
|
||||
///
|
||||
/// # Errors
|
||||
@@ -159,11 +316,51 @@ impl ProviderAdminRegistry {
|
||||
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 账号目录与关联列表共用套餐补全和展示规则,已知账号套餐优先于额度快照。
|
||||
pub(crate) fn resolve_account_plan(
|
||||
&self,
|
||||
provider_kind: &str,
|
||||
plan_type: &mut Option<String>,
|
||||
quota: Option<&ProviderQuota>,
|
||||
) -> Option<String> {
|
||||
if let Some(quota) = quota {
|
||||
quota.fill_missing_plan_type(plan_type);
|
||||
}
|
||||
self.plan_type_display(provider_kind, plan_type.as_deref())
|
||||
}
|
||||
|
||||
/// 账号页和 Dashboard 共用的大驼峰套餐展示名称,不修改原始套餐值。
|
||||
pub(crate) fn plan_type_display(
|
||||
&self,
|
||||
provider_kind: &str,
|
||||
plan_type: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let plan_type = explicit_plan_type(plan_type)?.trim();
|
||||
let provider = ProviderKind::new(provider_kind.to_owned())
|
||||
.ok()
|
||||
.and_then(|kind| self.providers.get(&kind));
|
||||
let display = provider.map_or_else(
|
||||
|| plan_type.to_owned(),
|
||||
|provider| provider.plan_type_display(plan_type),
|
||||
);
|
||||
// 所有 Provider 及未知套餐统一排版,保留 Plus 后缀的含义。
|
||||
Some(display.replace('+', " Plus ").to_upper_camel_case())
|
||||
}
|
||||
|
||||
/// 返回所有已注册 Provider 的 Dashboard 上游身份画像。
|
||||
pub fn dashboard_wire_profiles(&self) -> Vec<DashboardWireProfile> {
|
||||
pub fn dashboard_wire_profiles(
|
||||
&self,
|
||||
configurations: &std::collections::BTreeMap<
|
||||
ProviderKind,
|
||||
gateway_core::account::OpaqueProviderData,
|
||||
>,
|
||||
) -> Vec<DashboardWireProfile> {
|
||||
self.providers
|
||||
.values()
|
||||
.filter_map(|provider| provider.dashboard_wire_profile())
|
||||
.iter()
|
||||
.filter_map(|(kind, provider)| match configurations.get(kind) {
|
||||
Some(configuration) => provider.configured_wire_profile(configuration),
|
||||
None => provider.dashboard_wire_profile(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::account::{OutboundProxy, ProviderAccountId};
|
||||
|
||||
use super::store::AdminStoreResult;
|
||||
use crate::model::{
|
||||
MutationContext, Revision,
|
||||
proxies::{
|
||||
ImportProxyBinding, NewProxy, ProxyAccountListQuery, ProxyAccountPage, ProxyListQuery,
|
||||
ProxyMutation, ProxyPage, ProxyRecord, ProxyTestResult, UpdateProxy,
|
||||
},
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxyStore: Send + Sync {
|
||||
/// 在凭据交换到提交期间保护选定代理的连接配置和测试结果。
|
||||
async fn reserve_import(&self, id: &str) -> AdminStoreResult<ProxyImportReservation>;
|
||||
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage>;
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
query: ProxyAccountListQuery,
|
||||
) -> AdminStoreResult<ProxyAccountPage>;
|
||||
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord>;
|
||||
/// 仅在账号仍绑定指定代理时解除关联,并清除账号保存的连接地址。
|
||||
async fn remove_account(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
account_id: &ProviderAccountId,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
async fn create(
|
||||
&self,
|
||||
command: NewProxy,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyMutation>;
|
||||
async fn update(
|
||||
&self,
|
||||
command: UpdateProxy,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyMutation>;
|
||||
async fn delete(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
async fn record_test(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
result: ProxyTestResult,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyRecord>;
|
||||
}
|
||||
|
||||
/// 离开作用域时释放保护,错误返回和请求取消也遵循相同规则。
|
||||
pub trait ProxyImportGuard: Send + Sync {}
|
||||
|
||||
pub struct ProxyImportReservation {
|
||||
pub binding: ImportProxyBinding,
|
||||
pub guard: Box<dyn ProxyImportGuard>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxyProbe: Send + Sync {
|
||||
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult;
|
||||
}
|
||||
@@ -2,21 +2,28 @@
|
||||
//!
|
||||
//! 端口按业务资源拆分,方法使用领域模型,不暴露连接池、事务或 Redis client。
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{collections::BTreeMap, net::IpAddr, sync::Arc, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
use super::backup::BackupStorePorts;
|
||||
use crate::model::{
|
||||
MutationContext, Revision,
|
||||
accounts::{
|
||||
AccountListQuery, AccountPage, AccountRecord, AccountUsage, AccountUsageWindowQuery,
|
||||
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
|
||||
account_groups::{
|
||||
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage,
|
||||
DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
accounts::{
|
||||
AccountListQuery, AccountPage, AccountPageItem, AccountRuntimeSnapshot,
|
||||
AccountUpdateResult, AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult,
|
||||
AccountsUpdateResult, BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
|
||||
},
|
||||
auth::{AdminAuditEvent, AuthSession},
|
||||
client_keys::{
|
||||
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
|
||||
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
|
||||
NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled, UpdateClientKey,
|
||||
},
|
||||
observability::{
|
||||
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
|
||||
@@ -25,9 +32,9 @@ use crate::model::{
|
||||
},
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult,
|
||||
CredentialListQuery, CredentialMutationResult, CredentialPage, CredentialRotationCommit,
|
||||
ProviderExportCredentialInput,
|
||||
CredentialMutationResult, CredentialRotationCommit, ProviderExportCredentialInput,
|
||||
},
|
||||
quota_forecast_sampling::QuotaForecastHistory,
|
||||
settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings},
|
||||
};
|
||||
|
||||
@@ -37,6 +44,7 @@ pub enum AdminStoreErrorKind {
|
||||
Invalid,
|
||||
NotFound,
|
||||
StaleRevision,
|
||||
DuplicateName,
|
||||
Conflict,
|
||||
Unavailable,
|
||||
}
|
||||
@@ -80,9 +88,17 @@ pub type AdminStoreResult<T> = Result<T, AdminStoreError>;
|
||||
/// 账号目录与公共账号写操作。
|
||||
#[async_trait]
|
||||
pub trait AccountStore: Send + Sync {
|
||||
async fn list_accounts(&self, query: AccountListQuery) -> AdminStoreResult<AccountPage>;
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
query: AccountListQuery,
|
||||
runtime: AccountRuntimeSnapshot,
|
||||
) -> AdminStoreResult<AccountPage>;
|
||||
|
||||
async fn load_account(&self, account_id: &str) -> AdminStoreResult<Option<AccountRecord>>;
|
||||
async fn load_account(
|
||||
&self,
|
||||
account_id: &str,
|
||||
runtime: AccountRuntimeSnapshot,
|
||||
) -> AdminStoreResult<Option<AccountPageItem>>;
|
||||
|
||||
async fn load_account_usage(
|
||||
&self,
|
||||
@@ -95,22 +111,22 @@ pub trait AccountStore: Send + Sync {
|
||||
windows: &[AccountUsageWindowQuery],
|
||||
) -> AdminStoreResult<Vec<AccountUsageWindowResult>>;
|
||||
|
||||
async fn list_credentials(
|
||||
/// 从同一数据库语句取得截止快照的累计用量和有界历史观测。
|
||||
async fn load_quota_forecast_history(
|
||||
&self,
|
||||
provider_kind: &gateway_core::routing::ProviderKind,
|
||||
query: CredentialListQuery,
|
||||
) -> AdminStoreResult<CredentialPage>;
|
||||
window: &AccountUsageWindowQuery,
|
||||
) -> AdminStoreResult<QuotaForecastHistory>;
|
||||
|
||||
async fn credential_details(
|
||||
&self,
|
||||
provider_kind: &gateway_core::routing::ProviderKind,
|
||||
account_id: &gateway_core::engine::credential::ProviderAccountId,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
) -> AdminStoreResult<Option<CredentialDetails>>;
|
||||
|
||||
async fn load_credentials_for_export(
|
||||
&self,
|
||||
provider_kind: &gateway_core::routing::ProviderKind,
|
||||
account_ids: &[gateway_core::engine::credential::ProviderAccountId],
|
||||
account_ids: &[gateway_core::account::ProviderAccountId],
|
||||
) -> AdminStoreResult<Vec<ProviderExportCredentialInput>>;
|
||||
|
||||
async fn commit_credential_import(
|
||||
@@ -137,11 +153,31 @@ pub trait AccountStore: Send + Sync {
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult>;
|
||||
|
||||
async fn set_account_enabled(
|
||||
async fn update_account(
|
||||
&self,
|
||||
command: SetAccountEnabled,
|
||||
command: UpdateAccount,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
) -> AdminStoreResult<AccountUpdateResult>;
|
||||
|
||||
/// 在事务内按最新启用状态、账号上限和全局默认值判断,只降低并发上限。
|
||||
async fn lower_concurrency_limit(
|
||||
&self,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
limit: gateway_core::account::AccountConcurrencyLimit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Option<AccountUpdateResult>>;
|
||||
|
||||
async fn recover_account(
|
||||
&self,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountUpdateResult>;
|
||||
|
||||
async fn batch_update_accounts(
|
||||
&self,
|
||||
command: BatchUpdateAccounts,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountsUpdateResult>;
|
||||
|
||||
async fn delete_accounts(
|
||||
&self,
|
||||
@@ -151,16 +187,55 @@ pub trait AccountStore: Send + Sync {
|
||||
|
||||
async fn record_credential_export(
|
||||
&self,
|
||||
account_ids: &[gateway_core::engine::credential::ProviderAccountId],
|
||||
account_ids: &[gateway_core::account::ProviderAccountId],
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<()>;
|
||||
}
|
||||
|
||||
/// 管理员密码、会话、登录限流和安全审计。
|
||||
/// 可丢失账号运行态的管理读端口;跨存储编排由 Admin application service 拥有。
|
||||
#[async_trait]
|
||||
pub trait AccountRuntimeStore: Send + Sync {
|
||||
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot>;
|
||||
|
||||
async fn account_runtime(
|
||||
&self,
|
||||
account_ids: &[String],
|
||||
) -> AdminStoreResult<AccountRuntimeSnapshot>;
|
||||
|
||||
/// 容量熔断自动冻结中的账号与其冻结截止时间;429 临时限流不包含在内。
|
||||
async fn active_freezes(
|
||||
&self,
|
||||
) -> AdminStoreResult<BTreeMap<String, crate::model::accounts::AccountFreeze>>;
|
||||
|
||||
/// 读取容量失败窗口内观测到的在途并发峰值(自适应并发下调的证据)。
|
||||
async fn capacity_peaks(
|
||||
&self,
|
||||
account_ids: &[String],
|
||||
) -> AdminStoreResult<BTreeMap<String, u32>>;
|
||||
|
||||
/// 仅当冻结快照仍匹配时解除或顺延;旧探测不得覆盖手动恢复或新一轮冻结。
|
||||
async fn finish_freeze(
|
||||
&self,
|
||||
account_id: &str,
|
||||
expected: &crate::model::accounts::AccountFreeze,
|
||||
postpone_until: Option<DateTime<Utc>>,
|
||||
) -> AdminStoreResult<bool>;
|
||||
}
|
||||
|
||||
/// 控制面凭据、统一会话、登录限流与管理员安全审计。
|
||||
#[async_trait]
|
||||
pub trait AuthStore: Send + Sync {
|
||||
async fn load_password_hash(&self, admin_user_id: &str) -> AdminStoreResult<Option<String>>;
|
||||
|
||||
/// 密码更新与审计必须在同一事务提交;旧哈希不匹配时不写入。
|
||||
async fn change_password(
|
||||
&self,
|
||||
admin_user_id: &str,
|
||||
expected_hash: &str,
|
||||
password_hash: &str,
|
||||
audit: AdminAuditEvent,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
async fn create_password_hash_if_absent(
|
||||
&self,
|
||||
admin_user_id: &str,
|
||||
@@ -169,35 +244,38 @@ pub trait AuthStore: Send + Sync {
|
||||
|
||||
async fn load_admin_api_key(&self) -> AdminStoreResult<Option<AdminApiKey>>;
|
||||
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>>;
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>>;
|
||||
|
||||
async fn store_session(&self, session_id: &str, session: &AdminSession)
|
||||
-> AdminStoreResult<()>;
|
||||
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()>;
|
||||
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>>;
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>>;
|
||||
|
||||
async fn login_source_is_throttled(
|
||||
async fn client_key_enabled(
|
||||
&self,
|
||||
source: &str,
|
||||
failure_limit: u32,
|
||||
window_seconds: u64,
|
||||
id: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
async fn record_login_failure(
|
||||
/// 原子消费来源桶与全局桶的一次登录尝试;被拒绝时返回建议重试间隔。
|
||||
async fn consume_login_attempt(
|
||||
&self,
|
||||
source: &str,
|
||||
failure_limit: u32,
|
||||
window_seconds: u64,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
async fn clear_login_failures(&self, source: &str) -> AdminStoreResult<()>;
|
||||
source_ip: IpAddr,
|
||||
source_limit: u32,
|
||||
global_limit: u32,
|
||||
window: Duration,
|
||||
) -> AdminStoreResult<Option<Duration>>;
|
||||
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()>;
|
||||
}
|
||||
|
||||
/// Client API Key 管理及其 revision CAS 写入。
|
||||
/// Client API Key 资料读取与管理写入。
|
||||
#[async_trait]
|
||||
pub trait ClientKeyStore: Send + Sync {
|
||||
/// 按已验证的 ID 读取资料,不读取完整明文 Key。
|
||||
async fn get_client_key(
|
||||
&self,
|
||||
id: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<Option<ClientKeyRecord>>;
|
||||
|
||||
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage>;
|
||||
|
||||
async fn reveal_client_key(
|
||||
@@ -228,12 +306,66 @@ pub trait ClientKeyStore: Send + Sync {
|
||||
command: DeleteClientKey,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
|
||||
/// 仅修改运行时账本并原子记录审计,不推进配置版本。
|
||||
async fn reset_client_key_budget(
|
||||
&self,
|
||||
command: ResetClientKeyBudget,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<()>;
|
||||
}
|
||||
|
||||
/// Provider-neutral account group management transactions.
|
||||
#[async_trait]
|
||||
pub trait AccountGroupStore: Send + Sync {
|
||||
async fn list_account_groups(
|
||||
&self,
|
||||
query: AccountGroupListQuery,
|
||||
) -> AdminStoreResult<AccountGroupPage>;
|
||||
|
||||
async fn load_account_group_members(
|
||||
&self,
|
||||
group_ids: &[gateway_core::routing::AccountGroupId],
|
||||
) -> AdminStoreResult<Vec<AccountGroupMemberFact>>;
|
||||
|
||||
async fn create_account_group(
|
||||
&self,
|
||||
command: NewAccountGroup,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation>;
|
||||
|
||||
async fn update_account_group(
|
||||
&self,
|
||||
command: UpdateAccountGroup,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation>;
|
||||
|
||||
async fn set_account_group_enabled(
|
||||
&self,
|
||||
command: SetAccountGroupEnabled,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation>;
|
||||
|
||||
async fn delete_account_group(
|
||||
&self,
|
||||
command: DeleteAccountGroup,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation>;
|
||||
}
|
||||
|
||||
/// 逐条读取的已计算费用事实;消费结束或丢弃时释放查询资源。
|
||||
pub type UsageCalculatedBillingStream<'a> =
|
||||
BoxStream<'a, AdminStoreResult<UsageCalculatedBillingFact>>;
|
||||
|
||||
/// 用量、趋势、诊断与运维错误的只读能力。
|
||||
#[async_trait]
|
||||
pub trait ObservabilityStore: Send + Sync {
|
||||
async fn dashboard_summary(&self, range: TimeRange) -> AdminStoreResult<DashboardObservation>;
|
||||
/// 返回历史统计区间和指定观测时刻下的实时账号状态。
|
||||
async fn dashboard_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
observed_at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<DashboardObservation>;
|
||||
|
||||
/// 返回 Dashboard 可选的实时槽位事实。
|
||||
///
|
||||
@@ -254,12 +386,13 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<RequestMetricPoint>>;
|
||||
|
||||
/// 返回可由 Provider 重新校验的已计算费用事实,用于恢复标准费用趋势。
|
||||
async fn usage_calculated_billing_facts(
|
||||
/// 流式返回可由 Provider 重新校验的已计算费用事实,不保证顺序。
|
||||
/// 查询及解码错误由流返回;调用方应逐条聚合,避免收集整个区间。
|
||||
fn usage_calculated_billing_facts(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<UsageCalculatedBillingFact>>;
|
||||
) -> UsageCalculatedBillingStream<'_>;
|
||||
|
||||
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage>;
|
||||
|
||||
@@ -281,9 +414,21 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
|
||||
}
|
||||
|
||||
/// Runtime settings 与管理员 API Key 的 revision CAS 写入。
|
||||
/// Runtime settings 与管理员 API Key 写入。
|
||||
#[async_trait]
|
||||
pub trait SettingsStore: Send + Sync {
|
||||
async fn load_pricing(&self) -> AdminStoreResult<crate::model::pricing::StoredPricing>;
|
||||
async fn sync_pricing(
|
||||
&self,
|
||||
changes: crate::model::pricing::PricingSyncChanges,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<crate::model::Revision>;
|
||||
async fn update_pricing(
|
||||
&self,
|
||||
command: crate::model::pricing::UpdatePricing,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<crate::model::Revision>;
|
||||
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings>;
|
||||
|
||||
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool>;
|
||||
@@ -296,38 +441,64 @@ pub trait SettingsStore: Send + Sync {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
key: AdminApiKey,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation>;
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation>;
|
||||
}
|
||||
|
||||
/// 账号目录、运行态与分组所需的 Store 能力集合。
|
||||
#[derive(Clone)]
|
||||
pub struct AdminAccountStorePorts {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
runtime: Arc<dyn AccountRuntimeStore>,
|
||||
groups: Arc<dyn AccountGroupStore>,
|
||||
proxies: Arc<dyn super::proxy::ProxyStore>,
|
||||
}
|
||||
|
||||
impl AdminAccountStorePorts {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
runtime: Arc<dyn AccountRuntimeStore>,
|
||||
groups: Arc<dyn AccountGroupStore>,
|
||||
proxies: Arc<dyn super::proxy::ProxyStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
accounts,
|
||||
runtime,
|
||||
groups,
|
||||
proxies,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理用例所需能力的封闭集合。
|
||||
///
|
||||
/// 字段保持私有,每个 getter 只交出一种明确能力。该类型不提供通用拆包入口。
|
||||
#[derive(Clone)]
|
||||
pub struct AdminStorePorts {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
accounts: AdminAccountStorePorts,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
client_keys: Arc<dyn ClientKeyStore>,
|
||||
observability: Arc<dyn ObservabilityStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
backup: BackupStorePorts,
|
||||
}
|
||||
|
||||
impl AdminStorePorts {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
accounts: AdminAccountStorePorts,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
client_keys: Arc<dyn ClientKeyStore>,
|
||||
observability: Arc<dyn ObservabilityStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
backup: BackupStorePorts,
|
||||
) -> Self {
|
||||
Self {
|
||||
accounts,
|
||||
@@ -335,12 +506,28 @@ impl AdminStorePorts {
|
||||
client_keys,
|
||||
observability,
|
||||
settings,
|
||||
backup,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn accounts(&self) -> Arc<dyn AccountStore> {
|
||||
self.accounts.clone()
|
||||
self.accounts.accounts.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn account_runtime(&self) -> Arc<dyn AccountRuntimeStore> {
|
||||
self.accounts.runtime.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn account_groups(&self) -> Arc<dyn AccountGroupStore> {
|
||||
self.accounts.groups.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn proxies(&self) -> Arc<dyn super::proxy::ProxyStore> {
|
||||
self.accounts.proxies.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -362,4 +549,9 @@ impl AdminStorePorts {
|
||||
pub fn settings(&self) -> Arc<dyn SettingsStore> {
|
||||
self.settings.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn backup(&self) -> BackupStorePorts {
|
||||
self.backup.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Account group management use cases.
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::Arc,
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{
|
||||
account::{AccountStatus, resolve_account_status},
|
||||
routing::AccountGroupId,
|
||||
runtime::SnapshotControl,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
account_groups::{
|
||||
AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupListQuery,
|
||||
AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage, AccountGroupRecord,
|
||||
CreateAccountGroup, DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled,
|
||||
UpdateAccountGroup,
|
||||
},
|
||||
},
|
||||
ports::store::{AccountGroupStore, AccountRuntimeStore},
|
||||
};
|
||||
|
||||
use super::{map_store_error, publish_committed};
|
||||
|
||||
/// API-facing account group management service.
|
||||
#[async_trait]
|
||||
pub trait AccountGroupService: Send + Sync {
|
||||
async fn list(&self, query: AccountGroupListQuery) -> Result<AccountGroupPage, AdminError>;
|
||||
async fn create(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: CreateAccountGroup,
|
||||
) -> Result<AccountGroupMutation, AdminError>;
|
||||
async fn update(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateAccountGroup,
|
||||
) -> Result<AccountGroupMutation, AdminError>;
|
||||
async fn set_enabled(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: SetAccountGroupEnabled,
|
||||
) -> Result<AccountGroupMutation, AdminError>;
|
||||
async fn delete(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: DeleteAccountGroup,
|
||||
) -> Result<AccountGroupMutation, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultAccountGroupService {
|
||||
store: Arc<dyn AccountGroupStore>,
|
||||
runtime: Arc<dyn AccountRuntimeStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl DefaultAccountGroupService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
store: Arc<dyn AccountGroupStore>,
|
||||
runtime: Arc<dyn AccountRuntimeStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
runtime,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
async fn enrich_records(&self, records: &mut [AccountGroupRecord]) -> Result<(), AdminError> {
|
||||
if records.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let group_ids = records
|
||||
.iter()
|
||||
.map(|record| record.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let members = self
|
||||
.store
|
||||
.load_account_group_members(&group_ids)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "account group members"))?;
|
||||
let account_ids = members
|
||||
.iter()
|
||||
.map(|member| member.account_id.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let runtime = match self.runtime.account_runtime(&account_ids).await {
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "account group runtime projection is unavailable");
|
||||
Default::default()
|
||||
}
|
||||
};
|
||||
project_group_runtime(records, &members, &runtime);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish(
|
||||
&self,
|
||||
result: Result<AccountGroupMutation, crate::ports::store::AdminStoreError>,
|
||||
) -> Result<AccountGroupMutation, AdminError> {
|
||||
let mut result = result.map_err(|error| map_store_error(error, "account group"))?;
|
||||
if let Some(record) = result.record.as_mut() {
|
||||
self.enrich_records(std::slice::from_mut(record)).await?;
|
||||
}
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountGroupService for DefaultAccountGroupService {
|
||||
async fn list(&self, query: AccountGroupListQuery) -> Result<AccountGroupPage, AdminError> {
|
||||
let mut page = self
|
||||
.store
|
||||
.list_account_groups(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "account group"))?;
|
||||
self.enrich_records(&mut page.items).await?;
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: CreateAccountGroup,
|
||||
) -> Result<AccountGroupMutation, AdminError> {
|
||||
let id = AccountGroupId::new(format!("grp_{}", Uuid::now_v7().simple()))
|
||||
.map_err(|_| AdminError::internal("创建账号组 ID 失败"))?;
|
||||
self.publish(
|
||||
self.store
|
||||
.create_account_group(
|
||||
NewAccountGroup {
|
||||
id,
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
disable_fast: command.disable_fast,
|
||||
color: command.color,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateAccountGroup,
|
||||
) -> Result<AccountGroupMutation, AdminError> {
|
||||
self.publish(self.store.update_account_group(command, context).await)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_enabled(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: SetAccountGroupEnabled,
|
||||
) -> Result<AccountGroupMutation, AdminError> {
|
||||
self.publish(self.store.set_account_group_enabled(command, context).await)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: DeleteAccountGroup,
|
||||
) -> Result<AccountGroupMutation, AdminError> {
|
||||
self.publish(self.store.delete_account_group(command, context).await)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn project_group_runtime(
|
||||
records: &mut [AccountGroupRecord],
|
||||
members: &[AccountGroupMemberFact],
|
||||
runtime: &crate::model::accounts::AccountRuntimeSnapshot,
|
||||
) {
|
||||
let now = SystemTime::now();
|
||||
let mut status_by_account = BTreeMap::new();
|
||||
let mut slots_by_account = BTreeMap::new();
|
||||
let mut accounts_by_group = BTreeMap::<&str, Vec<&str>>::new();
|
||||
for member in members {
|
||||
let mut status = member.status.clone();
|
||||
status.cooldown = runtime.cooldown.get(&member.account_id).copied();
|
||||
status_by_account
|
||||
.entry(member.account_id.as_str())
|
||||
.or_insert_with(|| resolve_account_status(&status, now).status);
|
||||
slots_by_account
|
||||
.entry(member.account_id.as_str())
|
||||
.or_insert(member.total_slots);
|
||||
accounts_by_group
|
||||
.entry(member.group_id.as_str())
|
||||
.or_default()
|
||||
.push(member.account_id.as_str());
|
||||
}
|
||||
for record in records {
|
||||
let account_ids = accounts_by_group
|
||||
.get(record.id.as_str())
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
let available_ids = account_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|account_id| status_by_account.get(account_id) == Some(&AccountStatus::Normal))
|
||||
.collect::<Vec<_>>();
|
||||
let total = u64::try_from(account_ids.len()).unwrap_or(u64::MAX);
|
||||
let available = u64::try_from(available_ids.len()).unwrap_or(u64::MAX);
|
||||
record.account_summary = AccountGroupAccountSummary {
|
||||
available,
|
||||
limited: total.saturating_sub(available),
|
||||
total,
|
||||
};
|
||||
record.capacity = AccountGroupCapacity {
|
||||
used_slots: if available_ids.is_empty() {
|
||||
Some(0)
|
||||
} else {
|
||||
runtime.in_flight.as_ref().map(|in_flight| {
|
||||
available_ids.iter().fold(0_u64, |sum, account_id| {
|
||||
sum.saturating_add(in_flight.get(*account_id).copied().unwrap_or(0))
|
||||
})
|
||||
})
|
||||
},
|
||||
total_slots: available_ids.iter().fold(0_u64, |sum, account_id| {
|
||||
sum.saturating_add(slots_by_account.get(*account_id).copied().unwrap_or(0))
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,23 @@
|
||||
//! 管理员认证状态机。
|
||||
//! 控制面登录、会话恢复与身份校验的唯一 owner。
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{net::IpAddr, sync::Arc, time::Duration as StdDuration};
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_core::engine::execution::ClientKeyVerifier;
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq as _;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
AdminError, AdminErrorKind,
|
||||
auth::{
|
||||
AdminAuditEvent, AdminSession, AuditActorKind, LoginCommand, LoginError, LoginResult,
|
||||
AdminAuditEvent, AuditActorKind, AuthSession, ChangePassword, LoginCommand, LoginError,
|
||||
LoginResult, SessionSubject,
|
||||
},
|
||||
},
|
||||
ports::store::AuthStore,
|
||||
@@ -22,79 +25,193 @@ use crate::{
|
||||
|
||||
use super::map_store_error;
|
||||
|
||||
const LOGIN_FAILURE_LIMIT: u32 = 5;
|
||||
const LOGIN_FAILURE_WINDOW_SECONDS: u64 = 15 * 60;
|
||||
|
||||
/// API 鉴权与管理员登录消费的统一服务。
|
||||
/// 所有控制面接口消费同一个会话服务,权限由服务端身份决定。
|
||||
#[async_trait]
|
||||
pub trait AuthService: Send + Sync {
|
||||
async fn change_password(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
command: ChangePassword,
|
||||
source_ip: IpAddr,
|
||||
) -> Result<(), AdminError>;
|
||||
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError>;
|
||||
async fn session(&self, session_id: Option<&str>) -> Result<Option<AuthSession>, AdminError>;
|
||||
async fn resolve_admin_user_id(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Option<String>, AdminError>;
|
||||
async fn verify_admin_api_key(&self, key: &str) -> Result<bool, AdminError>;
|
||||
async fn login(&self, command: LoginCommand) -> Result<LoginResult, LoginError>;
|
||||
async fn validate_session(&self, session_id: Option<&str>) -> Result<bool, AdminError>;
|
||||
async fn login(
|
||||
&self,
|
||||
command: LoginCommand,
|
||||
source_ip: IpAddr,
|
||||
previous_session_id: Option<&str>,
|
||||
) -> Result<LoginResult, LoginError>;
|
||||
async fn logout(&self, session_id: &str) -> Result<(), AdminError>;
|
||||
}
|
||||
|
||||
/// 单管理员认证策略的最终实现。
|
||||
const MAX_SESSION_TTL_MINUTES: i64 = 366 * 24 * 60;
|
||||
const LOGIN_WINDOW: StdDuration = StdDuration::from_secs(60);
|
||||
const LOGIN_ATTEMPTS_PER_SOURCE: u32 = 10;
|
||||
const LOGIN_ATTEMPTS_GLOBAL: u32 = 200;
|
||||
|
||||
pub(crate) struct DefaultAuthService {
|
||||
default_admin_user_id: String,
|
||||
session_ttl: Duration,
|
||||
admin_session_ttl: Duration,
|
||||
key_session_ttl: Duration,
|
||||
store: Arc<dyn AuthStore>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
}
|
||||
|
||||
impl DefaultAuthService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
default_admin_user_id: impl Into<String>,
|
||||
session_ttl_minutes: u64,
|
||||
admin_session_ttl_minutes: u64,
|
||||
key_session_ttl_minutes: u64,
|
||||
store: Arc<dyn AuthStore>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
) -> Self {
|
||||
let minutes = i64::try_from(session_ttl_minutes)
|
||||
.unwrap_or(i64::MAX)
|
||||
.max(1);
|
||||
Self {
|
||||
default_admin_user_id: default_admin_user_id.into(),
|
||||
session_ttl: Duration::minutes(minutes),
|
||||
admin_session_ttl: session_ttl(admin_session_ttl_minutes),
|
||||
key_session_ttl: session_ttl(key_session_ttl_minutes),
|
||||
store,
|
||||
verifier,
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_failed_login(&self, source: &str) -> Result<LoginError, LoginError> {
|
||||
let throttled = self
|
||||
.store
|
||||
.record_login_failure(source, LOGIN_FAILURE_LIMIT, LOGIN_FAILURE_WINDOW_SECONDS)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?;
|
||||
Ok(if throttled {
|
||||
LoginError::Throttled
|
||||
} else {
|
||||
LoginError::InvalidCredentials
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_audit(&self, action: &str, occurred_at: chrono::DateTime<Utc>) -> AdminAuditEvent {
|
||||
fn auth_audit(&self, action: &str, admin_user_id: &str) -> AdminAuditEvent {
|
||||
AdminAuditEvent {
|
||||
id: format!("audit_{}", Uuid::now_v7().simple()),
|
||||
actor_kind: AuditActorKind::AdminSession,
|
||||
actor_admin_user_id: Some(self.default_admin_user_id.clone()),
|
||||
actor_ref: format!("admin:{}", self.default_admin_user_id),
|
||||
actor_admin_user_id: Some(admin_user_id.to_owned()),
|
||||
actor_ref: crate::model::auth::admin_session_actor_ref(admin_user_id),
|
||||
request_id: None,
|
||||
action: action.to_owned(),
|
||||
entity_kind: "admin_session".to_owned(),
|
||||
entity_ref: self.default_admin_user_id.clone(),
|
||||
entity_ref: admin_user_id.to_owned(),
|
||||
config_revision: None,
|
||||
changed_fields: Vec::new(),
|
||||
occurred_at,
|
||||
occurred_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn authenticate(&self, command: LoginCommand) -> Result<SessionSubject, LoginError> {
|
||||
match command {
|
||||
LoginCommand::Admin { username, password } => {
|
||||
if username.as_deref().unwrap_or(&self.default_admin_user_id)
|
||||
!= self.default_admin_user_id
|
||||
{
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
let password_hash = self
|
||||
.store
|
||||
.load_password_hash(&self.default_admin_user_id)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
.ok_or(LoginError::InvalidCredentials)?;
|
||||
if password.len() > 4096
|
||||
|| !verify_admin_password(&password, &password_hash)
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
{
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
Ok(SessionSubject::Admin {
|
||||
admin_user_id: self.default_admin_user_id.clone(),
|
||||
credential_fingerprint: password_fingerprint(&password_hash),
|
||||
})
|
||||
}
|
||||
LoginCommand::Key { api_key } => {
|
||||
if api_key.is_empty() || api_key.len() > 4096 {
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
let client_key_id = self.verifier.verify_client_key(&api_key)?;
|
||||
if !self
|
||||
.store
|
||||
.client_key_enabled(&client_key_id)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
{
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
Ok(SessionSubject::Key { client_key_id })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthService for DefaultAuthService {
|
||||
async fn change_password(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
command: ChangePassword,
|
||||
source_ip: IpAddr,
|
||||
) -> Result<(), AdminError> {
|
||||
let session = self
|
||||
.session(session_id)
|
||||
.await?
|
||||
.ok_or_else(|| AdminError::new(AdminErrorKind::Unauthorized, "请先登录"))?;
|
||||
let SessionSubject::Admin {
|
||||
admin_user_id,
|
||||
credential_fingerprint,
|
||||
} = session.subject
|
||||
else {
|
||||
return Err(AdminError::new(
|
||||
AdminErrorKind::Forbidden,
|
||||
"仅管理员可以修改密码",
|
||||
));
|
||||
};
|
||||
if self
|
||||
.store
|
||||
.consume_login_attempt(
|
||||
source_ip,
|
||||
LOGIN_ATTEMPTS_PER_SOURCE,
|
||||
LOGIN_ATTEMPTS_GLOBAL,
|
||||
LOGIN_WINDOW,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "password change limit"))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AdminError::new(
|
||||
AdminErrorKind::RateLimited,
|
||||
"尝试过于频繁,请稍后再试",
|
||||
));
|
||||
}
|
||||
validate_new_password(&command.new_password)?;
|
||||
let password_hash = self
|
||||
.store
|
||||
.load_password_hash(&admin_user_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator"))?
|
||||
.filter(|hash| password_fingerprint(hash) == credential_fingerprint)
|
||||
.ok_or_else(|| AdminError::conflict("密码已变更,请重新登录"))?;
|
||||
if command.current_password.len() > 4096
|
||||
|| !verify_admin_password(&command.current_password, &password_hash)?
|
||||
{
|
||||
return Err(AdminError::invalid("当前密码不正确"));
|
||||
}
|
||||
if command.new_password == command.current_password {
|
||||
return Err(AdminError::invalid("新密码不能与当前密码相同"));
|
||||
}
|
||||
let hash = hash_admin_password(&command.new_password)?;
|
||||
let mut audit = self.auth_audit("admin.password_changed", &admin_user_id);
|
||||
audit.entity_kind = "admin_user".to_owned();
|
||||
audit.changed_fields = vec!["password".to_owned()];
|
||||
if !self
|
||||
.store
|
||||
.change_password(&admin_user_id, &password_hash, &hash, audit)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator password"))?
|
||||
{
|
||||
return Err(AdminError::conflict("密码已变更,请重新登录"));
|
||||
}
|
||||
// 密码事务提交后旧指纹不再匹配,会话撤销不依赖 Redis 删除成功。
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError> {
|
||||
let hash = hash_admin_password(password)?;
|
||||
self.store
|
||||
@@ -103,22 +220,68 @@ impl AuthService for DefaultAuthService {
|
||||
.map_err(|error| map_store_error(error, "administrator"))
|
||||
}
|
||||
|
||||
async fn session(&self, session_id: Option<&str>) -> Result<Option<AuthSession>, AdminError> {
|
||||
let Some(session_id) = session_id.filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(session) = self
|
||||
.store
|
||||
.load_session(session_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "authentication session"))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if session.expires_at <= Utc::now() {
|
||||
let _ = self.store.delete_session(session_id).await;
|
||||
return Ok(None);
|
||||
}
|
||||
if let SessionSubject::Admin {
|
||||
admin_user_id,
|
||||
credential_fingerprint,
|
||||
} = &session.subject
|
||||
{
|
||||
let password_hash = self
|
||||
.store
|
||||
.load_password_hash(admin_user_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator session"))?;
|
||||
if password_hash
|
||||
.is_none_or(|hash| password_fingerprint(&hash) != *credential_fingerprint)
|
||||
{
|
||||
let _ = self.store.delete_session(session_id).await;
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
if let SessionSubject::Key { client_key_id } = &session.subject
|
||||
&& !self
|
||||
.store
|
||||
.client_key_enabled(client_key_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "session key"))?
|
||||
{
|
||||
let _ = self.store.delete_session(session_id).await;
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(session))
|
||||
}
|
||||
|
||||
async fn resolve_admin_user_id(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Option<String>, AdminError> {
|
||||
let Some(session_id) = session_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.store
|
||||
.load_session(session_id)
|
||||
.await
|
||||
.map(|session| {
|
||||
session
|
||||
.filter(|session| session.expires_at > Utc::now())
|
||||
.map(|session| session.admin_user_id)
|
||||
})
|
||||
.map_err(|error| map_store_error(error, "administrator session"))
|
||||
match self
|
||||
.session(session_id)
|
||||
.await?
|
||||
.map(|session| session.subject)
|
||||
{
|
||||
Some(SessionSubject::Admin { admin_user_id, .. }) => Ok(Some(admin_user_id)),
|
||||
Some(SessionSubject::Key { .. }) => Err(AdminError::new(
|
||||
AdminErrorKind::Forbidden,
|
||||
"当前身份无权访问管理接口",
|
||||
)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_admin_api_key(&self, key: &str) -> Result<bool, AdminError> {
|
||||
@@ -136,82 +299,77 @@ impl AuthService for DefaultAuthService {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn login(&self, command: LoginCommand) -> Result<LoginResult, LoginError> {
|
||||
let source = normalized_login_source(&command.source);
|
||||
if self
|
||||
async fn login(
|
||||
&self,
|
||||
command: LoginCommand,
|
||||
source_ip: IpAddr,
|
||||
previous_session_id: Option<&str>,
|
||||
) -> Result<LoginResult, LoginError> {
|
||||
if let Some(retry_after) = self
|
||||
.store
|
||||
.login_source_is_throttled(source, LOGIN_FAILURE_LIMIT, LOGIN_FAILURE_WINDOW_SECONDS)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
{
|
||||
return Err(LoginError::Throttled);
|
||||
}
|
||||
if command
|
||||
.username
|
||||
.as_deref()
|
||||
.unwrap_or(&self.default_admin_user_id)
|
||||
!= self.default_admin_user_id
|
||||
{
|
||||
return Err(self.record_failed_login(source).await?);
|
||||
}
|
||||
let hash = self
|
||||
.store
|
||||
.load_password_hash(&self.default_admin_user_id)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
.ok_or(LoginError::InvalidCredentials)?;
|
||||
if !verify_admin_password(&command.password, &hash).map_err(|_| LoginError::Unavailable)? {
|
||||
return Err(self.record_failed_login(source).await?);
|
||||
}
|
||||
|
||||
self.store
|
||||
.clear_login_failures(source)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?;
|
||||
let session_id = random_session_token();
|
||||
let expires_at = Utc::now() + self.session_ttl;
|
||||
self.store
|
||||
.store_session(
|
||||
&session_id,
|
||||
&AdminSession {
|
||||
admin_user_id: self.default_admin_user_id.clone(),
|
||||
expires_at,
|
||||
},
|
||||
.consume_login_attempt(
|
||||
source_ip,
|
||||
LOGIN_ATTEMPTS_PER_SOURCE,
|
||||
LOGIN_ATTEMPTS_GLOBAL,
|
||||
LOGIN_WINDOW,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?;
|
||||
if self
|
||||
.store
|
||||
.append_audit_event(self.auth_audit("admin.login", Utc::now()))
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
{
|
||||
return Err(LoginError::TooManyAttempts {
|
||||
retry_after_seconds: retry_after.as_secs().max(1),
|
||||
});
|
||||
}
|
||||
let subject = self.authenticate(command).await?;
|
||||
let ttl = match subject {
|
||||
SessionSubject::Admin { .. } => self.admin_session_ttl,
|
||||
SessionSubject::Key { .. } => self.key_session_ttl,
|
||||
};
|
||||
let session = AuthSession {
|
||||
subject,
|
||||
expires_at: Utc::now() + ttl,
|
||||
};
|
||||
let session_id = random_session_token();
|
||||
self.store
|
||||
.store_session(&session_id, &session)
|
||||
.await
|
||||
.is_err()
|
||||
.map_err(|_| LoginError::Unavailable)?;
|
||||
if let SessionSubject::Admin { admin_user_id, .. } = &session.subject
|
||||
&& self
|
||||
.store
|
||||
.append_audit_event(self.auth_audit("admin.login", admin_user_id))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = self.store.delete_session(&session_id).await;
|
||||
return Err(LoginError::Unavailable);
|
||||
}
|
||||
// 新身份验证成功后才撤销旧会话;撤销失败时不向浏览器提交新会话。
|
||||
if let Some(previous) = previous_session_id.filter(|value| !value.is_empty())
|
||||
&& self.logout(previous).await.is_err()
|
||||
{
|
||||
let _ = self.store.delete_session(&session_id).await;
|
||||
return Err(LoginError::Unavailable);
|
||||
}
|
||||
Ok(LoginResult {
|
||||
session_id,
|
||||
expires_at,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
async fn validate_session(&self, session_id: Option<&str>) -> Result<bool, AdminError> {
|
||||
Ok(self.resolve_admin_user_id(session_id).await?.is_some())
|
||||
}
|
||||
|
||||
async fn logout(&self, session_id: &str) -> Result<(), AdminError> {
|
||||
let session = self
|
||||
.store
|
||||
.delete_session(session_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator session"))?;
|
||||
if let Some(session) = session {
|
||||
let mut event = self.auth_audit("admin.logout", Utc::now());
|
||||
event.actor_admin_user_id = Some(session.admin_user_id.clone());
|
||||
event.actor_ref = format!("admin:{}", session.admin_user_id);
|
||||
event.entity_ref = session.admin_user_id;
|
||||
.map_err(|error| map_store_error(error, "authentication session"))?;
|
||||
if let Some(AuthSession {
|
||||
subject: SessionSubject::Admin { admin_user_id, .. },
|
||||
..
|
||||
}) = session
|
||||
{
|
||||
self.store
|
||||
.append_audit_event(event)
|
||||
.append_audit_event(self.auth_audit("admin.logout", &admin_user_id))
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator audit"))?;
|
||||
}
|
||||
@@ -219,17 +377,37 @@ impl AuthService for DefaultAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
fn session_ttl(minutes: u64) -> Duration {
|
||||
Duration::minutes(
|
||||
i64::try_from(minutes)
|
||||
.unwrap_or(MAX_SESSION_TTL_MINUTES)
|
||||
.clamp(1, MAX_SESSION_TTL_MINUTES),
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_new_password(password: &str) -> Result<(), AdminError> {
|
||||
if password.trim().chars().count() < 12
|
||||
|| password.len() > 1024
|
||||
|| password.chars().any(char::is_control)
|
||||
|| crate::WEAK_ADMIN_PASSWORDS.contains(&password.trim().to_ascii_lowercase().as_str())
|
||||
{
|
||||
return Err(AdminError::invalid(
|
||||
"新密码至少需要 12 个字符,最多 1024 字节,不能使用常见弱口令或控制字符",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash_admin_password(password: &str) -> Result<String, AdminError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.hash_password(password.as_bytes())
|
||||
.map(|hash| hash.to_string())
|
||||
.map_err(|_| AdminError::internal("Failed to hash administrator password"))
|
||||
.map_err(|_| AdminError::internal("管理员密码哈希失败"))
|
||||
}
|
||||
|
||||
fn verify_admin_password(password: &str, encoded: &str) -> Result<bool, AdminError> {
|
||||
let hash = PasswordHash::new(encoded)
|
||||
.map_err(|_| AdminError::internal("Stored administrator password hash is invalid"))?;
|
||||
.map_err(|_| AdminError::internal("已保存的管理员密码哈希不合法"))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(password.as_bytes(), &hash)
|
||||
.is_ok())
|
||||
@@ -247,7 +425,7 @@ fn valid_admin_api_key_shape(value: &str) -> bool {
|
||||
&& value[6..].bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn normalized_login_source(source: &str) -> &str {
|
||||
let source = source.trim();
|
||||
if source.is_empty() { "unknown" } else { source }
|
||||
// 指纹只绑定已加盐的密码哈希,不把密码或原始哈希复制到 Redis 会话。
|
||||
fn password_fingerprint(password_hash: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(Sha256::digest(password_hash.as_bytes()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
//! 备份配置、手动创建、下载与删除用例。
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
use secrecy::ExposeSecret as _;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::model::{
|
||||
AdminError, AdminErrorKind, MutationContext,
|
||||
auth::{AdminAuditEvent, AuditActorKind},
|
||||
backup::{
|
||||
BackupError, BackupRecord, BackupRecordListQuery, BackupRecordPage, BackupSettings,
|
||||
BackupStatus, BackupStorageConfig, BackupTriggerKind, ConnectionTestResult,
|
||||
DownloadUrlResult, UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
|
||||
build_backup_seed, code,
|
||||
},
|
||||
};
|
||||
use crate::ports::{
|
||||
backup::{BackupObjectStorePort, BackupRepository},
|
||||
store::AuthStore,
|
||||
};
|
||||
|
||||
use super::map_store_error;
|
||||
|
||||
/// 默认下载地址有效期。
|
||||
const DOWNLOAD_TTL: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// API 消费的备份管理服务。
|
||||
#[async_trait]
|
||||
pub trait BackupService: Send + Sync {
|
||||
async fn load_settings(&self) -> Result<BackupSettings, AdminError>;
|
||||
|
||||
async fn update_storage(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateBackupStorageCommand,
|
||||
) -> Result<BackupSettings, AdminError>;
|
||||
|
||||
async fn update_schedule(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateBackupScheduleCommand,
|
||||
) -> Result<BackupSettings, AdminError>;
|
||||
|
||||
async fn test_connection(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> Result<ConnectionTestResult, AdminError>;
|
||||
|
||||
async fn create_backup(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<BackupRecord, AdminError>;
|
||||
|
||||
async fn list_records(
|
||||
&self,
|
||||
query: BackupRecordListQuery,
|
||||
) -> Result<BackupRecordPage, AdminError>;
|
||||
|
||||
async fn download_url(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
backup_id: &str,
|
||||
) -> Result<DownloadUrlResult, AdminError>;
|
||||
|
||||
async fn delete_backup(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
backup_id: &str,
|
||||
) -> Result<BackupRecord, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultBackupService {
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl DefaultBackupService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
object_store,
|
||||
auth,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BackupService for DefaultBackupService {
|
||||
async fn load_settings(&self) -> Result<BackupSettings, AdminError> {
|
||||
self.repository
|
||||
.load_settings()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup settings"))
|
||||
}
|
||||
|
||||
async fn update_storage(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateBackupStorageCommand,
|
||||
) -> Result<BackupSettings, AdminError> {
|
||||
validate_storage(&command)?;
|
||||
let (settings, revision) = self
|
||||
.repository
|
||||
.update_storage_settings(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup storage"))?;
|
||||
super::publish_committed(self.snapshot.as_ref(), revision).await?;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
async fn update_schedule(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateBackupScheduleCommand,
|
||||
) -> Result<BackupSettings, AdminError> {
|
||||
validate_schedule(&command)?;
|
||||
let next_run_at = if command.schedule_enabled {
|
||||
Some(
|
||||
crate::backup::policy::BackupSchedule::parse(
|
||||
&command.cron_expression,
|
||||
&command.schedule_timezone,
|
||||
)
|
||||
.map_err(map_backup_error)?
|
||||
.next_after(Utc::now())
|
||||
.ok_or_else(|| AdminError::bad_request("无法从计划推导下次执行时间"))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.repository
|
||||
.update_schedule_settings(command, next_run_at, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup schedule"))
|
||||
}
|
||||
|
||||
async fn test_connection(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> Result<ConnectionTestResult, AdminError> {
|
||||
let settings = self
|
||||
.repository
|
||||
.load_settings()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup settings"))?;
|
||||
let Some(config) = BackupStorageConfig::from_settings(&settings) else {
|
||||
return Ok(ConnectionTestResult {
|
||||
ok: false,
|
||||
stage: "putObject",
|
||||
code: Some(code::INVALID_CONFIG),
|
||||
message: "备份存储配置不完整,无法执行连接测试".to_owned(),
|
||||
});
|
||||
};
|
||||
let result = self
|
||||
.object_store
|
||||
.test_connection(&config)
|
||||
.await
|
||||
.map_err(map_backup_error)?;
|
||||
if result.ok {
|
||||
let _ = self
|
||||
.repository
|
||||
.record_verification(config.storage_revision, Utc::now())
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup settings"))?;
|
||||
}
|
||||
self.append_audit(
|
||||
context,
|
||||
"backup.s3_connection_tested",
|
||||
"backup_settings",
|
||||
vec![],
|
||||
)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn create_backup(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<BackupRecord, AdminError> {
|
||||
let settings = self
|
||||
.repository
|
||||
.load_settings()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup settings"))?;
|
||||
if !settings.storage_configured() {
|
||||
return Err(AdminError::bad_request(
|
||||
"备份存储配置不完整,请先保存存储配置",
|
||||
));
|
||||
}
|
||||
if !settings.storage_verified() {
|
||||
return Err(AdminError::bad_request(
|
||||
"备份存储尚未通过连接测试,请先测试存储配置",
|
||||
));
|
||||
}
|
||||
let prefix = settings.prefix.as_deref().unwrap_or_default();
|
||||
let seed = build_backup_seed(
|
||||
BackupTriggerKind::Manual,
|
||||
None,
|
||||
prefix,
|
||||
Utc::now(),
|
||||
expires_at,
|
||||
)
|
||||
.map_err(map_backup_error)?;
|
||||
let record = self
|
||||
.repository
|
||||
.insert_backup_record(seed)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup record"))?;
|
||||
self.append_audit(context, "backup.created", "backup", vec![record.id.clone()])
|
||||
.await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
async fn list_records(
|
||||
&self,
|
||||
query: BackupRecordListQuery,
|
||||
) -> Result<BackupRecordPage, AdminError> {
|
||||
self.repository
|
||||
.list_backup_records(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup records"))
|
||||
}
|
||||
|
||||
async fn download_url(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
backup_id: &str,
|
||||
) -> Result<DownloadUrlResult, AdminError> {
|
||||
let record = self.require_record(backup_id).await?;
|
||||
if record.status != BackupStatus::Completed {
|
||||
return Err(AdminError::conflict(
|
||||
"只有 completed 的备份才能创建下载地址",
|
||||
));
|
||||
}
|
||||
let settings = self
|
||||
.repository
|
||||
.load_settings()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup settings"))?;
|
||||
let Some(config) = BackupStorageConfig::from_settings(&settings) else {
|
||||
return Err(AdminError::bad_request("备份存储配置不完整"));
|
||||
};
|
||||
let file_name = crate::model::backup::build_download_file_name(backup_id);
|
||||
let url = self
|
||||
.object_store
|
||||
.presigned_download(&config, &record.object_key, &file_name, DOWNLOAD_TTL)
|
||||
.await
|
||||
.map_err(map_backup_error)?;
|
||||
self.append_audit(
|
||||
context,
|
||||
"backup.download_url_created",
|
||||
"backup",
|
||||
vec![backup_id.to_owned()],
|
||||
)
|
||||
.await?;
|
||||
Ok(DownloadUrlResult {
|
||||
url,
|
||||
file_name,
|
||||
expires_in: DOWNLOAD_TTL,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_backup(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
backup_id: &str,
|
||||
) -> Result<BackupRecord, AdminError> {
|
||||
let record = self.require_record(backup_id).await?;
|
||||
if !record.status.can_be_deleted() {
|
||||
return Err(AdminError::conflict(
|
||||
"只有 completed 或 failed 的备份才能删除",
|
||||
));
|
||||
}
|
||||
let now = Utc::now();
|
||||
let deleting = self
|
||||
.repository
|
||||
.transition_to_deleting(backup_id, now)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup record"))?
|
||||
.ok_or_else(|| AdminError::conflict("备份记录已进入删除流程"))?;
|
||||
self.append_audit(
|
||||
context,
|
||||
"backup.delete_requested",
|
||||
"backup",
|
||||
vec![backup_id.to_owned()],
|
||||
)
|
||||
.await?;
|
||||
Ok(deleting)
|
||||
}
|
||||
}
|
||||
|
||||
impl DefaultBackupService {
|
||||
async fn require_record(&self, backup_id: &str) -> Result<BackupRecord, AdminError> {
|
||||
self.repository
|
||||
.load_backup_record(backup_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup record"))?
|
||||
.ok_or_else(|| AdminError::not_found("备份记录不存在"))
|
||||
}
|
||||
|
||||
async fn append_audit(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
action: &str,
|
||||
entity_ref: &str,
|
||||
changed_fields: Vec<String>,
|
||||
) -> Result<(), AdminError> {
|
||||
let actor_kind = match context.actor {
|
||||
crate::model::MutationActor::AdminSession { .. } => AuditActorKind::AdminSession,
|
||||
crate::model::MutationActor::AdminApiKey => AuditActorKind::AdminApiKey,
|
||||
crate::model::MutationActor::System => AuditActorKind::System,
|
||||
};
|
||||
let actor_ref = match &context.actor {
|
||||
crate::model::MutationActor::AdminSession { admin_user_id } => {
|
||||
crate::model::auth::admin_session_actor_ref(admin_user_id)
|
||||
}
|
||||
crate::model::MutationActor::AdminApiKey => "admin_api_key".to_owned(),
|
||||
crate::model::MutationActor::System => "system".to_owned(),
|
||||
};
|
||||
let actor_admin_user_id = match &context.actor {
|
||||
crate::model::MutationActor::AdminSession { admin_user_id } => {
|
||||
Some(admin_user_id.clone())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let event = AdminAuditEvent {
|
||||
id: format!("audit_{}", Uuid::now_v7().simple()),
|
||||
actor_kind,
|
||||
actor_admin_user_id,
|
||||
actor_ref,
|
||||
request_id: Some(context.request_id.clone()),
|
||||
action: action.to_owned(),
|
||||
entity_kind: "backup".to_owned(),
|
||||
entity_ref: entity_ref.to_owned(),
|
||||
config_revision: None,
|
||||
changed_fields,
|
||||
occurred_at: Utc::now(),
|
||||
};
|
||||
self.auth
|
||||
.append_audit_event(event)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup audit"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验存储配置;空串视为非法,Secret 缺省表示保留旧值。
|
||||
fn validate_storage(command: &UpdateBackupStorageCommand) -> Result<(), AdminError> {
|
||||
validate_endpoint(&command.endpoint)?;
|
||||
validate_nonempty("region", &command.region)?;
|
||||
validate_nonempty("bucket", &command.bucket)?;
|
||||
validate_nonempty("accessKeyId", &command.access_key_id)?;
|
||||
if let Some(secret) = command.secret_access_key.as_ref() {
|
||||
let value = secret.expose_secret();
|
||||
if value.is_empty() {
|
||||
return Err(AdminError::bad_request(
|
||||
"secretAccessKey 不能为空字符串;缺省表示保留旧值",
|
||||
));
|
||||
}
|
||||
validate_length("secretAccessKey", value, 512)?;
|
||||
}
|
||||
crate::model::backup::normalize_prefix(&command.prefix).map_err(map_backup_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_endpoint(value: &str) -> Result<(), AdminError> {
|
||||
if value.is_empty() {
|
||||
return Err(AdminError::bad_request("endpoint 不能为空"));
|
||||
}
|
||||
if !value.starts_with("https://") {
|
||||
return Err(AdminError::bad_request(
|
||||
"endpoint 必须使用 HTTPS;仅测试或显式开发配置允许 HTTP",
|
||||
));
|
||||
}
|
||||
if value.contains('@') || value.bytes().any(|byte| byte.is_ascii_control()) {
|
||||
return Err(AdminError::bad_request("endpoint 格式非法"));
|
||||
}
|
||||
validate_length("endpoint", value, 2048)
|
||||
}
|
||||
|
||||
fn validate_nonempty(field: &str, value: &str) -> Result<(), AdminError> {
|
||||
if value.is_empty() {
|
||||
return Err(AdminError::bad_request(format!("{field} 不能为空")));
|
||||
}
|
||||
validate_length(field, value, 255)
|
||||
}
|
||||
|
||||
fn validate_length(field: &str, value: &str, max: usize) -> Result<(), AdminError> {
|
||||
if value.len() > max || value.bytes().any(|byte| byte.is_ascii_control()) {
|
||||
return Err(AdminError::bad_request(format!(
|
||||
"{field} 超出长度或包含非法字符"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验调度配置并解析 Cron 与时区。
|
||||
fn validate_schedule(command: &UpdateBackupScheduleCommand) -> Result<(), AdminError> {
|
||||
if command.cron_expression.trim().is_empty() {
|
||||
return Err(AdminError::bad_request("cronExpression 不能为空"));
|
||||
}
|
||||
crate::backup::policy::BackupSchedule::parse(
|
||||
&command.cron_expression,
|
||||
&command.schedule_timezone,
|
||||
)
|
||||
.map_err(map_backup_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 备份基础设施错误到既有 `AdminErrorKind` 的唯一边界映射。
|
||||
fn map_backup_error(error: BackupError) -> AdminError {
|
||||
match error.code() {
|
||||
code::INVALID_CONFIG | code::INVALID_CRON | code::INVALID_TIMEZONE => {
|
||||
AdminError::bad_request(error.message().to_owned())
|
||||
}
|
||||
code::RECORD_NOT_FOUND => AdminError::not_found(error.message().to_owned()),
|
||||
code::ACTIVE_TASK_CONFLICT | code::STATE_CONFLICT | code::STORAGE_IDENTITY_LOCKED => {
|
||||
AdminError::conflict(error.message().to_owned())
|
||||
}
|
||||
code::STORE_UNAVAILABLE => AdminError::new(AdminErrorKind::Unavailable, "备份服务暂不可用"),
|
||||
_ => AdminError::bad_gateway("备份服务请求失败"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Codex 客户端下载信息用例。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
model::client_distribution::CodexDesktopWindowsDownloads,
|
||||
ports::client_distribution::ClientDistributionResolver,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait ClientDistributionService: Send + Sync {
|
||||
async fn codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultClientDistributionService {
|
||||
resolver: Arc<dyn ClientDistributionResolver>,
|
||||
}
|
||||
|
||||
impl DefaultClientDistributionService {
|
||||
#[must_use]
|
||||
pub(crate) const fn new(resolver: Arc<dyn ClientDistributionResolver>) -> Self {
|
||||
Self { resolver }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ClientDistributionService for DefaultClientDistributionService {
|
||||
async fn codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads {
|
||||
self.resolver.resolve_codex_desktop_windows(refresh).await
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::routing::snapshot::SnapshotControl;
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -15,10 +15,11 @@ use crate::{
|
||||
client_keys::{
|
||||
ClientKeyCursorValue, ClientKeyListQuery, ClientKeyMutation, ClientKeyPage,
|
||||
ClientKeySecret, ClientKeySortField, CreateClientKey, CreatedClientKey,
|
||||
DeleteClientKey, NewClientKey, SetClientKeyEnabled, UpdateClientKey,
|
||||
DeleteClientKey, NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
|
||||
UpdateClientKey,
|
||||
},
|
||||
},
|
||||
ports::store::ClientKeyStore,
|
||||
ports::store::{AdminStoreError, AdminStoreErrorKind, ClientKeyStore},
|
||||
};
|
||||
|
||||
use super::{map_store_error, publish_committed};
|
||||
@@ -48,22 +49,49 @@ pub trait ClientKeyService: Send + Sync {
|
||||
context: &MutationContext,
|
||||
command: DeleteClientKey,
|
||||
) -> Result<ClientKeyMutation, AdminError>;
|
||||
async fn reset_budget(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: ResetClientKeyBudget,
|
||||
) -> Result<ClientApiKeyId, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultClientKeyService {
|
||||
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
store: Arc<dyn ClientKeyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl DefaultClientKeyService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(store: Arc<dyn ClientKeyStore>, snapshot: Arc<dyn SnapshotControl>) -> Self {
|
||||
Self { store, snapshot }
|
||||
pub(crate) fn new(
|
||||
store: Arc<dyn ClientKeyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
snapshot,
|
||||
profile_provider,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyService for DefaultClientKeyService {
|
||||
async fn reset_budget(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: ResetClientKeyBudget,
|
||||
) -> Result<ClientApiKeyId, AdminError> {
|
||||
let id = command.id.clone();
|
||||
self.store
|
||||
.reset_client_key_budget(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError> {
|
||||
validate_cursor(&query)?;
|
||||
self.store
|
||||
@@ -77,7 +105,7 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
.reveal_client_key(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?
|
||||
.ok_or_else(|| AdminError::not_found("Client API key was not found"))
|
||||
.ok_or_else(|| AdminError::not_found("Client API Key 不存在"))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
@@ -85,27 +113,37 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
context: &MutationContext,
|
||||
command: CreateClientKey,
|
||||
) -> Result<CreatedClientKey, AdminError> {
|
||||
if let Some(profile) = &command.openai_client_profile_override {
|
||||
self.profile_provider
|
||||
.preview_client_profile(profile)
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
}
|
||||
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
|
||||
.map_err(|_| AdminError::internal("Failed to create Client API key ID"))?;
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let plaintext = format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes));
|
||||
.map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?;
|
||||
let plaintext = if let Some(key) = command.custom_key {
|
||||
key.expose_for_auth().to_owned()
|
||||
} else {
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
};
|
||||
let (config_revision, record) = self
|
||||
.store
|
||||
.create_client_key(
|
||||
NewClientKey {
|
||||
expected_config_revision: command.expected_config_revision,
|
||||
openai_client_profile_override: command.openai_client_profile_override,
|
||||
id,
|
||||
name: command.name,
|
||||
label: command.label,
|
||||
provider_kind: command.provider_kind,
|
||||
group_ids: command.group_ids,
|
||||
limits: command.limits,
|
||||
budget: command.budget,
|
||||
plaintext: plaintext.clone(),
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?;
|
||||
.map_err(map_client_key_write_error)?;
|
||||
publish_committed(self.snapshot.as_ref(), config_revision).await?;
|
||||
Ok(CreatedClientKey {
|
||||
config_revision,
|
||||
@@ -118,12 +156,17 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
context: &MutationContext,
|
||||
command: UpdateClientKey,
|
||||
) -> Result<ClientKeyMutation, AdminError> {
|
||||
if let Some(Some(profile)) = &command.openai_client_profile_override {
|
||||
self.profile_provider
|
||||
.preview_client_profile(profile)
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
}
|
||||
let id = command.id.clone();
|
||||
let (config_revision, record) = self
|
||||
.store
|
||||
.update_client_key(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?;
|
||||
.map_err(map_client_key_write_error)?;
|
||||
publish_committed(self.snapshot.as_ref(), config_revision).await?;
|
||||
Ok(ClientKeyMutation {
|
||||
config_revision,
|
||||
@@ -171,13 +214,21 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_client_key_write_error(error: AdminStoreError) -> AdminError {
|
||||
match error.kind() {
|
||||
AdminStoreErrorKind::DuplicateName => AdminError::conflict("名称已存在"),
|
||||
AdminStoreErrorKind::Conflict => AdminError::conflict("API Key 已存在,请使用其他密钥"),
|
||||
_ => map_store_error(error, "client API key"),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
|
||||
let Some(cursor) = &query.cursor else {
|
||||
return Ok(());
|
||||
};
|
||||
if cursor.sort != query.sort {
|
||||
return Err(AdminError::invalid(
|
||||
"Client API key cursor sort does not match the query",
|
||||
"Client API Key 游标排序与查询条件不一致",
|
||||
));
|
||||
}
|
||||
let matches = matches!(
|
||||
@@ -199,6 +250,6 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
|
||||
if matches {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AdminError::invalid("Invalid Client API key cursor"))
|
||||
Err(AdminError::invalid("Client API Key 游标不合法"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! 导入队列由 Host 管理生命周期,HTTP 只负责接受输入和读取快照。
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
panic::AssertUnwindSafe,
|
||||
sync::{Arc, Mutex, MutexGuard},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use chrono::Utc;
|
||||
use futures::{FutureExt as _, StreamExt as _, future::BoxFuture, stream::FuturesUnordered};
|
||||
use gateway_core::{
|
||||
lifecycle::CancellationToken,
|
||||
task::{DaemonTask, WorkerTaskError},
|
||||
};
|
||||
use tokio::{sync::Notify, time::Instant};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{openai::OpenAiService, xai::XaiService};
|
||||
use crate::model::{
|
||||
AdminError, AdminErrorKind, MutationActor, MutationContext,
|
||||
import_tasks::{
|
||||
ImportItemStatus, ImportTaskCounts, ImportTaskDetail, ImportTaskInput, ImportTaskItem,
|
||||
ImportTaskSummary, MAX_IMPORT_TASK_ITEMS, SubmitImportTask,
|
||||
},
|
||||
};
|
||||
|
||||
const CONCURRENCY: usize = 3;
|
||||
const MAX_ACTIVE_TASKS: usize = 8;
|
||||
const MAX_RETAINED_TASKS: usize = 100;
|
||||
const RETENTION: Duration = Duration::from_secs(3600);
|
||||
|
||||
pub trait ImportTasksService: Send + Sync {
|
||||
fn submit(&self, command: SubmitImportTask) -> Result<ImportTaskSummary, AdminError>;
|
||||
fn list(&self, context: &MutationContext) -> Vec<ImportTaskSummary>;
|
||||
fn detail(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
task_id: Uuid,
|
||||
) -> Result<ImportTaskDetail, AdminError>;
|
||||
fn stop(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
task_id: Uuid,
|
||||
) -> Result<ImportTaskDetail, AdminError>;
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
task_id: Uuid,
|
||||
submission_id: Uuid,
|
||||
fingerprint: [u8; 32],
|
||||
owner: MutationActor,
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
finished_at: Option<chrono::DateTime<Utc>>,
|
||||
finished_instant: Option<Instant>,
|
||||
stop_requested: bool,
|
||||
items: Vec<ImportTaskItem>,
|
||||
inputs: Vec<Option<ImportTaskInput>>,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
fn summary(&self) -> ImportTaskSummary {
|
||||
let mut counts = ImportTaskCounts::default();
|
||||
for item in &self.items {
|
||||
match item.status {
|
||||
ImportItemStatus::Pending => counts.pending += 1,
|
||||
ImportItemStatus::Running => counts.running += 1,
|
||||
ImportItemStatus::Succeeded => counts.succeeded += 1,
|
||||
ImportItemStatus::Failed => counts.failed += 1,
|
||||
ImportItemStatus::Unknown => counts.unknown += 1,
|
||||
ImportItemStatus::Skipped => counts.skipped += 1,
|
||||
}
|
||||
counts.imported_accounts += item.account_ids.len();
|
||||
}
|
||||
ImportTaskSummary {
|
||||
task_id: self.task_id,
|
||||
created_at: self.created_at,
|
||||
finished_at: self.finished_at,
|
||||
stop_requested: self.stop_requested,
|
||||
total: self.items.len(),
|
||||
counts,
|
||||
}
|
||||
}
|
||||
|
||||
fn detail(&self) -> ImportTaskDetail {
|
||||
ImportTaskDetail {
|
||||
summary: self.summary(),
|
||||
items: self.items.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_if_done(&mut self) {
|
||||
if self.finished_at.is_none()
|
||||
&& self.items.iter().all(|item| {
|
||||
!matches!(
|
||||
item.status,
|
||||
ImportItemStatus::Pending | ImportItemStatus::Running
|
||||
)
|
||||
})
|
||||
{
|
||||
self.finished_at = Some(Utc::now());
|
||||
self.finished_instant = Some(Instant::now());
|
||||
// 终态仅保留结果,连已清空的输入槽位分配也一并释放。
|
||||
self.inputs = Vec::new();
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
if self.finished_at.is_some() {
|
||||
return;
|
||||
}
|
||||
self.stop_requested = true;
|
||||
for (item, input) in self.items.iter_mut().zip(&mut self.inputs) {
|
||||
if item.status == ImportItemStatus::Pending {
|
||||
item.status = ImportItemStatus::Skipped;
|
||||
*input = None;
|
||||
}
|
||||
}
|
||||
self.finish_if_done();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Registry {
|
||||
tasks: VecDeque<Entry>,
|
||||
shutting_down: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultImportTasksService {
|
||||
registry: Mutex<Registry>,
|
||||
notify: Notify,
|
||||
openai: Arc<dyn OpenAiService>,
|
||||
xai: Arc<dyn XaiService>,
|
||||
}
|
||||
|
||||
impl DefaultImportTasksService {
|
||||
pub(crate) fn new(openai: Arc<dyn OpenAiService>, xai: Arc<dyn XaiService>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
registry: Mutex::default(),
|
||||
notify: Notify::new(),
|
||||
openai,
|
||||
xai,
|
||||
})
|
||||
}
|
||||
|
||||
fn registry(&self) -> MutexGuard<'_, Registry> {
|
||||
let mut registry = self
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
registry.tasks.retain(|entry| {
|
||||
entry
|
||||
.finished_instant
|
||||
.is_none_or(|at| at.elapsed() < RETENTION)
|
||||
});
|
||||
registry
|
||||
}
|
||||
|
||||
fn take_next(&self) -> Option<(Uuid, usize, ImportTaskInput)> {
|
||||
let mut registry = self.registry();
|
||||
if registry.shutting_down {
|
||||
return None;
|
||||
}
|
||||
let task_index = registry
|
||||
.tasks
|
||||
.iter()
|
||||
.position(|entry| entry.inputs.iter().any(Option::is_some))?;
|
||||
// 轮转任务,避免大批次独占后续全部执行机会。
|
||||
let mut entry = registry.tasks.remove(task_index)?;
|
||||
let index = entry.inputs.iter().position(Option::is_some)?;
|
||||
let input = entry.inputs[index].take()?;
|
||||
entry.items[index].status = ImportItemStatus::Running;
|
||||
let id = entry.task_id;
|
||||
registry.tasks.push_back(entry);
|
||||
Some((id, index, input))
|
||||
}
|
||||
|
||||
async fn execute(&self, id: Uuid, index: usize, input: ImportTaskInput) {
|
||||
let operation = async {
|
||||
match input.provider.as_str() {
|
||||
"openai" => self.openai.import_document(input.command).await,
|
||||
"xai" => self.xai.import_document(input.command).await,
|
||||
_ => Err(AdminError::invalid("不支持的导入平台")),
|
||||
}
|
||||
};
|
||||
let result = AssertUnwindSafe(operation).catch_unwind().await;
|
||||
let mut registry = self.registry();
|
||||
let Some(entry) = registry.tasks.iter_mut().find(|entry| entry.task_id == id) else {
|
||||
return;
|
||||
};
|
||||
let item = &mut entry.items[index];
|
||||
match result {
|
||||
Ok(Ok(result)) => {
|
||||
item.status = ImportItemStatus::Succeeded;
|
||||
item.account_ids = result.credential_ids;
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
// 存储或发布异常也可能发生在提交之后;不自动重放可能轮换 RT 的操作。
|
||||
item.status = if matches!(
|
||||
error.kind(),
|
||||
AdminErrorKind::UpstreamResultUnknown
|
||||
| AdminErrorKind::Internal
|
||||
| AdminErrorKind::Unavailable
|
||||
) {
|
||||
ImportItemStatus::Unknown
|
||||
} else {
|
||||
ImportItemStatus::Failed
|
||||
};
|
||||
item.message = Some(error.message().to_owned());
|
||||
}
|
||||
Err(_) => {
|
||||
item.status = ImportItemStatus::Unknown;
|
||||
item.message = Some("执行中断,请先核对账号列表再决定是否重新导入".to_owned());
|
||||
}
|
||||
}
|
||||
entry.finish_if_done();
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportTasksService for DefaultImportTasksService {
|
||||
fn submit(&self, command: SubmitImportTask) -> Result<ImportTaskSummary, AdminError> {
|
||||
if command.items.is_empty()
|
||||
|| command.items.len() > MAX_IMPORT_TASK_ITEMS
|
||||
|| command.items.iter().any(|item| {
|
||||
!matches!(item.provider.as_str(), "openai" | "xai")
|
||||
|| item.command.context.actor != command.context.actor
|
||||
})
|
||||
{
|
||||
return Err(AdminError::invalid("导入任务需包含 1–200 条有效输入"));
|
||||
}
|
||||
let mut registry = self.registry();
|
||||
if let Some(existing) = registry.tasks.iter().find(|entry| {
|
||||
entry.owner == command.context.actor && entry.submission_id == command.submission_id
|
||||
}) {
|
||||
return if existing.fingerprint == command.fingerprint {
|
||||
Ok(existing.summary())
|
||||
} else {
|
||||
Err(AdminError::new(
|
||||
AdminErrorKind::Conflict,
|
||||
"提交标识已用于其他导入内容",
|
||||
))
|
||||
};
|
||||
}
|
||||
if registry.shutting_down {
|
||||
return Err(AdminError::new(
|
||||
AdminErrorKind::Unavailable,
|
||||
"服务正在关闭,暂不接受导入任务",
|
||||
));
|
||||
}
|
||||
if registry
|
||||
.tasks
|
||||
.iter()
|
||||
.filter(|entry| entry.finished_at.is_none())
|
||||
.count()
|
||||
>= MAX_ACTIVE_TASKS
|
||||
|| registry.tasks.len() >= MAX_RETAINED_TASKS
|
||||
{
|
||||
return Err(AdminError::new(
|
||||
AdminErrorKind::RateLimited,
|
||||
"导入任务已满,请稍后再试",
|
||||
));
|
||||
}
|
||||
let items = command
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, input)| ImportTaskItem {
|
||||
index: index + 1,
|
||||
provider: input.provider.clone(),
|
||||
status: ImportItemStatus::Pending,
|
||||
account_ids: Vec::new(),
|
||||
message: None,
|
||||
})
|
||||
.collect();
|
||||
let entry = Entry {
|
||||
task_id: Uuid::now_v7(),
|
||||
submission_id: command.submission_id,
|
||||
fingerprint: command.fingerprint,
|
||||
owner: command.context.actor,
|
||||
created_at: Utc::now(),
|
||||
finished_at: None,
|
||||
finished_instant: None,
|
||||
stop_requested: false,
|
||||
items,
|
||||
inputs: command.items.into_iter().map(Some).collect(),
|
||||
};
|
||||
let summary = entry.summary();
|
||||
registry.tasks.push_back(entry);
|
||||
drop(registry);
|
||||
self.notify.notify_one();
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn list(&self, context: &MutationContext) -> Vec<ImportTaskSummary> {
|
||||
let mut tasks: Vec<_> = self
|
||||
.registry()
|
||||
.tasks
|
||||
.iter()
|
||||
.filter(|entry| entry.owner == context.actor)
|
||||
.map(Entry::summary)
|
||||
.collect();
|
||||
tasks.sort_by_key(|entry| std::cmp::Reverse(entry.task_id));
|
||||
tasks
|
||||
}
|
||||
|
||||
fn detail(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
task_id: Uuid,
|
||||
) -> Result<ImportTaskDetail, AdminError> {
|
||||
self.registry()
|
||||
.tasks
|
||||
.iter()
|
||||
.find(|entry| entry.task_id == task_id && entry.owner == context.actor)
|
||||
.map(Entry::detail)
|
||||
.ok_or_else(|| AdminError::not_found("导入任务不存在或已过期"))
|
||||
}
|
||||
|
||||
fn stop(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
task_id: Uuid,
|
||||
) -> Result<ImportTaskDetail, AdminError> {
|
||||
let mut registry = self.registry();
|
||||
let entry = registry
|
||||
.tasks
|
||||
.iter_mut()
|
||||
.find(|entry| entry.task_id == task_id && entry.owner == context.actor)
|
||||
.ok_or_else(|| AdminError::not_found("导入任务不存在或已过期"))?;
|
||||
entry.stop();
|
||||
Ok(entry.detail())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ImportTaskWorker(pub Arc<DefaultImportTasksService>);
|
||||
|
||||
impl DaemonTask for ImportTaskWorker {
|
||||
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
let mut active = FuturesUnordered::new();
|
||||
// 固定一个取消 future,避免循环反复注册中立 CancellationToken 的 waiter。
|
||||
let cancelled = cancellation.cancelled();
|
||||
tokio::pin!(cancelled);
|
||||
let mut cleanup = tokio::time::interval(Duration::from_secs(60));
|
||||
loop {
|
||||
if cancellation.is_cancelled() {
|
||||
let mut registry = self.0.registry();
|
||||
registry.shutting_down = true;
|
||||
for entry in &mut registry.tasks {
|
||||
entry.stop();
|
||||
}
|
||||
break;
|
||||
}
|
||||
while active.len() < CONCURRENCY {
|
||||
let Some((id, index, input)) = self.0.take_next() else {
|
||||
break;
|
||||
};
|
||||
active.push(self.0.execute(id, index, input));
|
||||
}
|
||||
tokio::select! {
|
||||
() = &mut cancelled => {},
|
||||
_ = active.next(), if !active.is_empty() => {},
|
||||
() = self.0.notify.notified() => {},
|
||||
_ = cleanup.tick() => { drop(self.0.registry()); },
|
||||
}
|
||||
}
|
||||
// Host 提供总关闭预算;此处让已发出的交换与入库尽量完成。
|
||||
while active.next().await.is_some() {}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Key 自助查询只从统一会话取得范围,复用现有观测和额度账本。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::{
|
||||
AuthService,
|
||||
model::{
|
||||
AdminError, AdminErrorKind,
|
||||
auth::SessionSubject,
|
||||
key_usage::{
|
||||
KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords,
|
||||
KeyUsageRecordsQuery,
|
||||
},
|
||||
observability::{
|
||||
OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start,
|
||||
},
|
||||
},
|
||||
ports::store::{ClientKeyStore, ObservabilityStore},
|
||||
};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
|
||||
use super::{map_store_error, observability::health_timeline_at};
|
||||
|
||||
#[async_trait]
|
||||
pub trait KeyUsageService: Send + Sync {
|
||||
async fn overview(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
query: KeyUsageQuery,
|
||||
) -> Result<Option<KeyUsageOverview>, AdminError>;
|
||||
|
||||
async fn records(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
query: KeyUsageRecordsQuery,
|
||||
) -> Result<Option<KeyUsageRecords>, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultKeyUsageService {
|
||||
auth: Arc<dyn AuthService>,
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
observations: Arc<dyn ObservabilityStore>,
|
||||
}
|
||||
|
||||
impl DefaultKeyUsageService {
|
||||
pub(crate) fn new(
|
||||
auth: Arc<dyn AuthService>,
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
observations: Arc<dyn ObservabilityStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth,
|
||||
keys,
|
||||
observations,
|
||||
}
|
||||
}
|
||||
|
||||
async fn key_id(&self, session_id: Option<&str>) -> Result<Option<ClientApiKeyId>, AdminError> {
|
||||
match self
|
||||
.auth
|
||||
.session(session_id)
|
||||
.await?
|
||||
.map(|session| session.subject)
|
||||
{
|
||||
Some(SessionSubject::Key { client_key_id }) => Ok(Some(client_key_id)),
|
||||
Some(SessionSubject::Admin { .. }) => Err(AdminError::new(
|
||||
AdminErrorKind::Forbidden,
|
||||
"当前身份无权访问密钥用量接口",
|
||||
)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_filter(id: &ClientApiKeyId, model: Option<String>) -> UsageFilter {
|
||||
UsageFilter {
|
||||
client_api_key_ref: Some(id.as_str().to_owned()),
|
||||
model,
|
||||
..UsageFilter::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl KeyUsageService for DefaultKeyUsageService {
|
||||
async fn overview(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
query: KeyUsageQuery,
|
||||
) -> Result<Option<KeyUsageOverview>, AdminError> {
|
||||
let Some(id) = self.key_id(session_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(key) = self
|
||||
.keys
|
||||
.get_client_key(&id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "key usage profile"))?
|
||||
.filter(|key| key.enabled)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let filter = usage_filter(&id, query.model);
|
||||
let now = Utc::now();
|
||||
// 健康条始终展示北京时间今日,不随历史范围或模型筛选改变。
|
||||
let today = TimeRange {
|
||||
start: china_day_start(now),
|
||||
end: now,
|
||||
};
|
||||
let (overview, trend, health_points) = futures::try_join!(
|
||||
self.observations.usage_summary(query.range, filter.clone()),
|
||||
self.observations.usage_trend(query.range, filter),
|
||||
self.observations
|
||||
.usage_trend(today, usage_filter(&id, None)),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "key usage overview"))?;
|
||||
Ok(Some(KeyUsageOverview {
|
||||
key,
|
||||
overview,
|
||||
trend,
|
||||
health_timeline: health_timeline_at(&health_points, now),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn records(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
query: KeyUsageRecordsQuery,
|
||||
) -> Result<Option<KeyUsageRecords>, AdminError> {
|
||||
let Some(id) = self.key_id(session_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let result = match query.kind {
|
||||
KeyUsageRecordKind::Success => KeyUsageRecords::Success(
|
||||
self.observations
|
||||
.list_usage_records(UsageQuery {
|
||||
range: query.usage.range,
|
||||
filter: usage_filter(&id, query.usage.model),
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "key usage records"))?,
|
||||
),
|
||||
KeyUsageRecordKind::Error => KeyUsageRecords::Error(
|
||||
self.observations
|
||||
.list_ops_errors(OpsErrorQuery {
|
||||
range: query.usage.range,
|
||||
filter: OpsErrorFilter {
|
||||
client_api_key_ref: Some(id.as_str().to_owned()),
|
||||
model: query.usage.model,
|
||||
..OpsErrorFilter::default()
|
||||
},
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "key usage errors"))?,
|
||||
),
|
||||
};
|
||||
Ok(Some(result))
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,32 @@
|
||||
//! 管理控制面的用例实现。
|
||||
|
||||
pub mod account_groups;
|
||||
pub mod accounts;
|
||||
pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod client_keys;
|
||||
pub mod import_tasks;
|
||||
pub mod key_usage;
|
||||
pub mod observability;
|
||||
pub mod openai;
|
||||
pub mod proxies;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
pub mod xai;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, AdminErrorKind, MutationContext,
|
||||
accounts::{DeleteAccounts, SetAccountEnabled},
|
||||
accounts::DeleteAccounts,
|
||||
provider_credentials::{
|
||||
AuthorizationMutationTarget, CredentialDeletion, CredentialDeletionResult,
|
||||
CredentialDetails, CredentialMutation, CredentialMutationResult,
|
||||
CredentialRotationCommit, PendingAuthorizationMutation, PreparedAuthorizationCommit,
|
||||
CredentialDetails, CredentialMutationResult, CredentialRotationCommit,
|
||||
PendingAuthorizationMutation, PreparedAuthorizationCommit,
|
||||
PreparedAuthorizationCredential, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
StartAuthorization,
|
||||
ProviderQuotaRequest, StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
@@ -27,20 +35,29 @@ use crate::{
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::credential::ProviderAccountId,
|
||||
routing::{ConfigRevision, ProviderKind, snapshot::SnapshotControl},
|
||||
account::ProviderAccountId,
|
||||
routing::{ConfigRevision, ProviderKind},
|
||||
runtime::SnapshotControl,
|
||||
};
|
||||
|
||||
fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError {
|
||||
let kind = match error.kind() {
|
||||
AdminStoreErrorKind::Invalid => AdminErrorKind::Invalid,
|
||||
AdminStoreErrorKind::NotFound => AdminErrorKind::NotFound,
|
||||
AdminStoreErrorKind::StaleRevision | AdminStoreErrorKind::Conflict => {
|
||||
AdminErrorKind::Conflict
|
||||
}
|
||||
AdminStoreErrorKind::StaleRevision
|
||||
| AdminStoreErrorKind::DuplicateName
|
||||
| AdminStoreErrorKind::Conflict => AdminErrorKind::Conflict,
|
||||
AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable,
|
||||
};
|
||||
AdminError::new(kind, format!("{resource} operation failed"))
|
||||
tracing::warn!(resource, error_kind = ?error.kind(), "admin store operation failed");
|
||||
let message = match kind {
|
||||
AdminErrorKind::Invalid => "请求参数不合法",
|
||||
AdminErrorKind::NotFound => "请求的资源不存在",
|
||||
AdminErrorKind::Conflict => "当前资源状态冲突,请刷新后重试",
|
||||
AdminErrorKind::Unavailable => "依赖服务暂不可用",
|
||||
_ => "服务内部错误",
|
||||
};
|
||||
AdminError::new(kind, message)
|
||||
}
|
||||
|
||||
fn map_provider_error(
|
||||
@@ -54,10 +71,24 @@ fn map_provider_error(
|
||||
ProviderAdminErrorKind::Unsupported => AdminErrorKind::Invalid,
|
||||
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
|
||||
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
|
||||
ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown,
|
||||
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
|
||||
ProviderAdminErrorKind::CredentialRefreshRequired => AdminErrorKind::Unavailable,
|
||||
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
|
||||
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
|
||||
};
|
||||
AdminError::new(kind, format!("{resource} operation failed"))
|
||||
tracing::warn!(resource, error_kind = ?error.kind(), "admin provider operation failed");
|
||||
let message = match kind {
|
||||
AdminErrorKind::Invalid => "Provider 请求不合法",
|
||||
AdminErrorKind::NotFound => "Provider 资源不存在",
|
||||
AdminErrorKind::Conflict => "Provider 资源状态冲突,请刷新后重试",
|
||||
AdminErrorKind::BadGateway => "上游服务请求失败",
|
||||
AdminErrorKind::UpstreamResultUnknown => "上游执行结果未知,请刷新状态后再决定是否重试",
|
||||
AdminErrorKind::Unavailable => "Provider 服务暂不可用",
|
||||
AdminErrorKind::Internal => "服务内部错误",
|
||||
_ => "Provider 操作失败",
|
||||
};
|
||||
AdminError::new(kind, error.public_message().unwrap_or(message))
|
||||
}
|
||||
|
||||
async fn publish_committed(
|
||||
@@ -65,11 +96,48 @@ async fn publish_committed(
|
||||
revision: crate::model::Revision,
|
||||
) -> Result<(), AdminError> {
|
||||
let revision = ConfigRevision::new(revision.get())
|
||||
.map_err(|_| AdminError::internal("Committed configuration revision is invalid"))?;
|
||||
.map_err(|_| AdminError::internal("已提交的配置版本不合法"))?;
|
||||
snapshot.publish_committed(revision).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish_credentials_and_observe_quota(
|
||||
provider: &Arc<dyn ProviderAdmin>,
|
||||
snapshot: &dyn SnapshotControl,
|
||||
revision: crate::model::Revision,
|
||||
account_ids: &[ProviderAccountId],
|
||||
request_id: &str,
|
||||
) -> Result<(), AdminError> {
|
||||
provider.account_facts_changed(account_ids).await;
|
||||
publish_committed(snapshot, revision).await?;
|
||||
|
||||
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果。
|
||||
let provider = Arc::clone(provider);
|
||||
let account_ids = account_ids.to_vec();
|
||||
let request_id = request_id.to_owned();
|
||||
tokio::spawn(async move {
|
||||
for account_id in account_ids {
|
||||
if let Err(error) = provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
request_id,
|
||||
provider = %provider.provider_kind().as_str(),
|
||||
account_id = %account_id.as_str(),
|
||||
quota_error = ?error.kind(),
|
||||
"initial quota observation failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn required_credential(
|
||||
accounts: &dyn AccountStore,
|
||||
provider_kind: &ProviderKind,
|
||||
@@ -80,47 +148,74 @@ async fn required_credential(
|
||||
.credential_details(provider_kind, account_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, resource))?
|
||||
.ok_or_else(|| AdminError::not_found(format!("{resource} was not found")))
|
||||
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
|
||||
}
|
||||
|
||||
async fn import_proxy_binding(
|
||||
proxies: &dyn crate::ports::proxy::ProxyStore,
|
||||
id: Option<&str>,
|
||||
) -> Result<Option<crate::ports::proxy::ProxyImportReservation>, AdminError> {
|
||||
let Some(id) = id else { return Ok(None) };
|
||||
let reservation = proxies
|
||||
.reserve_import(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
Ok(Some(reservation))
|
||||
}
|
||||
|
||||
async fn pending_authorization(
|
||||
accounts: &dyn AccountStore,
|
||||
proxies: &dyn crate::ports::proxy::ProxyStore,
|
||||
provider_kind: &ProviderKind,
|
||||
command: &StartAuthorization,
|
||||
resource: &'static str,
|
||||
) -> Result<PendingAuthorizationMutation, AdminError> {
|
||||
let proxy;
|
||||
let mut proxy_id = None;
|
||||
let target = match &command.reauthorization {
|
||||
Some(target) => {
|
||||
let details =
|
||||
required_credential(accounts, provider_kind, &target.account_id, resource).await?;
|
||||
if details.credential.credential_revision != target.credential_revision {
|
||||
return Err(AdminError::conflict(format!(
|
||||
"{resource} reauthorization target is stale"
|
||||
)));
|
||||
}
|
||||
Some(account_id) => {
|
||||
proxy = required_credential(accounts, provider_kind, account_id, resource)
|
||||
.await?
|
||||
.credential
|
||||
.outbound_proxy;
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id: target.account_id.clone(),
|
||||
expected_credential_revision: target.credential_revision,
|
||||
account_id: account_id.clone(),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
use crate::model::proxies::AccountProxySelection;
|
||||
proxy = match &command.outbound_proxy {
|
||||
Some(AccountProxySelection::Url(value)) => Some(value.clone()),
|
||||
Some(AccountProxySelection::Saved(id)) => {
|
||||
let record = proxies
|
||||
.get(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
proxy_id = Some(record.id);
|
||||
Some(record.proxy)
|
||||
}
|
||||
Some(AccountProxySelection::Direct) | None => None,
|
||||
};
|
||||
AuthorizationMutationTarget::Create {
|
||||
name: command.name.clone(),
|
||||
}
|
||||
}
|
||||
None => AuthorizationMutationTarget::Create {
|
||||
name: command.name.clone(),
|
||||
},
|
||||
};
|
||||
Ok(PendingAuthorizationMutation::new(
|
||||
command.expected_config_revision,
|
||||
provider_kind.clone(),
|
||||
target,
|
||||
crate::model::provider_credentials::AuthorizationOwnerBinding::from_context(
|
||||
&command.context,
|
||||
),
|
||||
))
|
||||
)
|
||||
.with_outbound_proxy(proxy)
|
||||
.with_outbound_proxy_id(proxy_id))
|
||||
}
|
||||
|
||||
fn validate_prepared_import(
|
||||
provider_kind: &ProviderKind,
|
||||
prepared: &PreparedCredentialImport,
|
||||
resource: &'static str,
|
||||
_resource: &'static str,
|
||||
) -> Result<(), AdminError> {
|
||||
if prepared.provider_kind != *provider_kind
|
||||
|| prepared
|
||||
@@ -128,9 +223,7 @@ fn validate_prepared_import(
|
||||
.iter()
|
||||
.any(|credential| credential.provider_kind != *provider_kind)
|
||||
{
|
||||
return Err(AdminError::conflict(format!(
|
||||
"{resource} prepared facts do not match the requested Provider scope"
|
||||
)));
|
||||
return Err(AdminError::conflict("Provider 准备结果与请求范围不一致"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -138,32 +231,36 @@ fn validate_prepared_import(
|
||||
fn validate_prepared_rotation(
|
||||
account: &crate::model::accounts::AccountRecord,
|
||||
prepared: &PreparedCredentialRotation,
|
||||
resource: &'static str,
|
||||
_resource: &'static str,
|
||||
) -> Result<(), AdminError> {
|
||||
let facts = prepared.facts();
|
||||
if facts.account_id.as_str() != account.id.as_str()
|
||||
|| facts.provider_kind != account.provider_kind
|
||||
|| facts.expected_credential_revision != account.credential_revision
|
||||
{
|
||||
return Err(AdminError::conflict(format!(
|
||||
"{resource} prepared facts do not match the current credential"
|
||||
)));
|
||||
return Err(AdminError::conflict("Provider 准备结果与当前凭据不一致"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_authorization_commit(
|
||||
async fn validate_authorization_commit(
|
||||
provider_kind: &ProviderKind,
|
||||
context: &MutationContext,
|
||||
prepared: &PreparedAuthorizationCommit,
|
||||
prepared: PreparedAuthorizationCommit,
|
||||
resource: &'static str,
|
||||
) -> Result<(), AdminError> {
|
||||
) -> Result<PreparedAuthorizationCommit, AdminError> {
|
||||
if prepared.pending.provider_kind() != provider_kind
|
||||
|| !prepared.pending.owner_binding().matches_context(context)
|
||||
{
|
||||
return Err(AdminError::conflict(format!(
|
||||
"{resource} pending authorization binding is invalid"
|
||||
)));
|
||||
let validation_error = AdminError::conflict("OAuth 授权绑定已失效,请重新发起授权");
|
||||
if let Err(error) = prepared.abort().await {
|
||||
tracing::warn!(
|
||||
resource,
|
||||
settlement_error = %error,
|
||||
"OAuth authorization claim release failed after preparation validation"
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
return Err(validation_error);
|
||||
}
|
||||
let matches_target = match (prepared.pending.target(), &prepared.credential) {
|
||||
(
|
||||
@@ -171,43 +268,73 @@ fn validate_authorization_commit(
|
||||
PreparedAuthorizationCredential::Create(credential),
|
||||
) => credential.provider_kind == *provider_kind,
|
||||
(
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id,
|
||||
expected_credential_revision,
|
||||
},
|
||||
AuthorizationMutationTarget::Reauthorize { account_id },
|
||||
PreparedAuthorizationCredential::Reauthorize(credential),
|
||||
) => {
|
||||
let facts = credential.facts();
|
||||
facts.provider_kind == *provider_kind
|
||||
&& facts.account_id == *account_id
|
||||
&& facts.expected_credential_revision == *expected_credential_revision
|
||||
facts.provider_kind == *provider_kind && facts.account_id == *account_id
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !matches_target {
|
||||
return Err(AdminError::conflict(format!(
|
||||
"{resource} prepared credential does not match its pending target"
|
||||
)));
|
||||
let validation_error = AdminError::conflict("OAuth 凭据与待提交目标不一致");
|
||||
if let Err(error) = prepared.abort().await {
|
||||
tracing::warn!(
|
||||
resource,
|
||||
settlement_error = %error,
|
||||
"OAuth authorization claim release failed after preparation validation"
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
return Err(validation_error);
|
||||
}
|
||||
Ok(())
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
async fn commit_authorization(
|
||||
accounts: &dyn AccountStore,
|
||||
prepared: PreparedAuthorizationCommit,
|
||||
settings: Option<crate::model::accounts::AccountImportSettings>,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let (command, guard) = prepared.into_commit();
|
||||
if settings.is_some()
|
||||
&& matches!(
|
||||
&prepared.credential,
|
||||
PreparedAuthorizationCredential::Reauthorize(_)
|
||||
)
|
||||
{
|
||||
prepared.abort().await?;
|
||||
return Err(AdminError::invalid("重新授权不能修改账号设置"));
|
||||
}
|
||||
let crate::model::provider_credentials::AuthorizationCommitSettlement {
|
||||
command,
|
||||
credential_guard,
|
||||
authorization_guard,
|
||||
} = prepared.into_commit(settings);
|
||||
match accounts.commit_authorization(command, context).await {
|
||||
Ok(result) => {
|
||||
if let Some(guard) = guard {
|
||||
if let Some(guard) = credential_guard {
|
||||
guard.finish();
|
||||
}
|
||||
if let Some(guard) = authorization_guard {
|
||||
guard.commit().await?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
Err(error) => {
|
||||
drop(guard);
|
||||
drop(credential_guard);
|
||||
if let Some(guard) = authorization_guard
|
||||
&& let Err(settlement_error) = guard.abort().await
|
||||
{
|
||||
tracing::warn!(
|
||||
resource,
|
||||
store_error = %error,
|
||||
settlement_error = %settlement_error,
|
||||
"OAuth authorization claim release failed after Store commit failure"
|
||||
);
|
||||
return Err(settlement_error);
|
||||
}
|
||||
Err(map_store_error(error, resource))
|
||||
}
|
||||
}
|
||||
@@ -215,8 +342,8 @@ async fn commit_authorization(
|
||||
|
||||
async fn commit_credential_rotation(
|
||||
accounts: &dyn AccountStore,
|
||||
expected_config_revision: crate::model::Revision,
|
||||
prepared: PreparedCredentialRotation,
|
||||
settings: Option<crate::model::accounts::UpdateAccount>,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
@@ -224,8 +351,8 @@ async fn commit_credential_rotation(
|
||||
match accounts
|
||||
.commit_credential_rotation(
|
||||
CredentialRotationCommit {
|
||||
expected_config_revision,
|
||||
prepared: facts,
|
||||
settings,
|
||||
},
|
||||
context,
|
||||
)
|
||||
@@ -244,7 +371,6 @@ async fn commit_credential_rotation(
|
||||
|
||||
async fn commit_credential_refresh(
|
||||
accounts: &dyn AccountStore,
|
||||
expected_config_revision: crate::model::Revision,
|
||||
prepared: PreparedCredentialRotation,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
@@ -253,8 +379,8 @@ async fn commit_credential_refresh(
|
||||
match accounts
|
||||
.commit_credential_refresh(
|
||||
CredentialRotationCommit {
|
||||
expected_config_revision,
|
||||
prepared: facts,
|
||||
settings: None,
|
||||
},
|
||||
context,
|
||||
)
|
||||
@@ -271,42 +397,6 @@ async fn commit_credential_refresh(
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_credential_enabled(
|
||||
accounts: &dyn AccountStore,
|
||||
provider: &dyn ProviderAdmin,
|
||||
command: CredentialMutation,
|
||||
enabled: bool,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
required_credential(
|
||||
accounts,
|
||||
provider.provider_kind(),
|
||||
&command.account_id,
|
||||
resource,
|
||||
)
|
||||
.await?;
|
||||
let account_id = command.account_id;
|
||||
let revision = accounts
|
||||
.set_account_enabled(
|
||||
SetAccountEnabled {
|
||||
expected_config_revision: command.expected_config_revision,
|
||||
account_id: account_id.as_str().to_owned(),
|
||||
enabled,
|
||||
},
|
||||
&command.context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, resource))?;
|
||||
if !enabled {
|
||||
provider.account_unavailable(&account_id).await;
|
||||
}
|
||||
Ok(CredentialMutationResult {
|
||||
config_revision: revision,
|
||||
account_id,
|
||||
credential_revision: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_credentials(
|
||||
accounts: &dyn AccountStore,
|
||||
provider: &dyn ProviderAdmin,
|
||||
@@ -320,7 +410,6 @@ async fn delete_credentials(
|
||||
let revision = accounts
|
||||
.delete_accounts(
|
||||
DeleteAccounts {
|
||||
expected_config_revision: command.expected_config_revision,
|
||||
account_ids: account_ids
|
||||
.iter()
|
||||
.map(|account_id| account_id.as_str().to_owned())
|
||||
@@ -333,6 +422,7 @@ async fn delete_credentials(
|
||||
for account_id in &account_ids {
|
||||
provider.account_unavailable(account_id).await;
|
||||
}
|
||||
provider.account_facts_changed(&account_ids).await;
|
||||
Ok(CredentialDeletionResult {
|
||||
config_revision: revision,
|
||||
account_ids,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
//! 观测查询、趋势、费用覆盖与健康阈值规则。
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, btree_map::Entry},
|
||||
collections::{BTreeMap, HashMap, btree_map::Entry},
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
time::{Duration as StdDuration, Instant},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration, Timelike as _, Utc};
|
||||
use gateway_core::{engine::credential::ProviderAccountId, routing::ProviderKind};
|
||||
use futures::TryStreamExt;
|
||||
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
@@ -21,13 +24,13 @@ use crate::{
|
||||
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost,
|
||||
UsageInsightsCostPoint, UsageInsightsHealth, UsageInsightsHealthPoint,
|
||||
UsageInsightsPerformance, UsageInsightsPerformancePoint, UsageOverview, UsagePage,
|
||||
UsageQuery, UsageSummary,
|
||||
UsageQuery, UsageSummary, china_day_start,
|
||||
},
|
||||
provider_credentials::ProviderQuotaRequest,
|
||||
},
|
||||
ports::{
|
||||
provider::{ProviderAdminErrorKind, ProviderAdminRegistry},
|
||||
store::{ObservabilityStore, SettingsStore},
|
||||
store::{AccountStore, ObservabilityStore, SettingsStore, UsageCalculatedBillingStream},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -38,7 +41,58 @@ const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4;
|
||||
const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10;
|
||||
const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3;
|
||||
const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 99.0;
|
||||
const CHINA_OFFSET_SECONDS: i64 = 8 * 60 * 60;
|
||||
const DASHBOARD_QUERY_BUCKET_SECONDS: i64 = 2;
|
||||
const DASHBOARD_QUERY_CACHE_RETENTION: StdDuration = StdDuration::from_secs(2);
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
struct DashboardQueryKey {
|
||||
start: DateTime<Utc>,
|
||||
end_bucket: i64,
|
||||
}
|
||||
|
||||
impl DashboardQueryKey {
|
||||
fn new(range: TimeRange) -> Self {
|
||||
Self {
|
||||
start: range.start,
|
||||
end_bucket: range
|
||||
.end
|
||||
.timestamp()
|
||||
.div_euclid(DASHBOARD_QUERY_BUCKET_SECONDS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DashboardQueryEntry {
|
||||
created_at: Instant,
|
||||
result: Arc<tokio::sync::OnceCell<Result<DashboardResult, AdminError>>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DashboardQueryCache {
|
||||
entries: tokio::sync::Mutex<HashMap<DashboardQueryKey, DashboardQueryEntry>>,
|
||||
}
|
||||
|
||||
impl DashboardQueryCache {
|
||||
async fn result_cell(
|
||||
&self,
|
||||
key: DashboardQueryKey,
|
||||
) -> Arc<tokio::sync::OnceCell<Result<DashboardResult, AdminError>>> {
|
||||
let now = Instant::now();
|
||||
let mut entries = self.entries.lock().await;
|
||||
entries.retain(|_, entry| {
|
||||
now.duration_since(entry.created_at) < DASHBOARD_QUERY_CACHE_RETENTION
|
||||
});
|
||||
Arc::clone(
|
||||
&entries
|
||||
.entry(key)
|
||||
.or_insert_with(|| DashboardQueryEntry {
|
||||
created_at: now,
|
||||
result: Arc::new(tokio::sync::OnceCell::new()),
|
||||
})
|
||||
.result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// API 消费的观测控制面服务。
|
||||
#[async_trait]
|
||||
@@ -73,70 +127,94 @@ pub trait ObservabilityService: Send + Sync {
|
||||
|
||||
pub(crate) struct DefaultObservabilityService {
|
||||
store: Arc<dyn ObservabilityStore>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
dashboard_queries: DashboardQueryCache,
|
||||
}
|
||||
|
||||
impl DefaultObservabilityService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
store: Arc<dyn ObservabilityStore>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
accounts,
|
||||
settings,
|
||||
providers,
|
||||
dashboard_queries: DashboardQueryCache::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ObservabilityService for DefaultObservabilityService {
|
||||
async fn dashboard_summary(
|
||||
async fn load_dashboard_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
kind: TrendKind,
|
||||
) -> Result<DashboardResult, AdminError> {
|
||||
let observed_at = Utc::now();
|
||||
let (mut observation, settings, runtime_slots) = futures::try_join!(
|
||||
self.store.dashboard_summary(range),
|
||||
self.store.dashboard_summary(range, observed_at),
|
||||
self.settings.load_runtime_settings(),
|
||||
self.store.dashboard_runtime_slots(range.end),
|
||||
self.store.dashboard_runtime_slots(observed_at),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "dashboard"))?;
|
||||
self.enrich_billing(&mut observation.recent_requests)?;
|
||||
self.enrich_list_billing(&mut observation.recent_requests);
|
||||
self.enrich_dashboard_quotas(&mut observation.account_usage)
|
||||
.await;
|
||||
for account in &mut observation.account_usage {
|
||||
account.plan_type_display = self
|
||||
.providers
|
||||
.plan_type_display(&account.provider_kind, account.plan_type.as_deref());
|
||||
}
|
||||
let today_start = china_day_start(observation.range.end);
|
||||
let yesterday_start = today_start - Duration::days(1);
|
||||
let today =
|
||||
dashboard_period_metrics(&observation.trend, today_start, observation.range.end);
|
||||
let yesterday = dashboard_period_metrics(&observation.trend, yesterday_start, today_start);
|
||||
let total_billing_usd = observation
|
||||
.attempts
|
||||
.costs
|
||||
.iter()
|
||||
.find(|cost| cost.currency == "USD")
|
||||
.map(|cost| cost.amount.clone());
|
||||
let total_billing_usd = observation.totals.billing_usd.clone();
|
||||
let total_cached_token_rate = rate_or_zero(
|
||||
observation.requests.cached_tokens,
|
||||
observation.requests.input_tokens,
|
||||
observation.totals.cached_tokens,
|
||||
observation.totals.input_tokens,
|
||||
);
|
||||
let average_first_token_latency_ms = average(
|
||||
observation.requests.first_token_latency_sum_ms,
|
||||
observation.requests.first_token_latency_count,
|
||||
);
|
||||
let trend = trend(kind, observation.trend.clone())?;
|
||||
let health_timeline = health_timeline_at(&observation.trend, Utc::now());
|
||||
let wire_profiles = self.providers.dashboard_wire_profiles();
|
||||
let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account);
|
||||
let active_accounts = runtime_slots
|
||||
.as_ref()
|
||||
.map_or(observation.provider_accounts.active, |slots| {
|
||||
slots.active_accounts
|
||||
let (first_token_latency_sum_ms, first_token_latency_count) = observation
|
||||
.trend
|
||||
.iter()
|
||||
.fold((0_u64, 0_u64), |(sum, count), point| {
|
||||
(
|
||||
sum.saturating_add(point.metrics.first_token_latency_sum_ms),
|
||||
count.saturating_add(point.metrics.first_token_latency_count),
|
||||
)
|
||||
});
|
||||
let total_slots = active_accounts.saturating_mul(max_concurrent_per_account);
|
||||
let average_first_token_latency_ms =
|
||||
average(first_token_latency_sum_ms, first_token_latency_count);
|
||||
let trend = trend(TrendKind::Usage, observation.trend.clone())?;
|
||||
let health_timeline = health_timeline_at(&observation.trend, Utc::now());
|
||||
let mut configurations = std::collections::BTreeMap::new();
|
||||
if let Some(configuration) = &settings.openai_client_profile {
|
||||
configurations.insert(
|
||||
gateway_core::routing::ProviderKind::new("openai").expect("static provider kind"),
|
||||
configuration.clone(),
|
||||
);
|
||||
}
|
||||
let wire_profiles = self.providers.dashboard_wire_profiles(&configurations);
|
||||
let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account);
|
||||
let total_slots = runtime_slots.as_ref().map_or_else(
|
||||
|| {
|
||||
observation
|
||||
.provider_accounts
|
||||
.normal
|
||||
.saturating_mul(max_concurrent_per_account)
|
||||
},
|
||||
|slots| {
|
||||
slots
|
||||
.inherited_accounts
|
||||
.saturating_mul(max_concurrent_per_account)
|
||||
.saturating_add(slots.overridden_slots)
|
||||
},
|
||||
);
|
||||
let used_slots = runtime_slots.and_then(|slots| slots.used_slots);
|
||||
Ok(DashboardResult {
|
||||
capacity: DashboardCapacity {
|
||||
@@ -157,6 +235,26 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
wire_profiles,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ObservabilityService for DefaultObservabilityService {
|
||||
async fn dashboard_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
kind: TrendKind,
|
||||
) -> Result<DashboardResult, AdminError> {
|
||||
let cell = self
|
||||
.dashboard_queries
|
||||
.result_cell(DashboardQueryKey::new(range))
|
||||
.await;
|
||||
let mut result = cell
|
||||
.get_or_init(|| self.load_dashboard_summary(range))
|
||||
.await
|
||||
.clone()?;
|
||||
result.trend = trend(kind, result.observation.trend.clone())?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn dashboard_trend(
|
||||
&self,
|
||||
@@ -177,20 +275,20 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
.list_usage_records(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage records"))?;
|
||||
self.enrich_billing(&mut page.items)?;
|
||||
self.enrich_list_billing(&mut page.items);
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
async fn usage_record_detail(&self, request_id: &str) -> Result<UsageDetail, AdminError> {
|
||||
if request_id.trim().is_empty() {
|
||||
return Err(AdminError::invalid("Usage record ID is required"));
|
||||
return Err(AdminError::invalid("用量记录 ID 不能为空"));
|
||||
}
|
||||
let mut detail = self
|
||||
.store
|
||||
.usage_record_detail(request_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage record"))?;
|
||||
self.enrich_billing(std::slice::from_mut(&mut detail.request))?;
|
||||
self.enrich_detail_billing(std::slice::from_mut(&mut detail.request));
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
@@ -219,13 +317,21 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> Result<UsageInsights, AdminError> {
|
||||
let (overview, trend, billing_facts) = futures::try_join!(
|
||||
self.store.usage_summary(range, filter.clone()),
|
||||
self.store.usage_trend(range, filter.clone()),
|
||||
self.store.usage_calculated_billing_facts(range, filter),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "usage insights"))?;
|
||||
let standard_costs = recover_standard_costs(&self.providers, billing_facts)?;
|
||||
let aggregates = async {
|
||||
futures::try_join!(
|
||||
self.store.usage_summary(range, filter.clone()),
|
||||
self.store.usage_trend(range, filter.clone()),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "usage insights"))
|
||||
};
|
||||
let ((overview, trend), standard_costs) = futures::try_join!(
|
||||
aggregates,
|
||||
recover_standard_costs(
|
||||
&self.providers,
|
||||
self.store
|
||||
.usage_calculated_billing_facts(range, filter.clone()),
|
||||
),
|
||||
)?;
|
||||
build_usage_insights(overview, trend, standard_costs)
|
||||
}
|
||||
|
||||
@@ -243,24 +349,50 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
let total_requests = items.iter().fold(0_u64, |total, item| {
|
||||
total.saturating_add(item.request_count)
|
||||
});
|
||||
Ok(DiagnosticsResult {
|
||||
dimension,
|
||||
items: items
|
||||
.into_iter()
|
||||
.map(|item| DiagnosticsItem {
|
||||
let mut items = items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let error_rate = rate_or_zero(item.failure_count, item.request_count);
|
||||
let non_completion_rate =
|
||||
rate_or_zero(item.non_completion_count, item.request_count);
|
||||
let retry_rate = rate_or_zero(item.retry_count, item.request_count);
|
||||
let impact_score = diagnostic_impact_score(
|
||||
item.request_count,
|
||||
total_requests,
|
||||
error_rate,
|
||||
non_completion_rate,
|
||||
retry_rate,
|
||||
item.first_token_p95_ms,
|
||||
);
|
||||
DiagnosticsItem {
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
request_count: item.request_count,
|
||||
success_count: item.request_count.saturating_sub(item.failure_count),
|
||||
success_count: item.success_count,
|
||||
error_count: item.failure_count,
|
||||
error_rate: rate_or_zero(item.failure_count, item.request_count),
|
||||
error_rate,
|
||||
request_share: rate_or_zero(item.request_count, total_requests),
|
||||
average_latency_ms: item.average_latency_ms,
|
||||
latency_p95_ms: item.latency_p95_ms,
|
||||
first_token_p95_ms: item.first_token_p95_ms,
|
||||
non_completion_count: item.non_completion_count,
|
||||
non_completion_rate,
|
||||
retry_count: item.retry_count,
|
||||
retry_rate,
|
||||
impact_score,
|
||||
estimated_cost: usd_cost(&item.costs),
|
||||
attempt_count: item.attempt_count,
|
||||
total_tokens: item.total_tokens,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by(|left, right| {
|
||||
right
|
||||
.impact_score
|
||||
.total_cmp(&left.impact_score)
|
||||
.then_with(|| right.request_count.cmp(&left.request_count))
|
||||
});
|
||||
Ok(DiagnosticsResult { dimension, items })
|
||||
}
|
||||
|
||||
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError> {
|
||||
@@ -274,35 +406,40 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
fn build_usage_insights(
|
||||
overview: UsageOverview,
|
||||
trend: Vec<RequestMetricPoint>,
|
||||
standard_costs: UsageStandardCosts,
|
||||
standard_costs: UsageCostScenarios,
|
||||
) -> Result<UsageInsights, AdminError> {
|
||||
let granularity = trend.first().map_or(
|
||||
crate::model::observability::Granularity::FifteenMinutes,
|
||||
|point| point.granularity,
|
||||
);
|
||||
if trend.iter().any(|point| point.granularity != granularity) {
|
||||
return Err(AdminError::internal(
|
||||
"Usage insight trend contains mixed granularities",
|
||||
));
|
||||
return Err(AdminError::internal("用量洞察趋势包含不一致的时间粒度"));
|
||||
}
|
||||
|
||||
let requests = &overview.requests;
|
||||
let failed_requests = service_failure_count(requests);
|
||||
let health_requests = requests.success_count.saturating_add(failed_requests);
|
||||
let health = UsageInsightsHealth {
|
||||
total_requests: health_requests,
|
||||
total_requests: requests.request_count,
|
||||
success_requests: requests.success_count,
|
||||
failed_requests,
|
||||
cancelled_requests: requests.cancelled_count,
|
||||
incomplete_requests: requests.incomplete_count,
|
||||
caller_error_requests: requests.caller_error_count,
|
||||
success_rate: rate_or_zero(requests.success_count, health_requests),
|
||||
completion_rate: rate_or_zero(
|
||||
requests
|
||||
.success_count
|
||||
.saturating_add(requests.failure_count),
|
||||
requests.request_count,
|
||||
),
|
||||
points: trend
|
||||
.iter()
|
||||
.map(|point| {
|
||||
let failed_requests = service_failure_count(&point.metrics);
|
||||
let health_requests = point.metrics.success_count.saturating_add(failed_requests);
|
||||
UsageInsightsHealthPoint {
|
||||
total_requests: point.metrics.request_count,
|
||||
bucket_start: point.bucket_start,
|
||||
success_requests: point.metrics.success_count,
|
||||
failed_requests,
|
||||
@@ -322,27 +459,74 @@ fn build_usage_insights(
|
||||
requests.first_token_latency_count,
|
||||
requests.request_count,
|
||||
),
|
||||
admission_decision_percentiles: requests.admission_decision_percentiles,
|
||||
account_selection_wait_percentiles: requests.account_selection_wait_percentiles,
|
||||
admission_decision_coverage: rate_or_zero(
|
||||
requests.admission_decision_count,
|
||||
requests.request_count,
|
||||
),
|
||||
account_selection_wait_coverage: rate_or_zero(
|
||||
requests.account_selection_wait_count,
|
||||
requests.request_count,
|
||||
),
|
||||
output_throughput_p10: requests.output_throughput_p10,
|
||||
output_throughput_p50: requests.output_throughput_p50,
|
||||
output_throughput_p90: requests.output_throughput_p90,
|
||||
capacity_utilization_avg_basis_points: requests.capacity_utilization_avg_basis_points,
|
||||
capacity_utilization_p95_basis_points: requests.capacity_utilization_p95_basis_points,
|
||||
capacity_coverage: rate_or_zero(requests.capacity_sample_count, requests.request_count),
|
||||
points: trend
|
||||
.iter()
|
||||
.map(|point| UsageInsightsPerformancePoint {
|
||||
bucket_start: point.bucket_start,
|
||||
latency_percentiles: point.metrics.latency_percentiles,
|
||||
first_token_latency_percentiles: point.metrics.first_token_latency_percentiles,
|
||||
admission_decision_percentiles: point.metrics.admission_decision_percentiles,
|
||||
account_selection_wait_percentiles: point
|
||||
.metrics
|
||||
.account_selection_wait_percentiles,
|
||||
output_throughput_p10: point.metrics.output_throughput_p10,
|
||||
output_throughput_p50: point.metrics.output_throughput_p50,
|
||||
output_throughput_p90: point.metrics.output_throughput_p90,
|
||||
capacity_utilization_avg_basis_points: point
|
||||
.metrics
|
||||
.capacity_utilization_avg_basis_points,
|
||||
capacity_utilization_p95_basis_points: point
|
||||
.metrics
|
||||
.capacity_utilization_p95_basis_points,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let estimated_cost = usd_cost(&overview.attempts.costs);
|
||||
let UsageStandardCosts {
|
||||
total: standard_cost,
|
||||
by_bucket: standard_costs_by_bucket,
|
||||
let UsageCostScenarios {
|
||||
standard:
|
||||
ScenarioCosts {
|
||||
total: standard_cost,
|
||||
by_bucket: standard_costs_by_bucket,
|
||||
},
|
||||
no_cache:
|
||||
ScenarioCosts {
|
||||
total: no_cache_cost,
|
||||
by_bucket: no_cache_costs_by_bucket,
|
||||
},
|
||||
} = standard_costs;
|
||||
let cache_savings = amount_difference(no_cache_cost.as_ref(), estimated_cost.as_ref());
|
||||
let tier_premium = amount_difference(estimated_cost.as_ref(), standard_cost.as_ref());
|
||||
let cost_per_request = estimated_cost
|
||||
.as_ref()
|
||||
.and_then(|cost| cost.checked_div_u64(requests.request_count));
|
||||
let cost_per_successful_request = estimated_cost
|
||||
.as_ref()
|
||||
.and_then(|cost| cost.checked_div_u64(requests.success_count));
|
||||
let cost = UsageInsightsCost {
|
||||
cost_per_request: estimated_cost
|
||||
.as_ref()
|
||||
.and_then(|cost| cost.checked_div_u64(requests.request_count)),
|
||||
cost_per_request,
|
||||
estimated_cost,
|
||||
standard_cost,
|
||||
no_cache_cost,
|
||||
cache_savings,
|
||||
tier_premium,
|
||||
tokens_per_request: rate_or_zero(requests.total_tokens, requests.request_count),
|
||||
cost_per_successful_request,
|
||||
cached_token_rate: rate_or_zero(requests.cached_tokens, requests.input_tokens),
|
||||
cache_hit_request_rate: ratio(
|
||||
requests.cache_hit_request_count,
|
||||
@@ -362,6 +546,11 @@ fn build_usage_insights(
|
||||
total_tokens: point.metrics.total_tokens,
|
||||
estimated_cost: usd_cost(&point.costs),
|
||||
standard_cost: standard_costs_by_bucket.get(&point.bucket_start).cloned(),
|
||||
no_cache_cost: no_cache_costs_by_bucket.get(&point.bucket_start).cloned(),
|
||||
cache_savings: amount_difference(
|
||||
no_cache_costs_by_bucket.get(&point.bucket_start),
|
||||
usd_cost(&point.costs).as_ref(),
|
||||
),
|
||||
cached_token_rate: rate_or_zero(
|
||||
point.metrics.cached_tokens,
|
||||
point.metrics.input_tokens,
|
||||
@@ -386,12 +575,12 @@ fn build_usage_insights(
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct UsageStandardCosts {
|
||||
struct ScenarioCosts {
|
||||
total: Option<DecimalAmount>,
|
||||
by_bucket: BTreeMap<DateTime<Utc>, DecimalAmount>,
|
||||
}
|
||||
|
||||
impl UsageStandardCosts {
|
||||
impl ScenarioCosts {
|
||||
fn add(
|
||||
&mut self,
|
||||
bucket_start: DateTime<Utc>,
|
||||
@@ -400,14 +589,15 @@ impl UsageStandardCosts {
|
||||
self.total = Some(match self.total.take() {
|
||||
Some(current) => current
|
||||
.checked_add(&amount)
|
||||
.ok_or_else(|| AdminError::internal("Usage standard cost exceeds decimal range"))?,
|
||||
.ok_or_else(|| AdminError::internal("用量标准成本超出数值范围"))?,
|
||||
None => amount.clone(),
|
||||
});
|
||||
match self.by_bucket.entry(bucket_start) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
let sum = entry.get().checked_add(&amount).ok_or_else(|| {
|
||||
AdminError::internal("Usage standard cost exceeds decimal range")
|
||||
})?;
|
||||
let sum = entry
|
||||
.get()
|
||||
.checked_add(&amount)
|
||||
.ok_or_else(|| AdminError::internal("用量标准成本超出数值范围"))?;
|
||||
entry.insert(sum);
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
@@ -418,24 +608,42 @@ impl UsageStandardCosts {
|
||||
}
|
||||
}
|
||||
|
||||
fn recover_standard_costs(
|
||||
#[derive(Debug, Default)]
|
||||
struct UsageCostScenarios {
|
||||
standard: ScenarioCosts,
|
||||
no_cache: ScenarioCosts,
|
||||
}
|
||||
|
||||
async fn recover_standard_costs(
|
||||
providers: &ProviderAdminRegistry,
|
||||
facts: Vec<UsageCalculatedBillingFact>,
|
||||
) -> Result<UsageStandardCosts, AdminError> {
|
||||
let mut standard_costs = UsageStandardCosts::default();
|
||||
for fact in facts {
|
||||
let Ok(provider_kind) = ProviderKind::new(fact.provider_kind) else {
|
||||
mut facts: UsageCalculatedBillingStream<'_>,
|
||||
) -> Result<UsageCostScenarios, AdminError> {
|
||||
let mut scenarios = UsageCostScenarios::default();
|
||||
while let Some(mut fact) = facts
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage billing facts"))?
|
||||
{
|
||||
let Ok(provider_kind) = ProviderKind::new(fact.provider_kind.clone()) else {
|
||||
continue;
|
||||
};
|
||||
let input = ProviderBillingInput {
|
||||
upstream_model_id: fact.upstream_model_id,
|
||||
upstream_model_id: fact.upstream_model_id.clone(),
|
||||
service_tier: fact.service_tier.clone(),
|
||||
input_tokens: fact.input_tokens,
|
||||
output_tokens: fact.output_tokens,
|
||||
cached_tokens: fact.cached_tokens,
|
||||
cache_write_tokens: fact.cache_write_tokens,
|
||||
total: fact.total,
|
||||
total: fact.total.clone(),
|
||||
};
|
||||
let breakdown = match providers.calculated_billing(&provider_kind, &input) {
|
||||
let breakdown = match fact
|
||||
.breakdown
|
||||
.take()
|
||||
.filter(|b| b.total_amount == input.total)
|
||||
.map_or_else(
|
||||
|| providers.calculated_billing(&provider_kind, &input),
|
||||
|b| Ok(Some(b)),
|
||||
) {
|
||||
Ok(breakdown) => breakdown,
|
||||
Err(error) if error.kind() == ProviderAdminErrorKind::Unsupported => continue,
|
||||
Err(error) => return Err(map_provider_error(error, "usage billing")),
|
||||
@@ -450,9 +658,96 @@ fn recover_standard_costs(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
standard_costs.add(fact.bucket_start, breakdown.standard_amount.amount)?;
|
||||
scenarios
|
||||
.standard
|
||||
.add(fact.bucket_start, breakdown.standard_amount.amount.clone())?;
|
||||
if let Some(no_cache_cost) = no_cache_cost(&fact, &breakdown) {
|
||||
scenarios.no_cache.add(fact.bucket_start, no_cache_cost)?;
|
||||
}
|
||||
}
|
||||
Ok(standard_costs)
|
||||
Ok(scenarios)
|
||||
}
|
||||
|
||||
fn no_cache_cost(
|
||||
fact: &UsageCalculatedBillingFact,
|
||||
breakdown: &crate::model::observability::CalculatedBillingBreakdown,
|
||||
) -> Option<DecimalAmount> {
|
||||
let image = breakdown.image.as_ref();
|
||||
let input_tokens = fact
|
||||
.input_tokens?
|
||||
.checked_sub(image.map_or(0, |i| i.input_tokens))?;
|
||||
let cached_tokens = fact
|
||||
.cached_tokens
|
||||
.unwrap_or_default()
|
||||
.checked_sub(image.map_or(0, |i| i.cached_tokens))?
|
||||
.min(input_tokens);
|
||||
let cache_write_tokens = fact
|
||||
.cache_write_tokens
|
||||
.unwrap_or_default()
|
||||
.min(input_tokens.saturating_sub(cached_tokens));
|
||||
let input_rate = decimal(&breakdown.input_price_per_million.amount)?;
|
||||
let replaced_input_amount = input_rate
|
||||
.scaled()
|
||||
.checked_mul(u128::from(cached_tokens.saturating_add(cache_write_tokens)))?
|
||||
.checked_div(1_000_000)?;
|
||||
let mut total = decimal(&breakdown.total_amount.amount)?
|
||||
.scaled()
|
||||
.checked_sub(decimal(&breakdown.cache_read_amount.amount)?.scaled())?
|
||||
.checked_sub(decimal(&breakdown.cache_write_amount.amount)?.scaled())?
|
||||
.checked_add(replaced_input_amount)?;
|
||||
if let Some(image) = image {
|
||||
let image_no_cache = decimal(&image.input_price_per_million.amount)?
|
||||
.scaled()
|
||||
.checked_mul(u128::from(image.cached_tokens))?
|
||||
.checked_div(1_000_000)?;
|
||||
total = total
|
||||
.checked_sub(decimal(&image.cache_read_amount.amount)?.scaled())?
|
||||
.checked_add(image_no_cache)?;
|
||||
}
|
||||
DecimalAmount::from_str(
|
||||
&gateway_core::metering::Decimal::from_scaled(total)
|
||||
.ok()?
|
||||
.canonical(),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn amount_difference(
|
||||
larger: Option<&DecimalAmount>,
|
||||
smaller: Option<&DecimalAmount>,
|
||||
) -> Option<DecimalAmount> {
|
||||
let difference = decimal(larger?)?
|
||||
.scaled()
|
||||
.checked_sub(decimal(smaller?)?.scaled())?;
|
||||
DecimalAmount::from_str(
|
||||
&gateway_core::metering::Decimal::from_scaled(difference)
|
||||
.ok()?
|
||||
.canonical(),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn decimal(value: &DecimalAmount) -> Option<gateway_core::metering::Decimal> {
|
||||
value.as_str().parse().ok()
|
||||
}
|
||||
|
||||
fn diagnostic_impact_score(
|
||||
request_count: u64,
|
||||
total_requests: u64,
|
||||
error_rate: f64,
|
||||
non_completion_rate: f64,
|
||||
retry_rate: f64,
|
||||
first_token_p95_ms: Option<u64>,
|
||||
) -> f64 {
|
||||
let request_share = rate_or_zero(request_count, total_requests);
|
||||
let slow_score = first_token_p95_ms
|
||||
.map_or(0.0, |value| value as f64 / 30_000.0)
|
||||
.min(1.0);
|
||||
error_rate * 0.35
|
||||
+ non_completion_rate * 0.25
|
||||
+ retry_rate.min(1.0) * 0.20
|
||||
+ request_share * 0.10
|
||||
+ slow_score * 0.10
|
||||
}
|
||||
|
||||
fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
|
||||
@@ -464,8 +759,31 @@ fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
|
||||
|
||||
impl DefaultObservabilityService {
|
||||
async fn enrich_dashboard_quotas(&self, accounts: &mut [DashboardAccountUsage]) {
|
||||
if accounts.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now = Utc::now();
|
||||
let ids = accounts
|
||||
.iter()
|
||||
.map(|account| account.account_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let mut rolling_usage = self
|
||||
.accounts
|
||||
.load_account_usage(
|
||||
TimeRange {
|
||||
start: now - Duration::hours(24),
|
||||
end: now,
|
||||
},
|
||||
&ids,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|usage| (usage.account_id.clone(), usage))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let providers = self.providers.clone();
|
||||
let quota_reads = accounts.iter().map(|account| {
|
||||
let rolling_usage = rolling_usage.remove(&account.account_id);
|
||||
let account_id = ProviderAccountId::new(account.account_id.clone()).ok();
|
||||
let provider = ProviderKind::new(account.provider_kind.clone())
|
||||
.ok()
|
||||
@@ -478,29 +796,31 @@ impl DefaultObservabilityService {
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id,
|
||||
refresh: false,
|
||||
rolling_usage: None,
|
||||
rolling_usage,
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|quota| quota.representative_used_percent())
|
||||
}
|
||||
});
|
||||
let quota_used = futures::future::join_all(quota_reads).await;
|
||||
for (account, used_percent) in accounts.iter_mut().zip(quota_used) {
|
||||
account.quota_used_percent = used_percent
|
||||
.or_else(|| (account.availability == "quota_exhausted").then_some(100.0));
|
||||
let quotas = futures::future::join_all(quota_reads).await;
|
||||
for (account, quota) in accounts.iter_mut().zip(quotas) {
|
||||
if let Some(mut quota) = quota {
|
||||
quota.fill_missing_plan_type(&mut account.plan_type);
|
||||
quota.apply_limit_reached_display();
|
||||
account.quota_used_percent = quota.representative_used_percent();
|
||||
account.quota_window = quota.representative_window().cloned();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn enrich_billing(
|
||||
&self,
|
||||
records: &mut [crate::model::observability::UsageRecord],
|
||||
) -> Result<(), AdminError> {
|
||||
/// 逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
|
||||
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回。
|
||||
fn enrich_list_billing(&self, records: &mut [crate::model::observability::UsageListRecord]) {
|
||||
for record in records {
|
||||
let Some(UsageBilling::Total { source, total }) = record.billing.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if source != "calculated" {
|
||||
if !matches!(source.as_str(), "calculated" | "provider_reported") {
|
||||
continue;
|
||||
}
|
||||
let (Some(provider), Some(upstream_model_id)) = (
|
||||
@@ -509,31 +829,63 @@ impl DefaultObservabilityService {
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let provider_kind = ProviderKind::new(provider.to_owned())
|
||||
.map_err(|_| AdminError::internal("Stored Provider kind is invalid"))?;
|
||||
let Ok(provider_kind) = ProviderKind::new(provider.to_owned()) else {
|
||||
continue;
|
||||
};
|
||||
let input = ProviderBillingInput {
|
||||
upstream_model_id: upstream_model_id.clone(),
|
||||
service_tier: record.service_tier.clone(),
|
||||
input_tokens: record.input_tokens,
|
||||
output_tokens: record.output_tokens,
|
||||
cached_tokens: record.cached_tokens,
|
||||
cache_write_tokens: record.cache_write_tokens,
|
||||
total: total.clone(),
|
||||
};
|
||||
if let Some(breakdown) = self
|
||||
.providers
|
||||
.calculated_billing(&provider_kind, &input)
|
||||
.map_err(|error| map_provider_error(error, "usage billing"))?
|
||||
{
|
||||
if let Ok(Some(breakdown)) = self.providers.calculated_billing(&provider_kind, &input) {
|
||||
record.billing = Some(UsageBilling::Calculated(Box::new(breakdown)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn enrich_detail_billing(&self, records: &mut [crate::model::observability::UsageRecord]) {
|
||||
for record in records {
|
||||
let Some(UsageBilling::Total { source, total }) = record.billing.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(source.as_str(), "calculated" | "provider_reported") {
|
||||
continue;
|
||||
}
|
||||
let (Some(provider), Some(upstream_model_id)) = (
|
||||
record.provider_kind.as_deref(),
|
||||
record.upstream_model_id.as_ref(),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(provider_kind) = ProviderKind::new(provider.to_owned()) else {
|
||||
continue;
|
||||
};
|
||||
let input = ProviderBillingInput {
|
||||
upstream_model_id: upstream_model_id.clone(),
|
||||
service_tier: record.service_tier.clone(),
|
||||
input_tokens: record.input_tokens,
|
||||
output_tokens: record.output_tokens,
|
||||
cached_tokens: record.cached_tokens,
|
||||
cache_write_tokens: record.cache_write_tokens,
|
||||
total: total.clone(),
|
||||
};
|
||||
if let Ok(Some(breakdown)) = self.providers.calculated_billing(&provider_kind, &input) {
|
||||
record.billing = Some(UsageBilling::Calculated(Box::new(breakdown)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 按指定时刻计算中国自然日的 96 个 15 分钟健康桶。
|
||||
#[must_use]
|
||||
fn health_timeline_at(records: &[RequestMetricPoint], now: DateTime<Utc>) -> HealthTimeline {
|
||||
pub(super) fn health_timeline_at(
|
||||
records: &[RequestMetricPoint],
|
||||
now: DateTime<Utc>,
|
||||
) -> HealthTimeline {
|
||||
let current_slot = quarter_hour_start(now);
|
||||
let start = china_day_start(now);
|
||||
let mut buckets = (0..HEALTH_TIMELINE_SLOTS)
|
||||
@@ -611,9 +963,9 @@ fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, Admi
|
||||
.saturating_add(point.cost_coverage.not_billable_count);
|
||||
for cost in &point.costs {
|
||||
if let Some(amount) = costs.get_mut(&cost.currency) {
|
||||
*amount = amount.checked_add(&cost.amount).ok_or_else(|| {
|
||||
AdminError::internal("Cost aggregation exceeded numeric bounds")
|
||||
})?;
|
||||
*amount = amount
|
||||
.checked_add(&cost.amount)
|
||||
.ok_or_else(|| AdminError::internal("成本聚合结果超出数值范围"))?;
|
||||
} else {
|
||||
costs.insert(cost.currency.clone(), cost.amount.clone());
|
||||
}
|
||||
@@ -629,6 +981,20 @@ fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, Admi
|
||||
metrics.cache_hit_request_count,
|
||||
metrics.cache_eligible_request_count,
|
||||
);
|
||||
let peak_first_token_p95_ms = points
|
||||
.iter()
|
||||
.filter_map(|point| point.metrics.first_token_latency_percentiles.p95_ms)
|
||||
.map(|value| value.as_f64())
|
||||
.reduce(f64::max);
|
||||
let peak_latency_p95_ms = points
|
||||
.iter()
|
||||
.filter_map(|point| point.metrics.latency_percentiles.p95_ms)
|
||||
.map(|value| value.as_f64())
|
||||
.reduce(f64::max);
|
||||
let minimum_output_throughput_p50 = points
|
||||
.iter()
|
||||
.filter_map(|point| point.metrics.output_throughput_p50)
|
||||
.min();
|
||||
Ok(Trend {
|
||||
kind,
|
||||
points: points.into_iter().map(trend_point).collect(),
|
||||
@@ -642,6 +1008,9 @@ fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, Admi
|
||||
average_latency_ms,
|
||||
max_latency_ms: metrics.max_latency_ms,
|
||||
min_latency_ms: metrics.min_latency_ms,
|
||||
peak_first_token_p95_ms,
|
||||
peak_latency_p95_ms,
|
||||
minimum_output_throughput_p50,
|
||||
success_rate,
|
||||
cache_hit_request_rate,
|
||||
costs: costs
|
||||
@@ -833,11 +1202,6 @@ fn service_failure_count(metrics: &RequestMetrics) -> u64 {
|
||||
.saturating_sub(metrics.caller_error_count)
|
||||
}
|
||||
|
||||
fn china_day_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = (value.timestamp() + CHINA_OFFSET_SECONDS).rem_euclid(24 * 60 * 60);
|
||||
value - Duration::seconds(elapsed) - Duration::nanoseconds(i64::from(value.nanosecond()))
|
||||
}
|
||||
|
||||
fn quarter_hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = value
|
||||
.timestamp()
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{engine::credential::ProviderAccountId, routing::snapshot::SnapshotControl};
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
provider_credentials::{
|
||||
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
|
||||
CredentialDeletionResult, CredentialDetails, CredentialImportCommit,
|
||||
CredentialImportResult, CredentialListQuery, CredentialMutation,
|
||||
CredentialMutationResult, CredentialPage, ImportCredentials, PrepareCredentialImport,
|
||||
PrepareCredentialRotation, ProviderQuotaRequest, RotateCredential, StartAuthorization,
|
||||
CredentialDeletionResult, CredentialImportCommit, CredentialImportResult,
|
||||
CredentialMutationResult, ImportCredentials, PrepareCredentialImport,
|
||||
PrepareCredentialRotation, RotateCredential, StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{provider::ProviderAdmin, store::AccountStore},
|
||||
@@ -21,19 +20,14 @@ use crate::{
|
||||
|
||||
use super::{
|
||||
commit_authorization, commit_credential_rotation, delete_credentials, map_provider_error,
|
||||
map_store_error, pending_authorization, publish_committed, required_credential,
|
||||
set_credential_enabled, validate_authorization_commit, validate_prepared_import,
|
||||
validate_prepared_rotation,
|
||||
map_store_error, pending_authorization, publish_committed,
|
||||
publish_credentials_and_observe_quota, required_credential, validate_authorization_commit,
|
||||
validate_prepared_import, validate_prepared_rotation,
|
||||
};
|
||||
|
||||
/// OpenAI 固定管理路由消费的服务。
|
||||
#[async_trait]
|
||||
pub trait OpenAiService: Send + Sync {
|
||||
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError>;
|
||||
async fn details(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<CredentialDetails, AdminError>;
|
||||
async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
@@ -50,14 +44,6 @@ pub trait OpenAiService: Send + Sync {
|
||||
&self,
|
||||
command: RotateCredential,
|
||||
) -> Result<CredentialMutationResult, AdminError>;
|
||||
async fn enable(
|
||||
&self,
|
||||
command: CredentialMutation,
|
||||
) -> Result<CredentialMutationResult, AdminError>;
|
||||
async fn disable(
|
||||
&self,
|
||||
command: CredentialMutation,
|
||||
) -> Result<CredentialMutationResult, AdminError>;
|
||||
async fn delete(
|
||||
&self,
|
||||
command: CredentialDeletion,
|
||||
@@ -67,6 +53,7 @@ pub trait OpenAiService: Send + Sync {
|
||||
pub(crate) struct DefaultOpenAiService {
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
@@ -75,11 +62,13 @@ impl DefaultOpenAiService {
|
||||
pub(crate) fn new(
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
accounts,
|
||||
proxies,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
@@ -87,35 +76,25 @@ impl DefaultOpenAiService {
|
||||
|
||||
#[async_trait]
|
||||
impl OpenAiService for DefaultOpenAiService {
|
||||
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError> {
|
||||
self.accounts
|
||||
.list_credentials(self.provider.provider_kind(), query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "OpenAI credential"))
|
||||
}
|
||||
|
||||
async fn details(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<CredentialDetails, AdminError> {
|
||||
required_credential(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
account_id,
|
||||
"OpenAI credential",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
let context = command.context;
|
||||
let expected_config_revision = command.expected_config_revision;
|
||||
let proxy_reservation = super::import_proxy_binding(
|
||||
self.proxies.as_ref(),
|
||||
command.outbound_proxy_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let outbound_proxy = proxy_reservation
|
||||
.as_ref()
|
||||
.map(|reservation| reservation.binding.clone());
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_import(PrepareCredentialImport {
|
||||
default_outbound_proxy: outbound_proxy
|
||||
.as_ref()
|
||||
.map(|binding| binding.proxy.clone()),
|
||||
document: command.document,
|
||||
})
|
||||
.await
|
||||
@@ -129,25 +108,23 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
.accounts
|
||||
.commit_credential_import(
|
||||
CredentialImportCommit {
|
||||
expected_config_revision,
|
||||
outbound_proxy,
|
||||
prepared,
|
||||
settings: command.settings,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "OpenAI credential import"))?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
// 导入已经提交,额度属于可重建观察事实;单个账号查询失败由周期任务重试。
|
||||
for account_id in &result.credential_ids {
|
||||
let _ = self
|
||||
.provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
drop(proxy_reservation);
|
||||
publish_credentials_and_observe_quota(
|
||||
&self.provider,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
&result.credential_ids,
|
||||
&context.request_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -157,6 +134,7 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
) -> Result<AuthorizationStarted, AdminError> {
|
||||
let pending = pending_authorization(
|
||||
self.accounts.as_ref(),
|
||||
self.proxies.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
&command,
|
||||
"OpenAI credential",
|
||||
@@ -170,28 +148,38 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
|
||||
async fn complete_authorization(
|
||||
&self,
|
||||
command: CompleteAuthorization,
|
||||
mut command: CompleteAuthorization,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.context.clone();
|
||||
let settings = command.settings.take();
|
||||
let prepared = self
|
||||
.provider
|
||||
.complete_authorization(command)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "OpenAI authorization"))?;
|
||||
validate_authorization_commit(
|
||||
let prepared = validate_authorization_commit(
|
||||
self.provider.provider_kind(),
|
||||
&context,
|
||||
&prepared,
|
||||
prepared,
|
||||
"OpenAI authorization",
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let result = commit_authorization(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
settings,
|
||||
&context,
|
||||
"OpenAI authorization",
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
publish_credentials_and_observe_quota(
|
||||
&self.provider,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
std::slice::from_ref(&result.account_id),
|
||||
&context.request_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -200,8 +188,18 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
command: RotateCredential,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.mutation.context;
|
||||
let expected_config_revision = command.mutation.expected_config_revision;
|
||||
let account_id = command.mutation.account_id;
|
||||
if command
|
||||
.settings
|
||||
.as_ref()
|
||||
.is_some_and(|settings| settings.account_id != account_id.as_str())
|
||||
{
|
||||
return Err(AdminError::invalid("凭据和账号设置的目标不一致"));
|
||||
}
|
||||
let disable_account = command
|
||||
.settings
|
||||
.as_ref()
|
||||
.is_some_and(|settings| !settings.enabled);
|
||||
let details = required_credential(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
@@ -209,17 +207,11 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
"OpenAI credential rotation",
|
||||
)
|
||||
.await?;
|
||||
if details.credential.credential_revision != command.expected_credential_revision {
|
||||
return Err(AdminError::conflict(
|
||||
"OpenAI credential rotation revision is stale",
|
||||
));
|
||||
}
|
||||
let account = details.credential;
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_rotation(PrepareCredentialRotation {
|
||||
account: account.clone(),
|
||||
expected_credential_revision: command.expected_credential_revision,
|
||||
provider_material: command.provider_material,
|
||||
})
|
||||
.await
|
||||
@@ -227,44 +219,18 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
validate_prepared_rotation(&account, &prepared, "OpenAI credential rotation")?;
|
||||
let result = commit_credential_rotation(
|
||||
self.accounts.as_ref(),
|
||||
expected_config_revision,
|
||||
prepared,
|
||||
command.settings,
|
||||
&context,
|
||||
"OpenAI credential rotation",
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn enable(
|
||||
&self,
|
||||
command: CredentialMutation,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let result = set_credential_enabled(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.as_ref(),
|
||||
command,
|
||||
true,
|
||||
"OpenAI credential",
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn disable(
|
||||
&self,
|
||||
command: CredentialMutation,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let result = set_credential_enabled(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.as_ref(),
|
||||
command,
|
||||
false,
|
||||
"OpenAI credential",
|
||||
)
|
||||
.await?;
|
||||
if disable_account {
|
||||
self.provider.account_unavailable(&account_id).await;
|
||||
}
|
||||
self.provider
|
||||
.account_facts_changed(std::slice::from_ref(&result.account_id))
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::{StreamExt, stream};
|
||||
use gateway_core::{
|
||||
account::{OutboundProxy, ProviderAccountId},
|
||||
routing::ProviderKind,
|
||||
runtime::SnapshotControl,
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use super::{map_store_error, publish_committed};
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, AdminErrorKind, MutationContext, Revision,
|
||||
provider_credentials::{ProviderQuotaRequest, explicit_plan_type},
|
||||
proxies::*,
|
||||
},
|
||||
ports::{
|
||||
provider::ProviderAdminRegistry,
|
||||
proxy::{ProxyProbe, ProxyStore},
|
||||
store::AdminStoreErrorKind,
|
||||
},
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxiesService: Send + Sync {
|
||||
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError>;
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
query: ProxyAccountListQuery,
|
||||
) -> Result<ProxyAccountPage, AdminError>;
|
||||
async fn remove_account(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
account_id: &str,
|
||||
context: &MutationContext,
|
||||
) -> Result<Revision, AdminError>;
|
||||
async fn create(
|
||||
&self,
|
||||
command: NewProxy,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyMutation, AdminError>;
|
||||
async fn update(
|
||||
&self,
|
||||
command: UpdateProxy,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyMutation, AdminError>;
|
||||
async fn delete(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> Result<Revision, AdminError>;
|
||||
async fn test(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyRecord, AdminError>;
|
||||
/// 探测未保存的连接地址,不写入代理记录或修改账号绑定。
|
||||
async fn probe(&self, proxy: &OutboundProxy) -> Result<ProxyTestResult, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultProxiesService {
|
||||
store: Arc<dyn ProxyStore>,
|
||||
probe: Arc<dyn ProxyProbe>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
providers: ProviderAdminRegistry,
|
||||
test_slots: Semaphore,
|
||||
}
|
||||
|
||||
impl DefaultProxiesService {
|
||||
pub(crate) fn new(
|
||||
store: Arc<dyn ProxyStore>,
|
||||
probe: Arc<dyn ProxyProbe>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
providers: ProviderAdminRegistry,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
probe,
|
||||
snapshot,
|
||||
providers,
|
||||
test_slots: Semaphore::new(4),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxiesService for DefaultProxiesService {
|
||||
async fn remove_account(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
account_id: &str,
|
||||
context: &MutationContext,
|
||||
) -> Result<Revision, AdminError> {
|
||||
if proxy_id.is_empty() || proxy_id.len() > 128 || proxy_id.chars().any(char::is_control) {
|
||||
return Err(AdminError::invalid("代理 ID 不合法"));
|
||||
}
|
||||
let account_id = ProviderAccountId::new(account_id.to_owned())
|
||||
.map_err(|_| AdminError::invalid("账号 ID 不合法"))?;
|
||||
let revision = self
|
||||
.store
|
||||
.remove_account(proxy_id, &account_id, context)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.kind() == AdminStoreErrorKind::Conflict {
|
||||
AdminError::conflict("账号的代理绑定已变化,请刷新后重试")
|
||||
} else {
|
||||
map_store_error(error, "proxy account")
|
||||
}
|
||||
})?;
|
||||
publish_committed(self.snapshot.as_ref(), revision).await?;
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
query: ProxyAccountListQuery,
|
||||
) -> Result<ProxyAccountPage, AdminError> {
|
||||
if query.proxy_id.is_empty()
|
||||
|| query.proxy_id.len() > 128
|
||||
|| query.proxy_id.chars().any(char::is_control)
|
||||
|| query.page == 0
|
||||
|| query.search.len() > 256
|
||||
|| query.search.chars().any(char::is_control)
|
||||
{
|
||||
return Err(AdminError::invalid("关联账号查询参数不合法"));
|
||||
}
|
||||
let mut page = self
|
||||
.store
|
||||
.list_accounts(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
page.items = stream::iter(page.items)
|
||||
.map(|mut account| async {
|
||||
// 与账号目录一致,缺失套餐时读取已有额度快照;限制并发且不触发上游刷新。
|
||||
let mut cached_quota = None;
|
||||
if explicit_plan_type(account.plan_type.as_deref()).is_none()
|
||||
&& let Ok(kind) = ProviderKind::new(account.provider_kind.clone())
|
||||
&& let Ok(provider) = self.providers.require(&kind)
|
||||
&& let Ok(account_id) = ProviderAccountId::new(account.id.clone())
|
||||
&& let Ok(quota) = provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id,
|
||||
refresh: false,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
cached_quota = Some(quota);
|
||||
}
|
||||
account.plan_type_display = self.providers.resolve_account_plan(
|
||||
&account.provider_kind,
|
||||
&mut account.plan_type,
|
||||
cached_quota.as_ref(),
|
||||
);
|
||||
account
|
||||
})
|
||||
.buffered(8)
|
||||
.collect()
|
||||
.await;
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError> {
|
||||
if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control)
|
||||
{
|
||||
return Err(AdminError::invalid("代理查询参数不合法"));
|
||||
}
|
||||
self.store
|
||||
.list(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
mut command: NewProxy,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyMutation, AdminError> {
|
||||
command.name = validate_name(&command.name)?;
|
||||
command.location = command
|
||||
.location
|
||||
.map(|location| location.normalized())
|
||||
.transpose()
|
||||
.map_err(|_| AdminError::invalid("代理位置不合法"))?;
|
||||
let result = self
|
||||
.store
|
||||
.create(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
mut command: UpdateProxy,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyMutation, AdminError> {
|
||||
command.name = validate_name(&command.name)?;
|
||||
command.location = command
|
||||
.location
|
||||
.map(|location| location.map(|value| value.normalized()).transpose())
|
||||
.transpose()
|
||||
.map_err(|_| AdminError::invalid("代理位置不合法"))?;
|
||||
let result = self
|
||||
.store
|
||||
.update(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> Result<Revision, AdminError> {
|
||||
let result = self
|
||||
.store
|
||||
.delete(id, revision, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
publish_committed(self.snapshot.as_ref(), result).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn probe(&self, proxy: &OutboundProxy) -> Result<ProxyTestResult, AdminError> {
|
||||
let _permit = self.test_slots.try_acquire().map_err(|_| {
|
||||
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
|
||||
})?;
|
||||
Ok(self.probe.test(proxy).await)
|
||||
}
|
||||
|
||||
async fn test(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyRecord, AdminError> {
|
||||
let _permit = self.test_slots.try_acquire().map_err(|_| {
|
||||
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
|
||||
})?;
|
||||
let record = self
|
||||
.store
|
||||
.get(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
if record.revision != revision {
|
||||
return Err(AdminError::conflict("代理已被修改,请刷新后重新测试"));
|
||||
}
|
||||
let result = self.probe.test(&record.proxy).await;
|
||||
self.store
|
||||
.record_test(id, revision, result, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_name(value: &str) -> Result<String, AdminError> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.chars().count() > 100 || value.chars().any(char::is_control) {
|
||||
return Err(AdminError::invalid("代理名称需要 1 至 100 个字符"));
|
||||
}
|
||||
Ok(value.to_owned())
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::routing::snapshot::SnapshotControl;
|
||||
use gateway_core::policy::CodexClientVersion;
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
|
||||
use crate::{
|
||||
@@ -22,6 +23,31 @@ use super::{map_store_error, publish_committed};
|
||||
/// API 消费的 Runtime settings 管理服务。
|
||||
#[async_trait]
|
||||
pub trait SettingsService: Send + Sync {
|
||||
async fn preview_pricing_sync(
|
||||
&self,
|
||||
) -> Result<crate::model::pricing::PricingSyncPreview, AdminError>;
|
||||
async fn sync_pricing(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: crate::model::pricing::SyncPricing,
|
||||
) -> Result<(), AdminError>;
|
||||
async fn pricing(&self) -> Result<crate::model::pricing::PricingCatalog, AdminError>;
|
||||
async fn update_pricing(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: crate::model::pricing::UpdatePricing,
|
||||
) -> Result<(), AdminError>;
|
||||
async fn client_profile_options(
|
||||
&self,
|
||||
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
|
||||
}
|
||||
async fn preview_client_profile(
|
||||
&self,
|
||||
_configuration: Option<&gateway_core::account::OpaqueProviderData>,
|
||||
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
|
||||
}
|
||||
async fn load(&self) -> Result<RuntimeSettings, AdminError>;
|
||||
async fn replace(
|
||||
&self,
|
||||
@@ -40,19 +66,232 @@ pub trait SettingsService: Send + Sync {
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultSettingsService {
|
||||
providers: crate::ports::provider::ProviderAdminRegistry,
|
||||
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
|
||||
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
store: Arc<dyn SettingsStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl DefaultSettingsService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(store: Arc<dyn SettingsStore>, snapshot: Arc<dyn SnapshotControl>) -> Self {
|
||||
Self { store, snapshot }
|
||||
pub(crate) fn new(
|
||||
store: Arc<dyn SettingsStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
providers: crate::ports::provider::ProviderAdminRegistry,
|
||||
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
|
||||
) -> Self {
|
||||
Self {
|
||||
providers,
|
||||
pricing_source,
|
||||
store,
|
||||
snapshot,
|
||||
profile_provider,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsService for DefaultSettingsService {
|
||||
async fn preview_pricing_sync(
|
||||
&self,
|
||||
) -> Result<crate::model::pricing::PricingSyncPreview, AdminError> {
|
||||
self.pricing_source.fetch().await
|
||||
}
|
||||
|
||||
async fn sync_pricing(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: crate::model::pricing::SyncPricing,
|
||||
) -> Result<(), AdminError> {
|
||||
let count = command
|
||||
.models
|
||||
.values()
|
||||
.map(std::collections::BTreeSet::len)
|
||||
.sum::<usize>();
|
||||
if count == 0
|
||||
|| count > 10_000
|
||||
|| command
|
||||
.models
|
||||
.values()
|
||||
.any(std::collections::BTreeSet::is_empty)
|
||||
{
|
||||
return Err(AdminError::invalid("请选择 1 至 10000 个模型"));
|
||||
}
|
||||
let mut current = self.pricing_source.fetch().await?;
|
||||
if current != command.preview {
|
||||
return Err(AdminError::invalid(
|
||||
"models.dev 价目已变化,请重新预览后确认",
|
||||
));
|
||||
}
|
||||
let stored = self
|
||||
.store
|
||||
.load_pricing()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "model pricing"))?;
|
||||
let mut changes = crate::model::pricing::PricingSyncChanges::new();
|
||||
for (provider, models) in command.models {
|
||||
let selected = changes.entry(provider.clone()).or_default();
|
||||
for model in models {
|
||||
let price = current
|
||||
.prices
|
||||
.get_mut(&provider)
|
||||
.and_then(|prices| prices.remove(&model));
|
||||
if price.is_none()
|
||||
&& !stored
|
||||
.synced
|
||||
.get(&provider)
|
||||
.is_some_and(|prices| prices.contains_key(&model))
|
||||
{
|
||||
return Err(AdminError::invalid("所选模型不在来源价目中,请重新预览"));
|
||||
}
|
||||
selected.insert(model, price);
|
||||
}
|
||||
}
|
||||
let revision = self
|
||||
.store
|
||||
.sync_pricing(changes, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "model pricing sync"))?;
|
||||
publish_committed(self.snapshot.as_ref(), revision).await
|
||||
}
|
||||
|
||||
async fn pricing(&self) -> Result<crate::model::pricing::PricingCatalog, AdminError> {
|
||||
let stored = self
|
||||
.store
|
||||
.load_pricing()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "model pricing"))?;
|
||||
Ok(crate::model::pricing::PricingCatalog {
|
||||
defaults: self.providers.pricing_catalog(),
|
||||
overrides: stored.overrides,
|
||||
synced: stored.synced,
|
||||
synced_at: stored.synced_at,
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_pricing(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: crate::model::pricing::UpdatePricing,
|
||||
) -> Result<(), AdminError> {
|
||||
use crate::model::pricing::PricingChange;
|
||||
let catalog = self.pricing().await?;
|
||||
if !catalog.defaults.contains_key(&command.provider)
|
||||
|| command.models.is_empty()
|
||||
|| command.models.len() > 500
|
||||
|| command.models.iter().any(|model| {
|
||||
model.is_empty()
|
||||
|| model.len() > 128
|
||||
|| model.trim() != model
|
||||
|| model.chars().any(char::is_whitespace)
|
||||
|| model.chars().any(char::is_control)
|
||||
})
|
||||
{
|
||||
return Err(AdminError::invalid("Provider、模型 ID 或批量数量不合法"));
|
||||
}
|
||||
if command.change == PricingChange::Delete
|
||||
&& command.models.iter().any(|model| {
|
||||
catalog
|
||||
.defaults
|
||||
.get(&command.provider)
|
||||
.is_some_and(|models| models.contains_key(model))
|
||||
})
|
||||
{
|
||||
return Err(AdminError::invalid("内置价目不能删除,仅支持人工覆盖"));
|
||||
}
|
||||
let defaults = gateway_core::metering::merge_pricing(catalog.defaults, &catalog.synced);
|
||||
let defaults = defaults.get(&command.provider);
|
||||
match &command.change {
|
||||
PricingChange::Replace(pricing) => {
|
||||
pricing.validate().map_err(AdminError::invalid)?;
|
||||
if command.provider == "xai"
|
||||
&& pricing
|
||||
.bands
|
||||
.keys()
|
||||
.any(|band| matches!(band.as_str(), "image" | "flex" | "long_flex"))
|
||||
{
|
||||
return Err(AdminError::invalid("xAI 不支持此价格档位"));
|
||||
}
|
||||
if !pricing.bands.contains_key("standard")
|
||||
&& command
|
||||
.models
|
||||
.iter()
|
||||
.any(|model| !defaults.is_some_and(|p| p.contains_key(model)))
|
||||
{
|
||||
return Err(AdminError::invalid("未知模型必须提供标准档价格"));
|
||||
}
|
||||
}
|
||||
PricingChange::Multiplier(bps) if *bps > 1_000_000 => {
|
||||
return Err(AdminError::invalid("倍率必须在 0 至 100 倍之间"));
|
||||
}
|
||||
PricingChange::Multiplier(_)
|
||||
if command.models.iter().any(|model| {
|
||||
!defaults.is_some_and(|p| p.contains_key(model))
|
||||
&& !catalog
|
||||
.overrides
|
||||
.get(&command.provider)
|
||||
.and_then(|p| p.get(model))
|
||||
.is_some_and(|p| p.bands.contains_key("standard"))
|
||||
}) =>
|
||||
{
|
||||
return Err(AdminError::invalid("请先为未知模型配置标准档价格"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let revision = self
|
||||
.store
|
||||
.update_pricing(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "model pricing"))?;
|
||||
publish_committed(self.snapshot.as_ref(), revision).await
|
||||
}
|
||||
|
||||
async fn client_profile_options(
|
||||
&self,
|
||||
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
|
||||
let mut options = self
|
||||
.profile_provider
|
||||
.client_profile_options()
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?
|
||||
.into_inner();
|
||||
let configuration = self
|
||||
.load()
|
||||
.await?
|
||||
.openai_client_profile
|
||||
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
|
||||
options.insert(
|
||||
"globalConfiguration".to_owned(),
|
||||
serde_json::Value::Object(configuration.into_inner()),
|
||||
);
|
||||
Ok(gateway_core::account::OpaqueProviderData::new(options))
|
||||
}
|
||||
|
||||
async fn preview_client_profile(
|
||||
&self,
|
||||
configuration: Option<&gateway_core::account::OpaqueProviderData>,
|
||||
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
|
||||
let global;
|
||||
let (configuration, source) = if let Some(configuration) = configuration {
|
||||
(configuration, "override")
|
||||
} else {
|
||||
global = self
|
||||
.load()
|
||||
.await?
|
||||
.openai_client_profile
|
||||
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
|
||||
(&global, "global")
|
||||
};
|
||||
let mut preview = self
|
||||
.profile_provider
|
||||
.preview_client_profile(configuration)
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?
|
||||
.into_inner();
|
||||
preview.insert("source".to_owned(), serde_json::Value::from(source));
|
||||
Ok(gateway_core::account::OpaqueProviderData::new(preview))
|
||||
}
|
||||
|
||||
async fn load(&self) -> Result<RuntimeSettings, AdminError> {
|
||||
self.store
|
||||
.load_runtime_settings()
|
||||
@@ -66,6 +305,11 @@ impl SettingsService for DefaultSettingsService {
|
||||
command: ReplaceRuntimeSettings,
|
||||
) -> Result<RuntimeSettings, AdminError> {
|
||||
validate_settings(&command)?;
|
||||
if let Some(profile) = &command.openai_client_profile {
|
||||
self.profile_provider
|
||||
.preview_client_profile(profile)
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
}
|
||||
let settings = self
|
||||
.store
|
||||
.replace_runtime_settings(command, context)
|
||||
@@ -86,13 +330,12 @@ impl SettingsService for DefaultSettingsService {
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> Result<RegeneratedAdminApiKey, AdminError> {
|
||||
let settings = self.load().await?;
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let key = AdminApiKey::new(format!("admin-{}", hex::encode(bytes)));
|
||||
let mutation = self
|
||||
.store
|
||||
.replace_admin_api_key(settings.config_revision, key.clone(), context)
|
||||
.replace_admin_api_key(key.clone(), context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator API key"))?;
|
||||
publish_committed(self.snapshot.as_ref(), mutation.config_revision).await?;
|
||||
@@ -103,10 +346,9 @@ impl SettingsService for DefaultSettingsService {
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> Result<AdminApiKeyMutation, AdminError> {
|
||||
let settings = self.load().await?;
|
||||
let mutation = self
|
||||
.store
|
||||
.delete_admin_api_key(settings.config_revision, context)
|
||||
.delete_admin_api_key(context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator API key"))?;
|
||||
publish_committed(self.snapshot.as_ref(), mutation.config_revision).await?;
|
||||
@@ -115,16 +357,41 @@ impl SettingsService for DefaultSettingsService {
|
||||
}
|
||||
|
||||
fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> {
|
||||
let valid = command.refresh_margin_seconds > 0
|
||||
let valid = command.request_location.validate().is_ok()
|
||||
&& command.responses_max_decompressed_body_bytes > 0
|
||||
&& isize::try_from(command.responses_max_decompressed_body_bytes).is_ok()
|
||||
&& command.refresh_margin_seconds > 0
|
||||
&& command.refresh_concurrency > 0
|
||||
&& command.max_concurrent_per_account > 0
|
||||
&& command.max_waiting_per_key <= 1_000
|
||||
&& command.max_waiting_per_account <= 1_000
|
||||
&& (1..=120).contains(&command.concurrency_wait_timeout_seconds)
|
||||
&& command.usage_retention_days >= 31
|
||||
&& command.ops_event_retention_days > 0
|
||||
&& command.audit_retention_days > 0
|
||||
&& i64::try_from(command.request_interval_ms).is_ok();
|
||||
&& valid_client_version(command.min_codex_desktop_version.as_deref())
|
||||
&& valid_client_version(command.min_codex_cli_version.as_deref())
|
||||
&& valid_probe_model(command.account_auto_freeze_probe_model.as_deref())
|
||||
&& i64::try_from(command.request_interval_ms).is_ok()
|
||||
&& (2..=1_000).contains(&command.account_auto_freeze_threshold)
|
||||
&& (60..=3_600).contains(&command.account_auto_freeze_window_seconds)
|
||||
&& (300..=604_800).contains(&command.account_auto_freeze_duration_seconds);
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AdminError::invalid("Runtime settings violate constraints"))
|
||||
Err(AdminError::invalid("运行时设置不满足约束"))
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_client_version(value: Option<&str>) -> bool {
|
||||
value.is_none_or(|value| CodexClientVersion::parse(value).is_ok())
|
||||
}
|
||||
|
||||
fn valid_probe_model(value: Option<&str>) -> bool {
|
||||
value.is_none_or(|value| {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value == value.trim()
|
||||
&& !value.bytes().any(|byte| byte.is_ascii_control())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -94,5 +94,12 @@ fn map_system_error(error: SystemOperationError) -> AdminError {
|
||||
SystemOperationErrorKind::Upstream => AdminErrorKind::BadGateway,
|
||||
SystemOperationErrorKind::Internal => AdminErrorKind::Internal,
|
||||
};
|
||||
AdminError::new(kind, error.message())
|
||||
let message = match kind {
|
||||
AdminErrorKind::Invalid => "系统操作请求不合法",
|
||||
AdminErrorKind::Conflict => "系统当前状态不允许执行该操作",
|
||||
AdminErrorKind::BadGateway => "系统更新服务请求失败",
|
||||
AdminErrorKind::Internal => "系统操作失败",
|
||||
_ => "系统操作失败",
|
||||
};
|
||||
AdminError::new(kind, message)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::routing::snapshot::SnapshotControl;
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
@@ -11,8 +11,8 @@ use crate::{
|
||||
provider_credentials::{
|
||||
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
|
||||
CredentialDeletionResult, CredentialImportCommit, CredentialImportResult,
|
||||
CredentialListQuery, CredentialMutation, CredentialMutationResult, CredentialPage,
|
||||
ImportCredentials, PrepareCredentialImport, ProviderQuotaRequest, StartAuthorization,
|
||||
CredentialMutationResult, ImportCredentials, PrepareCredentialImport,
|
||||
StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{provider::ProviderAdmin, store::AccountStore},
|
||||
@@ -20,14 +20,13 @@ use crate::{
|
||||
|
||||
use super::{
|
||||
commit_authorization, delete_credentials, map_provider_error, map_store_error,
|
||||
pending_authorization, publish_committed, set_credential_enabled,
|
||||
pending_authorization, publish_committed, publish_credentials_and_observe_quota,
|
||||
validate_authorization_commit, validate_prepared_import,
|
||||
};
|
||||
|
||||
/// xAI 固定管理路由消费的服务。
|
||||
#[async_trait]
|
||||
pub trait XaiService: Send + Sync {
|
||||
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError>;
|
||||
async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
@@ -40,14 +39,6 @@ pub trait XaiService: Send + Sync {
|
||||
&self,
|
||||
command: CompleteAuthorization,
|
||||
) -> Result<CredentialMutationResult, AdminError>;
|
||||
async fn enable(
|
||||
&self,
|
||||
command: CredentialMutation,
|
||||
) -> Result<CredentialMutationResult, AdminError>;
|
||||
async fn disable(
|
||||
&self,
|
||||
command: CredentialMutation,
|
||||
) -> Result<CredentialMutationResult, AdminError>;
|
||||
async fn delete(
|
||||
&self,
|
||||
command: CredentialDeletion,
|
||||
@@ -57,6 +48,7 @@ pub trait XaiService: Send + Sync {
|
||||
pub(crate) struct DefaultXaiService {
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
@@ -65,11 +57,13 @@ impl DefaultXaiService {
|
||||
pub(crate) fn new(
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
accounts,
|
||||
proxies,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
@@ -77,22 +71,25 @@ impl DefaultXaiService {
|
||||
|
||||
#[async_trait]
|
||||
impl XaiService for DefaultXaiService {
|
||||
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError> {
|
||||
self.accounts
|
||||
.list_credentials(self.provider.provider_kind(), query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "xAI credential"))
|
||||
}
|
||||
|
||||
async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
let context = command.context;
|
||||
let expected_config_revision = command.expected_config_revision;
|
||||
let proxy_reservation = super::import_proxy_binding(
|
||||
self.proxies.as_ref(),
|
||||
command.outbound_proxy_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let outbound_proxy = proxy_reservation
|
||||
.as_ref()
|
||||
.map(|reservation| reservation.binding.clone());
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_import(PrepareCredentialImport {
|
||||
default_outbound_proxy: outbound_proxy
|
||||
.as_ref()
|
||||
.map(|binding| binding.proxy.clone()),
|
||||
document: command.document,
|
||||
})
|
||||
.await
|
||||
@@ -106,25 +103,23 @@ impl XaiService for DefaultXaiService {
|
||||
.accounts
|
||||
.commit_credential_import(
|
||||
CredentialImportCommit {
|
||||
expected_config_revision,
|
||||
outbound_proxy,
|
||||
prepared,
|
||||
settings: command.settings,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "xAI credential import"))?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
// 导入已经提交,额度属于可重建观察事实;单个账号查询失败由周期任务重试。
|
||||
for account_id in &result.credential_ids {
|
||||
let _ = self
|
||||
.provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
drop(proxy_reservation);
|
||||
publish_credentials_and_observe_quota(
|
||||
&self.provider,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
&result.credential_ids,
|
||||
&context.request_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -134,6 +129,7 @@ impl XaiService for DefaultXaiService {
|
||||
) -> Result<AuthorizationStarted, AdminError> {
|
||||
let pending = pending_authorization(
|
||||
self.accounts.as_ref(),
|
||||
self.proxies.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
&command,
|
||||
"xAI credential",
|
||||
@@ -147,60 +143,38 @@ impl XaiService for DefaultXaiService {
|
||||
|
||||
async fn complete_authorization(
|
||||
&self,
|
||||
command: CompleteAuthorization,
|
||||
mut command: CompleteAuthorization,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.context.clone();
|
||||
let settings = command.settings.take();
|
||||
let prepared = self
|
||||
.provider
|
||||
.complete_authorization(command)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "xAI authorization"))?;
|
||||
validate_authorization_commit(
|
||||
let prepared = validate_authorization_commit(
|
||||
self.provider.provider_kind(),
|
||||
&context,
|
||||
&prepared,
|
||||
prepared,
|
||||
"xAI authorization",
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let result = commit_authorization(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
settings,
|
||||
&context,
|
||||
"xAI authorization",
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn enable(
|
||||
&self,
|
||||
command: CredentialMutation,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let result = set_credential_enabled(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.as_ref(),
|
||||
command,
|
||||
true,
|
||||
"xAI credential",
|
||||
publish_credentials_and_observe_quota(
|
||||
&self.provider,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
std::slice::from_ref(&result.account_id),
|
||||
&context.request_id,
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn disable(
|
||||
&self,
|
||||
command: CredentialMutation,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let result = set_credential_enabled(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.as_ref(),
|
||||
command,
|
||||
false,
|
||||
"xAI credential",
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! 备份模块测试:镜像 src/backup/ 的 policy 与 task。
|
||||
|
||||
mod policy;
|
||||
pub mod support;
|
||||
mod task;
|
||||
@@ -0,0 +1,171 @@
|
||||
//! 备份调度与保留策略(backup/policy.rs)的纯逻辑测试。
|
||||
|
||||
use chrono::{Duration, TimeZone as _, Utc};
|
||||
|
||||
use gateway_admin::{
|
||||
backup::policy::{BackupSchedule, RetentionReason, decide_retention},
|
||||
model::backup::{BackupRecord, BackupStatus, BackupTriggerKind},
|
||||
};
|
||||
|
||||
fn record(
|
||||
id: &str,
|
||||
trigger: BackupTriggerKind,
|
||||
status: BackupStatus,
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
completed_at: Option<chrono::DateTime<Utc>>,
|
||||
) -> BackupRecord {
|
||||
BackupRecord {
|
||||
id: id.to_owned(),
|
||||
trigger_kind: trigger,
|
||||
status,
|
||||
scheduled_at: if trigger == BackupTriggerKind::Scheduled {
|
||||
Some(created_at)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
object_key: format!("k/{id}"),
|
||||
size_bytes: Some(1),
|
||||
sha256: Some("a".repeat(64)),
|
||||
attempt_count: 0,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
started_at: Some(created_at),
|
||||
completed_at,
|
||||
expires_at: None,
|
||||
created_at,
|
||||
updated_at: created_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_bad_cron_and_timezone() {
|
||||
assert!(BackupSchedule::parse("61 * * * *", "Asia/Shanghai").is_err());
|
||||
assert!(BackupSchedule::parse("0 2 * * *", "Not/AZone").is_err());
|
||||
assert!(BackupSchedule::parse("0 2 * * *", "Asia/Shanghai").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_after_is_strictly_later() {
|
||||
let schedule = BackupSchedule::parse("0 2 * * *", "Asia/Shanghai").unwrap();
|
||||
let at = Utc.with_ymd_and_hms(2026, 8, 1, 18, 0, 0).unwrap();
|
||||
let next = schedule.next_after(at).unwrap();
|
||||
assert_eq!(next, Utc.with_ymd_and_hms(2026, 8, 2, 18, 0, 0).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_firing_is_at_or_before() {
|
||||
let schedule = BackupSchedule::parse("0 2 * * *", "Asia/Shanghai").unwrap();
|
||||
// 北京时间 02:00 = 前一日 18:00 UTC。
|
||||
let before = Utc.with_ymd_and_hms(2026, 8, 1, 17, 59, 0).unwrap();
|
||||
assert_eq!(
|
||||
schedule.last_firing_at_or_before(before).unwrap(),
|
||||
Utc.with_ymd_and_hms(2026, 7, 31, 18, 0, 0).unwrap()
|
||||
);
|
||||
let exact = Utc.with_ymd_and_hms(2026, 8, 1, 18, 0, 0).unwrap();
|
||||
assert_eq!(schedule.last_firing_at_or_before(exact).unwrap(), exact);
|
||||
let after = Utc.with_ymd_and_hms(2026, 8, 1, 19, 0, 0).unwrap();
|
||||
assert_eq!(schedule.last_firing_at_or_before(after).unwrap(), exact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dst_spring_forward_skips_missing_hour() {
|
||||
let schedule = BackupSchedule::parse("30 2 * * *", "America/New_York").unwrap();
|
||||
let at = Utc.with_ymd_and_hms(2026, 3, 8, 6, 29, 0).unwrap(); // 01:29 EST
|
||||
let next = schedule.next_after(at).unwrap();
|
||||
assert_eq!(next, Utc.with_ymd_and_hms(2026, 3, 9, 6, 30, 0).unwrap());
|
||||
let at_after_spring = Utc.with_ymd_and_hms(2026, 3, 8, 10, 0, 0).unwrap(); // 06:00 EDT
|
||||
let last = schedule.last_firing_at_or_before(at_after_spring).unwrap();
|
||||
assert_eq!(last, Utc.with_ymd_and_hms(2026, 3, 7, 7, 30, 0).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_count_keeps_newest_n() {
|
||||
let now = Utc::now();
|
||||
let base = now - Duration::days(10);
|
||||
let records = vec![
|
||||
record(
|
||||
"newest",
|
||||
BackupTriggerKind::Scheduled,
|
||||
BackupStatus::Completed,
|
||||
base + Duration::days(2),
|
||||
Some(base + Duration::days(2)),
|
||||
),
|
||||
record(
|
||||
"middle",
|
||||
BackupTriggerKind::Scheduled,
|
||||
BackupStatus::Completed,
|
||||
base + Duration::days(1),
|
||||
Some(base + Duration::days(1)),
|
||||
),
|
||||
record(
|
||||
"oldest",
|
||||
BackupTriggerKind::Scheduled,
|
||||
BackupStatus::Completed,
|
||||
base,
|
||||
Some(base),
|
||||
),
|
||||
];
|
||||
let decisions = decide_retention(0, 2, now, &records);
|
||||
let ids: Vec<_> = decisions.iter().map(|d| d.record_id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["oldest"]);
|
||||
assert_eq!(decisions[0].reason, RetentionReason::Count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_days_deletes_old_scheduled_but_keeps_newest() {
|
||||
let now = Utc::now();
|
||||
let records = vec![
|
||||
record(
|
||||
"recent",
|
||||
BackupTriggerKind::Scheduled,
|
||||
BackupStatus::Completed,
|
||||
now - Duration::days(1),
|
||||
Some(now - Duration::days(1)),
|
||||
),
|
||||
record(
|
||||
"ancient",
|
||||
BackupTriggerKind::Scheduled,
|
||||
BackupStatus::Completed,
|
||||
now - Duration::days(40),
|
||||
Some(now - Duration::days(40)),
|
||||
),
|
||||
];
|
||||
let decisions = decide_retention(30, 0, now, &records);
|
||||
let ids: Vec<_> = decisions.iter().map(|d| d.record_id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["ancient"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_scheduled_never_auto_deleted_even_when_ancient() {
|
||||
let now = Utc::now();
|
||||
let records = vec![record(
|
||||
"sole",
|
||||
BackupTriggerKind::Scheduled,
|
||||
BackupStatus::Completed,
|
||||
now - Duration::days(400),
|
||||
Some(now - Duration::days(400)),
|
||||
)];
|
||||
assert!(decide_retention(30, 1, now, &records).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_thresholds_delete_nothing() {
|
||||
let now = Utc::now();
|
||||
let records = vec![
|
||||
record(
|
||||
"newest",
|
||||
BackupTriggerKind::Scheduled,
|
||||
BackupStatus::Completed,
|
||||
now - Duration::days(2),
|
||||
Some(now - Duration::days(2)),
|
||||
),
|
||||
record(
|
||||
"old",
|
||||
BackupTriggerKind::Scheduled,
|
||||
BackupStatus::Completed,
|
||||
now - Duration::days(90),
|
||||
Some(now - Duration::days(90)),
|
||||
),
|
||||
];
|
||||
assert!(decide_retention(0, 0, now, &records).is_empty());
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
//! 备份测试共享 fixture:内存 fake 端口与辅助函数。
|
||||
//!
|
||||
//! 用内存 fake 端口驱动编排层,不依赖 MinIO/真实 S3。
|
||||
//! s3.rs / pg_dump.rs 是 SDK 薄适配器,协议正确性交给 aws-sdk / pg_dump 保证。
|
||||
//! 供 backup::task 与 use_case::backup 的测试复用。
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
MutationActor, MutationContext, Revision,
|
||||
auth::AdminAuditEvent,
|
||||
backup::{
|
||||
BackupError, BackupObjectMetadata, BackupRecord, BackupRecordListQuery,
|
||||
BackupRecordPage, BackupRecordSeed, BackupSettings, BackupStatus, BackupStorageConfig,
|
||||
BackupTriggerKind, ConnectionTestResult, UpdateBackupScheduleCommand,
|
||||
UpdateBackupStorageCommand,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
backup::{
|
||||
BackupObjectStorePort, BackupRepository, DatabaseDumpPort, DumpArtifact, DumpRequest,
|
||||
StagedArtifact, StatusTransitionUpdate, UploadObjectRequest,
|
||||
},
|
||||
store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, AuthStore},
|
||||
},
|
||||
};
|
||||
use secrecy::SecretString;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// 假归档内容;dump 与上传校验共用同一字节与校验值。
|
||||
pub(crate) const DUMP_CONTENT: &[u8] = b"custom-format-archive-bytes-0123456789";
|
||||
|
||||
/// 计算一段内容的 SHA-256 十六进制。
|
||||
pub(crate) fn sha256_hex(content: &[u8]) -> String {
|
||||
hex::encode(Sha256::digest(content))
|
||||
}
|
||||
|
||||
/// 返回 32 位十六进制的合法备份记录 id。
|
||||
pub(crate) fn backup_id(suffix: &str) -> String {
|
||||
format!("backup_{}", sha256_hex(suffix.as_bytes()))
|
||||
}
|
||||
|
||||
/// 已配置且已通过连接测试的设置快照。
|
||||
pub(crate) fn configured_settings() -> BackupSettings {
|
||||
let now = Utc::now();
|
||||
BackupSettings {
|
||||
storage_revision: 1,
|
||||
endpoint: Some("https://s3.example.com".to_owned()),
|
||||
region: Some("auto".to_owned()),
|
||||
bucket: Some("backup-bucket".to_owned()),
|
||||
access_key_id: Some("ak".to_owned()),
|
||||
secret_access_key: Some(SecretString::from("sk-value")),
|
||||
prefix: Some("codex".to_owned()),
|
||||
force_path_style: false,
|
||||
schedule_enabled: false,
|
||||
cron_expression: None,
|
||||
schedule_timezone: None,
|
||||
retention_days: 0,
|
||||
retention_count: 0,
|
||||
next_run_at: None,
|
||||
last_verified_at: Some(now),
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 seed 构造 queued 记录。
|
||||
pub(crate) fn queued_record(seed: &BackupRecordSeed) -> BackupRecord {
|
||||
let now = Utc::now();
|
||||
BackupRecord {
|
||||
id: seed.id.clone(),
|
||||
trigger_kind: seed.trigger_kind,
|
||||
status: BackupStatus::Queued,
|
||||
scheduled_at: seed.scheduled_at,
|
||||
object_key: seed.object_key.clone(),
|
||||
size_bytes: None,
|
||||
sha256: None,
|
||||
attempt_count: 0,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
expires_at: seed.expires_at,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// 内存版备份仓储。
|
||||
pub(crate) struct FakeBackupRepository {
|
||||
settings: Mutex<BackupSettings>,
|
||||
records: Mutex<Vec<BackupRecord>>,
|
||||
}
|
||||
|
||||
impl FakeBackupRepository {
|
||||
pub(crate) fn new(settings: BackupSettings) -> Self {
|
||||
Self {
|
||||
settings: Mutex::new(settings),
|
||||
records: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试辅助:读取当前全部记录。
|
||||
pub(crate) fn all_records(&self) -> Vec<BackupRecord> {
|
||||
self.records.lock().expect("records").clone()
|
||||
}
|
||||
|
||||
/// 测试辅助:强制把一条记录标记为指定时间完成的 completed。
|
||||
pub(crate) fn set_completed(&self, id: &str, completed_at: DateTime<Utc>) {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
if let Some(record) = records.iter_mut().find(|record| record.id == id) {
|
||||
record.status = BackupStatus::Completed;
|
||||
record.completed_at = Some(completed_at);
|
||||
record.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试辅助:强制把一条记录迁移到指定状态。
|
||||
pub(crate) fn force_status(&self, id: &str, status: BackupStatus) {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
if let Some(record) = records.iter_mut().find(|record| record.id == id) {
|
||||
record.status = status;
|
||||
if matches!(status, BackupStatus::Completed | BackupStatus::Failed) {
|
||||
record.completed_at = Some(Utc::now());
|
||||
}
|
||||
record.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BackupRepository for FakeBackupRepository {
|
||||
async fn load_settings(&self) -> AdminStoreResult<BackupSettings> {
|
||||
Ok(self.settings.lock().expect("settings").clone())
|
||||
}
|
||||
|
||||
async fn update_storage_settings(
|
||||
&self,
|
||||
command: UpdateBackupStorageCommand,
|
||||
_context: &MutationContext,
|
||||
) -> AdminStoreResult<(BackupSettings, Revision)> {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
settings.storage_revision += 1;
|
||||
settings.endpoint = Some(command.endpoint);
|
||||
settings.region = Some(command.region);
|
||||
settings.bucket = Some(command.bucket);
|
||||
settings.access_key_id = Some(command.access_key_id);
|
||||
if let Some(secret) = command.secret_access_key {
|
||||
settings.secret_access_key = Some(secret);
|
||||
}
|
||||
settings.prefix = Some(command.prefix);
|
||||
settings.force_path_style = command.force_path_style;
|
||||
settings.last_verified_at = None;
|
||||
settings.updated_at = Utc::now();
|
||||
let revision = Revision::new(settings.storage_revision).map_err(|_| {
|
||||
AdminStoreError::new(AdminStoreErrorKind::Invalid, "backup", "zero revision")
|
||||
})?;
|
||||
Ok((settings.clone(), revision))
|
||||
}
|
||||
|
||||
async fn update_schedule_settings(
|
||||
&self,
|
||||
command: UpdateBackupScheduleCommand,
|
||||
next_run_at: Option<DateTime<Utc>>,
|
||||
_context: &MutationContext,
|
||||
) -> AdminStoreResult<BackupSettings> {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
settings.schedule_enabled = command.schedule_enabled;
|
||||
settings.cron_expression = Some(command.cron_expression);
|
||||
settings.schedule_timezone = Some(command.schedule_timezone);
|
||||
settings.retention_days = command.retention_days;
|
||||
settings.retention_count = command.retention_count;
|
||||
settings.next_run_at = next_run_at;
|
||||
settings.updated_at = Utc::now();
|
||||
Ok(settings.clone())
|
||||
}
|
||||
|
||||
async fn record_verification(
|
||||
&self,
|
||||
storage_revision: u64,
|
||||
at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<bool> {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
if settings.storage_revision != storage_revision {
|
||||
return Ok(false);
|
||||
}
|
||||
settings.last_verified_at = Some(at);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn insert_backup_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<BackupRecord> {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
if records.iter().any(|record| record.status.is_active()) {
|
||||
return Err(AdminStoreError::new(
|
||||
AdminStoreErrorKind::Conflict,
|
||||
"backup record",
|
||||
"an active backup task already exists",
|
||||
));
|
||||
}
|
||||
let record = queued_record(&seed);
|
||||
records.push(record.clone());
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
async fn insert_scheduled_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<bool> {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
if records.iter().any(|record| record.status.is_active())
|
||||
|| records.iter().any(|record| {
|
||||
record.trigger_kind == BackupTriggerKind::Scheduled
|
||||
&& record.scheduled_at == seed.scheduled_at
|
||||
})
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
records.push(queued_record(&seed));
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn list_backup_records(
|
||||
&self,
|
||||
query: BackupRecordListQuery,
|
||||
) -> AdminStoreResult<BackupRecordPage> {
|
||||
let records = self.records.lock().expect("records");
|
||||
let mut items = records
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
query.status.is_none_or(|status| record.status == status)
|
||||
&& query
|
||||
.trigger
|
||||
.is_none_or(|trigger| record.trigger_kind == trigger)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.cmp(&a.id))
|
||||
});
|
||||
let total = items.len() as u64;
|
||||
let offset =
|
||||
(u64::from(query.page.saturating_sub(1)) * u64::from(query.page_size.get())) as usize;
|
||||
let limit = usize::from(query.page_size.get());
|
||||
items = items.into_iter().skip(offset).take(limit).collect();
|
||||
Ok(BackupRecordPage {
|
||||
items,
|
||||
total,
|
||||
page: query.page,
|
||||
page_size: query.page_size,
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_backup_record(&self, id: &str) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
Ok(self
|
||||
.records
|
||||
.lock()
|
||||
.expect("records")
|
||||
.iter()
|
||||
.find(|record| record.id == id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn list_intermediate_records(&self) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
Ok(self
|
||||
.records
|
||||
.lock()
|
||||
.expect("records")
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
matches!(
|
||||
record.status,
|
||||
BackupStatus::Dumping | BackupStatus::Uploading
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_pending_deletions(&self, _limit: u32) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
Ok(self
|
||||
.records
|
||||
.lock()
|
||||
.expect("records")
|
||||
.iter()
|
||||
.filter(|record| record.status == BackupStatus::Deleting)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_expired_records(&self, _limit: u32) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
let now = Utc::now();
|
||||
Ok(self
|
||||
.records
|
||||
.lock()
|
||||
.expect("records")
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
record
|
||||
.expires_at
|
||||
.is_some_and(|expires_at| expires_at <= now)
|
||||
&& matches!(
|
||||
record.status,
|
||||
BackupStatus::Completed | BackupStatus::Failed
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn claim_next_queued(
|
||||
&self,
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
let Some(record) = records
|
||||
.iter_mut()
|
||||
.find(|record| record.status == BackupStatus::Queued)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
record.status = BackupStatus::Dumping;
|
||||
record.started_at = Some(now);
|
||||
record.attempt_count += 1;
|
||||
record.updated_at = now;
|
||||
Ok(Some(record.clone()))
|
||||
}
|
||||
|
||||
async fn transition_status(
|
||||
&self,
|
||||
id: &str,
|
||||
from: BackupStatus,
|
||||
to: BackupStatus,
|
||||
update: StatusTransitionUpdate,
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
let Some(record) = records
|
||||
.iter_mut()
|
||||
.find(|record| record.id == id && record.status == from)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
record.status = to;
|
||||
if let Some(size) = update.size_bytes {
|
||||
record.size_bytes = Some(size);
|
||||
}
|
||||
if let Some(sha256) = update.sha256 {
|
||||
record.sha256 = Some(sha256);
|
||||
}
|
||||
if let Some(code) = update.error_code {
|
||||
record.error_code = Some(code);
|
||||
}
|
||||
if let Some(message) = update.error_message {
|
||||
record.error_message = Some(message);
|
||||
}
|
||||
record.completed_at = update.completed_at.or_else(|| {
|
||||
matches!(to, BackupStatus::Completed | BackupStatus::Failed).then_some(now)
|
||||
});
|
||||
record.updated_at = now;
|
||||
Ok(Some(record.clone()))
|
||||
}
|
||||
|
||||
async fn transition_to_deleting(
|
||||
&self,
|
||||
id: &str,
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
let Some(record) = records
|
||||
.iter_mut()
|
||||
.find(|record| record.id == id && record.status.can_be_deleted())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
record.status = BackupStatus::Deleting;
|
||||
record.updated_at = now;
|
||||
Ok(Some(record.clone()))
|
||||
}
|
||||
|
||||
async fn delete_record(&self, id: &str) -> AdminStoreResult<()> {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
records.retain(|record| record.id != id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn advance_schedule_cursor(
|
||||
&self,
|
||||
next_run_at: DateTime<Utc>,
|
||||
_expected_cron: &str,
|
||||
_expected_timezone: &str,
|
||||
) -> AdminStoreResult<bool> {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
settings.next_run_at = Some(next_run_at);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn list_scheduled_completed_desc(
|
||||
&self,
|
||||
_limit: u32,
|
||||
) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
let mut records = self
|
||||
.records
|
||||
.lock()
|
||||
.expect("records")
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
record.trigger_kind == BackupTriggerKind::Scheduled
|
||||
&& record.status == BackupStatus::Completed
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
records.sort_by(|a, b| {
|
||||
b.completed_at
|
||||
.cmp(&a.completed_at)
|
||||
.then_with(|| b.id.cmp(&a.id))
|
||||
});
|
||||
Ok(records)
|
||||
}
|
||||
}
|
||||
|
||||
/// 假 `pg_dump` 端口:把固定内容写入临时文件作为归档;可注入失败。
|
||||
pub(crate) struct FakeDumpPort {
|
||||
dir: TempDir,
|
||||
fail_dump: bool,
|
||||
}
|
||||
|
||||
impl FakeDumpPort {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
dir: tempfile::tempdir().expect("temp dir"),
|
||||
fail_dump: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 让下一次 `dump` 返回 `backup.pg_dump_failed`。
|
||||
pub(crate) fn fail_next_dump(mut self) -> Self {
|
||||
self.fail_dump = true;
|
||||
self
|
||||
}
|
||||
|
||||
fn artifact(&self, backup_id: &str) -> DumpArtifact {
|
||||
let path = self.dir.path().join(format!("{backup_id}.dump"));
|
||||
std::fs::write(&path, DUMP_CONTENT).expect("write artifact");
|
||||
DumpArtifact {
|
||||
path,
|
||||
size_bytes: DUMP_CONTENT.len() as u64,
|
||||
sha256: sha256_hex(DUMP_CONTENT),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DatabaseDumpPort for FakeDumpPort {
|
||||
async fn dump(
|
||||
&self,
|
||||
request: DumpRequest,
|
||||
) -> Result<DumpArtifact, gateway_admin::model::backup::BackupError> {
|
||||
if self.fail_dump {
|
||||
return Err(gateway_admin::model::backup::BackupError::new(
|
||||
gateway_admin::model::backup::code::PG_DUMP_FAILED,
|
||||
"pg_dump failed".to_owned(),
|
||||
));
|
||||
}
|
||||
if request.cancellation.is_cancelled() {
|
||||
return Err(gateway_admin::model::backup::BackupError::new(
|
||||
gateway_admin::model::backup::code::CANCELLED,
|
||||
"cancelled".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(self.artifact(&request.backup_id))
|
||||
}
|
||||
|
||||
async fn inspect_staging(
|
||||
&self,
|
||||
backup_id: &str,
|
||||
) -> Result<Option<StagedArtifact>, gateway_admin::model::backup::BackupError> {
|
||||
let path = self.dir.path().join(format!("{backup_id}.dump"));
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(StagedArtifact {
|
||||
path,
|
||||
size_bytes: DUMP_CONTENT.len() as u64,
|
||||
sha256: sha256_hex(DUMP_CONTENT),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn cleanup_staging(
|
||||
&self,
|
||||
_backup_id: &str,
|
||||
) -> Result<(), gateway_admin::model::backup::BackupError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 内存版对象存储:把上传内容按 key 保存并支持 Head/Delete/预签名。
|
||||
pub(crate) struct FakeObjectStore {
|
||||
pub(crate) objects: Mutex<std::collections::HashMap<String, Vec<u8>>>,
|
||||
upload_error: Option<BackupError>,
|
||||
}
|
||||
|
||||
impl FakeObjectStore {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
objects: Mutex::new(std::collections::HashMap::new()),
|
||||
upload_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 让上传返回指定的脱敏 S3 错误。
|
||||
pub(crate) fn fail_upload(mut self, code: &'static str, message: impl Into<String>) -> Self {
|
||||
self.upload_error = Some(BackupError::new(code, message.into()));
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn object(&self, key: &str) -> Option<Vec<u8>> {
|
||||
self.objects.lock().expect("objects").get(key).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BackupObjectStorePort for FakeObjectStore {
|
||||
async fn test_connection(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
) -> Result<ConnectionTestResult, gateway_admin::model::backup::BackupError> {
|
||||
Ok(ConnectionTestResult {
|
||||
ok: true,
|
||||
stage: "deleteObject",
|
||||
code: None,
|
||||
message: "连接测试成功".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn upload_file(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
request: UploadObjectRequest,
|
||||
) -> Result<(), gateway_admin::model::backup::BackupError> {
|
||||
if let Some(error) = &self.upload_error {
|
||||
return Err(error.clone());
|
||||
}
|
||||
let content = std::fs::read(&request.source).map_err(|_| {
|
||||
gateway_admin::model::backup::BackupError::new(
|
||||
gateway_admin::model::backup::code::S3_UPLOAD_FAILED,
|
||||
"read source".to_owned(),
|
||||
)
|
||||
})?;
|
||||
self.objects
|
||||
.lock()
|
||||
.expect("objects")
|
||||
.insert(request.object_key, content);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn head_object(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
object_key: &str,
|
||||
) -> Result<Option<BackupObjectMetadata>, gateway_admin::model::backup::BackupError> {
|
||||
let Some(content) = self.object(object_key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(BackupObjectMetadata::new(
|
||||
"backup-test".to_owned(),
|
||||
sha256_hex(&content),
|
||||
Utc::now(),
|
||||
content.len() as u64,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn delete_object(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
object_key: &str,
|
||||
) -> Result<(), gateway_admin::model::backup::BackupError> {
|
||||
self.objects.lock().expect("objects").remove(object_key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn presigned_download(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
object_key: &str,
|
||||
_file_name: &str,
|
||||
_ttl: std::time::Duration,
|
||||
) -> Result<String, gateway_admin::model::backup::BackupError> {
|
||||
Ok(format!("https://presigned.example.com/{object_key}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录审计事件的假 AuthStore。
|
||||
pub(crate) struct FakeAuthStore {
|
||||
audit: Mutex<Vec<AdminAuditEvent>>,
|
||||
}
|
||||
|
||||
impl FakeAuthStore {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
audit: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn audit_actions(&self) -> Vec<String> {
|
||||
self.audit
|
||||
.lock()
|
||||
.expect("audit")
|
||||
.iter()
|
||||
.map(|event| event.action.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthStore for FakeAuthStore {
|
||||
async fn load_password_hash(&self, _admin_user_id: &str) -> AdminStoreResult<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn change_password(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: gateway_admin::model::auth::AdminAuditEvent,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn create_password_hash_if_absent(
|
||||
&self,
|
||||
_admin_user_id: &str,
|
||||
_password_hash: &str,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn load_admin_api_key(
|
||||
&self,
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::settings::AdminApiKey>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn load_session(
|
||||
&self,
|
||||
_session_id: &str,
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::auth::AuthSession>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn store_session(
|
||||
&self,
|
||||
_session_id: &str,
|
||||
_session: &gateway_admin::model::auth::AuthSession,
|
||||
) -> AdminStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_session(
|
||||
&self,
|
||||
_session_id: &str,
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::auth::AuthSession>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn client_key_enabled(
|
||||
&self,
|
||||
_: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn consume_login_attempt(
|
||||
&self,
|
||||
_: std::net::IpAddr,
|
||||
_: u32,
|
||||
_: u32,
|
||||
_: std::time::Duration,
|
||||
) -> AdminStoreResult<Option<std::time::Duration>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
self.audit.lock().expect("audit").push(event);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统发起者上下文。
|
||||
pub(crate) fn system_context() -> MutationContext {
|
||||
MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "backup-runtime-test".to_owned(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! BackupTask 执行链测试:驱动内存 fake 端口跑完整 dump/upload/verify/retention 流程。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_admin::{
|
||||
backup::task::BackupTask,
|
||||
model::backup::{
|
||||
BackupRecordSeed, BackupStatus, BackupTriggerKind, UpdateBackupScheduleCommand, code,
|
||||
},
|
||||
ports::backup::{BackupRepository, DatabaseDumpPort, DumpRequest},
|
||||
};
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
|
||||
use super::support::{
|
||||
DUMP_CONTENT, FakeBackupRepository, FakeDumpPort, FakeObjectStore, backup_id,
|
||||
configured_settings, sha256_hex, system_context,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_task_runs_full_dump_upload_verify_pipeline() {
|
||||
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
|
||||
let dump = Arc::new(FakeDumpPort::new());
|
||||
let object_store = Arc::new(FakeObjectStore::new());
|
||||
|
||||
let seed = BackupRecordSeed {
|
||||
id: backup_id("manual"),
|
||||
trigger_kind: BackupTriggerKind::Manual,
|
||||
scheduled_at: None,
|
||||
object_key: "codex/2026/08/01/manual.dump".to_owned(),
|
||||
expires_at: None,
|
||||
};
|
||||
repository
|
||||
.insert_backup_record(seed.clone())
|
||||
.await
|
||||
.expect("insert queued");
|
||||
|
||||
let task = BackupTask::new(repository.clone(), dump.clone(), object_store.clone());
|
||||
task.run_cycle(&CancellationToken::new())
|
||||
.await
|
||||
.expect("run cycle");
|
||||
|
||||
let records = repository.all_records();
|
||||
assert_eq!(records.len(), 1);
|
||||
let finished = &records[0];
|
||||
assert_eq!(finished.status, BackupStatus::Completed);
|
||||
assert_eq!(finished.size_bytes, Some(DUMP_CONTENT.len() as u64));
|
||||
assert_eq!(
|
||||
finished.sha256.as_deref(),
|
||||
Some(sha256_hex(DUMP_CONTENT).as_str())
|
||||
);
|
||||
|
||||
// 对象已上传且内容与本地归档一致。
|
||||
let stored = object_store
|
||||
.object(&seed.object_key)
|
||||
.expect("uploaded object exists");
|
||||
assert_eq!(stored.as_slice(), DUMP_CONTENT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_task_marks_failed_when_dump_port_fails() {
|
||||
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
|
||||
let dump = Arc::new(FakeDumpPort::new().fail_next_dump());
|
||||
let object_store = Arc::new(FakeObjectStore::new());
|
||||
|
||||
let seed = BackupRecordSeed {
|
||||
id: backup_id("fail"),
|
||||
trigger_kind: BackupTriggerKind::Manual,
|
||||
scheduled_at: None,
|
||||
object_key: "codex/2026/08/01/fail.dump".to_owned(),
|
||||
expires_at: None,
|
||||
};
|
||||
repository
|
||||
.insert_backup_record(seed.clone())
|
||||
.await
|
||||
.expect("insert queued");
|
||||
|
||||
let task = BackupTask::new(repository.clone(), dump.clone(), object_store.clone());
|
||||
task.run_cycle(&CancellationToken::new())
|
||||
.await
|
||||
.expect("run cycle");
|
||||
|
||||
let records = repository.all_records();
|
||||
let failed = &records[0];
|
||||
assert_eq!(failed.status, BackupStatus::Failed);
|
||||
assert_eq!(failed.error_code.as_deref(), Some("backup.pg_dump_failed"));
|
||||
assert!(failed.completed_at.is_some());
|
||||
// 归档未上传,对象不存在。
|
||||
assert!(object_store.object(&seed.object_key).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_task_marks_failed_with_the_upload_error() {
|
||||
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
|
||||
let dump = Arc::new(FakeDumpPort::new());
|
||||
let object_store =
|
||||
Arc::new(FakeObjectStore::new().fail_upload(code::S3_UPLOAD_FAILED, "对象存储拒绝上传"));
|
||||
let seed = BackupRecordSeed {
|
||||
id: backup_id("upload-failure"),
|
||||
trigger_kind: BackupTriggerKind::Manual,
|
||||
scheduled_at: None,
|
||||
object_key: "codex/2026/08/01/upload-failure.dump".to_owned(),
|
||||
expires_at: None,
|
||||
};
|
||||
repository
|
||||
.insert_backup_record(seed.clone())
|
||||
.await
|
||||
.expect("insert queued");
|
||||
|
||||
let task = BackupTask::new(repository.clone(), dump, object_store);
|
||||
task.run_cycle(&CancellationToken::new())
|
||||
.await
|
||||
.expect("run cycle");
|
||||
|
||||
let records = repository.all_records();
|
||||
let failed = &records[0];
|
||||
assert_eq!(failed.status, BackupStatus::Failed);
|
||||
assert_eq!(failed.error_code.as_deref(), Some(code::S3_UPLOAD_FAILED));
|
||||
assert_eq!(
|
||||
failed.error_message.as_deref(),
|
||||
Some("上传到对象存储失败:对象存储拒绝上传")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_task_recovery_marks_failed_with_the_upload_error() {
|
||||
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
|
||||
let dump = Arc::new(FakeDumpPort::new());
|
||||
let object_store =
|
||||
Arc::new(FakeObjectStore::new().fail_upload(code::S3_UPLOAD_FAILED, "对象存储拒绝上传"));
|
||||
let seed = BackupRecordSeed {
|
||||
id: backup_id("recovered-upload-failure"),
|
||||
trigger_kind: BackupTriggerKind::Manual,
|
||||
scheduled_at: None,
|
||||
object_key: "codex/2026/08/01/recovered-upload-failure.dump".to_owned(),
|
||||
expires_at: None,
|
||||
};
|
||||
repository
|
||||
.insert_backup_record(seed.clone())
|
||||
.await
|
||||
.expect("insert queued");
|
||||
let cancellation = CancellationToken::new();
|
||||
dump.dump(DumpRequest {
|
||||
backup_id: seed.id.clone(),
|
||||
cancellation: cancellation.clone(),
|
||||
})
|
||||
.await
|
||||
.expect("stage dump");
|
||||
repository.force_status(&seed.id, BackupStatus::Dumping);
|
||||
|
||||
let task = BackupTask::new(repository.clone(), dump, object_store);
|
||||
task.run_cycle(&cancellation).await.expect("run cycle");
|
||||
|
||||
let records = repository.all_records();
|
||||
let failed = &records[0];
|
||||
assert_eq!(failed.status, BackupStatus::Failed);
|
||||
assert_eq!(failed.error_code.as_deref(), Some(code::S3_UPLOAD_FAILED));
|
||||
assert_eq!(
|
||||
failed.error_message.as_deref(),
|
||||
Some("上传到对象存储失败:对象存储拒绝上传")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retention_cleans_expired_scheduled_backups() {
|
||||
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
|
||||
let dump = Arc::new(FakeDumpPort::new());
|
||||
let object_store = Arc::new(FakeObjectStore::new());
|
||||
|
||||
// 造一条旧 completed 计划备份(超过 retention_days)与一条新备份。
|
||||
let old_seed = BackupRecordSeed {
|
||||
id: backup_id("old"),
|
||||
trigger_kind: BackupTriggerKind::Scheduled,
|
||||
scheduled_at: Some(Utc::now() - Duration::days(40)),
|
||||
object_key: "codex/2026/06/01/old.dump".to_owned(),
|
||||
expires_at: None,
|
||||
};
|
||||
let new_seed = BackupRecordSeed {
|
||||
id: backup_id("new"),
|
||||
trigger_kind: BackupTriggerKind::Scheduled,
|
||||
scheduled_at: Some(Utc::now() - Duration::days(1)),
|
||||
object_key: "codex/2026/07/01/new.dump".to_owned(),
|
||||
expires_at: None,
|
||||
};
|
||||
repository
|
||||
.insert_scheduled_record(old_seed.clone())
|
||||
.await
|
||||
.expect("insert old");
|
||||
// 旧备份 40 天前完成(释放活跃名额),再插入新备份。
|
||||
let now = Utc::now();
|
||||
repository.set_completed(&backup_id("old"), now - Duration::days(40));
|
||||
repository
|
||||
.insert_scheduled_record(new_seed.clone())
|
||||
.await
|
||||
.expect("insert new");
|
||||
// 新备份 1 天前完成。
|
||||
repository.set_completed(&backup_id("new"), now - Duration::days(1));
|
||||
// 两个对象都已上传。
|
||||
object_store
|
||||
.objects
|
||||
.lock()
|
||||
.expect("objects")
|
||||
.insert(old_seed.object_key.clone(), DUMP_CONTENT.to_vec());
|
||||
object_store
|
||||
.objects
|
||||
.lock()
|
||||
.expect("objects")
|
||||
.insert(new_seed.object_key.clone(), DUMP_CONTENT.to_vec());
|
||||
|
||||
// 设置 retention_days = 30。
|
||||
repository
|
||||
.update_schedule_settings(
|
||||
UpdateBackupScheduleCommand {
|
||||
schedule_enabled: false,
|
||||
cron_expression: "0 2 * * *".to_owned(),
|
||||
schedule_timezone: "Asia/Shanghai".to_owned(),
|
||||
retention_days: 30,
|
||||
retention_count: 0,
|
||||
},
|
||||
None,
|
||||
&system_context(),
|
||||
)
|
||||
.await
|
||||
.expect("set retention");
|
||||
|
||||
let task = BackupTask::new(repository.clone(), dump.clone(), object_store.clone());
|
||||
task.run_cycle(&CancellationToken::new())
|
||||
.await
|
||||
.expect("run cycle");
|
||||
|
||||
// 旧备份被硬删除,对象被删除;新备份保留。
|
||||
let records = repository.all_records();
|
||||
let ids: Vec<_> = records.iter().map(|record| record.id.as_str()).collect();
|
||||
assert_eq!(ids, vec![backup_id("new").as_str()]);
|
||||
assert!(object_store.object(&old_seed.object_key).is_none());
|
||||
assert!(object_store.object(&new_seed.object_key).is_some());
|
||||
}
|
||||
@@ -1,24 +1,36 @@
|
||||
use std::str::FromStr as _;
|
||||
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use gateway_core::engine::credential::OpaqueProviderData;
|
||||
use gateway_core::account::OpaqueProviderData;
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
PageSize, Revision,
|
||||
accounts::AccountUsage,
|
||||
auth::LoginCommand,
|
||||
client_keys::ClientKeyPageSize,
|
||||
observability::{DecimalAmount, RequestOutcome, TimeRange},
|
||||
provider_credentials::{
|
||||
CredentialCommitGuard, ProviderDocument, ProviderQuota, ProviderQuotaWindow,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
settings::AdminApiKey,
|
||||
},
|
||||
ports::store::{AccountStore, AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore},
|
||||
};
|
||||
|
||||
mod backup;
|
||||
mod model;
|
||||
mod use_case;
|
||||
|
||||
#[test]
|
||||
fn client_config_should_default_to_a_one_day_session() {
|
||||
assert_eq!(
|
||||
gateway_admin::ClientConfig::default().session_ttl_minutes,
|
||||
1440
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_should_reject_zero() {
|
||||
assert!(Revision::new(0).is_err());
|
||||
@@ -84,10 +96,9 @@ fn admin_api_key_debug_should_redact_plaintext() {
|
||||
|
||||
#[test]
|
||||
fn login_command_debug_should_redact_password() {
|
||||
let command = LoginCommand {
|
||||
let command = LoginCommand::Admin {
|
||||
username: None,
|
||||
password: "secret-password".to_owned(),
|
||||
source: "127.0.0.1".to_owned(),
|
||||
};
|
||||
assert!(!format!("{command:?}").contains("secret-password"));
|
||||
}
|
||||
@@ -118,6 +129,7 @@ fn provider_document_debug_should_not_expose_opaque_material() {
|
||||
#[test]
|
||||
fn representative_quota_should_prefer_short_window_and_highest_usage() {
|
||||
let quota = ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![
|
||||
@@ -126,12 +138,126 @@ fn representative_quota_should_prefer_short_window_and_highest_usage() {
|
||||
quota_window("shortTerm", Some(18_000), Some(30.0)),
|
||||
quota_window("shortTerm", Some(14_400), Some(45.0)),
|
||||
],
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
};
|
||||
|
||||
assert_eq!(quota.representative_used_percent(), Some(45.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn representative_quota_should_prefer_account_wide_window_over_model_specific_window() {
|
||||
let mut model_specific = quota_window("shortTerm", Some(18_000), Some(64.0));
|
||||
model_specific.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
|
||||
let quota = ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![
|
||||
model_specific,
|
||||
quota_window("shortTerm", Some(604_800), Some(5.0)),
|
||||
],
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
};
|
||||
|
||||
assert_eq!(quota.representative_used_percent(), Some(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_window_should_not_use_daily_rolling_usage_for_weekly_statistics() {
|
||||
let usage = AccountUsage {
|
||||
account_id: "acct_xai".to_owned(),
|
||||
request_count: 5,
|
||||
success_count: 5,
|
||||
input_tokens: Some(100),
|
||||
output_tokens: Some(20),
|
||||
cached_tokens: Some(80),
|
||||
cache_write_tokens: Some(0),
|
||||
reasoning_tokens: Some(0),
|
||||
image_input_tokens: Some(0),
|
||||
image_output_tokens: Some(0),
|
||||
image_request_count: 0,
|
||||
image_request_failed_count: 0,
|
||||
total_tokens: Some(120),
|
||||
cost_coverage: Default::default(),
|
||||
costs: Vec::new(),
|
||||
last_used_at: Some(Utc::now()),
|
||||
request_buckets: Vec::new(),
|
||||
models: Vec::new(),
|
||||
};
|
||||
let quota = ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
key: "free-rolling-24h".to_owned(),
|
||||
group: "shortTerm".to_owned(),
|
||||
label: "日限额".to_owned(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(86_400),
|
||||
used_percent: None,
|
||||
reset_at: None,
|
||||
limit_reached: false,
|
||||
local_usage: Some(usage),
|
||||
provider_data: None,
|
||||
}],
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
};
|
||||
|
||||
assert_eq!(quota.usage_window(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
|
||||
let mut quota = ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![
|
||||
quota_window("monthly", Some(2_592_000), Some(99.0)),
|
||||
quota_window("shortTerm", Some(604_800), Some(80.0)),
|
||||
quota_window("shortTerm", Some(18_000), Some(30.0)),
|
||||
quota_window("shortTerm", Some(14_400), Some(95.0)),
|
||||
],
|
||||
limit_reached: true,
|
||||
provider_data: None,
|
||||
};
|
||||
|
||||
assert_eq!(quota.representative_used_percent(), Some(100.0));
|
||||
quota.apply_limit_reached_display();
|
||||
|
||||
assert_eq!(quota.windows[0].used_percent, Some(99.0));
|
||||
assert_eq!(quota.windows[1].used_percent, Some(80.0));
|
||||
assert_eq!(quota.windows[2].used_percent, Some(30.0));
|
||||
assert_eq!(quota.windows[3].used_percent, Some(100.0));
|
||||
assert!(quota.windows[3].limit_reached);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_quota_should_preserve_the_provider_identified_reached_window() {
|
||||
let mut reached = quota_window("monthly", Some(2_592_000), Some(98.0));
|
||||
reached.limit_reached = true;
|
||||
let mut quota = ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![reached, quota_window("shortTerm", Some(18_000), Some(95.0))],
|
||||
limit_reached: true,
|
||||
provider_data: None,
|
||||
};
|
||||
|
||||
quota.apply_limit_reached_display();
|
||||
|
||||
assert_eq!(quota.windows[0].used_percent, Some(100.0));
|
||||
assert_eq!(quota.windows[1].used_percent, Some(95.0));
|
||||
assert!(!quota.windows[1].limit_reached);
|
||||
}
|
||||
|
||||
fn quota_window(
|
||||
group: &str,
|
||||
window_seconds: Option<u64>,
|
||||
@@ -141,10 +267,14 @@ fn quota_window(
|
||||
key: group.to_owned(),
|
||||
group: group.to_owned(),
|
||||
label: group.to_owned(),
|
||||
source: None,
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds,
|
||||
used_percent,
|
||||
reset_at: None,
|
||||
limit_reached: false,
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! 备份领域模型(model/backup)的纯逻辑测试。
|
||||
|
||||
use chrono::{TimeZone as _, Utc};
|
||||
use secrecy::SecretString;
|
||||
|
||||
use gateway_admin::model::backup::{
|
||||
BackupSettings, BackupStatus, BackupStorageConfig, BackupTriggerKind, build_object_key,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn status_machine_rejects_invalid_transitions() {
|
||||
assert!(BackupStatus::Queued.allows_transition_to(BackupStatus::Dumping));
|
||||
assert!(BackupStatus::Dumping.allows_transition_to(BackupStatus::Uploading));
|
||||
assert!(BackupStatus::Uploading.allows_transition_to(BackupStatus::Completed));
|
||||
assert!(BackupStatus::Completed.allows_transition_to(BackupStatus::Deleting));
|
||||
assert!(!BackupStatus::Queued.allows_transition_to(BackupStatus::Completed));
|
||||
assert!(!BackupStatus::Completed.allows_transition_to(BackupStatus::Dumping));
|
||||
assert!(!BackupStatus::Deleting.allows_transition_to(BackupStatus::Failed));
|
||||
assert!(!BackupStatus::Deleting.allows_transition_to(BackupStatus::Completed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_classification() {
|
||||
assert!(BackupStatus::Queued.is_active());
|
||||
assert!(BackupStatus::Uploading.is_active());
|
||||
assert!(!BackupStatus::Completed.is_active());
|
||||
assert!(BackupStatus::Completed.can_be_deleted());
|
||||
assert!(BackupStatus::Failed.can_be_deleted());
|
||||
assert!(!BackupStatus::Queued.can_be_deleted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_key_shape() {
|
||||
let at = Utc.with_ymd_and_hms(2026, 8, 1, 12, 30, 0).unwrap();
|
||||
let key = build_object_key("production/backups", "backup_abc", at).unwrap();
|
||||
assert_eq!(
|
||||
key,
|
||||
"production/backups/2026/08/01/codex-proxy-rs_20260801_123000_abc.dump"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_key_rejects_dangerous_prefixes() {
|
||||
let at = Utc::now();
|
||||
assert!(build_object_key("/leading", "backup_x", at).is_err());
|
||||
assert!(build_object_key("a/../b", "backup_x", at).is_err());
|
||||
assert!(build_object_key("a\\b", "backup_x", at).is_err());
|
||||
assert!(build_object_key("a\u{0001}b", "backup_x", at).is_err());
|
||||
assert!(build_object_key("", "backup_x", at).is_err());
|
||||
assert!(build_object_key("trailing//", "backup_x", at).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_and_status_roundtrip() {
|
||||
for status in [
|
||||
BackupStatus::Queued,
|
||||
BackupStatus::Dumping,
|
||||
BackupStatus::Uploading,
|
||||
BackupStatus::Completed,
|
||||
BackupStatus::Failed,
|
||||
BackupStatus::Deleting,
|
||||
] {
|
||||
assert_eq!(BackupStatus::parse(status.as_str()), Some(status));
|
||||
}
|
||||
assert_eq!(
|
||||
BackupTriggerKind::parse("manual"),
|
||||
Some(BackupTriggerKind::Manual)
|
||||
);
|
||||
assert_eq!(
|
||||
BackupTriggerKind::parse("scheduled"),
|
||||
Some(BackupTriggerKind::Scheduled)
|
||||
);
|
||||
assert_eq!(BackupTriggerKind::parse("unknown"), None);
|
||||
assert_eq!(BackupStatus::parse("skipped"), None);
|
||||
assert_eq!(BackupStatus::parse("deleted"), None);
|
||||
assert_eq!(BackupStatus::parse("unknown"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_from_settings_redacts_secret_in_debug() {
|
||||
let mut settings = BackupSettings {
|
||||
storage_revision: 3,
|
||||
endpoint: Some("https://example.com".to_owned()),
|
||||
region: Some("auto".to_owned()),
|
||||
bucket: Some("b".to_owned()),
|
||||
access_key_id: Some("ak".to_owned()),
|
||||
secret_access_key: Some(SecretString::from("sk-secret")),
|
||||
prefix: Some("p".to_owned()),
|
||||
force_path_style: false,
|
||||
schedule_enabled: false,
|
||||
cron_expression: None,
|
||||
schedule_timezone: None,
|
||||
retention_days: 0,
|
||||
retention_count: 0,
|
||||
next_run_at: None,
|
||||
last_verified_at: None,
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
assert!(!format!("{:?}", settings).contains("sk-secret"));
|
||||
let config = BackupStorageConfig::from_settings(&settings).unwrap();
|
||||
assert!(!format!("{config:?}").contains("sk-secret"));
|
||||
settings.endpoint = None;
|
||||
assert!(BackupStorageConfig::from_settings(&settings).is_none());
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! 模型模块测试:镜像 src/model/ 中带逻辑的模块。
|
||||
|
||||
mod backup;
|
||||
mod provider_credentials;
|
||||
mod quota_forecast;
|
||||
mod quota_forecast_sampling;
|
||||
@@ -0,0 +1,272 @@
|
||||
use gateway_admin::model::provider_credentials::{
|
||||
AccountUsagePeriod, AuthorizationMutationTarget, AuthorizationOwnerBinding,
|
||||
PendingAuthorizationMutation, ProviderQuota, ProviderQuotaWindow, ProviderQuotaWindowRole,
|
||||
QuotaLocalUsageAttribution,
|
||||
};
|
||||
use gateway_admin::model::{MutationActor, MutationContext};
|
||||
use gateway_core::account::ProviderAccountId;
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn usage_window_should_prefer_weekly_without_changing_dashboard_selection() {
|
||||
let quota = usage_quota(vec![
|
||||
usage_window("short", "shortTerm", 18_000),
|
||||
usage_window("month", "monthly", 30 * 86_400),
|
||||
usage_window("week", "shortTerm", 7 * 86_400),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
selected_usage_window("a),
|
||||
Some(("week", AccountUsagePeriod::Weekly))
|
||||
);
|
||||
assert_eq!(
|
||||
quota
|
||||
.representative_window()
|
||||
.map(|window| window.key.as_str()),
|
||||
Some("short")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_window_should_use_monthly_when_weekly_is_unavailable() {
|
||||
let mut model_week = usage_window("model-week", "shortTerm", 7 * 86_400);
|
||||
model_week.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
|
||||
let mut incomplete_week = usage_window("incomplete-week", "shortTerm", 7 * 86_400);
|
||||
incomplete_week.reset_at = None;
|
||||
let mut unknown_usage = usage_window("unknown-week", "shortTerm", 7 * 86_400);
|
||||
unknown_usage.local_usage = None;
|
||||
let quota = usage_quota(vec![
|
||||
model_week,
|
||||
incomplete_week,
|
||||
unknown_usage,
|
||||
usage_window("month", "monthly", 30 * 86_400),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
selected_usage_window("a),
|
||||
Some(("month", AccountUsagePeriod::Monthly))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_window_should_not_fallback_to_short_daily_or_incomplete_windows() {
|
||||
let mut missing_duration = usage_window("unknown-month", "monthly", 30 * 86_400);
|
||||
missing_duration.window_seconds = None;
|
||||
let quota = usage_quota(vec![
|
||||
usage_window("short", "shortTerm", 18_000),
|
||||
usage_window("day", "shortTerm", 86_400),
|
||||
usage_window("invalid-month", "monthly", 86_400),
|
||||
missing_duration,
|
||||
]);
|
||||
|
||||
assert_eq!(quota.usage_window(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_window_should_preserve_provider_order_for_equal_periods() {
|
||||
let quota = usage_quota(vec![
|
||||
usage_window("first-week", "shortTerm", 7 * 86_400),
|
||||
usage_window("second-week", "shortTerm", 7 * 86_400),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
selected_usage_window("a),
|
||||
Some(("first-week", AccountUsagePeriod::Weekly))
|
||||
);
|
||||
}
|
||||
|
||||
fn selected_usage_window(quota: &ProviderQuota) -> Option<(&str, AccountUsagePeriod)> {
|
||||
quota
|
||||
.usage_window()
|
||||
.map(|(window, period)| (window.key.as_str(), period))
|
||||
}
|
||||
|
||||
fn usage_quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows,
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_window(key: &str, group: &str, seconds: u64) -> ProviderQuotaWindow {
|
||||
ProviderQuotaWindow {
|
||||
key: key.to_owned(),
|
||||
group: group.to_owned(),
|
||||
label: key.to_owned(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(seconds),
|
||||
used_percent: Some(50.0),
|
||||
reset_at: Some(chrono::Utc::now()),
|
||||
limit_reached: false,
|
||||
local_usage: Some(gateway_admin::model::accounts::AccountUsage {
|
||||
account_id: "acct_test".to_owned(),
|
||||
request_count: 0,
|
||||
success_count: 0,
|
||||
input_tokens: None,
|
||||
output_tokens: None,
|
||||
cached_tokens: None,
|
||||
cache_write_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
image_input_tokens: None,
|
||||
image_output_tokens: None,
|
||||
image_request_count: 0,
|
||||
image_request_failed_count: 0,
|
||||
total_tokens: None,
|
||||
cost_coverage: Default::default(),
|
||||
costs: Vec::new(),
|
||||
last_used_at: None,
|
||||
request_buckets: Vec::new(),
|
||||
models: Vec::new(),
|
||||
}),
|
||||
provider_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashboard_quota_should_preserve_unknown_and_actual_window_facts() {
|
||||
let mut quota = ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: Vec::new(),
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
};
|
||||
assert!(quota.representative_window().is_none());
|
||||
assert!(quota.representative_used_percent().is_none());
|
||||
|
||||
let rolling = ProviderQuotaWindow {
|
||||
key: "provider_rolling".to_owned(),
|
||||
group: "requests".to_owned(),
|
||||
label: "Provider rolling window".to_owned(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: Some(ProviderQuotaWindowRole::Primary),
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(86_400),
|
||||
used_percent: None,
|
||||
reset_at: None,
|
||||
limit_reached: false,
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
};
|
||||
quota.windows.push(rolling.clone());
|
||||
assert_eq!(quota.representative_window(), Some(&rolling));
|
||||
assert!(quota.representative_used_percent().is_none());
|
||||
|
||||
quota.windows[0].used_percent = Some(99.6);
|
||||
quota.apply_limit_reached_display();
|
||||
let actual = quota.representative_window().expect("actual window");
|
||||
assert_eq!(actual.used_percent, Some(99.6));
|
||||
assert!(
|
||||
!actual.limit_reached,
|
||||
"display rounding cannot exhaust quota"
|
||||
);
|
||||
assert_eq!(actual.window_seconds, Some(86_400));
|
||||
assert_eq!(actual.reset_at, None);
|
||||
|
||||
quota.windows.insert(
|
||||
0,
|
||||
ProviderQuotaWindow {
|
||||
key: "model_specific".to_owned(),
|
||||
used_percent: Some(100.0),
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::Unavailable,
|
||||
..rolling
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
quota
|
||||
.representative_window()
|
||||
.map(|window| window.key.as_str()),
|
||||
Some("provider_rolling")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_mutation_v1_should_round_trip_all_targets_and_owners() {
|
||||
for provider in ["openai", "xai"] {
|
||||
for target in [
|
||||
AuthorizationMutationTarget::Create {
|
||||
name: "account".to_owned(),
|
||||
},
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id: ProviderAccountId::new("acct_1").expect("account id"),
|
||||
},
|
||||
] {
|
||||
for actor in [
|
||||
MutationActor::AdminSession {
|
||||
admin_user_id: "admin-1".to_owned(),
|
||||
},
|
||||
MutationActor::AdminApiKey,
|
||||
MutationActor::System,
|
||||
] {
|
||||
let expected = PendingAuthorizationMutation::new(
|
||||
ProviderKind::new(provider).expect("provider"),
|
||||
target.clone(),
|
||||
AuthorizationOwnerBinding::from_context(&MutationContext {
|
||||
actor,
|
||||
request_id: "request-1".to_owned(),
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
PendingAuthorizationMutation::from_storage_v1(Value::Object(
|
||||
expected.to_storage_v1()
|
||||
)),
|
||||
Ok(expected.clone()),
|
||||
);
|
||||
let with_proxy = expected
|
||||
.with_outbound_proxy(Some(
|
||||
gateway_core::account::OutboundProxy::parse(
|
||||
"http://user:secret@proxy.example:8080",
|
||||
)
|
||||
.unwrap(),
|
||||
))
|
||||
.with_outbound_proxy_id(Some("proxy_saved".to_owned()));
|
||||
assert_eq!(
|
||||
PendingAuthorizationMutation::from_storage_v1(Value::Object(
|
||||
with_proxy.to_storage_v1()
|
||||
)),
|
||||
Ok(with_proxy)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_mutation_v1_should_keep_existing_storage_fields_and_reject_invalid_documents() {
|
||||
let legacy = json!({
|
||||
"provider_kind": "openai",
|
||||
"target": { "kind": "reauthorize", "account_id": "acct_1" },
|
||||
"owner": { "kind": "admin_session", "admin_user_id": "admin-1" },
|
||||
"started_request_id": "request-1"
|
||||
});
|
||||
let restored =
|
||||
PendingAuthorizationMutation::from_storage_v1(legacy.clone()).expect("v1 document");
|
||||
assert_eq!(Value::Object(restored.to_storage_v1()), legacy);
|
||||
for (pointer, invalid) in [
|
||||
("/provider_kind", json!("")),
|
||||
("/target/account_id", json!("")),
|
||||
("/target/kind", json!("other")),
|
||||
("/owner/kind", json!("other")),
|
||||
("/started_request_id", json!(null)),
|
||||
] {
|
||||
let mut document = legacy.clone();
|
||||
*document.pointer_mut(pointer).expect("field") = invalid;
|
||||
assert!(
|
||||
PendingAuthorizationMutation::from_storage_v1(document).is_err(),
|
||||
"{pointer}"
|
||||
);
|
||||
}
|
||||
let mut document = legacy;
|
||||
document["schema_version"] = json!(2);
|
||||
assert!(PendingAuthorizationMutation::from_storage_v1(document).is_err());
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use gateway_admin::model::{
|
||||
accounts::{AccountCost, AccountUsage},
|
||||
observability::CostCoverage,
|
||||
provider_credentials::{ProviderQuota, ProviderQuotaWindow, QuotaLocalUsageAttribution},
|
||||
quota_forecast::{AccountQuotaForecast, account_quota_forecasts},
|
||||
quota_forecast_sampling::{QuotaForecastMethod, QuotaForecastSample, QuotaForecastUsage},
|
||||
};
|
||||
|
||||
fn now() -> DateTime<Utc> {
|
||||
"2026-09-12T00:00:00Z".parse().unwrap()
|
||||
}
|
||||
|
||||
fn window(key: &str, days: u64) -> ProviderQuotaWindow {
|
||||
ProviderQuotaWindow {
|
||||
key: key.to_owned(),
|
||||
group: if days > 7 { "monthly" } else { "shortTerm" }.to_owned(),
|
||||
label: key.to_owned(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(days * 86_400),
|
||||
used_percent: Some(20.0),
|
||||
reset_at: Some(now() + Duration::days(1)),
|
||||
limit_reached: false,
|
||||
local_usage: Some(AccountUsage {
|
||||
account_id: "acct_forecast".to_owned(),
|
||||
request_count: 10,
|
||||
success_count: 10,
|
||||
input_tokens: Some(900),
|
||||
output_tokens: Some(100),
|
||||
cached_tokens: Some(800),
|
||||
cache_write_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
image_input_tokens: None,
|
||||
image_output_tokens: None,
|
||||
image_request_count: 0,
|
||||
image_request_failed_count: 0,
|
||||
total_tokens: Some(1_000),
|
||||
cost_coverage: CostCoverage {
|
||||
calculated_count: 10,
|
||||
..Default::default()
|
||||
},
|
||||
costs: vec![AccountCost {
|
||||
currency: "USD".to_owned(),
|
||||
amount: "2".parse().unwrap(),
|
||||
}],
|
||||
last_used_at: Some(now()),
|
||||
request_buckets: Vec::new(),
|
||||
models: Vec::new(),
|
||||
}),
|
||||
provider_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: Some(now()),
|
||||
refresh_token_expires_at: None,
|
||||
windows,
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn forecast(quota: &ProviderQuota) -> [AccountQuotaForecast; 2] {
|
||||
account_quota_forecasts(quota, now() - Duration::days(60), now(), &samples(quota))
|
||||
}
|
||||
|
||||
fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
|
||||
quota
|
||||
.windows
|
||||
.iter()
|
||||
.filter_map(|window| {
|
||||
let usage = window.local_usage.as_ref()?;
|
||||
let usd = usage.costs.iter().find(|cost| cost.currency == "USD");
|
||||
Some(QuotaForecastSample {
|
||||
key: window.key.clone(),
|
||||
method: QuotaForecastMethod::Cumulative,
|
||||
start_at: window.reset_at?.checked_sub_signed(Duration::try_seconds(
|
||||
i64::try_from(window.window_seconds?).ok()?,
|
||||
)?)?,
|
||||
end_at: quota.observed_at.unwrap_or(now()),
|
||||
baseline_percent: 0.0,
|
||||
sampled_percent: window.used_percent.unwrap_or(0.0),
|
||||
block_count: 0,
|
||||
observation_count: 0,
|
||||
pending_request_count: 0,
|
||||
discontinuous: false,
|
||||
usage: QuotaForecastUsage {
|
||||
request_count: usage.request_count,
|
||||
tokens: usage.total_tokens.unwrap_or(0),
|
||||
input_tokens: usage.input_tokens.unwrap_or(0),
|
||||
output_tokens: usage.output_tokens.unwrap_or(0),
|
||||
cached_tokens: usage.cached_tokens.unwrap_or(0),
|
||||
missing_token_count: u64::from(usage.total_tokens.is_none()),
|
||||
known_cost_count: if usd.is_some() {
|
||||
usage.cost_coverage.known_count()
|
||||
} else {
|
||||
0
|
||||
},
|
||||
unavailable_cost_count: usage.cost_coverage.partial_count
|
||||
+ usage.cost_coverage.unavailable_count,
|
||||
usd: usd.map_or(0.0, |cost| cost.amount.as_str().parse().unwrap()),
|
||||
excluded_request_count: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecasts_use_actual_periods_and_do_not_extrapolate_remaining_capacity() {
|
||||
let [week, month] = forecast("a(vec![window("week", 7)]));
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert!(!week.extrapolated);
|
||||
assert!(month.extrapolated);
|
||||
assert_eq!(month.estimated_tokens, Some(21_429));
|
||||
assert!((month.estimated_usd.unwrap() - 10.0 * 30.0 / 7.0).abs() < 1e-10);
|
||||
assert_eq!(month.remaining_tokens, week.remaining_tokens);
|
||||
assert_eq!(month.remaining_usd, week.remaining_usd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corresponding_actual_window_takes_priority_and_retains_its_duration() {
|
||||
let [week, month] = forecast("a(vec![window("month", 31), window("week", 7)]));
|
||||
assert_eq!(week.source.unwrap().label, "week");
|
||||
assert_eq!(month.source.unwrap().label, "month");
|
||||
assert!(!month.extrapolated);
|
||||
assert_eq!(month.target_seconds, 31 * 86_400);
|
||||
assert_eq!(month.estimated_tokens, Some(5_000));
|
||||
let [week, _] = forecast("a(vec![window("month", 31)]));
|
||||
assert!(week.extrapolated);
|
||||
assert_eq!(week.estimated_tokens, Some(1_129));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_or_model_specific_windows_are_not_account_capacity() {
|
||||
let mut model = window("model", 7);
|
||||
model.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
|
||||
for item in forecast("a(vec![window("day", 1), model])) {
|
||||
assert!(item.source.is_none());
|
||||
assert!(item.unavailable_reason.is_some());
|
||||
assert!(item.estimated_tokens.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_or_tiny_percentages_cannot_produce_estimates() {
|
||||
for percent in [
|
||||
None,
|
||||
Some(f64::NAN),
|
||||
Some(f64::INFINITY),
|
||||
Some(-1.0),
|
||||
Some(101.0),
|
||||
Some(0.0),
|
||||
Some(0.5),
|
||||
Some(1.0),
|
||||
Some(4.9),
|
||||
] {
|
||||
let mut sample = window("week", 7);
|
||||
sample.used_percent = percent;
|
||||
let [week, _] = forecast("a(vec![sample]));
|
||||
assert!(week.unavailable_reason.is_some(), "{percent:?}");
|
||||
assert!(week.estimated_tokens.is_none());
|
||||
assert!(week.estimated_usd.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_samples_warn_and_exhaustion_uses_raw_not_display_percentages() {
|
||||
let mut sample = window("week", 7);
|
||||
sample.used_percent = Some(5.0);
|
||||
sample.limit_reached = true;
|
||||
let mut source = quota(vec![sample]);
|
||||
source.limit_reached = true;
|
||||
let [week, _] = forecast(&source);
|
||||
assert!(week.low_sample);
|
||||
assert_eq!(week.estimated_tokens, Some(20_000));
|
||||
source.windows[0].used_percent = Some(100.0);
|
||||
let [week, _] = forecast(&source);
|
||||
assert!(!week.low_sample);
|
||||
assert_eq!(week.remaining_tokens, Some(0));
|
||||
assert_eq!(week.remaining_usd, Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_future_or_missing_snapshots_do_not_predict() {
|
||||
for observed in [
|
||||
None,
|
||||
Some(now() - Duration::days(8)),
|
||||
Some(now() + Duration::seconds(1)),
|
||||
] {
|
||||
let mut source = quota(vec![window("week", 7)]);
|
||||
source.observed_at = observed;
|
||||
assert!(forecast(&source)[0].unavailable_reason.is_some());
|
||||
}
|
||||
let mut sample = window("week", 7);
|
||||
sample.reset_at = Some(now());
|
||||
assert!(
|
||||
forecast("a(vec![sample]))[0]
|
||||
.unavailable_reason
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_account_history_or_missing_usage_keep_diagnostics_without_estimates() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let [week, _] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(1), now(), &samples(&source));
|
||||
assert!(week.unavailable_reason.unwrap().contains("记录不完整"));
|
||||
assert!(week.estimated_tokens.is_none());
|
||||
let mut sample = window("week", 7);
|
||||
sample.local_usage = None;
|
||||
let [week, _] = forecast("a(vec![sample]));
|
||||
assert!(week.source.is_some());
|
||||
assert!(
|
||||
week.unavailable_reason
|
||||
.unwrap()
|
||||
.contains("没有网关用量记录")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_percent() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.method = QuotaForecastMethod::Incremental;
|
||||
sample.start_at = now() - Duration::hours(6);
|
||||
sample.baseline_percent = 10.0;
|
||||
sample.sampled_percent = 10.0;
|
||||
sample.block_count = 2;
|
||||
let [week, month] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(1), now(), &[sample]);
|
||||
assert_eq!(week.estimated_tokens, Some(10_000));
|
||||
assert_eq!(week.remaining_tokens, Some(8_000));
|
||||
assert_eq!(month.remaining_tokens, Some(8_000));
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert!(!week.low_sample);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_tokens_keep_estimates_from_recorded_usage() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.usage.missing_token_count = 1;
|
||||
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.incomplete_tokens);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.source.unwrap().tokens, Some(1_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discontinuous_samples_never_fall_back_to_cumulative_predictions() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.discontinuous = true;
|
||||
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.unavailable_reason.unwrap().contains("不连续"));
|
||||
assert!(week.estimated_tokens.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_costs_keep_estimates_from_known_amounts() {
|
||||
for coverage in [
|
||||
CostCoverage {
|
||||
calculated_count: 9,
|
||||
partial_count: 1,
|
||||
..Default::default()
|
||||
},
|
||||
CostCoverage {
|
||||
calculated_count: 9,
|
||||
unavailable_count: 1,
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
let mut sample = window("week", 7);
|
||||
sample.local_usage.as_mut().unwrap().cost_coverage = coverage;
|
||||
let [week, _] = forecast("a(vec![sample]));
|
||||
assert!(week.incomplete_cost);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimates() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
// 完整请求之外混有缺少计量的成功请求,不把已有用量和费用一并作废。
|
||||
sample.usage.request_count += 2;
|
||||
sample.usage.missing_token_count = 2;
|
||||
sample.usage.unavailable_cost_count = 2;
|
||||
sample.usage.excluded_request_count = 1;
|
||||
sample.pending_request_count = 1;
|
||||
let [week, month] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.incomplete_tokens);
|
||||
assert!(week.incomplete_cost);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert_eq!(month.estimated_tokens, Some(21_429));
|
||||
assert_eq!(month.remaining_tokens, week.remaining_tokens);
|
||||
assert_eq!(month.remaining_usd, week.remaining_usd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entirely_unknown_costs_leave_only_money_estimates_unavailable() {
|
||||
let mut sample = window("week", 7);
|
||||
let usage = sample.local_usage.as_mut().unwrap();
|
||||
usage.cost_coverage = CostCoverage {
|
||||
unavailable_count: usage.request_count,
|
||||
..Default::default()
|
||||
};
|
||||
usage.costs.clear();
|
||||
let [week, _] = forecast("a(vec![sample]));
|
||||
assert!(week.incomplete_cost);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, None);
|
||||
assert_eq!(week.remaining_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.usage = QuotaForecastUsage {
|
||||
request_count: 2,
|
||||
missing_token_count: 2,
|
||||
unavailable_cost_count: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.unavailable_reason.is_some());
|
||||
assert_eq!(week.estimated_tokens, None);
|
||||
assert_eq!(week.estimated_usd, None);
|
||||
assert_eq!(week.remaining_tokens, None);
|
||||
assert_eq!(week.remaining_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_known_cost_is_valid_but_missing_usd_is_not_invented() {
|
||||
let mut sample = window("week", 7);
|
||||
sample.local_usage.as_mut().unwrap().costs[0].amount = "0".parse().unwrap();
|
||||
assert_eq!(
|
||||
forecast("a(vec![sample.clone()]))[0].estimated_usd,
|
||||
Some(0.0)
|
||||
);
|
||||
sample.local_usage.as_mut().unwrap().costs[0].currency = "EUR".to_owned();
|
||||
assert_eq!(forecast("a(vec![sample]))[0].estimated_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_window_or_estimate_is_unavailable_without_panics_or_saturation() {
|
||||
let mut sample = window("month", 30);
|
||||
sample.window_seconds = Some(i64::MAX as u64);
|
||||
assert!(
|
||||
forecast("a(vec![sample]))[1]
|
||||
.unavailable_reason
|
||||
.is_some()
|
||||
);
|
||||
let mut sample = window("week", 7);
|
||||
sample.local_usage.as_mut().unwrap().total_tokens = Some(u64::MAX);
|
||||
let [week, _] = forecast("a(vec![sample]));
|
||||
assert!(week.estimated_tokens.is_none());
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use gateway_admin::model::quota_forecast_sampling::{
|
||||
QuotaForecastMethod, QuotaForecastPoint, QuotaForecastSample, QuotaForecastUsage,
|
||||
select_forecast_sample,
|
||||
};
|
||||
|
||||
fn time(minutes: i64) -> DateTime<Utc> {
|
||||
"2026-09-12T00:00:00Z".parse::<DateTime<Utc>>().unwrap() + Duration::minutes(minutes)
|
||||
}
|
||||
|
||||
fn point(minute: i64, percent: f64, tokens: u64) -> QuotaForecastPoint {
|
||||
QuotaForecastPoint {
|
||||
observed_at: time(minute),
|
||||
used_percent: percent,
|
||||
usage: QuotaForecastUsage {
|
||||
request_count: minute as u64,
|
||||
tokens,
|
||||
input_tokens: tokens,
|
||||
known_cost_count: minute as u64,
|
||||
usd: tokens as f64 / 1_000.0,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn select(points: Vec<QuotaForecastPoint>, current: QuotaForecastPoint) -> QuotaForecastSample {
|
||||
select_forecast_sample("week".to_owned(), time(0), current, points, 2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_readings_do_not_make_independent_blocks_or_shift_the_baseline() {
|
||||
let points = (1..100)
|
||||
.map(|minute| point(minute, 20.0, minute as u64 * 100))
|
||||
.collect();
|
||||
let sample = select(points, point(100, 25.0, 10_000));
|
||||
assert_eq!(sample.method, QuotaForecastMethod::Incremental);
|
||||
assert_eq!(sample.block_count, 1);
|
||||
assert_eq!(sample.start_at, time(1));
|
||||
assert_eq!(sample.sampled_percent, 5.0);
|
||||
assert_eq!(sample.usage.tokens, 9_900);
|
||||
assert_eq!(sample.pending_request_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_blocks_and_tail_replace_old_workload_without_averaging_individual_ratios() {
|
||||
let points = vec![
|
||||
point(1, 0.0, 0),
|
||||
point(2, 5.0, 100),
|
||||
point(3, 10.0, 200),
|
||||
point(4, 15.0, 400),
|
||||
point(5, 20.0, 600),
|
||||
point(6, 25.0, 800),
|
||||
];
|
||||
let sample = select(points, point(7, 27.0, 900));
|
||||
assert_eq!(sample.baseline_percent, 10.0);
|
||||
assert_eq!(sample.sampled_percent, 17.0);
|
||||
assert_eq!(sample.usage.tokens, 700);
|
||||
assert_eq!(sample.block_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_at_a_block_boundary_keeps_only_three_recent_blocks() {
|
||||
let points = (0..6)
|
||||
.map(|i| point(i + 1, i as f64 * 5.0, i as u64 * 100))
|
||||
.collect();
|
||||
let sample = select(points, point(7, 30.0, 600));
|
||||
assert_eq!(sample.baseline_percent, 15.0);
|
||||
assert_eq!(sample.sampled_percent, 15.0);
|
||||
assert_eq!(sample.usage.tokens, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_percentage_reversal_restarts_sampling_without_crossing_old_consumption() {
|
||||
let sample = select(
|
||||
vec![point(1, 40.0, 100), point(2, 50.0, 200), point(3, 1.0, 210)],
|
||||
point(4, 8.0, 280),
|
||||
);
|
||||
assert_eq!(sample.baseline_percent, 1.0);
|
||||
assert_eq!(sample.usage.tokens, 70);
|
||||
assert_eq!(sample.sampled_percent, 7.0);
|
||||
assert!(!sample.discontinuous);
|
||||
let waiting = select(
|
||||
vec![point(1, 40.0, 100), point(2, 50.0, 200)],
|
||||
point(3, 2.0, 210),
|
||||
);
|
||||
assert!(waiting.discontinuous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_point_reversal_is_skipped_without_counting_an_extra_block() {
|
||||
let sample = select(
|
||||
vec![
|
||||
point(1, 10.0, 100),
|
||||
point(2, 15.0, 200),
|
||||
point(3, 14.0, 210),
|
||||
],
|
||||
point(4, 20.0, 300),
|
||||
);
|
||||
assert_eq!(sample.block_count, 2);
|
||||
assert_eq!(sample.usage.tokens, 200);
|
||||
assert!(!sample.discontinuous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_token_and_cost_counts_are_local_to_the_selected_interval() {
|
||||
let mut baseline = point(1, 30.0, 1_000);
|
||||
baseline.usage.missing_token_count = 2;
|
||||
baseline.usage.unavailable_cost_count = 3;
|
||||
let mut end = point(3, 40.0, 2_000);
|
||||
end.usage.missing_token_count = 2;
|
||||
end.usage.unavailable_cost_count = 4;
|
||||
let sample = select(vec![baseline], end);
|
||||
assert_eq!(sample.usage.missing_token_count, 0);
|
||||
assert_eq!(sample.usage.unavailable_cost_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counter_regressions_fail_closed_and_future_points_are_not_used() {
|
||||
let invalid = select(vec![point(1, 10.0, 1_000)], point(2, 20.0, 500));
|
||||
assert!(invalid.discontinuous);
|
||||
let future = select(vec![point(10, 1.0, 1)], point(2, 20.0, 500));
|
||||
assert_eq!(future.method, QuotaForecastMethod::Cumulative);
|
||||
assert_eq!(future.observation_count, 0);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
str::FromStr as _,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
MutationContext, PageSize, Revision,
|
||||
account_groups::{
|
||||
AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupColor,
|
||||
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage,
|
||||
AccountGroupRecord, AccountGroupUsage, DeleteAccountGroup, NewAccountGroup,
|
||||
SetAccountGroupEnabled, UpdateAccountGroup,
|
||||
},
|
||||
accounts::AccountRuntimeSnapshot,
|
||||
observability::DecimalAmount,
|
||||
},
|
||||
ports::store::{
|
||||
AccountGroupStore, AccountRuntimeStore, AdminStoreError, AdminStoreErrorKind,
|
||||
AdminStoreResult,
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
account::{AccountStatusFacts, CredentialState, QuotaState},
|
||||
routing::AccountGroupId,
|
||||
};
|
||||
|
||||
use super::AdminHarness;
|
||||
|
||||
const GROUP_ID: &str = "grp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_query_service_enriches_only_current_page_members_with_runtime_facts() {
|
||||
let groups = Arc::new(FakeGroupStore::default());
|
||||
let runtime = Arc::new(FakeRuntimeStore::default());
|
||||
let service = AdminHarness::new()
|
||||
.account_groups(groups.clone())
|
||||
.account_runtime(runtime.clone())
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let page = service
|
||||
.account_groups()
|
||||
.list(AccountGroupListQuery {
|
||||
page: 1,
|
||||
page_size: PageSize::new(20).expect("page size"),
|
||||
search: None,
|
||||
enabled: None,
|
||||
})
|
||||
.await
|
||||
.expect("list projected account groups");
|
||||
|
||||
assert_eq!(
|
||||
groups
|
||||
.requested_groups
|
||||
.lock()
|
||||
.expect("requested groups")
|
||||
.as_slice(),
|
||||
[GROUP_ID]
|
||||
);
|
||||
assert_eq!(
|
||||
runtime
|
||||
.requested_accounts
|
||||
.lock()
|
||||
.expect("requested accounts")
|
||||
.as_slice(),
|
||||
["acct_available", "acct_limited"]
|
||||
);
|
||||
let group = &page.items[0];
|
||||
assert_eq!(
|
||||
group.account_summary,
|
||||
AccountGroupAccountSummary {
|
||||
available: 1,
|
||||
limited: 1,
|
||||
total: 2,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
group.capacity,
|
||||
AccountGroupCapacity {
|
||||
used_slots: Some(2),
|
||||
total_slots: 4,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeGroupStore {
|
||||
requested_groups: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountGroupStore for FakeGroupStore {
|
||||
async fn list_account_groups(
|
||||
&self,
|
||||
query: AccountGroupListQuery,
|
||||
) -> AdminStoreResult<AccountGroupPage> {
|
||||
Ok(AccountGroupPage {
|
||||
config_revision: Revision::new(1).expect("revision"),
|
||||
items: vec![group_record()],
|
||||
total: 1,
|
||||
page: query.page,
|
||||
page_size: query.page_size.get(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_account_group_members(
|
||||
&self,
|
||||
group_ids: &[AccountGroupId],
|
||||
) -> AdminStoreResult<Vec<AccountGroupMemberFact>> {
|
||||
*self.requested_groups.lock().expect("requested groups") = group_ids
|
||||
.iter()
|
||||
.map(|group_id| group_id.as_str().to_owned())
|
||||
.collect();
|
||||
Ok(vec![member("acct_available", 4), member("acct_limited", 3)])
|
||||
}
|
||||
|
||||
async fn create_account_group(
|
||||
&self,
|
||||
_: NewAccountGroup,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn update_account_group(
|
||||
&self,
|
||||
_: UpdateAccountGroup,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn set_account_group_enabled(
|
||||
&self,
|
||||
_: SetAccountGroupEnabled,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn delete_account_group(
|
||||
&self,
|
||||
_: DeleteAccountGroup,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation> {
|
||||
Err(unused())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeRuntimeStore {
|
||||
requested_accounts: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountRuntimeStore for FakeRuntimeStore {
|
||||
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
Ok(AccountRuntimeSnapshot::default())
|
||||
}
|
||||
|
||||
async fn account_runtime(
|
||||
&self,
|
||||
account_ids: &[String],
|
||||
) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
*self.requested_accounts.lock().expect("requested accounts") = account_ids.to_vec();
|
||||
Ok(AccountRuntimeSnapshot {
|
||||
cooldown: BTreeMap::from([(
|
||||
"acct_limited".to_owned(),
|
||||
std::time::SystemTime::from(Utc::now() + Duration::minutes(5)).into(),
|
||||
)]),
|
||||
in_flight: Some(BTreeMap::from([("acct_available".to_owned(), 2)])),
|
||||
})
|
||||
}
|
||||
|
||||
async fn active_freezes(
|
||||
&self,
|
||||
) -> AdminStoreResult<BTreeMap<String, gateway_admin::model::accounts::AccountFreeze>> {
|
||||
Ok(BTreeMap::new())
|
||||
}
|
||||
|
||||
async fn capacity_peaks(
|
||||
&self,
|
||||
_account_ids: &[String],
|
||||
) -> AdminStoreResult<BTreeMap<String, u32>> {
|
||||
Ok(BTreeMap::new())
|
||||
}
|
||||
|
||||
async fn finish_freeze(
|
||||
&self,
|
||||
_account_id: &str,
|
||||
_expected: &gateway_admin::model::accounts::AccountFreeze,
|
||||
_postpone_until: Option<DateTime<Utc>>,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn group_record() -> AccountGroupRecord {
|
||||
let now = Utc::now();
|
||||
AccountGroupRecord {
|
||||
disable_fast: false,
|
||||
id: group_id(),
|
||||
name: "Primary".to_owned(),
|
||||
description: None,
|
||||
color: AccountGroupColor::parse("#2563EBFF").expect("color"),
|
||||
enabled: true,
|
||||
member_count: 2,
|
||||
provider_counts: BTreeMap::from([("openai".to_owned(), 2)]),
|
||||
client_key_count: 1,
|
||||
account_summary: AccountGroupAccountSummary {
|
||||
available: 0,
|
||||
limited: 0,
|
||||
total: 0,
|
||||
},
|
||||
capacity: AccountGroupCapacity {
|
||||
used_slots: None,
|
||||
total_slots: 0,
|
||||
},
|
||||
usage: AccountGroupUsage {
|
||||
today_usd: DecimalAmount::from_str("1").expect("today usage"),
|
||||
retained_total_usd: DecimalAmount::from_str("3").expect("retained usage"),
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
fn member(account_id: &str, total_slots: u64) -> AccountGroupMemberFact {
|
||||
AccountGroupMemberFact {
|
||||
group_id: group_id(),
|
||||
account_id: account_id.to_owned(),
|
||||
status: AccountStatusFacts {
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
quota: QuotaState::default(),
|
||||
cooldown: None,
|
||||
last_error_reason: None,
|
||||
last_error_message: None,
|
||||
},
|
||||
total_slots,
|
||||
}
|
||||
}
|
||||
|
||||
fn group_id() -> AccountGroupId {
|
||||
AccountGroupId::new(GROUP_ID).expect("group ID")
|
||||
}
|
||||
|
||||
fn unused() -> AdminStoreError {
|
||||
AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
"account group",
|
||||
"unused test operation",
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
||||
use std::{collections::BTreeMap, sync::Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
auth::{AdminAuditEvent, AdminSession, LoginCommand},
|
||||
auth::{AdminAuditEvent, AuthSession, LoginCommand},
|
||||
settings::AdminApiKey,
|
||||
},
|
||||
ports::store::{AdminStoreResult, AuthStore},
|
||||
@@ -12,10 +13,64 @@ use gateway_admin::{
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemoryAuthStore {
|
||||
retry_after: Mutex<Option<std::time::Duration>>,
|
||||
unavailable: std::sync::atomic::AtomicBool,
|
||||
reject_delete: Mutex<Option<String>>,
|
||||
password_hash: Mutex<Option<String>>,
|
||||
sessions: Mutex<BTreeMap<String, AdminSession>>,
|
||||
sessions: Mutex<BTreeMap<String, AuthSession>>,
|
||||
audits: Mutex<Vec<AdminAuditEvent>>,
|
||||
failures: Mutex<u32>,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_change_obeys_rate_limit_and_rejects_unbound_legacy_sessions() {
|
||||
use gateway_admin::model::{
|
||||
AdminErrorKind,
|
||||
auth::{ChangePassword, SessionSubject},
|
||||
};
|
||||
let store = std::sync::Arc::new(MemoryAuthStore::default());
|
||||
let services = super::AdminHarness::new().auth(store.clone()).build().await;
|
||||
let auth = services.auth();
|
||||
let login = auth
|
||||
.login(
|
||||
LoginCommand::Admin {
|
||||
username: None,
|
||||
password: "strong-test-password".into(),
|
||||
},
|
||||
std::net::Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
*store.retry_after.lock().unwrap() = Some(std::time::Duration::from_secs(30));
|
||||
let error = auth
|
||||
.change_password(
|
||||
Some(&login.session_id),
|
||||
ChangePassword {
|
||||
current_password: "strong-test-password".into(),
|
||||
new_password: "new-strong-password".into(),
|
||||
},
|
||||
std::net::Ipv4Addr::LOCALHOST.into(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), AdminErrorKind::RateLimited);
|
||||
assert!(
|
||||
auth.session(Some(&login.session_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
store.sessions.lock().unwrap().insert(
|
||||
"legacy".into(),
|
||||
AuthSession {
|
||||
subject: SessionSubject::Admin {
|
||||
admin_user_id: "admin".into(),
|
||||
credential_fingerprint: String::new(),
|
||||
},
|
||||
expires_at: Utc::now() + TimeDelta::hours(1),
|
||||
},
|
||||
);
|
||||
assert!(auth.session(Some("legacy")).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -24,6 +79,25 @@ impl AuthStore for MemoryAuthStore {
|
||||
Ok(self.password_hash.lock().expect("password hash").clone())
|
||||
}
|
||||
|
||||
async fn change_password(
|
||||
&self,
|
||||
_: &str,
|
||||
expected_hash: &str,
|
||||
password_hash: &str,
|
||||
audit: gateway_admin::model::auth::AdminAuditEvent,
|
||||
) -> AdminStoreResult<bool> {
|
||||
let mut stored = self.password_hash.lock().unwrap();
|
||||
let Some(credentials) = stored
|
||||
.as_mut()
|
||||
.filter(|value| value.as_str() == expected_hash)
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
*credentials = password_hash.to_owned();
|
||||
self.audits.lock().unwrap().push(audit);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn create_password_hash_if_absent(
|
||||
&self,
|
||||
_: &str,
|
||||
@@ -41,7 +115,10 @@ impl AuthStore for MemoryAuthStore {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
if self.unavailable.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return Err(super::unavailable("session"));
|
||||
}
|
||||
Ok(self
|
||||
.sessions
|
||||
.lock()
|
||||
@@ -50,11 +127,7 @@ impl AuthStore for MemoryAuthStore {
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn store_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
session: &AdminSession,
|
||||
) -> AdminStoreResult<()> {
|
||||
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("sessions")
|
||||
@@ -62,33 +135,28 @@ impl AuthStore for MemoryAuthStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
if self.reject_delete.lock().unwrap().as_deref() == Some(session_id) {
|
||||
return Err(super::unavailable("delete session"));
|
||||
}
|
||||
Ok(self.sessions.lock().expect("sessions").remove(session_id))
|
||||
}
|
||||
|
||||
async fn login_source_is_throttled(
|
||||
async fn client_key_enabled(
|
||||
&self,
|
||||
_: &str,
|
||||
failure_limit: u32,
|
||||
_: u64,
|
||||
_: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(*self.failures.lock().expect("failures") >= failure_limit)
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn record_login_failure(
|
||||
async fn consume_login_attempt(
|
||||
&self,
|
||||
_: &str,
|
||||
failure_limit: u32,
|
||||
_: u64,
|
||||
) -> AdminStoreResult<bool> {
|
||||
let mut failures = self.failures.lock().expect("failures");
|
||||
*failures += 1;
|
||||
Ok(*failures >= failure_limit)
|
||||
}
|
||||
|
||||
async fn clear_login_failures(&self, _: &str) -> AdminStoreResult<()> {
|
||||
*self.failures.lock().expect("failures") = 0;
|
||||
Ok(())
|
||||
_: std::net::IpAddr,
|
||||
_: u32,
|
||||
_: u32,
|
||||
_: std::time::Duration,
|
||||
) -> AdminStoreResult<Option<std::time::Duration>> {
|
||||
Ok(*self.retry_after.lock().unwrap())
|
||||
}
|
||||
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
@@ -104,20 +172,24 @@ async fn successful_login_should_create_expiring_session_and_audit() {
|
||||
|
||||
let result = services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "strong-test-password".to_owned(),
|
||||
source: "127.0.0.1".to_owned(),
|
||||
})
|
||||
.login(
|
||||
LoginCommand::Admin {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "strong-test-password".to_owned(),
|
||||
},
|
||||
std::net::Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("login");
|
||||
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.validate_session(Some(&result.session_id))
|
||||
.session(Some(&result.session_id))
|
||||
.await
|
||||
.expect("validate")
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(store.audits.lock().expect("audits").len(), 1);
|
||||
}
|
||||
@@ -139,12 +211,160 @@ async fn repeated_default_initialization_should_not_replace_password() {
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "first-strong-password".to_owned(),
|
||||
source: "127.0.0.1".to_owned(),
|
||||
})
|
||||
.login(
|
||||
LoginCommand::Admin {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "first-strong-password".to_owned(),
|
||||
},
|
||||
std::net::Ipv4Addr::LOCALHOST.into(),
|
||||
None
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_with_huge_session_ttl_should_clamp_expiry_instead_of_panicking() {
|
||||
let store = std::sync::Arc::new(MemoryAuthStore::default());
|
||||
let services = super::AdminHarness::new()
|
||||
.auth(store.clone())
|
||||
.session_ttl_minutes(i64::MAX as u64)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let before = Utc::now();
|
||||
let result = services
|
||||
.auth()
|
||||
.login(
|
||||
LoginCommand::Admin {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "strong-test-password".to_owned(),
|
||||
},
|
||||
std::net::Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("login with huge session TTL");
|
||||
|
||||
assert!(result.session.expires_at > before);
|
||||
assert!(result.session.expires_at <= before + TimeDelta::days(366) + TimeDelta::minutes(1));
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.session(Some(&result.session_id))
|
||||
.await
|
||||
.expect("validate")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_sessions_are_removed_and_store_outages_are_not_treated_as_logout() {
|
||||
use gateway_admin::model::{AdminErrorKind, auth::SessionSubject};
|
||||
let store = std::sync::Arc::new(MemoryAuthStore::default());
|
||||
let services = super::AdminHarness::new().auth(store.clone()).build().await;
|
||||
store.sessions.lock().unwrap().insert(
|
||||
"expired".to_owned(),
|
||||
AuthSession {
|
||||
subject: SessionSubject::Admin {
|
||||
credential_fingerprint: String::new(),
|
||||
admin_user_id: "admin".to_owned(),
|
||||
},
|
||||
expires_at: Utc::now() - TimeDelta::seconds(1),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.session(Some("expired"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(store.sessions.lock().unwrap().is_empty());
|
||||
store
|
||||
.unavailable
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
services
|
||||
.auth()
|
||||
.session(Some("existing"))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::Unavailable
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn both_login_types_share_the_login_limit_before_credentials_are_checked() {
|
||||
use gateway_admin::model::auth::LoginError;
|
||||
let store = std::sync::Arc::new(MemoryAuthStore::default());
|
||||
*store.retry_after.lock().unwrap() = Some(std::time::Duration::from_secs(37));
|
||||
let services = super::AdminHarness::new().auth(store).build().await;
|
||||
for command in [
|
||||
LoginCommand::Admin {
|
||||
username: None,
|
||||
password: "wrong-password".to_owned(),
|
||||
},
|
||||
LoginCommand::Key {
|
||||
api_key: "unknown-key".to_owned(),
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
services
|
||||
.auth()
|
||||
.login(command, std::net::Ipv4Addr::LOCALHOST.into(), None)
|
||||
.await
|
||||
.unwrap_err(),
|
||||
LoginError::TooManyAttempts {
|
||||
retry_after_seconds: 37
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_rotation_discards_the_new_session_and_leaves_the_old_session_retryable() {
|
||||
use gateway_admin::model::auth::LoginError;
|
||||
let store = std::sync::Arc::new(MemoryAuthStore::default());
|
||||
let services = super::AdminHarness::new().auth(store.clone()).build().await;
|
||||
let command = || LoginCommand::Admin {
|
||||
username: None,
|
||||
password: "strong-test-password".to_owned(),
|
||||
};
|
||||
let original = services
|
||||
.auth()
|
||||
.login(command(), std::net::Ipv4Addr::LOCALHOST.into(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!format!("{original:?}").contains(&original.session_id));
|
||||
*store.reject_delete.lock().unwrap() = Some(original.session_id.clone());
|
||||
assert_eq!(
|
||||
services
|
||||
.auth()
|
||||
.login(
|
||||
command(),
|
||||
std::net::Ipv4Addr::LOCALHOST.into(),
|
||||
Some(&original.session_id)
|
||||
)
|
||||
.await
|
||||
.unwrap_err(),
|
||||
LoginError::Unavailable
|
||||
);
|
||||
assert_eq!(store.sessions.lock().unwrap().len(), 1);
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.session(Some(&original.session_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(services.auth().logout(&original.session_id).await.is_err());
|
||||
*store.reject_delete.lock().unwrap() = None;
|
||||
services.auth().logout(&original.session_id).await.unwrap();
|
||||
services.auth().logout(&original.session_id).await.unwrap();
|
||||
assert!(store.sessions.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration as StdDuration,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_admin::{
|
||||
model::auth::{AdminAuditEvent, AuthSession, LoginCommand, LoginError, SessionSubject},
|
||||
ports::store::{AdminStoreResult, AuthStore},
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::execution::{ClientAuthenticationError, ClientKeyVerifier},
|
||||
policy::ClientApiKeyId,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn client_login_command_debug_should_redact_api_key() {
|
||||
let command = LoginCommand::Key {
|
||||
api_key: "cpr_should_never_be_logged".to_owned(),
|
||||
};
|
||||
|
||||
let rendered = format!("{command:?}");
|
||||
|
||||
assert!(rendered.contains("[REDACTED]"));
|
||||
assert!(!rendered.contains("cpr_should_never_be_logged"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_should_create_key_bound_session_with_fixed_ttl() {
|
||||
let key = client_key("key-42");
|
||||
let store = Arc::new(MemoryKeyAuthStore::new(Some(key.clone())));
|
||||
let verifier = Arc::new(FixtureVerifier::accepting(key.clone()));
|
||||
let services = super::AdminHarness::new()
|
||||
.auth(store.clone())
|
||||
.client_key_verifier(verifier.clone())
|
||||
.client_session_ttl_minutes(30)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let login = services
|
||||
.auth()
|
||||
.login(
|
||||
login_command("valid-client-key"),
|
||||
Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("client login");
|
||||
|
||||
assert!(login.session_id.starts_with("session_"));
|
||||
assert!(!login.session_id.contains("valid-client-key"));
|
||||
assert_eq!(verifier.calls(), 1);
|
||||
assert_eq!(
|
||||
store.session(&login.session_id).map(|value| value.subject),
|
||||
Some(SessionSubject::Key {
|
||||
client_key_id: key.clone()
|
||||
})
|
||||
);
|
||||
let remaining = login.session.expires_at - Utc::now();
|
||||
assert!(remaining > Duration::minutes(29));
|
||||
assert!(remaining <= Duration::minutes(30));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_and_removed_keys_should_share_the_same_login_error() {
|
||||
let rejected_store = Arc::new(MemoryKeyAuthStore::new(Some(client_key("key-42"))));
|
||||
let rejected = super::AdminHarness::new()
|
||||
.auth(rejected_store.clone())
|
||||
.client_key_verifier(Arc::new(FixtureVerifier::rejecting()))
|
||||
.build()
|
||||
.await
|
||||
.auth()
|
||||
.login(
|
||||
login_command("unknown-key"),
|
||||
Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let removed = super::AdminHarness::new()
|
||||
.auth(Arc::new(MemoryKeyAuthStore::new(None)))
|
||||
.client_key_verifier(Arc::new(FixtureVerifier::accepting(
|
||||
ClientApiKeyId::new("removed-key").expect("key id"),
|
||||
)))
|
||||
.build()
|
||||
.await
|
||||
.auth()
|
||||
.login(
|
||||
login_command("disabled-key"),
|
||||
Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(rejected, Err(LoginError::InvalidCredentials));
|
||||
assert_eq!(removed, Err(LoginError::InvalidCredentials));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_should_reject_before_verifying_the_candidate_key() {
|
||||
let store = Arc::new(MemoryKeyAuthStore::new(Some(client_key("key-42"))));
|
||||
store.set_retry_after(Some(StdDuration::from_secs(37)));
|
||||
let verifier = Arc::new(FixtureVerifier::accepting(
|
||||
ClientApiKeyId::new("key-42").expect("key id"),
|
||||
));
|
||||
let services = super::AdminHarness::new()
|
||||
.auth(store.clone())
|
||||
.client_key_verifier(verifier.clone())
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let result = services
|
||||
.auth()
|
||||
.login(
|
||||
login_command("valid-client-key"),
|
||||
Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(LoginError::TooManyAttempts {
|
||||
retry_after_seconds: 37,
|
||||
})
|
||||
);
|
||||
assert_eq!(verifier.calls(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restored_session_should_be_revoked_when_its_key_is_no_longer_active() {
|
||||
let key = client_key("key-42");
|
||||
let store = Arc::new(MemoryKeyAuthStore::new(Some(key.clone())));
|
||||
let services = super::AdminHarness::new()
|
||||
.auth(store.clone())
|
||||
.client_key_verifier(Arc::new(FixtureVerifier::accepting(key.clone())))
|
||||
.build()
|
||||
.await;
|
||||
let login = services
|
||||
.auth()
|
||||
.login(
|
||||
login_command("valid-client-key"),
|
||||
Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("client login");
|
||||
store.replace_key(None);
|
||||
|
||||
let session = services
|
||||
.auth()
|
||||
.session(Some(&login.session_id))
|
||||
.await
|
||||
.expect("session status");
|
||||
|
||||
assert!(session.is_none());
|
||||
assert!(store.session(&login.session_id).is_none());
|
||||
}
|
||||
|
||||
fn login_command(api_key: &str) -> LoginCommand {
|
||||
LoginCommand::Key {
|
||||
api_key: api_key.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn client_key(id: &str) -> ClientApiKeyId {
|
||||
ClientApiKeyId::new(id).expect("client key id")
|
||||
}
|
||||
|
||||
struct FixtureVerifier {
|
||||
result: Result<ClientApiKeyId, ClientAuthenticationError>,
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl FixtureVerifier {
|
||||
fn accepting(id: ClientApiKeyId) -> Self {
|
||||
Self {
|
||||
result: Ok(id),
|
||||
calls: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn rejecting() -> Self {
|
||||
Self {
|
||||
result: Err(ClientAuthenticationError::InvalidKey),
|
||||
calls: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> usize {
|
||||
self.calls.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientKeyVerifier for FixtureVerifier {
|
||||
fn verify_client_key(&self, _: &str) -> Result<ClientApiKeyId, ClientAuthenticationError> {
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
self.result.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct MemoryKeyAuthStore {
|
||||
key: Mutex<Option<ClientApiKeyId>>,
|
||||
sessions: Mutex<HashMap<String, AuthSession>>,
|
||||
retry_after: Mutex<Option<StdDuration>>,
|
||||
}
|
||||
|
||||
impl MemoryKeyAuthStore {
|
||||
fn new(key: Option<ClientApiKeyId>) -> Self {
|
||||
Self {
|
||||
key: Mutex::new(key),
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
retry_after: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_key(&self, key: Option<ClientApiKeyId>) {
|
||||
*self.key.lock().expect("client key") = key;
|
||||
}
|
||||
|
||||
fn set_retry_after(&self, retry_after: Option<StdDuration>) {
|
||||
*self.retry_after.lock().expect("retry after") = retry_after;
|
||||
}
|
||||
|
||||
fn session(&self, session_id: &str) -> Option<AuthSession> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("client sessions")
|
||||
.get(session_id)
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthStore for MemoryKeyAuthStore {
|
||||
async fn load_password_hash(&self, _: &str) -> AdminStoreResult<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn change_password(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: gateway_admin::model::auth::AdminAuditEvent,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn create_password_hash_if_absent(&self, _: &str, _: &str) -> AdminStoreResult<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn load_admin_api_key(
|
||||
&self,
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::settings::AdminApiKey>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn append_audit_event(&self, _: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn client_key_enabled(&self, id: &ClientApiKeyId) -> AdminStoreResult<bool> {
|
||||
Ok(self
|
||||
.key
|
||||
.lock()
|
||||
.expect("client key")
|
||||
.as_ref()
|
||||
.is_some_and(|key| key == id))
|
||||
}
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
Ok(self.session(session_id))
|
||||
}
|
||||
|
||||
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("client sessions")
|
||||
.insert(session_id.to_owned(), session.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
Ok(self
|
||||
.sessions
|
||||
.lock()
|
||||
.expect("client sessions")
|
||||
.remove(session_id))
|
||||
}
|
||||
|
||||
async fn consume_login_attempt(
|
||||
&self,
|
||||
_: IpAddr,
|
||||
_: u32,
|
||||
_: u32,
|
||||
_: StdDuration,
|
||||
) -> AdminStoreResult<Option<StdDuration>> {
|
||||
Ok(*self.retry_after.lock().expect("retry after"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! BackupService 用例测试:通过 AdminHarness 注入 fake 备份端口构造真实服务。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gateway_admin::model::backup::BackupStatus;
|
||||
use gateway_admin::ports::backup::BackupStorePorts;
|
||||
|
||||
use crate::backup::support::{
|
||||
FakeAuthStore, FakeBackupRepository, FakeDumpPort, FakeObjectStore, configured_settings,
|
||||
system_context,
|
||||
};
|
||||
|
||||
use super::AdminHarness;
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_service_creates_downloads_and_deletes_with_audit() {
|
||||
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
|
||||
let dump = Arc::new(FakeDumpPort::new());
|
||||
let object_store = Arc::new(FakeObjectStore::new());
|
||||
let auth = Arc::new(FakeAuthStore::new());
|
||||
|
||||
let services = AdminHarness::new()
|
||||
.auth(auth.clone())
|
||||
.backup(BackupStorePorts::new(
|
||||
repository.clone(),
|
||||
dump,
|
||||
object_store,
|
||||
))
|
||||
.build()
|
||||
.await;
|
||||
|
||||
// 创建手动备份 → queued + 审计。
|
||||
let created = services
|
||||
.backups()
|
||||
.create_backup(&system_context(), None)
|
||||
.await
|
||||
.expect("create backup");
|
||||
assert_eq!(created.status, BackupStatus::Queued);
|
||||
assert!(created.object_key.starts_with("codex/"));
|
||||
assert!(
|
||||
auth.audit_actions()
|
||||
.iter()
|
||||
.any(|action| action == "backup.created")
|
||||
);
|
||||
|
||||
// 下载地址要求 completed 记录。
|
||||
repository.force_status(&created.id, BackupStatus::Completed);
|
||||
let download = services
|
||||
.backups()
|
||||
.download_url(&system_context(), &created.id)
|
||||
.await
|
||||
.expect("download url");
|
||||
assert!(download.url.starts_with("https://presigned.example.com/"));
|
||||
assert!(
|
||||
auth.audit_actions()
|
||||
.iter()
|
||||
.any(|action| action == "backup.download_url_created")
|
||||
);
|
||||
|
||||
// 删除 → deleting + 审计。
|
||||
let deleting = services
|
||||
.backups()
|
||||
.delete_backup(&system_context(), &created.id)
|
||||
.await
|
||||
.expect("delete backup");
|
||||
assert_eq!(deleting.status, BackupStatus::Deleting);
|
||||
assert!(
|
||||
auth.audit_actions()
|
||||
.iter()
|
||||
.any(|action| action == "backup.delete_requested")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_service_rejects_download_of_non_completed_record() {
|
||||
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
|
||||
let dump = Arc::new(FakeDumpPort::new());
|
||||
let object_store = Arc::new(FakeObjectStore::new());
|
||||
let auth = Arc::new(FakeAuthStore::new());
|
||||
|
||||
let services = AdminHarness::new()
|
||||
.auth(auth.clone())
|
||||
.backup(BackupStorePorts::new(
|
||||
repository.clone(),
|
||||
dump,
|
||||
object_store,
|
||||
))
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let created = services
|
||||
.backups()
|
||||
.create_backup(&system_context(), None)
|
||||
.await
|
||||
.expect("create backup");
|
||||
|
||||
// queued 记录不能创建下载地址。
|
||||
let error = services
|
||||
.backups()
|
||||
.download_url(&system_context(), &created.id)
|
||||
.await
|
||||
.expect_err("non-completed record must be rejected");
|
||||
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Conflict);
|
||||
}
|
||||
@@ -1,22 +1,48 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use chrono::Utc;
|
||||
use gateway_core::policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits};
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
AdminErrorKind, MutationContext, Revision,
|
||||
AdminErrorKind, MutationActor, MutationContext, Revision,
|
||||
client_keys::{
|
||||
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPage,
|
||||
ClientKeyPageSize, ClientKeyRecord, ClientKeySecret, ClientKeySort, ClientKeySortField,
|
||||
DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
|
||||
CreateClientKey, DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection,
|
||||
UpdateClientKey,
|
||||
},
|
||||
},
|
||||
ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, ClientKeyStore},
|
||||
};
|
||||
|
||||
struct UnusedClientKeyStore;
|
||||
#[derive(Default)]
|
||||
struct TestClientKeyStore {
|
||||
plaintexts: Mutex<Vec<String>>,
|
||||
create_error: Option<AdminStoreErrorKind>,
|
||||
update_error: Option<AdminStoreErrorKind>,
|
||||
resets: Mutex<Vec<gateway_admin::model::client_keys::ResetClientKeyBudget>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for UnusedClientKeyStore {
|
||||
impl ClientKeyStore for TestClientKeyStore {
|
||||
async fn reset_client_key_budget(
|
||||
&self,
|
||||
command: gateway_admin::model::client_keys::ResetClientKeyBudget,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<()> {
|
||||
self.resets.lock().unwrap().push(command);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_client_key(
|
||||
&self,
|
||||
_: &ClientApiKeyId,
|
||||
) -> AdminStoreResult<Option<ClientKeyRecord>> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
|
||||
assert_eq!(query.page_size.get(), u16::MAX);
|
||||
Ok(ClientKeyPage {
|
||||
@@ -36,10 +62,33 @@ impl ClientKeyStore for UnusedClientKeyStore {
|
||||
|
||||
async fn create_client_key(
|
||||
&self,
|
||||
_: NewClientKey,
|
||||
key: NewClientKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
|
||||
Err(unused())
|
||||
if let Some(kind) = self.create_error {
|
||||
return Err(AdminStoreError::new(
|
||||
kind,
|
||||
"client key",
|
||||
"test create failure",
|
||||
));
|
||||
}
|
||||
self.plaintexts.lock().unwrap().push(key.plaintext);
|
||||
let record = ClientKeyRecord {
|
||||
openai_client_profile_override: None,
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
groups: Vec::new(),
|
||||
provider_kinds: Vec::new(),
|
||||
prefix: String::new(),
|
||||
enabled: true,
|
||||
limits: key.limits,
|
||||
budget: Default::default(),
|
||||
last_used_at: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
Ok((Revision::new(2).unwrap(), record))
|
||||
}
|
||||
|
||||
async fn update_client_key(
|
||||
@@ -47,6 +96,13 @@ impl ClientKeyStore for UnusedClientKeyStore {
|
||||
_: UpdateClientKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
|
||||
if let Some(kind) = self.update_error {
|
||||
return Err(AdminStoreError::new(
|
||||
kind,
|
||||
"client key",
|
||||
"test update failure",
|
||||
));
|
||||
}
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
@@ -67,10 +123,31 @@ impl ClientKeyStore for UnusedClientKeyStore {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_budget_forwards_scope_and_returns_only_key_identity() {
|
||||
use gateway_admin::model::client_keys::{ClientKeyBudgetPeriod, ResetClientKeyBudget};
|
||||
let store = Arc::new(TestClientKeyStore::default());
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(store.clone())
|
||||
.build()
|
||||
.await;
|
||||
let command = ResetClientKeyBudget {
|
||||
id: ClientApiKeyId::new("key_reset").unwrap(),
|
||||
period: ClientKeyBudgetPeriod::Weekly,
|
||||
};
|
||||
let result = services
|
||||
.client_keys()
|
||||
.reset_budget(&mutation_context(), command.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, command.id);
|
||||
assert_eq!(*store.resets.lock().unwrap(), vec![command]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(std::sync::Arc::new(UnusedClientKeyStore))
|
||||
.client_keys(Arc::new(TestClientKeyStore::default()))
|
||||
.build()
|
||||
.await;
|
||||
let sort = ClientKeySort {
|
||||
@@ -98,7 +175,7 @@ async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
|
||||
#[tokio::test]
|
||||
async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() {
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(std::sync::Arc::new(UnusedClientKeyStore))
|
||||
.client_keys(Arc::new(TestClientKeyStore::default()))
|
||||
.build()
|
||||
.await;
|
||||
let page = services
|
||||
@@ -118,6 +195,107 @@ async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() {
|
||||
assert_eq!(page.total, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_preserves_migrated_keys_and_keeps_default_generation() {
|
||||
let store = Arc::new(TestClientKeyStore::default());
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(store.clone())
|
||||
.build()
|
||||
.await;
|
||||
for value in [Some("q".to_owned()), Some("legacy+/=:!".repeat(1024)), None] {
|
||||
let created = services
|
||||
.client_keys()
|
||||
.create(&mutation_context(), create_command(value.as_deref()))
|
||||
.await
|
||||
.unwrap();
|
||||
let plaintext = created.secret.expose_for_response();
|
||||
assert_eq!(store.plaintexts.lock().unwrap().last().unwrap(), plaintext);
|
||||
if let Some(value) = value {
|
||||
assert_eq!(plaintext, value);
|
||||
} else {
|
||||
assert!(plaintext.starts_with("sk_"));
|
||||
assert_eq!(plaintext.len(), 46);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_keys_return_actionable_conflicts_without_disclosing_the_key() {
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(Arc::new(TestClientKeyStore {
|
||||
create_error: Some(AdminStoreErrorKind::Conflict),
|
||||
..Default::default()
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
let key = "legacy-duplicate-must-stay-private";
|
||||
let error = services
|
||||
.client_keys()
|
||||
.create(&mutation_context(), create_command(Some(key)))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Conflict);
|
||||
assert_eq!(error.message(), "API Key 已存在,请使用其他密钥");
|
||||
assert!(!format!("{error:?}").contains(key));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_update() {
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(Arc::new(TestClientKeyStore {
|
||||
create_error: Some(AdminStoreErrorKind::DuplicateName),
|
||||
update_error: Some(AdminStoreErrorKind::DuplicateName),
|
||||
..Default::default()
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
let created = services
|
||||
.client_keys()
|
||||
.create(&mutation_context(), create_command(None))
|
||||
.await
|
||||
.unwrap_err();
|
||||
let updated = services
|
||||
.client_keys()
|
||||
.update(
|
||||
&mutation_context(),
|
||||
UpdateClientKey {
|
||||
openai_client_profile_override: None,
|
||||
id: ClientApiKeyId::new("key_existing").unwrap(),
|
||||
name: "Migration".to_owned(),
|
||||
label: None,
|
||||
group_ids: Vec::new(),
|
||||
limits: RateLimits::unlimited(),
|
||||
daily_limit_usd: None,
|
||||
weekly_limit_usd: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
for error in [created, updated] {
|
||||
assert_eq!(error.kind(), AdminErrorKind::Conflict);
|
||||
assert_eq!(error.message(), "名称已存在");
|
||||
}
|
||||
}
|
||||
|
||||
fn create_command(key: Option<&str>) -> CreateClientKey {
|
||||
CreateClientKey {
|
||||
openai_client_profile_override: None,
|
||||
custom_key: key.map(|value| PlaintextClientApiKey::new(value).unwrap()),
|
||||
name: "Migration".to_owned(),
|
||||
label: None,
|
||||
group_ids: Vec::new(),
|
||||
limits: RateLimits::unlimited(),
|
||||
budget: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mutation_context() -> MutationContext {
|
||||
MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "custom-key-test".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn unused() -> AdminStoreError {
|
||||
AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
//! 容量熔断冻结恢复编排回归:到期探测、失败顺延与自适应并发下调。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration as TimeDelta, Utc};
|
||||
use gateway_admin::freeze_recovery::{FreezeRecoveryDeps, FreezeRecoveryTask};
|
||||
use gateway_admin::model::MutationContext;
|
||||
use gateway_admin::model::accounts::{AccountFreeze, AccountRuntimeSnapshot};
|
||||
use gateway_admin::model::settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings,
|
||||
};
|
||||
use gateway_admin::ports::store::{
|
||||
AccountRuntimeStore, AccountStore, AdminStoreError, AdminStoreResult, SettingsStore,
|
||||
};
|
||||
use gateway_core::engine::probe::{
|
||||
AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult,
|
||||
};
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
use gateway_core::task::{ScheduledTask as _, WorkerCycleContext, WorkerId, WorkerKind};
|
||||
|
||||
use super::AdminHarness;
|
||||
use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, events, revision};
|
||||
|
||||
fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings {
|
||||
RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: revision(1),
|
||||
model_mappings: Default::default(),
|
||||
refresh_margin_seconds: 300,
|
||||
refresh_concurrency: 2,
|
||||
max_concurrent_per_account: 5,
|
||||
request_interval_ms: 0,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
rotation_strategy: gateway_admin::model::settings::RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
usage_retention_days: 31,
|
||||
ops_event_retention_days: 30,
|
||||
audit_retention_days: 90,
|
||||
account_auto_freeze_enabled: enabled,
|
||||
account_auto_freeze_threshold: 12,
|
||||
account_auto_freeze_window_seconds: 600,
|
||||
account_auto_freeze_duration_seconds: 7_200,
|
||||
account_auto_freeze_probe_enabled: probe_enabled,
|
||||
account_auto_freeze_probe_model: Some("gpt-5.5".to_owned()),
|
||||
account_auto_freeze_adaptive_concurrency: adaptive,
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
struct FreezeSettingsStore {
|
||||
settings: RuntimeSettings,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsStore for FreezeSettingsStore {
|
||||
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
|
||||
Ok(Default::default())
|
||||
}
|
||||
async fn sync_pricing(
|
||||
&self,
|
||||
_: gateway_admin::model::pricing::PricingSyncChanges,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<gateway_admin::model::Revision> {
|
||||
panic!("unexpected pricing sync")
|
||||
}
|
||||
async fn update_pricing(
|
||||
&self,
|
||||
_: gateway_admin::model::pricing::UpdatePricing,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<gateway_admin::model::Revision> {
|
||||
panic!("unexpected pricing update")
|
||||
}
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
|
||||
Ok(self.settings.clone())
|
||||
}
|
||||
|
||||
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn replace_runtime_settings(
|
||||
&self,
|
||||
_: ReplaceRuntimeSettings,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<RuntimeSettings> {
|
||||
Err(store_unavailable())
|
||||
}
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(store_unavailable())
|
||||
}
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(store_unavailable())
|
||||
}
|
||||
}
|
||||
|
||||
fn store_unavailable() -> AdminStoreError {
|
||||
AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"freeze recovery test",
|
||||
"unused",
|
||||
)
|
||||
}
|
||||
|
||||
/// 记录解冻/顺延调用的运行态 fake;冻结与峰值证据由测试预置。
|
||||
struct FreezeRuntimeStore {
|
||||
freezes: BTreeMap<String, AccountFreeze>,
|
||||
peaks: BTreeMap<String, u32>,
|
||||
cleared: Mutex<Vec<String>>,
|
||||
extended: Mutex<Vec<(String, DateTime<Utc>)>>,
|
||||
}
|
||||
|
||||
impl FreezeRuntimeStore {
|
||||
fn new(freezes: BTreeMap<String, AccountFreeze>, peaks: BTreeMap<String, u32>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
freezes,
|
||||
peaks,
|
||||
cleared: Mutex::new(Vec::new()),
|
||||
extended: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
fn cleared(&self) -> Vec<String> {
|
||||
self.cleared.lock().expect("cleared lock").clone()
|
||||
}
|
||||
|
||||
fn extended(&self) -> Vec<(String, DateTime<Utc>)> {
|
||||
self.extended.lock().expect("extended lock").clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountRuntimeStore for FreezeRuntimeStore {
|
||||
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
Ok(AccountRuntimeSnapshot::default())
|
||||
}
|
||||
|
||||
async fn account_runtime(
|
||||
&self,
|
||||
_account_ids: &[String],
|
||||
) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
Ok(AccountRuntimeSnapshot::default())
|
||||
}
|
||||
|
||||
async fn active_freezes(&self) -> AdminStoreResult<BTreeMap<String, AccountFreeze>> {
|
||||
Ok(self.freezes.clone())
|
||||
}
|
||||
|
||||
async fn capacity_peaks(
|
||||
&self,
|
||||
account_ids: &[String],
|
||||
) -> AdminStoreResult<BTreeMap<String, u32>> {
|
||||
Ok(account_ids
|
||||
.iter()
|
||||
.filter_map(|id| self.peaks.get(id).map(|peak| (id.clone(), *peak)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn finish_freeze(
|
||||
&self,
|
||||
account_id: &str,
|
||||
expected: &AccountFreeze,
|
||||
postpone_until: Option<DateTime<Utc>>,
|
||||
) -> AdminStoreResult<bool> {
|
||||
assert_eq!(self.freezes.get(account_id), Some(expected));
|
||||
if let Some(until) = postpone_until {
|
||||
self.extended
|
||||
.lock()
|
||||
.expect("extended")
|
||||
.push((account_id.to_owned(), until));
|
||||
} else {
|
||||
self.cleared
|
||||
.lock()
|
||||
.expect("cleared")
|
||||
.push(account_id.to_owned());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
struct SuccessfulProbe;
|
||||
|
||||
impl AccountProbe for SuccessfulProbe {
|
||||
fn probe(
|
||||
&self,
|
||||
_: AccountProbeRequest,
|
||||
) -> futures::future::BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
|
||||
Box::pin(async {
|
||||
Ok(AccountProbeResult {
|
||||
text: vec!["OK".to_owned()],
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FailingProbe;
|
||||
|
||||
impl AccountProbe for FailingProbe {
|
||||
fn probe(
|
||||
&self,
|
||||
_: AccountProbeRequest,
|
||||
) -> futures::future::BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
|
||||
Box::pin(async {
|
||||
Err(AccountProbeError::new(
|
||||
gateway_core::error::GatewayError::new(
|
||||
gateway_core::error::GatewayErrorKind::RateLimited,
|
||||
"still overloaded",
|
||||
),
|
||||
gateway_core::engine::probe::AccountProbeErrorSource::Upstream,
|
||||
Some(gateway_core::upstream::UpstreamSendState::NotSent),
|
||||
None,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn recovery_task(
|
||||
settings: RuntimeSettings,
|
||||
runtime: Arc<FreezeRuntimeStore>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
) -> (FreezeRecoveryTask, Arc<FakeAccountStore>) {
|
||||
recovery_task_with_store(settings, runtime, probe).await
|
||||
}
|
||||
|
||||
async fn recovery_task_with_store(
|
||||
settings: RuntimeSettings,
|
||||
runtime: Arc<FreezeRuntimeStore>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
) -> (FreezeRecoveryTask, Arc<FakeAccountStore>) {
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
let services = AdminHarness::new()
|
||||
.accounts(Arc::clone(&store) as Arc<dyn AccountStore>)
|
||||
.account_runtime(Arc::clone(&runtime) as Arc<dyn AccountRuntimeStore>)
|
||||
.settings(Arc::new(FreezeSettingsStore {
|
||||
settings: settings.clone(),
|
||||
}))
|
||||
.provider(FakeProviderAdmin::new("openai", events()))
|
||||
.probe(probe)
|
||||
.build()
|
||||
.await;
|
||||
let task = FreezeRecoveryTask::new(FreezeRecoveryDeps {
|
||||
accounts: services.accounts_handle(),
|
||||
store: store.clone(),
|
||||
runtime,
|
||||
settings: Arc::new(FreezeSettingsStore {
|
||||
settings: settings.clone(),
|
||||
}),
|
||||
});
|
||||
(task, store)
|
||||
}
|
||||
|
||||
async fn run_cycle(task: &FreezeRecoveryTask) {
|
||||
let worker = WorkerId::try_new(WorkerKind::AccountFreezeRecovery, "test").expect("worker id");
|
||||
let context = WorkerCycleContext::new(worker, None, CancellationToken::new());
|
||||
task.run_cycle(context)
|
||||
.await
|
||||
.expect("freeze recovery cycle");
|
||||
}
|
||||
|
||||
fn freeze_until(until: DateTime<Utc>) -> BTreeMap<String, AccountFreeze> {
|
||||
BTreeMap::from([(
|
||||
"acct_test".to_owned(),
|
||||
AccountFreeze {
|
||||
credential_revision: revision(1),
|
||||
until,
|
||||
generation: "freeze-generation".to_owned(),
|
||||
requires_probe: true,
|
||||
},
|
||||
)])
|
||||
}
|
||||
|
||||
fn freeze_due() -> BTreeMap<String, AccountFreeze> {
|
||||
freeze_until(Utc::now() - TimeDelta::seconds(1))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_success_clears_freeze() {
|
||||
let runtime = FreezeRuntimeStore::new(freeze_due(), BTreeMap::new());
|
||||
let (task, _store) = recovery_task(
|
||||
runtime_settings(true, true, false),
|
||||
Arc::clone(&runtime),
|
||||
Arc::new(SuccessfulProbe),
|
||||
)
|
||||
.await;
|
||||
|
||||
run_cycle(&task).await;
|
||||
|
||||
assert_eq!(runtime.cleared(), vec!["acct_test".to_owned()]);
|
||||
assert!(runtime.extended().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_failure_postpones_freeze() {
|
||||
let runtime = FreezeRuntimeStore::new(freeze_due(), BTreeMap::new());
|
||||
let (task, _store) = recovery_task(
|
||||
runtime_settings(true, true, false),
|
||||
Arc::clone(&runtime),
|
||||
Arc::new(FailingProbe),
|
||||
)
|
||||
.await;
|
||||
|
||||
run_cycle(&task).await;
|
||||
|
||||
assert!(runtime.cleared().is_empty());
|
||||
let extended = runtime.extended();
|
||||
assert_eq!(extended.len(), 1);
|
||||
let (account_id, until) = &extended[0];
|
||||
assert_eq!(account_id, "acct_test");
|
||||
let remaining = until.timestamp_millis() - Utc::now().timestamp_millis();
|
||||
assert!(
|
||||
remaining > 7_000_000 && remaining <= 7_200_000,
|
||||
"postponed freeze should be about 2 hours, got {remaining}ms"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_policy_releases_due_freeze_without_probing() {
|
||||
let runtime = FreezeRuntimeStore::new(freeze_due(), BTreeMap::new());
|
||||
let (task, _store) = recovery_task(
|
||||
runtime_settings(false, true, false),
|
||||
Arc::clone(&runtime),
|
||||
Arc::new(SuccessfulProbe),
|
||||
)
|
||||
.await;
|
||||
|
||||
run_cycle(&task).await;
|
||||
|
||||
assert_eq!(runtime.cleared(), vec!["acct_test".to_owned()]);
|
||||
assert!(runtime.extended().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adaptive_concurrency_lowers_limit_to_observed_peak() {
|
||||
let runtime = FreezeRuntimeStore::new(
|
||||
freeze_until(Utc::now() + TimeDelta::hours(1)),
|
||||
BTreeMap::from([("acct_test".to_owned(), 4_u32)]),
|
||||
);
|
||||
let (task, store) = recovery_task(
|
||||
runtime_settings(true, false, true),
|
||||
Arc::clone(&runtime),
|
||||
Arc::new(SuccessfulProbe),
|
||||
)
|
||||
.await;
|
||||
|
||||
run_cycle(&task).await;
|
||||
|
||||
// 峰值 4 × 0.8 = 3(不低于下限 2),低于全局默认 5,应下调到 3。
|
||||
assert_eq!(
|
||||
*store.lowered_limits.lock().expect("lowered limits"),
|
||||
vec![("acct_test".to_owned(), 3)]
|
||||
);
|
||||
assert!(store.update_commands().is_empty());
|
||||
assert!(runtime.extended().is_empty());
|
||||
assert!(runtime.cleared().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adaptive_concurrency_never_raises_limit() {
|
||||
let runtime = FreezeRuntimeStore::new(
|
||||
freeze_until(Utc::now() + TimeDelta::hours(1)),
|
||||
BTreeMap::from([("acct_test".to_owned(), 40_u32)]),
|
||||
);
|
||||
let (task, store) = recovery_task(
|
||||
runtime_settings(true, false, true),
|
||||
Arc::clone(&runtime),
|
||||
Arc::new(SuccessfulProbe),
|
||||
)
|
||||
.await;
|
||||
|
||||
run_cycle(&task).await;
|
||||
|
||||
// 峰值 40 的目标 32 高于全局默认 5;只降不升,不下发任何更新。
|
||||
assert!(store.update_commands().is_empty());
|
||||
}
|
||||
|
||||
/// 到期时间还早(超过探测提前量)的冻结不发起探测。
|
||||
#[tokio::test]
|
||||
async fn probe_skips_freezes_far_from_expiry() {
|
||||
let runtime = FreezeRuntimeStore::new(
|
||||
freeze_until(Utc::now() + TimeDelta::hours(1)),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
let (task, _store) = recovery_task(
|
||||
runtime_settings(true, true, false),
|
||||
Arc::clone(&runtime),
|
||||
Arc::new(SuccessfulProbe),
|
||||
)
|
||||
.await;
|
||||
|
||||
run_cycle(&task).await;
|
||||
|
||||
assert!(runtime.cleared().is_empty());
|
||||
assert!(runtime.extended().is_empty());
|
||||
}
|
||||
|
||||
/// 停用账号不参与探测恢复;并发下调同样跳过。
|
||||
#[tokio::test]
|
||||
async fn disabled_accounts_are_skipped() {
|
||||
let runtime = FreezeRuntimeStore::new(
|
||||
freeze_due(),
|
||||
BTreeMap::from([("acct_test".to_owned(), 4_u32)]),
|
||||
);
|
||||
let mut record = account_record("openai");
|
||||
record.enabled = false;
|
||||
let store = FakeAccountStore::with_account(record, events());
|
||||
let services = AdminHarness::new()
|
||||
.accounts(Arc::clone(&store) as Arc<dyn AccountStore>)
|
||||
.account_runtime(Arc::clone(&runtime) as Arc<dyn AccountRuntimeStore>)
|
||||
.settings(Arc::new(FreezeSettingsStore {
|
||||
settings: runtime_settings(true, true, true),
|
||||
}))
|
||||
.provider(FakeProviderAdmin::new("openai", events()))
|
||||
.probe(Arc::new(SuccessfulProbe))
|
||||
.build()
|
||||
.await;
|
||||
let task = FreezeRecoveryTask::new(FreezeRecoveryDeps {
|
||||
accounts: services.accounts_handle(),
|
||||
store: store.clone(),
|
||||
runtime: Arc::clone(&runtime) as Arc<dyn AccountRuntimeStore>,
|
||||
settings: Arc::new(FreezeSettingsStore {
|
||||
settings: runtime_settings(true, true, true),
|
||||
}),
|
||||
});
|
||||
|
||||
run_cycle(&task).await;
|
||||
|
||||
assert!(runtime.cleared().is_empty());
|
||||
assert!(runtime.extended().is_empty());
|
||||
assert!(store.update_commands().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use gateway_admin::{
|
||||
AdminBundle, AdminServices,
|
||||
model::{
|
||||
AdminErrorKind, MutationActor, MutationContext,
|
||||
import_tasks::{ImportTaskInput, SubmitImportTask},
|
||||
provider_credentials::ImportCredentials,
|
||||
},
|
||||
ports::provider::ProviderAdminErrorKind,
|
||||
};
|
||||
use gateway_core::{
|
||||
lifecycle::CancellationToken,
|
||||
routing::ProviderKind,
|
||||
task::{WorkerContribution, WorkerKind, WorkerRunnable},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
AdminHarness,
|
||||
accounts::{
|
||||
EventLog, FakeAccountStore, FakeProviderAdmin, context, document, events, recorded,
|
||||
},
|
||||
};
|
||||
|
||||
fn command(id: Uuid, count: usize) -> SubmitImportTask {
|
||||
let context = context("task-submit");
|
||||
SubmitImportTask {
|
||||
submission_id: id,
|
||||
fingerprint: [1; 32],
|
||||
context: context.clone(),
|
||||
items: (0..count)
|
||||
.map(|_| ImportTaskInput {
|
||||
provider: ProviderKind::new("openai").unwrap(),
|
||||
command: ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
context: context.clone(),
|
||||
document: document(),
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn harness() -> (AdminBundle, Arc<FakeProviderAdmin>, EventLog) {
|
||||
let log = events();
|
||||
let provider = FakeProviderAdmin::new("openai", log.clone());
|
||||
let bundle = AdminHarness::new()
|
||||
.provider(provider.clone())
|
||||
.accounts(FakeAccountStore::new("openai", log.clone()))
|
||||
.build_bundle()
|
||||
.await;
|
||||
(bundle, provider, log)
|
||||
}
|
||||
|
||||
fn start(bundle: &mut AdminBundle) -> (CancellationToken, tokio::task::JoinHandle<()>) {
|
||||
let registration = bundle
|
||||
.take_worker_contributions()
|
||||
.into_iter()
|
||||
.find_map(|contribution| match contribution {
|
||||
WorkerContribution::Registration(registration)
|
||||
if registration.id.kind() == WorkerKind::AccountImport =>
|
||||
{
|
||||
Some(registration)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("import registration");
|
||||
let WorkerRunnable::Daemon { task, .. } = registration.runnable else {
|
||||
panic!("import daemon")
|
||||
};
|
||||
let cancellation = CancellationToken::new();
|
||||
let token = cancellation.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
task.run(token).await.expect("import daemon exits cleanly");
|
||||
});
|
||||
(cancellation, handle)
|
||||
}
|
||||
|
||||
async fn wait_started(log: &EventLog, count: usize) {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while recorded(log)
|
||||
.iter()
|
||||
.filter(|event| **event == "provider.prepare_import")
|
||||
.count()
|
||||
< count
|
||||
{
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("imports started");
|
||||
}
|
||||
|
||||
async fn wait_finished(services: &AdminServices, id: Uuid) {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while services
|
||||
.import_tasks()
|
||||
.detail(&context("poll"), id)
|
||||
.unwrap()
|
||||
.summary
|
||||
.finished_at
|
||||
.is_none()
|
||||
{
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("imports finished");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_should_return_the_same_task_but_reject_changed_content() {
|
||||
let (bundle, _, _) = harness().await;
|
||||
let services = bundle.services();
|
||||
let id = Uuid::now_v7();
|
||||
let first = services.import_tasks().submit(command(id, 2)).unwrap();
|
||||
let retry = services.import_tasks().submit(command(id, 2)).unwrap();
|
||||
assert_eq!(first.task_id, retry.task_id);
|
||||
assert_eq!(
|
||||
services
|
||||
.import_tasks()
|
||||
.list(&context("reopened-page"))
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let mut changed = command(id, 2);
|
||||
changed.fingerprint = [2; 32];
|
||||
assert_eq!(
|
||||
services.import_tasks().submit(changed).unwrap_err().kind(),
|
||||
AdminErrorKind::Conflict
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_reads_and_stop_should_be_isolated_by_owner() {
|
||||
let (bundle, _, _) = harness().await;
|
||||
let services = bundle.services();
|
||||
let task = services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 1))
|
||||
.unwrap();
|
||||
let other = MutationContext {
|
||||
actor: MutationActor::AdminSession {
|
||||
admin_user_id: "another-admin".to_owned(),
|
||||
},
|
||||
request_id: "other-session".to_owned(),
|
||||
};
|
||||
assert!(services.import_tasks().list(&other).is_empty());
|
||||
assert_eq!(
|
||||
services
|
||||
.import_tasks()
|
||||
.detail(&other, task.task_id)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::NotFound
|
||||
);
|
||||
assert_eq!(
|
||||
services
|
||||
.import_tasks()
|
||||
.stop(&other, task.task_id)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::NotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stopping_should_skip_pending_items_and_allow_three_in_flight_to_commit() {
|
||||
let (mut bundle, provider, log) = harness().await;
|
||||
let gate = provider.block_imports();
|
||||
let services = bundle.services();
|
||||
let task = services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 8))
|
||||
.unwrap();
|
||||
let (cancel, worker) = start(&mut bundle);
|
||||
wait_started(&log, 3).await;
|
||||
let stopped = services
|
||||
.import_tasks()
|
||||
.stop(&context("stop"), task.task_id)
|
||||
.unwrap();
|
||||
assert_eq!(stopped.summary.counts.running, 3);
|
||||
assert_eq!(stopped.summary.counts.skipped, 5);
|
||||
gate.add_permits(3);
|
||||
wait_finished(&services, task.task_id).await;
|
||||
let finished = services
|
||||
.import_tasks()
|
||||
.detail(&context("new-page"), task.task_id)
|
||||
.unwrap();
|
||||
assert_eq!(finished.summary.counts.imported_accounts, 3);
|
||||
assert_eq!(
|
||||
recorded(&log)
|
||||
.iter()
|
||||
.filter(|event| **event == "provider.prepare_import")
|
||||
.count(),
|
||||
3
|
||||
);
|
||||
cancel.cancel();
|
||||
worker.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrency_limit_should_be_shared_across_tasks() {
|
||||
let (mut bundle, provider, log) = harness().await;
|
||||
let gate = provider.block_imports();
|
||||
let services = bundle.services();
|
||||
let first = services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 6))
|
||||
.unwrap();
|
||||
let second = services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 6))
|
||||
.unwrap();
|
||||
let (cancel, worker) = start(&mut bundle);
|
||||
wait_started(&log, 3).await;
|
||||
let summaries = services.import_tasks().list(&context("poll"));
|
||||
assert_eq!(
|
||||
summaries
|
||||
.iter()
|
||||
.map(|task| task.counts.running)
|
||||
.sum::<usize>(),
|
||||
3
|
||||
);
|
||||
assert!(summaries.iter().all(|task| task.counts.running > 0));
|
||||
gate.add_permits(12);
|
||||
wait_finished(&services, first.task_id).await;
|
||||
wait_finished(&services, second.task_id).await;
|
||||
cancel.cancel();
|
||||
worker.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_and_unknown_items_should_not_abort_the_batch_or_retry_credentials() {
|
||||
for (kind, failed, unknown) in [
|
||||
(ProviderAdminErrorKind::Invalid, 1, 0),
|
||||
(ProviderAdminErrorKind::Ambiguous, 0, 1),
|
||||
] {
|
||||
let (mut bundle, provider, log) = harness().await;
|
||||
provider.fail_next(kind);
|
||||
let services = bundle.services();
|
||||
let task = services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 4))
|
||||
.unwrap();
|
||||
let (cancel, worker) = start(&mut bundle);
|
||||
wait_finished(&services, task.task_id).await;
|
||||
let result = services
|
||||
.import_tasks()
|
||||
.detail(&context("poll"), task.task_id)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
result.summary.counts.succeeded,
|
||||
result.summary.counts.failed,
|
||||
result.summary.counts.unknown
|
||||
),
|
||||
(3, failed, unknown)
|
||||
);
|
||||
assert_eq!(
|
||||
recorded(&log)
|
||||
.iter()
|
||||
.filter(|event| **event == "provider.prepare_import")
|
||||
.count(),
|
||||
4
|
||||
);
|
||||
cancel.cancel();
|
||||
worker.await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_should_drain_running_items_and_reject_new_work() {
|
||||
let (mut bundle, provider, log) = harness().await;
|
||||
let gate = provider.block_imports();
|
||||
let services = bundle.services();
|
||||
let task = services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 7))
|
||||
.unwrap();
|
||||
let (cancel, worker) = start(&mut bundle);
|
||||
wait_started(&log, 3).await;
|
||||
cancel.cancel();
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(
|
||||
services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 1))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::Unavailable
|
||||
);
|
||||
assert!(!worker.is_finished());
|
||||
gate.add_permits(3);
|
||||
worker.await.unwrap();
|
||||
let result = services
|
||||
.import_tasks()
|
||||
.detail(&context("poll"), task.task_id)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
result.summary.counts.succeeded,
|
||||
result.summary.counts.skipped
|
||||
),
|
||||
(3, 4)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn completed_records_should_expire_and_new_process_should_start_empty() {
|
||||
let (bundle, _, _) = harness().await;
|
||||
let services = bundle.services();
|
||||
let task = services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 2))
|
||||
.unwrap();
|
||||
services
|
||||
.import_tasks()
|
||||
.stop(&context("stop"), task.task_id)
|
||||
.unwrap();
|
||||
tokio::time::advance(Duration::from_secs(3601)).await;
|
||||
assert!(services.import_tasks().list(&context("poll")).is_empty());
|
||||
assert_eq!(
|
||||
services
|
||||
.import_tasks()
|
||||
.detail(&context("poll"), task.task_id)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::NotFound
|
||||
);
|
||||
let (fresh, _, _) = harness().await;
|
||||
assert!(
|
||||
fresh
|
||||
.services()
|
||||
.import_tasks()
|
||||
.list(&context("poll"))
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_should_reject_excess_work_before_starting_any_imports() {
|
||||
let (bundle, _, log) = harness().await;
|
||||
let services = bundle.services();
|
||||
assert_eq!(
|
||||
services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 201))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
for _ in 0..8 {
|
||||
services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 1))
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 1))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::RateLimited
|
||||
);
|
||||
assert!(recorded(&log).is_empty());
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user