mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 20:12:39 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0bdd7e771 | ||
|
|
cffea2979b | ||
|
|
5e9d75b863 | ||
|
|
4126c4c091 | ||
|
|
ef8275aede | ||
|
|
f8445a241b | ||
|
|
fde95fcd0f | ||
|
|
ebfaf2496b | ||
|
|
8e078534c2 | ||
|
|
a185705d19 | ||
|
|
003deac540 | ||
|
|
3f7beb4679 | ||
|
|
7905d02ce5 | ||
|
|
134a2c0bd3 | ||
|
|
ec413f7802 | ||
|
|
30371e64cd | ||
|
|
793f62357f | ||
|
|
d1613ef020 | ||
|
|
c5971a6fcb | ||
|
|
a8bfdc6f36 | ||
|
|
eba7289a8e | ||
|
|
61a80114eb | ||
|
|
8ffa2a31ff | ||
|
|
5a8d6c4e41 | ||
|
|
c05b2311d8 | ||
|
|
e31783e443 | ||
|
|
a978d56c7b | ||
|
|
81e7397770 | ||
|
|
bd62c6f66c | ||
|
|
6c93f01c97 | ||
|
|
6af622c340 | ||
|
|
4696ed7d0b | ||
|
|
b8b72e1b18 | ||
|
|
04a61ee6ad | ||
|
|
1f9eac4fbd | ||
|
|
0b9d44545b | ||
|
|
927fcbc1e3 | ||
|
|
14701ff070 | ||
|
|
e85878624a | ||
|
|
8b4c8823d9 | ||
|
|
d1dd512bef | ||
|
|
e51e61008a | ||
|
|
7b934d6888 | ||
|
|
a753e91e2c | ||
|
|
e5384ac13b | ||
|
|
0738eadcb5 | ||
|
|
ef3c770d95 | ||
|
|
9da816154e | ||
|
|
af3b6bbd68 | ||
|
|
49200d4747 | ||
|
|
addd5ef1dc | ||
|
|
de094fb63e | ||
|
|
442323928d | ||
|
|
64ab08e4aa | ||
|
|
0df490961b | ||
|
|
9e072cefec | ||
|
|
a319333161 | ||
|
|
29bea0a758 | ||
|
|
304fcb04e3 | ||
|
|
2707f2793d | ||
|
|
e5ad287843 | ||
|
|
106043fd9a | ||
|
|
3258f7c620 | ||
|
|
c401595e01 | ||
|
|
a6ecc202f0 | ||
|
|
53c8ab5b00 | ||
|
|
e625ce3b3b | ||
|
|
865128998c | ||
|
|
27f094e096 | ||
|
|
2ae61f3da0 | ||
|
|
fa402b909a | ||
|
|
2e5973b165 | ||
|
|
ecf199d29d | ||
|
|
7cbb0d8552 | ||
|
|
c4a8878f1f | ||
|
|
95c8b2401e | ||
|
|
e669e51b92 | ||
|
|
ce9e70d5cf | ||
|
|
deb5e8756a | ||
|
|
2ee66c401d | ||
|
|
3071d41443 | ||
|
|
58ee0c8d75 | ||
|
|
78f85583ea | ||
|
|
7a7f51a534 | ||
|
|
db4295d646 | ||
|
|
3c41984733 | ||
|
|
04617cc0ff | ||
|
|
dabca7ca79 | ||
|
|
a90c18cbea | ||
|
|
e502766170 | ||
|
|
b8e844f4ee | ||
|
|
80a533c890 | ||
|
|
6d152893ff | ||
|
|
97afb4089c | ||
|
|
48fad26433 | ||
|
|
a05b873215 | ||
|
|
1b4029b050 | ||
|
|
139a3d4346 | ||
|
|
8c9cda1803 | ||
|
|
0a8831777b | ||
|
|
34c8dbd604 | ||
|
|
e311d368d3 | ||
|
|
f133fde643 | ||
|
|
c0b8f6b0a8 | ||
|
|
9ccc9077cc | ||
|
|
f5e484aa70 | ||
|
|
aa615c2677 | ||
|
|
032001598a | ||
|
|
6def36a171 | ||
|
|
d2ef0cb151 | ||
|
|
25bd4956f0 | ||
|
|
2518691dbc | ||
|
|
bfabfe60c8 | ||
|
|
f3312cf7d9 | ||
|
|
30202f8266 | ||
|
|
a2acbf553b | ||
|
|
dd0cbe91c9 | ||
|
|
0cc382e331 | ||
|
|
6becd11e39 | ||
|
|
80dd2929a3 | ||
|
|
4990ca0b2c | ||
|
|
344f303919 | ||
|
|
ff5b0e6254 | ||
|
|
fedeba2568 | ||
|
|
3c86e249f4 | ||
|
|
f72958d530 | ||
|
|
8b8b6b3132 | ||
|
|
9bc7d10c08 | ||
|
|
496005d688 | ||
|
|
041db5d824 | ||
|
|
3379603022 | ||
|
|
4a97491be6 | ||
|
|
35b5edb24c | ||
|
|
01062c3244 | ||
|
|
604b764cf5 | ||
|
|
f69042ca6e | ||
|
|
41b58b640a | ||
|
|
5da3325a30 | ||
|
|
93717394b2 | ||
|
|
6aa6b3a968 | ||
|
|
39107ca457 | ||
|
|
6e2ba0e4cd | ||
|
|
8a147fcc51 | ||
|
|
d0fa8c63f1 | ||
|
|
3a4966f786 | ||
|
|
3ed2873f14 | ||
|
|
c044d5c1f1 | ||
|
|
732aeef880 | ||
|
|
3dcf412b65 | ||
|
|
c8ff478876 | ||
|
|
f7979ae8ff | ||
|
|
8fd6f91b7e | ||
|
|
7840eb1c49 | ||
|
|
6102d64274 | ||
|
|
eea02c38fc | ||
|
|
0343dba916 | ||
|
|
b08cab91a9 | ||
|
|
ef88cf3f89 | ||
|
|
9d79b42729 | ||
|
|
7a05a7cdcc | ||
|
|
752b2eaa9b | ||
|
|
026d2a8083 | ||
|
|
5af38ccf41 | ||
|
|
340dc99e02 | ||
|
|
8a2d7c5bd3 | ||
|
|
ba2b0044e3 | ||
|
|
d2753dc2e2 | ||
|
|
c99e479bcc | ||
|
|
b8c640d218 | ||
|
|
1433fbc44d | ||
|
|
2cff4bf169 | ||
|
|
361983cf4d | ||
|
|
510ee451bd | ||
|
|
7bbe170405 | ||
|
|
3401a971a8 | ||
|
|
ed280b81fc | ||
|
|
841544345f | ||
|
|
4c73149af8 | ||
|
|
a1e1997632 | ||
|
|
a67351caec | ||
|
|
62846bb074 | ||
|
|
d4b9797ff7 | ||
|
|
19149ca196 | ||
|
|
831812b39d | ||
|
|
d98ac48eb9 | ||
|
|
72b29a7d4e | ||
|
|
14608dc6d4 | ||
|
|
daa4989124 | ||
|
|
8a95a46a69 | ||
|
|
9d498c2474 | ||
|
|
a2f802d409 | ||
|
|
b01196a7a8 | ||
|
|
e14fb2b6ff | ||
|
|
dd7a2b22f0 | ||
|
|
4e8ea7d568 | ||
|
|
a3f2b8fd87 | ||
|
|
23cdd20606 | ||
|
|
a62b821b5f | ||
|
|
774ff5d8c8 | ||
|
|
d2667393b3 | ||
|
|
e002fbb349 | ||
|
|
005bc5c8d4 | ||
|
|
bc4bd118d8 | ||
|
|
f3925db11b | ||
|
|
f9a467a4c0 | ||
|
|
8ce9288a6a | ||
|
|
c1e702be5e | ||
|
|
bc6b69289c | ||
|
|
5a0492bf88 | ||
|
|
fdc9d92fdb | ||
|
|
8cdd372b7e | ||
|
|
080a52121a | ||
|
|
37db8d031b | ||
|
|
f0e0b7e6d8 | ||
|
|
4c845ca697 | ||
|
|
95d27f2dce | ||
|
|
f2e7a55703 | ||
|
|
1ed9f59599 | ||
|
|
b4ad7fd363 | ||
|
|
ef2a22be70 | ||
|
|
c831bb979f | ||
|
|
3edcbfd120 | ||
|
|
1db10dc559 | ||
|
|
3f6b5c7bd7 | ||
|
|
2946281a43 | ||
|
|
335edde9c8 | ||
|
|
1a855d3e0e | ||
|
|
b1a6b80267 | ||
|
|
c3f759d13a | ||
|
|
539bfc8bad | ||
|
|
8b75dd5576 | ||
|
|
bd0f2d6405 | ||
|
|
71afe24710 | ||
|
|
500f241692 | ||
|
|
b92bbf0299 | ||
|
|
2594950993 | ||
|
|
9386ce8a13 | ||
|
|
be74deae73 | ||
|
|
2264a33085 | ||
|
|
d2585097f3 | ||
|
|
e86063155f | ||
|
|
1f7b7b9132 | ||
|
|
7043982ae3 | ||
|
|
57914967cb | ||
|
|
8bfbc5ca99 | ||
|
|
cb40288fb8 | ||
|
|
33766c299f | ||
|
|
13468b3fa5 | ||
|
|
e69e46ba02 | ||
|
|
eaf0691752 | ||
|
|
5fd030a01b | ||
|
|
fc3c5a1e0c | ||
|
|
e375623abf | ||
|
|
288d3a1cce | ||
|
|
ac685ccaf5 | ||
|
|
bc50c9d013 | ||
|
|
dda20ae9a6 | ||
|
|
c34dcd3700 | ||
|
|
7ed4e7e5e3 | ||
|
|
d2ac6b2c97 | ||
|
|
8f9296140f | ||
|
|
8bc0a277f2 | ||
|
|
456c619379 | ||
|
|
18e698bed6 | ||
|
|
26b5e87457 | ||
|
|
c2c19a7cbe | ||
|
|
2a75d7d238 | ||
|
|
1c3f2810af | ||
|
|
7c48f9a85f | ||
|
|
980a46ffb0 | ||
|
|
824f763a77 | ||
|
|
9b3c96dc68 | ||
|
|
52f2bc69fb | ||
|
|
067cf8b46e | ||
|
|
df9d9e2e40 | ||
|
|
0f7f8a317e | ||
|
|
d2b080e88b | ||
|
|
d11bdb13f5 | ||
|
|
29e39b96e5 | ||
|
|
08e994ad86 | ||
|
|
bc2244c83f | ||
|
|
26abd19a28 | ||
|
|
c29b50394f | ||
|
|
3d23cc399f | ||
|
|
c1fd1cb44e | ||
|
|
7e13b6d039 | ||
|
|
c993490a82 | ||
|
|
4d91f39474 | ||
|
|
af6bd44d2a | ||
|
|
541ca3bc27 | ||
|
|
7f5d067af2 | ||
|
|
174ea22eeb | ||
|
|
9fc006546c | ||
|
|
b960ec1980 | ||
|
|
60732a2e8c | ||
|
|
9d1064badd | ||
|
|
2b5944fd6c | ||
|
|
43e95dfa45 | ||
|
|
ca82fd6e98 | ||
|
|
a2779cd5f3 | ||
|
|
dbef64bb45 | ||
|
|
3ecfcc48ae | ||
|
|
115116e8bf | ||
|
|
35748d8c51 | ||
|
|
e2e375d694 | ||
|
|
1f5ee8123f | ||
|
|
7947619cc3 | ||
|
|
393a8fe56a | ||
|
|
09729ba54c | ||
|
|
590efe29a5 | ||
|
|
813920607e | ||
|
|
605b026cc4 | ||
|
|
2d97207d41 | ||
|
|
d67a2cad2b | ||
|
|
2de6ccb071 | ||
|
|
a1af031969 | ||
|
|
0ddd58aaf9 | ||
|
|
0eb6e21aaa | ||
|
|
e5e94d1eb1 | ||
|
|
1fb942dd77 | ||
|
|
09c6c6d740 | ||
|
|
0408bdb34f | ||
|
|
ab978e615e | ||
|
|
8d36ce3d20 | ||
|
|
e2bb90a80d | ||
|
|
08828293b7 | ||
|
|
502097026f | ||
|
|
a3a227c858 | ||
|
|
51f2b0e3ee | ||
|
|
531ae04a0b | ||
|
|
e4329a04e8 | ||
|
|
f3138ceb43 | ||
|
|
1c5c42c183 | ||
|
|
3fc0336e1d | ||
|
|
807850769f | ||
|
|
8852194670 | ||
|
|
4226ff8e3a | ||
|
|
ff08694d79 | ||
|
|
7e43ebc792 | ||
|
|
4a50d05393 | ||
|
|
ba01199812 | ||
|
|
25d7797d83 | ||
|
|
d22f4d9b5b | ||
|
|
f552448fb2 | ||
|
|
62d57c02d8 | ||
|
|
ad5e2a85b7 | ||
|
|
90ee85f3ef | ||
|
|
72fada40f6 | ||
|
|
695665cbc8 | ||
|
|
776f3f0de7 | ||
|
|
06e03f467a | ||
|
|
7c43b7327d | ||
|
|
10cb2ca42c | ||
|
|
3db00d3fee | ||
|
|
e8cc1fe26b | ||
|
|
410ea8490c | ||
|
|
a8a8a58f65 | ||
|
|
0765d10c1d | ||
|
|
40b8f04a6a | ||
|
|
f59a6ed74c | ||
|
|
5e4da92dec | ||
|
|
56650d6aed | ||
|
|
2fe7df9b81 | ||
|
|
4f641208a0 | ||
|
|
df247b4364 | ||
|
|
134179085c | ||
|
|
eb2b8632de | ||
|
|
858f3e4a78 | ||
|
|
716fcc6f3d | ||
|
|
be6cd1250c | ||
|
|
34015a1791 | ||
|
|
a733e66330 | ||
|
|
960d1886f3 | ||
|
|
221581400b | ||
|
|
c352d99d92 | ||
|
|
2147da682b | ||
|
|
12e6e70775 | ||
|
|
3b5072187a | ||
|
|
529744c7c7 | ||
|
|
736824dd7a | ||
|
|
705da4f610 | ||
|
|
d515c3045c | ||
|
|
12f991dde8 | ||
|
|
4e4ce440b3 | ||
|
|
bdb5be1c42 | ||
|
|
0de768e8be | ||
|
|
d348193179 | ||
|
|
bac925624f | ||
|
|
651bb019d5 | ||
|
|
4de53b61b8 | ||
|
|
dba6cefb6b | ||
|
|
80560e48d2 | ||
|
|
ade5944201 | ||
|
|
44452d4a9d | ||
|
|
8e84071f21 | ||
|
|
788b1cebf1 | ||
|
|
9b4a7d5d47 | ||
|
|
d6d9f6ea4f | ||
|
|
c3a425837c | ||
|
|
986310d0bd | ||
|
|
2d218fbe61 | ||
|
|
9d1b44e6a0 | ||
|
|
601a1d1662 | ||
|
|
83ef20bf8b | ||
|
|
3170c110a0 | ||
|
|
08ea29428d | ||
|
|
e4a0e69424 | ||
|
|
156cabd260 | ||
|
|
febc5cbdd8 | ||
|
|
3e30862ddd | ||
|
|
9950f4a2e2 | ||
|
|
d5bc576b4f | ||
|
|
f041b5cf0a | ||
|
|
fc4089f292 | ||
|
|
e8e360c802 | ||
|
|
7b51271a84 | ||
|
|
bc6d9c47d2 | ||
|
|
430fd8f20a | ||
|
|
2bbdd47002 | ||
|
|
6a61b2daca | ||
|
|
3f605c3543 | ||
|
|
23796a1b34 | ||
|
|
5b5e41450c | ||
|
|
10798abe4f | ||
|
|
4344f6afb0 | ||
|
|
ab369c363f | ||
|
|
6460b9895c | ||
|
|
f7da6e2bc6 | ||
|
|
60ff61132d | ||
|
|
1acbb12c65 | ||
|
|
cf6aa1a5c3 | ||
|
|
40a59c9e86 | ||
|
|
65d744d0e1 | ||
|
|
2381b6c104 | ||
|
|
a38220ebdc | ||
|
|
2ceaa4783c | ||
|
|
9b86585909 | ||
|
|
42d1bf298d | ||
|
|
eb7933af4c | ||
|
|
4355861ef2 | ||
|
|
0c80d52573 | ||
|
|
3a3b962de3 | ||
|
|
b245ab5848 | ||
|
|
6a62ea1b1d | ||
|
|
6b25900403 | ||
|
|
30df3f68e4 | ||
|
|
1c61225b28 | ||
|
|
7cb8ece8d3 | ||
|
|
de52f7ba89 | ||
|
|
42ea78fb47 | ||
|
|
8d13bbbea5 | ||
|
|
125b03aee6 | ||
|
|
65093591cd | ||
|
|
1f91b69ca6 | ||
|
|
9676b19fba | ||
|
|
a0593b0bf8 | ||
|
|
accb918eca | ||
|
|
62be9db9df | ||
|
|
4aedbfc4a8 | ||
|
|
143cab7bf5 | ||
|
|
536865032c | ||
|
|
8edad3a455 | ||
|
|
91dc31ef39 | ||
|
|
78e09a0f1a | ||
|
|
60bae26a2f | ||
|
|
43a2c13173 | ||
|
|
f863f664ac | ||
|
|
2c13ed32e7 | ||
|
|
d4da138cf5 | ||
|
|
b286cca0c4 | ||
|
|
31a0efc747 | ||
|
|
22e97898bb | ||
|
|
1eb79df0d3 | ||
|
|
07732a5a35 | ||
|
|
3504b8cc88 | ||
|
|
4f3b5110e2 | ||
|
|
324a491671 | ||
|
|
9da4cfbdcc | ||
|
|
5e43c7faac | ||
|
|
b28ac90364 | ||
|
|
8523a52c07 | ||
|
|
ad3522e34b | ||
|
|
74e296703a | ||
|
|
25e6688af6 | ||
|
|
92edda2f6e | ||
|
|
bef520b191 | ||
|
|
ba5b6970be | ||
|
|
b000aac474 | ||
|
|
6485081122 | ||
|
|
ec7c1b6f72 | ||
|
|
eedd9b147d | ||
|
|
2157ee344b | ||
|
|
d68a2aac1a | ||
|
|
f09d63f54e | ||
|
|
f479a5d10c | ||
|
|
bd1399f81e | ||
|
|
8b37ba8828 | ||
|
|
10aa88aab9 | ||
|
|
806bb23053 | ||
|
|
3fe7b4da78 | ||
|
|
57cf2df09f | ||
|
|
1dab126944 | ||
|
|
da85cc7e47 | ||
|
|
86800a8cd0 | ||
|
|
e2028a814e | ||
|
|
23f93a9348 | ||
|
|
a8afc20969 | ||
|
|
7c717365ef | ||
|
|
4c764d3fc9 | ||
|
|
a13a6113dd | ||
|
|
cb7dfea5a9 | ||
|
|
81b8b78378 | ||
|
|
b32b815e46 | ||
|
|
91ae671a58 | ||
|
|
343390057d |
@@ -135,6 +135,7 @@ docs/*
|
||||
!docs/PAYMENT.md
|
||||
!docs/PAYMENT_CN.md
|
||||
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
|
||||
!docs/ASYNC_IMAGE_TASKS.md
|
||||
!docs/legal/
|
||||
!docs/legal/*.md
|
||||
.serena/
|
||||
|
||||
+19
-5
@@ -18,7 +18,9 @@ ARG NPM_CONFIG_REGISTRY=
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 1: Frontend Builder
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM ${NODE_IMAGE} AS frontend-builder
|
||||
# --platform=$BUILDPLATFORM: the frontend output is JS (arch-neutral), so build
|
||||
# it on the native host arch instead of under QEMU emulation for the target.
|
||||
FROM --platform=${BUILDPLATFORM} ${NODE_IMAGE} AS frontend-builder
|
||||
ARG NPM_CONFIG_REGISTRY
|
||||
|
||||
WORKDIR /app/frontend
|
||||
@@ -44,7 +46,11 @@ RUN pnpm run build
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 2: Backend Builder
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM ${GOLANG_IMAGE} AS backend-builder
|
||||
# --platform=$BUILDPLATFORM: run the Go toolchain on the native host arch and
|
||||
# cross-compile to the target arch below. The binary is CGO_ENABLED=0, so this
|
||||
# is a clean pure-Go cross-compile — no QEMU emulation of go mod download / go
|
||||
# build (emulated networking here was dropping module fetches with EOF).
|
||||
FROM --platform=${BUILDPLATFORM} ${GOLANG_IMAGE} AS backend-builder
|
||||
|
||||
# Build arguments for version info (set by CI)
|
||||
ARG VERSION=
|
||||
@@ -52,6 +58,9 @@ ARG COMMIT=docker
|
||||
ARG DATE
|
||||
ARG GOPROXY
|
||||
ARG GOSUMDB
|
||||
# Populated by buildx from the --platform target (e.g. linux/amd64).
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
ENV GOPROXY=${GOPROXY}
|
||||
ENV GOSUMDB=${GOSUMDB}
|
||||
@@ -63,7 +72,10 @@ WORKDIR /app/backend
|
||||
|
||||
# Copy go mod files first (better caching)
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
# Cache mount keeps the module cache across builds so a transient CDN blip on
|
||||
# retry resumes instead of re-fetching every zip from scratch.
|
||||
RUN --mount=type=cache,id=sub2api-gomod,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
# Copy backend source first
|
||||
COPY backend/ ./
|
||||
@@ -73,10 +85,12 @@ COPY --from=frontend-builder /app/backend/internal/web/dist ./internal/web/dist
|
||||
|
||||
# Build the binary (BuildType=release for CI builds, embed frontend)
|
||||
# Version precedence: build arg VERSION > exact git tag > cmd/server/VERSION
|
||||
RUN VERSION_VALUE="${VERSION}" && \
|
||||
RUN --mount=type=cache,id=sub2api-gomod,target=/go/pkg/mod \
|
||||
--mount=type=cache,id=sub2api-gobuild,target=/root/.cache/go-build \
|
||||
VERSION_VALUE="${VERSION}" && \
|
||||
if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(./scripts/resolve-version.sh)"; fi && \
|
||||
DATE_VALUE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" && \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \
|
||||
-tags embed \
|
||||
-ldflags="-s -w -X main.Version=${VERSION_VALUE} -X main.Commit=${COMMIT} -X main.Date=${DATE_VALUE} -X main.BuildType=release" \
|
||||
-trimpath \
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Sub2API
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="assets/logo.svg" alt="Sub2API Logo" width="128" />
|
||||
|
||||
# Sub2API
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
@@ -77,8 +79,8 @@ Please read the following carefully before using this project:
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://api.pptoken.org/register?promo=SUB2API"><img src="assets/partners/logos/pptoken.png" alt="pptoken" width="150"></a></td>
|
||||
<td>Thanks to PPToken.org for sponsoring this project! <a href="https://api.pptoken.org/register?promo=SUB2API">PPToken.org</a> specializes in GPT model API relay services, supporting Codex, Claude Code, OpenAI-compatible clients, and Gemini CLI integration. Top-ups are 1:1 (¥1 = $1 credit); GPT models start at 0.16x rate multiplier, with overall cost at roughly 2.2% of official pricing and first-token latency around 1 second — ideal for developers seeking low-cost, high-speed access to GPT model capabilities. Technical support: 24/7 real human responses (no bots), @tech in the group chat and get a reply within 10 minutes. Sponsor benefit: the first 200 users who register via the <a href="https://api.pptoken.org/register?promo=SUB2API">exclusive registration link</a> and enter promo code `SUB2API` can claim free Codex / Claude Code trial credits — no minimum spend, no card required.
|
||||
<td width="180"><a href="https://api.pptoken.cc/register?promo=SUB2API"><img src="assets/partners/logos/pptoken.png" alt="pptoken" width="150"></a></td>
|
||||
<td>Thanks to PPToken.cc for sponsoring this project! <a href="https://api.pptoken.cc/register?promo=SUB2API">PPToken.cc</a> specializes in GPT model API relay services, supporting Codex, Claude Code, OpenAI-compatible clients, and Gemini CLI integration. Top-ups are 1:1 (¥1 = $1 credit); GPT models start at 0.16x rate multiplier, with overall cost at roughly 2.2% of official pricing and first-token latency around 1 second — ideal for developers seeking low-cost, high-speed access to GPT model capabilities. Technical support: 24/7 real human responses (no bots), @tech in the group chat and get a reply within 10 minutes. Sponsor benefit: the first 200 users who register via the <a href="https://api.pptoken.cc/register?promo=SUB2API">exclusive registration link</a> and enter promo code `SUB2API` can claim free Codex / Claude Code trial credits — no minimum spend, no card required.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -163,6 +165,24 @@ Model authenticity: no content intervention or secondary filtering — experienc
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
|
||||
<td>Thanks to Aimzoon for sponsoring this project! <a href="http://aimzoon.com">Aimzoon</a> provides stable, cost-effective AI API access services, enabling developers to quickly connect popular AI services to coding tools such as Codex, Claude Code, and Gemini CLI. No complex configuration — faster onboarding, more stable calls, and lower costs. Ongoing promotions including discounted Codex rates and special pricing, with free trial credits upon registration, bringing AI coding into your daily workflow. <a href="http://aimzoon.com">Click here</a> to register and try it out!
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>Thanks to Claude API for sponsoring this project! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> is an official-channel API provider focused on Claude models. Built on official Anthropic keys and the official AWS Bedrock channel, it delivers a stable integration experience for Claude Code and Agent applications, supports the full Claude model lineup, and retains official capabilities such as Tool Use and long context. The service involves no reverse engineering and no model degradation, making it a great fit for heavy Claude Code users, Agent engineers, and enterprise engineering teams. Register via the <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">exclusive link</a> and contact customer support to receive free trial credits; invoicing and team onboarding are also supported.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
|
||||
<td>Thanks to code0.ai for sponsoring this project! <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> is an AI coding workbench for developers and engineering teams, aggregating mainstream agent coding capabilities such as Claude Code and Codex, and covering common development scenarios including code generation, project understanding, debugging and fixing, code review, and documentation generation. It suits independent developers, Agent engineers, open-source maintainers, and enterprise R&D teams, with invoicing and team onboarding supported. Register via the <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">exclusive link</a> and contact customer support to receive free trial credits and experience a more efficient AI coding workflow.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## Overview
|
||||
@@ -524,6 +544,7 @@ database:
|
||||
redis:
|
||||
host: "localhost"
|
||||
port: 6379
|
||||
username: ""
|
||||
password: ""
|
||||
|
||||
jwt:
|
||||
@@ -553,9 +574,18 @@ Additional security-related options are available in `config.yaml`:
|
||||
- `security.response_headers.enabled` to enable configurable response header filtering (disabled uses default allowlist)
|
||||
- `security.csp` to control Content-Security-Policy headers
|
||||
- `billing.circuit_breaker` to fail closed on billing errors
|
||||
- `server.trusted_proxies` to enable X-Forwarded-For parsing
|
||||
- `security.trust_forwarded_ip_for_api_key_acl` enables legacy raw forwarded-header takeover (enabled by default for upgrade compatibility); disable it to enforce `server.trusted_proxies`, which should contain only the exact proxy CIDRs that connect directly to Sub2API
|
||||
- `security.forwarded_client_ip_headers` configures up to 16 third-party CDN client-IP header names; they are checked in order before the built-in headers only while legacy takeover is enabled
|
||||
- `turnstile.required` to require Turnstile in release mode
|
||||
|
||||
Custom client-IP headers can be set in YAML or as a comma-separated environment variable:
|
||||
|
||||
```bash
|
||||
SECURITY_FORWARDED_CLIENT_IP_HEADERS=True-Client-IP,X-CDN-Client-IP
|
||||
```
|
||||
|
||||
Header names are validated, canonicalized, and de-duplicated. The admin security settings can update the list without a restart; new installations persist YAML/environment defaults and existing installations backfill a missing database value. When legacy takeover is disabled, all custom and built-in raw forwarding headers are ignored and Gin uses only `server.trusted_proxies`. While takeover is enabled, firewall the origin to CDN/proxy addresses and make the edge overwrite every trusted client-IP header. See [`deploy/EDGE_SECURITY.md`](deploy/EDGE_SECURITY.md) for the complete migration and trust-boundary rules.
|
||||
|
||||
**⚠️ Security Warning: HTTP URL Configuration**
|
||||
|
||||
When `security.url_allowlist.enabled=false`, the system performs minimal URL validation and **allows HTTP URLs by default** (dev-friendly mode; Docker Compose deployments use the same default). For production, explicitly tighten this to HTTPS-only:
|
||||
@@ -605,17 +635,36 @@ per-turn user and account concurrency slots, which are released between turns.
|
||||
```yaml
|
||||
gateway:
|
||||
openai_ws:
|
||||
# Total time to receive and decompress the first client message.
|
||||
client_first_message_timeout_seconds: 30
|
||||
# Close a client socket idle between completed turns; 0 disables this safeguard.
|
||||
ingress_inter_turn_idle_timeout_seconds: 300
|
||||
# Distributed API-key limit for live client ingress sessions; 0 disables it.
|
||||
max_ingress_connections_per_api_key: 64
|
||||
```
|
||||
|
||||
The first-message timeout is a total read deadline. Deployments that accept
|
||||
large contexts or image-heavy requests over slower links can raise it to
|
||||
120-300 seconds. It expires before HTTP bridge routing, so bridge mode does not
|
||||
override this limit.
|
||||
|
||||
The connection cap is coordinated through Redis using a 60-second lease that
|
||||
is refreshed every 20 seconds. A process that cannot confirm a lease for a
|
||||
full lease lifetime closes its local WebSocket rather than continuing outside
|
||||
the global cap. Use `http_bridge` for client-WebSocket/upstream-HTTP operation
|
||||
when rolling out or mitigating upstream WebSocket issues.
|
||||
the global cap.
|
||||
|
||||
Enable the v2 mode router before selecting an account-level WS mode such as
|
||||
`http_bridge`:
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
openai_ws:
|
||||
mode_router_v2_enabled: true
|
||||
```
|
||||
|
||||
Or set `GATEWAY_OPENAI_WS_MODE_ROUTER_V2_ENABLED=true` in the environment.
|
||||
Use `http_bridge` for client-WebSocket/upstream-HTTP operation when rolling out
|
||||
or mitigating upstream WebSocket issues.
|
||||
|
||||
#### ⚠️ Important: Creating the Admin Account
|
||||
|
||||
@@ -675,6 +724,12 @@ Simple Mode is designed for individual developers or internal teams who want qui
|
||||
|
||||
---
|
||||
|
||||
## Asynchronous Image Tasks
|
||||
|
||||
Long-running OpenAI/Grok image generation and editing can be submitted through `/v1/images/generations/async` or `/v1/images/edits/async`, then polled at `/v1/images/tasks/{task_id}` without holding a CDN connection open. See [Asynchronous Image Tasks](docs/ASYNC_IMAGE_TASKS.md) for request and response examples.
|
||||
|
||||
---
|
||||
|
||||
## Grok / xAI Support
|
||||
|
||||
Sub2API supports both Grok subscription accounts through xAI OAuth and standard xAI API-key accounts. Both account types forward OpenAI-compatible Responses traffic to xAI.
|
||||
@@ -690,6 +745,7 @@ Sub2API supports both Grok subscription accounts through xAI OAuth and standard
|
||||
- Text models: `grok-4.5`, `grok-4.3`, `grok-build-0.1`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309`
|
||||
- Media targets for Grok groups: `/v1/images/generations`, `/images/generations`, `/v1/images/edits`, `/images/edits`, `/v1/videos/generations`, `/videos/generations`, `/v1/videos/edits`, `/videos/edits`, `/v1/videos/extensions`, `/videos/extensions`, `/v1/videos/{request_id}`, and `/videos/{request_id}`. Generation, editing, and extension requests require the group image-generation permission.
|
||||
- Media models: `grok-imagine`, `grok-imagine-image-quality`, `grok-imagine-image`, `grok-imagine-edit`, `grok-imagine-video`, and `grok-imagine-video-1.5`
|
||||
- JSON image-edit and video-generation requests accept image references in `image`, `images`, `reference_images`, and `mask` objects. Use `url` for xAI-compatible payloads; the legacy `image_url` field remains accepted and is normalized to `url` before forwarding.
|
||||
- Out of scope for this provider: TTS, transcription, browser automation, cookies, and Grok web scraping
|
||||
|
||||
### OAuth Configuration
|
||||
@@ -728,14 +784,13 @@ For API-key accounts, select **Grok → API Key** in the create-account dialog.
|
||||
|
||||
```toml
|
||||
[models]
|
||||
default = "sub2api-grok"
|
||||
web_search = "sub2api-grok"
|
||||
default = "grok"
|
||||
web_search = "grok"
|
||||
|
||||
[model."sub2api-grok"]
|
||||
[model."grok"]
|
||||
model = "grok-4.5"
|
||||
base_url = "https://your-sub2api.example.com/v1"
|
||||
name = "Grok 4.5 via Sub2API"
|
||||
description = "Grok 4.5 through a Sub2API Grok group"
|
||||
name = "Grok 4.5"
|
||||
api_key = "sk-your-sub2api-key"
|
||||
api_backend = "responses"
|
||||
context_window = 1000000
|
||||
@@ -746,7 +801,7 @@ Back up an existing `config.toml` before merging the entry. The file contains a
|
||||
|
||||
```bash
|
||||
grok inspect
|
||||
grok -p "Reply with sub2api-ok" -m sub2api-grok
|
||||
grok -p "Reply with sub2api-ok" -m grok
|
||||
```
|
||||
|
||||
The `base_url` above is the public Sub2API URL ending in `/v1`, not `api.x.ai` or the internal xAI OAuth proxy URL.
|
||||
@@ -757,6 +812,10 @@ xAI quota is passive. Sub2API does not invent subscription quota values; it reco
|
||||
|
||||
`401` responses temporarily remove accounts with invalid credentials from scheduling. `403` responses are treated as access or entitlement failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling.
|
||||
|
||||
New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts require positive paid-entitlement evidence from the xAI billing probe; Free, forbidden, missing, malformed, and inconclusive billing observations are excluded from new media generation. Unobserved OAuth accounts are probed before the first media request is forwarded, and imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`.
|
||||
|
||||
Administrators can override automatic media eligibility through the account create/update API by setting `extra.grok_media_eligible` to `false` (exclude) or `true` (force eligible). On update, set it to `null` to remove the override and return to automatic probe-based behavior; omitting the field preserves the current override. A weekly allowance period alone is not treated as a paid tier signal. Successful image responses must contain at least one actual image output; empty HTTP `200` responses trigger account failover instead of being counted and returned as successful generations.
|
||||
|
||||
---
|
||||
|
||||
## Antigravity Support
|
||||
|
||||
+33
-5
@@ -1,7 +1,9 @@
|
||||
# Sub2API
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="assets/logo.svg" alt="Sub2API Logo" width="128" />
|
||||
|
||||
# Sub2API
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
@@ -80,8 +82,8 @@
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://api.pptoken.org/register?promo=SUB2API"><img src="assets/partners/logos/pptoken.png" alt="pptoken" width="150"></a></td>
|
||||
<td>感谢 PPToken.org 赞助本项目! <a href="https://api.pptoken.org/register?promo=SUB2API">PPToken.org</a> 主打 GPT 系列模型 API 中转服务,支持 Codex、Claude Code、OpenAI 兼容客户端及 Gemini CLI 等工具接入。充值 1:1,1 元=1 美元额度;GPT 模型最低 0.16 倍倍率,综合成本约为官方价格的 0.22 折,最快首字 Token 约 1 秒,适合开发者低成本、高响应速度接入 GPT 模型能力。技术支持: 7×24 小时真人响应(不是机器人),群内@技术,10 分钟内有回复 。赞助商福利:前 200 名用户通过 <a href="https://api.pptoken.org/register?promo=SUB2API">[专属注册链接]</a> 注册,输入优惠码 `SUB2API`,可领取 Codex / Claude Code 免费试用额度,无门槛、不绑卡。
|
||||
<td width="180"><a href="https://api.pptoken.cc/register?promo=SUB2API"><img src="assets/partners/logos/pptoken.png" alt="pptoken" width="150"></a></td>
|
||||
<td>感谢 PPToken.cc 赞助本项目! <a href="https://api.pptoken.cc/register?promo=SUB2API">PPToken.cc</a> 主打 GPT 系列模型 API 中转服务,支持 Codex、Claude Code、OpenAI 兼容客户端及 Gemini CLI 等工具接入。充值 1:1,1 元=1 美元额度;GPT 模型最低 0.16 倍倍率,综合成本约为官方价格的 0.22 折,最快首字 Token 约 1 秒,适合开发者低成本、高响应速度接入 GPT 模型能力。技术支持: 7×24 小时真人响应(不是机器人),群内@技术,10 分钟内有回复 。赞助商福利:前 200 名用户通过 <a href="https://api.pptoken.cc/register?promo=SUB2API">[专属注册链接]</a> 注册,输入优惠码 `SUB2API`,可领取 Codex / Claude Code 免费试用额度,无门槛、不绑卡。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -166,6 +168,23 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
|
||||
<td>感谢 Aimzoon 对本项目的赞助! <a href="http://aimzoon.com">Aimzoon</a> 提供稳定、高性价比的 AI API 接入服务,支持开发者将常用 AI 服务快速接入 Codex、Claude Code、Gemini CLI 等编程工具。无需复杂配置,更快接入,更稳调用,更省成本。codex倍率优惠,特价倍率等促销不断,注册即送免费体验额度,让 AI 编程真正进入日常工作流。<a href="http://aimzoon.com">点击这里</a>注册体验!
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>感谢 Claude API 对本项目的赞助! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">[专属链接]</a>注册后联系客服,可领取免费测试额度,并支持开票和团队对接。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
|
||||
<td>感谢 code0.ai 对本项目的赞助! <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> 是面向开发者与技术团队的 AI 编程工作台,聚合 Claude Code、Codex 等主流 Agent 编程能力,支持代码生成、项目理解、调试修复、代码审查与文档生成等常见研发场景。适合独立开发者、Agent 工程师、开源项目维护者和企业研发团队使用,支持开票和团队对接。通过<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">[专属链接]</a>注册后联系客服,可领取免费测试额度,体验更高效的 AI 编程工作流。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
@@ -590,9 +609,18 @@ gateway:
|
||||
- `security.response_headers.enabled` 可启用可配置响应头过滤(关闭时使用默认白名单)
|
||||
- `security.csp` 配置 Content-Security-Policy
|
||||
- `billing.circuit_breaker` 计费异常时 fail-closed
|
||||
- `server.trusted_proxies` 启用可信代理解析 X-Forwarded-For
|
||||
- `security.trust_forwarded_ip_for_api_key_acl` 控制旧版原始转发头接管(为升级兼容默认开启);关闭后严格使用 `server.trusted_proxies`,其中只应填写直接连接 Sub2API 的精确代理 CIDR
|
||||
- `security.forwarded_client_ip_headers` 最多配置 16 个第三方 CDN 客户端 IP 请求头;仅在旧版接管开启时按顺序优先于内置请求头解析
|
||||
- `turnstile.required` 在 release 模式强制启用 Turnstile
|
||||
|
||||
自定义客户端 IP 请求头可通过 YAML 配置,也可使用逗号分隔的环境变量:
|
||||
|
||||
```bash
|
||||
SECURITY_FORWARDED_CLIENT_IP_HEADERS=True-Client-IP,X-CDN-Client-IP
|
||||
```
|
||||
|
||||
请求头名称会经过合法性校验、规范化和大小写无关去重。管理员可在安全设置中动态更新列表,无需重启;新安装会持久化 YAML/环境变量默认值,旧安装缺少数据库字段时会自动回填。关闭旧版接管后,自定义头和内置原始转发头均被忽略,只使用 `server.trusted_proxies`。开启接管时必须限制源站仅允许 CDN/代理访问,并确保边缘代理覆盖所有受信客户端 IP 请求头。完整迁移规则和信任边界见 [`deploy/EDGE_SECURITY.md`](deploy/EDGE_SECURITY.md)。
|
||||
|
||||
**网关防御纵深建议(重点)**
|
||||
|
||||
- `gateway.upstream_response_read_max_bytes`:限制非流式上游响应读取大小(默认 `8MB`),用于防止异常响应导致内存放大。
|
||||
|
||||
+34
-5
@@ -1,7 +1,9 @@
|
||||
# Sub2API
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="assets/logo.svg" alt="Sub2API Logo" width="128" />
|
||||
|
||||
# Sub2API
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
@@ -79,8 +81,8 @@
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://api.pptoken.org/register?promo=SUB2API"><img src="assets/partners/logos/pptoken.png" alt="pptoken" width="150"></a></td>
|
||||
<td>PPToken.org のご支援に感謝します!<a href="https://api.pptoken.org/register?promo=SUB2API">PPToken.org</a> は GPT シリーズモデルの API 中継サービスを専門としており、Codex、Claude Code、OpenAI 互換クライアント、Gemini CLI などのツール接続をサポートしています。チャージは 1:1(1元=1ドル分のクレジット)、GPT モデルは最低 0.16 倍のレート倍率で、総合コストは公式価格の約 2.2% 、最速ファーストトークンは約1秒 — 開発者が低コスト・高速レスポンスで GPT モデル機能にアクセスするのに最適です。テクニカルサポート:24時間365日リアルな人間が対応(ボットではありません)、グループ内で @技術 すれば 10 分以内に返信。スポンサー特典:先着 200 名のユーザーが<a href="https://api.pptoken.org/register?promo=SUB2API">専用登録リンク</a>から登録し、プロモコード `SUB2API` を入力すると、Codex / Claude Code の無料トライアルクレジットを獲得できます — 最低利用額なし、カード登録不要。
|
||||
<td width="180"><a href="https://api.pptoken.cc/register?promo=SUB2API"><img src="assets/partners/logos/pptoken.png" alt="pptoken" width="150"></a></td>
|
||||
<td>PPToken.cc のご支援に感謝します!<a href="https://api.pptoken.cc/register?promo=SUB2API">PPToken.cc</a> は GPT シリーズモデルの API 中継サービスを専門としており、Codex、Claude Code、OpenAI 互換クライアント、Gemini CLI などのツール接続をサポートしています。チャージは 1:1(1元=1ドル分のクレジット)、GPT モデルは最低 0.16 倍のレート倍率で、総合コストは公式価格の約 2.2% 、最速ファーストトークンは約1秒 — 開発者が低コスト・高速レスポンスで GPT モデル機能にアクセスするのに最適です。テクニカルサポート:24時間365日リアルな人間が対応(ボットではありません)、グループ内で @技術 すれば 10 分以内に返信。スポンサー特典:先着 200 名のユーザーが<a href="https://api.pptoken.cc/register?promo=SUB2API">専用登録リンク</a>から登録し、プロモコード `SUB2API` を入力すると、Codex / Claude Code の無料トライアルクレジットを獲得できます — 最低利用額なし、カード登録不要。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -161,6 +163,24 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
|
||||
<td>Aimzoon のご支援に感謝します!<a href="http://aimzoon.com">Aimzoon</a> は安定してコストパフォーマンスに優れた AI API 接続サービスを提供し、開発者が主要な AI サービスを Codex、Claude Code、Gemini CLI などのコーディングツールへ素早く接続できるようにします。複雑な設定は不要で、より速い接続、より安定した呼び出し、より低いコストを実現。Codex レート割引や特価レートなどのキャンペーンも随時開催中、登録するだけで無料お試しクレジットをプレゼント。AI コーディングを日常のワークフローへ。<a href="http://aimzoon.com">こちら</a>から登録してお試しください!
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>Claude API のご支援に感謝します!<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
|
||||
<td>code0.ai のご支援に感謝します!<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> は開発者と技術チーム向けの AI プログラミングワークベンチで、Claude Code や Codex などの主要な Agent コーディング能力を集約し、コード生成、プロジェクト理解、デバッグと修正、コードレビュー、ドキュメント生成といった一般的な開発シーンをサポートします。個人開発者、Agent エンジニア、OSS メンテナー、企業の開発チームに最適で、請求書発行やチーム導入にも対応。<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取り、より効率的な AI プログラミングワークフローを体験できます。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 概要
|
||||
@@ -551,9 +571,18 @@ default:
|
||||
- `security.response_headers.enabled` - 設定可能なレスポンスヘッダーフィルタリングを有効化(無効時はデフォルトの許可リストを使用)
|
||||
- `security.csp` - Content-Security-Policy ヘッダーの制御
|
||||
- `billing.circuit_breaker` - 課金エラー時にフェイルクローズ
|
||||
- `server.trusted_proxies` - X-Forwarded-For パースの有効化
|
||||
- `security.trust_forwarded_ip_for_api_key_acl` - 従来の生転送ヘッダーによる上書きを制御(アップグレード互換性のため既定で有効)。無効にすると `server.trusted_proxies` を厳格に使用し、Sub2API に直接接続するプロキシの正確な CIDR のみを指定
|
||||
- `security.forwarded_client_ip_headers` - サードパーティ CDN のクライアント IP ヘッダーを最大 16 個指定。従来モードが有効な場合のみ、設定順で組み込みヘッダーより先に評価
|
||||
- `turnstile.required` - リリースモードでの Turnstile 必須化
|
||||
|
||||
カスタムクライアント IP ヘッダーは YAML またはカンマ区切りの環境変数で設定できます:
|
||||
|
||||
```bash
|
||||
SECURITY_FORWARDED_CLIENT_IP_HEADERS=True-Client-IP,X-CDN-Client-IP
|
||||
```
|
||||
|
||||
ヘッダー名は検証、正規化、大小文字を区別しない重複排除が行われます。管理画面のセキュリティ設定から再起動せずに更新でき、新規インストールでは YAML/環境変数の既定値を保存し、既存環境ではデータベース値がない場合に補完します。従来モードを無効にするとカスタムおよび組み込みの生転送ヘッダーはすべて無視され、`server.trusted_proxies` のみを使用します。有効にする場合はオリジンへの接続元を CDN/プロキシに制限し、エッジで信頼する全クライアント IP ヘッダーを上書きしてください。移行規則と信頼境界の詳細は [`deploy/EDGE_SECURITY.md`](deploy/EDGE_SECURITY.md) を参照してください。
|
||||
|
||||
**⚠️ セキュリティ警告: HTTP URL 設定**
|
||||
|
||||
`security.url_allowlist.enabled=false` の場合、システムは最小限の URL バリデーションのみを行い、**デフォルトで HTTP URL を許可**します(開発フレンドリーモード。Docker Compose デプロイのデフォルトも同じです)。本番環境では、以下のように明示的に HTTPS のみに制限することを推奨します:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Sub2API</title>
|
||||
<desc id="desc">An interlocking S symbol representing subscription routing into APIs.</desc>
|
||||
<defs>
|
||||
<linearGradient id="s2a-bg" x1="72" y1="44" x2="442" y2="478" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#142B56"/>
|
||||
<stop offset=".52" stop-color="#0A1A39"/>
|
||||
<stop offset="1" stop-color="#061127"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="s2a-ambient" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(168 92) rotate(51) scale(342 382)">
|
||||
<stop stop-color="#3E68B0" stop-opacity=".28"/>
|
||||
<stop offset="1" stop-color="#3E68B0" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="s2a-brand" x1="4" y1="4" x2="20" y2="20" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#79F4BD"/>
|
||||
<stop offset=".48" stop-color="#39D9E7"/>
|
||||
<stop offset="1" stop-color="#3875F6"/>
|
||||
</linearGradient>
|
||||
<filter id="s2a-shadow" x="-25%" y="-25%" width="150%" height="165%" color-interpolation-filters="sRGB">
|
||||
<feDropShadow dx="0" dy=".55" stdDeviation=".65" flood-color="#020817" flood-opacity=".34"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect x="16" y="16" width="480" height="480" rx="120" fill="url(#s2a-bg)"/>
|
||||
<rect x="16" y="16" width="480" height="480" rx="120" fill="url(#s2a-ambient)"/>
|
||||
<rect x="16.75" y="16.75" width="478.5" height="478.5" rx="119.25" fill="none" stroke="#A5BFFF" stroke-opacity=".16" stroke-width="1.5"/>
|
||||
|
||||
<g transform="translate(52 52) scale(17)" fill="none" stroke="url(#s2a-brand)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.7" filter="url(#s2a-shadow)">
|
||||
<path d="m19.25 7.65-2.55-3.2a1.33 1.33 0 0 0-1.03-.5H8.58c-.34 0-.67.13-.91.37L4.15 7.65c-.93.88-.6 1.52.65 2.3l9.55 5.97"/>
|
||||
<path d="m4.75 16.35 2.55 3.2c.25.31.63.5 1.03.5h7.09c.34 0 .67-.13.91-.37l3.52-3.33c.93-.88.6-1.52-.65-2.3L9.65 8.08"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,20 @@
|
||||
# Ingress rejection log cleanup
|
||||
|
||||
This maintenance command removes historical admission rejections from
|
||||
`ops_error_logs` without matching unrelated authentication or upstream errors.
|
||||
It is a dry run unless `--execute` is supplied, and always requires an explicit
|
||||
RFC3339 cutoff.
|
||||
|
||||
```sh
|
||||
go run ./cmd/cleanup-ingress-reject-logs --before 2026-07-17T00:00:00Z
|
||||
go run ./cmd/cleanup-ingress-reject-logs --before 2026-07-17T00:00:00Z --execute
|
||||
```
|
||||
|
||||
Run the execute form only after every application instance has been upgraded so
|
||||
older instances cannot add new ingress rejection rows below the chosen cutoff.
|
||||
The classifier intentionally retains invariant failures such as
|
||||
`USER_NOT_FOUND`, database errors, quota/billing errors, and upstream failures.
|
||||
|
||||
After the rollout and cleanup are verified, run
|
||||
`backend/scripts/finalize-ingress-reject-cleanup.sql` in a maintenance window to
|
||||
remove the deprecated plaintext-key audit table and attribution columns.
|
||||
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/Wei-Shaw/sub2api/ent/runtime"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/repository"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const classifierVersion = "ingress-reject-v1"
|
||||
|
||||
type candidate struct {
|
||||
id int64
|
||||
statusCode int
|
||||
message string
|
||||
body string
|
||||
}
|
||||
|
||||
func main() {
|
||||
beforeRaw := flag.String("before", "", "required RFC3339 cutoff; only older rows are considered")
|
||||
execute := flag.Bool("execute", false, "delete matched rows (default is dry-run)")
|
||||
batchSize := flag.Int("batch-size", 5000, "scan/delete batch size (1-5000)")
|
||||
flag.Parse()
|
||||
|
||||
if *beforeRaw == "" {
|
||||
log.Fatal("--before is required")
|
||||
}
|
||||
before, err := time.Parse(time.RFC3339, *beforeRaw)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid --before: %v", err)
|
||||
}
|
||||
if *batchSize < 1 || *batchSize > 5000 {
|
||||
log.Fatal("--batch-size must be between 1 and 5000")
|
||||
}
|
||||
|
||||
cfg, err := config.LoadForBootstrap()
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
client, db, err := repository.InitEnt(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("initialize database: %v", err)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
counts, scanned, matched, deleted, err := cleanup(ctx, db, before, *batchSize, *execute)
|
||||
if err != nil {
|
||||
log.Fatalf("cleanup failed: %v", err)
|
||||
}
|
||||
|
||||
digest := sha256.Sum256([]byte(classifierVersion))
|
||||
mode := "dry-run"
|
||||
if *execute {
|
||||
mode = "execute"
|
||||
}
|
||||
fmt.Printf("mode=%s before=%s classifier=%s scanned=%d matched=%d deleted=%d\n",
|
||||
mode, before.UTC().Format(time.RFC3339), hex.EncodeToString(digest[:]), scanned, matched, deleted)
|
||||
reasons := make([]string, 0, len(counts))
|
||||
for reason := range counts {
|
||||
reasons = append(reasons, reason)
|
||||
}
|
||||
sort.Strings(reasons)
|
||||
for _, reason := range reasons {
|
||||
fmt.Printf("reason=%s count=%d\n", reason, counts[reason])
|
||||
}
|
||||
if *execute && deleted > 0 {
|
||||
fmt.Println("cleanup complete; schedule VACUUM (ANALYZE) ops_error_logs during normal maintenance")
|
||||
}
|
||||
}
|
||||
|
||||
func cleanup(ctx context.Context, db *sql.DB, before time.Time, batchSize int, execute bool) (map[string]int64, int64, int64, int64, error) {
|
||||
counts := make(map[string]int64)
|
||||
var cursor, scanned, matched, deleted int64
|
||||
for {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, COALESCE(status_code, 0), COALESCE(error_message, ''), COALESCE(error_body, '')
|
||||
FROM ops_error_logs
|
||||
WHERE id > $1
|
||||
AND created_at < $2
|
||||
AND error_phase = 'auth'
|
||||
AND account_id IS NULL
|
||||
AND upstream_status_code IS NULL
|
||||
AND COALESCE(upstream_error_message, '') = ''
|
||||
AND COALESCE(upstream_error_detail, '') = ''
|
||||
ORDER BY id ASC
|
||||
LIMIT $3`, cursor, before, batchSize)
|
||||
if err != nil {
|
||||
return nil, scanned, matched, deleted, err
|
||||
}
|
||||
|
||||
batch := make([]candidate, 0, batchSize)
|
||||
for rows.Next() {
|
||||
var item candidate
|
||||
if err := rows.Scan(&item.id, &item.statusCode, &item.message, &item.body); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, scanned, matched, deleted, err
|
||||
}
|
||||
batch = append(batch, item)
|
||||
cursor = item.id
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, scanned, matched, deleted, err
|
||||
}
|
||||
_ = rows.Close()
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(batch))
|
||||
for _, item := range batch {
|
||||
scanned++
|
||||
if reason, ok := historicalIngressRejectReason(item); ok {
|
||||
matched++
|
||||
counts[reason]++
|
||||
ids = append(ids, item.id)
|
||||
}
|
||||
}
|
||||
if execute && len(ids) > 0 {
|
||||
result, err := db.ExecContext(ctx,
|
||||
`DELETE FROM ops_error_logs WHERE id = ANY($1) AND created_at < $2`, pq.Array(ids), before)
|
||||
if err != nil {
|
||||
return nil, scanned, matched, deleted, err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, scanned, matched, deleted, err
|
||||
}
|
||||
deleted += n
|
||||
}
|
||||
}
|
||||
return counts, scanned, matched, deleted, nil
|
||||
}
|
||||
|
||||
func historicalIngressRejectReason(item candidate) (string, bool) {
|
||||
code, message := parseErrorIdentity(item.body, item.message)
|
||||
switch code {
|
||||
case "API_KEY_REQUIRED":
|
||||
return "missing_key", true
|
||||
case "INVALID_API_KEY":
|
||||
return "invalid_key", true
|
||||
case "API_KEY_DISABLED":
|
||||
return "key_disabled", true
|
||||
case "USER_INACTIVE":
|
||||
return "user_inactive", true
|
||||
case "GROUP_DELETED":
|
||||
return "group_deleted", true
|
||||
case "GROUP_DISABLED":
|
||||
return "group_disabled", true
|
||||
case "GROUP_NOT_ALLOWED":
|
||||
return "group_forbidden", true
|
||||
case "ACCESS_DENIED":
|
||||
return "ip_acl_denied", true
|
||||
case "api_key_in_query_deprecated":
|
||||
return "query_key_deprecated", true
|
||||
}
|
||||
|
||||
normalized := strings.TrimSpace(message)
|
||||
switch {
|
||||
case normalized == "API key is required":
|
||||
return "missing_key", true
|
||||
case normalized == "Invalid API key":
|
||||
return "invalid_key", true
|
||||
case normalized == "API key is disabled":
|
||||
return "key_disabled", true
|
||||
case normalized == "User account is not active":
|
||||
return "user_inactive", true
|
||||
case normalized == "API Key 所属分组已删除":
|
||||
return "group_deleted", true
|
||||
case normalized == "API Key 所属分组已停用":
|
||||
return "group_disabled", true
|
||||
case normalized == "API Key 所属专属分组不再允许当前用户使用":
|
||||
return "group_forbidden", true
|
||||
case normalized == "API Key is not assigned to any group and cannot be used. Please contact the administrator to assign it to a group.":
|
||||
return "group_unassigned", true
|
||||
case strings.HasPrefix(normalized, "Access denied. Your IP is "):
|
||||
return "ip_acl_denied", true
|
||||
case normalized == "Query parameter api_key is deprecated. Use Authorization header or key instead.":
|
||||
return "query_key_deprecated", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func parseErrorIdentity(body, fallbackMessage string) (string, string) {
|
||||
var payload struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Error struct {
|
||||
Code json.RawMessage `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &payload); err != nil {
|
||||
return "", fallbackMessage
|
||||
}
|
||||
message := payload.Message
|
||||
if message == "" {
|
||||
message = payload.Error.Message
|
||||
}
|
||||
if message == "" {
|
||||
message = fallbackMessage
|
||||
}
|
||||
return strings.TrimSpace(payload.Code), message
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHistoricalIngressRejectReason(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item candidate
|
||||
reason string
|
||||
match bool
|
||||
}{
|
||||
{name: "standard invalid key", item: candidate{body: `{"code":"INVALID_API_KEY","message":"Invalid API key"}`}, reason: "invalid_key", match: true},
|
||||
{name: "google missing key", item: candidate{body: `{"error":{"code":401,"message":"API key is required","status":"UNAUTHENTICATED"}}`}, reason: "missing_key", match: true},
|
||||
{name: "google group deleted", item: candidate{body: `{"error":{"code":403,"message":"API Key 所属分组已删除","status":"PERMISSION_DENIED"}}`}, reason: "group_deleted", match: true},
|
||||
{name: "ip acl", item: candidate{body: `{"code":"ACCESS_DENIED","message":"Access denied. Your IP is 192.0.2.1"}`}, reason: "ip_acl_denied", match: true},
|
||||
{name: "user not found remains", item: candidate{body: `{"code":"USER_NOT_FOUND","message":"User associated with API key not found"}`}, match: false},
|
||||
{name: "quota remains", item: candidate{body: `{"code":"API_KEY_QUOTA_EXHAUSTED","message":"quota"}`}, match: false},
|
||||
{name: "database failure remains", item: candidate{statusCode: 500, message: "Failed to validate API key", body: `{"code":"INTERNAL_ERROR","message":"Failed to validate API key"}`}, match: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reason, ok := historicalIngressRejectReason(tt.item)
|
||||
if ok != tt.match || reason != tt.reason {
|
||||
t.Fatalf("got (%q, %v), want (%q, %v)", reason, ok, tt.reason, tt.match)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func main() {
|
||||
log.Fatalf("failed to resolve admin user: %v", err)
|
||||
}
|
||||
|
||||
token, err := authService.GenerateToken(user)
|
||||
token, err := authService.GenerateToken(ctx, user)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.153
|
||||
0.1.162
|
||||
|
||||
@@ -153,6 +153,15 @@ func runMainServer() {
|
||||
log.Fatalf("Failed to initialize application: %v", err)
|
||||
}
|
||||
defer app.Cleanup()
|
||||
if app.PromptAudit != nil {
|
||||
if err := app.PromptAudit.Start(context.Background()); err != nil {
|
||||
// Startup continues so unrelated APIs stay up. Fail-closed (unavailable)
|
||||
// applies only when a persisted blocking policy was observed; without
|
||||
// blocking intent, Prompt Audit stays ModeOff so the gateway remains
|
||||
// usable and administrators can still disable the feature (#4560).
|
||||
log.Printf("Prompt Audit started in degraded state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 启动服务器
|
||||
go func() {
|
||||
@@ -174,7 +183,7 @@ func runMainServer() {
|
||||
defer cancel()
|
||||
|
||||
if err := app.Server.Shutdown(ctx); err != nil {
|
||||
log.Fatalf("Server forced to shutdown: %v", err)
|
||||
log.Printf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Server exited")
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/Wei-Shaw/sub2api/internal/repository"
|
||||
"github.com/Wei-Shaw/sub2api/internal/securityaudit"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -24,8 +25,9 @@ import (
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
Server *http.Server
|
||||
Cleanup func()
|
||||
Server *http.Server
|
||||
PromptAudit *securityaudit.PromptService
|
||||
Cleanup func()
|
||||
}
|
||||
|
||||
func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
@@ -36,6 +38,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
// Business layer ProviderSets
|
||||
repository.ProviderSet,
|
||||
service.ProviderSet,
|
||||
securityaudit.ProviderSet,
|
||||
payment.ProviderSet,
|
||||
middleware.ProviderSet,
|
||||
handler.ProviderSet,
|
||||
@@ -53,7 +56,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
provideCleanup,
|
||||
|
||||
// Application struct
|
||||
wire.Struct(new(Application), "Server", "Cleanup"),
|
||||
wire.Struct(new(Application), "Server", "PromptAudit", "Cleanup"),
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
@@ -78,6 +81,10 @@ func provideCleanup(
|
||||
opsCleanup *service.OpsCleanupService,
|
||||
opsScheduledReport *service.OpsScheduledReportService,
|
||||
opsSystemLogSink *service.OpsSystemLogSink,
|
||||
opsService *service.OpsService,
|
||||
opsIngressReject *service.OpsIngressRejectAggregator,
|
||||
apiKeyService *service.APIKeyService,
|
||||
authCacheInvalidationWorker *service.AuthCacheInvalidationWorker,
|
||||
schedulerSnapshot *service.SchedulerSnapshotService,
|
||||
tokenRefresh *service.TokenRefreshService,
|
||||
accountExpiry *service.AccountExpiryService,
|
||||
@@ -103,6 +110,9 @@ func provideCleanup(
|
||||
paymentOrderExpiry *service.PaymentOrderExpiryService,
|
||||
channelMonitorRunner *service.ChannelMonitorRunner,
|
||||
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
|
||||
upstreamBillingProbe *service.UpstreamBillingProbeService,
|
||||
auditLog *service.AuditLogService,
|
||||
promptAudit *securityaudit.PromptService,
|
||||
) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -115,6 +125,36 @@ func provideCleanup(
|
||||
|
||||
// 应用层清理步骤可并行执行,基础设施资源(Redis/Ent)最后按顺序关闭。
|
||||
parallelSteps := []cleanupStep{
|
||||
{"OpsIngressRejectAggregator", func() error {
|
||||
if opsIngressReject != nil {
|
||||
opsIngressReject.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"AuthCacheInvalidationWorker", func() error {
|
||||
if authCacheInvalidationWorker != nil {
|
||||
authCacheInvalidationWorker.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"AuthCacheInvalidationSubscriber", func() error {
|
||||
if apiKeyService != nil {
|
||||
apiKeyService.StopAuthCacheInvalidationSubscriber()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OpsRuntimeSettingsRefresh", func() error {
|
||||
if opsService != nil {
|
||||
opsService.StopRuntimeSettingsRefresh()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"PromptAuditService", func() error {
|
||||
if promptAudit != nil {
|
||||
return promptAudit.Shutdown(ctx)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OpsScheduledReportService", func() error {
|
||||
if opsScheduledReport != nil {
|
||||
opsScheduledReport.Stop()
|
||||
@@ -133,6 +173,12 @@ func provideCleanup(
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"AuditLogService", func() error {
|
||||
if auditLog != nil {
|
||||
auditLog.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OpsAlertEvaluatorService", func() error {
|
||||
if opsAlertEvaluator != nil {
|
||||
opsAlertEvaluator.Stop()
|
||||
@@ -279,6 +325,12 @@ func provideCleanup(
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"UpstreamBillingProbeService", func() error {
|
||||
if upstreamBillingProbe != nil {
|
||||
upstreamBillingProbe.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
|
||||
infraSteps := []cleanupStep{
|
||||
|
||||
+110
-33
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/Wei-Shaw/sub2api/internal/repository"
|
||||
"github.com/Wei-Shaw/sub2api/internal/securityaudit"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -96,9 +97,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
usageLogRepository := repository.NewUsageLogRepository(client, db)
|
||||
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
|
||||
opsRepository := repository.NewOpsRepository(db)
|
||||
batchImageRepository := repository.NewBatchImageRepository(db)
|
||||
batchImageQueue := repository.NewBatchImageQueue(redisClient, configConfig)
|
||||
batchImageDownloadLimiter := repository.NewBatchImageDownloadLimiter(redisClient, configConfig)
|
||||
usageBillingRepository := repository.NewUsageBillingRepository(client, db)
|
||||
gatewayCache := repository.NewGatewayCache(redisClient)
|
||||
schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
|
||||
@@ -137,11 +135,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
channelRepository := repository.NewChannelRepository(db)
|
||||
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
|
||||
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
|
||||
batchImageModelPricingResolver := service.ProvideBatchImageModelPricingResolver(modelPricingResolver)
|
||||
batchImagePublicService := service.NewBatchImagePublicService(batchImageRepository, accountRepository, groupRepository, userGroupRateRepository, batchImageQueue, batchImageModelPricingResolver, usageBillingRepository, apiKeyAuthCacheInvalidator, configConfig)
|
||||
batchImageDownloadService := service.NewBatchImageDownloadService(batchImageRepository, accountRepository, batchImageDownloadLimiter, configConfig)
|
||||
batchImageCleanupService := service.ProvideBatchImageCleanupService(batchImageRepository, accountRepository, configConfig)
|
||||
batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig)
|
||||
notificationEmailService := service.NewNotificationEmailService(settingRepository, emailService)
|
||||
balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository, notificationEmailService)
|
||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService, serviceUserPlatformQuotaRepository)
|
||||
@@ -164,7 +157,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache)
|
||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
|
||||
opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository)
|
||||
opsService := service.ProvideOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink, settingService)
|
||||
authCacheInvalidationOutboxRepository := repository.NewAuthCacheInvalidationOutboxRepository(db)
|
||||
authCacheInvalidationWorker := service.ProvideAuthCacheInvalidationWorker(authCacheInvalidationOutboxRepository, apiKeyCache, apiKeyService)
|
||||
opsService := service.ProvideOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink, settingService, authCacheInvalidationWorker, apiKeyService)
|
||||
usageHandler := handler.NewUsageHandler(usageService, apiKeyService, opsService, settingService)
|
||||
redeemHandler := handler.NewRedeemHandler(redeemService)
|
||||
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
|
||||
@@ -181,34 +176,39 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
leaderLockCache := repository.NewLeaderLockCache(redisClient)
|
||||
dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, leaderLockCache, db, configConfig)
|
||||
dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService)
|
||||
adminGroupRepository := repository.NewAdminGroupRepository(client, db)
|
||||
adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache)
|
||||
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
|
||||
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
|
||||
adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService)
|
||||
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache)
|
||||
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService)
|
||||
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService, settingService)
|
||||
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
|
||||
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
|
||||
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
|
||||
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
|
||||
grokQuotaFetcher := service.NewGrokQuotaFetcher()
|
||||
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, usageLogRepository)
|
||||
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory)
|
||||
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository)
|
||||
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService)
|
||||
usageCache := service.NewUsageCache()
|
||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
|
||||
accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService)
|
||||
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||
accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
|
||||
accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
|
||||
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
||||
dataManagementService := service.NewDataManagementService()
|
||||
dataManagementHandler := admin.NewDataManagementHandler(dataManagementService)
|
||||
backupObjectStoreFactory := repository.NewS3BackupStoreFactory()
|
||||
dbDumper := repository.NewPgDumper(configConfig)
|
||||
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper)
|
||||
backupHandler := admin.NewBackupHandler(backupService, userService)
|
||||
imageStorageFactory := repository.ProvideImageStorageFactory()
|
||||
imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig)
|
||||
backupHandler := admin.NewBackupHandler(backupService, userService, imageStorageSettingService)
|
||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService)
|
||||
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
|
||||
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
|
||||
grokOAuthHandler := admin.NewGrokOAuthHandler(grokOAuthService, adminService, grokQuotaService)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
|
||||
grokOAuthHandler := admin.NewGrokOAuthHandler(grokOAuthService, adminService, grokQuotaService, tokenRefreshService)
|
||||
proxyHandler := admin.NewProxyHandler(adminService)
|
||||
adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService)
|
||||
promoHandler := admin.NewPromoHandler(promoService)
|
||||
@@ -220,7 +220,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
registry := payment.ProvideRegistry()
|
||||
defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey)
|
||||
paymentService := service.ProvidePaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService, notificationEmailService)
|
||||
settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService)
|
||||
settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService, totpService, userService)
|
||||
opsHandler := admin.NewOpsHandler(opsService)
|
||||
updateCache := repository.NewUpdateCache(redisClient)
|
||||
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
|
||||
@@ -253,46 +253,73 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
contentModerationHashCache := repository.NewContentModerationHashCache(redisClient)
|
||||
contentModerationService := service.NewContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, apiKeyAuthCacheInvalidator, emailService)
|
||||
contentModerationHandler := admin.NewContentModerationHandler(contentModerationService)
|
||||
configManager := securityaudit.NewConfigManager(db, settingRepository, redisClient, secretEncryptor)
|
||||
postgreSQLRepository := securityaudit.NewPostgreSQLRepository(db)
|
||||
redisPayloadStore := securityaudit.NewRedisPayloadStore(redisClient)
|
||||
openAICompatibleScanner := securityaudit.NewOpenAICompatibleScanner()
|
||||
atomicMetrics := securityaudit.NewAtomicMetrics()
|
||||
promptService := securityaudit.NewPromptService(configManager, postgreSQLRepository, redisPayloadStore, openAICompatibleScanner, atomicMetrics)
|
||||
promptAdminHandler := securityaudit.NewPromptAdminHandler(promptService)
|
||||
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
|
||||
affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService)
|
||||
complianceHandler := admin.NewComplianceHandler(settingService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler)
|
||||
auditLogRepository := repository.NewAuditLogRepository(db)
|
||||
auditLogService := service.ProvideAuditLogService(auditLogRepository, settingService)
|
||||
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
|
||||
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService)
|
||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService)
|
||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, configConfig)
|
||||
legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService)
|
||||
coordinator := securityaudit.NewCoordinator(legacyEngine, promptService)
|
||||
gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator)
|
||||
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator)
|
||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
|
||||
totpHandler := handler.NewTotpHandler(totpService)
|
||||
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService)
|
||||
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
|
||||
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
|
||||
batchImageHandler := handler.NewBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService)
|
||||
imageTaskStore := repository.NewImageTaskStore(redisClient)
|
||||
imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorageSettingService)
|
||||
asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler)
|
||||
batchImageRepository := repository.NewBatchImageRepository(db)
|
||||
batchImageQueue := repository.NewBatchImageQueue(redisClient, configConfig)
|
||||
batchImageModelPricingResolver := service.ProvideBatchImageModelPricingResolver(modelPricingResolver)
|
||||
batchImagePublicService := service.NewBatchImagePublicService(batchImageRepository, accountRepository, groupRepository, userGroupRateRepository, batchImageQueue, batchImageModelPricingResolver, usageBillingRepository, apiKeyAuthCacheInvalidator, configConfig)
|
||||
batchImageDownloadLimiter := repository.NewBatchImageDownloadLimiter(redisClient, configConfig)
|
||||
batchImageDownloadService := service.NewBatchImageDownloadService(batchImageRepository, accountRepository, batchImageDownloadLimiter, configConfig)
|
||||
batchImageCleanupService := service.ProvideBatchImageCleanupService(batchImageRepository, accountRepository, configConfig)
|
||||
batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler)
|
||||
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
|
||||
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
|
||||
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
|
||||
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
|
||||
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService)
|
||||
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService)
|
||||
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
|
||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, redisClient)
|
||||
auditLogMiddleware := middleware.NewAuditLogMiddleware(auditLogService)
|
||||
stepUpAuthMiddleware := middleware.NewStepUpAuthMiddleware(totpService, userService, settingService)
|
||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, redisClient)
|
||||
httpServer := server.ProvideHTTPServer(configConfig, engine)
|
||||
opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig)
|
||||
opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig)
|
||||
opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig, proxyRepository)
|
||||
opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig, channelMonitorService, settingRepository, opsService)
|
||||
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
|
||||
opsIngressRejectAggregator := service.ProvideOpsIngressRejectAggregator(opsRepository, opsService)
|
||||
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
||||
proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository)
|
||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db)
|
||||
batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig)
|
||||
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
||||
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db)
|
||||
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
|
||||
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, auditLogService, promptService)
|
||||
application := &Application{
|
||||
Server: httpServer,
|
||||
Cleanup: v,
|
||||
Server: httpServer,
|
||||
PromptAudit: promptService,
|
||||
Cleanup: v,
|
||||
}
|
||||
return application, nil
|
||||
}
|
||||
@@ -300,8 +327,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
// wire.go:
|
||||
|
||||
type Application struct {
|
||||
Server *http.Server
|
||||
Cleanup func()
|
||||
Server *http.Server
|
||||
PromptAudit *securityaudit.PromptService
|
||||
Cleanup func()
|
||||
}
|
||||
|
||||
func providePrivacyClientFactory() service.PrivacyClientFactory {
|
||||
@@ -324,6 +352,10 @@ func provideCleanup(
|
||||
opsCleanup *service.OpsCleanupService,
|
||||
opsScheduledReport *service.OpsScheduledReportService,
|
||||
opsSystemLogSink *service.OpsSystemLogSink,
|
||||
opsService *service.OpsService,
|
||||
opsIngressReject *service.OpsIngressRejectAggregator,
|
||||
apiKeyService *service.APIKeyService,
|
||||
authCacheInvalidationWorker *service.AuthCacheInvalidationWorker,
|
||||
schedulerSnapshot *service.SchedulerSnapshotService,
|
||||
tokenRefresh *service.TokenRefreshService,
|
||||
accountExpiry *service.AccountExpiryService,
|
||||
@@ -349,6 +381,9 @@ func provideCleanup(
|
||||
paymentOrderExpiry *service.PaymentOrderExpiryService,
|
||||
channelMonitorRunner *service.ChannelMonitorRunner,
|
||||
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
|
||||
upstreamBillingProbe *service.UpstreamBillingProbeService,
|
||||
auditLog *service.AuditLogService,
|
||||
promptAudit *securityaudit.PromptService,
|
||||
) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -360,6 +395,36 @@ func provideCleanup(
|
||||
}
|
||||
|
||||
parallelSteps := []cleanupStep{
|
||||
{"OpsIngressRejectAggregator", func() error {
|
||||
if opsIngressReject != nil {
|
||||
opsIngressReject.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"AuthCacheInvalidationWorker", func() error {
|
||||
if authCacheInvalidationWorker != nil {
|
||||
authCacheInvalidationWorker.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"AuthCacheInvalidationSubscriber", func() error {
|
||||
if apiKeyService != nil {
|
||||
apiKeyService.StopAuthCacheInvalidationSubscriber()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OpsRuntimeSettingsRefresh", func() error {
|
||||
if opsService != nil {
|
||||
opsService.StopRuntimeSettingsRefresh()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"PromptAuditService", func() error {
|
||||
if promptAudit != nil {
|
||||
return promptAudit.Shutdown(ctx)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OpsScheduledReportService", func() error {
|
||||
if opsScheduledReport != nil {
|
||||
opsScheduledReport.Stop()
|
||||
@@ -378,6 +443,12 @@ func provideCleanup(
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"AuditLogService", func() error {
|
||||
if auditLog != nil {
|
||||
auditLog.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OpsAlertEvaluatorService", func() error {
|
||||
if opsAlertEvaluator != nil {
|
||||
opsAlertEvaluator.Stop()
|
||||
@@ -524,6 +595,12 @@ func provideCleanup(
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"UpstreamBillingProbeService", func() error {
|
||||
if upstreamBillingProbe != nil {
|
||||
upstreamBillingProbe.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
|
||||
infraSteps := []cleanupStep{
|
||||
|
||||
@@ -58,6 +58,10 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
&service.OpsCleanupService{},
|
||||
&service.OpsScheduledReportService{},
|
||||
opsSystemLogSinkSvc,
|
||||
nil, // opsService
|
||||
nil, // opsIngressRejectAggregator
|
||||
nil, // apiKeyService
|
||||
nil, // authCacheInvalidationWorker
|
||||
schedulerSnapshotSvc,
|
||||
tokenRefreshSvc,
|
||||
accountExpirySvc,
|
||||
@@ -83,6 +87,9 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
nil, // paymentOrderExpiry
|
||||
nil, // channelMonitorRunner
|
||||
nil, // quotaFlusher
|
||||
nil, // upstreamBillingProbe
|
||||
nil, // auditLog
|
||||
nil, // promptAudit
|
||||
)
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
|
||||
+40
-2
@@ -43,6 +43,8 @@ type Group struct {
|
||||
IsExclusive bool `json:"is_exclusive,omitempty"`
|
||||
// Status holds the value of the "status" field.
|
||||
Status string `json:"status,omitempty"`
|
||||
// 内部幂等恢复标识,不对 API 暴露
|
||||
DuplicateOperationID *string `json:"duplicate_operation_id,omitempty"`
|
||||
// Platform holds the value of the "platform" field.
|
||||
Platform string `json:"platform,omitempty"`
|
||||
// SubscriptionType holds the value of the "subscription_type" field.
|
||||
@@ -115,6 +117,10 @@ type Group struct {
|
||||
ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config,omitempty"`
|
||||
// 分组 RPM 上限,0 表示不限制;设置后接管该分组用户的限流
|
||||
RpmLimit int `json:"rpm_limit,omitempty"`
|
||||
// OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"`
|
||||
// OpenAI reasoning effort 自定义精确映射;先映射再应用上限
|
||||
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings,omitempty"`
|
||||
// Edges holds the relations/edges for other nodes in the graph.
|
||||
// The values are being populated by the GroupQuery when eager-loading is set.
|
||||
Edges GroupEdges `json:"edges"`
|
||||
@@ -221,7 +227,7 @@ func (*Group) scanValues(columns []string) ([]any, error) {
|
||||
values := make([]any, len(columns))
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig:
|
||||
case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings:
|
||||
values[i] = new([]byte)
|
||||
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet:
|
||||
values[i] = new(sql.NullBool)
|
||||
@@ -229,7 +235,7 @@ func (*Group) scanValues(columns []string) ([]any, error) {
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel:
|
||||
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel, group.FieldMaxReasoningEffort:
|
||||
values[i] = new(sql.NullString)
|
||||
case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
@@ -328,6 +334,13 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
} else if value.Valid {
|
||||
_m.Status = value.String
|
||||
}
|
||||
case group.FieldDuplicateOperationID:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field duplicate_operation_id", values[i])
|
||||
} else if value.Valid {
|
||||
_m.DuplicateOperationID = new(string)
|
||||
*_m.DuplicateOperationID = value.String
|
||||
}
|
||||
case group.FieldPlatform:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field platform", values[i])
|
||||
@@ -564,6 +577,20 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
} else if value.Valid {
|
||||
_m.RpmLimit = int(value.Int64)
|
||||
}
|
||||
case group.FieldMaxReasoningEffort:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field max_reasoning_effort", values[i])
|
||||
} else if value.Valid {
|
||||
_m.MaxReasoningEffort = value.String
|
||||
}
|
||||
case group.FieldReasoningEffortMappings:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field reasoning_effort_mappings", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.ReasoningEffortMappings); err != nil {
|
||||
return fmt.Errorf("unmarshal field reasoning_effort_mappings: %w", err)
|
||||
}
|
||||
}
|
||||
default:
|
||||
_m.selectValues.Set(columns[i], values[i])
|
||||
}
|
||||
@@ -680,6 +707,11 @@ func (_m *Group) String() string {
|
||||
builder.WriteString("status=")
|
||||
builder.WriteString(_m.Status)
|
||||
builder.WriteString(", ")
|
||||
if v := _m.DuplicateOperationID; v != nil {
|
||||
builder.WriteString("duplicate_operation_id=")
|
||||
builder.WriteString(*v)
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("platform=")
|
||||
builder.WriteString(_m.Platform)
|
||||
builder.WriteString(", ")
|
||||
@@ -811,6 +843,12 @@ func (_m *Group) String() string {
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("rpm_limit=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.RpmLimit))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("max_reasoning_effort=")
|
||||
builder.WriteString(_m.MaxReasoningEffort)
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("reasoning_effort_mappings=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ReasoningEffortMappings))
|
||||
builder.WriteByte(')')
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ const (
|
||||
FieldIsExclusive = "is_exclusive"
|
||||
// FieldStatus holds the string denoting the status field in the database.
|
||||
FieldStatus = "status"
|
||||
// FieldDuplicateOperationID holds the string denoting the duplicate_operation_id field in the database.
|
||||
FieldDuplicateOperationID = "duplicate_operation_id"
|
||||
// FieldPlatform holds the string denoting the platform field in the database.
|
||||
FieldPlatform = "platform"
|
||||
// FieldSubscriptionType holds the string denoting the subscription_type field in the database.
|
||||
@@ -112,6 +114,10 @@ const (
|
||||
FieldModelsListConfig = "models_list_config"
|
||||
// FieldRpmLimit holds the string denoting the rpm_limit field in the database.
|
||||
FieldRpmLimit = "rpm_limit"
|
||||
// FieldMaxReasoningEffort holds the string denoting the max_reasoning_effort field in the database.
|
||||
FieldMaxReasoningEffort = "max_reasoning_effort"
|
||||
// FieldReasoningEffortMappings holds the string denoting the reasoning_effort_mappings field in the database.
|
||||
FieldReasoningEffortMappings = "reasoning_effort_mappings"
|
||||
// EdgeAPIKeys holds the string denoting the api_keys edge name in mutations.
|
||||
EdgeAPIKeys = "api_keys"
|
||||
// EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations.
|
||||
@@ -199,6 +205,7 @@ var Columns = []string{
|
||||
FieldPeakRateMultiplier,
|
||||
FieldIsExclusive,
|
||||
FieldStatus,
|
||||
FieldDuplicateOperationID,
|
||||
FieldPlatform,
|
||||
FieldSubscriptionType,
|
||||
FieldDailyLimitUsd,
|
||||
@@ -235,6 +242,8 @@ var Columns = []string{
|
||||
FieldMessagesDispatchModelConfig,
|
||||
FieldModelsListConfig,
|
||||
FieldRpmLimit,
|
||||
FieldMaxReasoningEffort,
|
||||
FieldReasoningEffortMappings,
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -292,6 +301,8 @@ var (
|
||||
DefaultStatus string
|
||||
// StatusValidator is a validator for the "status" field. It is called by the builders before save.
|
||||
StatusValidator func(string) error
|
||||
// DuplicateOperationIDValidator is a validator for the "duplicate_operation_id" field. It is called by the builders before save.
|
||||
DuplicateOperationIDValidator func(string) error
|
||||
// DefaultPlatform holds the default value on creation for the "platform" field.
|
||||
DefaultPlatform string
|
||||
// PlatformValidator is a validator for the "platform" field. It is called by the builders before save.
|
||||
@@ -344,6 +355,12 @@ var (
|
||||
DefaultModelsListConfig domain.GroupModelsListConfig
|
||||
// DefaultRpmLimit holds the default value on creation for the "rpm_limit" field.
|
||||
DefaultRpmLimit int
|
||||
// DefaultMaxReasoningEffort holds the default value on creation for the "max_reasoning_effort" field.
|
||||
DefaultMaxReasoningEffort string
|
||||
// MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
|
||||
MaxReasoningEffortValidator func(string) error
|
||||
// DefaultReasoningEffortMappings holds the default value on creation for the "reasoning_effort_mappings" field.
|
||||
DefaultReasoningEffortMappings []domain.ReasoningEffortMapping
|
||||
)
|
||||
|
||||
// OrderOption defines the ordering options for the Group queries.
|
||||
@@ -414,6 +431,11 @@ func ByStatus(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldStatus, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByDuplicateOperationID orders the results by the duplicate_operation_id field.
|
||||
func ByDuplicateOperationID(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldDuplicateOperationID, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByPlatform orders the results by the platform field.
|
||||
func ByPlatform(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldPlatform, opts...).ToFunc()
|
||||
@@ -574,6 +596,11 @@ func ByRpmLimit(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldRpmLimit, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByMaxReasoningEffort orders the results by the max_reasoning_effort field.
|
||||
func ByMaxReasoningEffort(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldMaxReasoningEffort, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByAPIKeysCount orders the results by api_keys count.
|
||||
func ByAPIKeysCount(opts ...sql.OrderTermOption) OrderOption {
|
||||
return func(s *sql.Selector) {
|
||||
|
||||
@@ -115,6 +115,11 @@ func Status(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldStatus, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationID applies equality check predicate on the "duplicate_operation_id" field. It's identical to DuplicateOperationIDEQ.
|
||||
func DuplicateOperationID(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// Platform applies equality check predicate on the "platform" field. It's identical to PlatformEQ.
|
||||
func Platform(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldPlatform, v))
|
||||
@@ -275,6 +280,11 @@ func RpmLimit(v int) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldRpmLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffort applies equality check predicate on the "max_reasoning_effort" field. It's identical to MaxReasoningEffortEQ.
|
||||
func MaxReasoningEffort(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
|
||||
func CreatedAtEQ(v time.Time) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldCreatedAt, v))
|
||||
@@ -840,6 +850,81 @@ func StatusContainsFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContainsFold(FieldStatus, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDEQ applies the EQ predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDNEQ applies the NEQ predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDNEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDIn applies the In predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldDuplicateOperationID, vs...))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDNotIn applies the NotIn predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDNotIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldDuplicateOperationID, vs...))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDGT applies the GT predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDGT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDGTE applies the GTE predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDGTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDLT applies the LT predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDLT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDLTE applies the LTE predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDLTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDContains applies the Contains predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDContains(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContains(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDHasPrefix applies the HasPrefix predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDHasPrefix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasPrefix(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDHasSuffix applies the HasSuffix predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDHasSuffix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasSuffix(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDIsNil applies the IsNil predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDIsNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldIsNull(FieldDuplicateOperationID))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDNotNil applies the NotNil predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDNotNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldNotNull(FieldDuplicateOperationID))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDEqualFold applies the EqualFold predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDEqualFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEqualFold(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDContainsFold applies the ContainsFold predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDContainsFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContainsFold(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// PlatformEQ applies the EQ predicate on the "platform" field.
|
||||
func PlatformEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldPlatform, v))
|
||||
@@ -2025,6 +2110,71 @@ func RpmLimitLTE(v int) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldRpmLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortEQ applies the EQ predicate on the "max_reasoning_effort" field.
|
||||
func MaxReasoningEffortEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortNEQ applies the NEQ predicate on the "max_reasoning_effort" field.
|
||||
func MaxReasoningEffortNEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortIn applies the In predicate on the "max_reasoning_effort" field.
|
||||
func MaxReasoningEffortIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldMaxReasoningEffort, vs...))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortNotIn applies the NotIn predicate on the "max_reasoning_effort" field.
|
||||
func MaxReasoningEffortNotIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldMaxReasoningEffort, vs...))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortGT applies the GT predicate on the "max_reasoning_effort" field.
|
||||
func MaxReasoningEffortGT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortGTE applies the GTE predicate on the "max_reasoning_effort" field.
|
||||
func MaxReasoningEffortGTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortLT applies the LT predicate on the "max_reasoning_effort" field.
|
||||
func MaxReasoningEffortLT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortLTE applies the LTE predicate on the "max_reasoning_effort" field.
|
||||
func MaxReasoningEffortLTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortContains applies the Contains predicate on the "max_reasoning_effort" field.
|
||||
func MaxReasoningEffortContains(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContains(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortHasPrefix applies the HasPrefix predicate on the "max_reasoning_effort" field.
|
||||
func MaxReasoningEffortHasPrefix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasPrefix(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortHasSuffix applies the HasSuffix predicate on the "max_reasoning_effort" field.
|
||||
func MaxReasoningEffortHasSuffix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasSuffix(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortEqualFold applies the EqualFold predicate on the "max_reasoning_effort" field.
|
||||
func MaxReasoningEffortEqualFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEqualFold(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortContainsFold applies the ContainsFold predicate on the "max_reasoning_effort" field.
|
||||
func MaxReasoningEffortContainsFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// HasAPIKeys applies the HasEdge predicate on the "api_keys" edge.
|
||||
func HasAPIKeys() predicate.Group {
|
||||
return predicate.Group(func(s *sql.Selector) {
|
||||
|
||||
@@ -189,6 +189,20 @@ func (_c *GroupCreate) SetNillableStatus(v *string) *GroupCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetDuplicateOperationID sets the "duplicate_operation_id" field.
|
||||
func (_c *GroupCreate) SetDuplicateOperationID(v string) *GroupCreate {
|
||||
_c.mutation.SetDuplicateOperationID(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableDuplicateOperationID sets the "duplicate_operation_id" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableDuplicateOperationID(v *string) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetDuplicateOperationID(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetPlatform sets the "platform" field.
|
||||
func (_c *GroupCreate) SetPlatform(v string) *GroupCreate {
|
||||
_c.mutation.SetPlatform(v)
|
||||
@@ -677,6 +691,26 @@ func (_c *GroupCreate) SetNillableRpmLimit(v *int) *GroupCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffort sets the "max_reasoning_effort" field.
|
||||
func (_c *GroupCreate) SetMaxReasoningEffort(v string) *GroupCreate {
|
||||
_c.mutation.SetMaxReasoningEffort(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableMaxReasoningEffort sets the "max_reasoning_effort" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableMaxReasoningEffort(v *string) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetMaxReasoningEffort(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (_c *GroupCreate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupCreate {
|
||||
_c.mutation.SetReasoningEffortMappings(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
|
||||
func (_c *GroupCreate) AddAPIKeyIDs(ids ...int64) *GroupCreate {
|
||||
_c.mutation.AddAPIKeyIDs(ids...)
|
||||
@@ -938,6 +972,14 @@ func (_c *GroupCreate) defaults() error {
|
||||
v := group.DefaultRpmLimit
|
||||
_c.mutation.SetRpmLimit(v)
|
||||
}
|
||||
if _, ok := _c.mutation.MaxReasoningEffort(); !ok {
|
||||
v := group.DefaultMaxReasoningEffort
|
||||
_c.mutation.SetMaxReasoningEffort(v)
|
||||
}
|
||||
if _, ok := _c.mutation.ReasoningEffortMappings(); !ok {
|
||||
v := group.DefaultReasoningEffortMappings
|
||||
_c.mutation.SetReasoningEffortMappings(v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -993,6 +1035,11 @@ func (_c *GroupCreate) check() error {
|
||||
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Group.status": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _c.mutation.DuplicateOperationID(); ok {
|
||||
if err := group.DuplicateOperationIDValidator(v); err != nil {
|
||||
return &ValidationError{Name: "duplicate_operation_id", err: fmt.Errorf(`ent: validator failed for field "Group.duplicate_operation_id": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.Platform(); !ok {
|
||||
return &ValidationError{Name: "platform", err: errors.New(`ent: missing required field "Group.platform"`)}
|
||||
}
|
||||
@@ -1077,6 +1124,17 @@ func (_c *GroupCreate) check() error {
|
||||
if _, ok := _c.mutation.RpmLimit(); !ok {
|
||||
return &ValidationError{Name: "rpm_limit", err: errors.New(`ent: missing required field "Group.rpm_limit"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.MaxReasoningEffort(); !ok {
|
||||
return &ValidationError{Name: "max_reasoning_effort", err: errors.New(`ent: missing required field "Group.max_reasoning_effort"`)}
|
||||
}
|
||||
if v, ok := _c.mutation.MaxReasoningEffort(); ok {
|
||||
if err := group.MaxReasoningEffortValidator(v); err != nil {
|
||||
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.ReasoningEffortMappings(); !ok {
|
||||
return &ValidationError{Name: "reasoning_effort_mappings", err: errors.New(`ent: missing required field "Group.reasoning_effort_mappings"`)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1152,6 +1210,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldStatus, field.TypeString, value)
|
||||
_node.Status = value
|
||||
}
|
||||
if value, ok := _c.mutation.DuplicateOperationID(); ok {
|
||||
_spec.SetField(group.FieldDuplicateOperationID, field.TypeString, value)
|
||||
_node.DuplicateOperationID = &value
|
||||
}
|
||||
if value, ok := _c.mutation.Platform(); ok {
|
||||
_spec.SetField(group.FieldPlatform, field.TypeString, value)
|
||||
_node.Platform = value
|
||||
@@ -1296,6 +1358,14 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
|
||||
_node.RpmLimit = value
|
||||
}
|
||||
if value, ok := _c.mutation.MaxReasoningEffort(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
|
||||
_node.MaxReasoningEffort = value
|
||||
}
|
||||
if value, ok := _c.mutation.ReasoningEffortMappings(); ok {
|
||||
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
|
||||
_node.ReasoningEffortMappings = value
|
||||
}
|
||||
if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
@@ -2232,6 +2302,30 @@ func (u *GroupUpsert) AddRpmLimit(v int) *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffort sets the "max_reasoning_effort" field.
|
||||
func (u *GroupUpsert) SetMaxReasoningEffort(v string) *GroupUpsert {
|
||||
u.Set(group.FieldMaxReasoningEffort, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateMaxReasoningEffort sets the "max_reasoning_effort" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateMaxReasoningEffort() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldMaxReasoningEffort)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (u *GroupUpsert) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsert {
|
||||
u.Set(group.FieldReasoningEffortMappings, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateReasoningEffortMappings sets the "reasoning_effort_mappings" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateReasoningEffortMappings() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldReasoningEffortMappings)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateNewValues updates the mutable fields using the new values that were set on create.
|
||||
// Using this option is equivalent to using:
|
||||
//
|
||||
@@ -2246,6 +2340,9 @@ func (u *GroupUpsertOne) UpdateNewValues() *GroupUpsertOne {
|
||||
if _, exists := u.create.mutation.CreatedAt(); exists {
|
||||
s.SetIgnore(group.FieldCreatedAt)
|
||||
}
|
||||
if _, exists := u.create.mutation.DuplicateOperationID(); exists {
|
||||
s.SetIgnore(group.FieldDuplicateOperationID)
|
||||
}
|
||||
}))
|
||||
return u
|
||||
}
|
||||
@@ -3187,6 +3284,34 @@ func (u *GroupUpsertOne) UpdateRpmLimit() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffort sets the "max_reasoning_effort" field.
|
||||
func (u *GroupUpsertOne) SetMaxReasoningEffort(v string) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetMaxReasoningEffort(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateMaxReasoningEffort sets the "max_reasoning_effort" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateMaxReasoningEffort() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateMaxReasoningEffort()
|
||||
})
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (u *GroupUpsertOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetReasoningEffortMappings(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateReasoningEffortMappings sets the "reasoning_effort_mappings" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateReasoningEffortMappings() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateReasoningEffortMappings()
|
||||
})
|
||||
}
|
||||
|
||||
// Exec executes the query.
|
||||
func (u *GroupUpsertOne) Exec(ctx context.Context) error {
|
||||
if len(u.create.conflict) == 0 {
|
||||
@@ -3366,6 +3491,9 @@ func (u *GroupUpsertBulk) UpdateNewValues() *GroupUpsertBulk {
|
||||
if _, exists := b.mutation.CreatedAt(); exists {
|
||||
s.SetIgnore(group.FieldCreatedAt)
|
||||
}
|
||||
if _, exists := b.mutation.DuplicateOperationID(); exists {
|
||||
s.SetIgnore(group.FieldDuplicateOperationID)
|
||||
}
|
||||
}
|
||||
}))
|
||||
return u
|
||||
@@ -4308,6 +4436,34 @@ func (u *GroupUpsertBulk) UpdateRpmLimit() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffort sets the "max_reasoning_effort" field.
|
||||
func (u *GroupUpsertBulk) SetMaxReasoningEffort(v string) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetMaxReasoningEffort(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateMaxReasoningEffort sets the "max_reasoning_effort" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateMaxReasoningEffort() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateMaxReasoningEffort()
|
||||
})
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (u *GroupUpsertBulk) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetReasoningEffortMappings(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateReasoningEffortMappings sets the "reasoning_effort_mappings" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateReasoningEffortMappings() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateReasoningEffortMappings()
|
||||
})
|
||||
}
|
||||
|
||||
// Exec executes the query.
|
||||
func (u *GroupUpsertBulk) Exec(ctx context.Context) error {
|
||||
if u.create.err != nil {
|
||||
|
||||
@@ -913,6 +913,32 @@ func (_u *GroupUpdate) AddRpmLimit(v int) *GroupUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffort sets the "max_reasoning_effort" field.
|
||||
func (_u *GroupUpdate) SetMaxReasoningEffort(v string) *GroupUpdate {
|
||||
_u.mutation.SetMaxReasoningEffort(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableMaxReasoningEffort sets the "max_reasoning_effort" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableMaxReasoningEffort(v *string) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetMaxReasoningEffort(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (_u *GroupUpdate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdate {
|
||||
_u.mutation.SetReasoningEffortMappings(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendReasoningEffortMappings appends value to the "reasoning_effort_mappings" field.
|
||||
func (_u *GroupUpdate) AppendReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdate {
|
||||
_u.mutation.AppendReasoningEffortMappings(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
|
||||
func (_u *GroupUpdate) AddAPIKeyIDs(ids ...int64) *GroupUpdate {
|
||||
_u.mutation.AddAPIKeyIDs(ids...)
|
||||
@@ -1213,6 +1239,11 @@ func (_u *GroupUpdate) check() error {
|
||||
return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.MaxReasoningEffort(); ok {
|
||||
if err := group.MaxReasoningEffortValidator(v); err != nil {
|
||||
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1273,6 +1304,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.Status(); ok {
|
||||
_spec.SetField(group.FieldStatus, field.TypeString, value)
|
||||
}
|
||||
if _u.mutation.DuplicateOperationIDCleared() {
|
||||
_spec.ClearField(group.FieldDuplicateOperationID, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.Platform(); ok {
|
||||
_spec.SetField(group.FieldPlatform, field.TypeString, value)
|
||||
}
|
||||
@@ -1482,6 +1516,17 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.AddedRpmLimit(); ok {
|
||||
_spec.AddField(group.FieldRpmLimit, field.TypeInt, value)
|
||||
}
|
||||
if value, ok := _u.mutation.MaxReasoningEffort(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ReasoningEffortMappings(); ok {
|
||||
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedReasoningEffortMappings(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, group.FieldReasoningEffortMappings, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.APIKeysCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
@@ -2673,6 +2718,32 @@ func (_u *GroupUpdateOne) AddRpmLimit(v int) *GroupUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffort sets the "max_reasoning_effort" field.
|
||||
func (_u *GroupUpdateOne) SetMaxReasoningEffort(v string) *GroupUpdateOne {
|
||||
_u.mutation.SetMaxReasoningEffort(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableMaxReasoningEffort sets the "max_reasoning_effort" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableMaxReasoningEffort(v *string) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetMaxReasoningEffort(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (_u *GroupUpdateOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdateOne {
|
||||
_u.mutation.SetReasoningEffortMappings(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendReasoningEffortMappings appends value to the "reasoning_effort_mappings" field.
|
||||
func (_u *GroupUpdateOne) AppendReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdateOne {
|
||||
_u.mutation.AppendReasoningEffortMappings(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
|
||||
func (_u *GroupUpdateOne) AddAPIKeyIDs(ids ...int64) *GroupUpdateOne {
|
||||
_u.mutation.AddAPIKeyIDs(ids...)
|
||||
@@ -2986,6 +3057,11 @@ func (_u *GroupUpdateOne) check() error {
|
||||
return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.MaxReasoningEffort(); ok {
|
||||
if err := group.MaxReasoningEffortValidator(v); err != nil {
|
||||
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3063,6 +3139,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if value, ok := _u.mutation.Status(); ok {
|
||||
_spec.SetField(group.FieldStatus, field.TypeString, value)
|
||||
}
|
||||
if _u.mutation.DuplicateOperationIDCleared() {
|
||||
_spec.ClearField(group.FieldDuplicateOperationID, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.Platform(); ok {
|
||||
_spec.SetField(group.FieldPlatform, field.TypeString, value)
|
||||
}
|
||||
@@ -3272,6 +3351,17 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if value, ok := _u.mutation.AddedRpmLimit(); ok {
|
||||
_spec.AddField(group.FieldRpmLimit, field.TypeInt, value)
|
||||
}
|
||||
if value, ok := _u.mutation.MaxReasoningEffort(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ReasoningEffortMappings(); ok {
|
||||
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedReasoningEffortMappings(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, group.FieldReasoningEffortMappings, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.APIKeysCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
|
||||
@@ -845,6 +845,7 @@ var (
|
||||
{Name: "peak_rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
|
||||
{Name: "is_exclusive", Type: field.TypeBool, Default: false},
|
||||
{Name: "status", Type: field.TypeString, Size: 20, Default: "active"},
|
||||
{Name: "duplicate_operation_id", Type: field.TypeString, Nullable: true, Size: 64},
|
||||
{Name: "platform", Type: field.TypeString, Size: 50, Default: "anthropic"},
|
||||
{Name: "subscription_type", Type: field.TypeString, Size: 20, Default: "standard"},
|
||||
{Name: "daily_limit_usd", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
@@ -881,6 +882,8 @@ var (
|
||||
{Name: "messages_dispatch_model_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "models_list_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "rpm_limit", Type: field.TypeInt, Default: 0},
|
||||
{Name: "max_reasoning_effort", Type: field.TypeString, Size: 20, Default: ""},
|
||||
{Name: "reasoning_effort_mappings", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
}
|
||||
// GroupsTable holds the schema information for the "groups" table.
|
||||
GroupsTable = &schema.Table{
|
||||
@@ -896,12 +899,12 @@ var (
|
||||
{
|
||||
Name: "group_platform",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[13]},
|
||||
Columns: []*schema.Column{GroupsColumns[14]},
|
||||
},
|
||||
{
|
||||
Name: "group_subscription_type",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[14]},
|
||||
Columns: []*schema.Column{GroupsColumns[15]},
|
||||
},
|
||||
{
|
||||
Name: "group_is_exclusive",
|
||||
@@ -916,7 +919,15 @@ var (
|
||||
{
|
||||
Name: "group_sort_order",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[41]},
|
||||
Columns: []*schema.Column{GroupsColumns[42]},
|
||||
},
|
||||
{
|
||||
Name: "idx_groups_duplicate_operation_id_active",
|
||||
Unique: true,
|
||||
Columns: []*schema.Column{GroupsColumns[13]},
|
||||
Annotation: &entsql.IndexAnnotation{
|
||||
Where: "duplicate_operation_id IS NOT NULL AND deleted_at IS NULL",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1447,6 +1458,7 @@ var (
|
||||
{Name: "description", Type: field.TypeString, Default: "", SchemaType: map[string]string{"postgres": "text"}},
|
||||
{Name: "price", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,2)"}},
|
||||
{Name: "original_price", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,2)"}},
|
||||
{Name: "currency", Type: field.TypeString, Size: 3, Default: ""},
|
||||
{Name: "validity_days", Type: field.TypeInt, Default: 30},
|
||||
{Name: "validity_unit", Type: field.TypeString, Size: 10, Default: "day"},
|
||||
{Name: "features", Type: field.TypeString, Default: "", SchemaType: map[string]string{"postgres": "text"}},
|
||||
@@ -1470,7 +1482,7 @@ var (
|
||||
{
|
||||
Name: "subscriptionplan_for_sale",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{SubscriptionPlansColumns[10]},
|
||||
Columns: []*schema.Column{SubscriptionPlansColumns[11]},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+253
-2
@@ -20808,6 +20808,7 @@ type GroupMutation struct {
|
||||
addpeak_rate_multiplier *float64
|
||||
is_exclusive *bool
|
||||
status *string
|
||||
duplicate_operation_id *string
|
||||
platform *string
|
||||
subscription_type *string
|
||||
daily_limit_usd *float64
|
||||
@@ -20864,6 +20865,9 @@ type GroupMutation struct {
|
||||
models_list_config *domain.GroupModelsListConfig
|
||||
rpm_limit *int
|
||||
addrpm_limit *int
|
||||
max_reasoning_effort *string
|
||||
reasoning_effort_mappings *[]domain.ReasoningEffortMapping
|
||||
appendreasoning_effort_mappings []domain.ReasoningEffortMapping
|
||||
clearedFields map[string]struct{}
|
||||
api_keys map[int64]struct{}
|
||||
removedapi_keys map[int64]struct{}
|
||||
@@ -21484,6 +21488,55 @@ func (m *GroupMutation) ResetStatus() {
|
||||
m.status = nil
|
||||
}
|
||||
|
||||
// SetDuplicateOperationID sets the "duplicate_operation_id" field.
|
||||
func (m *GroupMutation) SetDuplicateOperationID(s string) {
|
||||
m.duplicate_operation_id = &s
|
||||
}
|
||||
|
||||
// DuplicateOperationID returns the value of the "duplicate_operation_id" field in the mutation.
|
||||
func (m *GroupMutation) DuplicateOperationID() (r string, exists bool) {
|
||||
v := m.duplicate_operation_id
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldDuplicateOperationID returns the old "duplicate_operation_id" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldDuplicateOperationID(ctx context.Context) (v *string, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldDuplicateOperationID is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldDuplicateOperationID requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldDuplicateOperationID: %w", err)
|
||||
}
|
||||
return oldValue.DuplicateOperationID, nil
|
||||
}
|
||||
|
||||
// ClearDuplicateOperationID clears the value of the "duplicate_operation_id" field.
|
||||
func (m *GroupMutation) ClearDuplicateOperationID() {
|
||||
m.duplicate_operation_id = nil
|
||||
m.clearedFields[group.FieldDuplicateOperationID] = struct{}{}
|
||||
}
|
||||
|
||||
// DuplicateOperationIDCleared returns if the "duplicate_operation_id" field was cleared in this mutation.
|
||||
func (m *GroupMutation) DuplicateOperationIDCleared() bool {
|
||||
_, ok := m.clearedFields[group.FieldDuplicateOperationID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetDuplicateOperationID resets all changes to the "duplicate_operation_id" field.
|
||||
func (m *GroupMutation) ResetDuplicateOperationID() {
|
||||
m.duplicate_operation_id = nil
|
||||
delete(m.clearedFields, group.FieldDuplicateOperationID)
|
||||
}
|
||||
|
||||
// SetPlatform sets the "platform" field.
|
||||
func (m *GroupMutation) SetPlatform(s string) {
|
||||
m.platform = &s
|
||||
@@ -23356,6 +23409,93 @@ func (m *GroupMutation) ResetRpmLimit() {
|
||||
m.addrpm_limit = nil
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffort sets the "max_reasoning_effort" field.
|
||||
func (m *GroupMutation) SetMaxReasoningEffort(s string) {
|
||||
m.max_reasoning_effort = &s
|
||||
}
|
||||
|
||||
// MaxReasoningEffort returns the value of the "max_reasoning_effort" field in the mutation.
|
||||
func (m *GroupMutation) MaxReasoningEffort() (r string, exists bool) {
|
||||
v := m.max_reasoning_effort
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldMaxReasoningEffort returns the old "max_reasoning_effort" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldMaxReasoningEffort(ctx context.Context) (v string, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldMaxReasoningEffort is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldMaxReasoningEffort requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldMaxReasoningEffort: %w", err)
|
||||
}
|
||||
return oldValue.MaxReasoningEffort, nil
|
||||
}
|
||||
|
||||
// ResetMaxReasoningEffort resets all changes to the "max_reasoning_effort" field.
|
||||
func (m *GroupMutation) ResetMaxReasoningEffort() {
|
||||
m.max_reasoning_effort = nil
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (m *GroupMutation) SetReasoningEffortMappings(dem []domain.ReasoningEffortMapping) {
|
||||
m.reasoning_effort_mappings = &dem
|
||||
m.appendreasoning_effort_mappings = nil
|
||||
}
|
||||
|
||||
// ReasoningEffortMappings returns the value of the "reasoning_effort_mappings" field in the mutation.
|
||||
func (m *GroupMutation) ReasoningEffortMappings() (r []domain.ReasoningEffortMapping, exists bool) {
|
||||
v := m.reasoning_effort_mappings
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldReasoningEffortMappings returns the old "reasoning_effort_mappings" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldReasoningEffortMappings(ctx context.Context) (v []domain.ReasoningEffortMapping, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldReasoningEffortMappings is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldReasoningEffortMappings requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldReasoningEffortMappings: %w", err)
|
||||
}
|
||||
return oldValue.ReasoningEffortMappings, nil
|
||||
}
|
||||
|
||||
// AppendReasoningEffortMappings adds dem to the "reasoning_effort_mappings" field.
|
||||
func (m *GroupMutation) AppendReasoningEffortMappings(dem []domain.ReasoningEffortMapping) {
|
||||
m.appendreasoning_effort_mappings = append(m.appendreasoning_effort_mappings, dem...)
|
||||
}
|
||||
|
||||
// AppendedReasoningEffortMappings returns the list of values that were appended to the "reasoning_effort_mappings" field in this mutation.
|
||||
func (m *GroupMutation) AppendedReasoningEffortMappings() ([]domain.ReasoningEffortMapping, bool) {
|
||||
if len(m.appendreasoning_effort_mappings) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return m.appendreasoning_effort_mappings, true
|
||||
}
|
||||
|
||||
// ResetReasoningEffortMappings resets all changes to the "reasoning_effort_mappings" field.
|
||||
func (m *GroupMutation) ResetReasoningEffortMappings() {
|
||||
m.reasoning_effort_mappings = nil
|
||||
m.appendreasoning_effort_mappings = nil
|
||||
}
|
||||
|
||||
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids.
|
||||
func (m *GroupMutation) AddAPIKeyIDs(ids ...int64) {
|
||||
if m.api_keys == nil {
|
||||
@@ -23714,7 +23854,7 @@ func (m *GroupMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *GroupMutation) Fields() []string {
|
||||
fields := make([]string, 0, 48)
|
||||
fields := make([]string, 0, 51)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, group.FieldCreatedAt)
|
||||
}
|
||||
@@ -23751,6 +23891,9 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.status != nil {
|
||||
fields = append(fields, group.FieldStatus)
|
||||
}
|
||||
if m.duplicate_operation_id != nil {
|
||||
fields = append(fields, group.FieldDuplicateOperationID)
|
||||
}
|
||||
if m.platform != nil {
|
||||
fields = append(fields, group.FieldPlatform)
|
||||
}
|
||||
@@ -23859,6 +24002,12 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.rpm_limit != nil {
|
||||
fields = append(fields, group.FieldRpmLimit)
|
||||
}
|
||||
if m.max_reasoning_effort != nil {
|
||||
fields = append(fields, group.FieldMaxReasoningEffort)
|
||||
}
|
||||
if m.reasoning_effort_mappings != nil {
|
||||
fields = append(fields, group.FieldReasoningEffortMappings)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -23891,6 +24040,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.IsExclusive()
|
||||
case group.FieldStatus:
|
||||
return m.Status()
|
||||
case group.FieldDuplicateOperationID:
|
||||
return m.DuplicateOperationID()
|
||||
case group.FieldPlatform:
|
||||
return m.Platform()
|
||||
case group.FieldSubscriptionType:
|
||||
@@ -23963,6 +24114,10 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.ModelsListConfig()
|
||||
case group.FieldRpmLimit:
|
||||
return m.RpmLimit()
|
||||
case group.FieldMaxReasoningEffort:
|
||||
return m.MaxReasoningEffort()
|
||||
case group.FieldReasoningEffortMappings:
|
||||
return m.ReasoningEffortMappings()
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -23996,6 +24151,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldIsExclusive(ctx)
|
||||
case group.FieldStatus:
|
||||
return m.OldStatus(ctx)
|
||||
case group.FieldDuplicateOperationID:
|
||||
return m.OldDuplicateOperationID(ctx)
|
||||
case group.FieldPlatform:
|
||||
return m.OldPlatform(ctx)
|
||||
case group.FieldSubscriptionType:
|
||||
@@ -24068,6 +24225,10 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldModelsListConfig(ctx)
|
||||
case group.FieldRpmLimit:
|
||||
return m.OldRpmLimit(ctx)
|
||||
case group.FieldMaxReasoningEffort:
|
||||
return m.OldMaxReasoningEffort(ctx)
|
||||
case group.FieldReasoningEffortMappings:
|
||||
return m.OldReasoningEffortMappings(ctx)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown Group field %s", name)
|
||||
}
|
||||
@@ -24161,6 +24322,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetStatus(v)
|
||||
return nil
|
||||
case group.FieldDuplicateOperationID:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetDuplicateOperationID(v)
|
||||
return nil
|
||||
case group.FieldPlatform:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
@@ -24413,6 +24581,20 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetRpmLimit(v)
|
||||
return nil
|
||||
case group.FieldMaxReasoningEffort:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetMaxReasoningEffort(v)
|
||||
return nil
|
||||
case group.FieldReasoningEffortMappings:
|
||||
v, ok := value.([]domain.ReasoningEffortMapping)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetReasoningEffortMappings(v)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown Group field %s", name)
|
||||
}
|
||||
@@ -24704,6 +24886,9 @@ func (m *GroupMutation) ClearedFields() []string {
|
||||
if m.FieldCleared(group.FieldDescription) {
|
||||
fields = append(fields, group.FieldDescription)
|
||||
}
|
||||
if m.FieldCleared(group.FieldDuplicateOperationID) {
|
||||
fields = append(fields, group.FieldDuplicateOperationID)
|
||||
}
|
||||
if m.FieldCleared(group.FieldDailyLimitUsd) {
|
||||
fields = append(fields, group.FieldDailyLimitUsd)
|
||||
}
|
||||
@@ -24763,6 +24948,9 @@ func (m *GroupMutation) ClearField(name string) error {
|
||||
case group.FieldDescription:
|
||||
m.ClearDescription()
|
||||
return nil
|
||||
case group.FieldDuplicateOperationID:
|
||||
m.ClearDuplicateOperationID()
|
||||
return nil
|
||||
case group.FieldDailyLimitUsd:
|
||||
m.ClearDailyLimitUsd()
|
||||
return nil
|
||||
@@ -24846,6 +25034,9 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldStatus:
|
||||
m.ResetStatus()
|
||||
return nil
|
||||
case group.FieldDuplicateOperationID:
|
||||
m.ResetDuplicateOperationID()
|
||||
return nil
|
||||
case group.FieldPlatform:
|
||||
m.ResetPlatform()
|
||||
return nil
|
||||
@@ -24954,6 +25145,12 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldRpmLimit:
|
||||
m.ResetRpmLimit()
|
||||
return nil
|
||||
case group.FieldMaxReasoningEffort:
|
||||
m.ResetMaxReasoningEffort()
|
||||
return nil
|
||||
case group.FieldReasoningEffortMappings:
|
||||
m.ResetReasoningEffortMappings()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown Group field %s", name)
|
||||
}
|
||||
@@ -38152,6 +38349,7 @@ type SubscriptionPlanMutation struct {
|
||||
addprice *float64
|
||||
original_price *float64
|
||||
addoriginal_price *float64
|
||||
currency *string
|
||||
validity_days *int
|
||||
addvalidity_days *int
|
||||
validity_unit *string
|
||||
@@ -38520,6 +38718,42 @@ func (m *SubscriptionPlanMutation) ResetOriginalPrice() {
|
||||
delete(m.clearedFields, subscriptionplan.FieldOriginalPrice)
|
||||
}
|
||||
|
||||
// SetCurrency sets the "currency" field.
|
||||
func (m *SubscriptionPlanMutation) SetCurrency(s string) {
|
||||
m.currency = &s
|
||||
}
|
||||
|
||||
// Currency returns the value of the "currency" field in the mutation.
|
||||
func (m *SubscriptionPlanMutation) Currency() (r string, exists bool) {
|
||||
v := m.currency
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldCurrency returns the old "currency" field's value of the SubscriptionPlan entity.
|
||||
// If the SubscriptionPlan object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *SubscriptionPlanMutation) OldCurrency(ctx context.Context) (v string, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldCurrency is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldCurrency requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldCurrency: %w", err)
|
||||
}
|
||||
return oldValue.Currency, nil
|
||||
}
|
||||
|
||||
// ResetCurrency resets all changes to the "currency" field.
|
||||
func (m *SubscriptionPlanMutation) ResetCurrency() {
|
||||
m.currency = nil
|
||||
}
|
||||
|
||||
// SetValidityDays sets the "validity_days" field.
|
||||
func (m *SubscriptionPlanMutation) SetValidityDays(i int) {
|
||||
m.validity_days = &i
|
||||
@@ -38882,7 +39116,7 @@ func (m *SubscriptionPlanMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *SubscriptionPlanMutation) Fields() []string {
|
||||
fields := make([]string, 0, 13)
|
||||
fields := make([]string, 0, 14)
|
||||
if m.group_id != nil {
|
||||
fields = append(fields, subscriptionplan.FieldGroupID)
|
||||
}
|
||||
@@ -38898,6 +39132,9 @@ func (m *SubscriptionPlanMutation) Fields() []string {
|
||||
if m.original_price != nil {
|
||||
fields = append(fields, subscriptionplan.FieldOriginalPrice)
|
||||
}
|
||||
if m.currency != nil {
|
||||
fields = append(fields, subscriptionplan.FieldCurrency)
|
||||
}
|
||||
if m.validity_days != nil {
|
||||
fields = append(fields, subscriptionplan.FieldValidityDays)
|
||||
}
|
||||
@@ -38940,6 +39177,8 @@ func (m *SubscriptionPlanMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.Price()
|
||||
case subscriptionplan.FieldOriginalPrice:
|
||||
return m.OriginalPrice()
|
||||
case subscriptionplan.FieldCurrency:
|
||||
return m.Currency()
|
||||
case subscriptionplan.FieldValidityDays:
|
||||
return m.ValidityDays()
|
||||
case subscriptionplan.FieldValidityUnit:
|
||||
@@ -38975,6 +39214,8 @@ func (m *SubscriptionPlanMutation) OldField(ctx context.Context, name string) (e
|
||||
return m.OldPrice(ctx)
|
||||
case subscriptionplan.FieldOriginalPrice:
|
||||
return m.OldOriginalPrice(ctx)
|
||||
case subscriptionplan.FieldCurrency:
|
||||
return m.OldCurrency(ctx)
|
||||
case subscriptionplan.FieldValidityDays:
|
||||
return m.OldValidityDays(ctx)
|
||||
case subscriptionplan.FieldValidityUnit:
|
||||
@@ -39035,6 +39276,13 @@ func (m *SubscriptionPlanMutation) SetField(name string, value ent.Value) error
|
||||
}
|
||||
m.SetOriginalPrice(v)
|
||||
return nil
|
||||
case subscriptionplan.FieldCurrency:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetCurrency(v)
|
||||
return nil
|
||||
case subscriptionplan.FieldValidityDays:
|
||||
v, ok := value.(int)
|
||||
if !ok {
|
||||
@@ -39227,6 +39475,9 @@ func (m *SubscriptionPlanMutation) ResetField(name string) error {
|
||||
case subscriptionplan.FieldOriginalPrice:
|
||||
m.ResetOriginalPrice()
|
||||
return nil
|
||||
case subscriptionplan.FieldCurrency:
|
||||
m.ResetCurrency()
|
||||
return nil
|
||||
case subscriptionplan.FieldValidityDays:
|
||||
m.ResetValidityDays()
|
||||
return nil
|
||||
|
||||
@@ -995,104 +995,118 @@ func init() {
|
||||
group.DefaultStatus = groupDescStatus.Default.(string)
|
||||
// group.StatusValidator is a validator for the "status" field. It is called by the builders before save.
|
||||
group.StatusValidator = groupDescStatus.Validators[0].(func(string) error)
|
||||
// groupDescDuplicateOperationID is the schema descriptor for duplicate_operation_id field.
|
||||
groupDescDuplicateOperationID := groupFields[9].Descriptor()
|
||||
// group.DuplicateOperationIDValidator is a validator for the "duplicate_operation_id" field. It is called by the builders before save.
|
||||
group.DuplicateOperationIDValidator = groupDescDuplicateOperationID.Validators[0].(func(string) error)
|
||||
// groupDescPlatform is the schema descriptor for platform field.
|
||||
groupDescPlatform := groupFields[9].Descriptor()
|
||||
groupDescPlatform := groupFields[10].Descriptor()
|
||||
// group.DefaultPlatform holds the default value on creation for the platform field.
|
||||
group.DefaultPlatform = groupDescPlatform.Default.(string)
|
||||
// group.PlatformValidator is a validator for the "platform" field. It is called by the builders before save.
|
||||
group.PlatformValidator = groupDescPlatform.Validators[0].(func(string) error)
|
||||
// groupDescSubscriptionType is the schema descriptor for subscription_type field.
|
||||
groupDescSubscriptionType := groupFields[10].Descriptor()
|
||||
groupDescSubscriptionType := groupFields[11].Descriptor()
|
||||
// group.DefaultSubscriptionType holds the default value on creation for the subscription_type field.
|
||||
group.DefaultSubscriptionType = groupDescSubscriptionType.Default.(string)
|
||||
// group.SubscriptionTypeValidator is a validator for the "subscription_type" field. It is called by the builders before save.
|
||||
group.SubscriptionTypeValidator = groupDescSubscriptionType.Validators[0].(func(string) error)
|
||||
// groupDescDefaultValidityDays is the schema descriptor for default_validity_days field.
|
||||
groupDescDefaultValidityDays := groupFields[14].Descriptor()
|
||||
groupDescDefaultValidityDays := groupFields[15].Descriptor()
|
||||
// group.DefaultDefaultValidityDays holds the default value on creation for the default_validity_days field.
|
||||
group.DefaultDefaultValidityDays = groupDescDefaultValidityDays.Default.(int)
|
||||
// groupDescAllowImageGeneration is the schema descriptor for allow_image_generation field.
|
||||
groupDescAllowImageGeneration := groupFields[15].Descriptor()
|
||||
groupDescAllowImageGeneration := groupFields[16].Descriptor()
|
||||
// group.DefaultAllowImageGeneration holds the default value on creation for the allow_image_generation field.
|
||||
group.DefaultAllowImageGeneration = groupDescAllowImageGeneration.Default.(bool)
|
||||
// groupDescAllowBatchImageGeneration is the schema descriptor for allow_batch_image_generation field.
|
||||
groupDescAllowBatchImageGeneration := groupFields[16].Descriptor()
|
||||
groupDescAllowBatchImageGeneration := groupFields[17].Descriptor()
|
||||
// group.DefaultAllowBatchImageGeneration holds the default value on creation for the allow_batch_image_generation field.
|
||||
group.DefaultAllowBatchImageGeneration = groupDescAllowBatchImageGeneration.Default.(bool)
|
||||
// groupDescImageRateIndependent is the schema descriptor for image_rate_independent field.
|
||||
groupDescImageRateIndependent := groupFields[17].Descriptor()
|
||||
groupDescImageRateIndependent := groupFields[18].Descriptor()
|
||||
// group.DefaultImageRateIndependent holds the default value on creation for the image_rate_independent field.
|
||||
group.DefaultImageRateIndependent = groupDescImageRateIndependent.Default.(bool)
|
||||
// groupDescImageRateMultiplier is the schema descriptor for image_rate_multiplier field.
|
||||
groupDescImageRateMultiplier := groupFields[18].Descriptor()
|
||||
groupDescImageRateMultiplier := groupFields[19].Descriptor()
|
||||
// group.DefaultImageRateMultiplier holds the default value on creation for the image_rate_multiplier field.
|
||||
group.DefaultImageRateMultiplier = groupDescImageRateMultiplier.Default.(float64)
|
||||
// groupDescBatchImageDiscountMultiplier is the schema descriptor for batch_image_discount_multiplier field.
|
||||
groupDescBatchImageDiscountMultiplier := groupFields[22].Descriptor()
|
||||
groupDescBatchImageDiscountMultiplier := groupFields[23].Descriptor()
|
||||
// group.DefaultBatchImageDiscountMultiplier holds the default value on creation for the batch_image_discount_multiplier field.
|
||||
group.DefaultBatchImageDiscountMultiplier = groupDescBatchImageDiscountMultiplier.Default.(float64)
|
||||
// groupDescBatchImageHoldMultiplier is the schema descriptor for batch_image_hold_multiplier field.
|
||||
groupDescBatchImageHoldMultiplier := groupFields[23].Descriptor()
|
||||
groupDescBatchImageHoldMultiplier := groupFields[24].Descriptor()
|
||||
// group.DefaultBatchImageHoldMultiplier holds the default value on creation for the batch_image_hold_multiplier field.
|
||||
group.DefaultBatchImageHoldMultiplier = groupDescBatchImageHoldMultiplier.Default.(float64)
|
||||
// groupDescVideoRateIndependent is the schema descriptor for video_rate_independent field.
|
||||
groupDescVideoRateIndependent := groupFields[24].Descriptor()
|
||||
groupDescVideoRateIndependent := groupFields[25].Descriptor()
|
||||
// group.DefaultVideoRateIndependent holds the default value on creation for the video_rate_independent field.
|
||||
group.DefaultVideoRateIndependent = groupDescVideoRateIndependent.Default.(bool)
|
||||
// groupDescVideoRateMultiplier is the schema descriptor for video_rate_multiplier field.
|
||||
groupDescVideoRateMultiplier := groupFields[25].Descriptor()
|
||||
groupDescVideoRateMultiplier := groupFields[26].Descriptor()
|
||||
// group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field.
|
||||
group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64)
|
||||
// groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field.
|
||||
groupDescClaudeCodeOnly := groupFields[30].Descriptor()
|
||||
groupDescClaudeCodeOnly := groupFields[31].Descriptor()
|
||||
// group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field.
|
||||
group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool)
|
||||
// groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field.
|
||||
groupDescModelRoutingEnabled := groupFields[34].Descriptor()
|
||||
groupDescModelRoutingEnabled := groupFields[35].Descriptor()
|
||||
// group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field.
|
||||
group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool)
|
||||
// groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field.
|
||||
groupDescMcpXMLInject := groupFields[35].Descriptor()
|
||||
groupDescMcpXMLInject := groupFields[36].Descriptor()
|
||||
// group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field.
|
||||
group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool)
|
||||
// groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field.
|
||||
groupDescSupportedModelScopes := groupFields[36].Descriptor()
|
||||
groupDescSupportedModelScopes := groupFields[37].Descriptor()
|
||||
// group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field.
|
||||
group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string)
|
||||
// groupDescSortOrder is the schema descriptor for sort_order field.
|
||||
groupDescSortOrder := groupFields[37].Descriptor()
|
||||
groupDescSortOrder := groupFields[38].Descriptor()
|
||||
// group.DefaultSortOrder holds the default value on creation for the sort_order field.
|
||||
group.DefaultSortOrder = groupDescSortOrder.Default.(int)
|
||||
// groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field.
|
||||
groupDescAllowMessagesDispatch := groupFields[38].Descriptor()
|
||||
groupDescAllowMessagesDispatch := groupFields[39].Descriptor()
|
||||
// group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field.
|
||||
group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool)
|
||||
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
|
||||
groupDescRequireOauthOnly := groupFields[39].Descriptor()
|
||||
groupDescRequireOauthOnly := groupFields[40].Descriptor()
|
||||
// group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field.
|
||||
group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool)
|
||||
// groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field.
|
||||
groupDescRequirePrivacySet := groupFields[40].Descriptor()
|
||||
groupDescRequirePrivacySet := groupFields[41].Descriptor()
|
||||
// group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field.
|
||||
group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool)
|
||||
// groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field.
|
||||
groupDescDefaultMappedModel := groupFields[41].Descriptor()
|
||||
groupDescDefaultMappedModel := groupFields[42].Descriptor()
|
||||
// group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field.
|
||||
group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string)
|
||||
// group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
|
||||
group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error)
|
||||
// groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field.
|
||||
groupDescMessagesDispatchModelConfig := groupFields[42].Descriptor()
|
||||
groupDescMessagesDispatchModelConfig := groupFields[43].Descriptor()
|
||||
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
|
||||
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
|
||||
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
|
||||
groupDescModelsListConfig := groupFields[43].Descriptor()
|
||||
groupDescModelsListConfig := groupFields[44].Descriptor()
|
||||
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
|
||||
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
|
||||
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
|
||||
groupDescRpmLimit := groupFields[44].Descriptor()
|
||||
groupDescRpmLimit := groupFields[45].Descriptor()
|
||||
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
|
||||
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
|
||||
// groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field.
|
||||
groupDescMaxReasoningEffort := groupFields[46].Descriptor()
|
||||
// group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field.
|
||||
group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string)
|
||||
// group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
|
||||
group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error)
|
||||
// groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field.
|
||||
groupDescReasoningEffortMappings := groupFields[47].Descriptor()
|
||||
// group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field.
|
||||
group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping)
|
||||
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
|
||||
idempotencyrecordMixinFields0 := idempotencyrecordMixin[0].Fields()
|
||||
_ = idempotencyrecordMixinFields0
|
||||
@@ -1718,40 +1732,46 @@ func init() {
|
||||
subscriptionplanDescDescription := subscriptionplanFields[2].Descriptor()
|
||||
// subscriptionplan.DefaultDescription holds the default value on creation for the description field.
|
||||
subscriptionplan.DefaultDescription = subscriptionplanDescDescription.Default.(string)
|
||||
// subscriptionplanDescCurrency is the schema descriptor for currency field.
|
||||
subscriptionplanDescCurrency := subscriptionplanFields[5].Descriptor()
|
||||
// subscriptionplan.DefaultCurrency holds the default value on creation for the currency field.
|
||||
subscriptionplan.DefaultCurrency = subscriptionplanDescCurrency.Default.(string)
|
||||
// subscriptionplan.CurrencyValidator is a validator for the "currency" field. It is called by the builders before save.
|
||||
subscriptionplan.CurrencyValidator = subscriptionplanDescCurrency.Validators[0].(func(string) error)
|
||||
// subscriptionplanDescValidityDays is the schema descriptor for validity_days field.
|
||||
subscriptionplanDescValidityDays := subscriptionplanFields[5].Descriptor()
|
||||
subscriptionplanDescValidityDays := subscriptionplanFields[6].Descriptor()
|
||||
// subscriptionplan.DefaultValidityDays holds the default value on creation for the validity_days field.
|
||||
subscriptionplan.DefaultValidityDays = subscriptionplanDescValidityDays.Default.(int)
|
||||
// subscriptionplanDescValidityUnit is the schema descriptor for validity_unit field.
|
||||
subscriptionplanDescValidityUnit := subscriptionplanFields[6].Descriptor()
|
||||
subscriptionplanDescValidityUnit := subscriptionplanFields[7].Descriptor()
|
||||
// subscriptionplan.DefaultValidityUnit holds the default value on creation for the validity_unit field.
|
||||
subscriptionplan.DefaultValidityUnit = subscriptionplanDescValidityUnit.Default.(string)
|
||||
// subscriptionplan.ValidityUnitValidator is a validator for the "validity_unit" field. It is called by the builders before save.
|
||||
subscriptionplan.ValidityUnitValidator = subscriptionplanDescValidityUnit.Validators[0].(func(string) error)
|
||||
// subscriptionplanDescFeatures is the schema descriptor for features field.
|
||||
subscriptionplanDescFeatures := subscriptionplanFields[7].Descriptor()
|
||||
subscriptionplanDescFeatures := subscriptionplanFields[8].Descriptor()
|
||||
// subscriptionplan.DefaultFeatures holds the default value on creation for the features field.
|
||||
subscriptionplan.DefaultFeatures = subscriptionplanDescFeatures.Default.(string)
|
||||
// subscriptionplanDescProductName is the schema descriptor for product_name field.
|
||||
subscriptionplanDescProductName := subscriptionplanFields[8].Descriptor()
|
||||
subscriptionplanDescProductName := subscriptionplanFields[9].Descriptor()
|
||||
// subscriptionplan.DefaultProductName holds the default value on creation for the product_name field.
|
||||
subscriptionplan.DefaultProductName = subscriptionplanDescProductName.Default.(string)
|
||||
// subscriptionplan.ProductNameValidator is a validator for the "product_name" field. It is called by the builders before save.
|
||||
subscriptionplan.ProductNameValidator = subscriptionplanDescProductName.Validators[0].(func(string) error)
|
||||
// subscriptionplanDescForSale is the schema descriptor for for_sale field.
|
||||
subscriptionplanDescForSale := subscriptionplanFields[9].Descriptor()
|
||||
subscriptionplanDescForSale := subscriptionplanFields[10].Descriptor()
|
||||
// subscriptionplan.DefaultForSale holds the default value on creation for the for_sale field.
|
||||
subscriptionplan.DefaultForSale = subscriptionplanDescForSale.Default.(bool)
|
||||
// subscriptionplanDescSortOrder is the schema descriptor for sort_order field.
|
||||
subscriptionplanDescSortOrder := subscriptionplanFields[10].Descriptor()
|
||||
subscriptionplanDescSortOrder := subscriptionplanFields[11].Descriptor()
|
||||
// subscriptionplan.DefaultSortOrder holds the default value on creation for the sort_order field.
|
||||
subscriptionplan.DefaultSortOrder = subscriptionplanDescSortOrder.Default.(int)
|
||||
// subscriptionplanDescCreatedAt is the schema descriptor for created_at field.
|
||||
subscriptionplanDescCreatedAt := subscriptionplanFields[11].Descriptor()
|
||||
subscriptionplanDescCreatedAt := subscriptionplanFields[12].Descriptor()
|
||||
// subscriptionplan.DefaultCreatedAt holds the default value on creation for the created_at field.
|
||||
subscriptionplan.DefaultCreatedAt = subscriptionplanDescCreatedAt.Default.(func() time.Time)
|
||||
// subscriptionplanDescUpdatedAt is the schema descriptor for updated_at field.
|
||||
subscriptionplanDescUpdatedAt := subscriptionplanFields[12].Descriptor()
|
||||
subscriptionplanDescUpdatedAt := subscriptionplanFields[13].Descriptor()
|
||||
// subscriptionplan.DefaultUpdatedAt holds the default value on creation for the updated_at field.
|
||||
subscriptionplan.DefaultUpdatedAt = subscriptionplanDescUpdatedAt.Default.(func() time.Time)
|
||||
// subscriptionplan.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
|
||||
|
||||
@@ -66,6 +66,12 @@ func (Group) Fields() []ent.Field {
|
||||
field.String("status").
|
||||
MaxLen(20).
|
||||
Default(domain.StatusActive),
|
||||
field.String("duplicate_operation_id").
|
||||
MaxLen(64).
|
||||
Optional().
|
||||
Nillable().
|
||||
Immutable().
|
||||
Comment("内部幂等恢复标识,不对 API 暴露"),
|
||||
|
||||
// Subscription-related fields (added by migration 003)
|
||||
field.String("platform").
|
||||
@@ -215,6 +221,16 @@ func (Group) Fields() []ent.Field {
|
||||
field.Int("rpm_limit").
|
||||
Default(0).
|
||||
Comment("分组 RPM 上限,0 表示不限制;设置后接管该分组用户的限流"),
|
||||
|
||||
// OpenAI/Codex 请求的推理强度上限(空字符串表示不限制)。
|
||||
field.String("max_reasoning_effort").
|
||||
MaxLen(20).
|
||||
Default("").
|
||||
Comment("OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max"),
|
||||
field.JSON("reasoning_effort_mappings", []domain.ReasoningEffortMapping{}).
|
||||
Default([]domain.ReasoningEffortMapping{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("OpenAI reasoning effort 自定义精确映射;先映射再应用上限"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,5 +260,9 @@ func (Group) Indexes() []ent.Index {
|
||||
index.Fields("is_exclusive"),
|
||||
index.Fields("deleted_at"),
|
||||
index.Fields("sort_order"),
|
||||
index.Fields("duplicate_operation_id").
|
||||
Unique().
|
||||
StorageKey("idx_groups_duplicate_operation_id_active").
|
||||
Annotations(entsql.IndexWhere("duplicate_operation_id IS NOT NULL AND deleted_at IS NULL")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ func (SubscriptionPlan) Fields() []ent.Field {
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,2)"}).
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("currency").
|
||||
MaxLen(3).
|
||||
Default(""),
|
||||
field.Int("validity_days").
|
||||
Default(30),
|
||||
field.String("validity_unit").
|
||||
|
||||
@@ -27,6 +27,8 @@ type SubscriptionPlan struct {
|
||||
Price float64 `json:"price,omitempty"`
|
||||
// OriginalPrice holds the value of the "original_price" field.
|
||||
OriginalPrice *float64 `json:"original_price,omitempty"`
|
||||
// Currency holds the value of the "currency" field.
|
||||
Currency string `json:"currency,omitempty"`
|
||||
// ValidityDays holds the value of the "validity_days" field.
|
||||
ValidityDays int `json:"validity_days,omitempty"`
|
||||
// ValidityUnit holds the value of the "validity_unit" field.
|
||||
@@ -57,7 +59,7 @@ func (*SubscriptionPlan) scanValues(columns []string) ([]any, error) {
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case subscriptionplan.FieldID, subscriptionplan.FieldGroupID, subscriptionplan.FieldValidityDays, subscriptionplan.FieldSortOrder:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case subscriptionplan.FieldName, subscriptionplan.FieldDescription, subscriptionplan.FieldValidityUnit, subscriptionplan.FieldFeatures, subscriptionplan.FieldProductName:
|
||||
case subscriptionplan.FieldName, subscriptionplan.FieldDescription, subscriptionplan.FieldCurrency, subscriptionplan.FieldValidityUnit, subscriptionplan.FieldFeatures, subscriptionplan.FieldProductName:
|
||||
values[i] = new(sql.NullString)
|
||||
case subscriptionplan.FieldCreatedAt, subscriptionplan.FieldUpdatedAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
@@ -113,6 +115,12 @@ func (_m *SubscriptionPlan) assignValues(columns []string, values []any) error {
|
||||
_m.OriginalPrice = new(float64)
|
||||
*_m.OriginalPrice = value.Float64
|
||||
}
|
||||
case subscriptionplan.FieldCurrency:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field currency", values[i])
|
||||
} else if value.Valid {
|
||||
_m.Currency = value.String
|
||||
}
|
||||
case subscriptionplan.FieldValidityDays:
|
||||
if value, ok := values[i].(*sql.NullInt64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field validity_days", values[i])
|
||||
@@ -214,6 +222,9 @@ func (_m *SubscriptionPlan) String() string {
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("currency=")
|
||||
builder.WriteString(_m.Currency)
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("validity_days=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ValidityDays))
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -23,6 +23,8 @@ const (
|
||||
FieldPrice = "price"
|
||||
// FieldOriginalPrice holds the string denoting the original_price field in the database.
|
||||
FieldOriginalPrice = "original_price"
|
||||
// FieldCurrency holds the string denoting the currency field in the database.
|
||||
FieldCurrency = "currency"
|
||||
// FieldValidityDays holds the string denoting the validity_days field in the database.
|
||||
FieldValidityDays = "validity_days"
|
||||
// FieldValidityUnit holds the string denoting the validity_unit field in the database.
|
||||
@@ -51,6 +53,7 @@ var Columns = []string{
|
||||
FieldDescription,
|
||||
FieldPrice,
|
||||
FieldOriginalPrice,
|
||||
FieldCurrency,
|
||||
FieldValidityDays,
|
||||
FieldValidityUnit,
|
||||
FieldFeatures,
|
||||
@@ -76,6 +79,10 @@ var (
|
||||
NameValidator func(string) error
|
||||
// DefaultDescription holds the default value on creation for the "description" field.
|
||||
DefaultDescription string
|
||||
// DefaultCurrency holds the default value on creation for the "currency" field.
|
||||
DefaultCurrency string
|
||||
// CurrencyValidator is a validator for the "currency" field. It is called by the builders before save.
|
||||
CurrencyValidator func(string) error
|
||||
// DefaultValidityDays holds the default value on creation for the "validity_days" field.
|
||||
DefaultValidityDays int
|
||||
// DefaultValidityUnit holds the default value on creation for the "validity_unit" field.
|
||||
@@ -133,6 +140,11 @@ func ByOriginalPrice(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldOriginalPrice, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByCurrency orders the results by the currency field.
|
||||
func ByCurrency(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldCurrency, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByValidityDays orders the results by the validity_days field.
|
||||
func ByValidityDays(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldValidityDays, opts...).ToFunc()
|
||||
|
||||
@@ -79,6 +79,11 @@ func OriginalPrice(v float64) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldEQ(FieldOriginalPrice, v))
|
||||
}
|
||||
|
||||
// Currency applies equality check predicate on the "currency" field. It's identical to CurrencyEQ.
|
||||
func Currency(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldEQ(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// ValidityDays applies equality check predicate on the "validity_days" field. It's identical to ValidityDaysEQ.
|
||||
func ValidityDays(v int) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldEQ(FieldValidityDays, v))
|
||||
@@ -379,6 +384,71 @@ func OriginalPriceNotNil() predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldNotNull(FieldOriginalPrice))
|
||||
}
|
||||
|
||||
// CurrencyEQ applies the EQ predicate on the "currency" field.
|
||||
func CurrencyEQ(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldEQ(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyNEQ applies the NEQ predicate on the "currency" field.
|
||||
func CurrencyNEQ(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldNEQ(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyIn applies the In predicate on the "currency" field.
|
||||
func CurrencyIn(vs ...string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldIn(FieldCurrency, vs...))
|
||||
}
|
||||
|
||||
// CurrencyNotIn applies the NotIn predicate on the "currency" field.
|
||||
func CurrencyNotIn(vs ...string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldNotIn(FieldCurrency, vs...))
|
||||
}
|
||||
|
||||
// CurrencyGT applies the GT predicate on the "currency" field.
|
||||
func CurrencyGT(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldGT(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyGTE applies the GTE predicate on the "currency" field.
|
||||
func CurrencyGTE(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldGTE(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyLT applies the LT predicate on the "currency" field.
|
||||
func CurrencyLT(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldLT(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyLTE applies the LTE predicate on the "currency" field.
|
||||
func CurrencyLTE(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldLTE(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyContains applies the Contains predicate on the "currency" field.
|
||||
func CurrencyContains(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldContains(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyHasPrefix applies the HasPrefix predicate on the "currency" field.
|
||||
func CurrencyHasPrefix(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldHasPrefix(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyHasSuffix applies the HasSuffix predicate on the "currency" field.
|
||||
func CurrencyHasSuffix(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldHasSuffix(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyEqualFold applies the EqualFold predicate on the "currency" field.
|
||||
func CurrencyEqualFold(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldEqualFold(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyContainsFold applies the ContainsFold predicate on the "currency" field.
|
||||
func CurrencyContainsFold(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldContainsFold(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// ValidityDaysEQ applies the EQ predicate on the "validity_days" field.
|
||||
func ValidityDaysEQ(v int) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldEQ(FieldValidityDays, v))
|
||||
|
||||
@@ -68,6 +68,20 @@ func (_c *SubscriptionPlanCreate) SetNillableOriginalPrice(v *float64) *Subscrip
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetCurrency sets the "currency" field.
|
||||
func (_c *SubscriptionPlanCreate) SetCurrency(v string) *SubscriptionPlanCreate {
|
||||
_c.mutation.SetCurrency(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableCurrency sets the "currency" field if the given value is not nil.
|
||||
func (_c *SubscriptionPlanCreate) SetNillableCurrency(v *string) *SubscriptionPlanCreate {
|
||||
if v != nil {
|
||||
_c.SetCurrency(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetValidityDays sets the "validity_days" field.
|
||||
func (_c *SubscriptionPlanCreate) SetValidityDays(v int) *SubscriptionPlanCreate {
|
||||
_c.mutation.SetValidityDays(v)
|
||||
@@ -219,6 +233,10 @@ func (_c *SubscriptionPlanCreate) defaults() {
|
||||
v := subscriptionplan.DefaultDescription
|
||||
_c.mutation.SetDescription(v)
|
||||
}
|
||||
if _, ok := _c.mutation.Currency(); !ok {
|
||||
v := subscriptionplan.DefaultCurrency
|
||||
_c.mutation.SetCurrency(v)
|
||||
}
|
||||
if _, ok := _c.mutation.ValidityDays(); !ok {
|
||||
v := subscriptionplan.DefaultValidityDays
|
||||
_c.mutation.SetValidityDays(v)
|
||||
@@ -272,6 +290,14 @@ func (_c *SubscriptionPlanCreate) check() error {
|
||||
if _, ok := _c.mutation.Price(); !ok {
|
||||
return &ValidationError{Name: "price", err: errors.New(`ent: missing required field "SubscriptionPlan.price"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.Currency(); !ok {
|
||||
return &ValidationError{Name: "currency", err: errors.New(`ent: missing required field "SubscriptionPlan.currency"`)}
|
||||
}
|
||||
if v, ok := _c.mutation.Currency(); ok {
|
||||
if err := subscriptionplan.CurrencyValidator(v); err != nil {
|
||||
return &ValidationError{Name: "currency", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.currency": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.ValidityDays(); !ok {
|
||||
return &ValidationError{Name: "validity_days", err: errors.New(`ent: missing required field "SubscriptionPlan.validity_days"`)}
|
||||
}
|
||||
@@ -353,6 +379,10 @@ func (_c *SubscriptionPlanCreate) createSpec() (*SubscriptionPlan, *sqlgraph.Cre
|
||||
_spec.SetField(subscriptionplan.FieldOriginalPrice, field.TypeFloat64, value)
|
||||
_node.OriginalPrice = &value
|
||||
}
|
||||
if value, ok := _c.mutation.Currency(); ok {
|
||||
_spec.SetField(subscriptionplan.FieldCurrency, field.TypeString, value)
|
||||
_node.Currency = value
|
||||
}
|
||||
if value, ok := _c.mutation.ValidityDays(); ok {
|
||||
_spec.SetField(subscriptionplan.FieldValidityDays, field.TypeInt, value)
|
||||
_node.ValidityDays = value
|
||||
@@ -521,6 +551,18 @@ func (u *SubscriptionPlanUpsert) ClearOriginalPrice() *SubscriptionPlanUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetCurrency sets the "currency" field.
|
||||
func (u *SubscriptionPlanUpsert) SetCurrency(v string) *SubscriptionPlanUpsert {
|
||||
u.Set(subscriptionplan.FieldCurrency, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateCurrency sets the "currency" field to the value that was provided on create.
|
||||
func (u *SubscriptionPlanUpsert) UpdateCurrency() *SubscriptionPlanUpsert {
|
||||
u.SetExcluded(subscriptionplan.FieldCurrency)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetValidityDays sets the "validity_days" field.
|
||||
func (u *SubscriptionPlanUpsert) SetValidityDays(v int) *SubscriptionPlanUpsert {
|
||||
u.Set(subscriptionplan.FieldValidityDays, v)
|
||||
@@ -760,6 +802,20 @@ func (u *SubscriptionPlanUpsertOne) ClearOriginalPrice() *SubscriptionPlanUpsert
|
||||
})
|
||||
}
|
||||
|
||||
// SetCurrency sets the "currency" field.
|
||||
func (u *SubscriptionPlanUpsertOne) SetCurrency(v string) *SubscriptionPlanUpsertOne {
|
||||
return u.Update(func(s *SubscriptionPlanUpsert) {
|
||||
s.SetCurrency(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateCurrency sets the "currency" field to the value that was provided on create.
|
||||
func (u *SubscriptionPlanUpsertOne) UpdateCurrency() *SubscriptionPlanUpsertOne {
|
||||
return u.Update(func(s *SubscriptionPlanUpsert) {
|
||||
s.UpdateCurrency()
|
||||
})
|
||||
}
|
||||
|
||||
// SetValidityDays sets the "validity_days" field.
|
||||
func (u *SubscriptionPlanUpsertOne) SetValidityDays(v int) *SubscriptionPlanUpsertOne {
|
||||
return u.Update(func(s *SubscriptionPlanUpsert) {
|
||||
@@ -1181,6 +1237,20 @@ func (u *SubscriptionPlanUpsertBulk) ClearOriginalPrice() *SubscriptionPlanUpser
|
||||
})
|
||||
}
|
||||
|
||||
// SetCurrency sets the "currency" field.
|
||||
func (u *SubscriptionPlanUpsertBulk) SetCurrency(v string) *SubscriptionPlanUpsertBulk {
|
||||
return u.Update(func(s *SubscriptionPlanUpsert) {
|
||||
s.SetCurrency(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateCurrency sets the "currency" field to the value that was provided on create.
|
||||
func (u *SubscriptionPlanUpsertBulk) UpdateCurrency() *SubscriptionPlanUpsertBulk {
|
||||
return u.Update(func(s *SubscriptionPlanUpsert) {
|
||||
s.UpdateCurrency()
|
||||
})
|
||||
}
|
||||
|
||||
// SetValidityDays sets the "validity_days" field.
|
||||
func (u *SubscriptionPlanUpsertBulk) SetValidityDays(v int) *SubscriptionPlanUpsertBulk {
|
||||
return u.Update(func(s *SubscriptionPlanUpsert) {
|
||||
|
||||
@@ -125,6 +125,20 @@ func (_u *SubscriptionPlanUpdate) ClearOriginalPrice() *SubscriptionPlanUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCurrency sets the "currency" field.
|
||||
func (_u *SubscriptionPlanUpdate) SetCurrency(v string) *SubscriptionPlanUpdate {
|
||||
_u.mutation.SetCurrency(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableCurrency sets the "currency" field if the given value is not nil.
|
||||
func (_u *SubscriptionPlanUpdate) SetNillableCurrency(v *string) *SubscriptionPlanUpdate {
|
||||
if v != nil {
|
||||
_u.SetCurrency(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetValidityDays sets the "validity_days" field.
|
||||
func (_u *SubscriptionPlanUpdate) SetValidityDays(v int) *SubscriptionPlanUpdate {
|
||||
_u.mutation.ResetValidityDays()
|
||||
@@ -277,6 +291,11 @@ func (_u *SubscriptionPlanUpdate) check() error {
|
||||
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.name": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.Currency(); ok {
|
||||
if err := subscriptionplan.CurrencyValidator(v); err != nil {
|
||||
return &ValidationError{Name: "currency", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.currency": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.ValidityUnit(); ok {
|
||||
if err := subscriptionplan.ValidityUnitValidator(v); err != nil {
|
||||
return &ValidationError{Name: "validity_unit", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.validity_unit": %w`, err)}
|
||||
@@ -329,6 +348,9 @@ func (_u *SubscriptionPlanUpdate) sqlSave(ctx context.Context) (_node int, err e
|
||||
if _u.mutation.OriginalPriceCleared() {
|
||||
_spec.ClearField(subscriptionplan.FieldOriginalPrice, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.Currency(); ok {
|
||||
_spec.SetField(subscriptionplan.FieldCurrency, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ValidityDays(); ok {
|
||||
_spec.SetField(subscriptionplan.FieldValidityDays, field.TypeInt, value)
|
||||
}
|
||||
@@ -473,6 +495,20 @@ func (_u *SubscriptionPlanUpdateOne) ClearOriginalPrice() *SubscriptionPlanUpdat
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCurrency sets the "currency" field.
|
||||
func (_u *SubscriptionPlanUpdateOne) SetCurrency(v string) *SubscriptionPlanUpdateOne {
|
||||
_u.mutation.SetCurrency(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableCurrency sets the "currency" field if the given value is not nil.
|
||||
func (_u *SubscriptionPlanUpdateOne) SetNillableCurrency(v *string) *SubscriptionPlanUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetCurrency(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetValidityDays sets the "validity_days" field.
|
||||
func (_u *SubscriptionPlanUpdateOne) SetValidityDays(v int) *SubscriptionPlanUpdateOne {
|
||||
_u.mutation.ResetValidityDays()
|
||||
@@ -638,6 +674,11 @@ func (_u *SubscriptionPlanUpdateOne) check() error {
|
||||
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.name": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.Currency(); ok {
|
||||
if err := subscriptionplan.CurrencyValidator(v); err != nil {
|
||||
return &ValidationError{Name: "currency", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.currency": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.ValidityUnit(); ok {
|
||||
if err := subscriptionplan.ValidityUnitValidator(v); err != nil {
|
||||
return &ValidationError{Name: "validity_unit", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.validity_unit": %w`, err)}
|
||||
@@ -707,6 +748,9 @@ func (_u *SubscriptionPlanUpdateOne) sqlSave(ctx context.Context) (_node *Subscr
|
||||
if _u.mutation.OriginalPriceCleared() {
|
||||
_spec.ClearField(subscriptionplan.FieldOriginalPrice, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.Currency(); ok {
|
||||
_spec.SetField(subscriptionplan.FieldCurrency, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ValidityDays(); ok {
|
||||
_spec.SetField(subscriptionplan.FieldValidityDays, field.TypeInt, value)
|
||||
}
|
||||
|
||||
+8
-8
@@ -42,12 +42,12 @@ require (
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
|
||||
github.com/zeromicro/go-zero v1.9.4
|
||||
go.uber.org/zap v1.24.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.39.0
|
||||
golang.org/x/mod v0.35.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/mod v0.37.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/term v0.44.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.44.3
|
||||
@@ -177,9 +177,9 @@ require (
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/grpc v1.75.1 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
|
||||
@@ -166,6 +166,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
@@ -419,16 +421,24 @@ golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -440,14 +450,22 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/net/http/httpguts"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -94,6 +97,7 @@ type Config struct {
|
||||
Update UpdateConfig `mapstructure:"update"`
|
||||
Idempotency IdempotencyConfig `mapstructure:"idempotency"`
|
||||
BatchImage BatchImageConfig `mapstructure:"batch_image"`
|
||||
ImageStorage ImageStorageConfig `mapstructure:"image_storage"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
@@ -226,6 +230,49 @@ type BatchImageConfig struct {
|
||||
VertexGCSBaseURL string `mapstructure:"vertex_gcs_base_url"`
|
||||
}
|
||||
|
||||
// ImageStorageConfig 配置异步图片任务结果上传的 S3 兼容对象存储。
|
||||
// Enabled 同时作为异步图片任务功能的总开关:未启用或未配置完整凭证时,
|
||||
// 异步生图接口整体禁用,避免把上游返回的大 base64 结果塞进 Redis。
|
||||
type ImageStorageConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Endpoint string `mapstructure:"endpoint"` // e.g. https://<account_id>.r2.cloudflarestorage.com
|
||||
Region string `mapstructure:"region"` // R2 用 "auto"
|
||||
Bucket string `mapstructure:"bucket"`
|
||||
AccessKeyID string `mapstructure:"access_key_id"`
|
||||
SecretAccessKey string `mapstructure:"secret_access_key"`
|
||||
Prefix string `mapstructure:"prefix"` // S3 key 前缀,如 "images/"
|
||||
ForcePathStyle bool `mapstructure:"force_path_style"` // MinIO/路径风格桶
|
||||
PublicBaseURL string `mapstructure:"public_base_url"` // 配了则返回 public_base_url/key 直链;否则 presigned
|
||||
PresignExpiry int `mapstructure:"presign_expiry_hours"` // public_base_url 为空时的 presigned 过期时长(小时)
|
||||
MaxDownloadByte int64 `mapstructure:"max_download_bytes"` // 下载上游 url 图片的字节上限
|
||||
}
|
||||
|
||||
// IsConfigured 检查对象存储必要字段是否已配置
|
||||
func (c *ImageStorageConfig) IsConfigured() bool {
|
||||
return c.Bucket != "" && c.AccessKeyID != "" && c.SecretAccessKey != ""
|
||||
}
|
||||
|
||||
// Active 返回异步图片任务是否可用:开关打开且凭证齐全
|
||||
func (c *ImageStorageConfig) Active() bool {
|
||||
return c.Enabled && c.IsConfigured()
|
||||
}
|
||||
|
||||
// MissingCredentialKeys 返回 IsConfigured 所缺的配置键名。
|
||||
// 用于启动日志:只说"凭证不完整"会让运维以为自己漏填了,而实际可能是值填了却没被读到。
|
||||
func (c *ImageStorageConfig) MissingCredentialKeys() []string {
|
||||
var missing []string
|
||||
if c.Bucket == "" {
|
||||
missing = append(missing, "image_storage.bucket")
|
||||
}
|
||||
if c.AccessKeyID == "" {
|
||||
missing = append(missing, "image_storage.access_key_id")
|
||||
}
|
||||
if c.SecretAccessKey == "" {
|
||||
missing = append(missing, "image_storage.secret_access_key")
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
type LinuxDoConnectConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
ClientID string `mapstructure:"client_id"`
|
||||
@@ -580,6 +627,18 @@ type TokenRefreshConfig struct {
|
||||
MaxRetries int `mapstructure:"max_retries"`
|
||||
// 重试退避基础时间(秒)
|
||||
RetryBackoffSeconds int `mapstructure:"retry_backoff_seconds"`
|
||||
// 每次从数据库读取的候选账号上限
|
||||
CandidatePageSize int `mapstructure:"candidate_page_size"`
|
||||
// 每个平台允许的并发刷新数
|
||||
ProviderConcurrency int `mapstructure:"provider_concurrency"`
|
||||
// 每个平台、每个进程允许的刷新请求速率
|
||||
ProviderQPS int `mapstructure:"provider_qps"`
|
||||
// 一个周期内连续临时失败达到此值后停止该平台
|
||||
ProviderFailureThreshold int `mapstructure:"provider_failure_threshold"`
|
||||
// 单次上游刷新尝试的超时(秒)
|
||||
AttemptTimeoutSeconds int `mapstructure:"attempt_timeout_seconds"`
|
||||
// 单个后台刷新周期的总超时(秒)
|
||||
CycleTimeoutSeconds int `mapstructure:"cycle_timeout_seconds"`
|
||||
}
|
||||
|
||||
type PricingConfig struct {
|
||||
@@ -598,16 +657,18 @@ type PricingConfig struct {
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"` // debug/release
|
||||
EnableServerTiming bool `mapstructure:"enable_server_timing"` // Admin UI Server-Timing response header
|
||||
FrontendURL string `mapstructure:"frontend_url"` // 前端基础 URL,用于生成邮件中的外部链接
|
||||
ReadHeaderTimeout int `mapstructure:"read_header_timeout"` // 读取请求头超时(秒)
|
||||
IdleTimeout int `mapstructure:"idle_timeout"` // 空闲连接超时(秒)
|
||||
TrustedProxies []string `mapstructure:"trusted_proxies"` // 可信代理列表(CIDR/IP)
|
||||
MaxRequestBodySize int64 `mapstructure:"max_request_body_size"` // 全局最大请求体限制
|
||||
H2C H2CConfig `mapstructure:"h2c"` // HTTP/2 Cleartext 配置
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"` // debug/release
|
||||
EnableServerTiming bool `mapstructure:"enable_server_timing"` // Admin UI Server-Timing response header
|
||||
FrontendURL string `mapstructure:"frontend_url"` // 前端基础 URL,用于生成邮件中的外部链接
|
||||
ReadHeaderTimeout int `mapstructure:"read_header_timeout"` // 读取请求头超时(秒)
|
||||
MaxHeaderBytes int `mapstructure:"max_header_bytes"` // 请求头最大字节数(HTTP/2 映射为 header-list 上限)
|
||||
IdleTimeout int `mapstructure:"idle_timeout"` // 空闲连接超时(秒)
|
||||
TrustedProxies []string `mapstructure:"trusted_proxies"` // 可信代理列表(CIDR/IP)
|
||||
TrustedProxiesConfigured bool `mapstructure:"-" json:"-" yaml:"-"` // 是否显式配置了可信代理列表
|
||||
MaxRequestBodySize int64 `mapstructure:"max_request_body_size"` // 全局最大请求体限制
|
||||
H2C H2CConfig `mapstructure:"h2c"` // HTTP/2 Cleartext 配置
|
||||
}
|
||||
|
||||
// H2CConfig HTTP/2 Cleartext 配置
|
||||
@@ -625,36 +686,103 @@ type CORSConfig struct {
|
||||
AllowCredentials bool `mapstructure:"allow_credentials"`
|
||||
}
|
||||
|
||||
const MaxForwardedClientIPHeaders = 16
|
||||
|
||||
type ForwardedClientIPSettings struct {
|
||||
TrustForwardedIP bool
|
||||
Headers []string
|
||||
}
|
||||
|
||||
type SecurityConfig struct {
|
||||
URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"`
|
||||
ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"`
|
||||
CSP CSPConfig `mapstructure:"csp"`
|
||||
ProxyFallback ProxyFallbackConfig `mapstructure:"proxy_fallback"`
|
||||
ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"`
|
||||
TrustForwardedIPForAPIKeyACL bool `mapstructure:"trust_forwarded_ip_for_api_key_acl"`
|
||||
trustForwardedIPForAPIKeyACLLive *atomic.Bool `mapstructure:"-"`
|
||||
URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"`
|
||||
ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"`
|
||||
CSP CSPConfig `mapstructure:"csp"`
|
||||
ProxyFallback ProxyFallbackConfig `mapstructure:"proxy_fallback"`
|
||||
ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"`
|
||||
// TrustForwardedIPForAPIKeyACL enables legacy raw forwarded-header takeover.
|
||||
// When disabled, server.trusted_proxies is authoritative for all client-IP consumers.
|
||||
TrustForwardedIPForAPIKeyACL bool `mapstructure:"trust_forwarded_ip_for_api_key_acl"`
|
||||
ForwardedClientIPHeaders []string `mapstructure:"forwarded_client_ip_headers" json:"forwarded_client_ip_headers" yaml:"forwarded_client_ip_headers"`
|
||||
forwardedClientIPSettingsLive *atomic.Pointer[ForwardedClientIPSettings] `mapstructure:"-" json:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
func NormalizeForwardedClientIPHeaders(headers []string) ([]string, error) {
|
||||
normalized := make([]string, 0, len(headers))
|
||||
seen := make(map[string]struct{}, len(headers))
|
||||
for _, header := range headers {
|
||||
header = strings.TrimSpace(header)
|
||||
if !httpguts.ValidHeaderFieldName(header) {
|
||||
return nil, fmt.Errorf("invalid HTTP header field name %q", header)
|
||||
}
|
||||
canonical := textproto.CanonicalMIMEHeaderKey(header)
|
||||
key := strings.ToLower(canonical)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
if len(normalized) == MaxForwardedClientIPHeaders {
|
||||
return nil, fmt.Errorf("forwarded client IP headers must contain at most %d unique names", MaxForwardedClientIPHeaders)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
normalized = append(normalized, canonical)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func cloneForwardedClientIPHeaders(headers []string) []string {
|
||||
if len(headers) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
return append([]string(nil), headers...)
|
||||
}
|
||||
|
||||
func (c *Config) ForwardedClientIPSettings() ForwardedClientIPSettings {
|
||||
if c == nil {
|
||||
return ForwardedClientIPSettings{Headers: []string{}}
|
||||
}
|
||||
live := c.Security.forwardedClientIPSettingsLive
|
||||
if live != nil {
|
||||
if snapshot := live.Load(); snapshot != nil {
|
||||
return ForwardedClientIPSettings{
|
||||
TrustForwardedIP: snapshot.TrustForwardedIP,
|
||||
Headers: cloneForwardedClientIPHeaders(snapshot.Headers),
|
||||
}
|
||||
}
|
||||
}
|
||||
return ForwardedClientIPSettings{
|
||||
TrustForwardedIP: c.Security.TrustForwardedIPForAPIKeyACL,
|
||||
Headers: cloneForwardedClientIPHeaders(c.Security.ForwardedClientIPHeaders),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) TrustForwardedIPForAPIKeyACL() bool {
|
||||
return c.ForwardedClientIPSettings().TrustForwardedIP
|
||||
}
|
||||
|
||||
// ForwardedClientIPTrustEnabled reports whether the legacy forwarded-header
|
||||
// compatibility mode currently overrides server.trusted_proxies.
|
||||
func (c *Config) ForwardedClientIPTrustEnabled() bool {
|
||||
return c != nil && c.TrustForwardedIPForAPIKeyACL()
|
||||
}
|
||||
|
||||
func (c *Config) SetForwardedClientIPSettings(enabled bool, headers []string) {
|
||||
if c == nil {
|
||||
return false
|
||||
return
|
||||
}
|
||||
live := c.Security.trustForwardedIPForAPIKeyACLLive
|
||||
if live == nil {
|
||||
return c.Security.TrustForwardedIPForAPIKeyACL
|
||||
headers = cloneForwardedClientIPHeaders(headers)
|
||||
if c.Security.forwardedClientIPSettingsLive == nil {
|
||||
c.Security.forwardedClientIPSettingsLive = &atomic.Pointer[ForwardedClientIPSettings]{}
|
||||
}
|
||||
return live.Load()
|
||||
c.Security.forwardedClientIPSettingsLive.Store(&ForwardedClientIPSettings{
|
||||
TrustForwardedIP: enabled,
|
||||
Headers: headers,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Config) SetTrustForwardedIPForAPIKeyACL(enabled bool) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.Security.TrustForwardedIPForAPIKeyACL = enabled
|
||||
if c.Security.trustForwardedIPForAPIKeyACLLive == nil {
|
||||
c.Security.trustForwardedIPForAPIKeyACLLive = &atomic.Bool{}
|
||||
}
|
||||
c.Security.trustForwardedIPForAPIKeyACLLive.Store(enabled)
|
||||
c.SetForwardedClientIPSettings(enabled, c.ForwardedClientIPSettings().Headers)
|
||||
}
|
||||
|
||||
type URLAllowlistConfig struct {
|
||||
@@ -748,8 +876,15 @@ type GatewayConfig struct {
|
||||
// OpenAIResponseHeaderTimeout: OpenAI/Codex 上游等待响应头的超时时间(秒),0表示无超时
|
||||
// OpenAI/Codex 请求可能在上游排队较久;默认不使用通用响应头超时截断。
|
||||
OpenAIResponseHeaderTimeout int `mapstructure:"openai_response_header_timeout"`
|
||||
// OpenAIFirstOutputTimeoutSeconds: native HTTP Responses 首个语义输出超时(秒),0表示禁用。
|
||||
OpenAIFirstOutputTimeoutSeconds int `mapstructure:"openai_first_output_timeout_seconds"`
|
||||
// OpenAIHighEffortFirstOutputTimeoutSeconds: high/xhigh/max 推理的首个语义输出超时(秒)。
|
||||
// 0 表示回退到 OpenAIFirstOutputTimeoutSeconds。
|
||||
OpenAIHighEffortFirstOutputTimeoutSeconds int `mapstructure:"openai_high_effort_first_output_timeout_seconds"`
|
||||
// 请求体最大字节数,用于网关请求体大小限制
|
||||
MaxBodySize int64 `mapstructure:"max_body_size"`
|
||||
// TextMaxBodySize limits endpoints that cannot carry inline image/video payloads.
|
||||
TextMaxBodySize int64 `mapstructure:"text_max_body_size"`
|
||||
// 非流式上游响应体读取上限(字节),用于防止无界读取导致内存放大
|
||||
UpstreamResponseReadMaxBytes int64 `mapstructure:"upstream_response_read_max_bytes"`
|
||||
// 代理探测响应体读取上限(字节)
|
||||
@@ -919,6 +1054,9 @@ func (c *UserMessageQueueConfig) GetEffectiveMode() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// DefaultOpenAIWSClientFirstMessageTimeoutSeconds preserves the legacy ingress deadline.
|
||||
const DefaultOpenAIWSClientFirstMessageTimeoutSeconds = 30
|
||||
|
||||
// GatewayOpenAIWSConfig OpenAI Responses WebSocket 配置。
|
||||
// 注意:默认全局开启;如需回滚可使用 force_http 或关闭 enabled。
|
||||
type GatewayOpenAIWSConfig struct {
|
||||
@@ -926,6 +1064,9 @@ type GatewayOpenAIWSConfig struct {
|
||||
ModeRouterV2Enabled bool `mapstructure:"mode_router_v2_enabled"`
|
||||
// IngressModeDefault: ingress 默认模式(off/ctx_pool/passthrough/http_bridge)
|
||||
IngressModeDefault string `mapstructure:"ingress_mode_default"`
|
||||
// ClientFirstMessageTimeoutSeconds bounds the total time to read and decompress
|
||||
// the first client response.create message after the WebSocket upgrade.
|
||||
ClientFirstMessageTimeoutSeconds int `mapstructure:"client_first_message_timeout_seconds"`
|
||||
// IngressInterTurnIdleTimeoutSeconds bounds the time a client may remain idle
|
||||
// between completed ingress turns. Zero disables this protection.
|
||||
IngressInterTurnIdleTimeoutSeconds int `mapstructure:"ingress_inter_turn_idle_timeout_seconds"`
|
||||
@@ -1029,11 +1170,35 @@ type GatewayOpenAIWSSchedulerScoreWeights struct {
|
||||
Reset float64 `mapstructure:"reset"`
|
||||
// QuotaHeadroom 倾向 7d 剩余额度更健康的账号;默认 0(关闭,不改变原有行为)。
|
||||
QuotaHeadroom float64 `mapstructure:"quota_headroom"`
|
||||
// UpstreamCost 倾向上游声明倍率更低的账号;默认 0(关闭,不改变原有行为)。
|
||||
UpstreamCost float64 `mapstructure:"upstream_cost"`
|
||||
// PreviousResponse/SessionSticky 仅在开启 OpenAI 高级调度的粘性加权时生效。
|
||||
PreviousResponse float64 `mapstructure:"previous_response"`
|
||||
SessionSticky float64 `mapstructure:"session_sticky"`
|
||||
}
|
||||
|
||||
func (w GatewayOpenAIWSSchedulerScoreWeights) BaseWeightSum() float64 {
|
||||
return w.Priority + w.Load + w.Queue + w.ErrorRate + w.TTFT + w.Reset + w.QuotaHeadroom + w.UpstreamCost
|
||||
}
|
||||
|
||||
func (w GatewayOpenAIWSSchedulerScoreWeights) TotalWeightSum() float64 {
|
||||
return w.BaseWeightSum() + w.PreviousResponse + w.SessionSticky
|
||||
}
|
||||
|
||||
func (w GatewayOpenAIWSSchedulerScoreWeights) IsValid() bool {
|
||||
for _, weight := range []float64{
|
||||
w.Priority, w.Load, w.Queue, w.ErrorRate, w.TTFT, w.Reset,
|
||||
w.QuotaHeadroom, w.UpstreamCost, w.PreviousResponse, w.SessionSticky,
|
||||
} {
|
||||
if weight < 0 || math.IsNaN(weight) || math.IsInf(weight, 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
baseSum := w.BaseWeightSum()
|
||||
return baseSum > 0 && !math.IsNaN(baseSum) && !math.IsInf(baseSum, 0) &&
|
||||
!math.IsNaN(w.TotalWeightSum()) && !math.IsInf(w.TotalWeightSum(), 0)
|
||||
}
|
||||
|
||||
// GatewayOpenAISchedulerConfig OpenAI 高级调度器配置。
|
||||
type GatewayOpenAISchedulerConfig struct {
|
||||
// StickyEscapeEnabled: 是否允许 session_hash sticky 在账号健康度劣化时临时逃逸
|
||||
@@ -1238,6 +1403,7 @@ func (d *DatabaseConfig) DSNWithTimezone(tz string) string {
|
||||
type RedisConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
DB int `mapstructure:"db"`
|
||||
// 连接池与超时配置(性能优化:可配置化连接池参数)
|
||||
@@ -1343,12 +1509,22 @@ type RateLimitConfig struct {
|
||||
|
||||
// APIKeyAuthCacheConfig API Key 认证缓存配置
|
||||
type APIKeyAuthCacheConfig struct {
|
||||
L1Size int `mapstructure:"l1_size"`
|
||||
L1TTLSeconds int `mapstructure:"l1_ttl_seconds"`
|
||||
L2TTLSeconds int `mapstructure:"l2_ttl_seconds"`
|
||||
NegativeTTLSeconds int `mapstructure:"negative_ttl_seconds"`
|
||||
JitterPercent int `mapstructure:"jitter_percent"`
|
||||
Singleflight bool `mapstructure:"singleflight"`
|
||||
L1Size int `mapstructure:"l1_size"`
|
||||
L1TTLSeconds int `mapstructure:"l1_ttl_seconds"`
|
||||
L2TTLSeconds int `mapstructure:"l2_ttl_seconds"`
|
||||
NegativeTTLSeconds int `mapstructure:"negative_ttl_seconds"`
|
||||
JitterPercent int `mapstructure:"jitter_percent"`
|
||||
Singleflight bool `mapstructure:"singleflight"`
|
||||
LookupConcurrency int `mapstructure:"lookup_concurrency"`
|
||||
InvalidAbuse InvalidAuthAbuseConfig `mapstructure:"invalid_abuse"`
|
||||
}
|
||||
|
||||
type InvalidAuthAbuseConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Threshold int `mapstructure:"threshold"`
|
||||
WindowSeconds int `mapstructure:"window_seconds"`
|
||||
BlockSeconds int `mapstructure:"block_seconds"`
|
||||
Capacity int `mapstructure:"capacity"`
|
||||
}
|
||||
|
||||
// SubscriptionCacheConfig 订阅认证 L1 缓存配置
|
||||
@@ -1475,17 +1651,31 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
|
||||
}
|
||||
// 配置文件不存在时使用默认值
|
||||
}
|
||||
trustedProxiesEnv, trustedProxiesEnvConfigured := os.LookupEnv("SERVER_TRUSTED_PROXIES")
|
||||
forwardedClientIPHeadersEnv, forwardedClientIPHeadersEnvConfigured := os.LookupEnv("SECURITY_FORWARDED_CLIENT_IP_HEADERS")
|
||||
trustedProxiesConfigured := viper.InConfig("server.trusted_proxies") ||
|
||||
viper.IsSet("server.trusted_proxies") || trustedProxiesEnvConfigured
|
||||
|
||||
var cfg Config
|
||||
if err := viper.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal config error: %w", err)
|
||||
}
|
||||
if trustedProxiesEnvConfigured {
|
||||
cfg.Server.TrustedProxies = normalizeStringSlice(strings.Split(trustedProxiesEnv, ","))
|
||||
}
|
||||
if forwardedClientIPHeadersEnvConfigured {
|
||||
cfg.Security.ForwardedClientIPHeaders = normalizeStringSlice(strings.Split(forwardedClientIPHeadersEnv, ","))
|
||||
}
|
||||
cfg.Server.TrustedProxiesConfigured = trustedProxiesConfigured
|
||||
if cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs == 0 {
|
||||
cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs = 15000
|
||||
}
|
||||
if cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate == 0 {
|
||||
cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate = 0.5
|
||||
}
|
||||
// Kept as a backstop: setEnvReachableDefaults now registers this key with its
|
||||
// effective default (true), so IsSet always reports true and this branch no
|
||||
// longer fires. It still guards the default if that registration is dropped.
|
||||
if !cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled && !viper.IsSet("gateway.openai_scheduler.sticky_escape_enabled") {
|
||||
cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled = true
|
||||
}
|
||||
@@ -1535,7 +1725,12 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
|
||||
cfg.Security.ResponseHeaders.AdditionalAllowed = normalizeStringSlice(cfg.Security.ResponseHeaders.AdditionalAllowed)
|
||||
cfg.Security.ResponseHeaders.ForceRemove = normalizeStringSlice(cfg.Security.ResponseHeaders.ForceRemove)
|
||||
cfg.Security.CSP.Policy = strings.TrimSpace(cfg.Security.CSP.Policy)
|
||||
cfg.SetTrustForwardedIPForAPIKeyACL(cfg.Security.TrustForwardedIPForAPIKeyACL)
|
||||
forwardedClientIPHeaders, err := NormalizeForwardedClientIPHeaders(cfg.Security.ForwardedClientIPHeaders)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("security.forwarded_client_ip_headers: %w", err)
|
||||
}
|
||||
cfg.Security.ForwardedClientIPHeaders = forwardedClientIPHeaders
|
||||
cfg.SetForwardedClientIPSettings(cfg.Security.TrustForwardedIPForAPIKeyACL, forwardedClientIPHeaders)
|
||||
cfg.Log.Level = strings.ToLower(strings.TrimSpace(cfg.Log.Level))
|
||||
cfg.Log.Format = strings.ToLower(strings.TrimSpace(cfg.Log.Format))
|
||||
cfg.Log.ServiceName = strings.TrimSpace(cfg.Log.ServiceName)
|
||||
@@ -1622,9 +1817,9 @@ func setDefaults() {
|
||||
viper.SetDefault("server.mode", "release")
|
||||
viper.SetDefault("server.enable_server_timing", false)
|
||||
viper.SetDefault("server.frontend_url", "")
|
||||
viper.SetDefault("server.read_header_timeout", 30) // 30秒读取请求头
|
||||
viper.SetDefault("server.idle_timeout", 120) // 120秒空闲超时
|
||||
viper.SetDefault("server.trusted_proxies", []string{})
|
||||
viper.SetDefault("server.read_header_timeout", 10) // 10秒读取请求头
|
||||
viper.SetDefault("server.max_header_bytes", 64*1024)
|
||||
viper.SetDefault("server.idle_timeout", 120) // 120秒空闲超时
|
||||
viper.SetDefault("server.max_request_body_size", int64(256*1024*1024))
|
||||
// H2C 默认配置
|
||||
viper.SetDefault("server.h2c.enabled", false)
|
||||
@@ -1681,7 +1876,7 @@ func setDefaults() {
|
||||
viper.SetDefault("security.csp.enabled", true)
|
||||
viper.SetDefault("security.csp.policy", DefaultCSPPolicy)
|
||||
viper.SetDefault("security.proxy_probe.insecure_skip_verify", false)
|
||||
viper.SetDefault("security.trust_forwarded_ip_for_api_key_acl", false)
|
||||
viper.SetDefault("security.trust_forwarded_ip_for_api_key_acl", true)
|
||||
|
||||
// Security - disable direct fallback on proxy error
|
||||
viper.SetDefault("security.proxy_fallback.allow_direct_on_error", false)
|
||||
@@ -1787,6 +1982,7 @@ func setDefaults() {
|
||||
// Redis
|
||||
viper.SetDefault("redis.host", "localhost")
|
||||
viper.SetDefault("redis.port", 6379)
|
||||
viper.SetDefault("redis.username", "")
|
||||
viper.SetDefault("redis.password", "")
|
||||
viper.SetDefault("redis.db", 0)
|
||||
viper.SetDefault("redis.dial_timeout_seconds", 5)
|
||||
@@ -1843,6 +2039,23 @@ func setDefaults() {
|
||||
viper.SetDefault("batch_image.vertex_batch_prediction_base_url", "")
|
||||
viper.SetDefault("batch_image.vertex_gcs_base_url", "")
|
||||
|
||||
// Image storage (async image task result offload to S3-compatible object storage)
|
||||
viper.SetDefault("image_storage.enabled", false)
|
||||
viper.SetDefault("image_storage.region", "auto")
|
||||
viper.SetDefault("image_storage.prefix", "images/")
|
||||
viper.SetDefault("image_storage.force_path_style", false)
|
||||
viper.SetDefault("image_storage.presign_expiry_hours", 24)
|
||||
viper.SetDefault("image_storage.max_download_bytes", 33554432)
|
||||
// Registered with empty defaults so AutomaticEnv can reach them: viper only
|
||||
// decodes keys present in AllKeys(), so a credential that is supplied purely
|
||||
// via IMAGE_STORAGE_* and never appears in config.yaml would be dropped and
|
||||
// silently disable the whole async image feature.
|
||||
viper.SetDefault("image_storage.endpoint", "")
|
||||
viper.SetDefault("image_storage.bucket", "")
|
||||
viper.SetDefault("image_storage.access_key_id", "")
|
||||
viper.SetDefault("image_storage.secret_access_key", "")
|
||||
viper.SetDefault("image_storage.public_base_url", "")
|
||||
|
||||
// Ops (vNext)
|
||||
viper.SetDefault("ops.enabled", true)
|
||||
viper.SetDefault("ops.use_preaggregated_tables", true)
|
||||
@@ -1899,6 +2112,12 @@ func setDefaults() {
|
||||
viper.SetDefault("api_key_auth_cache.negative_ttl_seconds", 30)
|
||||
viper.SetDefault("api_key_auth_cache.jitter_percent", 10)
|
||||
viper.SetDefault("api_key_auth_cache.singleflight", true)
|
||||
viper.SetDefault("api_key_auth_cache.lookup_concurrency", 64)
|
||||
viper.SetDefault("api_key_auth_cache.invalid_abuse.enabled", true)
|
||||
viper.SetDefault("api_key_auth_cache.invalid_abuse.threshold", 120)
|
||||
viper.SetDefault("api_key_auth_cache.invalid_abuse.window_seconds", 60)
|
||||
viper.SetDefault("api_key_auth_cache.invalid_abuse.block_seconds", 60)
|
||||
viper.SetDefault("api_key_auth_cache.invalid_abuse.capacity", 16384)
|
||||
|
||||
// Subscription auth L1 cache
|
||||
viper.SetDefault("subscription_cache.l1_size", 16384)
|
||||
@@ -1944,6 +2163,8 @@ func setDefaults() {
|
||||
// Gateway
|
||||
viper.SetDefault("gateway.response_header_timeout", 600) // 600秒(10分钟)等待上游响应头,LLM高负载时可能排队较久
|
||||
viper.SetDefault("gateway.openai_response_header_timeout", 0)
|
||||
viper.SetDefault("gateway.openai_first_output_timeout_seconds", 0)
|
||||
viper.SetDefault("gateway.openai_high_effort_first_output_timeout_seconds", 0)
|
||||
viper.SetDefault("gateway.log_upstream_error_body", true)
|
||||
viper.SetDefault("gateway.log_upstream_error_body_max_bytes", 2048)
|
||||
viper.SetDefault("gateway.inject_beta_for_apikey", false)
|
||||
@@ -1958,6 +2179,7 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.openai_ws.enabled", true)
|
||||
viper.SetDefault("gateway.openai_ws.mode_router_v2_enabled", false)
|
||||
viper.SetDefault("gateway.openai_ws.ingress_mode_default", "ctx_pool")
|
||||
viper.SetDefault("gateway.openai_ws.client_first_message_timeout_seconds", DefaultOpenAIWSClientFirstMessageTimeoutSeconds)
|
||||
viper.SetDefault("gateway.openai_ws.ingress_inter_turn_idle_timeout_seconds", 300)
|
||||
viper.SetDefault("gateway.openai_ws.max_ingress_connections_per_api_key", 64)
|
||||
viper.SetDefault("gateway.openai_ws.oauth_enabled", true)
|
||||
@@ -2007,6 +2229,7 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.ttft", 0.5)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.reset", 0.0)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.quota_headroom", 0.0)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.upstream_cost", 0.0)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.previous_response", 5.0)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.session_sticky", 3.0)
|
||||
// OpenAI HTTP upstream protocol strategy
|
||||
@@ -2023,6 +2246,7 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.antigravity_fallback_cooldown_minutes", 1)
|
||||
viper.SetDefault("gateway.antigravity_extra_retries", 10)
|
||||
viper.SetDefault("gateway.max_body_size", int64(256*1024*1024))
|
||||
viper.SetDefault("gateway.text_max_body_size", int64(32*1024*1024))
|
||||
viper.SetDefault("gateway.upstream_response_read_max_bytes", DefaultUpstreamResponseReadMaxBytes)
|
||||
viper.SetDefault("gateway.proxy_probe_response_read_max_bytes", int64(1024*1024))
|
||||
viper.SetDefault("gateway.gemini_debug_response_headers", false)
|
||||
@@ -2098,6 +2322,12 @@ func setDefaults() {
|
||||
viper.SetDefault("token_refresh.refresh_before_expiry_hours", 0.5) // 提前30分钟刷新(适配Google 1小时token)
|
||||
viper.SetDefault("token_refresh.max_retries", 3) // 最多重试3次
|
||||
viper.SetDefault("token_refresh.retry_backoff_seconds", 2) // 重试退避基础2秒
|
||||
viper.SetDefault("token_refresh.candidate_page_size", 200)
|
||||
viper.SetDefault("token_refresh.provider_concurrency", 4)
|
||||
viper.SetDefault("token_refresh.provider_qps", 2)
|
||||
viper.SetDefault("token_refresh.provider_failure_threshold", 3)
|
||||
viper.SetDefault("token_refresh.attempt_timeout_seconds", 15)
|
||||
viper.SetDefault("token_refresh.cycle_timeout_seconds", 240)
|
||||
|
||||
// Gemini OAuth - configure via environment variables or config file
|
||||
// GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET
|
||||
@@ -2111,9 +2341,140 @@ func setDefaults() {
|
||||
viper.SetDefault("subscription_maintenance.worker_count", 2)
|
||||
viper.SetDefault("subscription_maintenance.queue_size", 1024)
|
||||
|
||||
setEnvReachableDefaults()
|
||||
}
|
||||
|
||||
// setEnvReachableDefaults registers zero-valued defaults for keys that are
|
||||
// documented in deploy/config.example.yaml but had no default of their own.
|
||||
//
|
||||
// viper.Unmarshal only decodes the keys returned by AllKeys(), which unions
|
||||
// SetDefault keys, config-file keys and explicitly bound BindEnv keys.
|
||||
// AutomaticEnv can override a key already in that union, but it never adds one,
|
||||
// and the viper_bind_struct escape hatch is compiled out (we build with
|
||||
// -tags embed). So a key that lives only in the example file was unreachable by
|
||||
// environment variable: the value was read from the process environment and
|
||||
// then silently dropped. Deployments driven purely by env — which is what
|
||||
// deploy/docker-compose.yml does — got the zero value with no warning.
|
||||
//
|
||||
// The values below are deliberately zero rather than the documented example
|
||||
// values: an absent key already unmarshalled to the zero value, so registering
|
||||
// zero keeps behavior identical while making the key addressable from the
|
||||
// environment. Any subsystem that wants a richer default still applies it after
|
||||
// unmarshal, exactly as before.
|
||||
func setEnvReachableDefaults() {
|
||||
viper.SetDefault("gateway.forced_codex_instructions_template_file", "")
|
||||
viper.SetDefault("gateway.session_idle_timeout_minutes", 0)
|
||||
viper.SetDefault("gateway.user_message_queue.mode", "")
|
||||
viper.SetDefault("update.proxy_url", "")
|
||||
|
||||
// sticky_escape_enabled is the one exception to the zero-value rule: its
|
||||
// effective default is true, applied post-unmarshal via a viper.IsSet guard.
|
||||
// Registering false would make IsSet always report true and permanently
|
||||
// disable sticky escape, so register the effective default instead. An
|
||||
// explicit false in config or env still wins.
|
||||
viper.SetDefault("gateway.openai_scheduler.sticky_escape_enabled", true)
|
||||
viper.SetDefault("gateway.openai_scheduler.sticky_escape_error_rate", 0.0)
|
||||
viper.SetDefault("gateway.openai_scheduler.sticky_escape_ttft_ms", 0)
|
||||
|
||||
// server.trusted_proxies and security.forwarded_client_ip_headers are the
|
||||
// other exception: load() distinguishes explicit configuration from absence
|
||||
// (issue #4600), and viper.IsSet also reports registered defaults, so a
|
||||
// SetDefault would make trusted proxies look permanently configured. Both
|
||||
// environment variables are parsed by hand in load() via os.LookupEnv and
|
||||
// were never silently dropped; binding them here records that reachability
|
||||
// where AllKeys() — and the env-reachability guard — can see it, without
|
||||
// affecting IsSet while the variables are absent. BindEnv only errors when
|
||||
// called without arguments.
|
||||
_ = viper.BindEnv("server.trusted_proxies", "SERVER_TRUSTED_PROXIES")
|
||||
_ = viper.BindEnv("security.forwarded_client_ip_headers", "SECURITY_FORWARDED_CLIENT_IP_HEADERS")
|
||||
|
||||
// Third-party login providers. These carry client secrets and are exactly
|
||||
// the settings an operator expects to inject via the environment, but every
|
||||
// key here was previously unreachable that way.
|
||||
for _, provider := range []string{"github_oauth", "google_oauth"} {
|
||||
viper.SetDefault(provider+".enabled", false)
|
||||
viper.SetDefault(provider+".client_id", "")
|
||||
viper.SetDefault(provider+".client_secret", "")
|
||||
viper.SetDefault(provider+".authorize_url", "")
|
||||
viper.SetDefault(provider+".token_url", "")
|
||||
viper.SetDefault(provider+".userinfo_url", "")
|
||||
viper.SetDefault(provider+".emails_url", "")
|
||||
viper.SetDefault(provider+".scopes", "")
|
||||
viper.SetDefault(provider+".redirect_url", "")
|
||||
viper.SetDefault(provider+".frontend_redirect_url", "")
|
||||
}
|
||||
|
||||
viper.SetDefault("dingtalk_connect.client_id", "")
|
||||
viper.SetDefault("dingtalk_connect.client_secret", "")
|
||||
viper.SetDefault("dingtalk_connect.internal_corp_id", "")
|
||||
viper.SetDefault("dingtalk_connect.redirect_url", "")
|
||||
viper.SetDefault("dingtalk_connect.bypass_registration", false)
|
||||
viper.SetDefault("dingtalk_connect.username_attribute_key", "")
|
||||
viper.SetDefault("dingtalk_connect.enable_attribute_matching", false)
|
||||
viper.SetDefault("dingtalk_connect.enable_attribute_sync", false)
|
||||
viper.SetDefault("dingtalk_connect.attribute_sync_fields", []string{})
|
||||
viper.SetDefault("dingtalk_connect.attribute_sync_overwrite_policy", "")
|
||||
viper.SetDefault("dingtalk_connect.sync_display_name", false)
|
||||
viper.SetDefault("dingtalk_connect.sync_display_name_attr_key", "")
|
||||
viper.SetDefault("dingtalk_connect.sync_display_name_attr_name", "")
|
||||
viper.SetDefault("dingtalk_connect.sync_dept", false)
|
||||
viper.SetDefault("dingtalk_connect.sync_dept_attr_key", "")
|
||||
viper.SetDefault("dingtalk_connect.sync_dept_attr_name", "")
|
||||
viper.SetDefault("dingtalk_connect.sync_corp_email", false)
|
||||
viper.SetDefault("dingtalk_connect.sync_corp_email_attr_key", "")
|
||||
viper.SetDefault("dingtalk_connect.sync_corp_email_attr_name", "")
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
forwardedClientIPHeaders, err := NormalizeForwardedClientIPHeaders(c.Security.ForwardedClientIPHeaders)
|
||||
if err != nil {
|
||||
return fmt.Errorf("security.forwarded_client_ip_headers: %w", err)
|
||||
}
|
||||
c.Security.ForwardedClientIPHeaders = forwardedClientIPHeaders
|
||||
c.SetForwardedClientIPSettings(c.Security.TrustForwardedIPForAPIKeyACL, forwardedClientIPHeaders)
|
||||
if c.Server.ReadHeaderTimeout < 1 || c.Server.ReadHeaderTimeout > 60 {
|
||||
return fmt.Errorf("server.read_header_timeout must be between 1 and 60 seconds")
|
||||
}
|
||||
if c.Server.MaxHeaderBytes < 8*1024 || c.Server.MaxHeaderBytes > 1024*1024 {
|
||||
return fmt.Errorf("server.max_header_bytes must be between 8192 and 1048576 bytes")
|
||||
}
|
||||
if c.Server.IdleTimeout <= 0 {
|
||||
return fmt.Errorf("server.idle_timeout must be positive")
|
||||
}
|
||||
if c.Server.MaxRequestBodySize < 0 {
|
||||
return fmt.Errorf("server.max_request_body_size must be non-negative")
|
||||
}
|
||||
if c.Server.H2C.Enabled {
|
||||
if c.Server.H2C.MaxConcurrentStreams == 0 {
|
||||
return fmt.Errorf("server.h2c.max_concurrent_streams must be positive")
|
||||
}
|
||||
if c.Server.H2C.IdleTimeout <= 0 {
|
||||
return fmt.Errorf("server.h2c.idle_timeout must be positive")
|
||||
}
|
||||
if c.Server.H2C.MaxReadFrameSize < 16*1024 || c.Server.H2C.MaxReadFrameSize > 16*1024*1024-1 {
|
||||
return fmt.Errorf("server.h2c.max_read_frame_size must be between 16384 and 16777215 bytes")
|
||||
}
|
||||
if c.Server.H2C.MaxUploadBufferPerConnection < 65535 {
|
||||
return fmt.Errorf("server.h2c.max_upload_buffer_per_connection must be at least 65535 bytes")
|
||||
}
|
||||
if c.Server.H2C.MaxUploadBufferPerStream <= 0 {
|
||||
return fmt.Errorf("server.h2c.max_upload_buffer_per_stream must be positive")
|
||||
}
|
||||
}
|
||||
if c.APIKeyAuth.InvalidAbuse.Enabled {
|
||||
if c.APIKeyAuth.InvalidAbuse.Threshold < 10 {
|
||||
return fmt.Errorf("api_key_auth_cache.invalid_abuse.threshold must be at least 10")
|
||||
}
|
||||
if c.APIKeyAuth.InvalidAbuse.WindowSeconds < 1 || c.APIKeyAuth.InvalidAbuse.WindowSeconds > 3600 {
|
||||
return fmt.Errorf("api_key_auth_cache.invalid_abuse.window_seconds must be between 1 and 3600")
|
||||
}
|
||||
if c.APIKeyAuth.InvalidAbuse.BlockSeconds < 1 || c.APIKeyAuth.InvalidAbuse.BlockSeconds > 3600 {
|
||||
return fmt.Errorf("api_key_auth_cache.invalid_abuse.block_seconds must be between 1 and 3600")
|
||||
}
|
||||
if c.APIKeyAuth.InvalidAbuse.Capacity < 256 || c.APIKeyAuth.InvalidAbuse.Capacity > 1_000_000 {
|
||||
return fmt.Errorf("api_key_auth_cache.invalid_abuse.capacity must be between 256 and 1000000")
|
||||
}
|
||||
}
|
||||
jwtSecret := strings.TrimSpace(c.JWT.Secret)
|
||||
if jwtSecret == "" {
|
||||
return fmt.Errorf("jwt.secret is required")
|
||||
@@ -2639,6 +3000,9 @@ func (c *Config) Validate() error {
|
||||
if c.Gateway.MaxBodySize <= 0 {
|
||||
return fmt.Errorf("gateway.max_body_size must be positive")
|
||||
}
|
||||
if c.Gateway.TextMaxBodySize <= 0 || c.Gateway.TextMaxBodySize > c.Gateway.MaxBodySize {
|
||||
return fmt.Errorf("gateway.text_max_body_size must be positive and no greater than gateway.max_body_size")
|
||||
}
|
||||
if c.Gateway.UpstreamResponseReadMaxBytes <= 0 {
|
||||
return fmt.Errorf("gateway.upstream_response_read_max_bytes must be positive")
|
||||
}
|
||||
@@ -2651,6 +3015,14 @@ func (c *Config) Validate() error {
|
||||
if c.Gateway.OpenAIResponseHeaderTimeout < 0 {
|
||||
return fmt.Errorf("gateway.openai_response_header_timeout must be non-negative")
|
||||
}
|
||||
if c.Gateway.OpenAIFirstOutputTimeoutSeconds < 0 || c.Gateway.OpenAIFirstOutputTimeoutSeconds > 600 ||
|
||||
(c.Gateway.OpenAIFirstOutputTimeoutSeconds > 0 && c.Gateway.OpenAIFirstOutputTimeoutSeconds < 30) {
|
||||
return fmt.Errorf("gateway.openai_first_output_timeout_seconds must be 0 or between 30-600 seconds")
|
||||
}
|
||||
if c.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds < 0 || c.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds > 1800 ||
|
||||
(c.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds > 0 && c.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds < 30) {
|
||||
return fmt.Errorf("gateway.openai_high_effort_first_output_timeout_seconds must be 0 or between 30-1800 seconds")
|
||||
}
|
||||
if strings.TrimSpace(c.Gateway.ConnectionPoolIsolation) != "" {
|
||||
switch c.Gateway.ConnectionPoolIsolation {
|
||||
case ConnectionPoolIsolationProxy, ConnectionPoolIsolationAccount, ConnectionPoolIsolationAccountProxy:
|
||||
@@ -2740,6 +3112,9 @@ func (c *Config) Validate() error {
|
||||
if c.Gateway.OpenAIWS.MaxConnsPerAccount <= 0 {
|
||||
return fmt.Errorf("gateway.openai_ws.max_conns_per_account must be positive")
|
||||
}
|
||||
if c.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds <= 0 {
|
||||
return fmt.Errorf("gateway.openai_ws.client_first_message_timeout_seconds must be positive")
|
||||
}
|
||||
if c.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds < 0 {
|
||||
return fmt.Errorf("gateway.openai_ws.ingress_inter_turn_idle_timeout_seconds must be non-negative")
|
||||
}
|
||||
@@ -2856,25 +3231,26 @@ func (c *Config) Validate() error {
|
||||
if c.Gateway.OpenAIHTTP2.FallbackTTLSeconds < 0 {
|
||||
return fmt.Errorf("gateway.openai_http2.fallback_ttl_seconds must be non-negative")
|
||||
}
|
||||
if c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.SessionSticky < 0 {
|
||||
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights.* must be non-negative")
|
||||
weights := c.Gateway.OpenAIWS.SchedulerScoreWeights
|
||||
for _, weight := range []float64{
|
||||
weights.Priority, weights.Load, weights.Queue, weights.ErrorRate, weights.TTFT,
|
||||
weights.Reset, weights.QuotaHeadroom, weights.UpstreamCost,
|
||||
weights.PreviousResponse, weights.SessionSticky,
|
||||
} {
|
||||
if weight < 0 || math.IsNaN(weight) || math.IsInf(weight, 0) {
|
||||
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights.* must be non-negative and finite")
|
||||
}
|
||||
}
|
||||
weightSum := c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority +
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load +
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue +
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate +
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT +
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom
|
||||
weightSum := weights.BaseWeightSum()
|
||||
if weightSum <= 0 {
|
||||
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights must not all be zero")
|
||||
}
|
||||
if math.IsNaN(weightSum) || math.IsInf(weightSum, 0) {
|
||||
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights base-weight sum must be finite")
|
||||
}
|
||||
if totalWeightSum := weights.TotalWeightSum(); math.IsNaN(totalWeightSum) || math.IsInf(totalWeightSum, 0) {
|
||||
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights total-weight sum must be finite")
|
||||
}
|
||||
if c.Gateway.OpenAIScheduler.StickyEscapeTTFTMs <= 0 {
|
||||
return fmt.Errorf("gateway.openai_scheduler.sticky_escape_ttft_ms must be positive")
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -34,6 +37,222 @@ func TestLoadServerTimingConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadRedisUsernameFromEnvironment(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("REDIS_USERNAME", "app-user")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "app-user", cfg.Redis.Username)
|
||||
}
|
||||
|
||||
func TestLoadHTTPIngressSafetyDefaults(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 10, cfg.Server.ReadHeaderTimeout)
|
||||
require.Equal(t, 64*1024, cfg.Server.MaxHeaderBytes)
|
||||
require.Empty(t, cfg.Server.TrustedProxies)
|
||||
require.False(t, cfg.Server.TrustedProxiesConfigured)
|
||||
require.True(t, cfg.TrustForwardedIPForAPIKeyACL())
|
||||
require.Equal(t, int64(32*1024*1024), cfg.Gateway.TextMaxBodySize)
|
||||
require.True(t, cfg.APIKeyAuth.InvalidAbuse.Enabled)
|
||||
require.Equal(t, 120, cfg.APIKeyAuth.InvalidAbuse.Threshold)
|
||||
require.Equal(t, 16384, cfg.APIKeyAuth.InvalidAbuse.Capacity)
|
||||
}
|
||||
|
||||
func TestNormalizeForwardedClientIPHeaders(t *testing.T) {
|
||||
headers, err := NormalizeForwardedClientIPHeaders([]string{
|
||||
" x-cdn-client-ip ",
|
||||
"X-CDN-CLIENT-IP",
|
||||
"true-client-ip",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"X-Cdn-Client-Ip", "True-Client-Ip"}, headers)
|
||||
|
||||
_, err = NormalizeForwardedClientIPHeaders([]string{"X Invalid"})
|
||||
require.ErrorContains(t, err, "invalid HTTP header field name")
|
||||
}
|
||||
|
||||
func TestNormalizeForwardedClientIPHeadersLimit(t *testing.T) {
|
||||
headers := make([]string, 0, MaxForwardedClientIPHeaders+1)
|
||||
for i := 0; i <= MaxForwardedClientIPHeaders; i++ {
|
||||
headers = append(headers, fmt.Sprintf("X-CDN-IP-%d", i))
|
||||
}
|
||||
|
||||
_, err := NormalizeForwardedClientIPHeaders(headers)
|
||||
require.ErrorContains(t, err, "at most 16 unique names")
|
||||
}
|
||||
|
||||
func TestLoadForwardedClientIPHeadersNormalizesAndSnapshots(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
viper.Set("security.forwarded_client_ip_headers", []string{" x-cdn-ip ", "X-CDN-IP", "true-client-ip"})
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
snapshot := cfg.ForwardedClientIPSettings()
|
||||
require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, snapshot.Headers)
|
||||
|
||||
snapshot.Headers[0] = "X-Mutated"
|
||||
require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, cfg.ForwardedClientIPSettings().Headers)
|
||||
}
|
||||
|
||||
func TestForwardedClientIPSettingsConcurrentPublication(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
cfg.SetForwardedClientIPSettings(true, []string{"X-Public-A"})
|
||||
|
||||
const iterations = 2000
|
||||
start := make(chan struct{})
|
||||
errCh := make(chan error, 8)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, settings := range []ForwardedClientIPSettings{
|
||||
{TrustForwardedIP: true, Headers: []string{"X-Public-A"}},
|
||||
{TrustForwardedIP: false, Headers: []string{"X-Public-B"}},
|
||||
} {
|
||||
settings := settings
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
for i := 0; i < iterations; i++ {
|
||||
cfg.SetForwardedClientIPSettings(settings.TrustForwardedIP, settings.Headers)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < cap(errCh); i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
for j := 0; j < iterations; j++ {
|
||||
snapshot := cfg.ForwardedClientIPSettings()
|
||||
validA := snapshot.TrustForwardedIP && len(snapshot.Headers) == 1 && snapshot.Headers[0] == "X-Public-A"
|
||||
validB := !snapshot.TrustForwardedIP && len(snapshot.Headers) == 1 && snapshot.Headers[0] == "X-Public-B"
|
||||
if !validA && !validB {
|
||||
errCh <- fmt.Errorf("observed inconsistent forwarded IP settings: %+v", snapshot)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadForwardedClientIPHeadersFromEnvironment(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("SECURITY_FORWARDED_CLIENT_IP_HEADERS", " x-cdn-ip , X-CDN-IP, true-client-ip ")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, cfg.ForwardedClientIPSettings().Headers)
|
||||
}
|
||||
|
||||
func TestLoadExplicitEmptyForwardedClientIPHeadersFromEnvironment(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
viper.Set("security.forwarded_client_ip_headers", []string{"X-Yaml-IP"})
|
||||
t.Setenv("SECURITY_FORWARDED_CLIENT_IP_HEADERS", "")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, cfg.ForwardedClientIPSettings().Headers)
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidForwardedClientIPHeaderFromEnvironment(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("SECURITY_FORWARDED_CLIENT_IP_HEADERS", "X-Valid-IP, X Invalid")
|
||||
|
||||
_, err := Load()
|
||||
require.ErrorContains(t, err, "security.forwarded_client_ip_headers")
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidForwardedClientIPHeader(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
viper.Set("security.forwarded_client_ip_headers", []string{"X Invalid"})
|
||||
|
||||
_, err := Load()
|
||||
require.ErrorContains(t, err, "security.forwarded_client_ip_headers")
|
||||
}
|
||||
|
||||
func TestLoadExplicitEmptyTrustedProxiesEnablesConfiguredMode(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
viper.Set("server.trusted_proxies", []string{})
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, cfg.Server.TrustedProxies)
|
||||
require.True(t, cfg.Server.TrustedProxiesConfigured)
|
||||
}
|
||||
|
||||
func TestLoadExplicitTrustedProxiesEnablesConfiguredMode(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
viper.Set("server.trusted_proxies", []string{"127.0.0.1/32"})
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"127.0.0.1/32"}, cfg.Server.TrustedProxies)
|
||||
require.True(t, cfg.Server.TrustedProxiesConfigured)
|
||||
}
|
||||
|
||||
func TestLoadTrustedProxiesFromEnvironment(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("SERVER_TRUSTED_PROXIES", "127.0.0.1/32, ::1/128")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"127.0.0.1/32", "::1/128"}, cfg.Server.TrustedProxies)
|
||||
require.True(t, cfg.Server.TrustedProxiesConfigured)
|
||||
}
|
||||
|
||||
func TestLoadExplicitEmptyTrustedProxiesFromEnvironment(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("SERVER_TRUSTED_PROXIES", "")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, cfg.Server.TrustedProxies)
|
||||
require.True(t, cfg.Server.TrustedProxiesConfigured)
|
||||
}
|
||||
|
||||
func TestLoadTrustedProxiesPresenceFromYAML(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
want []string
|
||||
configured bool
|
||||
}{
|
||||
{name: "absent", yaml: "server:\n mode: debug\n", configured: false},
|
||||
{name: "explicit empty", yaml: "server:\n trusted_proxies: []\n", want: []string{}, configured: true},
|
||||
{
|
||||
name: "populated",
|
||||
yaml: "server:\n trusted_proxies:\n - 127.0.0.1/32\n - ::1/128\n",
|
||||
want: []string{"127.0.0.1/32", "::1/128"},
|
||||
configured: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
configDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(test.yaml), 0o600))
|
||||
t.Setenv("DATA_DIR", configDir)
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, test.want, cfg.Server.TrustedProxies)
|
||||
require.Equal(t, test.configured, cfg.Server.TrustedProxiesConfigured)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadForBootstrapAllowsMissingJWTSecret(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("JWT_SECRET", "")
|
||||
@@ -98,6 +317,34 @@ func TestLoadDefaultSchedulingConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultOpenAIFirstOutputTimeoutsDisabled(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, cfg.Gateway.OpenAIFirstOutputTimeoutSeconds)
|
||||
require.Zero(t, cfg.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds)
|
||||
}
|
||||
|
||||
func TestLoadOpenAIFirstOutputTimeoutsFromEnv(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("GATEWAY_OPENAI_FIRST_OUTPUT_TIMEOUT_SECONDS", "90")
|
||||
t.Setenv("GATEWAY_OPENAI_HIGH_EFFORT_FIRST_OUTPUT_TIMEOUT_SECONDS", "240")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 90, cfg.Gateway.OpenAIFirstOutputTimeoutSeconds)
|
||||
require.Equal(t, 240, cfg.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds)
|
||||
}
|
||||
|
||||
func TestValidateOpenAIFirstOutputTimeoutMinimum(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
cfg.Gateway.OpenAIFirstOutputTimeoutSeconds = 30
|
||||
require.NoError(t, cfg.Validate())
|
||||
}
|
||||
|
||||
func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
@@ -187,6 +434,9 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
|
||||
if cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom != 0 {
|
||||
t.Fatalf("Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = %v, want 0", cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom)
|
||||
}
|
||||
if cfg.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost != 0 {
|
||||
t.Fatalf("Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = %v, want 0", cfg.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost)
|
||||
}
|
||||
if !cfg.Gateway.OpenAIWS.StoreDisabledForceNewConn {
|
||||
t.Fatalf("Gateway.OpenAIWS.StoreDisabledForceNewConn = false, want true")
|
||||
}
|
||||
@@ -199,6 +449,13 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
|
||||
if cfg.Gateway.OpenAIWS.IngressModeDefault != "ctx_pool" {
|
||||
t.Fatalf("Gateway.OpenAIWS.IngressModeDefault = %q, want %q", cfg.Gateway.OpenAIWS.IngressModeDefault, "ctx_pool")
|
||||
}
|
||||
if cfg.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds != DefaultOpenAIWSClientFirstMessageTimeoutSeconds {
|
||||
t.Fatalf(
|
||||
"Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds = %d, want %d",
|
||||
cfg.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds,
|
||||
DefaultOpenAIWSClientFirstMessageTimeoutSeconds,
|
||||
)
|
||||
}
|
||||
if cfg.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds != 300 {
|
||||
t.Fatalf("Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds = %d, want 300", cfg.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds)
|
||||
}
|
||||
@@ -207,6 +464,15 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOpenAIWSClientFirstMessageTimeoutFromEnv(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("GATEWAY_OPENAI_WS_CLIENT_FIRST_MESSAGE_TIMEOUT_SECONDS", "120")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 120, cfg.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds)
|
||||
}
|
||||
|
||||
func TestLoadDefaultOpenAICompactModel(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
@@ -1137,6 +1403,51 @@ func TestValidateConfigErrors(t *testing.T) {
|
||||
mutate func(*Config)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "server read header timeout",
|
||||
mutate: func(c *Config) { c.Server.ReadHeaderTimeout = 0 },
|
||||
wantErr: "server.read_header_timeout",
|
||||
},
|
||||
{
|
||||
name: "server max header bytes too small",
|
||||
mutate: func(c *Config) { c.Server.MaxHeaderBytes = 4096 },
|
||||
wantErr: "server.max_header_bytes",
|
||||
},
|
||||
{
|
||||
name: "server max request body size",
|
||||
mutate: func(c *Config) { c.Server.MaxRequestBodySize = -1 },
|
||||
wantErr: "server.max_request_body_size",
|
||||
},
|
||||
{
|
||||
name: "h2c zero concurrent streams",
|
||||
mutate: func(c *Config) {
|
||||
c.Server.H2C.Enabled = true
|
||||
c.Server.H2C.MaxConcurrentStreams = 0
|
||||
},
|
||||
wantErr: "server.h2c.max_concurrent_streams",
|
||||
},
|
||||
{
|
||||
name: "h2c oversized read frame",
|
||||
mutate: func(c *Config) {
|
||||
c.Server.H2C.Enabled = true
|
||||
c.Server.H2C.MaxReadFrameSize = 16 * 1024 * 1024
|
||||
},
|
||||
wantErr: "server.h2c.max_read_frame_size",
|
||||
},
|
||||
{
|
||||
name: "invalid auth abuse threshold too small",
|
||||
mutate: func(c *Config) {
|
||||
c.APIKeyAuth.InvalidAbuse.Threshold = 9
|
||||
},
|
||||
wantErr: "api_key_auth_cache.invalid_abuse.threshold",
|
||||
},
|
||||
{
|
||||
name: "invalid auth abuse capacity too small",
|
||||
mutate: func(c *Config) {
|
||||
c.APIKeyAuth.InvalidAbuse.Capacity = 255
|
||||
},
|
||||
wantErr: "api_key_auth_cache.invalid_abuse.capacity",
|
||||
},
|
||||
{
|
||||
name: "jwt secret required",
|
||||
mutate: func(c *Config) { c.JWT.Secret = "" },
|
||||
@@ -1338,6 +1649,11 @@ func TestValidateConfigErrors(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Gateway.MaxBodySize = 0 },
|
||||
wantErr: "gateway.max_body_size",
|
||||
},
|
||||
{
|
||||
name: "gateway text body exceeds media body",
|
||||
mutate: func(c *Config) { c.Gateway.TextMaxBodySize = c.Gateway.MaxBodySize + 1 },
|
||||
wantErr: "gateway.text_max_body_size",
|
||||
},
|
||||
{
|
||||
name: "gateway response header timeout",
|
||||
mutate: func(c *Config) { c.Gateway.ResponseHeaderTimeout = -1 },
|
||||
@@ -1348,6 +1664,16 @@ func TestValidateConfigErrors(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIResponseHeaderTimeout = -1 },
|
||||
wantErr: "gateway.openai_response_header_timeout",
|
||||
},
|
||||
{
|
||||
name: "gateway openai first output timeout below minimum",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIFirstOutputTimeoutSeconds = 29 },
|
||||
wantErr: "gateway.openai_first_output_timeout_seconds",
|
||||
},
|
||||
{
|
||||
name: "gateway openai high effort first output timeout too large",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds = 1801 },
|
||||
wantErr: "gateway.openai_high_effort_first_output_timeout_seconds",
|
||||
},
|
||||
{
|
||||
name: "gateway max idle conns",
|
||||
mutate: func(c *Config) { c.Gateway.MaxIdleConns = 0 },
|
||||
@@ -1682,6 +2008,16 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.MaxConnsPerAccount = 0 },
|
||||
wantErr: "gateway.openai_ws.max_conns_per_account",
|
||||
},
|
||||
{
|
||||
name: "client_first_message_timeout_seconds 必须为正数",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds = 0 },
|
||||
wantErr: "gateway.openai_ws.client_first_message_timeout_seconds",
|
||||
},
|
||||
{
|
||||
name: "client_first_message_timeout_seconds 不能为负数",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds = -1 },
|
||||
wantErr: "gateway.openai_ws.client_first_message_timeout_seconds",
|
||||
},
|
||||
{
|
||||
name: "ingress_inter_turn_idle_timeout_seconds 不能为负数",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds = -1 },
|
||||
@@ -1802,6 +2138,42 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = -0.1 },
|
||||
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative",
|
||||
},
|
||||
{
|
||||
name: "scheduler_score_weights upstream_cost 不能为负数",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = -0.1 },
|
||||
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative",
|
||||
},
|
||||
{
|
||||
name: "scheduler_score_weights reset 不能为负数",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.Reset = -0.1 },
|
||||
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative",
|
||||
},
|
||||
{
|
||||
name: "scheduler_score_weights 不能为 NaN",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse = math.NaN() },
|
||||
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative and finite",
|
||||
},
|
||||
{
|
||||
name: "scheduler_score_weights 不能为 Inf",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = math.Inf(1) },
|
||||
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative and finite",
|
||||
},
|
||||
{
|
||||
name: "scheduler_score_weights 总和不能溢出",
|
||||
mutate: func(c *Config) {
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = math.MaxFloat64
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load = math.MaxFloat64
|
||||
},
|
||||
wantErr: "gateway.openai_ws.scheduler_score_weights base-weight sum must be finite",
|
||||
},
|
||||
{
|
||||
name: "scheduler_score_weights 含 sticky 总和不能溢出",
|
||||
mutate: func(c *Config) {
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = math.MaxFloat64
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse = math.MaxFloat64
|
||||
},
|
||||
wantErr: "gateway.openai_ws.scheduler_score_weights total-weight sum must be finite",
|
||||
},
|
||||
{
|
||||
name: "scheduler_score_weights 不能全为 0",
|
||||
mutate: func(c *Config) {
|
||||
@@ -1853,6 +2225,30 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("upstream_cost 可作为唯一有效调度权重", func(t *testing.T) {
|
||||
cfg := buildValid(t)
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = 0.1
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("reset 可作为唯一有效调度权重", func(t *testing.T) {
|
||||
cfg := buildValid(t)
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Reset = 0.1
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfig_AutoScaleDisabledIgnoreAutoScaleFields(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//go:build unit
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// collectMapstructureKeys walks a config struct and returns every dotted key
|
||||
// viper would need in order to populate it.
|
||||
func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]string) {
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
if field.PkgPath != "" {
|
||||
continue // unexported
|
||||
}
|
||||
tag := field.Tag.Get("mapstructure")
|
||||
name, _, _ := strings.Cut(tag, ",")
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = strings.ToLower(field.Name)
|
||||
}
|
||||
key := name
|
||||
if prefix != "" {
|
||||
key = prefix + "." + name
|
||||
}
|
||||
|
||||
ft := field.Type
|
||||
for ft.Kind() == reflect.Ptr {
|
||||
ft = ft.Elem()
|
||||
}
|
||||
if ft.Kind() == reflect.Struct {
|
||||
collectMapstructureKeys(ft, key, out)
|
||||
continue
|
||||
}
|
||||
if ft.Kind() == reflect.Map {
|
||||
// A map cannot be expressed in a single environment variable, so it
|
||||
// is out of scope here — such settings need a config file either way.
|
||||
continue
|
||||
}
|
||||
out[strings.ToLower(key)] = ft.String()
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigKeysAreEnvReachable is the systemic guard behind the image_storage
|
||||
// bug: viper.Unmarshal only decodes keys returned by AllKeys(), which unions
|
||||
// SetDefault keys, config-file keys and explicit BindEnv keys. AutomaticEnv can
|
||||
// override a key already in that union but never introduces one, and the
|
||||
// viper_bind_struct escape hatch is compiled out (we build with -tags embed).
|
||||
//
|
||||
// So a Config field with no registered default is unreachable by environment
|
||||
// variable whenever the deployment has no config.yaml containing it — the
|
||||
// operator sets the variable, the loader discards it, and the feature behaves
|
||||
// as if it were never configured. That is exactly how image_storage credentials
|
||||
// were lost, silently disabling async image tasks for env-driven deployments.
|
||||
//
|
||||
// When this fails, register a zero-valued default in setEnvReachableDefaults
|
||||
// for each reported key.
|
||||
func TestConfigKeysAreEnvReachable(t *testing.T) {
|
||||
bound := map[string]string{}
|
||||
collectMapstructureKeys(reflect.TypeOf(Config{}), "", bound)
|
||||
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
setDefaults()
|
||||
registered := map[string]struct{}{}
|
||||
for _, key := range viper.AllKeys() {
|
||||
registered[key] = struct{}{}
|
||||
}
|
||||
|
||||
var unreachable []string
|
||||
for key, kind := range bound {
|
||||
if _, ok := registered[key]; !ok {
|
||||
unreachable = append(unreachable, key+" ("+kind+")")
|
||||
}
|
||||
}
|
||||
sort.Strings(unreachable)
|
||||
|
||||
if len(unreachable) > 0 {
|
||||
t.Fatalf("%d config keys have no default registered, so their environment variables are silently ignored:\n %s",
|
||||
len(unreachable), strings.Join(unreachable, "\n "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build unit
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestLoadImageStorageFromEnv guards against a viper trap that silently disabled
|
||||
// asynchronous image tasks for every environment-variable-only deployment.
|
||||
//
|
||||
// viper only decodes keys returned by AllKeys(), which unions SetDefault keys,
|
||||
// config-file keys and explicit BindEnv keys. AutomaticEnv can override a key
|
||||
// that is already in that list, but it never introduces a new one. Credentials
|
||||
// such as image_storage.bucket therefore need an (empty) default registered, or
|
||||
// IMAGE_STORAGE_BUCKET is dropped on the floor and Active() stays false while
|
||||
// image_storage.enabled reads true — the endpoints 404 with no useful signal.
|
||||
func TestLoadImageStorageFromEnv(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("IMAGE_STORAGE_ENABLED", "true")
|
||||
t.Setenv("IMAGE_STORAGE_ENDPOINT", "https://acct.r2.cloudflarestorage.com")
|
||||
t.Setenv("IMAGE_STORAGE_BUCKET", "my-images")
|
||||
t.Setenv("IMAGE_STORAGE_ACCESS_KEY_ID", "ak")
|
||||
t.Setenv("IMAGE_STORAGE_SECRET_ACCESS_KEY", "sk")
|
||||
t.Setenv("IMAGE_STORAGE_PUBLIC_BASE_URL", "https://cdn.example.com")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, cfg.ImageStorage.Enabled)
|
||||
require.Equal(t, "https://acct.r2.cloudflarestorage.com", cfg.ImageStorage.Endpoint)
|
||||
require.Equal(t, "my-images", cfg.ImageStorage.Bucket)
|
||||
require.Equal(t, "ak", cfg.ImageStorage.AccessKeyID)
|
||||
require.Equal(t, "sk", cfg.ImageStorage.SecretAccessKey)
|
||||
require.Equal(t, "https://cdn.example.com", cfg.ImageStorage.PublicBaseURL)
|
||||
|
||||
require.True(t, cfg.ImageStorage.IsConfigured())
|
||||
require.True(t, cfg.ImageStorage.Active(), "async image tasks must be active when every credential is supplied via env")
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package domain
|
||||
|
||||
// ReasoningEffortMapping rewrites one explicit OpenAI/Codex reasoning effort
|
||||
// value to another before the group ceiling is applied.
|
||||
type ReasoningEffortMapping struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeCodexImportEntryAcceptsAgentIdentityAuthJSON(t *testing.T) {
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
require.NoError(t, err)
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString(der)
|
||||
|
||||
item, err := normalizeCodexImportEntry(codexImportEntry{
|
||||
Index: 1,
|
||||
Value: map[string]any{
|
||||
"auth_mode": "agentIdentity",
|
||||
"agent_identity": map[string]any{
|
||||
"agent_runtime_id": "runtime-import",
|
||||
"agent_private_key": privateKeyBase64,
|
||||
"account_id": "account-import",
|
||||
"chatgpt_user_id": "user-import",
|
||||
"email": "agent@example.invalid",
|
||||
"plan_type": "pro",
|
||||
"chatgpt_account_is_fedramp": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.True(t, item.IsAgentIdentity)
|
||||
require.Equal(t, service.OpenAIAuthModeAgentIdentity, item.Credentials["auth_mode"])
|
||||
require.Equal(t, "runtime-import", item.Credentials["agent_runtime_id"])
|
||||
require.Equal(t, privateKeyBase64, item.Credentials["agent_private_key"])
|
||||
require.Equal(t, "account-import", item.Credentials["chatgpt_account_id"])
|
||||
require.Equal(t, "user-import", item.Credentials["chatgpt_user_id"])
|
||||
require.NotContains(t, item.Credentials, "access_token")
|
||||
require.NotContains(t, item.Credentials, "refresh_token")
|
||||
require.NotEmpty(t, item.WarningTexts)
|
||||
}
|
||||
|
||||
func TestBuildCodexAgentIdentityKeysUseChatGPTAccountOnly(t *testing.T) {
|
||||
keys := buildCodexAgentIdentityKeys("team-a")
|
||||
require.Equal(t, []string{"account:team-a"}, keys)
|
||||
}
|
||||
|
||||
func TestCodexAgentIdentityIndexSeparatesTeamsForSameUser(t *testing.T) {
|
||||
existing := service.Account{
|
||||
ID: 1,
|
||||
Credentials: map[string]any{
|
||||
"auth_mode": service.OpenAIAuthModeAgentIdentity,
|
||||
"chatgpt_account_id": "team-a",
|
||||
"chatgpt_user_id": "same-user",
|
||||
"agent_runtime_id": "runtime-a",
|
||||
},
|
||||
}
|
||||
index := buildCodexAccountIndex([]service.Account{existing})
|
||||
|
||||
teamBKeys := buildCodexAgentIdentityKeys("team-b")
|
||||
matched, _ := index.Find(teamBKeys, "same-user")
|
||||
require.Nil(t, matched)
|
||||
|
||||
teamAKeys := buildCodexAgentIdentityKeys("team-a")
|
||||
matched, matchedKey := index.Find(teamAKeys, "same-user")
|
||||
require.NotNil(t, matched)
|
||||
require.Equal(t, int64(1), matched.ID)
|
||||
require.Equal(t, "account:team-a", matchedKey)
|
||||
}
|
||||
|
||||
func TestImportCodexSessionsKeepsAgentIdentityTeamsSeparate(t *testing.T) {
|
||||
first := buildAgentIdentityImportValue(t, "runtime-a", "team-a", "same-user", "task-a")
|
||||
second := buildAgentIdentityImportValue(t, "runtime-b", "team-b", "same-user", "task-b")
|
||||
svc := newCodexImportMemoryAdminService(nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
result, err := handler.importCodexSessions(context.Background(), CodexSessionImportRequest{
|
||||
SkipDefaultGroupBind: boolPtr(true),
|
||||
}, []codexImportEntry{{Index: 1, Value: first}, {Index: 2, Value: second}})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, result.Created)
|
||||
require.Zero(t, result.Updated)
|
||||
require.Zero(t, result.Skipped)
|
||||
require.Len(t, svc.createdAccounts, 2)
|
||||
}
|
||||
|
||||
func TestImportCodexSessionsMergesAgentIdentityRuntimesForSameTeam(t *testing.T) {
|
||||
first := buildAgentIdentityImportValue(t, "runtime-a", "team-a", "same-user", "task-a")
|
||||
second := buildAgentIdentityImportValue(t, "runtime-b", "team-a", "same-user", "task-b")
|
||||
firstIdentity, ok := first["agent_identity"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
existing := service.Account{
|
||||
ID: 41,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"auth_mode": service.OpenAIAuthModeAgentIdentity,
|
||||
"agent_runtime_id": firstIdentity["agent_runtime_id"],
|
||||
"agent_private_key": firstIdentity["agent_private_key"],
|
||||
"task_id": firstIdentity["task_id"],
|
||||
"chatgpt_account_id": firstIdentity["account_id"],
|
||||
"chatgpt_user_id": firstIdentity["chatgpt_user_id"],
|
||||
},
|
||||
}
|
||||
svc := newCodexImportMemoryAdminService([]service.Account{existing})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
result, err := handler.importCodexSessions(context.Background(), CodexSessionImportRequest{
|
||||
SkipDefaultGroupBind: boolPtr(true),
|
||||
}, []codexImportEntry{{Index: 1, Value: second}})
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, result.Created)
|
||||
require.Equal(t, 1, result.Updated)
|
||||
require.Len(t, svc.updatedAccounts, 1)
|
||||
require.Equal(t, "runtime-b", svc.updatedAccounts[0].input.Credentials["agent_runtime_id"])
|
||||
require.Equal(t, "task-b", svc.updatedAccounts[0].input.Credentials["task_id"])
|
||||
}
|
||||
|
||||
func buildAgentIdentityImportValue(t *testing.T, runtimeID, accountID, userID, taskID string) map[string]any {
|
||||
t.Helper()
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
require.NoError(t, err)
|
||||
return map[string]any{
|
||||
"auth_mode": "agentIdentity",
|
||||
"agent_identity": map[string]any{
|
||||
"agent_runtime_id": runtimeID,
|
||||
"agent_private_key": base64.StdEncoding.EncodeToString(der),
|
||||
"task_id": taskID,
|
||||
"account_id": accountID,
|
||||
"chatgpt_user_id": userID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCodexSessionsCreatesAgentIdentityWithoutOAuthExpiry(t *testing.T) {
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := newCodexImportMemoryAdminService(nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
result, err := handler.importCodexSessions(context.Background(), CodexSessionImportRequest{
|
||||
SkipDefaultGroupBind: boolPtr(true),
|
||||
}, []codexImportEntry{{
|
||||
Index: 1,
|
||||
Value: map[string]any{
|
||||
"auth_mode": "agentIdentity",
|
||||
"agent_identity": map[string]any{
|
||||
"agent_runtime_id": "runtime-import",
|
||||
"agent_private_key": base64.StdEncoding.EncodeToString(der),
|
||||
"task_id": "task-import",
|
||||
"account_id": "account-import",
|
||||
"chatgpt_user_id": "user-import",
|
||||
},
|
||||
},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, result.Created)
|
||||
require.Zero(t, result.Failed)
|
||||
require.Len(t, svc.createdAccounts, 1)
|
||||
created := svc.createdAccounts[0]
|
||||
require.Nil(t, created.ExpiresAt)
|
||||
require.Nil(t, created.AutoPauseOnExpired)
|
||||
require.Equal(t, service.OpenAIAuthModeAgentIdentity, created.Credentials["auth_mode"])
|
||||
require.NotContains(t, created.Credentials, "access_token")
|
||||
require.NotContains(t, created.Credentials, "refresh_token")
|
||||
}
|
||||
@@ -72,20 +72,25 @@ type codexImportEntry struct {
|
||||
}
|
||||
|
||||
type codexImportAccount struct {
|
||||
Name string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
IDToken string
|
||||
Email string
|
||||
AccountID string
|
||||
UserID string
|
||||
PlanType string
|
||||
Organization string
|
||||
Credentials map[string]any
|
||||
Extra map[string]any
|
||||
TokenExpiresAt *time.Time
|
||||
IdentityKeys []string
|
||||
WarningTexts []string
|
||||
Name string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
IDToken string
|
||||
Email string
|
||||
AccountID string
|
||||
UserID string
|
||||
PlanType string
|
||||
Organization string
|
||||
AgentRuntimeID string
|
||||
AgentPrivateKey string
|
||||
AgentTaskID string
|
||||
AgentFedRAMP bool
|
||||
IsAgentIdentity bool
|
||||
Credentials map[string]any
|
||||
Extra map[string]any
|
||||
TokenExpiresAt *time.Time
|
||||
IdentityKeys []string
|
||||
WarningTexts []string
|
||||
}
|
||||
|
||||
type codexJWTClaims struct {
|
||||
@@ -496,6 +501,41 @@ func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, err
|
||||
case string:
|
||||
item.AccessToken = strings.TrimSpace(raw)
|
||||
case map[string]any:
|
||||
if agentIdentity, ok := firstCodexMap(raw, []string{"agent_identity"}, []string{"agentIdentity"}); ok || strings.EqualFold(firstCodexString(raw, []string{"auth_mode"}, []string{"authMode"}), service.OpenAIAuthModeAgentIdentity) {
|
||||
if !ok {
|
||||
agentIdentity = raw
|
||||
}
|
||||
item.IsAgentIdentity = true
|
||||
item.AgentRuntimeID = firstCodexString(agentIdentity, []string{"agent_runtime_id"}, []string{"agentRuntimeId"})
|
||||
item.AgentPrivateKey = firstCodexString(agentIdentity, []string{"agent_private_key"}, []string{"agentPrivateKey"})
|
||||
item.AgentTaskID = firstCodexString(agentIdentity, []string{"task_id"}, []string{"taskId"})
|
||||
item.AccountID = firstCodexString(agentIdentity, []string{"account_id"}, []string{"accountId"})
|
||||
item.UserID = firstCodexString(agentIdentity, []string{"chatgpt_user_id"}, []string{"chatgptUserId"})
|
||||
item.Email = firstCodexString(agentIdentity, []string{"email"})
|
||||
item.PlanType = firstCodexString(agentIdentity, []string{"plan_type"}, []string{"planType"})
|
||||
item.AgentFedRAMP = firstCodexBool(agentIdentity, []string{"chatgpt_account_is_fedramp"}, []string{"chatgptAccountIsFedramp"})
|
||||
if item.AgentRuntimeID == "" || item.AgentPrivateKey == "" || item.AccountID == "" || item.UserID == "" {
|
||||
return nil, errors.New("agent identity 缺少必要字段")
|
||||
}
|
||||
if err := service.ValidateOpenAIAgentIdentityPrivateKey(item.AgentPrivateKey); err != nil {
|
||||
return nil, errors.New("agent identity private key 格式无效")
|
||||
}
|
||||
item.Credentials["auth_mode"] = service.OpenAIAuthModeAgentIdentity
|
||||
item.Credentials["agent_runtime_id"] = item.AgentRuntimeID
|
||||
item.Credentials["agent_private_key"] = item.AgentPrivateKey
|
||||
item.Credentials["chatgpt_account_id"] = item.AccountID
|
||||
item.Credentials["chatgpt_user_id"] = item.UserID
|
||||
item.Credentials["chatgpt_account_is_fedramp"] = item.AgentFedRAMP
|
||||
setCodexCredentialIfNotEmpty(item.Credentials, "task_id", item.AgentTaskID)
|
||||
setCodexCredentialIfNotEmpty(item.Credentials, "email", item.Email)
|
||||
setCodexCredentialIfNotEmpty(item.Credentials, "plan_type", item.PlanType)
|
||||
if item.AgentTaskID == "" {
|
||||
item.WarningTexts = append(item.WarningTexts, "未包含 task_id,首次请求会使用现有 runtime 注册新 task")
|
||||
}
|
||||
item.IdentityKeys = buildCodexAgentIdentityKeys(item.AccountID)
|
||||
item.Name = buildCodexImportAccountName(item, entry.Index)
|
||||
return item, nil
|
||||
}
|
||||
item.AccessToken = firstCodexString(raw,
|
||||
[]string{"tokens", "access_token"},
|
||||
[]string{"tokens", "accessToken"},
|
||||
@@ -577,6 +617,9 @@ func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, err
|
||||
return nil, fmt.Errorf("第 %d 条格式不支持", entry.Index)
|
||||
}
|
||||
|
||||
if item.IsAgentIdentity {
|
||||
return item, nil
|
||||
}
|
||||
if item.AccessToken == "" {
|
||||
return nil, errors.New("缺少 accessToken/access_token")
|
||||
}
|
||||
@@ -731,6 +774,12 @@ func resolveCodexImportExpiry(req CodexSessionImportRequest, item *codexImportAc
|
||||
if item == nil {
|
||||
return nil, nil, nil, nil, errors.New("导入项为空")
|
||||
}
|
||||
// Agent Identity has no OAuth access-token lifetime. Its runtime/task
|
||||
// lifecycle is handled by the upstream task recovery path, so it must not
|
||||
// be rejected or auto-paused by the OAuth import expiry policy.
|
||||
if item.IsAgentIdentity {
|
||||
return nil, nil, nil, nil, nil
|
||||
}
|
||||
|
||||
var requestExpiresAt *time.Time
|
||||
if req.ExpiresAt != nil && *req.ExpiresAt > 0 {
|
||||
@@ -812,6 +861,9 @@ func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any {
|
||||
"openai_auth_mode": {},
|
||||
"token_type": {},
|
||||
"chatgpt_account_is_fedramp": {},
|
||||
"agent_runtime_id": {},
|
||||
"agent_private_key": {},
|
||||
"task_id": {},
|
||||
}
|
||||
out := make(map[string]any, len(input))
|
||||
for key, value := range input {
|
||||
@@ -842,6 +894,19 @@ func buildCodexImportIdentityKeys(accountID, userID, email, accessToken, refresh
|
||||
return buildCodexStoredIdentityKeys(accountID, userID, email, accessToken)
|
||||
}
|
||||
|
||||
func buildCodexAgentIdentityKeys(accountID string) []string {
|
||||
// Agent Identity credentials belonging to the same ChatGPT account are
|
||||
// intentionally merged, while the same user may own multiple accounts.
|
||||
// Do not use user/email/runtime as fallback keys here: user_id is shared
|
||||
// across Team workspaces and runtime_id changes when a new runtime is
|
||||
// registered for the same account.
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{"account:" + accountID}
|
||||
}
|
||||
|
||||
// buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度,
|
||||
// 让 accessToken-only 账号后续升级为完整 OAuth 时仍能命中并更新原账号。
|
||||
func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) []string {
|
||||
@@ -891,6 +956,10 @@ func (i *codexAccountIndex) Add(account service.Account) {
|
||||
for _, key := range keys {
|
||||
i.accountsByKey[key] = upsertCodexAccount(i.accountsByKey[key], account)
|
||||
}
|
||||
if runtimeID := codexCredentialString(account.Credentials, "agent_runtime_id"); runtimeID != "" {
|
||||
key := "agent:" + runtimeID
|
||||
i.accountsByKey[key] = upsertCodexAccount(i.accountsByKey[key], account)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *codexAccountIndex) remove(accountID int64) {
|
||||
@@ -1043,6 +1112,38 @@ func firstCodexString(obj map[string]any, paths ...[]string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstCodexMap(obj map[string]any, paths ...[]string) (map[string]any, bool) {
|
||||
for _, path := range paths {
|
||||
value, ok := codexPathValue(obj, path)
|
||||
if !ok || value == nil {
|
||||
continue
|
||||
}
|
||||
if mapped, ok := value.(map[string]any); ok {
|
||||
return mapped, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func firstCodexBool(obj map[string]any, paths ...[]string) bool {
|
||||
for _, path := range paths {
|
||||
value, ok := codexPathValue(obj, path)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch value := value.(type) {
|
||||
case bool:
|
||||
return value
|
||||
case string:
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func copyCodexExtraString(obj map[string]any, extra map[string]any, key string, path []string) {
|
||||
value := firstCodexString(obj, path)
|
||||
if value != "" {
|
||||
|
||||
@@ -559,7 +559,7 @@ func TestNormalizeCodexImportUsesJWTSubForAccessTokenOnlyIdentity(t *testing.T)
|
||||
|
||||
func TestImportCodexSessionsAccessTokenOnlySameWorkspaceDifferentUsersCreatesTwoAccounts(t *testing.T) {
|
||||
svc := newCodexImportMemoryAdminService(nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: buildCodexAccessOnlyImportValue(t, "workspace-1", "user-1")},
|
||||
@@ -583,7 +583,7 @@ func TestImportCodexSessionsAccessTokenOnlySameWorkspaceDifferentUsersCreatesTwo
|
||||
|
||||
func TestImportCodexSessionsAccessTokenOnlySameWorkspaceAndUserDifferentTokensCreatesTwoAccounts(t *testing.T) {
|
||||
svc := newCodexImportMemoryAdminService(nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: map[string]any{
|
||||
@@ -632,7 +632,7 @@ func TestImportCodexSessionsAccessTokenOnlySameUserUpdatesExisting(t *testing.T)
|
||||
},
|
||||
Extra: map[string]any{"openai_long_context_billing_enabled": false},
|
||||
}})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: map[string]any{"access_token": existingToken}},
|
||||
@@ -670,7 +670,7 @@ func TestImportCodexSessionsUpgradesAccessTokenOnlyAccountWithRefreshToken(t *te
|
||||
"access_token": oldToken,
|
||||
},
|
||||
}})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: map[string]any{
|
||||
@@ -709,7 +709,7 @@ func TestImportCodexSessionsAccessTokenOnlyPreservesExistingRefreshToken(t *test
|
||||
"client_id": "client-old",
|
||||
},
|
||||
}})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: map[string]any{"access_token": existingToken}},
|
||||
@@ -752,7 +752,7 @@ func TestImportCodexSessionsBatchOldAccessTokenDoesNotRollbackRefreshToken(t *te
|
||||
"refresh_token": "refresh-old",
|
||||
},
|
||||
}})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: map[string]any{
|
||||
@@ -798,7 +798,7 @@ func TestImportCodexSessionsWithRefreshTokenKeepsExistingDedup(t *testing.T) {
|
||||
"refresh_token": "refresh-old",
|
||||
},
|
||||
}})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-1", "refresh-new")},
|
||||
|
||||
@@ -66,6 +66,7 @@ func setupAccountDataRouter() (*gin.Engine, *stubAdminService) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
router.GET("/api/v1/admin/accounts/data", h.ExportData)
|
||||
|
||||
@@ -52,6 +52,7 @@ type AccountHandler struct {
|
||||
openaiOAuthService *service.OpenAIOAuthService
|
||||
geminiOAuthService *service.GeminiOAuthService
|
||||
antigravityOAuthService *service.AntigravityOAuthService
|
||||
grokOAuthService service.GrokOAuthTokenService
|
||||
rateLimitService *service.RateLimitService
|
||||
accountUsageService *service.AccountUsageService
|
||||
accountTestService *service.AccountTestService
|
||||
@@ -60,7 +61,13 @@ type AccountHandler struct {
|
||||
sessionLimitCache service.SessionLimitCache
|
||||
rpmCache service.RPMCache
|
||||
tokenCacheInvalidator service.TokenCacheInvalidator
|
||||
grokImportProber grokUsageProber
|
||||
grokImportProber grokImportProber
|
||||
upstreamBillingProbe *service.UpstreamBillingProbeService
|
||||
}
|
||||
|
||||
// SetUpstreamBillingProbeService attaches the optional remote billing probe service.
|
||||
func (h *AccountHandler) SetUpstreamBillingProbeService(probe *service.UpstreamBillingProbeService) {
|
||||
h.upstreamBillingProbe = probe
|
||||
}
|
||||
|
||||
// NewAccountHandler creates a new admin account handler
|
||||
@@ -70,6 +77,7 @@ func NewAccountHandler(
|
||||
openaiOAuthService *service.OpenAIOAuthService,
|
||||
geminiOAuthService *service.GeminiOAuthService,
|
||||
antigravityOAuthService *service.AntigravityOAuthService,
|
||||
grokOAuthService service.GrokOAuthTokenService,
|
||||
rateLimitService *service.RateLimitService,
|
||||
accountUsageService *service.AccountUsageService,
|
||||
accountTestService *service.AccountTestService,
|
||||
@@ -85,6 +93,7 @@ func NewAccountHandler(
|
||||
openaiOAuthService: openaiOAuthService,
|
||||
geminiOAuthService: geminiOAuthService,
|
||||
antigravityOAuthService: antigravityOAuthService,
|
||||
grokOAuthService: grokOAuthService,
|
||||
rateLimitService: rateLimitService,
|
||||
accountUsageService: accountUsageService,
|
||||
accountTestService: accountTestService,
|
||||
@@ -112,6 +121,7 @@ type CreateAccountRequest struct {
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
ExpiresAt *int64 `json:"expires_at"`
|
||||
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
|
||||
ProbeEnabled *bool `json:"upstream_billing_probe_enabled"`
|
||||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
||||
}
|
||||
|
||||
@@ -150,6 +160,7 @@ type BulkUpdateAccountsRequest struct {
|
||||
GroupIDs *[]int64 `json:"group_ids"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
ProbeEnabled *bool `json:"upstream_billing_probe_enabled"`
|
||||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
||||
}
|
||||
|
||||
@@ -819,6 +830,7 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
||||
GroupIDs: req.GroupIDs,
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
AutoPauseOnExpired: req.AutoPauseOnExpired,
|
||||
ProbeEnabled: req.ProbeEnabled,
|
||||
SkipMixedChannelCheck: skipCheck,
|
||||
})
|
||||
if execErr != nil {
|
||||
@@ -860,6 +872,53 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
||||
response.Success(c, result.Data)
|
||||
}
|
||||
|
||||
// Duplicate handles creating an independent account from an existing account's configuration.
|
||||
// POST /api/v1/admin/accounts/:id/duplicate
|
||||
func (h *AccountHandler) Duplicate(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
actorScope := adminActorScope(c)
|
||||
|
||||
result, err := executeAdminIdempotent(
|
||||
c,
|
||||
"admin.accounts.duplicate",
|
||||
struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
}{AccountID: accountID},
|
||||
service.DefaultWriteIdempotencyTTL(),
|
||||
func(ctx context.Context) (any, error) {
|
||||
account, execErr := h.adminService.DuplicateAccount(ctx, accountID, actorScope, c.GetHeader("Idempotency-Key"))
|
||||
if execErr != nil {
|
||||
return nil, execErr
|
||||
}
|
||||
return h.buildAccountResponseWithRuntime(ctx, account), nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
reason := infraerrors.Reason(err)
|
||||
if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) {
|
||||
recovered, recoverErr := h.adminService.RecoverDuplicateAccount(c.Request.Context(), accountID, actorScope, c.GetHeader("Idempotency-Key"))
|
||||
if recoverErr != nil {
|
||||
slog.Warn("account_duplicate_recovery_failed", "account_id", accountID, "actor_scope", actorScope, "reason", reason, "error", recoverErr)
|
||||
} else if recovered != nil {
|
||||
c.Header("X-Idempotency-Recovered", "true")
|
||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), recovered))
|
||||
return
|
||||
}
|
||||
}
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result != nil && result.Replayed {
|
||||
c.Header("X-Idempotency-Replayed", "true")
|
||||
}
|
||||
response.Success(c, result.Data)
|
||||
}
|
||||
|
||||
// Update handles updating an account
|
||||
// PUT /api/v1/admin/accounts/:id
|
||||
func (h *AccountHandler) Update(c *gin.Context) {
|
||||
@@ -1179,6 +1238,19 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
|
||||
return nil, "", fmt.Errorf("failed to clear account error: %w", clearErr)
|
||||
}
|
||||
}
|
||||
} else if account.Platform == service.PlatformGrok {
|
||||
if h.grokOAuthService == nil {
|
||||
return nil, "", fmt.Errorf("grok oauth service is not configured")
|
||||
}
|
||||
tokenInfo, err := h.grokOAuthService.RefreshAccountToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to refresh Grok credentials: %w", err)
|
||||
}
|
||||
|
||||
newCredentials = service.MergeCredentials(account.Credentials, h.grokOAuthService.BuildAccountCredentials(tokenInfo))
|
||||
if baseURL := strings.TrimSpace(account.GetCredential("base_url")); baseURL != "" {
|
||||
newCredentials["base_url"] = baseURL
|
||||
}
|
||||
} else {
|
||||
// Use Anthropic/Claude OAuth service to refresh token
|
||||
tokenInfo, err := h.oauthService.RefreshAccountToken(ctx, account)
|
||||
@@ -1838,7 +1910,8 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
|
||||
req.Schedulable != nil ||
|
||||
req.GroupIDs != nil ||
|
||||
len(req.Credentials) > 0 ||
|
||||
len(req.Extra) > 0
|
||||
len(req.Extra) > 0 ||
|
||||
req.ProbeEnabled != nil
|
||||
|
||||
if !hasUpdates {
|
||||
response.BadRequest(c, "No updates provided")
|
||||
@@ -1859,6 +1932,7 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
|
||||
GroupIDs: req.GroupIDs,
|
||||
Credentials: req.Credentials,
|
||||
Extra: req.Extra,
|
||||
ProbeEnabled: req.ProbeEnabled,
|
||||
SkipMixedChannelCheck: skipCheck,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -32,7 +32,7 @@ func (s *availableModelsAdminService) GetAccount(_ context.Context, id int64) (*
|
||||
func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router.GET("/api/v1/admin/accounts/:id/models", handler.GetAvailableModels)
|
||||
return router
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi
|
||||
&config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
|
||||
nil,
|
||||
)
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, accountTestSvc, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, accountTestSvc, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/accounts/:id/models/sync-upstream", handler.SyncUpstreamModels)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type duplicateAccountAdminServiceStub struct {
|
||||
service.AdminService
|
||||
account *service.Account
|
||||
calls int
|
||||
recoverCalls int
|
||||
accountID int64
|
||||
actorScope string
|
||||
operationKey string
|
||||
recoverScope string
|
||||
recoverKey string
|
||||
recoverErr error
|
||||
created bool
|
||||
}
|
||||
|
||||
type blockingDuplicateAdminServiceStub struct {
|
||||
service.AdminService
|
||||
account *service.Account
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
calls atomic.Int32
|
||||
recoverCalls atomic.Int32
|
||||
recoverErr error
|
||||
}
|
||||
|
||||
type failOnceMarkSucceededRepo struct {
|
||||
*memoryIdempotencyRepoStub
|
||||
failNext bool
|
||||
}
|
||||
|
||||
func (r *failOnceMarkSucceededRepo) MarkSucceeded(ctx context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error {
|
||||
if r.failNext {
|
||||
r.failNext = false
|
||||
return errors.New("mark succeeded failed")
|
||||
}
|
||||
return r.memoryIdempotencyRepoStub.MarkSucceeded(ctx, id, responseStatus, responseBody, expiresAt)
|
||||
}
|
||||
|
||||
func (s *duplicateAccountAdminServiceStub) DuplicateAccount(_ context.Context, accountID int64, actorScope, operationKey string) (*service.Account, error) {
|
||||
s.calls++
|
||||
s.accountID = accountID
|
||||
s.actorScope = actorScope
|
||||
s.operationKey = operationKey
|
||||
s.created = true
|
||||
return s.account, nil
|
||||
}
|
||||
|
||||
func (s *duplicateAccountAdminServiceStub) RecoverDuplicateAccount(_ context.Context, _ int64, actorScope, operationKey string) (*service.Account, error) {
|
||||
s.recoverCalls++
|
||||
s.recoverScope = actorScope
|
||||
s.recoverKey = operationKey
|
||||
if s.recoverErr != nil {
|
||||
return nil, s.recoverErr
|
||||
}
|
||||
if !s.created {
|
||||
return nil, nil
|
||||
}
|
||||
return s.account, nil
|
||||
}
|
||||
|
||||
func (s *blockingDuplicateAdminServiceStub) DuplicateAccount(_ context.Context, _ int64, _, _ string) (*service.Account, error) {
|
||||
s.calls.Add(1)
|
||||
close(s.started)
|
||||
<-s.release
|
||||
return s.account, nil
|
||||
}
|
||||
|
||||
func (s *blockingDuplicateAdminServiceStub) RecoverDuplicateAccount(_ context.Context, _ int64, _, _ string) (*service.Account, error) {
|
||||
s.recoverCalls.Add(1)
|
||||
return nil, s.recoverErr
|
||||
}
|
||||
|
||||
func setupDuplicateAccountRouter(t *testing.T, svc service.AdminService) *gin.Engine {
|
||||
t.Helper()
|
||||
previousCoordinator := service.DefaultIdempotencyCoordinator()
|
||||
service.SetDefaultIdempotencyCoordinator(nil)
|
||||
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) })
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77})
|
||||
c.Next()
|
||||
})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/accounts/:id/duplicate", handler.Duplicate)
|
||||
return router
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerRedactsCredentials(t *testing.T) {
|
||||
svc := &duplicateAccountAdminServiceStub{
|
||||
account: &service.Account{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
Credentials: map[string]any{"api_key": "top-secret-key"},
|
||||
},
|
||||
}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, 1, svc.calls)
|
||||
require.Contains(t, recorder.Body.String(), `"name":"primary (Copy)"`)
|
||||
require.NotContains(t, recorder.Body.String(), "top-secret-key")
|
||||
var responseBody struct {
|
||||
Data struct {
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Schedulable bool `json:"schedulable"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &responseBody))
|
||||
require.Empty(t, responseBody.Data.Credentials)
|
||||
require.False(t, responseBody.Data.Schedulable)
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerRejectsInvalidID(t *testing.T) {
|
||||
svc := &duplicateAccountAdminServiceStub{}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/not-a-number/duplicate", nil)
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Zero(t, svc.calls)
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerReplaysSameIdempotencyKey(t *testing.T) {
|
||||
svc := &duplicateAccountAdminServiceStub{
|
||||
account: &service.Account{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
},
|
||||
}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
repo := newMemoryIdempotencyRepoStub()
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
|
||||
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-account-42")
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
first := call()
|
||||
second := call()
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
require.Equal(t, 1, svc.calls)
|
||||
require.Equal(t, int64(42), svc.accountID)
|
||||
require.Equal(t, "admin:77", svc.actorScope)
|
||||
require.Equal(t, "duplicate-account-42", svc.operationKey)
|
||||
require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed"))
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerRecoversAfterMarkSucceededFailure(t *testing.T) {
|
||||
svc := &duplicateAccountAdminServiceStub{
|
||||
account: &service.Account{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
},
|
||||
}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
repo := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true}
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
|
||||
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-account-42-recovery")
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
first := call()
|
||||
second := call()
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
require.Equal(t, "true", first.Header().Get("X-Idempotency-Recovered"))
|
||||
require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered"))
|
||||
require.Equal(t, 1, svc.calls, "ambiguous retries must not repeat the create side effect")
|
||||
require.Equal(t, 2, svc.recoverCalls)
|
||||
require.Equal(t, "admin:77", svc.recoverScope)
|
||||
require.Equal(t, "duplicate-account-42-recovery", svc.recoverKey)
|
||||
require.Contains(t, second.Body.String(), `"id":43`)
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerPreservesIdempotencyErrorWhenRecoveryLookupFails(t *testing.T) {
|
||||
svc := &duplicateAccountAdminServiceStub{
|
||||
account: &service.Account{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
},
|
||||
recoverErr: errors.New("recovery database unavailable"),
|
||||
}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
repo := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true}
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-account-42-recovery-error")
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "IDEMPOTENCY_STORE_UNAVAILABLE")
|
||||
require.Equal(t, 1, svc.calls)
|
||||
require.Equal(t, 1, svc.recoverCalls)
|
||||
require.Equal(t, "admin:77", svc.recoverScope)
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerDoesNotReexecuteWhileOriginalIsProcessing(t *testing.T) {
|
||||
svc := &blockingDuplicateAdminServiceStub{
|
||||
account: &service.Account{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
},
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
recoverErr: errors.New("recovery database unavailable"),
|
||||
}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), service.DefaultIdempotencyConfig()))
|
||||
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-account-42-active")
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
firstDone := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() { firstDone <- call() }()
|
||||
<-svc.started
|
||||
|
||||
second := call()
|
||||
require.Equal(t, http.StatusConflict, second.Code)
|
||||
require.Contains(t, second.Body.String(), "IDEMPOTENCY_IN_PROGRESS")
|
||||
require.Equal(t, int32(1), svc.calls.Load())
|
||||
require.Equal(t, int32(1), svc.recoverCalls.Load())
|
||||
|
||||
close(svc.release)
|
||||
select {
|
||||
case first := <-firstDone:
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("original duplicate request did not finish")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type grokRefreshOAuthStub struct {
|
||||
account *service.Account
|
||||
info *service.GrokTokenInfo
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *grokRefreshOAuthStub) RefreshAccountToken(_ context.Context, account *service.Account) (*service.GrokTokenInfo, error) {
|
||||
s.calls++
|
||||
s.account = account
|
||||
return s.info, nil
|
||||
}
|
||||
|
||||
func (s *grokRefreshOAuthStub) BuildAccountCredentials(info *service.GrokTokenInfo) map[string]any {
|
||||
return map[string]any{
|
||||
"access_token": info.AccessToken,
|
||||
"refresh_token": info.RefreshToken,
|
||||
"expires_at": info.ExpiresAt,
|
||||
"base_url": "https://api.x.ai/v1",
|
||||
}
|
||||
}
|
||||
|
||||
type grokRefreshAdminService struct {
|
||||
*stubAdminService
|
||||
updatedCredentials map[string]any
|
||||
}
|
||||
|
||||
func (s *grokRefreshAdminService) UpdateAccount(_ context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) {
|
||||
s.updatedCredentials = input.Credentials
|
||||
return &service.Account{
|
||||
ID: id,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
Credentials: input.Credentials,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestRefreshSingleAccountRoutesGrokThroughGrokOAuthService(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminSvc := &grokRefreshAdminService{stubAdminService: newStubAdminService()}
|
||||
grokOAuth := &grokRefreshOAuthStub{info: &service.GrokTokenInfo{
|
||||
AccessToken: "new-access",
|
||||
RefreshToken: "new-refresh",
|
||||
ExpiresAt: 1_800_000_000,
|
||||
}}
|
||||
handler := NewAccountHandler(
|
||||
adminSvc,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
grokOAuth,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
account := &service.Account{
|
||||
ID: 4227,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "old-access",
|
||||
"refresh_token": "old-refresh",
|
||||
"base_url": "https://example.invalid/v1",
|
||||
"subscription_tier": "SUPER_GROK",
|
||||
"entitlement_status": "ACTIVE",
|
||||
},
|
||||
}
|
||||
|
||||
updated, warning, err := handler.refreshSingleAccount(context.Background(), account)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, warning)
|
||||
require.Equal(t, 1, grokOAuth.calls)
|
||||
require.Same(t, account, grokOAuth.account)
|
||||
require.Equal(t, "new-access", adminSvc.updatedCredentials["access_token"])
|
||||
require.Equal(t, "new-refresh", adminSvc.updatedCredentials["refresh_token"])
|
||||
require.Equal(t, "https://example.invalid/v1", adminSvc.updatedCredentials["base_url"])
|
||||
require.Equal(t, "SUPER_GROK", adminSvc.updatedCredentials["subscription_tier"])
|
||||
require.Equal(t, "ACTIVE", adminSvc.updatedCredentials["entitlement_status"])
|
||||
require.Equal(t, adminSvc.updatedCredentials, updated.Credentials)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ func setupAccountListRouter() (*gin.Engine, *stubAdminService) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
adminSvc := newStubAdminService()
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router.GET("/api/v1/admin/accounts", handler.List)
|
||||
return router, adminSvc
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestAccountAdminBoundariesRejectMalformedOpenAILongContextBillingValue(t *t
|
||||
if tt.setup != nil {
|
||||
tt.setup(stub)
|
||||
}
|
||||
handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
tt.mount(router, handler)
|
||||
recorder := httptest.NewRecorder()
|
||||
@@ -99,7 +99,7 @@ func TestAccountAdminBoundariesRejectMalformedOpenAILongContextBillingValue(t *t
|
||||
|
||||
func TestAccountCreateBoundaryDoesNotApplyOpenAIValidationToOtherPlatforms(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewAccountHandler(newStubAdminService(), nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(newStubAdminService(), nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.POST("/accounts", handler.Create)
|
||||
recorder := httptest.NewRecorder()
|
||||
@@ -121,7 +121,7 @@ func TestApplyOAuthCredentialsRejectsMalformedOpenAILongContextBillingBeforeMuta
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
}
|
||||
handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.POST("/accounts/:id/apply-oauth-credentials", handler.ApplyOAuthCredentials)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
func setupAccountMixedChannelRouter(adminSvc *stubAdminService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
accountHandler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
accountHandler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/accounts/check-mixed-channel", accountHandler.CheckMixedChannel)
|
||||
router.POST("/api/v1/admin/accounts", accountHandler.Create)
|
||||
router.PUT("/api/v1/admin/accounts/:id", accountHandler.Update)
|
||||
@@ -222,3 +222,22 @@ func TestBulkUpdateAcceptsFilterTargetRequest(t *testing.T) {
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, float64(0), resp["code"])
|
||||
}
|
||||
|
||||
func TestBulkUpdateAcceptsDedicatedUpstreamBillingProbeSetting(t *testing.T) {
|
||||
adminSvc := newStubAdminService()
|
||||
router := setupAccountMixedChannelRouter(adminSvc)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"account_ids": []int64{1, 2},
|
||||
"upstream_billing_probe_enabled": false,
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/bulk-update", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.NotNil(t, adminSvc.lastBulkUpdateAccountInput)
|
||||
require.NotNil(t, adminSvc.lastBulkUpdateAccountInput.ProbeEnabled)
|
||||
require.False(t, *adminSvc.lastBulkUpdateAccountInput.ProbeEnabled)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ func TestAccountHandler_Create_AnthropicAPIKeyPassthroughExtraForwarded(t *testi
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
router := gin.New()
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type upstreamBillingProbeEnabledRequest struct {
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
|
||||
type upstreamBillingProbeBatchRequest struct {
|
||||
AccountIDs []int64 `json:"account_ids" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *AccountHandler) GetUpstreamBillingProbeSettings(c *gin.Context) {
|
||||
if h.upstreamBillingProbe == nil {
|
||||
response.ErrorFrom(c, service.ErrUpstreamBillingProbeUnavailable)
|
||||
return
|
||||
}
|
||||
settings, err := h.upstreamBillingProbe.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, settings)
|
||||
}
|
||||
|
||||
func (h *AccountHandler) UpdateUpstreamBillingProbeSettings(c *gin.Context) {
|
||||
if h.upstreamBillingProbe == nil {
|
||||
response.ErrorFrom(c, service.ErrUpstreamBillingProbeUnavailable)
|
||||
return
|
||||
}
|
||||
var req service.UpstreamBillingProbeSettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.upstreamBillingProbe.UpdateSettings(c.Request.Context(), &req); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
settings, err := h.upstreamBillingProbe.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, settings)
|
||||
}
|
||||
|
||||
func (h *AccountHandler) SetUpstreamBillingProbeEnabled(c *gin.Context) {
|
||||
if h.upstreamBillingProbe == nil {
|
||||
response.ErrorFrom(c, service.ErrUpstreamBillingProbeUnavailable)
|
||||
return
|
||||
}
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || accountID <= 0 {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
var req upstreamBillingProbeEnabledRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.upstreamBillingProbe.SetAccountEnabled(c.Request.Context(), accountID, *req.Enabled); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"account_id": accountID, "enabled": *req.Enabled})
|
||||
}
|
||||
|
||||
func (h *AccountHandler) ProbeUpstreamBilling(c *gin.Context) {
|
||||
if h.upstreamBillingProbe == nil {
|
||||
response.ErrorFrom(c, service.ErrUpstreamBillingProbeUnavailable)
|
||||
return
|
||||
}
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || accountID <= 0 {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
snapshot, err := h.upstreamBillingProbe.ProbeAccount(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, service.UpstreamBillingProbeResult{AccountID: accountID, Snapshot: snapshot})
|
||||
}
|
||||
|
||||
func (h *AccountHandler) ProbeUpstreamBillingBatch(c *gin.Context) {
|
||||
if h.upstreamBillingProbe == nil {
|
||||
response.ErrorFrom(c, service.ErrUpstreamBillingProbeUnavailable)
|
||||
return
|
||||
}
|
||||
var req upstreamBillingProbeBatchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(req.AccountIDs) == 0 || len(req.AccountIDs) > service.UpstreamBillingProbeMaxBatchSize {
|
||||
response.BadRequest(c, "account_ids must contain between 1 and 20 items")
|
||||
return
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(req.AccountIDs))
|
||||
accountIDs := make([]int64, 0, len(req.AccountIDs))
|
||||
for _, accountID := range req.AccountIDs {
|
||||
if accountID <= 0 {
|
||||
response.BadRequest(c, "account_ids must contain positive IDs")
|
||||
return
|
||||
}
|
||||
if _, exists := seen[accountID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[accountID] = struct{}{}
|
||||
accountIDs = append(accountIDs, accountID)
|
||||
}
|
||||
response.Success(c, gin.H{"results": h.upstreamBillingProbe.ProbeAccounts(c.Request.Context(), accountIDs)})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupUpstreamBillingProbeRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewAccountHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler.SetUpstreamBillingProbeService(service.NewUpstreamBillingProbeService(nil, nil, nil))
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/admin/accounts/upstream-billing-probe/settings", handler.GetUpstreamBillingProbeSettings)
|
||||
router.POST("/admin/accounts/upstream-billing-probe/batch", handler.ProbeUpstreamBillingBatch)
|
||||
router.PUT("/admin/accounts/:id/upstream-billing-probe", handler.SetUpstreamBillingProbeEnabled)
|
||||
return router
|
||||
}
|
||||
|
||||
func TestAccountHandlerGetUpstreamBillingProbeSettingsReturnsDefaults(t *testing.T) {
|
||||
router := setupUpstreamBillingProbeRouter()
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/admin/accounts/upstream-billing-probe/settings", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
var response struct {
|
||||
Data service.UpstreamBillingProbeSettings `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
require.True(t, response.Data.Enabled)
|
||||
require.Equal(t, 30, response.Data.IntervalMinutes)
|
||||
}
|
||||
|
||||
func TestAccountHandlerProbeUpstreamBillingBatchValidatesIDs(t *testing.T) {
|
||||
router := setupUpstreamBillingProbeRouter()
|
||||
|
||||
for _, body := range []string{`{"account_ids":[]}`, `{"account_ids":[0]}`} {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/accounts/upstream-billing-probe/batch", bytes.NewBufferString(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, request)
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountHandlerSetUpstreamBillingProbeEnabledRejectsInvalidID(t *testing.T) {
|
||||
router := setupUpstreamBillingProbeRouter()
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPut, "/admin/accounts/not-an-id/upstream-billing-probe", bytes.NewBufferString(`{"enabled":true}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
|
||||
func TestAccountHandlerSetUpstreamBillingProbeEnabledRequiresValue(t *testing.T) {
|
||||
router := setupUpstreamBillingProbeRouter()
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPut, "/admin/accounts/1/upstream-billing-probe", bytes.NewBufferString(`{}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
|
||||
router := gin.New()
|
||||
adminSvc := newStubAdminService()
|
||||
|
||||
userHandler := NewUserHandler(adminSvc, nil, nil, nil)
|
||||
userHandler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil, nil)
|
||||
groupHandler := NewGroupHandler(adminSvc, nil, nil)
|
||||
proxyHandler := NewProxyHandler(adminSvc)
|
||||
redeemHandler := NewRedeemHandler(adminSvc, nil)
|
||||
|
||||
@@ -33,6 +33,7 @@ type stubAdminService struct {
|
||||
createSparkShadowErr error
|
||||
updateAccountErr error
|
||||
bulkUpdateAccountErr error
|
||||
lastBulkUpdateAccountInput *service.BulkUpdateAccountsInput
|
||||
getAccountResult *service.Account
|
||||
updateAccountCalls int
|
||||
updateAccountExtraCalls int
|
||||
@@ -195,6 +196,10 @@ func (s *stubAdminService) BatchUpdateConcurrency(ctx context.Context, userIDs [
|
||||
return len(userIDs), nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
|
||||
return len(userIDs), nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]service.APIKey, int64, error) {
|
||||
return s.apiKeys, int64(len(s.apiKeys)), nil
|
||||
}
|
||||
@@ -293,6 +298,15 @@ func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.Creat
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) {
|
||||
group := service.Group{ID: 201, Name: "group (Copy)", Status: "inactive"}
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) UpdateGroup(ctx context.Context, id int64, input *service.UpdateGroupInput) (*service.Group, error) {
|
||||
group := service.Group{ID: id, Name: input.Name, Status: service.StatusActive}
|
||||
return &group, nil
|
||||
@@ -418,6 +432,15 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*service.Account, error) {
|
||||
account := service.Account{ID: 301, Name: "account (Copy)", Status: service.StatusActive, Schedulable: false}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) RecoverDuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*service.Account, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) {
|
||||
s.updateAccountCalls++
|
||||
if s.updateAccountErr != nil {
|
||||
@@ -456,6 +479,7 @@ func (s *stubAdminService) SetAccountSchedulable(ctx context.Context, id int64,
|
||||
}
|
||||
|
||||
func (s *stubAdminService) BulkUpdateAccounts(ctx context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) {
|
||||
s.lastBulkUpdateAccountInput = input
|
||||
if s.bulkUpdateAccountErr != nil {
|
||||
return nil, s.bulkUpdateAccountErr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuditLogHandler 操作审计日志管理接口。
|
||||
// 审计日志仅管理员可见;不提供单条删除,仅支持带 TOTP 验证的全量清空。
|
||||
type AuditLogHandler struct {
|
||||
auditService *service.AuditLogService
|
||||
totpService *service.TotpService
|
||||
}
|
||||
|
||||
// NewAuditLogHandler 创建审计日志处理器。
|
||||
func NewAuditLogHandler(auditService *service.AuditLogService, totpService *service.TotpService) *AuditLogHandler {
|
||||
return &AuditLogHandler{
|
||||
auditService: auditService,
|
||||
totpService: totpService,
|
||||
}
|
||||
}
|
||||
|
||||
// List 分页查询审计日志。
|
||||
// GET /api/v1/admin/audit-logs
|
||||
func (h *AuditLogHandler) List(c *gin.Context) {
|
||||
page, pageSize := response.ParsePagination(c)
|
||||
if pageSize > 200 {
|
||||
pageSize = 200
|
||||
}
|
||||
|
||||
filter := &service.AuditLogFilter{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
ActorEmail: strings.TrimSpace(c.Query("actor_email")),
|
||||
AuthMethod: strings.TrimSpace(c.Query("auth_method")),
|
||||
Action: strings.TrimSpace(c.Query("action")),
|
||||
Method: strings.TrimSpace(c.Query("method")),
|
||||
ClientIP: strings.TrimSpace(c.Query("client_ip")),
|
||||
Query: strings.TrimSpace(c.Query("q")),
|
||||
}
|
||||
|
||||
if v := strings.TrimSpace(c.Query("actor_user_id")); v != "" {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.BadRequest(c, "Invalid actor_user_id")
|
||||
return
|
||||
}
|
||||
filter.ActorUserID = &id
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("start_time")); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid start_time, expect RFC3339")
|
||||
return
|
||||
}
|
||||
filter.StartTime = &t
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("end_time")); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid end_time, expect RFC3339")
|
||||
return
|
||||
}
|
||||
filter.EndTime = &t
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("success")); v != "" {
|
||||
success := v == "true"
|
||||
filter.Success = &success
|
||||
}
|
||||
|
||||
result, err := h.auditService.List(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Paginated(c, result.Logs, int64(result.Total), result.Page, result.PageSize)
|
||||
}
|
||||
|
||||
// Get 查询单条审计日志详情(含脱敏后的请求体)。
|
||||
// GET /api/v1/admin/audit-logs/:id
|
||||
func (h *AuditLogHandler) Get(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(c.Param("id")), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.BadRequest(c, "Invalid audit log id")
|
||||
return
|
||||
}
|
||||
item, err := h.auditService.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
type auditLogClearRequest struct {
|
||||
TotpCode string `json:"totp_code" binding:"required"`
|
||||
}
|
||||
|
||||
// Clear 全量清空审计日志。
|
||||
// POST /api/v1/admin/audit-logs/clear
|
||||
//
|
||||
// 安全要求(与需求对齐):
|
||||
// 1. 每次清空都必须现场验证 TOTP 码(不复用 step-up sudo 窗口)
|
||||
// 2. 未启用 TOTP 的管理员不允许清空
|
||||
// 3. admin API key(机器凭证)不允许清空
|
||||
// 4. 清空完成后同步写入一条留痕记录(操作者、IP、UA、删除行数)
|
||||
func (h *AuditLogHandler) Clear(c *gin.Context) {
|
||||
if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey {
|
||||
response.ErrorWithDetails(c, http.StatusForbidden,
|
||||
"Admin API key cannot clear audit logs; a two-factor verified admin session is required",
|
||||
"STEP_UP_ADMIN_API_KEY_FORBIDDEN", nil)
|
||||
return
|
||||
}
|
||||
|
||||
subject, ok := middleware.GetAuthSubjectFromContext(c)
|
||||
if !ok || subject.UserID <= 0 {
|
||||
response.Unauthorized(c, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req auditLogClearRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.ErrorWithDetails(c, http.StatusBadRequest,
|
||||
"TOTP code is required to clear audit logs", "TOTP_CODE_REQUIRED", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 校验 TOTP:未启用(ErrTotpNotSetup)、码错误、尝试过多均在此拦截。
|
||||
if err := h.totpService.VerifyCode(c.Request.Context(), subject.UserID, strings.TrimSpace(req.TotpCode)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
uid := subject.UserID
|
||||
role, _ := middleware.GetUserRoleFromContext(c)
|
||||
trace := &service.AuditLog{
|
||||
ActorUserID: &uid,
|
||||
ActorEmail: c.GetString(middleware.ContextKeyAuthEmail),
|
||||
ActorRole: role,
|
||||
AuthMethod: c.GetString("auth_method"),
|
||||
CredentialMasked: middleware.MaskedRequestCredential(c),
|
||||
Method: http.MethodPost,
|
||||
Path: c.FullPath(),
|
||||
ClientIP: middleware.SecurityClientIP(c),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
StatusCode: http.StatusOK,
|
||||
}
|
||||
if requestID := c.Writer.Header().Get("X-Request-ID"); requestID != "" {
|
||||
trace.RequestID = requestID
|
||||
}
|
||||
|
||||
deleted, err := h.auditService.ClearAll(c.Request.Context(), trace)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 留痕记录已同步落库,跳过异步审计中间件的重复记录。
|
||||
middleware.SkipAudit(c)
|
||||
response.Success(c, gin.H{"deleted": deleted})
|
||||
}
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
type BackupHandler struct {
|
||||
backupService *service.BackupService
|
||||
userService *service.UserService
|
||||
imageStorage *service.ImageStorageSettingService
|
||||
}
|
||||
|
||||
func NewBackupHandler(backupService *service.BackupService, userService *service.UserService) *BackupHandler {
|
||||
func NewBackupHandler(backupService *service.BackupService, userService *service.UserService, imageStorage *service.ImageStorageSettingService) *BackupHandler {
|
||||
return &BackupHandler{
|
||||
backupService: backupService,
|
||||
userService: userService,
|
||||
imageStorage: imageStorage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,3 +205,48 @@ func (h *BackupHandler) RestoreBackup(c *gin.Context) {
|
||||
}
|
||||
response.Accepted(c, record)
|
||||
}
|
||||
|
||||
// ─── 异步生图对象存储配置 ───
|
||||
//
|
||||
// 与备份共用一套 S3 客户端构造,因此放在同一个页面下:勾选"复用备份 S3"即可直接
|
||||
// 借用备份已配置的端点与密钥,只用不同的前缀区分对象(备份走 backups/,图片走 images/)。
|
||||
|
||||
func (h *BackupHandler) GetImageStorageConfig(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
cfg, err := h.imageStorage.Get(ctx)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{
|
||||
"config": cfg,
|
||||
"secret_configured": h.imageStorage.SecretConfigured(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *BackupHandler) UpdateImageStorageConfig(c *gin.Context) {
|
||||
var req service.ImageStorageSettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg, err := h.imageStorage.Update(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, cfg)
|
||||
}
|
||||
|
||||
func (h *BackupHandler) TestImageStorageConnection(c *gin.Context) {
|
||||
var req service.ImageStorageSettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.imageStorage.TestConnection(c.Request.Context(), req); err != nil {
|
||||
response.Success(c, gin.H{"ok": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"ok": true, "message": "connection successful"})
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func (f *failingAdminService) UpdateAccount(ctx context.Context, id int64, input
|
||||
func setupAccountHandlerWithService(adminSvc service.AdminService) (*gin.Engine, *AccountHandler) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/accounts/batch-update-credentials", handler.BatchUpdateCredentials)
|
||||
return router, handler
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ type channelModelPricingRequest struct {
|
||||
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
|
||||
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
|
||||
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
|
||||
Intervals []pricingIntervalRequest `json:"intervals"`
|
||||
@@ -115,6 +116,7 @@ type channelModelPricingResponse struct {
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []pricingIntervalResponse `json:"intervals"`
|
||||
@@ -222,6 +224,7 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
@@ -273,6 +276,7 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe
|
||||
OutputPrice: r.OutputPrice,
|
||||
CacheWritePrice: r.CacheWritePrice,
|
||||
CacheReadPrice: r.CacheReadPrice,
|
||||
ImageInputPrice: r.ImageInputPrice,
|
||||
ImageOutputPrice: r.ImageOutputPrice,
|
||||
PerRequestPrice: r.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
@@ -498,6 +502,7 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
|
||||
"output_price": pricing.OutputPricePerToken,
|
||||
"cache_write_price": pricing.CacheCreationPricePerToken,
|
||||
"cache_read_price": pricing.CacheReadPricePerToken,
|
||||
"image_input_price": pricing.ImageInputPricePerToken,
|
||||
"image_output_price": pricing.ImageOutputPricePerToken,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type duplicateChannelMonitorHandlerRepoStub struct {
|
||||
service.ChannelMonitorRepository
|
||||
source *service.ChannelMonitor
|
||||
byOperation map[string]*service.ChannelMonitor
|
||||
createCalls int
|
||||
}
|
||||
|
||||
func (r *duplicateChannelMonitorHandlerRepoStub) GetByID(_ context.Context, id int64) (*service.ChannelMonitor, error) {
|
||||
if r.source == nil || r.source.ID != id {
|
||||
return nil, service.ErrChannelMonitorNotFound
|
||||
}
|
||||
return r.source, nil
|
||||
}
|
||||
|
||||
func (r *duplicateChannelMonitorHandlerRepoStub) Create(_ context.Context, monitor *service.ChannelMonitor) error {
|
||||
r.createCalls++
|
||||
monitor.ID = int64(100 + r.createCalls)
|
||||
stored := *monitor
|
||||
if stored.DuplicateOperationID != "" {
|
||||
if r.byOperation == nil {
|
||||
r.byOperation = make(map[string]*service.ChannelMonitor)
|
||||
}
|
||||
r.byOperation[stored.DuplicateOperationID] = &stored
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *duplicateChannelMonitorHandlerRepoStub) FindByDuplicateOperationID(_ context.Context, operationID string) (*service.ChannelMonitor, error) {
|
||||
monitor := r.byOperation[operationID]
|
||||
if monitor == nil {
|
||||
return nil, nil
|
||||
}
|
||||
cloned := *monitor
|
||||
return &cloned, nil
|
||||
}
|
||||
|
||||
type duplicateChannelMonitorHandlerEncryptor struct{}
|
||||
|
||||
func (duplicateChannelMonitorHandlerEncryptor) Encrypt(plaintext string) (string, error) {
|
||||
return "ENC:" + plaintext, nil
|
||||
}
|
||||
|
||||
func (duplicateChannelMonitorHandlerEncryptor) Decrypt(ciphertext string) (string, error) {
|
||||
return strings.TrimPrefix(ciphertext, "ENC:"), nil
|
||||
}
|
||||
|
||||
func setupDuplicateChannelMonitorRouter(t *testing.T) (*gin.Engine, *duplicateChannelMonitorHandlerRepoStub) {
|
||||
t.Helper()
|
||||
previousCoordinator := service.DefaultIdempotencyCoordinator()
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(
|
||||
newMemoryIdempotencyRepoStub(),
|
||||
service.DefaultIdempotencyConfig(),
|
||||
))
|
||||
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) })
|
||||
|
||||
repo := &duplicateChannelMonitorHandlerRepoStub{
|
||||
source: &service.ChannelMonitor{
|
||||
ID: 42,
|
||||
Name: "primary",
|
||||
Provider: service.MonitorProviderOpenAI,
|
||||
APIMode: service.MonitorAPIModeResponses,
|
||||
Endpoint: "https://api.example.com",
|
||||
APIKey: "ENC:top-secret",
|
||||
PrimaryModel: "gpt-5.4-mini",
|
||||
ExtraModels: []string{"gpt-5.4"},
|
||||
Enabled: true,
|
||||
IntervalSeconds: 60,
|
||||
BodyOverrideMode: service.MonitorBodyOverrideModeOff,
|
||||
},
|
||||
}
|
||||
monitorService := service.NewChannelMonitorService(repo, duplicateChannelMonitorHandlerEncryptor{})
|
||||
handler := NewChannelMonitorHandler(monitorService)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77})
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/admin/channel-monitors/:id/duplicate", handler.Duplicate)
|
||||
return router, repo
|
||||
}
|
||||
|
||||
func TestDuplicateChannelMonitorHandlerRedactsKeyAndReplaysRetry(t *testing.T) {
|
||||
router, repo := setupDuplicateChannelMonitorRouter(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-channel-monitor-42")
|
||||
first := httptest.NewRecorder()
|
||||
router.ServeHTTP(first, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
require.Equal(t, 1, repo.createCalls)
|
||||
require.Contains(t, first.Body.String(), `"name":"primary (Copy)"`)
|
||||
require.Contains(t, first.Body.String(), `"api_key_masked":"top-***"`)
|
||||
require.Contains(t, first.Body.String(), `"created_by":77`)
|
||||
require.Contains(t, first.Body.String(), `"enabled":false`)
|
||||
require.NotContains(t, first.Body.String(), "top-secret")
|
||||
|
||||
retryRequest := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/42/duplicate", nil)
|
||||
retryRequest.Header.Set("Idempotency-Key", "duplicate-channel-monitor-42")
|
||||
retry := httptest.NewRecorder()
|
||||
router.ServeHTTP(retry, retryRequest)
|
||||
|
||||
require.Equal(t, http.StatusOK, retry.Code)
|
||||
require.Equal(t, "true", retry.Header().Get("X-Idempotency-Replayed"))
|
||||
require.Equal(t, 1, repo.createCalls)
|
||||
require.JSONEq(t, first.Body.String(), retry.Body.String())
|
||||
}
|
||||
|
||||
func TestDuplicateChannelMonitorHandlerRejectsInvalidID(t *testing.T) {
|
||||
router, repo := setupDuplicateChannelMonitorRouter(t)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/not-a-number/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-channel-monitor-invalid")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Zero(t, repo.createCalls)
|
||||
}
|
||||
|
||||
func TestDuplicateChannelMonitorHandlerRecoversAfterMarkSucceededFailure(t *testing.T) {
|
||||
router, repo := setupDuplicateChannelMonitorRouter(t)
|
||||
idempotencyRepo := &failOnceMarkSucceededRepo{
|
||||
memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(),
|
||||
failNext: true,
|
||||
}
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(
|
||||
idempotencyRepo,
|
||||
service.DefaultIdempotencyConfig(),
|
||||
))
|
||||
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-channel-monitor-recovery")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
first := call()
|
||||
second := call()
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
require.Equal(t, "true", first.Header().Get("X-Idempotency-Recovered"))
|
||||
require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered"))
|
||||
require.Equal(t, 1, repo.createCalls, "ambiguous retries must not repeat the create side effect")
|
||||
require.Contains(t, second.Body.String(), `"id":101`)
|
||||
require.Contains(t, second.Body.String(), `"api_key_masked":"top-***"`)
|
||||
require.NotContains(t, second.Body.String(), "top-secret")
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -333,6 +335,63 @@ func (h *ChannelMonitorHandler) Create(c *gin.Context) {
|
||||
response.Created(c, channelMonitorToResponse(m))
|
||||
}
|
||||
|
||||
// Duplicate POST /api/v1/admin/channel-monitors/:id/duplicate
|
||||
func (h *ChannelMonitorHandler) Duplicate(c *gin.Context) {
|
||||
id, ok := ParseChannelMonitorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
subject, _ := middleware2.GetAuthSubjectFromContext(c)
|
||||
actorScope := adminActorScope(c)
|
||||
|
||||
result, err := executeAdminIdempotent(
|
||||
c,
|
||||
"admin.channel_monitors.duplicate",
|
||||
struct {
|
||||
MonitorID int64 `json:"monitor_id"`
|
||||
}{MonitorID: id},
|
||||
service.DefaultWriteIdempotencyTTL(),
|
||||
func(ctx context.Context) (any, error) {
|
||||
monitor, err := h.monitorService.Duplicate(
|
||||
ctx,
|
||||
id,
|
||||
subject.UserID,
|
||||
actorScope,
|
||||
c.GetHeader("Idempotency-Key"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return channelMonitorToResponse(monitor), nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
reason := infraerrors.Reason(err)
|
||||
if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) {
|
||||
recovered, recoverErr := h.monitorService.RecoverDuplicate(
|
||||
c.Request.Context(),
|
||||
id,
|
||||
actorScope,
|
||||
c.GetHeader("Idempotency-Key"),
|
||||
)
|
||||
if recoverErr != nil {
|
||||
slog.Warn("channel_monitor_duplicate_recovery_failed", "monitor_id", id, "actor_scope", actorScope, "reason", reason, "error", recoverErr)
|
||||
} else if recovered != nil {
|
||||
c.Header("X-Idempotency-Recovered", "true")
|
||||
response.Success(c, channelMonitorToResponse(recovered))
|
||||
return
|
||||
}
|
||||
}
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result != nil && result.Replayed {
|
||||
c.Header("X-Idempotency-Replayed", "true")
|
||||
}
|
||||
response.Success(c, result.Data)
|
||||
}
|
||||
|
||||
// Update PUT /api/v1/admin/channel-monitors/:id
|
||||
func (h *ChannelMonitorHandler) Update(c *gin.Context) {
|
||||
id, ok := ParseChannelMonitorID(c)
|
||||
|
||||
@@ -15,12 +15,12 @@ const (
|
||||
grokImportProbeTimeout = 25 * time.Second
|
||||
)
|
||||
|
||||
type grokUsageProber interface {
|
||||
ProbeUsage(ctx context.Context, accountID int64) (*service.GrokQuotaProbeResult, error)
|
||||
type grokImportProber interface {
|
||||
QueryQuota(ctx context.Context, accountID int64) (*service.GrokQuotaProbeResult, error)
|
||||
}
|
||||
|
||||
type grokImportProbeTask struct {
|
||||
prober grokUsageProber
|
||||
prober grokImportProber
|
||||
accountID int64
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func newGrokImportProbeScheduler(concurrency int, timeout time.Duration) *grokIm
|
||||
}
|
||||
}
|
||||
|
||||
func (s *grokImportProbeScheduler) schedule(prober grokUsageProber, account *service.Account) {
|
||||
func (s *grokImportProbeScheduler) schedule(prober grokImportProber, account *service.Account) {
|
||||
if s == nil || prober == nil || account == nil || account.ID <= 0 {
|
||||
return
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func (s *grokImportProbeScheduler) nextTask() (grokImportProbeTask, bool) {
|
||||
return task, true
|
||||
}
|
||||
|
||||
func (s *grokImportProbeScheduler) run(prober grokUsageProber, accountID int64) {
|
||||
func (s *grokImportProbeScheduler) run(prober grokImportProber, accountID int64) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
slog.Error(
|
||||
@@ -112,7 +112,7 @@ func (s *grokImportProbeScheduler) run(prober grokUsageProber, accountID int64)
|
||||
// while this timeout only bounds the actual upstream probe execution.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
|
||||
defer cancel()
|
||||
result, err := prober.ProbeUsage(ctx, accountID)
|
||||
result, err := prober.QueryQuota(ctx, accountID)
|
||||
if err != nil {
|
||||
slog.Warn(
|
||||
"grok_import_active_probe_failed",
|
||||
@@ -173,6 +173,7 @@ func ProvideAccountHandler(
|
||||
openaiOAuthService *service.OpenAIOAuthService,
|
||||
geminiOAuthService *service.GeminiOAuthService,
|
||||
antigravityOAuthService *service.AntigravityOAuthService,
|
||||
grokOAuthService service.GrokOAuthTokenService,
|
||||
rateLimitService *service.RateLimitService,
|
||||
accountUsageService *service.AccountUsageService,
|
||||
accountTestService *service.AccountTestService,
|
||||
@@ -189,6 +190,7 @@ func ProvideAccountHandler(
|
||||
openaiOAuthService,
|
||||
geminiOAuthService,
|
||||
antigravityOAuthService,
|
||||
grokOAuthService,
|
||||
rateLimitService,
|
||||
accountUsageService,
|
||||
accountTestService,
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestGrokSSOBatchImportKeepsCreatedAccountsWhenOneAutomaticProbeFails(t *tes
|
||||
defer oauthService.Stop()
|
||||
prober := newGrokImportProbeStub(3)
|
||||
prober.failures[502] = infraerrors.New(502, "GROK_TEST_PROBE_FAILED", "sensitive-upstream-body")
|
||||
handler := NewGrokOAuthHandler(oauthService, adminService, nil)
|
||||
handler := NewGrokOAuthHandler(oauthService, adminService, nil, nil)
|
||||
handler.importProber = prober
|
||||
|
||||
router := gin.New()
|
||||
@@ -101,7 +101,7 @@ func TestAccountCreateWithoutAutomaticGrokProbeServiceStillSucceeds(t *testing.T
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewAccountHandler(
|
||||
newGrokImportAdminService(),
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
|
||||
router := gin.New()
|
||||
|
||||
@@ -36,7 +36,7 @@ func newGrokImportProbeStub(buffer int) *grokImportProbeStub {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *grokImportProbeStub) ProbeUsage(ctx context.Context, accountID int64) (*service.GrokQuotaProbeResult, error) {
|
||||
func (s *grokImportProbeStub) QueryQuota(ctx context.Context, accountID int64) (*service.GrokQuotaProbeResult, error) {
|
||||
_, deadlineSeen := ctx.Deadline()
|
||||
s.mu.Lock()
|
||||
s.calls[accountID]++
|
||||
@@ -69,7 +69,7 @@ func (s *grokImportProbeStub) ProbeUsage(ctx context.Context, accountID int64) (
|
||||
return nil, failure
|
||||
}
|
||||
return &service.GrokQuotaProbeResult{
|
||||
Source: "active_probe",
|
||||
Source: "hybrid_probe",
|
||||
Model: "grok-4.5",
|
||||
StatusCode: 200,
|
||||
ResetSupported: false,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
@@ -22,19 +23,22 @@ type GrokOAuthHandler struct {
|
||||
grokOAuthService *service.GrokOAuthService
|
||||
adminService service.AdminService
|
||||
quotaService *service.GrokQuotaService
|
||||
importProber grokUsageProber
|
||||
importProber grokImportProber
|
||||
reconciler service.GrokOAuthReconciler
|
||||
}
|
||||
|
||||
func NewGrokOAuthHandler(
|
||||
grokOAuthService *service.GrokOAuthService,
|
||||
adminService service.AdminService,
|
||||
quotaService *service.GrokQuotaService,
|
||||
reconciler service.GrokOAuthReconciler,
|
||||
) *GrokOAuthHandler {
|
||||
return &GrokOAuthHandler{
|
||||
grokOAuthService: grokOAuthService,
|
||||
adminService: adminService,
|
||||
quotaService: quotaService,
|
||||
importProber: quotaService,
|
||||
reconciler: reconciler,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +164,50 @@ func (h *GrokOAuthHandler) RefreshAccountToken(c *gin.Context) {
|
||||
response.Success(c, dto.AccountFromService(updatedAccount))
|
||||
}
|
||||
|
||||
type GrokOAuthReconcileRequest struct {
|
||||
DryRun *bool `json:"dry_run"`
|
||||
Apply bool `json:"apply"`
|
||||
AfterID int64 `json:"after_id"`
|
||||
Limit int `json:"limit"`
|
||||
RefreshWindowSeconds int64 `json:"refresh_window_seconds"`
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) ReconcileOAuthAccounts(c *gin.Context) {
|
||||
var req GrokOAuthReconcileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request")
|
||||
return
|
||||
}
|
||||
dryRun := true
|
||||
if req.DryRun != nil {
|
||||
dryRun = *req.DryRun
|
||||
}
|
||||
if req.Apply == dryRun {
|
||||
response.ErrorFrom(c, service.ErrGrokOAuthReconcileMode)
|
||||
return
|
||||
}
|
||||
if req.RefreshWindowSeconds < 0 || req.RefreshWindowSeconds > int64((24*time.Hour)/time.Second) {
|
||||
response.ErrorFrom(c, service.ErrGrokOAuthReconcileWindow)
|
||||
return
|
||||
}
|
||||
if h.reconciler == nil {
|
||||
response.InternalError(c, "Grok OAuth reconciliation service is unavailable")
|
||||
return
|
||||
}
|
||||
result, err := h.reconciler.ReconcileGrokOAuth(c.Request.Context(), service.GrokOAuthReconcileInput{
|
||||
DryRun: dryRun,
|
||||
Apply: req.Apply,
|
||||
AfterID: req.AfterID,
|
||||
Limit: req.Limit,
|
||||
RefreshWindow: time.Duration(req.RefreshWindowSeconds) * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
|
||||
var req struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
@@ -325,8 +373,7 @@ func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req Gr
|
||||
return grokSSOImportWorkerResult{item: GrokSSOToOAuthItemResult{Index: index, Error: grokSSOImportErrorMessage(err)}}
|
||||
}
|
||||
|
||||
credentials := h.grokOAuthService.BuildAccountCredentials(tokenInfo)
|
||||
credentials = service.MergeCredentials(cloneGrokSSOMap(req.Credentials), credentials)
|
||||
credentials := grokSSOImportCredentials(h.grokOAuthService.BuildAccountCredentials(tokenInfo), req.Credentials)
|
||||
name := grokSSOImportAccountName(req.Name, tokenInfo, index, total)
|
||||
expiresAt, autoPauseOnExpired := grokSSOImportExpiry(req.ExpiresAt, req.AutoPauseOnExpired, tokenInfo)
|
||||
account, err := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{
|
||||
@@ -360,6 +407,18 @@ func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req Gr
|
||||
}
|
||||
}
|
||||
|
||||
// grokSSOImportCredentials 合并 SSO 兑换出的凭据与导入请求携带的运营侧配置。
|
||||
// token 字段以 BuildAccountCredentials 为准(请求不可覆盖);但 base_url 是运营侧
|
||||
// 配置且 Build 恒写官方地址,会吞掉导入时指定的自定义转发地址——与
|
||||
// RefreshAccountToken 的保留逻辑对齐,请求显式提供时以请求为准。
|
||||
func grokSSOImportCredentials(built map[string]any, reqCredentials map[string]any) map[string]any {
|
||||
credentials := service.MergeCredentials(cloneGrokSSOMap(reqCredentials), built)
|
||||
if reqBaseURL, ok := reqCredentials["base_url"].(string); ok && strings.TrimSpace(reqBaseURL) != "" {
|
||||
credentials["base_url"] = strings.TrimSpace(reqBaseURL)
|
||||
}
|
||||
return credentials
|
||||
}
|
||||
|
||||
func grokSSOImportExpiry(requestExpiresAt *int64, requestAutoPause *bool, tokenInfo *service.GrokTokenInfo) (*int64, *bool) {
|
||||
if tokenInfo == nil || strings.TrimSpace(tokenInfo.RefreshToken) != "" || tokenInfo.ExpiresAt <= 0 {
|
||||
return requestExpiresAt, requestAutoPause
|
||||
|
||||
@@ -47,6 +47,19 @@ type grokQuotaHandlerUpstream struct {
|
||||
bodies [][]byte
|
||||
}
|
||||
|
||||
type grokOAuthReconcilerStub struct {
|
||||
input service.GrokOAuthReconcileInput
|
||||
calls int
|
||||
result *service.GrokOAuthReconcileResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *grokOAuthReconcilerStub) ReconcileGrokOAuth(_ context.Context, input service.GrokOAuthReconcileInput) (*service.GrokOAuthReconcileResult, error) {
|
||||
s.calls++
|
||||
s.input = input
|
||||
return s.result, s.err
|
||||
}
|
||||
|
||||
func (u *grokQuotaHandlerUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
var body []byte
|
||||
if req.Body != nil {
|
||||
@@ -90,15 +103,18 @@ func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
|
||||
ID: 42,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "access-token",
|
||||
"expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
|
||||
"access_token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
|
||||
},
|
||||
}}
|
||||
upstream := &grokQuotaHandlerUpstream{}
|
||||
quotaService := service.NewGrokQuotaService(repo, nil, service.NewGrokTokenProvider(repo, nil), upstream)
|
||||
handler := NewGrokOAuthHandler(nil, nil, quotaService)
|
||||
quotaService := service.NewGrokQuotaService(repo, nil, service.NewGrokTokenProvider(repo, nil), upstream, nil)
|
||||
handler := NewGrokOAuthHandler(nil, nil, quotaService, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/api/v1/admin/grok/accounts/:id/quota", handler.QueryQuota)
|
||||
@@ -120,8 +136,12 @@ func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
|
||||
for i, upstreamReq := range requests {
|
||||
require.Equal(t, "Bearer access-token", upstreamReq.Header.Get("Authorization"))
|
||||
if upstreamReq.URL.String() == xai.DefaultCLIBaseURL+"/responses" {
|
||||
require.Equal(t, "application/json, text/event-stream", upstreamReq.Header.Get("Accept"))
|
||||
require.Contains(t, string(bodies[i]), `"model":"grok-4.5"`)
|
||||
require.Contains(t, string(bodies[i]), `"store":false`)
|
||||
require.Contains(t, string(bodies[i]), `"input":"hi"`)
|
||||
require.Contains(t, string(bodies[i]), `"stream":true`)
|
||||
require.NotContains(t, string(bodies[i]), `"max_output_tokens"`)
|
||||
require.NotContains(t, string(bodies[i]), `"store"`)
|
||||
}
|
||||
}
|
||||
require.NotNil(t, repo.updates[42])
|
||||
@@ -135,8 +155,8 @@ func TestGrokOAuthHandlerResetQuotaReturnsUnsupported(t *testing.T) {
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
}}
|
||||
quotaService := service.NewGrokQuotaService(repo, nil, nil, nil)
|
||||
handler := NewGrokOAuthHandler(nil, nil, quotaService)
|
||||
quotaService := service.NewGrokQuotaService(repo, nil, nil, nil, nil)
|
||||
handler := NewGrokOAuthHandler(nil, nil, quotaService, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/grok/accounts/:id/reset-quota", handler.ResetQuota)
|
||||
@@ -154,7 +174,7 @@ func TestGrokOAuthHandlerRuntimeSanityDoesNotExposeSecrets(t *testing.T) {
|
||||
t.Setenv(xai.EnvBaseURL, "http://127.0.0.1:8080/v1?access_token=secret")
|
||||
t.Setenv(xai.EnvClientID, "client-secret-like-value")
|
||||
|
||||
handler := NewGrokOAuthHandler(nil, nil, nil)
|
||||
handler := NewGrokOAuthHandler(nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.GET("/api/v1/admin/grok/runtime-sanity", handler.RuntimeSanity)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -207,6 +227,45 @@ func TestGrokSSOImportExpiryPreservesRequestSettingsWithRefreshToken(t *testing.
|
||||
require.Same(t, &requestedAutoPause, autoPause)
|
||||
}
|
||||
|
||||
func TestGrokSSOImportCredentialsPreservesRequestedBaseURL(t *testing.T) {
|
||||
built := map[string]any{
|
||||
"access_token": "at-1",
|
||||
"base_url": xai.DefaultCLIBaseURL,
|
||||
}
|
||||
reqCredentials := map[string]any{
|
||||
"base_url": "https://relay.example.com/v1",
|
||||
"header_override_enabled": true,
|
||||
"header_overrides": map[string]any{"x-relay-key": "k"},
|
||||
}
|
||||
|
||||
credentials := grokSSOImportCredentials(built, reqCredentials)
|
||||
|
||||
// token 字段以兑换结果为准;base_url 是运营侧配置,必须保留请求里的自定义地址
|
||||
require.Equal(t, "at-1", credentials["access_token"])
|
||||
require.Equal(t, "https://relay.example.com/v1", credentials["base_url"])
|
||||
require.Equal(t, true, credentials["header_override_enabled"])
|
||||
require.Equal(t, map[string]any{"x-relay-key": "k"}, credentials["header_overrides"])
|
||||
// 入参不被污染(req.Credentials 会被多个 worker 并发读取)
|
||||
require.Equal(t, "https://relay.example.com/v1", reqCredentials["base_url"])
|
||||
}
|
||||
|
||||
func TestGrokSSOImportCredentialsDefaultsToOfficialBaseURL(t *testing.T) {
|
||||
built := map[string]any{
|
||||
"access_token": "at-1",
|
||||
"base_url": xai.DefaultCLIBaseURL,
|
||||
}
|
||||
|
||||
credentials := grokSSOImportCredentials(built, nil)
|
||||
require.Equal(t, xai.DefaultCLIBaseURL, credentials["base_url"])
|
||||
|
||||
credentials = grokSSOImportCredentials(map[string]any{
|
||||
"access_token": "at-2",
|
||||
"base_url": xai.DefaultCLIBaseURL,
|
||||
}, map[string]any{"base_url": " "})
|
||||
require.Equal(t, xai.DefaultCLIBaseURL, credentials["base_url"])
|
||||
require.Equal(t, "at-2", credentials["access_token"])
|
||||
}
|
||||
|
||||
func TestGrokSSOImportWorkerRecoversPanic(t *testing.T) {
|
||||
h := &GrokOAuthHandler{}
|
||||
result := h.safeCreateAccountFromSSOToken(context.Background(), GrokSSOToOAuthRequest{}, "token", 2, 3)
|
||||
@@ -216,3 +275,69 @@ func TestGrokSSOImportWorkerRecoversPanic(t *testing.T) {
|
||||
require.Equal(t, 2, result.item.Index)
|
||||
require.Contains(t, result.item.Error, "internal worker panic")
|
||||
}
|
||||
|
||||
func TestGrokOAuthHandlerReconcileDefaultsToDryRun(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
reconciler := &grokOAuthReconcilerStub{result: &service.GrokOAuthReconcileResult{
|
||||
DryRun: true,
|
||||
Scanned: 2,
|
||||
Actionable: 1,
|
||||
WouldBlock: 1,
|
||||
Items: []service.GrokOAuthReconcileItem{{AccountID: 42, Reason: service.GrokOAuthReconcileReasonMissingRefreshToken, Action: service.GrokOAuthReconcileActionBlock, Outcome: service.GrokOAuthReconcileOutcomePlanned}},
|
||||
NextAfterID: 0,
|
||||
}}
|
||||
handler := NewGrokOAuthHandler(nil, nil, nil, reconciler)
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/grok/oauth/reconcile", handler.ReconcileOAuthAccounts)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/reconcile", strings.NewReader(`{}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, 1, reconciler.calls)
|
||||
require.True(t, reconciler.input.DryRun)
|
||||
require.False(t, reconciler.input.Apply)
|
||||
require.Contains(t, rec.Body.String(), `"reason":"missing_refresh_token"`)
|
||||
require.NotContains(t, rec.Body.String(), `"refresh_token":`)
|
||||
require.NotContains(t, rec.Body.String(), `"access_token":`)
|
||||
}
|
||||
|
||||
func TestGrokOAuthHandlerReconcileRequiresExplicitApply(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
reconciler := &grokOAuthReconcilerStub{}
|
||||
handler := NewGrokOAuthHandler(nil, nil, nil, reconciler)
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/grok/oauth/reconcile", handler.ReconcileOAuthAccounts)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/reconcile", strings.NewReader(`{"dry_run":false}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Zero(t, reconciler.calls)
|
||||
require.NotContains(t, rec.Body.String(), "credentials")
|
||||
}
|
||||
|
||||
func TestGrokOAuthHandlerReconcileExplicitApply(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
reconciler := &grokOAuthReconcilerStub{result: &service.GrokOAuthReconcileResult{DryRun: false, Refreshed: 1}}
|
||||
handler := NewGrokOAuthHandler(nil, nil, nil, reconciler)
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/grok/oauth/reconcile", handler.ReconcileOAuthAccounts)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/reconcile", strings.NewReader(`{"apply":true,"dry_run":false,"after_id":10,"limit":25,"refresh_window_seconds":3600}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, 1, reconciler.calls)
|
||||
require.True(t, reconciler.input.Apply)
|
||||
require.False(t, reconciler.input.DryRun)
|
||||
require.Equal(t, int64(10), reconciler.input.AfterID)
|
||||
require.Equal(t, 25, reconciler.input.Limit)
|
||||
require.Equal(t, time.Hour, reconciler.input.RefreshWindow)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -129,6 +132,10 @@ type CreateGroupRequest struct {
|
||||
ModelsListConfig service.GroupModelsListConfig `json:"models_list_config"`
|
||||
// 分组 RPM 上限(0 = 不限制)
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
// OpenAI/Codex 请求推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort"`
|
||||
// OpenAI/Codex 推理强度精确映射。
|
||||
ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
// 从指定分组复制账号(创建后自动绑定)
|
||||
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
|
||||
}
|
||||
@@ -183,6 +190,10 @@ type UpdateGroupRequest struct {
|
||||
ModelsListConfig *service.GroupModelsListConfig `json:"models_list_config"`
|
||||
// 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动
|
||||
RPMLimit *int `json:"rpm_limit"`
|
||||
// OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。
|
||||
MaxReasoningEffort *string `json:"max_reasoning_effort"`
|
||||
// nil 不修改,空数组清空,非空数组替换。
|
||||
ReasoningEffortMappings *[]service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
|
||||
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
|
||||
}
|
||||
@@ -351,6 +362,8 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: req.ModelsListConfig,
|
||||
RPMLimit: req.RPMLimit,
|
||||
MaxReasoningEffort: req.MaxReasoningEffort,
|
||||
ReasoningEffortMappings: req.ReasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -361,6 +374,53 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
response.Success(c, dto.GroupFromServiceAdmin(group))
|
||||
}
|
||||
|
||||
// Duplicate handles creating an inactive group copy with the source account bindings.
|
||||
// POST /api/v1/admin/groups/:id/duplicate
|
||||
func (h *GroupHandler) Duplicate(c *gin.Context) {
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || groupID <= 0 {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
return
|
||||
}
|
||||
actorScope := adminActorScope(c)
|
||||
|
||||
result, err := executeAdminIdempotent(
|
||||
c,
|
||||
"admin.groups.duplicate",
|
||||
struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
}{GroupID: groupID},
|
||||
service.DefaultWriteIdempotencyTTL(),
|
||||
func(ctx context.Context) (any, error) {
|
||||
group, execErr := h.adminService.DuplicateGroup(ctx, groupID, actorScope, c.GetHeader("Idempotency-Key"))
|
||||
if execErr != nil {
|
||||
return nil, execErr
|
||||
}
|
||||
return dto.GroupFromServiceAdmin(group), nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
reason := infraerrors.Reason(err)
|
||||
if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) {
|
||||
recovered, recoverErr := h.adminService.RecoverDuplicateGroup(c.Request.Context(), groupID, actorScope, c.GetHeader("Idempotency-Key"))
|
||||
if recoverErr != nil {
|
||||
slog.Warn("group_duplicate_recovery_failed", "group_id", groupID, "actor_scope", actorScope, "reason", reason, "error", recoverErr)
|
||||
} else if recovered != nil {
|
||||
c.Header("X-Idempotency-Recovered", "true")
|
||||
response.Success(c, dto.GroupFromServiceAdmin(recovered))
|
||||
return
|
||||
}
|
||||
}
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result != nil && result.Replayed {
|
||||
c.Header("X-Idempotency-Replayed", "true")
|
||||
}
|
||||
response.Success(c, result.Data)
|
||||
}
|
||||
|
||||
// Update handles updating a group
|
||||
// PUT /api/v1/admin/groups/:id
|
||||
func (h *GroupHandler) Update(c *gin.Context) {
|
||||
@@ -420,6 +480,8 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: req.ModelsListConfig,
|
||||
RPMLimit: req.RPMLimit,
|
||||
MaxReasoningEffort: req.MaxReasoningEffort,
|
||||
ReasoningEffortMappings: req.ReasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type duplicateGroupAdminServiceStub struct {
|
||||
service.AdminService
|
||||
group *service.Group
|
||||
calls int
|
||||
recoverCalls int
|
||||
groupID int64
|
||||
actorScope string
|
||||
operationKey string
|
||||
recoverScope string
|
||||
recoverKey string
|
||||
created bool
|
||||
}
|
||||
|
||||
func (s *duplicateGroupAdminServiceStub) DuplicateGroup(_ context.Context, groupID int64, actorScope, operationKey string) (*service.Group, error) {
|
||||
s.calls++
|
||||
s.groupID = groupID
|
||||
s.actorScope = actorScope
|
||||
s.operationKey = operationKey
|
||||
s.created = true
|
||||
return s.group, nil
|
||||
}
|
||||
|
||||
func (s *duplicateGroupAdminServiceStub) RecoverDuplicateGroup(_ context.Context, _ int64, actorScope, operationKey string) (*service.Group, error) {
|
||||
s.recoverCalls++
|
||||
s.recoverScope = actorScope
|
||||
s.recoverKey = operationKey
|
||||
if !s.created {
|
||||
return nil, nil
|
||||
}
|
||||
return s.group, nil
|
||||
}
|
||||
|
||||
func setupDuplicateGroupRouter(t *testing.T, svc service.AdminService) *gin.Engine {
|
||||
t.Helper()
|
||||
previousCoordinator := service.DefaultIdempotencyCoordinator()
|
||||
service.SetDefaultIdempotencyCoordinator(nil)
|
||||
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) })
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77})
|
||||
c.Next()
|
||||
})
|
||||
handler := NewGroupHandler(svc, nil, nil)
|
||||
router.POST("/api/v1/admin/groups/:id/duplicate", handler.Duplicate)
|
||||
return router
|
||||
}
|
||||
|
||||
func duplicateGroupHandlerFixture() *service.Group {
|
||||
return &service.Group{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Status: "inactive",
|
||||
RateMultiplier: 1,
|
||||
AccountCount: 3,
|
||||
ActiveAccountCount: 2,
|
||||
DuplicateOperationID: "internal-operation-must-not-leak",
|
||||
ModelRouting: map[string][]int64{"claude-*": {7}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateGroupHandlerReturnsAdminDTOWithoutOperationMetadata(t *testing.T) {
|
||||
svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()}
|
||||
router := setupDuplicateGroupRouter(t, svc)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil)
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, 1, svc.calls)
|
||||
require.Contains(t, recorder.Body.String(), `"name":"primary (Copy)"`)
|
||||
require.Contains(t, recorder.Body.String(), `"status":"inactive"`)
|
||||
require.Contains(t, recorder.Body.String(), `"account_count":3`)
|
||||
require.NotContains(t, recorder.Body.String(), "duplicate_operation_id")
|
||||
require.NotContains(t, recorder.Body.String(), "internal-operation-must-not-leak")
|
||||
}
|
||||
|
||||
func TestDuplicateGroupHandlerRejectsInvalidID(t *testing.T) {
|
||||
for _, id := range []string{"not-a-number", "0", "-1"} {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
svc := &duplicateGroupAdminServiceStub{}
|
||||
router := setupDuplicateGroupRouter(t, svc)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/"+id+"/duplicate", nil)
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Zero(t, svc.calls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateGroupHandlerReplaysSameIdempotencyKey(t *testing.T) {
|
||||
svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()}
|
||||
router := setupDuplicateGroupRouter(t, svc)
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), service.DefaultIdempotencyConfig()))
|
||||
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-group-42")
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
first := call()
|
||||
second := call()
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
require.Equal(t, 1, svc.calls)
|
||||
require.Equal(t, int64(42), svc.groupID)
|
||||
require.Equal(t, "admin:77", svc.actorScope)
|
||||
require.Equal(t, "duplicate-group-42", svc.operationKey)
|
||||
require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed"))
|
||||
}
|
||||
|
||||
func TestDuplicateGroupHandlerRecoversAfterMarkSucceededFailure(t *testing.T) {
|
||||
svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()}
|
||||
router := setupDuplicateGroupRouter(t, svc)
|
||||
repo := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true}
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
|
||||
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-group-42-recovery")
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
first := call()
|
||||
second := call()
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
require.Equal(t, "true", first.Header().Get("X-Idempotency-Recovered"))
|
||||
require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered"))
|
||||
require.Equal(t, 1, svc.calls, "ambiguous retries must not repeat the create side effect")
|
||||
require.Equal(t, 2, svc.recoverCalls)
|
||||
require.Equal(t, "admin:77", svc.recoverScope)
|
||||
require.Equal(t, "duplicate-group-42-recovery", svc.recoverKey)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUpdateGroupRequestReasoningEffortMappingsTriState(t *testing.T) {
|
||||
t.Run("omitted means unchanged", func(t *testing.T) {
|
||||
var req UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{}`), &req))
|
||||
require.Nil(t, req.ReasoningEffortMappings)
|
||||
})
|
||||
|
||||
t.Run("empty array means clear", func(t *testing.T) {
|
||||
var req UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"reasoning_effort_mappings":[]}`), &req))
|
||||
require.NotNil(t, req.ReasoningEffortMappings)
|
||||
require.Empty(t, *req.ReasoningEffortMappings)
|
||||
})
|
||||
|
||||
t.Run("non empty array means replace", func(t *testing.T) {
|
||||
var req UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"reasoning_effort_mappings":[{"from":"max","to":"xhigh"}]}`), &req))
|
||||
require.NotNil(t, req.ReasoningEffortMappings)
|
||||
require.Len(t, *req.ReasoningEffortMappings, 1)
|
||||
require.Equal(t, "max", (*req.ReasoningEffortMappings)[0].From)
|
||||
require.Equal(t, "xhigh", (*req.ReasoningEffortMappings)[0].To)
|
||||
})
|
||||
}
|
||||
@@ -37,14 +37,9 @@ func executeAdminIdempotent(
|
||||
return &service.IdempotencyExecuteResult{Data: data}, nil
|
||||
}
|
||||
|
||||
actorScope := "admin:0"
|
||||
if subject, ok := middleware2.GetAuthSubjectFromContext(c); ok {
|
||||
actorScope = "admin:" + strconv.FormatInt(subject.UserID, 10)
|
||||
}
|
||||
|
||||
return coordinator.Execute(c.Request.Context(), service.IdempotencyExecuteOptions{
|
||||
Scope: scope,
|
||||
ActorScope: actorScope,
|
||||
ActorScope: adminActorScope(c),
|
||||
Method: c.Request.Method,
|
||||
Route: c.FullPath(),
|
||||
IdempotencyKey: c.GetHeader("Idempotency-Key"),
|
||||
@@ -54,6 +49,14 @@ func executeAdminIdempotent(
|
||||
}, execute)
|
||||
}
|
||||
|
||||
func adminActorScope(c *gin.Context) string {
|
||||
actorScope := "admin:0"
|
||||
if subject, ok := middleware2.GetAuthSubjectFromContext(c); ok {
|
||||
actorScope = "admin:" + strconv.FormatInt(subject.UserID, 10)
|
||||
}
|
||||
return actorScope
|
||||
}
|
||||
|
||||
func executeAdminIdempotentJSON(
|
||||
c *gin.Context,
|
||||
scope string,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetAuthCacheInvalidationHealth exposes durable outbox lag and subscriber health.
|
||||
func (h *OpsHandler) GetAuthCacheInvalidationHealth(c *gin.Context) {
|
||||
if h.opsService == nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
|
||||
return
|
||||
}
|
||||
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, h.opsService.GetAuthCacheInvalidationHealth(c.Request.Context()))
|
||||
}
|
||||
@@ -386,8 +386,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) {
|
||||
filter.EndTime = &endTime
|
||||
}
|
||||
filter.View = "all"
|
||||
filter.Phase = "upstream"
|
||||
// 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。
|
||||
filter.ErrorPhasesAny = []string{"upstream", "account_auth"}
|
||||
// Provider-health list includes recovered inference and credential rows.
|
||||
filter.IncludeRecoveredUpstream = true
|
||||
filter.Owner = "provider"
|
||||
filter.Source = strings.TrimSpace(c.Query("error_source"))
|
||||
@@ -470,8 +470,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) {
|
||||
}
|
||||
|
||||
filter.View = parseOpsViewParam(c)
|
||||
filter.Phase = "upstream"
|
||||
// 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。
|
||||
filter.ErrorPhasesAny = []string{"upstream", "account_auth"}
|
||||
// Provider-health list includes recovered inference and credential rows.
|
||||
filter.IncludeRecoveredUpstream = true
|
||||
filter.Owner = "provider"
|
||||
filter.Source = strings.TrimSpace(c.Query("error_source"))
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var ingressRejectReasons = map[string]struct{}{
|
||||
"query_api_key_deprecated": {}, "api_key_required": {}, "invalid_api_key": {},
|
||||
"invalid_auth_rate_limited": {},
|
||||
"api_key_auth_overloaded": {},
|
||||
"api_key_disabled": {}, "ip_restricted": {}, "user_inactive": {}, "group_deleted": {},
|
||||
"group_disabled": {}, "group_not_allowed": {}, "group_unassigned": {}, "other": {},
|
||||
}
|
||||
|
||||
var ingressRejectRouteFamilies = map[string]struct{}{
|
||||
"antigravity": {}, "gemini": {}, "codex": {}, "messages": {}, "responses": {},
|
||||
"chat_completions": {}, "images": {}, "videos": {}, "embeddings": {}, "models": {}, "other": {},
|
||||
}
|
||||
|
||||
var ingressRejectProtocols = map[string]struct{}{
|
||||
"google": {}, "anthropic": {}, "openai": {}, "gateway": {}, "other": {},
|
||||
}
|
||||
|
||||
// ListIngressRejects returns bounded security aggregates, never raw credentials or request bodies.
|
||||
func (h *OpsHandler) ListIngressRejects(c *gin.Context) {
|
||||
if h.opsService == nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
|
||||
return
|
||||
}
|
||||
page, pageSize := response.ParsePagination(c)
|
||||
if pageSize > 200 {
|
||||
pageSize = 200
|
||||
}
|
||||
startTime, endTime, err := parseOpsTimeRange(c, "1h")
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
filter := &service.OpsIngressRejectFilter{Page: page, PageSize: pageSize}
|
||||
if !startTime.IsZero() {
|
||||
filter.StartTime = &startTime
|
||||
}
|
||||
if !endTime.IsZero() {
|
||||
filter.EndTime = &endTime
|
||||
}
|
||||
if filter.RejectReason, err = parseIngressRejectEnum(c, "reason", ingressRejectReasons); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
if filter.RouteFamily, err = parseIngressRejectEnum(c, "route_family", ingressRejectRouteFamilies); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
if filter.Protocol, err = parseIngressRejectEnum(c, "protocol", ingressRejectProtocols); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
if raw := strings.TrimSpace(c.Query("client_ip")); raw != "" {
|
||||
addr, parseErr := netip.ParseAddr(raw)
|
||||
if parseErr != nil {
|
||||
response.BadRequest(c, "Invalid client_ip")
|
||||
return
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
if addr.Is6() {
|
||||
addr = netip.PrefixFrom(addr, 64).Masked().Addr()
|
||||
}
|
||||
filter.ClientIP = addr.String()
|
||||
}
|
||||
if filter.UserID, err = parseOptionalPositiveID(c, "user_id"); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
if filter.APIKeyID, err = parseOptionalPositiveID(c, "api_key_id"); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.opsService.ListIngressRejects(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *OpsHandler) GetIngressRejectHealth(c *gin.Context) {
|
||||
if h.opsService == nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
|
||||
return
|
||||
}
|
||||
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, h.opsService.GetIngressRejectHealth())
|
||||
}
|
||||
|
||||
func parseIngressRejectEnum(c *gin.Context, name string, allowed map[string]struct{}) (string, error) {
|
||||
value := strings.TrimSpace(c.Query(name))
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if _, ok := allowed[value]; !ok {
|
||||
return "", &ingressRejectQueryError{message: "Invalid " + name}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseOptionalPositiveID(c *gin.Context, name string) (*int64, error) {
|
||||
raw := strings.TrimSpace(c.Query(name))
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || value <= 0 {
|
||||
return nil, &ingressRejectQueryError{message: "Invalid " + name}
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
type ingressRejectQueryError struct{ message string }
|
||||
|
||||
func (e *ingressRejectQueryError) Error() string { return e.message }
|
||||
@@ -0,0 +1,41 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newIngressRejectHandlerForTest() *OpsHandler {
|
||||
return NewOpsHandler(service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil))
|
||||
}
|
||||
|
||||
func TestListIngressRejectsValidatesFilters(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, path := range []string{
|
||||
"/api/v1/admin/ops/ingress-rejections?reason=not-valid",
|
||||
"/api/v1/admin/ops/ingress-rejections?client_ip=not-an-ip",
|
||||
"/api/v1/admin/ops/ingress-rejections?user_id=0",
|
||||
"/api/v1/admin/ops/ingress-rejections?api_key_id=-1",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
context, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
context.Request = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
newIngressRejectHandlerForTest().ListIngressRejects(context)
|
||||
require.Equal(t, http.StatusBadRequest, context.Writer.Status())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIngressRejectHealthAvailable(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
context.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/ops/ingress-rejections/health", nil)
|
||||
newIngressRejectHandlerForTest().GetIngressRejectHealth(context)
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), `"capacity":8192`)
|
||||
}
|
||||
@@ -59,6 +59,8 @@ type SettingHandler struct {
|
||||
paymentService *service.PaymentService
|
||||
userAttributeService *service.UserAttributeService
|
||||
notificationEmailService *service.NotificationEmailService
|
||||
totpService *service.TotpService
|
||||
userService *service.UserService
|
||||
}
|
||||
|
||||
// NewSettingHandler 创建系统设置处理器
|
||||
@@ -80,6 +82,15 @@ func (h *SettingHandler) SetNotificationEmailService(notificationEmailService *s
|
||||
h.notificationEmailService = notificationEmailService
|
||||
}
|
||||
|
||||
// SetStepUpDeps attaches the services backing the step-up switch preconditions
|
||||
// (enable requires the acting admin to have TOTP enabled; disable is itself a
|
||||
// step-up gated operation), without changing the constructor signature used by
|
||||
// existing unit tests.
|
||||
func (h *SettingHandler) SetStepUpDeps(totpService *service.TotpService, userService *service.UserService) {
|
||||
h.totpService = totpService
|
||||
h.userService = userService
|
||||
}
|
||||
|
||||
// GetSettings 获取所有系统设置
|
||||
// GET /api/v1/admin/settings
|
||||
func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
@@ -123,6 +134,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||
TotpEnabled: settings.TotpEnabled,
|
||||
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
|
||||
SessionBindingEnabled: settings.SessionBindingEnabled,
|
||||
StepUpEnabled: settings.StepUpEnabled,
|
||||
AuditLogRetentionDays: settings.AuditLogRetentionDays,
|
||||
LoginAgreementEnabled: settings.LoginAgreementEnabled,
|
||||
LoginAgreementMode: settings.LoginAgreementMode,
|
||||
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
|
||||
@@ -138,6 +152,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||
TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured,
|
||||
APIKeyACLTrustForwardedIP: settings.APIKeyACLTrustForwardedIP,
|
||||
ForwardedClientIPHeaders: settings.ForwardedClientIPHeaders,
|
||||
LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled,
|
||||
LinuxDoConnectClientID: settings.LinuxDoConnectClientID,
|
||||
LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured,
|
||||
@@ -229,6 +244,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours,
|
||||
AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays,
|
||||
AffiliateRebatePerInviteeCap: settings.AffiliateRebatePerInviteeCap,
|
||||
AdminRechargeRebateEnabled: settings.AdminRechargeRebateEnabled,
|
||||
DefaultUserRPMLimit: settings.DefaultUserRPMLimit,
|
||||
DefaultSubscriptions: defaultSubscriptions,
|
||||
EnableModelFallback: settings.EnableModelFallback,
|
||||
@@ -268,6 +284,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource,
|
||||
PaymentVisibleMethodAlipayEnabled: settings.PaymentVisibleMethodAlipayEnabled,
|
||||
PaymentVisibleMethodWxpayEnabled: settings.PaymentVisibleMethodWxpayEnabled,
|
||||
OpenAILowUpstreamRatePriorityEnabled: settings.OpenAILowUpstreamRatePriorityEnabled,
|
||||
OpenAIOAuthSchedulingRateMultiplier: settings.OpenAIOAuthSchedulingRateMultiplier,
|
||||
OpenAIAdvancedSchedulerEnabled: settings.OpenAIAdvancedSchedulerEnabled,
|
||||
OpenAIAdvancedSchedulerStickyWeightedEnabled: settings.OpenAIAdvancedSchedulerStickyWeightedEnabled,
|
||||
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled,
|
||||
@@ -279,6 +297,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
OpenAIAdvancedSchedulerWeightTTFT: settings.OpenAIAdvancedSchedulerWeightTTFT,
|
||||
OpenAIAdvancedSchedulerWeightReset: settings.OpenAIAdvancedSchedulerWeightReset,
|
||||
OpenAIAdvancedSchedulerWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom,
|
||||
OpenAIAdvancedSchedulerWeightUpstreamCost: settings.OpenAIAdvancedSchedulerWeightUpstreamCost,
|
||||
OpenAIAdvancedSchedulerWeightPreviousResponse: settings.OpenAIAdvancedSchedulerWeightPreviousResponse,
|
||||
OpenAIAdvancedSchedulerWeightSessionSticky: settings.OpenAIAdvancedSchedulerWeightSessionSticky,
|
||||
OpenAIAdvancedSchedulerEffectiveLBTopK: settings.OpenAIAdvancedSchedulerEffectiveLBTopK,
|
||||
@@ -289,6 +308,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
OpenAIAdvancedSchedulerEffectiveWeightTTFT: settings.OpenAIAdvancedSchedulerEffectiveWeightTTFT,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightReset: settings.OpenAIAdvancedSchedulerEffectiveWeightReset,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost: settings.OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse: settings.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightSessionSticky: settings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky,
|
||||
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
||||
|
||||
@@ -56,6 +56,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.TotpEnabled != after.TotpEnabled {
|
||||
changed = append(changed, "totp_enabled")
|
||||
}
|
||||
if before.SessionBindingEnabled != after.SessionBindingEnabled {
|
||||
changed = append(changed, "session_binding_enabled")
|
||||
}
|
||||
if before.StepUpEnabled != after.StepUpEnabled {
|
||||
changed = append(changed, "step_up_enabled")
|
||||
}
|
||||
if before.LoginAgreementEnabled != after.LoginAgreementEnabled {
|
||||
changed = append(changed, "login_agreement_enabled")
|
||||
}
|
||||
@@ -101,6 +107,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.APIKeyACLTrustForwardedIP != after.APIKeyACLTrustForwardedIP {
|
||||
changed = append(changed, "api_key_acl_trust_forwarded_ip")
|
||||
}
|
||||
if !equalStringSlice(before.ForwardedClientIPHeaders, after.ForwardedClientIPHeaders) {
|
||||
changed = append(changed, "forwarded_client_ip_headers")
|
||||
}
|
||||
if before.LinuxDoConnectEnabled != after.LinuxDoConnectEnabled {
|
||||
changed = append(changed, "linuxdo_connect_enabled")
|
||||
}
|
||||
@@ -308,6 +317,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.AffiliateRebatePerInviteeCap != after.AffiliateRebatePerInviteeCap {
|
||||
changed = append(changed, "affiliate_rebate_per_invitee_cap")
|
||||
}
|
||||
if before.AdminRechargeRebateEnabled != after.AdminRechargeRebateEnabled {
|
||||
changed = append(changed, "affiliate_admin_recharge_enabled")
|
||||
}
|
||||
if !equalDefaultSubscriptions(before.DefaultSubscriptions, after.DefaultSubscriptions) {
|
||||
changed = append(changed, "default_subscriptions")
|
||||
}
|
||||
@@ -437,6 +449,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.PaymentVisibleMethodWxpayEnabled != after.PaymentVisibleMethodWxpayEnabled {
|
||||
changed = append(changed, "payment_visible_method_wxpay_enabled")
|
||||
}
|
||||
if before.OpenAILowUpstreamRatePriorityEnabled != after.OpenAILowUpstreamRatePriorityEnabled {
|
||||
changed = append(changed, "openai_low_upstream_rate_priority_enabled")
|
||||
}
|
||||
if before.OpenAIOAuthSchedulingRateMultiplier != after.OpenAIOAuthSchedulingRateMultiplier {
|
||||
changed = append(changed, "openai_oauth_scheduling_rate_multiplier")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerEnabled != after.OpenAIAdvancedSchedulerEnabled {
|
||||
changed = append(changed, "openai_advanced_scheduler_enabled")
|
||||
}
|
||||
@@ -470,6 +488,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.OpenAIAdvancedSchedulerWeightQuotaHeadroom != after.OpenAIAdvancedSchedulerWeightQuotaHeadroom {
|
||||
changed = append(changed, "openai_advanced_scheduler_weight_quota_headroom")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerWeightUpstreamCost != after.OpenAIAdvancedSchedulerWeightUpstreamCost {
|
||||
changed = append(changed, "openai_advanced_scheduler_weight_upstream_cost")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerWeightPreviousResponse != after.OpenAIAdvancedSchedulerWeightPreviousResponse {
|
||||
changed = append(changed, "openai_advanced_scheduler_weight_previous_response")
|
||||
}
|
||||
|
||||
@@ -223,6 +223,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
|
||||
"payment_visible_method_alipay_enabled": true,
|
||||
"payment_visible_method_wxpay_enabled": false,
|
||||
"openai_advanced_scheduler_enabled": true,
|
||||
"openai_oauth_scheduling_rate_multiplier": 0.05,
|
||||
"openai_advanced_scheduler_subscription_priority_enabled": true,
|
||||
}
|
||||
rawBody, err := json.Marshal(body)
|
||||
@@ -241,6 +242,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
|
||||
require.Equal(t, "true", repo.values[service.SettingPaymentVisibleMethodAlipayEnabled])
|
||||
require.Equal(t, "false", repo.values[service.SettingPaymentVisibleMethodWxpayEnabled])
|
||||
require.Equal(t, "true", repo.values["openai_advanced_scheduler_enabled"])
|
||||
require.Equal(t, "0.05", repo.values[service.SettingKeyOpenAIOAuthSchedulingRateMultiplier])
|
||||
require.Equal(t, "true", repo.values[service.SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled])
|
||||
|
||||
var resp response.Response
|
||||
@@ -252,6 +254,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
|
||||
require.Equal(t, true, data["payment_visible_method_alipay_enabled"])
|
||||
require.Equal(t, false, data["payment_visible_method_wxpay_enabled"])
|
||||
require.Equal(t, true, data["openai_advanced_scheduler_enabled"])
|
||||
require.Equal(t, 0.05, data["openai_oauth_scheduling_rate_multiplier"])
|
||||
require.Equal(t, true, data["openai_advanced_scheduler_subscription_priority_enabled"])
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// step-up 开关转换的门控测试。
|
||||
// 测试环境不注入认证上下文/userService,因此一旦触发校验会以 401/403/500 中止;
|
||||
// 借此区分「触发了转换校验」与「直接放行到常规保存(200)」。
|
||||
|
||||
func newStepUpSwitchTestHandler(t *testing.T, stored map[string]string) (*SettingHandler, *settingHandlerRepoStub) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &settingHandlerRepoStub{values: stored}
|
||||
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
|
||||
return NewSettingHandler(svc, nil, nil, nil, nil, nil, nil), repo
|
||||
}
|
||||
|
||||
func doUpdateSettings(t *testing.T, h *SettingHandler, body map[string]any, prepare func(c *gin.Context)) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rawBody, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
if prepare != nil {
|
||||
prepare(c)
|
||||
}
|
||||
|
||||
h.UpdateSettings(c)
|
||||
return rec
|
||||
}
|
||||
|
||||
// 开启开关(false→true):无认证上下文时拒绝,且带专用错误标记。
|
||||
func TestUpdateSettingsEnableStepUpRejectsWithoutSession(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, nil)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "STEP_UP_ENABLE_REQUIRES_TOTP")
|
||||
require.NotEqual(t, "true", repo.values[service.SettingKeyStepUpEnabled])
|
||||
}
|
||||
|
||||
// 开启开关:admin API key(机器凭证)一律拒绝,reason 与门控保持一致便于前端分流。
|
||||
func TestUpdateSettingsEnableStepUpRejectsAdminAPIKey(t *testing.T) {
|
||||
h, _ := newStepUpSwitchTestHandler(t, map[string]string{})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, func(c *gin.Context) {
|
||||
c.Set("auth_method", service.AuditAuthMethodAdminAPIKey)
|
||||
})
|
||||
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_FORBIDDEN")
|
||||
}
|
||||
|
||||
// 开启开关:有认证会话但 userService 未注入时 fail-closed(500),不得放行。
|
||||
func TestUpdateSettingsEnableStepUpFailsClosedWithoutUserService(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, func(c *gin.Context) {
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 1})
|
||||
})
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
require.NotEqual(t, "true", repo.values[service.SettingKeyStepUpEnabled])
|
||||
}
|
||||
|
||||
// 关闭开关(true→false)本身是敏感操作:无认证上下文时被 step-up 门控以 401 拦截。
|
||||
func TestUpdateSettingsDisableStepUpRequiresStepUp(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeyStepUpEnabled: "true",
|
||||
})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": false}, nil)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled])
|
||||
}
|
||||
|
||||
// 关闭开关:admin API key 被 step-up 门控以 403 拦截。
|
||||
func TestUpdateSettingsDisableStepUpRejectsAdminAPIKey(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeyStepUpEnabled: "true",
|
||||
})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": false}, func(c *gin.Context) {
|
||||
c.Set("auth_method", service.AuditAuthMethodAdminAPIKey)
|
||||
})
|
||||
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_FORBIDDEN")
|
||||
require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled])
|
||||
}
|
||||
|
||||
// 无状态转换(false→false):不触发任何转换校验,常规保存成功且默认持久化为 false。
|
||||
func TestUpdateSettingsStepUpNoTransitionSkipsGate(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": false}, nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "false", repo.values[service.SettingKeyStepUpEnabled])
|
||||
// 会话 IP/UA 绑定默认关闭:未显式提交时持久化 false。
|
||||
require.Equal(t, "false", repo.values[service.SettingKeySessionBindingEnabled])
|
||||
}
|
||||
|
||||
// 保持开启(true→true):不触发转换校验,常规保存不被打断。
|
||||
func TestUpdateSettingsStepUpKeepEnabledSkipsGate(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeyStepUpEnabled: "true",
|
||||
})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled])
|
||||
}
|
||||
|
||||
// 省略字段=保持现值:不含 step_up_enabled/session_binding_enabled 的旧客户端全量保存
|
||||
// 不得把已开启的安全开关静默重置,也不触发任何转换门控。
|
||||
func TestUpdateSettingsOmittedSecuritySwitchesKeepStoredValues(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeyStepUpEnabled: "true",
|
||||
service.SettingKeySessionBindingEnabled: "true",
|
||||
})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled])
|
||||
require.Equal(t, "true", repo.values[service.SettingKeySessionBindingEnabled])
|
||||
}
|
||||
|
||||
// 省略字段在开关本就关闭时同样保持关闭(默认值路径)。
|
||||
func TestUpdateSettingsOmittedSecuritySwitchesKeepDisabled(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "false", repo.values[service.SettingKeyStepUpEnabled])
|
||||
require.Equal(t, "false", repo.values[service.SettingKeySessionBindingEnabled])
|
||||
}
|
||||
|
||||
func TestUpdateSettingsForwardedClientIPHeadersOmittedPreservesAndEmptyClears(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeyForwardedClientIPHeaders: `["X-Cdn-Ip","True-Client-Ip"]`,
|
||||
})
|
||||
|
||||
preserved := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil)
|
||||
require.Equal(t, http.StatusOK, preserved.Code)
|
||||
require.JSONEq(t, `["X-Cdn-Ip","True-Client-Ip"]`, repo.values[service.SettingKeyForwardedClientIPHeaders])
|
||||
require.Contains(t, preserved.Body.String(), `"forwarded_client_ip_headers":["X-Cdn-Ip","True-Client-Ip"]`)
|
||||
|
||||
cleared := doUpdateSettings(t, h, map[string]any{"forwarded_client_ip_headers": []string{}}, nil)
|
||||
require.Equal(t, http.StatusOK, cleared.Code)
|
||||
require.JSONEq(t, `[]`, repo.values[service.SettingKeyForwardedClientIPHeaders])
|
||||
require.Contains(t, cleared.Body.String(), `"forwarded_client_ip_headers":[]`)
|
||||
}
|
||||
|
||||
func TestUpdateSettingsMalformedForwardedClientIPHeadersRemainFailClosedWhenOmitted(t *testing.T) {
|
||||
cfg := &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}
|
||||
repo := &settingHandlerRepoStub{values: map[string]string{
|
||||
service.SettingKeyAPIKeyACLTrustForwardedIP: "true",
|
||||
service.SettingKeyForwardedClientIPHeaders: `{"not":"an array"}`,
|
||||
}}
|
||||
svc := service.NewSettingService(repo, cfg)
|
||||
require.ErrorContains(t, svc.LoadForwardedClientIPSettings(context.Background()), "load forwarded client ip headers")
|
||||
require.False(t, cfg.ForwardedClientIPSettings().TrustForwardedIP)
|
||||
h := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "false", repo.values[service.SettingKeyAPIKeyACLTrustForwardedIP])
|
||||
require.JSONEq(t, `[]`, repo.values[service.SettingKeyForwardedClientIPHeaders])
|
||||
runtimeSettings := cfg.ForwardedClientIPSettings()
|
||||
require.False(t, runtimeSettings.TrustForwardedIP)
|
||||
require.Empty(t, runtimeSettings.Headers)
|
||||
require.Contains(t, rec.Body.String(), `"api_key_acl_trust_forwarded_ip":false`)
|
||||
require.Contains(t, rec.Body.String(), `"forwarded_client_ip_headers":[]`)
|
||||
}
|
||||
|
||||
func TestUpdateSettingsRejectsInvalidForwardedClientIPHeader(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeyForwardedClientIPHeaders: `["X-Existing-IP"]`,
|
||||
})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{
|
||||
"forwarded_client_ip_headers": []string{"X Invalid"},
|
||||
}, nil)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.JSONEq(t, `["X-Existing-IP"]`, repo.values[service.SettingKeyForwardedClientIPHeaders])
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -26,7 +27,10 @@ type UpdateSettingsRequest struct {
|
||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||
FrontendURL string `json:"frontend_url"`
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||
SessionBindingEnabled *bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定(省略=保持现值)
|
||||
StepUpEnabled *bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA(省略=保持现值)
|
||||
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
|
||||
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
|
||||
LoginAgreementMode string `json:"login_agreement_mode"`
|
||||
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
|
||||
@@ -47,7 +51,8 @@ type UpdateSettingsRequest struct {
|
||||
TurnstileSecretKey string `json:"turnstile_secret_key"`
|
||||
|
||||
// API Key IP 访问控制设置
|
||||
APIKeyACLTrustForwardedIP *bool `json:"api_key_acl_trust_forwarded_ip"`
|
||||
APIKeyACLTrustForwardedIP *bool `json:"api_key_acl_trust_forwarded_ip"`
|
||||
ForwardedClientIPHeaders *[]string `json:"forwarded_client_ip_headers"`
|
||||
|
||||
// LinuxDo Connect OAuth 登录
|
||||
LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"`
|
||||
@@ -149,6 +154,7 @@ type UpdateSettingsRequest struct {
|
||||
AffiliateRebateFreezeHours *int `json:"affiliate_rebate_freeze_hours"`
|
||||
AffiliateRebateDurationDays *int `json:"affiliate_rebate_duration_days"`
|
||||
AffiliateRebatePerInviteeCap *float64 `json:"affiliate_rebate_per_invitee_cap"`
|
||||
AdminRechargeRebateEnabled *bool `json:"affiliate_admin_recharge_enabled"`
|
||||
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
|
||||
DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"`
|
||||
AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"`
|
||||
@@ -242,19 +248,22 @@ type UpdateSettingsRequest struct {
|
||||
PaymentVisibleMethodWxpayEnabled *bool `json:"payment_visible_method_wxpay_enabled"`
|
||||
|
||||
// OpenAI account scheduling
|
||||
OpenAIAdvancedSchedulerEnabled *bool `json:"openai_advanced_scheduler_enabled"`
|
||||
OpenAIAdvancedSchedulerStickyWeightedEnabled *bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"`
|
||||
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled *bool `json:"openai_advanced_scheduler_subscription_priority_enabled"`
|
||||
OpenAIAdvancedSchedulerLBTopK *string `json:"openai_advanced_scheduler_lb_top_k"`
|
||||
OpenAIAdvancedSchedulerWeightPriority *string `json:"openai_advanced_scheduler_weight_priority"`
|
||||
OpenAIAdvancedSchedulerWeightLoad *string `json:"openai_advanced_scheduler_weight_load"`
|
||||
OpenAIAdvancedSchedulerWeightQueue *string `json:"openai_advanced_scheduler_weight_queue"`
|
||||
OpenAIAdvancedSchedulerWeightErrorRate *string `json:"openai_advanced_scheduler_weight_error_rate"`
|
||||
OpenAIAdvancedSchedulerWeightTTFT *string `json:"openai_advanced_scheduler_weight_ttft"`
|
||||
OpenAIAdvancedSchedulerWeightReset *string `json:"openai_advanced_scheduler_weight_reset"`
|
||||
OpenAIAdvancedSchedulerWeightQuotaHeadroom *string `json:"openai_advanced_scheduler_weight_quota_headroom"`
|
||||
OpenAIAdvancedSchedulerWeightPreviousResponse *string `json:"openai_advanced_scheduler_weight_previous_response"`
|
||||
OpenAIAdvancedSchedulerWeightSessionSticky *string `json:"openai_advanced_scheduler_weight_session_sticky"`
|
||||
OpenAILowUpstreamRatePriorityEnabled *bool `json:"openai_low_upstream_rate_priority_enabled"`
|
||||
OpenAIOAuthSchedulingRateMultiplier *float64 `json:"openai_oauth_scheduling_rate_multiplier"`
|
||||
OpenAIAdvancedSchedulerEnabled *bool `json:"openai_advanced_scheduler_enabled"`
|
||||
OpenAIAdvancedSchedulerStickyWeightedEnabled *bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"`
|
||||
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled *bool `json:"openai_advanced_scheduler_subscription_priority_enabled"`
|
||||
OpenAIAdvancedSchedulerLBTopK *string `json:"openai_advanced_scheduler_lb_top_k"`
|
||||
OpenAIAdvancedSchedulerWeightPriority *string `json:"openai_advanced_scheduler_weight_priority"`
|
||||
OpenAIAdvancedSchedulerWeightLoad *string `json:"openai_advanced_scheduler_weight_load"`
|
||||
OpenAIAdvancedSchedulerWeightQueue *string `json:"openai_advanced_scheduler_weight_queue"`
|
||||
OpenAIAdvancedSchedulerWeightErrorRate *string `json:"openai_advanced_scheduler_weight_error_rate"`
|
||||
OpenAIAdvancedSchedulerWeightTTFT *string `json:"openai_advanced_scheduler_weight_ttft"`
|
||||
OpenAIAdvancedSchedulerWeightReset *string `json:"openai_advanced_scheduler_weight_reset"`
|
||||
OpenAIAdvancedSchedulerWeightQuotaHeadroom *string `json:"openai_advanced_scheduler_weight_quota_headroom"`
|
||||
OpenAIAdvancedSchedulerWeightUpstreamCost *string `json:"openai_advanced_scheduler_weight_upstream_cost"`
|
||||
OpenAIAdvancedSchedulerWeightPreviousResponse *string `json:"openai_advanced_scheduler_weight_previous_response"`
|
||||
OpenAIAdvancedSchedulerWeightSessionSticky *string `json:"openai_advanced_scheduler_weight_session_sticky"`
|
||||
|
||||
// 余额不足提醒
|
||||
BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"`
|
||||
@@ -329,6 +338,41 @@ type UpdateSettingsRequest struct {
|
||||
|
||||
// UpdateSettings 更新系统设置
|
||||
// PUT /api/v1/admin/settings
|
||||
// ensureActorTotpForStepUp 校验当前操作者具备开启 step-up 门控的条件:
|
||||
// 必须是真人管理员会话(admin API key 无法完成 TOTP step-up,拒绝)且本人已启用 TOTP。
|
||||
// 校验失败时写入错误响应并返回 false。
|
||||
func (h *SettingHandler) ensureActorTotpForStepUp(c *gin.Context) bool {
|
||||
if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey {
|
||||
response.ErrorWithDetails(c, http.StatusForbidden,
|
||||
"Admin API key cannot enable step-up verification; use an admin session with TOTP enabled",
|
||||
"STEP_UP_ADMIN_API_KEY_FORBIDDEN", nil)
|
||||
return false
|
||||
}
|
||||
subject, ok := middleware.GetAuthSubjectFromContext(c)
|
||||
if !ok || subject.UserID <= 0 {
|
||||
response.ErrorWithDetails(c, http.StatusForbidden,
|
||||
"Enabling step-up verification requires an authenticated admin session",
|
||||
"STEP_UP_ENABLE_REQUIRES_TOTP", nil)
|
||||
return false
|
||||
}
|
||||
if h.userService == nil {
|
||||
response.InternalError(c, "Step-up precondition check unavailable")
|
||||
return false
|
||||
}
|
||||
user, err := h.userService.GetByID(c.Request.Context(), subject.UserID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return false
|
||||
}
|
||||
if !user.TotpEnabled {
|
||||
response.ErrorWithDetails(c, http.StatusBadRequest,
|
||||
"Enable two-factor authentication (TOTP) for your account before turning on step-up verification",
|
||||
"STEP_UP_ENABLE_REQUIRES_TOTP", nil)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
var req UpdateSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -347,6 +391,38 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 两个安全开关的请求字段为指针:省略字段=保持现值,避免旧客户端/脚本
|
||||
// 用不含新字段的全量 payload 保存设置时把安全开关静默重置。
|
||||
sessionBindingEnabled := previousSettings.SessionBindingEnabled
|
||||
if req.SessionBindingEnabled != nil {
|
||||
sessionBindingEnabled = *req.SessionBindingEnabled
|
||||
}
|
||||
stepUpEnabled := previousSettings.StepUpEnabled
|
||||
if req.StepUpEnabled != nil {
|
||||
stepUpEnabled = *req.StepUpEnabled
|
||||
}
|
||||
forwardedClientIPHeaders := append([]string(nil), previousSettings.ForwardedClientIPHeaders...)
|
||||
if req.ForwardedClientIPHeaders != nil {
|
||||
forwardedClientIPHeaders = append([]string(nil), (*req.ForwardedClientIPHeaders)...)
|
||||
}
|
||||
|
||||
// 开启敏感操作 step-up 门控属自锁风险操作:仅允许本人已启用 TOTP 的管理员会话开启,
|
||||
// 否则开启后操作者立即被挡在所有敏感操作之外。仅在 false→true 的开启瞬间校验,
|
||||
// 保持开启状态的常规设置保存不受影响。
|
||||
if stepUpEnabled && !previousSettings.StepUpEnabled {
|
||||
if !h.ensureActorTotpForStepUp(c) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// 关闭 step-up 门控本身就是敏感操作:防止拿到管理员会话的攻击者先关闸再执行导出/备份。
|
||||
// previousSettings 已证实开关处于开启状态,使用无条件门控变体,
|
||||
// 避免门控内部二次读取开关时因存储故障 fail-open(前端捕获 STEP_UP_REQUIRED 弹码重试)。
|
||||
if !stepUpEnabled && previousSettings.StepUpEnabled {
|
||||
if !middleware.EnforceStepUpAlways(c, h.totpService, h.userService) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if req.DefaultConcurrency < 1 {
|
||||
req.DefaultConcurrency = 1
|
||||
@@ -391,6 +467,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
if affiliateRebatePerInviteeCap < 0 {
|
||||
affiliateRebatePerInviteeCap = service.AffiliateRebatePerInviteeCapDefault
|
||||
}
|
||||
adminRechargeRebateEnabled := previousSettings.AdminRechargeRebateEnabled
|
||||
if req.AdminRechargeRebateEnabled != nil {
|
||||
adminRechargeRebateEnabled = *req.AdminRechargeRebateEnabled
|
||||
}
|
||||
// 通用表格配置:兼容旧客户端未传字段时保留当前值。
|
||||
if req.TableDefaultPageSize <= 0 {
|
||||
req.TableDefaultPageSize = previousSettings.TableDefaultPageSize
|
||||
@@ -1171,6 +1251,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
FrontendURL: req.FrontendURL,
|
||||
InvitationCodeEnabled: req.InvitationCodeEnabled,
|
||||
TotpEnabled: req.TotpEnabled,
|
||||
SessionBindingEnabled: sessionBindingEnabled,
|
||||
StepUpEnabled: stepUpEnabled,
|
||||
AuditLogRetentionDays: req.AuditLogRetentionDays,
|
||||
LoginAgreementEnabled: req.LoginAgreementEnabled,
|
||||
LoginAgreementMode: loginAgreementMode,
|
||||
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
|
||||
@@ -1191,6 +1274,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.APIKeyACLTrustForwardedIP
|
||||
}(),
|
||||
ForwardedClientIPHeaders: forwardedClientIPHeaders,
|
||||
LinuxDoConnectEnabled: req.LinuxDoConnectEnabled,
|
||||
LinuxDoConnectClientID: req.LinuxDoConnectClientID,
|
||||
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
|
||||
@@ -1279,6 +1363,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
AffiliateRebateFreezeHours: affiliateRebateFreezeHours,
|
||||
AffiliateRebateDurationDays: affiliateRebateDurationDays,
|
||||
AffiliateRebatePerInviteeCap: affiliateRebatePerInviteeCap,
|
||||
AdminRechargeRebateEnabled: adminRechargeRebateEnabled,
|
||||
DefaultUserRPMLimit: req.DefaultUserRPMLimit,
|
||||
DefaultSubscriptions: defaultSubscriptions,
|
||||
EnableModelFallback: req.EnableModelFallback,
|
||||
@@ -1423,6 +1508,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.PaymentVisibleMethodWxpayEnabled
|
||||
}(),
|
||||
OpenAILowUpstreamRatePriorityEnabled: func() bool {
|
||||
if req.OpenAILowUpstreamRatePriorityEnabled != nil {
|
||||
return *req.OpenAILowUpstreamRatePriorityEnabled
|
||||
}
|
||||
return previousSettings.OpenAILowUpstreamRatePriorityEnabled
|
||||
}(),
|
||||
OpenAIOAuthSchedulingRateMultiplier: func() float64 {
|
||||
if req.OpenAIOAuthSchedulingRateMultiplier != nil {
|
||||
return *req.OpenAIOAuthSchedulingRateMultiplier
|
||||
}
|
||||
return previousSettings.OpenAIOAuthSchedulingRateMultiplier
|
||||
}(),
|
||||
OpenAIAdvancedSchedulerEnabled: func() bool {
|
||||
if req.OpenAIAdvancedSchedulerEnabled != nil {
|
||||
return *req.OpenAIAdvancedSchedulerEnabled
|
||||
@@ -1449,6 +1546,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
OpenAIAdvancedSchedulerWeightTTFT: stringSetting(req.OpenAIAdvancedSchedulerWeightTTFT, previousSettings.OpenAIAdvancedSchedulerWeightTTFT),
|
||||
OpenAIAdvancedSchedulerWeightReset: stringSetting(req.OpenAIAdvancedSchedulerWeightReset, previousSettings.OpenAIAdvancedSchedulerWeightReset),
|
||||
OpenAIAdvancedSchedulerWeightQuotaHeadroom: stringSetting(req.OpenAIAdvancedSchedulerWeightQuotaHeadroom, previousSettings.OpenAIAdvancedSchedulerWeightQuotaHeadroom),
|
||||
OpenAIAdvancedSchedulerWeightUpstreamCost: stringSetting(req.OpenAIAdvancedSchedulerWeightUpstreamCost, previousSettings.OpenAIAdvancedSchedulerWeightUpstreamCost),
|
||||
OpenAIAdvancedSchedulerWeightPreviousResponse: stringSetting(req.OpenAIAdvancedSchedulerWeightPreviousResponse, previousSettings.OpenAIAdvancedSchedulerWeightPreviousResponse),
|
||||
OpenAIAdvancedSchedulerWeightSessionSticky: stringSetting(req.OpenAIAdvancedSchedulerWeightSessionSticky, previousSettings.OpenAIAdvancedSchedulerWeightSessionSticky),
|
||||
BalanceLowNotifyEnabled: func() bool {
|
||||
@@ -1596,6 +1694,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if h.opsService != nil {
|
||||
h.opsService.SetMonitoringEnabled(settings.OpsMonitoringEnabled)
|
||||
}
|
||||
|
||||
// Update OpenAI fast policy (stored under dedicated key, only when provided).
|
||||
if req.OpenAIFastPolicySettings != nil {
|
||||
@@ -1683,6 +1784,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
|
||||
TotpEnabled: updatedSettings.TotpEnabled,
|
||||
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
|
||||
SessionBindingEnabled: updatedSettings.SessionBindingEnabled,
|
||||
StepUpEnabled: updatedSettings.StepUpEnabled,
|
||||
AuditLogRetentionDays: updatedSettings.AuditLogRetentionDays,
|
||||
LoginAgreementEnabled: updatedSettings.LoginAgreementEnabled,
|
||||
LoginAgreementMode: updatedSettings.LoginAgreementMode,
|
||||
LoginAgreementUpdatedAt: updatedSettings.LoginAgreementUpdatedAt,
|
||||
@@ -1698,6 +1802,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
TurnstileSiteKey: updatedSettings.TurnstileSiteKey,
|
||||
TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured,
|
||||
APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP,
|
||||
ForwardedClientIPHeaders: updatedSettings.ForwardedClientIPHeaders,
|
||||
LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled,
|
||||
LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID,
|
||||
LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured,
|
||||
@@ -1786,6 +1891,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
AffiliateRebateFreezeHours: updatedSettings.AffiliateRebateFreezeHours,
|
||||
AffiliateRebateDurationDays: updatedSettings.AffiliateRebateDurationDays,
|
||||
AffiliateRebatePerInviteeCap: updatedSettings.AffiliateRebatePerInviteeCap,
|
||||
AdminRechargeRebateEnabled: updatedSettings.AdminRechargeRebateEnabled,
|
||||
DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit,
|
||||
DefaultSubscriptions: updatedDefaultSubscriptions,
|
||||
EnableModelFallback: updatedSettings.EnableModelFallback,
|
||||
@@ -1824,6 +1930,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource,
|
||||
PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled,
|
||||
PaymentVisibleMethodWxpayEnabled: updatedSettings.PaymentVisibleMethodWxpayEnabled,
|
||||
OpenAILowUpstreamRatePriorityEnabled: updatedSettings.OpenAILowUpstreamRatePriorityEnabled,
|
||||
OpenAIOAuthSchedulingRateMultiplier: updatedSettings.OpenAIOAuthSchedulingRateMultiplier,
|
||||
OpenAIAdvancedSchedulerEnabled: updatedSettings.OpenAIAdvancedSchedulerEnabled,
|
||||
OpenAIAdvancedSchedulerStickyWeightedEnabled: updatedSettings.OpenAIAdvancedSchedulerStickyWeightedEnabled,
|
||||
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: updatedSettings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled,
|
||||
@@ -1835,6 +1943,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
OpenAIAdvancedSchedulerWeightTTFT: updatedSettings.OpenAIAdvancedSchedulerWeightTTFT,
|
||||
OpenAIAdvancedSchedulerWeightReset: updatedSettings.OpenAIAdvancedSchedulerWeightReset,
|
||||
OpenAIAdvancedSchedulerWeightQuotaHeadroom: updatedSettings.OpenAIAdvancedSchedulerWeightQuotaHeadroom,
|
||||
OpenAIAdvancedSchedulerWeightUpstreamCost: updatedSettings.OpenAIAdvancedSchedulerWeightUpstreamCost,
|
||||
OpenAIAdvancedSchedulerWeightPreviousResponse: updatedSettings.OpenAIAdvancedSchedulerWeightPreviousResponse,
|
||||
OpenAIAdvancedSchedulerWeightSessionSticky: updatedSettings.OpenAIAdvancedSchedulerWeightSessionSticky,
|
||||
OpenAIAdvancedSchedulerEffectiveLBTopK: updatedSettings.OpenAIAdvancedSchedulerEffectiveLBTopK,
|
||||
@@ -1845,6 +1954,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
OpenAIAdvancedSchedulerEffectiveWeightTTFT: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightTTFT,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightReset: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightReset,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightSessionSticky: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky,
|
||||
BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled,
|
||||
|
||||
@@ -22,6 +22,27 @@ type SystemHandler struct {
|
||||
lockSvc *service.SystemOperationLockService
|
||||
}
|
||||
|
||||
// systemUpdateTimeout bounds a full in-place update or rollback: the release
|
||||
// manifest fetch plus a large binary download over slow links. It must stay
|
||||
// above the GitHub download client timeout (10 minutes) so the download owns
|
||||
// its own deadline.
|
||||
const systemUpdateTimeout = 15 * time.Minute
|
||||
|
||||
// systemUpdateContext detaches a long-running update/rollback from the HTTP
|
||||
// request lifetime. Browsers and reverse proxies commonly abort idle requests
|
||||
// after 30-60s (axios default, nginx proxy_read_timeout), which canceled
|
||||
// c.Request.Context() mid-download and killed the update with
|
||||
// "download failed: context canceled" (#4504). The swap keeps running after a
|
||||
// client disconnect; a later retry then hits the system operation lock or
|
||||
// reports "Already up to date".
|
||||
func systemUpdateContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
base := context.Background()
|
||||
if ctx != nil {
|
||||
base = context.WithoutCancel(ctx)
|
||||
}
|
||||
return context.WithTimeout(base, systemUpdateTimeout)
|
||||
}
|
||||
|
||||
type systemUpdateService interface {
|
||||
CheckUpdate(ctx context.Context, force bool) (*service.UpdateInfo, error)
|
||||
PerformUpdate(ctx context.Context) error
|
||||
@@ -75,9 +96,12 @@ func (h *SystemHandler) PerformUpdate(c *gin.Context) {
|
||||
release(releaseReason, succeeded)
|
||||
}()
|
||||
|
||||
if err := h.updateSvc.PerformUpdate(ctx); err != nil {
|
||||
updateCtx, cancel := systemUpdateContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
if err := h.updateSvc.PerformUpdate(updateCtx); err != nil {
|
||||
if errors.Is(err, service.ErrNoUpdateAvailable) {
|
||||
info, checkErr := h.updateSvc.CheckUpdate(ctx, false)
|
||||
info, checkErr := h.updateSvc.CheckUpdate(updateCtx, false)
|
||||
if checkErr != nil {
|
||||
releaseReason = "SYSTEM_UPDATE_FAILED"
|
||||
return nil, checkErr
|
||||
@@ -152,7 +176,10 @@ func (h *SystemHandler) Rollback(c *gin.Context) {
|
||||
}()
|
||||
|
||||
if targetVersion != "" {
|
||||
err = h.updateSvc.RollbackToVersion(ctx, targetVersion)
|
||||
// 指定版本回退同样要下载完整二进制,与更新一样和请求生命周期解耦。
|
||||
rollbackCtx, cancel := systemUpdateContext(ctx)
|
||||
defer cancel()
|
||||
err = h.updateSvc.RollbackToVersion(rollbackCtx, targetVersion)
|
||||
} else {
|
||||
err = h.updateSvc.Rollback()
|
||||
}
|
||||
|
||||
@@ -18,18 +18,22 @@ import (
|
||||
)
|
||||
|
||||
type systemHandlerUpdateServiceStub struct {
|
||||
performErr error
|
||||
updateInfo *service.UpdateInfo
|
||||
checkErr error
|
||||
checkForces []bool
|
||||
performCall int
|
||||
rollbackCall int
|
||||
rollbackToCall int
|
||||
rollbackToVersions []string
|
||||
rollbackToErr error
|
||||
rollbackVersions []service.RollbackVersion
|
||||
rollbackVersionsErr error
|
||||
rollbackVersionsCall int
|
||||
performErr error
|
||||
updateInfo *service.UpdateInfo
|
||||
checkErr error
|
||||
checkForces []bool
|
||||
performCall int
|
||||
performCtxErr error
|
||||
performHasDeadline bool
|
||||
rollbackCall int
|
||||
rollbackToCall int
|
||||
rollbackToCtxErr error
|
||||
rollbackToHasDeadline bool
|
||||
rollbackToVersions []string
|
||||
rollbackToErr error
|
||||
rollbackVersions []service.RollbackVersion
|
||||
rollbackVersionsErr error
|
||||
rollbackVersionsCall int
|
||||
}
|
||||
|
||||
func (s *systemHandlerUpdateServiceStub) CheckUpdate(_ context.Context, force bool) (*service.UpdateInfo, error) {
|
||||
@@ -37,8 +41,10 @@ func (s *systemHandlerUpdateServiceStub) CheckUpdate(_ context.Context, force bo
|
||||
return s.updateInfo, s.checkErr
|
||||
}
|
||||
|
||||
func (s *systemHandlerUpdateServiceStub) PerformUpdate(context.Context) error {
|
||||
func (s *systemHandlerUpdateServiceStub) PerformUpdate(ctx context.Context) error {
|
||||
s.performCall++
|
||||
s.performCtxErr = ctx.Err()
|
||||
_, s.performHasDeadline = ctx.Deadline()
|
||||
return s.performErr
|
||||
}
|
||||
|
||||
@@ -52,8 +58,10 @@ func (s *systemHandlerUpdateServiceStub) ListRollbackVersions(context.Context) (
|
||||
return s.rollbackVersions, s.rollbackVersionsErr
|
||||
}
|
||||
|
||||
func (s *systemHandlerUpdateServiceStub) RollbackToVersion(_ context.Context, version string) error {
|
||||
func (s *systemHandlerUpdateServiceStub) RollbackToVersion(ctx context.Context, version string) error {
|
||||
s.rollbackToCall++
|
||||
s.rollbackToCtxErr = ctx.Err()
|
||||
_, s.rollbackToHasDeadline = ctx.Deadline()
|
||||
s.rollbackToVersions = append(s.rollbackToVersions, version)
|
||||
return s.rollbackToErr
|
||||
}
|
||||
@@ -165,6 +173,55 @@ func TestSystemHandlerPerformUpdateFailureStillReturnsInternalError(t *testing.T
|
||||
require.Equal(t, "internal error", body.Message)
|
||||
}
|
||||
|
||||
// TestSystemHandlerPerformUpdateSurvivesClientDisconnect reproduces #4504:
|
||||
// the browser or a reverse proxy (axios 30s default, nginx proxy_read_timeout
|
||||
// 60s) aborts the long-running update request and cancels the request
|
||||
// context. The download must keep running on a detached, bounded context
|
||||
// instead of dying with "download failed: context canceled".
|
||||
func TestSystemHandlerPerformUpdateSurvivesClientDisconnect(t *testing.T) {
|
||||
updateSvc := &systemHandlerUpdateServiceStub{}
|
||||
repo := newMemoryIdempotencyRepoStub()
|
||||
router := newSystemHandlerTestRouter(t, updateSvc, repo)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/update", nil)
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
req = req.WithContext(canceledCtx)
|
||||
req.Header.Set("Idempotency-Key", "disconnected-update")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, 1, updateSvc.performCall)
|
||||
require.NoError(t, updateSvc.performCtxErr,
|
||||
"update must not observe the canceled request context")
|
||||
require.True(t, updateSvc.performHasDeadline,
|
||||
"detached update context must still be bounded by a deadline")
|
||||
requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
|
||||
}
|
||||
|
||||
func TestSystemHandlerRollbackToVersionSurvivesClientDisconnect(t *testing.T) {
|
||||
updateSvc := &systemHandlerUpdateServiceStub{}
|
||||
repo := newMemoryIdempotencyRepoStub()
|
||||
router := newSystemHandlerTestRouter(t, updateSvc, repo)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback",
|
||||
strings.NewReader(`{"version":"0.1.146"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
req = req.WithContext(canceledCtx)
|
||||
req.Header.Set("Idempotency-Key", "disconnected-rollback")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, 1, updateSvc.rollbackToCall)
|
||||
require.NoError(t, updateSvc.rollbackToCtxErr,
|
||||
"versioned rollback must not observe the canceled request context")
|
||||
require.True(t, updateSvc.rollbackToHasDeadline,
|
||||
"detached rollback context must still be bounded by a deadline")
|
||||
requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
|
||||
}
|
||||
|
||||
func TestSystemHandlerRollbackWithoutBodyUsesLegacyBackup(t *testing.T) {
|
||||
updateSvc := &systemHandlerUpdateServiceStub{}
|
||||
repo := newMemoryIdempotencyRepoStub()
|
||||
|
||||
@@ -172,18 +172,19 @@ func (h *UsageHandler) List(c *gin.Context) {
|
||||
SortOrder: c.DefaultQuery("sort_order", "desc"),
|
||||
}
|
||||
filters := usagestats.UsageLogFilters{
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
Model: model,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
ExactTotal: exactTotal,
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
Model: model,
|
||||
ModelFilterSource: usagestats.ModelSourceRequested,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
ExactTotal: exactTotal,
|
||||
}
|
||||
|
||||
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
|
||||
@@ -312,17 +313,18 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
||||
|
||||
// Build filters and call GetStatsWithFilters
|
||||
filters := usagestats.UsageLogFilters{
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
Model: model,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
StartTime: &startTime,
|
||||
EndTime: &endTime,
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
Model: model,
|
||||
ModelFilterSource: usagestats.ModelSourceRequested,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
StartTime: &startTime,
|
||||
EndTime: &endTime,
|
||||
}
|
||||
|
||||
var stats *usagestats.UsageStats
|
||||
|
||||
@@ -60,6 +60,19 @@ func TestAdminUsageListRequestTypePriority(t *testing.T) {
|
||||
require.Nil(t, repo.listFilters.Stream)
|
||||
}
|
||||
|
||||
func TestAdminUsageListUsesRequestedModelForDisplayModelFilter(t *testing.T) {
|
||||
repo := &adminUsageRepoCapture{}
|
||||
router := newAdminUsageRequestTypeTestRouter(repo)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/usage?model=grok-imagine-video-1.5", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "grok-imagine-video-1.5", repo.listFilters.Model)
|
||||
require.Equal(t, usagestats.ModelSourceRequested, repo.listFilters.ModelFilterSource)
|
||||
}
|
||||
|
||||
func TestAdminUsageListInvalidRequestType(t *testing.T) {
|
||||
repo := &adminUsageRepoCapture{}
|
||||
router := newAdminUsageRequestTypeTestRouter(repo)
|
||||
@@ -119,6 +132,19 @@ func TestAdminUsageStatsRequestTypePriority(t *testing.T) {
|
||||
require.Nil(t, repo.statsFilters.Stream)
|
||||
}
|
||||
|
||||
func TestAdminUsageStatsUsesRequestedModelForDisplayModelFilter(t *testing.T) {
|
||||
repo := &adminUsageRepoCapture{}
|
||||
router := newAdminUsageRequestTypeTestRouter(repo)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/usage/stats?model=grok-imagine-video-1.5", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "grok-imagine-video-1.5", repo.statsFilters.Model)
|
||||
require.Equal(t, usagestats.ModelSourceRequested, repo.statsFilters.ModelFilterSource)
|
||||
}
|
||||
|
||||
func TestAdminUsageStatsInvalidRequestType(t *testing.T) {
|
||||
repo := &adminUsageRepoCapture{}
|
||||
router := newAdminUsageRequestTypeTestRouter(repo)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/quotaview"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -30,6 +31,9 @@ type UserHandler struct {
|
||||
concurrencyService *service.ConcurrencyService
|
||||
userPlatformQuotaRepo service.UserPlatformQuotaRepository // T13 admin quota view
|
||||
billingCache service.BillingCache // T17/T18 缓存失效(PUT/POST 路径)
|
||||
totpService *service.TotpService // 角色提升为管理员的 step-up 门控
|
||||
userService *service.UserService
|
||||
settingService *service.SettingService // step-up 功能开关
|
||||
}
|
||||
|
||||
// NewUserHandler creates a new admin user handler
|
||||
@@ -38,12 +42,18 @@ func NewUserHandler(
|
||||
concurrencyService *service.ConcurrencyService,
|
||||
userPlatformQuotaRepo service.UserPlatformQuotaRepository,
|
||||
billingCache service.BillingCache,
|
||||
totpService *service.TotpService,
|
||||
userService *service.UserService,
|
||||
settingService *service.SettingService,
|
||||
) *UserHandler {
|
||||
return &UserHandler{
|
||||
adminService: adminService,
|
||||
concurrencyService: concurrencyService,
|
||||
userPlatformQuotaRepo: userPlatformQuotaRepo,
|
||||
billingCache: billingCache,
|
||||
totpService: totpService,
|
||||
userService: userService,
|
||||
settingService: settingService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +276,13 @@ func (h *UserHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 创建管理员账号属权限敏感操作:需最近完成 step-up 2FA 验证。
|
||||
if req.Role == service.RoleAdmin {
|
||||
if !middleware.EnforceStepUp(c, h.totpService, h.userService, h.settingService) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
user, err := h.adminService.CreateUser(c.Request.Context(), &service.CreateUserInput{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
@@ -308,6 +325,21 @@ func (h *UserHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 把普通用户提升为管理员属权限敏感操作:需最近完成 step-up 2FA 验证。
|
||||
// 目标已是管理员时(前端编辑表单总是携带 role)不触发,避免日常编辑被打断。
|
||||
if req.Role == service.RoleAdmin {
|
||||
target, err := h.adminService.GetUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if target.Role != service.RoleAdmin {
|
||||
if !middleware.EnforceStepUp(c, h.totpService, h.userService, h.settingService) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用指针类型直接传递,nil 表示未提供该字段
|
||||
user, err := h.adminService.UpdateUser(c.Request.Context(), userID, &service.UpdateUserInput{
|
||||
Email: req.Email,
|
||||
@@ -577,6 +609,73 @@ func (h *UserHandler) BatchUpdateConcurrency(c *gin.Context) {
|
||||
response.Success(c, gin.H{"affected": affected})
|
||||
}
|
||||
|
||||
// BatchUpdateLimits overwrites concurrency and/or RPM limits for multiple users.
|
||||
// POST /api/v1/admin/users/batch-limits
|
||||
type BatchUpdateLimitsRequest struct {
|
||||
UserIDs []int64 `json:"user_ids"`
|
||||
All bool `json:"all"`
|
||||
Concurrency *int `json:"concurrency" binding:"omitempty,min=0"`
|
||||
RPMLimit *int `json:"rpm_limit" binding:"omitempty,min=0"`
|
||||
}
|
||||
|
||||
func (h *UserHandler) BatchUpdateLimits(c *gin.Context) {
|
||||
var req BatchUpdateLimitsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Concurrency == nil && req.RPMLimit == nil {
|
||||
response.BadRequest(c, "at least one of concurrency or rpm_limit is required")
|
||||
return
|
||||
}
|
||||
if !req.All && len(req.UserIDs) == 0 {
|
||||
response.BadRequest(c, "user_ids is required unless all=true")
|
||||
return
|
||||
}
|
||||
if !req.All && len(req.UserIDs) > 500 {
|
||||
response.BadRequest(c, "user_ids cannot exceed 500")
|
||||
return
|
||||
}
|
||||
|
||||
userIDs := req.UserIDs
|
||||
if req.All {
|
||||
userIDs = nil
|
||||
page := 1
|
||||
const pageSize = 500
|
||||
for {
|
||||
users, _, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, service.UserListFilters{}, "id", "asc")
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
for _, user := range users {
|
||||
userIDs = append(userIDs, user.ID)
|
||||
}
|
||||
if len(users) < pageSize {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
}
|
||||
|
||||
if len(userIDs) == 0 {
|
||||
response.Success(c, gin.H{"affected": 0})
|
||||
return
|
||||
}
|
||||
|
||||
affected, err := h.adminService.BatchUpdateLimits(
|
||||
c.Request.Context(),
|
||||
userIDs,
|
||||
req.Concurrency,
|
||||
req.RPMLimit,
|
||||
)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"affected": affected})
|
||||
}
|
||||
|
||||
// GetUserPlatformQuotas GET /admin/users/:id/platform-quotas
|
||||
// admin 视角:D14 lazy 归零 + 暴露 *_window_start 调试字段
|
||||
func (h *UserHandler) GetUserPlatformQuotas(c *gin.Context) {
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestUserHandlerListIncludesActivityFieldsAndSortParams(t *testing.T) {
|
||||
UpdatedAt: lastLoginAt,
|
||||
},
|
||||
}
|
||||
handler := NewUserHandler(adminSvc, nil, nil, nil)
|
||||
handler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
@@ -89,7 +89,7 @@ func TestUserHandlerGetByIDIncludesActivityFields(t *testing.T) {
|
||||
UpdatedAt: lastLoginAt,
|
||||
},
|
||||
}
|
||||
handler := NewUserHandler(adminSvc, nil, nil, nil)
|
||||
handler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type batchLimitsAdminServiceStub struct {
|
||||
*stubAdminService
|
||||
calls []batchLimitsAdminServiceCall
|
||||
}
|
||||
|
||||
type batchLimitsAdminServiceCall struct {
|
||||
userIDs []int64
|
||||
concurrency *int
|
||||
rpmLimit *int
|
||||
}
|
||||
|
||||
func cloneIntPointer(value *int) *int {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (s *batchLimitsAdminServiceStub) BatchUpdateLimits(_ context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
|
||||
s.calls = append(s.calls, batchLimitsAdminServiceCall{
|
||||
userIDs: append([]int64(nil), userIDs...),
|
||||
concurrency: cloneIntPointer(concurrency),
|
||||
rpmLimit: cloneIntPointer(rpmLimit),
|
||||
})
|
||||
return len(userIDs), nil
|
||||
}
|
||||
|
||||
func setupBatchLimitsRouter(serviceStub service.AdminService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
handler := NewUserHandler(serviceStub, nil, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/users/batch-limits", handler.BatchUpdateLimits)
|
||||
return router
|
||||
}
|
||||
|
||||
func postBatchLimits(t *testing.T, router *gin.Engine, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/admin/users/batch-limits",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func TestUserHandlerBatchUpdateLimitsAcceptsPartialAndZeroValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
expectedConcurrency *int
|
||||
expectedRPMLimit *int
|
||||
}{
|
||||
{name: "concurrency only", body: `{"user_ids":[1,2],"concurrency":10}`, expectedConcurrency: pointerTo(10)},
|
||||
{name: "both limits", body: `{"user_ids":[1,2],"concurrency":8,"rpm_limit":60}`, expectedConcurrency: pointerTo(8), expectedRPMLimit: pointerTo(60)},
|
||||
{name: "explicit zero", body: `{"user_ids":[1,2],"concurrency":0,"rpm_limit":0}`, expectedConcurrency: pointerTo(0), expectedRPMLimit: pointerTo(0)},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
serviceStub := &batchLimitsAdminServiceStub{stubAdminService: newStubAdminService()}
|
||||
recorder := postBatchLimits(t, setupBatchLimitsRouter(serviceStub), []byte(test.body))
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Len(t, serviceStub.calls, 1)
|
||||
require.Equal(t, []int64{1, 2}, serviceStub.calls[0].userIDs)
|
||||
require.Equal(t, test.expectedConcurrency, serviceStub.calls[0].concurrency)
|
||||
require.Equal(t, test.expectedRPMLimit, serviceStub.calls[0].rpmLimit)
|
||||
|
||||
var response struct {
|
||||
Data struct {
|
||||
Affected int `json:"affected"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
require.Equal(t, 2, response.Data.Affected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandlerBatchUpdateLimitsRejectsInvalidRequests(t *testing.T) {
|
||||
tooManyIDs := make([]int64, 501)
|
||||
for index := range tooManyIDs {
|
||||
tooManyIDs[index] = int64(index + 1)
|
||||
}
|
||||
tooManyBody, err := json.Marshal(map[string]any{"user_ids": tooManyIDs, "rpm_limit": 10})
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{name: "no limits", body: []byte(`{"user_ids":[1]}`)},
|
||||
{name: "invalid json", body: []byte(`{"user_ids":`)},
|
||||
{name: "missing user ids", body: []byte(`{"rpm_limit":10}`)},
|
||||
{name: "more than 500 ids", body: tooManyBody},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
serviceStub := &batchLimitsAdminServiceStub{stubAdminService: newStubAdminService()}
|
||||
recorder := postBatchLimits(t, setupBatchLimitsRouter(serviceStub), test.body)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Empty(t, serviceStub.calls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandlerBatchUpdateLimitsAllUsesEveryListedUser(t *testing.T) {
|
||||
base := newStubAdminService()
|
||||
base.users = []service.User{{ID: 11}, {ID: 12}, {ID: 13}}
|
||||
serviceStub := &batchLimitsAdminServiceStub{stubAdminService: base}
|
||||
recorder := postBatchLimits(
|
||||
t,
|
||||
setupBatchLimitsRouter(serviceStub),
|
||||
[]byte(`{"all":true,"user_ids":[999],"rpm_limit":0}`),
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Len(t, serviceStub.calls, 1)
|
||||
require.Equal(t, []int64{11, 12, 13}, serviceStub.calls[0].userIDs)
|
||||
require.Equal(t, 1, base.lastListUsers.calls)
|
||||
}
|
||||
|
||||
func pointerTo(value int) *int {
|
||||
return &value
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func (s *getByIDAdminStub) GetUserIncludeDeleted(_ context.Context, id int64) (*
|
||||
func setupGetByIDRouter(svc service.AdminService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
h := NewUserHandler(svc, nil, nil, nil)
|
||||
h := NewUserHandler(svc, nil, nil, nil, nil, nil, nil)
|
||||
r.GET("/admin/users/:id", h.GetByID)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestAdminUserList_ParsesAPIKeyGroupID(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stub := &listUsersFilterStub{AdminService: newStubAdminService()}
|
||||
r := gin.New()
|
||||
h := NewUserHandler(stub, nil, nil, nil)
|
||||
h := NewUserHandler(stub, nil, nil, nil, nil, nil, nil)
|
||||
r.GET("/admin/users", h.List)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 角色提升为管理员的 step-up 门控条件测试。
|
||||
// 测试环境不注入认证上下文,因此门控一旦触发会以 401 中止;
|
||||
// 借此区分「触发了 step-up 校验」与「直接放行到业务层(200)」。
|
||||
func setupRoleStepUpRouter(t *testing.T) (*gin.Engine, *stubAdminService) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
adminSvc := newStubAdminService()
|
||||
// 追加一个已是管理员的目标用户,验证「目标已是 admin 不触发门控」。
|
||||
adminSvc.users = append(adminSvc.users, service.User{
|
||||
ID: 2,
|
||||
Email: "admin@example.com",
|
||||
Role: service.RoleAdmin,
|
||||
Status: service.StatusActive,
|
||||
})
|
||||
|
||||
h := NewUserHandler(adminSvc, nil, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/users", h.Create)
|
||||
router.PUT("/api/v1/admin/users/:id", h.Update)
|
||||
return router, adminSvc
|
||||
}
|
||||
|
||||
func doJSON(t *testing.T, router *gin.Engine, method, path string, payload map[string]any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestUpdateUserPromoteToAdminRequiresStepUp(t *testing.T) {
|
||||
router, _ := setupRoleStepUpRouter(t)
|
||||
|
||||
rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/1", map[string]any{"role": "admin"})
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
|
||||
func TestUpdateUserKeepAdminRoleSkipsStepUp(t *testing.T) {
|
||||
router, _ := setupRoleStepUpRouter(t)
|
||||
|
||||
rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/2", map[string]any{"role": "admin"})
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
func TestUpdateUserRegularRoleSkipsStepUp(t *testing.T) {
|
||||
router, _ := setupRoleStepUpRouter(t)
|
||||
|
||||
rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/1", map[string]any{"role": "user", "email": "u@example.com"})
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
func TestCreateAdminUserRequiresStepUp(t *testing.T) {
|
||||
router, _ := setupRoleStepUpRouter(t)
|
||||
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/v1/admin/users", map[string]any{
|
||||
"email": "new-admin@example.com", "password": "pass123", "role": "admin",
|
||||
})
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
|
||||
func TestCreateRegularUserSkipsStepUp(t *testing.T) {
|
||||
router, _ := setupRoleStepUpRouter(t)
|
||||
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/v1/admin/users", map[string]any{
|
||||
"email": "new-user@example.com", "password": "pass123", "role": "user",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
@@ -46,14 +46,14 @@ type CreateAPIKeyRequest struct {
|
||||
|
||||
// UpdateAPIKeyRequest represents the update API key request payload
|
||||
type UpdateAPIKeyRequest struct {
|
||||
Name string `json:"name"`
|
||||
GroupID *int64 `json:"group_id"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
IPWhitelist []string `json:"ip_whitelist"` // IP 白名单
|
||||
IPBlacklist []string `json:"ip_blacklist"` // IP 黑名单
|
||||
Quota *float64 `json:"quota"` // 配额限制 (USD), 0=无限制
|
||||
ExpiresAt *string `json:"expires_at"` // 过期时间 (ISO 8601)
|
||||
ResetQuota *bool `json:"reset_quota"` // 重置已用配额
|
||||
Name string `json:"name"`
|
||||
GroupID *int64 `json:"group_id"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
IPWhitelist *[]string `json:"ip_whitelist"` // IP 白名单(nil 不修改,空数组清空)
|
||||
IPBlacklist *[]string `json:"ip_blacklist"` // IP 黑名单(nil 不修改,空数组清空)
|
||||
Quota *float64 `json:"quota"` // 配额限制 (USD), 0=无限制
|
||||
ExpiresAt *string `json:"expires_at"` // 过期时间 (ISO 8601)
|
||||
ResetQuota *bool `json:"reset_quota"` // 重置已用配额
|
||||
|
||||
// Rate limit fields (nil = no change, 0 = unlimited)
|
||||
RateLimit5h *float64 `json:"rate_limit_5h"`
|
||||
|
||||
@@ -107,7 +107,7 @@ func (h *AuthHandler) respondWithTokenPair(c *gin.Context, user *service.User) {
|
||||
if err != nil {
|
||||
slog.Error("failed to generate token pair", "error", err, "user_id", user.ID)
|
||||
// 回退到只返回Access Token
|
||||
token, tokenErr := h.authService.GenerateToken(user)
|
||||
token, tokenErr := h.authService.GenerateToken(c.Request.Context(), user)
|
||||
if tokenErr != nil {
|
||||
response.InternalError(c, "Failed to generate token")
|
||||
return
|
||||
|
||||
@@ -282,7 +282,7 @@ func TestLinuxDoOAuthBindStartAcceptsAccessTokenCookie(t *testing.T) {
|
||||
Save(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := handler.authService.GenerateToken(&service.User{
|
||||
token, err := handler.authService.GenerateToken(context.Background(), &service.User{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
|
||||
@@ -3168,6 +3168,9 @@ func (r *oauthPendingFlowUserRepo) BatchSetConcurrency(context.Context, []int64,
|
||||
func (r *oauthPendingFlowUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
|
||||
panic("unexpected BatchAddConcurrency call")
|
||||
}
|
||||
func (r *oauthPendingFlowUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
|
||||
panic("unexpected BatchUpdateLimits call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) {
|
||||
return map[int64]*time.Time{}, nil
|
||||
@@ -3360,6 +3363,14 @@ func (s *oauthPendingFlowTotpCacheStub) ClearVerifyAttempts(_ context.Context, u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowTotpCacheStub) SetStepUpGrant(_ context.Context, _ int64, _ string, _ time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowTotpCacheStub) HasStepUpGrant(_ context.Context, _ int64, _ string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type oauthPendingFlowTotpEncryptorStub struct{}
|
||||
|
||||
func (oauthPendingFlowTotpEncryptorStub) Encrypt(plaintext string) (string, error) {
|
||||
|
||||
@@ -72,6 +72,7 @@ type userSupportedModelPricing struct {
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []userPricingIntervalDTO `json:"intervals"`
|
||||
@@ -284,6 +285,7 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -20,6 +21,7 @@ type BatchImageHandler struct {
|
||||
service *service.BatchImagePublicService
|
||||
download *service.BatchImageDownloadService
|
||||
cleanup *service.BatchImageCleanupService
|
||||
openAI *OpenAIGatewayHandler
|
||||
}
|
||||
|
||||
func NewBatchImageHandler(service *service.BatchImagePublicService, download *service.BatchImageDownloadService, cleanup *service.BatchImageCleanupService) *BatchImageHandler {
|
||||
@@ -37,6 +39,9 @@ func (h *BatchImageHandler) Submit(c *gin.Context) {
|
||||
batchImageError(c, infraerrors.New(http.StatusUnauthorized, "API_KEY_REQUIRED", "API key is required"))
|
||||
return
|
||||
}
|
||||
if !h.checkSecurityAuditBeforeSubmit(c, &req) {
|
||||
return
|
||||
}
|
||||
got, err := h.service.Submit(c.Request.Context(), owner, req, c.GetHeader("Idempotency-Key"))
|
||||
if err != nil {
|
||||
batchImageError(c, err)
|
||||
@@ -45,6 +50,44 @@ func (h *BatchImageHandler) Submit(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, got)
|
||||
}
|
||||
|
||||
func (h *BatchImageHandler) checkSecurityAuditBeforeSubmit(c *gin.Context, req *service.BatchImageSubmitRequest) bool {
|
||||
if h == nil || h.openAI == nil || req == nil {
|
||||
return true
|
||||
}
|
||||
apiKey, ok := middleware.GetAPIKeyFromContext(c)
|
||||
if !ok || apiKey == nil {
|
||||
batchImageError(c, infraerrors.New(http.StatusUnauthorized, "API_KEY_REQUIRED", "API key is required"))
|
||||
return false
|
||||
}
|
||||
subject, ok := middleware.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
batchImageError(c, infraerrors.New(http.StatusInternalServerError, "USER_CONTEXT_REQUIRED", "User context not found"))
|
||||
return false
|
||||
}
|
||||
items := make([]map[string]string, 0, len(req.Items))
|
||||
for _, item := range req.Items {
|
||||
if prompt := strings.TrimSpace(item.Prompt); prompt != "" {
|
||||
items = append(items, map[string]string{"prompt": prompt})
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return true
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"request": map[string]any{"items": items}})
|
||||
if err != nil {
|
||||
batchImageError(c, infraerrors.New(http.StatusBadRequest, "INVALID_BATCH_PROMPT", "batch prompts are invalid"))
|
||||
return false
|
||||
}
|
||||
reqLog := requestLogger(c, "handler.batch_image.security_audit",
|
||||
zap.Int64("user_id", subject.UserID), zap.Int64("api_key_id", apiKey.ID), zap.String("model", req.Model))
|
||||
decision := h.openAI.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, req.Model, body)
|
||||
if decision != nil && !decision.AllowNextStage {
|
||||
h.openAI.openAISecurityAuditError(c, decision)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *BatchImageHandler) Get(c *gin.Context) {
|
||||
owner, ok := batchImageOwnerFromContext(c)
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestChatCompletionsRejectsGPTImageModelsBeforeScheduling(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
for _, model := range []string{"gpt-image-1", "gpt-image-1.5", "gpt-image-2"} {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
call func(*gin.Context)
|
||||
}{
|
||||
{
|
||||
name: "gateway",
|
||||
call: (&GatewayHandler{}).ChatCompletions,
|
||||
},
|
||||
{
|
||||
name: "openai_gateway",
|
||||
call: newOpenAIImageChatRejectionHandler(t).ChatCompletions,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name+"/"+model, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
body := []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"draw"}]}`)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||
setImageChatTestAuth(c)
|
||||
|
||||
tc.call(c)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String())
|
||||
require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "Chat Completions")
|
||||
_, selected := c.Get(opsAccountIDKey)
|
||||
require.False(t, selected, "rejection must happen before account selection")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIChatCompletionsImageModelRejectionDoesNotAcquireConcurrency(t *testing.T) {
|
||||
var acquireCalls atomic.Int64
|
||||
cache := &concurrencyCacheMock{
|
||||
acquireUserSlotFn: func(context.Context, int64, int, string) (bool, error) {
|
||||
acquireCalls.Add(1)
|
||||
return true, nil
|
||||
},
|
||||
}
|
||||
h := newOpenAIImageChatRejectionHandlerWithCache(t, cache)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(
|
||||
`{"model":"gpt-image-2","messages":[{"role":"user","content":"draw"}]}`,
|
||||
))
|
||||
setImageChatTestAuth(c)
|
||||
|
||||
h.ChatCompletions(c)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Zero(t, acquireCalls.Load(), "rejection must happen before user/account concurrency and scheduling")
|
||||
}
|
||||
|
||||
func newOpenAIImageChatRejectionHandler(t *testing.T) *OpenAIGatewayHandler {
|
||||
t.Helper()
|
||||
return newOpenAIImageChatRejectionHandlerWithCache(t, &concurrencyCacheMock{})
|
||||
}
|
||||
|
||||
func newOpenAIImageChatRejectionHandlerWithCache(t *testing.T, cache *concurrencyCacheMock) *OpenAIGatewayHandler {
|
||||
t.Helper()
|
||||
return &OpenAIGatewayHandler{
|
||||
gatewayService: &service.OpenAIGatewayService{},
|
||||
billingCacheService: &service.BillingCacheService{},
|
||||
apiKeyService: &service.APIKeyService{},
|
||||
concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(cache), SSEPingFormatNone, time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func setImageChatTestAuth(c *gin.Context) {
|
||||
apiKey := &service.APIKey{ID: 4348, UserID: 4348, User: &service.User{ID: 4348}}
|
||||
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 1})
|
||||
}
|
||||
@@ -12,13 +12,6 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (h *GatewayHandler) checkContentModeration(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision {
|
||||
if h == nil || h.contentModerationService == nil {
|
||||
return nil
|
||||
}
|
||||
return runContentModeration(c, reqLog, h.contentModerationService, apiKey, subject, protocol, model, body)
|
||||
}
|
||||
|
||||
func contentModerationStatus(decision *service.ContentModerationDecision) int {
|
||||
if decision == nil || decision.StatusCode < 400 || decision.StatusCode > 599 {
|
||||
return http.StatusForbidden
|
||||
@@ -30,13 +23,6 @@ func contentModerationErrorCode(decision *service.ContentModerationDecision) str
|
||||
return "content_policy_violation"
|
||||
}
|
||||
|
||||
func (h *OpenAIGatewayHandler) checkContentModeration(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision {
|
||||
if h == nil || h.contentModerationService == nil {
|
||||
return nil
|
||||
}
|
||||
return runContentModeration(c, reqLog, h.contentModerationService, apiKey, subject, protocol, model, body)
|
||||
}
|
||||
|
||||
func runContentModeration(c *gin.Context, reqLog *zap.Logger, svc *service.ContentModerationService, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision {
|
||||
if svc == nil || c == nil || c.Request == nil {
|
||||
return nil
|
||||
|
||||
@@ -20,6 +20,7 @@ func TestRedactCredentials_StripsSensitiveKeysAndReportsStatus(t *testing.T) {
|
||||
"aws_secret_access_key": "aws-secret",
|
||||
"service_account_json": map[string]any{"private_key": "..."},
|
||||
"private_key": "raw-key",
|
||||
"agent_private_key": "agent-key-secret",
|
||||
// 非敏感
|
||||
"base_url": "https://api.example.com",
|
||||
"model_mapping": map[string]any{"foo": "bar"},
|
||||
@@ -35,6 +36,7 @@ func TestRedactCredentials_StripsSensitiveKeysAndReportsStatus(t *testing.T) {
|
||||
require.NotContains(t, out, "aws_secret_access_key")
|
||||
require.NotContains(t, out, "service_account_json")
|
||||
require.NotContains(t, out, "private_key")
|
||||
require.NotContains(t, out, "agent_private_key")
|
||||
|
||||
require.Equal(t, "https://api.example.com", out["base_url"])
|
||||
require.Equal(t, map[string]any{"foo": "bar"}, out["model_mapping"])
|
||||
@@ -47,6 +49,7 @@ func TestRedactCredentials_StripsSensitiveKeysAndReportsStatus(t *testing.T) {
|
||||
require.True(t, status["has_aws_secret_access_key"])
|
||||
require.True(t, status["has_service_account_json"])
|
||||
require.True(t, status["has_private_key"])
|
||||
require.True(t, status["has_agent_private_key"])
|
||||
|
||||
// 状态 map 不应携带非敏感键的 has_*
|
||||
require.NotContains(t, status, "has_base_url")
|
||||
@@ -84,6 +87,7 @@ func TestRedactCredentials_AllKnownSensitiveKeys(t *testing.T) {
|
||||
"api_key", "session_key", "cookie",
|
||||
"aws_secret_access_key", "aws_session_token",
|
||||
"service_account_json", "service_account", "private_key",
|
||||
"agent_private_key",
|
||||
}
|
||||
in := make(map[string]any, len(keys))
|
||||
for _, k := range keys {
|
||||
|
||||
@@ -207,6 +207,8 @@ func groupFromServiceBase(g *service.Group) Group {
|
||||
RequireOAuthOnly: g.RequireOAuthOnly,
|
||||
RequirePrivacySet: g.RequirePrivacySet,
|
||||
RPMLimit: g.RPMLimit,
|
||||
MaxReasoningEffort: g.MaxReasoningEffort,
|
||||
ReasoningEffortMappings: g.ReasoningEffortMappings,
|
||||
CreatedAt: g.CreatedAt,
|
||||
UpdatedAt: g.UpdatedAt,
|
||||
}
|
||||
@@ -634,6 +636,8 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
|
||||
ImageSize: l.ImageSize,
|
||||
ImageInputSize: l.ImageInputSize,
|
||||
ImageOutputSize: l.ImageOutputSize,
|
||||
ImageInputTokens: l.ImageInputTokens,
|
||||
ImageInputCost: l.ImageInputCost,
|
||||
ImageOutputTokens: l.ImageOutputTokens,
|
||||
ImageOutputCost: l.ImageOutputCost,
|
||||
ImageSizeSource: l.ImageSizeSource,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user