diff --git a/.env.example b/.env.example index a63ed7668e98..3020f089ca12 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,27 @@ # 端口号 # PORT=3000 -# 前端基础URL -# FRONTEND_BASE_URL=https://your-frontend-url.com +# 前端交付模式(与 RUN_MODE / APP_PLANE 正交): +# auto - 默认兼容:slave + FRONTEND_BASE_URL 时跳转,否则使用嵌入资源 +# embedded - 强制嵌入双主题;frontend_external 构建会启动失败 +# redirect - 强制把未知页面 301 到 FRONTEND_BASE_URL(含 master) +# disabled - 纯后端,不注册前端 NoRoute;配合独立 Nginx 前端镜像 +# FRONTEND_MODE=auto +# 前端基础 URL:仅 redirect/auto(slave) 使用;必须是无路径/查询/凭据的 HTTP(S) origin +# FRONTEND_BASE_URL=https://console.example.com +# 允许浏览器跨域访问 API 的可信来源,默认不允许跨域;多个来源用英文逗号分隔 +# 同源 Nginx 反代时通常无需配置 CORS_ALLOWED_ORIGINS +# CORS_ALLOWED_ORIGINS=https://console.example.com,https://admin.example.com +# HTTP 慢连接防护;流式响应不设置全局 WriteTimeout +# HTTP_READ_HEADER_TIMEOUT_SECONDS=10 +# HTTP_IDLE_TIMEOUT_SECONDS=120 +# HTTP_MAX_HEADER_BYTES=1048576 +# 进程职责与 HTTP 平面;默认 all 保持单进程兼容 +# RUN_MODE=all # all|serve|worker|scheduler|migrate +# APP_PLANE=all # all|relay|management +# Prometheus 指标默认关闭;启用后建议同时配置随机 Bearer token +# 独立前端边缘默认不代理 /metrics,请在后端网络内抓取 +# METRICS_ENABLED=false +# METRICS_TOKEN= # 调试相关配置 @@ -33,6 +53,7 @@ # SQL_MAX_OPEN_CONNS=1000 # 数据库连接最大生命周期(秒) # SQL_MAX_LIFETIME=60 +# READINESS_TIMEOUT_SECONDS=3 # 缓存相关配置 @@ -58,6 +79,15 @@ # RELAY_TIMEOUT=0 # Relay HTTP 客户端空闲连接超时时间,单位秒,默认跟随 Go 标准库,设置为0表示不限制 # RELAY_IDLE_CONN_TIMEOUT=90 +# RELAY_DIAL_TIMEOUT=10 +# RELAY_TLS_HANDSHAKE_TIMEOUT=10 +# RELAY_RESPONSE_HEADER_TIMEOUT=120 +# RELAY_EXPECT_CONTINUE_TIMEOUT=1 +# Comma-separated proxy IPs/CIDRs whose forwarding headers may be trusted. +# Leave unset to ignore X-Forwarded-For and X-Real-IP. For a local reverse proxy, +# use TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128 only when port 3000 is not directly exposed. +# Separated frontend Nginx in Docker usually needs the compose bridge CIDR, e.g. 172.16.0.0/12. +# TRUSTED_PROXY_CIDRS= # 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值 # STREAMING_TIMEOUT=300 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 000000000000..6c32c198c177 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,192 @@ +name: Quality Gate + +on: + workflow_call: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: quality-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + go-quality: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: 1.26.5 + cache: true + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - name: Build embedded frontend assets + working-directory: web + env: + DISABLE_ESLINT_PLUGIN: 'true' + run: | + bun install --frozen-lockfile + (cd default && bun run build) + (cd classic && bun run build) + + - name: Validate build metadata + run: | + test -s VERSION + test "$(go env GOVERSION)" = "go1.26.5" + + - name: Build + run: go build -trimpath -buildvcs=true ./... + + - name: Build pure backend without embedded frontend + # frontend_external excludes //go:embed web/*/dist so the backend can ship without Bun assets. + run: go build -trimpath -buildvcs=true -tags frontend_external . + + - name: Test + env: + GOSUMDB: sum.golang.org + run: go test -count=1 ./... + + - name: Test pure backend package with frontend_external + env: + GOSUMDB: sum.golang.org + run: go test -count=1 -tags frontend_external . + + - name: Vet + run: go vet ./... + + - name: Vulnerability scan + run: | + go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + govulncheck ./... + + web-quality: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - name: Install locked dependencies + working-directory: web + run: bun install --frozen-lockfile + + - name: Audit dependencies + working-directory: web + run: bun audit + + - name: Typecheck default frontend + working-directory: web/default + run: bun run typecheck + + - name: Test default frontend + working-directory: web/default + run: bun test + + - name: Build default frontend + working-directory: web/default + env: + DISABLE_ESLINT_PLUGIN: 'true' + run: bun run build + + - name: Enforce entry bundle budget + working-directory: web/default + run: bun run bundle:check + + - name: Test classic frontend + working-directory: web/classic + run: bun test + + - name: Build classic frontend + working-directory: web/classic + run: bun run build + + - name: Enforce classic bundle budget + working-directory: web/classic + run: bun run bundle:check + + - name: Test shared web tooling + working-directory: web + run: bun run test:tooling + + image-reproducibility: + needs: [go-quality, web-quality] + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Build pinned integrated image + run: docker build --tag new-api:quality . + + - name: Build pure backend image + run: docker build -f Dockerfile.backend --tag new-api-backend:quality . + + - name: Resolve nginx unprivileged base digest + # Pin the frontend runtime base at build time so floating tags cannot silently drift. + id: nginx_base + run: | + set -euo pipefail + IMAGE_REF='nginxinc/nginx-unprivileged:1.27-alpine' + docker pull "$IMAGE_REF" + DIGEST_REF="$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE_REF")" + test -n "$DIGEST_REF" + echo "image=${DIGEST_REF}" >> "$GITHUB_OUTPUT" + echo "Pinned frontend nginx base: ${DIGEST_REF}" >> "$GITHUB_STEP_SUMMARY" + + - name: Build separated frontend image + run: | + docker build \ + -f deploy/separated/Dockerfile.frontend \ + --build-arg "NGINX_IMAGE=${{ steps.nginx_base.outputs.image }}" \ + --tag new-api-frontend:quality . + + - name: Validate frontend Nginx configuration + # Variable proxy_pass + resolver defers DNS; use loopback upstream so nginx -t + # does not require a live backend hostname in this single-container job. + run: | + docker run --rm --entrypoint /bin/sh new-api-frontend:quality -c ' + set -eu + export BACKEND_UPSTREAM=127.0.0.1:3000 + export DNS_RESOLVER=127.0.0.1 + export NGINX_PORT=8080 + export SERVER_NAME=_ + export CLIENT_MAX_BODY_SIZE=100m + export PROXY_CONNECT_TIMEOUT=60s + export PROXY_SEND_TIMEOUT=3600s + export PROXY_READ_TIMEOUT=3600s + envsubst '\''${BACKEND_UPSTREAM} ${NGINX_PORT} ${SERVER_NAME} ${CLIENT_MAX_BODY_SIZE} ${PROXY_CONNECT_TIMEOUT} ${PROXY_SEND_TIMEOUT} ${PROXY_READ_TIMEOUT} ${DNS_RESOLVER}'\'' \ + < /etc/nginx/templates/nginx.conf.template > /tmp/nginx-test.conf + nginx -t -c /tmp/nginx-test.conf + ' + + - name: Record separated image digests + if: always() + run: | + { + echo '### Separated delivery images' + for image in new-api:quality new-api-backend:quality new-api-frontend:quality; do + if docker image inspect "$image" >/dev/null 2>&1; then + id="$(docker image inspect --format='{{.Id}}' "$image")" + echo "- \`${image}\`: \`${id}\`" + else + echo "- \`${image}\`: missing" + fi + done + echo "- nginx base: \`${{ steps.nginx_base.outputs.image }}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e519749794f..d719ec9e255f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,15 @@ on: - '!*-alpha*' jobs: + quality-gate: + name: Quality Gate + uses: ./.github/workflows/quality.yml + permissions: + contents: read + linux: name: Linux Release + needs: quality-gate runs-on: ubuntu-latest steps: - name: Checkout @@ -28,7 +35,7 @@ jobs: echo "VERSION=$VERSION" >> $GITHUB_ENV - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: latest + bun-version: 1.3.14 - name: Build Frontend (default) env: CI: "" @@ -50,16 +57,16 @@ jobs: - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version: '>=1.25.1' + go-version: 1.26.5 - name: Build Backend (amd64) run: | go mod download - go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION + go build -trimpath -buildvcs=true -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION - name: Build Backend (arm64) run: | sudo apt-get update DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu - CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION + CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -trimpath -buildvcs=true -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION - name: Generate checksums run: sha256sum new-api-* > checksums-linux.txt @@ -75,6 +82,7 @@ jobs: macos: name: macOS Release + needs: quality-gate runs-on: macos-latest steps: - name: Checkout @@ -87,7 +95,7 @@ jobs: echo "VERSION=$VERSION" >> $GITHUB_ENV - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: latest + bun-version: 1.3.14 - name: Build Frontend (default) env: CI: "" @@ -110,11 +118,11 @@ jobs: - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version: '>=1.25.1' + go-version: 1.26.5 - name: Build Backend run: | go mod download - go build -ldflags "-X 'new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION + go build -trimpath -buildvcs=true -ldflags "-X 'github.com/QuantumNous/new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION - name: Generate checksums run: shasum -a 256 new-api-macos-* > checksums-macos.txt @@ -130,6 +138,7 @@ jobs: windows: name: Windows Release + needs: quality-gate runs-on: windows-latest defaults: run: @@ -145,7 +154,7 @@ jobs: echo "VERSION=$VERSION" >> $GITHUB_ENV - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: latest + bun-version: 1.3.14 - name: Build Frontend (default) env: CI: "" @@ -167,11 +176,11 @@ jobs: - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version: '>=1.25.1' + go-version: 1.26.5 - name: Build Backend run: | go mod download - go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe + go build -trimpath -buildvcs=true -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe - name: Generate checksums run: sha256sum new-api-*.exe > checksums-windows.txt diff --git a/.gitignore b/.gitignore index c3afceb021f1..0250c5e88643 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ data/ token_estimator_test.go skills-lock.json .playwright-mcp +artifacts/ # Local-only live probes and scratch test workspaces. .local-tests/ diff --git a/Dockerfile b/Dockerfile index e2788f55b2bd..402e98bc35f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,9 @@ COPY web/classic/package.json ./classic/package.json RUN bun install --frozen-lockfile COPY ./web/default ./default COPY ./VERSION /build/VERSION -RUN cd default && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build +RUN test -s /build/VERSION \ + && cd default \ + && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic @@ -15,12 +17,14 @@ WORKDIR /build/web COPY web/package.json web/bun.lock ./ COPY web/default/package.json ./default/package.json COPY web/classic/package.json ./classic/package.json -RUN bun install --filter ./classic --frozen-lockfile +RUN bun install --frozen-lockfile COPY ./web/classic ./classic COPY ./VERSION /build/VERSION -RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build +RUN test -s /build/VERSION \ + && cd classic \ + && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build -FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2 +FROM golang:1.26.5-alpine@sha256:0178a641fbb4858c5f1b48e34bdaabe0350a330a1b1149aabd498d0699ff5fb2 AS builder2 ENV GO111MODULE=on CGO_ENABLED=0 ARG TARGETOS @@ -36,7 +40,8 @@ RUN go mod download COPY . . COPY --from=builder /build/web/default/dist ./web/default/dist COPY --from=builder-classic /build/web/classic/dist ./web/classic/dist -RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api +RUN test -s VERSION \ + && go build -trimpath -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 000000000000..063be25e9f25 --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,39 @@ +# Backend-only image: builds without embedding frontend assets. +# Requires go build tag frontend_external and runtime FRONTEND_MODE=disabled|redirect. + +FROM golang:1.26.5-alpine@sha256:0178a641fbb4858c5f1b48e34bdaabe0350a330a1b1149aabd498d0699ff5fb2 AS builder + +ENV GO111MODULE=on CGO_ENABLED=0 +ARG TARGETOS +ARG TARGETARCH +ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} +ENV GOEXPERIMENT=greenteagc + +WORKDIR /build + +ADD go.mod go.sum ./ +RUN go mod download + +COPY . . +# Pure backend build: do not copy or synthesize web/*/dist; frontend_external excludes embed. +RUN test -s VERSION \ + && go build -trimpath -buildvcs=true -tags frontend_external \ + -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" \ + -o new-api + +FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \ + && rm -rf /var/lib/apt/lists/* \ + && update-ca-certificates + +COPY --from=builder /build/new-api / +COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/ + +# Default pure-backend delivery; override to redirect when a separate console origin is used. +ENV FRONTEND_MODE=disabled + +EXPOSE 3000 +WORKDIR /data +ENTRYPOINT ["/new-api"] diff --git a/Dockerfile.dev b/Dockerfile.dev index 81c221bf113c..72c78821084f 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,7 +1,8 @@ -# Backend-only build for frontend development -# Skips frontend build, uses a placeholder for //go:embed web/dist +# Backend-only build for frontend development. +# Uses frontend_external so web/*/dist is not required or embedded. +# Pair with bun web dev server (API proxied to :3000) or FRONTEND_MODE=redirect. -FROM golang:1.26.1-alpine AS builder +FROM golang:1.26.5-alpine@sha256:0178a641fbb4858c5f1b48e34bdaabe0350a330a1b1149aabd498d0699ff5fb2 AS builder ENV GO111MODULE=on CGO_ENABLED=0 ARG TARGETOS @@ -16,13 +17,12 @@ RUN go mod download COPY . . -RUN mkdir -p web/default/dist web/classic/dist && \ - echo 'devuse frontend dev server' > web/default/dist/index.html && \ - echo 'devuse frontend dev server' > web/classic/dist/index.html +RUN test -s VERSION \ + && go build -trimpath -buildvcs=true -tags frontend_external \ + -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" \ + -o new-api -RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api - -FROM debian:bookworm-slim +FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates tzdata wget \ @@ -31,6 +31,9 @@ RUN apt-get update \ COPY --from=builder /build/new-api / COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/ + +ENV FRONTEND_MODE=disabled + EXPOSE 3000 WORKDIR /data ENTRYPOINT ["/new-api"] diff --git a/README.md b/README.md index 65e3facdb24e..165c399a3264 100644 --- a/README.md +++ b/README.md @@ -319,8 +319,8 @@ docker run --name new-api -d --restart always \ | `REDIS_CONN_STRING` | Redis connection string | - | | `RELAY_IDLE_CONN_TIMEOUT` | Idle keep-alive timeout for relay HTTP clients, seconds. Defaults to Go standard library behavior; set `0` to disable | `90` | | `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` | -| `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` | -| `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` | +| `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `128` | +| `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `128` | | `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | Error log switch | `false` | | `PYROSCOPE_URL` | Pyroscope server address | - | diff --git a/VERSION b/VERSION index e69de29bb2d1..ffbebf54dddd 100644 --- a/VERSION +++ b/VERSION @@ -0,0 +1 @@ +v1.0.0-rc.12-dev diff --git a/common/constants.go b/common/constants.go index 87d212f99732..96a983fbc1c8 100644 --- a/common/constants.go +++ b/common/constants.go @@ -22,7 +22,7 @@ var TopUpLink = "" var themeValue atomic.Value // stores string; safe for concurrent read/write func init() { - themeValue.Store("classic") + themeValue.Store("default") } func GetTheme() string { @@ -186,6 +186,10 @@ var BatchUpdateInterval int var RelayTimeout int // unit is second var RelayIdleConnTimeout int // unit is second +var RelayDialTimeout int +var RelayTLSHandshakeTimeout int +var RelayResponseHeaderTimeout int +var RelayExpectContinueTimeout int var RelayMaxIdleConns int var RelayMaxIdleConnsPerHost int diff --git a/common/custom-event.go b/common/custom-event.go index 1bea2fd72b17..8e25ade4bba9 100644 --- a/common/custom-event.go +++ b/common/custom-event.go @@ -9,7 +9,6 @@ import ( "io" "net/http" "strings" - "sync" ) type stringWriter interface { @@ -53,8 +52,6 @@ type CustomEvent struct { Id string Retry uint Data interface{} - - Mutex sync.Mutex } func encode(writer io.Writer, event CustomEvent) error { @@ -63,9 +60,13 @@ func encode(writer io.Writer, event CustomEvent) error { } func writeData(w stringWriter, data interface{}) error { - dataReplacer.WriteString(w, fmt.Sprint(data)) - if strings.HasPrefix(data.(string), "data") { - w.writeString("\n\n") + value := fmt.Sprint(data) + if _, err := dataReplacer.WriteString(w, value); err != nil { + return err + } + if strings.HasPrefix(value, "data") { + _, err := w.writeString("\n\n") + return err } return nil } @@ -76,8 +77,6 @@ func (r CustomEvent) Render(w http.ResponseWriter) error { } func (r CustomEvent) WriteContentType(w http.ResponseWriter) { - r.Mutex.Lock() - defer r.Mutex.Unlock() header := w.Header() header["Content-Type"] = writeContentType diff --git a/common/email_test.go b/common/email_test.go index 47916fdfb995..7ae45ae3d70a 100644 --- a/common/email_test.go +++ b/common/email_test.go @@ -364,7 +364,7 @@ func TestSMTPPlainAuthRejectsRemotePlaintextConnection(t *testing.T) { SMTPFrom = "sender@example.com" SMTPToken = "secret" - conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", server.host, server.port)) + conn, err := net.Dial("tcp", net.JoinHostPort(server.host, strconv.Itoa(server.port))) require.NoError(t, err) client, err := smtp.NewClient(conn, SMTPServer) require.NoError(t, err) diff --git a/common/env.go b/common/env.go index 1aa340f85ea1..2ee085b6f7aa 100644 --- a/common/env.go +++ b/common/env.go @@ -36,3 +36,15 @@ func GetEnvOrDefaultBool(env string, defaultValue bool) bool { } return b } + +func GetEnvOrDefaultFloat(env string, defaultValue float64) float64 { + if env == "" || os.Getenv(env) == "" { + return defaultValue + } + f, err := strconv.ParseFloat(os.Getenv(env), 64) + if err != nil { + SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %.2f", env, err.Error(), defaultValue)) + return defaultValue + } + return f +} diff --git a/common/http_client.go b/common/http_client.go new file mode 100644 index 000000000000..bb6b5b0efe0b --- /dev/null +++ b/common/http_client.go @@ -0,0 +1,39 @@ +package common + +import ( + "context" + "net" + "net/http" + "net/url" + "time" +) + +type DialContextFunc func(context.Context, string, string) (net.Conn, error) + +// NewOutboundHTTPTransport applies the shared connection lifecycle policy. +// ResponseHeaderTimeout bounds an upstream that never responds while leaving +// response-body streaming governed by request context and streaming timeouts. +func NewOutboundHTTPTransport(proxy func(*http.Request) (*url.URL, error), dialContext DialContextFunc) *http.Transport { + if dialContext == nil { + dialer := &net.Dialer{ + Timeout: time.Duration(RelayDialTimeout) * time.Second, + KeepAlive: 30 * time.Second, + } + dialContext = dialer.DialContext + } + transport := &http.Transport{ + Proxy: proxy, + DialContext: dialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: RelayMaxIdleConns, + MaxIdleConnsPerHost: RelayMaxIdleConnsPerHost, + IdleConnTimeout: time.Duration(RelayIdleConnTimeout) * time.Second, + TLSHandshakeTimeout: time.Duration(RelayTLSHandshakeTimeout) * time.Second, + ResponseHeaderTimeout: time.Duration(RelayResponseHeaderTimeout) * time.Second, + ExpectContinueTimeout: time.Duration(RelayExpectContinueTimeout) * time.Second, + } + if TLSInsecureSkipVerify { + transport.TLSClientConfig = InsecureTLSConfig.Clone() + } + return transport +} diff --git a/common/http_client_test.go b/common/http_client_test.go new file mode 100644 index 000000000000..9fd60b902ea2 --- /dev/null +++ b/common/http_client_test.go @@ -0,0 +1,99 @@ +package common + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestNewOutboundHTTPTransportUsesLifecycleTimeouts(t *testing.T) { + previousDial := RelayDialTimeout + previousTLS := RelayTLSHandshakeTimeout + previousHeader := RelayResponseHeaderTimeout + previousExpect := RelayExpectContinueTimeout + previousIdle := RelayIdleConnTimeout + previousMaxIdle := RelayMaxIdleConns + previousMaxIdleHost := RelayMaxIdleConnsPerHost + t.Cleanup(func() { + RelayDialTimeout = previousDial + RelayTLSHandshakeTimeout = previousTLS + RelayResponseHeaderTimeout = previousHeader + RelayExpectContinueTimeout = previousExpect + RelayIdleConnTimeout = previousIdle + RelayMaxIdleConns = previousMaxIdle + RelayMaxIdleConnsPerHost = previousMaxIdleHost + }) + + RelayDialTimeout = 7 + RelayTLSHandshakeTimeout = 8 + RelayResponseHeaderTimeout = 9 + RelayExpectContinueTimeout = 2 + RelayIdleConnTimeout = 90 + RelayMaxIdleConns = 200 + RelayMaxIdleConnsPerHost = 50 + + transport := NewOutboundHTTPTransport(http.ProxyFromEnvironment, nil) + require.Equal(t, 8*time.Second, transport.TLSHandshakeTimeout) + require.Equal(t, 9*time.Second, transport.ResponseHeaderTimeout) + require.Equal(t, 2*time.Second, transport.ExpectContinueTimeout) + require.Equal(t, 90*time.Second, transport.IdleConnTimeout) + require.Equal(t, 200, transport.MaxIdleConns) + require.Equal(t, 50, transport.MaxIdleConnsPerHost) + require.NotNil(t, transport.DialContext) +} + +func TestOutboundTransportTimesOutWaitingForResponseHeaders(t *testing.T) { + previousHeader := RelayResponseHeaderTimeout + previousDial := RelayDialTimeout + RelayResponseHeaderTimeout = 1 + RelayDialTimeout = 2 + t.Cleanup(func() { + RelayResponseHeaderTimeout = previousHeader + RelayDialTimeout = previousDial + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(1500 * time.Millisecond) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := &http.Client{Transport: NewOutboundHTTPTransport(nil, nil)} + started := time.Now() + _, err := client.Get(server.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "timeout awaiting response headers") + require.Less(t, time.Since(started), 1400*time.Millisecond) +} + +func TestOutboundTransportHonorsRequestCancellation(t *testing.T) { + previousHeader := RelayResponseHeaderTimeout + RelayResponseHeaderTimeout = 0 + t.Cleanup(func() { RelayResponseHeaderTimeout = previousHeader }) + + started := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { + close(started) + <-request.Context().Done() + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil) + require.NoError(t, err) + result := make(chan error, 1) + client := &http.Client{Transport: NewOutboundHTTPTransport(nil, nil)} + go func() { + _, requestErr := client.Do(request) + result <- requestErr + }() + <-started + cancel() + require.ErrorIs(t, <-result, context.Canceled) + require.True(t, errors.Is(ctx.Err(), context.Canceled)) +} diff --git a/common/init.go b/common/init.go index 88b2dc3e62e1..dadd289aee8b 100644 --- a/common/init.go +++ b/common/init.go @@ -108,6 +108,10 @@ func InitEnv() { BatchUpdateInterval = GetEnvOrDefault("BATCH_UPDATE_INTERVAL", 5) RelayTimeout = GetEnvOrDefault("RELAY_TIMEOUT", 0) RelayIdleConnTimeout = GetEnvOrDefault("RELAY_IDLE_CONN_TIMEOUT", 90) + RelayDialTimeout = GetEnvOrDefault("RELAY_DIAL_TIMEOUT", 10) + RelayTLSHandshakeTimeout = GetEnvOrDefault("RELAY_TLS_HANDSHAKE_TIMEOUT", 10) + RelayResponseHeaderTimeout = GetEnvOrDefault("RELAY_RESPONSE_HEADER_TIMEOUT", 120) + RelayExpectContinueTimeout = GetEnvOrDefault("RELAY_EXPECT_CONTINUE_TIMEOUT", 1) RelayMaxIdleConns = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS", 500) RelayMaxIdleConnsPerHost = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS_PER_HOST", 100) @@ -185,4 +189,13 @@ func initConstantEnv() { } } constant.TrustedRedirectDomains = trustedDomains + + // Adaptive channel balance + constant.AdaptiveBalanceEnabled = GetEnvOrDefaultBool("ADAPTIVE_BALANCE_ENABLED", false) + constant.AdaptiveBalanceShadowMode = GetEnvOrDefaultBool("ADAPTIVE_BALANCE_SHADOW_MODE", false) + constant.ChannelCircuitBreakerEnabled = GetEnvOrDefaultBool("CHANNEL_CIRCUIT_BREAKER_ENABLED", false) + constant.MaxRetryChannels = GetEnvOrDefault("MAX_RETRY_CHANNELS", 3) + constant.ChannelCooldownSeconds = GetEnvOrDefault("CHANNEL_COOLDOWN_SECONDS", 30) + constant.EwmaAlpha = GetEnvOrDefaultFloat("EWMA_ALPHA", 0.1) + constant.MaxChannelConcurrency = GetEnvOrDefault("MAX_CHANNEL_CONCURRENCY", 10) } diff --git a/common/quota.go b/common/quota.go index dfd65d273ee5..0270606454be 100644 --- a/common/quota.go +++ b/common/quota.go @@ -1,5 +1,15 @@ package common +import "os" + +// GetTrustQuota returns the balance threshold above which pre-consume may be +// skipped. Disabled by default — concurrent settle can overdraft without a +// floor on the trust path. Opt in with TRUST_PRECONSUME_ENABLED=true|1. func GetTrustQuota() int { - return int(10 * QuotaPerUnit) + switch os.Getenv("TRUST_PRECONSUME_ENABLED") { + case "1", "true", "TRUE", "yes", "on": + return int(10 * QuotaPerUnit) + default: + return 0 + } } diff --git a/constant/context_key.go b/constant/context_key.go index b856bc3dda14..de9c309b4786 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -72,4 +72,9 @@ const ( // fallback in authHelper (finishAdminAudit) skips its record to avoid // duplicate entries. ContextKeyAuditLogged ContextKey = "audit_logged" + + // ContextKeyThreadId / ContextKeyTraceId hold AxonHub-compatible + // conversation observability IDs (AH-Thread-Id / AH-Trace-Id). + ContextKeyThreadId ContextKey = "thread_id" + ContextKeyTraceId ContextKey = "trace_id" ) diff --git a/constant/env.go b/constant/env.go index 512bfc31126b..0dfd794bb590 100644 --- a/constant/env.go +++ b/constant/env.go @@ -25,3 +25,12 @@ var TaskPricePatches []string // TrustedRedirectDomains is a list of trusted domains for redirect URL validation. // Domains support subdomain matching (e.g., "example.com" matches "sub.example.com"). var TrustedRedirectDomains []string + +// Adaptive channel balance settings +var AdaptiveBalanceEnabled bool +var AdaptiveBalanceShadowMode bool +var ChannelCircuitBreakerEnabled bool +var MaxRetryChannels int +var ChannelCooldownSeconds int +var EwmaAlpha float64 +var MaxChannelConcurrency int diff --git a/controller/channel-test.go b/controller/channel-test.go index 4ba3698bd54c..1e99775d0fee 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -20,6 +20,7 @@ import ( "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/pkg/billingexpr" + perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics" "github.com/QuantumNous/new-api/relay" relaycommon "github.com/QuantumNous/new-api/relay/common" relayconstant "github.com/QuantumNous/new-api/relay/constant" @@ -39,6 +40,12 @@ type testResult struct { context *gin.Context localErr error newAPIError *types.NewAPIError + // Probe data for perf_metrics (populated on success). + relayInfo *relaycommon.RelayInfo + usage *dto.Usage + latencyMs int64 + outputTokens int64 + testModel string } func normalizeChannelTestEndpoint(channel *model.Channel, modelName, endpointType string) string { @@ -52,9 +59,156 @@ func normalizeChannelTestEndpoint(channel *model.Channel, modelName, endpointTyp if channel != nil && channel.Type == constant.ChannelTypeCodex { return string(constant.EndpointTypeOpenAIResponse) } + // Infer non-chat endpoints so auto-test does not force image/audio models through /chat/completions. + if kind := detectProbeModelKind(modelName); kind != "" { + return kind + } + if channel != nil && channel.Type == constant.ChannelTypeMokaAI { + return string(constant.EndpointTypeEmbeddings) + } + if channel != nil && channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(strings.ToLower(modelName), "seedream") { + return string(constant.EndpointTypeImageGeneration) + } return normalized } +// detectProbeModelKind returns a constant.EndpointType string for known non-chat models. +// Empty string means default chat completions path. +func detectProbeModelKind(modelName string) string { + name := strings.ToLower(strings.TrimSpace(modelName)) + if name == "" { + return "" + } + if strings.HasSuffix(name, ratio_setting.CompactModelSuffix) { + return string(constant.EndpointTypeOpenAIResponseCompact) + } + if strings.Contains(name, "codex") { + return string(constant.EndpointTypeOpenAIResponse) + } + if strings.Contains(name, "rerank") { + return string(constant.EndpointTypeJinaRerank) + } + if strings.Contains(name, "embedding") || + strings.Contains(name, "embed") || + strings.HasPrefix(name, "m3e") || + strings.Contains(name, "bge-") || + strings.Contains(name, "text-embedding") { + return string(constant.EndpointTypeEmbeddings) + } + // Image generation / edit models — never chat-test these (#6121). + if isImageProbeModel(name) { + return string(constant.EndpointTypeImageGeneration) + } + return "" +} + +func isImageProbeModel(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + return false + } + imageHints := []string{ + "gpt-image", "dall-e", "dalle", "seedream", "flux", "imagen", + "stable-diffusion", "sdxl", "midjourney", "mj-", "image-gen", + "text-to-image", "t2i", "cogview", "kolors", "playground-v", + } + for _, h := range imageHints { + if strings.Contains(name, h) { + return true + } + } + // Bare suffixes like "*-image" / "image-*" when not embedding/vision chat. + if strings.Contains(name, "image") && + !strings.Contains(name, "vision") && + !strings.Contains(name, "chat") && + !strings.Contains(name, "embedding") { + return true + } + return false +} + +func isAudioOrVideoProbeModel(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + return false + } + hints := []string{ + "whisper", "tts-", "tts_", "-tts", "speech", "audio-", "-audio", + "sora", "kling", "runway", "luma", "hailuo", "vidu", "cogvideo", + "text-to-video", "t2v", "minimax-video", + } + for _, h := range hints { + if strings.Contains(name, h) { + return true + } + } + return false +} + +// isChatCapableProbeModel reports whether auto-test can safely use chat completions. +func isChatCapableProbeModel(name string) bool { + name = strings.TrimSpace(name) + if name == "" { + return false + } + kind := detectProbeModelKind(name) + if kind == string(constant.EndpointTypeImageGeneration) || + kind == string(constant.EndpointTypeEmbeddings) || + kind == string(constant.EndpointTypeJinaRerank) { + return false + } + if isAudioOrVideoProbeModel(name) { + return false + } + return true +} + +// pickAutoTestModel chooses a chat-capable model for batch auto-test. +// Prefers channel.TestModel when chat-capable; otherwise first chat-capable model +// in the channel list. Empty means skip auto probe for this channel. +func pickAutoTestModel(channel *model.Channel) string { + if channel == nil { + return "" + } + if channel.TestModel != nil { + if name := strings.TrimSpace(*channel.TestModel); name != "" && isChatCapableProbeModel(name) { + return name + } + } + for _, m := range channel.GetModels() { + if name := strings.TrimSpace(m); name != "" && isChatCapableProbeModel(name) { + return name + } + } + return "" +} + +func shouldSkipAutoChannelTest(channel *model.Channel) bool { + if channel == nil { + return true + } + if channel.Status == common.ChannelStatusManuallyDisabled { + return true + } + if channel.GetSetting().SkipAutoTest { + return true + } + // Channel types without chat/completion test support. + unsupported := []int{ + constant.ChannelTypeMidjourney, + constant.ChannelTypeMidjourneyPlus, + constant.ChannelTypeSunoAPI, + constant.ChannelTypeKling, + constant.ChannelTypeJimeng, + constant.ChannelTypeDoubaoVideo, + constant.ChannelTypeVidu, + } + if lo.Contains(unsupported, channel.Type) { + return true + } + return false +} + func resolveChannelTestUserID(c *gin.Context) (int, error) { if c != nil { if userID := c.GetInt("id"); userID > 0 { @@ -120,34 +274,29 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te requestPath = endpointInfo.Path } } else { - // 如果没有指定端点类型,使用原有的自动检测逻辑 - - if strings.Contains(strings.ToLower(testModel), "rerank") { - requestPath = "/v1/rerank" - } - - // 先判断是否为 Embedding 模型 - if strings.Contains(strings.ToLower(testModel), "embedding") || - strings.HasPrefix(testModel, "m3e") || // m3e 系列模型 - strings.Contains(testModel, "bge-") || // bge 系列模型 - strings.Contains(testModel, "embed") || - channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型 - requestPath = "/v1/embeddings" // 修改请求路径 + // 如果没有指定端点类型,使用统一的模型种类检测 + kind := detectProbeModelKind(testModel) + if kind == "" && channel.Type == constant.ChannelTypeMokaAI { + kind = string(constant.EndpointTypeEmbeddings) } - - // VolcEngine 图像生成模型 - if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") { - requestPath = "/v1/images/generations" - } - - // responses-only models - if strings.Contains(strings.ToLower(testModel), "codex") { - requestPath = "/v1/responses" + if kind == "" && channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(strings.ToLower(testModel), "seedream") { + kind = string(constant.EndpointTypeImageGeneration) } - - // responses compaction models (must use /v1/responses/compact) - if strings.HasSuffix(testModel, ratio_setting.CompactModelSuffix) { - requestPath = "/v1/responses/compact" + if endpointInfo, ok := common.GetDefaultEndpointInfo(constant.EndpointType(kind)); ok { + requestPath = endpointInfo.Path + } else { + switch kind { + case string(constant.EndpointTypeJinaRerank): + requestPath = "/v1/rerank" + case string(constant.EndpointTypeEmbeddings): + requestPath = "/v1/embeddings" + case string(constant.EndpointTypeImageGeneration): + requestPath = "/v1/images/generations" + case string(constant.EndpointTypeOpenAIResponse): + requestPath = "/v1/responses" + case string(constant.EndpointTypeOpenAIResponseCompact): + requestPath = "/v1/responses/compact" + } } } if strings.HasPrefix(requestPath, "/v1/responses/compact") { @@ -512,9 +661,14 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te }) common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody))) return testResult{ - context: c, - localErr: nil, - newAPIError: nil, + context: c, + localErr: nil, + newAPIError: nil, + relayInfo: info, + usage: usage, + latencyMs: milliseconds, + outputTokens: int64(usage.CompletionTokens), + testModel: info.OriginModelName, } } @@ -757,37 +911,33 @@ func buildTestRequest(model string, endpointType string, channel *model.Channel, } } - // 自动检测逻辑(保持原有行为) - if strings.Contains(strings.ToLower(model), "rerank") { + // 自动检测逻辑(与 detectProbeModelKind / normalizeChannelTestEndpoint 对齐) + switch detectProbeModelKind(model) { + case string(constant.EndpointTypeJinaRerank): return &dto.RerankRequest{ Model: model, Query: "What is Deep Learning?", Documents: []any{"Deep Learning is a subset of machine learning.", "Machine learning is a field of artificial intelligence."}, TopN: lo.ToPtr(2), } - } - - // 先判断是否为 Embedding 模型 - if strings.Contains(strings.ToLower(model), "embedding") || - strings.HasPrefix(model, "m3e") || - strings.Contains(model, "bge-") { - // 返回 EmbeddingRequest + case string(constant.EndpointTypeEmbeddings): return &dto.EmbeddingRequest{ Model: model, Input: []any{"hello world"}, } - } - - // Responses compaction models (must use /v1/responses/compact) - if strings.HasSuffix(model, ratio_setting.CompactModelSuffix) { + case string(constant.EndpointTypeImageGeneration): + return &dto.ImageRequest{ + Model: model, + Prompt: "a cute cat", + N: lo.ToPtr(uint(1)), + Size: "1024x1024", + } + case string(constant.EndpointTypeOpenAIResponseCompact): return &dto.OpenAIResponsesCompactionRequest{ Model: model, Input: testResponsesInput, } - } - - // Responses-only models (e.g. codex series) - if strings.Contains(strings.ToLower(model), "codex") { + case string(constant.EndpointTypeOpenAIResponse): return &dto.OpenAIResponsesRequest{ Model: model, Input: json.RawMessage(`[{"role":"user","content":"hi"}]`), @@ -825,6 +975,52 @@ func buildTestRequest(model string, endpointType string, channel *model.Channel, return testRequest } +func resolveProbeModelName(result testResult, channel *model.Channel, requested string) string { + if result.testModel != "" { + return result.testModel + } + if requested = strings.TrimSpace(requested); requested != "" { + return requested + } + if channel != nil && channel.TestModel != nil { + if name := strings.TrimSpace(*channel.TestModel); name != "" { + return name + } + } + if channel != nil { + models := channel.GetModels() + if len(models) > 0 { + if name := strings.TrimSpace(models[0]); name != "" { + return name + } + } + } + return "" +} + +func recordChannelProbeMetric(result testResult, channel *model.Channel, requested string, latencyMs int64) { + modelName := resolveProbeModelName(result, channel, requested) + if modelName == "" { + return + } + probeGroup := "probe" + if result.relayInfo != nil && result.relayInfo.UsingGroup != "" { + probeGroup = result.relayInfo.UsingGroup + } + generationMs := int64(0) + if result.outputTokens > 0 && latencyMs > 0 { + generationMs = latencyMs + } + perfmetrics.Record(perfmetrics.Sample{ + Model: modelName, + Group: probeGroup, + LatencyMs: latencyMs, + Success: result.localErr == nil && result.newAPIError == nil, + OutputTokens: result.outputTokens, + GenerationMs: generationMs, + }) +} + func TestChannel(c *gin.Context) { channelId, err := strconv.Atoi(c.Param("id")) if err != nil { @@ -858,11 +1054,15 @@ func TestChannel(c *gin.Context) { requestCtx = c.Request.Context() } result := testChannel(requestCtx, channel, testUserID, testModel, endpointType, isStream) + tok := time.Now() + milliseconds := tok.Sub(tik).Milliseconds() + // Always record success and failure probes so success_rate stays honest. + go recordChannelProbeMetric(result, channel, testModel, milliseconds) if result.localErr != nil { resp := gin.H{ "success": false, "message": result.localErr.Error(), - "time": 0.0, + "time": float64(milliseconds) / 1000.0, } if result.newAPIError != nil { resp["error_code"] = result.newAPIError.GetErrorCode() @@ -870,10 +1070,9 @@ func TestChannel(c *gin.Context) { c.JSON(http.StatusOK, resp) return } - tok := time.Now() - milliseconds := tok.Sub(tik).Milliseconds() go channel.UpdateResponseTime(milliseconds) consumedTime := float64(milliseconds) / 1000.0 + if result.newAPIError != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -922,9 +1121,18 @@ func performChannelTests(ctx context.Context, channels []*model.Channel, testUse if channel.Status == common.ChannelStatusManuallyDisabled { continue } + if shouldSkipAutoChannelTest(channel) { + continue + } + // Only auto-probe chat-capable models so image/audio/video do not pollute perf_metrics. + autoModel := pickAutoTestModel(channel) + if autoModel == "" { + common.SysLog(fmt.Sprintf("skip auto test channel %d (%s): no chat-capable test model", channel.Id, channel.Name)) + continue + } isChannelEnabled := channel.Status == common.ChannelStatusEnabled tik := time.Now() - result := testChannel(ctx, channel, testUserID, "", "", shouldUseStreamForAutomaticChannelTest(channel)) + result := testChannel(ctx, channel, testUserID, autoModel, "", shouldUseStreamForAutomaticChannelTest(channel)) tok := time.Now() milliseconds := tok.Sub(tik).Milliseconds() if ctx != nil && ctx.Err() != nil { @@ -968,6 +1176,8 @@ func performChannelTests(ctx context.Context, channels []*model.Channel, testUse } channel.UpdateResponseTime(milliseconds) + // Record success and failure probes for model-square health badges. + recordChannelProbeMetric(result, channel, autoModel, milliseconds) if common.RequestInterval > 0 { if ctx == nil { time.Sleep(common.RequestInterval) diff --git a/controller/channel.go b/controller/channel.go index a2b5687ab319..1ef3a9acf5e7 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -233,7 +233,7 @@ func FetchUpstreamModels(c *gin.Context) { return } - ids, err := fetchChannelUpstreamModelIDs(channel) + ids, err := fetchChannelUpstreamModelIDs(c.Request.Context(), channel) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -870,6 +870,12 @@ type ChannelBatch struct { Tag *string `json:"tag"` } +// ChannelSkipAutoTestBatch toggles skip_auto_test on channel settings. +type ChannelSkipAutoTestBatch struct { + Ids []int `json:"ids"` + Skip bool `json:"skip"` +} + func DeleteChannelBatch(c *gin.Context) { channelBatch := ChannelBatch{} err := c.ShouldBindJSON(&channelBatch) @@ -1181,7 +1187,7 @@ func FetchModels(c *gin.Context) { key = strings.Split(key, "\n")[0] if req.Type == constant.ChannelTypeOllama { - models, err := ollama.FetchOllamaModels(baseURL, key) + models, err := ollama.FetchOllamaModels(c.Request.Context(), baseURL, key) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -1219,10 +1225,10 @@ func FetchModels(c *gin.Context) { return } - client := &http.Client{} + client := service.GetHttpClientWithTimeout(30 * time.Second) url := fmt.Sprintf("%s/v1/models", baseURL) - request, err := http.NewRequest("GET", url, nil) + request, err := http.NewRequestWithContext(c.Request.Context(), "GET", url, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "success": false, @@ -1303,6 +1309,48 @@ func BatchSetChannelTag(c *gin.Context) { return } +// BatchSetChannelSkipAutoTest sets skip_auto_test on selected channels. +// Manual channel tests remain available; only AutomaticallyTestChannels is gated. +func BatchSetChannelSkipAutoTest(c *gin.Context) { + req := ChannelSkipAutoTestBatch{} + if err := c.ShouldBindJSON(&req); err != nil || len(req.Ids) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "invalid parameters", + }) + return + } + updated := 0 + for _, id := range req.Ids { + channel, err := model.GetChannelById(id, true) + if err != nil || channel == nil { + continue + } + setting := channel.GetSetting() + if setting.SkipAutoTest == req.Skip { + updated++ + continue + } + setting.SkipAutoTest = req.Skip + channel.SetSetting(setting) + if err := channel.Update(); err != nil { + common.SysLog(fmt.Sprintf("batch skip_auto_test update failed id=%d: %v", id, err)) + continue + } + updated++ + } + model.InitChannelCache() + recordManageAudit(c, "channel.skip_auto_test_batch", map[string]interface{}{ + "count": updated, + "skip": req.Skip, + }) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": updated, + }) +} + func GetTagModels(c *gin.Context) { tag := c.Query("tag") if tag == "" { @@ -1958,7 +2006,7 @@ func OllamaPullModel(c *gin.Context) { } key := strings.Split(channel.Key, "\n")[0] - err = ollama.PullOllamaModel(baseURL, key, req.ModelName) + err = ollama.PullOllamaModel(c.Request.Context(), baseURL, key, req.ModelName) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "success": false, @@ -2036,7 +2084,7 @@ func OllamaPullModelStream(c *gin.Context) { } // 执行拉取 - err = ollama.PullOllamaModelStream(baseURL, key, req.ModelName, progressCallback) + err = ollama.PullOllamaModelStream(c.Request.Context(), baseURL, key, req.ModelName, progressCallback) if err != nil { errorData, _ := json.Marshal(gin.H{ @@ -2103,7 +2151,7 @@ func OllamaDeleteModel(c *gin.Context) { } key := strings.Split(channel.Key, "\n")[0] - err = ollama.DeleteOllamaModel(baseURL, key, req.ModelName) + err = ollama.DeleteOllamaModel(c.Request.Context(), baseURL, key, req.ModelName) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "success": false, @@ -2152,7 +2200,7 @@ func OllamaVersion(c *gin.Context) { } key := strings.Split(channel.Key, "\n")[0] - version, err := ollama.FetchOllamaVersion(baseURL, key) + version, err := ollama.FetchOllamaVersion(c.Request.Context(), baseURL, key) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, diff --git a/controller/channel_auto_test_helpers_test.go b/controller/channel_auto_test_helpers_test.go new file mode 100644 index 000000000000..c88e17055980 --- /dev/null +++ b/controller/channel_auto_test_helpers_test.go @@ -0,0 +1,93 @@ +package controller + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" +) + +func TestDetectProbeModelKind(t *testing.T) { + cases := []struct { + model string + want string + }{ + {"gpt-image-2", string(constant.EndpointTypeImageGeneration)}, + {"dall-e-3", string(constant.EndpointTypeImageGeneration)}, + {"seedream-3.0", string(constant.EndpointTypeImageGeneration)}, + {"text-embedding-3-small", string(constant.EndpointTypeEmbeddings)}, + {"bge-m3", string(constant.EndpointTypeEmbeddings)}, + {"jina-rerank-v2", string(constant.EndpointTypeJinaRerank)}, + {"gpt-5-codex", string(constant.EndpointTypeOpenAIResponse)}, + {"gpt-4o-mini", ""}, + {"claude-sonnet-4", ""}, + } + for _, tc := range cases { + if got := detectProbeModelKind(tc.model); got != tc.want { + t.Fatalf("detectProbeModelKind(%q)=%q want %q", tc.model, got, tc.want) + } + } +} + +func TestIsChatCapableProbeModel(t *testing.T) { + if !isChatCapableProbeModel("gpt-4o-mini") { + t.Fatal("gpt-4o-mini should be chat capable") + } + for _, name := range []string{"gpt-image-2", "whisper-1", "text-embedding-3-large", "sora-2"} { + if isChatCapableProbeModel(name) { + t.Fatalf("%s should not be chat capable", name) + } + } +} + +func TestPickAutoTestModel(t *testing.T) { + imageOnly := "gpt-image-2" + chat := "gpt-4o-mini" + ch := &model.Channel{Models: "gpt-image-2,gpt-4o-mini"} + if got := pickAutoTestModel(ch); got != chat { + t.Fatalf("pickAutoTestModel=%q want %q", got, chat) + } + ch.TestModel = &imageOnly + if got := pickAutoTestModel(ch); got != chat { + t.Fatalf("with image TestModel pick=%q want %q", got, chat) + } + ch.TestModel = &chat + if got := pickAutoTestModel(ch); got != chat { + t.Fatalf("with chat TestModel pick=%q want %q", got, chat) + } + ch.Models = "gpt-image-2,dall-e-3" + ch.TestModel = &imageOnly + if got := pickAutoTestModel(ch); got != "" { + t.Fatalf("image-only channel pick=%q want empty", got) + } +} + +func TestShouldSkipAutoChannelTest(t *testing.T) { + ch := &model.Channel{Status: common.ChannelStatusEnabled} + if shouldSkipAutoChannelTest(ch) { + t.Fatal("enabled channel should not skip") + } + ch.Status = common.ChannelStatusManuallyDisabled + if !shouldSkipAutoChannelTest(ch) { + t.Fatal("manually disabled should skip") + } + ch.Status = common.ChannelStatusEnabled + ch.SetSetting(dto.ChannelSettings{SkipAutoTest: true}) + if !shouldSkipAutoChannelTest(ch) { + t.Fatal("SkipAutoTest setting should skip") + } +} + +func TestNormalizeChannelTestEndpointInfersImage(t *testing.T) { + if got := normalizeChannelTestEndpoint(nil, "gpt-image-2", ""); got != string(constant.EndpointTypeImageGeneration) { + t.Fatalf("image endpoint=%q", got) + } + if got := normalizeChannelTestEndpoint(nil, "gpt-4o-mini", ""); got != "" { + t.Fatalf("chat endpoint should be empty, got %q", got) + } + if got := normalizeChannelTestEndpoint(nil, "gpt-4o-mini", "openai"); got != "openai" { + t.Fatalf("explicit endpoint not preserved: %q", got) + } +} diff --git a/controller/channel_upstream_update.go b/controller/channel_upstream_update.go index 122a9f6bf9e0..83d8e728eb76 100644 --- a/controller/channel_upstream_update.go +++ b/controller/channel_upstream_update.go @@ -231,7 +231,7 @@ func collectPendingUpstreamModelChangesFromModels( } func collectPendingUpstreamModelChanges(channel *model.Channel, settings dto.ChannelOtherSettings) (pendingAddModels []string, pendingRemoveModels []string, err error) { - upstreamModels, err := fetchChannelUpstreamModelIDs(channel) + upstreamModels, err := fetchChannelUpstreamModelIDs(context.Background(), channel) if err != nil { return nil, nil, err } @@ -255,7 +255,7 @@ func getUpstreamModelUpdateMinCheckIntervalSeconds() int64 { return interval } -func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) { +func fetchChannelUpstreamModelIDs(ctx context.Context, channel *model.Channel) ([]string, error) { baseURL := constant.ChannelBaseURLs[channel.Type] if channel.GetBaseURL() != "" { baseURL = channel.GetBaseURL() @@ -263,7 +263,7 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) { if channel.Type == constant.ChannelTypeOllama { key := strings.TrimSpace(strings.Split(channel.Key, "\n")[0]) - models, err := ollama.FetchOllamaModels(baseURL, key) + models, err := ollama.FetchOllamaModels(ctx, baseURL, key) if err != nil { return nil, err } diff --git a/controller/custom_oauth.go b/controller/custom_oauth.go index 8172e29718f3..045a5956362f 100644 --- a/controller/custom_oauth.go +++ b/controller/custom_oauth.go @@ -12,6 +12,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/oauth" + "github.com/QuantumNous/new-api/service" "github.com/gin-gonic/gin" ) @@ -176,7 +177,7 @@ func FetchCustomOAuthDiscovery(c *gin.Context) { } httpReq.Header.Set("Accept", "application/json") - client := &http.Client{Timeout: 20 * time.Second} + client := service.GetSSRFProtectedHTTPClientWithTimeout(20 * time.Second) resp, err := client.Do(httpReq) if err != nil { common.ApiErrorMsg(c, "获取 Discovery 配置失败: "+err.Error()) diff --git a/controller/group.go b/controller/group.go index 6ba339a3f9bd..10ae1f57f478 100644 --- a/controller/group.go +++ b/controller/group.go @@ -27,6 +27,13 @@ func GetUserGroups(c *gin.Context) { usableGroups := make(map[string]map[string]interface{}) userGroup := "" userId := c.GetInt("id") + if userId <= 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "登录后查看可用分组", + }) + return + } userGroup, _ = model.GetUserGroup(userId, false) userUsableGroups := service.GetUserUsableGroups(userGroup) for groupName, _ := range ratio_setting.GetGroupRatioCopy() { diff --git a/controller/health.go b/controller/health.go new file mode 100644 index 000000000000..1e4d18da6a7d --- /dev/null +++ b/controller/health.go @@ -0,0 +1,52 @@ +package controller + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" +) + +type readinessProbe func(context.Context) error + +func checkReadiness(ctx context.Context, databasePing readinessProbe, redisPing readinessProbe) (string, error) { + if err := databasePing(ctx); err != nil { + return "database", err + } + if redisPing != nil { + if err := redisPing(ctx); err != nil { + return "redis", err + } + } + return "", nil +} + +func GetReadiness(c *gin.Context) { + timeoutSeconds := common.GetEnvOrDefault("READINESS_TIMEOUT_SECONDS", 3) + if timeoutSeconds <= 0 { + timeoutSeconds = 3 + } + ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSeconds)*time.Second) + defer cancel() + + var redisPing readinessProbe + if common.RedisEnabled { + redisPing = func(ctx context.Context) error { + if common.RDB == nil { + return errors.New("redis client is not initialized") + } + return common.RDB.Ping(ctx).Err() + } + } + + component, err := checkReadiness(ctx, model.PingDBContext, redisPing) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable", "component": component}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} diff --git a/controller/health_test.go b/controller/health_test.go new file mode 100644 index 000000000000..0ab6b22781cf --- /dev/null +++ b/controller/health_test.go @@ -0,0 +1,42 @@ +package controller + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheckReadinessSucceedsWhenDependenciesAreHealthy(t *testing.T) { + component, err := checkReadiness( + context.Background(), + func(context.Context) error { return nil }, + func(context.Context) error { return nil }, + ) + + require.NoError(t, err) + require.Empty(t, component) +} + +func TestCheckReadinessReportsDatabaseFailure(t *testing.T) { + component, err := checkReadiness( + context.Background(), + func(context.Context) error { return errors.New("database unavailable") }, + nil, + ) + + require.Error(t, err) + require.Equal(t, "database", component) +} + +func TestCheckReadinessReportsRedisFailure(t *testing.T) { + component, err := checkReadiness( + context.Background(), + func(context.Context) error { return nil }, + func(context.Context) error { return errors.New("redis unavailable") }, + ) + + require.Error(t, err) + require.Equal(t, "redis", component) +} diff --git a/controller/log.go b/controller/log.go index ce9b4666fa5e..3fd5e31e8697 100644 --- a/controller/log.go +++ b/controller/log.go @@ -1,6 +1,7 @@ package controller import ( + "errors" "net/http" "strconv" @@ -12,17 +13,24 @@ import ( func GetAllLogs(c *gin.Context) { pageInfo := common.GetPageQuery(c) - logType, _ := strconv.Atoi(c.Query("type")) - startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) - endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) - username := c.Query("username") - tokenName := c.Query("token_name") - modelName := c.Query("model_name") - channel, _ := strconv.Atoi(c.Query("channel")) - group := c.Query("group") - requestId := c.Query("request_id") - upstreamRequestId := c.Query("upstream_request_id") - logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId, upstreamRequestId) + query := getLogQuery(c) + if isLogCursorPagination(c) { + logs, nextCursor, hasMore, err := model.GetAllLogsByCursor(query, c.Query("cursor"), pageInfo.GetPageSize(), pageInfo.GetStartIdx()) + if err != nil { + writeLogQueryError(c, err) + return + } + common.ApiSuccess(c, gin.H{ + "items": logs, + "page": pageInfo.GetPage(), + "page_size": pageInfo.GetPageSize(), + "has_more": hasMore, + "next_cursor": nextCursor, + }) + return + } + + logs, total, err := model.GetAllLogs(query, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) if err != nil { common.ApiError(c, err) return @@ -30,21 +38,61 @@ func GetAllLogs(c *gin.Context) { pageInfo.SetTotal(int(total)) pageInfo.SetItems(logs) common.ApiSuccess(c, pageInfo) - return } -func GetUserLogs(c *gin.Context) { - pageInfo := common.GetPageQuery(c) - userId := c.GetInt("id") +func getLogQuery(c *gin.Context) model.LogQuery { logType, _ := strconv.Atoi(c.Query("type")) startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) - tokenName := c.Query("token_name") - modelName := c.Query("model_name") - group := c.Query("group") - requestId := c.Query("request_id") - upstreamRequestId := c.Query("upstream_request_id") - logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId, upstreamRequestId) + channel, _ := strconv.Atoi(c.Query("channel")) + return model.LogQuery{ + LogType: logType, + StartTimestamp: startTimestamp, + EndTimestamp: endTimestamp, + ModelName: c.Query("model_name"), + Username: c.Query("username"), + TokenName: c.Query("token_name"), + Channel: channel, + Group: c.Query("group"), + RequestId: c.Query("request_id"), + UpstreamRequestId: c.Query("upstream_request_id"), + TraceId: c.Query("trace_id"), + } +} + +func isLogCursorPagination(c *gin.Context) bool { + return c.Query("pagination") == "cursor" || c.Request.URL.Query().Has("cursor") +} + +func writeLogQueryError(c *gin.Context, err error) { + if errors.Is(err, model.ErrInvalidLogCursor) { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid log cursor"}) + return + } + common.ApiError(c, err) +} + +func GetUserLogs(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + userId := c.GetInt("id") + query := getLogQuery(c) + if isLogCursorPagination(c) { + logs, nextCursor, hasMore, err := model.GetUserLogsByCursor(userId, query, c.Query("cursor"), pageInfo.GetPageSize(), pageInfo.GetStartIdx()) + if err != nil { + writeLogQueryError(c, err) + return + } + common.ApiSuccess(c, gin.H{ + "items": logs, + "page": pageInfo.GetPage(), + "page_size": pageInfo.GetPageSize(), + "has_more": hasMore, + "next_cursor": nextCursor, + }) + return + } + + logs, total, err := model.GetUserLogs(userId, query, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) if err != nil { common.ApiError(c, err) return @@ -52,7 +100,6 @@ func GetUserLogs(c *gin.Context) { pageInfo.SetTotal(int(total)) pageInfo.SetItems(logs) common.ApiSuccess(c, pageInfo) - return } // Deprecated: SearchAllLogs 已废弃,前端未使用该接口。 diff --git a/controller/misc.go b/controller/misc.go index fb2029878747..fe61ed6dcce8 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -14,6 +14,7 @@ import ( "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/oauth" + "github.com/QuantumNous/new-api/pkg/observability" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/console_setting" "github.com/QuantumNous/new-api/setting/operation_setting" @@ -93,6 +94,7 @@ func GetStatus(c *gin.Context) { "password_login_enabled": common.PasswordLoginEnabled, "password_register_enabled": common.PasswordRegisterEnabled, "default_use_auto_group": setting.DefaultUseAutoGroup, + "rum_enabled": observability.Enabled(), "usd_exchange_rate": operation_setting.USDExchangeRate, "price": operation_setting.Price, diff --git a/controller/option.go b/controller/option.go index a97f07b841b7..072cc8c12d46 100644 --- a/controller/option.go +++ b/controller/option.go @@ -232,6 +232,15 @@ func UpdateOption(c *gin.Context) { }) return } + case "GroupGroupRatio": + err = ratio_setting.CheckGroupGroupRatio(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } case "ImageRatio": err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string)) if err != nil { diff --git a/controller/perf_metrics.go b/controller/perf_metrics.go index 66d0787f2a92..f90775cb9ece 100644 --- a/controller/perf_metrics.go +++ b/controller/perf_metrics.go @@ -19,7 +19,17 @@ func GetPerfMetricsSummary(c *gin.Context) { } } - activeGroups := append(lo.Keys(ratio_setting.GetGroupRatioCopy()), "auto") + // Prefer configured groups; always keep probe/auto/default so channel tests + // and the common default group are never filtered out. When no group ratio + // is configured at all, query every group rather than returning an empty set. + groupRatio := ratio_setting.GetGroupRatioCopy() + var activeGroups []string + if len(groupRatio) == 0 { + activeGroups = nil + } else { + activeGroups = append(lo.Keys(groupRatio), "auto", "probe", "default") + activeGroups = lo.Uniq(activeGroups) + } result, err := perfmetrics.QuerySummaryAll(hours, activeGroups) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -75,8 +85,12 @@ func GetPerfMetrics(c *gin.Context) { func filterActiveGroups(groups []perfmetrics.GroupResult) []perfmetrics.GroupResult { activeRatios := ratio_setting.GetGroupRatioCopy() + // Empty group ratio means "don't filter" — same policy as summary. + if len(activeRatios) == 0 { + return groups + } return lo.Filter(groups, func(g perfmetrics.GroupResult, _ int) bool { _, ok := activeRatios[g.Group] - return ok || g.Group == "auto" + return ok || g.Group == "auto" || g.Group == "probe" || g.Group == "default" }) } diff --git a/controller/relay.go b/controller/relay.go index 6e91ccb60506..2e01d339dad1 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -6,6 +6,7 @@ import ( "io" "log" "net/http" + "net/url" "strings" "time" @@ -200,6 +201,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { addUsedChannel(c, channel.Id) bodyStorage, bodyErr := common.GetBodyStorage(c) if bodyErr != nil { + service.ReleaseAdaptiveCircuitPermit(c, channel.Id) // Ensure consistent 413 for oversized bodies even when error occurs later (e.g., retry path) if common.IsRequestBodyTooLargeError(bodyErr) || errors.Is(bodyErr, common.ErrRequestBodyTooLarge) { newAPIError = types.NewErrorWithStatusCode(bodyErr, types.ErrorCodeReadRequestBodyFailed, http.StatusRequestEntityTooLarge, types.ErrOptionWithSkipRetry()) @@ -210,15 +212,46 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { } c.Request.Body = io.NopCloser(bodyStorage) - switch relayFormat { - case types.RelayFormatOpenAIRealtime: - newAPIError = relay.WssHelper(c, relayInfo) - case types.RelayFormatClaude: - newAPIError = relay.ClaudeHelper(c, relayInfo) - case types.RelayFormatGemini: - newAPIError = geminiRelayHandler(c, relayInfo) - default: - newAPIError = relayHandler(c, relayInfo) + attemptStart := time.Now() + service.IncChannelConcurrency(channel.Id) + // Always dec even if helper panics (CustomRecovery still runs after). + func() { + defer service.DecChannelConcurrency(channel.Id) + switch relayFormat { + case types.RelayFormatOpenAIRealtime: + newAPIError = relay.WssHelper(c, relayInfo) + case types.RelayFormatClaude: + newAPIError = relay.ClaudeHelper(c, relayInfo) + case types.RelayFormatGemini: + newAPIError = geminiRelayHandler(c, relayInfo) + default: + newAPIError = relayHandler(c, relayInfo) + } + }() + { + statusCode := http.StatusOK + var recErr error + if newAPIError != nil { + statusCode = newAPIError.StatusCode + if statusCode == 0 { + statusCode = http.StatusInternalServerError + } + recErr = newAPIError + } + // Prefer UsingGroup (resolved auto group) so score buckets match selection. + metricGroup := relayInfo.UsingGroup + if metricGroup == "" { + metricGroup = relayInfo.TokenGroup + } + service.RecordAdaptiveResult( + c, + channel.Id, + metricGroup, + relayInfo.OriginModelName, + statusCode, + time.Since(attemptStart), + recErr, + ) } if newAPIError == nil { @@ -250,12 +283,30 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { var upgrader = websocket.Upgrader{ Subprotocols: []string{"realtime"}, // WS 握手支持的协议,如果有使用 Sec-WebSocket-Protocol,则必须在此声明对应的 Protocol TODO add other protocol - CheckOrigin: func(r *http.Request) bool { - return true // 允许跨域 - }, + CheckOrigin: isRealtimeWebSocketOriginAllowed, +} + +func isRealtimeWebSocketOriginAllowed(r *http.Request) bool { + if r == nil { + return false + } + originValue := strings.TrimSpace(r.Header.Get("Origin")) + if originValue == "" { + return true + } + + origin, err := url.Parse(originValue) + if err != nil || origin.Host == "" || (origin.Scheme != "http" && origin.Scheme != "https") { + return false + } + if strings.EqualFold(origin.Host, r.Host) { + return true + } + return common.ValidateRedirectURL(originValue) == nil } func addUsedChannel(c *gin.Context, channelId int) { + service.MarkChannelUsed(c, channelId) useChannel := c.GetStringSlice("use_channel") useChannel = append(useChannel, fmt.Sprintf("%d", channelId)) c.Set("use_channel", useChannel) @@ -317,6 +368,7 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service newAPIError := middleware.SetupContextForSelectedChannel(c, channel, info.OriginModelName) if newAPIError != nil { + service.ReleaseAdaptiveCircuitPermit(c, channel.Id) return nil, newAPIError } return channel, nil @@ -329,16 +381,22 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b if service.ShouldSkipRetryAfterChannelAffinityFailure(c) { return false } - if types.IsChannelError(openaiErr) { - return true + if retryTimes <= 0 { + return false } - if types.IsSkipRetryError(openaiErr) { + if _, ok := c.Get("specific_channel_id"); ok { return false } - if retryTimes <= 0 { + if openaiErr.GetErrorCode() == types.ErrorCodeGetChannelFailed { return false } - if _, ok := c.Get("specific_channel_id"); ok { + if isUpstreamChannelQuotaError(openaiErr) { + return true + } + if types.IsChannelError(openaiErr) { + return true + } + if types.IsSkipRetryError(openaiErr) { return false } code := openaiErr.StatusCode @@ -354,6 +412,44 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b return operation_setting.ShouldRetryByStatusCode(code) } +func isUpstreamChannelQuotaError(err *types.NewAPIError) bool { + if err == nil { + return false + } + code := strings.ToLower(strings.TrimSpace(string(err.GetErrorCode()))) + if code == string(types.ErrorCodeInsufficientUserQuota) || code == string(types.ErrorCodePreConsumeTokenQuotaFailed) { + return false + } + if err.StatusCode == http.StatusPaymentRequired { + return true + } + for _, marker := range []string{ + "insufficient_quota", + "quota_exceeded", + "billing_hard_limit_reached", + "insufficient_balance", + "insufficient_credits", + } { + if strings.Contains(code, marker) { + return true + } + } + message := strings.ToLower(err.Error()) + for _, marker := range []string{ + "insufficient quota", + "quota exceeded", + "insufficient balance", + "insufficient credit", + "额度不足", + "余额不足", + } { + if strings.Contains(message, marker) { + return true + } + } + return false +} + func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) { logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, common.LocalLogPreview(err.Error()))) // 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况 diff --git a/controller/relay_origin_test.go b/controller/relay_origin_test.go new file mode 100644 index 000000000000..ef0e44c072fa --- /dev/null +++ b/controller/relay_origin_test.go @@ -0,0 +1,43 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/constant" + "github.com/stretchr/testify/require" +) + +func TestRealtimeWebSocketOriginAllowed(t *testing.T) { + originalDomains := append([]string(nil), constant.TrustedRedirectDomains...) + constant.TrustedRedirectDomains = []string{"example.com"} + t.Cleanup(func() { + constant.TrustedRedirectDomains = originalDomains + }) + + tests := []struct { + name string + origin string + host string + want bool + }{ + {name: "missing origin", host: "api.internal", want: true}, + {name: "same origin", origin: "https://api.internal", host: "api.internal", want: true}, + {name: "trusted exact domain", origin: "https://example.com", host: "api.internal", want: true}, + {name: "trusted subdomain", origin: "https://console.example.com", host: "api.internal", want: true}, + {name: "untrusted domain", origin: "https://evil.example.net", host: "api.internal", want: false}, + {name: "suffix spoof", origin: "https://fakeexample.com", host: "api.internal", want: false}, + {name: "invalid scheme", origin: "file://example.com", host: "api.internal", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "https://"+tt.host+"/v1/realtime", nil) + if tt.origin != "" { + request.Header.Set("Origin", tt.origin) + } + require.Equal(t, tt.want, isRealtimeWebSocketOriginAllowed(request)) + }) + } +} diff --git a/controller/relay_retry_test.go b/controller/relay_retry_test.go new file mode 100644 index 000000000000..53d6f9b0ba4d --- /dev/null +++ b/controller/relay_retry_test.go @@ -0,0 +1,37 @@ +package controller + +import ( + "errors" + "net/http" + "testing" + + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestShouldRetryStopsAfterChannelSelectionFailure(t *testing.T) { + ctx, _ := gin.CreateTestContext(nil) + err := types.NewError(errors.New("no eligible channel"), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) + require.False(t, shouldRetry(ctx, err, 2)) +} + +func TestShouldRetrySwitchesChannelOnUpstreamQuotaExhaustion(t *testing.T) { + ctx, _ := gin.CreateTestContext(nil) + err := types.WithOpenAIError(types.OpenAIError{ + Message: "upstream account has insufficient balance", + Code: "insufficient_quota", + }, http.StatusTooManyRequests) + require.True(t, shouldRetry(ctx, err, 2)) +} + +func TestShouldRetryDoesNotSwitchForLocalUserQuota(t *testing.T) { + ctx, _ := gin.CreateTestContext(nil) + err := types.NewErrorWithStatusCode( + errors.New("user quota insufficient"), + types.ErrorCodeInsufficientUserQuota, + http.StatusForbidden, + types.ErrOptionWithSkipRetry(), + ) + require.False(t, shouldRetry(ctx, err, 2)) +} diff --git a/controller/rum.go b/controller/rum.go new file mode 100644 index 000000000000..3897fe06f225 --- /dev/null +++ b/controller/rum.go @@ -0,0 +1,56 @@ +package controller + +import ( + "math" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/pkg/observability" + "github.com/gin-gonic/gin" +) + +type webVitalSample struct { + Name string `json:"name"` + Value float64 `json:"value"` + Rating string `json:"rating"` +} + +func validateWebVitalSample(sample webVitalSample) (webVitalSample, bool) { + sample.Name = strings.ToUpper(strings.TrimSpace(sample.Name)) + sample.Rating = strings.ToLower(strings.TrimSpace(sample.Rating)) + if sample.Name != "CLS" && sample.Name != "INP" && sample.Name != "LCP" { + return sample, false + } + if sample.Rating != "good" && sample.Rating != "needs-improvement" && sample.Rating != "poor" { + return sample, false + } + if math.IsNaN(sample.Value) || math.IsInf(sample.Value, 0) || sample.Value < 0 { + return sample, false + } + if sample.Name == "CLS" && sample.Value > 100 { + return sample, false + } + if sample.Name != "CLS" && sample.Value > 600_000 { + return sample, false + } + return sample, true +} + +func RecordWebVital(c *gin.Context) { + if !observability.Enabled() { + c.Status(http.StatusNotFound) + return + } + var sample webVitalSample + if err := c.ShouldBindJSON(&sample); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid web vital sample"}) + return + } + sample, ok := validateWebVitalSample(sample) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid web vital sample"}) + return + } + observability.ObserveWebVital(sample.Name, sample.Rating, sample.Value) + c.Status(http.StatusNoContent) +} diff --git a/controller/rum_test.go b/controller/rum_test.go new file mode 100644 index 000000000000..496dba37c471 --- /dev/null +++ b/controller/rum_test.go @@ -0,0 +1,41 @@ +package controller + +import ( + "math" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestValidateWebVitalSample(t *testing.T) { + sample, ok := validateWebVitalSample(webVitalSample{Name: "lcp", Value: 2450, Rating: "good"}) + require.True(t, ok) + require.Equal(t, "LCP", sample.Name) + + for _, invalid := range []webVitalSample{ + {Name: "FID", Value: 10, Rating: "good"}, + {Name: "INP", Value: -1, Rating: "poor"}, + {Name: "CLS", Value: math.NaN(), Rating: "good"}, + {Name: "LCP", Value: 1000, Rating: "unknown"}, + } { + _, ok := validateWebVitalSample(invalid) + require.False(t, ok) + } +} + +func TestRecordWebVitalIsDisabledWithMetrics(t *testing.T) { + t.Setenv("METRICS_ENABLED", "false") + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/api/rum", RecordWebVital) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/rum", strings.NewReader(`{"name":"LCP","value":1000,"rating":"good"}`)) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusNotFound, recorder.Code) +} diff --git a/controller/token.go b/controller/token.go index 836e9b2952ac..c5cb4e542883 100644 --- a/controller/token.go +++ b/controller/token.go @@ -4,7 +4,6 @@ import ( "fmt" "net/http" "strconv" - "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/i18n" @@ -116,28 +115,9 @@ func GetTokenStatus(c *gin.Context) { } func GetTokenUsage(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - if authHeader == "" { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": "No Authorization header", - }) - return - } - - parts := strings.Split(authHeader, " ") - if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": "Invalid Bearer token", - }) - return - } - tokenKey := parts[1] - - token, err := model.GetTokenByKey(strings.TrimPrefix(tokenKey, "sk-"), false) + token, err := model.GetTokenById(c.GetInt("token_id")) if err != nil { - common.SysError("failed to get token by key: " + err.Error()) + common.SysError("failed to get token by id: " + err.Error()) common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed) return } diff --git a/controller/token_test.go b/controller/token_test.go index 12b1cbdd84fb..153184a94e00 100644 --- a/controller/token_test.go +++ b/controller/token_test.go @@ -16,6 +16,7 @@ import ( "github.com/QuantumNous/new-api/model" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -417,6 +418,32 @@ func TestGetAllTokensMasksKeyInResponse(t *testing.T) { } } +func TestGetTokenUsageUsesAuthenticatedTokenID(t *testing.T) { + db := setupTokenControllerTestDB(t) + token := seedToken(t, db, 7, "usage-token", "usage-key-with-suffix") + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, "/api/usage/token/", nil) + ctx.Request.Header.Set("Authorization", "Bearer sk-wrong-key-wrong-suffix") + ctx.Set("token_id", token.Id) + + GetTokenUsage(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + var response struct { + Code bool `json:"code"` + Data struct { + Name string `json:"name"` + TotalGranted int `json:"total_granted"` + } `json:"data"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.True(t, response.Code) + require.Equal(t, token.Name, response.Data.Name) + require.Equal(t, token.RemainQuota+token.UsedQuota, response.Data.TotalGranted) +} + func TestSearchTokensMasksKeyInResponse(t *testing.T) { db := setupTokenControllerTestDB(t) token := seedToken(t, db, 1, "searchable-token", "ijkl1234mnop5678") diff --git a/controller/topup_creem.go b/controller/topup_creem.go index 7472690e22fb..2bb26c2aafef 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -12,6 +12,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting" "io" "net/http" @@ -420,9 +421,7 @@ func genCreemLink(ctx context.Context, referenceId string, product *CreemProduct logger.LogInfo(ctx, fmt.Sprintf("Creem 支付请求已发送 api_url=%s product_id=%s email=%q trade_no=%s", apiUrl, product.ProductId, email, referenceId)) // 发送请求 - client := &http.Client{ - Timeout: 30 * time.Second, - } + client := service.GetHttpClientWithTimeout(30 * time.Second) resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("发送HTTP请求失败: %v", err) diff --git a/controller/trace.go b/controller/trace.go new file mode 100644 index 000000000000..5b9feaff0e1c --- /dev/null +++ b/controller/trace.go @@ -0,0 +1,65 @@ +package controller + +import ( + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" +) + +// GetTraceLogs returns logs for an AxonHub-style trace_id (admin). +// GET /api/log/trace/:trace_id +func GetTraceLogs(c *gin.Context) { + traceId := strings.TrimSpace(c.Param("trace_id")) + if traceId == "" { + traceId = strings.TrimSpace(c.Query("trace_id")) + } + if traceId == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "trace_id required"}) + return + } + logs, err := model.GetLogsByTraceId(traceId, 200) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()}) + return + } + // strip nothing extra for admin; ensure Other is parseable map for UI + items := make([]gin.H, 0, len(logs)) + for _, l := range logs { + if l == nil { + continue + } + other, _ := common.StrToMap(l.Other) + traceId := l.TraceId + if traceId == "" { + traceId, _ = other["trace_id"].(string) + } + items = append(items, gin.H{ + "id": l.Id, + "created_at": l.CreatedAt, + "type": l.Type, + "model_name": l.ModelName, + "channel": l.ChannelId, + "token_name": l.TokenName, + "quota": l.Quota, + "use_time": l.UseTime, + "is_stream": l.IsStream, + "group": l.Group, + "request_id": l.RequestId, + "content": l.Content, + "other": other, + "thread_id": other["thread_id"], + "trace_id": traceId, + }) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "trace_id": traceId, + "count": len(items), + "logs": items, + }, + }) +} diff --git a/controller/uptime_kuma.go b/controller/uptime_kuma.go index 2beceb426f8d..a1ad472da46c 100644 --- a/controller/uptime_kuma.go +++ b/controller/uptime_kuma.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/console_setting" "github.com/gin-gonic/gin" @@ -138,7 +139,7 @@ func GetUptimeKumaStatus(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) defer cancel() - client := &http.Client{Timeout: httpTimeout} + client := service.GetHttpClientWithTimeout(httpTimeout) results := make([]UptimeGroupResult, len(groups)) g, gCtx := errgroup.WithContext(ctx) diff --git a/controller/user.go b/controller/user.go index 6316fd13121a..47d1116a4a35 100644 --- a/controller/user.go +++ b/controller/user.go @@ -171,7 +171,16 @@ func setupLogin(user *model.User, c *gin.Context) { func Logout(c *gin.Context) { session := sessions.Default(c) + // 清空会话数据,并强制 MaxAge=-1,确保浏览器删除 Secure session cookie。 + // 仅 Clear()+Save() 在部分客户端上会留下空 cookie,导致登出后仍被当成半登录态。 session.Clear() + session.Options(sessions.Options{ + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: common.SessionCookieSecure, + SameSite: http.SameSiteStrictMode, + }) err := session.Save() if err != nil { c.JSON(http.StatusOK, gin.H{ diff --git a/controller/wechat.go b/controller/wechat.go index 8889daca77db..93b085f35f12 100644 --- a/controller/wechat.go +++ b/controller/wechat.go @@ -11,6 +11,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -31,9 +32,7 @@ func getWeChatIdByCode(code string) (string, error) { return "", err } req.Header.Set("Authorization", common.WeChatServerToken) - client := http.Client{ - Timeout: 5 * time.Second, - } + client := service.GetHttpClientWithTimeout(5 * time.Second) httpResponse, err := client.Do(req) if err != nil { return "", err diff --git a/deploy/prometheus/new-api-alerts.yml b/deploy/prometheus/new-api-alerts.yml new file mode 100644 index 000000000000..4a085c6362f2 --- /dev/null +++ b/deploy/prometheus/new-api-alerts.yml @@ -0,0 +1,45 @@ +groups: + - name: new-api-slo + rules: + - alert: NewAPIRelayHighErrorRate + expr: | + sum(rate(newapi_http_requests_total{route_class="relay",status=~"5.."}[5m])) + / + clamp_min(sum(rate(newapi_http_requests_total{route_class="relay"}[5m])), 0.001) + > 0.01 + for: 10m + labels: + severity: page + annotations: + summary: Relay 5xx ratio is above 1% + + - alert: NewAPIManagementLatencyHigh + expr: | + histogram_quantile(0.95, + sum by (le) (rate(newapi_http_request_duration_seconds_bucket{route_class="api"}[10m])) + ) > 0.5 + for: 15m + labels: + severity: warning + annotations: + summary: Management API P95 latency is above 500ms + + - alert: NewAPIInFlightRequestsHigh + expr: sum(newapi_http_requests_in_flight) > 800 + for: 10m + labels: + severity: warning + annotations: + summary: In-flight request count is unusually high + + - alert: NewAPIWebVitalsPoor + expr: | + sum(rate(newapi_web_vital_value_count{rating="poor"}[15m])) + / + clamp_min(sum(rate(newapi_web_vital_value_count[15m])), 0.001) + > 0.25 + for: 30m + labels: + severity: warning + annotations: + summary: More than 25% of reported Web Vitals are poor diff --git a/deploy/separated/Dockerfile.frontend b/deploy/separated/Dockerfile.frontend new file mode 100644 index 000000000000..9345f1f8972c --- /dev/null +++ b/deploy/separated/Dockerfile.frontend @@ -0,0 +1,52 @@ +# Frontend-only image: builds web/default and serves via non-root Nginx with same-origin API proxy. +# Build context MUST be the authoritative repository root (D:\newapi\src). +# +# NGINX_IMAGE may be a tag or repo@sha256 digest. Quality CI resolves the tag to a digest +# at build time so the runtime base is pinned even when the Dockerfile default is a tag. + +# NGINX_IMAGE may be a tag or repo@sha256 digest. Quality CI re-resolves the floating tag +# at build time; the default below is the digest last pinned by CI on 2026-07-18. +ARG NGINX_IMAGE=nginxinc/nginx-unprivileged@sha256:65e3e85dbaed8ba248841d9d58a899b6197106c23cb0ff1a132b7bfe0547e4c0 + +FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder + +WORKDIR /build/web +COPY web/package.json web/bun.lock ./ +COPY web/default/package.json ./default/package.json +COPY web/classic/package.json ./classic/package.json +RUN bun install --frozen-lockfile +COPY ./web/default ./default +COPY ./VERSION /build/VERSION +RUN test -s /build/VERSION \ + && cd default \ + && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build + +# nginxinc unprivileged image listens on 8080 and runs as non-root by default. +FROM ${NGINX_IMAGE} + +USER root +RUN apk add --no-cache curl gettext \ + && mkdir -p /etc/nginx/templates /var/log/nginx \ + && chown -R nginx:nginx /etc/nginx /var/cache/nginx /var/log/nginx /usr/share/nginx/html + +COPY --from=builder /build/web/default/dist /usr/share/nginx/html +COPY deploy/separated/nginx.conf.template /etc/nginx/templates/nginx.conf.template +COPY deploy/separated/docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod 755 /docker-entrypoint.sh \ + && chown -R nginx:nginx /usr/share/nginx/html + +USER nginx +ENV BACKEND_UPSTREAM=backend:3000 \ + DNS_RESOLVER=127.0.0.11 \ + NGINX_PORT=8080 \ + SERVER_NAME=_ \ + CLIENT_MAX_BODY_SIZE=100m \ + PROXY_CONNECT_TIMEOUT=60s \ + PROXY_SEND_TIMEOUT=3600s \ + PROXY_READ_TIMEOUT=3600s + +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -fsS "http://127.0.0.1:${NGINX_PORT}/frontend-healthz" | grep -q '"status":"ok"' + +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/deploy/separated/README.md b/deploy/separated/README.md new file mode 100644 index 000000000000..c9e283a4642d --- /dev/null +++ b/deploy/separated/README.md @@ -0,0 +1,134 @@ +# Separated frontend/backend delivery + +This directory packages the recommended same-origin split: + +1. **Backend image** (`Dockerfile.backend` at repo root) — Go binary built with `-tags frontend_external`, no Bun, no embedded `web/*/dist`. +2. **Frontend image** (`Dockerfile.frontend` here) — builds only `web/default`, serves static files on non-root port 8080, reverse-proxies API/Relay/SSE/WebSocket to the backend. + +The monorepo and the default embedded Docker image remain fully supported. + +## Why same-origin + +Prefer: + +```text +browser --> frontend Nginx (:8080) + |-- /assets, SPA + +-- /api /v1 /v1beta /mj /pg /suno /kling /jimeng /healthz /livez /readyz --> backend (:3000) +``` + +Benefits: + +- Session cookies stay first-party (no extra CORS credential surface). +- CSRF and OAuth callback hosts stay on a single public origin. +- SSE and WebSocket upgrade stay on the same host the SPA already uses. + +Cross-origin `FRONTEND_MODE=redirect` is supported by the backend for multi-host layouts, but expands Cookie/CORS/OAuth risk and is not the default recommendation. + +## Build + +From the authoritative repository root (`D:\newapi\src`, never `_qn_tmp`): + +```bash +# Pure backend (no frontend dist required) +docker build -f Dockerfile.backend -t new-api-backend:local . + +# Default SPA frontend + Nginx proxy +docker build -f deploy/separated/Dockerfile.frontend -t new-api-frontend:local . + +# Or compose +docker compose -f deploy/separated/docker-compose.yml build +``` + +Local Go equivalent: + +```bash +go build -trimpath -buildvcs=true -tags frontend_external -o new-api-backend . +FRONTEND_MODE=disabled ./new-api-backend +``` + +## Runtime configuration + +| Variable | Component | Notes | +|---|---|---| +| `FRONTEND_MODE` | backend | `disabled` (pure API) or `redirect` (jump unknown pages to another origin). Backend image defaults to `disabled`. | +| `FRONTEND_BASE_URL` | backend | Required for `redirect`; must be an absolute HTTP(S) origin with no path/query/userinfo. | +| `BACKEND_UPSTREAM` | frontend | Nginx upstream host:port, default `backend:3000`. Resolved at request time. | +| `DNS_RESOLVER` | frontend | Resolver for deferred upstream DNS; default Docker DNS `127.0.0.11`. | +| `NGINX_PORT` | frontend | Listen port inside container, default `8080`. | +| `TRUSTED_PROXY_CIDRS` | backend | Must include the frontend/proxy network so client IPs from `X-Forwarded-For` are trusted. | +| `SESSION_COOKIE_SECURE` / `SESSION_COOKIE_TRUSTED_URL` | backend | Configure for HTTPS production entries. | + +Do **not** publish `/metrics` on the public frontend edge. Scrape metrics on the backend network with `METRICS_TOKEN`. + +## Nginx coverage + +Proxied with original path + query preserved: + +- `/api`, `/v1`, `/v1beta`, `/mj`, `/:mode/mj`, `/pg`, `/suno`, `/kling`, `/jimeng` +- `/healthz`, `/livez`, `/readyz` +- `/v1/realtime` WebSocket (`Upgrade` / `Connection`) +- Streaming endpoints (`proxy_buffering off`, long read/send timeouts) + +Local SPA health: `GET /frontend-healthz`. + +SPA rules: + +- `try_files` → `index.html` +- `/assets/*` long-cache immutable +- `index.html` `Cache-Control: no-cache` + +## Validate Nginx config + +Inside a running frontend container (or a one-shot build): + +```bash +docker run --rm --entrypoint /bin/sh new-api-frontend:local -c 'nginx -t -c /etc/nginx/nginx.conf' +# or after entrypoint substitution during start +``` + +The entrypoint always runs `nginx -t` before `daemon off`. + +Quality CI pulls `nginxinc/nginx-unprivileged:1.27-alpine`, resolves its registry digest, and builds with +`--build-arg NGINX_IMAGE=`. Local builds may still use the tag default; prefer the CI-resolved +digest when freezing a production frontend image. + +## Makefile shortcuts + +From repository root: + +```bash +make build-backend # go build -tags frontend_external +make docker-backend # Dockerfile.backend +make docker-frontend # deploy/separated/Dockerfile.frontend +make docker-separated # backend + frontend images +``` + +## Compose smoke checklist + +Automated: + +```bash +# bash +FRONTEND_BASE=http://127.0.0.1:8080 ./deploy/separated/smoke.sh + +# Windows PowerShell +powershell -NoProfile -ExecutionPolicy Bypass -File .\deploy\separated\smoke.ps1 +``` + +Manual extras: + +1. Confirm SSE is not buffered (chat stream). +2. Confirm WebSocket upgrade works for `/v1/realtime`. +3. Confirm `/metrics` remains 404 on the public edge. + +## Rollback + +- Configuration-only: point traffic back to the monolithic image (`Dockerfile`) or a single binary with default embedded assets and unset/override `FRONTEND_MODE`. +- Image-only: redeploy the previous integrated `new-api` image; database migrations remain additive. + +See also: + +- `docs/operations/runtime-separation.md` +- `docs/operations/build-and-release.md` +- `docs/adr/0001-frontend-backend-delivery-seam.md` diff --git a/deploy/separated/docker-compose.yml b/deploy/separated/docker-compose.yml new file mode 100644 index 000000000000..0d0ed42b363d --- /dev/null +++ b/deploy/separated/docker-compose.yml @@ -0,0 +1,78 @@ +# Same-origin frontend + pure backend example stack. +# Build from the authoritative repository root: +# docker compose -f deploy/separated/docker-compose.yml build +# Required secrets must be provided via environment or an env file (never commit real values). + +services: + backend: + image: ${NEW_API_BACKEND_IMAGE:-new-api-backend:local} + build: + context: ../.. + dockerfile: Dockerfile.backend + container_name: new-api-backend + restart: always + command: --log-dir /app/logs + environment: + - FRONTEND_MODE=disabled + - RUN_MODE=${RUN_MODE:-all} + - APP_PLANE=${APP_PLANE:-all} + - SQL_DSN=${SQL_DSN:?Set SQL_DSN} + - REDIS_CONN_STRING=${REDIS_CONN_STRING:-} + - TZ=Asia/Shanghai + - ERROR_LOG_ENABLED=true + - BATCH_UPDATE_ENABLED=true + - NODE_NAME=new-api-backend-1 + - TRUSTED_PROXY_CIDRS=${TRUSTED_PROXY_CIDRS:-172.16.0.0/12} + - METRICS_ENABLED=${METRICS_ENABLED:-false} + - METRICS_TOKEN=${METRICS_TOKEN:-} + - SESSION_SECRET=${SESSION_SECRET:-} + - SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-} + - SESSION_COOKIE_TRUSTED_URL=${SESSION_COOKIE_TRUSTED_URL:-} + volumes: + - backend_data:/data + - backend_logs:/app/logs + networks: + - new-api-separated + healthcheck: + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:3000/readyz | grep -q '\"status\":\"ok\"'"] + interval: 30s + timeout: 10s + retries: 3 + # Backend is intentionally not published; only the frontend edge is exposed. + + frontend: + image: ${NEW_API_FRONTEND_IMAGE:-new-api-frontend:local} + build: + context: ../.. + dockerfile: deploy/separated/Dockerfile.frontend + container_name: new-api-frontend + restart: always + depends_on: + backend: + condition: service_healthy + environment: + - BACKEND_UPSTREAM=backend:3000 + - DNS_RESOLVER=127.0.0.11 + - NGINX_PORT=8080 + - SERVER_NAME=_ + - CLIENT_MAX_BODY_SIZE=100m + - PROXY_CONNECT_TIMEOUT=60s + - PROXY_SEND_TIMEOUT=3600s + - PROXY_READ_TIMEOUT=3600s + ports: + - "${FRONTEND_PUBLISH_PORT:-8080}:8080" + networks: + - new-api-separated + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/frontend-healthz | grep -q '\"status\":\"ok\"'"] + interval: 30s + timeout: 5s + retries: 3 + +networks: + new-api-separated: + driver: bridge + +volumes: + backend_data: + backend_logs: diff --git a/deploy/separated/docker-entrypoint.sh b/deploy/separated/docker-entrypoint.sh new file mode 100755 index 000000000000..f4d84652a8c0 --- /dev/null +++ b/deploy/separated/docker-entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# 渲染 nginx 模板并以前台模式启动,供独立前端镜像 entrypoint 使用。 +set -eu + +export BACKEND_UPSTREAM="${BACKEND_UPSTREAM:-backend:3000}" +export NGINX_PORT="${NGINX_PORT:-8080}" +export SERVER_NAME="${SERVER_NAME:-_}" +export CLIENT_MAX_BODY_SIZE="${CLIENT_MAX_BODY_SIZE:-100m}" +export PROXY_CONNECT_TIMEOUT="${PROXY_CONNECT_TIMEOUT:-60s}" +export PROXY_SEND_TIMEOUT="${PROXY_SEND_TIMEOUT:-3600s}" +export PROXY_READ_TIMEOUT="${PROXY_READ_TIMEOUT:-3600s}" +# Docker 内置 DNS;非 Docker 环境可改为可用解析器。 +export DNS_RESOLVER="${DNS_RESOLVER:-127.0.0.11}" + +TEMPLATE="/etc/nginx/templates/nginx.conf.template" +TARGET="/etc/nginx/nginx.conf" + +if [ ! -f "$TEMPLATE" ]; then + echo "missing nginx template: $TEMPLATE" >&2 + exit 1 +fi + +# 只替换已知占位符,避免误伤 nginx 变量($host、$uri 等)。 +envsubst '${BACKEND_UPSTREAM} ${NGINX_PORT} ${SERVER_NAME} ${CLIENT_MAX_BODY_SIZE} ${PROXY_CONNECT_TIMEOUT} ${PROXY_SEND_TIMEOUT} ${PROXY_READ_TIMEOUT} ${DNS_RESOLVER}' \ + < "$TEMPLATE" > "$TARGET" + +# 启动前做配置语法检查,失败直接退出容器。 +nginx -t -c "$TARGET" + +exec nginx -g 'daemon off;' -c "$TARGET" diff --git a/deploy/separated/nginx.conf.template b/deploy/separated/nginx.conf.template new file mode 100644 index 000000000000..05e295cf35bc --- /dev/null +++ b/deploy/separated/nginx.conf.template @@ -0,0 +1,235 @@ +# Nginx SPA + same-origin reverse proxy for new-api frontend delivery. +# Placeholders are substituted by docker-entrypoint.sh via envsubst. +# +# Upstream uses a variable + resolver so: +# 1) nginx -t works without the backend hostname existing (CI / cold start) +# 2) Docker Compose service names are resolved at request time via 127.0.0.11 + +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + server_tokens off; + client_max_body_size ${CLIENT_MAX_BODY_SIZE}; + + # Streaming / SSE / WebSocket-friendly upgrade map. + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + # Long-lived relay streams and realtime sockets need extended idle timeouts. + proxy_connect_timeout ${PROXY_CONNECT_TIMEOUT}; + proxy_send_timeout ${PROXY_SEND_TIMEOUT}; + proxy_read_timeout ${PROXY_READ_TIMEOUT}; + + # Docker embedded DNS by default; override for non-Docker runtimes. + resolver ${DNS_RESOLVER} valid=10s ipv6=off; + + server { + listen ${NGINX_PORT}; + server_name ${SERVER_NAME}; + root /usr/share/nginx/html; + index index.html; + + # Independent frontend health for container orchestrators (does not probe backend). + location = /frontend-healthz { + access_log off; + default_type application/json; + return 200 '{"status":"ok","component":"frontend"}'; + } + + # Do not expose Prometheus metrics on the public frontend edge by default. + location = /metrics { + return 404; + } + + # Fingerprinted static assets: long cache, immutable. + location /assets/ { + try_files $uri =404; + access_log off; + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # Shared proxy settings for API, relay, SSE, and WebSocket paths. + # Variable proxy_pass defers DNS lookup to request time and keeps $request_uri intact. + location /api/ { + set $backend_upstream ${BACKEND_UPSTREAM}; + proxy_pass http://$backend_upstream$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_request_buffering off; + proxy_cache off; + } + + location /v1/ { + set $backend_upstream ${BACKEND_UPSTREAM}; + proxy_pass http://$backend_upstream$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_buffering off; + proxy_request_buffering off; + proxy_cache off; + proxy_read_timeout ${PROXY_READ_TIMEOUT}; + proxy_send_timeout ${PROXY_SEND_TIMEOUT}; + } + + location /v1beta/ { + set $backend_upstream ${BACKEND_UPSTREAM}; + proxy_pass http://$backend_upstream$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_request_buffering off; + proxy_cache off; + } + + location /mj/ { + set $backend_upstream ${BACKEND_UPSTREAM}; + proxy_pass http://$backend_upstream$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + } + + # Midjourney mode-prefixed paths: /:mode/mj/... + location ~ ^/[^/]+/mj(/|$) { + set $backend_upstream ${BACKEND_UPSTREAM}; + proxy_pass http://$backend_upstream$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + } + + location /pg/ { + set $backend_upstream ${BACKEND_UPSTREAM}; + proxy_pass http://$backend_upstream$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + } + + location /suno/ { + set $backend_upstream ${BACKEND_UPSTREAM}; + proxy_pass http://$backend_upstream$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + } + + location /kling/ { + set $backend_upstream ${BACKEND_UPSTREAM}; + proxy_pass http://$backend_upstream$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + } + + location /jimeng { + set $backend_upstream ${BACKEND_UPSTREAM}; + proxy_pass http://$backend_upstream$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + } + + # Backend liveness/readiness through the same origin (optional operational path). + location = /healthz { + set $backend_upstream ${BACKEND_UPSTREAM}; + proxy_pass http://$backend_upstream/healthz; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + access_log off; + } + + location = /livez { + set $backend_upstream ${BACKEND_UPSTREAM}; + proxy_pass http://$backend_upstream/livez; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + access_log off; + } + + location = /readyz { + set $backend_upstream ${BACKEND_UPSTREAM}; + proxy_pass http://$backend_upstream/readyz; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + access_log off; + } + + # SPA fallback: never cache the HTML shell so releases roll out immediately. + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache" always; + } + } +} diff --git a/deploy/separated/smoke.ps1 b/deploy/separated/smoke.ps1 new file mode 100644 index 000000000000..cb03ef90575a --- /dev/null +++ b/deploy/separated/smoke.ps1 @@ -0,0 +1,80 @@ +# Same-origin separated stack smoke checks (Windows PowerShell). +# Usage: +# powershell -NoProfile -ExecutionPolicy Bypass -File .\deploy\separated\smoke.ps1 +# powershell -NoProfile -ExecutionPolicy Bypass -File .\deploy\separated\smoke.ps1 -FrontendBase http://127.0.0.1:8080 +param( + [string]$FrontendBase = 'http://127.0.0.1:8080' +) + +$ErrorActionPreference = 'Stop' +$FrontendBase = $FrontendBase.TrimEnd('/') +$pass = 0 +$fail = 0 + +function Invoke-Check { + param( + [string]$Name, + [scriptblock]$Body + ) + try { + & $Body + Write-Host "PASS $Name" + $script:pass++ + } catch { + Write-Host "FAIL $Name : $($_.Exception.Message)" + $script:fail++ + } +} + +Write-Host "Smoke against $FrontendBase" + +Invoke-Check 'frontend-healthz' { + $r = Invoke-WebRequest -UseBasicParsing -Uri "$FrontendBase/frontend-healthz" -TimeoutSec 15 + if ($r.StatusCode -ne 200) { throw "status $($r.StatusCode)" } + if ($r.Content -notmatch '"status"\s*:\s*"ok"') { throw 'missing status ok' } +} + +Invoke-Check 'spa index' { + $r = Invoke-WebRequest -UseBasicParsing -Uri "$FrontendBase/" -TimeoutSec 15 + if ($r.StatusCode -ne 200) { throw "status $($r.StatusCode)" } +} + +Invoke-Check 'api status via proxy' { + $r = Invoke-WebRequest -UseBasicParsing -Uri "$FrontendBase/api/status" -TimeoutSec 15 + if ($r.StatusCode -ne 200) { throw "status $($r.StatusCode)" } + if ($r.Content -notmatch '\{') { throw 'non-json body' } +} + +Invoke-Check 'v1 without token is 401' { + try { + Invoke-WebRequest -UseBasicParsing -Uri "$FrontendBase/v1/models" -TimeoutSec 15 | Out-Null + throw 'expected 401' + } catch { + $resp = $_.Exception.Response + if (-not $resp) { throw $_ } + $code = [int]$resp.StatusCode + if ($code -ne 401) { throw "status $code" } + } +} + +Invoke-Check 'readyz via proxy' { + $r = Invoke-WebRequest -UseBasicParsing -Uri "$FrontendBase/readyz" -TimeoutSec 15 + if ($r.StatusCode -ne 200 -and $r.StatusCode -ne 503) { throw "status $($r.StatusCode)" } + if ($r.Content -notmatch '"status"') { throw 'missing status field' } +} + +Invoke-Check 'metrics blocked on edge' { + try { + Invoke-WebRequest -UseBasicParsing -Uri "$FrontendBase/metrics" -TimeoutSec 15 | Out-Null + throw 'expected 404' + } catch { + $resp = $_.Exception.Response + if (-not $resp) { throw $_ } + $code = [int]$resp.StatusCode + if ($code -ne 404) { throw "status $code" } + } +} + +Write-Host "" +Write-Host "passed=$pass failed=$fail" +if ($fail -ne 0) { exit 1 } diff --git a/deploy/separated/smoke.sh b/deploy/separated/smoke.sh new file mode 100755 index 000000000000..f0ae4b2409d8 --- /dev/null +++ b/deploy/separated/smoke.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Same-origin separated stack smoke checks. +# Usage: +# FRONTEND_BASE=http://127.0.0.1:8080 ./deploy/separated/smoke.sh +set -euo pipefail + +FRONTEND_BASE="${FRONTEND_BASE:-http://127.0.0.1:8080}" +FRONTEND_BASE="${FRONTEND_BASE%/}" + +pass=0 +fail=0 + +check() { + local name="$1" + shift + if "$@"; then + echo "PASS ${name}" + pass=$((pass + 1)) + else + echo "FAIL ${name}" + fail=$((fail + 1)) + fi +} + +body_has() { + local url="$1" + local needle="$2" + local code + local body + body="$(curl -fsS --max-time 15 "${url}")" || return 1 + printf '%s' "${body}" | grep -q "${needle}" +} + +http_code() { + local method="$1" + local url="$2" + local expect="$3" + local code + code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 -X "${method}" "${url}")" || return 1 + test "${code}" = "${expect}" +} + +echo "Smoke against ${FRONTEND_BASE}" +check "frontend-healthz" body_has "${FRONTEND_BASE}/frontend-healthz" '"status":"ok"' +check "spa index" http_code GET "${FRONTEND_BASE}/" 200 +check "api status via proxy" body_has "${FRONTEND_BASE}/api/status" '{' +check "v1 without token is 401" http_code GET "${FRONTEND_BASE}/v1/models" 401 +check "readyz via proxy" body_has "${FRONTEND_BASE}/readyz" '"status"' +check "metrics blocked on edge" http_code GET "${FRONTEND_BASE}/metrics" 404 + +echo +echo "passed=${pass} failed=${fail}" +test "${fail}" -eq 0 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f98f4b0d8d1d..a9cbdca96027 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,10 +1,12 @@ -# Frontend Development - Backend built from local source +# Frontend Development - Backend built from local source (pure API, no embedded SPA) # # Usage: # 1. docker compose -f docker-compose.dev.yml up -d # 2. cd web && bun install && bun run dev # 3. Open http://localhost:3001 (Rsbuild dev server, API auto-proxied to :3000) # +# Backend image uses Dockerfile.dev (-tags frontend_external, FRONTEND_MODE=disabled). +# # Rebuild backend after Go code changes: # docker compose -f docker-compose.dev.yml up -d --build new-api # @@ -13,7 +15,6 @@ # # Reset data: # docker compose -f docker-compose.dev.yml down -v - services: new-api: build: @@ -31,10 +32,14 @@ services: - REDIS_CONN_STRING=redis://redis - TZ=Asia/Shanghai - BATCH_UPDATE_ENABLED=true + # Pure API for local bun dev (web proxies to :3000). Override to embedded only with integrated image. + - FRONTEND_MODE=disabled # Enable only when accessing the dev backend through HTTPS. SESSION_COOKIE_TRUSTED_URL is required when true. # - SESSION_COOKIE_SECURE=true # - SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com - depends_on: + # When bun/vite proxies from another host, set CORS or use same-origin proxy only. + # - CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3001 + # - TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128 depends_on: redis: condition: service_started postgres: diff --git a/docker-compose.yml b/docker-compose.yml index f5881f4a24cc..1eee05027017 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ # New-API Docker Compose Configuration # # Quick Start: -# 1. docker-compose up -d +# 1. docker compose up -d # 2. Access at http://localhost:3000 # # Using MySQL instead of PostgreSQL: @@ -10,13 +10,11 @@ # 3. Uncomment mysql in depends_on (line 28) # 4. Uncomment mysql_data in volumes section (line 64) # -# ⚠️ IMPORTANT: Change all default passwords before deploying to production! - -version: '3.4' # For compatibility with older Docker versions +# Required environment: NEW_API_IMAGE, POSTGRES_PASSWORD, REDIS_PASSWORD. services: new-api: - image: calciumion/new-api:latest + image: ${NEW_API_IMAGE:?Set NEW_API_IMAGE to a versioned image or digest} container_name: new-api restart: always command: --log-dir /app/logs @@ -26,15 +24,20 @@ services: - ./data:/data - ./logs:/app/logs environment: - - SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production! -# - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL -# - LOG_SQL_DSN=postgresql://root:123456@postgres:5432/new-api-log # OPTIONAL: If you want a separate database for logging, uncomment and set this -# - LOG_SQL_DSN=clickhouse://default:123456@clickhouse:9000/new_api_logs # OPTIONAL: Use ClickHouse for logs only; also uncomment clickhouse in depends_on and the clickhouse service below + - SQL_DSN=postgresql://${POSTGRES_USER:-newapi}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-new-api} +# - SQL_DSN=${MYSQL_USER:-newapi}:${MYSQL_PASSWORD:?Set MYSQL_PASSWORD}@tcp(mysql:3306)/${MYSQL_DATABASE:-new-api} # Point to the mysql service, uncomment if using MySQL +# - LOG_SQL_DSN=postgresql://${POSTGRES_USER:-newapi}:${POSTGRES_LOG_PASSWORD:?Set POSTGRES_LOG_PASSWORD}@postgres:5432/new-api-log # OPTIONAL: Use a separate database/user in production +# - LOG_SQL_DSN=clickhouse://${CLICKHOUSE_USER:-newapi}:${CLICKHOUSE_PASSWORD:?Set CLICKHOUSE_PASSWORD}@clickhouse:9000/new_api_logs # OPTIONAL: Use ClickHouse for logs only # - LOG_SQL_CLICKHOUSE_TTL_DAYS=0 # OPTIONAL: ClickHouse log retention days. Unset or 0 disables automatic deletion; set to e.g. 30 to keep 30 days - - REDIS_CONN_STRING=redis://:123456@redis:6379 # ⚠️ IMPORTANT: Change the password in production! + - REDIS_CONN_STRING=redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD}@redis:6379 - TZ=Asia/Shanghai - ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording) - BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update) + - RUN_MODE=${RUN_MODE:-all} + - APP_PLANE=${APP_PLANE:-all} + - METRICS_ENABLED=${METRICS_ENABLED:-false} + - METRICS_TOKEN=${METRICS_TOKEN:-} +# - TRUSTED_PROXY_CIDRS=172.16.0.0/12 # Set only to the actual reverse-proxy network; unset ignores forwarded IP headers - NODE_NAME=new-api-node-1 # 节点名称,用于审计日志中标识节点身份;多节点/容器部署时建议设置 (Node name used in audit logs; recommended when running multiple instances or in containers) # - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions) # - RELAY_IDLE_CONN_TIMEOUT=90 # Relay HTTP 客户端空闲连接超时时间,单位秒,默认跟随 Go 标准库,设置为0表示不限制 (Relay HTTP client idle keep-alive timeout in seconds, defaults to Go standard library; set 0 to disable) @@ -54,27 +57,27 @@ services: networks: - new-api-network healthcheck: - test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"] + test: ["CMD-SHELL", "if [ '${RUN_MODE:-all}' = 'all' ] || [ '${RUN_MODE:-all}' = 'serve' ]; then wget -q -O - http://localhost:3000/readyz | grep -q '\"status\":\"ok\"'; else kill -0 1; fi"] interval: 30s timeout: 10s retries: 3 redis: - image: redis:latest + image: redis:7.4.2-alpine container_name: redis restart: always - command: ["redis-server", "--requirepass", "123456"] # ⚠️ IMPORTANT: Change this password in production! + command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?Set REDIS_PASSWORD}"] networks: - new-api-network postgres: - image: postgres:15 + image: postgres:15.10-alpine container_name: postgres restart: always environment: - POSTGRES_USER: root - POSTGRES_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! - POSTGRES_DB: new-api + POSTGRES_USER: ${POSTGRES_USER:-newapi} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-new-api} volumes: - pg_data:/var/lib/postgresql/data networks: @@ -83,12 +86,14 @@ services: # - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker # mysql: -# image: mysql:8.2 +# image: mysql:8.4.6 # container_name: mysql # restart: always # environment: -# MYSQL_ROOT_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! -# MYSQL_DATABASE: new-api +# MYSQL_USER: ${MYSQL_USER:-newapi} +# MYSQL_PASSWORD: ${MYSQL_PASSWORD:?Set MYSQL_PASSWORD} +# MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD} +# MYSQL_DATABASE: ${MYSQL_DATABASE:-new-api} # volumes: # - mysql_data:/var/lib/mysql # networks: @@ -97,13 +102,13 @@ services: # - "3306:3306" # Uncomment if you need to access MySQL from outside Docker # clickhouse: -# image: clickhouse/clickhouse-server:24.8 +# image: clickhouse/clickhouse-server:24.8.14.39-alpine # container_name: clickhouse # restart: always # environment: # CLICKHOUSE_DB: new_api_logs -# CLICKHOUSE_USER: default -# CLICKHOUSE_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! +# CLICKHOUSE_USER: ${CLICKHOUSE_USER:-newapi} +# CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?Set CLICKHOUSE_PASSWORD} # CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 # volumes: # - clickhouse_data:/var/lib/clickhouse diff --git a/docs/adr/0001-frontend-backend-delivery-seam.md b/docs/adr/0001-frontend-backend-delivery-seam.md new file mode 100644 index 000000000000..678df71bb364 --- /dev/null +++ b/docs/adr/0001-frontend-backend-delivery-seam.md @@ -0,0 +1,103 @@ +# ADR 0001: Frontend/backend delivery seam + +- Status: Accepted +- Date: 2026-07-18 +- Context: monorepo `new-api` historically embeds dual React themes into a single Go binary + +## Decision summary + +Keep a **single Git repository**. Keep the **embedded dual-theme binary as the default compatibility path**. Add an explicit **delivery seam** so operators can also ship: + +1. a pure Go backend (`-tags frontend_external` + `FRONTEND_MODE=disabled|redirect`) +2. a standalone default-theme SPA served by Nginx that reverse-proxies API/Relay traffic on the **same public origin** + +## Why not two repositories + +- Product, release tags, `VERSION`, CI quality gates, and operational docs already share one revision. +- Dual-repo split would force synchronized versioning for SPA ↔ API contracts (`/api/status`, OAuth callbacks, cookie names) without removing the need for integration tests. +- Contributors already navigate `router/`, `controller/`, and `web/default/` in one tree; the cost of monorepo coupling is lower than the cost of split release coordination for this product stage. + +## Why keep embedded by default + +- Existing Windows single-exe, Docker Compose, and Electron-style distributions depend on one artifact. +- Rollback and incident response stay simple: replace one binary/image. +- The new seam is opt-in; operators who do not set `FRONTEND_MODE` or the build tag keep prior behavior (`auto` + embed). + +## Build tag: `frontend_external` + +| File | Build constraint | Role | +|---|---|---| +| `frontend_assets_embedded.go` | `//go:build !frontend_external` | `//go:embed` both themes; inject analytics into index HTML | +| `frontend_assets_external.go` | `//go:build frontend_external` | Returns empty `ThemeAssets` | + +`main` always calls `prepareFrontendAssets()` then `router.SetRouterForPlane(...)`. Embedded mode refuses empty assets via `ThemeAssets.Available()` so a pure-backend binary cannot panic inside `EmbedFolder`. + +## Runtime: `FRONTEND_MODE` + +| Value | Semantics | +|---|---| +| `auto` | Legacy: non-master + `FRONTEND_BASE_URL` → redirect; otherwise embed | +| `embedded` | Force embed; error if assets missing | +| `redirect` | Force redirect to origin `FRONTEND_BASE_URL` even on master | +| `disabled` | No web `NoRoute`; pure API 404 for unknown paths | + +`FRONTEND_BASE_URL` in redirect mode must be an absolute HTTP(S) origin: no userinfo, path (except empty/`/`), query, or fragment. Redirects preserve `RequestURI` (path + query). + +## Why same-origin frontend→backend proxy is recommended + +Preferred production layout: + +```text +Public origin (HTTPS) + └── Nginx frontend container + ├── static SPA (/ and /assets) + └── reverse proxy → backend:3000 for + /api /v1 /v1beta /mj /:mode/mj /pg /suno /kling /jimeng + /healthz /livez /readyz +``` + +Consequences: + +| Concern | Same-origin proxy | Cross-origin SPA + API | +|---|---|---| +| Session cookies | First-party; existing `SESSION_COOKIE_*` keys stay sufficient | Needs careful `SameSite`, domain, and often broader CORS allowlist with credentials | +| CSRF | Same site model preserved | Cross-site form/fetch surface expands | +| OAuth callbacks | Single public host | Must register extra redirect URIs and trusted URLs | +| SSE | `proxy_buffering off` + long timeouts on one host | Browser CORS + buffering at each edge hop | +| WebSocket `/v1/realtime` | `Upgrade` / `Connection` on same host | Extra origin checks and sticky proxy rules | + +`/metrics` is **not** proxied on the public frontend edge; scrape on the backend network with `METRICS_TOKEN`. + +## Images and CI + +- `Dockerfile` — integrated (unchanged default) +- `Dockerfile.backend` — Go 1.26.5 builder, Debian runtime, `-tags frontend_external`, default `FRONTEND_MODE=disabled`, no Bun +- `deploy/separated/Dockerfile.frontend` — Bun 1.3.14 (pinned digest) builds `web/default`; `nginxinc/nginx-unprivileged` on 8080 +- Quality workflow builds all three images and runs `nginx -t` on the rendered frontend config + +## Rollback + +1. **Config only:** point the edge back to an integrated binary/image; unset `FRONTEND_MODE` or set `auto`. +2. **Artifact only:** redeploy the previous integrated image digest/tag from the release inventory. +3. **DB:** migrations remain additive; no schema down-migration is required for this delivery change. + +## Alternatives considered + +1. **Two repositories** — rejected for release coupling (see above). +2. **Backend serves SPA from volume mount without embed** — possible later; current seam prefers an immutable frontend image for cache headers and independent scaling. +3. **Default to separated images** — rejected; would break existing single-artifact operators without notice. +4. **Expand CORS as the primary multi-host strategy** — allowed via existing middleware for deliberate multi-origin setups, not recommended as the default path for cookie sessions. + +## Consequences + +- Positive: independent frontend deploys, smaller pure-backend images, clearer APP_PLANE/RUN_MODE + UI delivery matrix. +- Negative: two more Dockerfiles and CI image builds; operators must set `TRUSTED_PROXY_CIDRS` correctly when Nginx sits in front. +- Neutral: classic theme is still embedded in the integrated path; separated image currently ships default theme only (classic remains available via integrated build or a future sibling image). + +## References + +- `frontend_assets_embedded.go` / `frontend_assets_external.go` +- `router/main.go` (`parseFrontendMode`, `setFrontendRouter`, …) +- `deploy/separated/README.md` +- `docs/operations/runtime-separation.md` +- `docs/operations/build-and-release.md` diff --git a/docs/operations/build-and-release.md b/docs/operations/build-and-release.md new file mode 100644 index 000000000000..7015af99e231 --- /dev/null +++ b/docs/operations/build-and-release.md @@ -0,0 +1,71 @@ +# Build and release boundary + +## Authoritative source + +Build and release the customized application only from the Git repository at `D:\newapi\src`. +The sibling `D:\newapi\_qn_tmp` directory is an upstream reference clone. It is not a release source +and must not be mixed into the build context. + +## Version rules + +- `VERSION` is a non-empty development fallback. +- Tagged release workflows use `git describe --tags` and inject the resolved value into + `github.com/QuantumNous/new-api/common.Version`. +- Go VCS metadata must remain enabled so `go version -m ` records the source revision and + whether the source tree was modified. +- Go and Bun versions are pinned in quality and release workflows. + +## Delivery artifacts + +| Artifact | How to build | Frontend assets | +|---|---|---| +| Integrated binary / `Dockerfile` | Default `go build` after building both web themes | Embedded dual theme | +| Pure backend / `Dockerfile.backend` | `go build -tags frontend_external` | None; set `FRONTEND_MODE=disabled` or `redirect` | +| Frontend SPA / `deploy/separated/Dockerfile.frontend` | Bun build of `web/default` + Nginx | Static only; proxies API to backend | + +Quality CI exercises all three image paths plus `nginx -t` on the rendered frontend config. +The integrated image remains the default compatibility path. + +Local pure-backend example: + +```powershell +go build -trimpath -buildvcs=true -tags frontend_external -o new-api-backend.exe . +$env:FRONTEND_MODE = 'disabled' +.\new-api-backend.exe +``` + +Separated compose example (from repo root): + +```bash +make docker-separated +docker compose -f deploy/separated/docker-compose.yml build +FRONTEND_BASE=http://127.0.0.1:8080 ./deploy/separated/smoke.sh +``` + +Frontend runtime base pinning: quality CI resolves `nginxinc/nginx-unprivileged:1.27-alpine` to a +digest and passes `NGINX_IMAGE` as a build-arg. Prefer that digest when freezing production images. + +## Windows release evidence + +From `D:\newapi\src`, build a release and generate its evidence files: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-release.ps1 +``` + +To inventory an existing binary without rebuilding it: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-release.ps1 ` + -ExistingBinary D:\newapi\new-api-fixed.exe ` + -OutputDirectory D:\newapi\release-manifests ` + -AllowDirty +``` + +The script writes a SHA-256 file, Go build/dependency inventory, and JSON manifest. It verifies the +embedded VCS revision against the current authoritative repository HEAD. The Go inventory is useful +traceability evidence but is not a standardized SBOM; release signing and a pinned SBOM generator +remain separate release requirements. + +Official builds require a clean working tree. `-AllowDirty` exists only for diagnostics and current +binary inventory. diff --git a/docs/operations/cleanup-after-healthcheck-20260718.md b/docs/operations/cleanup-after-healthcheck-20260718.md new file mode 100644 index 000000000000..eb4fa047ee38 --- /dev/null +++ b/docs/operations/cleanup-after-healthcheck-20260718.md @@ -0,0 +1,29 @@ +KEEP logout-fix2-20260718 135.6MB (current prod artifact) +DEL 135.6MB D:\newapi\release-manifests\logout-fix-20260718 +DEL 135.6MB D:\newapi\release-manifests\hsts-prod-20260718 +DEL 135.6MB D:\newapi\release-manifests\noroute-fix-20260718 +DEL 135.9MB D:\newapi\release-manifests\zh-unwrap-20260718 +DEL 0.01MB D:\newapi\docs\_run_healthcheck.py +DEL 0.00MB D:\newapi\docs\_healthcheck-raw.csv +TOTAL_FREED_MB=542.8 +--- remaining top --- +.agents 0.0 MB +.env 0.0 MB +.git 0.0 MB +_qn_tmp 36.1 MB +_qn_tmp.UPSTREAM-REFERENCE.md 0.0 MB +backups 182.3 MB +data 27.0 MB +docs 0.3 MB +logs 0.3 MB +new-api-fixed.exe 135.6 MB +overview.md 0.0 MB +register-task-com.ps1 0.0 MB +register-task.bat 0.0 MB +release-manifests 135.6 MB +run-tunnel.bat 0.0 MB +scripts 0.0 MB +src 2052.2 MB +start-newapi-hidden.ps1 0.0 MB +swap-restart-opt3.ps1 0.0 MB +tools 10.1 MB \ No newline at end of file diff --git a/docs/operations/cloudflare-hsts-runbook.md b/docs/operations/cloudflare-hsts-runbook.md new file mode 100644 index 000000000000..a7e3b7bc2c53 --- /dev/null +++ b/docs/operations/cloudflare-hsts-runbook.md @@ -0,0 +1,73 @@ +# Cloudflare HSTS 操作说明(控制台,不改应用代码) + +- **日期**:2026-07-18 +- **Zone**:`incc.qzz.io`(Zone ID `5809d3b745bd78542b59f0d852a66167`,Account `1f96bc464a296cbf14ed104073ccba08`) +- **Tunnel**:`incc-newapi` → `http://localhost:3000` +- **为何本机未自动打开 HSTS**: + - Wrangler OAuth token **无** `zone_settings:edit`(读 `security_header` 返回 403 Unauthorized)。 + - 浏览器自动化打开 `dash.cloudflare.com` 被 **CF 人机验证**拦截。 +- **推荐**:你在已登录的浏览器中按下列点击开启(**不改 new-api 代码、不重启 exe**)。 + +--- + +## 推荐参数(对单域名生产站) + +| 项 | 建议值 | 说明 | +|----|--------|------| +| Enable HSTS (Strict-Transport-Security) | **On** | 浏览器强制 HTTPS | +| Max Age Header | **6 months**(15768000)或 12 months | 首次可 6 个月,稳定后 12 个月 | +| Apply HSTS policy to subdomains (includeSubDomains) | **Off**(若只有 `incc.qzz.io` 且无子域业务) | 有 `www` 且均 HTTPS 才考虑 On | +| Preload | **Off**(除非你明确要提交 HSTS preload 列表) | 误开会很难撤销 | +| No-Sniff header | 可 On | 与 HSTS 同页常见选项 | + +同时确认: + +- SSL/TLS → Overview:**Full (strict)** +- SSL/TLS → Edge Certificates:**Always Use HTTPS = On**(你侧 HTTP 已 301,多半已开) + +--- + +## 点击路径 + +1. 登录 https://dash.cloudflare.com +2. 选择站点 **incc.qzz.io** +3. 左侧 **SSL/TLS** → **Edge Certificates** +4. 找到 **HTTP Strict Transport Security (HSTS)** → **Enable HSTS** +5. 按上表设置 → Save +6. 验证(任选): + +```bash +curl -sSI https://incc.qzz.io/ | findstr /i strict +``` + +期望类似: + +```text +strict-transport-security: max-age=15768000 +``` + +(具体 max-age 以你选的为准;若在 CF 开,头由 CF 注入,源站 new-api 无需改。) + +--- + +## 回滚 + +同一页面关闭 HSTS 或把 max-age 调为 0;已缓存 HSTS 的浏览器会保留到 max-age 到期。 + +--- + +## 自动化缺口(可选后续) + +若希望脚本开关 HSTS,创建 **API Token** 权限至少: + +- Zone → Zone Settings → Edit +- Zone → Zone → Read + +绑定 zone `incc.qzz.io` 后: + +```http +PATCH /zones/{zone_id}/settings/security_header +``` + +body 使用 CF 文档中的 `strict_transport_security` 结构。 +**不要**把 token 写进仓库或聊天记录。 diff --git a/docs/operations/cookie-https-readonly-checklist.md b/docs/operations/cookie-https-readonly-checklist.md new file mode 100644 index 000000000000..ebbd1924ee23 --- /dev/null +++ b/docs/operations/cookie-https-readonly-checklist.md @@ -0,0 +1,301 @@ +# Cookie / HTTPS 只读核对清单 + +- **日期**:2026-07-18 +- **环境**:生产 `D:\newapi` + 公网 `https://incc.qzz.io` +- **运行版本**:`v1.0.0-rc.21-34-gbbddd729` / PID **32544** +- **方法**:只读;**不输出** `.env` 密钥/SESSION_SECRET 值;不改配置、不重启服务、不登录真实账号写 Cookie +- **权威源码行为**: + - `common/session_cookie.go` — `SESSION_COOKIE_SECURE` + `SESSION_COOKIE_TRUSTED_URL` 启动校验 + - `main.go` — `sessions.Options{ Path:/, MaxAge:30d, HttpOnly:true, Secure:env, SameSite:Strict }` + - `trusted_proxy.go` — `TRUSTED_PROXY_CIDRS` 控制 Gin 是否信任 `X-Forwarded-For` + +--- + +## 0. 执行摘要 + +| 项 | 结论 | 等级 | +|----|------|------| +| Cookie 配置键名 | 正确使用 `SESSION_COOKIE_SECURE` / `SESSION_COOKIE_TRUSTED_URL`(无错误 `SESSION_SECURE`) | 通过 | +| Secure Cookie 已启用 | `SESSION_COOKIE_SECURE=TRUE`;近期启动日志**无** “Session cookie is not secure” 警告 | 通过 | +| Trusted URL 形态 | 1 条:`https://incc.qzz.io`(https、有 host、无 userinfo/query) | 通过 | +| 与公网入口一致性 | 公网 host = `incc.qzz.io`,与 Trusted URL host 一致 | 通过 | +| HTTPS 可达 | `https://incc.qzz.io/*` 200;应用版本头匹配生产 | 通过 | +| HTTP→HTTPS | `http://incc.qzz.io/` → **301** `https://incc.qzz.io/`(Cloudflare) | 通过 | +| TLS 证书 | Let's Encrypt,CN=`incc.qzz.io`,有效至 **2026-09-25** | 通过(注意续期) | +| HSTS | 响应中 **无** `Strict-Transport-Security` | **缺口** | +| 直连 3000 | 监听 `::`;防火墙规则 **Block direct TCP 3000** 已启用 | 通过 | +| Tunnel | 服务 `Cloudflared` Running;进程存在 | 通过 | +| Trusted Proxy CIDR | 仅 `127.0.0.1/32`、`::1/128`(适合本机 cloudflared 回源) | 通过(在「仅本机回源」假设下) | +| 登录后 Set-Cookie 实测 | **未做**(避免真实登录);属性依赖源码常量 | 残余风险 | + +**总体**:生产 Cookie/HTTPS **配置与入口对齐良好**,可认为 Secure Cookie 链路在配置层已闭环。剩余主要是 **HSTS 未开**、**登录后 Cookie 属性需浏览器人工点验**、以及 **CF Access/WAF 策略未导出**。 + +--- + +## 1. 源码契约(应如何配) + +### 1.1 环境变量 + +| 变量 | 约束(源码) | +|------|----------------| +| `SESSION_COOKIE_SECURE` | 仅 `true` / `false` / 空。空或 `false` 时 **禁止** 再设 Trusted URL | +| `SESSION_COOKIE_TRUSTED_URL` | `SECURE=true` 时**必填**;逗号分隔;每项必须是 **https + host** | +| `SESSION_SECRET` | 会话签名密钥(本清单不读值;仅确认 nonEmpty) | +| `TRUSTED_PROXY_CIDRS` | 逗号分隔 CIDR;空 = 完全不信任转发头 | + +### 1.2 运行时 Cookie 选项(`main.go`) + +| 属性 | 值 | +|------|-----| +| Name | `session` | +| Path | `/` | +| MaxAge | 2592000(30 天) | +| HttpOnly | **true** | +| Secure | **`SESSION_COOKIE_SECURE`** | +| SameSite | **Strict** | + +### 1.3 Trusted URL 的作用边界 + +启动时校验 Trusted URL 列表并写入 `SessionCookieTrustedURLs`。 +本审计范围内:**Secure 开关本身由 `SESSION_COOKIE_SECURE` 全局决定**,不是按请求 Host 动态切换。Trusted URL 用于强制「开 Secure 时必须声明可信 HTTPS 入口」。 + +--- + +## 2. 生产 `.env` 只读结果(无密钥) + +### 2.1 键存在性 + +| 键 | present | nonEmpty | +|----|---------|----------| +| `SESSION_SECRET` | 是 | 是 | +| `SESSION_COOKIE_SECURE` | 是 | 是 | +| `SESSION_COOKIE_TRUSTED_URL` | 是 | 是 | +| `TRUSTED_PROXY_CIDRS` | 是 | 是 | +| `PORT` | 是 | 是 | +| `SQLITE_PATH` | 是 | 是 | +| `FRONTEND_BASE_URL` | **否** | — | +| `CORS_ALLOWED_ORIGINS` | **否** | — | +| `METRICS_ENABLED` | **否** | — | +| `METRICS_TOKEN` | **否** | — | + +说明: + +- 未设 `FRONTEND_BASE_URL` / `CORS_ALLOWED_ORIGINS` 符合**同源一体化**部署,合理。 +- Metrics 默认关,符合当前 `/metrics` 404 行为。 + +### 2.2 布尔与形态(安全可展示) + +| 项 | 结果 | +|----|------| +| `SESSION_COOKIE_SECURE` | **TRUE** | +| `SESSION_COOKIE_TRUSTED_URL` 条数 | **1** | +| 条目 1 | scheme=`https` host=`incc.qzz.io` port=443 path=`/` userinfo=否 query=否 absolute=是 | +| `TRUSTED_PROXY_CIDRS` | `127.0.0.1/32`,`::1/128` | + +### 2.3 与错误配置对照 + +| 历史问题 | 本环境 | +|----------|--------| +| 脚本写 `SESSION_SECURE`(源码不读) | `.env` **无**该键 | +| Secure=true 但缺 Trusted URL | **未出现**(两者皆 nonEmpty) | +| Trusted URL 用 http | **未出现**(仅 https) | + +--- + +## 3. 传输与入口 + +### 3.1 HTTPS / HTTP + +| 检查 | 结果 | +|------|------| +| `https://incc.qzz.io/livez` | 200 JSON;`x-new-api-version=v1.0.0-rc.21-34-gbbddd729` | +| `https://incc.qzz.io/` | 200 HTML;`Cache-Control: no-cache` | +| `https://incc.qzz.io/api/status` | 200 JSON | +| `https://incc.qzz.io/sign-in` | 200 HTML | +| `http://incc.qzz.io/` | **301** → `https://incc.qzz.io/`(Cloudflare) | +| `http://incc.qzz.io/livez` | 502(非 HTTPS 路径;以 301 首页为准) | +| `Strict-Transport-Security` | **响应中缺失** | + +### 3.2 TLS 证书 + +| 项 | 值 | +|----|-----| +| Subject | CN=`incc.qzz.io` | +| Issuer | Let's Encrypt (YE2) | +| 有效期 | 2026-06-27 → **2026-09-25** | +| 算法 | sha384ECDSA | +| 剩余天数(审计日 2026-07-18) | 约 **69 天** | + +建议:确认 cloudflared/源站或 CF 自动续期;到期前 14 天再查一次。 + +### 3.3 边缘与进程 + +| 项 | 结果 | +|----|------| +| Cloudflare | `Server: cloudflare`,有 `CF-RAY` | +| 本机 Tunnel | 服务 `Cloudflared` = Running;进程 cloudflared 存在 | +| 应用监听 | `:: :3000` Listen(PID 32544) | +| 防火墙 | 规则 **「New API - Block direct TCP 3000」Inbound Block Enabled** | +| 本机回环 | `127.0.0.1:3000` 可连(预期,供 Tunnel/本机) | + +--- + +## 4. Cookie 属性(代码 + 运行时缺口) + +### 4.1 代码保证(生产已 SECURE=true) + +登录成功后发出的 `session` Cookie **应**为: + +- `HttpOnly` +- `Secure` +- `SameSite=Strict` +- `Path=/` +- Max-Age ≈ 30 天 + +### 4.2 本清单未完成的实测 + +| 检查 | 状态 | 原因 | +|------|------|------| +| 真实登录后 DevTools 看 `Set-Cookie` | **未做** | 避免使用真实账号/写入会话 | +| 错误密码是否 Set-Cookie | **未做** | 同上(可选用一次性测试号) | + +**推荐人工 2 分钟验证(你本机浏览器):** + +1. 打开 `https://incc.qzz.io/sign-in`(无痕窗口)。 +2. 登录成功后 F12 → Application → Cookies → `https://incc.qzz.io`。 +3. 核对 `session`: + - Secure = ✅ + - HttpOnly = ✅ + - SameSite = **Strict** + - Path = `/` +4. 用 `http://` 无法保留该 Cookie(浏览器应拒绝 Secure Cookie)。 + +### 4.3 未认证请求 + +| URL | Set-Cookie | +|-----|------------| +| `https://incc.qzz.io/api/user/self` 401 | 无 | +| `http://127.0.0.1:3000/api/user/self` 401 | 无 | +| `/livez` `/api/status` `/` | 无 | + +符合「未建会话不写 Cookie」。 + +--- + +## 5. 代理信任与客户端 IP + +| 项 | 结果 | +|----|------| +| `TRUSTED_PROXY_CIDRS` | 仅 loopback | +| 含义 | 仅当请求来自 127.0.0.1/::1 时,Gin 才采信 `X-Forwarded-For` / `X-Real-IP` | +| 与 Tunnel 匹配度 | cloudflared 本机回源时 **正确**;若改为局域网反代/多跳,需把反代出口 CIDR 写进列表 | + +**风险**:CIDR 过宽 → IP 伪造;过窄且反代不在 loopback → 审计 IP 全是反代地址。当前「本机 tunnel」模型合适。 + +--- + +## 6. CORS / 同源 + +| 项 | 结果 | +|----|------| +| `CORS_ALLOWED_ORIGINS` | 未配置 | +| `FRONTEND_BASE_URL` | 未配置 | +| 部署形态 | 一体化 + 公网单 origin `https://incc.qzz.io` | + +结论:Cookie 会话走**第一方同源**,无需为控制台扩大 CORS。若将来前后端分离且不同 origin,必须重开 CORS + 再审 SameSite/Trusted URL。 + +--- + +## 7. 核对矩阵(勾选表) + +### A. 配置层(本机已代勾) + +- [x] 使用 `SESSION_COOKIE_SECURE` 而非 `SESSION_SECURE` +- [x] `SESSION_COOKIE_SECURE=true` +- [x] `SESSION_COOKIE_TRUSTED_URL` 非空且全为 https +- [x] Trusted host 与公网入口 host 一致(`incc.qzz.io`) +- [x] `SESSION_SECRET` 存在且非空(值未读) +- [x] 启动日志无 “Session cookie is not secure” +- [x] `TRUSTED_PROXY_CIDRS` 存在;当前为 loopback +- [x] 公网 HTTPS 200 且版本头匹配 +- [x] HTTP 首页 301 到 HTTPS +- [x] 3000 有 inbound block +- [x] cloudflared 在跑 +- [ ] **HSTS 响应头**(未通过) +- [ ] **登录后 Cookie 属性浏览器点验**(待人工) +- [ ] **CF Access / WAF / Tunnel 路由只读导出**(未取控制台) +- [ ] **证书续期机制确认**(到期 2026-09-25) + +### B. 建议的人工 / 控制台项 + +1. **浏览器 Cookie 点验**(见 §4.2) +2. **Cloudflare Dashboard**(只读截图/导出): + - SSL/TLS 模式(建议 Full (strict)) + - Always Use HTTPS + - HSTS 是否在 CF 层开启(可补源站缺失) + - Tunnel Public Hostname → `http://127.0.0.1:3000` + - 是否有 Access 策略覆盖管理路径 +3. **OAuth 回调**(若启用 GitHub/OIDC 等):回调 URL 是否仅为 `https://incc.qzz.io/...` +4. **多入口**:若还有自定义域,必须追加到 `SESSION_COOKIE_TRUSTED_URL`(改配置需确认后重启) + +--- + +## 8. 发现项与优先级 + +| ID | 发现 | 等级 | 建议 | +|----|------|------|------| +| C1 | 无 HSTS 响应头 | P1 | 在 Cloudflare 启用 HSTS(含 includeSubDomains 需谨慎);或源站中间件添加(改代码/反代) | +| C2 | 登录后 Set-Cookie 未在本清单实测 | P1 | 管理员无痕登录点验 §4.2 | +| C3 | CF Access/WAF 无导出证据 | P1 | 控制台只读导出归档 | +| C4 | LE 证书 ~69 天到期 | P2 | 确认自动续期;到期前复查 | +| C5 | `TRUSTED_PROXY_CIDRS` 仅 loopback | 信息 | 保持;换反代拓扑时再改 | +| C6 | 3000 监听 `::` 依赖防火墙封锁 | 信息 | 保持 Block 规则;勿删除 | + +**无 P0 配置错误**(在「单域名 HTTPS + 本机 Tunnel」模型下)。 + +--- + +## 9. 明确未做 + +- 未读取或打印 `SESSION_SECRET` 及任何密钥值 +- 未修改 `.env`、未重启进程 +- 未真实登录、未写入会话 Cookie +- 未调用 Cloudflare API、未改 DNS/Tunnel +- 未扫描支付/OAuth 回调完整列表(需账号与控制台) + +--- + +## 10. 可选下一步(需你确认再执行) + +1. **仅文档**:把本清单链入 `docs/operations`(可复制进仓库)。 +2. **HSTS**:CF 面板开启(推荐,无代码变更)或源站加头(需发版)。 +3. **你完成 §4.2 点验后**,把结果回填本文件「人工」一节。 +4. **OAuth 全量回调清单**(若你启用了第三方登录)。 + +--- + +## 11. 证据索引 + +| 证据 | 来源 | +|------|------| +| 键名 / 布尔 / URL 形态 / CIDR | 本地 `.env` 解析(无值输出) | +| HTTPS/HTTP/HSTS/版本头 | `HttpWebRequest` + `curl -sSI` | +| TLS | `SslStream` + X509 | +| 监听/防火墙/Tunnel | `Get-NetTCPConnection` / `Get-NetFirewallRule` / `Get-Service` | +| Secure 启动警告 | 最近 3 个 `oneapi-*.log` 检索 | +| Cookie 代码 | `main.go`、`common/session_cookie.go` | + +## 12. 2026-07-18 续:HSTS / 文档入库 / OAuth + +| 动作 | 结果 | +|------|------| +| CF 自动开 HSTS | **未完成**:Wrangler token 无 zone_settings 写权限(403);控制台自动化遇人机验证 | +| HSTS 操作手册 | 见同目录 `cloudflare-hsts-runbook.md`(面板点击即可,不改代码) | +| 清单入库 | 本文件位于 `src/docs/operations/` | +| OAuth 回调全量核对 | 见 `oauth-callback-domain-checklist.md` | + +### OAuth 关键偏差(摘要) + +- 仅 **GitHub OAuth** 启用;GitHub App 回调应登记 **`https://incc.qzz.io/oauth/github`**(不是 `/api/oauth/github`)。 +- 系统 option 仍显示 `server_address=http://localhost:3000`,Passkey `rp_id=localhost` / `origins=http://localhost:3000` / `allow_insecure=true` —— **与公网 HTTPS 不一致**,需管理后台修正(本清单不写库)。 + diff --git a/docs/operations/healthcheck-logout-regression-20260718.md b/docs/operations/healthcheck-logout-regression-20260718.md new file mode 100644 index 000000000000..a12f9e22bead --- /dev/null +++ b/docs/operations/healthcheck-logout-regression-20260718.md @@ -0,0 +1,115 @@ +# 健康检查 / 登出回归报告 + +- **时间:** 2026-07-18(复检补强 10:53 UTC+8 附近) +- **入口:** `https://incc.qzz.io` / `D:\newapi` +- **生产进程:** PID **19984**,启动 2026-07-18T18:39:47 +- **二进制 revision:** `33be725d327813816d791d9d55a8a0d761a5566e`(与 `src` HEAD 一致,`vcs.modified=false`) +- **SHA-256:** `7a71b9280dd3b19cae11794d5ff10cb84b69871e66f5b0bd3a4879a182f4fc4d` +- **版本串:** `v1.0.0-rc.21-41-g33be725d` + +--- + +## 1. 版本一致性 + +| 检查项 | 结果 | +|--------|------| +| 生产 exe revision | `33be725d…` | +| 源码 HEAD | `33be725d…` | +| 匹配 | **是** | +| dirty | false | + +--- + +## 2. 本机探针(127.0.0.1:3000) + +| 路径 | 期望 | 实际 | 说明 | +|------|------|------|------| +| `/livez` | 200 JSON | **通过** | 无 HSTS(纯 HTTP 预期) | +| `/readyz` | 200 JSON | **通过** | | +| `/healthz` | 200 JSON | **通过** | | +| `/api/status` | 200 JSON | **通过** | | +| `/v1/models` | 401 | **通过** | 无 Token | +| `/metrics` | 404 非 HTML | **通过** | SPA 不再吞路径 | +| `/frontend-healthz` | 404 非 HTML | **通过** | | +| `/api/user/logout` | 200 JSON | **通过** | | +| `/sign-in` | 200 HTML | **通过** | `lang=zh-CN` | +| `/console` | 200 HTML | **通过** | SPA | +| `/livez` + `X-Forwarded-Proto: https` | HSTS | **通过** | `max-age=15768000` | + +--- + +## 3. 公网探针(https://incc.qzz.io) + +| 路径 | 结果 | 说明 | +|------|------|------| +| `/livez` | **200** + HSTS | 稳定 | +| `/readyz` | **200** + HSTS | 稳定 | +| `/api/user/logout` | **200** + HSTS | 稳定 | +| `/oauth/github` | **200** HTML + HSTS | SPA 回调壳 | +| `/api/oauth/state` | **200** JSON + HSTS | | +| `/metrics` | **404** JSON 非 HTML | 通过 | +| `/api/status` GET | **200**(重试后) | 偶发 CF/`RemoteDisconnected`/403 挑战;**本地同源始终 200** | +| `/sign-in` GET | **200**(重试后) | 同上,偶发边缘抖动 | +| `http://incc.qzz.io/` | **301 → https://incc.qzz.io/** | `curl -sSI` 确认 | + +**说明:** `curl -sI`(HEAD)对部分 API 可能返回 404,属 **HEAD 未实现/未路由**,不能当作 GET 失败;GET 体正常返回 JSON。 + +### 公网/本地 status 字段(GET) + +| 字段 | 值 | +|------|-----| +| version | `v1.0.0-rc.21-41-g33be725d` | +| server_address | `https://incc.qzz.io` | +| github_oauth | true / `Ov23liURGuQ4SZgvLGGq` | +| passkey_rp_id | `incc.qzz.io` | +| passkey_origins | `https://incc.qzz.io` | +| passkey_allow_insecure | false | +| theme | default | + +--- + +## 4. 登出回归 + +| 检查 | 结果 | +|------|------| +| `GET /api/user/logout` | 200 `{"success":true}` | +| Set-Cookie | `session` **Max-Age=0; Secure; HttpOnly; SameSite=Strict; Path=/** | +| 前端版本 | 含 `33be725d` 登出抑制 Session expired + `location.replace('/sign-in')` | +| 日志样本 | 可见 `GET /api/user/logout` 后 `GET /sign-in` | + +**未做:** 持真实登录 Cookie 的浏览器 UI 全自动点退(需人工 30 秒确认)。 + +--- + +## 5. 基础设施 + +| 项 | 结果 | +|----|------| +| 防火墙 Block TCP 3000 | Enabled / Block | +| Cloudflared | Running,进程 1 | +| SQLite 备份任务 | Ready;上次 11:02:10 result=0;下次 03:30 | +| 最新备份 | `one-api-20260718-110210.db` 26.9MB | + +--- + +## 6. 源码门禁 + +| 门禁 | 结果 | +|------|------| +| `go test ./router` | pass | +| `go test ./middleware` | pass | +| `go test -tags frontend_external .` | pass | + +--- + +## 7. 总判定 + +| 类别 | 判定 | +|------|------| +| 核心健康(livez/readyz/本地 status/鉴权边界/metrics 404) | **通过** | +| 登出 Cookie 过期语义 | **通过** | +| HSTS | **通过**(公网 + 反代头) | +| 公网个别 GET 偶发断开/403 | **边缘/挑战抖动**,非进程宕机;本地直连正常 | +| 完整 UI 登出手测 | **待用户点一次** | + +**总体:生产健康,可继续使用。** 若 UI 登出仍异常,带截图/文案再开一轮。 diff --git a/docs/operations/log-query-baseline.md b/docs/operations/log-query-baseline.md new file mode 100644 index 000000000000..9813decadd31 --- /dev/null +++ b/docs/operations/log-query-baseline.md @@ -0,0 +1,28 @@ +# Log Query Baseline + +Cursor pagination uses `(created_at, id)` for SQLite/MySQL/PostgreSQL and `(created_at, request_id)` for ClickHouse. Offset pagination remains available only for compatibility. + +PostgreSQL staging baseline: + +```sql +EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) +SELECT * FROM logs +WHERE (created_at < :created_at OR (created_at = :created_at AND id < :id)) +ORDER BY created_at DESC, id DESC +LIMIT 101; + +EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) +SELECT * FROM logs +WHERE trace_id = :trace_id +ORDER BY created_at ASC, id ASC +LIMIT 200; +``` + +Acceptance: + +- no large `OFFSET` in the cursor query; +- an index scan uses `idx_created_at_id` or a more selective filter index; +- trace lookup uses `idx_logs_trace_id` / `idx_logs_trace_created`; +- representative deep-page P95 is below 300 ms under staging data volume. + +The model test suite runs SQLite `EXPLAIN QUERY PLAN` assertions. PostgreSQL, MySQL and ClickHouse plans must be captured in staging because their optimizers and data distributions cannot be represented by the in-memory unit database. diff --git a/docs/operations/oauth-callback-domain-checklist.md b/docs/operations/oauth-callback-domain-checklist.md new file mode 100644 index 000000000000..e25fcede2ab3 --- /dev/null +++ b/docs/operations/oauth-callback-domain-checklist.md @@ -0,0 +1,241 @@ +# OAuth 回调域名只读全量核对 + +- **日期**:2026-07-18 +- **公网入口**:`https://incc.qzz.io` +- **运行版本**:`v1.0.0-rc.21-34-gbbddd729` +- **方法**:只读 `/api/status`、源码路由与 OAuth 客户端构造;**不**读取 client secret、不发起真实 OAuth 授权、不改 GitHub App 设置 + +--- + +## 1. 生产启用了哪些身份方式 + +来源:本机 `GET http://127.0.0.1:3000/api/status`(字段为公开状态,不含 secret) + +| 方式 | 启用 | client_id / 备注 | +|------|------|------------------| +| **GitHub OAuth** | **是** | `github_client_id=Ov23liURGuQ4SZgvLGGq`(公开 client_id) | +| Discord OAuth | 否 | client_id 空 | +| OIDC | 否 | endpoint/client_id 空 | +| LinuxDO OAuth | 否 | client_id 空 | +| Telegram OAuth | 否 | bot 名空 | +| WeChat 登录 | 否 | — | +| **Passkey** | **是** | 见 §4(当前配置指向 localhost,生产域名下不可用) | +| 密码登录 | 状态里 `password_login_enabled` 存在(本清单不展开账号策略) | | + +结论:第三方 OAuth 仅 **GitHub** 需回调域名闭环;Passkey 配置与公网域名**不一致**。 + +--- + +## 2. GitHub OAuth 实际跳转链路(源码) + +### 2.1 授权 URL 构造 + +文件:`web/default/src/lib/oauth.ts` → `buildGitHubOAuthUrl` + +```text +https://github.com/login/oauth/authorize + ?client_id= + &state= + &scope=user:email +``` + +**注意:authorize 请求未带 `redirect_uri` 参数。** +因此 GitHub 使用 **OAuth App / GitHub App 控制台里配置的唯一 Authorization callback URL**。 + +### 2.2 浏览器回调(前端 SPA) + +路由:`/oauth/$provider`(`web/default/src/routes/oauth/$provider.tsx`) + +生产期望 URL: + +```text +https://incc.qzz.io/oauth/github?code=...&state=... +``` + +页面再调用: + +```text +GET https://incc.qzz.io/api/oauth/github?code=...&state=... +``` + +### 2.3 后端换票 + +路由:`router/api-router.go` + +```text +GET /api/oauth/state → GenerateOAuthCode(写 session oauth_state) +GET /api/oauth/:provider → HandleOAuth(校验 state,ExchangeToken,建会话) +``` + +GitHub 换票:`oauth/github.go` POST `https://github.com/login/oauth/access_token` +(body 含 client_id/client_secret/code;**无 redirect_uri 字段**,与 authorize 一致,依赖 App 默认回调)。 + +### 2.4 必须登记在 GitHub 上的回调 + +| 位置 | 应配置值 | +|------|----------| +| GitHub OAuth App → Authorization callback URL | **`https://incc.qzz.io/oauth/github`** | + +| 错误配置示例 | 后果 | +|--------------|------| +| `http://localhost:3000/oauth/github` | 生产登录 redirect_uri_mismatch / 回本地 | +| `https://incc.qzz.io/api/oauth/github` | 前端路由对不上,SPA 收不到 code 展示 | +| `https://incc.qzz.io/oauth/github/`(尾斜杠不一致) | 可能 mismatch(取决于 GitHub 严格匹配) | +| 仅 HTTP | 与公网 HTTPS 不一致 | + +**本审计无法读取你的 GitHub Developer Settings**;请打开 +https://github.com/settings/developers → 对应 OAuth App(client_id 前缀 `Ov23li…`)人工核对 callback 是否**精确等于**上表。 + +### 2.5 绑定流程 + +已登录用户绑定 GitHub 时仍走同一 authorize + `/oauth/github` 回调(`window.opener` 区分 bind/login)。 +Callback 仍必须是同一 URL。 + +--- + +## 3. 其他 Provider 回调模板(当前未启用,备查) + +若将来打开,前端会显式带 `redirect_uri=window.location.origin/...`: + +| Provider | 启用 | Authorize redirect_uri(前端) | 后端 API | +|----------|------|--------------------------------|----------| +| Discord | 否 | `{origin}/oauth/discord` | `/api/oauth/discord` | +| OIDC | 否 | `{origin}/oauth/oidc` | `/api/oauth/oidc` | +| LinuxDO | 否 | 构造函数未带 redirect_uri(与 GitHub 类似,依赖控制台默认) | `/api/oauth/linuxdo` | +| WeChat / Telegram | 否 | 非标准路由 `/api/oauth/wechat`、`/api/oauth/telegram/*` | 见 `api-router.go` | + +生产 origin 固定为 `https://incc.qzz.io` 时,未来启用 Discord/OIDC 应在对应控制台登记: + +- `https://incc.qzz.io/oauth/discord` +- `https://incc.qzz.io/oauth/oidc` + +--- + +## 4. 与 OAuth 相关的系统地址字段(高优先级偏差) + +`/api/status` 仍暴露: + +| 字段 | 当前值 | 期望(生产) | 影响 | +|------|--------|--------------|------| +| `server_address` | `http://localhost:3000` | `https://incc.qzz.io` | 邮件链接、部分回跳、第三方文档/支付回调文案可能指错域名 | +| `passkey_rp_id` | `localhost` | `incc.qzz.io` | **公网 Passkey 无法绑定/登录** | +| `passkey_origins` | `http://localhost:3000` | `https://incc.qzz.io` | 同上 | +| `passkey_allow_insecure` | `true` | 生产应为 `false` | 与 HTTPS 生产策略不一致 | +| `passkey_login` | `true` | 可保留 true,但须先修正 rp_id/origins | 功能开关开着但配置不可用 | +| `docs_link` | `https://docs.newapi.pro` | 可保留 | 外链文档 | + +**说明**:`SESSION_COOKIE_TRUSTED_URL=https://incc.qzz.io` 已正确;**业务 option 里的 ServerAddress / Passkey 仍像开发机默认值**,与 Cookie 层脱节。 + +### 建议修改入口(需你确认后改,本清单不自动写库) + +管理后台 → 系统设置(站点 / 认证 / Passkey)或 options: + +1. **Server Address / 服务器地址** → `https://incc.qzz.io`(无尾斜杠或与项目约定一致) +2. **Passkey RP ID** → `incc.qzz.io` +3. **Passkey Origins** → `https://incc.qzz.io` +4. **Passkey allow insecure** → `false` +5. 保存后**重新登录**测 Passkey;已在 localhost 注册的凭据不会自动迁移到新 rp_id + +--- + +## 5. Cookie / 会话与 OAuth 交叉 + +| 项 | 状态 | +|----|------| +| OAuth state 存 session cookie | `/api/oauth/state` 使用 `sessions` | +| Secure Cookie | 已 `SESSION_COOKIE_SECURE=true` + Trusted URL | +| SameSite=Strict | 源码默认 Strict | + +**SameSite=Strict 与 OAuth:** +GitHub 回跳是**跨站导航回到** `incc.qzz.io`。部分浏览器对「顶级导航带回第一方 Cookie」仍发送 cookie;若出现「state 无效」,需验证: + +- 回调是否仍在 `https://incc.qzz.io` 第一方; +- 是否被中间页跨站; +- 必要时评估 `Lax`(需产品决策,不在本清单自动改)。 + +--- + +## 6. Tunnel / 域名(与回调一致) + +`C:\Users\yuanjia\.cloudflared\config-incc-newapi.yml`(只读): + +```yaml +hostname: incc.qzz.io +service: http://localhost:3000 +``` + +- 公网仅 `incc.qzz.io` → OAuth 回调只登记这一 host 即可。 +- 若将来加 `www` 或其他域名:Cookie Trusted URL、ServerAddress、Passkey origins、GitHub callback **全部**要同步扩展。 + +--- + +## 7. 核对勾选表 + +### 已由自动化完成 + +- [x] 列出已启用 OAuth/Passkey +- [x] 从源码固定 GitHub 回调路径为 `/oauth/github` +- [x] 后端 API 为 `/api/oauth/github`(二次调用,不是 GitHub 回调) +- [x] 发现 `server_address` / Passkey 仍为 localhost +- [x] Tunnel hostname = `incc.qzz.io` + +### 需你人工完成 + +- [ ] GitHub Developer Settings 中 callback **精确**为 `https://incc.qzz.io/oauth/github` +- [ ] 用无痕窗口点一次「Continue with GitHub」确认可回跳并登录 +- [ ] 后台改 ServerAddress + Passkey 四项后复测 Passkey +- [ ] 若有自定义 OAuth Provider(DB 表),在后台列表中逐个核对 redirect + +--- + +## 8. 风险分级 + +| ID | 项 | 等级 | +|----|----|------| +| O1 | GitHub callback 未在本环境验证(控制台在 GitHub) | P1 人工 | +| O2 | `server_address=http://localhost:3000` | **P0/P1** 业务配置错误 | +| O3 | Passkey rp_id/origins=localhost 且 allow_insecure=true | **P0/P1** 公网 Passkey 失效/不安全默认 | +| O4 | SameSite=Strict 与 OAuth 兼容性 | P2 观察 | +| O5 | Discord/OIDC 未启用 | 信息 | + +--- + +## 9. 明确未做 + +- 未读取 GitHub client secret +- 未修改 options 数据库 +- 未代表你点击 GitHub 授权 +- 未改 Cloudflare / DNS + +--- + +## 11. 2026-07-18 生产执行 + +| 项 | 状态 | +|----|------| +| ServerAddress | **已改为** `https://incc.qzz.io`(DB options,已反映到 `/api/status`) | +| passkey.rp_id / origins | **已改为** `incc.qzz.io` / `https://incc.qzz.io` | +| passkey.allow_insecure_origin | **已改为** `false` | +| options 备份 | `D:\newapi\backups\options-before-prod-fix-20260718-162714.sql` | +| GitHub callback URL | **仍需人工**在 GitHub Developer Settings 设为 `https://incc.qzz.io/oauth/github`(API/浏览器自动化无法代改) | +| HSTS | 应用层已上线(`3830dc07`);CF 面板可再开双保险 | + +人工勾选: + +- [x] GitHub OAuth App callback 已设为 `https://incc.qzz.io/oauth/github`(用户 2026-07-18 控制台截图确认) +- [x] GitHub **Homepage URL** 已改为 `https://incc.qzz.io/`(2026-07-18 本机 Chrome 代改,页面提示 Application updated successfully) +- [ ] 无痕窗口实测「Continue with GitHub」登录成功 +- [ ] (可选)Passkey 在生产域名重新注册 +- [ ] (可选)CF Edge Certificates 开启 HSTS(应用层 HSTS 已上线) + +### GitHub OAuth App 当前快照(2026-07-18 已更新,无 secret) + +| 字段 | 值 | 判定 | +|------|-----|------| +| Application name | newapi | 可保留 | +| Client ID | Ov23liURGuQ4SZgvLGGq | 与 `/api/status` 一致 | +| Homepage URL | `https://incc.qzz.io/` | **正确** | +| Authorization callback URL | `https://incc.qzz.io/oauth/github` | **正确** | +| Client secret | 控制台显示 Never used | 做一次真实登录后会变为 used | +| Device Flow | 未要求 | 可保持关闭 | + diff --git a/docs/operations/optimization-audit-2026-07-18.md b/docs/operations/optimization-audit-2026-07-18.md new file mode 100644 index 000000000000..ec28af20af40 --- /dev/null +++ b/docs/operations/optimization-audit-2026-07-18.md @@ -0,0 +1,266 @@ +# new-api 优化审计与执行计划 + +- **审计日期**:2026-07-18 +- **权威源码**:`D:\newapi\src` +- **分支 / HEAD**:`feat/adaptive-channel-balance-rc12` / `6ce0799034e836180031953f83cc7dab4f1d6e08` +- **生产二进制**:`D:\newapi\new-api-fixed.exe` +- **生产 PID**:33016(审计时) +- **生产 SHA-256**:`75450924043c1f19f53357d9399772ccb8b5a2a794bfab33a945d0d493603e37` +- **嵌入 revision**:`6ce0799034e836180031953f83cc7dab4f1d6e08`(与 HEAD 一致) +- **公网入口**:`https://incc.qzz.io` +- **范围边界**:不读 `.env` 值、不读业务表数据/日志正文;不改生产 DB;本审计后的代码修复可部署,但须单独记录。 + +--- + +## 1. 测试方法与证据 + +### 1.1 已执行的自动化 / 本地门禁 + +| 门禁 | 命令 / 方式 | 结果 | +|------|-------------|------| +| Router 单测 | `go test ./router -count=1` | pass | +| 纯后端构建测试 | `go test -tags frontend_external . -count=1` | pass | +| Vet | `go vet ./router .` | clean | +| 默认前端类型检查 | `bun run typecheck`(`web/default`) | pass | +| 中文 locale 结构 | 解析 `zh.json` nested `translation` 键数 | 5182;`Home=主页`,`Sign in=登录` | +| PR CI | GitHub Actions run `29634700553` | go/web/image **全绿** | + +### 1.2 运行时探针(本机 127.0.0.1:3000) + +| 路径 | HTTP | Content-Type / 说明 | +|------|------|---------------------| +| `/livez` | 200 | `application/json` `{"plane":"all","status":"ok"}` | +| `/readyz` | 200 | `application/json` `{"status":"ok"}` | +| `/healthz` | 200 | JSON 存活(兼容) | +| `/api/status` | 200 | JSON 管理状态 | +| `/v1/models`(无 Token) | **401** | 预期鉴权失败 | +| `/api/no-such` | **404** | API 未匹配 | +| `/unknown-page-xyz` | 200 HTML | SPA NoRoute 回退(预期对前端路由) | +| **`/metrics`** | **200 HTML SPA** | **异常:未启用 metrics 时不应伪装成页面** | +| **`/frontend-healthz`** | **200 HTML SPA** | 分离 Nginx 才有该端点;一体机误匹配到 SPA | + +### 1.3 公网探针 + +| 路径 | 结果 | +|------|------| +| `https://incc.qzz.io/livez` | 200 | +| `https://incc.qzz.io/readyz` | 200 | +| `https://incc.qzz.io/api/status` | 200 | +| 浏览器自动化访问首页/登录 | 触发 **Cloudflare 人机验证**(自动化环境);此前已用浏览器验证中文 UI 通过 | + +### 1.4 配置键名(仅键名,无值) + +生产 `.env` 已包含: + +- `PORT` +- `SQLITE_PATH` +- `SESSION_SECRET` +- `SESSION_COOKIE_SECURE` +- `SESSION_COOKIE_TRUSTED_URL` +- `TRUSTED_PROXY_CIDRS` + +说明:Cookie 相关键名已与源码对齐(不再使用错误的 `SESSION_SECURE`)。 + +### 1.5 备份与恢复 + +| 项 | 状态 | +|----|------| +| 计划任务 `NewAPI-SQLiteBackup` | Ready;每日 03:30;上次 2026-07-18 11:02:10;`LastTaskResult=0` | +| 备份脚本 | `D:\newapi\scripts\backup-sqlite.ps1`(`.backup` + integrity + sha256) | +| 最新备份文件 | `backups/db/one-api-20260718-110210.db` + `.sha256` | +| 恢复演练记录 | `backups/restore-tests/restore-20260718-112444.json` 存在 | +| sqlite3 工具 | `D:\newapi\tools\sqlite\sqlite3.exe` 存在 | + +### 1.6 前端中文 + +| 项 | 状态 | +|----|------| +| 默认主题 i18n 嵌套展开 | 已修(`6ce07990`) | +| 默认主题默认中文 / 忽略 navigator | 已修(`cfff28c1`) | +| 浏览器实测首页中文 | 此前通过(主页/控制台/模型广场/登录等) | +| 经典主题 locale 结构 | 文件为 `{translation:{...}}`,**静态 resources 导入方式与 i18next 兼容**(与 default 的 custom backend 不同) | + +### 1.7 磁盘与清理 + +清理后约释放 1.59GB+;生产 exe/data/src 保留。`src` 仍约 2GB(含 node_modules/dist,属构建依赖)。 + +--- + +## 2. 架构现状(简图) + +```text +浏览器 / Tunnel / Cloudflare + │ + ▼ + new-api-fixed.exe (一体化 embed,RUN_MODE/APP_PLANE 默认 all) + │ + ├─ /livez /readyz /healthz + ├─ /api/* /v1/* /mj /pg /suno /kling /jimeng ... + └─ SPA NoRoute + 静态资源 (web/default|classic dist) +``` + +可选未部署路径: + +```text +frontend Nginx (:8080) ──反代──► backend (:3000, FRONTEND_MODE=disabled, -tags frontend_external) +``` + +--- + +## 3. 问题清单(按优先级) + +### P0 — 必须尽快 + +#### P0-1 SPA NoRoute 吞掉运维/指标路径 + +- **现象**:`METRICS_ENABLED` 未开时,`GET /metrics` 返回 **200 + index.html**,而不是 404/503。 +- **根因**:`router/web-router.go` 的 `NoRoute` 仅排除 `/v1`、`/api`、`/assets` 前缀;`/metrics` 未注册时落入 SPA。 +- **影响**: + - 监控误判「有页面」; + - 扫描器/编排以为指标端点存在; + - 与 fail-closed metrics 设计意图不一致(启用但无 token 应为 503,未启用应为明确非 HTML 失败)。 +- **证据**:本机探针 Content-Type `text/html`。 +- **建议修复**:NoRoute 对明确的后端/运维前缀直接 `404` JSON(或现有 `RelayNotFound`),至少包括: + - `/metrics` + - `/livez` `/readyz` `/healthz`(已注册时不会落到 NoRoute;防御性仍可列) + - `/v1beta`、`/mj`、`/pg`、`/suno`、`/kling`、`/jimeng`、`/dashboard`(billing 兼容路径若未挂到 api 组则需核对) +- **验证**:`curl -i /metrics` → 非 HTML;启用 metrics 无 token → 503;有 token → 200 文本指标。 +- **风险**:低;仅改变未注册路径的失败形态。 +- **置信度**:高。 + +#### P0-2 生产 Secure Cookie / HTTPS 闭环仍依赖环境证据 + +- **现象**:键名已正确配置,但本审计**不读取值**,无法证明: + - `SESSION_COOKIE_SECURE=true` 是否在公网 HTTPS 下启用; + - `SESSION_COOKIE_TRUSTED_URL` 是否完整包含 `https://incc.qzz.io` 等入口; + - Tunnel/Access/WAF catch-all 是否存在。 +- **影响**:会话 Cookie 明文风险、OAuth 回调失败或过宽信任。 +- **建议**:在**不打印密钥**前提下做只读核对清单(管理员本地执行):检查 Secure/SameSite 属性、回调 URL 列表、Cloudflare 策略导出。 +- **本轮是否自动改生产 `.env`**:**否**(高风险,需单独确认)。 +- **置信度**:高(键名);配置正确性未证。 + +### P1 — 高收益 + +#### P1-1 供应链:签名 + 完整 SBOM + +- **现状**:exe `Authenticode=NotSigned`;仅有 Go module CycloneDX(非前端完整 SBOM)。 +- **建议**:clean build → 签名 → Go SBOM + 前端 lockfile SBOM → 写入 release manifest。 +- **阻塞**:证书/策略选择。 +- **置信度**:高。 + +#### P1-2 真实业务 E2E + +- **现状**:单测/门禁强;未用沙箱凭据跑 鉴权→Relay→计费→支付→调度。 +- **建议**:隔离 staging + 合成账户 + 预算上限。 +- **置信度**:高。 + +#### P1-3 前端体积 + +- **现状**:默认前端产物仍很大(构建日志 index/async 数 MB 级);预算门禁已存在。 +- **建议**:RUM/瀑布后再拆 VChart/Shiki/Mermaid 等。 +- **置信度**:体积高;用户影响中。 + +#### P1-4 非 root 容器 + +- **现状**:一体化 Dockerfile 仍默认 root 运行;分离前端已用 unprivileged Nginx。 +- **建议**:staging 验证 volume 权限后改 USER。 +- **置信度**:高。 + +#### P1-5 Cloudflare 对自动化/部分地区挑战 + +- **现象**:browser-act 访问公网触发「正在进行安全验证」。 +- **影响**:自动化巡检、部分用户体验。 +- **建议**:对 `/livez`/`/readyz` 放行;管理后台保持挑战;记录 Ray ID 策略。 +- **置信度**:中(需 CF 控制台)。 + +### P2 — 中期 + +| ID | 项 | 说明 | +|----|----|------| +| P2-1 | SQLite 容量/连接池 | 无基准前不改默认 100/1000 | +| P2-2 | 经典前端类型/a11y | 渐进 `checkJs` + axe | +| P2-3 | 分离部署上线 | 镜像与 compose 已就绪,生产仍一体化 | +| P2-4 | `src` 体积 | node_modules/dist 占磁盘;可选清理后 CI/本地重装 | +| P2-5 | `/frontend-healthz` 一体机语义 | 一体机可显式 404,避免与分离部署混淆 | + +### 已关闭(本周期) + +| 项 | 状态 | +|----|------| +| 前后端交付缝 + CI 三镜像 | 完成 | +| 中文默认 + locale 嵌套展开 | 完成并部署 | +| 可信代理 fail-closed / metrics token fail-closed 源码 | 已有 | +| SQLite 日备 + 恢复演练文件 | 有 | +| 磁盘临时产物清理 | 已做一轮 | + +--- + +## 4. 目标设定(本轮最优执行) + +在**不改生产密钥/不碰 DB 数据**前提下,本轮只做: + +1. **落地 P0-1**:修复 SPA NoRoute 误吞 `/metrics` 及同类后端前缀;补回归测试。 +2. **补文档**:本审计文件 + 运维注意点写入 `runtime-separation` 交叉链接。 +3. **本地验证**:`go test ./router`、构建标签测试。 +4. **推送 fork**。 +5. **生产部署**:因属安全/运维语义修复,**构建并 promote 到线上**(与中文修复同一发布通道),验证 `/metrics` 不再返回 HTML。 +6. **不自动改** `.env` Cookie 真值、不启用 metrics、不上分离镜像(除非后续确认)。 + +成功标准: + +- `GET /metrics`(默认未启用)→ **404**(或非 HTML),不再是 SPA。 +- `/livez` `/readyz` `/api/status` 仍 200。 +- `/v1/models` 无 Token 仍 401。 +- 首页中文仍正常。 +- 测试通过并推送到 fork。 + +--- + +## 5. 回滚 + +- 二进制:`backups/releases` 中保留的最新旧包,或 `release-manifests/zh-unwrap-20260718` 对应包。 +- 代码:`git revert` NoRoute 提交。 +- 配置:未改 `.env` 则无配置回滚。 + +--- + +## 6. 明确不做(除非再次确认) + +- 读取或修改 `.env` 中的密钥/Cookie 真值 +- 开启 `METRICS_ENABLED` 并配置 token +- 切换生产到前后端分离 compose +- 代码签名采购与实施 +- 真实上游收费 E2E + +--- + +## 8. 本轮执行记录(2026-07-18 续) + +| 目标 | 结果 | +|------|------| +| 文档 | 本文件 | +| P0-1 SPA NoRoute 修复 | 提交 `bbddd729`,已推 fork,已部署生产 | +| 生产 PID | **32544** | +| 生产 SHA-256 | `d1120e03acd4498cf008bb71441cc07734f7fc0901a3639ce94904b21b35090c` | +| `/metrics` | **404** 非 HTML(修复前为 200 HTML) | +| `/frontend-healthz` | **404** 非 HTML | +| `/livez` `/readyz` `/api/status` | 200 JSON | +| `/v1/models` | 401 | +| `/console` | 200 HTML(SPA 正常) | +| 测试 | `go test ./router` 通过(含 `TestIsNonSPARequestPath`、`TestEmbeddedFrontendDoesNotServeSPAForMetrics`) | + +### 未在本轮执行 + +- 修改生产 Cookie 真值 / Tunnel 策略 +- 启用 metrics token +- 签名与完整 SBOM +- 真实业务 E2E +- 前后端分离上生产 + +### 下一步建议(需确认) + +1. 只读核对 `SESSION_COOKIE_SECURE` / `TRUSTED_URL` 与公网 HTTPS 是否一致 +2. 沙箱 E2E 矩阵 +3. 签名 + SBOM 发布流程 +4. 前端体积 RUM 后拆包 + diff --git a/docs/operations/project-overview-20260718.md b/docs/operations/project-overview-20260718.md new file mode 100644 index 000000000000..f9899b8bb41f --- /dev/null +++ b/docs/operations/project-overview-20260718.md @@ -0,0 +1,142 @@ +# D:\newapi 项目交付概览(更新) + +- **实施起始:** 2026-07-17 +- **最后验证:** 2026-07-18(健康检查 + 登出回归 + 磁盘清理) +- **权威源码:** `D:\newapi\src` +- **分支:** `feat/adaptive-channel-balance-rc12` +- **HEAD / 生产 revision:** `33be725d327813816d791d9d55a8a0d761a5566e` +- **生产进程:** PID 19984(`new-api-fixed.exe`) +- **公网:** `https://incc.qzz.io` +- **Fork:** `xvyimu/new-api` 已与 HEAD 同步 + +--- + +## 1. 当前结论(一句话) + +生产已跑在含中文 UI、HSTS、Secure Cookie 配置、OAuth/Passkey 域名修正、登出加固与 SPA 运维路径修复的一体化二进制上;本地/核心公网探针与 Go 门禁通过。剩余主要是人工 UI 登出点验、CF 面板 HSTS 双保险、签名/SBOM、真实业务 E2E。 + +--- + +## 2. 架构(现行) + +```text +浏览器 → Cloudflare → cloudflared Tunnel → 127.0.0.1:3000 + └─ new-api-fixed.exe(一体化 embed,RUN_MODE/APP_PLANE 默认 all) + ├─ /livez /readyz /healthz + ├─ /api/* /v1/* relay 前缀… + └─ SPA(default 主题,中文默认) +``` + +可选未切换:`deploy/separated` 前后端分离镜像 + Nginx 同源反代。 + +唯一构建源:`D:\newapi\src`。`_qn_tmp` 仅上游参考,禁止发布。 + +--- + +## 3. 本周期已落地(相对 07-17 基线) + +### 3.1 安全 / 会话 / HTTPS + +| 项 | 状态 | +|----|------| +| `SESSION_COOKIE_SECURE=true` + Trusted URL=`https://incc.qzz.io` | 生产已配 | +| 应用层 HSTS `max-age=15768000`(`X-Forwarded-Proto=https`) | 已部署 | +| 3000 入站防火墙 Block | 已启用 | +| Tunnel `incc.qzz.io` → localhost:3000 | Running | +| SPA 不再把 `/metrics` 等伪装成 HTML 200 | 已部署 | +| 登出:`session` Max-Age=0 + 前端硬跳登录 + 抑制 401 噪声 | 已部署 | + +### 3.2 中文与 OAuth / Passkey + +| 项 | 状态 | +|----|------| +| 默认前端 locale 嵌套 `translation` 展开 | 已修 | +| 中文默认 / 忽略 navigator 英文 | 已修 | +| `ServerAddress` | `https://incc.qzz.io` | +| Passkey rp/origins | 生产域名;`allow_insecure=false` | +| GitHub callback | `https://incc.qzz.io/oauth/github` | +| GitHub Homepage | `https://incc.qzz.io/` | + +### 3.3 交付缝 / CI(源码,生产仍一体化) + +- `frontend_external` + `FRONTEND_MODE` +- `Dockerfile.backend` / `deploy/separated/*` +- quality:pure backend、三镜像、nginx digest 解析 +- 文档:ADR、runtime-separation、cookie/oauth/hsts 清单 + +### 3.4 备份 + +- 日备任务 `NewAPI-SQLiteBackup` 正常(`.backup` + integrity + sha256) +- 最新库备份:`backups/db/one-api-20260718-110210.db` + +### 3.5 磁盘 + +- 已清理过期 release-build / 中间诊断包 / 旧日志等 +- 本轮再删旧 `release-manifests/*`(保留当前 prod 对应 `logout-fix2-20260718`),约 **+543 MB** +- 现 `release-manifests` ≈ 136 MB;`src` ≈ 2.0 GB(主要为 `web/node_modules`,构建需要,未删) + +--- + +## 4. 最新健康检查摘要(2026-07-18) + +详见:`docs/healthcheck-logout-regression-20260718.md` + +| 类别 | 结果 | +|------|------| +| 版本三边一致 | 通过 | +| 本机 livez/readyz/status/metrics404/logout | 通过 | +| 公网 livez/readyz/HSTS/logout cookie | 通过 | +| 公网部分路径偶发 CF 断开/403 | 边缘抖动;重试或本地直连正常 | +| `go test` router/middleware/frontend_external | 通过 | +| UI 登录→退出手测 | 建议你再点一次确认 | + +--- + +## 5. 风险与未完成 + +| 优先级 | 项 | 状态 | +|--------|----|------| +| P1 | 完整 UI 登出手测 | 接口层已过;待人工 | +| P1 | CF 面板 HSTS 双保险 | 应用层已有;面板需账号操作(见 runbook) | +| P1 | Authenticode 签名 + 完整 SBOM | 未做 | +| P1 | 真实鉴权/Relay/计费/支付 E2E | 未做 | +| P2 | 前端体积 RUM 后拆包 | 预算门禁已有 | +| P2 | 非 root 容器 / 分离部署上生产 | 镜像就绪未切 | +| P2 | 证书续期(LE ~2026-09-25) | 观察 | +| 信息 | `curl -sI` HEAD 对部分 API 404 | 不影响 GET | + +--- + +## 6. 关键路径索引 + +| 用途 | 路径 | +|------|------| +| 源码 | `D:\newapi\src` | +| 生产 exe | `D:\newapi\new-api-fixed.exe` | +| 数据 | `D:\newapi\data` | +| 本轮健康报告 | `D:\newapi\docs\healthcheck-logout-regression-20260718.md` | +| 优化审计 | `D:\newapi\docs\optimization-audit-2026-07-18.md` | +| Cookie/HTTPS 清单 | `src/docs/operations/cookie-https-readonly-checklist.md` | +| OAuth 清单 | `src/docs/operations/oauth-callback-domain-checklist.md` | +| HSTS runbook | `src/docs/operations/cloudflare-hsts-runbook.md` | +| 运维执行记录 | `D:\newapi\docs\ops-fix-execution-2026-07-18.md` | +| 当前发布证据目录 | `D:\newapi\release-manifests\logout-fix2-20260718\` | +| 启动/晋升 | `start-newapi-hidden.ps1` + 计划任务 `NewAPIServer` | + +--- + +## 7. 建议下一步(需确认) + +1. 你本机无痕:**登录 → 退出**,确认无 “Session expired” 连环提示。 +2. (可选)CF Edge Certificates 再开 HSTS。 +3. 签名 + SBOM 发布流程。 +4. 沙箱 E2E。 +5. 是否合并 PR #1。 + +--- + +## 8. 回滚 + +- 二进制:`release-manifests\logout-fix2-20260718\` 或 `backups\releases\` 最新包 +- options:`backups\options-before-prod-fix-20260718-162714.sql` +- 代码:fork 分支 `git revert` 对应提交 diff --git a/docs/operations/runtime-separation.md b/docs/operations/runtime-separation.md new file mode 100644 index 000000000000..3dff68711b43 --- /dev/null +++ b/docs/operations/runtime-separation.md @@ -0,0 +1,91 @@ +# Runtime Separation + +The default remains a single compatible process: + +```text +RUN_MODE=all +APP_PLANE=all +``` + +`RUN_MODE` controls process responsibility: + +| Mode | HTTP | Claims tasks | Creates scheduled tasks | Exits after migration | +|---|---:|---:|---:|---:| +| `all` | yes | yes | yes | no | +| `serve` | yes | no | no | no | +| `worker` | no | yes | no | no | +| `scheduler` | no | no | yes | no | +| `migrate` | no | no | no | yes | + +`APP_PLANE` controls the routes exposed by an HTTP process: + +| Plane | Routes | +|---|---| +| `all` | Relay, management API and web UI | +| `relay` | Relay/video routes and `/healthz` only | +| `management` | Management API, dashboard and web UI | + +## Frontend delivery (`FRONTEND_MODE`) + +Independent of `RUN_MODE` / `APP_PLANE`, the HTTP process can deliver the console in four modes: + +| Mode | Behavior | +|---|---| +| `auto` (default) | Compatible legacy behavior: slave nodes with `FRONTEND_BASE_URL` redirect; master embeds assets when available | +| `embedded` | Always register embedded dual-theme static assets (fails fast if the binary was built with `frontend_external`) | +| `redirect` | Always 301 non-API pages to `FRONTEND_BASE_URL` (including master). URL must be an absolute HTTP(S) origin without credentials, path, query, or fragment | +| `disabled` | Pure API process: no web `NoRoute`, unknown paths return Gin 404 | + +Build tags: + +| Tag | Result | +|---|---| +| *(default / no tag)* | `frontend_assets_embedded.go` embeds `web/default/dist` and `web/classic/dist` | +| `frontend_external` | `frontend_assets_external.go` returns empty assets; pair with `FRONTEND_MODE=disabled` or `redirect` | + +Recommended same-origin split (frontend Nginx proxies backend): + +```text +browser --> frontend container (:8080) + |-- SPA + /assets + +-- /api /v1 /v1beta /mj /:mode/mj /pg /suno /kling /jimeng + /healthz /livez /readyz --> backend (:3000, FRONTEND_MODE=disabled) +``` + +See `deploy/separated/README.md` and ADR `docs/adr/0001-frontend-backend-delivery-seam.md`. + +Operational shortcuts from repository root: + +```bash +make build-backend +make docker-separated +FRONTEND_BASE=http://127.0.0.1:8080 ./deploy/separated/smoke.sh +``` + +## Split Deployment (process roles) + +1. Run one `RUN_MODE=migrate` job before updating application instances. +2. Run at least one `RUN_MODE=scheduler` process with `NODE_TYPE=master`. +3. Run at least one `RUN_MODE=worker` process with `NODE_TYPE=master`. +4. Run Relay instances with `RUN_MODE=serve`, `APP_PLANE=relay`. +5. Run management instances with `RUN_MODE=serve`, `APP_PLANE=management`. + +Relay and management instances may use `NODE_TYPE=slave` after the migration job succeeds. Worker and scheduler processes must not use `NODE_TYPE=slave` because task execution is master-only. + +When management HTTP is pure backend (`FRONTEND_MODE=disabled`), put the SPA on a same-origin reverse proxy rather than expanding public CORS unless a multi-origin layout is intentionally accepted. + +## Metrics + +Set `METRICS_ENABLED=true` to expose `/metrics`. Set `METRICS_TOKEN` and scrape with `Authorization: Bearer `. If no token is configured, restrict the endpoint at the network layer. The separated frontend edge deliberately returns 404 for `/metrics` so metrics stay off the public console origin. + +## Rollback + +- Process roles: restore `RUN_MODE=all` and `APP_PLANE=all` and start the previous single process. +- Frontend delivery: restore the integrated image/binary (default embed build) and leave `FRONTEND_MODE` unset/`auto`. +- Database migrations add compatible columns/indexes and do not require destructive rollback. + +## SPA NoRoute boundary + +Embedded mode must not serve `index.html` for backend/ops paths such as `/metrics`, +`/v1`, `/v1beta`, `/mj`, `/pg`, `/suno`, `/kling`, `/jimeng`, `/dashboard`, or +`/frontend-healthz`. Unregistered paths under those prefixes return API-style 404 JSON. diff --git a/docs/operations/security-risk-acceptance.md b/docs/operations/security-risk-acceptance.md new file mode 100644 index 000000000000..ff70749d5873 --- /dev/null +++ b/docs/operations/security-risk-acceptance.md @@ -0,0 +1,13 @@ +# Security Risk Acceptance + +## GO-2026-5932 + +- **Reviewed:** 2026-07-17 +- **Review by:** 2026-10-17 +- **Module:** `golang.org/x/crypto@v0.52.0` +- **Affected package:** `golang.org/x/crypto/openpgp` +- **Decision:** Temporarily accepted as unreachable. + +`govulncheck -show verbose ./...` reports zero symbol-level and zero package-level vulnerabilities. The repository does not import `openpgp`; the advisory appears only because another safe `x/crypto` package keeps the module in the dependency graph. The advisory has no fixed version. + +Revoke this acceptance immediately if `openpgp` becomes reachable, a transitive dependency starts importing it, or a maintained replacement/fixed release becomes available. CI continues to run `govulncheck` on every pull request so either change becomes visible. diff --git a/docs/operations/slo.md b/docs/operations/slo.md new file mode 100644 index 000000000000..436c9adf3485 --- /dev/null +++ b/docs/operations/slo.md @@ -0,0 +1,31 @@ +# Service Level Objectives + +## Objectives + +| Surface | Indicator | Objective (rolling 30 days) | +|---|---|---| +| Relay | non-4xx availability | >= 99.9% | +| Management API | non-5xx availability | >= 99.5% | +| Management API | request latency P95 | < 300 ms | +| Browser | LCP P75 | <= 2.5 s | +| Browser | INP P75 | <= 200 ms | +| Browser | CLS P75 | <= 0.1 | + +The Relay latency SLO must be split by route and upstream model. End-to-end model generation time is not a gateway-only SLO; alert on gateway errors, header timeout, cancellation and queueing separately. + +## Collection + +- Enable `METRICS_ENABLED` and scrape `/metrics` every 15 seconds. +- The HTTP middleware exports request count, duration and in-flight requests using bounded route templates rather than raw paths. +- The frontend sends only metric name, value and rating to `/api/rum`. It sends no URL, user ID, token, trace ID or content, and honors browser Do Not Track. +- Keep `trace_id` and `request_id` in structured logs for drill-down after an alert. + +## Error Budget Workflow + +1. Page on sustained Relay 5xx or availability burn. +2. Create a trace sample from affected route/model groups. +3. Separate database, gateway and upstream duration before mitigation. +4. Freeze risky releases when the 30-day error budget is exhausted. +5. Record the incident, corrective action and regression test. + +Prometheus alert examples are in `deploy/prometheus/new-api-alerts.yml`. diff --git a/dto/channel_settings.go b/dto/channel_settings.go index dbcfd3181ae9..79da5427b97c 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -17,6 +17,9 @@ type ChannelSettings struct { PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"` SystemPrompt string `json:"system_prompt,omitempty"` SystemPromptOverride bool `json:"system_prompt_override,omitempty"` + // SkipAutoTest excludes this channel from AutomaticallyTestChannels / testAllChannels. + // Manual single-channel tests still work. Upstream discussion: #5205. + SkipAutoTest bool `json:"skip_auto_test,omitempty"` } type VertexKeyType string diff --git a/frontend_assets_embedded.go b/frontend_assets_embedded.go new file mode 100644 index 000000000000..2bebe8860f32 --- /dev/null +++ b/frontend_assets_embedded.go @@ -0,0 +1,84 @@ +//go:build !frontend_external + +package main + +import ( + "bytes" + "embed" + "os" + "strings" + + "github.com/QuantumNous/new-api/router" +) + +//go:embed web/default/dist +var buildFS embed.FS + +//go:embed web/default/dist/index.html +var indexPage []byte + +//go:embed web/classic/dist +var classicBuildFS embed.FS + +//go:embed web/classic/dist/index.html +var classicIndexPage []byte + +// prepareFrontendAssets 注入一体化部署所需的分析脚本,并返回双主题嵌入资源。 +func prepareFrontendAssets() router.ThemeAssets { + // 先修改内存中的首页,再把同一份字节交给路由层,避免静态文件与 SPA 回退内容不一致。 + InjectUmamiAnalytics() + InjectGoogleAnalytics() + return router.ThemeAssets{ + DefaultBuildFS: buildFS, + DefaultIndexPage: indexPage, + ClassicBuildFS: classicBuildFS, + ClassicIndexPage: classicIndexPage, + } +} + +// InjectUmamiAnalytics 把可选的 Umami 配置注入两个主题的首页模板。 +func InjectUmamiAnalytics() { + analyticsInjectBuilder := &strings.Builder{} + if os.Getenv("UMAMI_WEBSITE_ID") != "" { + umamiSiteID := os.Getenv("UMAMI_WEBSITE_ID") + umamiScriptURL := os.Getenv("UMAMI_SCRIPT_URL") + if umamiScriptURL == "" { + umamiScriptURL = "https://analytics.umami.is/script.js" + } + analyticsInjectBuilder.WriteString("") + } + analyticsInjectBuilder.WriteString("\n") + analyticsInject := []byte(analyticsInjectBuilder.String()) + placeholder := []byte("\n") + indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject) + classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject) +} + +// InjectGoogleAnalytics 把可选的 Google Analytics 配置注入两个主题的首页模板。 +func InjectGoogleAnalytics() { + analyticsInjectBuilder := &strings.Builder{} + if os.Getenv("GOOGLE_ANALYTICS_ID") != "" { + gaID := os.Getenv("GOOGLE_ANALYTICS_ID") + // 生成 Google Analytics 4 的最小启动脚本。 + analyticsInjectBuilder.WriteString("") + analyticsInjectBuilder.WriteString("") + } + analyticsInjectBuilder.WriteString("\n") + analyticsInject := []byte(analyticsInjectBuilder.String()) + placeholder := []byte("\n") + indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject) + classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject) +} diff --git a/frontend_assets_external.go b/frontend_assets_external.go new file mode 100644 index 000000000000..a50ca6120865 --- /dev/null +++ b/frontend_assets_external.go @@ -0,0 +1,10 @@ +//go:build frontend_external + +package main + +import "github.com/QuantumNous/new-api/router" + +// prepareFrontendAssets 为纯后端构建返回空资源,运行时必须选择 disabled 或 redirect 模式。 +func prepareFrontendAssets() router.ThemeAssets { + return router.ThemeAssets{} +} diff --git a/go.mod b/go.mod index f44126002065..197fe98d7891 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/QuantumNous/new-api // +heroku goVersion go1.18 go 1.25.1 +toolchain go1.26.5 + require ( github.com/Calcium-Ion/go-epay v0.0.4 github.com/abema/go-mp4 v1.4.1 @@ -36,6 +38,7 @@ require ( github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.5.0 + github.com/prometheus/client_golang v1.22.0 github.com/samber/hot v0.11.0 github.com/samber/lo v1.52.0 github.com/shirou/gopsutil v3.21.11+incompatible @@ -50,11 +53,11 @@ require ( github.com/waffo-com/waffo-go v1.3.2 github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c golang.org/x/crypto v0.52.0 - golang.org/x/image v0.41.0 + golang.org/x/image v0.43.0 golang.org/x/net v0.55.0 - golang.org/x/sync v0.20.0 + golang.org/x/sync v0.21.0 golang.org/x/sys v0.45.0 - golang.org/x/text v0.37.0 + golang.org/x/text v0.38.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.4.3 gorm.io/driver/postgres v1.5.2 @@ -140,7 +143,6 @@ require ( github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pelletier/go-toml/v2 v2.2.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect diff --git a/go.sum b/go.sum index e2faeb0d096d..5182ee96c7ec 100644 --- a/go.sum +++ b/go.sum @@ -2229,6 +2229,8 @@ golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeap golang.org/x/image v0.0.0-20220302094943-723b81ca9867/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -2262,6 +2264,7 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 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.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -2397,6 +2400,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 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-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -2583,6 +2588,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 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.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -2677,6 +2684,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= 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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/i18n/i18n.go b/i18n/i18n.go index 7ca8d2aa9971..eaa217be4e64 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -19,7 +19,8 @@ const ( LangZhCN = "zh-CN" LangZhTW = "zh-TW" LangEn = "en" - DefaultLang = LangEn // Fallback to English if language not supported + // DefaultLang 在无法识别 Accept-Language / 用户语言时回落简体中文。 + DefaultLang = LangZhCN ) //go:embed locales/*.yaml diff --git a/main.go b/main.go index 770ea156ba86..4f1a045b4423 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,7 @@ package main import ( - "bytes" "context" - "embed" "errors" "fmt" "log" @@ -23,6 +21,7 @@ import ( "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/oauth" + "github.com/QuantumNous/new-api/pkg/observability" perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics" "github.com/QuantumNous/new-api/relay" "github.com/QuantumNous/new-api/router" @@ -40,28 +39,20 @@ import ( _ "net/http/pprof" ) -//go:embed web/default/dist -var buildFS embed.FS - -//go:embed web/default/dist/index.html -var indexPage []byte - -//go:embed web/classic/dist -var classicBuildFS embed.FS - -//go:embed web/classic/dist/index.html -var classicIndexPage []byte - func main() { startTime := time.Now() + mode, plane, err := parseRuntimeConfig(os.Getenv("RUN_MODE"), os.Getenv("APP_PLANE"), os.Getenv("NODE_TYPE")) + if err != nil { + log.Fatalf("invalid runtime configuration: %v", err) + } - err := InitResources() + err = InitResources() if err != nil { common.FatalLog("failed to initialize resources: " + err.Error()) return } - common.SysLog("New API " + common.Version + " started") + common.SysLog(fmt.Sprintf("New API %s started: run_mode=%s plane=%s", common.Version, mode, plane)) if os.Getenv("GIN_MODE") != "debug" { gin.SetMode(gin.ReleaseMode) } @@ -75,10 +66,26 @@ func main() { common.FatalLog("failed to close database: " + err.Error()) } }() + if mode == runModeMigrate { + common.SysLog("database migration completed") + return + } + systemTaskCtx, stopSystemTasks := context.WithCancel(context.Background()) + defer stopSystemTasks() if common.RedisEnabled { // for compatibility with old versions common.MemoryCacheEnabled = true + // Multi-instance adaptive metrics snapshot (best-effort, 2m TTL). + if mode.servesHTTP() || mode.runsWorker() { + gopool.Go(func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + service.SyncAdaptiveMetricsToRedis() + } + }) + } } if common.MemoryCacheEnabled { common.SysLog("memory cache enabled") @@ -113,9 +120,11 @@ func main() { go authz.StartPolicySync(common.SyncFrequency) // 数据看板 - go model.UpdateQuotaData() + if mode.servesHTTP() { + go model.UpdateQuotaData() + } - if os.Getenv("CHANNEL_UPDATE_FREQUENCY") != "" { + if mode.runsScheduler() && os.Getenv("CHANNEL_UPDATE_FREQUENCY") != "" { frequency, err := strconv.Atoi(os.Getenv("CHANNEL_UPDATE_FREQUENCY")) if err != nil { common.FatalLog("failed to parse CHANNEL_UPDATE_FREQUENCY: " + err.Error()) @@ -124,10 +133,12 @@ func main() { } // Codex credential auto-refresh check every 10 minutes, refresh when expires within 1 day - service.StartCodexCredentialAutoRefreshTask() + if mode.runsScheduler() { + service.StartCodexCredentialAutoRefreshTask() - // Subscription quota reset task (daily/weekly/monthly/custom) - service.StartSubscriptionQuotaResetTask() + // Subscription quota reset task (daily/weekly/monthly/custom) + service.StartSubscriptionQuotaResetTask() + } // Report this process as a system instance so the System Info page can show // all currently alive nodes in multi-instance deployments. @@ -136,12 +147,16 @@ func main() { // Wire task polling adaptor factory (breaks service -> relay import cycle). // Must run before the system task runner starts: the async_task_poll handler // calls service.RunTaskPollingOnce, which needs this factory set. - service.GetTaskAdaptorFunc = func(platform constant.TaskPlatform) service.TaskPollingAdaptor { - a := relay.GetTaskAdaptor(platform) - if a == nil { - return nil + if mode.runsWorker() || mode.runsScheduler() { + service.GetTaskAdaptorFunc = func(platform constant.TaskPlatform) service.TaskPollingAdaptor { + a := relay.GetTaskAdaptor(platform) + if a == nil { + return nil + } + return a } - return a + + controller.RegisterScheduledSystemTasks() } // Register the periodic channel test, upstream model update, and async task @@ -149,10 +164,14 @@ func main() { // (DB-lease dedup across masters + run history), then start the runner that // schedules and executes them. Master-only execution and the UpdateTask // switch are enforced inside the runner and each handler's Enabled(). - controller.RegisterScheduledSystemTasks() - service.StartSystemTaskRunner() + if mode.runsScheduler() { + service.StartSystemTaskSchedulerContext(systemTaskCtx) + } + if mode.runsWorker() { + service.StartSystemTaskWorkerContext(systemTaskCtx) + } - if os.Getenv("BATCH_UPDATE_ENABLED") == "true" { + if mode.servesHTTP() && os.Getenv("BATCH_UPDATE_ENABLED") == "true" { common.BatchUpdateEnabled = true common.SysLog("batch update enabled with interval " + strconv.Itoa(common.BatchUpdateInterval) + "s") model.InitBatchUpdater() @@ -160,10 +179,10 @@ func main() { if os.Getenv("ENABLE_PPROF") == "true" { gopool.Go(func() { - log.Println(http.ListenAndServe("0.0.0.0:8005", nil)) + log.Println(http.ListenAndServe("127.0.0.1:8005", nil)) }) go common.Monitor() - common.SysLog("pprof enabled") + common.SysLog("pprof enabled on 127.0.0.1:8005") } err = common.StartPyroScope() @@ -171,13 +190,38 @@ func main() { common.SysError(fmt.Sprintf("start pyroscope error : %v", err)) } + if !mode.servesHTTP() { + common.SysLog(fmt.Sprintf("runtime ready: run_mode=%s", mode)) + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + sig := <-quit + common.SysLog(fmt.Sprintf("received signal: %v, shutting down...", sig)) + stopSystemTasks() + shutdownTimeout := time.Duration(common.GetEnvOrDefault("SHUTDOWN_TIMEOUT_SECONDS", 120)) * time.Second + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := service.WaitForSystemTasks(ctx); err != nil { + common.SysError(fmt.Sprintf("system tasks did not stop before shutdown deadline: %v", err)) + } + return + } + // Initialize HTTP server server := gin.New() + if err := configureTrustedProxies(server); err != nil { + common.FatalLog("failed to configure trusted proxies: " + err.Error()) + return + } server.Use(gin.CustomRecovery(func(c *gin.Context, err any) { - common.SysLog(fmt.Sprintf("panic detected: %v", err)) + reqID := c.GetString(common.RequestIdKey) + common.SysLog(fmt.Sprintf("panic detected request_id=%s: %v", reqID, err)) + msg := "Internal server error" + if reqID != "" { + msg = fmt.Sprintf("Internal server error (request_id=%s)", reqID) + } c.JSON(http.StatusInternalServerError, gin.H{ "error": gin.H{ - "message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err), + "message": msg, "type": "new_api_panic", }, }) @@ -186,7 +230,14 @@ func main() { //server.Use(gzip.Gzip(gzip.DefaultCompression)) server.Use(middleware.RequestId()) server.Use(middleware.Version()) + server.Use(middleware.TraceContext()) + // HSTS 等安全头:经 Tunnel/CF 的 HTTPS 回源会带 X-Forwarded-Proto。 + server.Use(middleware.SecurityHeaders()) server.Use(middleware.I18n()) + if observability.Enabled() { + server.Use(observability.HTTPMiddleware()) + server.GET("/metrics", observability.MetricsAuth(), gin.WrapH(observability.Handler())) + } middleware.SetUpLogger(server) // Initialize session store store := cookie.NewStore([]byte(common.SessionSecret)) @@ -199,25 +250,18 @@ func main() { }) server.Use(sessions.Sessions("session", store)) - InjectUmamiAnalytics() - InjectGoogleAnalytics() - // 设置路由 - router.SetRouter(server, router.ThemeAssets{ - DefaultBuildFS: buildFS, - DefaultIndexPage: indexPage, - ClassicBuildFS: classicBuildFS, - ClassicIndexPage: classicIndexPage, - }) + // 统一通过构建适配器取得前端资源,使后端镜像可以选择完全不嵌入静态文件。 + if err := router.SetRouterForPlane(server, prepareFrontendAssets(), plane); err != nil { + common.FatalLog("failed to configure router: " + err.Error()) + return + } var port = os.Getenv("PORT") if port == "" { port = strconv.Itoa(*common.Port) } - srv := &http.Server{ - Addr: ":" + port, - Handler: server, - } + srv := newHTTPServer(":"+port, server) go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { @@ -233,6 +277,7 @@ func main() { signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) sig := <-quit common.SysLog(fmt.Sprintf("received signal: %v, shutting down...", sig)) + stopSystemTasks() // SSE streams may run for minutes; give them time to finish before forced exit shutdownTimeout := time.Duration(common.GetEnvOrDefault("SHUTDOWN_TIMEOUT_SECONDS", 120)) * time.Second @@ -241,6 +286,9 @@ func main() { if err := srv.Shutdown(ctx); err != nil { common.SysError(fmt.Sprintf("server forced to shutdown: %v", err)) } + if err := service.WaitForSystemTasks(ctx); err != nil { + common.SysError(fmt.Sprintf("system tasks did not stop before shutdown deadline: %v", err)) + } // 内存中的看板数据保存入库,避免重启丢失未落库数据 (issue #5679) if common.DataExportEnabled { model.SaveQuotaDataCache() @@ -248,49 +296,14 @@ func main() { common.SysLog("server exited") } -func InjectUmamiAnalytics() { - analyticsInjectBuilder := &strings.Builder{} - if os.Getenv("UMAMI_WEBSITE_ID") != "" { - umamiSiteID := os.Getenv("UMAMI_WEBSITE_ID") - umamiScriptURL := os.Getenv("UMAMI_SCRIPT_URL") - if umamiScriptURL == "" { - umamiScriptURL = "https://analytics.umami.is/script.js" - } - analyticsInjectBuilder.WriteString("") - } - analyticsInjectBuilder.WriteString("\n") - analyticsInject := []byte(analyticsInjectBuilder.String()) - placeholder := []byte("\n") - indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject) - classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject) -} - -func InjectGoogleAnalytics() { - analyticsInjectBuilder := &strings.Builder{} - if os.Getenv("GOOGLE_ANALYTICS_ID") != "" { - gaID := os.Getenv("GOOGLE_ANALYTICS_ID") - // Google Analytics 4 (gtag.js) - analyticsInjectBuilder.WriteString("") - analyticsInjectBuilder.WriteString("") - } - analyticsInjectBuilder.WriteString("\n") - analyticsInject := []byte(analyticsInjectBuilder.String()) - placeholder := []byte("\n") - indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject) - classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject) +func newHTTPServer(addr string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: time.Duration(common.GetEnvOrDefault("HTTP_READ_HEADER_TIMEOUT_SECONDS", 10)) * time.Second, + IdleTimeout: time.Duration(common.GetEnvOrDefault("HTTP_IDLE_TIMEOUT_SECONDS", 120)) * time.Second, + MaxHeaderBytes: common.GetEnvOrDefault("HTTP_MAX_HEADER_BYTES", 1<<20), + } } func InitResources() error { diff --git a/main_server_test.go b/main_server_test.go new file mode 100644 index 000000000000..1724fe9fcb6e --- /dev/null +++ b/main_server_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestNewHTTPServerUsesSafeConnectionDefaults(t *testing.T) { + t.Setenv("HTTP_READ_HEADER_TIMEOUT_SECONDS", "") + t.Setenv("HTTP_IDLE_TIMEOUT_SECONDS", "") + t.Setenv("HTTP_MAX_HEADER_BYTES", "") + + server := newHTTPServer(":3000", http.NewServeMux()) + require.Equal(t, 10*time.Second, server.ReadHeaderTimeout) + require.Equal(t, 120*time.Second, server.IdleTimeout) + require.Equal(t, 1<<20, server.MaxHeaderBytes) +} + +func TestNewHTTPServerAcceptsTimeoutOverrides(t *testing.T) { + t.Setenv("HTTP_READ_HEADER_TIMEOUT_SECONDS", "7") + t.Setenv("HTTP_IDLE_TIMEOUT_SECONDS", "90") + t.Setenv("HTTP_MAX_HEADER_BYTES", "524288") + + server := newHTTPServer(":3000", http.NewServeMux()) + require.Equal(t, 7*time.Second, server.ReadHeaderTimeout) + require.Equal(t, 90*time.Second, server.IdleTimeout) + require.Equal(t, 524288, server.MaxHeaderBytes) +} diff --git a/makefile b/makefile index 58c4ae4c6677..835d4c078476 100644 --- a/makefile +++ b/makefile @@ -10,25 +10,54 @@ DEV_POSTGRES_DB = new-api DEV_POSTGRES_USER = root DEV_SQLITE_PATH ?= one-api.db -.PHONY: all build-web build-web-classic build-all-web start-api dev dev-api dev-api-rebuild dev-web dev-web-classic reset-setup +.PHONY: all check-build-root check-version build-web build-web-classic build-all-web start-api dev dev-api dev-api-rebuild dev-web dev-web-classic reset-setup build-backend docker-integrated docker-backend docker-frontend docker-separated all: build-all-web start-api -build-web: +check-build-root: + @test "$$(git rev-parse --show-prefix)" = "" || (echo "Run make from the authoritative repository root." && exit 1) + @test "$$(basename "$$(git rev-parse --show-toplevel)")" != "_qn_tmp" || (echo "Refusing to build from the upstream reference tree." && exit 1) + +check-version: check-build-root + @test -s VERSION || (echo "VERSION must not be empty." && exit 1) + +build-web: check-version @echo "Building default web..." @cd ./web && bun install --frozen-lockfile @cd $(WEB_DIR) && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build -build-web-classic: +build-web-classic: check-version @echo "Building classic web..." @cd ./web && bun install --frozen-lockfile @cd $(WEB_CLASSIC_DIR) && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build build-all-web: build-web build-web-classic -start-api: +# Pure backend binary without embedding web/*/dist (requires FRONTEND_MODE=disabled|redirect at runtime). +build-backend: check-version + @echo "Building pure backend (tags=frontend_external)..." + @go build -trimpath -buildvcs=true -tags frontend_external \ + -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$$(cat VERSION)'" \ + -o new-api-backend . + +docker-integrated: check-build-root + @echo "Building integrated image..." + @docker build --tag new-api:local . + +docker-backend: check-build-root + @echo "Building pure backend image..." + @docker build -f Dockerfile.backend --tag new-api-backend:local . + +docker-frontend: check-build-root + @echo "Building separated frontend image..." + @docker build -f deploy/separated/Dockerfile.frontend --tag new-api-frontend:local . + +docker-separated: docker-backend docker-frontend + @echo "Separated images ready: new-api-backend:local new-api-frontend:local" + +start-api: check-build-root @echo "Starting api dev server..." - @cd $(API_DIR) && go run main.go & + @cd $(API_DIR) && go run . & dev-api: @echo "Starting api services (docker)..." diff --git a/middleware/auth.go b/middleware/auth.go index 86abddc79945..00d2aafca613 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -23,6 +23,24 @@ import ( "gorm.io/gorm" ) +func asIntID(v any) int { + switch x := v.(type) { + case int: + return x + case int32: + return int(x) + case int64: + return int(x) + case float64: + return int(x) + case string: + n, _ := strconv.Atoi(x) + return n + default: + return 0 + } +} + func validUserInfo(username string, role int) bool { // check username is empty if strings.TrimSpace(username) == "" { @@ -34,12 +52,39 @@ func validUserInfo(username string, role int) bool { return true } +func getFreshSessionUser(userID int) (*model.User, error) { + if userID <= 0 { + return nil, gorm.ErrRecordNotFound + } + if model.DB == nil { + return nil, model.ErrDatabase + } + return model.GetUserById(userID, false) +} + +func abortSessionUserRefresh(c *gin.Context, err error) { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn), + }) + } else { + common.SysLog("session user refresh failed: " + err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": common.TranslateMessage(c, i18n.MsgDatabaseError), + }) + } + c.Abort() +} + func authHelper(c *gin.Context, minRole int) { session := sessions.Default(c) username := session.Get("username") role := session.Get("role") id := session.Get("id") status := session.Get("status") + authenticatedBySession := username != nil useAccessToken := false if username == nil { // Check access token @@ -113,7 +158,7 @@ func authHelper(c *gin.Context, minRole int) { return } - if id != apiUserId { + if asIntID(id) != apiUserId { c.JSON(http.StatusUnauthorized, gin.H{ "success": false, "message": common.TranslateMessage(c, i18n.MsgAuthUserIdMismatch), @@ -121,7 +166,29 @@ func authHelper(c *gin.Context, minRole int) { c.Abort() return } - if status.(int) == common.UserStatusDisabled { + // Session cookies are identity hints. Authorization always comes from the + // current database row so bans, deletion and demotion fail closed. + userGroup := "" + if g := session.Get("group"); g != nil { + if gs, ok := g.(string); ok { + userGroup = gs + } + } + if authenticatedBySession { + full, refreshErr := getFreshSessionUser(asIntID(id)) + if refreshErr != nil { + abortSessionUserRefresh(c, refreshErr) + return + } + username = full.Username + role = full.Role + status = full.Status + userGroup = full.Group + } + statusInt := asIntID(status) + roleInt := asIntID(role) + usernameStr, _ := username.(string) + if statusInt == common.UserStatusDisabled { c.JSON(http.StatusOK, gin.H{ "success": false, "message": common.TranslateMessage(c, i18n.MsgAuthUserBanned), @@ -129,7 +196,7 @@ func authHelper(c *gin.Context, minRole int) { c.Abort() return } - if role.(int) < minRole { + if roleInt < minRole { c.JSON(http.StatusOK, gin.H{ "success": false, "message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege), @@ -137,7 +204,7 @@ func authHelper(c *gin.Context, minRole int) { c.Abort() return } - if !validUserInfo(username.(string), role.(int)) { + if !validUserInfo(usernameStr, roleInt) { c.JSON(http.StatusOK, gin.H{ "success": false, "message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid), @@ -145,13 +212,18 @@ func authHelper(c *gin.Context, minRole int) { c.Abort() return } + // Normalize context values after possible interface typing from sessions. + username = usernameStr + role = roleInt + status = statusInt + id = asIntID(id) // 防止不同newapi版本冲突,导致数据不通用 c.Header("Auth-Version", "864b7076dbcd0a3c01b5520316720ebf") c.Set("username", username) c.Set("role", role) c.Set("id", id) - c.Set("group", session.Get("group")) - c.Set("user_group", session.Get("group")) + c.Set("group", userGroup) + c.Set("user_group", userGroup) c.Set("use_access_token", useAccessToken) // 管理/root 写操作审计兜底:内聚在鉴权链路里,保证任何经过 AdminAuth/RootAuth @@ -220,24 +292,41 @@ func WssAuth(c *gin.Context) { // Used for endpoints that need to be accessible from both the dashboard and API clients. func TokenOrUserAuth() func(c *gin.Context) { return func(c *gin.Context) { - // Try session auth first (dashboard users) + // Try session auth first (dashboard users) — re-source status from DB + // so ban/disable takes effect before 30-day cookie expires. session := sessions.Default(c) if id := session.Get("id"); id != nil { - if status, ok := session.Get("status").(int); ok && status == common.UserStatusEnabled { - c.Set("id", id) - c.Next() + uid := asIntID(id) + full, refreshErr := getFreshSessionUser(uid) + if refreshErr != nil { + abortSessionUserRefresh(c, refreshErr) return } + if full.Status != common.UserStatusEnabled { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": common.TranslateMessage(c, i18n.MsgAuthUserBanned), + }) + c.Abort() + return + } + c.Set("id", full.Id) + c.Set("username", full.Username) + c.Set("role", full.Role) + c.Set("status", full.Status) + c.Set("group", full.Group) + c.Set("user_group", full.Group) + c.Next() + return } // Fall back to token auth (API clients) TokenAuth()(c) } } -// TokenAuthReadOnly 宽松版本的令牌认证中间件,用于只读查询接口。 -// 只验证令牌 key 是否存在,不检查令牌状态、过期时间和额度。 -// 即使令牌已过期、已耗尽或已禁用,也允许访问。 -// 仍然检查用户是否被封禁。 +// TokenAuthReadOnly is used by usage/log query endpoints. +// Rejects explicitly disabled tokens; expired/exhausted tokens may still read metadata. +// User ban checks remain required. func TokenAuthReadOnly() func(c *gin.Context) { return func(c *gin.Context) { key := c.Request.Header.Get("Authorization") diff --git a/middleware/auth_test.go b/middleware/auth_test.go new file mode 100644 index 000000000000..9aa5f939e91b --- /dev/null +++ b/middleware/auth_test.go @@ -0,0 +1,224 @@ +package middleware + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupTokenOrUserAuthTestDB(t *testing.T) *gorm.DB { + t.Helper() + + originalDB := model.DB + originalRedisEnabled := common.RedisEnabled + common.RedisEnabled = false + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.User{})) + model.DB = db + + t.Cleanup(func() { + model.DB = originalDB + common.RedisEnabled = originalRedisEnabled + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + }) + + return db +} + +func tokenOrUserAuthSessionCookies(t *testing.T, router *gin.Engine, userID int) []*http.Cookie { + t.Helper() + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/login", nil) + router.ServeHTTP(recorder, request) + require.Equal(t, http.StatusNoContent, recorder.Code) + return recorder.Result().Cookies() +} + +func newTokenOrUserAuthTestRouter(t *testing.T, userID int, handler gin.HandlerFunc) *gin.Engine { + t.Helper() + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(sessions.Sessions("session", cookie.NewStore([]byte("token-or-user-auth-test")))) + router.GET("/login", func(c *gin.Context) { + session := sessions.Default(c) + session.Set("id", userID) + session.Set("username", "stale-user") + session.Set("role", common.RoleCommonUser) + session.Set("status", common.UserStatusEnabled) + session.Set("group", "default") + require.NoError(t, session.Save()) + c.Status(http.StatusNoContent) + }) + router.GET("/protected", TokenOrUserAuth(), handler) + return router +} + +func TestTokenOrUserAuthRejectsDBDisabledSessionUser(t *testing.T) { + db := setupTokenOrUserAuthTestDB(t) + user := &model.User{ + Id: 101, + Username: "disabled-user", + Password: "not-used-in-test", + Role: common.RoleCommonUser, + Status: common.UserStatusDisabled, + Group: "default", + } + require.NoError(t, db.Create(user).Error) + + handlerCalled := false + router := newTokenOrUserAuthTestRouter(t, user.Id, func(c *gin.Context) { + handlerCalled = true + c.Status(http.StatusNoContent) + }) + cookies := tokenOrUserAuthSessionCookies(t, router, user.Id) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/protected", nil) + for _, sessionCookie := range cookies { + request.AddCookie(sessionCookie) + } + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusForbidden, recorder.Code) + require.False(t, handlerCalled) +} + +func TestTokenOrUserAuthRefreshesSessionContextFromDB(t *testing.T) { + db := setupTokenOrUserAuthTestDB(t) + user := &model.User{ + Id: 102, + Username: "fresh-user", + Password: "not-used-in-test", + Role: common.RoleAdminUser, + Status: common.UserStatusEnabled, + Group: "premium", + } + require.NoError(t, db.Create(user).Error) + + router := newTokenOrUserAuthTestRouter(t, user.Id, func(c *gin.Context) { + require.Equal(t, user.Username, c.GetString("username")) + require.Equal(t, user.Role, c.GetInt("role")) + require.Equal(t, user.Group, c.GetString("user_group")) + c.Status(http.StatusNoContent) + }) + cookies := tokenOrUserAuthSessionCookies(t, router, user.Id) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/protected", nil) + for _, sessionCookie := range cookies { + request.AddCookie(sessionCookie) + } + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusNoContent, recorder.Code) +} + +func TestTokenOrUserAuthRejectsMissingSessionUser(t *testing.T) { + setupTokenOrUserAuthTestDB(t) + const missingUserID = 103 + + handlerCalled := false + router := newTokenOrUserAuthTestRouter(t, missingUserID, func(c *gin.Context) { + handlerCalled = true + c.Status(http.StatusNoContent) + }) + cookies := tokenOrUserAuthSessionCookies(t, router, missingUserID) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/protected", nil) + for _, sessionCookie := range cookies { + request.AddCookie(sessionCookie) + } + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusUnauthorized, recorder.Code) + require.False(t, handlerCalled) +} + +func TestTokenOrUserAuthFailsClosedOnDatabaseError(t *testing.T) { + db := setupTokenOrUserAuthTestDB(t) + user := &model.User{ + Id: 104, + Username: "db-error-user", + Password: "not-used-in-test", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + } + require.NoError(t, db.Create(user).Error) + + handlerCalled := false + router := newTokenOrUserAuthTestRouter(t, user.Id, func(c *gin.Context) { + handlerCalled = true + c.Status(http.StatusNoContent) + }) + cookies := tokenOrUserAuthSessionCookies(t, router, user.Id) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/protected", nil) + for _, sessionCookie := range cookies { + request.AddCookie(sessionCookie) + } + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusInternalServerError, recorder.Code) + require.False(t, handlerCalled) +} + +func TestAdminAuthRejectsDeletedSessionUser(t *testing.T) { + setupTokenOrUserAuthTestDB(t) + const missingUserID = 105 + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(sessions.Sessions("session", cookie.NewStore([]byte("admin-auth-test")))) + router.GET("/login", func(c *gin.Context) { + session := sessions.Default(c) + session.Set("id", missingUserID) + session.Set("username", "deleted-admin") + session.Set("role", common.RoleAdminUser) + session.Set("status", common.UserStatusEnabled) + session.Set("group", "default") + require.NoError(t, session.Save()) + c.Status(http.StatusNoContent) + }) + handlerCalled := false + router.GET("/admin", AdminAuth(), func(c *gin.Context) { + handlerCalled = true + c.Status(http.StatusNoContent) + }) + + cookies := tokenOrUserAuthSessionCookies(t, router, missingUserID) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/admin", nil) + request.Header.Set("New-Api-User", fmt.Sprintf("%d", missingUserID)) + for _, sessionCookie := range cookies { + request.AddCookie(sessionCookie) + } + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusUnauthorized, recorder.Code) + require.False(t, handlerCalled) +} diff --git a/middleware/cache.go b/middleware/cache.go index 1a9dff877d9e..8c554690b572 100644 --- a/middleware/cache.go +++ b/middleware/cache.go @@ -1,15 +1,21 @@ package middleware import ( + "regexp" + "github.com/gin-gonic/gin" ) +var fingerprintedAssetPattern = regexp.MustCompile(`\.[0-9a-f]{8,}\.(?:css|eot|gif|ico|jpe?g|js|png|svg|ttf|webp|woff2?)$`) + func Cache() func(c *gin.Context) { return func(c *gin.Context) { - if c.Request.RequestURI == "/" { - c.Header("Cache-Control", "no-cache") + if fingerprintedAssetPattern.MatchString(c.Request.URL.Path) { + c.Header("Cache-Control", "public, max-age=31536000, immutable") } else { - c.Header("Cache-Control", "max-age=604800") // one week + // HTML, SPA routes and stable filenames must revalidate so a release + // cannot strand clients on an old entry document. + c.Header("Cache-Control", "no-cache") } c.Header("Cache-Version", "b688f2fb5be447c25e5aa3bd063087a83db32a288bf6a4f35f2d8db310e40b14") c.Next() diff --git a/middleware/cache_test.go b/middleware/cache_test.go new file mode 100644 index 000000000000..0e7175c4d013 --- /dev/null +++ b/middleware/cache_test.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func cacheHeaderForPath(path string) string { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(Cache()) + router.GET("/*path", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) + return recorder.Header().Get("Cache-Control") +} + +func TestCacheUsesImmutablePolicyForFingerprintedAssets(t *testing.T) { + require.Equal(t, "public, max-age=31536000, immutable", cacheHeaderForPath("/static/js/index.457aecb830.js?theme=default")) + require.Equal(t, "public, max-age=31536000, immutable", cacheHeaderForPath("/static/font/public-sans.035c7fe496.woff2")) +} + +func TestCacheRevalidatesHTMLAndStableAssets(t *testing.T) { + require.Equal(t, "no-cache", cacheHeaderForPath("/dashboard")) + require.Equal(t, "no-cache", cacheHeaderForPath("/favicon.ico")) +} diff --git a/middleware/cors.go b/middleware/cors.go index e90d77bdd34b..b8b7b6090e21 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -1,20 +1,93 @@ package middleware import ( + "net/url" + "os" + "strings" + "github.com/QuantumNous/new-api/common" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" ) func CORS() gin.HandlerFunc { + allowedOrigins := parseAllowedOrigins("CORS_ALLOWED_ORIGINS") + if len(allowedOrigins) == 0 { + // Reuse the explicitly trusted HTTPS frontends when secure session cookies + // are configured. With neither setting present, cross-origin access is + // denied; normal same-origin requests do not require CORS headers. + allowedOrigins = parseAllowedOrigins("SESSION_COOKIE_TRUSTED_URL") + } + if len(allowedOrigins) == 0 { + return func(c *gin.Context) { + c.Next() + } + } + config := cors.DefaultConfig() - config.AllowAllOrigins = true + config.AllowOrigins = allowedOrigins config.AllowCredentials = true - config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} - config.AllowHeaders = []string{"*"} + config.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"} + config.AllowHeaders = []string{ + "Accept", + "Authorization", + "Content-Type", + "New-Api-Key", + "New-Api-User", + "X-Request-Id", + "X-Trace-Id", + "X-Thread-Id", + "AH-Trace-Id", + "AH-Thread-Id", + } + config.ExposeHeaders = []string{ + "X-Oneapi-Request-Id", + "X-Request-Id", + "X-Trace-Id", + "X-Thread-Id", + "AH-Trace-Id", + "AH-Thread-Id", + } return cors.New(config) } +func parseAllowedOrigins(name string) []string { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return nil + } + seen := make(map[string]struct{}) + values := make([]string, 0) + for _, item := range strings.Split(raw, ",") { + value, ok := normalizeAllowedOrigin(item) + if !ok { + common.SysError("ignoring invalid " + name + " origin entry") + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + values = append(values, value) + } + return values +} + +func normalizeAllowedOrigin(value string) (string, bool) { + value = strings.TrimSpace(value) + if value == "" || strings.Contains(value, "*") { + return "", false + } + parsed, err := url.Parse(value) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return "", false + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") { + return "", false + } + return parsed.Scheme + "://" + parsed.Host, true +} + func Version() gin.HandlerFunc { return func(c *gin.Context) { c.Header("X-New-Api-Version", common.Version) diff --git a/middleware/cors_test.go b/middleware/cors_test.go new file mode 100644 index 000000000000..07ce3c7a5584 --- /dev/null +++ b/middleware/cors_test.go @@ -0,0 +1,83 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func corsTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(CORS()) + router.GET("/resource", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + return router +} + +func performCORSPreflight(t *testing.T, router *gin.Engine, origin string) *httptest.ResponseRecorder { + t.Helper() + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodOptions, "/resource", nil) + request.Header.Set("Origin", origin) + request.Header.Set("Access-Control-Request-Method", http.MethodGet) + request.Header.Set("Access-Control-Request-Headers", "authorization,content-type") + router.ServeHTTP(recorder, request) + return recorder +} + +func TestCORSRejectsCrossOriginByDefault(t *testing.T) { + t.Setenv("CORS_ALLOWED_ORIGINS", "") + t.Setenv("SESSION_COOKIE_TRUSTED_URL", "") + + recorder := performCORSPreflight(t, corsTestRouter(), "https://evil.example") + require.Empty(t, recorder.Header().Get("Access-Control-Allow-Origin")) + require.NotEqual(t, http.StatusNoContent, recorder.Code) +} + +func TestCORSRejectsOriginOutsideConfiguredAllowlist(t *testing.T) { + t.Setenv("CORS_ALLOWED_ORIGINS", "https://console.example") + t.Setenv("SESSION_COOKIE_TRUSTED_URL", "") + + recorder := performCORSPreflight(t, corsTestRouter(), "https://evil.example") + require.Equal(t, http.StatusForbidden, recorder.Code) + require.Empty(t, recorder.Header().Get("Access-Control-Allow-Origin")) +} + +func TestCORSAllowsConfiguredCredentialOrigin(t *testing.T) { + t.Setenv("CORS_ALLOWED_ORIGINS", "https://console.example, https://admin.example") + t.Setenv("SESSION_COOKIE_TRUSTED_URL", "") + + recorder := performCORSPreflight(t, corsTestRouter(), "https://console.example") + require.Equal(t, http.StatusNoContent, recorder.Code) + require.Equal(t, "https://console.example", recorder.Header().Get("Access-Control-Allow-Origin")) + require.Equal(t, "true", recorder.Header().Get("Access-Control-Allow-Credentials")) +} + +func TestCORSFallsBackToTrustedSessionURLs(t *testing.T) { + t.Setenv("CORS_ALLOWED_ORIGINS", "") + t.Setenv("SESSION_COOKIE_TRUSTED_URL", "https://console.example") + + recorder := performCORSPreflight(t, corsTestRouter(), "https://console.example") + require.Equal(t, "https://console.example", recorder.Header().Get("Access-Control-Allow-Origin")) +} + +func TestCORSRejectsWildcardAndInvalidOrigins(t *testing.T) { + t.Setenv("CORS_ALLOWED_ORIGINS", "*,javascript:alert(1),https://console.example/path") + t.Setenv("SESSION_COOKIE_TRUSTED_URL", "") + + recorder := performCORSPreflight(t, corsTestRouter(), "https://evil.example") + require.Empty(t, recorder.Header().Get("Access-Control-Allow-Origin")) + require.NotEqual(t, http.StatusNoContent, recorder.Code) +} + +func TestCORSNormalizesTrailingSlash(t *testing.T) { + t.Setenv("CORS_ALLOWED_ORIGINS", "https://console.example/") + t.Setenv("SESSION_COOKIE_TRUSTED_URL", "") + + recorder := performCORSPreflight(t, corsTestRouter(), "https://console.example") + require.Equal(t, http.StatusNoContent, recorder.Code) + require.Equal(t, "https://console.example", recorder.Header().Get("Access-Control-Allow-Origin")) +} diff --git a/middleware/header_nav_test.go b/middleware/header_nav_test.go index d4c9c221ef2d..a8ccbece831d 100644 --- a/middleware/header_nav_test.go +++ b/middleware/header_nav_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" @@ -36,6 +37,17 @@ func withHeaderNavModules(t *testing.T, raw string) { func performHeaderNavRequest(t *testing.T, handler gin.HandlerFunc, authenticated bool) *httptest.ResponseRecorder { t.Helper() + if authenticated { + db := setupTokenOrUserAuthTestDB(t) + require.NoError(t, db.Create(&model.User{ + Id: 1, + Username: "tester", + Password: "not-used-in-test", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + }).Error) + } gin.SetMode(gin.TestMode) router := gin.New() diff --git a/middleware/recover.go b/middleware/recover.go index 745a61015dae..3af236f63416 100644 --- a/middleware/recover.go +++ b/middleware/recover.go @@ -13,11 +13,16 @@ func RelayPanicRecover() gin.HandlerFunc { return func(c *gin.Context) { defer func() { if err := recover(); err != nil { - common.SysLog(fmt.Sprintf("panic detected: %v", err)) + reqID := c.GetString(common.RequestIdKey) + common.SysLog(fmt.Sprintf("panic detected request_id=%s: %v", reqID, err)) common.SysLog(fmt.Sprintf("stacktrace from panic: %s", string(debug.Stack()))) + msg := "Internal server error" + if reqID != "" { + msg = fmt.Sprintf("Internal server error (request_id=%s)", reqID) + } c.JSON(http.StatusInternalServerError, gin.H{ "error": gin.H{ - "message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err), + "message": msg, "type": "new_api_panic", }, }) diff --git a/middleware/security_headers.go b/middleware/security_headers.go new file mode 100644 index 000000000000..8cea9bfe605f --- /dev/null +++ b/middleware/security_headers.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// SecurityHeaders 为经 HTTPS 到达的请求附加安全响应头。 +// 当反向代理/Tunnel 终止 TLS 时,通过 X-Forwarded-Proto 识别 HTTPS。 +// HSTS 仅在判定为 HTTPS 时发送,避免本机 http://127.0.0.1 被浏览器错误 HSTS。 +func SecurityHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Referrer-Policy", "strict-origin-when-cross-origin") + c.Header("X-Frame-Options", "SAMEORIGIN") + + if isHTTPSRequest(c.Request) { + // 6 months; no includeSubDomains/preload — single-host production default. + c.Header("Strict-Transport-Security", "max-age=15768000") + } + c.Next() + } +} + +// isHTTPSRequest 判断客户端侧是否为 HTTPS(含反代转发头)。 +func isHTTPSRequest(r *http.Request) bool { + if r.TLS != nil { + return true + } + proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) + if proto == "" { + return false + } + // 取最左(最靠近客户端)的协议标记。 + if i := strings.IndexByte(proto, ','); i >= 0 { + proto = proto[:i] + } + return strings.EqualFold(strings.TrimSpace(proto), "https") +} diff --git a/middleware/security_headers_test.go b/middleware/security_headers_test.go new file mode 100644 index 000000000000..9fb3f775e02c --- /dev/null +++ b/middleware/security_headers_test.go @@ -0,0 +1,48 @@ +package middleware + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestSecurityHeadersSetsHSTSOnlyForHTTPS(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("plain http no hsts", func(t *testing.T) { + engine := gin.New() + engine.Use(SecurityHeaders()) + engine.GET("/x", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x", nil)) + require.Equal(t, http.StatusOK, rec.Code) + require.Empty(t, rec.Header().Get("Strict-Transport-Security")) + require.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options")) + }) + + t.Run("x-forwarded-proto https sets hsts", func(t *testing.T) { + engine := gin.New() + engine.Use(SecurityHeaders()) + engine.GET("/x", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("X-Forwarded-Proto", "https") + engine.ServeHTTP(rec, req) + require.Equal(t, "max-age=15768000", rec.Header().Get("Strict-Transport-Security")) + }) + + t.Run("direct tls sets hsts", func(t *testing.T) { + engine := gin.New() + engine.Use(SecurityHeaders()) + engine.GET("/x", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.TLS = &tls.ConnectionState{} + engine.ServeHTTP(rec, req) + require.Equal(t, "max-age=15768000", rec.Header().Get("Strict-Transport-Security")) + }) +} diff --git a/middleware/trace.go b/middleware/trace.go new file mode 100644 index 000000000000..121d07cf2198 --- /dev/null +++ b/middleware/trace.go @@ -0,0 +1,88 @@ +package middleware + +import ( + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +const maxClientTraceIDBytes = 256 + +func normalizeClientTraceID(value string) string { + value = strings.TrimSpace(value) + if value == "" || len(value) > maxClientTraceIDBytes { + return "" + } + for _, r := range value { + if r < 0x21 || r > 0x7e { + return "" + } + } + return value +} + +func firstValidTraceHeader(c *gin.Context, names ...string) string { + if c == nil || c.Request == nil { + return "" + } + for _, name := range names { + if value := normalizeClientTraceID(c.GetHeader(name)); value != "" { + return value + } + } + return "" +} + +// TraceContext injects AxonHub-compatible Thread/Trace IDs for agent observability. +// Accepts AH-* and X-* aliases; generates UUIDs when missing. Echoes headers on the response. +// +// Affinity sticky only uses client-provided Trace IDs (see affinity_trace_id) so +// auto-generated per-request IDs do not pollute the channel affinity LRU. +func TraceContext() gin.HandlerFunc { + return func(c *gin.Context) { + clientThread := firstValidTraceHeader(c, + "AH-Thread-Id", "Ah-Thread-Id", "X-Thread-Id", "X-Ah-Thread-Id") + clientTrace := firstValidTraceHeader(c, + "AH-Trace-Id", "Ah-Trace-Id", "X-Trace-Id", "X-Ah-Trace-Id") + + // Optional coding-tool fallbacks count as client-provided. + if clientTrace == "" { + clientTrace = firstValidTraceHeader(c, "Session_id", "Session-Id", "X-Session-Id") + } + + threadID := clientThread + traceID := clientTrace + if threadID == "" { + threadID = uuid.NewString() + } + if traceID == "" { + if rid := c.GetString(common.RequestIdKey); rid != "" { + traceID = rid + } else { + traceID = uuid.NewString() + } + } + + c.Set(string(constant.ContextKeyThreadId), threadID) + c.Set(string(constant.ContextKeyTraceId), traceID) + c.Set("thread_id", threadID) + c.Set("trace_id", traceID) + // Only client-supplied traces are sticky-affinity eligible. + if clientTrace != "" { + c.Set("affinity_trace_id", clientTrace) + c.Set("trace_client_provided", true) + } else { + c.Set("trace_client_provided", false) + } + + c.Header("AH-Thread-Id", threadID) + c.Header("AH-Trace-Id", traceID) + c.Header("X-Thread-Id", threadID) + c.Header("X-Trace-Id", traceID) + + c.Next() + } +} diff --git a/middleware/trace_test.go b/middleware/trace_test.go new file mode 100644 index 000000000000..45fe98524c66 --- /dev/null +++ b/middleware/trace_test.go @@ -0,0 +1,95 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestTraceContextGeneratesAndEchoes(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(RequestId()) + r.Use(TraceContext()) + r.GET("/v1/chat/completions", func(c *gin.Context) { + require.NotEmpty(t, c.GetString("thread_id")) + require.NotEmpty(t, c.GetString("trace_id")) + require.Empty(t, c.GetString("affinity_trace_id")) + require.False(t, c.GetBool("trace_client_provided")) + _, _ = service.GetPreferredChannelByAffinity(c, "gpt-test", "default") + _, affinityConfigured := service.GetChannelAffinityStatsContext(c) + require.False(t, affinityConfigured) + service.RecordChannelAffinity(c, 123) + c.JSON(200, gin.H{ + "thread_id": c.GetString("thread_id"), + "trace_id": c.GetString("trace_id"), + }) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/chat/completions", nil) + r.ServeHTTP(w, req) + require.Equal(t, 200, w.Code) + require.NotEmpty(t, w.Header().Get("AH-Thread-Id")) + require.NotEmpty(t, w.Header().Get("AH-Trace-Id")) + // When client omits AH-Trace-Id, fallback links to request id. + require.Equal(t, w.Header().Get(common.RequestIdKey), w.Header().Get("AH-Trace-Id")) +} + +func TestTraceContextRespectsClientHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(RequestId()) + r.Use(TraceContext()) + r.GET("/v1/chat/completions", func(c *gin.Context) { + require.Equal(t, "trace-xyz", c.GetString("affinity_trace_id")) + require.True(t, c.GetBool("trace_client_provided")) + _, _ = service.GetPreferredChannelByAffinity(c, "gpt-test", "default") + _, affinityConfigured := service.GetChannelAffinityStatsContext(c) + require.True(t, affinityConfigured) + c.JSON(200, gin.H{ + "thread_id": c.GetString("thread_id"), + "trace_id": c.GetString("trace_id"), + }) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/chat/completions", nil) + req.Header.Set("AH-Thread-Id", "thread-abc") + req.Header.Set("X-Trace-Id", "trace-xyz") + r.ServeHTTP(w, req) + require.Equal(t, 200, w.Code) + require.Equal(t, "thread-abc", w.Header().Get("AH-Thread-Id")) + require.Equal(t, "trace-xyz", w.Header().Get("AH-Trace-Id")) + require.Equal(t, "trace-xyz", w.Header().Get("X-Trace-Id")) +} + +func TestTraceContextRejectsOversizedAffinityTrace(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(RequestId()) + r.Use(TraceContext()) + r.GET("/v1/chat/completions", func(c *gin.Context) { + require.Empty(t, c.GetString("affinity_trace_id")) + require.False(t, c.GetBool("trace_client_provided")) + require.NotEqual(t, c.GetHeader("AH-Trace-Id"), c.GetString("trace_id")) + c.Status(http.StatusNoContent) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/chat/completions", nil) + req.Header.Set("AH-Trace-Id", string(make([]byte, maxClientTraceIDBytes+1))) + r.ServeHTTP(w, req) + require.Equal(t, http.StatusNoContent, w.Code) +} + +func TestTraceContextRejectsControlCharactersForAffinity(t *testing.T) { + require.Empty(t, normalizeClientTraceID("trace\tattacker")) + require.Empty(t, normalizeClientTraceID("trace attacker")) + require.Equal(t, "trace-safe_123", normalizeClientTraceID(" trace-safe_123 ")) +} diff --git a/middleware/turnstile-check.go b/middleware/turnstile-check.go index af87fad4423c..c1bd7c7670b0 100644 --- a/middleware/turnstile-check.go +++ b/middleware/turnstile-check.go @@ -4,8 +4,11 @@ import ( "encoding/json" "net/http" "net/url" + "strings" + "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/service" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) @@ -32,11 +35,28 @@ func TurnstileCheck() gin.HandlerFunc { c.Abort() return } - rawRes, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{ + form := url.Values{ "secret": {common.TurnstileSecretKey}, "response": {response}, "remoteip": {c.ClientIP()}, - }) + } + request, err := http.NewRequestWithContext( + c.Request.Context(), + http.MethodPost, + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + strings.NewReader(form.Encode()), + ) + if err != nil { + common.SysLog(err.Error()) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + c.Abort() + return + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rawRes, err := service.GetHttpClientWithTimeout(10 * time.Second).Do(request) if err != nil { common.SysLog(err.Error()) c.JSON(http.StatusOK, gin.H{ diff --git a/model/ability.go b/model/ability.go index e67b28301e02..216b22912dd7 100644 --- a/model/ability.go +++ b/model/ability.go @@ -106,6 +106,10 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) { } func GetChannel(group string, model string, retry int, requestPath string) (*Channel, error) { + return GetChannelExcluding(group, model, retry, requestPath, nil) +} + +func GetChannelExcluding(group string, model string, retry int, requestPath string, excluded map[int]struct{}) (*Channel, error) { var abilities []Ability var err error = nil @@ -122,6 +126,12 @@ func GetChannel(group string, model string, retry int, requestPath string) (*Cha return nil, err } abilities = filterAbilitiesByRequestPathAndModel(abilities, requestPath, model) + if len(excluded) > 0 { + abilities = lo.Filter(abilities, func(ability Ability, _ int) bool { + _, skip := excluded[ability.ChannelId] + return !skip + }) + } channel := Channel{} if len(abilities) > 0 { // Randomly choose one diff --git a/model/channel_cache.go b/model/channel_cache.go index 81923017d79c..9dcb095fa9eb 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -111,10 +111,60 @@ func SyncChannelCache(frequency int) { } } +// GetSatisfiedChannels returns all enabled channels for group+model (path-aware), +// highest priority first. Used by adaptive balance candidate collection. +// When memory cache is off, falls back to a single DB-selected channel. +func GetSatisfiedChannels(group string, modelName string, requestPath string) ([]*Channel, error) { + if !common.MemoryCacheEnabled { + ch, err := GetChannel(group, modelName, 0, requestPath) + if err != nil { + return nil, err + } + if ch == nil { + return nil, nil + } + return []*Channel{ch}, nil + } + + channelSyncLock.RLock() + defer channelSyncLock.RUnlock() + + ids := filterChannelsByRequestPathAndModel(group2model2channels[group][modelName], requestPath, modelName) + if len(ids) == 0 { + normalizedModel := ratio_setting.FormatMatchingModelName(modelName) + ids = filterChannelsByRequestPathAndModel(group2model2channels[group][normalizedModel], requestPath, normalizedModel) + } + if len(ids) == 0 { + return nil, nil + } + + out := make([]*Channel, 0, len(ids)) + seen := make(map[int]struct{}, len(ids)) + for _, id := range ids { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ch, ok := channelsIDM[id] + if !ok || ch == nil { + continue + } + if ch.Status != common.ChannelStatusEnabled { + continue + } + out = append(out, ch) + } + return out, nil +} + func GetRandomSatisfiedChannel(group string, model string, retry int, requestPath string) (*Channel, error) { + return GetRandomSatisfiedChannelExcluding(group, model, retry, requestPath, nil) +} + +func GetRandomSatisfiedChannelExcluding(group string, model string, retry int, requestPath string, excluded map[int]struct{}) (*Channel, error) { // if memory cache is disabled, get channel directly from database if !common.MemoryCacheEnabled { - return GetChannel(group, model, retry, requestPath) + return GetChannelExcluding(group, model, retry, requestPath, excluded) } channelSyncLock.RLock() @@ -132,6 +182,18 @@ func GetRandomSatisfiedChannel(group string, model string, retry int, requestPat if len(channels) == 0 { return nil, nil } + if len(excluded) > 0 { + filtered := make([]int, 0, len(channels)) + for _, channelID := range channels { + if _, skip := excluded[channelID]; !skip { + filtered = append(filtered, channelID) + } + } + channels = filtered + if len(channels) == 0 { + return nil, nil + } + } if len(channels) == 1 { if channel, ok := channelsIDM[channels[0]]; ok { diff --git a/model/channel_selection_exclusion_test.go b/model/channel_selection_exclusion_test.go new file mode 100644 index 000000000000..c50033512856 --- /dev/null +++ b/model/channel_selection_exclusion_test.go @@ -0,0 +1,54 @@ +package model + +import ( + "fmt" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestGetChannelExcludingSkipsPreviouslyFailedChannel(t *testing.T) { + originalDB := DB + originalMemoryCacheEnabled := common.MemoryCacheEnabled + common.MemoryCacheEnabled = false + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&Channel{}, &Ability{})) + DB = db + t.Cleanup(func() { + DB = originalDB + common.MemoryCacheEnabled = originalMemoryCacheEnabled + if sqlDB, dbErr := db.DB(); dbErr == nil { + _ = sqlDB.Close() + } + }) + + priority := int64(100) + require.NoError(t, db.Create(&Channel{Id: 1, Name: "depleted"}).Error) + require.NoError(t, db.Create(&Channel{Id: 2, Name: "available"}).Error) + require.NoError(t, db.Create(&Ability{ + Group: "default", Model: "gpt-test", ChannelId: 1, + Enabled: true, Priority: &priority, Weight: 100, + }).Error) + require.NoError(t, db.Create(&Ability{ + Group: "default", Model: "gpt-test", ChannelId: 2, + Enabled: true, Priority: &priority, Weight: 1, + }).Error) + + channel, err := GetChannelExcluding( + "default", + "gpt-test", + 0, + "/v1/chat/completions", + map[int]struct{}{1: {}}, + ) + require.NoError(t, err) + require.NotNil(t, channel) + require.Equal(t, 2, channel.Id) +} diff --git a/model/clickhouse_log_test.go b/model/clickhouse_log_test.go index d9737e6b226d..727e8ed496fb 100644 --- a/model/clickhouse_log_test.go +++ b/model/clickhouse_log_test.go @@ -83,6 +83,7 @@ func TestClickHouseLogCreateTableSQL(t *testing.T) { assert.Contains(t, withoutTTL, "ENGINE = MergeTree()") assert.Contains(t, withoutTTL, "PARTITION BY toYYYYMM(toDateTime(created_at))") assert.Contains(t, withoutTTL, "ORDER BY (created_at, request_id)") + assert.Contains(t, withoutTTL, "trace_id String DEFAULT ''") assert.NotContains(t, withoutTTL, "TTL ") withTTL := clickHouseLogCreateTableSQL(30) @@ -90,6 +91,13 @@ func TestClickHouseLogCreateTableSQL(t *testing.T) { assert.Contains(t, withTTL, "TTL toDateTime(created_at) + INTERVAL 30 DAY DELETE") } +func TestClickHouseLogSchemaMigrationSQL(t *testing.T) { + statements := clickHouseLogSchemaMigrationSQL() + require.Len(t, statements, 2) + assert.Contains(t, statements[0], "ADD COLUMN IF NOT EXISTS trace_id") + assert.Contains(t, statements[1], "ADD INDEX IF NOT EXISTS idx_logs_trace_id") +} + func TestClickHouseCreateTableHasTTL(t *testing.T) { assert.True(t, clickHouseCreateTableHasTTL("CREATE TABLE logs (...)\nTTL toDateTime(created_at) + INTERVAL 30 DAY DELETE")) assert.True(t, clickHouseCreateTableHasTTL("CREATE TABLE logs (...) TTL toDateTime(created_at)")) diff --git a/model/log.go b/model/log.go index 506bd504b686..b4751b5d9113 100644 --- a/model/log.go +++ b/model/log.go @@ -2,12 +2,15 @@ package model import ( "context" + "encoding/base64" + "encoding/json" "errors" "fmt" "strings" "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/types" @@ -57,9 +60,9 @@ func sanitizeClickHouseLikePattern(input string) (string, error) { } type Log struct { - Id int `json:"id" gorm:"index:idx_created_at_id,priority:2;index:idx_user_id_id,priority:2"` - UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"` - CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:1;index:idx_created_at_type"` + Id int `json:"id" gorm:"index:idx_created_at_id,priority:2;index:idx_user_id_id,priority:2;index:idx_logs_user_created_id,priority:3"` + UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1;index:idx_logs_user_created_id,priority:1"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:1;index:idx_created_at_type;index:idx_logs_user_created_id,priority:2;index:idx_logs_trace_created,priority:2"` Type int `json:"type" gorm:"index:idx_created_at_type"` Content string `json:"content"` Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"` @@ -77,6 +80,7 @@ type Log struct { Ip string `json:"ip" gorm:"index;default:''"` RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"` UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"` + TraceId string `json:"trace_id,omitempty" gorm:"type:varchar(128);index:idx_logs_trace_id;index:idx_logs_trace_created,priority:1;default:''"` Other string `json:"other"` } @@ -98,11 +102,64 @@ func ensureLogRequestId(log *Log) { } } +func ensureLogTraceId(log *Log) { + if log == nil || log.TraceId != "" || log.Other == "" { + return + } + other, err := common.StrToMap(log.Other) + if err != nil || other == nil { + return + } + if traceId, ok := other["trace_id"].(string); ok { + log.TraceId = strings.TrimSpace(traceId) + } +} + func createLog(log *Log) error { ensureLogRequestId(log) + ensureLogTraceId(log) return LOG_DB.Create(log).Error } +var ErrInvalidLogCursor = errors.New("invalid log cursor") + +type logCursor struct { + CreatedAt int64 `json:"t"` + Id int `json:"i,omitempty"` + RequestId string `json:"r,omitempty"` +} + +func encodeLogCursor(log *Log) (string, error) { + if log == nil || log.CreatedAt <= 0 { + return "", ErrInvalidLogCursor + } + payload, err := json.Marshal(logCursor{ + CreatedAt: log.CreatedAt, + Id: log.Id, + RequestId: log.RequestId, + }) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(payload), nil +} + +func decodeLogCursor(value string) (logCursor, error) { + var cursor logCursor + value = strings.TrimSpace(value) + if value == "" { + return cursor, nil + } + payload, err := base64.RawURLEncoding.DecodeString(value) + if err != nil || len(payload) > 512 { + return cursor, ErrInvalidLogCursor + } + if err := json.Unmarshal(payload, &cursor); err != nil || cursor.CreatedAt <= 0 { + return logCursor{}, ErrInvalidLogCursor + } + return cursor, nil +} + func clickHouseLogOrder(prefix string) string { return prefix + "created_at desc, " + prefix + "request_id desc" } @@ -279,6 +336,42 @@ func RecordTopupLog(userId int, content string, callerIp string, paymentMethod s } } +// mergeTraceIntoOther injects thread_id/trace_id from gin context into Other JSON. +func mergeTraceIntoOther(c *gin.Context, otherStr string) string { + other, _ := common.StrToMap(otherStr) + if other == nil { + other = map[string]interface{}{} + } + if c != nil { + if v := strings.TrimSpace(c.GetString("thread_id")); v != "" { + other["thread_id"] = v + } + if v := strings.TrimSpace(c.GetString("trace_id")); v != "" { + other["trace_id"] = v + } + // Also try constant keys if set under typed names + if v := strings.TrimSpace(c.GetString(string(constant.ContextKeyThreadId))); v != "" { + other["thread_id"] = v + } + if v := strings.TrimSpace(c.GetString(string(constant.ContextKeyTraceId))); v != "" { + other["trace_id"] = v + } + } + return common.MapToJsonStr(other) +} + +func traceIdFromContext(c *gin.Context) string { + if c == nil { + return "" + } + for _, key := range []string{"trace_id", string(constant.ContextKeyTraceId)} { + if value := strings.TrimSpace(c.GetString(key)); value != "" { + return value + } + } + return "" +} + func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int, isStream bool, group string, other map[string]interface{}) { logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, common.LocalLogPreview(content))) @@ -317,7 +410,8 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, }(), RequestId: requestId, UpstreamRequestId: upstreamRequestId, - Other: otherStr, + TraceId: traceIdFromContext(c), + Other: mergeTraceIntoOther(c, otherStr), } err := createLog(log) if err != nil { @@ -350,6 +444,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) upstreamRequestId := c.GetString(common.UpstreamRequestIdKey) createdAt := common.GetTimestamp() otherStr := common.MapToJsonStr(params.Other) + otherStr = mergeTraceIntoOther(c, otherStr) // 判断是否需要记录 IP needRecordIp := false if settingMap, err := GetUserSetting(userId, false); err == nil { @@ -381,6 +476,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) }(), RequestId: requestId, UpstreamRequestId: upstreamRequestId, + TraceId: traceIdFromContext(c), Other: otherStr, } err := createLog(log) @@ -465,40 +561,69 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) { } } -func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) { - var tx *gorm.DB - if logType == LogTypeUnknown { - tx = LOG_DB - } else { - tx = LOG_DB.Where("logs.type = ?", logType) +type LogQuery struct { + LogType int + StartTimestamp int64 + EndTimestamp int64 + ModelName string + Username string + TokenName string + Channel int + Group string + RequestId string + UpstreamRequestId string + TraceId string +} + +func buildLogQuery(query LogQuery, userId *int) (*gorm.DB, error) { + tx := LOG_DB.Model(&Log{}) + if userId != nil { + tx = tx.Where("logs.user_id = ?", *userId) + } + if query.LogType != LogTypeUnknown { + tx = tx.Where("logs.type = ?", query.LogType) } - if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil { - return nil, 0, err + var err error + if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", query.ModelName); err != nil { + return nil, err } - if tx, err = applyExplicitLogTextFilter(tx, "logs.username", username); err != nil { - return nil, 0, err + if userId == nil { + if tx, err = applyExplicitLogTextFilter(tx, "logs.username", query.Username); err != nil { + return nil, err + } } - if tokenName != "" { - tx = tx.Where("logs.token_name = ?", tokenName) + if query.TokenName != "" { + tx = tx.Where("logs.token_name = ?", query.TokenName) } - if requestId != "" { - tx = tx.Where("logs.request_id = ?", requestId) + if query.RequestId != "" { + tx = tx.Where("logs.request_id = ?", query.RequestId) } - if upstreamRequestId != "" { - tx = tx.Where("logs.upstream_request_id = ?", upstreamRequestId) + if query.UpstreamRequestId != "" { + tx = tx.Where("logs.upstream_request_id = ?", query.UpstreamRequestId) } - if startTimestamp != 0 { - tx = tx.Where("logs.created_at >= ?", startTimestamp) + if query.TraceId != "" { + tx = tx.Where("logs.trace_id = ?", query.TraceId) } - if endTimestamp != 0 { - tx = tx.Where("logs.created_at <= ?", endTimestamp) + if query.StartTimestamp != 0 { + tx = tx.Where("logs.created_at >= ?", query.StartTimestamp) } - if channel != 0 { - tx = tx.Where("logs.channel_id = ?", channel) + if query.EndTimestamp != 0 { + tx = tx.Where("logs.created_at <= ?", query.EndTimestamp) } - if group != "" { - tx = tx.Where("logs."+logGroupCol+" = ?", group) + if userId == nil && query.Channel != 0 { + tx = tx.Where("logs.channel_id = ?", query.Channel) + } + if query.Group != "" { + tx = tx.Where("logs."+logGroupCol+" = ?", query.Group) + } + return tx, nil +} + +func GetAllLogs(query LogQuery, startIdx int, num int) (logs []*Log, total int64, err error) { + tx, err := buildLogQuery(query, nil) + if err != nil { + return nil, 0, err } err = tx.Model(&Log{}).Count(&total).Error if err != nil { @@ -516,6 +641,14 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName assignDisplayLogIds(logs, startIdx) } + if err := populateLogChannelNames(logs); err != nil { + return logs, total, err + } + + return logs, total, nil +} + +func populateLogChannelNames(logs []*Log) error { channelIds := types.NewSet[int]() for _, log := range logs { if log.ChannelId != 0 { @@ -543,8 +676,8 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName } } else { // Bulk query channels from DB - if err = DB.Table("channels").Select("id, name").Where("id IN ?", channelIds.Items()).Find(&channels).Error; err != nil { - return logs, total, err + if err := DB.Table("channels").Select("id, name").Where("id IN ?", channelIds.Items()).Find(&channels).Error; err != nil { + return err } } channelMap := make(map[int]string, len(channels)) @@ -556,46 +689,22 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName } } - return logs, total, err + return nil } const logSearchCountLimit = 10000 -func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) { - var tx *gorm.DB - if logType == LogTypeUnknown { - tx = LOG_DB.Where("logs.user_id = ?", userId) - } else { - tx = LOG_DB.Where("logs.user_id = ? and logs.type = ?", userId, logType) - } - - if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil { +func GetUserLogs(userId int, query LogQuery, startIdx int, num int) (logs []*Log, total int64, err error) { + tx, err := buildLogQuery(query, &userId) + if err != nil { return nil, 0, err } - if tokenName != "" { - tx = tx.Where("logs.token_name = ?", tokenName) - } - if requestId != "" { - tx = tx.Where("logs.request_id = ?", requestId) - } - if upstreamRequestId != "" { - tx = tx.Where("logs.upstream_request_id = ?", upstreamRequestId) - } - if startTimestamp != 0 { - tx = tx.Where("logs.created_at >= ?", startTimestamp) - } - if endTimestamp != 0 { - tx = tx.Where("logs.created_at <= ?", endTimestamp) - } - if group != "" { - tx = tx.Where("logs."+logGroupCol+" = ?", group) - } err = tx.Model(&Log{}).Limit(logSearchCountLimit).Count(&total).Error if err != nil { common.SysError("failed to count user logs: " + err.Error()) return nil, 0, errors.New("查询日志失败") } - order := "logs.id desc" + order := "logs.created_at desc, logs.id desc" if common.UsingLogDatabase(common.DatabaseTypeClickHouse) { order = clickHouseLogOrder("logs.") } @@ -609,6 +718,99 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int return logs, total, err } +func applyLogCursor(tx *gorm.DB, value string) (*gorm.DB, error) { + cursor, err := decodeLogCursor(value) + if err != nil || cursor.CreatedAt == 0 { + if strings.TrimSpace(value) == "" { + return tx, nil + } + return nil, ErrInvalidLogCursor + } + if common.UsingLogDatabase(common.DatabaseTypeClickHouse) { + if cursor.RequestId == "" { + return nil, ErrInvalidLogCursor + } + return tx.Where( + "logs.created_at < ? OR (logs.created_at = ? AND logs.request_id < ?)", + cursor.CreatedAt, + cursor.CreatedAt, + cursor.RequestId, + ), nil + } + if cursor.Id <= 0 { + return nil, ErrInvalidLogCursor + } + return tx.Where( + "logs.created_at < ? OR (logs.created_at = ? AND logs.id < ?)", + cursor.CreatedAt, + cursor.CreatedAt, + cursor.Id, + ), nil +} + +func getLogsByCursor(tx *gorm.DB, cursorValue string, num int) (logs []*Log, nextCursor string, hasMore bool, err error) { + if num <= 0 { + num = common.ItemsPerPage + } + if num > 100 { + num = 100 + } + tx, err = applyLogCursor(tx, cursorValue) + if err != nil { + return nil, "", false, err + } + + order := "logs.created_at desc, logs.id desc" + if common.UsingLogDatabase(common.DatabaseTypeClickHouse) { + order = clickHouseLogOrder("logs.") + } + if err := tx.Order(order).Limit(num + 1).Find(&logs).Error; err != nil { + return nil, "", false, err + } + if len(logs) <= num { + return logs, "", false, nil + } + + hasMore = true + logs = logs[:num] + nextCursor, err = encodeLogCursor(logs[len(logs)-1]) + if err != nil { + return nil, "", false, err + } + return logs, nextCursor, hasMore, nil +} + +func GetAllLogsByCursor(query LogQuery, cursorValue string, num int, displayStart int) (logs []*Log, nextCursor string, hasMore bool, err error) { + tx, err := buildLogQuery(query, nil) + if err != nil { + return nil, "", false, err + } + logs, nextCursor, hasMore, err = getLogsByCursor(tx, cursorValue, num) + if err != nil { + return nil, "", false, err + } + if common.UsingLogDatabase(common.DatabaseTypeClickHouse) { + assignDisplayLogIds(logs, displayStart) + } + if err := populateLogChannelNames(logs); err != nil { + return logs, "", false, err + } + return logs, nextCursor, hasMore, nil +} + +func GetUserLogsByCursor(userId int, query LogQuery, cursorValue string, num int, displayStart int) (logs []*Log, nextCursor string, hasMore bool, err error) { + tx, err := buildLogQuery(query, &userId) + if err != nil { + return nil, "", false, err + } + logs, nextCursor, hasMore, err = getLogsByCursor(tx, cursorValue, num) + if err != nil { + return nil, "", false, err + } + formatUserLogs(logs, displayStart) + return logs, nextCursor, hasMore, nil +} + type Stat struct { Quota int `json:"quota"` Rpm int `json:"rpm"` @@ -762,3 +964,41 @@ func DeleteOldLog(ctx context.Context, targetTimestamp int64, limit int) (int64, return total, nil } + +// GetLogsByTraceId uses the indexed trace_id column for new rows and falls +// back to legacy JSON only when no indexed rows exist. +func GetLogsByTraceId(traceId string, limit int) ([]*Log, error) { + traceId = strings.TrimSpace(traceId) + if traceId == "" { + return nil, errors.New("trace_id empty") + } + if limit <= 0 || limit > 500 { + limit = 100 + } + var logs []*Log + order := "created_at asc, id asc" + if common.UsingLogDatabase(common.DatabaseTypeClickHouse) { + order = "created_at asc, request_id asc" + } + err := LOG_DB.Model(&Log{}). + Where("trace_id = ?", traceId). + Order(order). + Limit(limit). + Find(&logs).Error + if err != nil || len(logs) > 0 { + return logs, err + } + + legacy := LOG_DB.Model(&Log{}) + if common.UsingLogDatabase(common.DatabaseTypeClickHouse) { + safe := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_", "\"", "").Replace(traceId) + pattern := "%\"trace_id\":\"" + safe + "\"%" + legacy = legacy.Where("other LIKE ?", pattern) + } else { + safe := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_", "\"", "").Replace(traceId) + pattern := "%\"trace_id\":\"" + safe + "\"%" + legacy = legacy.Where("other LIKE ? ESCAPE '!'", pattern) + } + err = legacy.Order(order).Limit(limit).Find(&logs).Error + return logs, err +} diff --git a/model/log_cursor_test.go b/model/log_cursor_test.go new file mode 100644 index 000000000000..53169ec9384a --- /dev/null +++ b/model/log_cursor_test.go @@ -0,0 +1,105 @@ +package model + +import ( + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func withLogTestDatabase(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&Log{})) + + previousDB := DB + previousLogDB := LOG_DB + previousType := common.LogDatabaseType() + DB = db + LOG_DB = db + common.SetLogDatabaseType(common.DatabaseTypeSQLite) + t.Cleanup(func() { + DB = previousDB + LOG_DB = previousLogDB + common.SetLogDatabaseType(previousType) + }) + return db +} + +func TestLogCursorPaginationDoesNotSkipTimestampTies(t *testing.T) { + db := withLogTestDatabase(t) + logs := []*Log{ + {Id: 1, CreatedAt: 100, RequestId: "request-1"}, + {Id: 2, CreatedAt: 100, RequestId: "request-2"}, + {Id: 3, CreatedAt: 99, RequestId: "request-3"}, + {Id: 4, CreatedAt: 98, RequestId: "request-4"}, + } + require.NoError(t, db.Create(&logs).Error) + + first, cursor, hasMore, err := GetAllLogsByCursor(LogQuery{}, "", 2, 0) + require.NoError(t, err) + require.True(t, hasMore) + require.NotEmpty(t, cursor) + require.Equal(t, []int{2, 1}, []int{first[0].Id, first[1].Id}) + + second, nextCursor, hasMore, err := GetAllLogsByCursor(LogQuery{}, cursor, 2, 2) + require.NoError(t, err) + require.False(t, hasMore) + require.Empty(t, nextCursor) + require.Equal(t, []int{3, 4}, []int{second[0].Id, second[1].Id}) +} + +func TestLogCursorRejectsMalformedValues(t *testing.T) { + withLogTestDatabase(t) + _, _, _, err := GetAllLogsByCursor(LogQuery{}, "not-base64", 20, 0) + require.ErrorIs(t, err, ErrInvalidLogCursor) +} + +func TestCreateLogCopiesTraceIdFromLegacyOtherField(t *testing.T) { + withLogTestDatabase(t) + log := &Log{ + CreatedAt: 100, + Other: common.MapToJsonStr(map[string]interface{}{"trace_id": "trace-123"}), + } + require.NoError(t, createLog(log)) + require.Equal(t, "trace-123", log.TraceId) + + logs, err := GetLogsByTraceId("trace-123", 10) + require.NoError(t, err) + require.Len(t, logs, 1) + require.Equal(t, log.Id, logs[0].Id) +} + +func TestLogCursorQueriesHaveSQLiteIndexPlans(t *testing.T) { + db := withLogTestDatabase(t) + type planRow struct { + Detail string `gorm:"column:detail"` + } + var cursorPlan []planRow + require.NoError(t, db.Raw( + "EXPLAIN QUERY PLAN SELECT * FROM logs WHERE created_at < ? OR (created_at = ? AND id < ?) ORDER BY created_at DESC, id DESC LIMIT 101", + 100, + 100, + 10, + ).Scan(&cursorPlan).Error) + cursorDetails := make([]string, 0, len(cursorPlan)) + for _, row := range cursorPlan { + cursorDetails = append(cursorDetails, row.Detail) + } + require.Contains(t, strings.Join(cursorDetails, "\n"), "idx_created_at_id") + + var tracePlan []planRow + require.NoError(t, db.Raw( + "EXPLAIN QUERY PLAN SELECT * FROM logs WHERE trace_id = ? ORDER BY created_at ASC, id ASC LIMIT 200", + "trace-123", + ).Scan(&tracePlan).Error) + traceDetails := make([]string, 0, len(tracePlan)) + for _, row := range tracePlan { + traceDetails = append(traceDetails, row.Detail) + } + require.Contains(t, strings.Join(traceDetails, "\n"), "idx_logs_trace") +} diff --git a/model/main.go b/model/main.go index 76f98a59c307..4d63db0027fd 100644 --- a/model/main.go +++ b/model/main.go @@ -1,6 +1,8 @@ package model import ( + "context" + "database/sql" "fmt" "log" "net/url" @@ -54,6 +56,29 @@ var DB *gorm.DB var LOG_DB *gorm.DB +type sqlConnectionPoolConfig struct { + maxIdleConns int + maxOpenConns int + maxLifetime time.Duration +} + +func connectionPoolConfig() sqlConnectionPoolConfig { + return sqlConnectionPoolConfig{ + maxIdleConns: common.GetEnvOrDefault("SQL_MAX_IDLE_CONNS", 100), + maxOpenConns: common.GetEnvOrDefault("SQL_MAX_OPEN_CONNS", 1000), + maxLifetime: time.Second * time.Duration( + common.GetEnvOrDefault("SQL_MAX_LIFETIME", 60), + ), + } +} + +func configureConnectionPool(sqlDB *sql.DB) { + config := connectionPoolConfig() + sqlDB.SetMaxIdleConns(config.maxIdleConns) + sqlDB.SetMaxOpenConns(config.maxOpenConns) + sqlDB.SetConnMaxLifetime(config.maxLifetime) +} + func createRootAccountIfNeed() error { var user User //if user.Status != common.UserStatusEnabled { @@ -200,9 +225,7 @@ func InitDB() (err error) { if err != nil { return err } - sqlDB.SetMaxIdleConns(common.GetEnvOrDefault("SQL_MAX_IDLE_CONNS", 100)) - sqlDB.SetMaxOpenConns(common.GetEnvOrDefault("SQL_MAX_OPEN_CONNS", 1000)) - sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetEnvOrDefault("SQL_MAX_LIFETIME", 60))) + configureConnectionPool(sqlDB) if !common.IsMasterNode { return nil @@ -244,9 +267,7 @@ func InitLogDB() (err error) { if err != nil { return err } - sqlDB.SetMaxIdleConns(common.GetEnvOrDefault("SQL_MAX_IDLE_CONNS", 100)) - sqlDB.SetMaxOpenConns(common.GetEnvOrDefault("SQL_MAX_OPEN_CONNS", 1000)) - sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetEnvOrDefault("SQL_MAX_LIFETIME", 60))) + configureConnectionPool(sqlDB) if !common.IsMasterNode { return nil @@ -400,9 +421,21 @@ func migrateClickHouseLogDB() error { if err := LOG_DB.Exec(clickHouseLogCreateTableSQL(ttlDays)).Error; err != nil { return err } + for _, statement := range clickHouseLogSchemaMigrationSQL() { + if err := LOG_DB.Exec(statement).Error; err != nil { + return err + } + } return syncClickHouseLogTTL(ttlDays) } +func clickHouseLogSchemaMigrationSQL() []string { + return []string{ + "ALTER TABLE logs ADD COLUMN IF NOT EXISTS trace_id String DEFAULT ''", + "ALTER TABLE logs ADD INDEX IF NOT EXISTS idx_logs_trace_id trace_id TYPE bloom_filter(0.01) GRANULARITY 1", + } +} + func clickHouseLogTTLDays() int { ttlDays := common.GetEnvOrDefault("LOG_SQL_CLICKHOUSE_TTL_DAYS", 0) if ttlDays < 0 { @@ -448,6 +481,7 @@ CREATE TABLE IF NOT EXISTS logs ( ip String DEFAULT '', request_id String DEFAULT '', upstream_request_id String DEFAULT '', + trace_id String DEFAULT '', other String DEFAULT '' ) ENGINE = MergeTree() @@ -805,13 +839,7 @@ func PingDB() error { return nil } - sqlDB, err := DB.DB() - if err != nil { - log.Printf("Error getting sql.DB from GORM: %v", err) - return err - } - - err = sqlDB.Ping() + err := PingDBContext(context.Background()) if err != nil { log.Printf("Error pinging DB: %v", err) return err @@ -821,3 +849,11 @@ func PingDB() error { common.SysLog("Database pinged successfully") return nil } + +func PingDBContext(ctx context.Context) error { + sqlDB, err := DB.DB() + if err != nil { + return err + } + return sqlDB.PingContext(ctx) +} diff --git a/model/main_pool_test.go b/model/main_pool_test.go new file mode 100644 index 000000000000..3359b4afe6a9 --- /dev/null +++ b/model/main_pool_test.go @@ -0,0 +1,30 @@ +package model + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestConnectionPoolConfigPreservesExistingDefaults(t *testing.T) { + t.Setenv("SQL_MAX_IDLE_CONNS", "") + t.Setenv("SQL_MAX_OPEN_CONNS", "") + t.Setenv("SQL_MAX_LIFETIME", "") + + config := connectionPoolConfig() + require.Equal(t, 100, config.maxIdleConns) + require.Equal(t, 1000, config.maxOpenConns) + require.Equal(t, 60*time.Second, config.maxLifetime) +} + +func TestConnectionPoolConfigHonorsExplicitOverrides(t *testing.T) { + t.Setenv("SQL_MAX_IDLE_CONNS", "3") + t.Setenv("SQL_MAX_OPEN_CONNS", "7") + t.Setenv("SQL_MAX_LIFETIME", "90") + + config := connectionPoolConfig() + require.Equal(t, 3, config.maxIdleConns) + require.Equal(t, 7, config.maxOpenConns) + require.Equal(t, 90*time.Second, config.maxLifetime) +} diff --git a/model/option.go b/model/option.go index 8e8587f271c8..ae10b6d7d3ab 100644 --- a/model/option.go +++ b/model/option.go @@ -176,13 +176,17 @@ func InitOptionMap() { common.OptionMap["AutomaticRetryStatusCodes"] = operation_setting.AutomaticRetryStatusCodesToString() common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled()) - // 自动添加所有注册的模型配置 + // ExportAllConfigs 补充注册的配置(OptionMap 为空时填充,后面会被 DB 值覆盖) modelConfigs := config.GlobalConfig.ExportAllConfigs() for k, v := range modelConfigs { - common.OptionMap[k] = v + if _, exists := common.OptionMap[k]; !exists { + common.OptionMap[k] = v + } } common.OptionMapRWMutex.Unlock() + + // 后加载数据库值(在锁外执行,因为 updateOptionMap 内部同锁) loadOptionsFromDatabase() } diff --git a/model/perf_metric.go b/model/perf_metric.go index f9c33c851989..cdffce0f87a8 100644 --- a/model/perf_metric.go +++ b/model/perf_metric.go @@ -1,6 +1,7 @@ package model import ( + "strings" "time" "gorm.io/gorm" @@ -50,6 +51,8 @@ func UpsertPerfMetric(metric *PerfMetric) error { func GetPerfMetrics(modelName string, group string, startTs int64, endTs int64) ([]PerfMetric, error) { var metrics []PerfMetric + // Keep in sync with pkg/perf_metrics.NormalizeModelName — no import (cycle). + modelName = strings.ToLower(strings.TrimSpace(modelName)) query := DB.Model(&PerfMetric{}). Where("model_name = ? AND bucket_ts >= ? AND bucket_ts <= ?", modelName, startTs, endTs) if group != "" { diff --git a/model/token.go b/model/token.go index 5d62258e7920..07736bf66f77 100644 --- a/model/token.go +++ b/model/token.go @@ -413,30 +413,39 @@ func DecreaseTokenQuota(id int, key string, quota int) (err error) { if quota < 0 { return errors.New("quota 不能为负数!") } + // Synchronous floor first; never batch-skip debit (overdraft risk). + if err = decreaseTokenQuota(id, quota); err != nil { + return err + } if common.RedisEnabled { gopool.Go(func() { - err := cacheDecrTokenQuota(key, int64(quota)) - if err != nil { - common.SysLog("failed to decrease token quota: " + err.Error()) + if err := cacheDecrTokenQuota(key, int64(quota)); err != nil { + common.SysLog("failed to decrease token quota cache: " + err.Error()) } }) } - if common.BatchUpdateEnabled { - addNewRecord(BatchUpdateTypeTokenQuota, id, -quota) - return nil - } - return decreaseTokenQuota(id, quota) + return nil } func decreaseTokenQuota(id int, quota int) (err error) { - err = DB.Model(&Token{}).Where("id = ?", id).Updates( - map[string]interface{}{ - "remain_quota": gorm.Expr("remain_quota - ?", quota), - "used_quota": gorm.Expr("used_quota + ?", quota), - "accessed_time": common.GetTimestamp(), - }, - ).Error - return err + // Floor guard for limited tokens only. Unlimited tokens still bookkeep + // remain/used (remain may already be negative historically) without floor. + result := DB.Model(&Token{}). + Where("id = ? AND (unlimited_quota = ? OR remain_quota >= ?)", id, true, quota). + Updates( + map[string]interface{}{ + "remain_quota": gorm.Expr("remain_quota - ?", quota), + "used_quota": gorm.Expr("used_quota + ?", quota), + "accessed_time": common.GetTimestamp(), + }, + ) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("令牌额度不足") + } + return nil } // CountUserTokens returns total number of tokens for the given user, used for pagination diff --git a/model/user.go b/model/user.go index 03eb589ede80..80905190345c 100644 --- a/model/user.go +++ b/model/user.go @@ -1105,25 +1105,33 @@ func DecreaseUserQuota(id int, quota int, db bool) (err error) { if quota < 0 { return errors.New("quota 不能为负数!") } + // Always apply DB floor first so concurrent pre-consume cannot overdraft. + // BatchUpdate only accelerates non-critical increments; debit stays synchronous. + if err = decreaseUserQuota(id, quota); err != nil { + return err + } + // Cache after successful DB debit only (avoids optimistic under-read on floor fail). gopool.Go(func() { - err := cacheDecrUserQuota(id, int64(quota)) - if err != nil { - common.SysLog("failed to decrease user quota: " + err.Error()) + if err := cacheDecrUserQuota(id, int64(quota)); err != nil { + common.SysLog("failed to decrease user quota cache: " + err.Error()) } }) - if !db && common.BatchUpdateEnabled { - addNewRecord(BatchUpdateTypeUserQuota, id, -quota) - return nil - } - return decreaseUserQuota(id, quota) + _ = db // retained for API compatibility with callers + return nil } func decreaseUserQuota(id int, quota int) (err error) { - err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota - ?", quota)).Error - if err != nil { - return err + // Floor guard: refuse to drive balance negative under concurrent pre-consume. + result := DB.Model(&User{}). + Where("id = ? AND quota >= ?", id, quota). + Update("quota", gorm.Expr("quota - ?", quota)) + if result.Error != nil { + return result.Error } - return err + if result.RowsAffected == 0 { + return errors.New("用户额度不足") + } + return nil } func DeltaUpdateUserQuota(id int, delta int) (err error) { @@ -1185,15 +1193,31 @@ func updateUserQuotaUsedQuotaAndRequestCount(id int, quota int, usedQuota int, r return } - err := DB.Model(&User{}).Where("id = ?", id).Updates( + // Debit (quota < 0) must refuse to drive balance negative under concurrent settle. + query := DB.Model(&User{}).Where("id = ?", id) + if quota < 0 { + query = query.Where("quota >= ?", -quota) + } + result := query.Updates( map[string]interface{}{ "quota": gorm.Expr("quota + ?", quota), "used_quota": gorm.Expr("used_quota + ?", usedQuota), "request_count": gorm.Expr("request_count + ?", requestCount), }, - ).Error - if err != nil { - common.SysLog("failed to batch update user quota, used quota and request count: " + err.Error()) + ) + if result.Error != nil { + common.SysLog("failed to batch update user quota, used quota and request count: " + result.Error.Error()) + return + } + if quota < 0 && result.RowsAffected == 0 { + common.SysLog(fmt.Sprintf("batch user quota debit skipped (insufficient balance): user=%d delta=%d", id, quota)) + // Still apply used_quota/request_count so usage stats are not lost when balance is already zero. + _ = DB.Model(&User{}).Where("id = ?", id).Updates( + map[string]interface{}{ + "used_quota": gorm.Expr("used_quota + ?", usedQuota), + "request_count": gorm.Expr("request_count + ?", requestCount), + }, + ).Error } } diff --git a/model/utils.go b/model/utils.go index b17937064938..1c1b639457f4 100644 --- a/model/utils.go +++ b/model/utils.go @@ -82,10 +82,16 @@ func batchUpdate() { for key, value := range store { switch i { case BatchUpdateTypeTokenQuota: - err := increaseTokenQuota(key, value) - if err != nil { - common.SysLog("failed to batch update token quota: " + err.Error()) - } + // Negative delta must use floor path (remain_quota >= debit). + var err error + if value < 0 { + err = decreaseTokenQuota(key, -value) + } else if value > 0 { + err = increaseTokenQuota(key, value) + } + if err != nil { + common.SysLog("failed to batch update token quota: " + err.Error()) + } case BatchUpdateTypeChannelUsedQuota: updateChannelUsedQuota(key, value) } diff --git a/oauth/discord.go b/oauth/discord.go index b626d2f82e5e..c3ebb3d94741 100644 --- a/oauth/discord.go +++ b/oauth/discord.go @@ -71,9 +71,7 @@ func (p *DiscordProvider) ExchangeToken(ctx context.Context, code string, c *gin req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") - client := http.Client{ - Timeout: 5 * time.Second, - } + client := newHTTPClient(5 * time.Second) res, err := client.Do(req) if err != nil { logger.LogError(ctx, fmt.Sprintf("[OAuth-Discord] ExchangeToken error: %s", err.Error())) @@ -116,9 +114,7 @@ func (p *DiscordProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (* } req.Header.Set("Authorization", "Bearer "+token.AccessToken) - client := http.Client{ - Timeout: 5 * time.Second, - } + client := newHTTPClient(5 * time.Second) res, err := client.Do(req) if err != nil { logger.LogError(ctx, fmt.Sprintf("[OAuth-Discord] GetUserInfo error: %s", err.Error())) diff --git a/oauth/generic.go b/oauth/generic.go index 11bbb9b625f6..03be71fdf15f 100644 --- a/oauth/generic.go +++ b/oauth/generic.go @@ -131,9 +131,7 @@ func (p *GenericOAuthProvider) ExchangeToken(ctx context.Context, code string, c logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken: token_endpoint=%s, redirect_uri=%s, auth_style=%d", p.config.Slug, p.config.TokenEndpoint, redirectUri, authStyle) - client := http.Client{ - Timeout: 20 * time.Second, - } + client := newHTTPClient(20 * time.Second) res, err := client.Do(req) if err != nil { logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] ExchangeToken error: %s", p.config.Slug, err.Error())) @@ -212,9 +210,7 @@ func (p *GenericOAuthProvider) GetUserInfo(ctx context.Context, token *OAuthToke req.Header.Set("Authorization", fmt.Sprintf("%s %s", tokenType, token.AccessToken)) req.Header.Set("Accept", "application/json") - client := http.Client{ - Timeout: 20 * time.Second, - } + client := newHTTPClient(20 * time.Second) res, err := client.Do(req) if err != nil { logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] GetUserInfo error: %s", p.config.Slug, err.Error())) diff --git a/oauth/github.go b/oauth/github.go index 314118a3765c..e0757c8f47b0 100644 --- a/oauth/github.go +++ b/oauth/github.go @@ -69,9 +69,7 @@ func (p *GitHubProvider) ExchangeToken(ctx context.Context, code string, c *gin. req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - client := http.Client{ - Timeout: 20 * time.Second, - } + client := newHTTPClient(20 * time.Second) res, err := client.Do(req) if err != nil { logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] ExchangeToken error: %s", err.Error())) @@ -111,9 +109,7 @@ func (p *GitHubProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*O } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) - client := http.Client{ - Timeout: 20 * time.Second, - } + client := newHTTPClient(20 * time.Second) res, err := client.Do(req) if err != nil { logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] GetUserInfo error: %s", err.Error())) diff --git a/oauth/http_client.go b/oauth/http_client.go new file mode 100644 index 000000000000..d4dec9989ac7 --- /dev/null +++ b/oauth/http_client.go @@ -0,0 +1,21 @@ +package oauth + +import ( + "net/http" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" +) + +var ( + oauthTransportOnce sync.Once + oauthTransport *http.Transport +) + +func newHTTPClient(timeout time.Duration) *http.Client { + oauthTransportOnce.Do(func() { + oauthTransport = common.NewOutboundHTTPTransport(http.ProxyFromEnvironment, nil) + }) + return &http.Client{Transport: oauthTransport, Timeout: timeout} +} diff --git a/oauth/linuxdo.go b/oauth/linuxdo.go index 1ed91e00999c..644cb399a593 100644 --- a/oauth/linuxdo.go +++ b/oauth/linuxdo.go @@ -76,7 +76,7 @@ func (p *LinuxDOProvider) ExchangeToken(ctx context.Context, code string, c *gin req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") - client := http.Client{Timeout: 5 * time.Second} + client := newHTTPClient(5 * time.Second) res, err := client.Do(req) if err != nil { logger.LogError(ctx, fmt.Sprintf("[OAuth-LinuxDO] ExchangeToken error: %s", err.Error())) @@ -119,7 +119,7 @@ func (p *LinuxDOProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (* req.Header.Set("Authorization", "Bearer "+token.AccessToken) req.Header.Set("Accept", "application/json") - client := http.Client{Timeout: 5 * time.Second} + client := newHTTPClient(5 * time.Second) res, err := client.Do(req) if err != nil { logger.LogError(ctx, fmt.Sprintf("[OAuth-LinuxDO] GetUserInfo error: %s", err.Error())) diff --git a/oauth/oidc.go b/oauth/oidc.go index 9bdc6d01e572..1e1423f6eacc 100644 --- a/oauth/oidc.go +++ b/oauth/oidc.go @@ -73,9 +73,7 @@ func (p *OIDCProvider) ExchangeToken(ctx context.Context, code string, c *gin.Co req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") - client := http.Client{ - Timeout: 5 * time.Second, - } + client := newHTTPClient(5 * time.Second) res, err := client.Do(req) if err != nil { logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] ExchangeToken error: %s", err.Error())) @@ -120,9 +118,7 @@ func (p *OIDCProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*OAu } req.Header.Set("Authorization", "Bearer "+token.AccessToken) - client := http.Client{ - Timeout: 5 * time.Second, - } + client := newHTTPClient(5 * time.Second) res, err := client.Do(req) if err != nil { logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] GetUserInfo error: %s", err.Error())) diff --git a/pkg/ionet/client.go b/pkg/ionet/client.go index e53947570c96..13367dbb6300 100644 --- a/pkg/ionet/client.go +++ b/pkg/ionet/client.go @@ -8,6 +8,8 @@ import ( "net/url" "strconv" "time" + + "github.com/QuantumNous/new-api/common" ) const ( @@ -25,7 +27,8 @@ type DefaultHTTPClient struct { func NewDefaultHTTPClient(timeout time.Duration) *DefaultHTTPClient { return &DefaultHTTPClient{ client: &http.Client{ - Timeout: timeout, + Transport: common.NewOutboundHTTPTransport(http.ProxyFromEnvironment, nil), + Timeout: timeout, }, } } diff --git a/pkg/observability/metrics.go b/pkg/observability/metrics.go new file mode 100644 index 000000000000..acd66d7ff2d4 --- /dev/null +++ b/pkg/observability/metrics.go @@ -0,0 +1,105 @@ +package observability + +import ( + "crypto/subtle" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +const routeTagContextKey = "route_tag" + +var ( + httpRequests = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "newapi", + Name: "http_requests_total", + Help: "Total HTTP requests by plane, route class, method, route and status.", + }, []string{"plane", "route_class", "method", "route", "status"}) + httpDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "newapi", + Name: "http_request_duration_seconds", + Help: "HTTP request duration by plane, route class, method and route.", + Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60}, + }, []string{"plane", "route_class", "method", "route"}) + httpInFlight = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "newapi", + Name: "http_requests_in_flight", + Help: "Current in-flight HTTP requests by plane.", + }, []string{"plane"}) + webVitals = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "newapi", + Name: "web_vital_value", + Help: "Privacy-preserving browser Web Vital samples (CLS unitless; LCP and INP milliseconds).", + Buckets: []float64{0.01, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 50, 100, 200, 500, 1000, 2500, 4000, 10000, 60000}, + }, []string{"name", "rating"}) +) + +func init() { + prometheus.MustRegister(httpRequests, httpDuration, httpInFlight, webVitals) +} + +func Enabled() bool { + return common.GetEnvOrDefaultBool("METRICS_ENABLED", false) +} + +func planeName() string { + switch value := strings.ToLower(strings.TrimSpace(os.Getenv("APP_PLANE"))); value { + case "relay", "management": + return value + default: + return "all" + } +} + +func HTTPMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + plane := planeName() + start := time.Now() + httpInFlight.WithLabelValues(plane).Inc() + defer httpInFlight.WithLabelValues(plane).Dec() + + c.Next() + route := c.FullPath() + if route == "" { + route = "unmatched" + } + routeClass := c.GetString(routeTagContextKey) + if routeClass == "" { + routeClass = "unknown" + } + method := c.Request.Method + httpRequests.WithLabelValues(plane, routeClass, method, route, strconv.Itoa(c.Writer.Status())).Inc() + httpDuration.WithLabelValues(plane, routeClass, method, route).Observe(time.Since(start).Seconds()) + } +} + +func MetricsAuth() gin.HandlerFunc { + return func(c *gin.Context) { + expected := strings.TrimSpace(os.Getenv("METRICS_TOKEN")) + if expected == "" { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + provided := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")) + if len(provided) != len(expected) || subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + c.Next() + } +} + +func Handler() http.Handler { + return promhttp.Handler() +} + +func ObserveWebVital(name, rating string, value float64) { + webVitals.WithLabelValues(name, rating).Observe(value) +} diff --git a/pkg/observability/metrics_test.go b/pkg/observability/metrics_test.go new file mode 100644 index 000000000000..3491b25433a4 --- /dev/null +++ b/pkg/observability/metrics_test.go @@ -0,0 +1,39 @@ +package observability + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestMetricsAuthFailsClosedWithoutConfiguredToken(t *testing.T) { + t.Setenv("METRICS_TOKEN", "") + require.Equal(t, http.StatusServiceUnavailable, metricsAuthStatus(t, "")) +} + +func TestMetricsAuthRejectsInvalidToken(t *testing.T) { + t.Setenv("METRICS_TOKEN", "metrics-secret") + require.Equal(t, http.StatusUnauthorized, metricsAuthStatus(t, "Bearer wrong")) +} + +func TestMetricsAuthAcceptsConfiguredToken(t *testing.T) { + t.Setenv("METRICS_TOKEN", "metrics-secret") + require.Equal(t, http.StatusNoContent, metricsAuthStatus(t, "Bearer metrics-secret")) +} + +func metricsAuthStatus(t *testing.T, authorization string) int { + t.Helper() + engine := gin.New() + engine.Use(MetricsAuth()) + engine.GET("/metrics", func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodGet, "/metrics", nil) + request.Header.Set("Authorization", authorization) + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + return recorder.Code +} diff --git a/pkg/perf_metrics/metrics.go b/pkg/perf_metrics/metrics.go index 33b79ee478e9..5a2966d0a075 100644 --- a/pkg/perf_metrics/metrics.go +++ b/pkg/perf_metrics/metrics.go @@ -56,6 +56,7 @@ func RecordRelaySample(info *relaycommon.RelayInfo, success bool, outputTokens i func Record(sample Sample) { setting := perf_metrics_setting.GetSetting() + sample.Model = NormalizeModelName(sample.Model) if !setting.Enabled || sample.Model == "" { return } @@ -83,6 +84,10 @@ func Query(params QueryParams) (QueryResult, error) { if params.Hours > 24*30 { params.Hours = 24 * 30 } + params.Model = NormalizeModelName(params.Model) + if params.Model == "" { + return QueryResult{SeriesSchema: seriesSchema, Groups: []GroupResult{}}, nil + } endTs := time.Now().Unix() startTs := endTs - int64(params.Hours)*3600 @@ -92,8 +97,9 @@ func Query(params QueryParams) (QueryResult, error) { return QueryResult{}, err } for _, row := range rows { + // Historical rows may still use mixed casing; fold into the normalized key. mergeCounters(merged, bucketKey{ - model: row.ModelName, + model: params.Model, group: row.Group, bucketTs: row.BucketTs, }, counters{ @@ -109,13 +115,18 @@ func Query(params QueryParams) (QueryResult, error) { hotBuckets.Range(func(key, value any) bool { k := key.(bucketKey) - if k.model != params.Model || k.bucketTs < startTs || k.bucketTs > endTs { + if NormalizeModelName(k.model) != params.Model || k.bucketTs < startTs || k.bucketTs > endTs { return true } if params.Group != "" && k.group != params.Group { return true } - mergeCounters(merged, k, value.(*atomicBucket).snapshot()) + // Re-key under the normalized model so mixed-case hot buckets collapse. + mergeCounters(merged, bucketKey{ + model: params.Model, + group: k.group, + bucketTs: k.bucketTs, + }, value.(*atomicBucket).snapshot()) return true }) @@ -148,8 +159,13 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) { outputTokens: row.OutputTokens, generationMs: row.GenerationMs, } - mergeModelTotals(totals, row.ModelName, value) - mergeModelBucket(modelBuckets, row.ModelName, row.BucketTs, value) + // Collapse mixed-case historical rows onto one summary key. + name := NormalizeModelName(row.ModelName) + if name == "" { + continue + } + mergeModelTotals(totals, name, value) + mergeModelBucket(modelBuckets, name, row.BucketTs, value) } hotBuckets.Range(func(key, value any) bool { @@ -166,8 +182,12 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) { if snap.requestCount == 0 { return true } - mergeModelTotals(totals, k.model, snap) - mergeModelBucket(modelBuckets, k.model, k.bucketTs, snap) + name := NormalizeModelName(k.model) + if name == "" { + return true + } + mergeModelTotals(totals, name, snap) + mergeModelBucket(modelBuckets, name, k.bucketTs, snap) return true }) @@ -176,6 +196,11 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) { if total.requestCount == 0 { continue } + // Drop image/audio/embedding probe noise from the square summary. + // Detail Query still serves any model name for debugging. + if !IsChatCapableModelName(name) { + continue + } avgLatency := total.totalLatencyMs / total.requestCount successRate := float64(total.successCount) / float64(total.requestCount) * 100 avgTps := 0.0 diff --git a/pkg/perf_metrics/normalize.go b/pkg/perf_metrics/normalize.go new file mode 100644 index 000000000000..7c521888d045 --- /dev/null +++ b/pkg/perf_metrics/normalize.go @@ -0,0 +1,77 @@ +package perfmetrics + +import "strings" + +// NormalizeModelName folds model identifiers for perf storage and lookup. +// Pricing / abilities may store mixed casings of the same model +// (e.g. deepseek-v4-flash vs Deepseek-V4-Flash); health metrics must key +// them together so badges and detail views resolve real samples. +// +// Only case + surrounding whitespace are changed — path-style names and +// free-tier suffixes stay distinct (a/b, :free, [free]). +func NormalizeModelName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +// IsChatCapableModelName reports whether a model name is suitable for the +// model-square health summary. Image / audio / video / embedding / rerank +// probes produce noisy keys that should not pollute the chat health view. +// Kept here (not controller) so QuerySummaryAll can filter without import cycles. +func IsChatCapableModelName(name string) bool { + name = NormalizeModelName(name) + if name == "" { + return false + } + if isImageLikeModelName(name) || isAudioOrVideoLikeModelName(name) || isEmbeddingOrRerankModelName(name) { + return false + } + return true +} + +func isImageLikeModelName(name string) bool { + imageHints := []string{ + "gpt-image", "dall-e", "dalle", "seedream", "flux", "imagen", + "stable-diffusion", "sdxl", "midjourney", "mj-", "image-gen", + "text-to-image", "t2i", "cogview", "kolors", "playground-v", + } + for _, h := range imageHints { + if strings.Contains(name, h) { + return true + } + } + if strings.Contains(name, "image") && + !strings.Contains(name, "vision") && + !strings.Contains(name, "chat") && + !strings.Contains(name, "embedding") { + return true + } + return false +} + +func isAudioOrVideoLikeModelName(name string) bool { + hints := []string{ + "whisper", "tts-", "tts_", "-tts", "speech", "audio-", "-audio", + "sora", "kling", "runway", "luma", "hailuo", "vidu", "cogvideo", + "text-to-video", "t2v", "minimax-video", + } + for _, h := range hints { + if strings.Contains(name, h) { + return true + } + } + return false +} + +func isEmbeddingOrRerankModelName(name string) bool { + if strings.Contains(name, "rerank") { + return true + } + if strings.Contains(name, "embedding") || + strings.Contains(name, "embed") || + strings.HasPrefix(name, "m3e") || + strings.Contains(name, "bge-") || + strings.Contains(name, "text-embedding") { + return true + } + return false +} diff --git a/pkg/perf_metrics/normalize_test.go b/pkg/perf_metrics/normalize_test.go new file mode 100644 index 000000000000..316438b509b1 --- /dev/null +++ b/pkg/perf_metrics/normalize_test.go @@ -0,0 +1,50 @@ +package perfmetrics + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeModelName(t *testing.T) { + t.Parallel() + cases := []struct { + in, want string + }{ + {"Deepseek-V4-Flash", "deepseek-v4-flash"}, + {" deepseek-v4-flash ", "deepseek-v4-flash"}, + {"DeepSeek-V4-Flash", "deepseek-v4-flash"}, + {"gpt-4o", "gpt-4o"}, + {"provider/Path/Model", "provider/path/model"}, + {"model:free", "model:free"}, + {"model[free]", "model[free]"}, + {"", ""}, + {" ", ""}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.in, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, NormalizeModelName(tc.in)) + }) + } +} + +func TestNormalizeModelNameCollapsesCasingOnly(t *testing.T) { + t.Parallel() + a := NormalizeModelName("Deepseek-V4-Flash") + b := NormalizeModelName("deepseek-v4-flash") + require.Equal(t, a, b) + require.NotEqual(t, NormalizeModelName("foo:free"), NormalizeModelName("foo")) +} + +func TestIsChatCapableModelName(t *testing.T) { + t.Parallel() + require.True(t, IsChatCapableModelName("gpt-4o-mini")) + for _, name := range []string{ + "gpt-image-2", "dall-e-3", "whisper-1", "sora-2", + "text-embedding-3-small", "bge-m3", "jina-rerank-v2", + } { + require.Falsef(t, IsChatCapableModelName(name), "%s should not be chat capable for summary", name) + } +} diff --git a/relay/channel/ali/image.go b/relay/channel/ali/image.go index af0717a38d53..0a7c9390fb30 100644 --- a/relay/channel/ali/image.go +++ b/relay/channel/ali/image.go @@ -1,6 +1,7 @@ package ali import ( + "context" "encoding/base64" "errors" "fmt" @@ -192,19 +193,19 @@ func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, reque return &imageRequest, nil } -func updateTask(info *relaycommon.RelayInfo, taskID string) (*AliResponse, error, []byte) { +func updateTask(ctx context.Context, info *relaycommon.RelayInfo, taskID string) (*AliResponse, error, []byte) { url := fmt.Sprintf("%s/api/v1/tasks/%s", info.ChannelBaseUrl, taskID) var aliResponse AliResponse - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return &aliResponse, err, nil } req.Header.Set("Authorization", "Bearer "+info.ApiKey) - client := &http.Client{} + client := service.GetHttpClientWithTimeout(30 * time.Second) resp, err := client.Do(req) if err != nil { common.SysLog("updateTask client.Do err: " + err.Error()) @@ -237,7 +238,7 @@ func asyncTaskWait(c *gin.Context, info *relaycommon.RelayInfo, taskID string) ( for { logger.LogDebug(c, "asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds) step++ - rsp, err, body := updateTask(info, taskID) + rsp, err, body := updateTask(c.Request.Context(), info, taskID) responseBody = body if err != nil { logger.LogWarn(c, "asyncTaskWait UpdateTask err: "+err.Error()) diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index 9fae7df078d0..37abe5e60a84 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -386,7 +386,7 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody targetHeader.Set(key, value) } targetHeader.Set("Content-Type", c.Request.Header.Get("Content-Type")) - targetConn, _, err := websocket.DefaultDialer.Dial(fullRequestURL, targetHeader) + targetConn, _, err := websocket.DefaultDialer.DialContext(c.Request.Context(), fullRequestURL, targetHeader) if err != nil { return nil, fmt.Errorf("dial failed to %s: %w", common.SanitizeURLForLog(fullRequestURL), err) } @@ -475,6 +475,10 @@ func DoRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http return doRequest(c, req, info) } func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) { + if c == nil || c.Request == nil { + return nil, errors.New("request context is unavailable") + } + req = req.WithContext(c.Request.Context()) var client *http.Client var err error if info.ChannelSetting.Proxy != "" { diff --git a/relay/channel/baidu/adaptor.go b/relay/channel/baidu/adaptor.go index b8b4735b3b7d..5af2f47d7817 100644 --- a/relay/channel/baidu/adaptor.go +++ b/relay/channel/baidu/adaptor.go @@ -26,8 +26,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me - panic("implement me") - return nil, nil + return nil, errors.New("not implemented") } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { diff --git a/relay/channel/cloudflare/adaptor.go b/relay/channel/cloudflare/adaptor.go index af3446238316..805d60efeee7 100644 --- a/relay/channel/cloudflare/adaptor.go +++ b/relay/channel/cloudflare/adaptor.go @@ -27,8 +27,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me - panic("implement me") - return nil, nil + return nil, errors.New("not implemented") } func (a *Adaptor) Init(info *relaycommon.RelayInfo) { diff --git a/relay/channel/cohere/adaptor.go b/relay/channel/cohere/adaptor.go index 664eb67841a7..58bbebd23e7e 100644 --- a/relay/channel/cohere/adaptor.go +++ b/relay/channel/cohere/adaptor.go @@ -25,8 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me - panic("implement me") - return nil, nil + return nil, errors.New("not implemented") } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { diff --git a/relay/channel/dify/adaptor.go b/relay/channel/dify/adaptor.go index 4ffee3e60c05..e8659af95dc0 100644 --- a/relay/channel/dify/adaptor.go +++ b/relay/channel/dify/adaptor.go @@ -32,8 +32,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me - panic("implement me") - return nil, nil + return nil, errors.New("not implemented") } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { @@ -109,7 +108,6 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom } else { return difyHandler(c, info, resp) } - return } func (a *Adaptor) GetModelList() []string { diff --git a/relay/channel/jina/adaptor.go b/relay/channel/jina/adaptor.go index 3f2d01d9625f..d4d58e16edb0 100644 --- a/relay/channel/jina/adaptor.go +++ b/relay/channel/jina/adaptor.go @@ -27,8 +27,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me - panic("implement me") - return nil, nil + return nil, errors.New("not implemented") } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { diff --git a/relay/channel/mistral/adaptor.go b/relay/channel/mistral/adaptor.go index 88d72e0fc90d..84c81c82ace6 100644 --- a/relay/channel/mistral/adaptor.go +++ b/relay/channel/mistral/adaptor.go @@ -24,8 +24,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me - panic("implement me") - return nil, nil + return nil, errors.New("not implemented") } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { diff --git a/relay/channel/mokaai/adaptor.go b/relay/channel/mokaai/adaptor.go index f50c1e6be231..a77c239ddca1 100644 --- a/relay/channel/mokaai/adaptor.go +++ b/relay/channel/mokaai/adaptor.go @@ -26,8 +26,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me - panic("implement me") - return nil, nil + return nil, errors.New("not implemented") } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go index 06e4d94cd79e..cb85a003a1b2 100644 --- a/relay/channel/ollama/relay-ollama.go +++ b/relay/channel/ollama/relay-ollama.go @@ -1,6 +1,7 @@ package ollama import ( + "context" "encoding/json" "fmt" "io" @@ -278,11 +279,11 @@ func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h return usage, nil } -func FetchOllamaModels(baseURL, apiKey string) ([]OllamaModel, error) { +func FetchOllamaModels(ctx context.Context, baseURL, apiKey string) ([]OllamaModel, error) { url := fmt.Sprintf("%s/api/tags", baseURL) - client := &http.Client{} - request, err := http.NewRequest("GET", url, nil) + client := service.GetHttpClientWithTimeout(30 * time.Second) + request, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, fmt.Errorf("创建请求失败: %v", err) } @@ -318,7 +319,7 @@ func FetchOllamaModels(baseURL, apiKey string) ([]OllamaModel, error) { } // 拉取 Ollama 模型 (非流式) -func PullOllamaModel(baseURL, apiKey, modelName string) error { +func PullOllamaModel(ctx context.Context, baseURL, apiKey, modelName string) error { url := fmt.Sprintf("%s/api/pull", baseURL) pullRequest := OllamaPullRequest{ @@ -331,10 +332,8 @@ func PullOllamaModel(baseURL, apiKey, modelName string) error { return fmt.Errorf("序列化请求失败: %v", err) } - client := &http.Client{ - Timeout: 30 * 60 * 1000 * time.Millisecond, // 30分钟超时,支持大模型 - } - request, err := http.NewRequest("POST", url, strings.NewReader(string(requestBody))) + client := service.GetHttpClientWithTimeout(30 * time.Minute) + request, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(requestBody))) if err != nil { return fmt.Errorf("创建请求失败: %v", err) } @@ -359,7 +358,7 @@ func PullOllamaModel(baseURL, apiKey, modelName string) error { } // 流式拉取 Ollama 模型 (支持进度回调) -func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback func(OllamaPullResponse)) error { +func PullOllamaModelStream(ctx context.Context, baseURL, apiKey, modelName string, progressCallback func(OllamaPullResponse)) error { url := fmt.Sprintf("%s/api/pull", baseURL) pullRequest := OllamaPullRequest{ @@ -372,10 +371,8 @@ func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback f return fmt.Errorf("序列化请求失败: %v", err) } - client := &http.Client{ - Timeout: 60 * 60 * 1000 * time.Millisecond, // 1小时超时,支持超大模型 - } - request, err := http.NewRequest("POST", url, strings.NewReader(string(requestBody))) + client := service.GetHttpClientWithTimeout(time.Hour) + request, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(requestBody))) if err != nil { return fmt.Errorf("创建请求失败: %v", err) } @@ -436,7 +433,7 @@ func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback f } // 删除 Ollama 模型 -func DeleteOllamaModel(baseURL, apiKey, modelName string) error { +func DeleteOllamaModel(ctx context.Context, baseURL, apiKey, modelName string) error { url := fmt.Sprintf("%s/api/delete", baseURL) deleteRequest := OllamaDeleteRequest{ @@ -448,8 +445,8 @@ func DeleteOllamaModel(baseURL, apiKey, modelName string) error { return fmt.Errorf("序列化请求失败: %v", err) } - client := &http.Client{} - request, err := http.NewRequest("DELETE", url, strings.NewReader(string(requestBody))) + client := service.GetHttpClientWithTimeout(30 * time.Second) + request, err := http.NewRequestWithContext(ctx, "DELETE", url, strings.NewReader(string(requestBody))) if err != nil { return fmt.Errorf("创建请求失败: %v", err) } @@ -473,7 +470,7 @@ func DeleteOllamaModel(baseURL, apiKey, modelName string) error { return nil } -func FetchOllamaVersion(baseURL, apiKey string) (string, error) { +func FetchOllamaVersion(ctx context.Context, baseURL, apiKey string) (string, error) { trimmedBase := strings.TrimRight(baseURL, "/") if trimmedBase == "" { return "", fmt.Errorf("baseURL 为空") @@ -481,8 +478,8 @@ func FetchOllamaVersion(baseURL, apiKey string) (string, error) { url := fmt.Sprintf("%s/api/version", trimmedBase) - client := &http.Client{Timeout: 10 * time.Second} - request, err := http.NewRequest("GET", url, nil) + client := service.GetHttpClientWithTimeout(10 * time.Second) + request, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return "", fmt.Errorf("创建请求失败: %v", err) } diff --git a/relay/channel/openai/usage.go b/relay/channel/openai/usage.go index 4085a1f392c2..93dbb5fadd0d 100644 --- a/relay/channel/openai/usage.go +++ b/relay/channel/openai/usage.go @@ -47,9 +47,27 @@ func applyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, res usage.PromptTokensDetails.CachedTokens = cachedTokens } } + case constant.ChannelTypeXai: + // xAI stream/non-stream may put cache hits on non-standard fields. + if usage.PromptTokensDetails.CachedTokens == 0 { + if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 { + usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens + } else if cachedTokens, ok := extractCachedTokensFromBody(responseBody); ok { + usage.PromptTokensDetails.CachedTokens = cachedTokens + } else if usage.PromptCacheHitTokens > 0 { + usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens + } + } } } +// ApplyUsagePostProcessing normalizes provider-specific cache token fields into +// usage.PromptTokensDetails.CachedTokens for quota settlement. Exported for +// channel adaptors (e.g. xAI) that build usage outside OaiStreamHandler. +func ApplyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, responseBody []byte) { + applyUsagePostProcessing(info, usage, responseBody) +} + func extractCachedTokensFromBody(body []byte) (int, bool) { if len(body) == 0 { return 0, false diff --git a/relay/channel/palm/adaptor.go b/relay/channel/palm/adaptor.go index 3c1302d811be..24a61fce54cb 100644 --- a/relay/channel/palm/adaptor.go +++ b/relay/channel/palm/adaptor.go @@ -25,8 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me - panic("implement me") - return nil, nil + return nil, errors.New("not implemented") } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { diff --git a/relay/channel/tencent/adaptor.go b/relay/channel/tencent/adaptor.go index eb698553771b..18d8fbd4bf23 100644 --- a/relay/channel/tencent/adaptor.go +++ b/relay/channel/tencent/adaptor.go @@ -33,8 +33,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me - panic("implement me") - return nil, nil + return nil, errors.New("not implemented") } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { diff --git a/relay/channel/volcengine/tts.go b/relay/channel/volcengine/tts.go index 2b03981d4221..6a443359dbd5 100644 --- a/relay/channel/volcengine/tts.go +++ b/relay/channel/volcengine/tts.go @@ -1,7 +1,6 @@ package volcengine import ( - "context" "encoding/base64" "encoding/json" "errors" @@ -209,7 +208,7 @@ func handleTTSWebSocketResponse(c *gin.Context, requestURL string, volcRequest V header := http.Header{} header.Set("Authorization", fmt.Sprintf("Bearer;%s", token)) - conn, resp, dialErr := websocket.DefaultDialer.DialContext(context.Background(), requestURL, header) + conn, resp, dialErr := websocket.DefaultDialer.DialContext(c.Request.Context(), requestURL, header) if dialErr != nil { if resp != nil { return nil, types.NewErrorWithStatusCode( diff --git a/relay/channel/xai/text.go b/relay/channel/xai/text.go index f9a8ee2e6f96..83e8901d84cc 100644 --- a/relay/channel/xai/text.go +++ b/relay/channel/xai/text.go @@ -21,6 +21,8 @@ func streamResponseXAI2OpenAI(xAIResp *dto.ChatCompletionsStreamResponse, usage return nil } if xAIResp.Usage != nil { + // Keep provider usage intact for the client stream; billing uses the + // separately accumulated `usage` (see xAIStreamHandler). xAIResp.Usage.CompletionTokens = usage.CompletionTokens } openAIResp := &dto.ChatCompletionsStreamResponse{ @@ -35,6 +37,70 @@ func streamResponseXAI2OpenAI(xAIResp *dto.ChatCompletionsStreamResponse, usage return openAIResp } +// mergeXAIStreamUsage copies stream chunk usage into the accumulated billing usage. +// xAI may place cached tokens on prompt_tokens_details, input_tokens_details, or +// top-level prompt_cache_hit_tokens; previously only prompt/total were kept (#6144). +func mergeXAIStreamUsage(dst *dto.Usage, src *dto.Usage) { + if dst == nil || src == nil { + return + } + if src.PromptTokens > 0 { + dst.PromptTokens = src.PromptTokens + } + if src.TotalTokens > 0 { + dst.TotalTokens = src.TotalTokens + } + if src.CompletionTokens > 0 { + dst.CompletionTokens = src.CompletionTokens + } else if dst.TotalTokens > 0 && dst.PromptTokens > 0 { + dst.CompletionTokens = dst.TotalTokens - dst.PromptTokens + } + if src.InputTokens > 0 { + dst.InputTokens = src.InputTokens + } + if src.OutputTokens > 0 { + dst.OutputTokens = src.OutputTokens + } + if src.PromptCacheHitTokens > 0 { + dst.PromptCacheHitTokens = src.PromptCacheHitTokens + } + + // Prefer standard prompt_tokens_details.cached_tokens. + if src.PromptTokensDetails.CachedTokens > 0 { + dst.PromptTokensDetails.CachedTokens = src.PromptTokensDetails.CachedTokens + } + if src.PromptTokensDetails.CachedCreationTokens > 0 { + dst.PromptTokensDetails.CachedCreationTokens = src.PromptTokensDetails.CachedCreationTokens + } + if src.PromptTokensDetails.TextTokens > 0 { + dst.PromptTokensDetails.TextTokens = src.PromptTokensDetails.TextTokens + } + if src.PromptTokensDetails.AudioTokens > 0 { + dst.PromptTokensDetails.AudioTokens = src.PromptTokensDetails.AudioTokens + } + if src.PromptTokensDetails.ImageTokens > 0 { + dst.PromptTokensDetails.ImageTokens = src.PromptTokensDetails.ImageTokens + } + + // Fallbacks used by some OpenAI-compatible providers. + if dst.PromptTokensDetails.CachedTokens == 0 && src.InputTokensDetails != nil && src.InputTokensDetails.CachedTokens > 0 { + dst.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens + } + if dst.PromptTokensDetails.CachedTokens == 0 && src.PromptCacheHitTokens > 0 { + dst.PromptTokensDetails.CachedTokens = src.PromptCacheHitTokens + } + + if src.CompletionTokenDetails.ReasoningTokens > 0 { + dst.CompletionTokenDetails.ReasoningTokens = src.CompletionTokenDetails.ReasoningTokens + } + if src.CompletionTokenDetails.TextTokens > 0 { + dst.CompletionTokenDetails.TextTokens = src.CompletionTokenDetails.TextTokens + } + if src.CompletionTokenDetails.AudioTokens > 0 { + dst.CompletionTokenDetails.AudioTokens = src.CompletionTokenDetails.AudioTokens + } +} + func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { usage := &dto.Usage{} var responseTextBuilder strings.Builder @@ -51,12 +117,10 @@ func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re return } - // 把 xAI 的usage转换为 OpenAI 的usage + // Preserve full usage for billing — not only prompt/total (#6144). if xAIResp.Usage != nil { containStreamUsage = true - usage.PromptTokens = xAIResp.Usage.PromptTokens - usage.TotalTokens = xAIResp.Usage.TotalTokens - usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens + mergeXAIStreamUsage(usage, xAIResp.Usage) } openaiResponse := streamResponseXAI2OpenAI(xAIResp, usage) @@ -72,6 +136,9 @@ func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re usage.CompletionTokens += toolCount * 7 } + // Align with OpenAI stream path: recover any remaining cache fields. + openai.ApplyUsagePostProcessing(info, usage, nil) + helper.Done(c) service.CloseResponseBodyGracefully(resp) return usage, nil @@ -92,6 +159,14 @@ func xAIHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response if xaiResponse.Usage != nil { xaiResponse.Usage.CompletionTokens = xaiResponse.Usage.TotalTokens - xaiResponse.Usage.PromptTokens xaiResponse.Usage.CompletionTokenDetails.TextTokens = xaiResponse.Usage.CompletionTokens - xaiResponse.Usage.CompletionTokenDetails.ReasoningTokens + // Normalize cache fields for quota settlement. + if xaiResponse.Usage.PromptTokensDetails.CachedTokens == 0 { + if xaiResponse.Usage.InputTokensDetails != nil && xaiResponse.Usage.InputTokensDetails.CachedTokens > 0 { + xaiResponse.Usage.PromptTokensDetails.CachedTokens = xaiResponse.Usage.InputTokensDetails.CachedTokens + } else if xaiResponse.Usage.PromptCacheHitTokens > 0 { + xaiResponse.Usage.PromptTokensDetails.CachedTokens = xaiResponse.Usage.PromptCacheHitTokens + } + } } // new body @@ -102,5 +177,9 @@ func xAIHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response service.IOCopyBytesGracefully(c, resp, encodeJson) + if xaiResponse.Usage != nil { + openai.ApplyUsagePostProcessing(info, xaiResponse.Usage, responseBody) + } + return xaiResponse.Usage, nil } diff --git a/relay/channel/xai/text_test.go b/relay/channel/xai/text_test.go new file mode 100644 index 000000000000..9125f9eafda0 --- /dev/null +++ b/relay/channel/xai/text_test.go @@ -0,0 +1,57 @@ +package xai + +import ( + "testing" + + "github.com/QuantumNous/new-api/dto" +) + +func TestMergeXAIStreamUsagePreservesCachedTokens(t *testing.T) { + dst := &dto.Usage{} + src := &dto.Usage{ + PromptTokens: 100, + TotalTokens: 140, + CompletionTokens: 40, + PromptTokensDetails: dto.InputTokenDetails{ + CachedTokens: 60, + }, + } + mergeXAIStreamUsage(dst, src) + if dst.PromptTokens != 100 || dst.CompletionTokens != 40 || dst.TotalTokens != 140 { + t.Fatalf("token totals wrong: %+v", dst) + } + if dst.PromptTokensDetails.CachedTokens != 60 { + t.Fatalf("cached tokens=%d want 60", dst.PromptTokensDetails.CachedTokens) + } +} + +func TestMergeXAIStreamUsageFallbackPromptCacheHit(t *testing.T) { + dst := &dto.Usage{} + src := &dto.Usage{ + PromptTokens: 80, + TotalTokens: 100, + PromptCacheHitTokens: 50, + } + mergeXAIStreamUsage(dst, src) + if dst.PromptTokensDetails.CachedTokens != 50 { + t.Fatalf("cached tokens=%d want 50", dst.PromptTokensDetails.CachedTokens) + } + if dst.CompletionTokens != 20 { + t.Fatalf("completion=%d want 20", dst.CompletionTokens) + } +} + +func TestMergeXAIStreamUsageFallbackInputDetails(t *testing.T) { + dst := &dto.Usage{} + src := &dto.Usage{ + PromptTokens: 10, + TotalTokens: 12, + InputTokensDetails: &dto.InputTokenDetails{ + CachedTokens: 7, + }, + } + mergeXAIStreamUsage(dst, src) + if dst.PromptTokensDetails.CachedTokens != 7 { + t.Fatalf("cached tokens=%d want 7", dst.PromptTokensDetails.CachedTokens) + } +} diff --git a/relay/channel/xunfei/adaptor.go b/relay/channel/xunfei/adaptor.go index 686b0cbd2e12..83de2f45c20f 100644 --- a/relay/channel/xunfei/adaptor.go +++ b/relay/channel/xunfei/adaptor.go @@ -25,8 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me - panic("implement me") - return nil, nil + return nil, errors.New("not implemented") } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { diff --git a/relay/channel/zhipu/adaptor.go b/relay/channel/zhipu/adaptor.go index 3ed4b3596112..89f711bf9471 100644 --- a/relay/channel/zhipu/adaptor.go +++ b/relay/channel/zhipu/adaptor.go @@ -25,8 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me - panic("implement me") - return nil, nil + return nil, errors.New("not implemented") } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { diff --git a/router/api-router.go b/router/api-router.go index 83f9259b2132..31a14be3d0d6 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -22,6 +22,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/setup", controller.GetSetup) apiRouter.POST("/setup", anonymousRequestBodyLimit, controller.PostSetup) apiRouter.GET("/status", controller.GetStatus) + apiRouter.POST("/rum", anonymousRequestBodyLimit, controller.RecordWebVital) apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus) apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels) apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus) @@ -32,8 +33,9 @@ func SetApiRouter(router *gin.Engine) { //apiRouter.GET("/midjourney", controller.GetMidjourney) apiRouter.GET("/home_page_content", controller.GetHomePageContent) apiRouter.GET("/pricing", middleware.HeaderNavModuleAuth("pricing"), controller.GetPricing) + // Require login — model traffic profiles are not public intel. perfMetricsRoute := apiRouter.Group("/perf-metrics") - perfMetricsRoute.Use(middleware.HeaderNavModulePublicOrUserAuth("pricing")) + perfMetricsRoute.Use(middleware.UserAuth()) { perfMetricsRoute.GET("/summary", controller.GetPerfMetricsSummary) perfMetricsRoute.GET("", controller.GetPerfMetrics) @@ -75,7 +77,7 @@ func SetApiRouter(router *gin.Engine) { userRoute.GET("/logout", controller.Logout) userRoute.POST("/epay/notify", anonymousRequestBodyLimit, controller.EpayNotify) userRoute.GET("/epay/notify", controller.EpayNotify) - userRoute.GET("/groups", controller.GetUserGroups) + userRoute.GET("/groups", middleware.UserAuth(), controller.GetUserGroups) selfRoute := userRoute.Group("/") selfRoute.Use(middleware.UserAuth()) @@ -269,6 +271,7 @@ func SetApiRouter(router *gin.Engine) { // Legacy synchronous direct-delete route used only by the classic frontend. // TODO: remove once the classic frontend is removed; the default frontend uses /system-task/log-cleanup. logRoute.DELETE("/", middleware.RootAuth(), controller.DeleteHistoryLogs) + logRoute.GET("/trace/:trace_id", middleware.AdminAuth(), controller.GetTraceLogs) logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat) logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat) logRoute.GET("/channel_affinity_usage_cache", middleware.AdminAuth(), controller.GetChannelAffinityUsageCacheStats) diff --git a/router/channel-router.go b/router/channel-router.go index b85cbd884b77..c7d8f658bef6 100644 --- a/router/channel-router.go +++ b/router/channel-router.go @@ -69,6 +69,7 @@ var channelPermissionRoutes = []permissionRoute{ {method: http.MethodDelete, path: "/ollama/delete", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaDeleteModel}, {method: http.MethodGet, path: "/ollama/version/:id", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaVersion}, {method: http.MethodPost, path: "/batch/tag", permission: authz.ChannelWrite, handler: controller.BatchSetChannelTag}, + {method: http.MethodPost, path: "/batch/skip_auto_test", permission: authz.ChannelWrite, handler: controller.BatchSetChannelSkipAutoTest}, {method: http.MethodGet, path: "/tag/models", permission: authz.ChannelRead, handler: controller.GetTagModels}, {method: http.MethodPost, path: "/copy/:id", permission: authz.ChannelSensitiveWrite, handler: controller.CopyChannel}, {method: http.MethodPost, path: "/multi_key/manage", permission: authz.ChannelOperate, handler: controller.ManageMultiKeys}, diff --git a/router/main.go b/router/main.go index d3769bd591ba..f8d10fd864c8 100644 --- a/router/main.go +++ b/router/main.go @@ -1,34 +1,162 @@ package router import ( + "errors" "fmt" "net/http" + "net/url" "os" "strings" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/controller" "github.com/QuantumNous/new-api/middleware" "github.com/gin-gonic/gin" ) +type Plane string + +const ( + PlaneAll Plane = "all" + PlaneRelay Plane = "relay" + PlaneManagement Plane = "management" +) + +type frontendMode string + +const ( + frontendModeAuto frontendMode = "auto" + frontendModeEmbedded frontendMode = "embedded" + frontendModeRedirect frontendMode = "redirect" + frontendModeDisabled frontendMode = "disabled" +) + +func ParsePlane(value string) (Plane, error) { + switch Plane(strings.ToLower(strings.TrimSpace(value))) { + case "", PlaneAll: + return PlaneAll, nil + case PlaneRelay: + return PlaneRelay, nil + case PlaneManagement: + return PlaneManagement, nil + default: + return "", errors.New("APP_PLANE must be one of: all, relay, management") + } +} + +// parseFrontendMode 解析前端交付模式,空值保持旧版自动选择行为。 +func parseFrontendMode(value string) (frontendMode, error) { + switch frontendMode(strings.ToLower(strings.TrimSpace(value))) { + case "", frontendModeAuto: + return frontendModeAuto, nil + case frontendModeEmbedded: + return frontendModeEmbedded, nil + case frontendModeRedirect: + return frontendModeRedirect, nil + case frontendModeDisabled: + return frontendModeDisabled, nil + default: + return "", errors.New("FRONTEND_MODE must be one of: auto, embedded, redirect, disabled") + } +} + func SetRouter(router *gin.Engine, assets ThemeAssets) { - SetApiRouter(router) - SetDashboardRouter(router) - SetRelayRouter(router) - SetVideoRouter(router) - frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL") - if common.IsMasterNode && frontendBaseUrl != "" { - frontendBaseUrl = "" - common.SysLog("FRONTEND_BASE_URL is ignored on master node") - } - if frontendBaseUrl == "" { - SetWebRouter(router, assets) - } else { - frontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, "/") - router.NoRoute(func(c *gin.Context) { - c.Set(middleware.RouteTagKey, "web") - c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseUrl, c.Request.RequestURI)) - }) + _ = SetRouterForPlane(router, assets, PlaneAll) +} + +func SetRouterForPlane(engine *gin.Engine, assets ThemeAssets, plane Plane) error { + if _, err := ParsePlane(string(plane)); err != nil { + return err + } + engine.Use(middleware.CORS()) + livenessHandler := func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok", "plane": plane}) + } + engine.GET("/healthz", livenessHandler) + engine.GET("/livez", livenessHandler) + engine.GET("/readyz", controller.GetReadiness) + + if plane == PlaneAll || plane == PlaneManagement { + SetApiRouter(engine) + SetDashboardRouter(engine) + } + if plane == PlaneAll || plane == PlaneRelay { + SetRelayRouter(engine) + SetVideoRouter(engine) + } + if plane == PlaneRelay { + return nil + } + return setFrontendRouter(engine, assets) +} + +// setFrontendRouter 按显式模式注册嵌入页面、外部跳转或纯后端路由。 +func setFrontendRouter(router *gin.Engine, assets ThemeAssets) error { + mode, err := parseFrontendMode(os.Getenv("FRONTEND_MODE")) + if err != nil { + return err + } + + switch mode { + case frontendModeEmbedded: + return registerEmbeddedFrontend(router, assets) + case frontendModeRedirect: + return registerFrontendRedirect(router, os.Getenv("FRONTEND_BASE_URL")) + case frontendModeDisabled: + // 纯后端模式故意不注册 NoRoute,未知路径由 Gin 返回 404。 + return nil + case frontendModeAuto: + frontendBaseURL := strings.TrimSpace(os.Getenv("FRONTEND_BASE_URL")) + if frontendBaseURL != "" && !common.IsMasterNode { + return registerFrontendRedirect(router, frontendBaseURL) + } + if frontendBaseURL != "" { + common.SysLog("FRONTEND_BASE_URL is ignored on master node in FRONTEND_MODE=auto") + } + return registerEmbeddedFrontend(router, assets) + default: + return fmt.Errorf("unsupported frontend mode %q", mode) + } +} + +// registerEmbeddedFrontend 校验嵌入资源存在后注册原有双主题静态路由。 +func registerEmbeddedFrontend(router *gin.Engine, assets ThemeAssets) error { + if !assets.Available() { + return errors.New("embedded frontend assets are unavailable; use FRONTEND_MODE=disabled or redirect for a frontend_external build") + } + SetWebRouter(router, assets) + return nil +} + +// registerFrontendRedirect 把非 API 页面永久跳转到独立前端入口。 +func registerFrontendRedirect(router *gin.Engine, rawBaseURL string) error { + frontendBaseURL, err := normalizeFrontendBaseURL(rawBaseURL) + if err != nil { + return err + } + router.NoRoute(func(c *gin.Context) { + c.Set(middleware.RouteTagKey, "web") + c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseURL, c.Request.RequestURI)) + }) + return nil +} + +// normalizeFrontendBaseURL 只接受无凭据、无路径、无查询参数的 HTTP(S) 前端源站。 +func normalizeFrontendBaseURL(raw string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", errors.New("FRONTEND_BASE_URL is required when FRONTEND_MODE=redirect") + } + parsed, err := url.Parse(value) + if err != nil { + return "", fmt.Errorf("invalid FRONTEND_BASE_URL: %w", err) + } + if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return "", errors.New("FRONTEND_BASE_URL must be an absolute HTTP(S) origin") + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") { + return "", errors.New("FRONTEND_BASE_URL must not contain credentials, a path, a query, or a fragment") } + return parsed.Scheme + "://" + parsed.Host, nil } diff --git a/router/main_test.go b/router/main_test.go new file mode 100644 index 000000000000..24adaf54862d --- /dev/null +++ b/router/main_test.go @@ -0,0 +1,239 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestParsePlane(t *testing.T) { + for input, expected := range map[string]Plane{ + "": PlaneAll, + "all": PlaneAll, + "RELAY": PlaneRelay, + "management": PlaneManagement, + } { + actual, err := ParsePlane(input) + require.NoError(t, err) + require.Equal(t, expected, actual) + } + _, err := ParsePlane("public") + require.Error(t, err) +} + +// TestParseFrontendMode 验证前端交付模式的兼容默认值与非法值拒绝逻辑。 +func TestParseFrontendMode(t *testing.T) { + for input, expected := range map[string]frontendMode{ + "": frontendModeAuto, + "AUTO": frontendModeAuto, + "embedded": frontendModeEmbedded, + "redirect": frontendModeRedirect, + "disabled": frontendModeDisabled, + } { + actual, err := parseFrontendMode(input) + require.NoError(t, err) + require.Equal(t, expected, actual) + } + _, err := parseFrontendMode("static") + require.Error(t, err) +} + +// TestFrontendModeRequiresAssetsOrExplicitExternalDelivery 验证纯后端构建不会误入嵌入资源路径。 +func TestFrontendModeRequiresAssetsOrExplicitExternalDelivery(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Setenv("FRONTEND_MODE", "embedded") + err := SetRouterForPlane(gin.New(), ThemeAssets{}, PlaneManagement) + require.ErrorContains(t, err, "embedded frontend assets are unavailable") + + t.Setenv("FRONTEND_MODE", "disabled") + require.NoError(t, SetRouterForPlane(gin.New(), ThemeAssets{}, PlaneManagement)) +} + +// TestFrontendDisabledReturnsNotFoundForUnknownPage 验证纯后端模式对未知页面返回 404 且 API 仍可用。 +func TestFrontendDisabledReturnsNotFoundForUnknownPage(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("FRONTEND_MODE", "disabled") + t.Setenv("FRONTEND_BASE_URL", "") + + engine := gin.New() + require.NoError(t, SetRouterForPlane(engine, ThemeAssets{}, PlaneManagement)) + + unknown := httptest.NewRecorder() + engine.ServeHTTP(unknown, httptest.NewRequest(http.MethodGet, "/console/settings", nil)) + require.Equal(t, http.StatusNotFound, unknown.Code) + + status := httptest.NewRecorder() + engine.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/api/status", nil)) + require.NotEqual(t, http.StatusNotFound, status.Code) +} + +// TestIsNonSPARequestPath 验证运维与 Relay 前缀不会被当成前端路由。 +func TestIsNonSPARequestPath(t *testing.T) { + for _, path := range []string{ + "/metrics", + "/metrics?foo=1", + "/v1/models", + "/v1beta/models", + "/api/status", + "/pg/chat/completions", + "/mj/submit", + "/suno/submit", + "/kling/v1/videos/text2video", + "/jimeng/", + "/dashboard/billing/usage", + "/frontend-healthz", + "/readyz", + "/fast/mj/task", + } { + require.Truef(t, isNonSPARequestPath(path), "expected non-SPA: %s", path) + } + for _, path := range []string{ + "/", + "/console", + "/pricing", + "/about", + "/sign-in", + "/static/js/index.js", + } { + require.Falsef(t, isNonSPARequestPath(path), "expected SPA-capable: %s", path) + } +} + +// TestEmbeddedFrontendDoesNotServeSPAForMetrics 验证嵌入模式下 /metrics 未启用时不是 HTML 200。 +func TestEmbeddedFrontendDoesNotServeSPAForMetrics(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("FRONTEND_MODE", "embedded") + t.Setenv("METRICS_ENABLED", "") + t.Setenv("METRICS_TOKEN", "") + + assets := ThemeAssets{ + DefaultIndexPage: []byte("default"), + ClassicIndexPage: []byte("classic"), + } + engine := gin.New() + require.NoError(t, SetRouterForPlane(engine, assets, PlaneManagement)) + + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + require.Equal(t, http.StatusNotFound, recorder.Code) + require.NotContains(t, recorder.Header().Get("Content-Type"), "text/html") + require.NotContains(t, recorder.Body.String(), "default") + + // 真正的前端路由仍应回退 index。 + home := httptest.NewRecorder() + engine.ServeHTTP(home, httptest.NewRequest(http.MethodGet, "/console", nil)) + require.Equal(t, http.StatusOK, home.Code) + require.Contains(t, home.Body.String(), "default") +} + +// TestFrontendModeAutoIgnoresBaseURLOnMaster 验证 auto 模式下 master 忽略 FRONTEND_BASE_URL,且无资源时失败。 +func TestFrontendModeAutoIgnoresBaseURLOnMaster(t *testing.T) { + gin.SetMode(gin.TestMode) + previousMaster := common.IsMasterNode + common.IsMasterNode = true + t.Cleanup(func() { common.IsMasterNode = previousMaster }) + + t.Setenv("FRONTEND_MODE", "auto") + t.Setenv("FRONTEND_BASE_URL", "https://console.example") + err := SetRouterForPlane(gin.New(), ThemeAssets{}, PlaneManagement) + require.ErrorContains(t, err, "embedded frontend assets are unavailable") +} + +// TestExplicitFrontendRedirectWorksOnMaster 验证明示 redirect 可覆盖旧版 master 自动嵌入行为。 +func TestExplicitFrontendRedirectWorksOnMaster(t *testing.T) { + gin.SetMode(gin.TestMode) + previousMaster := common.IsMasterNode + common.IsMasterNode = true + t.Cleanup(func() { common.IsMasterNode = previousMaster }) + t.Setenv("FRONTEND_MODE", "redirect") + t.Setenv("FRONTEND_BASE_URL", "https://console.example/") + + engine := gin.New() + require.NoError(t, SetRouterForPlane(engine, ThemeAssets{}, PlaneManagement)) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/settings?tab=security", nil) + engine.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusMovedPermanently, recorder.Code) + require.Equal(t, "https://console.example/settings?tab=security", recorder.Header().Get("Location")) +} + +// TestFrontendRedirectRejectsNonOriginURL 验证跳转配置不能携带路径或非 HTTP(S) 协议。 +func TestFrontendRedirectRejectsNonOriginURL(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("FRONTEND_MODE", "redirect") + + t.Setenv("FRONTEND_BASE_URL", "https://console.example/admin") + require.Error(t, SetRouterForPlane(gin.New(), ThemeAssets{}, PlaneManagement)) + + t.Setenv("FRONTEND_BASE_URL", "javascript:alert(1)") + require.Error(t, SetRouterForPlane(gin.New(), ThemeAssets{}, PlaneManagement)) +} + +func TestSetRouterForPlaneIsolatesRelayAndManagementRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + hasRoute := func(engine *gin.Engine, method, path string) bool { + for _, route := range engine.Routes() { + if route.Method == method && route.Path == path { + return true + } + } + return false + } + + relayEngine := gin.New() + require.NoError(t, SetRouterForPlane(relayEngine, ThemeAssets{}, PlaneRelay)) + require.True(t, hasRoute(relayEngine, "GET", "/healthz")) + require.True(t, hasRoute(relayEngine, "GET", "/livez")) + require.True(t, hasRoute(relayEngine, "GET", "/readyz")) + require.True(t, hasRoute(relayEngine, "POST", "/v1/chat/completions")) + require.False(t, hasRoute(relayEngine, "GET", "/api/status")) + + previousMaster := common.IsMasterNode + common.IsMasterNode = false + t.Cleanup(func() { common.IsMasterNode = previousMaster }) + t.Setenv("FRONTEND_BASE_URL", "https://console.example") + managementEngine := gin.New() + require.NoError(t, SetRouterForPlane(managementEngine, ThemeAssets{}, PlaneManagement)) + require.True(t, hasRoute(managementEngine, "GET", "/healthz")) + require.True(t, hasRoute(managementEngine, "GET", "/livez")) + require.True(t, hasRoute(managementEngine, "GET", "/readyz")) + require.True(t, hasRoute(managementEngine, "GET", "/api/status")) + require.False(t, hasRoute(managementEngine, "POST", "/v1/chat/completions")) +} + +func TestManagementAPIRoutesApplyCORSAllowlist(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("CORS_ALLOWED_ORIGINS", "https://console.example") + t.Setenv("SESSION_COOKIE_TRUSTED_URL", "") + t.Setenv("FRONTEND_BASE_URL", "https://console.example") + previousMaster := common.IsMasterNode + common.IsMasterNode = false + t.Cleanup(func() { common.IsMasterNode = previousMaster }) + + engine := gin.New() + require.NoError(t, SetRouterForPlane(engine, ThemeAssets{}, PlaneManagement)) + + preflight := func(origin string) *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodOptions, "/api/status", nil) + request.Header.Set("Origin", origin) + request.Header.Set("Access-Control-Request-Method", http.MethodGet) + engine.ServeHTTP(recorder, request) + return recorder + } + + trusted := preflight("https://console.example") + require.Equal(t, http.StatusNoContent, trusted.Code) + require.Equal(t, "https://console.example", trusted.Header().Get("Access-Control-Allow-Origin")) + require.Equal(t, "true", trusted.Header().Get("Access-Control-Allow-Credentials")) + + untrusted := preflight("https://evil.example") + require.Equal(t, http.StatusForbidden, untrusted.Code) + require.Empty(t, untrusted.Header().Get("Access-Control-Allow-Origin")) +} diff --git a/router/web-router.go b/router/web-router.go index 0d475e90d54d..6734882642e7 100644 --- a/router/web-router.go +++ b/router/web-router.go @@ -13,7 +13,7 @@ import ( "github.com/gin-gonic/gin" ) -// ThemeAssets holds the embedded frontend assets for both themes. +// ThemeAssets 保存默认主题与经典主题的一体化嵌入资源。 type ThemeAssets struct { DefaultBuildFS embed.FS DefaultIndexPage []byte @@ -21,6 +21,57 @@ type ThemeAssets struct { ClassicIndexPage []byte } +// Available 判断两个主题的首页是否同时存在,防止纯后端构建误入嵌入模式后 panic。 +func (assets ThemeAssets) Available() bool { + return len(assets.DefaultIndexPage) > 0 && len(assets.ClassicIndexPage) > 0 +} + +// nonSPAPathPrefixes 是绝不能回退到 SPA HTML 的后端/运维路径前缀。 +// 未注册时必须返回 API 风格 404,避免 /metrics 等被 index.html 伪装成 200。 +var nonSPAPathPrefixes = []string{ + "/api", + "/v1", + "/v1beta", + "/assets", + "/metrics", + "/pg", + "/mj", + "/suno", + "/kling", + "/jimeng", + "/dashboard", + "/healthz", + "/livez", + "/readyz", + "/frontend-healthz", +} + +// isNonSPARequestPath 判断路径是否属于 API/Relay/运维端点,禁止 SPA NoRoute 接管。 +func isNonSPARequestPath(requestURI string) bool { + path := requestURI + if i := strings.IndexByte(path, '?'); i >= 0 { + path = path[:i] + } + if path == "" { + path = "/" + } + for _, prefix := range nonSPAPathPrefixes { + if path == prefix || strings.HasPrefix(path, prefix+"/") { + return true + } + } + // Midjourney 模式前缀:/:mode/mj 或 /:mode/mj/... + trimmed := strings.Trim(path, "/") + if trimmed == "" { + return false + } + parts := strings.Split(trimmed, "/") + if len(parts) >= 2 && parts[1] == "mj" { + return true + } + return false +} + func SetWebRouter(router *gin.Engine, assets ThemeAssets) { defaultFS := common.EmbedFolder(assets.DefaultBuildFS, "web/default/dist") classicFS := common.EmbedFolder(assets.ClassicBuildFS, "web/classic/dist") @@ -32,7 +83,8 @@ func SetWebRouter(router *gin.Engine, assets ThemeAssets) { router.Use(static.Serve("/", themeFS)) router.NoRoute(func(c *gin.Context) { c.Set(middleware.RouteTagKey, "web") - if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") { + if isNonSPARequestPath(c.Request.RequestURI) { + // 未注册的后端/运维路径返回 JSON 404,禁止回退 HTML。 controller.RelayNotFound(c) return } diff --git a/runtime_mode.go b/runtime_mode.go new file mode 100644 index 000000000000..db5fa686920e --- /dev/null +++ b/runtime_mode.go @@ -0,0 +1,66 @@ +package main + +import ( + "errors" + "fmt" + "strings" + + "github.com/QuantumNous/new-api/router" +) + +type runMode string + +const ( + runModeAll runMode = "all" + runModeServe runMode = "serve" + runModeWorker runMode = "worker" + runModeScheduler runMode = "scheduler" + runModeMigrate runMode = "migrate" +) + +func parseRunMode(value string) (runMode, error) { + switch runMode(strings.ToLower(strings.TrimSpace(value))) { + case "", runModeAll: + return runModeAll, nil + case runModeServe: + return runModeServe, nil + case runModeWorker: + return runModeWorker, nil + case runModeScheduler: + return runModeScheduler, nil + case runModeMigrate: + return runModeMigrate, nil + default: + return "", errors.New("RUN_MODE must be one of: all, serve, worker, scheduler, migrate") + } +} + +func (mode runMode) servesHTTP() bool { + return mode == runModeAll || mode == runModeServe +} + +func (mode runMode) runsWorker() bool { + return mode == runModeAll || mode == runModeWorker +} + +func (mode runMode) runsScheduler() bool { + return mode == runModeAll || mode == runModeScheduler +} + +func parseRuntimeConfig(runModeValue, planeValue, nodeType string) (runMode, router.Plane, error) { + mode, err := parseRunMode(runModeValue) + if err != nil { + return "", "", err + } + plane, err := router.ParsePlane(planeValue) + if err != nil { + return "", "", err + } + if mode == runModeMigrate && strings.EqualFold(strings.TrimSpace(nodeType), "slave") { + return "", "", fmt.Errorf("RUN_MODE=migrate requires NODE_TYPE to be master or unset") + } + if (mode == runModeWorker || mode == runModeScheduler) && strings.EqualFold(strings.TrimSpace(nodeType), "slave") { + return "", "", fmt.Errorf("RUN_MODE=%s requires NODE_TYPE to be master or unset", mode) + } + return mode, plane, nil +} diff --git a/runtime_mode_test.go b/runtime_mode_test.go new file mode 100644 index 000000000000..340ffa9f00b1 --- /dev/null +++ b/runtime_mode_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseRunMode(t *testing.T) { + for input, expected := range map[string]runMode{ + "": runModeAll, + "all": runModeAll, + "SERVE": runModeServe, + "worker": runModeWorker, + "scheduler": runModeScheduler, + "migrate": runModeMigrate, + } { + actual, err := parseRunMode(input) + require.NoError(t, err) + require.Equal(t, expected, actual) + } + _, err := parseRunMode("cron") + require.Error(t, err) +} + +func TestRunModeCapabilities(t *testing.T) { + require.True(t, runModeAll.servesHTTP()) + require.True(t, runModeAll.runsWorker()) + require.True(t, runModeAll.runsScheduler()) + require.True(t, runModeServe.servesHTTP()) + require.False(t, runModeServe.runsWorker()) + require.False(t, runModeWorker.servesHTTP()) + require.True(t, runModeWorker.runsWorker()) + require.True(t, runModeScheduler.runsScheduler()) +} + +func TestParseRuntimeConfigRejectsInvalidCombinations(t *testing.T) { + _, _, err := parseRuntimeConfig("all", "public", "") + require.Error(t, err) + + _, _, err = parseRuntimeConfig("migrate", "all", "slave") + require.Error(t, err) + _, _, err = parseRuntimeConfig("worker", "all", "slave") + require.Error(t, err) + _, _, err = parseRuntimeConfig("scheduler", "all", "slave") + require.Error(t, err) + + mode, plane, err := parseRuntimeConfig("serve", "relay", "slave") + require.NoError(t, err) + require.Equal(t, runModeServe, mode) + require.Equal(t, "relay", string(plane)) +} diff --git a/scripts/build-release.ps1 b/scripts/build-release.ps1 new file mode 100644 index 000000000000..657ccc92fc5f --- /dev/null +++ b/scripts/build-release.ps1 @@ -0,0 +1,185 @@ +[CmdletBinding()] +param( + [string]$ExistingBinary = "", + [string]$OutputDirectory = "artifacts", + [switch]$SkipWebBuild, + [switch]$AllowDirty +) + +$ErrorActionPreference = "Stop" + +function Assert-ExitCode([string]$Step) { + if ($LASTEXITCODE -ne 0) { + throw "$Step failed with exit code $LASTEXITCODE" + } +} + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path.TrimEnd("\") +$gitRoot = (& git -C $repoRoot rev-parse --show-toplevel).Trim().Replace("/", "\").TrimEnd("\") +Assert-ExitCode "git root detection" +if (-not [string]::Equals($repoRoot, $gitRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Build root mismatch: expected $repoRoot, got $gitRoot" +} +if ((Split-Path $repoRoot -Leaf) -eq "_qn_tmp") { + throw "Refusing to build from the upstream reference tree" +} + +$versionFile = Join-Path $repoRoot "VERSION" +$versionFallback = (Get-Content -LiteralPath $versionFile -Raw).Trim() +if ([string]::IsNullOrWhiteSpace($versionFallback)) { + throw "VERSION must not be empty" +} + +$head = (& git -C $repoRoot rev-parse HEAD).Trim() +Assert-ExitCode "git revision detection" +$branch = (& git -C $repoRoot branch --show-current).Trim() +Assert-ExitCode "git branch detection" +$describe = (& git -C $repoRoot describe --tags --always --dirty).Trim() +Assert-ExitCode "git version detection" +$statusLines = @(& git -C $repoRoot status --porcelain) +Assert-ExitCode "git status detection" +$isDirty = $statusLines.Count -gt 0 +if ($isDirty -and -not $AllowDirty) { + throw "Release builds require a clean working tree. Use -AllowDirty only for diagnostics." +} + +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + throw "OutputDirectory must not be empty" +} +if (-not [System.IO.Path]::IsPathRooted($OutputDirectory)) { + $OutputDirectory = Join-Path $repoRoot $OutputDirectory +} +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null +$OutputDirectory = (Resolve-Path $OutputDirectory).Path + +$version = if ([string]::IsNullOrWhiteSpace($describe)) { $versionFallback } else { $describe } +$buildCommand = "existing-binary" +$isExistingBinary = -not [string]::IsNullOrWhiteSpace($ExistingBinary) + +if (-not $isExistingBinary) { + if (-not $SkipWebBuild) { + $oldFrontendVersion = $env:VITE_REACT_APP_VERSION + $oldDisableEslint = $env:DISABLE_ESLINT_PLUGIN + Push-Location (Join-Path $repoRoot "web") + try { + & bun install --frozen-lockfile + Assert-ExitCode "bun install" + $env:VITE_REACT_APP_VERSION = $version + $env:DISABLE_ESLINT_PLUGIN = "true" + Push-Location "default" + try { + & bun run build + Assert-ExitCode "default frontend build" + } finally { + Pop-Location + } + Push-Location "classic" + try { + & bun run build + Assert-ExitCode "classic frontend build" + } finally { + Pop-Location + } + } finally { + Pop-Location + $env:VITE_REACT_APP_VERSION = $oldFrontendVersion + $env:DISABLE_ESLINT_PLUGIN = $oldDisableEslint + } + } else { + foreach ($indexPath in @("web\default\dist\index.html", "web\classic\dist\index.html")) { + if (-not (Test-Path -LiteralPath (Join-Path $repoRoot $indexPath))) { + throw "Missing embedded frontend asset: $indexPath" + } + } + } + + $safeVersion = [regex]::Replace($version, "[^0-9A-Za-z._-]", "_") + $artifactPath = Join-Path $OutputDirectory "new-api-$safeVersion.exe" + $ldflags = "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$version'" + Push-Location $repoRoot + try { + & go build -trimpath -buildvcs=true -ldflags $ldflags -o $artifactPath . + Assert-ExitCode "Go release build" + } finally { + Pop-Location + } + $buildCommand = "go build -trimpath -buildvcs=true -ldflags " +} else { + $artifactPath = (Resolve-Path $ExistingBinary).Path +} + +$artifact = Get-Item -LiteralPath $artifactPath +$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPath +$buildInfo = (& go version -m $artifactPath 2>&1 | Out-String).Trim() +Assert-ExitCode "Go build info extraction" +$revisionMatch = [regex]::Match($buildInfo, "vcs\.revision=([0-9a-fA-F]+)") +$modifiedMatch = [regex]::Match($buildInfo, "vcs\.modified=(true|false)") +$moduleVersionMatch = [regex]::Match($buildInfo, "(?m)^\s*mod\s+\S+\s+(\S+)") +$embeddedRevision = if ($revisionMatch.Success) { $revisionMatch.Groups[1].Value } else { "" } +$embeddedModified = if ($modifiedMatch.Success) { $modifiedMatch.Groups[1].Value } else { "unknown" } +$embeddedModuleVersion = if ($moduleVersionMatch.Success) { $moduleVersionMatch.Groups[1].Value } else { "" } +$revisionMatchesHead = $embeddedRevision -ne "" -and $embeddedRevision -eq $head + +$goVersion = (& go version).Trim() +Assert-ExitCode "Go version detection" +$bunVersion = "unavailable" +if (Get-Command bun -ErrorAction SilentlyContinue) { + $bunVersion = (& bun --version).Trim() + Assert-ExitCode "Bun version detection" +} +$signatureStatus = "not-applicable" +if (Get-Command Get-AuthenticodeSignature -ErrorAction SilentlyContinue) { + $signatureStatus = [string](Get-AuthenticodeSignature -LiteralPath $artifactPath).Status +} + +$artifactFileName = $artifact.Name +$buildInfoPath = Join-Path $OutputDirectory ($artifactFileName + ".buildinfo.txt") +$checksumPath = Join-Path $OutputDirectory ($artifactFileName + ".sha256") +$manifestPath = Join-Path $OutputDirectory ($artifactFileName + ".manifest.json") +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +[System.IO.File]::WriteAllText($buildInfoPath, $buildInfo + [Environment]::NewLine, $utf8NoBom) +[System.IO.File]::WriteAllText($checksumPath, $hash.Hash.ToLowerInvariant() + " " + $artifactFileName + [Environment]::NewLine, $utf8NoBom) + +$manifest = [ordered]@{ + schemaVersion = 1 + generatedAtUtc = (Get-Date).ToUniversalTime().ToString("o") + artifact = [ordered]@{ + fileName = $artifactFileName + path = $artifact.FullName + sizeBytes = $artifact.Length + sha256 = $hash.Hash.ToLowerInvariant() + authenticodeStatus = $signatureStatus + } + source = [ordered]@{ + authoritativeRoot = $repoRoot + branch = $branch + currentHead = $head + currentDescribe = $describe + currentWorkingTreeDirty = $isDirty + embeddedRevision = $embeddedRevision + embeddedModified = $embeddedModified + embeddedModuleVersion = $embeddedModuleVersion + embeddedRevisionMatchesCurrentHead = $revisionMatchesHead + } + build = [ordered]@{ + command = $buildCommand + versionFallback = $versionFallback + resolvedVersion = $(if ($isExistingBinary) { "not-derived-from-current-tree" } else { $version }) + goVersion = $goVersion + bunVersion = $bunVersion + } + evidence = [ordered]@{ + buildInfo = $buildInfoPath + checksum = $checksumPath + dependencyInventoryFormat = "go version -m" + standardizedSbomGenerated = $false + } +} +$manifestJson = $manifest | ConvertTo-Json -Depth 6 +[System.IO.File]::WriteAllText($manifestPath, $manifestJson + [Environment]::NewLine, $utf8NoBom) + +Write-Output "artifact=$artifactPath" +Write-Output "manifest=$manifestPath" +Write-Output "checksum=$checksumPath" +Write-Output "build_info=$buildInfoPath" +Write-Output "embedded_revision_matches_head=$revisionMatchesHead" diff --git a/scripts/local-ops.ps1 b/scripts/local-ops.ps1 new file mode 100644 index 000000000000..36636a2cafff --- /dev/null +++ b/scripts/local-ops.ps1 @@ -0,0 +1,108 @@ +# Local Windows helpers for the authoritative new-api tree (D:\newapi\src). +# Does not touch the production new-api-fixed.exe process. + +param( + [ValidateSet('check', 'backend', 'test-router', 'inventory')] + [string]$Action = 'check', + [string]$OutputDirectory = '' +) + +$ErrorActionPreference = 'Stop' +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path + +function Assert-RepoRoot { + $prefix = (& git -C $repoRoot rev-parse --show-prefix).Trim() + if ($prefix -ne '') { + throw "Run from the authoritative repository root scripts: expected empty git prefix, got '$prefix'" + } + $leaf = Split-Path (& git -C $repoRoot rev-parse --show-toplevel).Trim() -Leaf + if ($leaf -eq '_qn_tmp') { + throw 'Refusing to operate on the upstream reference tree _qn_tmp' + } +} + +function Invoke-Check { + Assert-RepoRoot + Push-Location $repoRoot + try { + Write-Host 'gofmt (delivery seam files)...' + $targets = @( + 'frontend_assets_embedded.go', + 'frontend_assets_external.go', + 'main.go', + 'router/main.go', + 'router/main_test.go', + 'router/web-router.go' + ) + $dirty = @(gofmt -l @targets) + if ($dirty.Count -gt 0) { + throw "gofmt dirty: $($dirty -join ', ')" + } + Write-Host 'go test ./router...' + go test ./router -count=1 + if ($LASTEXITCODE -ne 0) { throw "router tests failed: $LASTEXITCODE" } + Write-Host 'go test -tags frontend_external .' + go test -tags frontend_external . -count=1 + if ($LASTEXITCODE -ne 0) { throw "frontend_external tests failed: $LASTEXITCODE" } + Write-Host 'go vet ./router .' + go vet ./router . + if ($LASTEXITCODE -ne 0) { throw "go vet failed: $LASTEXITCODE" } + Write-Host 'local check OK' + } finally { + Pop-Location + } +} + +function Invoke-BackendBuild { + Assert-RepoRoot + Push-Location $repoRoot + try { + $out = Join-Path $repoRoot 'new-api-backend.exe' + Write-Host "building pure backend -> $out" + go build -trimpath -buildvcs=true -tags frontend_external ` + -ldflags "-s -w -X github.com/QuantumNous/new-api/common.Version=$((Get-Content VERSION -Raw).Trim())" ` + -o $out . + if ($LASTEXITCODE -ne 0) { throw "backend build failed: $LASTEXITCODE" } + $hash = (Get-FileHash -LiteralPath $out -Algorithm SHA256).Hash.ToLowerInvariant() + Write-Host "backend=$out size=$((Get-Item $out).Length) sha256=$hash" + Write-Host 'Run with FRONTEND_MODE=disabled (or redirect). Do not replace production new-api-fixed.exe unless explicitly promoted.' + } finally { + Pop-Location + } +} + +function Invoke-Inventory { + Assert-RepoRoot + $script = Join-Path $repoRoot 'scripts\build-release.ps1' + if (-not (Test-Path -LiteralPath $script)) { + throw "missing $script" + } + $outDir = $OutputDirectory + if ([string]::IsNullOrWhiteSpace($outDir)) { + $outDir = 'D:\newapi\release-manifests' + } + $existing = 'D:\newapi\new-api-fixed.exe' + if (-not (Test-Path -LiteralPath $existing)) { + throw "production binary missing: $existing" + } + Write-Host "inventory existing binary (AllowDirty diagnostic only) -> $outDir" + & powershell -NoProfile -ExecutionPolicy Bypass -File $script ` + -ExistingBinary $existing ` + -OutputDirectory $outDir ` + -AllowDirty + if ($LASTEXITCODE -ne 0) { throw "inventory failed: $LASTEXITCODE" } +} + +switch ($Action) { + 'check' { Invoke-Check } + 'backend' { Invoke-BackendBuild } + 'test-router' { + Assert-RepoRoot + Push-Location $repoRoot + try { + go test ./router -count=1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } finally { Pop-Location } + } + 'inventory' { Invoke-Inventory } +} diff --git a/service/billing_session.go b/service/billing_session.go index 32344eaf405c..b1aa77c10089 100644 --- a/service/billing_session.go +++ b/service/billing_session.go @@ -68,6 +68,7 @@ func (s *BillingSession) Settle(actualQuota int) error { // 资金来源已提交,令牌调整失败只能记录日志;标记 settled 防止 Refund 误退资金 common.SysLog(fmt.Sprintf("error adjusting token quota after funding settled (userId=%d, tokenId=%d, delta=%d): %s", s.relayInfo.UserId, s.relayInfo.TokenId, delta, tokenErr.Error())) + return tokenErr } } // 3) 更新 relayInfo 上的订阅 PostDelta(用于日志) @@ -75,7 +76,7 @@ func (s *BillingSession) Settle(actualQuota int) error { s.relayInfo.SubscriptionPostDelta += int64(delta) } s.settled = true - return tokenErr + return nil } // Refund 退还所有预扣费,幂等安全,异步执行。 diff --git a/service/billing_session_test.go b/service/billing_session_test.go new file mode 100644 index 000000000000..4b52eff7178a --- /dev/null +++ b/service/billing_session_test.go @@ -0,0 +1,67 @@ +package service + +import ( + "testing" + + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/stretchr/testify/require" +) + +type billingSessionTestFunding struct { + settleCalls int + deltas []int +} + +func (f *billingSessionTestFunding) Source() string { + return BillingSourceWallet +} + +func (f *billingSessionTestFunding) PreConsume(int) error { + return nil +} + +func (f *billingSessionTestFunding) Settle(delta int) error { + f.settleCalls++ + f.deltas = append(f.deltas, delta) + return nil +} + +func (f *billingSessionTestFunding) Refund() error { + return nil +} + +func TestBillingSessionSettleRetriesTokenAdjustmentWithoutSettlingFundingTwice(t *testing.T) { + truncate(t) + seedToken(t, 901, 801, "billing-session-token", 5) + + funding := &billingSessionTestFunding{} + session := &BillingSession{ + relayInfo: &relaycommon.RelayInfo{ + UserId: 801, + TokenId: 901, + TokenKey: "billing-session-token", + }, + funding: funding, + } + + err := session.Settle(10) + require.Error(t, err) + require.True(t, session.fundingSettled) + require.False(t, session.settled) + require.Equal(t, 1, funding.settleCalls) + require.Equal(t, []int{10}, funding.deltas) + + require.NoError(t, model.DB.Model(&model.Token{}). + Where("id = ?", session.relayInfo.TokenId). + Update("remain_quota", 20).Error) + + require.NoError(t, session.Settle(10)) + require.True(t, session.settled) + require.Equal(t, 1, funding.settleCalls) + + var token model.Token + require.NoError(t, model.DB.First(&token, session.relayInfo.TokenId).Error) + require.Equal(t, 10, token.RemainQuota) + require.Equal(t, 10, token.UsedQuota) +} diff --git a/service/channel_adaptive.go b/service/channel_adaptive.go new file mode 100644 index 000000000000..09840325415b --- /dev/null +++ b/service/channel_adaptive.go @@ -0,0 +1,329 @@ +package service + +import ( + "fmt" + "math/rand/v2" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" +) + +var ( + adaptiveLogEnabled bool + adaptiveLogSample = 0.01 // 采样率 +) + +// 请求上下文 key +type adaptiveContextKey string + +const ( + ctxKeyAdaptiveUsedChannels adaptiveContextKey = "adaptive_used_channels" + ctxKeyAdaptiveGroup adaptiveContextKey = "adaptive_group" + ctxKeyAdaptiveModel adaptiveContextKey = "adaptive_model" + ctxKeyAdaptiveSelected adaptiveContextKey = "adaptive_selected" + ctxKeyAdaptiveScores adaptiveContextKey = "adaptive_scores" + ctxKeyAdaptiveCircuitPermit adaptiveContextKey = "adaptive_circuit_permit" +) + +// AdaptiveSelectChannel 动态评分调度器主入口。 +// 所有回退必须调用 cacheGetRandomSatisfiedChannelLegacy,禁止再进 CacheGetRandomSatisfiedChannel。 +func AdaptiveSelectChannel(param *RetryParam) (*model.Channel, string, error) { + ctx := param.Ctx + + // 未开启完整自适应:仅 legacy(含「只开 shadow」旧行为,避免递归) + if !constant.AdaptiveBalanceEnabled { + return cacheGetRandomSatisfiedChannelLegacy(param) + } + + // 提取 group 和 model + group := common.GetContextKeyString(ctx, constant.ContextKeyUsingGroup) + if group == "" { + group = param.TokenGroup + } + modelName := param.ModelName + + // 获取该 group+model 下的可用渠道 + channels, err := getCandidateChannels(group, modelName, param) + if err != nil { + return nil, group, err + } + if len(channels) == 0 { + return cacheGetRandomSatisfiedChannelLegacy(param) + } + + // 获取亲和偏好 channel + preferredID := getPreferredChannelID(ctx, modelName, group) + + // 评分 + candidates := ScoreCandidates(channels, group, modelName, preferredID) + + usedIDs := getAdaptiveUsedChannels(ctx) + filtered, permits := filterAdaptiveCandidates( + candidates, group, modelName, preferredID, usedIDs, constant.AdaptiveBalanceShadowMode, + ) + + if len(filtered) == 0 { + if shouldFallbackToLegacy(len(candidates), len(filtered), constant.AdaptiveBalanceShadowMode) { + return cacheGetRandomSatisfiedChannelLegacy(param) + } + return nil, group, fmt.Errorf("adaptive: no available channels after circuit and retry filtering") + } + + // topK 加权随机选择 + selected := SelectTopKWeighted(filtered, 3) + if selected == nil { + releaseUnselectedCircuitPermits(permits, 0) + return nil, group, fmt.Errorf("adaptive: failed to select an eligible channel") + } + + // Shadow Mode:选择仍走旧逻辑,仅记录对比 + if constant.AdaptiveBalanceShadowMode { + oldCh, oldGroup, oldErr := cacheGetRandomSatisfiedChannelLegacy(param) + + // 采样日志 + if adaptiveLogEnabled || randFloat64() < adaptiveLogSample { + logAdaptiveCompare(ctx, modelName, group, selected, oldCh) + } + + // shadow mode never changes routing or acquires half-open permits. + if oldCh != nil { + addAdaptiveUsedChannel(ctx, oldCh.Id) + storeAdaptiveSelection(ctx, selected.Channel, group, candidates) + } + return oldCh, oldGroup, oldErr + } + + // 正常模式:使用动态选择的渠道 + selectGroup := group + ch := selected.Channel + permit := permits[ch.Id] + releaseUnselectedCircuitPermits(permits, ch.Id) + + addAdaptiveUsedChannel(ctx, ch.Id) + storeAdaptiveSelection(ctx, ch, group, candidates) + ctx.Set(string(ctxKeyAdaptiveCircuitPermit), permit) + + logger.LogDebug(ctx, "adaptive selected channel #%d (score=%.3f) for group=%s model=%s", + ch.Id, selected.Score, group, modelName) + + return ch, selectGroup, nil +} + +func filterAdaptiveCandidates( + candidates []CandidateScore, + group string, + modelName string, + preferredID int, + usedIDs []int, + shadowMode bool, +) ([]CandidateScore, map[int]CircuitPermit) { + filtered := make([]CandidateScore, 0, len(candidates)) + permits := make(map[int]CircuitPermit, len(candidates)) + for _, candidate := range candidates { + channelID := candidate.Channel.Id + if containsInt(usedIDs, channelID) { + continue + } + if shadowMode { + if IsCircuitOpen(channelID) || candidate.Score <= 0 { + continue + } + filtered = append(filtered, candidate) + continue + } + + permit, ok := AcquireCircuitPermit(channelID) + if !ok { + continue + } + if permit.HalfOpen { + candidate = scoreCandidate(candidate.Channel, group, modelName, preferredID, 0.5) + } + if candidate.Score <= 0 { + ReleaseCircuitPermit(permit) + continue + } + permits[channelID] = permit + filtered = append(filtered, candidate) + } + return filtered, permits +} + +func ReleaseAdaptiveCircuitPermit(c *gin.Context, channelID int) { + if c == nil || channelID <= 0 { + return + } + permitAny, ok := c.Get(string(ctxKeyAdaptiveCircuitPermit)) + permit, permitOK := permitAny.(CircuitPermit) + if !ok || !permitOK || permit.ChannelID != channelID { + return + } + ReleaseCircuitPermit(permit) + c.Set(string(ctxKeyAdaptiveCircuitPermit), CircuitPermit{}) +} + +func shouldFallbackToLegacy(candidateCount, filteredCount int, shadowMode bool) bool { + return candidateCount == 0 || (shadowMode && filteredCount == 0) +} + +func releaseUnselectedCircuitPermits(permits map[int]CircuitPermit, selectedChannelID int) { + for channelID, permit := range permits { + if channelID != selectedChannelID { + ReleaseCircuitPermit(permit) + } + } +} + +// getCandidateChannels 获取 group+model 全部候选(非单渠道路由) +func getCandidateChannels(group, modelName string, param *RetryParam) ([]*model.Channel, error) { + // auto 分组:优先用上下文已解析的 auto group,否则 legacy 解析一次 + if group == "auto" || param.TokenGroup == "auto" { + if g := common.GetContextKeyString(param.Ctx, constant.ContextKeyAutoGroup); g != "" { + group = g + } else { + // 用 legacy 解析 auto → 具体 group,再拉全量候选 + ch, selectGroup, err := cacheGetRandomSatisfiedChannelLegacy(param) + if err != nil { + return nil, err + } + if ch == nil { + return nil, nil + } + if selectGroup != "" { + group = selectGroup + } + // 继续用解析后的 group 拉全量;若失败至少返回当前渠道 + list, listErr := model.GetSatisfiedChannels(group, modelName, param.RequestPath) + if listErr != nil { + return []*model.Channel{ch}, nil + } + if len(list) == 0 { + return []*model.Channel{ch}, nil + } + return list, nil + } + } + + return model.GetSatisfiedChannels(group, modelName, param.RequestPath) +} + +// getPreferredChannelID 读取亲和偏好(如果有) +func getPreferredChannelID(ctx *gin.Context, modelName, group string) int { + if !common.MemoryCacheEnabled { + return 0 + } + id, found := GetPreferredChannelByAffinity(ctx, modelName, group) + if found { + return id + } + return 0 +} + +// getAdaptiveUsedChannels 获取本次请求已用过的渠道 ID 列表 +func getAdaptiveUsedChannels(c *gin.Context) []int { + v, ok := c.Get(string(ctxKeyAdaptiveUsedChannels)) + if !ok { + return nil + } + ids, _ := v.([]int) + return ids +} + +// addAdaptiveUsedChannel 记录本次请求使用过的渠道 +func addAdaptiveUsedChannel(c *gin.Context, channelID int) { + existing := getAdaptiveUsedChannels(c) + existing = append(existing, channelID) + c.Set(string(ctxKeyAdaptiveUsedChannels), existing) +} + +func MarkChannelUsed(c *gin.Context, channelID int) { + if c == nil || channelID <= 0 || containsInt(getAdaptiveUsedChannels(c), channelID) { + return + } + addAdaptiveUsedChannel(c, channelID) +} + +func adaptiveUsedChannelSet(c *gin.Context) map[int]struct{} { + used := getAdaptiveUsedChannels(c) + if len(used) == 0 { + return nil + } + excluded := make(map[int]struct{}, len(used)) + for _, channelID := range used { + excluded[channelID] = struct{}{} + } + return excluded +} + +// storeAdaptiveSelection 保存本次选择结果到上下文(供失败回写用) +func storeAdaptiveSelection(c *gin.Context, ch *model.Channel, group string, candidates []CandidateScore) { + c.Set(string(ctxKeyAdaptiveSelected), ch.Id) + c.Set(string(ctxKeyAdaptiveGroup), group) + if len(candidates) > 0 { + c.Set(string(ctxKeyAdaptiveScores), candidates) + } +} + +// logAdaptiveCompare shadow mode 日志 +func logAdaptiveCompare(c *gin.Context, modelName, group string, selected *CandidateScore, oldCh *model.Channel) { + oldID := 0 + if oldCh != nil { + oldID = oldCh.Id + } + logger.LogDebug(c, "[shadow] model=%s group=%s adaptive=#%d(%.3f) orig=#%d", + modelName, group, selected.Channel.Id, selected.Score, oldID) +} + +// RecordAdaptiveResult 请求完成后回调:更新指标 + 熔断状态 +func RecordAdaptiveResult(c *gin.Context, channelID int, group, modelName string, statusCode int, latency time.Duration, err error) { + if !constant.AdaptiveBalanceEnabled { + return + } + if channelID <= 0 { + return + } + + succeeded := err == nil && statusCode < 400 + if succeeded { + ObserveSuccess(channelID, group, modelName, latency) + } else { + ObserveFailure(channelID, group, modelName, statusCode, latency) + } + + if constant.AdaptiveBalanceShadowMode { + return + } + permitAny, ok := c.Get(string(ctxKeyAdaptiveCircuitPermit)) + permit, permitOK := permitAny.(CircuitPermit) + if !ok || !permitOK || permit.ChannelID != channelID { + return + } + if succeeded { + RecordCircuitSuccessWithPermit(permit) + } else if statusCode >= 500 || statusCode == 429 { + RecordCircuitFailureWithPermit(permit, fmt.Sprintf("HTTP %d", statusCode)) + } else { + // A client/input error still proves the upstream is reachable. Do not + // leave a half-open permit stuck or preserve an old failure streak. + RecordCircuitSuccessWithPermit(permit) + } +} + +// containsInt 检查 int 切片是否包含某值 +func containsInt(slice []int, val int) bool { + for _, v := range slice { + if v == val { + return true + } + } + return false +} + +// randFloat64 生成 [0,1) 随机数 +var randFloat64 = func() float64 { + return rand.Float64() +} diff --git a/service/channel_adaptive_test.go b/service/channel_adaptive_test.go new file mode 100644 index 000000000000..ff5c4615a953 --- /dev/null +++ b/service/channel_adaptive_test.go @@ -0,0 +1,295 @@ +package service + +import ( + "fmt" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + + "github.com/stretchr/testify/require" +) + +// 测试辅助:构造测试渠道 +func testChannel(id int, weight uint, priority int64) *model.Channel { + w := weight + return &model.Channel{ + Id: id, + Weight: &w, + Priority: &priority, + Name: fmt.Sprintf("ch-%d", id), + Status: common.ChannelStatusEnabled, + } +} + +func init() { + // 测试前重置全局状态 + globalSnapshot.mu.Lock() + globalSnapshot.metrics = make(map[metricsKey]*ChannelMetrics) + globalSnapshot.mu.Unlock() + + constant.EwmaAlpha = 0.3 + constant.MaxChannelConcurrency = 10 + constant.ChannelCircuitBreakerEnabled = true + constant.AdaptiveBalanceEnabled = true + constant.AdaptiveBalanceShadowMode = false +} + +// 测试1:高延迟渠道会被降权 +func TestHighLatencyDowngraded(t *testing.T) { + ch1 := testChannel(1, 10, 100) + ch2 := testChannel(2, 10, 100) + + // ch1 低延迟,ch2 高延迟 + ObserveSuccess(1, "test", "gpt-4", 200*time.Millisecond) + ObserveSuccess(1, "test", "gpt-4", 150*time.Millisecond) + ObserveSuccess(1, "test", "gpt-4", 180*time.Millisecond) + ObserveSuccess(2, "test", "gpt-4", 5*time.Second) + ObserveSuccess(2, "test", "gpt-4", 6*time.Second) + ObserveSuccess(2, "test", "gpt-4", 4*time.Second) + + channels := []*model.Channel{ch1, ch2} + candidates := ScoreCandidates(channels, "test", "gpt-4", 0) + + require.GreaterOrEqual(t, len(candidates), 2) + // ch1(低延迟)的分数应显著高于 ch2 + require.Equal(t, 1, candidates[0].Channel.Id, "expected ch1 (low latency) to rank first") + scoreDiff := candidates[0].Score - candidates[1].Score + require.Greater(t, scoreDiff, 0.1, "expected significant score difference") + t.Logf("ch1 (low latency) score=%.4f, ch2 (high latency) score=%.4f", candidates[0].Score, candidates[1].Score) +} + +// 测试2:429 渠道不会完全排除但会被降权 +func TestRateLimitedChannelDowngraded(t *testing.T) { + ch1 := testChannel(10, 10, 100) + ch2 := testChannel(11, 10, 100) + + // ch1 正常,ch2 有 429 + ObserveSuccess(10, "test", "gpt-4", 300*time.Millisecond) + ObserveSuccess(10, "test", "gpt-4", 250*time.Millisecond) + ObserveFailure(11, "test", "gpt-4", 429, 100*time.Millisecond) + ObserveFailure(11, "test", "gpt-4", 429, 100*time.Millisecond) + ObserveFailure(11, "test", "gpt-4", 429, 100*time.Millisecond) + + channels := []*model.Channel{ch1, ch2} + candidates := ScoreCandidates(channels, "test", "gpt-4", 0) + + require.GreaterOrEqual(t, len(candidates), 2) + // 正常渠道应排名更高 + require.Equal(t, 10, candidates[0].Channel.Id, "expected ch10 (normal) to rank first") + t.Logf("ch10 (normal) score=%.4f rate_limit_factor=%.4f", candidates[0].Score, candidates[0].RateLimitFactor) + t.Logf("ch11 (429) score=%.4f rate_limit_factor=%.4f", candidates[1].Score, candidates[1].RateLimitFactor) + require.Less(t, candidates[1].RateLimitFactor, candidates[0].RateLimitFactor) +} + +// 测试3:熔断渠道不会被选中 +func TestCircuitBreakerChannelExcluded(t *testing.T) { + ch := testChannel(20, 10, 100) + + // 模拟三次连续失败,触发熔断 + RecordCircuitFailure(20, "500 Internal Server Error") + RecordCircuitFailure(20, "500 Internal Server Error") + RecordCircuitFailure(20, "500 Internal Server Error") + + require.True(t, IsCircuitOpen(20), "expected circuit to be open after 3 consecutive failures") + + // 评分中应过滤掉熔断渠道 + channels := []*model.Channel{ch} + _ = ScoreCandidates(channels, "test", "gpt-4", 0) + + // channel_adaptive.go 中过滤逻辑会跳过 open 渠道 + require.True(t, IsCircuitOpen(20)) +} + +// 测试4:多渠道重试不会重复选择同一个渠道 +func TestNoDuplicateChannelInRetry(t *testing.T) { + ch1 := testChannel(30, 10, 100) + ch2 := testChannel(31, 10, 100) + ch3 := testChannel(32, 10, 100) + + // 全部成功 + for _, id := range []int{30, 31, 32} { + ObserveSuccess(id, "test", "gpt-4", 200*time.Millisecond) + } + + channels := []*model.Channel{ch1, ch2, ch3} + candidates := ScoreCandidates(channels, "test", "gpt-4", 0) + + // 模拟已使用的渠道 + usedIDs := []int{30} + + // 过滤掉已使用的渠道 + var filtered []CandidateScore + for _, c := range candidates { + if containsInt(usedIDs, c.Channel.Id) { + continue + } + filtered = append(filtered, c) + } + + require.Len(t, filtered, 2) + selected := SelectTopKWeighted(filtered, 3) + require.NotNil(t, selected) + require.NotEqual(t, 30, selected.Channel.Id) + t.Logf("selected ch%d (ch30 excluded)", selected.Channel.Id) +} + +// 测试5:TopK 加权随机不会全部集中在最高分渠道 +func TestTopKWeightedRandomFairness(t *testing.T) { + channels := make([]*model.Channel, 10) + for i := 0; i < 10; i++ { + channels[i] = testChannel(100+i, 10, 100) + ObserveSuccess(100+i, "test", "gpt-4", time.Duration(200+(i*100))*time.Millisecond) + } + + candidates := ScoreCandidates(channels, "test", "gpt-4", 0) + require.GreaterOrEqual(t, len(candidates), 10) + + // 模拟多次选择,统计分布 + selectionCount := make(map[int]int) + trials := 1000 + for i := 0; i < trials; i++ { + selected := SelectTopKWeighted(candidates, 3) + if selected != nil { + selectionCount[selected.Channel.Id]++ + } + } + + // TopK 的前三名应该都有一定比例 + for _, c := range candidates[:3] { + count := selectionCount[c.Channel.Id] + ratio := float64(count) / float64(trials) + t.Logf("ch%d selection rate: %.2f%% (score=%.3f)", c.Channel.Id, ratio*100, c.Score) + require.GreaterOrEqual(t, ratio, 0.05, "ch%d selected too few times", c.Channel.Id) + } +} + +// 测试6:EWMA 计算正确 +func TestEwmaUpdate(t *testing.T) { + alpha := 0.3 + + // 初始 1.0,观察到 0.5 + result := EwmaUpdate(1.0, 0.5, alpha) + expected := 0.3*0.5 + 0.7*1.0 // = 0.85 + require.InDelta(t, expected, result, 0.001) + + // 再次衰减 + result2 := EwmaUpdate(result, 0.5, alpha) + expected2 := 0.3*0.5 + 0.7*0.85 // = 0.745 + require.InDelta(t, expected2, result2, 0.001) +} + +// 测试7:延迟桶边界 +func TestLatencyBucket(t *testing.T) { + tests := []struct { + latency time.Duration + bucket int + }{ + {100 * time.Millisecond, 0}, + {500 * time.Millisecond, 0}, + {600 * time.Millisecond, 1}, + {1 * time.Second, 1}, + {1500 * time.Millisecond, 2}, + {3 * time.Second, 3}, + {7 * time.Second, 4}, + {15 * time.Second, 5}, + } + + for _, tt := range tests { + b := latencyBucket(tt.latency) + require.Equal(t, tt.bucket, b, "latencyBucket(%v)", tt.latency) + } +} + +func TestAdaptiveFilteringRecomputesHalfOpenProbeScore(t *testing.T) { + channelID := 220 + circuitBreakers.Delete(channelID) + t.Cleanup(func() { circuitBreakers.Delete(channelID) }) + + cb := getCircuitBreaker(channelID) + cb.mu.Lock() + cb.State = CircuitOpen + cb.OpenUntil = time.Now().Add(-time.Second) + cb.ConsecutiveFailure = 3 + cb.mu.Unlock() + + ch := testChannel(channelID, 10, 100) + candidates := ScoreCandidates([]*model.Channel{ch}, "test", "gpt-4", 0) + require.Len(t, candidates, 1) + require.Zero(t, candidates[0].Score) + + filtered, permits := filterAdaptiveCandidates( + candidates, + "test", + "gpt-4", + 0, + nil, + false, + ) + require.Len(t, filtered, 1) + require.Greater(t, filtered[0].Score, 0.0) + require.True(t, permits[channelID].HalfOpen) + ReleaseCircuitPermit(permits[channelID]) +} + +func TestAdaptiveLegacyFallbackPolicyDoesNotBypassFiltering(t *testing.T) { + require.True(t, shouldFallbackToLegacy(0, 0, false)) + require.False(t, shouldFallbackToLegacy(2, 0, false)) + require.True(t, shouldFallbackToLegacy(2, 0, true)) +} + +func TestOpenCircuitIgnoresLateSuccess(t *testing.T) { + channelID := 221 + circuitBreakers.Delete(channelID) + t.Cleanup(func() { circuitBreakers.Delete(channelID) }) + + RecordCircuitFailure(channelID, "500") + RecordCircuitFailure(channelID, "500") + RecordCircuitFailure(channelID, "500") + require.True(t, IsCircuitOpen(channelID)) + + RecordCircuitSuccess(channelID) + state, failures, _ := GetCircuitState(channelID) + require.Equal(t, CircuitOpen, state) + require.Equal(t, 3, failures) +} + +func TestGetMetricsReturnsImmutableSnapshot(t *testing.T) { + channelID := 222 + ObserveSuccess(channelID, "snapshot", "gpt-4", 250*time.Millisecond) + + first := GetMetrics(channelID, "snapshot", "gpt-4") + first.SuccessRate = 0 + first.AvgLatency = 99 * time.Second + + second := GetMetrics(channelID, "snapshot", "gpt-4") + require.Greater(t, second.SuccessRate, 0.0) + require.NotEqual(t, 99*time.Second, second.AvgLatency) +} + +func TestHalfOpenClientErrorReleasesProbeAndClosesCircuit(t *testing.T) { + channelID := 223 + circuitBreakers.Delete(channelID) + t.Cleanup(func() { circuitBreakers.Delete(channelID) }) + + cb := getCircuitBreaker(channelID) + cb.mu.Lock() + cb.State = CircuitOpen + cb.OpenUntil = time.Now().Add(-time.Second) + cb.ConsecutiveFailure = 3 + cb.mu.Unlock() + permit, ok := AcquireCircuitPermit(channelID) + require.True(t, ok) + require.True(t, permit.HalfOpen) + + ctx, _ := gin.CreateTestContext(nil) + ctx.Set(string(ctxKeyAdaptiveCircuitPermit), permit) + RecordAdaptiveResult(ctx, channelID, "test", "gpt-4", 400, time.Millisecond, fmt.Errorf("bad request")) + + state, failures, _ := GetCircuitState(channelID) + require.Equal(t, CircuitClosed, state) + require.Zero(t, failures) +} diff --git a/service/channel_affinity.go b/service/channel_affinity.go index 96ec13e248cc..076ac468cfaf 100644 --- a/service/channel_affinity.go +++ b/service/channel_affinity.go @@ -1,6 +1,7 @@ package service import ( + "context" "fmt" "hash/fnv" "regexp" @@ -27,6 +28,7 @@ const ( ginKeyChannelAffinitySkipRetry = "channel_affinity_skip_retry_on_failure" channelAffinityCacheNamespace = "new-api:channel_affinity:v1" + channelAffinityRedisLRUIndex = "new-api:channel_affinity_lru:v1" channelAffinityUsageCacheStatsNamespace = "new-api:channel_affinity_usage_cache_stats:v1" ) @@ -207,6 +209,13 @@ func ClearChannelAffinityCacheAll() int { common.SysError(fmt.Sprintf("channel affinity cache delete many failed: err=%v", err)) } } + if common.RedisEnabled && common.RDB != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := common.RDB.Del(ctx, channelAffinityRedisLRUIndex).Err(); err != nil { + common.SysError(fmt.Sprintf("channel affinity LRU index clear failed: err=%v", err)) + } + } return len(keys) } @@ -238,10 +247,42 @@ func ClearChannelAffinityCacheByRuleName(ruleName string) (int, error) { } cache := getChannelAffinityCache() - deleted, err := cache.DeleteByPrefix(ruleName) + if !common.RedisEnabled || common.RDB == nil { + return cache.DeleteByPrefix(ruleName) + } + keys, err := cache.Keys() if err != nil { return 0, err } + prefix := cache.FullKey(ruleName) + ":" + matched := make([]string, 0, len(keys)) + for _, key := range keys { + if strings.HasPrefix(key, prefix) { + matched = append(matched, key) + } + } + if len(matched) == 0 { + return 0, nil + } + deletedMap, err := cache.DeleteMany(matched) + if err != nil { + return 0, err + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + members := make([]interface{}, 0, len(matched)) + for _, key := range matched { + members = append(members, key) + } + if err := common.RDB.ZRem(ctx, channelAffinityRedisLRUIndex, members...).Err(); err != nil { + return 0, err + } + deleted := 0 + for _, ok := range deletedMap { + if ok { + deleted++ + } + } return deleted, nil } @@ -334,18 +375,35 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf } } -func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, modelName string, usingGroup string, affinityValue string) string { +func fixedAffinityKeyPart(value string) string { + return fmt.Sprintf("%x", common.Sha256Raw([]byte(value))) +} + +func channelAffinityCredentialScope(c *gin.Context) string { + if c != nil { + if tokenID := c.GetInt("token_id"); tokenID > 0 { + return fmt.Sprintf("token:%d", tokenID) + } + if userID := c.GetInt("id"); userID > 0 { + return fmt.Sprintf("user:%d", userID) + } + } + return "anonymous" +} + +func buildChannelAffinityCacheKeySuffix(c *gin.Context, rule operation_setting.ChannelAffinityRule, modelName string, usingGroup string, affinityValue string) string { parts := make([]string, 0, 4) if rule.IncludeRuleName && rule.Name != "" { parts = append(parts, rule.Name) } if rule.IncludeModelName && modelName != "" { - parts = append(parts, modelName) + parts = append(parts, fixedAffinityKeyPart(modelName)[:16]) } if rule.IncludeUsingGroup && usingGroup != "" { - parts = append(parts, usingGroup) + parts = append(parts, fixedAffinityKeyPart(usingGroup)[:16]) } - parts = append(parts, affinityValue) + scopedValue := channelAffinityCredentialScope(c) + "\x00" + affinityValue + parts = append(parts, fixedAffinityKeyPart(scopedValue)) return strings.Join(parts, ":") } @@ -591,7 +649,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup if ttlSeconds <= 0 { ttlSeconds = setting.DefaultTTLSeconds } - cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, modelName, usingGroup, affinityValue) + cacheKeySuffix := buildChannelAffinityCacheKeySuffix(c, rule, modelName, usingGroup, affinityValue) cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix setChannelAffinityContext(c, channelAffinityMeta{ CacheKey: cacheKeyFull, @@ -734,11 +792,50 @@ func RecordChannelAffinity(c *gin.Context, channelID int) { ttlSeconds = 3600 } cache := getChannelAffinityCache() - if err := cache.SetWithTTL(cacheKey, channelID, time.Duration(ttlSeconds)*time.Second); err != nil { + if err := setChannelAffinityWithLimit(cache, cacheKey, channelID, time.Duration(ttlSeconds)*time.Second, setting.MaxEntries); err != nil { common.SysError(fmt.Sprintf("channel affinity cache set failed: key=%s, err=%v", cacheKey, err)) } } +const channelAffinityRedisSetScript = ` +redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2]) +redis.call('ZADD', KEYS[2], ARGV[3], KEYS[1]) +local max_entries = tonumber(ARGV[4]) +if max_entries and max_entries > 0 then + local count = redis.call('ZCARD', KEYS[2]) + local overflow = count - max_entries + if overflow > 0 then + local victims = redis.call('ZRANGE', KEYS[2], 0, overflow - 1) + for _, key in ipairs(victims) do + redis.call('DEL', key) + end + redis.call('ZREMRANGEBYRANK', KEYS[2], 0, overflow - 1) + end +end +return 1 +` + +func setChannelAffinityWithLimit(cache *cachex.HybridCache[int], key string, channelID int, ttl time.Duration, maxEntries int) error { + if !common.RedisEnabled || common.RDB == nil { + return cache.SetWithTTL(key, channelID, ttl) + } + if maxEntries <= 0 { + maxEntries = 100_000 + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, err := common.RDB.Eval( + ctx, + channelAffinityRedisSetScript, + []string{cache.FullKey(key), channelAffinityRedisLRUIndex}, + strconv.Itoa(channelID), + strconv.FormatInt(ttl.Milliseconds(), 10), + strconv.FormatInt(time.Now().UnixNano(), 10), + strconv.Itoa(maxEntries), + ).Result() + return err +} + type ChannelAffinityUsageCacheStats struct { RuleName string `json:"rule_name"` UsingGroup string `json:"using_group"` diff --git a/service/channel_affinity_template_test.go b/service/channel_affinity_template_test.go index fb703a24e720..ed148431f7ed 100644 --- a/service/channel_affinity_template_test.go +++ b/service/channel_affinity_template_test.go @@ -205,7 +205,7 @@ func TestGetPreferredChannelByAffinity_RequestHeaderKeySource(t *testing.T) { } affinityValue := fmt.Sprintf("header-hit-%d", time.Now().UnixNano()) - cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, "gpt-5", "default", affinityValue) + cacheKeySuffix := buildChannelAffinityCacheKeySuffix(nil, rule, "gpt-5", "default", affinityValue) cache := getChannelAffinityCache() require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9528, time.Minute)) @@ -236,6 +236,28 @@ func TestGetPreferredChannelByAffinity_RequestHeaderKeySource(t *testing.T) { require.Equal(t, buildChannelAffinityKeyHint(affinityValue), meta.KeyHint) } +func TestChannelAffinityCacheKeyIsScopedAndBounded(t *testing.T) { + rule := operation_setting.ChannelAffinityRule{ + Name: "trace-affinity", + IncludeRuleName: true, + IncludeModelName: true, + IncludeUsingGroup: true, + } + longTrace := strings.Repeat("attacker-controlled-trace-", 100) + + ctx1, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx1.Set("token_id", 101) + ctx2, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx2.Set("token_id", 202) + + key1 := buildChannelAffinityCacheKeySuffix(ctx1, rule, strings.Repeat("model", 100), "default", longTrace) + key2 := buildChannelAffinityCacheKeySuffix(ctx2, rule, strings.Repeat("model", 100), "default", longTrace) + + require.NotEqual(t, key1, key2) + require.NotContains(t, key1, longTrace) + require.Less(t, len(key1), 160) +} + func TestClearCurrentChannelAffinityCache(t *testing.T) { gin.SetMode(gin.TestMode) @@ -280,7 +302,7 @@ func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) { require.NotNil(t, codexRule) affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano()) - cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "gpt-5", "default", affinityValue) + cacheKeySuffix := buildChannelAffinityCacheKeySuffix(nil, *codexRule, "gpt-5", "default", affinityValue) cache := getChannelAffinityCache() require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute)) diff --git a/service/channel_affinity_usage_cache_test.go b/service/channel_affinity_usage_cache_test.go index 64d3d715b547..2af84f6da178 100644 --- a/service/channel_affinity_usage_cache_test.go +++ b/service/channel_affinity_usage_cache_test.go @@ -4,7 +4,6 @@ import ( "fmt" "net/http/httptest" "testing" - "time" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/types" @@ -12,7 +11,16 @@ import ( "github.com/stretchr/testify/require" ) -func buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP string) *gin.Context { +func buildChannelAffinityStatsContextForTest(t *testing.T) (*gin.Context, string, string, string) { + t.Helper() + ruleName := fmt.Sprintf("rule_%s", t.Name()) + usingGroup := "default" + keyFP := fmt.Sprintf("fp_%s", t.Name()) + entryKey := channelAffinityUsageCacheEntryKey(ruleName, usingGroup, keyFP) + t.Cleanup(func() { + _, _ = getChannelAffinityUsageCacheStatsCache().DeleteMany([]string{entryKey}) + }) + rec := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(rec) setChannelAffinityContext(ctx, channelAffinityMeta{ @@ -22,14 +30,11 @@ func buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP string) UsingGroup: usingGroup, KeyFingerprint: keyFP, }) - return ctx + return ctx, ruleName, usingGroup, keyFP } func TestObserveChannelAffinityUsageCacheByRelayFormat_ClaudeMode(t *testing.T) { - ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano()) - usingGroup := "default" - keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano()) - ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP) + ctx, ruleName, usingGroup, keyFP := buildChannelAffinityStatsContextForTest(t) usage := &dto.Usage{ PromptTokens: 100, @@ -53,10 +58,7 @@ func TestObserveChannelAffinityUsageCacheByRelayFormat_ClaudeMode(t *testing.T) } func TestObserveChannelAffinityUsageCacheByRelayFormat_MixedMode(t *testing.T) { - ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano()) - usingGroup := "default" - keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano()) - ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP) + ctx, ruleName, usingGroup, keyFP := buildChannelAffinityStatsContextForTest(t) openAIUsage := &dto.Usage{ PromptTokens: 100, @@ -83,10 +85,7 @@ func TestObserveChannelAffinityUsageCacheByRelayFormat_MixedMode(t *testing.T) { } func TestObserveChannelAffinityUsageCacheByRelayFormat_UnsupportedModeKeepsEmpty(t *testing.T) { - ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano()) - usingGroup := "default" - keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano()) - ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP) + ctx, ruleName, usingGroup, keyFP := buildChannelAffinityStatsContextForTest(t) usage := &dto.Usage{ PromptTokens: 100, diff --git a/service/channel_circuit.go b/service/channel_circuit.go new file mode 100644 index 000000000000..d3b8185f5b51 --- /dev/null +++ b/service/channel_circuit.go @@ -0,0 +1,257 @@ +package service + +import ( + "sync" + "time" + + "github.com/QuantumNous/new-api/constant" +) + +// CircuitState 熔断器状态 +type CircuitState string + +const ( + CircuitClosed CircuitState = "closed" // 正常 + CircuitOpen CircuitState = "open" // 熔断打开,不选 + CircuitHalfOpen CircuitState = "half_open" // 半开,允许探测 +) + +// ChannelCircuitBreaker 渠道熔断器(本地状态 + Redis 同步) +// 约定:请求路径上只读本地状态,不访问 Redis。 +type ChannelCircuitBreaker struct { + mu sync.RWMutex + + State CircuitState + ConsecutiveFailure int // 连续失败计数 + OpenUntil time.Time // open 状态过期时间 + HalfOpenLimit int // half-open 最大探测数 + HalfOpenInFlight int // half-open 进行中的探测数 + HalfOpenSince time.Time // when current half-open probe started + LastError string // 最近一次错误信息 + Generation uint64 // invalidates results from requests started before a transition +} + +type CircuitPermit struct { + ChannelID int + Generation uint64 + HalfOpen bool +} + +var ( + circuitBreakers sync.Map // map[int]*ChannelCircuitBreaker, key=channelID +) + +// getCircuitBreaker 获取或创建渠道熔断器 +func getCircuitBreaker(channelID int) *ChannelCircuitBreaker { + v, _ := circuitBreakers.LoadOrStore(channelID, &ChannelCircuitBreaker{ + State: CircuitClosed, + HalfOpenLimit: 1, + }) + return v.(*ChannelCircuitBreaker) +} + +// IsCircuitOpen 判断渠道是否熔断(请求路径使用,读本地状态) +func IsCircuitOpen(channelID int) bool { + if !constant.ChannelCircuitBreakerEnabled { + return false + } + + cb := getCircuitBreaker(channelID) + cb.mu.RLock() + defer cb.mu.RUnlock() + + if cb.State == CircuitClosed { + return false + } + + if cb.State == CircuitOpen && time.Now().After(cb.OpenUntil) { + // Cooldown elapsed: still report open so selector must call ProbeHalfOpen. + return true + } + + return true +} + +// IsInCooldown 判断渠道是否在 429 cooldown 中 +func IsInCooldown(channelID int, cooldownUntil time.Time) bool { + if cooldownUntil.IsZero() { + return false + } + return time.Now().Before(cooldownUntil) +} + +// RecordSuccess 成功调用 -> 重置熔断状态 +func RecordCircuitSuccess(channelID int) { + if !constant.ChannelCircuitBreakerEnabled { + return + } + + cb := getCircuitBreaker(channelID) + cb.mu.Lock() + defer cb.mu.Unlock() + + // A success without a selection-time permit may be a late result from a + // request that started before the circuit opened. It must not close an + // open/half-open circuit. + if cb.State != CircuitClosed { + return + } + cb.ConsecutiveFailure = 0 + cb.OpenUntil = time.Time{} + cb.LastError = "" +} + +func RecordCircuitSuccessWithPermit(permit CircuitPermit) { + if !constant.ChannelCircuitBreakerEnabled || permit.ChannelID <= 0 { + return + } + + cb := getCircuitBreaker(permit.ChannelID) + cb.mu.Lock() + defer cb.mu.Unlock() + + if permit.Generation != cb.Generation { + return + } + if permit.HalfOpen { + if cb.State != CircuitHalfOpen { + return + } + if cb.HalfOpenInFlight > 0 { + cb.HalfOpenInFlight-- + } + cb.State = CircuitClosed + cb.Generation++ + cb.HalfOpenSince = time.Time{} + } else if cb.State != CircuitClosed { + return + } + + cb.ConsecutiveFailure = 0 + cb.OpenUntil = time.Time{} + cb.LastError = "" +} + +// RecordFailure 失败调用 -> 可能触发熔断 +func RecordCircuitFailure(channelID int, errMsg string) { + if !constant.ChannelCircuitBreakerEnabled { + return + } + cb := getCircuitBreaker(channelID) + cb.mu.RLock() + permit := CircuitPermit{ChannelID: channelID, Generation: cb.Generation} + cb.mu.RUnlock() + RecordCircuitFailureWithPermit(permit, errMsg) +} + +func RecordCircuitFailureWithPermit(permit CircuitPermit, errMsg string) { + if !constant.ChannelCircuitBreakerEnabled || permit.ChannelID <= 0 { + return + } + + cb := getCircuitBreaker(permit.ChannelID) + cb.mu.Lock() + defer cb.mu.Unlock() + + if permit.Generation != cb.Generation { + return + } + if permit.HalfOpen { + if cb.State != CircuitHalfOpen { + return + } + if cb.HalfOpenInFlight > 0 { + cb.HalfOpenInFlight-- + } + cb.HalfOpenSince = time.Time{} + cb.State = CircuitOpen + cb.Generation++ + cb.ConsecutiveFailure++ + cb.LastError = errMsg + cb.OpenUntil = time.Now().Add(time.Duration(constant.ChannelCooldownSeconds) * time.Second) + return + } + if cb.State != CircuitClosed { + return + } + + cb.ConsecutiveFailure++ + cb.LastError = errMsg + + // closed 状态下连续失败达到阈值 -> open + threshold := 3 + if cb.ConsecutiveFailure >= threshold { + cb.State = CircuitOpen + cb.Generation++ + cb.OpenUntil = time.Now().Add(time.Duration(constant.ChannelCooldownSeconds) * time.Second) + } + + // 如果配置了熔断但未启用,不做任何事 +} + +func AcquireCircuitPermit(channelID int) (CircuitPermit, bool) { + if !constant.ChannelCircuitBreakerEnabled { + return CircuitPermit{ChannelID: channelID}, true + } + + cb := getCircuitBreaker(channelID) + cb.mu.Lock() + defer cb.mu.Unlock() + + if cb.State == CircuitClosed { + return CircuitPermit{ChannelID: channelID, Generation: cb.Generation}, true + } + if cb.State == CircuitOpen && time.Now().After(cb.OpenUntil) { + cb.State = CircuitHalfOpen + cb.HalfOpenInFlight = 0 + cb.HalfOpenSince = time.Time{} + } + if cb.State != CircuitHalfOpen { + return CircuitPermit{}, false + } + + const halfOpenProbeTimeout = 60 * time.Second + if cb.HalfOpenInFlight > 0 && !cb.HalfOpenSince.IsZero() && time.Since(cb.HalfOpenSince) > halfOpenProbeTimeout { + cb.HalfOpenInFlight = 0 + cb.HalfOpenSince = time.Time{} + } + if cb.HalfOpenInFlight >= cb.HalfOpenLimit { + return CircuitPermit{}, false + } + + cb.HalfOpenInFlight++ + cb.HalfOpenSince = time.Now() + return CircuitPermit{ChannelID: channelID, Generation: cb.Generation, HalfOpen: true}, true +} + +func ReleaseCircuitPermit(permit CircuitPermit) { + if !constant.ChannelCircuitBreakerEnabled || !permit.HalfOpen || permit.ChannelID <= 0 { + return + } + cb := getCircuitBreaker(permit.ChannelID) + cb.mu.Lock() + defer cb.mu.Unlock() + if cb.State != CircuitHalfOpen || cb.Generation != permit.Generation { + return + } + if cb.HalfOpenInFlight > 0 { + cb.HalfOpenInFlight-- + } + if cb.HalfOpenInFlight == 0 { + cb.HalfOpenSince = time.Time{} + } +} + +// ProbeHalfOpen 申请进入 half-open 探测(由选择器调用) +func ProbeHalfOpen(channelID int) bool { + _, ok := AcquireCircuitPermit(channelID) + return ok +} + +// GetCircuitState 读取熔断状态(供日志/观测使用) +func GetCircuitState(channelID int) (CircuitState, int, string) { + cb := getCircuitBreaker(channelID) + cb.mu.RLock() + defer cb.mu.RUnlock() + return cb.State, cb.ConsecutiveFailure, cb.LastError +} diff --git a/service/channel_metrics.go b/service/channel_metrics.go new file mode 100644 index 000000000000..be3bef021068 --- /dev/null +++ b/service/channel_metrics.go @@ -0,0 +1,310 @@ +package service + +import ( + "fmt" + "math" + "sync" + "sync/atomic" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" +) + +// ChannelMetrics 渠道运行时指标,按 (channelID, group, model) 分桶 +type ChannelMetrics struct { + mu sync.Mutex `json:"-"` // 保护并发写 + SuccessRate float64 `json:"success_rate"` // EWMA + ErrorRate float64 `json:"error_rate"` // EWMA + RateLimitRate float64 `json:"rate_limit_rate"` // EWMA 429 率 + Status5xxRate float64 `json:"status_5xx_rate"` // EWMA 5xx 率 + AvgLatency time.Duration `json:"avg_latency"` // EWMA 平均延迟 + SampleCount int64 `json:"sample_count"` // 总样本数 + LastSeen time.Time `json:"last_seen"` + + // 延迟直方图(轻量桶),用于近似 p95 + LatencyBuckets [6]int64 `json:"latency_buckets"` // <=500ms, <=1s, <=2s, <=5s, <=10s, >10s +} + +// LocalMetricsSnapshot 进程内本地指标快照,定期从 Redis sync 或直接从本地累加 +type LocalMetricsSnapshot struct { + mu sync.RWMutex + metrics map[metricsKey]*ChannelMetrics + updatedAt time.Time +} + +type metricsKey struct { + ChannelID int + Group string + Model string +} + +var globalSnapshot = &LocalMetricsSnapshot{ + metrics: make(map[metricsKey]*ChannelMetrics), +} + +// ensureKey 获取或创建指定 key 的指标桶 +func (s *LocalMetricsSnapshot) ensureKey(key metricsKey) *ChannelMetrics { + s.mu.Lock() + defer s.mu.Unlock() + m, ok := s.metrics[key] + if !ok { + m = &ChannelMetrics{ + SuccessRate: 1.0, // 冷启动默认信任 + AvgLatency: 500 * time.Millisecond, + } + s.metrics[key] = m + } + return m +} + +// EwmaUpdate 更新指标的 EWMA 值 +func EwmaUpdate(current, observed, alpha float64) float64 { + if alpha <= 0 || alpha > 1 { + alpha = constant.EwmaAlpha + } + return alpha*observed + (1-alpha)*current +} + +// ObserveSuccess 记录一次成功调用 +func ObserveSuccess(channelID int, group, model string, latency time.Duration) { + alpha := constant.EwmaAlpha + key := metricsKey{channelID, group, model} + m := globalSnapshot.ensureKey(key) + + m.mu.Lock() + defer m.mu.Unlock() + + m.SampleCount++ + m.LastSeen = time.Now() + + // 更新延迟 EWMA + if m.AvgLatency == 0 { + m.AvgLatency = latency + } else { + m.AvgLatency = time.Duration(EwmaUpdate(float64(m.AvgLatency), float64(latency), alpha)) + } + + // 更新延迟桶 + bucket := latencyBucket(latency) + if bucket >= 0 && bucket < len(m.LatencyBuckets) { + m.LatencyBuckets[bucket]++ + } + + // 更新成功率 EWMA + m.SuccessRate = EwmaUpdate(m.SuccessRate, 1.0, alpha) + m.ErrorRate = EwmaUpdate(m.ErrorRate, 0, alpha) + m.RateLimitRate = EwmaUpdate(m.RateLimitRate, 0, alpha) + m.Status5xxRate = EwmaUpdate(m.Status5xxRate, 0, alpha) +} + +// ObserveFailure 记录一次失败 +func ObserveFailure(channelID int, group, model string, statusCode int, latency time.Duration) { + alpha := constant.EwmaAlpha + key := metricsKey{channelID, group, model} + m := globalSnapshot.ensureKey(key) + + m.mu.Lock() + defer m.mu.Unlock() + + m.SampleCount++ + m.LastSeen = time.Now() + + // 更新延迟 + if m.AvgLatency == 0 { + m.AvgLatency = latency + } else { + m.AvgLatency = time.Duration(EwmaUpdate(float64(m.AvgLatency), float64(latency), alpha)) + } + + m.SuccessRate = EwmaUpdate(m.SuccessRate, 0, alpha) + m.ErrorRate = EwmaUpdate(m.ErrorRate, 1, alpha) + + switch { + case statusCode == 429: + m.RateLimitRate = EwmaUpdate(m.RateLimitRate, 1, alpha) + m.Status5xxRate = EwmaUpdate(m.Status5xxRate, 0, alpha) + case statusCode >= 500: + m.Status5xxRate = EwmaUpdate(m.Status5xxRate, 1, alpha) + m.RateLimitRate = EwmaUpdate(m.RateLimitRate, 0, alpha) + default: + m.RateLimitRate = EwmaUpdate(m.RateLimitRate, 0, alpha) + m.Status5xxRate = EwmaUpdate(m.Status5xxRate, 0, alpha) + } +} + +// GetMetrics 读取指定 (channelID, group, model) 的指标快照 +func GetMetrics(channelID int, group, model string) *ChannelMetrics { + key := metricsKey{channelID, group, model} + globalSnapshot.mu.RLock() + m, ok := globalSnapshot.metrics[key] + globalSnapshot.mu.RUnlock() + if ok { + return snapshotChannelMetrics(m) + } + + // 尝试回退到 (channelID, group) + key2 := metricsKey{channelID, group, ""} + globalSnapshot.mu.RLock() + m2, ok2 := globalSnapshot.metrics[key2] + globalSnapshot.mu.RUnlock() + if ok2 { + return snapshotChannelMetrics(m2) + } + + // 回退到 (channelID) + key3 := metricsKey{channelID, "", ""} + globalSnapshot.mu.RLock() + m3, ok3 := globalSnapshot.metrics[key3] + globalSnapshot.mu.RUnlock() + if ok3 { + return snapshotChannelMetrics(m3) + } + + // 无数据,返回中性默认值 + return &ChannelMetrics{ + SuccessRate: 1.0, + AvgLatency: 500 * time.Millisecond, + } +} + +func snapshotChannelMetrics(m *ChannelMetrics) *ChannelMetrics { + if m == nil { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + return &ChannelMetrics{ + SuccessRate: m.SuccessRate, + ErrorRate: m.ErrorRate, + RateLimitRate: m.RateLimitRate, + Status5xxRate: m.Status5xxRate, + AvgLatency: m.AvgLatency, + SampleCount: m.SampleCount, + LastSeen: m.LastSeen, + LatencyBuckets: m.LatencyBuckets, + } +} + +// GetP95Latency 从桶近似计算 p95 延迟 +func GetP95Latency(buckets [6]int64) time.Duration { + var total int64 + for _, v := range buckets { + total += v + } + if total == 0 { + return 0 + } + + target := int64(math.Ceil(float64(total) * 0.95)) + var cumulative int64 + + bucketBoundaries := []time.Duration{ + 500 * time.Millisecond, + 1 * time.Second, + 2 * time.Second, + 5 * time.Second, + 10 * time.Second, + math.MaxInt64, + } + + for i, count := range buckets { + cumulative += count + if cumulative >= target { + return bucketBoundaries[i] + } + } + return 10 * time.Second +} + +// CurrentConcurrencyTracker 本地并发计数器(原子操作,零网络开销) +type CurrentConcurrencyTracker struct { + counters sync.Map // map[int]*atomic.Int64 +} + +var globalConcurrency = &CurrentConcurrencyTracker{} + +func (t *CurrentConcurrencyTracker) Inc(channelID int) int64 { + v, _ := t.counters.LoadOrStore(channelID, new(atomic.Int64)) + return v.(*atomic.Int64).Add(1) +} + +func (t *CurrentConcurrencyTracker) Dec(channelID int) int64 { + v, ok := t.counters.Load(channelID) + if !ok { + return 0 + } + return v.(*atomic.Int64).Add(-1) +} + +func (t *CurrentConcurrencyTracker) Get(channelID int) int64 { + v, ok := t.counters.Load(channelID) + if !ok { + return 0 + } + return v.(*atomic.Int64).Load() +} + +// IncChannelConcurrency 增加并发计数 +func IncChannelConcurrency(channelID int) int64 { + return globalConcurrency.Inc(channelID) +} + +// DecChannelConcurrency 减少并发计数 +func DecChannelConcurrency(channelID int) int64 { + return globalConcurrency.Dec(channelID) +} + +// GetChannelConcurrency 获取当前并发 +func GetChannelConcurrency(channelID int) int64 { + return globalConcurrency.Get(channelID) +} + +func latencyBucket(latency time.Duration) int { + switch { + case latency <= 500*time.Millisecond: + return 0 + case latency <= 1*time.Second: + return 1 + case latency <= 2*time.Second: + return 2 + case latency <= 5*time.Second: + return 3 + case latency <= 10*time.Second: + return 4 + default: + return 5 + } +} + +// SyncAdaptiveMetricsToRedis publishes a compact snapshot for multi-instance +// sticky-or-shared observation. Best-effort; failures are silent. +func SyncAdaptiveMetricsToRedis() { + if !common.RedisEnabled || common.RDB == nil { + return + } + globalSnapshot.mu.RLock() + defer globalSnapshot.mu.RUnlock() + type row struct { + ChannelID int `json:"c"` + Group string `json:"g"` + Model string `json:"m"` + SuccessRate float64 `json:"s"` + SampleCount int64 `json:"n"` + } + out := make([]row, 0, len(globalSnapshot.metrics)) + for k, m := range globalSnapshot.metrics { + m.mu.Lock() + out = append(out, row{k.ChannelID, k.Group, k.Model, m.SuccessRate, m.SampleCount}) + m.mu.Unlock() + } + b, err := common.Marshal(out) + if err != nil { + return + } + key := fmt.Sprintf("newapi:adaptive:metrics:%s", common.NodeName) + if key == "newapi:adaptive:metrics:" { + key = "newapi:adaptive:metrics:default" + } + _ = common.RedisSet(key, string(b), 2*time.Minute) +} diff --git a/service/channel_score.go b/service/channel_score.go new file mode 100644 index 000000000000..2e7b88219e65 --- /dev/null +++ b/service/channel_score.go @@ -0,0 +1,165 @@ +package service + +import ( + "github.com/QuantumNous/new-api/constant" + "math" + "math/rand" + "sort" + "time" + + "github.com/QuantumNous/new-api/model" +) + +// CandidateScore 评分候选结果 +type CandidateScore struct { + Channel *model.Channel + Score float64 + + // 各因子明细(供日志/观测用) + BaseWeight float64 `json:"base_weight"` + SuccessFactor float64 `json:"success_factor"` + LatencyFactor float64 `json:"latency_factor"` + RateLimitFactor float64 `json:"rate_limit_factor"` + ConcurrencyFactor float64 `json:"concurrency_factor"` + CircuitFactor float64 `json:"circuit_factor"` + AffinityFactor float64 `json:"affinity_factor"` +} + +// ScoreCandidates 对一组渠道进行动态评分,返回排序后的候选列表 +// 不传 group, model 时会回退到 channel 级别指标 +func ScoreCandidates(channels []*model.Channel, group, model string, preferredChannelID int) []CandidateScore { + if len(channels) == 0 { + return nil + } + + candidates := make([]CandidateScore, 0, len(channels)) + + for _, ch := range channels { + circuitFactor := 1.0 + if constant.ChannelCircuitBreakerEnabled { + if IsCircuitOpen(ch.Id) { + circuitFactor = 0.0 + } + state, _, _ := GetCircuitState(ch.Id) + if state == CircuitHalfOpen { + circuitFactor = 0.5 + } + } + + candidates = append(candidates, scoreCandidate(ch, group, model, preferredChannelID, circuitFactor)) + } + + // 按分数降序排序 + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].Score > candidates[j].Score + }) + + return candidates +} + +func scoreCandidate(ch *model.Channel, group, model string, preferredChannelID int, circuitFactor float64) CandidateScore { + metrics := GetMetrics(ch.Id, group, model) + baseWeight := float64(ch.GetWeight()) + if baseWeight <= 0 { + baseWeight = 1.0 + } + successFactor := math.Pow(metrics.SuccessRate, 2) + latencyMs := float64(metrics.AvgLatency) / float64(time.Millisecond) + latencyFactor := latencyToScore(latencyMs) + rateLimitFactor := math.Max(0, 1.0-metrics.RateLimitRate) + + currentConcurrency := GetChannelConcurrency(ch.Id) + maxConcurrency := int64(constant.MaxChannelConcurrency) + concurrencyFactor := 1.0 + if maxConcurrency > 0 && currentConcurrency >= maxConcurrency { + concurrencyFactor = 0.1 + } else if maxConcurrency > 0 { + concurrencyFactor = 1.0 - float64(currentConcurrency)/float64(maxConcurrency)*0.5 + } + + affinityFactor := 1.0 + if preferredChannelID > 0 && ch.Id == preferredChannelID { + affinityFactor = 1.5 + } + score := baseWeight * successFactor * latencyFactor * rateLimitFactor * + concurrencyFactor * circuitFactor * affinityFactor + score *= 0.95 + rand.Float64()*0.1 + + return CandidateScore{ + Channel: ch, + Score: score, + BaseWeight: baseWeight, + SuccessFactor: successFactor, + LatencyFactor: latencyFactor, + RateLimitFactor: rateLimitFactor, + ConcurrencyFactor: concurrencyFactor, + CircuitFactor: circuitFactor, + AffinityFactor: affinityFactor, + } +} + +// SelectTopKWeighted 从候选列表中取 topK 然后按 score 加权随机选一个 +func SelectTopKWeighted(candidates []CandidateScore, k int) *CandidateScore { + if len(candidates) == 0 { + return nil + } + + if len(candidates) == 1 { + return &candidates[0] + } + + // 取 topK + if k <= 0 { + k = 3 + } + if k > len(candidates) { + k = len(candidates) + } + top := candidates[:k] + + // 加权随机 + var totalWeight float64 + for _, c := range top { + if c.Score > 0 { + totalWeight += c.Score + } + } + + if totalWeight <= 0 { + // 所有分数为 0,均匀随机 + idx := rand.Intn(len(top)) + return &top[idx] + } + + r := rand.Float64() * totalWeight + var cumulative float64 + for i, c := range top { + cumulative += c.Score + if r < cumulative { + return &top[i] + } + } + + return &top[len(top)-1] +} + +// latencyToScore 将延迟(毫秒)映射到 [0, 1] 分数 +// 500ms → 1.0, 1s → 0.8, 2s → 0.5, 5s → 0.2, 10s+ → 0.05 +func latencyToScore(ms float64) float64 { + switch { + case ms <= 0: + return 1.0 + case ms <= 500: + return 1.0 + case ms <= 1000: + return 0.8 + case ms <= 2000: + return 0.5 + case ms <= 5000: + return 0.2 + case ms <= 10000: + return 0.1 + default: + return 0.05 + } +} diff --git a/service/channel_select.go b/service/channel_select.go index 24c4e252bfb3..4de77641e314 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -82,6 +82,18 @@ func (p *RetryParam) ResetRetryNextTry() { // Retry=3: GroupB, priority1 (startRetryIndex=2, priorityRetry=1) // 分组B, 优先级1 func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, error) { + // Adaptive entry. AdaptiveSelectChannel must only call + // cacheGetRandomSatisfiedChannelLegacy — never this function — or flags + // cause infinite recursion / stack overflow. + if constant.AdaptiveBalanceEnabled || constant.AdaptiveBalanceShadowMode { + return AdaptiveSelectChannel(param) + } + return cacheGetRandomSatisfiedChannelLegacy(param) +} + +// cacheGetRandomSatisfiedChannelLegacy is the original random / auto-group picker. +// Safe to call from adaptive fallbacks and candidate collection. +func cacheGetRandomSatisfiedChannelLegacy(param *RetryParam) (*model.Channel, string, error) { var channel *model.Channel var err error selectGroup := param.TokenGroup @@ -116,7 +128,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, } logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry) - channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry, param.RequestPath) + channel, _ = model.GetRandomSatisfiedChannelExcluding(autoGroup, param.ModelName, priorityRetry, param.RequestPath, adaptiveUsedChannelSet(param.Ctx)) if channel == nil { // Current group has no available channel for this model, try next group // 当前分组没有该模型的可用渠道,尝试下一个分组 @@ -154,7 +166,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, break } } else { - channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry(), param.RequestPath) + channel, err = model.GetRandomSatisfiedChannelExcluding(param.TokenGroup, param.ModelName, param.GetRetry(), param.RequestPath, adaptiveUsedChannelSet(param.Ctx)) if err != nil { return nil, param.TokenGroup, err } diff --git a/service/http_client.go b/service/http_client.go index dd36db6c0fb5..b23510b5f992 100644 --- a/service/http_client.go +++ b/service/http_client.go @@ -54,30 +54,17 @@ func ValidateSSRFProtectedFetchURL(urlStr string) error { } func InitHttpClient() { - transport := &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second, - ForceAttemptHTTP2: true, - Proxy: http.ProxyFromEnvironment, // Support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars - } - if common.TLSInsecureSkipVerify { - transport.TLSClientConfig = common.InsecureTLSConfig - } + transport := common.NewOutboundHTTPTransport(http.ProxyFromEnvironment, nil) + httpClient = newOutboundHTTPClient(transport, checkRedirect) + ssrfProtectedHTTPClient = newProtectedFetchHTTPClient() +} - if common.RelayTimeout == 0 { - httpClient = &http.Client{ - Transport: transport, - CheckRedirect: checkRedirect, - } - } else { - httpClient = &http.Client{ - Transport: transport, - Timeout: time.Duration(common.RelayTimeout) * time.Second, - CheckRedirect: checkRedirect, - } +func newOutboundHTTPClient(transport http.RoundTripper, redirect func(*http.Request, []*http.Request) error) *http.Client { + client := &http.Client{Transport: transport, CheckRedirect: redirect} + if common.RelayTimeout > 0 { + client.Timeout = time.Duration(common.RelayTimeout) * time.Second } - ssrfProtectedHTTPClient = newProtectedFetchHTTPClient() + return client } // GetHttpClient returns the general outbound client used by relay/provider @@ -91,6 +78,16 @@ func GetHttpClient() *http.Client { return httpClient } +func GetHttpClientWithTimeout(timeout time.Duration) *http.Client { + base := GetHttpClient() + if base == nil { + return &http.Client{Timeout: timeout} + } + client := *base + client.Timeout = timeout + return &client +} + // GetSSRFProtectedHTTPClient 返回带拨号时 SSRF 校验的客户端。 // ssrfProtectedHTTPClient 由 InitHttpClient 在启动时初始化,运行期只读。 func GetSSRFProtectedHTTPClient() *http.Client { @@ -100,6 +97,16 @@ func GetSSRFProtectedHTTPClient() *http.Client { return ssrfProtectedHTTPClient } +func GetSSRFProtectedHTTPClientWithTimeout(timeout time.Duration) *http.Client { + base := GetSSRFProtectedHTTPClient() + if base == nil { + return &http.Client{Timeout: timeout} + } + client := *base + client.Timeout = timeout + return &client +} + // GetHttpClientWithProxy returns the default client or a proxy-enabled one when proxyURL is provided. func GetHttpClientWithProxy(proxyURL string) (*http.Client, error) { if proxyURL == "" { @@ -143,21 +150,8 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) { switch parsedURL.Scheme { case "http", "https": - transport := &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second, - ForceAttemptHTTP2: true, - Proxy: http.ProxyURL(parsedURL), - } - if common.TLSInsecureSkipVerify { - transport.TLSClientConfig = common.InsecureTLSConfig - } - client := &http.Client{ - Transport: transport, - CheckRedirect: checkRedirect, - } - client.Timeout = time.Duration(common.RelayTimeout) * time.Second + transport := common.NewOutboundHTTPTransport(http.ProxyURL(parsedURL), nil) + client := newOutboundHTTPClient(transport, checkRedirect) proxyClientLock.Lock() proxyClients[proxyURL] = client proxyClientLock.Unlock() @@ -183,21 +177,14 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) { return nil, err } - transport := &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second, - ForceAttemptHTTP2: true, - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - return dialer.Dial(network, addr) - }, - } - if common.TLSInsecureSkipVerify { - transport.TLSClientConfig = common.InsecureTLSConfig + dialContext := func(ctx context.Context, network, addr string) (net.Conn, error) { + if contextDialer, ok := dialer.(proxy.ContextDialer); ok { + return contextDialer.DialContext(ctx, network, addr) + } + return dialer.Dial(network, addr) } - - client := &http.Client{Transport: transport, CheckRedirect: checkRedirect} - client.Timeout = time.Duration(common.RelayTimeout) * time.Second + transport := common.NewOutboundHTTPTransport(nil, dialContext) + client := newOutboundHTTPClient(transport, checkRedirect) proxyClientLock.Lock() proxyClients[proxyURL] = client proxyClientLock.Unlock() diff --git a/service/protected_fetch_client.go b/service/protected_fetch_client.go index 9d1d4cc87871..50b7dd4be97b 100644 --- a/service/protected_fetch_client.go +++ b/service/protected_fetch_client.go @@ -69,7 +69,7 @@ func newProtectedFetchHTTPClientWithProxy(resolver ssrfResolver, dialContext fun } if dialContext == nil { netDialer := &net.Dialer{ - Timeout: 30 * time.Second, + Timeout: time.Duration(common.RelayDialTimeout) * time.Second, KeepAlive: 30 * time.Second, } dialContext = netDialer.DialContext @@ -153,18 +153,7 @@ func (t *ssrfProtectedRoundTripper) newTransport(proxyURL *url.URL) *http.Transp proxyFunc = nil } - transport := &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second, - ForceAttemptHTTP2: true, - Proxy: proxyFunc, - DialContext: dialContext, - } - if common.TLSInsecureSkipVerify { - transport.TLSClientConfig = common.InsecureTLSConfig - } - return transport + return common.NewOutboundHTTPTransport(proxyFunc, dialContext) } func (d *protectedFetchDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { diff --git a/service/system_task.go b/service/system_task.go index b7182aef1f31..3a113d593a9c 100644 --- a/service/system_task.go +++ b/service/system_task.go @@ -104,7 +104,9 @@ type LogCleanupResult struct { } var ( - systemTaskRunnerOnce sync.Once + systemTaskWorkerOnce sync.Once + systemTaskSchedulerOnce sync.Once + systemTaskWG sync.WaitGroup // systemTaskWakeup signals the runner to check for runnable tasks // immediately instead of waiting for the idle poll. Buffered so a signal // raised while the runner is busy is not lost and is handled on the next loop. @@ -121,50 +123,122 @@ func notifySystemTaskRunner() { } func StartSystemTaskRunner() { - systemTaskRunnerOnce.Do(func() { + StartSystemTaskRunnerContext(context.Background()) +} + +func StartSystemTaskRunnerContext(ctx context.Context) { + StartSystemTaskSchedulerContext(ctx) + StartSystemTaskWorkerContext(ctx) +} + +func StartSystemTaskWorker() { + StartSystemTaskWorkerContext(context.Background()) +} + +func StartSystemTaskWorkerContext(ctx context.Context) { + if ctx == nil { + ctx = context.Background() + } + systemTaskWorkerOnce.Do(func() { if !common.IsMasterNode { return } runnerID := fmt.Sprintf("%s-%s", common.NodeName, common.GetRandomString(8)) + systemTaskWG.Add(1) gopool.Go(func() { - logger.LogInfo(context.Background(), fmt.Sprintf("system task runner started: runner=%s idle_interval=%s", runnerID, systemTaskRunnerIdleInterval)) - - ticker := time.NewTicker(systemTaskRunnerIdleInterval) - defer ticker.Stop() - - var lastScheduler time.Time - var lastStaleLockCleanup time.Time - runPass := func() { - // The scheduler/stale-lock pass is throttled independently of the - // claim pass: wakeups (e.g. a manual log cleanup) should claim - // immediately without re-running the scheduler every time. - now := time.Now() - if now.Sub(lastStaleLockCleanup) >= systemTaskStaleLockInterval { - lastStaleLockCleanup = now - if err := model.ExpireStaleSystemTaskLocks(common.GetTimestamp()); err != nil { - logger.LogWarn(context.Background(), fmt.Sprintf("system task stale lock cleanup failed: %v", err)) - } - } - if now.Sub(lastScheduler) >= systemTaskSchedulerInterval { - lastScheduler = now - runSystemTaskScheduler() - } - runSystemTaskClaimPass(runnerID) - } + defer systemTaskWG.Done() + runSystemTaskWorkerLoop(ctx, runnerID) + }) + }) +} - runPass() - for { - select { - case <-ticker.C: - case <-systemTaskWakeup: - } - runPass() - } +func StartSystemTaskScheduler() { + StartSystemTaskSchedulerContext(context.Background()) +} + +func StartSystemTaskSchedulerContext(ctx context.Context) { + if ctx == nil { + ctx = context.Background() + } + systemTaskSchedulerOnce.Do(func() { + if !common.IsMasterNode { + return + } + systemTaskWG.Add(1) + gopool.Go(func() { + defer systemTaskWG.Done() + runSystemTaskSchedulerLoop(ctx) }) }) } +func runSystemTaskWorkerLoop(ctx context.Context, runnerID string) { + logger.LogInfo(ctx, fmt.Sprintf("system task runner started: runner=%s idle_interval=%s", runnerID, systemTaskRunnerIdleInterval)) + ticker := time.NewTicker(systemTaskRunnerIdleInterval) + defer ticker.Stop() + + select { + case <-ctx.Done(): + return + default: + } + runSystemTaskClaimPassContext(ctx, runnerID) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + case <-systemTaskWakeup: + } + runSystemTaskClaimPassContext(ctx, runnerID) + } +} + +func runSystemTaskSchedulerLoop(ctx context.Context) { + logger.LogInfo(ctx, fmt.Sprintf("system task scheduler started: interval=%s", systemTaskSchedulerInterval)) + ticker := time.NewTicker(systemTaskSchedulerInterval) + defer ticker.Stop() + runPass := func() { + if err := model.ExpireStaleSystemTaskLocks(common.GetTimestamp()); err != nil { + logger.LogWarn(ctx, fmt.Sprintf("system task stale lock cleanup failed: %v", err)) + } + runSystemTaskScheduler() + } + + select { + case <-ctx.Done(): + return + default: + } + runPass() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + runPass() + } + } +} + +func WaitForSystemTasks(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + done := make(chan struct{}) + go func() { + systemTaskWG.Wait() + close(done) + }() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + func StartLogCleanupTask(targetTimestamp int64) (*model.SystemTask, error) { if targetTimestamp <= 0 { return nil, errors.New("target timestamp is required") @@ -223,6 +297,16 @@ func EnqueueSystemTask(taskType string, payload any) (*model.SystemTask, bool, e // and dispatches each claimed task in its own goroutine so a long-running // handler (e.g. channel test) never blocks another type (e.g. log cleanup). func runSystemTaskClaimPass(runnerID string) { + runSystemTaskClaimPassContext(context.Background(), runnerID) +} + +func runSystemTaskClaimPassContext(ctx context.Context, runnerID string) { + if ctx == nil { + ctx = context.Background() + } + if ctx.Err() != nil { + return + } handlers := registeredSystemTaskHandlers() taskTypes := make([]string, 0, len(handlers)) for _, handler := range handlers { @@ -234,6 +318,9 @@ func runSystemTaskClaimPass(runnerID string) { return } for _, handler := range handlers { + if ctx.Err() != nil { + return + } task := pendingTasks[handler.Type()] if task == nil { continue @@ -248,9 +335,11 @@ func runSystemTaskClaimPass(runnerID string) { } dispatchHandler := handler dispatchTask := claimedTask + systemTaskWG.Add(1) gopool.Go(func() { - runWithLeaseHeartbeat(dispatchTask, runnerID, func(ctx context.Context) { - dispatchHandler.Run(ctx, dispatchTask, runnerID) + defer systemTaskWG.Done() + runWithLeaseHeartbeat(ctx, dispatchTask, runnerID, func(handlerCtx context.Context) { + dispatchHandler.Run(handlerCtx, dispatchTask, runnerID) }) }) } @@ -305,8 +394,11 @@ func runSystemTaskScheduler() { // runWithLeaseHeartbeat renews the per-type lock on a background ticker while // fn runs. The TTL is a crash-detection window, not a task time limit: an // arbitrarily long handler stays alive as long as the heartbeat succeeds. -func runWithLeaseHeartbeat(task *model.SystemTask, runnerID string, fn func(ctx context.Context)) { - ctx, cancel := context.WithCancel(context.Background()) +func runWithLeaseHeartbeat(parent context.Context, task *model.SystemTask, runnerID string, fn func(ctx context.Context)) { + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithCancel(parent) defer cancel() interval := systemTaskLockTTL / 3 diff --git a/service/system_task_test.go b/service/system_task_test.go index baaf9142eb11..3ce92fe2fd28 100644 --- a/service/system_task_test.go +++ b/service/system_task_test.go @@ -107,6 +107,69 @@ func TestSystemTaskSchedulerSkipsDisabled(t *testing.T) { assert.Equal(t, int64(0), countSystemTasks(t, handler.taskType)) } +func TestSystemTaskLoopsStopWhenContextCanceled(t *testing.T) { + truncate(t) + withSystemTaskRegistry(t) + + ctx, cancel := context.WithCancel(context.Background()) + workerDone := make(chan struct{}) + schedulerDone := make(chan struct{}) + go func() { + runSystemTaskWorkerLoop(ctx, "runner-cancel") + close(workerDone) + }() + go func() { + runSystemTaskSchedulerLoop(ctx) + close(schedulerDone) + }() + + cancel() + for name, done := range map[string]<-chan struct{}{ + "worker": workerDone, + "scheduler": schedulerDone, + } { + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("%s did not stop after context cancellation", name) + } + } +} + +func TestSystemTaskClaimPassContextCancelsDispatchedHandler(t *testing.T) { + truncate(t) + + started := make(chan struct{}) + canceled := make(chan error, 1) + handler := &stubScheduledHandler{ + taskType: "test_context_cancel", + onRun: func(ctx context.Context, _ *model.SystemTask, _ string) { + close(started) + <-ctx.Done() + canceled <- ctx.Err() + }, + } + withSystemTaskRegistry(t, handler) + _, err := model.CreateSystemTask(handler.taskType, nil, nil) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + runSystemTaskClaimPassContext(ctx, "runner-cancel") + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("claimed task handler did not start") + } + cancel() + + select { + case err := <-canceled: + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(2 * time.Second): + t.Fatal("claimed task handler did not receive context cancellation") + } +} + func TestSystemTaskClaimPassDispatchesByType(t *testing.T) { truncate(t) diff --git a/setting/operation_setting/channel_affinity_setting.go b/setting/operation_setting/channel_affinity_setting.go index a925a847d6dd..fef0c7f22d6a 100644 --- a/setting/operation_setting/channel_affinity_setting.go +++ b/setting/operation_setting/channel_affinity_setting.go @@ -116,6 +116,23 @@ var channelAffinitySetting = ChannelAffinitySetting{ MaxEntries: 100_000, DefaultTTLSeconds: 3600, Rules: []ChannelAffinityRule{ + { + Name: "axonhub trace sticky", + ModelRegex: []string{".*"}, + PathRegex: []string{"/v1/.*"}, + // Only client-provided traces (middleware sets affinity_trace_id). + KeySources: []ChannelAffinityKeySource{ + {Type: "context_string", Key: "affinity_trace_id"}, + {Type: "request_header", Key: "AH-Trace-Id"}, + {Type: "request_header", Key: "X-Trace-Id"}, + }, + ValueRegex: "", + TTLSeconds: 1800, + SkipRetryOnFailure: false, + IncludeUsingGroup: true, + IncludeModelName: true, + IncludeRuleName: true, + }, { Name: "codex cli trace", ModelRegex: []string{"^gpt-.*$"}, diff --git a/setting/perf_metrics_setting/config.go b/setting/perf_metrics_setting/config.go index fb7780e53b4c..bc887b9560be 100644 --- a/setting/perf_metrics_setting/config.go +++ b/setting/perf_metrics_setting/config.go @@ -13,7 +13,9 @@ var perfMetricsSetting = PerfMetricsSetting{ Enabled: true, FlushInterval: 5, BucketTime: "hour", - RetentionDays: 0, + // Default 1 day — SQLite probe traffic grows quickly; ops can raise via UI. + // 0 still means "keep forever" when explicitly set. + RetentionDays: 1, } func init() { diff --git a/setting/ratio_setting/group_ratio.go b/setting/ratio_setting/group_ratio.go index 7d16d9283932..5e06542e4119 100644 --- a/setting/ratio_setting/group_ratio.go +++ b/setting/ratio_setting/group_ratio.go @@ -1,8 +1,8 @@ package ratio_setting import ( - "encoding/json" "errors" + "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/setting/config" @@ -73,7 +73,37 @@ func GroupRatio2JSONString() string { } func UpdateGroupRatioByJSONString(jsonStr string) error { - return types.LoadFromJsonString(groupRatioMap, jsonStr) + // Empty object would wipe defaults and break pricing + perf-metrics group + // filters (summary only returns groups present in this map). Keep defaults. + trimmed := strings.TrimSpace(jsonStr) + if trimmed == "" || trimmed == "{}" || trimmed == "null" { + groupRatioMap.Clear() + groupRatioMap.AddAll(defaultGroupRatio) + return nil + } + tmp := make(map[string]float64) + if err := common.Unmarshal([]byte(trimmed), &tmp); err != nil { + return err + } + if len(tmp) == 0 { + groupRatioMap.Clear() + groupRatioMap.AddAll(defaultGroupRatio) + return nil + } + // Reject negative ratios before load (same rule as CheckGroupRatio). + for name, ratio := range tmp { + if ratio < 0 { + return errors.New("group ratio must be not less than 0: " + name) + } + } + if err := types.LoadFromJsonString(groupRatioMap, trimmed); err != nil { + return err + } + // Always keep a usable default group so pricing/perf never filter to empty. + if _, ok := groupRatioMap.Get("default"); !ok { + groupRatioMap.Set("default", 1) + } + return nil } func GetGroupRatio(name string) float64 { @@ -102,12 +132,36 @@ func GroupGroupRatio2JSONString() string { } func UpdateGroupGroupRatioByJSONString(jsonStr string) error { - return types.LoadFromJsonString(groupGroupRatioMap, jsonStr) + // Empty / null should not wipe nested group-group overrides to a broken state. + // Unlike GroupRatio, empty here means "no nested overrides" (valid), but still + // reject null-ish wipe of malformed payloads and negative ratios. + trimmed := strings.TrimSpace(jsonStr) + if trimmed == "" || trimmed == "null" { + groupGroupRatioMap.Clear() + return nil + } + tmp := make(map[string]map[string]float64) + if err := common.Unmarshal([]byte(trimmed), &tmp); err != nil { + return err + } + for userGroup, nested := range tmp { + for usingGroup, ratio := range nested { + if ratio < 0 { + return errors.New("group_group_ratio must be not less than 0: " + userGroup + " -> " + usingGroup) + } + } + } + return types.LoadFromJsonString(groupGroupRatioMap, trimmed) } func CheckGroupRatio(jsonStr string) error { + trimmed := strings.TrimSpace(jsonStr) + if trimmed == "" || trimmed == "{}" || trimmed == "null" { + // Empty is accepted; UpdateGroupRatioByJSONString restores defaults. + return nil + } checkGroupRatio := make(map[string]float64) - err := json.Unmarshal([]byte(jsonStr), &checkGroupRatio) + err := common.Unmarshal([]byte(trimmed), &checkGroupRatio) if err != nil { return err } @@ -118,3 +172,23 @@ func CheckGroupRatio(jsonStr string) error { } return nil } + +// CheckGroupGroupRatio validates nested user→using group ratio maps. +func CheckGroupGroupRatio(jsonStr string) error { + trimmed := strings.TrimSpace(jsonStr) + if trimmed == "" || trimmed == "{}" || trimmed == "null" { + return nil + } + check := make(map[string]map[string]float64) + if err := common.Unmarshal([]byte(trimmed), &check); err != nil { + return err + } + for userGroup, nested := range check { + for usingGroup, ratio := range nested { + if ratio < 0 { + return errors.New("group_group_ratio must be not less than 0: " + userGroup + " -> " + usingGroup) + } + } + } + return nil +} diff --git a/setting/ratio_setting/group_ratio_empty_test.go b/setting/ratio_setting/group_ratio_empty_test.go new file mode 100644 index 000000000000..578b544f28d0 --- /dev/null +++ b/setting/ratio_setting/group_ratio_empty_test.go @@ -0,0 +1,84 @@ +package ratio_setting + +import ( + "testing" +) + +func TestUpdateGroupRatioByJSONStringKeepsDefaultsOnEmpty(t *testing.T) { + // Ensure defaults are present first. + if err := UpdateGroupRatioByJSONString(`{"default":1,"vip":1,"svip":1}`); err != nil { + t.Fatalf("seed defaults: %v", err) + } + if err := UpdateGroupRatioByJSONString(`{}`); err != nil { + t.Fatalf("empty update: %v", err) + } + got := GetGroupRatioCopy() + if len(got) == 0 { + t.Fatalf("expected defaults after empty update, got empty map") + } + if _, ok := got["default"]; !ok { + t.Fatalf("expected default group after empty update, got %#v", got) + } +} + +func TestUpdateGroupRatioByJSONStringAcceptsCustom(t *testing.T) { + if err := UpdateGroupRatioByJSONString(`{"default":1,"pro":1.2}`); err != nil { + t.Fatalf("custom update: %v", err) + } + got := GetGroupRatioCopy() + if got["pro"] != 1.2 { + t.Fatalf("expected pro=1.2, got %#v", got) + } + if _, ok := got["default"]; !ok { + t.Fatalf("expected default retained/set, got %#v", got) + } +} + +func TestUpdateGroupRatioInjectsDefaultWhenMissing(t *testing.T) { + if err := UpdateGroupRatioByJSONString(`{"pro":1.5}`); err != nil { + t.Fatalf("update without default: %v", err) + } + got := GetGroupRatioCopy() + if _, ok := got["default"]; !ok { + t.Fatalf("expected default injected, got %#v", got) + } + if got["pro"] != 1.5 { + t.Fatalf("pro lost: %#v", got) + } +} + +func TestUpdateGroupRatioRejectsNegative(t *testing.T) { + if err := UpdateGroupRatioByJSONString(`{"default":-1}`); err == nil { + t.Fatal("expected error for negative ratio") + } +} + +func TestUpdateGroupGroupRatioEmptyClears(t *testing.T) { + if err := UpdateGroupGroupRatioByJSONString(`{"vip":{"default":0.9}}`); err != nil { + t.Fatalf("seed nested: %v", err) + } + if err := UpdateGroupGroupRatioByJSONString(`{}`); err != nil { + t.Fatalf("empty nested: %v", err) + } + if r, ok := GetGroupGroupRatio("vip", "default"); ok { + t.Fatalf("expected cleared nested map, still got %v", r) + } +} + +func TestUpdateGroupGroupRatioRejectsNegative(t *testing.T) { + if err := UpdateGroupGroupRatioByJSONString(`{"vip":{"default":-0.1}}`); err == nil { + t.Fatal("expected negative nested ratio error") + } +} + +func TestCheckGroupRatioAllowsEmpty(t *testing.T) { + if err := CheckGroupRatio(`{}`); err != nil { + t.Fatalf("empty should pass check: %v", err) + } + if err := CheckGroupRatio(`{"default":1}`); err != nil { + t.Fatalf("valid should pass: %v", err) + } + if err := CheckGroupRatio(`{"default":-2}`); err == nil { + t.Fatal("negative should fail check") + } +} diff --git a/setting/system_setting/theme.go b/setting/system_setting/theme.go index 44dfc142941d..2848ed10f327 100644 --- a/setting/system_setting/theme.go +++ b/setting/system_setting/theme.go @@ -10,7 +10,7 @@ type ThemeSettings struct { } var themeSettings = ThemeSettings{ - Frontend: "classic", + Frontend: "default", } func init() { diff --git a/setting/user_usable_group.go b/setting/user_usable_group.go index eb04b7f30534..24f85da178a1 100644 --- a/setting/user_usable_group.go +++ b/setting/user_usable_group.go @@ -1,7 +1,7 @@ package setting import ( - "encoding/json" + "strings" "sync" "github.com/QuantumNous/new-api/common" @@ -28,7 +28,7 @@ func UserUsableGroups2JSONString() string { userUsableGroupsMutex.RLock() defer userUsableGroupsMutex.RUnlock() - jsonBytes, err := json.Marshal(userUsableGroups) + jsonBytes, err := common.Marshal(userUsableGroups) if err != nil { common.SysLog("error marshalling user groups: " + err.Error()) } @@ -39,8 +39,28 @@ func UpdateUserUsableGroupsByJSONString(jsonStr string) error { userUsableGroupsMutex.Lock() defer userUsableGroupsMutex.Unlock() - userUsableGroups = make(map[string]string) - return json.Unmarshal([]byte(jsonStr), &userUsableGroups) + // Empty object wipes defaults and empties the pricing page (filter by usable groups). + trimmed := strings.TrimSpace(jsonStr) + if trimmed == "" || trimmed == "{}" || trimmed == "null" { + userUsableGroups = map[string]string{ + "default": "默认分组", + "vip": "vip分组", + } + return nil + } + tmp := make(map[string]string) + if err := common.Unmarshal([]byte(trimmed), &tmp); err != nil { + return err + } + if len(tmp) == 0 { + userUsableGroups = map[string]string{ + "default": "默认分组", + "vip": "vip分组", + } + return nil + } + userUsableGroups = tmp + return nil } func GetUsableGroupDescription(groupName string) string { diff --git a/setting/user_usable_group_empty_test.go b/setting/user_usable_group_empty_test.go new file mode 100644 index 000000000000..e0b7149043da --- /dev/null +++ b/setting/user_usable_group_empty_test.go @@ -0,0 +1,16 @@ +package setting + +import "testing" + +func TestUpdateUserUsableGroupsByJSONStringKeepsDefaultsOnEmpty(t *testing.T) { + if err := UpdateUserUsableGroupsByJSONString(`{"default":"默认分组","vip":"vip分组"}`); err != nil { + t.Fatalf("seed: %v", err) + } + if err := UpdateUserUsableGroupsByJSONString(`{}`); err != nil { + t.Fatalf("empty: %v", err) + } + got := GetUserUsableGroupsCopy() + if _, ok := got["default"]; !ok { + t.Fatalf("expected default usable group, got %#v", got) + } +} diff --git a/trusted_proxy.go b/trusted_proxy.go new file mode 100644 index 000000000000..641fb9551170 --- /dev/null +++ b/trusted_proxy.go @@ -0,0 +1,27 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/gin-gonic/gin" +) + +func configureTrustedProxies(engine *gin.Engine) error { + raw := strings.TrimSpace(os.Getenv("TRUSTED_PROXY_CIDRS")) + if raw == "" { + return engine.SetTrustedProxies(nil) + } + + items := strings.Split(raw, ",") + trustedProxies := make([]string, 0, len(items)) + for _, item := range items { + proxy := strings.TrimSpace(item) + if proxy == "" { + return fmt.Errorf("TRUSTED_PROXY_CIDRS contains an empty entry") + } + trustedProxies = append(trustedProxies, proxy) + } + return engine.SetTrustedProxies(trustedProxies) +} diff --git a/trusted_proxy_test.go b/trusted_proxy_test.go new file mode 100644 index 000000000000..824fead671c1 --- /dev/null +++ b/trusted_proxy_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestConfigureTrustedProxiesDisablesForwardedHeadersByDefault(t *testing.T) { + t.Setenv("TRUSTED_PROXY_CIDRS", "") + engine := gin.New() + require.NoError(t, configureTrustedProxies(engine)) + + require.Equal(t, "203.0.113.10", requestClientIP(engine, "203.0.113.10:4321", "198.51.100.20")) +} + +func TestConfigureTrustedProxiesUsesConfiguredCIDRs(t *testing.T) { + t.Setenv("TRUSTED_PROXY_CIDRS", "127.0.0.1/32, ::1/128") + engine := gin.New() + require.NoError(t, configureTrustedProxies(engine)) + + require.Equal(t, "198.51.100.20", requestClientIP(engine, "127.0.0.1:4321", "198.51.100.20")) +} + +func TestConfigureTrustedProxiesRejectsInvalidCIDR(t *testing.T) { + t.Setenv("TRUSTED_PROXY_CIDRS", "not-a-cidr") + require.Error(t, configureTrustedProxies(gin.New())) +} + +func requestClientIP(engine *gin.Engine, remoteAddr string, forwardedFor string) string { + var clientIP string + engine.GET("/", func(c *gin.Context) { + clientIP = c.ClientIP() + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodGet, "/", nil) + request.RemoteAddr = remoteAddr + request.Header.Set("X-Forwarded-For", forwardedFor) + engine.ServeHTTP(httptest.NewRecorder(), request) + return clientIP +} diff --git a/web/bun.lock b/web/bun.lock index d86f3a8b3ffe..469bbc01518f 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -19,6 +19,7 @@ "axios": "catalog:", "clsx": "catalog:", "dayjs": "catalog:", + "dompurify": "3.4.11", "highlight.js": "^11.11.1", "history": "^5.3.0", "i18next": "^23.16.8", @@ -57,6 +58,7 @@ "eslint-plugin-header": "^3.1.1", "eslint-plugin-react-hooks": "^5.2.0", "i18next-cli": "^1.10.3", + "jsdom": "^26.1.0", "postcss": "^8.5.3", "prettier": "catalog:", "prop-types": "^15.8.1", @@ -123,6 +125,7 @@ "tw-animate-css": "^1.4.0", "use-stick-to-bottom": "^1.1.6", "vaul": "^1.1.2", + "web-vitals": "^5.1.0", "zod": "^4.4.3", "zustand": "^5.0.14", }, @@ -146,6 +149,12 @@ }, }, }, + "overrides": { + "dompurify": "3.4.11", + "form-data": "4.0.6", + "hono": "4.12.25", + "minimist": "1.2.8", + }, "catalog": { "@lobehub/icons": "^5.10.1", "@rsbuild/core": "^2.1.4", @@ -190,6 +199,8 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + "@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -280,6 +291,16 @@ "@croct/json5-parser": ["@croct/json5-parser@0.2.2", "", { "dependencies": { "@croct/json": "^2.1.0" } }, "sha512-0NJMLrbeLbQ0eCVj3UoH/kG2QckUgOASfwmfDTjyW1xAYPyTNJXcWVT/dssJdTJd0pRchW+qF0VFWQHcxs1OVw=="], + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + "@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], @@ -1436,6 +1457,8 @@ "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "cytoscape": ["cytoscape@3.33.4", "", {}, "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww=="], @@ -1512,6 +1535,8 @@ "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], + "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], "date-fns-tz": ["date-fns-tz@1.3.8", "", { "peerDependencies": { "date-fns": ">=2.0.0" } }, "sha512-qwNXUFtMHTTU6CFSFjoJ80W8Fzzp24LntbjFFBgL/faqds4e5mo9mftoRLgr3Vi1trISsg4awSpYVsOQCRnapQ=="], @@ -1520,6 +1545,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -1720,7 +1747,7 @@ "for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="], - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], @@ -1832,7 +1859,9 @@ "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], - "hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], + "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], @@ -1842,6 +1871,8 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], @@ -1854,7 +1885,7 @@ "i18next-resources-for-ts": ["i18next-resources-for-ts@2.1.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@swc/core": "^1.15.18", "chokidar": "^5.0.0", "yaml": "^2.8.2" }, "bin": { "i18next-resources-for-ts": "bin/i18next-resources-for-ts.js" } }, "sha512-n5UexwEVt0OoIAhG2MWpSnAVJW1U8mQrQTmXyxc5DMAx+NLhcLZhSMJo/FnUsA5JQ3obTYqTgB7YIuZKWpDgow=="], - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -1926,6 +1957,8 @@ "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], @@ -1956,6 +1989,8 @@ "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -2258,6 +2293,8 @@ "numeral": ["numeral@2.0.6", "", {}, "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA=="], + "nwsapi": ["nwsapi@2.2.24", "", {}, "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], @@ -2616,6 +2653,8 @@ "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], "run-async": ["run-async@4.0.6", "", {}, "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ=="], @@ -2634,6 +2673,8 @@ "sass-formatter": ["sass-formatter@0.7.9", "", { "dependencies": { "suf-log": "^2.5.3" } }, "sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "screenfull": ["screenfull@5.2.0", "", {}, "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA=="], @@ -2748,6 +2789,8 @@ "swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -2774,6 +2817,10 @@ "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "to-vfile": ["to-vfile@8.0.0", "", { "dependencies": { "vfile": "^6.0.0" } }, "sha512-IcmH1xB5576MJc9qcfEC/m/nQCFt3fzMHz45sSlgJyTWjRbKW1HAkJpuf3DgE57YzIlZcwcBZA5ENQbBo4aLkg=="], @@ -2786,6 +2833,10 @@ "topojson-server": ["topojson-server@3.0.1", "", { "dependencies": { "commander": "2" }, "bin": { "geo2topo": "bin/geo2topo" } }, "sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw=="], + "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -2894,20 +2945,38 @@ "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "web-vitals": ["web-vitals@5.3.0", "", {}, "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], @@ -2926,6 +2995,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@codemirror/autocomplete/@codemirror/view": ["@codemirror/view@6.43.3", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng=="], "@codemirror/lang-html/@codemirror/view": ["@codemirror/view@6.43.3", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng=="], @@ -2964,6 +3035,8 @@ "@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "@lobehub/fluent-emoji/lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], "@lobehub/icons/lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="], @@ -3114,6 +3187,8 @@ "babel-plugin-macros/cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], + "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "cosmiconfig/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -3146,18 +3221,20 @@ "geojson-dissolve/@turf/meta": ["@turf/meta@3.14.0", "", {}, "sha512-OtXqLQuR9hlQ/HkAF/OdzRea7E0eZK1ay8y8CBXkoO2R6v34CsDrWYLMSo0ZzMsaQDpKo76NPP2GGo+PyG1cSg=="], - "geojson-flatten/minimist": ["minimist@1.2.0", "", {}, "sha512-7Wl+Jz+IGWuSdgsQEJ4JunV0si/iMhg42MnQQG6h1R6TNeVenp4U9x5CC5v/gYqz/fENLQITAWXidNtVL0NNbw=="], - "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "i18next-cli/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "i18next-cli/ora": ["ora@9.4.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ=="], "i18next-cli/react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + "jsdom/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "leva/react-dropzone": ["react-dropzone@12.1.0", "", { "dependencies": { "attr-accept": "^2.2.2", "file-selector": "^0.5.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8" } }, "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog=="], @@ -3170,8 +3247,6 @@ "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "mermaid/dompurify": ["dompurify@3.4.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA=="], - "mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], @@ -3200,6 +3275,8 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "rc-menu/@rc-component/trigger": ["@rc-component/trigger@2.3.1", "", { "dependencies": { "@babel/runtime": "^7.23.2", "@rc-component/portal": "^1.1.0", "classnames": "^2.3.2", "rc-motion": "^2.0.0", "rc-resize-observer": "^1.3.1", "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A=="], "react-i18next/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], @@ -3246,8 +3323,6 @@ "simplify-geojson/concat-stream": ["concat-stream@1.4.11", "", { "dependencies": { "inherits": "~2.0.1", "readable-stream": "~1.1.9", "typedarray": "~0.0.5" } }, "sha512-X3JMh8+4je3U1cQpG87+f9lXHDrqcb2MVLg9L7o8b1UZ0DzhRrUpdn65ttzu10PpJPPI3MQNkis+oha6TSA9Mw=="], - "simplify-geojson/minimist": ["minimist@1.2.6", "", {}, "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="], - "split-string/extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="], "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -3388,16 +3463,12 @@ "d3-fetch/d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], - "d3-fetch/d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], "d3/d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], - "d3/d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "glob/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], @@ -3414,6 +3485,8 @@ "i18next-cli/ora/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + "jsdom/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "leva/react-dropzone/file-selector": ["file-selector@0.5.0", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA=="], "mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], diff --git a/web/classic/bundle-budget.json b/web/classic/bundle-budget.json new file mode 100644 index 000000000000..ffac7ff5bb7e --- /dev/null +++ b/web/classic/bundle-budget.json @@ -0,0 +1,5 @@ +{ + "entryGzipKiB": 1100, + "maxJsGzipKiB": 2150, + "totalJsGzipKiB": 5200 +} diff --git a/web/classic/package.json b/web/classic/package.json index ecc6971280bf..ba9f6389638f 100644 --- a/web/classic/package.json +++ b/web/classic/package.json @@ -14,6 +14,7 @@ "axios": "catalog:", "clsx": "catalog:", "dayjs": "catalog:", + "dompurify": "3.4.11", "history": "^5.3.0", "highlight.js": "^11.11.1", "i18next": "^23.16.8", @@ -45,7 +46,9 @@ }, "scripts": { "dev": "rsbuild dev", + "test": "bun test", "build": "rsbuild build", + "bundle:check": "node ../scripts/check-bundle-budget.mjs bundle-budget.json", "lint": "prettier . --check", "lint:fix": "prettier . --write", "eslint": "bunx eslint \"**/*.{js,jsx}\" --cache", @@ -83,6 +86,7 @@ "eslint-plugin-header": "^3.1.1", "eslint-plugin-react-hooks": "^5.2.0", "i18next-cli": "^1.10.3", + "jsdom": "^26.1.0", "postcss": "^8.5.3", "prop-types": "^15.8.1", "prettier": "catalog:", diff --git a/web/classic/rsbuild.config.ts b/web/classic/rsbuild.config.ts index 3ccf1e96df8e..b45cb33034f9 100644 --- a/web/classic/rsbuild.config.ts +++ b/web/classic/rsbuild.config.ts @@ -10,6 +10,7 @@ const semiUiDir = path.resolve( path.dirname(require.resolve('@douyinfe/semi-ui')), '../..', ) +const semiDateFnsDir = path.resolve(semiUiDir, 'node_modules/date-fns') export default defineConfig(({ envMode }) => { const env = loadEnv({ mode: envMode, prefixes: ['VITE_'] }) @@ -43,6 +44,8 @@ export default defineConfig(({ envMode }) => { resolve: { alias: { '@': path.resolve(__dirname, './src'), + // date-fns-tz 1.x imports date-fns v2 internals; the workspace root uses v4. + 'date-fns': semiDateFnsDir, '@douyinfe/semi-ui/dist/css/semi.css': path.resolve( semiUiDir, 'dist/css/semi.css', diff --git a/web/classic/src/components/common/DocumentRenderer/index.jsx b/web/classic/src/components/common/DocumentRenderer/index.jsx index 3325b2feb8ae..670692aef51e 100644 --- a/web/classic/src/components/common/DocumentRenderer/index.jsx +++ b/web/classic/src/components/common/DocumentRenderer/index.jsx @@ -27,6 +27,7 @@ import { } from '@douyinfe/semi-illustrations'; import { useTranslation } from 'react-i18next'; import MarkdownRenderer from '../markdown/MarkdownRenderer'; +import { sanitizeHtml as sanitizeDocumentHtml } from '../../../helpers/sanitizeHtml'; // Check whether content is a URL. const isUrl = (content) => { @@ -46,19 +47,9 @@ const isHtmlContent = (content) => { return htmlTagRegex.test(content); }; -// Parse HTML content and extract inline styles. +// Keep the existing payload shape while dropping executable markup and CSS. const sanitizeHtml = (html) => { - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = html; - - const styles = Array.from(tempDiv.querySelectorAll('style')) - .map((style) => style.innerHTML) - .join('\n'); - - const bodyContent = tempDiv.querySelector('body'); - const content = bodyContent ? bodyContent.innerHTML : html; - - return { content, styles }; + return { content: sanitizeDocumentHtml(html), styles: '' }; }; /** diff --git a/web/classic/src/components/dashboard/AnnouncementsPanel.jsx b/web/classic/src/components/dashboard/AnnouncementsPanel.jsx index c62850b3b8fa..e54b304e6323 100644 --- a/web/classic/src/components/dashboard/AnnouncementsPanel.jsx +++ b/web/classic/src/components/dashboard/AnnouncementsPanel.jsx @@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Card, Tag, Timeline, Empty } from '@douyinfe/semi-ui'; import { Bell } from 'lucide-react'; -import { marked } from 'marked'; +import { renderMarkdown } from '../../helpers/sanitizeHtml'; import { IllustrationConstruction, IllustrationConstructionDark, @@ -80,7 +80,7 @@ const AnnouncementsPanel = ({ {announcementData.length > 0 ? ( {announcementData.map((item, idx) => { - const htmlExtra = item.extra ? marked.parse(item.extra) : ''; + const htmlExtra = item.extra ? renderMarkdown(item.extra) : ''; return (
diff --git a/web/classic/src/components/dashboard/FaqPanel.jsx b/web/classic/src/components/dashboard/FaqPanel.jsx index a092abbd1089..232841b6260e 100644 --- a/web/classic/src/components/dashboard/FaqPanel.jsx +++ b/web/classic/src/components/dashboard/FaqPanel.jsx @@ -21,7 +21,7 @@ import React from 'react'; import { Card, Collapse, Empty } from '@douyinfe/semi-ui'; import { HelpCircle } from 'lucide-react'; import { IconPlus, IconMinus } from '@douyinfe/semi-icons'; -import { marked } from 'marked'; +import { renderMarkdown } from '../../helpers/sanitizeHtml'; import { IllustrationConstruction, IllustrationConstructionDark, @@ -62,7 +62,7 @@ const FaqPanel = ({ >
diff --git a/web/classic/src/components/layout/Footer.jsx b/web/classic/src/components/layout/Footer.jsx index 759e45ab9b05..7ae37dbfc19f 100644 --- a/web/classic/src/components/layout/Footer.jsx +++ b/web/classic/src/components/layout/Footer.jsx @@ -21,11 +21,12 @@ import React, { useEffect, useState, useMemo, useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { Typography } from '@douyinfe/semi-ui'; import { getFooterHTML, getLogo, getSystemName } from '../../helpers'; +import { sanitizeHtml } from '../../helpers/sanitizeHtml'; import { StatusContext } from '../../context/Status'; const FooterBar = () => { const { t } = useTranslation(); - const [footer, setFooter] = useState(getFooterHTML()); + const [footer, setFooter] = useState(sanitizeHtml(getFooterHTML())); const systemName = getSystemName(); const logo = getLogo(); const [statusState] = useContext(StatusContext); @@ -34,7 +35,7 @@ const FooterBar = () => { const loadFooter = () => { let footer_html = localStorage.getItem('footer_html'); if (footer_html) { - setFooter(footer_html); + setFooter(sanitizeHtml(footer_html)); } }; diff --git a/web/classic/src/components/layout/NoticeModal.jsx b/web/classic/src/components/layout/NoticeModal.jsx index c8197a58ba7b..f400063cd020 100644 --- a/web/classic/src/components/layout/NoticeModal.jsx +++ b/web/classic/src/components/layout/NoticeModal.jsx @@ -28,7 +28,7 @@ import { } from '@douyinfe/semi-ui'; import { useTranslation } from 'react-i18next'; import { API, showError, getRelativeTime } from '../../helpers'; -import { marked } from 'marked'; +import { renderMarkdown } from '../../helpers/sanitizeHtml'; import { IllustrationNoContent, IllustrationNoContentDark, @@ -89,7 +89,7 @@ const NoticeModal = ({ const { success, message, data } = res.data; if (success) { if (data !== '') { - const htmlNotice = marked.parse(data); + const htmlNotice = renderMarkdown(data); setNoticeContent(htmlNotice); } else { setNoticeContent(''); @@ -170,8 +170,8 @@ const NoticeModal = ({
{processedAnnouncements.map((item, idx) => { - const htmlContent = marked.parse(item.content || ''); - const htmlExtra = item.extra ? marked.parse(item.extra) : ''; + const htmlContent = renderMarkdown(item.content || ''); + const htmlExtra = item.extra ? renderMarkdown(item.extra) : ''; return ( { }, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]); const renderedContent = useMemo(() => { - return linkifyHtml(highlightedContent); + return sanitizeHtml(linkifyHtml(highlightedContent)); }, [highlightedContent]); const handleCopy = useCallback(async () => { diff --git a/web/classic/src/components/settings/OtherSetting.jsx b/web/classic/src/components/settings/OtherSetting.jsx index a9848551b590..2b04b45516db 100644 --- a/web/classic/src/components/settings/OtherSetting.jsx +++ b/web/classic/src/components/settings/OtherSetting.jsx @@ -35,7 +35,7 @@ import { showSuccess, timestamp2string, } from '../../helpers'; -import { marked } from 'marked'; +import { renderMarkdown } from '../../helpers/sanitizeHtml'; import { useTranslation } from 'react-i18next'; import { StatusContext } from '../../context/Status'; import Text from '@douyinfe/semi-ui/lib/es/typography/text'; @@ -271,7 +271,7 @@ const OtherSetting = () => { } else { setUpdateData({ tag_name: tag_name, - content: marked.parse(body), + content: renderMarkdown(body), }); setShowUpdateModal(true); } diff --git a/web/classic/src/helpers/sanitizeHtml.js b/web/classic/src/helpers/sanitizeHtml.js new file mode 100644 index 000000000000..b92b758e5788 --- /dev/null +++ b/web/classic/src/helpers/sanitizeHtml.js @@ -0,0 +1,24 @@ +import createDOMPurify from 'dompurify'; +import { marked } from 'marked'; + +const SANITIZE_OPTIONS = { + USE_PROFILES: { html: true }, + FORBID_TAGS: ['style', 'iframe', 'object', 'embed', 'form'], + FORBID_ATTR: ['style', 'srcdoc', 'srcset'], +}; + +export function createHtmlSanitizer(windowObject) { + const purifier = createDOMPurify(windowObject); + return (dirty) => purifier.sanitize(String(dirty ?? ''), SANITIZE_OPTIONS); +} + +const browserSanitizer = + typeof window === 'undefined' ? () => '' : createHtmlSanitizer(window); + +export function sanitizeHtml(dirty) { + return browserSanitizer(dirty); +} + +export function renderMarkdown(markdown) { + return sanitizeHtml(marked.parse(String(markdown ?? ''))); +} diff --git a/web/classic/src/helpers/sanitizeHtml.test.js b/web/classic/src/helpers/sanitizeHtml.test.js new file mode 100644 index 000000000000..e9bcd89b7aba --- /dev/null +++ b/web/classic/src/helpers/sanitizeHtml.test.js @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'bun:test'; +import { JSDOM } from 'jsdom'; + +import { createHtmlSanitizer } from './sanitizeHtml'; + +const sanitizeHtml = createHtmlSanitizer(new JSDOM('').window); + +describe('sanitizeHtml', () => { + it('removes scripts, event handlers, and javascript URLs', () => { + const dirty = [ + '', + '', + 'click', + ].join(''); + + const clean = sanitizeHtml(dirty); + + expect(clean).not.toContain(' { + expect(sanitizeHtml('

Title

Body

')).toBe( + '

Title

Body

', + ); + }); + + it('removes inline styles and embedded documents', () => { + const clean = sanitizeHtml( + '

Body

', + ); + + expect(clean).toBe('

Body

'); + }); +}); diff --git a/web/classic/src/helpers/utils.jsx b/web/classic/src/helpers/utils.jsx index c2e72820a351..1912e2bf7bc9 100644 --- a/web/classic/src/helpers/utils.jsx +++ b/web/classic/src/helpers/utils.jsx @@ -18,7 +18,11 @@ For commercial licensing, please contact support@quantumnous.com */ import { Toast, Pagination } from '@douyinfe/semi-ui'; -import { toastConstants, BILLING_PRICING_VARS, BILLING_VAR_REGEX } from '../constants'; +import { + toastConstants, + BILLING_PRICING_VARS, + BILLING_VAR_REGEX, +} from '../constants'; import React from 'react'; import { toast } from 'react-toastify'; import { @@ -27,9 +31,12 @@ import { } from '../constants/playground.constants'; import { TABLE_COMPACT_MODES_KEY } from '../constants'; import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile'; +import { sanitizeHtml } from './sanitizeHtml'; const HTMLToastContent = ({ htmlContent }) => { - return
; + return ( +
+ ); }; export default HTMLToastContent; export function isAdmin() { @@ -725,7 +732,9 @@ export const calculateModelPrice = ({ ? formatTokenPrice(inputRatioPriceUSD * Number(record.cache_ratio)) : null, createCachePrice: hasRatioValue(record.create_cache_ratio) - ? formatTokenPrice(inputRatioPriceUSD * Number(record.create_cache_ratio)) + ? formatTokenPrice( + inputRatioPriceUSD * Number(record.create_cache_ratio), + ) : null, imagePrice: hasRatioValue(record.image_ratio) ? formatTokenPrice(inputRatioPriceUSD * Number(record.image_ratio)) @@ -771,11 +780,7 @@ export const calculateModelPrice = ({ }; }; -export const getModelPriceItems = ( - priceData, - t, - quotaDisplayType = 'USD', -) => { +export const getModelPriceItems = (priceData, t, quotaDisplayType = 'USD') => { if (priceData.isDynamicPricing) { return [ { @@ -883,7 +888,10 @@ export const getModelPriceItems = ( value: priceData.audioOutputPrice, suffix: unitSuffix, }, - ].filter((item) => item.value !== null && item.value !== undefined && item.value !== ''); + ].filter( + (item) => + item.value !== null && item.value !== undefined && item.value !== '', + ); } return [ @@ -893,12 +901,18 @@ export const getModelPriceItems = ( value: priceData.price, suffix: ` / ${t('次')}`, }, - ].filter((item) => item.value !== null && item.value !== undefined && item.value !== ''); + ].filter( + (item) => + item.value !== null && item.value !== undefined && item.value !== '', + ); }; // 格式化动态计费摘要(用于卡片视图,与 formatPriceInfo 风格统一) export const formatDynamicPriceSummary = (billingExpr, t, groupRatio = 1) => { - if (!billingExpr) return {t('动态计费')}; + if (!billingExpr) + return ( + {t('动态计费')} + ); const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; let symbol = '$'; @@ -929,7 +943,9 @@ export const formatDynamicPriceSummary = (billingExpr, t, groupRatio = 1) => { const varLabels = BILLING_PRICING_VARS.map((v) => [v.key, v.label]); - const hasTimeCondition = /\b(?:hour|minute|weekday|month|day)\(/.test(exprBody); + const hasTimeCondition = /\b(?:hour|minute|weekday|month|day)\(/.test( + exprBody, + ); const hasRequestCondition = /\b(?:param|header)\(/.test(exprBody); const tags = []; @@ -954,35 +970,35 @@ export const formatDynamicPriceSummary = (billingExpr, t, groupRatio = 1) => { )} {(tierCount > 1 || hasTimeCondition || hasRequestCondition) && ( - - - {t('动态计费')} - - {tags.map((tag) => ( + - {tag} + {t('动态计费')} - ))} - + {tags.map((tag) => ( + + {tag} + + ))} + )} ); diff --git a/web/classic/src/i18n/i18n.js b/web/classic/src/i18n/i18n.js index 357ae592fbb9..1c9e2701b7ff 100644 --- a/web/classic/src/i18n/i18n.js +++ b/web/classic/src/i18n/i18n.js @@ -30,6 +30,27 @@ import jaTranslation from './locales/ja.json'; import viTranslation from './locales/vi.json'; import { supportedLanguages } from './language'; +// 默认简体中文:忽略 navigator;一次性迁移历史 en 缓存。 +const I18N_STORAGE_KEY = 'i18nextLng'; +const I18N_DEFAULT_ZH_MIGRATION_KEY = 'i18nDefaultZhCN.v1'; +try { + if (typeof localStorage !== 'undefined') { + if (!localStorage.getItem(I18N_DEFAULT_ZH_MIGRATION_KEY)) { + const cached = localStorage.getItem(I18N_STORAGE_KEY); + if ( + !cached || + cached === 'en' || + String(cached).toLowerCase().startsWith('en-') + ) { + localStorage.setItem(I18N_STORAGE_KEY, 'zh-CN'); + } + localStorage.setItem(I18N_DEFAULT_ZH_MIGRATION_KEY, '1'); + } + } +} catch (e) { + // ignore storage failures +} + i18n .use(LanguageDetector) .use(initReactI18next) @@ -50,6 +71,11 @@ i18n interpolation: { escapeValue: false, }, + detection: { + order: ['localStorage'], + caches: ['localStorage'], + lookupLocalStorage: I18N_STORAGE_KEY, + }, }); window.__i18n = i18n; diff --git a/web/classic/src/pages/About/index.jsx b/web/classic/src/pages/About/index.jsx index 2f8091a9093c..be80ba86bfb7 100644 --- a/web/classic/src/pages/About/index.jsx +++ b/web/classic/src/pages/About/index.jsx @@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import React, { useEffect, useState } from 'react'; import { API, showError } from '../../helpers'; -import { marked } from 'marked'; +import { renderMarkdown, sanitizeHtml } from '../../helpers/sanitizeHtml'; import { Empty } from '@douyinfe/semi-ui'; import { IllustrationConstruction, @@ -34,13 +34,18 @@ const About = () => { const currentYear = new Date().getFullYear(); const displayAbout = async () => { - setAbout(localStorage.getItem('about') || ''); + const cachedAbout = localStorage.getItem('about') || ''; + setAbout( + cachedAbout.startsWith('https://') + ? cachedAbout + : sanitizeHtml(cachedAbout), + ); const res = await API.get('/api/about'); const { success, message, data } = res.data; if (success) { let aboutContent = data; if (!data.startsWith('https://')) { - aboutContent = marked.parse(data); + aboutContent = renderMarkdown(data); } setAbout(aboutContent); localStorage.setItem('about', aboutContent); diff --git a/web/classic/src/pages/Home/index.jsx b/web/classic/src/pages/Home/index.jsx index bb2dd0d2e749..fbc41cc7a25c 100644 --- a/web/classic/src/pages/Home/index.jsx +++ b/web/classic/src/pages/Home/index.jsx @@ -30,7 +30,7 @@ import { useIsMobile } from '../../hooks/common/useIsMobile'; import { API_ENDPOINTS } from '../../constants/common.constant'; import { StatusContext } from '../../context/Status'; import { useActualTheme } from '../../context/Theme'; -import { marked } from 'marked'; +import { renderMarkdown, sanitizeHtml } from '../../helpers/sanitizeHtml'; import { useTranslation } from 'react-i18next'; import { IconGithubLogo, @@ -82,13 +82,18 @@ const Home = () => { const isChinese = i18n.language.startsWith('zh'); const displayHomePageContent = async () => { - setHomePageContent(localStorage.getItem('home_page_content') || ''); + const cachedContent = localStorage.getItem('home_page_content') || ''; + setHomePageContent( + cachedContent.startsWith('https://') + ? cachedContent + : sanitizeHtml(cachedContent), + ); const res = await API.get('/api/home_page_content'); const { success, message, data } = res.data; if (success) { let content = data; if (!data.startsWith('https://')) { - content = marked.parse(data); + content = renderMarkdown(data); } setHomePageContent(content); localStorage.setItem('home_page_content', content); diff --git a/web/default/bundle-budget.json b/web/default/bundle-budget.json new file mode 100644 index 000000000000..a2a5a5f9d004 --- /dev/null +++ b/web/default/bundle-budget.json @@ -0,0 +1,5 @@ +{ + "entryGzipKiB": 400, + "maxJsGzipKiB": 3000, + "totalJsGzipKiB": 16500 +} diff --git a/web/default/index.html b/web/default/index.html index 7f280b401afd..f9c7642513d5 100644 --- a/web/default/index.html +++ b/web/default/index.html @@ -1,5 +1,5 @@ - + diff --git a/web/default/package.json b/web/default/package.json index 67906f26f473..6afd1230a6b2 100644 --- a/web/default/package.json +++ b/web/default/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "rsbuild dev", "build": "rsbuild build", + "bundle:check": "node scripts/check-bundle-budget.mjs", "build:check": "tsgo -b && rsbuild build", "typecheck": "tsgo -b", "lint": "oxlint -c .oxlintrc.json .", @@ -74,6 +75,7 @@ "tw-animate-css": "^1.4.0", "use-stick-to-bottom": "^1.1.6", "vaul": "^1.1.2", + "web-vitals": "^5.1.0", "zod": "^4.4.3", "zustand": "^5.0.14" }, @@ -99,7 +101,7 @@ "brace-expansion": "2.1.1", "dompurify": "3.4.11", "fast-uri": "3.1.2", - "hono": "4.12.22", + "hono": "4.12.25", "ip-address": "10.2.0", "js-cookie": "3.0.7", "mermaid": "11.15.0", diff --git a/web/default/rsbuild.config.ts b/web/default/rsbuild.config.ts index 3b9d11de6a88..93e2b39c5336 100644 --- a/web/default/rsbuild.config.ts +++ b/web/default/rsbuild.config.ts @@ -50,6 +50,28 @@ export default defineConfig(({ envMode }) => { priority: 0, enforce: true, }, + 'vendor-vchart': { + test: /node_modules[\\/]@visactor[\\/]/, + name: 'vendor-vchart', + chunks: 'all', + priority: 0, + enforce: true, + }, + 'vendor-shiki': { + test: /node_modules[\\/]shiki[\\/]/, + name: 'vendor-shiki', + chunks: 'async', + priority: 0, + enforce: true, + }, + 'vendor-recharts': { + // Legacy shadcn chart helper; keep out of main if unused on critical path. + test: /node_modules[\\/]recharts[\\/]/, + name: 'vendor-recharts', + chunks: 'async', + priority: 0, + enforce: true, + }, }, }, source: { diff --git a/web/default/scripts/check-bundle-budget.mjs b/web/default/scripts/check-bundle-budget.mjs new file mode 100644 index 000000000000..ada03b543fde --- /dev/null +++ b/web/default/scripts/check-bundle-budget.mjs @@ -0,0 +1,21 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { runBundleBudgetCheck } from "../../scripts/check-bundle-budget.mjs"; + +await runBundleBudgetCheck({ configPath: "bundle-budget.json" }); diff --git a/web/default/src/components/data-table/core/pagination.tsx b/web/default/src/components/data-table/core/pagination.tsx index 7eef0390766a..37179f3adfe3 100644 --- a/web/default/src/components/data-table/core/pagination.tsx +++ b/web/default/src/components/data-table/core/pagination.tsx @@ -38,16 +38,14 @@ import { cn, getPageNumbers } from '@/lib/utils' type DataTablePaginationProps = { table: Table + pageSizeOptions?: readonly number[] } -const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50, 100] as const -const PAGE_SIZE_SELECT_ITEMS = PAGE_SIZE_OPTIONS.map((pageSize) => ({ - value: `${pageSize}`, - label: pageSize, -})) +const PAGE_SIZE_OPTIONS = [10, 20, 21, 30, 40, 50, 100] as const export function DataTablePagination({ table, + pageSizeOptions, }: DataTablePaginationProps) { const { t } = useTranslation() const pagination = table.getState().pagination @@ -56,6 +54,14 @@ export function DataTablePagination({ const totalPages = table.getPageCount() const totalRows = table.getRowCount() const pageNumbers = getPageNumbers(currentPage, totalPages) + const sizeOptions = + pageSizeOptions && pageSizeOptions.length > 0 + ? pageSizeOptions + : PAGE_SIZE_OPTIONS + const sizeSelectItems = sizeOptions.map((size) => ({ + value: `${size}`, + label: size, + })) return (
({ {t('Rows per page')}