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 5af42d63..73ca35d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,9 +109,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..160a0df9 --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,62 @@ +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 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: 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/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 569117b1..fa965e6f 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, Ent | | 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..b8a818b3 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, Ent | | 前端 | 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/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 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..31dc3682 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) @@ -114,7 +114,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { adminRedeemHandler := admin.NewRedeemHandler(adminService) settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService) updateCache := repository.NewUpdateCache(redisClient) - gitHubReleaseClient := repository.NewGitHubReleaseClient() + gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig) serviceBuildInfo := provideServiceBuildInfo(buildInfo) updateService := service.ProvideUpdateService(updateCache, gitHubReleaseClient, serviceBuildInfo) systemHandler := handler.ProvideSystemHandler(updateService) @@ -125,7 +125,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository) userAttributeHandler := admin.NewUserAttributeHandler(userAttributeService) adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler) - pricingRemoteClient := repository.NewPricingRemoteClient(configConfig) + pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig) pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient) if err != nil { return nil, err @@ -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..e48201f3 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", @@ -371,6 +373,7 @@ var ( {Name: "stream", Type: field.TypeBool, Default: false}, {Name: "duration_ms", Type: field.TypeInt, Nullable: true}, {Name: "first_token_ms", Type: field.TypeInt, Nullable: true}, + {Name: "user_agent", Type: field.TypeString, Nullable: true, Size: 512}, {Name: "image_count", Type: field.TypeInt, Default: 0}, {Name: "image_size", Type: field.TypeString, Nullable: true, Size: 10}, {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, @@ -388,31 +391,31 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "usage_logs_api_keys_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[23]}, + Columns: []*schema.Column{UsageLogsColumns[24]}, RefColumns: []*schema.Column{APIKeysColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_accounts_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[24]}, + Columns: []*schema.Column{UsageLogsColumns[25]}, RefColumns: []*schema.Column{AccountsColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_groups_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[25]}, + Columns: []*schema.Column{UsageLogsColumns[26]}, RefColumns: []*schema.Column{GroupsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "usage_logs_users_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[26]}, + Columns: []*schema.Column{UsageLogsColumns[27]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_user_subscriptions_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[27]}, + Columns: []*schema.Column{UsageLogsColumns[28]}, RefColumns: []*schema.Column{UserSubscriptionsColumns[0]}, OnDelete: schema.SetNull, }, @@ -421,32 +424,32 @@ var ( { Name: "usagelog_user_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[26]}, + Columns: []*schema.Column{UsageLogsColumns[27]}, }, { Name: "usagelog_api_key_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[23]}, + Columns: []*schema.Column{UsageLogsColumns[24]}, }, { Name: "usagelog_account_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[24]}, + Columns: []*schema.Column{UsageLogsColumns[25]}, }, { Name: "usagelog_group_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[25]}, + Columns: []*schema.Column{UsageLogsColumns[26]}, }, { Name: "usagelog_subscription_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[27]}, + Columns: []*schema.Column{UsageLogsColumns[28]}, }, { Name: "usagelog_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[22]}, + Columns: []*schema.Column{UsageLogsColumns[23]}, }, { Name: "usagelog_model", @@ -461,12 +464,12 @@ var ( { Name: "usagelog_user_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[26], UsageLogsColumns[22]}, + Columns: []*schema.Column{UsageLogsColumns[27], UsageLogsColumns[23]}, }, { Name: "usagelog_api_key_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[23], UsageLogsColumns[22]}, + Columns: []*schema.Column{UsageLogsColumns[24], UsageLogsColumns[23]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 91883413..a809e858 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 @@ -8107,6 +8234,7 @@ type UsageLogMutation struct { addduration_ms *int first_token_ms *int addfirst_token_ms *int + user_agent *string image_count *int addimage_count *int image_size *string @@ -9463,6 +9591,55 @@ func (m *UsageLogMutation) ResetFirstTokenMs() { delete(m.clearedFields, usagelog.FieldFirstTokenMs) } +// SetUserAgent sets the "user_agent" field. +func (m *UsageLogMutation) SetUserAgent(s string) { + m.user_agent = &s +} + +// UserAgent returns the value of the "user_agent" field in the mutation. +func (m *UsageLogMutation) UserAgent() (r string, exists bool) { + v := m.user_agent + if v == nil { + return + } + return *v, true +} + +// OldUserAgent returns the old "user_agent" field's value of the UsageLog entity. +// If the UsageLog 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 *UsageLogMutation) OldUserAgent(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUserAgent is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUserAgent requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUserAgent: %w", err) + } + return oldValue.UserAgent, nil +} + +// ClearUserAgent clears the value of the "user_agent" field. +func (m *UsageLogMutation) ClearUserAgent() { + m.user_agent = nil + m.clearedFields[usagelog.FieldUserAgent] = struct{}{} +} + +// UserAgentCleared returns if the "user_agent" field was cleared in this mutation. +func (m *UsageLogMutation) UserAgentCleared() bool { + _, ok := m.clearedFields[usagelog.FieldUserAgent] + return ok +} + +// ResetUserAgent resets all changes to the "user_agent" field. +func (m *UsageLogMutation) ResetUserAgent() { + m.user_agent = nil + delete(m.clearedFields, usagelog.FieldUserAgent) +} + // SetImageCount sets the "image_count" field. func (m *UsageLogMutation) SetImageCount(i int) { m.image_count = &i @@ -9773,7 +9950,7 @@ func (m *UsageLogMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UsageLogMutation) Fields() []string { - fields := make([]string, 0, 27) + fields := make([]string, 0, 28) if m.user != nil { fields = append(fields, usagelog.FieldUserID) } @@ -9846,6 +10023,9 @@ func (m *UsageLogMutation) Fields() []string { if m.first_token_ms != nil { fields = append(fields, usagelog.FieldFirstTokenMs) } + if m.user_agent != nil { + fields = append(fields, usagelog.FieldUserAgent) + } if m.image_count != nil { fields = append(fields, usagelog.FieldImageCount) } @@ -9911,6 +10091,8 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) { return m.DurationMs() case usagelog.FieldFirstTokenMs: return m.FirstTokenMs() + case usagelog.FieldUserAgent: + return m.UserAgent() case usagelog.FieldImageCount: return m.ImageCount() case usagelog.FieldImageSize: @@ -9974,6 +10156,8 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value return m.OldDurationMs(ctx) case usagelog.FieldFirstTokenMs: return m.OldFirstTokenMs(ctx) + case usagelog.FieldUserAgent: + return m.OldUserAgent(ctx) case usagelog.FieldImageCount: return m.OldImageCount(ctx) case usagelog.FieldImageSize: @@ -10157,6 +10341,13 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error { } m.SetFirstTokenMs(v) return nil + case usagelog.FieldUserAgent: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUserAgent(v) + return nil case usagelog.FieldImageCount: v, ok := value.(int) if !ok { @@ -10427,6 +10618,9 @@ func (m *UsageLogMutation) ClearedFields() []string { if m.FieldCleared(usagelog.FieldFirstTokenMs) { fields = append(fields, usagelog.FieldFirstTokenMs) } + if m.FieldCleared(usagelog.FieldUserAgent) { + fields = append(fields, usagelog.FieldUserAgent) + } if m.FieldCleared(usagelog.FieldImageSize) { fields = append(fields, usagelog.FieldImageSize) } @@ -10456,6 +10650,9 @@ func (m *UsageLogMutation) ClearField(name string) error { case usagelog.FieldFirstTokenMs: m.ClearFirstTokenMs() return nil + case usagelog.FieldUserAgent: + m.ClearUserAgent() + return nil case usagelog.FieldImageSize: m.ClearImageSize() return nil @@ -10539,6 +10736,9 @@ func (m *UsageLogMutation) ResetField(name string) error { case usagelog.FieldFirstTokenMs: m.ResetFirstTokenMs() return nil + case usagelog.FieldUserAgent: + m.ResetUserAgent() + return nil case usagelog.FieldImageCount: m.ResetImageCount() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index e2cb6a3c..6ccfc6d2 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() @@ -521,16 +525,20 @@ func init() { usagelogDescStream := usagelogFields[21].Descriptor() // usagelog.DefaultStream holds the default value on creation for the stream field. usagelog.DefaultStream = usagelogDescStream.Default.(bool) + // usagelogDescUserAgent is the schema descriptor for user_agent field. + usagelogDescUserAgent := usagelogFields[24].Descriptor() + // usagelog.UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save. + usagelog.UserAgentValidator = usagelogDescUserAgent.Validators[0].(func(string) error) // usagelogDescImageCount is the schema descriptor for image_count field. - usagelogDescImageCount := usagelogFields[24].Descriptor() + usagelogDescImageCount := usagelogFields[25].Descriptor() // usagelog.DefaultImageCount holds the default value on creation for the image_count field. usagelog.DefaultImageCount = usagelogDescImageCount.Default.(int) // usagelogDescImageSize is the schema descriptor for image_size field. - usagelogDescImageSize := usagelogFields[25].Descriptor() + usagelogDescImageSize := usagelogFields[26].Descriptor() // usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save. usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error) // usagelogDescCreatedAt is the schema descriptor for created_at field. - usagelogDescCreatedAt := usagelogFields[26].Descriptor() + usagelogDescCreatedAt := usagelogFields[27].Descriptor() // usagelog.DefaultCreatedAt holds the default value on creation for the created_at field. usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time) userMixin := schema.User{}.Mixin() 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/ent/schema/usage_log.go b/backend/ent/schema/usage_log.go index af99904d..df955181 100644 --- a/backend/ent/schema/usage_log.go +++ b/backend/ent/schema/usage_log.go @@ -96,6 +96,10 @@ func (UsageLog) Fields() []ent.Field { field.Int("first_token_ms"). Optional(). Nillable(), + field.String("user_agent"). + MaxLen(512). + Optional(). + Nillable(), // 图片生成字段(仅 gemini-3-pro-image 等图片模型使用) field.Int("image_count"). diff --git a/backend/ent/usagelog.go b/backend/ent/usagelog.go index 35cd337f..798f3a9f 100644 --- a/backend/ent/usagelog.go +++ b/backend/ent/usagelog.go @@ -70,6 +70,8 @@ type UsageLog struct { DurationMs *int `json:"duration_ms,omitempty"` // FirstTokenMs holds the value of the "first_token_ms" field. FirstTokenMs *int `json:"first_token_ms,omitempty"` + // UserAgent holds the value of the "user_agent" field. + UserAgent *string `json:"user_agent,omitempty"` // ImageCount holds the value of the "image_count" field. ImageCount int `json:"image_count,omitempty"` // ImageSize holds the value of the "image_size" field. @@ -165,7 +167,7 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullFloat64) case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount: values[i] = new(sql.NullInt64) - case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldImageSize: + case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldUserAgent, usagelog.FieldImageSize: values[i] = new(sql.NullString) case usagelog.FieldCreatedAt: values[i] = new(sql.NullTime) @@ -338,6 +340,13 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error { _m.FirstTokenMs = new(int) *_m.FirstTokenMs = int(value.Int64) } + case usagelog.FieldUserAgent: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field user_agent", values[i]) + } else if value.Valid { + _m.UserAgent = new(string) + *_m.UserAgent = value.String + } case usagelog.FieldImageCount: if value, ok := values[i].(*sql.NullInt64); !ok { return fmt.Errorf("unexpected type %T for field image_count", values[i]) @@ -498,6 +507,11 @@ func (_m *UsageLog) String() string { builder.WriteString(fmt.Sprintf("%v", *v)) } builder.WriteString(", ") + if v := _m.UserAgent; v != nil { + builder.WriteString("user_agent=") + builder.WriteString(*v) + } + builder.WriteString(", ") builder.WriteString("image_count=") builder.WriteString(fmt.Sprintf("%v", _m.ImageCount)) builder.WriteString(", ") diff --git a/backend/ent/usagelog/usagelog.go b/backend/ent/usagelog/usagelog.go index bc0cedc8..d3edfb4d 100644 --- a/backend/ent/usagelog/usagelog.go +++ b/backend/ent/usagelog/usagelog.go @@ -62,6 +62,8 @@ const ( FieldDurationMs = "duration_ms" // FieldFirstTokenMs holds the string denoting the first_token_ms field in the database. FieldFirstTokenMs = "first_token_ms" + // FieldUserAgent holds the string denoting the user_agent field in the database. + FieldUserAgent = "user_agent" // FieldImageCount holds the string denoting the image_count field in the database. FieldImageCount = "image_count" // FieldImageSize holds the string denoting the image_size field in the database. @@ -144,6 +146,7 @@ var Columns = []string{ FieldStream, FieldDurationMs, FieldFirstTokenMs, + FieldUserAgent, FieldImageCount, FieldImageSize, FieldCreatedAt, @@ -194,6 +197,8 @@ var ( DefaultBillingType int8 // DefaultStream holds the default value on creation for the "stream" field. DefaultStream bool + // UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save. + UserAgentValidator func(string) error // DefaultImageCount holds the default value on creation for the "image_count" field. DefaultImageCount int // ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save. @@ -330,6 +335,11 @@ func ByFirstTokenMs(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldFirstTokenMs, opts...).ToFunc() } +// ByUserAgent orders the results by the user_agent field. +func ByUserAgent(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUserAgent, opts...).ToFunc() +} + // ByImageCount orders the results by the image_count field. func ByImageCount(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldImageCount, opts...).ToFunc() diff --git a/backend/ent/usagelog/where.go b/backend/ent/usagelog/where.go index 7d9edae1..c7acd59d 100644 --- a/backend/ent/usagelog/where.go +++ b/backend/ent/usagelog/where.go @@ -175,6 +175,11 @@ func FirstTokenMs(v int) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldFirstTokenMs, v)) } +// UserAgent applies equality check predicate on the "user_agent" field. It's identical to UserAgentEQ. +func UserAgent(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldUserAgent, v)) +} + // ImageCount applies equality check predicate on the "image_count" field. It's identical to ImageCountEQ. func ImageCount(v int) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldImageCount, v)) @@ -1110,6 +1115,81 @@ func FirstTokenMsNotNil() predicate.UsageLog { return predicate.UsageLog(sql.FieldNotNull(FieldFirstTokenMs)) } +// UserAgentEQ applies the EQ predicate on the "user_agent" field. +func UserAgentEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldUserAgent, v)) +} + +// UserAgentNEQ applies the NEQ predicate on the "user_agent" field. +func UserAgentNEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldUserAgent, v)) +} + +// UserAgentIn applies the In predicate on the "user_agent" field. +func UserAgentIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldIn(FieldUserAgent, vs...)) +} + +// UserAgentNotIn applies the NotIn predicate on the "user_agent" field. +func UserAgentNotIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotIn(FieldUserAgent, vs...)) +} + +// UserAgentGT applies the GT predicate on the "user_agent" field. +func UserAgentGT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGT(FieldUserAgent, v)) +} + +// UserAgentGTE applies the GTE predicate on the "user_agent" field. +func UserAgentGTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGTE(FieldUserAgent, v)) +} + +// UserAgentLT applies the LT predicate on the "user_agent" field. +func UserAgentLT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLT(FieldUserAgent, v)) +} + +// UserAgentLTE applies the LTE predicate on the "user_agent" field. +func UserAgentLTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLTE(FieldUserAgent, v)) +} + +// UserAgentContains applies the Contains predicate on the "user_agent" field. +func UserAgentContains(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContains(FieldUserAgent, v)) +} + +// UserAgentHasPrefix applies the HasPrefix predicate on the "user_agent" field. +func UserAgentHasPrefix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasPrefix(FieldUserAgent, v)) +} + +// UserAgentHasSuffix applies the HasSuffix predicate on the "user_agent" field. +func UserAgentHasSuffix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasSuffix(FieldUserAgent, v)) +} + +// UserAgentIsNil applies the IsNil predicate on the "user_agent" field. +func UserAgentIsNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldIsNull(FieldUserAgent)) +} + +// UserAgentNotNil applies the NotNil predicate on the "user_agent" field. +func UserAgentNotNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotNull(FieldUserAgent)) +} + +// UserAgentEqualFold applies the EqualFold predicate on the "user_agent" field. +func UserAgentEqualFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEqualFold(FieldUserAgent, v)) +} + +// UserAgentContainsFold applies the ContainsFold predicate on the "user_agent" field. +func UserAgentContainsFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContainsFold(FieldUserAgent, v)) +} + // ImageCountEQ applies the EQ predicate on the "image_count" field. func ImageCountEQ(v int) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldImageCount, v)) diff --git a/backend/ent/usagelog_create.go b/backend/ent/usagelog_create.go index ef4a9ca2..f77650ab 100644 --- a/backend/ent/usagelog_create.go +++ b/backend/ent/usagelog_create.go @@ -323,6 +323,20 @@ func (_c *UsageLogCreate) SetNillableFirstTokenMs(v *int) *UsageLogCreate { return _c } +// SetUserAgent sets the "user_agent" field. +func (_c *UsageLogCreate) SetUserAgent(v string) *UsageLogCreate { + _c.mutation.SetUserAgent(v) + return _c +} + +// SetNillableUserAgent sets the "user_agent" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableUserAgent(v *string) *UsageLogCreate { + if v != nil { + _c.SetUserAgent(*v) + } + return _c +} + // SetImageCount sets the "image_count" field. func (_c *UsageLogCreate) SetImageCount(v int) *UsageLogCreate { _c.mutation.SetImageCount(v) @@ -567,6 +581,11 @@ func (_c *UsageLogCreate) check() error { if _, ok := _c.mutation.Stream(); !ok { return &ValidationError{Name: "stream", err: errors.New(`ent: missing required field "UsageLog.stream"`)} } + if v, ok := _c.mutation.UserAgent(); ok { + if err := usagelog.UserAgentValidator(v); err != nil { + return &ValidationError{Name: "user_agent", err: fmt.Errorf(`ent: validator failed for field "UsageLog.user_agent": %w`, err)} + } + } if _, ok := _c.mutation.ImageCount(); !ok { return &ValidationError{Name: "image_count", err: errors.New(`ent: missing required field "UsageLog.image_count"`)} } @@ -690,6 +709,10 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) { _spec.SetField(usagelog.FieldFirstTokenMs, field.TypeInt, value) _node.FirstTokenMs = &value } + if value, ok := _c.mutation.UserAgent(); ok { + _spec.SetField(usagelog.FieldUserAgent, field.TypeString, value) + _node.UserAgent = &value + } if value, ok := _c.mutation.ImageCount(); ok { _spec.SetField(usagelog.FieldImageCount, field.TypeInt, value) _node.ImageCount = value @@ -1247,6 +1270,24 @@ func (u *UsageLogUpsert) ClearFirstTokenMs() *UsageLogUpsert { return u } +// SetUserAgent sets the "user_agent" field. +func (u *UsageLogUpsert) SetUserAgent(v string) *UsageLogUpsert { + u.Set(usagelog.FieldUserAgent, v) + return u +} + +// UpdateUserAgent sets the "user_agent" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateUserAgent() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldUserAgent) + return u +} + +// ClearUserAgent clears the value of the "user_agent" field. +func (u *UsageLogUpsert) ClearUserAgent() *UsageLogUpsert { + u.SetNull(usagelog.FieldUserAgent) + return u +} + // SetImageCount sets the "image_count" field. func (u *UsageLogUpsert) SetImageCount(v int) *UsageLogUpsert { u.Set(usagelog.FieldImageCount, v) @@ -1804,6 +1845,27 @@ func (u *UsageLogUpsertOne) ClearFirstTokenMs() *UsageLogUpsertOne { }) } +// SetUserAgent sets the "user_agent" field. +func (u *UsageLogUpsertOne) SetUserAgent(v string) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetUserAgent(v) + }) +} + +// UpdateUserAgent sets the "user_agent" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateUserAgent() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateUserAgent() + }) +} + +// ClearUserAgent clears the value of the "user_agent" field. +func (u *UsageLogUpsertOne) ClearUserAgent() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.ClearUserAgent() + }) +} + // SetImageCount sets the "image_count" field. func (u *UsageLogUpsertOne) SetImageCount(v int) *UsageLogUpsertOne { return u.Update(func(s *UsageLogUpsert) { @@ -2533,6 +2595,27 @@ func (u *UsageLogUpsertBulk) ClearFirstTokenMs() *UsageLogUpsertBulk { }) } +// SetUserAgent sets the "user_agent" field. +func (u *UsageLogUpsertBulk) SetUserAgent(v string) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetUserAgent(v) + }) +} + +// UpdateUserAgent sets the "user_agent" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateUserAgent() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateUserAgent() + }) +} + +// ClearUserAgent clears the value of the "user_agent" field. +func (u *UsageLogUpsertBulk) ClearUserAgent() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.ClearUserAgent() + }) +} + // SetImageCount sets the "image_count" field. func (u *UsageLogUpsertBulk) SetImageCount(v int) *UsageLogUpsertBulk { return u.Update(func(s *UsageLogUpsert) { diff --git a/backend/ent/usagelog_update.go b/backend/ent/usagelog_update.go index 7eb2132b..2e77eef7 100644 --- a/backend/ent/usagelog_update.go +++ b/backend/ent/usagelog_update.go @@ -504,6 +504,26 @@ func (_u *UsageLogUpdate) ClearFirstTokenMs() *UsageLogUpdate { return _u } +// SetUserAgent sets the "user_agent" field. +func (_u *UsageLogUpdate) SetUserAgent(v string) *UsageLogUpdate { + _u.mutation.SetUserAgent(v) + return _u +} + +// SetNillableUserAgent sets the "user_agent" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableUserAgent(v *string) *UsageLogUpdate { + if v != nil { + _u.SetUserAgent(*v) + } + return _u +} + +// ClearUserAgent clears the value of the "user_agent" field. +func (_u *UsageLogUpdate) ClearUserAgent() *UsageLogUpdate { + _u.mutation.ClearUserAgent() + return _u +} + // SetImageCount sets the "image_count" field. func (_u *UsageLogUpdate) SetImageCount(v int) *UsageLogUpdate { _u.mutation.ResetImageCount() @@ -644,6 +664,11 @@ func (_u *UsageLogUpdate) check() error { return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model": %w`, err)} } } + if v, ok := _u.mutation.UserAgent(); ok { + if err := usagelog.UserAgentValidator(v); err != nil { + return &ValidationError{Name: "user_agent", err: fmt.Errorf(`ent: validator failed for field "UsageLog.user_agent": %w`, err)} + } + } if v, ok := _u.mutation.ImageSize(); ok { if err := usagelog.ImageSizeValidator(v); err != nil { return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)} @@ -784,6 +809,12 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.FirstTokenMsCleared() { _spec.ClearField(usagelog.FieldFirstTokenMs, field.TypeInt) } + if value, ok := _u.mutation.UserAgent(); ok { + _spec.SetField(usagelog.FieldUserAgent, field.TypeString, value) + } + if _u.mutation.UserAgentCleared() { + _spec.ClearField(usagelog.FieldUserAgent, field.TypeString) + } if value, ok := _u.mutation.ImageCount(); ok { _spec.SetField(usagelog.FieldImageCount, field.TypeInt, value) } @@ -1433,6 +1464,26 @@ func (_u *UsageLogUpdateOne) ClearFirstTokenMs() *UsageLogUpdateOne { return _u } +// SetUserAgent sets the "user_agent" field. +func (_u *UsageLogUpdateOne) SetUserAgent(v string) *UsageLogUpdateOne { + _u.mutation.SetUserAgent(v) + return _u +} + +// SetNillableUserAgent sets the "user_agent" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableUserAgent(v *string) *UsageLogUpdateOne { + if v != nil { + _u.SetUserAgent(*v) + } + return _u +} + +// ClearUserAgent clears the value of the "user_agent" field. +func (_u *UsageLogUpdateOne) ClearUserAgent() *UsageLogUpdateOne { + _u.mutation.ClearUserAgent() + return _u +} + // SetImageCount sets the "image_count" field. func (_u *UsageLogUpdateOne) SetImageCount(v int) *UsageLogUpdateOne { _u.mutation.ResetImageCount() @@ -1586,6 +1637,11 @@ func (_u *UsageLogUpdateOne) check() error { return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model": %w`, err)} } } + if v, ok := _u.mutation.UserAgent(); ok { + if err := usagelog.UserAgentValidator(v); err != nil { + return &ValidationError{Name: "user_agent", err: fmt.Errorf(`ent: validator failed for field "UsageLog.user_agent": %w`, err)} + } + } if v, ok := _u.mutation.ImageSize(); ok { if err := usagelog.ImageSizeValidator(v); err != nil { return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)} @@ -1743,6 +1799,12 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err if _u.mutation.FirstTokenMsCleared() { _spec.ClearField(usagelog.FieldFirstTokenMs, field.TypeInt) } + if value, ok := _u.mutation.UserAgent(); ok { + _spec.SetField(usagelog.FieldUserAgent, field.TypeString, value) + } + if _u.mutation.UserAgentCleared() { + _spec.ClearField(usagelog.FieldUserAgent, field.TypeString) + } if value, ok := _u.mutation.ImageCount(); ok { _spec.SetField(usagelog.FieldImageCount, field.TypeInt, value) } 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 cb5c5afa..c1e15290 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -52,6 +52,15 @@ type Config struct { RunMode string `mapstructure:"run_mode" yaml:"run_mode"` Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC" Gemini GeminiConfig `mapstructure:"gemini"` + Update UpdateConfig `mapstructure:"update"` +} + +// UpdateConfig 在线更新相关配置 +type UpdateConfig struct { + // ProxyURL 用于访问 GitHub 的代理地址 + // 支持 http/https/socks5/socks5h 协议 + // 例如: "http://127.0.0.1:7890", "socks5://127.0.0.1:1080" + ProxyURL string `mapstructure:"proxy_url"` } type GeminiConfig struct { @@ -148,7 +157,7 @@ type CSPConfig struct { } type ProxyProbeConfig struct { - InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` + InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` // 已禁用:禁止跳过 TLS 证书验证 } type BillingConfig struct { @@ -448,8 +457,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{}) @@ -558,6 +567,10 @@ func setDefaults() { viper.SetDefault("gemini.oauth.client_secret", "") viper.SetDefault("gemini.oauth.scopes", "") viper.SetDefault("gemini.quota.policy", "") + + // Update - 在线更新配置 + // 代理地址为空表示直连 GitHub(适用于海外服务器) + viper.SetDefault("update.proxy_url", "") } func (c *Config) Validate() error { 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") 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/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..ad336b3e 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 @@ -151,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 { @@ -171,8 +172,50 @@ 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 - now := timezone.Now() + userTZ := c.Query("timezone") + now := timezone.NowInUserLocation(userTZ) var startTime, endTime time.Time startDateStr := c.Query("start_date") @@ -180,12 +223,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,39 +238,31 @@ 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 } - 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/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/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index de3cbad9..2d8ff957 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -108,6 +108,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) { // 获取订阅信息(可能为nil)- 提前获取用于后续检查 subscription, _ := middleware2.GetSubscriptionFromContext(c) + // 获取 User-Agent + userAgent := c.Request.UserAgent() + // 0. 检查wait队列是否已满 maxWait := service.CalculateMaxWait(subject.Concurrency) canWait, err := h.concurrencyHelper.IncrementWaitCount(c.Request.Context(), subject.UserID, maxWait) @@ -267,7 +270,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } // 异步记录使用量(subscription已在函数开头获取) - go func(result *service.ForwardResult, usedAccount *service.Account) { + go func(result *service.ForwardResult, usedAccount *service.Account, ua string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ @@ -276,10 +279,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) { User: apiKey.User, Account: usedAccount, Subscription: subscription, + UserAgent: ua, }); err != nil { log.Printf("Record usage failed: %v", err) } - }(result, account) + }(result, account, userAgent) return } } @@ -394,7 +398,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } // 异步记录使用量(subscription已在函数开头获取) - go func(result *service.ForwardResult, usedAccount *service.Account) { + go func(result *service.ForwardResult, usedAccount *service.Account, ua string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ @@ -403,10 +407,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) { User: apiKey.User, Account: usedAccount, Subscription: subscription, + UserAgent: ua, }); err != nil { log.Printf("Record usage failed: %v", err) } - }(result, account) + }(result, account, userAgent) return } } 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() + } +} diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index aaf651e9..fc8c7cd6 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -164,6 +164,9 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { // Get subscription (may be nil) subscription, _ := middleware.GetSubscriptionFromContext(c) + // 获取 User-Agent + userAgent := c.Request.UserAgent() + // For Gemini native API, do not send Claude-style ping frames. geminiConcurrency := NewConcurrencyHelper(h.concurrencyHelper.concurrencyService, SSEPingFormatNone, 0) @@ -300,7 +303,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { } // 6) record usage async - go func(result *service.ForwardResult, usedAccount *service.Account) { + go func(result *service.ForwardResult, usedAccount *service.Account, ua string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ @@ -309,10 +312,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { User: apiKey.User, Account: usedAccount, Subscription: subscription, + UserAgent: ua, }); err != nil { log.Printf("Record usage failed: %v", err) } - }(result, account) + }(result, account, userAgent) return } } diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 04d268a5..f76a9851 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -242,7 +242,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { } // Async record usage - go func(result *service.OpenAIForwardResult, usedAccount *service.Account) { + go func(result *service.OpenAIForwardResult, usedAccount *service.Account, ua string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ @@ -251,10 +251,11 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { User: apiKey.User, Account: usedAccount, Subscription: subscription, + UserAgent: ua, }); err != nil { log.Printf("Record usage failed: %v", err) } - }(result, account) + }(result, account, userAgent) return } } 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/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/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/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/repository/github_release_service.go b/backend/internal/repository/github_release_service.go index dd53c091..77839626 100644 --- a/backend/internal/repository/github_release_service.go +++ b/backend/internal/repository/github_release_service.go @@ -14,23 +14,33 @@ import ( ) type githubReleaseClient struct { - httpClient *http.Client - allowPrivateHosts bool + httpClient *http.Client + downloadHTTPClient *http.Client } -func NewGitHubReleaseClient() service.GitHubReleaseClient { - allowPrivate := false +// NewGitHubReleaseClient 创建 GitHub Release 客户端 +// proxyURL 为空时直连 GitHub,支持 http/https/socks5/socks5h 协议 +func NewGitHubReleaseClient(proxyURL string) service.GitHubReleaseClient { sharedClient, err := httpclient.GetClient(httpclient.Options{ - Timeout: 30 * time.Second, - ValidateResolvedIP: true, - AllowPrivateHosts: allowPrivate, + Timeout: 30 * time.Second, + ProxyURL: proxyURL, }) if err != nil { sharedClient = &http.Client{Timeout: 30 * time.Second} } + + // 下载客户端需要更长的超时时间 + downloadClient, err := httpclient.GetClient(httpclient.Options{ + Timeout: 10 * time.Minute, + ProxyURL: proxyURL, + }) + if err != nil { + downloadClient = &http.Client{Timeout: 10 * time.Minute} + } + return &githubReleaseClient{ - httpClient: sharedClient, - allowPrivateHosts: allowPrivate, + httpClient: sharedClient, + downloadHTTPClient: downloadClient, } } @@ -68,15 +78,8 @@ func (c *githubReleaseClient) DownloadFile(ctx context.Context, url, dest string return err } - downloadClient, err := httpclient.GetClient(httpclient.Options{ - Timeout: 10 * time.Minute, - ValidateResolvedIP: true, - AllowPrivateHosts: c.allowPrivateHosts, - }) - if err != nil { - downloadClient = &http.Client{Timeout: 10 * time.Minute} - } - resp, err := downloadClient.Do(req) + // 使用预配置的下载客户端(已包含代理配置) + resp, err := c.downloadHTTPClient.Do(req) if err != nil { return err } diff --git a/backend/internal/repository/github_release_service_test.go b/backend/internal/repository/github_release_service_test.go index 4eebe81d..d375a193 100644 --- a/backend/internal/repository/github_release_service_test.go +++ b/backend/internal/repository/github_release_service_test.go @@ -39,8 +39,8 @@ func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) { func newTestGitHubReleaseClient() *githubReleaseClient { return &githubReleaseClient{ - httpClient: &http.Client{}, - allowPrivateHosts: true, + httpClient: &http.Client{}, + downloadHTTPClient: &http.Client{}, } } @@ -234,7 +234,7 @@ func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Success() { httpClient: &http.Client{ Transport: &testTransport{testServerURL: s.srv.URL}, }, - allowPrivateHosts: true, + downloadHTTPClient: &http.Client{}, } release, err := s.client.FetchLatestRelease(context.Background(), "test/repo") @@ -254,7 +254,7 @@ func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Non200() { httpClient: &http.Client{ Transport: &testTransport{testServerURL: s.srv.URL}, }, - allowPrivateHosts: true, + downloadHTTPClient: &http.Client{}, } _, err := s.client.FetchLatestRelease(context.Background(), "test/repo") @@ -272,7 +272,7 @@ func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_InvalidJSON() { httpClient: &http.Client{ Transport: &testTransport{testServerURL: s.srv.URL}, }, - allowPrivateHosts: true, + downloadHTTPClient: &http.Client{}, } _, err := s.client.FetchLatestRelease(context.Background(), "test/repo") @@ -288,7 +288,7 @@ func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_ContextCancel() { httpClient: &http.Client{ Transport: &testTransport{testServerURL: s.srv.URL}, }, - allowPrivateHosts: true, + downloadHTTPClient: &http.Client{}, } ctx, cancel := context.WithCancel(context.Background()) diff --git a/backend/internal/repository/pricing_service.go b/backend/internal/repository/pricing_service.go index 791c89c6..07d796b8 100644 --- a/backend/internal/repository/pricing_service.go +++ b/backend/internal/repository/pricing_service.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" "github.com/Wei-Shaw/sub2api/internal/service" ) @@ -17,17 +16,12 @@ type pricingRemoteClient struct { httpClient *http.Client } -func NewPricingRemoteClient(cfg *config.Config) service.PricingRemoteClient { - allowPrivate := false - validateResolvedIP := true - if cfg != nil { - allowPrivate = cfg.Security.URLAllowlist.AllowPrivateHosts - validateResolvedIP = cfg.Security.URLAllowlist.Enabled - } +// NewPricingRemoteClient 创建定价数据远程客户端 +// proxyURL 为空时直连,支持 http/https/socks5/socks5h 协议 +func NewPricingRemoteClient(proxyURL string) service.PricingRemoteClient { sharedClient, err := httpclient.GetClient(httpclient.Options{ - Timeout: 30 * time.Second, - ValidateResolvedIP: validateResolvedIP, - AllowPrivateHosts: allowPrivate, + Timeout: 30 * time.Second, + ProxyURL: proxyURL, }) if err != nil { sharedClient = &http.Client{Timeout: 30 * time.Second} diff --git a/backend/internal/repository/pricing_service_test.go b/backend/internal/repository/pricing_service_test.go index 6745ac58..6ea11211 100644 --- a/backend/internal/repository/pricing_service_test.go +++ b/backend/internal/repository/pricing_service_test.go @@ -6,7 +6,6 @@ import ( "net/http/httptest" "testing" - "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -20,13 +19,7 @@ type PricingServiceSuite struct { func (s *PricingServiceSuite) SetupTest() { s.ctx = context.Background() - client, ok := NewPricingRemoteClient(&config.Config{ - Security: config.SecurityConfig{ - URLAllowlist: config.URLAllowlistConfig{ - AllowPrivateHosts: true, - }, - }, - }).(*pricingRemoteClient) + client, ok := NewPricingRemoteClient("").(*pricingRemoteClient) require.True(s.T(), ok, "type assertion failed") s.client = client } 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/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 82d5e833..bd5c8b4f 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -22,7 +22,7 @@ import ( "github.com/lib/pq" ) -const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, billing_type, stream, duration_ms, first_token_ms, image_count, image_size, created_at" +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, billing_type, stream, duration_ms, first_token_ms, user_agent, image_count, image_size, created_at" type usageLogRepository struct { client *dbent.Client @@ -109,6 +109,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) stream, duration_ms, first_token_ms, + user_agent, image_count, image_size, created_at @@ -118,8 +119,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, - $20, $21, $22, $23, $24, - $25, $26, $27 + $20, $21, $22, $23, $24, $25, $26, $27, $28 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -129,6 +129,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) subscriptionID := nullInt64(log.SubscriptionID) duration := nullInt(log.DurationMs) firstToken := nullInt(log.FirstTokenMs) + userAgent := nullString(log.UserAgent) imageSize := nullString(log.ImageSize) var requestIDArg any @@ -161,6 +162,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) log.Stream, duration, firstToken, + userAgent, log.ImageCount, imageSize, createdAt, @@ -1388,6 +1390,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 @@ -1795,6 +1872,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e stream bool durationMs sql.NullInt64 firstTokenMs sql.NullInt64 + userAgent sql.NullString imageCount int imageSize sql.NullString createdAt time.Time @@ -1826,6 +1904,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &stream, &durationMs, &firstTokenMs, + &userAgent, &imageCount, &imageSize, &createdAt, @@ -1877,6 +1956,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e value := int(firstTokenMs.Int64) log.FirstTokenMs = &value } + if userAgent.Valid { + log.UserAgent = &userAgent.String + } if imageSize.Valid { log.ImageSize = &imageSize.String } diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index f7574563..ba09f85e 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -25,6 +25,18 @@ func ProvideConcurrencyCache(rdb *redis.Client, cfg *config.Config) service.Conc return NewConcurrencyCache(rdb, cfg.Gateway.ConcurrencySlotTTLMinutes, waitTTLSeconds) } +// ProvideGitHubReleaseClient 创建 GitHub Release 客户端 +// 从配置中读取代理设置,支持国内服务器通过代理访问 GitHub +func ProvideGitHubReleaseClient(cfg *config.Config) service.GitHubReleaseClient { + return NewGitHubReleaseClient(cfg.Update.ProxyURL) +} + +// ProvidePricingRemoteClient 创建定价数据远程客户端 +// 从配置中读取代理设置,支持国内服务器通过代理访问 GitHub 上的定价数据 +func ProvidePricingRemoteClient(cfg *config.Config) service.PricingRemoteClient { + return NewPricingRemoteClient(cfg.Update.ProxyURL) +} + // ProviderSet is the Wire provider set for all repositories var ProviderSet = wire.NewSet( NewUserRepository, @@ -53,8 +65,8 @@ var ProviderSet = wire.NewSet( // HTTP service ports (DI Strategy A: return interface directly) NewTurnstileVerifier, - NewPricingRemoteClient, - NewGitHubReleaseClient, + ProvidePricingRemoteClient, + ProvideGitHubReleaseClient, NewProxyExitInfoProber, NewClaudeUsageFetcher, NewClaudeOAuthClient, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index f98ebc59..bd3278c8 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1065,6 +1065,10 @@ func (r *stubUsageLogRepo) GetAccountUsageStats(ctx context.Context, accountID i return nil, errors.New("not implemented") } +func (r *stubUsageLogRepo) GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) { + return nil, errors.New("not implemented") +} + type stubSettingRepo struct { all map[string]string } 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/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/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/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/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/gateway_service.go b/backend/internal/service/gateway_service.go index a83e7d05..98c061d4 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项目) @@ -98,12 +109,13 @@ type ClaudeUsage struct { // ForwardResult 转发结果 type ForwardResult struct { - RequestID string - Usage ClaudeUsage - Model string - Stream bool - Duration time.Duration - FirstTokenMs *int // 首字时间(流式请求) + RequestID string + Usage ClaudeUsage + Model string + Stream bool + Duration time.Duration + FirstTokenMs *int // 首字时间(流式请求) + ClientDisconnect bool // 客户端是否在流式传输过程中断开 // 图片生成计费字段(仅 gemini-3-pro-image 使用) ImageCount int // 生成的图片数量 @@ -355,17 +367,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) } @@ -443,7 +446,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) @@ -660,9 +664,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) } @@ -685,6 +687,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 @@ -723,8 +742,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) } @@ -812,8 +831,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) @@ -1013,15 +1032,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 +1049,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 +1102,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 +1240,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 { @@ -1316,6 +1466,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A // 处理正常响应 var usage *ClaudeUsage var firstTokenMs *int + var clientDisconnect bool if reqStream { streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel) if err != nil { @@ -1328,6 +1479,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A } usage = streamResult.usage firstTokenMs = streamResult.firstTokenMs + clientDisconnect = streamResult.clientDisconnect } else { usage, err = s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, reqModel) if err != nil { @@ -1336,12 +1488,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A } return &ForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - Usage: *usage, - Model: originalModel, // 使用原始模型用于计费和日志 - Stream: reqStream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + RequestID: resp.Header.Get("x-request-id"), + Usage: *usage, + Model: originalModel, // 使用原始模型用于计费和日志 + Stream: reqStream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, }, nil } @@ -1696,8 +1849,9 @@ func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *ht // streamingResult 流式响应结果 type streamingResult struct { - usage *ClaudeUsage - firstTokenMs *int + usage *ClaudeUsage + firstTokenMs *int + clientDisconnect bool // 客户端是否在流式传输过程中断开 } func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*streamingResult, error) { @@ -1793,14 +1947,27 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http } needModelReplace := originalModel != mappedModel + clientDisconnected := false // 客户端断开标志,断开后继续读取上游以获取完整usage for { select { case ev, ok := <-events: if !ok { - return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil + // 上游完成,返回结果 + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, nil } if ev.err != nil { + // 检测 context 取消(客户端断开会导致 context 取消,进而影响上游读取) + if errors.Is(ev.err, context.Canceled) || errors.Is(ev.err, context.DeadlineExceeded) { + log.Printf("Context canceled during streaming, returning collected usage") + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil + } + // 客户端已通过写入失败检测到断开,上游也出错了,返回已收集的 usage + if clientDisconnected { + log.Printf("Upstream read error after client disconnect: %v, returning collected usage", ev.err) + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil + } + // 客户端未断开,正常的错误处理 if errors.Is(ev.err, bufio.ErrTooLong) { log.Printf("SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, ev.err) sendErrorEvent("response_too_large") @@ -1811,38 +1978,40 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http } line := ev.line if line == "event: error" { + // 上游返回错误事件,如果客户端已断开仍返回已收集的 usage + if clientDisconnected { + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil + } return nil, errors.New("have error in stream") } // Extract data from SSE line (supports both "data: " and "data:" formats) + var data string if sseDataRe.MatchString(line) { - data := sseDataRe.ReplaceAllString(line, "") - + data = sseDataRe.ReplaceAllString(line, "") // 如果有模型映射,替换响应中的model字段 if needModelReplace { line = s.replaceModelInSSELine(line, mappedModel, originalModel) } + } - // 转发行 + // 写入客户端(统一处理 data 行和非 data 行) + if !clientDisconnected { if _, err := fmt.Fprintf(w, "%s\n", line); err != nil { - sendErrorEvent("write_failed") - return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, err + clientDisconnected = true + log.Printf("Client disconnected during streaming, continuing to drain upstream for billing") + } else { + flusher.Flush() } - flusher.Flush() + } - // 记录首字时间:第一个有效的 content_block_delta 或 message_start - if firstTokenMs == nil && data != "" && data != "[DONE]" { + // 无论客户端是否断开,都解析 usage(仅对 data 行) + if data != "" { + if firstTokenMs == nil && data != "[DONE]" { ms := int(time.Since(startTime).Milliseconds()) firstTokenMs = &ms } s.parseSSEUsage(data, usage) - } else { - // 非 data 行直接转发 - if _, err := fmt.Fprintf(w, "%s\n", line); err != nil { - sendErrorEvent("write_failed") - return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, err - } - flusher.Flush() } case <-intervalCh: @@ -1850,6 +2019,11 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http if time.Since(lastRead) < streamInterval { continue } + if clientDisconnected { + // 客户端已断开,上游也超时了,返回已收集的 usage + log.Printf("Upstream timeout after client disconnect, returning collected usage") + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil + } log.Printf("Stream data interval timeout: account=%d model=%s interval=%s", account.ID, originalModel, streamInterval) sendErrorEvent("stream_timeout") return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout") @@ -2003,6 +2177,7 @@ type RecordUsageInput struct { User *User Account *Account Subscription *UserSubscription // 可选:订阅信息 + UserAgent string // 请求的 User-Agent } // RecordUsage 记录使用量并扣费(或更新订阅用量) @@ -2088,6 +2263,11 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu CreatedAt: time.Now(), } + // 添加 UserAgent + if input.UserAgent != "" { + usageLog.UserAgent = &input.UserAgent + } + // 添加分组和订阅关联 if apiKey.GroupID != nil { usageLog.GroupID = apiKey.GroupID 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/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 08bd8df5..d744bfab 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1092,6 +1092,7 @@ type OpenAIRecordUsageInput struct { User *User Account *Account Subscription *UserSubscription + UserAgent string // 请求的 User-Agent } // RecordUsage records usage and deducts balance @@ -1161,6 +1162,11 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec CreatedAt: time.Now(), } + // 添加 UserAgent + if input.UserAgent != "" { + usageLog.UserAgent = &input.UserAgent + } + if apiKey.GroupID != nil { usageLog.GroupID = apiKey.GroupID } diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index 255f0440..9ecb7098 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -38,6 +38,7 @@ type UsageLog struct { Stream bool DurationMs *int FirstTokenMs *int + UserAgent *string // 图片生成字段 ImageCount int 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/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/028_add_usage_logs_user_agent.sql b/backend/migrations/028_add_usage_logs_user_agent.sql new file mode 100644 index 00000000..e7e1a581 --- /dev/null +++ b/backend/migrations/028_add_usage_logs_user_agent.sql @@ -0,0 +1,10 @@ +-- Add user_agent column to usage_logs table +-- Records the User-Agent header from API requests for analytics and debugging + +ALTER TABLE usage_logs + ADD COLUMN IF NOT EXISTS user_agent VARCHAR(512); + +-- Optional: Add index for user_agent queries (uncomment if needed for analytics) +-- CREATE INDEX IF NOT EXISTS idx_usage_logs_user_agent ON usage_logs(user_agent); + +COMMENT ON COLUMN usage_logs.user_agent IS 'User-Agent header from the API request'; 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/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 13803325..bd8abc5c 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -69,6 +69,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) # ----------------------------------------------------------------------------- @@ -105,3 +123,17 @@ GEMINI_OAUTH_SCOPES= # Example: # GEMINI_QUOTA_POLICY={"tiers":{"LEGACY":{"pro_rpd":50,"flash_rpd":1500,"cooldown_minutes":30},"PRO":{"pro_rpd":1500,"flash_rpd":4000,"cooldown_minutes":5},"ULTRA":{"pro_rpd":2000,"flash_rpd":0,"cooldown_minutes":5}}} GEMINI_QUOTA_POLICY= + +# ----------------------------------------------------------------------------- +# Update Configuration (在线更新配置) +# ----------------------------------------------------------------------------- +# Proxy URL for accessing GitHub (used for online updates and pricing data) +# 用于访问 GitHub 的代理地址(用于在线更新和定价数据获取) +# Supports: http, https, socks5, socks5h +# Examples: +# HTTP proxy: http://127.0.0.1:7890 +# SOCKS5 proxy: socks5://127.0.0.1:1080 +# With authentication: http://user:pass@proxy.example.com:8080 +# Leave empty for direct connection (recommended for overseas servers) +# 留空表示直连(适用于海外服务器) +UPDATE_PROXY_URL= diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 84f5f578..49bf0afa 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -388,3 +388,18 @@ gemini: # Cooldown time (minutes) after hitting quota # 达到配额后的冷却时间(分钟) cooldown_minutes: 5 + +# ============================================================================= +# Update Configuration (在线更新配置) +# ============================================================================= +update: + # Proxy URL for accessing GitHub (used for online updates and pricing data) + # 用于访问 GitHub 的代理地址(用于在线更新和定价数据获取) + # Supports: http, https, socks5, socks5h + # Examples: + # - HTTP proxy: "http://127.0.0.1:7890" + # - SOCKS5 proxy: "socks5://127.0.0.1:1080" + # - With authentication: "http://user:pass@proxy.example.com:8080" + # Leave empty for direct connection (recommended for overseas servers) + # 留空表示直连(适用于海外服务器) + proxy_url: "" diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 6c344614..6a370e9a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -101,9 +101,21 @@ 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:-} + + # ======================================================================= + # Update Configuration (在线更新配置) + # ======================================================================= + # Proxy for accessing GitHub (online updates + pricing data) + # Examples: http://host:port, socks5://host:port + - UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-} depends_on: postgres: condition: service_healthy 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/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/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) => { 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 4ac149f2..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 @@ -1140,7 +1192,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 } diff --git a/frontend/src/components/admin/account/AccountTableFilters.vue b/frontend/src/components/admin/account/AccountTableFilters.vue index 42043b33..47ceedd7 100644 --- a/frontend/src/components/admin/account/AccountTableFilters.vue +++ b/frontend/src/components/admin/account/AccountTableFilters.vue @@ -7,15 +7,18 @@ @update:model-value="$emit('update:searchQuery', $event)" @search="$emit('change')" /> - - +
diff --git a/frontend/src/components/admin/user/UserAllowedGroupsModal.vue b/frontend/src/components/admin/user/UserAllowedGroupsModal.vue index 409fd835..c1783fd2 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 }) 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 31e242f2..1918577a 100644 --- a/frontend/src/components/admin/user/UserBalanceModal.vue +++ b/frontend/src/components/admin/user/UserBalanceModal.vue @@ -51,7 +51,8 @@ const handleBalanceSubmit = async () => { await adminAPI.users.updateBalance(props.user.id, form.amount, props.operation, form.notes) appStore.showSuccess(t('common.success')); emit('success'); emit('close') } catch (e: any) { + console.error('Failed to update balance:', e) appStore.showError(e.response?.data?.detail || t('common.error')) } finally { submitting.value = false } } - \ No newline at end of file + 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 @@
-
-                
-                
-              
+
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') }}

Set the account call priority.

📊 Priority Rules:
  • Higher number = higher priority
  • System uses high-priority accounts first
  • Same priority = random selection

💡 Use Case: Set main account to high priority, backup accounts to low priority

', + description: '

Set the account call priority.

📊 Priority Rules:

💡 Use Case: Set main account to lower value, backup accounts to higher value

', nextBtn: 'Next' }, accountGroups: { diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index fb46bbbe..ef9d2e39 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: '总消费', @@ -857,7 +859,7 @@ export default { accountsLabel: '指定账号', accountsPlaceholder: '选择账号(留空则不限制)', priorityLabel: '优先级', - priorityHint: '数值越高优先级越高,用于账号调度', + priorityHint: '数值越小优先级越高,用于账号调度', statusLabel: '状态' }, exclusiveObj: { @@ -1059,6 +1061,7 @@ export default { groups: '分组', usageWindows: '用量窗口', lastUsed: '最近使用', + expiresAt: '过期时间', actions: '操作' }, clearRateLimit: '清除速率限制', @@ -1178,7 +1181,7 @@ export default { credentialsLabel: '凭证', credentialsPlaceholder: '请输入 Cookie 或 API Key', priorityLabel: '优先级', - priorityHint: '数值越高优先级越高', + priorityHint: '数值越小优先级越高', weightLabel: '权重', weightHint: '用于负载均衡的权重值', statusLabel: '状态' @@ -1284,12 +1287,17 @@ export default { errorCodeExists: '该错误码已被选中', interceptWarmupRequests: '拦截预热请求', interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token', + autoPauseOnExpired: '过期自动暂停调度', + autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度', + expired: '已过期', proxy: '代理', noProxy: '无代理', concurrency: '并发数', priority: '优先级', - priorityHint: '优先级越高的账号优先使用', - higherPriorityFirst: '数值越高优先级越高', + priorityHint: '优先级越小的账号优先使用', + expiresAt: '过期时间', + expiresAtHint: '留空表示不过期', + higherPriorityFirst: '数值越小优先级越高', mixedScheduling: '在 /v1/messages 中使用', mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度', mixedSchedulingTooltip: @@ -1836,6 +1844,7 @@ export default { userFilter: '用户', searchUserPlaceholder: '按邮箱搜索用户...', searchApiKeyPlaceholder: '按名称搜索 API 密钥...', + searchAccountPlaceholder: '按名称搜索账号...', selectedUser: '已选择', user: '用户', account: '账户', @@ -2126,7 +2135,7 @@ export default { }, accountPriority: { title: '⚖️ 4. 优先级(可选)', - description: '

设置账号的调用优先级。

📊 优先级规则:

💡 使用场景:主账号设置高优先级,备用账号设置低优先级

', + description: '

设置账号的调用优先级。

📊 优先级规则:

💡 使用场景:主账号设置低数值,备用账号设置高数值

', nextBtn: '下一步' }, accountGroups: { 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 8e035e33..0ca22a76 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -6,6 +6,7 @@ @@ -69,6 +70,25 @@ +