From f5603b0780f1dcd245a5b4fc26fdb56b6f2c1379 Mon Sep 17 00:00:00 2001 From: Yuhao Jiang Date: Mon, 5 Jan 2026 14:08:34 -0600 Subject: [PATCH 01/22] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B7=A8?= =?UTF-8?q?=E6=97=B6=E5=8C=BA=E7=94=A8=E6=88=B7=E6=97=A5=E6=9C=9F=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E6=9F=A5=E8=AF=A2=E4=B8=8D=E5=87=86=E7=A1=AE=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:当用户时区与服务器时区不同时,日期范围查询使用服务器时区解析, 导致用户看到的数据与预期不符。 修复方案: - 前端:所有 GET 请求自动携带用户时区参数 - 后端:新增时区辅助函数,所有日期解析和默认日期范围计算都使用用户时区 - 当用户时区为空或无效时,自动回退到服务器时区 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../handler/admin/dashboard_handler.go | 16 ++++---- .../internal/handler/admin/usage_handler.go | 16 ++++---- backend/internal/handler/usage_handler.go | 32 +++++++++------- backend/internal/pkg/timezone/timezone.go | 37 +++++++++++++++++++ frontend/src/api/client.ts | 17 +++++++++ 5 files changed, 90 insertions(+), 28 deletions(-) diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index fe54d75f..30cdd914 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -26,31 +26,33 @@ func NewDashboardHandler(dashboardService *service.DashboardService) *DashboardH } // parseTimeRange parses start_date, end_date query parameters +// Uses user's timezone if provided, otherwise falls back to server timezone func parseTimeRange(c *gin.Context) (time.Time, time.Time) { - now := timezone.Now() + userTZ := c.Query("timezone") // Get user's timezone from request + now := timezone.NowInUserLocation(userTZ) startDate := c.Query("start_date") endDate := c.Query("end_date") var startTime, endTime time.Time if startDate != "" { - if t, err := timezone.ParseInLocation("2006-01-02", startDate); err == nil { + if t, err := timezone.ParseInUserLocation("2006-01-02", startDate, userTZ); err == nil { startTime = t } else { - startTime = timezone.StartOfDay(now.AddDate(0, 0, -7)) + startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ) } } else { - startTime = timezone.StartOfDay(now.AddDate(0, 0, -7)) + startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ) } if endDate != "" { - if t, err := timezone.ParseInLocation("2006-01-02", endDate); err == nil { + if t, err := timezone.ParseInUserLocation("2006-01-02", endDate, userTZ); err == nil { endTime = t.Add(24 * time.Hour) // Include the end date } else { - endTime = timezone.StartOfDay(now.AddDate(0, 0, 1)) + endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ) } } else { - endTime = timezone.StartOfDay(now.AddDate(0, 0, 1)) + endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ) } return startTime, endTime diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index 37da93d3..9d14afd2 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -102,8 +102,9 @@ func (h *UsageHandler) List(c *gin.Context) { // Parse date range var startTime, endTime *time.Time + userTZ := c.Query("timezone") // Get user's timezone from request if startDateStr := c.Query("start_date"); startDateStr != "" { - t, err := timezone.ParseInLocation("2006-01-02", startDateStr) + t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD") return @@ -112,7 +113,7 @@ func (h *UsageHandler) List(c *gin.Context) { } if endDateStr := c.Query("end_date"); endDateStr != "" { - t, err := timezone.ParseInLocation("2006-01-02", endDateStr) + t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") return @@ -172,7 +173,8 @@ func (h *UsageHandler) Stats(c *gin.Context) { } // Parse date range - now := timezone.Now() + userTZ := c.Query("timezone") // Get user's timezone from request + now := timezone.NowInUserLocation(userTZ) var startTime, endTime time.Time startDateStr := c.Query("start_date") @@ -180,12 +182,12 @@ func (h *UsageHandler) Stats(c *gin.Context) { if startDateStr != "" && endDateStr != "" { var err error - startTime, err = timezone.ParseInLocation("2006-01-02", startDateStr) + startTime, err = timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD") return } - endTime, err = timezone.ParseInLocation("2006-01-02", endDateStr) + endTime, err = timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") return @@ -195,13 +197,13 @@ func (h *UsageHandler) Stats(c *gin.Context) { period := c.DefaultQuery("period", "today") switch period { case "today": - startTime = timezone.StartOfDay(now) + startTime = timezone.StartOfDayInUserLocation(now, userTZ) case "week": startTime = now.AddDate(0, 0, -7) case "month": startTime = now.AddDate(0, -1, 0) default: - startTime = timezone.StartOfDay(now) + startTime = timezone.StartOfDayInUserLocation(now, userTZ) } endTime = now } diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index 9e503d4c..129dbfa6 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -88,8 +88,9 @@ func (h *UsageHandler) List(c *gin.Context) { // Parse date range var startTime, endTime *time.Time + userTZ := c.Query("timezone") // Get user's timezone from request if startDateStr := c.Query("start_date"); startDateStr != "" { - t, err := timezone.ParseInLocation("2006-01-02", startDateStr) + t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD") return @@ -98,7 +99,7 @@ func (h *UsageHandler) List(c *gin.Context) { } if endDateStr := c.Query("end_date"); endDateStr != "" { - t, err := timezone.ParseInLocation("2006-01-02", endDateStr) + t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") return @@ -194,7 +195,8 @@ func (h *UsageHandler) Stats(c *gin.Context) { } // 获取时间范围参数 - now := timezone.Now() + userTZ := c.Query("timezone") // Get user's timezone from request + now := timezone.NowInUserLocation(userTZ) var startTime, endTime time.Time // 优先使用 start_date 和 end_date 参数 @@ -204,12 +206,12 @@ func (h *UsageHandler) Stats(c *gin.Context) { if startDateStr != "" && endDateStr != "" { // 使用自定义日期范围 var err error - startTime, err = timezone.ParseInLocation("2006-01-02", startDateStr) + startTime, err = timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD") return } - endTime, err = timezone.ParseInLocation("2006-01-02", endDateStr) + endTime, err = timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") return @@ -221,13 +223,13 @@ func (h *UsageHandler) Stats(c *gin.Context) { period := c.DefaultQuery("period", "today") switch period { case "today": - startTime = timezone.StartOfDay(now) + startTime = timezone.StartOfDayInUserLocation(now, userTZ) case "week": startTime = now.AddDate(0, 0, -7) case "month": startTime = now.AddDate(0, -1, 0) default: - startTime = timezone.StartOfDay(now) + startTime = timezone.StartOfDayInUserLocation(now, userTZ) } endTime = now } @@ -248,31 +250,33 @@ func (h *UsageHandler) Stats(c *gin.Context) { } // parseUserTimeRange parses start_date, end_date query parameters for user dashboard +// Uses user's timezone if provided, otherwise falls back to server timezone func parseUserTimeRange(c *gin.Context) (time.Time, time.Time) { - now := timezone.Now() + userTZ := c.Query("timezone") // Get user's timezone from request + now := timezone.NowInUserLocation(userTZ) startDate := c.Query("start_date") endDate := c.Query("end_date") var startTime, endTime time.Time if startDate != "" { - if t, err := timezone.ParseInLocation("2006-01-02", startDate); err == nil { + if t, err := timezone.ParseInUserLocation("2006-01-02", startDate, userTZ); err == nil { startTime = t } else { - startTime = timezone.StartOfDay(now.AddDate(0, 0, -7)) + startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ) } } else { - startTime = timezone.StartOfDay(now.AddDate(0, 0, -7)) + startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ) } if endDate != "" { - if t, err := timezone.ParseInLocation("2006-01-02", endDate); err == nil { + if t, err := timezone.ParseInUserLocation("2006-01-02", endDate, userTZ); err == nil { endTime = t.Add(24 * time.Hour) // Include the end date } else { - endTime = timezone.StartOfDay(now.AddDate(0, 0, 1)) + endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ) } } else { - endTime = timezone.StartOfDay(now.AddDate(0, 0, 1)) + endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ) } return startTime, endTime diff --git a/backend/internal/pkg/timezone/timezone.go b/backend/internal/pkg/timezone/timezone.go index 35795648..40f6e38f 100644 --- a/backend/internal/pkg/timezone/timezone.go +++ b/backend/internal/pkg/timezone/timezone.go @@ -122,3 +122,40 @@ func StartOfMonth(t time.Time) time.Time { func ParseInLocation(layout, value string) (time.Time, error) { return time.ParseInLocation(layout, value, Location()) } + +// ParseInUserLocation parses a time string in the user's timezone. +// If userTZ is empty or invalid, falls back to the configured server timezone. +func ParseInUserLocation(layout, value, userTZ string) (time.Time, error) { + loc := Location() // default to server timezone + if userTZ != "" { + if userLoc, err := time.LoadLocation(userTZ); err == nil { + loc = userLoc + } + } + return time.ParseInLocation(layout, value, loc) +} + +// NowInUserLocation returns the current time in the user's timezone. +// If userTZ is empty or invalid, falls back to the configured server timezone. +func NowInUserLocation(userTZ string) time.Time { + if userTZ == "" { + return Now() + } + if userLoc, err := time.LoadLocation(userTZ); err == nil { + return time.Now().In(userLoc) + } + return Now() +} + +// StartOfDayInUserLocation returns the start of the given day in the user's timezone. +// If userTZ is empty or invalid, falls back to the configured server timezone. +func StartOfDayInUserLocation(t time.Time, userTZ string) time.Time { + loc := Location() + if userTZ != "" { + if userLoc, err := time.LoadLocation(userTZ); err == nil { + loc = userLoc + } + } + t = t.In(loc) + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1cc8e55b..4e53069a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -21,6 +21,15 @@ export const apiClient: AxiosInstance = axios.create({ // ==================== Request Interceptor ==================== +// Get user's timezone +const getUserTimezone = (): string => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone + } catch { + return 'UTC' + } +} + apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // Attach token from localStorage @@ -34,6 +43,14 @@ apiClient.interceptors.request.use( config.headers['Accept-Language'] = getLocale() } + // Attach timezone for all GET requests (backend may use it for default date ranges) + if (config.method === 'get') { + if (!config.params) { + config.params = {} + } + config.params.timezone = getUserTimezone() + } + return config }, (error) => { From 3f0017d1f18c118fedc52e5ae0cd5b8c30a25dc3 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Tue, 6 Jan 2026 11:36:38 +0800 Subject: [PATCH 02/22] =?UTF-8?q?fix(=E5=AE=89=E5=85=A8):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=BE=9D=E8=B5=96=E6=BC=8F=E6=B4=9E=E5=B9=B6=E5=BC=BA?= =?UTF-8?q?=E5=8C=96=E5=AE=89=E5=85=A8=E6=89=AB=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要改动: - 固定 Go 1.25.5 与 CI 校验并更新扫描流程 - 升级 quic-go、x/crypto、req 等依赖并通过 govulncheck - 强化 JWT 校验、TLS 配置与 xlsx 动态加载 - 新增审计豁免清单与校验脚本 --- .github/audit-exceptions.yml | 16 + .github/workflows/backend-ci.yml | 10 +- .github/workflows/release.yml | 7 +- .github/workflows/security-scan.yml | 60 +++ .gitignore | 3 + Dockerfile | 4 +- README.md | 63 ++- README_CN.md | 63 ++- backend/go.mod | 37 +- backend/go.sum | 68 ++- backend/internal/config/config.go | 2 +- backend/internal/pkg/httpclient/pool.go | 6 +- .../repository/proxy_probe_service.go | 2 +- backend/internal/service/auth_service.go | 19 +- backend/internal/service/email_service.go | 8 +- config.yaml | 390 ++++++++++++++++++ deploy/.env.example | 18 + deploy/docker-compose.yml | 9 +- docs/dependency-security.md | 58 +++ frontend/audit.json | 118 ++++++ frontend/src/views/admin/UsageView.vue | 6 +- tools/check_pnpm_audit_exceptions.py | 247 +++++++++++ 22 files changed, 1127 insertions(+), 87 deletions(-) create mode 100644 .github/audit-exceptions.yml create mode 100644 .github/workflows/security-scan.yml create mode 100644 config.yaml create mode 100644 docs/dependency-security.md create mode 100644 frontend/audit.json create mode 100644 tools/check_pnpm_audit_exceptions.py diff --git a/.github/audit-exceptions.yml b/.github/audit-exceptions.yml new file mode 100644 index 00000000..a1d8411c --- /dev/null +++ b/.github/audit-exceptions.yml @@ -0,0 +1,16 @@ +version: 1 +exceptions: + - package: xlsx + advisory: "GHSA-4r6h-8v6p-xvw6" + severity: high + reason: "Admin export only; switched to dynamic import to reduce exposure (CVE-2023-30533)" + mitigation: "Load only on export; restrict export permissions and data scope" + expires_on: "2026-04-05" + owner: "security@your-domain" + - package: xlsx + advisory: "GHSA-5pgg-2g8v-p4x9" + severity: high + reason: "Admin export only; switched to dynamic import to reduce exposure (CVE-2024-22363)" + mitigation: "Load only on export; restrict export permissions and data scope" + expires_on: "2026-04-05" + owner: "security@your-domain" diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 5e77f46e..3ea8860a 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -15,8 +15,11 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: backend/go.mod - check-latest: true + check-latest: false cache: true + - name: Verify Go version + run: | + go version | grep -q 'go1.25.5' - name: Unit tests working-directory: backend run: make test-unit @@ -31,8 +34,11 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: backend/go.mod - check-latest: true + check-latest: false cache: true + - name: Verify Go version + run: | + go version | grep -q 'go1.25.5' - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1dc2278e..fae8048f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,9 +104,14 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version-file: backend/go.mod + check-latest: false cache-dependency-path: backend/go.sum + - name: Verify Go version + run: | + go version | grep -q 'go1.25.5' + # Docker setup for GoReleaser - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 00000000..f148b318 --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,60 @@ +name: Security Scan + +on: + push: + pull_request: + schedule: + - cron: '0 3 * * 1' + +permissions: + contents: read + +jobs: + backend-security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + check-latest: false + cache-dependency-path: backend/go.sum + - name: Verify Go version + run: | + go version | grep -q 'go1.25.5' + - name: Run govulncheck + working-directory: backend + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + - name: Run gosec + working-directory: backend + run: | + go install github.com/securego/gosec/v2/cmd/gosec@latest + gosec -severity high -confidence high ./... + + frontend-security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: frontend/pnpm-lock.yaml + - name: Set up pnpm + uses: pnpm/action-setup@v2 + - name: Install dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + - name: Run pnpm audit + working-directory: frontend + run: | + pnpm audit --prod --audit-level=high --json > audit.json || true + - name: Check audit exceptions + run: | + python tools/check_pnpm_audit_exceptions.py \ + --audit frontend/audit.json \ + --exceptions .github/audit-exceptions.yml diff --git a/.gitignore b/.gitignore index d7ec87e4..93ae19f3 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,6 @@ backend/cmd/server/server deploy/docker-compose.override.yml .gocache/ vite.config.js +!docs/ +docs/* +!docs/dependency-security.md diff --git a/Dockerfile b/Dockerfile index 8ccd8629..b3320300 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,8 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.25-alpine -ARG ALPINE_IMAGE=alpine:3.19 +ARG GOLANG_IMAGE=golang:1.25.5-alpine +ARG ALPINE_IMAGE=alpine:3.20 ARG GOPROXY=https://goproxy.cn,direct ARG GOSUMDB=sum.golang.google.cn diff --git a/README.md b/README.md index 569117b1..684ad0f2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
-[![Go](https://img.shields.io/badge/Go-1.21+-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.25.5-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -44,13 +44,19 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot | Component | Technology | |-----------|------------| -| Backend | Go 1.21+, Gin, GORM | +| Backend | Go 1.25.5, Gin, GORM | | Frontend | Vue 3.4+, Vite 5+, TailwindCSS | | Database | PostgreSQL 15+ | | Cache/Queue | Redis 7+ | --- +## Documentation + +- Dependency Security: `docs/dependency-security.md` + +--- + ## Deployment ### Method 1: Script Installation (Recommended) @@ -160,6 +166,22 @@ ADMIN_PASSWORD=your_admin_password # Optional: Custom port SERVER_PORT=8080 + +# Optional: Security configuration +# Enable URL allowlist validation (false to skip allowlist checks, only basic format validation) +SECURITY_URL_ALLOWLIST_ENABLED=false + +# Allow insecure HTTP URLs when allowlist is disabled (default: false, requires https) +# ⚠️ WARNING: Enabling this allows HTTP (plaintext) URLs which can expose API keys +# Only recommended for: +# - Development/testing environments +# - Internal networks with trusted endpoints +# - When using local test servers (http://localhost) +# PRODUCTION: Keep this false or use HTTPS URLs only +SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false + +# Allow private IP addresses for upstream/pricing/CRS (for internal deployments) +SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=false ``` ```bash @@ -276,13 +298,48 @@ Additional security-related options are available in `config.yaml`: - `cors.allowed_origins` for CORS allowlist - `security.url_allowlist` for upstream/pricing/CRS host allowlists - `security.url_allowlist.enabled` to disable URL validation (use with caution) -- `security.url_allowlist.allow_insecure_http` to allow http URLs when validation is disabled +- `security.url_allowlist.allow_insecure_http` to allow HTTP URLs when validation is disabled +- `security.url_allowlist.allow_private_hosts` to allow private/local IP addresses - `security.response_headers.enabled` to enable configurable response header filtering (disabled uses default allowlist) - `security.csp` to control Content-Security-Policy headers - `billing.circuit_breaker` to fail closed on billing errors - `server.trusted_proxies` to enable X-Forwarded-For parsing - `turnstile.required` to require Turnstile in release mode +**⚠️ Security Warning: HTTP URL Configuration** + +When `security.url_allowlist.enabled=false`, the system performs minimal URL validation by default, **rejecting HTTP URLs** and only allowing HTTPS. To allow HTTP URLs (e.g., for development or internal testing), you must explicitly set: + +```yaml +security: + url_allowlist: + enabled: false # Disable allowlist checks + allow_insecure_http: true # Allow HTTP URLs (⚠️ INSECURE) +``` + +**Or via environment variable:** + +```bash +SECURITY_URL_ALLOWLIST_ENABLED=false +SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true +``` + +**Risks of allowing HTTP:** +- API keys and data transmitted in **plaintext** (vulnerable to interception) +- Susceptible to **man-in-the-middle (MITM) attacks** +- **NOT suitable for production** environments + +**When to use HTTP:** +- ✅ Development/testing with local servers (http://localhost) +- ✅ Internal networks with trusted endpoints +- ✅ Testing account connectivity before obtaining HTTPS +- ❌ Production environments (use HTTPS only) + +**Example error without this setting:** +``` +Invalid base URL: invalid url scheme: http +``` + If you disable URL validation or response header filtering, harden your network layer: - Enforce an egress allowlist for upstream domains/IPs - Block private/loopback/link-local ranges diff --git a/README_CN.md b/README_CN.md index 59434e46..22a601bc 100644 --- a/README_CN.md +++ b/README_CN.md @@ -2,7 +2,7 @@
-[![Go](https://img.shields.io/badge/Go-1.21+-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.25.5-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -44,13 +44,19 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅( | 组件 | 技术 | |------|------| -| 后端 | Go 1.21+, Gin, GORM | +| 后端 | Go 1.25.5, Gin, GORM | | 前端 | Vue 3.4+, Vite 5+, TailwindCSS | | 数据库 | PostgreSQL 15+ | | 缓存/队列 | Redis 7+ | --- +## 文档 + +- 依赖安全:`docs/dependency-security.md` + +--- + ## 部署方式 ### 方式一:脚本安装(推荐) @@ -160,6 +166,22 @@ ADMIN_PASSWORD=your_admin_password # 可选:自定义端口 SERVER_PORT=8080 + +# 可选:安全配置 +# 启用 URL 白名单验证(false 则跳过白名单检查,仅做基本格式校验) +SECURITY_URL_ALLOWLIST_ENABLED=false + +# 关闭白名单时,是否允许 http:// URL(默认 false,只允许 https://) +# ⚠️ 警告:允许 HTTP 会暴露 API 密钥(明文传输) +# 仅建议在以下场景使用: +# - 开发/测试环境 +# - 内部可信网络 +# - 本地测试服务器(http://localhost) +# 生产环境:保持 false 或仅使用 HTTPS URL +SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false + +# 是否允许私有 IP 地址用于上游/定价/CRS(内网部署时使用) +SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=false ``` ```bash @@ -276,13 +298,48 @@ default: - `cors.allowed_origins` 配置 CORS 白名单 - `security.url_allowlist` 配置上游/价格数据/CRS 主机白名单 - `security.url_allowlist.enabled` 可关闭 URL 校验(慎用) -- `security.url_allowlist.allow_insecure_http` 关闭校验时允许 http URL +- `security.url_allowlist.allow_insecure_http` 关闭校验时允许 HTTP URL +- `security.url_allowlist.allow_private_hosts` 允许私有/本地 IP 地址 - `security.response_headers.enabled` 可启用可配置响应头过滤(关闭时使用默认白名单) - `security.csp` 配置 Content-Security-Policy - `billing.circuit_breaker` 计费异常时 fail-closed - `server.trusted_proxies` 启用可信代理解析 X-Forwarded-For - `turnstile.required` 在 release 模式强制启用 Turnstile +**⚠️ 安全警告:HTTP URL 配置** + +当 `security.url_allowlist.enabled=false` 时,系统默认执行最小 URL 校验,**拒绝 HTTP URL**,仅允许 HTTPS。要允许 HTTP URL(例如用于开发或内网测试),必须显式设置: + +```yaml +security: + url_allowlist: + enabled: false # 禁用白名单检查 + allow_insecure_http: true # 允许 HTTP URL(⚠️ 不安全) +``` + +**或通过环境变量:** + +```bash +SECURITY_URL_ALLOWLIST_ENABLED=false +SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true +``` + +**允许 HTTP 的风险:** +- API 密钥和数据以**明文传输**(可被截获) +- 易受**中间人攻击 (MITM)** +- **不适合生产环境** + +**适用场景:** +- ✅ 开发/测试环境的本地服务器(http://localhost) +- ✅ 内网可信端点 +- ✅ 获取 HTTPS 前测试账号连通性 +- ❌ 生产环境(仅使用 HTTPS) + +**未设置此项时的错误示例:** +``` +Invalid base URL: invalid url scheme: http +``` + 如关闭 URL 校验或响应头过滤,请加强网络层防护: - 出站访问白名单限制上游域名/IP - 阻断私网/回环/链路本地地址 diff --git a/backend/go.mod b/backend/go.mod index 73bbf95c..9ac48305 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,16 +1,14 @@ module github.com/Wei-Shaw/sub2api -go 1.24.0 - -toolchain go1.24.11 +go 1.25.5 require ( entgo.io/ent v0.14.5 github.com/gin-gonic/gin v1.9.1 - github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 - github.com/imroc/req/v3 v3.56.0 + github.com/imroc/req/v3 v3.57.0 github.com/lib/pq v1.10.9 github.com/redis/go-redis/v9 v9.17.2 github.com/spf13/viper v1.18.2 @@ -20,16 +18,16 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/zeromicro/go-zero v1.9.4 - golang.org/x/crypto v0.44.0 - golang.org/x/net v0.47.0 - golang.org/x/term v0.37.0 + golang.org/x/crypto v0.46.0 + golang.org/x/net v0.48.0 + golang.org/x/sync v0.19.0 + golang.org/x/term v0.38.0 gopkg.in/yaml.v3 v3.0.1 ) require ( ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 // indirect dario.cat/mergo v1.0.2 // indirect - filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/agext/levenshtein v1.2.3 // indirect @@ -64,7 +62,6 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect - github.com/go-sql-driver/mysql v1.9.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect @@ -74,10 +71,8 @@ require ( github.com/hashicorp/hcl/v2 v2.18.1 // indirect github.com/icholy/digest v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jinzhu/inflection v1.0.0 // indirect - github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.1 // indirect + github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect @@ -105,8 +100,8 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/quic-go/qpack v0.5.1 // indirect - github.com/quic-go/quic-go v0.56.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.57.1 // indirect github.com/refraction-networking/utls v1.8.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect @@ -141,16 +136,12 @@ require ( go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/tools v0.39.0 // indirect golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect google.golang.org/grpc v1.75.1 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gorm.io/datatypes v1.2.7 // indirect - gorm.io/driver/mysql v1.5.6 // indirect - gorm.io/gorm v1.30.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 8272855e..38e2b53e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -4,8 +4,6 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4= entgo.io/ent v0.14.5/go.mod h1:zTzLmWtPvGpmSwtkaayM2cm5m819NdM7z7tYPq3vN0U= -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= @@ -96,15 +94,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= -github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo= -github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= -github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -126,8 +121,8 @@ github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZY github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= -github.com/imroc/req/v3 v3.56.0 h1:t6YdqqerYBXhZ9+VjqsQs5wlKxdUNEvsgBhxWc1AEEo= -github.com/imroc/req/v3 v3.56.0/go.mod h1:cUZSooE8hhzFNOrAbdxuemXDQxFXLQTnu3066jr7ZGk= +github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= +github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -138,14 +133,10 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= -github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= -github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= -github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= @@ -219,10 +210,10 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= -github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= -github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY= -github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10= +github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo= @@ -335,16 +326,16 @@ go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTV golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -354,16 +345,16 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY= golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= @@ -386,13 +377,6 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk= -gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY= -gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= -gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= -gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= -gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= -gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 6886d84e..b02a1c97 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -147,7 +147,7 @@ type CSPConfig struct { } type ProxyProbeConfig struct { - InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` + InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` // 已禁用:禁止跳过 TLS 证书验证 } type BillingConfig struct { diff --git a/backend/internal/pkg/httpclient/pool.go b/backend/internal/pkg/httpclient/pool.go index 7bf5cff4..76b7aa91 100644 --- a/backend/internal/pkg/httpclient/pool.go +++ b/backend/internal/pkg/httpclient/pool.go @@ -16,7 +16,6 @@ package httpclient import ( - "crypto/tls" "fmt" "net/http" "net/url" @@ -40,7 +39,7 @@ type Options struct { ProxyURL string // 代理 URL(支持 http/https/socks5/socks5h) Timeout time.Duration // 请求总超时时间 ResponseHeaderTimeout time.Duration // 等待响应头超时时间 - InsecureSkipVerify bool // 是否跳过 TLS 证书验证 + InsecureSkipVerify bool // 是否跳过 TLS 证书验证(已禁用,不允许设置为 true) ProxyStrict bool // 严格代理模式:代理失败时返回错误而非回退 ValidateResolvedIP bool // 是否校验解析后的 IP(防止 DNS Rebinding) AllowPrivateHosts bool // 允许私有地址解析(与 ValidateResolvedIP 一起使用) @@ -113,7 +112,8 @@ func buildTransport(opts Options) (*http.Transport, error) { } if opts.InsecureSkipVerify { - transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + // 安全要求:禁止跳过证书验证,避免中间人攻击。 + return nil, fmt.Errorf("insecure_skip_verify is not allowed; install a trusted certificate instead") } proxyURL := strings.TrimSpace(opts.ProxyURL) diff --git a/backend/internal/repository/proxy_probe_service.go b/backend/internal/repository/proxy_probe_service.go index 218d093f..5c42e4d1 100644 --- a/backend/internal/repository/proxy_probe_service.go +++ b/backend/internal/repository/proxy_probe_service.go @@ -24,7 +24,7 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber { validateResolvedIP = cfg.Security.URLAllowlist.Enabled } if insecure { - log.Printf("[ProxyProbe] Warning: TLS verification is disabled for proxy probing.") + log.Printf("[ProxyProbe] Warning: insecure_skip_verify is not allowed and will cause probe failure.") } return &proxyProbeService{ ipInfoURL: defaultIPInfoURL, diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 91551314..85772e75 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -20,12 +20,16 @@ var ( ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists") ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token") ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired") + ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large") ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked") ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required") ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable") ) +// maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。 +const maxTokenLength = 8192 + // JWTClaims JWT载荷数据 type JWTClaims struct { UserID int64 `json:"user_id"` @@ -309,7 +313,20 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string // ValidateToken 验证JWT token并返回用户声明 func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) { - token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (any, error) { + // 先做长度校验,尽早拒绝异常超长 token,降低 DoS 风险。 + if len(tokenString) > maxTokenLength { + return nil, ErrTokenTooLarge + } + + // 使用解析器并限制可接受的签名算法,防止算法混淆。 + parser := jwt.NewParser(jwt.WithValidMethods([]string{ + jwt.SigningMethodHS256.Name, + jwt.SigningMethodHS384.Name, + jwt.SigningMethodHS512.Name, + })) + + // 保留默认 claims 校验(exp/nbf),避免放行过期或未生效的 token。 + token, err := parser.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (any, error) { // 验证签名方法 if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index d6a3c05b..afd8907c 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -140,6 +140,8 @@ func (s *EmailService) SendEmailWithConfig(config *SMTPConfig, to, subject, body func (s *EmailService) sendMailTLS(addr string, auth smtp.Auth, from, to string, msg []byte, host string) error { tlsConfig := &tls.Config{ ServerName: host, + // 强制 TLS 1.2+,避免协议降级导致的弱加密风险。 + MinVersion: tls.VersionTLS12, } conn, err := tls.Dial("tcp", addr, tlsConfig) @@ -311,7 +313,11 @@ func (s *EmailService) TestSMTPConnectionWithConfig(config *SMTPConfig) error { addr := fmt.Sprintf("%s:%d", config.Host, config.Port) if config.UseTLS { - tlsConfig := &tls.Config{ServerName: config.Host} + tlsConfig := &tls.Config{ + ServerName: config.Host, + // 与发送逻辑一致,显式要求 TLS 1.2+。 + MinVersion: tls.VersionTLS12, + } conn, err := tls.Dial("tcp", addr, tlsConfig) if err != nil { return fmt.Errorf("tls connection failed: %w", err) diff --git a/config.yaml b/config.yaml new file mode 100644 index 00000000..f43c9c19 --- /dev/null +++ b/config.yaml @@ -0,0 +1,390 @@ +# Sub2API Configuration File +# Sub2API 配置文件 +# +# Copy this file to /etc/sub2api/config.yaml and modify as needed +# 复制此文件到 /etc/sub2api/config.yaml 并根据需要修改 +# +# Documentation / 文档: https://github.com/Wei-Shaw/sub2api + +# ============================================================================= +# Server Configuration +# 服务器配置 +# ============================================================================= +server: + # Bind address (0.0.0.0 for all interfaces) + # 绑定地址(0.0.0.0 表示监听所有网络接口) + host: "0.0.0.0" + # Port to listen on + # 监听端口 + port: 8080 + # Mode: "debug" for development, "release" for production + # 运行模式:"debug" 用于开发,"release" 用于生产环境 + mode: "release" + # Trusted proxies for X-Forwarded-For parsing (CIDR/IP). Empty disables trusted proxies. + # 信任的代理地址(CIDR/IP 格式),用于解析 X-Forwarded-For 头。留空则禁用代理信任。 + trusted_proxies: [] + +# ============================================================================= +# Run Mode Configuration +# 运行模式配置 +# ============================================================================= +# Run mode: "standard" (default) or "simple" (for internal use) +# 运行模式:"standard"(默认)或 "simple"(内部使用) +# - standard: Full SaaS features with billing/balance checks +# - standard: 完整 SaaS 功能,包含计费和余额校验 +# - simple: Hides SaaS features and skips billing/balance checks +# - simple: 隐藏 SaaS 功能,跳过计费和余额校验 +run_mode: "standard" + +# ============================================================================= +# CORS Configuration +# 跨域资源共享 (CORS) 配置 +# ============================================================================= +cors: + # Allowed origins list. Leave empty to disable cross-origin requests. + # 允许的来源列表。留空则禁用跨域请求。 + allowed_origins: [] + # Allow credentials (cookies/authorization headers). Cannot be used with "*". + # 允许携带凭证(cookies/授权头)。不能与 "*" 通配符同时使用。 + allow_credentials: true + +# ============================================================================= +# Security Configuration +# 安全配置 +# ============================================================================= +security: + url_allowlist: + # Enable URL allowlist validation (disable to skip all URL checks) + # 启用 URL 白名单验证(禁用则跳过所有 URL 检查) + enabled: false + # Allowed upstream hosts for API proxying + # 允许代理的上游 API 主机列表 + upstream_hosts: + - "api.openai.com" + - "api.anthropic.com" + - "api.kimi.com" + - "open.bigmodel.cn" + - "api.minimaxi.com" + - "generativelanguage.googleapis.com" + - "cloudcode-pa.googleapis.com" + - "*.openai.azure.com" + # Allowed hosts for pricing data download + # 允许下载定价数据的主机列表 + pricing_hosts: + - "raw.githubusercontent.com" + # Allowed hosts for CRS sync (required when using CRS sync) + # 允许 CRS 同步的主机列表(使用 CRS 同步功能时必须配置) + crs_hosts: [] + # Allow localhost/private IPs for upstream/pricing/CRS (use only in trusted networks) + # 允许本地/私有 IP 地址用于上游/定价/CRS(仅在可信网络中使用) + allow_private_hosts: true + # Allow http:// URLs when allowlist is disabled (default: false, require https) + # 白名单禁用时是否允许 http:// URL(默认: false,要求 https) + allow_insecure_http: true + response_headers: + # Enable configurable response header filtering (disable to use default allowlist) + # 启用可配置的响应头过滤(禁用则使用默认白名单) + enabled: false + # Extra allowed response headers from upstream + # 额外允许的上游响应头 + additional_allowed: [] + # Force-remove response headers from upstream + # 强制移除的上游响应头 + force_remove: [] + csp: + # Enable Content-Security-Policy header + # 启用内容安全策略 (CSP) 响应头 + enabled: true + # Default CSP policy (override if you host assets on other domains) + # 默认 CSP 策略(如果静态资源托管在其他域名,请自行覆盖) + policy: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + proxy_probe: + # Allow skipping TLS verification for proxy probe (debug only) + # 允许代理探测时跳过 TLS 证书验证(仅用于调试) + insecure_skip_verify: false + +# ============================================================================= +# Gateway Configuration +# 网关配置 +# ============================================================================= +gateway: + # Timeout for waiting upstream response headers (seconds) + # 等待上游响应头超时时间(秒) + response_header_timeout: 600 + # Max request body size in bytes (default: 100MB) + # 请求体最大字节数(默认 100MB) + max_body_size: 104857600 + # Connection pool isolation strategy: + # 连接池隔离策略: + # - proxy: Isolate by proxy, same proxy shares connection pool (suitable for few proxies, many accounts) + # - proxy: 按代理隔离,同一代理共享连接池(适合代理少、账户多) + # - account: Isolate by account, same account shares connection pool (suitable for few accounts, strict isolation) + # - account: 按账户隔离,同一账户共享连接池(适合账户少、需严格隔离) + # - account_proxy: Isolate by account+proxy combination (default, finest granularity) + # - account_proxy: 按账户+代理组合隔离(默认,最细粒度) + connection_pool_isolation: "account_proxy" + # HTTP upstream connection pool settings (HTTP/2 + multi-proxy scenario defaults) + # HTTP 上游连接池配置(HTTP/2 + 多代理场景默认值) + # Max idle connections across all hosts + # 所有主机的最大空闲连接数 + max_idle_conns: 240 + # Max idle connections per host + # 每个主机的最大空闲连接数 + max_idle_conns_per_host: 120 + # Max connections per host + # 每个主机的最大连接数 + max_conns_per_host: 240 + # Idle connection timeout (seconds) + # 空闲连接超时时间(秒) + idle_conn_timeout_seconds: 90 + # Upstream client cache settings + # 上游连接池客户端缓存配置 + # max_upstream_clients: Max cached clients, evicts least recently used when exceeded + # max_upstream_clients: 最大缓存客户端数量,超出后淘汰最久未使用的 + max_upstream_clients: 5000 + # client_idle_ttl_seconds: Client idle reclaim threshold (seconds), reclaimed when idle and no active requests + # client_idle_ttl_seconds: 客户端空闲回收阈值(秒),超时且无活跃请求时回收 + client_idle_ttl_seconds: 900 + # Concurrency slot expiration time (minutes) + # 并发槽位过期时间(分钟) + concurrency_slot_ttl_minutes: 30 + # Stream data interval timeout (seconds), 0=disable + # 流数据间隔超时(秒),0=禁用 + stream_data_interval_timeout: 180 + # Stream keepalive interval (seconds), 0=disable + # 流式 keepalive 间隔(秒),0=禁用 + stream_keepalive_interval: 10 + # SSE max line size in bytes (default: 10MB) + # SSE 单行最大字节数(默认 10MB) + max_line_size: 10485760 + # Log upstream error response body summary (safe/truncated; does not log request content) + # 记录上游错误响应体摘要(安全/截断;不记录请求内容) + log_upstream_error_body: false + # Max bytes to log from upstream error body + # 记录上游错误响应体的最大字节数 + log_upstream_error_body_max_bytes: 2048 + # Auto inject anthropic-beta header for API-key accounts when needed (default: off) + # 需要时自动为 API-key 账户注入 anthropic-beta 头(默认:关闭) + inject_beta_for_apikey: false + # Allow failover on selected 400 errors (default: off) + # 允许在特定 400 错误时进行故障转移(默认:关闭) + failover_on_400: false + +# ============================================================================= +# Concurrency Wait Configuration +# 并发等待配置 +# ============================================================================= +concurrency: + # SSE ping interval during concurrency wait (seconds) + # 并发等待期间的 SSE ping 间隔(秒) + ping_interval: 10 + +# ============================================================================= +# Database Configuration (PostgreSQL) +# 数据库配置 (PostgreSQL) +# ============================================================================= +database: + # Database host address + # 数据库主机地址 + host: "localhost" + # Database port + # 数据库端口 + port: 5432 + # Database username + # 数据库用户名 + user: "postgres" + # Database password + # 数据库密码 + password: "your_secure_password_here" + # Database name + # 数据库名称 + dbname: "sub2api" + # SSL mode: disable, require, verify-ca, verify-full + # SSL 模式:disable(禁用), require(要求), verify-ca(验证CA), verify-full(完全验证) + sslmode: "disable" + +# ============================================================================= +# Redis Configuration +# Redis 配置 +# ============================================================================= +redis: + # Redis host address + # Redis 主机地址 + host: "localhost" + # Redis port + # Redis 端口 + port: 6379 + # Redis password (leave empty if no password is set) + # Redis 密码(如果未设置密码则留空) + password: "" + # Database number (0-15) + # 数据库编号(0-15) + db: 0 + +# ============================================================================= +# JWT Configuration +# JWT 配置 +# ============================================================================= +jwt: + # IMPORTANT: Change this to a random string in production! + # 重要:生产环境中请更改为随机字符串! + # Generate with / 生成命令: openssl rand -hex 32 + secret: "change-this-to-a-secure-random-string" + # Token expiration time in hours (max 24) + # 令牌过期时间(小时,最大 24) + expire_hour: 24 + +# ============================================================================= +# Default Settings +# 默认设置 +# ============================================================================= +default: + # Initial admin account (created on first run) + # 初始管理员账户(首次运行时创建) + admin_email: "admin@example.com" + admin_password: "admin123" + + # Default settings for new users + # 新用户默认设置 + # Max concurrent requests per user + # 每用户最大并发请求数 + user_concurrency: 5 + # Initial balance for new users + # 新用户初始余额 + user_balance: 0 + + # API key settings + # API 密钥设置 + # Prefix for generated API keys + # 生成的 API 密钥前缀 + api_key_prefix: "sk-" + + # Rate multiplier (affects billing calculation) + # 费率倍数(影响计费计算) + rate_multiplier: 1.0 + +# ============================================================================= +# Rate Limiting +# 速率限制 +# ============================================================================= +rate_limit: + # Cooldown time (in minutes) when upstream returns 529 (overloaded) + # 上游返回 529(过载)时的冷却时间(分钟) + overload_cooldown_minutes: 10 + +# ============================================================================= +# Pricing Data Source (Optional) +# 定价数据源(可选) +# ============================================================================= +pricing: + # URL to fetch model pricing data (default: LiteLLM) + # 获取模型定价数据的 URL(默认:LiteLLM) + remote_url: "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json" + # Hash verification URL (optional) + # 哈希校验 URL(可选) + hash_url: "" + # Local data directory for caching + # 本地数据缓存目录 + data_dir: "./data" + # Fallback pricing file + # 备用定价文件 + fallback_file: "./resources/model-pricing/model_prices_and_context_window.json" + # Update interval in hours + # 更新间隔(小时) + update_interval_hours: 24 + # Hash check interval in minutes + # 哈希检查间隔(分钟) + hash_check_interval_minutes: 10 + +# ============================================================================= +# Billing Configuration +# 计费配置 +# ============================================================================= +billing: + circuit_breaker: + # Enable circuit breaker for billing service + # 启用计费服务熔断器 + enabled: true + # Number of failures before opening circuit + # 触发熔断的失败次数阈值 + failure_threshold: 5 + # Time to wait before attempting reset (seconds) + # 熔断后重试等待时间(秒) + reset_timeout_seconds: 30 + # Number of requests to allow in half-open state + # 半开状态允许通过的请求数 + half_open_requests: 3 + +# ============================================================================= +# Turnstile Configuration +# Turnstile 人机验证配置 +# ============================================================================= +turnstile: + # Require Turnstile in release mode (when enabled, login/register will fail if not configured) + # 在 release 模式下要求 Turnstile 验证(启用后,若未配置则登录/注册会失败) + required: false + +# ============================================================================= +# Gemini OAuth (Required for Gemini accounts) +# Gemini OAuth 配置(Gemini 账户必需) +# ============================================================================= +# Sub2API supports TWO Gemini OAuth modes: +# Sub2API 支持两种 Gemini OAuth 模式: +# +# 1. Code Assist OAuth (requires GCP project_id) +# 1. Code Assist OAuth(需要 GCP project_id) +# - Uses: cloudcode-pa.googleapis.com (Code Assist API) +# - 使用:cloudcode-pa.googleapis.com(Code Assist API) +# +# 2. AI Studio OAuth (no project_id needed) +# 2. AI Studio OAuth(不需要 project_id) +# - Uses: generativelanguage.googleapis.com (AI Studio API) +# - 使用:generativelanguage.googleapis.com(AI Studio API) +# +# Default: Uses Gemini CLI's public OAuth credentials (same as Google's official CLI tool) +# 默认:使用 Gemini CLI 的公开 OAuth 凭证(与 Google 官方 CLI 工具相同) +gemini: + oauth: + # Gemini CLI public OAuth credentials (works for both Code Assist and AI Studio) + # Gemini CLI 公开 OAuth 凭证(适用于 Code Assist 和 AI Studio) + client_id: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" + client_secret: "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" + # Optional scopes (space-separated). Leave empty to auto-select based on oauth_type. + # 可选的权限范围(空格分隔)。留空则根据 oauth_type 自动选择。 + scopes: "" + quota: + # Optional: local quota simulation for Gemini Code Assist (local billing). + # 可选:Gemini Code Assist 本地配额模拟(本地计费)。 + # These values are used for UI progress + precheck scheduling, not official Google quotas. + # 这些值用于 UI 进度显示和预检调度,并非 Google 官方配额。 + tiers: + LEGACY: + # Pro model requests per day + # Pro 模型每日请求数 + pro_rpd: 50 + # Flash model requests per day + # Flash 模型每日请求数 + flash_rpd: 1500 + # Cooldown time (minutes) after hitting quota + # 达到配额后的冷却时间(分钟) + cooldown_minutes: 30 + PRO: + # Pro model requests per day + # Pro 模型每日请求数 + pro_rpd: 1500 + # Flash model requests per day + # Flash 模型每日请求数 + flash_rpd: 4000 + # Cooldown time (minutes) after hitting quota + # 达到配额后的冷却时间(分钟) + cooldown_minutes: 5 + ULTRA: + # Pro model requests per day + # Pro 模型每日请求数 + pro_rpd: 2000 + # Flash model requests per day (0 = unlimited) + # Flash 模型每日请求数(0 = 无限制) + flash_rpd: 0 + # Cooldown time (minutes) after hitting quota + # 达到配额后的冷却时间(分钟) + cooldown_minutes: 5 diff --git a/deploy/.env.example b/deploy/.env.example index 60ec51ec..d9e81959 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -66,6 +66,24 @@ JWT_EXPIRE_HOUR=24 # Leave unset to use default ./config.yaml #CONFIG_FILE=./config.yaml +# ----------------------------------------------------------------------------- +# Security Configuration +# ----------------------------------------------------------------------------- +# URL Allowlist Configuration +# 启用 URL 白名单验证(false 则跳过白名单检查,仅做基本格式校验) +SECURITY_URL_ALLOWLIST_ENABLED=false + +# 关闭白名单时,是否允许 http:// URL(默认 false,只允许 https://) +# ⚠️ 警告:允许 HTTP 存在安全风险(明文传输),仅建议在开发/测试环境或可信内网中使用 +# Allow insecure HTTP URLs when allowlist is disabled (default: false, requires https) +# ⚠️ WARNING: Allowing HTTP has security risks (plaintext transmission) +# Only recommended for dev/test environments or trusted networks +SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true + +# 是否允许本地/私有 IP 地址用于上游/定价/CRS(仅在可信网络中使用) +# Allow localhost/private IPs for upstream/pricing/CRS (use only in trusted networks) +SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=true + # ----------------------------------------------------------------------------- # Gemini OAuth (OPTIONAL, required only for Gemini OAuth accounts) # ----------------------------------------------------------------------------- diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index ca9ea031..1c9d06b0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -98,9 +98,14 @@ services: # ======================================================================= # Security Configuration (URL Allowlist) # ======================================================================= - - SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-} - # Allow private IP addresses for CRS sync (for internal deployments) + # Enable URL allowlist validation (false to skip allowlist checks) + - SECURITY_URL_ALLOWLIST_ENABLED=${SECURITY_URL_ALLOWLIST_ENABLED:-false} + # Allow insecure HTTP URLs when allowlist is disabled (default: false, requires https) + - SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=${SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP:-false} + # Allow private IP addresses for upstream/pricing/CRS (for internal deployments) - SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-false} + # Upstream hosts whitelist (comma-separated, only used when enabled=true) + - SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-} depends_on: postgres: condition: service_healthy diff --git a/docs/dependency-security.md b/docs/dependency-security.md new file mode 100644 index 00000000..66545011 --- /dev/null +++ b/docs/dependency-security.md @@ -0,0 +1,58 @@ +# Dependency Security + +This document describes how dependency and toolchain security is managed in this repo. + +## Go Toolchain Policy (Pinned to 1.25.5) + +The Go toolchain is pinned to 1.25.5 to address known security issues. + +Locations that MUST stay aligned: +- `backend/go.mod`: `go 1.25.5` and `toolchain go1.25.5` +- `Dockerfile`: `GOLANG_IMAGE=golang:1.25.5-alpine` +- Workflows: use `go-version-file: backend/go.mod` and verify `go1.25.5` + +Update process: +1. Change `backend/go.mod` (go + toolchain) to the new patch version. +2. Update `Dockerfile` GOLANG_IMAGE to the same patch version. +3. Update workflows if needed and keep the `go version` check in place. +4. Run `govulncheck` and the CI security scan workflow. + +## Security Scans + +Automated scans run via `.github/workflows/security-scan.yml`: +- `govulncheck` for Go dependencies +- `gosec` for static security issues +- `pnpm audit` for frontend production dependencies + +Policy: +- High/Critical findings fail the build unless explicitly exempted. +- Exemptions must include mitigation and an expiry date. + +## Audit Exceptions + +Exception list location: `.github/audit-exceptions.yml` + +Required fields: +- `package` +- `advisory` (GHSA ID or advisory URL from pnpm audit) +- `severity` +- `mitigation` +- `expires_on` (recommended <= 90 days) + +Process: +1. Add an exception with mitigation details and an expiry date. +2. Ensure the exception is reviewed before expiry. +3. Remove the exception when the dependency is upgraded or replaced. + +## Frontend xlsx Mitigation (Plan A) + +Current mitigation: +- Use dynamic import so `xlsx` only loads during export. +- Keep export access restricted and data scope limited. + +## Rollback Guidance + +If a change causes issues: +- Go: revert `backend/go.mod` and `Dockerfile` to the previous version. +- Frontend: revert the dynamic import change if needed. +- CI: remove exception entries and re-run scans to confirm status. diff --git a/frontend/audit.json b/frontend/audit.json new file mode 100644 index 00000000..18831c33 --- /dev/null +++ b/frontend/audit.json @@ -0,0 +1,118 @@ +{ + "actions": [ + { + "action": "review", + "module": "xlsx", + "resolves": [ + { + "id": 1108110, + "path": ".>xlsx", + "dev": false, + "bundled": false, + "optional": false + }, + { + "id": 1108111, + "path": ".>xlsx", + "dev": false, + "bundled": false, + "optional": false + } + ] + } + ], + "advisories": { + "1108110": { + "findings": [ + { + "version": "0.18.5", + "paths": [ + ".>xlsx" + ] + } + ], + "found_by": null, + "deleted": null, + "references": "- https://nvd.nist.gov/vuln/detail/CVE-2023-30533\n- https://cdn.sheetjs.com/advisories/CVE-2023-30533\n- https://git.sheetjs.com/sheetjs/sheetjs/src/branch/master/CHANGELOG.md\n- https://git.sheetjs.com/sheetjs/sheetjs/issues/2667\n- https://git.sheetjs.com/sheetjs/sheetjs/issues/2986\n- https://cdn.sheetjs.com\n- https://github.com/advisories/GHSA-4r6h-8v6p-xvw6", + "created": "2023-04-24T09:30:19.000Z", + "id": 1108110, + "npm_advisory_id": null, + "overview": "All versions of SheetJS CE through 0.19.2 are vulnerable to \"Prototype Pollution\" when reading specially crafted files. Workflows that do not read arbitrary files (for example, exporting data to spreadsheet files) are unaffected.\n\nA non-vulnerable version cannot be found via npm, as the repository hosted on GitHub and the npm package `xlsx` are no longer maintained. Version 0.19.3 can be downloaded via https://cdn.sheetjs.com/.", + "reported_by": null, + "title": "Prototype Pollution in sheetJS", + "metadata": null, + "cves": [ + "CVE-2023-30533" + ], + "access": "public", + "severity": "high", + "module_name": "xlsx", + "vulnerable_versions": "<0.19.3", + "github_advisory_id": "GHSA-4r6h-8v6p-xvw6", + "recommendation": "None", + "patched_versions": "<0.0.0", + "updated": "2025-09-19T15:23:41.000Z", + "cvss": { + "score": 7.8, + "vectorString": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H" + }, + "cwe": [ + "CWE-1321" + ], + "url": "https://github.com/advisories/GHSA-4r6h-8v6p-xvw6" + }, + "1108111": { + "findings": [ + { + "version": "0.18.5", + "paths": [ + ".>xlsx" + ] + } + ], + "found_by": null, + "deleted": null, + "references": "- https://nvd.nist.gov/vuln/detail/CVE-2024-22363\n- https://cdn.sheetjs.com/advisories/CVE-2024-22363\n- https://cwe.mitre.org/data/definitions/1333.html\n- https://git.sheetjs.com/sheetjs/sheetjs/src/tag/v0.20.2\n- https://cdn.sheetjs.com\n- https://github.com/advisories/GHSA-5pgg-2g8v-p4x9", + "created": "2024-04-05T06:30:46.000Z", + "id": 1108111, + "npm_advisory_id": null, + "overview": "SheetJS Community Edition before 0.20.2 is vulnerable.to Regular Expression Denial of Service (ReDoS).\n\nA non-vulnerable version cannot be found via npm, as the repository hosted on GitHub and the npm package `xlsx` are no longer maintained. Version 0.20.2 can be downloaded via https://cdn.sheetjs.com/.", + "reported_by": null, + "title": "SheetJS Regular Expression Denial of Service (ReDoS)", + "metadata": null, + "cves": [ + "CVE-2024-22363" + ], + "access": "public", + "severity": "high", + "module_name": "xlsx", + "vulnerable_versions": "<0.20.2", + "github_advisory_id": "GHSA-5pgg-2g8v-p4x9", + "recommendation": "None", + "patched_versions": "<0.0.0", + "updated": "2025-09-19T15:23:26.000Z", + "cvss": { + "score": 7.5, + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + }, + "cwe": [ + "CWE-1333" + ], + "url": "https://github.com/advisories/GHSA-5pgg-2g8v-p4x9" + } + }, + "muted": [], + "metadata": { + "vulnerabilities": { + "info": 0, + "low": 0, + "moderate": 0, + "high": 2, + "critical": 0 + }, + "dependencies": 639, + "devDependencies": 0, + "optionalDependencies": 0, + "totalDependencies": 639 + } +} diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index 8d3fe19f..edc1e194 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -12,7 +12,7 @@ \ No newline at end of file + diff --git a/tools/check_pnpm_audit_exceptions.py b/tools/check_pnpm_audit_exceptions.py new file mode 100644 index 00000000..34f95a58 --- /dev/null +++ b/tools/check_pnpm_audit_exceptions.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +import argparse +import json +import sys +from datetime import date + + +HIGH_SEVERITIES = {"high", "critical"} +REQUIRED_FIELDS = {"package", "advisory", "severity", "mitigation", "expires_on"} + + +def split_kv(line: str) -> tuple[str, str]: + # 解析 "key: value" 形式的简单 YAML 行,并去除引号。 + key, value = line.split(":", 1) + value = value.strip() + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + return key.strip(), value + + +def parse_exceptions(path: str) -> list[dict]: + # 轻量解析异常清单,避免引入额外依赖。 + exceptions = [] + current = None + with open(path, "r", encoding="utf-8") as handle: + for raw in handle: + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("version:") or line.startswith("exceptions:"): + continue + if line.startswith("- "): + if current: + exceptions.append(current) + current = {} + line = line[2:].strip() + if line: + key, value = split_kv(line) + current[key] = value + continue + if current is not None and ":" in line: + key, value = split_kv(line) + current[key] = value + if current: + exceptions.append(current) + return exceptions + + +def pick_advisory_id(advisory: dict) -> str | None: + # 优先使用可稳定匹配的标识(GHSA/URL/CVE),避免误匹配到其他同名漏洞。 + return ( + advisory.get("github_advisory_id") + or advisory.get("url") + or (advisory.get("cves") or [None])[0] + or (str(advisory.get("id")) if advisory.get("id") is not None else None) + or advisory.get("title") + or advisory.get("advisory") + or advisory.get("overview") + ) + + +def iter_vulns(data: dict): + # 兼容 pnpm audit 的不同输出结构(advisories / vulnerabilities),并提取 advisory 标识。 + advisories = data.get("advisories") + if isinstance(advisories, dict): + for advisory in advisories.values(): + name = advisory.get("module_name") or advisory.get("name") + severity = advisory.get("severity") + advisory_id = pick_advisory_id(advisory) + title = ( + advisory.get("title") + or advisory.get("advisory") + or advisory.get("overview") + or advisory.get("url") + ) + yield name, severity, advisory_id, title + + vulnerabilities = data.get("vulnerabilities") + if isinstance(vulnerabilities, dict): + for name, vuln in vulnerabilities.items(): + severity = vuln.get("severity") + via = vuln.get("via", []) + titles = [] + advisories = [] + if isinstance(via, list): + for item in via: + if isinstance(item, dict): + advisories.append( + item.get("github_advisory_id") + or item.get("url") + or item.get("source") + or item.get("title") + or item.get("name") + ) + titles.append( + item.get("title") + or item.get("url") + or item.get("advisory") + or item.get("source") + ) + elif isinstance(item, str): + advisories.append(item) + titles.append(item) + elif isinstance(via, str): + advisories.append(via) + titles.append(via) + title = "; ".join([t for t in titles if t]) + for advisory_id in [a for a in advisories if a]: + yield name, severity, advisory_id, title + + +def normalize_severity(severity: str) -> str: + # 统一大小写,避免比较失败。 + return (severity or "").strip().lower() + + +def normalize_package(name: str) -> str: + # 包名只去掉首尾空白,保留原始大小写,同时兼容非字符串输入。 + if name is None: + return "" + return str(name).strip() + + +def normalize_advisory(advisory: str) -> str: + # advisory 统一为小写匹配,避免 GHSA/URL 因大小写差异导致漏匹配。 + # pnpm 的 source 字段可能是数字,这里统一转为字符串以保证可比较。 + if advisory is None: + return "" + return str(advisory).strip().lower() + + +def parse_date(value: str) -> date | None: + # 仅接受 ISO8601 日期格式,非法值视为无效。 + try: + return date.fromisoformat(value) + except ValueError: + return None + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--audit", required=True) + parser.add_argument("--exceptions", required=True) + args = parser.parse_args() + + with open(args.audit, "r", encoding="utf-8") as handle: + audit = json.load(handle) + + # 读取异常清单并建立索引,便于快速匹配包名 + advisory。 + exceptions = parse_exceptions(args.exceptions) + exception_index = {} + errors = [] + + for exc in exceptions: + missing = [field for field in REQUIRED_FIELDS if not exc.get(field)] + if missing: + errors.append( + f"Exception missing required fields {missing}: {exc.get('package', '')}" + ) + continue + exc_severity = normalize_severity(exc.get("severity")) + exc_package = normalize_package(exc.get("package")) + exc_advisory = normalize_advisory(exc.get("advisory")) + exc_date = parse_date(exc.get("expires_on")) + if exc_date is None: + errors.append( + f"Exception has invalid expires_on date: {exc.get('package', '')}" + ) + continue + if not exc_package or not exc_advisory: + errors.append("Exception missing package or advisory value") + continue + key = (exc_package, exc_advisory) + if key in exception_index: + errors.append( + f"Duplicate exception for {exc_package} advisory {exc.get('advisory')}" + ) + continue + exception_index[key] = { + "raw": exc, + "severity": exc_severity, + "expires_on": exc_date, + } + + today = date.today() + missing_exceptions = [] + expired_exceptions = [] + + # 去重处理:同一包名 + advisory 可能在不同字段重复出现。 + seen = set() + for name, severity, advisory_id, title in iter_vulns(audit): + sev = normalize_severity(severity) + if sev not in HIGH_SEVERITIES or not name: + continue + advisory_key = normalize_advisory(advisory_id) + if not advisory_key: + errors.append( + f"High/Critical vulnerability missing advisory id: {name} ({sev})" + ) + continue + key = (normalize_package(name), advisory_key) + if key in seen: + continue + seen.add(key) + exc = exception_index.get(key) + if exc is None: + missing_exceptions.append((name, sev, advisory_id, title)) + continue + if exc["severity"] and exc["severity"] != sev: + errors.append( + "Exception severity mismatch: " + f"{name} ({advisory_id}) expected {sev}, got {exc['severity']}" + ) + if exc["expires_on"] and exc["expires_on"] < today: + expired_exceptions.append( + (name, sev, advisory_id, exc["expires_on"].isoformat()) + ) + + if missing_exceptions: + errors.append("High/Critical vulnerabilities missing exceptions:") + for name, sev, advisory_id, title in missing_exceptions: + label = f"{name} ({sev})" + if advisory_id: + label = f"{label} [{advisory_id}]" + if title: + label = f"{label}: {title}" + errors.append(f"- {label}") + + if expired_exceptions: + errors.append("Exceptions expired:") + for name, sev, advisory_id, expires_on in expired_exceptions: + errors.append( + f"- {name} ({sev}) [{advisory_id}] expired on {expires_on}" + ) + + if errors: + sys.stderr.write("\n".join(errors) + "\n") + return 1 + + print("Audit exceptions validated.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4790aced1571e25f05d52be091f8a4b10943e6b8 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Tue, 6 Jan 2026 12:42:06 +0800 Subject: [PATCH 03/22] =?UTF-8?q?fix(=E5=89=8D=E7=AB=AF):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20ESLint=20=E4=BB=A3=E7=A0=81=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 AccountTableFilters.vue 中的 vue/no-mutating-props 错误,使用 emit 模式替代直接修改 props - 修复 TypeScript 类型错误,支持 Select 组件的 null 值类型 - 为所有空 catch 块添加错误日志,提升代码可维护性和调试能力 - 涉及文件:AccountTableFilters.vue, UserAllowedGroupsModal.vue, UserApiKeysModal.vue, UserBalanceModal.vue, AccountsView.vue, UsageView.vue, DashboardView.vue, ProfileView.vue 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../admin/account/AccountTableFilters.vue | 8 +++++--- .../admin/user/UserAllowedGroupsModal.vue | 4 ++-- .../src/components/admin/user/UserApiKeysModal.vue | 2 +- .../src/components/admin/user/UserBalanceModal.vue | 2 +- frontend/src/views/admin/AccountsView.vue | 14 +++++++------- frontend/src/views/admin/UsageView.vue | 4 ++-- frontend/src/views/user/DashboardView.vue | 6 +++--- frontend/src/views/user/ProfileView.vue | 2 +- 8 files changed, 22 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/admin/account/AccountTableFilters.vue b/frontend/src/components/admin/account/AccountTableFilters.vue index 3721acc6..457afe8c 100644 --- a/frontend/src/components/admin/account/AccountTableFilters.vue +++ b/frontend/src/components/admin/account/AccountTableFilters.vue @@ -9,15 +9,17 @@ />
- +
diff --git a/frontend/src/components/admin/user/UserAllowedGroupsModal.vue b/frontend/src/components/admin/user/UserAllowedGroupsModal.vue index 669772e3..e538e4e5 100644 --- a/frontend/src/components/admin/user/UserAllowedGroupsModal.vue +++ b/frontend/src/components/admin/user/UserAllowedGroupsModal.vue @@ -48,12 +48,12 @@ const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const a const groups = ref([]); const selectedIds = ref([]); const loading = ref(false); const submitting = ref(false) watch(() => props.show, (v) => { if(v && props.user) { selectedIds.value = props.user.allowed_groups || []; load() } }) -const load = async () => { loading.value = true; try { const res = await adminAPI.groups.list(1, 1000); groups.value = res.items.filter(g => g.subscription_type === 'standard' && g.status === 'active') } catch {} finally { loading.value = false } } +const load = async () => { loading.value = true; try { const res = await adminAPI.groups.list(1, 1000); groups.value = res.items.filter(g => g.subscription_type === 'standard' && g.status === 'active') } catch (error) { console.error('Failed to load groups:', error) } finally { loading.value = false } } const handleSave = async () => { if (!props.user) return; submitting.value = true try { await adminAPI.users.update(props.user.id, { allowed_groups: selectedIds.value.length > 0 ? selectedIds.value : null }) appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close') - } catch {} finally { submitting.value = false } + } catch (error) { console.error('Failed to update allowed groups:', error) } finally { submitting.value = false } } \ No newline at end of file diff --git a/frontend/src/components/admin/user/UserApiKeysModal.vue b/frontend/src/components/admin/user/UserApiKeysModal.vue index 27c006bc..ef098ba1 100644 --- a/frontend/src/components/admin/user/UserApiKeysModal.vue +++ b/frontend/src/components/admin/user/UserApiKeysModal.vue @@ -42,6 +42,6 @@ const apiKeys = ref([]); const loading = ref(false) watch(() => props.show, (v) => { if (v && props.user) load() }) const load = async () => { if (!props.user) return; loading.value = true - try { const res = await adminAPI.users.getUserApiKeys(props.user.id); apiKeys.value = res.items || [] } catch {} finally { loading.value = false } + try { const res = await adminAPI.users.getUserApiKeys(props.user.id); apiKeys.value = res.items || [] } catch (error) { console.error('Failed to load API keys:', error) } finally { loading.value = false } } \ No newline at end of file diff --git a/frontend/src/components/admin/user/UserBalanceModal.vue b/frontend/src/components/admin/user/UserBalanceModal.vue index 19e9ccab..41050629 100644 --- a/frontend/src/components/admin/user/UserBalanceModal.vue +++ b/frontend/src/components/admin/user/UserBalanceModal.vue @@ -41,6 +41,6 @@ const handleBalanceSubmit = async () => { try { await adminAPI.users.updateBalance(props.user.id, form.amount, props.operation, form.notes) appStore.showSuccess(t('common.success')); emit('success'); emit('close') - } catch {} finally { submitting.value = false } + } catch (error) { console.error('Failed to update balance:', error) } finally { submitting.value = false } } \ No newline at end of file diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 5a3138c9..c886de01 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -191,7 +191,7 @@ const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true } const openMenu = (a: Account, e: MouseEvent) => { menu.acc = a; menu.pos = { top: e.clientY, left: e.clientX - 200 }; menu.show = true } const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) } const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] } -const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch {} } +const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } } const handleBulkUpdated = () => { showBulkEdit.value = false; selIds.value = []; reload() } const closeTestModal = () => { showTest.value = false; testingAcc.value = null } const closeStatsModal = () => { showStats.value = false; statsAcc.value = null } @@ -199,14 +199,14 @@ const closeReAuthModal = () => { showReAuth.value = false; reAuthAcc.value = nul const handleTest = (a: Account) => { testingAcc.value = a; showTest.value = true } const handleViewStats = (a: Account) => { statsAcc.value = a; showStats.value = true } const handleReAuth = (a: Account) => { reAuthAcc.value = a; showReAuth.value = true } -const handleRefresh = async (a: Account) => { try { await adminAPI.accounts.refreshCredentials(a.id); load() } catch {} } -const handleResetStatus = async (a: Account) => { try { await adminAPI.accounts.clearError(a.id); appStore.showSuccess(t('common.success')); load() } catch {} } -const handleClearRateLimit = async (a: Account) => { try { await adminAPI.accounts.clearError(a.id); appStore.showSuccess(t('common.success')); load() } catch {} } +const handleRefresh = async (a: Account) => { try { await adminAPI.accounts.refreshCredentials(a.id); load() } catch (error) { console.error('Failed to refresh credentials:', error) } } +const handleResetStatus = async (a: Account) => { try { await adminAPI.accounts.clearError(a.id); appStore.showSuccess(t('common.success')); load() } catch (error) { console.error('Failed to reset status:', error) } } +const handleClearRateLimit = async (a: Account) => { try { await adminAPI.accounts.clearError(a.id); appStore.showSuccess(t('common.success')); load() } catch (error) { console.error('Failed to clear rate limit:', error) } } const handleDelete = (a: Account) => { deletingAcc.value = a; showDeleteDialog.value = true } -const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch {} } +const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch (error) { console.error('Failed to delete account:', error) } } const handleToggleSchedulable = async (a: Account) => { togglingSchedulable.value = a.id; try { await adminAPI.accounts.update(a.id, { schedulable: !a.schedulable }); load() } finally { togglingSchedulable.value = null } } const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true } -const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch {} } +const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch (error) { console.error('Failed to reset temp unscheduled:', error) } } -onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch {} }) +onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch (error) { console.error('Failed to load proxies/groups:', error) } }) diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index edc1e194..03324e6e 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -35,9 +35,9 @@ const loadLogs = async () => { try { const res = await adminAPI.usage.list({ page: pagination.page, page_size: pagination.page_size, ...filters.value }, { signal: c.signal }) if(!c.signal.aborted) { usageLogs.value = res.items; pagination.total = res.total } - } catch {} finally { if(abortController === c) loading.value = false } + } catch (error: any) { if(error?.name !== 'AbortError') console.error('Failed to load usage logs:', error) } finally { if(abortController === c) loading.value = false } } -const loadStats = async () => { try { const s = await adminAPI.usage.getStats(filters.value); usageStats.value = s } catch {} } +const loadStats = async () => { try { const s = await adminAPI.usage.getStats(filters.value); usageStats.value = s } catch (error) { console.error('Failed to load usage stats:', error) } } const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats() } const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value }; applyFilters() } const handlePageChange = (p: number) => { pagination.page = p; loadLogs() } diff --git a/frontend/src/views/user/DashboardView.vue b/frontend/src/views/user/DashboardView.vue index 39d2f877..55bbc66b 100644 --- a/frontend/src/views/user/DashboardView.vue +++ b/frontend/src/views/user/DashboardView.vue @@ -28,9 +28,9 @@ const trendData = ref([]); const modelStats = ref const formatLD = (d: Date) => d.toISOString().split('T')[0] const startDate = ref(formatLD(new Date(Date.now() - 6 * 86400000))); const endDate = ref(formatLD(new Date())); const granularity = ref('day') -const loadStats = async () => { loading.value = true; try { await authStore.refreshUser(); stats.value = await usageAPI.getDashboardStats() } catch {} finally { loading.value = false } } -const loadCharts = async () => { loadingCharts.value = true; try { const res = await Promise.all([usageAPI.getDashboardTrend({ start_date: startDate.value, end_date: endDate.value, granularity: granularity.value as any }), usageAPI.getDashboardModels({ start_date: startDate.value, end_date: endDate.value })]); trendData.value = res[0].trend || []; modelStats.value = res[1].models || [] } catch {} finally { loadingCharts.value = false } } -const loadRecent = async () => { loadingUsage.value = true; try { const res = await usageAPI.getByDateRange(startDate.value, endDate.value); recentUsage.value = res.items.slice(0, 5) } catch {} finally { loadingUsage.value = false } } +const loadStats = async () => { loading.value = true; try { await authStore.refreshUser(); stats.value = await usageAPI.getDashboardStats() } catch (error) { console.error('Failed to load dashboard stats:', error) } finally { loading.value = false } } +const loadCharts = async () => { loadingCharts.value = true; try { const res = await Promise.all([usageAPI.getDashboardTrend({ start_date: startDate.value, end_date: endDate.value, granularity: granularity.value as any }), usageAPI.getDashboardModels({ start_date: startDate.value, end_date: endDate.value })]); trendData.value = res[0].trend || []; modelStats.value = res[1].models || [] } catch (error) { console.error('Failed to load charts:', error) } finally { loadingCharts.value = false } } +const loadRecent = async () => { loadingUsage.value = true; try { const res = await usageAPI.getByDateRange(startDate.value, endDate.value); recentUsage.value = res.items.slice(0, 5) } catch (error) { console.error('Failed to load recent usage:', error) } finally { loadingUsage.value = false } } onMounted(() => { loadStats(); loadCharts(); loadRecent() }) diff --git a/frontend/src/views/user/ProfileView.vue b/frontend/src/views/user/ProfileView.vue index eaf98b77..4b6511ea 100644 --- a/frontend/src/views/user/ProfileView.vue +++ b/frontend/src/views/user/ProfileView.vue @@ -35,6 +35,6 @@ const WalletIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24' const BoltIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' })]) } const CalendarIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M6.75 3v2.25M17.25 3v2.25' })]) } -onMounted(async () => { try { const s = await authAPI.getPublicSettings(); contactInfo.value = s.contact_info || '' } catch {} }) +onMounted(async () => { try { const s = await authAPI.getPublicSettings(); contactInfo.value = s.contact_info || '' } catch (error) { console.error('Failed to load contact info:', error) } }) const formatCurrency = (v: number) => `$${v.toFixed(2)}` \ No newline at end of file From 0c7a58fcc77388d27d40b3bcaf281e95439cf7c4 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Tue, 6 Jan 2026 12:56:29 +0800 Subject: [PATCH 04/22] =?UTF-8?q?fix(=E9=85=8D=E7=BD=AE):=20=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=20URL=20=E5=AE=89=E5=85=A8=E9=85=8D=E7=BD=AE=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=80=BC=E4=B8=BA=E5=BC=80=E5=8F=91=E5=8F=8B=E5=A5=BD?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调整以下配置的默认值以匹配 .env.example: - allow_insecure_http: false → true (允许 HTTP URL) - allow_private_hosts: false → true (允许本地/私有 IP) **改动说明:** - 默认允许 HTTP URL,方便开发测试环境使用 - 默认允许本地和私有 IP 地址 - 与 deploy/.env.example 中的推荐配置保持一致 - 更新相应的单元测试以验证新的默认值 **安全提示:** ⚠️ 这些默认值适合开发/测试环境 ⚠️ 生产环境建议显式配置更严格的安全策略 ⚠️ HTTP 存在明文传输风险,仅在可信网络中使用 **测试结果:** - ✅ 所有单元测试通过 - ✅ golangci-lint 无问题 相关文件: - backend/internal/config/config.go:451-452 - backend/internal/config/config_test.go:83-88 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/internal/config/config.go | 4 ++-- backend/internal/config/config_test.go | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index cab6ce14..e49c188b 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -448,8 +448,8 @@ func setDefaults() { "raw.githubusercontent.com", }) viper.SetDefault("security.url_allowlist.crs_hosts", []string{}) - viper.SetDefault("security.url_allowlist.allow_private_hosts", false) - viper.SetDefault("security.url_allowlist.allow_insecure_http", false) + viper.SetDefault("security.url_allowlist.allow_private_hosts", true) + viper.SetDefault("security.url_allowlist.allow_insecure_http", true) viper.SetDefault("security.response_headers.enabled", false) viper.SetDefault("security.response_headers.additional_allowed", []string{}) viper.SetDefault("security.response_headers.force_remove", []string{}) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 1f6ed58e..f28680c6 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -80,8 +80,11 @@ func TestLoadDefaultSecurityToggles(t *testing.T) { if cfg.Security.URLAllowlist.Enabled { t.Fatalf("URLAllowlist.Enabled = true, want false") } - if cfg.Security.URLAllowlist.AllowInsecureHTTP { - t.Fatalf("URLAllowlist.AllowInsecureHTTP = true, want false") + if !cfg.Security.URLAllowlist.AllowInsecureHTTP { + t.Fatalf("URLAllowlist.AllowInsecureHTTP = false, want true") + } + if !cfg.Security.URLAllowlist.AllowPrivateHosts { + t.Fatalf("URLAllowlist.AllowPrivateHosts = false, want true") } if cfg.Security.ResponseHeaders.Enabled { t.Fatalf("ResponseHeaders.Enabled = true, want false") From 58b17771983b6ef1f96f714a432fd8f1c12d6fed Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Tue, 6 Jan 2026 13:42:47 +0800 Subject: [PATCH 05/22] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=20frontend-?= =?UTF-8?q?security=20job=20=E4=B8=AD=E7=9A=84=20pnpm=20=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **问题描述:** GitHub Actions 在 frontend-security job 中报错: "Error: Unable to locate executable file: pnpm" **根本原因:** setup-node@v4 在尝试使用 pnpm cache 时,pnpm 还未安装 **解决方案:** 1. 调整步骤顺序:先安装 pnpm,再设置 Node.js 2. 升级 pnpm/action-setup 从 v2 到 v4 3. 明确指定 pnpm version: 9 **修改内容:** - 将 "Set up pnpm" 步骤移到 "Set up Node.js" 之前 - 更新 pnpm/action-setup@v2 → pnpm/action-setup@v4 - 添加 version: 9 配置 **正确的步骤顺序:** 1. Checkout 代码 2. Set up pnpm (指定版本) 3. Set up Node.js (可以使用 pnpm cache) 4. Install dependencies 相关 Issue: #174 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/security-scan.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index f148b318..160a0df9 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -38,14 +38,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'pnpm' cache-dependency-path: frontend/pnpm-lock.yaml - - name: Set up pnpm - uses: pnpm/action-setup@v2 - name: Install dependencies working-directory: frontend run: pnpm install --frozen-lockfile From 1181b332f7800ad80814e22fb91e3120517d6e6e Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Tue, 6 Jan 2026 15:46:36 +0800 Subject: [PATCH 06/22] =?UTF-8?q?fix(=E5=89=8D=E7=AB=AF):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=BC=96=E8=BE=91=E8=B4=A6=E5=8F=B7=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E6=97=A0=E6=B3=95=E6=98=BE=E7=A4=BA=E5=85=B7?= =?UTF-8?q?=E4=BD=93=E5=8E=9F=E5=9B=A0=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 API 返回 message 字段,但前端读取 detail 字段,导致无法显示具体错误信息。 现在优先读取 message 字段,兼容 detail 字段。 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/components/account/EditAccountModal.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 4ac149f2..3f47ee31 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1140,7 +1140,7 @@ const handleSubmit = async () => { emit('updated') handleClose() } catch (error: any) { - appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToUpdate')) + appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate')) } finally { submitting.value = false } From 5a52cb608cc0de8a21066670e210ce39caac85dd Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Tue, 6 Jan 2026 19:20:05 +0800 Subject: [PATCH 07/22] =?UTF-8?q?fix(=E5=89=8D=E7=AB=AF):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=B4=A6=E5=8F=B7=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E8=BF=87=E6=BB=A4=E4=B8=8D=E7=94=9F=E6=95=88?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加 @update:filters 事件监听,使过滤器参数能正确同步到数据请求中。 修复了平台、类型、状态三个过滤器全部失效的问题。 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- frontend/src/views/admin/AccountsView.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index eb73a5ca..c95b89f3 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -6,6 +6,7 @@ From 66fe484f0df3905c14907e3e1bb6c2e7bf91dad8 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Tue, 6 Jan 2026 20:26:32 +0800 Subject: [PATCH 08/22] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E5=AE=89=E5=85=A8=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dependency-security.md | 58 ------------------------------------- 1 file changed, 58 deletions(-) delete mode 100644 docs/dependency-security.md diff --git a/docs/dependency-security.md b/docs/dependency-security.md deleted file mode 100644 index 66545011..00000000 --- a/docs/dependency-security.md +++ /dev/null @@ -1,58 +0,0 @@ -# Dependency Security - -This document describes how dependency and toolchain security is managed in this repo. - -## Go Toolchain Policy (Pinned to 1.25.5) - -The Go toolchain is pinned to 1.25.5 to address known security issues. - -Locations that MUST stay aligned: -- `backend/go.mod`: `go 1.25.5` and `toolchain go1.25.5` -- `Dockerfile`: `GOLANG_IMAGE=golang:1.25.5-alpine` -- Workflows: use `go-version-file: backend/go.mod` and verify `go1.25.5` - -Update process: -1. Change `backend/go.mod` (go + toolchain) to the new patch version. -2. Update `Dockerfile` GOLANG_IMAGE to the same patch version. -3. Update workflows if needed and keep the `go version` check in place. -4. Run `govulncheck` and the CI security scan workflow. - -## Security Scans - -Automated scans run via `.github/workflows/security-scan.yml`: -- `govulncheck` for Go dependencies -- `gosec` for static security issues -- `pnpm audit` for frontend production dependencies - -Policy: -- High/Critical findings fail the build unless explicitly exempted. -- Exemptions must include mitigation and an expiry date. - -## Audit Exceptions - -Exception list location: `.github/audit-exceptions.yml` - -Required fields: -- `package` -- `advisory` (GHSA ID or advisory URL from pnpm audit) -- `severity` -- `mitigation` -- `expires_on` (recommended <= 90 days) - -Process: -1. Add an exception with mitigation details and an expiry date. -2. Ensure the exception is reviewed before expiry. -3. Remove the exception when the dependency is upgraded or replaced. - -## Frontend xlsx Mitigation (Plan A) - -Current mitigation: -- Use dynamic import so `xlsx` only loads during export. -- Keep export access restricted and data scope limited. - -## Rollback Guidance - -If a change causes issues: -- Go: revert `backend/go.mod` and `Dockerfile` to the previous version. -- Frontend: revert the dynamic import change if needed. -- CI: remove exception entries and re-run scans to confirm status. From 823497a2afe7ebe1a1418b00657145b483d5682a Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Tue, 6 Jan 2026 20:31:40 +0800 Subject: [PATCH 09/22] =?UTF-8?q?fix(=E5=B9=B6=E5=8F=91):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20wrapReleaseOnDone=20goroutine=20=E6=B3=84=E9=9C=B2?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题描述: - wrapReleaseOnDone 函数创建的 goroutine 会持续等待 ctx.Done() - 即使 release() 已被调用,goroutine 仍不会退出 - 高并发场景下(1000 req/s)会产生 3000+ 个泄露 goroutine 修复方案: - 添加 quit channel 作为退出信号 - 正常释放时 close(quit) 通知 goroutine 立即退出 - 使用 select 监听 ctx.Done() 和 quit 两个信号 - 确保 goroutine 在正常流程中及时退出 测试覆盖: - 新增 5 个单元测试验证修复效果 - 验证 goroutine 不泄露 - 验证并发安全性和多次调用保护 - 性能影响:471.9 ns/op, 208 B/op 影响范围: - gateway_handler.go: 每请求调用 2-4 次 - openai_gateway_handler.go: 每请求调用 2-3 次 - 修复后 goroutine 泄露数量从 3/req 降至 0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- backend/internal/handler/gateway_helper.go | 24 ++- .../internal/handler/gateway_helper_test.go | 141 ++++++++++++++++++ 2 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 backend/internal/handler/gateway_helper_test.go diff --git a/backend/internal/handler/gateway_helper.go b/backend/internal/handler/gateway_helper.go index 2eb3ac72..5de519c7 100644 --- a/backend/internal/handler/gateway_helper.go +++ b/backend/internal/handler/gateway_helper.go @@ -83,19 +83,33 @@ func NewConcurrencyHelper(concurrencyService *service.ConcurrencyService, pingFo // wrapReleaseOnDone ensures release runs at most once and still triggers on context cancellation. // 用于避免客户端断开或上游超时导致的并发槽位泄漏。 +// 修复:添加 quit channel 确保 goroutine 及时退出,避免泄露 func wrapReleaseOnDone(ctx context.Context, releaseFunc func()) func() { if releaseFunc == nil { return nil } var once sync.Once - wrapped := func() { - once.Do(releaseFunc) + quit := make(chan struct{}) + + release := func() { + once.Do(func() { + releaseFunc() + close(quit) // 通知监听 goroutine 退出 + }) } + go func() { - <-ctx.Done() - wrapped() + select { + case <-ctx.Done(): + // Context 取消时释放资源 + release() + case <-quit: + // 正常释放已完成,goroutine 退出 + return + } }() - return wrapped + + return release } // IncrementWaitCount increments the wait count for a user diff --git a/backend/internal/handler/gateway_helper_test.go b/backend/internal/handler/gateway_helper_test.go new file mode 100644 index 00000000..664258f8 --- /dev/null +++ b/backend/internal/handler/gateway_helper_test.go @@ -0,0 +1,141 @@ +package handler + +import ( + "context" + "runtime" + "sync/atomic" + "testing" + "time" +) + +// TestWrapReleaseOnDone_NoGoroutineLeak 验证 wrapReleaseOnDone 修复后不会泄露 goroutine +func TestWrapReleaseOnDone_NoGoroutineLeak(t *testing.T) { + // 记录测试开始时的 goroutine 数量 + runtime.GC() + time.Sleep(100 * time.Millisecond) + initialGoroutines := runtime.NumGoroutine() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var releaseCount int32 + release := wrapReleaseOnDone(ctx, func() { + atomic.AddInt32(&releaseCount, 1) + }) + + // 正常释放 + release() + + // 等待足够时间确保 goroutine 退出 + time.Sleep(200 * time.Millisecond) + + // 验证只释放一次 + if count := atomic.LoadInt32(&releaseCount); count != 1 { + t.Errorf("expected release count to be 1, got %d", count) + } + + // 强制 GC,清理已退出的 goroutine + runtime.GC() + time.Sleep(100 * time.Millisecond) + + // 验证 goroutine 数量没有增加(允许±2的误差,考虑到测试框架本身可能创建的 goroutine) + finalGoroutines := runtime.NumGoroutine() + if finalGoroutines > initialGoroutines+2 { + t.Errorf("goroutine leak detected: initial=%d, final=%d, leaked=%d", + initialGoroutines, finalGoroutines, finalGoroutines-initialGoroutines) + } +} + +// TestWrapReleaseOnDone_ContextCancellation 验证 context 取消时也能正确释放 +func TestWrapReleaseOnDone_ContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + var releaseCount int32 + _ = wrapReleaseOnDone(ctx, func() { + atomic.AddInt32(&releaseCount, 1) + }) + + // 取消 context,应该触发释放 + cancel() + + // 等待释放完成 + time.Sleep(100 * time.Millisecond) + + // 验证释放被调用 + if count := atomic.LoadInt32(&releaseCount); count != 1 { + t.Errorf("expected release count to be 1, got %d", count) + } +} + +// TestWrapReleaseOnDone_MultipleCallsOnlyReleaseOnce 验证多次调用 release 只释放一次 +func TestWrapReleaseOnDone_MultipleCallsOnlyReleaseOnce(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var releaseCount int32 + release := wrapReleaseOnDone(ctx, func() { + atomic.AddInt32(&releaseCount, 1) + }) + + // 调用多次 + release() + release() + release() + + // 等待执行完成 + time.Sleep(100 * time.Millisecond) + + // 验证只释放一次 + if count := atomic.LoadInt32(&releaseCount); count != 1 { + t.Errorf("expected release count to be 1, got %d", count) + } +} + +// TestWrapReleaseOnDone_NilReleaseFunc 验证 nil releaseFunc 不会 panic +func TestWrapReleaseOnDone_NilReleaseFunc(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + release := wrapReleaseOnDone(ctx, nil) + + if release != nil { + t.Error("expected nil release function when releaseFunc is nil") + } +} + +// TestWrapReleaseOnDone_ConcurrentCalls 验证并发调用的安全性 +func TestWrapReleaseOnDone_ConcurrentCalls(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var releaseCount int32 + release := wrapReleaseOnDone(ctx, func() { + atomic.AddInt32(&releaseCount, 1) + }) + + // 并发调用 release + const numGoroutines = 10 + for i := 0; i < numGoroutines; i++ { + go release() + } + + // 等待所有 goroutine 完成 + time.Sleep(200 * time.Millisecond) + + // 验证只释放一次 + if count := atomic.LoadInt32(&releaseCount); count != 1 { + t.Errorf("expected release count to be 1, got %d", count) + } +} + +// BenchmarkWrapReleaseOnDone 性能基准测试 +func BenchmarkWrapReleaseOnDone(b *testing.B) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + release := wrapReleaseOnDone(ctx, func() {}) + release() + } +} From 015974a27e76240abef8bbf5ea17b272703ab1ee Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 6 Jan 2026 22:19:07 +0800 Subject: [PATCH 10/22] =?UTF-8?q?feat(admin/usage):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E7=94=A8=E9=87=8F=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=92=8C=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端改进: - 新增 GetStatsWithFilters 方法支持完整筛选条件 - Stats 端点支持 account_id, group_id, model, stream, billing_type 参数 - 统一使用 filters 结构体,移除冗余的分支逻辑 前端改进: - 统计卡片添加"所选范围内"文字提示 - 优化总消费显示格式,清晰展示实际费用和标准计费 - Token 和费用列添加问号图标 tooltip 显示详细信息 - API Key 搜索框体验优化:点击即显示下拉选项 - 选择用户后自动加载该用户的所有 API Key --- .../internal/handler/admin/usage_handler.go | 79 +++++--- backend/internal/repository/usage_log_repo.go | 75 ++++++++ .../internal/service/account_usage_service.go | 1 + backend/internal/service/usage_service.go | 9 + frontend/src/api/admin/usage.ts | 8 +- .../components/admin/usage/UsageFilters.vue | 28 ++- .../admin/usage/UsageStatsCards.vue | 23 ++- .../src/components/admin/usage/UsageTable.vue | 170 ++++++++++++++++-- 8 files changed, 341 insertions(+), 52 deletions(-) diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index 9d14afd2..ad336b3e 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -152,8 +152,8 @@ func (h *UsageHandler) List(c *gin.Context) { // Stats handles getting usage statistics with filters // GET /api/v1/admin/usage/stats func (h *UsageHandler) Stats(c *gin.Context) { - // Parse filters - var userID, apiKeyID int64 + // Parse filters - same as List endpoint + var userID, apiKeyID, accountID, groupID int64 if userIDStr := c.Query("user_id"); userIDStr != "" { id, err := strconv.ParseInt(userIDStr, 10, 64) if err != nil { @@ -172,8 +172,49 @@ func (h *UsageHandler) Stats(c *gin.Context) { apiKeyID = id } + if accountIDStr := c.Query("account_id"); accountIDStr != "" { + id, err := strconv.ParseInt(accountIDStr, 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account_id") + return + } + accountID = id + } + + if groupIDStr := c.Query("group_id"); groupIDStr != "" { + id, err := strconv.ParseInt(groupIDStr, 10, 64) + if err != nil { + response.BadRequest(c, "Invalid group_id") + return + } + groupID = id + } + + model := c.Query("model") + + var stream *bool + if streamStr := c.Query("stream"); streamStr != "" { + val, err := strconv.ParseBool(streamStr) + if err != nil { + response.BadRequest(c, "Invalid stream value, use true or false") + return + } + stream = &val + } + + var billingType *int8 + if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" { + val, err := strconv.ParseInt(billingTypeStr, 10, 8) + if err != nil { + response.BadRequest(c, "Invalid billing_type") + return + } + bt := int8(val) + billingType = &bt + } + // Parse date range - userTZ := c.Query("timezone") // Get user's timezone from request + userTZ := c.Query("timezone") now := timezone.NowInUserLocation(userTZ) var startTime, endTime time.Time @@ -208,28 +249,20 @@ func (h *UsageHandler) Stats(c *gin.Context) { endTime = now } - if apiKeyID > 0 { - stats, err := h.usageService.GetStatsByAPIKey(c.Request.Context(), apiKeyID, startTime, endTime) - if err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, stats) - return + // Build filters and call GetStatsWithFilters + filters := usagestats.UsageLogFilters{ + UserID: userID, + APIKeyID: apiKeyID, + AccountID: accountID, + GroupID: groupID, + Model: model, + Stream: stream, + BillingType: billingType, + StartTime: &startTime, + EndTime: &endTime, } - if userID > 0 { - stats, err := h.usageService.GetStatsByUser(c.Request.Context(), userID, startTime, endTime) - if err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, stats) - return - } - - // Get global stats - stats, err := h.usageService.GetGlobalStats(c.Request.Context(), startTime, endTime) + stats, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 82d5e833..4df10b23 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -1388,6 +1388,81 @@ func (r *usageLogRepository) GetGlobalStats(ctx context.Context, startTime, endT return stats, nil } +// GetStatsWithFilters gets usage statistics with optional filters +func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters UsageLogFilters) (*UsageStats, error) { + conditions := make([]string, 0, 9) + args := make([]any, 0, 9) + + if filters.UserID > 0 { + conditions = append(conditions, fmt.Sprintf("user_id = $%d", len(args)+1)) + args = append(args, filters.UserID) + } + if filters.APIKeyID > 0 { + conditions = append(conditions, fmt.Sprintf("api_key_id = $%d", len(args)+1)) + args = append(args, filters.APIKeyID) + } + if filters.AccountID > 0 { + conditions = append(conditions, fmt.Sprintf("account_id = $%d", len(args)+1)) + args = append(args, filters.AccountID) + } + if filters.GroupID > 0 { + conditions = append(conditions, fmt.Sprintf("group_id = $%d", len(args)+1)) + args = append(args, filters.GroupID) + } + if filters.Model != "" { + conditions = append(conditions, fmt.Sprintf("model = $%d", len(args)+1)) + args = append(args, filters.Model) + } + if filters.Stream != nil { + conditions = append(conditions, fmt.Sprintf("stream = $%d", len(args)+1)) + args = append(args, *filters.Stream) + } + if filters.BillingType != nil { + conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1)) + args = append(args, int16(*filters.BillingType)) + } + if filters.StartTime != nil { + conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1)) + args = append(args, *filters.StartTime) + } + if filters.EndTime != nil { + conditions = append(conditions, fmt.Sprintf("created_at <= $%d", len(args)+1)) + args = append(args, *filters.EndTime) + } + + query := fmt.Sprintf(` + SELECT + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(actual_cost), 0) as total_actual_cost, + COALESCE(AVG(duration_ms), 0) as avg_duration_ms + FROM usage_logs + %s + `, buildWhere(conditions)) + + stats := &UsageStats{} + if err := scanSingleRow( + ctx, + r.sql, + query, + args, + &stats.TotalRequests, + &stats.TotalInputTokens, + &stats.TotalOutputTokens, + &stats.TotalCacheTokens, + &stats.TotalCost, + &stats.TotalActualCost, + &stats.AverageDurationMs, + ); err != nil { + return nil, err + } + stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens + return stats, nil +} + // AccountUsageHistory represents daily usage history for an account type AccountUsageHistory = usagestats.AccountUsageHistory diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 6971fafa..f1ee43d2 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -47,6 +47,7 @@ type UsageLogRepository interface { // Admin usage listing/stats ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]UsageLog, *pagination.PaginationResult, error) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error) + GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) // Account stats GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error) diff --git a/backend/internal/service/usage_service.go b/backend/internal/service/usage_service.go index 29362cc6..10a294ae 100644 --- a/backend/internal/service/usage_service.go +++ b/backend/internal/service/usage_service.go @@ -319,3 +319,12 @@ func (s *UsageService) GetGlobalStats(ctx context.Context, startTime, endTime ti } return stats, nil } + +// GetStatsWithFilters returns usage stats with optional filters. +func (s *UsageService) GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) { + stats, err := s.usageRepo.GetStatsWithFilters(ctx, filters) + if err != nil { + return nil, fmt.Errorf("get usage stats with filters: %w", err) + } + return stats, nil +} diff --git a/frontend/src/api/admin/usage.ts b/frontend/src/api/admin/usage.ts index 42c23a87..4712dafd 100644 --- a/frontend/src/api/admin/usage.ts +++ b/frontend/src/api/admin/usage.ts @@ -54,15 +54,21 @@ export async function list( /** * Get usage statistics with optional filters (admin only) - * @param params - Query parameters (user_id, api_key_id, period/date range) + * @param params - Query parameters for filtering * @returns Usage statistics */ export async function getStats(params: { user_id?: number api_key_id?: number + account_id?: number + group_id?: number + model?: string + stream?: boolean + billing_type?: number period?: string start_date?: string end_date?: string + timezone?: string }): Promise { const { data } = await apiClient.get('/admin/usage/stats', { params diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue index d6077ec5..822f41a8 100644 --- a/frontend/src/components/admin/usage/UsageFilters.vue +++ b/frontend/src/components/admin/usage/UsageFilters.vue @@ -50,7 +50,7 @@ class="input pr-8" :placeholder="t('admin.usage.searchApiKeyPlaceholder')" @input="debounceApiKeySearch" - @focus="showApiKeyDropdown = true" + @focus="onApiKeyFocus" /> +
+ +
@@ -166,6 +197,7 @@ const filters = toRef(props, 'modelValue') const userSearchRef = ref(null) const apiKeySearchRef = ref(null) +const accountSearchRef = ref(null) const userKeyword = ref('') const userResults = ref([]) @@ -177,9 +209,17 @@ const apiKeyResults = ref([]) const showApiKeyDropdown = ref(false) let apiKeySearchTimeout: ReturnType | null = null +interface SimpleAccount { + id: number + name: string +} +const accountKeyword = ref('') +const accountResults = ref([]) +const showAccountDropdown = ref(false) +let accountSearchTimeout: ReturnType | null = null + const modelOptions = ref([{ value: null, label: t('admin.usage.allModels') }]) const groupOptions = ref([{ value: null, label: t('admin.usage.allGroups') }]) -const accountOptions = ref([{ value: null, label: t('admin.usage.allAccounts') }]) const streamTypeOptions = ref([ { value: null, label: t('admin.usage.allTypes') }, @@ -278,6 +318,37 @@ const onClearApiKey = () => { emitChange() } +const debounceAccountSearch = () => { + if (accountSearchTimeout) clearTimeout(accountSearchTimeout) + accountSearchTimeout = setTimeout(async () => { + if (!accountKeyword.value) { + accountResults.value = [] + return + } + try { + const res = await adminAPI.accounts.list(1, 20, { search: accountKeyword.value }) + accountResults.value = res.items.map((a) => ({ id: a.id, name: a.name })) + } catch { + accountResults.value = [] + } + }, 300) +} + +const selectAccount = (a: SimpleAccount) => { + accountKeyword.value = a.name + showAccountDropdown.value = false + filters.value.account_id = a.id + emitChange() +} + +const clearAccount = () => { + accountKeyword.value = '' + accountResults.value = [] + showAccountDropdown.value = false + filters.value.account_id = undefined + emitChange() +} + const onApiKeyFocus = () => { showApiKeyDropdown.value = true // Trigger search if no results yet @@ -292,9 +363,11 @@ const onDocumentClick = (e: MouseEvent) => { const clickedInsideUser = userSearchRef.value?.contains(target) ?? false const clickedInsideApiKey = apiKeySearchRef.value?.contains(target) ?? false + const clickedInsideAccount = accountSearchRef.value?.contains(target) ?? false if (!clickedInsideUser) showUserDropdown.value = false if (!clickedInsideApiKey) showApiKeyDropdown.value = false + if (!clickedInsideAccount) showAccountDropdown.value = false } watch( @@ -333,20 +406,27 @@ watch( } ) +watch( + () => filters.value.account_id, + (accountId) => { + if (!accountId) { + accountKeyword.value = '' + accountResults.value = [] + } + } +) + onMounted(async () => { document.addEventListener('click', onDocumentClick) try { - const [gs, ms, as] = await Promise.all([ + const [gs, ms] = await Promise.all([ adminAPI.groups.list(1, 1000), - adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate }), - adminAPI.accounts.list(1, 1000) + adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate }) ]) groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name }))) - accountOptions.value.push(...as.items.map((a: any) => ({ value: a.id, label: a.name }))) - const uniqueModels = new Set() ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model)) modelOptions.value.push( diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index fd5768a9..79465bb7 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -143,8 +143,8 @@ >
-
-
Token {{ t('usage.details') }}
+
+
{{ t('usage.tokenDetails') }}
{{ t('admin.usage.inputTokens') }} {{ tokenTooltipData.input_tokens.toLocaleString() }} @@ -184,6 +184,27 @@ >
+ +
+
{{ t('usage.costDetails') }}
+
+ {{ t('admin.usage.inputCost') }} + ${{ tooltipData.input_cost.toFixed(6) }} +
+
+ {{ t('admin.usage.outputCost') }} + ${{ tooltipData.output_cost.toFixed(6) }} +
+
+ {{ t('admin.usage.cacheCreationCost') }} + ${{ tooltipData.cache_creation_cost.toFixed(6) }} +
+
+ {{ t('admin.usage.cacheReadCost') }} + ${{ tooltipData.cache_read_cost.toFixed(6) }} +
+
+
{{ t('usage.rate') }} {{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 393641a7..4634d8b6 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -376,6 +376,8 @@ export default { usage: { title: 'Usage Records', description: 'View and analyze your API usage history', + costDetails: 'Cost Breakdown', + tokenDetails: 'Token Breakdown', totalRequests: 'Total Requests', totalTokens: 'Total Tokens', totalCost: 'Total Cost', @@ -1691,6 +1693,7 @@ export default { userFilter: 'User', searchUserPlaceholder: 'Search user by email...', searchApiKeyPlaceholder: 'Search API key by name...', + searchAccountPlaceholder: 'Search account by name...', selectedUser: 'Selected', user: 'User', account: 'Account', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index fb46bbbe..7e326bab 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -373,6 +373,8 @@ export default { usage: { title: '使用记录', description: '查看和分析您的 API 使用历史', + costDetails: '成本明细', + tokenDetails: 'Token 明细', totalRequests: '总请求数', totalTokens: '总 Token', totalCost: '总消费', @@ -1836,6 +1838,7 @@ export default { userFilter: '用户', searchUserPlaceholder: '按邮箱搜索用户...', searchApiKeyPlaceholder: '按名称搜索 API 密钥...', + searchAccountPlaceholder: '按名称搜索账号...', selectedUser: '已选择', user: '用户', account: '账户', diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index d5e94145..522f1b00 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -85,11 +85,48 @@ const exportToExcel = async () => { if (all.length >= total || res.items.length < 100) break; p++ } if(!c.signal.aborted) { - // 动态加载 xlsx,降低首屏包体并减少高危依赖的常驻暴露面。 const XLSX = await import('xlsx') - const ws = XLSX.utils.json_to_sheet(all); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Usage') - saveAs(new Blob([XLSX.write(wb, { bookType: 'xlsx', type: 'array' })], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `usage_${Date.now()}.xlsx`) - appStore.showSuccess('Export Success') + const headers = [ + t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'), + t('admin.usage.account'), t('usage.model'), t('admin.usage.group'), + t('usage.type'), + t('admin.usage.inputTokens'), t('admin.usage.outputTokens'), + t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'), + t('admin.usage.inputCost'), t('admin.usage.outputCost'), + t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'), + t('usage.rate'), t('usage.original'), t('usage.billed'), + t('usage.billingType'), t('usage.firstToken'), t('usage.duration'), + t('admin.usage.requestId') + ] + const rows = all.map(log => [ + log.created_at, + log.user?.email || '', + log.api_key?.name || '', + log.account?.name || '', + log.model, + log.group?.name || '', + log.stream ? t('usage.stream') : t('usage.sync'), + log.input_tokens, + log.output_tokens, + log.cache_read_tokens, + log.cache_creation_tokens, + log.input_cost?.toFixed(6) || '0.000000', + log.output_cost?.toFixed(6) || '0.000000', + log.cache_read_cost?.toFixed(6) || '0.000000', + log.cache_creation_cost?.toFixed(6) || '0.000000', + log.rate_multiplier?.toFixed(2) || '1.00', + log.total_cost?.toFixed(6) || '0.000000', + log.actual_cost?.toFixed(6) || '0.000000', + log.billing_type === 1 ? t('usage.subscription') : t('usage.balance'), + log.first_token_ms ?? '', + log.duration_ms, + log.request_id || '' + ]) + const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, 'Usage') + saveAs(new Blob([XLSX.write(wb, { bookType: 'xlsx', type: 'array' })], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `usage_${filters.value.start_date}_to_${filters.value.end_date}.xlsx`) + appStore.showSuccess(t('usage.exportSuccess')) } } catch (error) { console.error('Failed to export:', error); appStore.showError('Export Failed') } finally { if(exportAbortController === c) { exportAbortController = null; exporting.value = false; exportProgress.show = false } } diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index 567d4061..489e2726 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -342,8 +342,8 @@ >
-
-
Token 明细
+
+
{{ t('usage.tokenDetails') }}
{{ t('admin.usage.inputTokens') }} {{ tokenTooltipData.input_tokens.toLocaleString() }} @@ -389,6 +389,27 @@ class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800" >
+ +
+
{{ t('usage.costDetails') }}
+
+ {{ t('admin.usage.inputCost') }} + ${{ tooltipData.input_cost.toFixed(6) }} +
+
+ {{ t('admin.usage.outputCost') }} + ${{ tooltipData.output_cost.toFixed(6) }} +
+
+ {{ t('admin.usage.cacheCreationCost') }} + ${{ tooltipData.cache_creation_cost.toFixed(6) }} +
+
+ {{ t('admin.usage.cacheReadCost') }} + ${{ tooltipData.cache_read_cost.toFixed(6) }} +
+
+
{{ t('usage.rate') }} Date: Wed, 7 Jan 2026 10:17:09 +0800 Subject: [PATCH 13/22] =?UTF-8?q?fix(gateway):=20=E4=BF=AE=E5=A4=8D=20cach?= =?UTF-8?q?e=5Fcontrol=20=E5=9D=97=E8=B6=85=E9=99=90=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=20Claude=20Code=20=E6=A3=80?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: - OAuth/SetupToken 账号注入 system prompt 后可能导致 cache_control 块超过 Anthropic API 的 4 个限制 - Claude Code 检测使用精确匹配,无法识别 Agent SDK 等变体 修复: - 新增 enforceCacheControlLimit 函数,强制执行 4 个块限制 - 优先从 messages 移除,再从 system 尾部移除(保护注入的 prompt) - 改用前缀匹配检测 Claude Code 系统提示词,支持多种变体: - 标准版、Agent SDK 版、Explore Agent 版、Compact 版 --- backend/internal/service/gateway_service.go | 148 +++++++++++++++++++- 1 file changed, 145 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index a83e7d05..8fd0b918 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -35,6 +35,7 @@ const ( stickySessionTTL = time.Hour // 粘性会话TTL defaultMaxLineSize = 10 * 1024 * 1024 claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." + maxCacheControlBlocks = 4 // Anthropic API 允许的最大 cache_control 块数量 ) // sseDataRe matches SSE data lines with optional whitespace after colon. @@ -43,6 +44,16 @@ var ( sseDataRe = regexp.MustCompile(`^data:\s*`) sessionIDRegex = regexp.MustCompile(`session_([a-f0-9-]{36})`) claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`) + + // claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表 + // 支持多种变体:标准版、Agent SDK 版、Explore Agent 版、Compact 版等 + // 注意:前缀之间不应存在包含关系,否则会导致冗余匹配 + claudeCodePromptPrefixes = []string{ + "You are Claude Code, Anthropic's official CLI for Claude", // 标准版 & Agent SDK 版(含 running within...) + "You are a Claude agent, built on Anthropic's Claude Agent SDK", // Agent SDK 变体 + "You are a file search specialist for Claude Code", // Explore Agent 版 + "You are a helpful AI assistant tasked with summarizing conversations", // Compact 版 + } ) // allowedHeaders 白名单headers(参考CRS项目) @@ -1013,15 +1024,15 @@ func isClaudeCodeClient(userAgent string, metadataUserID string) bool { } // systemIncludesClaudeCodePrompt 检查 system 中是否已包含 Claude Code 提示词 -// 支持 string 和 []any 两种格式 +// 使用前缀匹配支持多种变体(标准版、Agent SDK 版等) func systemIncludesClaudeCodePrompt(system any) bool { switch v := system.(type) { case string: - return v == claudeCodeSystemPrompt + return hasClaudeCodePrefix(v) case []any: for _, item := range v { if m, ok := item.(map[string]any); ok { - if text, ok := m["text"].(string); ok && text == claudeCodeSystemPrompt { + if text, ok := m["text"].(string); ok && hasClaudeCodePrefix(text) { return true } } @@ -1030,6 +1041,16 @@ func systemIncludesClaudeCodePrompt(system any) bool { return false } +// hasClaudeCodePrefix 检查文本是否以 Claude Code 提示词的特征前缀开头 +func hasClaudeCodePrefix(text string) bool { + for _, prefix := range claudeCodePromptPrefixes { + if strings.HasPrefix(text, prefix) { + return true + } + } + return false +} + // injectClaudeCodePrompt 在 system 开头注入 Claude Code 提示词 // 处理 null、字符串、数组三种格式 func injectClaudeCodePrompt(body []byte, system any) []byte { @@ -1073,6 +1094,124 @@ func injectClaudeCodePrompt(body []byte, system any) []byte { return result } +// enforceCacheControlLimit 强制执行 cache_control 块数量限制(最多 4 个) +// 超限时优先从 messages 中移除 cache_control,保护 system 中的缓存控制 +func enforceCacheControlLimit(body []byte) []byte { + var data map[string]any + if err := json.Unmarshal(body, &data); err != nil { + return body + } + + // 计算当前 cache_control 块数量 + count := countCacheControlBlocks(data) + if count <= maxCacheControlBlocks { + return body + } + + // 超限:优先从 messages 中移除,再从 system 中移除 + for count > maxCacheControlBlocks { + if removeCacheControlFromMessages(data) { + count-- + continue + } + if removeCacheControlFromSystem(data) { + count-- + continue + } + break + } + + result, err := json.Marshal(data) + if err != nil { + return body + } + return result +} + +// countCacheControlBlocks 统计 system 和 messages 中的 cache_control 块数量 +func countCacheControlBlocks(data map[string]any) int { + count := 0 + + // 统计 system 中的块 + if system, ok := data["system"].([]any); ok { + for _, item := range system { + if m, ok := item.(map[string]any); ok { + if _, has := m["cache_control"]; has { + count++ + } + } + } + } + + // 统计 messages 中的块 + if messages, ok := data["messages"].([]any); ok { + for _, msg := range messages { + if msgMap, ok := msg.(map[string]any); ok { + if content, ok := msgMap["content"].([]any); ok { + for _, item := range content { + if m, ok := item.(map[string]any); ok { + if _, has := m["cache_control"]; has { + count++ + } + } + } + } + } + } + } + + return count +} + +// removeCacheControlFromMessages 从 messages 中移除一个 cache_control(从头开始) +// 返回 true 表示成功移除,false 表示没有可移除的 +func removeCacheControlFromMessages(data map[string]any) bool { + messages, ok := data["messages"].([]any) + if !ok { + return false + } + + for _, msg := range messages { + msgMap, ok := msg.(map[string]any) + if !ok { + continue + } + content, ok := msgMap["content"].([]any) + if !ok { + continue + } + for _, item := range content { + if m, ok := item.(map[string]any); ok { + if _, has := m["cache_control"]; has { + delete(m, "cache_control") + return true + } + } + } + } + return false +} + +// removeCacheControlFromSystem 从 system 中移除一个 cache_control(从尾部开始,保护注入的 prompt) +// 返回 true 表示成功移除,false 表示没有可移除的 +func removeCacheControlFromSystem(data map[string]any) bool { + system, ok := data["system"].([]any) + if !ok { + return false + } + + // 从尾部开始移除,保护开头注入的 Claude Code prompt + for i := len(system) - 1; i >= 0; i-- { + if m, ok := system[i].(map[string]any); ok { + if _, has := m["cache_control"]; has { + delete(m, "cache_control") + return true + } + } + } + return false +} + // Forward 转发请求到Claude API func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) { startTime := time.Now() @@ -1093,6 +1232,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A body = injectClaudeCodePrompt(body, parsed.System) } + // 强制执行 cache_control 块数量限制(最多 4 个) + body = enforceCacheControlLimit(body) + // 应用模型映射(仅对apikey类型账号) originalModel := reqModel if account.Type == AccountTypeAPIKey { From fc8fa83fcc03cbeea471f7d0e547031f6187f3a0 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 7 Jan 2026 10:26:24 +0800 Subject: [PATCH 14/22] =?UTF-8?q?fix(keys):=20=E4=BF=AE=E5=A4=8D=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E6=A1=86=E7=AC=AC=E4=B8=80=E8=A1=8C=E5=A4=9A=E4=BD=99?= =?UTF-8?q?=E7=A9=BA=E6=A0=BC=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pre 标签会原样保留内部空白字符,导致 code 标签前的模板缩进 被渲染为实际空格。将 pre/code 标签写在同一行消除此问题。 --- frontend/src/components/keys/UseKeyModal.vue | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 16c39bf8..546a53ab 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -105,10 +105,7 @@
-
-                
-                
-              
+
From d99a3ef14b0c55351c572c7fdca9519d00bf99bd Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 7 Jan 2026 10:56:52 +0800 Subject: [PATCH 15/22] =?UTF-8?q?fix(gateway):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E8=B7=A8=E5=88=86=E7=BB=84=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:账号可能被调度到未分配的分组(如 simon 账号被调度到 claude_default) 根因: - 强制平台模式下分组查询失败时回退到全平台查询 - listSchedulableAccounts 中分组为空时回退到无分组查询 - 粘性会话只检查平台匹配,未校验账号分组归属 修复: - 移除强制平台模式的回退逻辑,分组内无账号时返回错误 - 移除 listSchedulableAccounts 的回退逻辑 - 新增 isAccountInGroup 方法用于分组校验 - 在三处粘性会话检查中增加分组归属验证 --- backend/internal/service/gateway_service.go | 43 ++++++++++++--------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 8fd0b918..120637d5 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -366,17 +366,8 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context return s.selectAccountWithMixedScheduling(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform) } - // 强制平台模式:优先按分组查找,找不到再查全部该平台账户 - if hasForcePlatform && groupID != nil { - account, err := s.selectAccountForModelWithPlatform(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform) - if err == nil { - return account, nil - } - // 分组中找不到,回退查询全部该平台账户 - groupID = nil - } - // antigravity 分组、强制平台模式或无分组使用单平台选择 + // 注意:强制平台模式也必须遵守分组限制,不再回退到全平台查询 return s.selectAccountForModelWithPlatform(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform) } @@ -454,7 +445,8 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro accountID, err := s.cache.GetSessionAccountID(ctx, sessionHash) if err == nil && accountID > 0 && !isExcluded(accountID) { account, err := s.accountRepo.GetByID(ctx, accountID) - if err == nil && s.isAccountAllowedForPlatform(account, platform, useMixed) && + if err == nil && s.isAccountInGroup(account, groupID) && + s.isAccountAllowedForPlatform(account, platform, useMixed) && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency) @@ -671,9 +663,7 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform) } else if groupID != nil { accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, platform) - if err == nil && len(accounts) == 0 && hasForcePlatform { - accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform) - } + // 分组内无账号则返回空列表,由上层处理错误,不再回退到全平台查询 } else { accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform) } @@ -696,6 +686,23 @@ func (s *GatewayService) isAccountAllowedForPlatform(account *Account, platform return account.Platform == platform } +// isAccountInGroup checks if the account belongs to the specified group. +// Returns true if groupID is nil (no group restriction) or account belongs to the group. +func (s *GatewayService) isAccountInGroup(account *Account, groupID *int64) bool { + if groupID == nil { + return true // 无分组限制 + } + if account == nil { + return false + } + for _, ag := range account.AccountGroups { + if ag.GroupID == *groupID { + return true + } + } + return false +} + func (s *GatewayService) tryAcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int) (*AcquireResult, error) { if s.concurrencyService == nil { return &AcquireResult{Acquired: true, ReleaseFunc: func() {}}, nil @@ -734,8 +741,8 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, if err == nil && accountID > 0 { if _, excluded := excludedIDs[accountID]; !excluded { account, err := s.accountRepo.GetByID(ctx, accountID) - // 检查账号平台是否匹配(确保粘性会话不会跨平台) - if err == nil && account.Platform == platform && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { + // 检查账号分组归属和平台匹配(确保粘性会话不会跨分组或跨平台) + if err == nil && s.isAccountInGroup(account, groupID) && account.Platform == platform && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil { log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err) } @@ -823,8 +830,8 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g if err == nil && accountID > 0 { if _, excluded := excludedIDs[accountID]; !excluded { account, err := s.accountRepo.GetByID(ctx, accountID) - // 检查账号是否有效:原生平台直接匹配,antigravity 需要启用混合调度 - if err == nil && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { + // 检查账号分组归属和有效性:原生平台直接匹配,antigravity 需要启用混合调度 + if err == nil && s.isAccountInGroup(account, groupID) && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) { if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil { log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err) From b19c7875a41a697c73d413206369a05ba45909a1 Mon Sep 17 00:00:00 2001 From: Xu Kang <7836246@qq.com> Date: Wed, 7 Jan 2026 15:01:07 +0800 Subject: [PATCH 16/22] fix(i18n): use correct translation key for dashboard redeem code description (#194) Changed dashboard.addBalance to dashboard.addBalanceWithCode to match the existing translation key in locale files. --- .../src/components/user/dashboard/UserDashboardQuickActions.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/user/dashboard/UserDashboardQuickActions.vue b/frontend/src/components/user/dashboard/UserDashboardQuickActions.vue index 9d884aed..44ab98d9 100644 --- a/frontend/src/components/user/dashboard/UserDashboardQuickActions.vue +++ b/frontend/src/components/user/dashboard/UserDashboardQuickActions.vue @@ -40,7 +40,7 @@

{{ t('dashboard.redeemCode') }}

-

{{ t('dashboard.addBalance') }}

+

{{ t('dashboard.addBalanceWithCode') }}

Date: Wed, 7 Jan 2026 16:35:51 +0800 Subject: [PATCH 17/22] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Go=20?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E3=80=81=E5=8C=85=E7=AE=A1=E7=90=86=E5=99=A8?= =?UTF-8?q?=E5=92=8C=E6=8A=80=E6=9C=AF=E6=A0=88=E6=96=87=E6=A1=A3=20(#195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/Dockerfile: Go 版本从 1.21 更新到 1.25.5(与 go.mod 一致) - Makefile: 使用 pnpm 替代 npm(与 pnpm-lock.yaml 和 CI 一致) - README.md/README_CN.md: 技术栈从 GORM 修正为 Ent --- Makefile | 6 +++--- README.md | 2 +- README_CN.md | 2 +- backend/Dockerfile | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 4a08c23b..a5e18a37 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ build-backend: # 编译前端(需要已安装依赖) build-frontend: - @npm --prefix frontend run build + @pnpm --dir frontend run build # 运行测试(后端 + 前端) test: test-backend test-frontend @@ -18,5 +18,5 @@ test-backend: @$(MAKE) -C backend test test-frontend: - @npm --prefix frontend run lint:check - @npm --prefix frontend run typecheck + @pnpm --dir frontend run lint:check + @pnpm --dir frontend run typecheck diff --git a/README.md b/README.md index 684ad0f2..fa965e6f 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot | Component | Technology | |-----------|------------| -| Backend | Go 1.25.5, Gin, GORM | +| Backend | Go 1.25.5, Gin, Ent | | Frontend | Vue 3.4+, Vite 5+, TailwindCSS | | Database | PostgreSQL 15+ | | Cache/Queue | Redis 7+ | diff --git a/README_CN.md b/README_CN.md index 22a601bc..b8a818b3 100644 --- a/README_CN.md +++ b/README_CN.md @@ -44,7 +44,7 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅( | 组件 | 技术 | |------|------| -| 后端 | Go 1.25.5, Gin, GORM | +| 后端 | Go 1.25.5, Gin, Ent | | 前端 | Vue 3.4+, Vite 5+, TailwindCSS | | 数据库 | PostgreSQL 15+ | | 缓存/队列 | Redis 7+ | diff --git a/backend/Dockerfile b/backend/Dockerfile index 3bc4e50f..770fdedf 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.21-alpine +FROM golang:1.25.5-alpine WORKDIR /app From 2b528c5f813b64b6a7ed38f6c75eda0d8fc725f8 Mon Sep 17 00:00:00 2001 From: LLLLLLiulei <1065070665@qq.com> Date: Wed, 7 Jan 2026 16:59:35 +0800 Subject: [PATCH 18/22] feat: auto-pause expired accounts --- backend/cmd/server/wire.go | 5 + backend/cmd/server/wire_gen.go | 12 +- backend/ent/account.go | 29 +++- backend/ent/account/account.go | 18 +++ backend/ent/account/where.go | 70 +++++++++ backend/ent/account_create.go | 143 ++++++++++++++++++ backend/ent/account_update.go | 86 +++++++++++ backend/ent/migrate/schema.go | 14 +- backend/ent/mutation.go | 129 +++++++++++++++- backend/ent/runtime/runtime.go | 8 +- backend/ent/schema/account.go | 10 ++ .../internal/handler/admin/account_handler.go | 8 + backend/internal/handler/dto/mappers.go | 16 +- backend/internal/handler/dto/types.go | 32 ++-- backend/internal/repository/account_repo.go | 49 +++++- backend/internal/service/account.go | 35 +++-- .../service/account_expiry_service.go | 71 +++++++++ backend/internal/service/account_service.go | 55 ++++--- .../service/account_service_delete_test.go | 4 + backend/internal/service/admin_service.go | 44 ++++-- .../service/gateway_multiplatform_test.go | 3 + .../service/gemini_multiplatform_test.go | 3 + backend/internal/service/wire.go | 8 + .../migrations/030_add_account_expires_at.sql | 10 ++ .../components/account/CreateAccountModal.vue | 135 ++++++++++++----- .../components/account/EditAccountModal.vue | 118 +++++++++++---- frontend/src/i18n/locales/en.ts | 6 + frontend/src/i18n/locales/zh.ts | 6 + frontend/src/types/index.ts | 6 + frontend/src/utils/format.ts | 41 ++++- frontend/src/views/admin/AccountsView.vue | 41 ++++- frontend/vite.config.ts | 3 +- 32 files changed, 1062 insertions(+), 156 deletions(-) create mode 100644 backend/internal/service/account_expiry_service.go create mode 100644 backend/migrations/030_add_account_expires_at.sql diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index ff6ab4e6..9447de45 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -63,6 +63,7 @@ func provideCleanup( entClient *ent.Client, rdb *redis.Client, tokenRefresh *service.TokenRefreshService, + accountExpiry *service.AccountExpiryService, pricing *service.PricingService, emailQueue *service.EmailQueueService, billingCache *service.BillingCacheService, @@ -84,6 +85,10 @@ func provideCleanup( tokenRefresh.Stop() return nil }}, + {"AccountExpiryService", func() error { + accountExpiry.Stop() + return nil + }}, {"PricingService", func() error { pricing.Stop() return nil diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 768254f9..e952b298 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -87,6 +87,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig) geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient() geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, configConfig) + antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository) geminiQuotaService := service.NewGeminiQuotaService(configConfig, settingRepository) tempUnschedCache := repository.NewTempUnschedCache(redisClient) rateLimitService := service.NewRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache) @@ -97,13 +98,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { geminiTokenCache := repository.NewGeminiTokenCache(redisClient) geminiTokenProvider := service.NewGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService) gatewayCache := repository.NewGatewayCache(redisClient) - antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository) antigravityTokenProvider := service.NewAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService) httpUpstream := repository.NewHTTPUpstream(configConfig) antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, antigravityTokenProvider, rateLimitService, httpUpstream, settingService) accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig) concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig) - concurrencyService := service.NewConcurrencyService(concurrencyCache) + concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService) oAuthHandler := admin.NewOAuthHandler(oAuthService) @@ -148,7 +148,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService) httpServer := server.ProvideHTTPServer(configConfig, engine) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig) - v := provideCleanup(client, redisClient, tokenRefreshService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService) + accountExpiryService := service.ProvideAccountExpiryService(accountRepository) + v := provideCleanup(client, redisClient, tokenRefreshService, accountExpiryService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService) application := &Application{ Server: httpServer, Cleanup: v, @@ -174,6 +175,7 @@ func provideCleanup( entClient *ent.Client, rdb *redis.Client, tokenRefresh *service.TokenRefreshService, + accountExpiry *service.AccountExpiryService, pricing *service.PricingService, emailQueue *service.EmailQueueService, billingCache *service.BillingCacheService, @@ -194,6 +196,10 @@ func provideCleanup( tokenRefresh.Stop() return nil }}, + {"AccountExpiryService", func() error { + accountExpiry.Stop() + return nil + }}, {"PricingService", func() error { pricing.Stop() return nil diff --git a/backend/ent/account.go b/backend/ent/account.go index e4823366..e960d324 100644 --- a/backend/ent/account.go +++ b/backend/ent/account.go @@ -49,6 +49,10 @@ type Account struct { ErrorMessage *string `json:"error_message,omitempty"` // LastUsedAt holds the value of the "last_used_at" field. LastUsedAt *time.Time `json:"last_used_at,omitempty"` + // Account expiration time (NULL means no expiration). + ExpiresAt *time.Time `json:"expires_at,omitempty"` + // Auto pause scheduling when account expires. + AutoPauseOnExpired bool `json:"auto_pause_on_expired,omitempty"` // Schedulable holds the value of the "schedulable" field. Schedulable bool `json:"schedulable,omitempty"` // RateLimitedAt holds the value of the "rate_limited_at" field. @@ -129,13 +133,13 @@ func (*Account) scanValues(columns []string) ([]any, error) { switch columns[i] { case account.FieldCredentials, account.FieldExtra: values[i] = new([]byte) - case account.FieldSchedulable: + case account.FieldAutoPauseOnExpired, account.FieldSchedulable: values[i] = new(sql.NullBool) case account.FieldID, account.FieldProxyID, account.FieldConcurrency, account.FieldPriority: values[i] = new(sql.NullInt64) case account.FieldName, account.FieldNotes, account.FieldPlatform, account.FieldType, account.FieldStatus, account.FieldErrorMessage, account.FieldSessionWindowStatus: values[i] = new(sql.NullString) - case account.FieldCreatedAt, account.FieldUpdatedAt, account.FieldDeletedAt, account.FieldLastUsedAt, account.FieldRateLimitedAt, account.FieldRateLimitResetAt, account.FieldOverloadUntil, account.FieldSessionWindowStart, account.FieldSessionWindowEnd: + case account.FieldCreatedAt, account.FieldUpdatedAt, account.FieldDeletedAt, account.FieldLastUsedAt, account.FieldExpiresAt, account.FieldRateLimitedAt, account.FieldRateLimitResetAt, account.FieldOverloadUntil, account.FieldSessionWindowStart, account.FieldSessionWindowEnd: values[i] = new(sql.NullTime) default: values[i] = new(sql.UnknownType) @@ -257,6 +261,19 @@ func (_m *Account) assignValues(columns []string, values []any) error { _m.LastUsedAt = new(time.Time) *_m.LastUsedAt = value.Time } + case account.FieldExpiresAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field expires_at", values[i]) + } else if value.Valid { + _m.ExpiresAt = new(time.Time) + *_m.ExpiresAt = value.Time + } + case account.FieldAutoPauseOnExpired: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field auto_pause_on_expired", values[i]) + } else if value.Valid { + _m.AutoPauseOnExpired = value.Bool + } case account.FieldSchedulable: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field schedulable", values[i]) @@ -416,6 +433,14 @@ func (_m *Account) String() string { builder.WriteString(v.Format(time.ANSIC)) } builder.WriteString(", ") + if v := _m.ExpiresAt; v != nil { + builder.WriteString("expires_at=") + builder.WriteString(v.Format(time.ANSIC)) + } + builder.WriteString(", ") + builder.WriteString("auto_pause_on_expired=") + builder.WriteString(fmt.Sprintf("%v", _m.AutoPauseOnExpired)) + builder.WriteString(", ") builder.WriteString("schedulable=") builder.WriteString(fmt.Sprintf("%v", _m.Schedulable)) builder.WriteString(", ") diff --git a/backend/ent/account/account.go b/backend/ent/account/account.go index 26f72018..402e16ee 100644 --- a/backend/ent/account/account.go +++ b/backend/ent/account/account.go @@ -45,6 +45,10 @@ const ( FieldErrorMessage = "error_message" // FieldLastUsedAt holds the string denoting the last_used_at field in the database. FieldLastUsedAt = "last_used_at" + // FieldExpiresAt holds the string denoting the expires_at field in the database. + FieldExpiresAt = "expires_at" + // FieldAutoPauseOnExpired holds the string denoting the auto_pause_on_expired field in the database. + FieldAutoPauseOnExpired = "auto_pause_on_expired" // FieldSchedulable holds the string denoting the schedulable field in the database. FieldSchedulable = "schedulable" // FieldRateLimitedAt holds the string denoting the rate_limited_at field in the database. @@ -115,6 +119,8 @@ var Columns = []string{ FieldStatus, FieldErrorMessage, FieldLastUsedAt, + FieldExpiresAt, + FieldAutoPauseOnExpired, FieldSchedulable, FieldRateLimitedAt, FieldRateLimitResetAt, @@ -172,6 +178,8 @@ var ( DefaultStatus string // StatusValidator is a validator for the "status" field. It is called by the builders before save. StatusValidator func(string) error + // DefaultAutoPauseOnExpired holds the default value on creation for the "auto_pause_on_expired" field. + DefaultAutoPauseOnExpired bool // DefaultSchedulable holds the default value on creation for the "schedulable" field. DefaultSchedulable bool // SessionWindowStatusValidator is a validator for the "session_window_status" field. It is called by the builders before save. @@ -251,6 +259,16 @@ func ByLastUsedAt(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldLastUsedAt, opts...).ToFunc() } +// ByExpiresAt orders the results by the expires_at field. +func ByExpiresAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldExpiresAt, opts...).ToFunc() +} + +// ByAutoPauseOnExpired orders the results by the auto_pause_on_expired field. +func ByAutoPauseOnExpired(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAutoPauseOnExpired, opts...).ToFunc() +} + // BySchedulable orders the results by the schedulable field. func BySchedulable(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldSchedulable, opts...).ToFunc() diff --git a/backend/ent/account/where.go b/backend/ent/account/where.go index 1ab75a13..6c639fd1 100644 --- a/backend/ent/account/where.go +++ b/backend/ent/account/where.go @@ -120,6 +120,16 @@ func LastUsedAt(v time.Time) predicate.Account { return predicate.Account(sql.FieldEQ(FieldLastUsedAt, v)) } +// ExpiresAt applies equality check predicate on the "expires_at" field. It's identical to ExpiresAtEQ. +func ExpiresAt(v time.Time) predicate.Account { + return predicate.Account(sql.FieldEQ(FieldExpiresAt, v)) +} + +// AutoPauseOnExpired applies equality check predicate on the "auto_pause_on_expired" field. It's identical to AutoPauseOnExpiredEQ. +func AutoPauseOnExpired(v bool) predicate.Account { + return predicate.Account(sql.FieldEQ(FieldAutoPauseOnExpired, v)) +} + // Schedulable applies equality check predicate on the "schedulable" field. It's identical to SchedulableEQ. func Schedulable(v bool) predicate.Account { return predicate.Account(sql.FieldEQ(FieldSchedulable, v)) @@ -855,6 +865,66 @@ func LastUsedAtNotNil() predicate.Account { return predicate.Account(sql.FieldNotNull(FieldLastUsedAt)) } +// ExpiresAtEQ applies the EQ predicate on the "expires_at" field. +func ExpiresAtEQ(v time.Time) predicate.Account { + return predicate.Account(sql.FieldEQ(FieldExpiresAt, v)) +} + +// ExpiresAtNEQ applies the NEQ predicate on the "expires_at" field. +func ExpiresAtNEQ(v time.Time) predicate.Account { + return predicate.Account(sql.FieldNEQ(FieldExpiresAt, v)) +} + +// ExpiresAtIn applies the In predicate on the "expires_at" field. +func ExpiresAtIn(vs ...time.Time) predicate.Account { + return predicate.Account(sql.FieldIn(FieldExpiresAt, vs...)) +} + +// ExpiresAtNotIn applies the NotIn predicate on the "expires_at" field. +func ExpiresAtNotIn(vs ...time.Time) predicate.Account { + return predicate.Account(sql.FieldNotIn(FieldExpiresAt, vs...)) +} + +// ExpiresAtGT applies the GT predicate on the "expires_at" field. +func ExpiresAtGT(v time.Time) predicate.Account { + return predicate.Account(sql.FieldGT(FieldExpiresAt, v)) +} + +// ExpiresAtGTE applies the GTE predicate on the "expires_at" field. +func ExpiresAtGTE(v time.Time) predicate.Account { + return predicate.Account(sql.FieldGTE(FieldExpiresAt, v)) +} + +// ExpiresAtLT applies the LT predicate on the "expires_at" field. +func ExpiresAtLT(v time.Time) predicate.Account { + return predicate.Account(sql.FieldLT(FieldExpiresAt, v)) +} + +// ExpiresAtLTE applies the LTE predicate on the "expires_at" field. +func ExpiresAtLTE(v time.Time) predicate.Account { + return predicate.Account(sql.FieldLTE(FieldExpiresAt, v)) +} + +// ExpiresAtIsNil applies the IsNil predicate on the "expires_at" field. +func ExpiresAtIsNil() predicate.Account { + return predicate.Account(sql.FieldIsNull(FieldExpiresAt)) +} + +// ExpiresAtNotNil applies the NotNil predicate on the "expires_at" field. +func ExpiresAtNotNil() predicate.Account { + return predicate.Account(sql.FieldNotNull(FieldExpiresAt)) +} + +// AutoPauseOnExpiredEQ applies the EQ predicate on the "auto_pause_on_expired" field. +func AutoPauseOnExpiredEQ(v bool) predicate.Account { + return predicate.Account(sql.FieldEQ(FieldAutoPauseOnExpired, v)) +} + +// AutoPauseOnExpiredNEQ applies the NEQ predicate on the "auto_pause_on_expired" field. +func AutoPauseOnExpiredNEQ(v bool) predicate.Account { + return predicate.Account(sql.FieldNEQ(FieldAutoPauseOnExpired, v)) +} + // SchedulableEQ applies the EQ predicate on the "schedulable" field. func SchedulableEQ(v bool) predicate.Account { return predicate.Account(sql.FieldEQ(FieldSchedulable, v)) diff --git a/backend/ent/account_create.go b/backend/ent/account_create.go index 2d7debc0..0725d43d 100644 --- a/backend/ent/account_create.go +++ b/backend/ent/account_create.go @@ -195,6 +195,34 @@ func (_c *AccountCreate) SetNillableLastUsedAt(v *time.Time) *AccountCreate { return _c } +// SetExpiresAt sets the "expires_at" field. +func (_c *AccountCreate) SetExpiresAt(v time.Time) *AccountCreate { + _c.mutation.SetExpiresAt(v) + return _c +} + +// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. +func (_c *AccountCreate) SetNillableExpiresAt(v *time.Time) *AccountCreate { + if v != nil { + _c.SetExpiresAt(*v) + } + return _c +} + +// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field. +func (_c *AccountCreate) SetAutoPauseOnExpired(v bool) *AccountCreate { + _c.mutation.SetAutoPauseOnExpired(v) + return _c +} + +// SetNillableAutoPauseOnExpired sets the "auto_pause_on_expired" field if the given value is not nil. +func (_c *AccountCreate) SetNillableAutoPauseOnExpired(v *bool) *AccountCreate { + if v != nil { + _c.SetAutoPauseOnExpired(*v) + } + return _c +} + // SetSchedulable sets the "schedulable" field. func (_c *AccountCreate) SetSchedulable(v bool) *AccountCreate { _c.mutation.SetSchedulable(v) @@ -405,6 +433,10 @@ func (_c *AccountCreate) defaults() error { v := account.DefaultStatus _c.mutation.SetStatus(v) } + if _, ok := _c.mutation.AutoPauseOnExpired(); !ok { + v := account.DefaultAutoPauseOnExpired + _c.mutation.SetAutoPauseOnExpired(v) + } if _, ok := _c.mutation.Schedulable(); !ok { v := account.DefaultSchedulable _c.mutation.SetSchedulable(v) @@ -464,6 +496,9 @@ func (_c *AccountCreate) check() error { return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Account.status": %w`, err)} } } + if _, ok := _c.mutation.AutoPauseOnExpired(); !ok { + return &ValidationError{Name: "auto_pause_on_expired", err: errors.New(`ent: missing required field "Account.auto_pause_on_expired"`)} + } if _, ok := _c.mutation.Schedulable(); !ok { return &ValidationError{Name: "schedulable", err: errors.New(`ent: missing required field "Account.schedulable"`)} } @@ -555,6 +590,14 @@ func (_c *AccountCreate) createSpec() (*Account, *sqlgraph.CreateSpec) { _spec.SetField(account.FieldLastUsedAt, field.TypeTime, value) _node.LastUsedAt = &value } + if value, ok := _c.mutation.ExpiresAt(); ok { + _spec.SetField(account.FieldExpiresAt, field.TypeTime, value) + _node.ExpiresAt = &value + } + if value, ok := _c.mutation.AutoPauseOnExpired(); ok { + _spec.SetField(account.FieldAutoPauseOnExpired, field.TypeBool, value) + _node.AutoPauseOnExpired = value + } if value, ok := _c.mutation.Schedulable(); ok { _spec.SetField(account.FieldSchedulable, field.TypeBool, value) _node.Schedulable = value @@ -898,6 +941,36 @@ func (u *AccountUpsert) ClearLastUsedAt() *AccountUpsert { return u } +// SetExpiresAt sets the "expires_at" field. +func (u *AccountUpsert) SetExpiresAt(v time.Time) *AccountUpsert { + u.Set(account.FieldExpiresAt, v) + return u +} + +// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. +func (u *AccountUpsert) UpdateExpiresAt() *AccountUpsert { + u.SetExcluded(account.FieldExpiresAt) + return u +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (u *AccountUpsert) ClearExpiresAt() *AccountUpsert { + u.SetNull(account.FieldExpiresAt) + return u +} + +// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field. +func (u *AccountUpsert) SetAutoPauseOnExpired(v bool) *AccountUpsert { + u.Set(account.FieldAutoPauseOnExpired, v) + return u +} + +// UpdateAutoPauseOnExpired sets the "auto_pause_on_expired" field to the value that was provided on create. +func (u *AccountUpsert) UpdateAutoPauseOnExpired() *AccountUpsert { + u.SetExcluded(account.FieldAutoPauseOnExpired) + return u +} + // SetSchedulable sets the "schedulable" field. func (u *AccountUpsert) SetSchedulable(v bool) *AccountUpsert { u.Set(account.FieldSchedulable, v) @@ -1308,6 +1381,41 @@ func (u *AccountUpsertOne) ClearLastUsedAt() *AccountUpsertOne { }) } +// SetExpiresAt sets the "expires_at" field. +func (u *AccountUpsertOne) SetExpiresAt(v time.Time) *AccountUpsertOne { + return u.Update(func(s *AccountUpsert) { + s.SetExpiresAt(v) + }) +} + +// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. +func (u *AccountUpsertOne) UpdateExpiresAt() *AccountUpsertOne { + return u.Update(func(s *AccountUpsert) { + s.UpdateExpiresAt() + }) +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (u *AccountUpsertOne) ClearExpiresAt() *AccountUpsertOne { + return u.Update(func(s *AccountUpsert) { + s.ClearExpiresAt() + }) +} + +// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field. +func (u *AccountUpsertOne) SetAutoPauseOnExpired(v bool) *AccountUpsertOne { + return u.Update(func(s *AccountUpsert) { + s.SetAutoPauseOnExpired(v) + }) +} + +// UpdateAutoPauseOnExpired sets the "auto_pause_on_expired" field to the value that was provided on create. +func (u *AccountUpsertOne) UpdateAutoPauseOnExpired() *AccountUpsertOne { + return u.Update(func(s *AccountUpsert) { + s.UpdateAutoPauseOnExpired() + }) +} + // SetSchedulable sets the "schedulable" field. func (u *AccountUpsertOne) SetSchedulable(v bool) *AccountUpsertOne { return u.Update(func(s *AccountUpsert) { @@ -1904,6 +2012,41 @@ func (u *AccountUpsertBulk) ClearLastUsedAt() *AccountUpsertBulk { }) } +// SetExpiresAt sets the "expires_at" field. +func (u *AccountUpsertBulk) SetExpiresAt(v time.Time) *AccountUpsertBulk { + return u.Update(func(s *AccountUpsert) { + s.SetExpiresAt(v) + }) +} + +// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. +func (u *AccountUpsertBulk) UpdateExpiresAt() *AccountUpsertBulk { + return u.Update(func(s *AccountUpsert) { + s.UpdateExpiresAt() + }) +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (u *AccountUpsertBulk) ClearExpiresAt() *AccountUpsertBulk { + return u.Update(func(s *AccountUpsert) { + s.ClearExpiresAt() + }) +} + +// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field. +func (u *AccountUpsertBulk) SetAutoPauseOnExpired(v bool) *AccountUpsertBulk { + return u.Update(func(s *AccountUpsert) { + s.SetAutoPauseOnExpired(v) + }) +} + +// UpdateAutoPauseOnExpired sets the "auto_pause_on_expired" field to the value that was provided on create. +func (u *AccountUpsertBulk) UpdateAutoPauseOnExpired() *AccountUpsertBulk { + return u.Update(func(s *AccountUpsert) { + s.UpdateAutoPauseOnExpired() + }) +} + // SetSchedulable sets the "schedulable" field. func (u *AccountUpsertBulk) SetSchedulable(v bool) *AccountUpsertBulk { return u.Update(func(s *AccountUpsert) { diff --git a/backend/ent/account_update.go b/backend/ent/account_update.go index e329abcd..dcc3212d 100644 --- a/backend/ent/account_update.go +++ b/backend/ent/account_update.go @@ -247,6 +247,40 @@ func (_u *AccountUpdate) ClearLastUsedAt() *AccountUpdate { return _u } +// SetExpiresAt sets the "expires_at" field. +func (_u *AccountUpdate) SetExpiresAt(v time.Time) *AccountUpdate { + _u.mutation.SetExpiresAt(v) + return _u +} + +// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. +func (_u *AccountUpdate) SetNillableExpiresAt(v *time.Time) *AccountUpdate { + if v != nil { + _u.SetExpiresAt(*v) + } + return _u +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (_u *AccountUpdate) ClearExpiresAt() *AccountUpdate { + _u.mutation.ClearExpiresAt() + return _u +} + +// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field. +func (_u *AccountUpdate) SetAutoPauseOnExpired(v bool) *AccountUpdate { + _u.mutation.SetAutoPauseOnExpired(v) + return _u +} + +// SetNillableAutoPauseOnExpired sets the "auto_pause_on_expired" field if the given value is not nil. +func (_u *AccountUpdate) SetNillableAutoPauseOnExpired(v *bool) *AccountUpdate { + if v != nil { + _u.SetAutoPauseOnExpired(*v) + } + return _u +} + // SetSchedulable sets the "schedulable" field. func (_u *AccountUpdate) SetSchedulable(v bool) *AccountUpdate { _u.mutation.SetSchedulable(v) @@ -610,6 +644,15 @@ func (_u *AccountUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.LastUsedAtCleared() { _spec.ClearField(account.FieldLastUsedAt, field.TypeTime) } + if value, ok := _u.mutation.ExpiresAt(); ok { + _spec.SetField(account.FieldExpiresAt, field.TypeTime, value) + } + if _u.mutation.ExpiresAtCleared() { + _spec.ClearField(account.FieldExpiresAt, field.TypeTime) + } + if value, ok := _u.mutation.AutoPauseOnExpired(); ok { + _spec.SetField(account.FieldAutoPauseOnExpired, field.TypeBool, value) + } if value, ok := _u.mutation.Schedulable(); ok { _spec.SetField(account.FieldSchedulable, field.TypeBool, value) } @@ -1016,6 +1059,40 @@ func (_u *AccountUpdateOne) ClearLastUsedAt() *AccountUpdateOne { return _u } +// SetExpiresAt sets the "expires_at" field. +func (_u *AccountUpdateOne) SetExpiresAt(v time.Time) *AccountUpdateOne { + _u.mutation.SetExpiresAt(v) + return _u +} + +// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. +func (_u *AccountUpdateOne) SetNillableExpiresAt(v *time.Time) *AccountUpdateOne { + if v != nil { + _u.SetExpiresAt(*v) + } + return _u +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (_u *AccountUpdateOne) ClearExpiresAt() *AccountUpdateOne { + _u.mutation.ClearExpiresAt() + return _u +} + +// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field. +func (_u *AccountUpdateOne) SetAutoPauseOnExpired(v bool) *AccountUpdateOne { + _u.mutation.SetAutoPauseOnExpired(v) + return _u +} + +// SetNillableAutoPauseOnExpired sets the "auto_pause_on_expired" field if the given value is not nil. +func (_u *AccountUpdateOne) SetNillableAutoPauseOnExpired(v *bool) *AccountUpdateOne { + if v != nil { + _u.SetAutoPauseOnExpired(*v) + } + return _u +} + // SetSchedulable sets the "schedulable" field. func (_u *AccountUpdateOne) SetSchedulable(v bool) *AccountUpdateOne { _u.mutation.SetSchedulable(v) @@ -1409,6 +1486,15 @@ func (_u *AccountUpdateOne) sqlSave(ctx context.Context) (_node *Account, err er if _u.mutation.LastUsedAtCleared() { _spec.ClearField(account.FieldLastUsedAt, field.TypeTime) } + if value, ok := _u.mutation.ExpiresAt(); ok { + _spec.SetField(account.FieldExpiresAt, field.TypeTime, value) + } + if _u.mutation.ExpiresAtCleared() { + _spec.ClearField(account.FieldExpiresAt, field.TypeTime) + } + if value, ok := _u.mutation.AutoPauseOnExpired(); ok { + _spec.SetField(account.FieldAutoPauseOnExpired, field.TypeBool, value) + } if value, ok := _u.mutation.Schedulable(); ok { _spec.SetField(account.FieldSchedulable, field.TypeBool, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index d0e43bf3..4fd96f87 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -80,6 +80,8 @@ var ( {Name: "status", Type: field.TypeString, Size: 20, Default: "active"}, {Name: "error_message", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}}, {Name: "last_used_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "expires_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "auto_pause_on_expired", Type: field.TypeBool, Default: true}, {Name: "schedulable", Type: field.TypeBool, Default: true}, {Name: "rate_limited_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "rate_limit_reset_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, @@ -97,7 +99,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "accounts_proxies_proxy", - Columns: []*schema.Column{AccountsColumns[22]}, + Columns: []*schema.Column{AccountsColumns[24]}, RefColumns: []*schema.Column{ProxiesColumns[0]}, OnDelete: schema.SetNull, }, @@ -121,7 +123,7 @@ var ( { Name: "account_proxy_id", Unique: false, - Columns: []*schema.Column{AccountsColumns[22]}, + Columns: []*schema.Column{AccountsColumns[24]}, }, { Name: "account_priority", @@ -136,22 +138,22 @@ var ( { Name: "account_schedulable", Unique: false, - Columns: []*schema.Column{AccountsColumns[15]}, + Columns: []*schema.Column{AccountsColumns[17]}, }, { Name: "account_rate_limited_at", Unique: false, - Columns: []*schema.Column{AccountsColumns[16]}, + Columns: []*schema.Column{AccountsColumns[18]}, }, { Name: "account_rate_limit_reset_at", Unique: false, - Columns: []*schema.Column{AccountsColumns[17]}, + Columns: []*schema.Column{AccountsColumns[19]}, }, { Name: "account_overload_until", Unique: false, - Columns: []*schema.Column{AccountsColumns[18]}, + Columns: []*schema.Column{AccountsColumns[20]}, }, { Name: "account_deleted_at", diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 91883413..ccda9b17 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -1006,6 +1006,8 @@ type AccountMutation struct { status *string error_message *string last_used_at *time.Time + expires_at *time.Time + auto_pause_on_expired *bool schedulable *bool rate_limited_at *time.Time rate_limit_reset_at *time.Time @@ -1770,6 +1772,91 @@ func (m *AccountMutation) ResetLastUsedAt() { delete(m.clearedFields, account.FieldLastUsedAt) } +// SetExpiresAt sets the "expires_at" field. +func (m *AccountMutation) SetExpiresAt(t time.Time) { + m.expires_at = &t +} + +// ExpiresAt returns the value of the "expires_at" field in the mutation. +func (m *AccountMutation) ExpiresAt() (r time.Time, exists bool) { + v := m.expires_at + if v == nil { + return + } + return *v, true +} + +// OldExpiresAt returns the old "expires_at" field's value of the Account entity. +// If the Account object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AccountMutation) OldExpiresAt(ctx context.Context) (v *time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldExpiresAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldExpiresAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldExpiresAt: %w", err) + } + return oldValue.ExpiresAt, nil +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (m *AccountMutation) ClearExpiresAt() { + m.expires_at = nil + m.clearedFields[account.FieldExpiresAt] = struct{}{} +} + +// ExpiresAtCleared returns if the "expires_at" field was cleared in this mutation. +func (m *AccountMutation) ExpiresAtCleared() bool { + _, ok := m.clearedFields[account.FieldExpiresAt] + return ok +} + +// ResetExpiresAt resets all changes to the "expires_at" field. +func (m *AccountMutation) ResetExpiresAt() { + m.expires_at = nil + delete(m.clearedFields, account.FieldExpiresAt) +} + +// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field. +func (m *AccountMutation) SetAutoPauseOnExpired(b bool) { + m.auto_pause_on_expired = &b +} + +// AutoPauseOnExpired returns the value of the "auto_pause_on_expired" field in the mutation. +func (m *AccountMutation) AutoPauseOnExpired() (r bool, exists bool) { + v := m.auto_pause_on_expired + if v == nil { + return + } + return *v, true +} + +// OldAutoPauseOnExpired returns the old "auto_pause_on_expired" field's value of the Account entity. +// If the Account object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AccountMutation) OldAutoPauseOnExpired(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAutoPauseOnExpired is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAutoPauseOnExpired requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAutoPauseOnExpired: %w", err) + } + return oldValue.AutoPauseOnExpired, nil +} + +// ResetAutoPauseOnExpired resets all changes to the "auto_pause_on_expired" field. +func (m *AccountMutation) ResetAutoPauseOnExpired() { + m.auto_pause_on_expired = nil +} + // SetSchedulable sets the "schedulable" field. func (m *AccountMutation) SetSchedulable(b bool) { m.schedulable = &b @@ -2269,7 +2356,7 @@ func (m *AccountMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *AccountMutation) Fields() []string { - fields := make([]string, 0, 22) + fields := make([]string, 0, 24) if m.created_at != nil { fields = append(fields, account.FieldCreatedAt) } @@ -2315,6 +2402,12 @@ func (m *AccountMutation) Fields() []string { if m.last_used_at != nil { fields = append(fields, account.FieldLastUsedAt) } + if m.expires_at != nil { + fields = append(fields, account.FieldExpiresAt) + } + if m.auto_pause_on_expired != nil { + fields = append(fields, account.FieldAutoPauseOnExpired) + } if m.schedulable != nil { fields = append(fields, account.FieldSchedulable) } @@ -2374,6 +2467,10 @@ func (m *AccountMutation) Field(name string) (ent.Value, bool) { return m.ErrorMessage() case account.FieldLastUsedAt: return m.LastUsedAt() + case account.FieldExpiresAt: + return m.ExpiresAt() + case account.FieldAutoPauseOnExpired: + return m.AutoPauseOnExpired() case account.FieldSchedulable: return m.Schedulable() case account.FieldRateLimitedAt: @@ -2427,6 +2524,10 @@ func (m *AccountMutation) OldField(ctx context.Context, name string) (ent.Value, return m.OldErrorMessage(ctx) case account.FieldLastUsedAt: return m.OldLastUsedAt(ctx) + case account.FieldExpiresAt: + return m.OldExpiresAt(ctx) + case account.FieldAutoPauseOnExpired: + return m.OldAutoPauseOnExpired(ctx) case account.FieldSchedulable: return m.OldSchedulable(ctx) case account.FieldRateLimitedAt: @@ -2555,6 +2656,20 @@ func (m *AccountMutation) SetField(name string, value ent.Value) error { } m.SetLastUsedAt(v) return nil + case account.FieldExpiresAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetExpiresAt(v) + return nil + case account.FieldAutoPauseOnExpired: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAutoPauseOnExpired(v) + return nil case account.FieldSchedulable: v, ok := value.(bool) if !ok { @@ -2676,6 +2791,9 @@ func (m *AccountMutation) ClearedFields() []string { if m.FieldCleared(account.FieldLastUsedAt) { fields = append(fields, account.FieldLastUsedAt) } + if m.FieldCleared(account.FieldExpiresAt) { + fields = append(fields, account.FieldExpiresAt) + } if m.FieldCleared(account.FieldRateLimitedAt) { fields = append(fields, account.FieldRateLimitedAt) } @@ -2723,6 +2841,9 @@ func (m *AccountMutation) ClearField(name string) error { case account.FieldLastUsedAt: m.ClearLastUsedAt() return nil + case account.FieldExpiresAt: + m.ClearExpiresAt() + return nil case account.FieldRateLimitedAt: m.ClearRateLimitedAt() return nil @@ -2794,6 +2915,12 @@ func (m *AccountMutation) ResetField(name string) error { case account.FieldLastUsedAt: m.ResetLastUsedAt() return nil + case account.FieldExpiresAt: + m.ResetExpiresAt() + return nil + case account.FieldAutoPauseOnExpired: + m.ResetAutoPauseOnExpired() + return nil case account.FieldSchedulable: m.ResetSchedulable() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index e2cb6a3c..5fe8d905 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -181,12 +181,16 @@ func init() { account.DefaultStatus = accountDescStatus.Default.(string) // account.StatusValidator is a validator for the "status" field. It is called by the builders before save. account.StatusValidator = accountDescStatus.Validators[0].(func(string) error) + // accountDescAutoPauseOnExpired is the schema descriptor for auto_pause_on_expired field. + accountDescAutoPauseOnExpired := accountFields[13].Descriptor() + // account.DefaultAutoPauseOnExpired holds the default value on creation for the auto_pause_on_expired field. + account.DefaultAutoPauseOnExpired = accountDescAutoPauseOnExpired.Default.(bool) // accountDescSchedulable is the schema descriptor for schedulable field. - accountDescSchedulable := accountFields[12].Descriptor() + accountDescSchedulable := accountFields[14].Descriptor() // account.DefaultSchedulable holds the default value on creation for the schedulable field. account.DefaultSchedulable = accountDescSchedulable.Default.(bool) // accountDescSessionWindowStatus is the schema descriptor for session_window_status field. - accountDescSessionWindowStatus := accountFields[18].Descriptor() + accountDescSessionWindowStatus := accountFields[20].Descriptor() // account.SessionWindowStatusValidator is a validator for the "session_window_status" field. It is called by the builders before save. account.SessionWindowStatusValidator = accountDescSessionWindowStatus.Validators[0].(func(string) error) accountgroupFields := schema.AccountGroup{}.Fields() diff --git a/backend/ent/schema/account.go b/backend/ent/schema/account.go index 55c75f28..ec192a97 100644 --- a/backend/ent/schema/account.go +++ b/backend/ent/schema/account.go @@ -118,6 +118,16 @@ func (Account) Fields() []ent.Field { Optional(). Nillable(). SchemaType(map[string]string{dialect.Postgres: "timestamptz"}), + // expires_at: 账户过期时间(可为空) + field.Time("expires_at"). + Optional(). + Nillable(). + Comment("Account expiration time (NULL means no expiration)."). + SchemaType(map[string]string{dialect.Postgres: "timestamptz"}), + // auto_pause_on_expired: 过期后自动暂停调度 + field.Bool("auto_pause_on_expired"). + Default(true). + Comment("Auto pause scheduling when account expires."), // ========== 调度和速率限制相关字段 ========== // 这些字段在 migrations/005_schema_parity.sql 中添加 diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 4303e020..da9f6990 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -85,6 +85,8 @@ type CreateAccountRequest struct { Concurrency int `json:"concurrency"` Priority int `json:"priority"` GroupIDs []int64 `json:"group_ids"` + ExpiresAt *int64 `json:"expires_at"` + AutoPauseOnExpired *bool `json:"auto_pause_on_expired"` ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险 } @@ -101,6 +103,8 @@ type UpdateAccountRequest struct { Priority *int `json:"priority"` Status string `json:"status" binding:"omitempty,oneof=active inactive"` GroupIDs *[]int64 `json:"group_ids"` + ExpiresAt *int64 `json:"expires_at"` + AutoPauseOnExpired *bool `json:"auto_pause_on_expired"` ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险 } @@ -204,6 +208,8 @@ func (h *AccountHandler) Create(c *gin.Context) { Concurrency: req.Concurrency, Priority: req.Priority, GroupIDs: req.GroupIDs, + ExpiresAt: req.ExpiresAt, + AutoPauseOnExpired: req.AutoPauseOnExpired, SkipMixedChannelCheck: skipCheck, }) if err != nil { @@ -261,6 +267,8 @@ func (h *AccountHandler) Update(c *gin.Context) { Priority: req.Priority, // 指针类型,nil 表示未提供 Status: req.Status, GroupIDs: req.GroupIDs, + ExpiresAt: req.ExpiresAt, + AutoPauseOnExpired: req.AutoPauseOnExpired, SkipMixedChannelCheck: skipCheck, }) if err != nil { diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index d937ed77..764a4132 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -1,7 +1,11 @@ // Package dto provides data transfer objects for HTTP handlers. package dto -import "github.com/Wei-Shaw/sub2api/internal/service" +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) func UserFromServiceShallow(u *service.User) *User { if u == nil { @@ -120,6 +124,8 @@ func AccountFromServiceShallow(a *service.Account) *Account { Status: a.Status, ErrorMessage: a.ErrorMessage, LastUsedAt: a.LastUsedAt, + ExpiresAt: timeToUnixSeconds(a.ExpiresAt), + AutoPauseOnExpired: a.AutoPauseOnExpired, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt, Schedulable: a.Schedulable, @@ -157,6 +163,14 @@ func AccountFromService(a *service.Account) *Account { return out } +func timeToUnixSeconds(value *time.Time) *int64 { + if value == nil { + return nil + } + ts := value.Unix() + return &ts +} + func AccountGroupFromService(ag *service.AccountGroup) *AccountGroup { if ag == nil { return nil diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index a8761f81..a11662fe 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -60,21 +60,23 @@ type Group struct { } type Account struct { - ID int64 `json:"id"` - Name string `json:"name"` - Notes *string `json:"notes"` - Platform string `json:"platform"` - Type string `json:"type"` - Credentials map[string]any `json:"credentials"` - Extra map[string]any `json:"extra"` - ProxyID *int64 `json:"proxy_id"` - Concurrency int `json:"concurrency"` - Priority int `json:"priority"` - Status string `json:"status"` - ErrorMessage string `json:"error_message"` - LastUsedAt *time.Time `json:"last_used_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Notes *string `json:"notes"` + Platform string `json:"platform"` + Type string `json:"type"` + Credentials map[string]any `json:"credentials"` + Extra map[string]any `json:"extra"` + ProxyID *int64 `json:"proxy_id"` + Concurrency int `json:"concurrency"` + Priority int `json:"priority"` + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *int64 `json:"expires_at"` + AutoPauseOnExpired bool `json:"auto_pause_on_expired"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` Schedulable bool `json:"schedulable"` diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 1073ae0d..83f02608 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -76,7 +76,8 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account SetPriority(account.Priority). SetStatus(account.Status). SetErrorMessage(account.ErrorMessage). - SetSchedulable(account.Schedulable) + SetSchedulable(account.Schedulable). + SetAutoPauseOnExpired(account.AutoPauseOnExpired) if account.ProxyID != nil { builder.SetProxyID(*account.ProxyID) @@ -84,6 +85,9 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account if account.LastUsedAt != nil { builder.SetLastUsedAt(*account.LastUsedAt) } + if account.ExpiresAt != nil { + builder.SetExpiresAt(*account.ExpiresAt) + } if account.RateLimitedAt != nil { builder.SetRateLimitedAt(*account.RateLimitedAt) } @@ -280,7 +284,8 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account SetPriority(account.Priority). SetStatus(account.Status). SetErrorMessage(account.ErrorMessage). - SetSchedulable(account.Schedulable) + SetSchedulable(account.Schedulable). + SetAutoPauseOnExpired(account.AutoPauseOnExpired) if account.ProxyID != nil { builder.SetProxyID(*account.ProxyID) @@ -292,6 +297,11 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account } else { builder.ClearLastUsedAt() } + if account.ExpiresAt != nil { + builder.SetExpiresAt(*account.ExpiresAt) + } else { + builder.ClearExpiresAt() + } if account.RateLimitedAt != nil { builder.SetRateLimitedAt(*account.RateLimitedAt) } else { @@ -570,6 +580,7 @@ func (r *accountRepository) ListSchedulable(ctx context.Context) ([]service.Acco dbaccount.StatusEQ(service.StatusActive), dbaccount.SchedulableEQ(true), tempUnschedulablePredicate(), + notExpiredPredicate(now), dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)), dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)), ). @@ -596,6 +607,7 @@ func (r *accountRepository) ListSchedulableByPlatform(ctx context.Context, platf dbaccount.StatusEQ(service.StatusActive), dbaccount.SchedulableEQ(true), tempUnschedulablePredicate(), + notExpiredPredicate(now), dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)), dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)), ). @@ -629,6 +641,7 @@ func (r *accountRepository) ListSchedulableByPlatforms(ctx context.Context, plat dbaccount.StatusEQ(service.StatusActive), dbaccount.SchedulableEQ(true), tempUnschedulablePredicate(), + notExpiredPredicate(now), dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)), dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)), ). @@ -727,6 +740,27 @@ func (r *accountRepository) SetSchedulable(ctx context.Context, id int64, schedu return err } +func (r *accountRepository) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) { + result, err := r.sql.ExecContext(ctx, ` + UPDATE accounts + SET schedulable = FALSE, + updated_at = NOW() + WHERE deleted_at IS NULL + AND schedulable = TRUE + AND auto_pause_on_expired = TRUE + AND expires_at IS NOT NULL + AND expires_at <= $1 + `, now) + if err != nil { + return 0, err + } + rows, err := result.RowsAffected() + if err != nil { + return 0, err + } + return rows, nil +} + func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error { if len(updates) == 0 { return nil @@ -861,6 +895,7 @@ func (r *accountRepository) queryAccountsByGroup(ctx context.Context, groupID in preds = append(preds, dbaccount.SchedulableEQ(true), tempUnschedulablePredicate(), + notExpiredPredicate(now), dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)), dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)), ) @@ -971,6 +1006,14 @@ func tempUnschedulablePredicate() dbpredicate.Account { }) } +func notExpiredPredicate(now time.Time) dbpredicate.Account { + return dbaccount.Or( + dbaccount.ExpiresAtIsNil(), + dbaccount.ExpiresAtGT(now), + dbaccount.AutoPauseOnExpiredEQ(false), + ) +} + func (r *accountRepository) loadTempUnschedStates(ctx context.Context, accountIDs []int64) (map[int64]tempUnschedSnapshot, error) { out := make(map[int64]tempUnschedSnapshot) if len(accountIDs) == 0 { @@ -1086,6 +1129,8 @@ func accountEntityToService(m *dbent.Account) *service.Account { Status: m.Status, ErrorMessage: derefString(m.ErrorMessage), LastUsedAt: m.LastUsedAt, + ExpiresAt: m.ExpiresAt, + AutoPauseOnExpired: m.AutoPauseOnExpired, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, Schedulable: m.Schedulable, diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index eb765988..cfce9bfa 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -9,21 +9,23 @@ import ( ) type Account struct { - ID int64 - Name string - Notes *string - Platform string - Type string - Credentials map[string]any - Extra map[string]any - ProxyID *int64 - Concurrency int - Priority int - Status string - ErrorMessage string - LastUsedAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time + ID int64 + Name string + Notes *string + Platform string + Type string + Credentials map[string]any + Extra map[string]any + ProxyID *int64 + Concurrency int + Priority int + Status string + ErrorMessage string + LastUsedAt *time.Time + ExpiresAt *time.Time + AutoPauseOnExpired bool + CreatedAt time.Time + UpdatedAt time.Time Schedulable bool @@ -60,6 +62,9 @@ func (a *Account) IsSchedulable() bool { return false } now := time.Now() + if a.AutoPauseOnExpired && a.ExpiresAt != nil && !now.Before(*a.ExpiresAt) { + return false + } if a.OverloadUntil != nil && now.Before(*a.OverloadUntil) { return false } diff --git a/backend/internal/service/account_expiry_service.go b/backend/internal/service/account_expiry_service.go new file mode 100644 index 00000000..eaada11c --- /dev/null +++ b/backend/internal/service/account_expiry_service.go @@ -0,0 +1,71 @@ +package service + +import ( + "context" + "log" + "sync" + "time" +) + +// AccountExpiryService periodically pauses expired accounts when auto-pause is enabled. +type AccountExpiryService struct { + accountRepo AccountRepository + interval time.Duration + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup +} + +func NewAccountExpiryService(accountRepo AccountRepository, interval time.Duration) *AccountExpiryService { + return &AccountExpiryService{ + accountRepo: accountRepo, + interval: interval, + stopCh: make(chan struct{}), + } +} + +func (s *AccountExpiryService) Start() { + if s == nil || s.accountRepo == nil || s.interval <= 0 { + return + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + + s.runOnce() + for { + select { + case <-ticker.C: + s.runOnce() + case <-s.stopCh: + return + } + } + }() +} + +func (s *AccountExpiryService) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { + close(s.stopCh) + }) + s.wg.Wait() +} + +func (s *AccountExpiryService) runOnce() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + updated, err := s.accountRepo.AutoPauseExpiredAccounts(ctx, time.Now()) + if err != nil { + log.Printf("[AccountExpiry] Auto pause expired accounts failed: %v", err) + return + } + if updated > 0 { + log.Printf("[AccountExpiry] Auto paused %d expired accounts", updated) + } +} diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index c84cb5e9..e1b93fcb 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -38,6 +38,7 @@ type AccountRepository interface { BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error SetError(ctx context.Context, id int64, errorMsg string) error SetSchedulable(ctx context.Context, id int64, schedulable bool) error + AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error ListSchedulable(ctx context.Context) ([]Account, error) @@ -71,29 +72,33 @@ type AccountBulkUpdate struct { // CreateAccountRequest 创建账号请求 type CreateAccountRequest struct { - Name string `json:"name"` - Notes *string `json:"notes"` - Platform string `json:"platform"` - Type string `json:"type"` - Credentials map[string]any `json:"credentials"` - Extra map[string]any `json:"extra"` - ProxyID *int64 `json:"proxy_id"` - Concurrency int `json:"concurrency"` - Priority int `json:"priority"` - GroupIDs []int64 `json:"group_ids"` + Name string `json:"name"` + Notes *string `json:"notes"` + Platform string `json:"platform"` + Type string `json:"type"` + Credentials map[string]any `json:"credentials"` + Extra map[string]any `json:"extra"` + ProxyID *int64 `json:"proxy_id"` + Concurrency int `json:"concurrency"` + Priority int `json:"priority"` + GroupIDs []int64 `json:"group_ids"` + ExpiresAt *time.Time `json:"expires_at"` + AutoPauseOnExpired *bool `json:"auto_pause_on_expired"` } // UpdateAccountRequest 更新账号请求 type UpdateAccountRequest struct { - Name *string `json:"name"` - Notes *string `json:"notes"` - Credentials *map[string]any `json:"credentials"` - Extra *map[string]any `json:"extra"` - ProxyID *int64 `json:"proxy_id"` - Concurrency *int `json:"concurrency"` - Priority *int `json:"priority"` - Status *string `json:"status"` - GroupIDs *[]int64 `json:"group_ids"` + Name *string `json:"name"` + Notes *string `json:"notes"` + Credentials *map[string]any `json:"credentials"` + Extra *map[string]any `json:"extra"` + ProxyID *int64 `json:"proxy_id"` + Concurrency *int `json:"concurrency"` + Priority *int `json:"priority"` + Status *string `json:"status"` + GroupIDs *[]int64 `json:"group_ids"` + ExpiresAt *time.Time `json:"expires_at"` + AutoPauseOnExpired *bool `json:"auto_pause_on_expired"` } // AccountService 账号管理服务 @@ -134,6 +139,12 @@ func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) ( Concurrency: req.Concurrency, Priority: req.Priority, Status: StatusActive, + ExpiresAt: req.ExpiresAt, + } + if req.AutoPauseOnExpired != nil { + account.AutoPauseOnExpired = *req.AutoPauseOnExpired + } else { + account.AutoPauseOnExpired = true } if err := s.accountRepo.Create(ctx, account); err != nil { @@ -224,6 +235,12 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount if req.Status != nil { account.Status = *req.Status } + if req.ExpiresAt != nil { + account.ExpiresAt = req.ExpiresAt + } + if req.AutoPauseOnExpired != nil { + account.AutoPauseOnExpired = *req.AutoPauseOnExpired + } // 先验证分组是否存在(在任何写操作之前) if req.GroupIDs != nil { diff --git a/backend/internal/service/account_service_delete_test.go b/backend/internal/service/account_service_delete_test.go index 974a515c..edad8672 100644 --- a/backend/internal/service/account_service_delete_test.go +++ b/backend/internal/service/account_service_delete_test.go @@ -103,6 +103,10 @@ func (s *accountRepoStub) SetSchedulable(ctx context.Context, id int64, schedula panic("unexpected SetSchedulable call") } +func (s *accountRepoStub) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) { + panic("unexpected AutoPauseExpiredAccounts call") +} + func (s *accountRepoStub) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error { panic("unexpected BindGroups call") } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 0eacfd16..80acd440 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -122,16 +122,18 @@ type UpdateGroupInput struct { } type CreateAccountInput struct { - Name string - Notes *string - Platform string - Type string - Credentials map[string]any - Extra map[string]any - ProxyID *int64 - Concurrency int - Priority int - GroupIDs []int64 + Name string + Notes *string + Platform string + Type string + Credentials map[string]any + Extra map[string]any + ProxyID *int64 + Concurrency int + Priority int + GroupIDs []int64 + ExpiresAt *int64 + AutoPauseOnExpired *bool // SkipMixedChannelCheck skips the mixed channel risk check when binding groups. // This should only be set when the caller has explicitly confirmed the risk. SkipMixedChannelCheck bool @@ -148,6 +150,8 @@ type UpdateAccountInput struct { Priority *int // 使用指针区分"未提供"和"设置为0" Status string GroupIDs *[]int64 + ExpiresAt *int64 + AutoPauseOnExpired *bool SkipMixedChannelCheck bool // 跳过混合渠道检查(用户已确认风险) } @@ -700,6 +704,15 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou Status: StatusActive, Schedulable: true, } + if input.ExpiresAt != nil && *input.ExpiresAt > 0 { + expiresAt := time.Unix(*input.ExpiresAt, 0) + account.ExpiresAt = &expiresAt + } + if input.AutoPauseOnExpired != nil { + account.AutoPauseOnExpired = *input.AutoPauseOnExpired + } else { + account.AutoPauseOnExpired = true + } if err := s.accountRepo.Create(ctx, account); err != nil { return nil, err } @@ -755,6 +768,17 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if input.Status != "" { account.Status = input.Status } + if input.ExpiresAt != nil { + if *input.ExpiresAt <= 0 { + account.ExpiresAt = nil + } else { + expiresAt := time.Unix(*input.ExpiresAt, 0) + account.ExpiresAt = &expiresAt + } + } + if input.AutoPauseOnExpired != nil { + account.AutoPauseOnExpired = *input.AutoPauseOnExpired + } // 先验证分组是否存在(在任何写操作之前) if input.GroupIDs != nil { diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 6c8198b2..47279581 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -105,6 +105,9 @@ func (m *mockAccountRepoForPlatform) SetError(ctx context.Context, id int64, err func (m *mockAccountRepoForPlatform) SetSchedulable(ctx context.Context, id int64, schedulable bool) error { return nil } +func (m *mockAccountRepoForPlatform) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) { + return 0, nil +} func (m *mockAccountRepoForPlatform) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error { return nil } diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index 0a434835..5070b510 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -90,6 +90,9 @@ func (m *mockAccountRepoForGemini) SetError(ctx context.Context, id int64, error func (m *mockAccountRepoForGemini) SetSchedulable(ctx context.Context, id int64, schedulable bool) error { return nil } +func (m *mockAccountRepoForGemini) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) { + return 0, nil +} func (m *mockAccountRepoForGemini) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error { return nil } diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index d4b984d6..cb73409b 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -47,6 +47,13 @@ func ProvideTokenRefreshService( return svc } +// ProvideAccountExpiryService creates and starts AccountExpiryService. +func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpiryService { + svc := NewAccountExpiryService(accountRepo, time.Minute) + svc.Start() + return svc +} + // ProvideTimingWheelService creates and starts TimingWheelService func ProvideTimingWheelService() *TimingWheelService { svc := NewTimingWheelService() @@ -110,6 +117,7 @@ var ProviderSet = wire.NewSet( NewCRSSyncService, ProvideUpdateService, ProvideTokenRefreshService, + ProvideAccountExpiryService, ProvideTimingWheelService, ProvideDeferredService, NewAntigravityQuotaFetcher, diff --git a/backend/migrations/030_add_account_expires_at.sql b/backend/migrations/030_add_account_expires_at.sql new file mode 100644 index 00000000..905220e9 --- /dev/null +++ b/backend/migrations/030_add_account_expires_at.sql @@ -0,0 +1,10 @@ +-- Add expires_at for account expiration configuration +ALTER TABLE accounts ADD COLUMN IF NOT EXISTS expires_at timestamptz; +-- Document expires_at meaning +COMMENT ON COLUMN accounts.expires_at IS 'Account expiration time (NULL means no expiration).'; +-- Add auto_pause_on_expired for account expiration scheduling control +ALTER TABLE accounts ADD COLUMN IF NOT EXISTS auto_pause_on_expired boolean NOT NULL DEFAULT true; +-- Document auto_pause_on_expired meaning +COMMENT ON COLUMN accounts.auto_pause_on_expired IS 'Auto pause scheduling when account expires.'; +-- Ensure existing accounts are enabled by default +UPDATE accounts SET auto_pause_on_expired = true; diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 0091873c..e90bec6c 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1012,7 +1012,7 @@
-
+
@@ -1213,46 +1213,81 @@

{{ t('admin.accounts.priorityHint') }}

+
+ + +

{{ t('admin.accounts.expiresAtHint') }}

+
- -
- -
- - ? - - -
- {{ t('admin.accounts.mixedSchedulingTooltip') }} -
+
+
+
+ +

+ {{ t('admin.accounts.autoPauseOnExpiredDesc') }} +

+
- - +
+ +
+ +
+ + ? + + +
+ {{ t('admin.accounts.mixedSchedulingTooltip') }} +
+
+
+
+ + + +
@@ -1598,6 +1633,7 @@ import Icon from '@/components/icons/Icon.vue' import ProxySelector from '@/components/common/ProxySelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' +import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' // Type for exposed OAuthAuthorizationFlow component @@ -1713,6 +1749,7 @@ const customErrorCodesEnabled = ref(false) const selectedErrorCodes = ref([]) const customErrorCodeInput = ref(null) const interceptWarmupRequests = ref(false) +const autoPauseOnExpired = ref(true) const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const tempUnschedEnabled = ref(false) const tempUnschedRules = ref([]) @@ -1795,7 +1832,8 @@ const form = reactive({ proxy_id: null as number | null, concurrency: 10, priority: 1, - group_ids: [] as number[] + group_ids: [] as number[], + expires_at: null as number | null }) // Helper to check if current type needs OAuth flow @@ -1805,6 +1843,13 @@ const isManualInputMethod = computed(() => { return oauthFlowRef.value?.inputMethod === 'manual' }) +const expiresAtInput = computed({ + get: () => formatDateTimeLocal(form.expires_at), + set: (value: string) => { + form.expires_at = parseDateTimeLocal(value) + } +}) + const canExchangeCode = computed(() => { const authCode = oauthFlowRef.value?.authCode || '' if (form.platform === 'openai') { @@ -2055,6 +2100,7 @@ const resetForm = () => { form.concurrency = 10 form.priority = 1 form.group_ids = [] + form.expires_at = null accountCategory.value = 'oauth-based' addMethod.value = 'oauth' apiKeyBaseUrl.value = 'https://api.anthropic.com' @@ -2066,6 +2112,7 @@ const resetForm = () => { selectedErrorCodes.value = [] customErrorCodeInput.value = null interceptWarmupRequests.value = false + autoPauseOnExpired.value = true tempUnschedEnabled.value = false tempUnschedRules.value = [] geminiOAuthType.value = 'code_assist' @@ -2133,7 +2180,6 @@ const handleSubmit = async () => { if (interceptWarmupRequests.value) { credentials.intercept_warmup_requests = true } - if (!applyTempUnschedConfig(credentials)) { return } @@ -2144,7 +2190,8 @@ const handleSubmit = async () => { try { await adminAPI.accounts.create({ ...form, - group_ids: form.group_ids + group_ids: form.group_ids, + auto_pause_on_expired: autoPauseOnExpired.value }) appStore.showSuccess(t('admin.accounts.accountCreated')) emit('created') @@ -2182,6 +2229,9 @@ const handleGenerateUrl = async () => { } } +const formatDateTimeLocal = formatDateTimeLocalInput +const parseDateTimeLocal = parseDateTimeLocalInput + // Create account and handle success/failure const createAccountAndFinish = async ( platform: AccountPlatform, @@ -2202,7 +2252,9 @@ const createAccountAndFinish = async ( proxy_id: form.proxy_id, concurrency: form.concurrency, priority: form.priority, - group_ids: form.group_ids + group_ids: form.group_ids, + expires_at: form.expires_at, + auto_pause_on_expired: autoPauseOnExpired.value }) appStore.showSuccess(t('admin.accounts.accountCreated')) emit('created') @@ -2416,7 +2468,8 @@ const handleCookieAuth = async (sessionKey: string) => { extra, proxy_id: form.proxy_id, concurrency: form.concurrency, - priority: form.priority + priority: form.priority, + auto_pause_on_expired: autoPauseOnExpired.value }) successCount++ diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 3f47ee31..3b36cfbf 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -365,7 +365,7 @@
-
+
@@ -565,39 +565,74 @@ />
- -
- - +

{{ t('admin.accounts.expiresAtHint') }}

- -
- -
- +
+
+ +

+ {{ t('admin.accounts.autoPauseOnExpiredDesc') }} +

+
+ +
+
+ +
+
+ + + + {{ t('admin.accounts.mixedScheduling') }} + + +
+ + ? + +
+ class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700" + > + {{ t('admin.accounts.mixedSchedulingTooltip') }} +
+
@@ -666,6 +701,7 @@ import Icon from '@/components/icons/Icon.vue' import ProxySelector from '@/components/common/ProxySelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' +import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { getPresetMappingsByPlatform, commonErrorCodes, @@ -721,6 +757,7 @@ const customErrorCodesEnabled = ref(false) const selectedErrorCodes = ref([]) const customErrorCodeInput = ref(null) const interceptWarmupRequests = ref(false) +const autoPauseOnExpired = ref(false) const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const tempUnschedEnabled = ref(false) const tempUnschedRules = ref([]) @@ -771,7 +808,8 @@ const form = reactive({ concurrency: 1, priority: 1, status: 'active' as 'active' | 'inactive', - group_ids: [] as number[] + group_ids: [] as number[], + expires_at: null as number | null }) const statusOptions = computed(() => [ @@ -779,6 +817,13 @@ const statusOptions = computed(() => [ { value: 'inactive', label: t('common.inactive') } ]) +const expiresAtInput = computed({ + get: () => formatDateTimeLocal(form.expires_at), + set: (value: string) => { + form.expires_at = parseDateTimeLocal(value) + } +}) + // Watchers watch( () => props.account, @@ -791,10 +836,12 @@ watch( form.priority = newAccount.priority form.status = newAccount.status as 'active' | 'inactive' form.group_ids = newAccount.group_ids || [] + form.expires_at = newAccount.expires_at ?? null // Load intercept warmup requests setting (applies to all account types) const credentials = newAccount.credentials as Record | undefined interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true + autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true // Load mixed scheduling setting (only for antigravity accounts) const extra = newAccount.extra as Record | undefined @@ -1042,6 +1089,9 @@ function toPositiveNumber(value: unknown) { return Math.trunc(num) } +const formatDateTimeLocal = formatDateTimeLocalInput +const parseDateTimeLocal = parseDateTimeLocalInput + // Methods const handleClose = () => { emit('close') @@ -1057,6 +1107,10 @@ const handleSubmit = async () => { if (updatePayload.proxy_id === null) { updatePayload.proxy_id = 0 } + if (form.expires_at === null) { + updatePayload.expires_at = 0 + } + updatePayload.auto_pause_on_expired = autoPauseOnExpired.value // For apikey type, handle credentials update if (props.account.type === 'apikey') { @@ -1097,7 +1151,6 @@ const handleSubmit = async () => { if (interceptWarmupRequests.value) { newCredentials.intercept_warmup_requests = true } - if (!applyTempUnschedConfig(newCredentials)) { submitting.value = false return @@ -1114,7 +1167,6 @@ const handleSubmit = async () => { } else { delete newCredentials.intercept_warmup_requests } - if (!applyTempUnschedConfig(newCredentials)) { submitting.value = false return diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 4634d8b6..97321ca6 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1011,6 +1011,7 @@ export default { groups: 'Groups', usageWindows: 'Usage Windows', lastUsed: 'Last Used', + expiresAt: 'Expires At', actions: 'Actions' }, tempUnschedulable: { @@ -1152,11 +1153,16 @@ export default { interceptWarmupRequests: 'Intercept Warmup Requests', interceptWarmupRequestsDesc: 'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens', + autoPauseOnExpired: 'Auto Pause On Expired', + autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires', + expired: 'Expired', proxy: 'Proxy', noProxy: 'No Proxy', concurrency: 'Concurrency', priority: 'Priority', priorityHint: 'Higher priority accounts are used first', + expiresAt: 'Expires At', + expiresAtHint: 'Leave empty for no expiration', higherPriorityFirst: 'Higher value means higher priority', mixedScheduling: 'Use in /v1/messages', mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 7e326bab..3f0e2c4f 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1061,6 +1061,7 @@ export default { groups: '分组', usageWindows: '用量窗口', lastUsed: '最近使用', + expiresAt: '过期时间', actions: '操作' }, clearRateLimit: '清除速率限制', @@ -1286,11 +1287,16 @@ export default { errorCodeExists: '该错误码已被选中', interceptWarmupRequests: '拦截预热请求', interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token', + autoPauseOnExpired: '过期自动暂停调度', + autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度', + expired: '已过期', proxy: '代理', noProxy: '无代理', concurrency: '并发数', priority: '优先级', priorityHint: '优先级越高的账号优先使用', + expiresAt: '过期时间', + expiresAtHint: '留空表示不过期', higherPriorityFirst: '数值越高优先级越高', mixedScheduling: '在 /v1/messages 中使用', mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 98368b0e..b16c66ef 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -401,6 +401,8 @@ export interface Account { status: 'active' | 'inactive' | 'error' error_message: string | null last_used_at: string | null + expires_at: number | null + auto_pause_on_expired: boolean created_at: string updated_at: string proxy?: Proxy @@ -491,6 +493,8 @@ export interface CreateAccountRequest { concurrency?: number priority?: number group_ids?: number[] + expires_at?: number | null + auto_pause_on_expired?: boolean confirm_mixed_channel_risk?: boolean } @@ -506,6 +510,8 @@ export interface UpdateAccountRequest { schedulable?: boolean status?: 'active' | 'inactive' group_ids?: number[] + expires_at?: number | null + auto_pause_on_expired?: boolean confirm_mixed_channel_risk?: boolean } diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index 2dc8da4e..bdc68660 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -96,6 +96,7 @@ export function formatBytes(bytes: number, decimals: number = 2): string { * 格式化日期 * @param date 日期字符串或 Date 对象 * @param options Intl.DateTimeFormatOptions + * @param localeOverride 可选 locale 覆盖 * @returns 格式化后的日期字符串 */ export function formatDate( @@ -108,14 +109,15 @@ export function formatDate( minute: '2-digit', second: '2-digit', hour12: false - } + }, + localeOverride?: string ): string { if (!date) return '' const d = new Date(date) if (isNaN(d.getTime())) return '' - const locale = getLocale() + const locale = localeOverride ?? getLocale() return new Intl.DateTimeFormat(locale, options).format(d) } @@ -135,10 +137,41 @@ export function formatDateOnly(date: string | Date | null | undefined): string { /** * 格式化日期时间(完整格式) * @param date 日期字符串或 Date 对象 + * @param options Intl.DateTimeFormatOptions + * @param localeOverride 可选 locale 覆盖 * @returns 格式化后的日期时间字符串 */ -export function formatDateTime(date: string | Date | null | undefined): string { - return formatDate(date) +export function formatDateTime( + date: string | Date | null | undefined, + options?: Intl.DateTimeFormatOptions, + localeOverride?: string +): string { + return formatDate(date, options, localeOverride) +} + +/** + * 格式化为 datetime-local 控件值(YYYY-MM-DDTHH:mm,使用本地时间) + */ +export function formatDateTimeLocalInput(timestampSeconds: number | null): string { + if (!timestampSeconds) return '' + const date = new Date(timestampSeconds * 1000) + if (isNaN(date.getTime())) return '' + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day}T${hours}:${minutes}` +} + +/** + * 解析 datetime-local 控件值为时间戳(秒,使用本地时间) + */ +export function parseDateTimeLocalInput(value: string): number | null { + if (!value) return null + const date = new Date(value) + if (isNaN(date.getTime())) return null + return Math.floor(date.getTime() / 1000) } /** diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index c95b89f3..0ca22a76 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -70,6 +70,25 @@ +