diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c0524c8d8..c51b3c075e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -271,3 +271,36 @@ jobs: parse_mode: "Markdown", disable_web_page_preview: true }')" + + sync-version-file: + needs: [release] + if: ${{ needs.release.result == 'success' }} + runs-on: ubuntu-latest + steps: + - name: Checkout default branch + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Sync VERSION file to released tag + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION=${{ github.event.inputs.tag }} + VERSION=${VERSION#v} + else + VERSION=${GITHUB_REF#refs/tags/v} + fi + + CURRENT_VERSION=$(tr -d '\r\n' < backend/cmd/server/VERSION || true) + if [ "$CURRENT_VERSION" = "$VERSION" ]; then + echo "VERSION file already matches $VERSION" + exit 0 + fi + + echo "$VERSION" > backend/cmd/server/VERSION + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add backend/cmd/server/VERSION + git commit -m "chore: sync VERSION to ${VERSION} [skip ci]" + git push origin HEAD:${{ github.event.repository.default_branch }} diff --git a/.gitignore b/.gitignore index da11257671..f87dde6dc5 100644 --- a/.gitignore +++ b/.gitignore @@ -128,6 +128,7 @@ deploy/docker-compose.override.yml .gocache/ vite.config.js docs/* +!docs/ACCOUNT_SCHEDULING_FLOW.md .serena/ # =================== diff --git a/.goreleaser.simple.yaml b/.goreleaser.simple.yaml index 2155ed9df8..14f67fd1cb 100644 --- a/.goreleaser.simple.yaml +++ b/.goreleaser.simple.yaml @@ -47,6 +47,8 @@ dockers: - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/sub2api:latest" dockerfile: Dockerfile.goreleaser use: buildx + extra_files: + - deploy/docker-entrypoint.sh build_flag_templates: - "--platform=linux/amd64" - "--label=org.opencontainers.image.version={{ .Version }}" diff --git a/.goreleaser.yaml b/.goreleaser.yaml index da2f9aa57c..41f9a5559b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -63,6 +63,8 @@ dockers: - "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64" dockerfile: Dockerfile.goreleaser use: buildx + extra_files: + - deploy/docker-entrypoint.sh build_flag_templates: - "--platform=linux/amd64" - "--label=org.opencontainers.image.version={{ .Version }}" @@ -76,6 +78,8 @@ dockers: - "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64" dockerfile: Dockerfile.goreleaser use: buildx + extra_files: + - deploy/docker-entrypoint.sh build_flag_templates: - "--platform=linux/arm64" - "--label=org.opencontainers.image.version={{ .Version }}" @@ -89,6 +93,8 @@ dockers: - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/sub2api:{{ .Version }}-amd64" dockerfile: Dockerfile.goreleaser use: buildx + extra_files: + - deploy/docker-entrypoint.sh build_flag_templates: - "--platform=linux/amd64" - "--label=org.opencontainers.image.version={{ .Version }}" @@ -102,6 +108,8 @@ dockers: - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/sub2api:{{ .Version }}-arm64" dockerfile: Dockerfile.goreleaser use: buildx + extra_files: + - deploy/docker-entrypoint.sh build_flag_templates: - "--platform=linux/arm64" - "--label=org.opencontainers.image.version={{ .Version }}" diff --git a/Dockerfile b/Dockerfile index 8fd48cc234..a16eb958f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -92,6 +92,7 @@ LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api" RUN apk add --no-cache \ ca-certificates \ tzdata \ + su-exec \ libpq \ zstd-libs \ lz4-libs \ @@ -120,8 +121,9 @@ COPY --from=backend-builder --chown=sub2api:sub2api /app/backend/resources /app/ # Create data directory RUN mkdir -p /app/data && chown sub2api:sub2api /app/data -# Switch to non-root user -USER sub2api +# Copy entrypoint script (fixes volume permissions then drops to sub2api) +COPY deploy/docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh # Expose port (can be overridden by SERVER_PORT env var) EXPOSE 8080 @@ -130,5 +132,6 @@ EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ CMD wget -q -T 5 -O /dev/null http://localhost:${SERVER_PORT:-8080}/health || exit 1 -# Run the application -ENTRYPOINT ["/app/sub2api"] +# Run the application (entrypoint fixes /app/data ownership then execs as sub2api) +ENTRYPOINT ["/app/docker-entrypoint.sh"] +CMD ["/app/sub2api"] diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser index 419994b9ac..f251d154c6 100644 --- a/Dockerfile.goreleaser +++ b/Dockerfile.goreleaser @@ -21,6 +21,7 @@ RUN apk add --no-cache \ ca-certificates \ tzdata \ curl \ + su-exec \ libpq \ zstd-libs \ lz4-libs \ @@ -47,11 +48,15 @@ COPY sub2api /app/sub2api # Create data directory RUN mkdir -p /app/data && chown -R sub2api:sub2api /app -USER sub2api +# Copy entrypoint script (fixes volume permissions then drops to sub2api) +COPY deploy/docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ CMD curl -f http://localhost:${SERVER_PORT:-8080}/health || exit 1 -ENTRYPOINT ["/app/sub2api"] +# Run the application (entrypoint fixes /app/data ownership then execs as sub2api) +ENTRYPOINT ["/app/docker-entrypoint.sh"] +CMD ["/app/sub2api"] diff --git a/README.md b/README.md index 4f2d4c511d..97dea36463 100644 --- a/README.md +++ b/README.md @@ -178,10 +178,10 @@ mkdir -p sub2api-deploy && cd sub2api-deploy curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash # Start services -docker-compose up -d +docker compose up -d # View logs -docker-compose logs -f sub2api +docker compose logs -f sub2api ``` **What the script does:** @@ -245,16 +245,16 @@ mkdir -p data postgres_data redis_data # 5. Start all services # Option A: Local directory version (recommended - easy migration) -docker-compose -f docker-compose.local.yml up -d +docker compose -f docker-compose.local.yml up -d # Option B: Named volumes version (simple setup) -docker-compose up -d +docker compose up -d # 6. Check status -docker-compose -f docker-compose.local.yml ps +docker compose -f docker-compose.local.yml ps # 7. View logs -docker-compose -f docker-compose.local.yml logs -f sub2api +docker compose -f docker-compose.local.yml logs -f sub2api ``` #### Deployment Versions @@ -272,15 +272,15 @@ Open `http://YOUR_SERVER_IP:8080` in your browser. If admin password was auto-generated, find it in logs: ```bash -docker-compose -f docker-compose.local.yml logs sub2api | grep "admin password" +docker compose -f docker-compose.local.yml logs sub2api | grep "admin password" ``` #### Upgrade ```bash # Pull latest image and recreate container -docker-compose -f docker-compose.local.yml pull -docker-compose -f docker-compose.local.yml up -d +docker compose -f docker-compose.local.yml pull +docker compose -f docker-compose.local.yml up -d ``` #### Easy Migration (Local Directory Version) @@ -289,7 +289,7 @@ When using `docker-compose.local.yml`, migrate to a new server easily: ```bash # On source server -docker-compose -f docker-compose.local.yml down +docker compose -f docker-compose.local.yml down cd .. tar czf sub2api-complete.tar.gz sub2api-deploy/ @@ -299,23 +299,23 @@ scp sub2api-complete.tar.gz user@new-server:/path/ # On new server tar xzf sub2api-complete.tar.gz cd sub2api-deploy/ -docker-compose -f docker-compose.local.yml up -d +docker compose -f docker-compose.local.yml up -d ``` #### Useful Commands ```bash # Stop all services -docker-compose -f docker-compose.local.yml down +docker compose -f docker-compose.local.yml down # Restart -docker-compose -f docker-compose.local.yml restart +docker compose -f docker-compose.local.yml restart # View all logs -docker-compose -f docker-compose.local.yml logs -f +docker compose -f docker-compose.local.yml logs -f # Remove all data (caution!) -docker-compose -f docker-compose.local.yml down +docker compose -f docker-compose.local.yml down rm -rf data/ postgres_data/ redis_data/ ``` diff --git a/README_CN.md b/README_CN.md index 849f384094..b1f5341d52 100644 --- a/README_CN.md +++ b/README_CN.md @@ -177,10 +177,10 @@ mkdir -p sub2api-deploy && cd sub2api-deploy curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash # 启动服务 -docker-compose up -d +docker compose up -d # 查看日志 -docker-compose logs -f sub2api +docker compose logs -f sub2api ``` **脚本功能:** @@ -244,16 +244,16 @@ mkdir -p data postgres_data redis_data # 5. 启动所有服务 # 选项 A:本地目录版(推荐 - 易于迁移) -docker-compose -f docker-compose.local.yml up -d +docker compose -f docker-compose.local.yml up -d # 选项 B:命名卷版(简单设置) -docker-compose up -d +docker compose up -d # 6. 查看状态 -docker-compose -f docker-compose.local.yml ps +docker compose -f docker-compose.local.yml ps # 7. 查看日志 -docker-compose -f docker-compose.local.yml logs -f sub2api +docker compose -f docker-compose.local.yml logs -f sub2api ``` #### 部署版本对比 @@ -283,15 +283,15 @@ docker-compose -f docker-compose.local.yml logs -f sub2api 如果管理员密码是自动生成的,在日志中查找: ```bash -docker-compose -f docker-compose.local.yml logs sub2api | grep "admin password" +docker compose -f docker-compose.local.yml logs sub2api | grep "admin password" ``` #### 升级 ```bash # 拉取最新镜像并重建容器 -docker-compose -f docker-compose.local.yml pull -docker-compose -f docker-compose.local.yml up -d +docker compose -f docker-compose.local.yml pull +docker compose -f docker-compose.local.yml up -d ``` #### 轻松迁移(本地目录版) @@ -300,7 +300,7 @@ docker-compose -f docker-compose.local.yml up -d ```bash # 源服务器 -docker-compose -f docker-compose.local.yml down +docker compose -f docker-compose.local.yml down cd .. tar czf sub2api-complete.tar.gz sub2api-deploy/ @@ -310,23 +310,23 @@ scp sub2api-complete.tar.gz user@new-server:/path/ # 新服务器 tar xzf sub2api-complete.tar.gz cd sub2api-deploy/ -docker-compose -f docker-compose.local.yml up -d +docker compose -f docker-compose.local.yml up -d ``` #### 常用命令 ```bash # 停止所有服务 -docker-compose -f docker-compose.local.yml down +docker compose -f docker-compose.local.yml down # 重启 -docker-compose -f docker-compose.local.yml restart +docker compose -f docker-compose.local.yml restart # 查看所有日志 -docker-compose -f docker-compose.local.yml logs -f +docker compose -f docker-compose.local.yml logs -f # 删除所有数据(谨慎!) -docker-compose -f docker-compose.local.yml down +docker compose -f docker-compose.local.yml down rm -rf data/ postgres_data/ redis_data/ ``` diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 685fee4b8c..d9d5fa6219 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.101.1 +0.1.104.1 diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index e259e902e1..b2c7ea0b2c 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -110,7 +110,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig) concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig) adminUserHandler := admin.NewUserHandler(adminService, concurrencyService) - groupHandler := admin.NewGroupHandler(adminService) claudeOAuthClient := repository.NewClaudeOAuthClient() oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient) openAIOAuthClient := repository.NewOpenAIOAuthClient() @@ -137,12 +136,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { gatewayCache := repository.NewGatewayCache(redisClient) schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db) schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig) - antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oauthRefreshAPI) + antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oauthRefreshAPI, tempUnschedCache) antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService) accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig) rpmCache := repository.NewRPMCache(redisClient) + groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) + groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, gatewayCache) adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService) dataManagementService := service.NewDataManagementService() diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 03c66cd13e..c6604fe1c8 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -717,6 +717,7 @@ var ( {Name: "id", Type: field.TypeInt64, Increment: true}, {Name: "request_id", Type: field.TypeString, Size: 64}, {Name: "model", Type: field.TypeString, Size: 100}, + {Name: "upstream_model", Type: field.TypeString, Nullable: true, Size: 100}, {Name: "input_tokens", Type: field.TypeInt, Default: 0}, {Name: "output_tokens", Type: field.TypeInt, Default: 0}, {Name: "cache_creation_tokens", Type: field.TypeInt, Default: 0}, @@ -756,31 +757,31 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "usage_logs_api_keys_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[28]}, + Columns: []*schema.Column{UsageLogsColumns[29]}, RefColumns: []*schema.Column{APIKeysColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_accounts_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[29]}, + Columns: []*schema.Column{UsageLogsColumns[30]}, RefColumns: []*schema.Column{AccountsColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_groups_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[30]}, + Columns: []*schema.Column{UsageLogsColumns[31]}, RefColumns: []*schema.Column{GroupsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "usage_logs_users_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[31]}, + Columns: []*schema.Column{UsageLogsColumns[32]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_user_subscriptions_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[32]}, + Columns: []*schema.Column{UsageLogsColumns[33]}, RefColumns: []*schema.Column{UserSubscriptionsColumns[0]}, OnDelete: schema.SetNull, }, @@ -789,32 +790,32 @@ var ( { Name: "usagelog_user_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[31]}, + Columns: []*schema.Column{UsageLogsColumns[32]}, }, { Name: "usagelog_api_key_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[28]}, + Columns: []*schema.Column{UsageLogsColumns[29]}, }, { Name: "usagelog_account_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[29]}, + Columns: []*schema.Column{UsageLogsColumns[30]}, }, { Name: "usagelog_group_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[30]}, + Columns: []*schema.Column{UsageLogsColumns[31]}, }, { Name: "usagelog_subscription_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[32]}, + Columns: []*schema.Column{UsageLogsColumns[33]}, }, { Name: "usagelog_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[27]}, + Columns: []*schema.Column{UsageLogsColumns[28]}, }, { Name: "usagelog_model", @@ -829,17 +830,17 @@ var ( { Name: "usagelog_user_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[31], UsageLogsColumns[27]}, + Columns: []*schema.Column{UsageLogsColumns[32], UsageLogsColumns[28]}, }, { Name: "usagelog_api_key_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[28], UsageLogsColumns[27]}, + Columns: []*schema.Column{UsageLogsColumns[29], UsageLogsColumns[28]}, }, { Name: "usagelog_group_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[30], UsageLogsColumns[27]}, + Columns: []*schema.Column{UsageLogsColumns[31], UsageLogsColumns[28]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 8177d14d5c..2f43299ffb 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -18293,6 +18293,7 @@ type UsageLogMutation struct { id *int64 request_id *string model *string + upstream_model *string input_tokens *int addinput_tokens *int output_tokens *int @@ -18630,6 +18631,55 @@ func (m *UsageLogMutation) ResetModel() { m.model = nil } +// SetUpstreamModel sets the "upstream_model" field. +func (m *UsageLogMutation) SetUpstreamModel(s string) { + m.upstream_model = &s +} + +// UpstreamModel returns the value of the "upstream_model" field in the mutation. +func (m *UsageLogMutation) UpstreamModel() (r string, exists bool) { + v := m.upstream_model + if v == nil { + return + } + return *v, true +} + +// OldUpstreamModel returns the old "upstream_model" 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) OldUpstreamModel(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUpstreamModel is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUpstreamModel requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUpstreamModel: %w", err) + } + return oldValue.UpstreamModel, nil +} + +// ClearUpstreamModel clears the value of the "upstream_model" field. +func (m *UsageLogMutation) ClearUpstreamModel() { + m.upstream_model = nil + m.clearedFields[usagelog.FieldUpstreamModel] = struct{}{} +} + +// UpstreamModelCleared returns if the "upstream_model" field was cleared in this mutation. +func (m *UsageLogMutation) UpstreamModelCleared() bool { + _, ok := m.clearedFields[usagelog.FieldUpstreamModel] + return ok +} + +// ResetUpstreamModel resets all changes to the "upstream_model" field. +func (m *UsageLogMutation) ResetUpstreamModel() { + m.upstream_model = nil + delete(m.clearedFields, usagelog.FieldUpstreamModel) +} + // SetGroupID sets the "group_id" field. func (m *UsageLogMutation) SetGroupID(i int64) { m.group = &i @@ -20251,7 +20301,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, 32) + fields := make([]string, 0, 33) if m.user != nil { fields = append(fields, usagelog.FieldUserID) } @@ -20267,6 +20317,9 @@ func (m *UsageLogMutation) Fields() []string { if m.model != nil { fields = append(fields, usagelog.FieldModel) } + if m.upstream_model != nil { + fields = append(fields, usagelog.FieldUpstreamModel) + } if m.group != nil { fields = append(fields, usagelog.FieldGroupID) } @@ -20366,6 +20419,8 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) { return m.RequestID() case usagelog.FieldModel: return m.Model() + case usagelog.FieldUpstreamModel: + return m.UpstreamModel() case usagelog.FieldGroupID: return m.GroupID() case usagelog.FieldSubscriptionID: @@ -20439,6 +20494,8 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value return m.OldRequestID(ctx) case usagelog.FieldModel: return m.OldModel(ctx) + case usagelog.FieldUpstreamModel: + return m.OldUpstreamModel(ctx) case usagelog.FieldGroupID: return m.OldGroupID(ctx) case usagelog.FieldSubscriptionID: @@ -20537,6 +20594,13 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error { } m.SetModel(v) return nil + case usagelog.FieldUpstreamModel: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUpstreamModel(v) + return nil case usagelog.FieldGroupID: v, ok := value.(int64) if !ok { @@ -20975,6 +21039,9 @@ func (m *UsageLogMutation) AddField(name string, value ent.Value) error { // mutation. func (m *UsageLogMutation) ClearedFields() []string { var fields []string + if m.FieldCleared(usagelog.FieldUpstreamModel) { + fields = append(fields, usagelog.FieldUpstreamModel) + } if m.FieldCleared(usagelog.FieldGroupID) { fields = append(fields, usagelog.FieldGroupID) } @@ -21016,6 +21083,9 @@ func (m *UsageLogMutation) FieldCleared(name string) bool { // error if the field is not defined in the schema. func (m *UsageLogMutation) ClearField(name string) error { switch name { + case usagelog.FieldUpstreamModel: + m.ClearUpstreamModel() + return nil case usagelog.FieldGroupID: m.ClearGroupID() return nil @@ -21066,6 +21136,9 @@ func (m *UsageLogMutation) ResetField(name string) error { case usagelog.FieldModel: m.ResetModel() return nil + case usagelog.FieldUpstreamModel: + m.ResetUpstreamModel() + return nil case usagelog.FieldGroupID: m.ResetGroupID() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index ff8a655bb8..aa47756f1a 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -825,92 +825,96 @@ func init() { return nil } }() + // usagelogDescUpstreamModel is the schema descriptor for upstream_model field. + usagelogDescUpstreamModel := usagelogFields[5].Descriptor() + // usagelog.UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save. + usagelog.UpstreamModelValidator = usagelogDescUpstreamModel.Validators[0].(func(string) error) // usagelogDescInputTokens is the schema descriptor for input_tokens field. - usagelogDescInputTokens := usagelogFields[7].Descriptor() + usagelogDescInputTokens := usagelogFields[8].Descriptor() // usagelog.DefaultInputTokens holds the default value on creation for the input_tokens field. usagelog.DefaultInputTokens = usagelogDescInputTokens.Default.(int) // usagelogDescOutputTokens is the schema descriptor for output_tokens field. - usagelogDescOutputTokens := usagelogFields[8].Descriptor() + usagelogDescOutputTokens := usagelogFields[9].Descriptor() // usagelog.DefaultOutputTokens holds the default value on creation for the output_tokens field. usagelog.DefaultOutputTokens = usagelogDescOutputTokens.Default.(int) // usagelogDescCacheCreationTokens is the schema descriptor for cache_creation_tokens field. - usagelogDescCacheCreationTokens := usagelogFields[9].Descriptor() + usagelogDescCacheCreationTokens := usagelogFields[10].Descriptor() // usagelog.DefaultCacheCreationTokens holds the default value on creation for the cache_creation_tokens field. usagelog.DefaultCacheCreationTokens = usagelogDescCacheCreationTokens.Default.(int) // usagelogDescCacheReadTokens is the schema descriptor for cache_read_tokens field. - usagelogDescCacheReadTokens := usagelogFields[10].Descriptor() + usagelogDescCacheReadTokens := usagelogFields[11].Descriptor() // usagelog.DefaultCacheReadTokens holds the default value on creation for the cache_read_tokens field. usagelog.DefaultCacheReadTokens = usagelogDescCacheReadTokens.Default.(int) // usagelogDescCacheCreation5mTokens is the schema descriptor for cache_creation_5m_tokens field. - usagelogDescCacheCreation5mTokens := usagelogFields[11].Descriptor() + usagelogDescCacheCreation5mTokens := usagelogFields[12].Descriptor() // usagelog.DefaultCacheCreation5mTokens holds the default value on creation for the cache_creation_5m_tokens field. usagelog.DefaultCacheCreation5mTokens = usagelogDescCacheCreation5mTokens.Default.(int) // usagelogDescCacheCreation1hTokens is the schema descriptor for cache_creation_1h_tokens field. - usagelogDescCacheCreation1hTokens := usagelogFields[12].Descriptor() + usagelogDescCacheCreation1hTokens := usagelogFields[13].Descriptor() // usagelog.DefaultCacheCreation1hTokens holds the default value on creation for the cache_creation_1h_tokens field. usagelog.DefaultCacheCreation1hTokens = usagelogDescCacheCreation1hTokens.Default.(int) // usagelogDescInputCost is the schema descriptor for input_cost field. - usagelogDescInputCost := usagelogFields[13].Descriptor() + usagelogDescInputCost := usagelogFields[14].Descriptor() // usagelog.DefaultInputCost holds the default value on creation for the input_cost field. usagelog.DefaultInputCost = usagelogDescInputCost.Default.(float64) // usagelogDescOutputCost is the schema descriptor for output_cost field. - usagelogDescOutputCost := usagelogFields[14].Descriptor() + usagelogDescOutputCost := usagelogFields[15].Descriptor() // usagelog.DefaultOutputCost holds the default value on creation for the output_cost field. usagelog.DefaultOutputCost = usagelogDescOutputCost.Default.(float64) // usagelogDescCacheCreationCost is the schema descriptor for cache_creation_cost field. - usagelogDescCacheCreationCost := usagelogFields[15].Descriptor() + usagelogDescCacheCreationCost := usagelogFields[16].Descriptor() // usagelog.DefaultCacheCreationCost holds the default value on creation for the cache_creation_cost field. usagelog.DefaultCacheCreationCost = usagelogDescCacheCreationCost.Default.(float64) // usagelogDescCacheReadCost is the schema descriptor for cache_read_cost field. - usagelogDescCacheReadCost := usagelogFields[16].Descriptor() + usagelogDescCacheReadCost := usagelogFields[17].Descriptor() // usagelog.DefaultCacheReadCost holds the default value on creation for the cache_read_cost field. usagelog.DefaultCacheReadCost = usagelogDescCacheReadCost.Default.(float64) // usagelogDescTotalCost is the schema descriptor for total_cost field. - usagelogDescTotalCost := usagelogFields[17].Descriptor() + usagelogDescTotalCost := usagelogFields[18].Descriptor() // usagelog.DefaultTotalCost holds the default value on creation for the total_cost field. usagelog.DefaultTotalCost = usagelogDescTotalCost.Default.(float64) // usagelogDescActualCost is the schema descriptor for actual_cost field. - usagelogDescActualCost := usagelogFields[18].Descriptor() + usagelogDescActualCost := usagelogFields[19].Descriptor() // usagelog.DefaultActualCost holds the default value on creation for the actual_cost field. usagelog.DefaultActualCost = usagelogDescActualCost.Default.(float64) // usagelogDescRateMultiplier is the schema descriptor for rate_multiplier field. - usagelogDescRateMultiplier := usagelogFields[19].Descriptor() + usagelogDescRateMultiplier := usagelogFields[20].Descriptor() // usagelog.DefaultRateMultiplier holds the default value on creation for the rate_multiplier field. usagelog.DefaultRateMultiplier = usagelogDescRateMultiplier.Default.(float64) // usagelogDescBillingType is the schema descriptor for billing_type field. - usagelogDescBillingType := usagelogFields[21].Descriptor() + usagelogDescBillingType := usagelogFields[22].Descriptor() // usagelog.DefaultBillingType holds the default value on creation for the billing_type field. usagelog.DefaultBillingType = usagelogDescBillingType.Default.(int8) // usagelogDescStream is the schema descriptor for stream field. - usagelogDescStream := usagelogFields[22].Descriptor() + usagelogDescStream := usagelogFields[23].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[25].Descriptor() + usagelogDescUserAgent := usagelogFields[26].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) // usagelogDescIPAddress is the schema descriptor for ip_address field. - usagelogDescIPAddress := usagelogFields[26].Descriptor() + usagelogDescIPAddress := usagelogFields[27].Descriptor() // usagelog.IPAddressValidator is a validator for the "ip_address" field. It is called by the builders before save. usagelog.IPAddressValidator = usagelogDescIPAddress.Validators[0].(func(string) error) // usagelogDescImageCount is the schema descriptor for image_count field. - usagelogDescImageCount := usagelogFields[27].Descriptor() + usagelogDescImageCount := usagelogFields[28].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[28].Descriptor() + usagelogDescImageSize := usagelogFields[29].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) // usagelogDescMediaType is the schema descriptor for media_type field. - usagelogDescMediaType := usagelogFields[29].Descriptor() + usagelogDescMediaType := usagelogFields[30].Descriptor() // usagelog.MediaTypeValidator is a validator for the "media_type" field. It is called by the builders before save. usagelog.MediaTypeValidator = usagelogDescMediaType.Validators[0].(func(string) error) // usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field. - usagelogDescCacheTTLOverridden := usagelogFields[30].Descriptor() + usagelogDescCacheTTLOverridden := usagelogFields[31].Descriptor() // usagelog.DefaultCacheTTLOverridden holds the default value on creation for the cache_ttl_overridden field. usagelog.DefaultCacheTTLOverridden = usagelogDescCacheTTLOverridden.Default.(bool) // usagelogDescCreatedAt is the schema descriptor for created_at field. - usagelogDescCreatedAt := usagelogFields[31].Descriptor() + usagelogDescCreatedAt := usagelogFields[32].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/usage_log.go b/backend/ent/schema/usage_log.go index dcca1a0ad2..8f8a5255b9 100644 --- a/backend/ent/schema/usage_log.go +++ b/backend/ent/schema/usage_log.go @@ -41,6 +41,12 @@ func (UsageLog) Fields() []ent.Field { field.String("model"). MaxLen(100). NotEmpty(), + // UpstreamModel stores the actual upstream model name when model mapping + // is applied. NULL means no mapping — the requested model was used as-is. + field.String("upstream_model"). + MaxLen(100). + Optional(). + Nillable(), field.Int64("group_id"). Optional(). Nillable(), diff --git a/backend/ent/usagelog.go b/backend/ent/usagelog.go index f6968d0d97..014851c99e 100644 --- a/backend/ent/usagelog.go +++ b/backend/ent/usagelog.go @@ -32,6 +32,8 @@ type UsageLog struct { RequestID string `json:"request_id,omitempty"` // Model holds the value of the "model" field. Model string `json:"model,omitempty"` + // UpstreamModel holds the value of the "upstream_model" field. + UpstreamModel *string `json:"upstream_model,omitempty"` // GroupID holds the value of the "group_id" field. GroupID *int64 `json:"group_id,omitempty"` // SubscriptionID holds the value of the "subscription_id" field. @@ -175,7 +177,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.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldMediaType: + case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldUpstreamModel, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldMediaType: values[i] = new(sql.NullString) case usagelog.FieldCreatedAt: values[i] = new(sql.NullTime) @@ -230,6 +232,13 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error { } else if value.Valid { _m.Model = value.String } + case usagelog.FieldUpstreamModel: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field upstream_model", values[i]) + } else if value.Valid { + _m.UpstreamModel = new(string) + *_m.UpstreamModel = value.String + } case usagelog.FieldGroupID: if value, ok := values[i].(*sql.NullInt64); !ok { return fmt.Errorf("unexpected type %T for field group_id", values[i]) @@ -477,6 +486,11 @@ func (_m *UsageLog) String() string { builder.WriteString("model=") builder.WriteString(_m.Model) builder.WriteString(", ") + if v := _m.UpstreamModel; v != nil { + builder.WriteString("upstream_model=") + builder.WriteString(*v) + } + builder.WriteString(", ") if v := _m.GroupID; v != nil { builder.WriteString("group_id=") builder.WriteString(fmt.Sprintf("%v", *v)) diff --git a/backend/ent/usagelog/usagelog.go b/backend/ent/usagelog/usagelog.go index ba97b84376..789407e71f 100644 --- a/backend/ent/usagelog/usagelog.go +++ b/backend/ent/usagelog/usagelog.go @@ -24,6 +24,8 @@ const ( FieldRequestID = "request_id" // FieldModel holds the string denoting the model field in the database. FieldModel = "model" + // FieldUpstreamModel holds the string denoting the upstream_model field in the database. + FieldUpstreamModel = "upstream_model" // FieldGroupID holds the string denoting the group_id field in the database. FieldGroupID = "group_id" // FieldSubscriptionID holds the string denoting the subscription_id field in the database. @@ -135,6 +137,7 @@ var Columns = []string{ FieldAccountID, FieldRequestID, FieldModel, + FieldUpstreamModel, FieldGroupID, FieldSubscriptionID, FieldInputTokens, @@ -179,6 +182,8 @@ var ( RequestIDValidator func(string) error // ModelValidator is a validator for the "model" field. It is called by the builders before save. ModelValidator func(string) error + // UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save. + UpstreamModelValidator func(string) error // DefaultInputTokens holds the default value on creation for the "input_tokens" field. DefaultInputTokens int // DefaultOutputTokens holds the default value on creation for the "output_tokens" field. @@ -258,6 +263,11 @@ func ByModel(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldModel, opts...).ToFunc() } +// ByUpstreamModel orders the results by the upstream_model field. +func ByUpstreamModel(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUpstreamModel, opts...).ToFunc() +} + // ByGroupID orders the results by the group_id field. func ByGroupID(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldGroupID, opts...).ToFunc() diff --git a/backend/ent/usagelog/where.go b/backend/ent/usagelog/where.go index af96033559..5f341976e9 100644 --- a/backend/ent/usagelog/where.go +++ b/backend/ent/usagelog/where.go @@ -80,6 +80,11 @@ func Model(v string) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldModel, v)) } +// UpstreamModel applies equality check predicate on the "upstream_model" field. It's identical to UpstreamModelEQ. +func UpstreamModel(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModel, v)) +} + // GroupID applies equality check predicate on the "group_id" field. It's identical to GroupIDEQ. func GroupID(v int64) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldGroupID, v)) @@ -405,6 +410,81 @@ func ModelContainsFold(v string) predicate.UsageLog { return predicate.UsageLog(sql.FieldContainsFold(FieldModel, v)) } +// UpstreamModelEQ applies the EQ predicate on the "upstream_model" field. +func UpstreamModelEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModel, v)) +} + +// UpstreamModelNEQ applies the NEQ predicate on the "upstream_model" field. +func UpstreamModelNEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldUpstreamModel, v)) +} + +// UpstreamModelIn applies the In predicate on the "upstream_model" field. +func UpstreamModelIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldIn(FieldUpstreamModel, vs...)) +} + +// UpstreamModelNotIn applies the NotIn predicate on the "upstream_model" field. +func UpstreamModelNotIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotIn(FieldUpstreamModel, vs...)) +} + +// UpstreamModelGT applies the GT predicate on the "upstream_model" field. +func UpstreamModelGT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGT(FieldUpstreamModel, v)) +} + +// UpstreamModelGTE applies the GTE predicate on the "upstream_model" field. +func UpstreamModelGTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGTE(FieldUpstreamModel, v)) +} + +// UpstreamModelLT applies the LT predicate on the "upstream_model" field. +func UpstreamModelLT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLT(FieldUpstreamModel, v)) +} + +// UpstreamModelLTE applies the LTE predicate on the "upstream_model" field. +func UpstreamModelLTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLTE(FieldUpstreamModel, v)) +} + +// UpstreamModelContains applies the Contains predicate on the "upstream_model" field. +func UpstreamModelContains(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContains(FieldUpstreamModel, v)) +} + +// UpstreamModelHasPrefix applies the HasPrefix predicate on the "upstream_model" field. +func UpstreamModelHasPrefix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasPrefix(FieldUpstreamModel, v)) +} + +// UpstreamModelHasSuffix applies the HasSuffix predicate on the "upstream_model" field. +func UpstreamModelHasSuffix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasSuffix(FieldUpstreamModel, v)) +} + +// UpstreamModelIsNil applies the IsNil predicate on the "upstream_model" field. +func UpstreamModelIsNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldIsNull(FieldUpstreamModel)) +} + +// UpstreamModelNotNil applies the NotNil predicate on the "upstream_model" field. +func UpstreamModelNotNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotNull(FieldUpstreamModel)) +} + +// UpstreamModelEqualFold applies the EqualFold predicate on the "upstream_model" field. +func UpstreamModelEqualFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEqualFold(FieldUpstreamModel, v)) +} + +// UpstreamModelContainsFold applies the ContainsFold predicate on the "upstream_model" field. +func UpstreamModelContainsFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContainsFold(FieldUpstreamModel, v)) +} + // GroupIDEQ applies the EQ predicate on the "group_id" field. func GroupIDEQ(v int64) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldGroupID, v)) diff --git a/backend/ent/usagelog_create.go b/backend/ent/usagelog_create.go index e0285a5ef2..26be5dcb2d 100644 --- a/backend/ent/usagelog_create.go +++ b/backend/ent/usagelog_create.go @@ -57,6 +57,20 @@ func (_c *UsageLogCreate) SetModel(v string) *UsageLogCreate { return _c } +// SetUpstreamModel sets the "upstream_model" field. +func (_c *UsageLogCreate) SetUpstreamModel(v string) *UsageLogCreate { + _c.mutation.SetUpstreamModel(v) + return _c +} + +// SetNillableUpstreamModel sets the "upstream_model" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableUpstreamModel(v *string) *UsageLogCreate { + if v != nil { + _c.SetUpstreamModel(*v) + } + return _c +} + // SetGroupID sets the "group_id" field. func (_c *UsageLogCreate) SetGroupID(v int64) *UsageLogCreate { _c.mutation.SetGroupID(v) @@ -596,6 +610,11 @@ func (_c *UsageLogCreate) check() error { return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model": %w`, err)} } } + if v, ok := _c.mutation.UpstreamModel(); ok { + if err := usagelog.UpstreamModelValidator(v); err != nil { + return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)} + } + } if _, ok := _c.mutation.InputTokens(); !ok { return &ValidationError{Name: "input_tokens", err: errors.New(`ent: missing required field "UsageLog.input_tokens"`)} } @@ -714,6 +733,10 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) { _spec.SetField(usagelog.FieldModel, field.TypeString, value) _node.Model = value } + if value, ok := _c.mutation.UpstreamModel(); ok { + _spec.SetField(usagelog.FieldUpstreamModel, field.TypeString, value) + _node.UpstreamModel = &value + } if value, ok := _c.mutation.InputTokens(); ok { _spec.SetField(usagelog.FieldInputTokens, field.TypeInt, value) _node.InputTokens = value @@ -1011,6 +1034,24 @@ func (u *UsageLogUpsert) UpdateModel() *UsageLogUpsert { return u } +// SetUpstreamModel sets the "upstream_model" field. +func (u *UsageLogUpsert) SetUpstreamModel(v string) *UsageLogUpsert { + u.Set(usagelog.FieldUpstreamModel, v) + return u +} + +// UpdateUpstreamModel sets the "upstream_model" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateUpstreamModel() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldUpstreamModel) + return u +} + +// ClearUpstreamModel clears the value of the "upstream_model" field. +func (u *UsageLogUpsert) ClearUpstreamModel() *UsageLogUpsert { + u.SetNull(usagelog.FieldUpstreamModel) + return u +} + // SetGroupID sets the "group_id" field. func (u *UsageLogUpsert) SetGroupID(v int64) *UsageLogUpsert { u.Set(usagelog.FieldGroupID, v) @@ -1600,6 +1641,27 @@ func (u *UsageLogUpsertOne) UpdateModel() *UsageLogUpsertOne { }) } +// SetUpstreamModel sets the "upstream_model" field. +func (u *UsageLogUpsertOne) SetUpstreamModel(v string) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetUpstreamModel(v) + }) +} + +// UpdateUpstreamModel sets the "upstream_model" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateUpstreamModel() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateUpstreamModel() + }) +} + +// ClearUpstreamModel clears the value of the "upstream_model" field. +func (u *UsageLogUpsertOne) ClearUpstreamModel() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.ClearUpstreamModel() + }) +} + // SetGroupID sets the "group_id" field. func (u *UsageLogUpsertOne) SetGroupID(v int64) *UsageLogUpsertOne { return u.Update(func(s *UsageLogUpsert) { @@ -2434,6 +2496,27 @@ func (u *UsageLogUpsertBulk) UpdateModel() *UsageLogUpsertBulk { }) } +// SetUpstreamModel sets the "upstream_model" field. +func (u *UsageLogUpsertBulk) SetUpstreamModel(v string) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetUpstreamModel(v) + }) +} + +// UpdateUpstreamModel sets the "upstream_model" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateUpstreamModel() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateUpstreamModel() + }) +} + +// ClearUpstreamModel clears the value of the "upstream_model" field. +func (u *UsageLogUpsertBulk) ClearUpstreamModel() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.ClearUpstreamModel() + }) +} + // SetGroupID sets the "group_id" field. func (u *UsageLogUpsertBulk) SetGroupID(v int64) *UsageLogUpsertBulk { return u.Update(func(s *UsageLogUpsert) { diff --git a/backend/ent/usagelog_update.go b/backend/ent/usagelog_update.go index b46e5b56e5..b7c4632c10 100644 --- a/backend/ent/usagelog_update.go +++ b/backend/ent/usagelog_update.go @@ -102,6 +102,26 @@ func (_u *UsageLogUpdate) SetNillableModel(v *string) *UsageLogUpdate { return _u } +// SetUpstreamModel sets the "upstream_model" field. +func (_u *UsageLogUpdate) SetUpstreamModel(v string) *UsageLogUpdate { + _u.mutation.SetUpstreamModel(v) + return _u +} + +// SetNillableUpstreamModel sets the "upstream_model" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableUpstreamModel(v *string) *UsageLogUpdate { + if v != nil { + _u.SetUpstreamModel(*v) + } + return _u +} + +// ClearUpstreamModel clears the value of the "upstream_model" field. +func (_u *UsageLogUpdate) ClearUpstreamModel() *UsageLogUpdate { + _u.mutation.ClearUpstreamModel() + return _u +} + // SetGroupID sets the "group_id" field. func (_u *UsageLogUpdate) SetGroupID(v int64) *UsageLogUpdate { _u.mutation.SetGroupID(v) @@ -745,6 +765,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.UpstreamModel(); ok { + if err := usagelog.UpstreamModelValidator(v); err != nil { + return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_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)} @@ -795,6 +820,12 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.Model(); ok { _spec.SetField(usagelog.FieldModel, field.TypeString, value) } + if value, ok := _u.mutation.UpstreamModel(); ok { + _spec.SetField(usagelog.FieldUpstreamModel, field.TypeString, value) + } + if _u.mutation.UpstreamModelCleared() { + _spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString) + } if value, ok := _u.mutation.InputTokens(); ok { _spec.SetField(usagelog.FieldInputTokens, field.TypeInt, value) } @@ -1177,6 +1208,26 @@ func (_u *UsageLogUpdateOne) SetNillableModel(v *string) *UsageLogUpdateOne { return _u } +// SetUpstreamModel sets the "upstream_model" field. +func (_u *UsageLogUpdateOne) SetUpstreamModel(v string) *UsageLogUpdateOne { + _u.mutation.SetUpstreamModel(v) + return _u +} + +// SetNillableUpstreamModel sets the "upstream_model" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableUpstreamModel(v *string) *UsageLogUpdateOne { + if v != nil { + _u.SetUpstreamModel(*v) + } + return _u +} + +// ClearUpstreamModel clears the value of the "upstream_model" field. +func (_u *UsageLogUpdateOne) ClearUpstreamModel() *UsageLogUpdateOne { + _u.mutation.ClearUpstreamModel() + return _u +} + // SetGroupID sets the "group_id" field. func (_u *UsageLogUpdateOne) SetGroupID(v int64) *UsageLogUpdateOne { _u.mutation.SetGroupID(v) @@ -1833,6 +1884,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.UpstreamModel(); ok { + if err := usagelog.UpstreamModelValidator(v); err != nil { + return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_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)} @@ -1900,6 +1956,12 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err if value, ok := _u.mutation.Model(); ok { _spec.SetField(usagelog.FieldModel, field.TypeString, value) } + if value, ok := _u.mutation.UpstreamModel(); ok { + _spec.SetField(usagelog.FieldUpstreamModel, field.TypeString, value) + } + if _u.mutation.UpstreamModelCleared() { + _spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString) + } if value, ok := _u.mutation.InputTokens(); ok { _spec.SetField(usagelog.FieldInputTokens, field.TypeInt, value) } diff --git a/backend/go.sum b/backend/go.sum index 7a87910595..ace053ba71 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -26,8 +26,6 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= -github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= -github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4= github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA= github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q= @@ -64,8 +62,6 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWA github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU= github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c= github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= -github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= -github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= @@ -217,7 +213,6 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -375,8 +370,6 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -385,6 +378,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc= @@ -500,11 +495,11 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index c51046a21e..4e69ca0252 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -82,8 +82,8 @@ var DefaultAntigravityModelMapping = map[string]string{ "claude-opus-4-5-20251101": "claude-opus-4-6-thinking", // 迁移旧模型 "claude-sonnet-4-5-20250929": "claude-sonnet-4-5", // Claude Haiku → Sonnet(无 Haiku 支持) - "claude-haiku-4-5": "claude-sonnet-4-5", - "claude-haiku-4-5-20251001": "claude-sonnet-4-5", + "claude-haiku-4-5": "claude-sonnet-4-6", + "claude-haiku-4-5-20251001": "claude-sonnet-4-6", // Gemini 2.5 白名单 "gemini-2.5-flash": "gemini-2.5-flash", "gemini-2.5-flash-image": "gemini-2.5-flash-image", diff --git a/backend/internal/handler/admin/account_affinity_handler.go b/backend/internal/handler/admin/account_affinity_handler.go new file mode 100644 index 0000000000..62f4c3fe80 --- /dev/null +++ b/backend/internal/handler/admin/account_affinity_handler.go @@ -0,0 +1,254 @@ +package admin + +import ( + "context" + "log/slog" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// 账号亲和调度 API 与契约说明见 docs/ACCOUNT_SCHEDULING_FLOW.md 。 + +// GetAffinityClients returns the list of affinity clients for an account with last active timestamps. +// GET /api/v1/admin/accounts/:id/affinity-clients +func (h *AccountHandler) GetAffinityClients(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + + account, err := h.adminService.GetAccount(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + + if !account.IsAffinityEnabled() { + response.Success(c, []service.AffinityClient{}) + return + } + + if h.gatewayCache == nil || len(account.GroupIDs) == 0 { + response.Success(c, []service.AffinityClient{}) + return + } + + clients, err := h.gatewayCache.GetAccountAffinityClientsWithScores( + c.Request.Context(), accountID, account.GroupIDs, service.ClientAffinityTTL, + ) + if err != nil { + response.Success(c, []service.AffinityClient{}) + return + } + + response.Success(c, clients) +} + +// clearAccountAffinity 清除指定账号在所有分组的亲和记录。 +func (h *AccountHandler) clearAccountAffinity(ctx context.Context, accountID int64, groupIDs []int64) { + if h.gatewayCache == nil || len(groupIDs) == 0 { + return + } + if err := h.gatewayCache.ClearAccountAffinity(ctx, accountID, groupIDs); err != nil { + // 清理失败不影响主流程,记录日志即可 + slog.Warn("clear account affinity failed", + "account_id", accountID, + "error", err, + ) + } +} + +// countUniqueUsersFromAffinityMembers 从 "{userID}/{clientID}" 格式的成员列表中计算唯一用户数。 +func countUniqueUsersFromAffinityMembers(members []string) int64 { + users := make(map[string]struct{}, len(members)) + for _, m := range members { + if idx := strings.Index(m, "/"); idx > 0 { + users[m[:idx]] = struct{}{} + } + } + return int64(len(users)) +} + +// AffinityDetailClient 亲和详情中的单个客户端信息 +type AffinityDetailClient struct { + ClientID string `json:"client_id"` + LastActive time.Time `json:"last_active"` + IsPinned bool `json:"is_pinned"` +} + +// AffinityDetailUser 亲和详情中按用户分组的信息 +type AffinityDetailUser struct { + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + ClientCount int `json:"client_count"` + IsPinned bool `json:"is_pinned"` + Clients []AffinityDetailClient `json:"clients"` +} + +// AffinityDetailsResponse 亲和详情响应 +type AffinityDetailsResponse struct { + Users []AffinityDetailUser `json:"users"` + TotalUsers int `json:"total_users"` + TotalClients int `json:"total_clients"` + PinnedUsers []int64 `json:"pinned_users"` +} + +type affinityStateSnapshot struct { + enabled bool + groupIDs []int64 +} + +func (h *AccountHandler) captureAffinityStates(ctx context.Context, accountIDs []int64) map[int64]affinityStateSnapshot { + states := make(map[int64]affinityStateSnapshot, len(accountIDs)) + if h.gatewayCache == nil || len(accountIDs) == 0 { + return states + } + for _, accountID := range accountIDs { + account, err := h.adminService.GetAccount(ctx, accountID) + if err != nil || account == nil { + continue + } + states[accountID] = affinityStateSnapshot{ + enabled: account.IsAffinityEnabled(), + groupIDs: append([]int64(nil), account.GroupIDs...), + } + } + return states +} + +func (h *AccountHandler) clearAffinityCacheIfDisabled(ctx context.Context, accountID int64, oldState affinityStateSnapshot) { + if h.gatewayCache == nil || !oldState.enabled { + return + } + account, err := h.adminService.GetAccount(ctx, accountID) + if err != nil || account == nil || account.IsAffinityEnabled() { + return + } + groupIDs := oldState.groupIDs + if len(account.GroupIDs) > 0 { + groupIDs = mergeGroupIDs(groupIDs, account.GroupIDs) + } + h.clearAccountAffinity(ctx, accountID, groupIDs) +} + +func (h *AccountHandler) clearAffinityCacheForBulkIfDisabled(ctx context.Context, accountIDs []int64, oldStates map[int64]affinityStateSnapshot) { + for _, accountID := range accountIDs { + oldState, ok := oldStates[accountID] + if !ok { + continue + } + h.clearAffinityCacheIfDisabled(ctx, accountID, oldState) + } +} + +// GetAffinityDetails returns the affinity details grouped by user for an account. +// GET /api/v1/admin/accounts/:id/affinity-details +func (h *AccountHandler) GetAffinityDetails(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + + emptyResp := AffinityDetailsResponse{ + Users: []AffinityDetailUser{}, + TotalUsers: 0, + TotalClients: 0, + PinnedUsers: []int64{}, + } + + account, err := h.adminService.GetAccount(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + + if !account.IsAffinityEnabled() { + response.Success(c, emptyResp) + return + } + + pinnedUsers := account.GetPinnedUsers() + if pinnedUsers == nil { + pinnedUsers = []int64{} + } + + if h.gatewayCache == nil || len(account.GroupIDs) == 0 { + emptyResp.PinnedUsers = pinnedUsers + response.Success(c, emptyResp) + return + } + + clients, err := h.gatewayCache.GetAccountAffinityClientsWithScores( + c.Request.Context(), accountID, account.GroupIDs, service.ClientAffinityTTL, + ) + if err != nil { + emptyResp.PinnedUsers = pinnedUsers + response.Success(c, emptyResp) + return + } + + pinnedSet := make(map[int64]struct{}, len(pinnedUsers)) + for _, uid := range pinnedUsers { + pinnedSet[uid] = struct{}{} + } + + // 按 UserID 分组 + userMap := make(map[int64]*AffinityDetailUser) + var userOrder []int64 + for _, cl := range clients { + u, ok := userMap[cl.UserID] + if !ok { + _, pinned := pinnedSet[cl.UserID] + u = &AffinityDetailUser{ + UserID: cl.UserID, + UserEmail: "", + ClientCount: 0, + IsPinned: pinned, + Clients: []AffinityDetailClient{}, + } + userMap[cl.UserID] = u + userOrder = append(userOrder, cl.UserID) + } + u.Clients = append(u.Clients, AffinityDetailClient{ + ClientID: cl.ClientID, + LastActive: cl.LastActive, + IsPinned: false, // 客户端级别暂无 pinned 概念 + }) + } + + // 关联用户邮箱(查询失败时保持空字符串,不影响主流程) + for _, uid := range userOrder { + user, uErr := h.adminService.GetUser(c.Request.Context(), uid) + if uErr != nil || user == nil { + continue + } + if u, ok := userMap[uid]; ok { + if user.Email != "" { + u.UserEmail = user.Email + } else { + u.UserEmail = user.Username + } + } + } + + users := make([]AffinityDetailUser, 0, len(userOrder)) + for _, uid := range userOrder { + u := userMap[uid] + u.ClientCount = len(u.Clients) + users = append(users, *u) + } + + response.Success(c, AffinityDetailsResponse{ + Users: users, + TotalUsers: len(users), + TotalClients: len(clients), + PinnedUsers: pinnedUsers, + }) +} diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 214afc5a9a..1196ba1d8a 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -9,7 +9,6 @@ import ( "errors" "fmt" "log" - "log/slog" "net/http" "strconv" "strings" @@ -169,6 +168,8 @@ type AccountWithConcurrency struct { CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数 } +const accountListGroupUngroupedQueryValue = "ungrouped" + func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency { item := AccountWithConcurrency{ Account: dto.AccountFromService(account), @@ -211,7 +212,7 @@ func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, ac } // 亲和客户端数据(启用亲和的账号始终返回 count,即使为 0) - if account.IsClientAffinityEnabled() { + if account.IsAffinityEnabled() { if h.gatewayCache != nil && len(account.GroupIDs) > 0 { accountGroups := map[int64][]int64{account.ID: account.GroupIDs} if clients, err := h.gatewayCache.GetAccountAffinityClientsBatch(ctx, accountGroups, service.ClientAffinityTTL); err == nil { @@ -219,17 +220,22 @@ func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, ac count := int64(len(cl)) item.AffinityClientCount = &count item.AffinityClients = cl + userCount := countUniqueUsersFromAffinityMembers(cl) + item.AffinityUserCount = &userCount } else { zero := int64(0) item.AffinityClientCount = &zero + item.AffinityUserCount = &zero } } else { zero := int64(0) item.AffinityClientCount = &zero + item.AffinityUserCount = &zero } } else { zero := int64(0) item.AffinityClientCount = &zero + item.AffinityUserCount = &zero } } @@ -253,7 +259,20 @@ func (h *AccountHandler) List(c *gin.Context) { var groupID int64 if groupIDStr := c.Query("group"); groupIDStr != "" { - groupID, _ = strconv.ParseInt(groupIDStr, 10, 64) + if groupIDStr == accountListGroupUngroupedQueryValue { + groupID = service.AccountListGroupUngrouped + } else { + parsedGroupID, parseErr := strconv.ParseInt(groupIDStr, 10, 64) + if parseErr != nil { + response.ErrorFrom(c, infraerrors.BadRequest("INVALID_GROUP_FILTER", "invalid group filter")) + return + } + if parsedGroupID < 0 { + response.ErrorFrom(c, infraerrors.BadRequest("INVALID_GROUP_FILTER", "invalid group filter")) + return + } + groupID = parsedGroupID + } } accounts, total, err := h.adminService.ListAccounts(c.Request.Context(), page, pageSize, platform, accountType, status, search, groupID) @@ -351,7 +370,7 @@ func (h *AccountHandler) List(c *gin.Context) { accountGroups := make(map[int64][]int64) for i := range accounts { acc := &accounts[i] - if acc.IsClientAffinityEnabled() && len(acc.GroupIDs) > 0 { + if acc.IsAffinityEnabled() && len(acc.GroupIDs) > 0 { accountGroups[acc.ID] = acc.GroupIDs } } @@ -391,14 +410,18 @@ func (h *AccountHandler) List(c *gin.Context) { } // 注入亲和客户端数据到 DTO(启用亲和的账号始终返回 count,即使为 0) - if acc.IsClientAffinityEnabled() { + if acc.IsAffinityEnabled() { if clients, ok := affinityClients[acc.ID]; ok && len(clients) > 0 { count := int64(len(clients)) item.AffinityClientCount = &count item.AffinityClients = clients + // 从成员列表中解析唯一用户数 + userCount := countUniqueUsersFromAffinityMembers(clients) + item.AffinityUserCount = &userCount } else { zero := int64(0) item.AffinityClientCount = &zero + item.AffinityUserCount = &zero } } @@ -622,15 +645,7 @@ func (h *AccountHandler) Update(c *gin.Context) { // base_rpm 输入校验:负值归零,超过 10000 截断 sanitizeExtraBaseRPM(req.Extra) - // 记录更新前的亲和状态,用于检测亲和关闭时清理 Redis 记录 - oldAffinityEnabled := false - var oldGroupIDs []int64 - if len(req.Extra) > 0 && h.gatewayCache != nil { - if oldAccount, err := h.adminService.GetAccount(c.Request.Context(), accountID); err == nil { - oldAffinityEnabled = oldAccount.IsClientAffinityEnabled() - oldGroupIDs = oldAccount.GroupIDs - } - } + oldStates := h.captureAffinityStates(c.Request.Context(), []int64{accountID}) // 确定是否跳过混合渠道检查 skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk @@ -668,14 +683,7 @@ func (h *AccountHandler) Update(c *gin.Context) { return } - // 亲和关闭时清理 Redis 中的亲和记录 - if oldAffinityEnabled && !account.IsClientAffinityEnabled() { - groupIDs := oldGroupIDs - if len(account.GroupIDs) > 0 { - groupIDs = mergeGroupIDs(oldGroupIDs, account.GroupIDs) - } - h.clearAccountAffinity(c.Request.Context(), accountID, groupIDs) - } + h.clearAffinityCacheForBulkIfDisabled(c.Request.Context(), []int64{accountID}, oldStates) response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } @@ -1393,6 +1401,8 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) { return } + oldStates := h.captureAffinityStates(c.Request.Context(), req.AccountIDs) + result, err := h.adminService.BulkUpdateAccounts(c.Request.Context(), &service.BulkUpdateAccountsInput{ AccountIDs: req.AccountIDs, Name: req.Name, @@ -1427,6 +1437,8 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) { return } + h.clearAffinityCacheForBulkIfDisabled(c.Request.Context(), req.AccountIDs, oldStates) + response.Success(c, result) } @@ -1575,7 +1587,7 @@ func (h *OAuthHandler) SetupTokenCookieAuth(c *gin.Context) { } // GetUsage handles getting account usage information -// GET /api/v1/admin/accounts/:id/usage +// GET /api/v1/admin/accounts/:id/usage?source=passive|active func (h *AccountHandler) GetUsage(c *gin.Context) { accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { @@ -1583,7 +1595,14 @@ func (h *AccountHandler) GetUsage(c *gin.Context) { return } - usage, err := h.accountUsageService.GetUsage(c.Request.Context(), accountID) + source := c.DefaultQuery("source", "active") + + var usage *service.UsageInfo + if source == "passive" { + usage, err = h.accountUsageService.GetPassiveUsage(c.Request.Context(), accountID) + } else { + usage, err = h.accountUsageService.GetUsage(c.Request.Context(), accountID) + } if err != nil { response.ErrorFrom(c, err) return @@ -1639,56 +1658,6 @@ func (h *AccountHandler) ResetQuota(c *gin.Context) { response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } -// GetAffinityClients returns the list of affinity clients for an account with last active timestamps. -// GET /api/v1/admin/accounts/:id/affinity-clients -func (h *AccountHandler) GetAffinityClients(c *gin.Context) { - accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) - if err != nil { - response.BadRequest(c, "Invalid account ID") - return - } - - account, err := h.adminService.GetAccount(c.Request.Context(), accountID) - if err != nil { - response.ErrorFrom(c, err) - return - } - - if !account.IsClientAffinityEnabled() { - response.Success(c, []service.AffinityClient{}) - return - } - - if h.gatewayCache == nil || len(account.GroupIDs) == 0 { - response.Success(c, []service.AffinityClient{}) - return - } - - clients, err := h.gatewayCache.GetAccountAffinityClientsWithScores( - c.Request.Context(), accountID, account.GroupIDs, service.ClientAffinityTTL, - ) - if err != nil { - response.Success(c, []service.AffinityClient{}) - return - } - - response.Success(c, clients) -} - -// clearAccountAffinity 清除指定账号在所有分组的亲和记录。 -func (h *AccountHandler) clearAccountAffinity(ctx context.Context, accountID int64, groupIDs []int64) { - if h.gatewayCache == nil || len(groupIDs) == 0 { - return - } - if err := h.gatewayCache.ClearAccountAffinity(ctx, accountID, groupIDs); err != nil { - // 清理失败不影响主流程,记录日志即可 - slog.Warn("clear account affinity failed", - "account_id", accountID, - "error", err, - ) - } -} - // mergeGroupIDs 合并两个 groupID 切片并去重。 func mergeGroupIDs(a, b []int64) []int64 { seen := make(map[int64]struct{}, len(a)+len(b)) diff --git a/backend/internal/handler/admin/account_handler_affinity_cache_test.go b/backend/internal/handler/admin/account_handler_affinity_cache_test.go new file mode 100644 index 0000000000..3c22bb543a --- /dev/null +++ b/backend/internal/handler/admin/account_handler_affinity_cache_test.go @@ -0,0 +1,298 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type affinityCacheAdminService struct { + *stubAdminService + accounts map[int64]*service.Account + bulkResultIDs []int64 + bulkFailedCount int +} + +func (s *affinityCacheAdminService) GetAccount(_ context.Context, id int64) (*service.Account, error) { + if acc, ok := s.accounts[id]; ok { + cloned := *acc + return &cloned, nil + } + return s.stubAdminService.GetAccount(context.Background(), id) +} + +func (s *affinityCacheAdminService) UpdateAccount(_ context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) { + acc := s.accounts[id] + if acc == nil { + return s.stubAdminService.UpdateAccount(context.Background(), id, input) + } + if input.Extra != nil { + acc.Extra = input.Extra + } + if input.GroupIDs != nil { + acc.GroupIDs = *input.GroupIDs + } + cloned := *acc + return &cloned, nil +} + +func (s *affinityCacheAdminService) BulkUpdateAccounts(_ context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) { + for _, accountID := range input.AccountIDs { + acc := s.accounts[accountID] + if acc == nil { + continue + } + if input.Extra != nil { + acc.Extra = input.Extra + } + if input.GroupIDs != nil { + acc.GroupIDs = *input.GroupIDs + } + } + successIDs := append([]int64(nil), input.AccountIDs...) + failed := 0 + if len(s.bulkResultIDs) > 0 { + successIDs = append([]int64(nil), s.bulkResultIDs...) + failed = s.bulkFailedCount + } + return &service.BulkUpdateAccountsResult{ + Success: len(successIDs), + Failed: failed, + SuccessIDs: successIDs, + Results: []service.BulkUpdateAccountResult{}, + }, nil +} + +type affinityCacheClearRecorder struct { + clearCalls []struct { + accountID int64 + groupIDs []int64 + } +} + +func (r *affinityCacheClearRecorder) GetSessionAccountID(_ context.Context, _ int64, _ string) (int64, error) { + return 0, nil +} +func (r *affinityCacheClearRecorder) SetSessionAccountID(_ context.Context, _ int64, _ string, _ int64, _ time.Duration) error { + return nil +} +func (r *affinityCacheClearRecorder) RefreshSessionTTL(_ context.Context, _ int64, _ string, _ time.Duration) error { + return nil +} +func (r *affinityCacheClearRecorder) DeleteSessionAccountID(_ context.Context, _ int64, _ string) error { + return nil +} +func (r *affinityCacheClearRecorder) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { + return nil, nil +} +func (r *affinityCacheClearRecorder) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error { + return nil +} +func (r *affinityCacheClearRecorder) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) { + return map[int64]int64{}, nil +} +func (r *affinityCacheClearRecorder) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) { + return map[int64][]string{}, nil +} +func (r *affinityCacheClearRecorder) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]service.AffinityClient, error) { + return nil, nil +} +func (r *affinityCacheClearRecorder) ClearAccountAffinity(_ context.Context, accountID int64, groupIDs []int64) error { + r.clearCalls = append(r.clearCalls, struct { + accountID int64 + groupIDs []int64 + }{ + accountID: accountID, + groupIDs: append([]int64(nil), groupIDs...), + }) + return nil +} +func (r *affinityCacheClearRecorder) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (users, clients, perUser int64, err error) { + return 0, 0, 0, nil +} + +func setupAffinityCacheRouter(adminSvc service.AdminService, gatewayCache service.GatewayCache) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, gatewayCache) + router.PUT("/api/v1/admin/accounts/:id", handler.Update) + router.POST("/api/v1/admin/accounts/bulk-update", handler.BulkUpdate) + return router +} + +func TestAccountHandlerUpdate_DisablingAffinityClearsCache(t *testing.T) { + adminSvc := &affinityCacheAdminService{ + stubAdminService: newStubAdminService(), + accounts: map[int64]*service.Account{ + 3: { + ID: 3, + Platform: service.PlatformAnthropic, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + GroupIDs: []int64{1, 2}, + Extra: map[string]any{ + "client_affinity_enabled": true, + }, + }, + }, + } + cache := &affinityCacheClearRecorder{} + router := setupAffinityCacheRouter(adminSvc, cache) + + body, _ := json.Marshal(map[string]any{ + "extra": map[string]any{}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/accounts/3", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Len(t, cache.clearCalls, 1) + require.Equal(t, int64(3), cache.clearCalls[0].accountID) + require.Equal(t, []int64{1, 2}, cache.clearCalls[0].groupIDs) +} + +func TestAccountHandlerBulkUpdate_DisablingAffinityClearsCache(t *testing.T) { + adminSvc := &affinityCacheAdminService{ + stubAdminService: newStubAdminService(), + accounts: map[int64]*service.Account{ + 1: { + ID: 1, + Platform: service.PlatformAnthropic, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + GroupIDs: []int64{11}, + Extra: map[string]any{ + "client_affinity_enabled": true, + }, + }, + 2: { + ID: 2, + Platform: service.PlatformAnthropic, + Type: service.AccountTypeSetupToken, + Status: service.StatusActive, + GroupIDs: []int64{22}, + Extra: map[string]any{ + "client_affinity_enabled": true, + }, + }, + }, + } + cache := &affinityCacheClearRecorder{} + router := setupAffinityCacheRouter(adminSvc, cache) + + body, _ := json.Marshal(map[string]any{ + "account_ids": []int64{1, 2}, + "extra": map[string]any{ + "client_affinity_enabled": false, + }, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/bulk-update", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Len(t, cache.clearCalls, 2) + require.Equal(t, int64(1), cache.clearCalls[0].accountID) + require.Equal(t, []int64{11}, cache.clearCalls[0].groupIDs) + require.Equal(t, int64(2), cache.clearCalls[1].accountID) + require.Equal(t, []int64{22}, cache.clearCalls[1].groupIDs) +} + +func TestAccountHandlerBulkUpdate_DisablingAffinityWithNewFlagClearsCache(t *testing.T) { + adminSvc := &affinityCacheAdminService{ + stubAdminService: newStubAdminService(), + accounts: map[int64]*service.Account{ + 1: { + ID: 1, + Platform: service.PlatformAnthropic, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + GroupIDs: []int64{33}, + Extra: map[string]any{ + "affinity_enabled": true, + }, + }, + }, + } + cache := &affinityCacheClearRecorder{} + router := setupAffinityCacheRouter(adminSvc, cache) + + body, _ := json.Marshal(map[string]any{ + "account_ids": []int64{1}, + "extra": map[string]any{ + "affinity_enabled": false, + }, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/bulk-update", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Len(t, cache.clearCalls, 1) + require.Equal(t, int64(1), cache.clearCalls[0].accountID) + require.Equal(t, []int64{33}, cache.clearCalls[0].groupIDs) +} + +func TestAccountHandlerBulkUpdate_PartialFailureStillClearsDisabledAffinityCache(t *testing.T) { + adminSvc := &affinityCacheAdminService{ + stubAdminService: newStubAdminService(), + accounts: map[int64]*service.Account{ + 1: { + ID: 1, + Platform: service.PlatformAnthropic, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + GroupIDs: []int64{44}, + Extra: map[string]any{ + "affinity_enabled": true, + }, + }, + 2: { + ID: 2, + Platform: service.PlatformAnthropic, + Type: service.AccountTypeSetupToken, + Status: service.StatusActive, + GroupIDs: []int64{55}, + Extra: map[string]any{ + "client_affinity_enabled": true, + }, + }, + }, + bulkResultIDs: []int64{1}, + bulkFailedCount: 1, + } + cache := &affinityCacheClearRecorder{} + router := setupAffinityCacheRouter(adminSvc, cache) + + body, _ := json.Marshal(map[string]any{ + "account_ids": []int64{1, 2}, + "group_ids": []int64{101}, + "extra": map[string]any{ + "affinity_enabled": false, + "client_affinity_enabled": false, + }, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/bulk-update", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Len(t, cache.clearCalls, 2) + require.Equal(t, int64(1), cache.clearCalls[0].accountID) + require.Equal(t, []int64{44, 101}, cache.clearCalls[0].groupIDs) + require.Equal(t, int64(2), cache.clearCalls[1].accountID) + require.Equal(t, []int64{55, 101}, cache.clearCalls[1].groupIDs) +} diff --git a/backend/internal/handler/admin/account_handler_affinity_details_test.go b/backend/internal/handler/admin/account_handler_affinity_details_test.go new file mode 100644 index 0000000000..0a4b52d579 --- /dev/null +++ b/backend/internal/handler/admin/account_handler_affinity_details_test.go @@ -0,0 +1,205 @@ +package admin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type affinityDetailsAdminService struct { + *stubAdminService + account service.Account + usersByID map[int64]service.User +} + +func (s *affinityDetailsAdminService) GetAccount(_ context.Context, id int64) (*service.Account, error) { + if s.account.ID == id { + acc := s.account + return &acc, nil + } + return s.stubAdminService.GetAccount(context.Background(), id) +} + +func (s *affinityDetailsAdminService) GetUser(_ context.Context, id int64) (*service.User, error) { + if u, ok := s.usersByID[id]; ok { + user := u + return &user, nil + } + return s.stubAdminService.GetUser(context.Background(), id) +} + +type affinityDetailsGatewayCacheStub struct { + clients []service.AffinityClient +} + +func (s *affinityDetailsGatewayCacheStub) GetSessionAccountID(_ context.Context, _ int64, _ string) (int64, error) { + return 0, nil +} +func (s *affinityDetailsGatewayCacheStub) SetSessionAccountID(_ context.Context, _ int64, _ string, _ int64, _ time.Duration) error { + return nil +} +func (s *affinityDetailsGatewayCacheStub) RefreshSessionTTL(_ context.Context, _ int64, _ string, _ time.Duration) error { + return nil +} +func (s *affinityDetailsGatewayCacheStub) DeleteSessionAccountID(_ context.Context, _ int64, _ string) error { + return nil +} +func (s *affinityDetailsGatewayCacheStub) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { + return nil, nil +} +func (s *affinityDetailsGatewayCacheStub) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error { + return nil +} +func (s *affinityDetailsGatewayCacheStub) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) { + return map[int64]int64{}, nil +} +func (s *affinityDetailsGatewayCacheStub) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) { + return map[int64][]string{}, nil +} +func (s *affinityDetailsGatewayCacheStub) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]service.AffinityClient, error) { + return s.clients, nil +} +func (s *affinityDetailsGatewayCacheStub) ClearAccountAffinity(_ context.Context, _ int64, _ []int64) error { + return nil +} +func (s *affinityDetailsGatewayCacheStub) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (users, clients, perUser int64, err error) { + return 0, 0, 0, nil +} + +func setupAffinityDetailsRouter(adminSvc service.AdminService, gatewayCache service.GatewayCache) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, gatewayCache) + router.GET("/api/v1/admin/accounts/:id/affinity-details", handler.GetAffinityDetails) + return router +} + +func TestAccountHandlerGetAffinityDetails_ContractFields(t *testing.T) { + adminSvc := &affinityDetailsAdminService{ + stubAdminService: newStubAdminService(), + account: service.Account{ + ID: 88, + Platform: service.PlatformAnthropic, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + GroupIDs: []int64{1}, + Extra: map[string]any{ + "client_affinity_enabled": true, + "pinned_users": []any{float64(42), float64(99)}, + }, + }, + usersByID: map[int64]service.User{ + 42: {ID: 42, Email: "user42@example.com"}, + 7: {ID: 7, Username: "user7"}, + }, + } + cache := &affinityDetailsGatewayCacheStub{ + clients: []service.AffinityClient{ + {UserID: 42, ClientID: "client-a", LastActive: time.Now().Add(-time.Minute)}, + {UserID: 7, ClientID: "client-b", LastActive: time.Now().Add(-2 * time.Minute)}, + {UserID: 42, ClientID: "client-c", LastActive: time.Now().Add(-3 * time.Minute)}, + }, + } + router := setupAffinityDetailsRouter(adminSvc, cache) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/88/affinity-details", nil) + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var resp struct { + Code int `json:"code"` + Data struct { + Users []struct { + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + ClientCount int `json:"client_count"` + IsPinned bool `json:"is_pinned"` + Clients []struct { + ClientID string `json:"client_id"` + } `json:"clients"` + } `json:"users"` + TotalUsers int `json:"total_users"` + TotalClients int `json:"total_clients"` + PinnedUsers []int64 `json:"pinned_users"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Code) + require.Equal(t, 2, resp.Data.TotalUsers) + require.Equal(t, 3, resp.Data.TotalClients) + require.Equal(t, []int64{42, 99}, resp.Data.PinnedUsers) + require.Len(t, resp.Data.Users, 2) + + usersByID := make(map[int64]struct { + UserEmail string + ClientCount int + IsPinned bool + ClientLen int + }, len(resp.Data.Users)) + for _, u := range resp.Data.Users { + usersByID[u.UserID] = struct { + UserEmail string + ClientCount int + IsPinned bool + ClientLen int + }{ + UserEmail: u.UserEmail, + ClientCount: u.ClientCount, + IsPinned: u.IsPinned, + ClientLen: len(u.Clients), + } + } + require.Equal(t, "user42@example.com", usersByID[42].UserEmail) + require.Equal(t, 2, usersByID[42].ClientCount) + require.True(t, usersByID[42].IsPinned) + require.Equal(t, 2, usersByID[42].ClientLen) + + require.Equal(t, "user7", usersByID[7].UserEmail) + require.Equal(t, 1, usersByID[7].ClientCount) + require.False(t, usersByID[7].IsPinned) + require.Equal(t, 1, usersByID[7].ClientLen) +} + +func TestAccountHandlerGetAffinityDetails_DisabledReturnsEmptyContract(t *testing.T) { + adminSvc := &affinityDetailsAdminService{ + stubAdminService: newStubAdminService(), + account: service.Account{ + ID: 89, + Platform: service.PlatformAnthropic, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + GroupIDs: []int64{1}, + }, + usersByID: map[int64]service.User{}, + } + router := setupAffinityDetailsRouter(adminSvc, &affinityDetailsGatewayCacheStub{}) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/89/affinity-details", nil) + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var resp struct { + Code int `json:"code"` + Data struct { + Users []any `json:"users"` + TotalUsers int `json:"total_users"` + TotalClients int `json:"total_clients"` + PinnedUsers []int64 `json:"pinned_users"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Code) + require.Empty(t, resp.Data.Users) + require.Equal(t, 0, resp.Data.TotalUsers) + require.Equal(t, 0, resp.Data.TotalClients) + require.Empty(t, resp.Data.PinnedUsers) +} diff --git a/backend/internal/handler/admin/admin_basic_handlers_test.go b/backend/internal/handler/admin/admin_basic_handlers_test.go index 4de10d3e22..cba3ae2149 100644 --- a/backend/internal/handler/admin/admin_basic_handlers_test.go +++ b/backend/internal/handler/admin/admin_basic_handlers_test.go @@ -17,7 +17,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) { adminSvc := newStubAdminService() userHandler := NewUserHandler(adminSvc, nil) - groupHandler := NewGroupHandler(adminSvc) + groupHandler := NewGroupHandler(adminSvc, nil, nil) proxyHandler := NewProxyHandler(adminSvc) redeemHandler := NewRedeemHandler(adminSvc, nil) diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 37a72cb439..61e2c2bd3b 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -445,5 +445,9 @@ func (s *stubAdminService) EnsureOpenAIPrivacy(ctx context.Context, account *ser return "" } +func (s *stubAdminService) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*service.ReplaceUserGroupResult, error) { + return &service.ReplaceUserGroupResult{MigratedKeys: 0}, nil +} + // Ensure stub implements interface. var _ service.AdminService = (*stubAdminService)(nil) diff --git a/backend/internal/handler/admin/backup_handler.go b/backend/internal/handler/admin/backup_handler.go index d19713ee46..2f528322f3 100644 --- a/backend/internal/handler/admin/backup_handler.go +++ b/backend/internal/handler/admin/backup_handler.go @@ -98,12 +98,12 @@ func (h *BackupHandler) CreateBackup(c *gin.Context) { expireDays = *req.ExpireDays } - record, err := h.backupService.CreateBackup(c.Request.Context(), "manual", expireDays) + record, err := h.backupService.StartBackup(c.Request.Context(), "manual", expireDays) if err != nil { response.ErrorFrom(c, err) return } - response.Success(c, record) + response.Accepted(c, record) } func (h *BackupHandler) ListBackups(c *gin.Context) { @@ -196,9 +196,10 @@ func (h *BackupHandler) RestoreBackup(c *gin.Context) { return } - if err := h.backupService.RestoreBackup(c.Request.Context(), backupID); err != nil { + record, err := h.backupService.StartRestore(c.Request.Context(), backupID) + if err != nil { response.ErrorFrom(c, err) return } - response.Success(c, gin.H{"restored": true}) + response.Accepted(c, record) } diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index f415b48f2e..2a214471a4 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -9,6 +9,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -272,6 +273,7 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) { // Parse optional filter params var userID, apiKeyID, accountID, groupID int64 + modelSource := usagestats.ModelSourceRequested var requestType *int16 var stream *bool var billingType *int8 @@ -296,6 +298,13 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) { groupID = id } } + if rawModelSource := strings.TrimSpace(c.Query("model_source")); rawModelSource != "" { + if !usagestats.IsValidModelSource(rawModelSource) { + response.BadRequest(c, "Invalid model_source, use requested/upstream/mapping") + return + } + modelSource = rawModelSource + } if requestTypeStr := strings.TrimSpace(c.Query("request_type")); requestTypeStr != "" { parsed, err := service.ParseUsageRequestType(requestTypeStr) if err != nil { @@ -322,7 +331,7 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) { } } - stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType) + stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType) if err != nil { response.Error(c, 500, "Failed to get model statistics") return @@ -604,3 +613,47 @@ func (h *DashboardHandler) GetBatchAPIKeysUsage(c *gin.Context) { c.Header("X-Snapshot-Cache", "miss") response.Success(c, payload) } + +// GetUserBreakdown handles getting per-user usage breakdown within a dimension. +// GET /api/v1/admin/dashboard/user-breakdown +// Query params: start_date, end_date, group_id, model, endpoint, endpoint_type, limit +func (h *DashboardHandler) GetUserBreakdown(c *gin.Context) { + startTime, endTime := parseTimeRange(c) + + dim := usagestats.UserBreakdownDimension{} + if v := c.Query("group_id"); v != "" { + if id, err := strconv.ParseInt(v, 10, 64); err == nil { + dim.GroupID = id + } + } + dim.Model = c.Query("model") + rawModelSource := strings.TrimSpace(c.DefaultQuery("model_source", usagestats.ModelSourceRequested)) + if !usagestats.IsValidModelSource(rawModelSource) { + response.BadRequest(c, "Invalid model_source, use requested/upstream/mapping") + return + } + dim.ModelType = rawModelSource + dim.Endpoint = c.Query("endpoint") + dim.EndpointType = c.DefaultQuery("endpoint_type", "inbound") + + limit := 50 + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { + limit = n + } + } + + stats, err := h.dashboardService.GetUserBreakdownStats( + c.Request.Context(), startTime, endTime, dim, limit, + ) + if err != nil { + response.Error(c, 500, "Failed to get user breakdown stats") + return + } + + response.Success(c, gin.H{ + "users": stats, + "start_date": startTime.Format("2006-01-02"), + "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"), + }) +} diff --git a/backend/internal/handler/admin/dashboard_handler_request_type_test.go b/backend/internal/handler/admin/dashboard_handler_request_type_test.go index 9aec61d469..6056f725b5 100644 --- a/backend/internal/handler/admin/dashboard_handler_request_type_test.go +++ b/backend/internal/handler/admin/dashboard_handler_request_type_test.go @@ -149,6 +149,28 @@ func TestDashboardModelStatsInvalidStream(t *testing.T) { require.Equal(t, http.StatusBadRequest, rec.Code) } +func TestDashboardModelStatsInvalidModelSource(t *testing.T) { + repo := &dashboardUsageRepoCapture{} + router := newDashboardRequestTypeTestRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/admin/dashboard/models?model_source=invalid", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestDashboardModelStatsValidModelSource(t *testing.T) { + repo := &dashboardUsageRepoCapture{} + router := newDashboardRequestTypeTestRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/admin/dashboard/models?model_source=upstream", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) +} + func TestDashboardUsersRankingLimitAndCache(t *testing.T) { dashboardUsersRankingCache = newSnapshotCache(5 * time.Minute) repo := &dashboardUsageRepoCapture{ diff --git a/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go b/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go new file mode 100644 index 0000000000..b3a05111b3 --- /dev/null +++ b/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go @@ -0,0 +1,229 @@ +package admin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// --- mock repo --- + +type userBreakdownRepoCapture struct { + service.UsageLogRepository + capturedDim usagestats.UserBreakdownDimension + capturedLimit int + result []usagestats.UserBreakdownItem +} + +func (r *userBreakdownRepoCapture) GetUserBreakdownStats( + _ context.Context, _, _ time.Time, + dim usagestats.UserBreakdownDimension, limit int, +) ([]usagestats.UserBreakdownItem, error) { + r.capturedDim = dim + r.capturedLimit = limit + if r.result != nil { + return r.result, nil + } + return []usagestats.UserBreakdownItem{}, nil +} + +func newUserBreakdownRouter(repo *userBreakdownRepoCapture) *gin.Engine { + gin.SetMode(gin.TestMode) + svc := service.NewDashboardService(repo, nil, nil, nil) + h := NewDashboardHandler(svc, nil) + router := gin.New() + router.GET("/admin/dashboard/user-breakdown", h.GetUserBreakdown) + return router +} + +// --- tests --- + +func TestGetUserBreakdown_GroupIDFilter(t *testing.T) { + repo := &userBreakdownRepoCapture{} + router := newUserBreakdownRouter(repo) + + req := httptest.NewRequest(http.MethodGet, + "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&group_id=42", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, int64(42), repo.capturedDim.GroupID) + require.Empty(t, repo.capturedDim.Model) + require.Empty(t, repo.capturedDim.Endpoint) + require.Equal(t, 50, repo.capturedLimit) // default limit +} + +func TestGetUserBreakdown_ModelFilter(t *testing.T) { + repo := &userBreakdownRepoCapture{} + router := newUserBreakdownRouter(repo) + + req := httptest.NewRequest(http.MethodGet, + "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=claude-opus-4-6", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "claude-opus-4-6", repo.capturedDim.Model) + require.Equal(t, usagestats.ModelSourceRequested, repo.capturedDim.ModelType) + require.Equal(t, int64(0), repo.capturedDim.GroupID) +} + +func TestGetUserBreakdown_ModelSourceFilter(t *testing.T) { + repo := &userBreakdownRepoCapture{} + router := newUserBreakdownRouter(repo) + + req := httptest.NewRequest(http.MethodGet, + "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=claude-opus-4-6&model_source=upstream", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, usagestats.ModelSourceUpstream, repo.capturedDim.ModelType) +} + +func TestGetUserBreakdown_InvalidModelSource(t *testing.T) { + repo := &userBreakdownRepoCapture{} + router := newUserBreakdownRouter(repo) + + req := httptest.NewRequest(http.MethodGet, + "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model_source=foobar", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestGetUserBreakdown_EndpointFilter(t *testing.T) { + repo := &userBreakdownRepoCapture{} + router := newUserBreakdownRouter(repo) + + req := httptest.NewRequest(http.MethodGet, + "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&endpoint=/v1/messages&endpoint_type=upstream", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "/v1/messages", repo.capturedDim.Endpoint) + require.Equal(t, "upstream", repo.capturedDim.EndpointType) +} + +func TestGetUserBreakdown_DefaultEndpointType(t *testing.T) { + repo := &userBreakdownRepoCapture{} + router := newUserBreakdownRouter(repo) + + req := httptest.NewRequest(http.MethodGet, + "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&endpoint=/chat", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "inbound", repo.capturedDim.EndpointType) +} + +func TestGetUserBreakdown_CustomLimit(t *testing.T) { + repo := &userBreakdownRepoCapture{} + router := newUserBreakdownRouter(repo) + + req := httptest.NewRequest(http.MethodGet, + "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=test&limit=100", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, 100, repo.capturedLimit) +} + +func TestGetUserBreakdown_LimitClamped(t *testing.T) { + repo := &userBreakdownRepoCapture{} + router := newUserBreakdownRouter(repo) + + // limit > 200 should fall back to default 50 + req := httptest.NewRequest(http.MethodGet, + "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=test&limit=999", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, 50, repo.capturedLimit) +} + +func TestGetUserBreakdown_ResponseFormat(t *testing.T) { + repo := &userBreakdownRepoCapture{ + result: []usagestats.UserBreakdownItem{ + {UserID: 1, Email: "alice@test.com", Requests: 100, TotalTokens: 50000, Cost: 1.5, ActualCost: 1.2}, + {UserID: 2, Email: "bob@test.com", Requests: 50, TotalTokens: 25000, Cost: 0.8, ActualCost: 0.6}, + }, + } + router := newUserBreakdownRouter(repo) + + req := httptest.NewRequest(http.MethodGet, + "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&group_id=1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp struct { + Code int `json:"code"` + Data struct { + Users []usagestats.UserBreakdownItem `json:"users"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + } `json:"data"` + } + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.Equal(t, 0, resp.Code) + require.Len(t, resp.Data.Users, 2) + require.Equal(t, int64(1), resp.Data.Users[0].UserID) + require.Equal(t, "alice@test.com", resp.Data.Users[0].Email) + require.Equal(t, int64(100), resp.Data.Users[0].Requests) + require.InDelta(t, 1.2, resp.Data.Users[0].ActualCost, 0.001) + require.Equal(t, "2026-03-01", resp.Data.StartDate) + require.Equal(t, "2026-03-16", resp.Data.EndDate) +} + +func TestGetUserBreakdown_EmptyResult(t *testing.T) { + repo := &userBreakdownRepoCapture{} + router := newUserBreakdownRouter(repo) + + req := httptest.NewRequest(http.MethodGet, + "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&group_id=999", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp struct { + Data struct { + Users []usagestats.UserBreakdownItem `json:"users"` + } `json:"data"` + } + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.Empty(t, resp.Data.Users) +} + +func TestGetUserBreakdown_NoFilters(t *testing.T) { + repo := &userBreakdownRepoCapture{} + router := newUserBreakdownRouter(repo) + + req := httptest.NewRequest(http.MethodGet, + "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, int64(0), repo.capturedDim.GroupID) + require.Empty(t, repo.capturedDim.Model) + require.Empty(t, repo.capturedDim.Endpoint) +} diff --git a/backend/internal/handler/admin/dashboard_query_cache.go b/backend/internal/handler/admin/dashboard_query_cache.go index 47af511751..815c516154 100644 --- a/backend/internal/handler/admin/dashboard_query_cache.go +++ b/backend/internal/handler/admin/dashboard_query_cache.go @@ -38,6 +38,7 @@ type dashboardModelGroupCacheKey struct { APIKeyID int64 `json:"api_key_id"` AccountID int64 `json:"account_id"` GroupID int64 `json:"group_id"` + ModelSource string `json:"model_source,omitempty"` RequestType *int16 `json:"request_type"` Stream *bool `json:"stream"` BillingType *int8 `json:"billing_type"` @@ -111,6 +112,7 @@ func (h *DashboardHandler) getModelStatsCached( ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, + modelSource string, requestType *int16, stream *bool, billingType *int8, @@ -122,12 +124,13 @@ func (h *DashboardHandler) getModelStatsCached( APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID, + ModelSource: usagestats.NormalizeModelSource(modelSource), RequestType: requestType, Stream: stream, BillingType: billingType, }) entry, hit, err := dashboardModelStatsCache.GetOrLoad(key, func() (any, error) { - return h.dashboardService.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType) + return h.dashboardService.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, modelSource) }) if err != nil { return nil, hit, err diff --git a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go index 16e10339b8..517ae7bd14 100644 --- a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go +++ b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go @@ -200,6 +200,7 @@ func (h *DashboardHandler) buildSnapshotV2Response( filters.APIKeyID, filters.AccountID, filters.GroupID, + usagestats.ModelSourceRequested, filters.RequestType, filters.Stream, filters.BillingType, diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 7be27e9b26..c7ef313541 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -9,6 +9,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -16,7 +17,9 @@ import ( // GroupHandler handles admin group management type GroupHandler struct { - adminService service.AdminService + adminService service.AdminService + dashboardService *service.DashboardService + groupCapacityService *service.GroupCapacityService } type optionalLimitField struct { @@ -69,9 +72,11 @@ func (f optionalLimitField) ToServiceInput() *float64 { } // NewGroupHandler creates a new admin group handler -func NewGroupHandler(adminService service.AdminService) *GroupHandler { +func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler { return &GroupHandler{ - adminService: adminService, + adminService: adminService, + dashboardService: dashboardService, + groupCapacityService: groupCapacityService, } } @@ -367,6 +372,33 @@ func (h *GroupHandler) GetStats(c *gin.Context) { _ = groupID // TODO: implement actual stats } +// GetUsageSummary returns today's and cumulative cost for all groups. +// GET /api/v1/admin/groups/usage-summary?timezone=Asia/Shanghai +func (h *GroupHandler) GetUsageSummary(c *gin.Context) { + userTZ := c.Query("timezone") + now := timezone.NowInUserLocation(userTZ) + todayStart := timezone.StartOfDayInUserLocation(now, userTZ) + + results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart) + if err != nil { + response.Error(c, 500, "Failed to get group usage summary") + return + } + + response.Success(c, results) +} + +// GetCapacitySummary returns aggregated capacity (concurrency/sessions/RPM) for all active groups. +// GET /api/v1/admin/groups/capacity-summary +func (h *GroupHandler) GetCapacitySummary(c *gin.Context) { + results, err := h.groupCapacityService.GetAllGroupCapacity(c.Request.Context()) + if err != nil { + response.Error(c, 500, "Failed to get group capacity summary") + return + } + response.Success(c, results) +} + // GetGroupAPIKeys handles getting API keys in a group // GET /api/v1/admin/groups/:id/api-keys func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) { diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index b7820b81d0..ecd2ce3685 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -129,6 +129,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { OpsQueryModeDefault: settings.OpsQueryModeDefault, OpsMetricsIntervalSeconds: settings.OpsMetricsIntervalSeconds, MinClaudeCodeVersion: settings.MinClaudeCodeVersion, + MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion, AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling, BackendModeEnabled: settings.BackendModeEnabled, }) @@ -203,6 +204,7 @@ type UpdateSettingsRequest struct { OpsMetricsIntervalSeconds *int `json:"ops_metrics_interval_seconds"` MinClaudeCodeVersion string `json:"min_claude_code_version"` + MaxClaudeCodeVersion string `json:"max_claude_code_version"` // 分组隔离 AllowUngroupedKeyScheduling bool `json:"allow_ungrouped_key_scheduling"` @@ -446,6 +448,22 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + // 验证最高版本号格式(空字符串=禁用,或合法 semver) + if req.MaxClaudeCodeVersion != "" { + if !semverPattern.MatchString(req.MaxClaudeCodeVersion) { + response.Error(c, http.StatusBadRequest, "max_claude_code_version must be empty or a valid semver (e.g. 3.0.0)") + return + } + } + + // 交叉验证:如果同时设置了最低和最高版本号,最高版本号必须 >= 最低版本号 + if req.MinClaudeCodeVersion != "" && req.MaxClaudeCodeVersion != "" { + if service.CompareVersions(req.MaxClaudeCodeVersion, req.MinClaudeCodeVersion) < 0 { + response.Error(c, http.StatusBadRequest, "max_claude_code_version must be greater than or equal to min_claude_code_version") + return + } + } + settings := &service.SystemSettings{ RegistrationEnabled: req.RegistrationEnabled, EmailVerifyEnabled: req.EmailVerifyEnabled, @@ -492,6 +510,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { EnableIdentityPatch: req.EnableIdentityPatch, IdentityPatchPrompt: req.IdentityPatchPrompt, MinClaudeCodeVersion: req.MinClaudeCodeVersion, + MaxClaudeCodeVersion: req.MaxClaudeCodeVersion, AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling, BackendModeEnabled: req.BackendModeEnabled, OpsMonitoringEnabled: func() bool { @@ -592,6 +611,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { OpsQueryModeDefault: updatedSettings.OpsQueryModeDefault, OpsMetricsIntervalSeconds: updatedSettings.OpsMetricsIntervalSeconds, MinClaudeCodeVersion: updatedSettings.MinClaudeCodeVersion, + MaxClaudeCodeVersion: updatedSettings.MaxClaudeCodeVersion, AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling, BackendModeEnabled: updatedSettings.BackendModeEnabled, }) @@ -748,6 +768,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.MinClaudeCodeVersion != after.MinClaudeCodeVersion { changed = append(changed, "min_claude_code_version") } + if before.MaxClaudeCodeVersion != after.MaxClaudeCodeVersion { + changed = append(changed, "max_claude_code_version") + } if before.AllowUngroupedKeyScheduling != after.AllowUngroupedKeyScheduling { changed = append(changed, "allow_ungrouped_key_scheduling") } @@ -981,6 +1004,58 @@ func (h *SettingHandler) DeleteAdminAPIKey(c *gin.Context) { response.Success(c, gin.H{"message": "Admin API key deleted"}) } +// GetOverloadCooldownSettings 获取529过载冷却配置 +// GET /api/v1/admin/settings/overload-cooldown +func (h *SettingHandler) GetOverloadCooldownSettings(c *gin.Context) { + settings, err := h.settingService.GetOverloadCooldownSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.OverloadCooldownSettings{ + Enabled: settings.Enabled, + CooldownMinutes: settings.CooldownMinutes, + }) +} + +// UpdateOverloadCooldownSettingsRequest 更新529过载冷却配置请求 +type UpdateOverloadCooldownSettingsRequest struct { + Enabled bool `json:"enabled"` + CooldownMinutes int `json:"cooldown_minutes"` +} + +// UpdateOverloadCooldownSettings 更新529过载冷却配置 +// PUT /api/v1/admin/settings/overload-cooldown +func (h *SettingHandler) UpdateOverloadCooldownSettings(c *gin.Context) { + var req UpdateOverloadCooldownSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + settings := &service.OverloadCooldownSettings{ + Enabled: req.Enabled, + CooldownMinutes: req.CooldownMinutes, + } + + if err := h.settingService.SetOverloadCooldownSettings(c.Request.Context(), settings); err != nil { + response.BadRequest(c, err.Error()) + return + } + + updatedSettings, err := h.settingService.GetOverloadCooldownSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.OverloadCooldownSettings{ + Enabled: updatedSettings.Enabled, + CooldownMinutes: updatedSettings.CooldownMinutes, + }) +} + // GetStreamTimeoutSettings 获取流超时处理配置 // GET /api/v1/admin/settings/stream-timeout func (h *SettingHandler) GetStreamTimeoutSettings(c *gin.Context) { diff --git a/backend/internal/handler/admin/subscription_handler.go b/backend/internal/handler/admin/subscription_handler.go index 342964b6ff..611666decf 100644 --- a/backend/internal/handler/admin/subscription_handler.go +++ b/backend/internal/handler/admin/subscription_handler.go @@ -77,12 +77,13 @@ func (h *SubscriptionHandler) List(c *gin.Context) { } } status := c.Query("status") + platform := c.Query("platform") // Parse sorting parameters sortBy := c.DefaultQuery("sort_by", "created_at") sortOrder := c.DefaultQuery("sort_order", "desc") - subscriptions, pagination, err := h.subscriptionService.List(c.Request.Context(), page, pageSize, userID, groupID, status, sortBy, sortOrder) + subscriptions, pagination, err := h.subscriptionService.List(c.Request.Context(), page, pageSize, userID, groupID, status, platform, sortBy, sortOrder) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index 5a55ab14da..998308dd99 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -75,6 +75,7 @@ type UpdateBalanceRequest struct { // - role: filter by user role // - search: search in email, username // - attr[{id}]: filter by custom attribute value, e.g. attr[1]=company +// - group_name: fuzzy filter by allowed group name func (h *UserHandler) List(c *gin.Context) { page, pageSize := response.ParsePagination(c) @@ -89,6 +90,7 @@ func (h *UserHandler) List(c *gin.Context) { Status: c.Query("status"), Role: c.Query("role"), Search: search, + GroupName: strings.TrimSpace(c.Query("group_name")), Attributes: parseAttributeFilters(c), } if raw, ok := c.GetQuery("include_subscriptions"); ok { @@ -366,3 +368,35 @@ func (h *UserHandler) GetBalanceHistory(c *gin.Context) { "total_recharged": totalRecharged, }) } + +// ReplaceGroupRequest represents the request to replace a user's exclusive group +type ReplaceGroupRequest struct { + OldGroupID int64 `json:"old_group_id" binding:"required,gt=0"` + NewGroupID int64 `json:"new_group_id" binding:"required,gt=0"` +} + +// ReplaceGroup handles replacing a user's exclusive group +// POST /api/v1/admin/users/:id/replace-group +func (h *UserHandler) ReplaceGroup(c *gin.Context) { + userID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid user ID") + return + } + + var req ReplaceGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + result, err := h.adminService.ReplaceUserGroup(c.Request.Context(), userID, req.OldGroupID, req.NewGroupID) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{ + "migrated_keys": result.MigratedKeys, + }) +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 447d909558..7952a21377 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -143,6 +143,8 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { SimulateClaudeMaxEnabled: g.SimulateClaudeMaxEnabled, SupportedModelScopes: g.SupportedModelScopes, AccountCount: g.AccountCount, + ActiveAccountCount: g.ActiveAccountCount, + RateLimitedAccountCount: g.RateLimitedAccountCount, SortOrder: g.SortOrder, } if len(g.AccountGroups) > 0 { @@ -266,9 +268,14 @@ func AccountFromServiceShallow(a *service.Account) *Account { } // 客户端亲和调度(Anthropic 和 Antigravity 账号) - if a.IsClientAffinityEnabled() { + if a.IsAffinityEnabled() { enabled := true out.ClientAffinityEnabled = &enabled + allow := a.IsAffinityAllowSwitch() + out.AffinityAllowSwitch = &allow + if pinnedUsers := a.GetPinnedUsers(); len(pinnedUsers) > 0 { + out.PinnedUserIDs = pinnedUsers + } } // 提取账号配额限制(apikey / bedrock 类型有效) @@ -528,6 +535,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog { AccountID: l.AccountID, RequestID: l.RequestID, Model: l.Model, + UpstreamModel: l.UpstreamModel, ServiceTier: l.ServiceTier, ReasoningEffort: l.ReasoningEffort, InboundEndpoint: l.InboundEndpoint, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 8a78bda27c..fd0e790cf1 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -79,6 +79,7 @@ type SystemSettings struct { OpsMetricsIntervalSeconds int `json:"ops_metrics_interval_seconds"` MinClaudeCodeVersion string `json:"min_claude_code_version"` + MaxClaudeCodeVersion string `json:"max_claude_code_version"` // 分组隔离 AllowUngroupedKeyScheduling bool `json:"allow_ungrouped_key_scheduling"` @@ -167,6 +168,12 @@ type ListSoraS3ProfilesResponse struct { Items []SoraS3Profile `json:"items"` } +// OverloadCooldownSettings 529过载冷却配置 DTO +type OverloadCooldownSettings struct { + Enabled bool `json:"enabled"` + CooldownMinutes int `json:"cooldown_minutes"` +} + // StreamTimeoutSettings 流超时处理配置 DTO type StreamTimeoutSettings struct { Enabled bool `json:"enabled"` diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index db1e9a610c..cddc430353 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -124,9 +124,11 @@ type AdminGroup struct { DefaultMappedModel string `json:"default_mapped_model"` // 支持的模型系列(仅 antigravity 平台使用) - SupportedModelScopes []string `json:"supported_model_scopes"` - AccountGroups []AccountGroup `json:"account_groups,omitempty"` - AccountCount int64 `json:"account_count,omitempty"` + SupportedModelScopes []string `json:"supported_model_scopes"` + AccountGroups []AccountGroup `json:"account_groups,omitempty"` + AccountCount int64 `json:"account_count,omitempty"` + ActiveAccountCount int64 `json:"active_account_count,omitempty"` + RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"` // 分组排序 SortOrder int `json:"sort_order"` @@ -201,6 +203,15 @@ type Account struct { // 启用后新会话会优先调度到客户端之前使用过的账号 ClientAffinityEnabled *bool `json:"client_affinity_enabled,omitempty"` + // 亲和允许切换(默认 true) + AffinityAllowSwitch *bool `json:"affinity_allow_switch,omitempty"` + + // 亲和用户数量(admin 列表端点注入) + AffinityUserCount *int64 `json:"affinity_user_count,omitempty"` + + // 指定亲和用户 ID 列表 + PinnedUserIDs []int64 `json:"pinned_user_ids,omitempty"` + // 亲和客户端数据(仅 admin 列表端点注入,不由 mapper 填充) AffinityClientCount *int64 `json:"affinity_client_count,omitempty"` AffinityClients []string `json:"affinity_clients,omitempty"` @@ -342,6 +353,9 @@ type UsageLog struct { AccountID int64 `json:"account_id"` RequestID string `json:"request_id"` Model string `json:"model"` + // UpstreamModel is the actual model sent to the upstream provider after mapping. + // Omitted when no mapping was applied (requested model was used as-is). + UpstreamModel *string `json:"upstream_model,omitempty"` // ServiceTier records the OpenAI service tier used for billing, e.g. "priority" / "flex". ServiceTier *string `json:"service_tier,omitempty"` // ReasoningEffort is the request's reasoning effort level. diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index cb90f49ba9..64728e4679 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -291,7 +291,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } for { - selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, "") // Gemini 不使用会话限制 + selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制 if err != nil { if len(fs.FailedAccountIDs) == 0 { h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted) @@ -501,8 +501,16 @@ func (h *GatewayHandler) Messages(c *gin.Context) { for { // 选择支持该模型的账号 - selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID) + selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID, subject.UserID) if err != nil { + if errors.Is(err, service.ErrAffinityNoSwitch) { + h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Affinity account unavailable and switching is disabled", streamStarted) + return + } + if errors.Is(err, service.ErrAffinityLimitExceeded) { + h.handleStreamingAwareError(c, http.StatusTooManyRequests, "api_error", "Affinity client limit exceeded", streamStarted) + return + } if len(fs.FailedAccountIDs) == 0 { h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted) return @@ -1222,6 +1230,10 @@ func (h *GatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *se } } + // 记录原始上游状态码,以便 ops 错误日志捕获真实的上游错误 + upstreamMsg := service.ExtractUpstreamErrorMessage(responseBody) + service.SetOpsUpstreamError(c, statusCode, upstreamMsg, "") + // 使用默认的错误映射 status, errType, errMsg := h.mapUpstreamError(statusCode) h.handleStreamingAwareError(c, status, errType, errMsg, streamStarted) @@ -1230,6 +1242,7 @@ func (h *GatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *se // handleFailoverExhaustedSimple 简化版本,用于没有响应体的情况 func (h *GatewayHandler) handleFailoverExhaustedSimple(c *gin.Context, statusCode int, streamStarted bool) { status, errType, errMsg := h.mapUpstreamError(statusCode) + service.SetOpsUpstreamError(c, statusCode, errMsg, "") h.handleStreamingAwareError(c, status, errType, errMsg, streamStarted) } @@ -1279,7 +1292,7 @@ func (h *GatewayHandler) ensureForwardErrorResponse(c *gin.Context, streamStarte return true } -// checkClaudeCodeVersion 检查 Claude Code 客户端版本是否满足最低要求 +// checkClaudeCodeVersion 检查 Claude Code 客户端版本是否满足版本要求 // 仅对已识别的 Claude Code 客户端执行,count_tokens 路径除外 func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool { ctx := c.Request.Context() @@ -1292,8 +1305,8 @@ func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool { return true } - minVersion := h.settingService.GetMinClaudeCodeVersion(ctx) - if minVersion == "" { + minVersion, maxVersion := h.settingService.GetClaudeCodeVersionBounds(ctx) + if minVersion == "" && maxVersion == "" { return true // 未设置,不检查 } @@ -1304,13 +1317,22 @@ func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool { return false } - if service.CompareVersions(clientVersion, minVersion) < 0 { + if minVersion != "" && service.CompareVersions(clientVersion, minVersion) < 0 { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("Your Claude Code version (%s) is below the minimum required version (%s). Please update: npm update -g @anthropic-ai/claude-code", clientVersion, minVersion)) return false } + if maxVersion != "" && service.CompareVersions(clientVersion, maxVersion) > 0 { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", + fmt.Sprintf("Your Claude Code version (%s) exceeds the maximum allowed version (%s). "+ + "Please downgrade: npm install -g @anthropic-ai/claude-code@%s && "+ + "set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 to prevent auto-upgrade", + clientVersion, maxVersion, maxVersion)) + return false + } + return true } diff --git a/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go b/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go index 6bcc0003e8..b9dbe0ce1e 100644 --- a/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go +++ b/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go @@ -76,7 +76,7 @@ func (f *fakeGroupRepo) ListActiveByPlatform(context.Context, string) ([]service return nil, nil } func (f *fakeGroupRepo) ExistsByName(context.Context, string) (bool, error) { return false, nil } -func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, error) { return 0, nil } +func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, int64, error) { return 0, 0, nil } func (f *fakeGroupRepo) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { return 0, nil } diff --git a/backend/internal/handler/gateway_helper_hotpath_test.go b/backend/internal/handler/gateway_helper_hotpath_test.go index 9e90410754..4a67719980 100644 --- a/backend/internal/handler/gateway_helper_hotpath_test.go +++ b/backend/internal/handler/gateway_helper_hotpath_test.go @@ -136,7 +136,7 @@ func validClaudeCodeBodyJSON() []byte { return []byte(`{ "model":"claude-3-5-sonnet-20241022", "system":[{"text":"You are Claude Code, Anthropic's official CLI for Claude."}], - "metadata":{"user_id":"user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_abc-123"} + "metadata":{"user_id":"user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} }`) } @@ -190,7 +190,7 @@ func TestSetClaudeCodeClientContext_ReuseParsedRequestAndContextCache(t *testing System: []any{ map[string]any{"text": "You are Claude Code, Anthropic's official CLI for Claude."}, }, - MetadataUserID: "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_abc-123", + MetadataUserID: "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", } // body 非法 JSON,如果函数复用 parsedReq 成功则仍应判定为 Claude Code。 @@ -209,7 +209,7 @@ func TestSetClaudeCodeClientContext_ReuseParsedRequestAndContextCache(t *testing "system": []any{ map[string]any{"text": "You are Claude Code, Anthropic's official CLI for Claude."}, }, - "metadata": map[string]any{"user_id": "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_abc-123"}, + "metadata": map[string]any{"user_id": "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}, }) SetClaudeCodeClientContext(c, []byte(`{invalid`), nil) diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index cfe809114b..74c89d0179 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -352,7 +352,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { } for { - selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, fs.FailedAccountIDs, "") // Gemini 不使用会话限制 + selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制 if err != nil { if len(fs.FailedAccountIDs) == 0 { googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error()) @@ -593,6 +593,10 @@ func (h *GatewayHandler) handleGeminiFailoverExhausted(c *gin.Context, failoverE } } + // 记录原始上游状态码,以便 ops 错误日志捕获真实的上游错误 + upstreamMsg := service.ExtractUpstreamErrorMessage(responseBody) + service.SetOpsUpstreamError(c, statusCode, upstreamMsg, "") + // 使用默认的错误映射 status, message := mapGeminiUpstreamError(statusCode) googleError(c, status, message) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index c681e61de1..ec957feb75 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1435,6 +1435,10 @@ func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverE } } + // 记录原始上游状态码,以便 ops 错误日志捕获真实的上游错误 + upstreamMsg := service.ExtractUpstreamErrorMessage(responseBody) + service.SetOpsUpstreamError(c, statusCode, upstreamMsg, "") + // 使用默认的错误映射 status, errType, errMsg := h.mapUpstreamError(statusCode) h.handleStreamingAwareError(c, status, errType, errMsg, streamStarted) @@ -1443,6 +1447,7 @@ func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverE // handleFailoverExhaustedSimple 简化版本,用于没有响应体的情况 func (h *OpenAIGatewayHandler) handleFailoverExhaustedSimple(c *gin.Context, statusCode int, streamStarted bool) { status, errType, errMsg := h.mapUpstreamError(statusCode) + service.SetOpsUpstreamError(c, statusCode, errMsg, "") h.handleStreamingAwareError(c, status, errType, errMsg, streamStarted) } diff --git a/backend/internal/handler/sora_client_handler_test.go b/backend/internal/handler/sora_client_handler_test.go index 92e77d9251..a9b4e51cee 100644 --- a/backend/internal/handler/sora_client_handler_test.go +++ b/backend/internal/handler/sora_client_handler_test.go @@ -945,6 +945,9 @@ func (r *stubUserRepoForHandler) ExistsByEmail(context.Context, string) (bool, e func (r *stubUserRepoForHandler) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil } +func (r *stubUserRepoForHandler) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error { + return nil +} func (r *stubUserRepoForHandler) UpdateTotpSecret(context.Context, int64, *string) error { return nil } func (r *stubUserRepoForHandler) EnableTotp(context.Context, int64) error { return nil } func (r *stubUserRepoForHandler) DisableTotp(context.Context, int64) error { return nil } @@ -1020,6 +1023,20 @@ func (r *stubAPIKeyRepoForHandler) SearchAPIKeys(context.Context, int64, string, func (r *stubAPIKeyRepoForHandler) ClearGroupIDByGroupID(context.Context, int64) (int64, error) { return 0, nil } +func (r *stubAPIKeyRepoForHandler) UpdateGroupIDByUserAndGroup(_ context.Context, userID, oldGroupID, newGroupID int64) (int64, error) { + var updated int64 + for id, key := range r.keys { + if key.UserID != userID || key.GroupID == nil || *key.GroupID != oldGroupID { + continue + } + clone := *key + gid := newGroupID + clone.GroupID = &gid + r.keys[id] = &clone + updated++ + } + return updated, nil +} func (r *stubAPIKeyRepoForHandler) CountByGroupID(context.Context, int64) (int64, error) { return 0, nil } diff --git a/backend/internal/handler/sora_gateway_handler.go b/backend/internal/handler/sora_gateway_handler.go index dc301ce149..c9c7de178f 100644 --- a/backend/internal/handler/sora_gateway_handler.go +++ b/backend/internal/handler/sora_gateway_handler.go @@ -225,7 +225,7 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) { var lastFailoverHeaders http.Header for { - selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionHash, reqModel, failedAccountIDs, "") + selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionHash, reqModel, failedAccountIDs, "", int64(0)) if err != nil { reqLog.Warn("sora.account_select_failed", zap.Error(err), @@ -484,6 +484,9 @@ func (h *SoraGatewayHandler) handleConcurrencyError(c *gin.Context, err error, s } func (h *SoraGatewayHandler) handleFailoverExhausted(c *gin.Context, statusCode int, responseHeaders http.Header, responseBody []byte, streamStarted bool) { + upstreamMsg := service.ExtractUpstreamErrorMessage(responseBody) + service.SetOpsUpstreamError(c, statusCode, upstreamMsg, "") + status, errType, errMsg := h.mapUpstreamError(statusCode, responseHeaders, responseBody) h.handleStreamingAwareError(c, status, errType, errMsg, streamStarted) } diff --git a/backend/internal/handler/sora_gateway_handler_test.go b/backend/internal/handler/sora_gateway_handler_test.go index 7170415dfa..5c631132d6 100644 --- a/backend/internal/handler/sora_gateway_handler_test.go +++ b/backend/internal/handler/sora_gateway_handler_test.go @@ -273,8 +273,8 @@ func (r *stubGroupRepo) ListActiveByPlatform(ctx context.Context, platform strin func (r *stubGroupRepo) ExistsByName(ctx context.Context, name string) (bool, error) { return false, nil } -func (r *stubGroupRepo) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { - return 0, nil +func (r *stubGroupRepo) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) { + return 0, 0, nil } func (r *stubGroupRepo) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, nil @@ -345,6 +345,12 @@ func (s *stubUsageLogRepo) GetUpstreamEndpointStatsWithFilters(ctx context.Conte func (s *stubUsageLogRepo) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) { return nil, nil } +func (s *stubUsageLogRepo) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) { + return nil, nil +} +func (s *stubUsageLogRepo) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) { + return nil, nil +} func (s *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) { return nil, nil } diff --git a/backend/internal/handler/sora_videos_handler.go b/backend/internal/handler/sora_videos_handler.go index 46edbd3624..b2a9d79c81 100644 --- a/backend/internal/handler/sora_videos_handler.go +++ b/backend/internal/handler/sora_videos_handler.go @@ -323,7 +323,7 @@ func (h *SoraVideosHandler) selectAccount(c *gin.Context, model string) (*servic } selection, err := h.gatewayService.SelectAccountWithLoadAwareness( - c.Request.Context(), apiKey.GroupID, "", model, nil, "", + c.Request.Context(), apiKey.GroupID, "", model, nil, "", int64(0), ) if err != nil { soraErrorResponse(c, http.StatusServiceUnavailable, "server_error", "No available accounts") diff --git a/backend/internal/pkg/antigravity/client.go b/backend/internal/pkg/antigravity/client.go index af3a0bfcd6..f24ff5a8d9 100644 --- a/backend/internal/pkg/antigravity/client.go +++ b/backend/internal/pkg/antigravity/client.go @@ -228,9 +228,18 @@ type Client struct { httpClient *http.Client } +const ( + // proxyDialTimeout 代理 TCP 连接超时(含代理握手),代理不通时快速失败 + proxyDialTimeout = 5 * time.Second + // proxyTLSHandshakeTimeout 代理 TLS 握手超时 + proxyTLSHandshakeTimeout = 5 * time.Second + // clientTimeout 整体请求超时(含连接、发送、等待响应、读取 body) + clientTimeout = 10 * time.Second +) + func NewClient(proxyURL string) (*Client, error) { client := &http.Client{ - Timeout: 30 * time.Second, + Timeout: clientTimeout, } _, parsed, err := proxyurl.Parse(proxyURL) @@ -238,7 +247,12 @@ func NewClient(proxyURL string) (*Client, error) { return nil, err } if parsed != nil { - transport := &http.Transport{} + transport := &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: proxyDialTimeout, + }).DialContext, + TLSHandshakeTimeout: proxyTLSHandshakeTimeout, + } if err := proxyutil.ConfigureTransportProxy(transport, parsed); err != nil { return nil, fmt.Errorf("configure proxy: %w", err) } @@ -250,8 +264,8 @@ func NewClient(proxyURL string) (*Client, error) { }, nil } -// isConnectionError 判断是否为连接错误(网络超时、DNS 失败、连接拒绝) -func isConnectionError(err error) bool { +// IsConnectionError 判断是否为连接错误(网络超时、DNS 失败、连接拒绝) +func IsConnectionError(err error) bool { if err == nil { return false } @@ -276,7 +290,7 @@ func isConnectionError(err error) bool { // shouldFallbackToNextURL 判断是否应切换到下一个 URL // 与 Antigravity-Manager 保持一致:连接错误、429、408、404、5xx 触发 URL 降级 func shouldFallbackToNextURL(err error, statusCode int) bool { - if isConnectionError(err) { + if IsConnectionError(err) { return true } return statusCode == http.StatusTooManyRequests || diff --git a/backend/internal/pkg/antigravity/client_test.go b/backend/internal/pkg/antigravity/client_test.go index 7d5bba9398..61a08c3de5 100644 --- a/backend/internal/pkg/antigravity/client_test.go +++ b/backend/internal/pkg/antigravity/client_test.go @@ -274,8 +274,8 @@ func TestNewClient_无代理(t *testing.T) { if client.httpClient == nil { t.Fatal("httpClient 为 nil") } - if client.httpClient.Timeout != 30*time.Second { - t.Errorf("Timeout 不匹配: got %v, want 30s", client.httpClient.Timeout) + if client.httpClient.Timeout != clientTimeout { + t.Errorf("Timeout 不匹配: got %v, want %v", client.httpClient.Timeout, clientTimeout) } // 无代理时 Transport 应为 nil(使用默认) if client.httpClient.Transport != nil { @@ -322,11 +322,11 @@ func TestNewClient_无效代理URL(t *testing.T) { } // --------------------------------------------------------------------------- -// isConnectionError +// IsConnectionError // --------------------------------------------------------------------------- func TestIsConnectionError_nil(t *testing.T) { - if isConnectionError(nil) { + if IsConnectionError(nil) { t.Error("nil 错误不应判定为连接错误") } } @@ -338,7 +338,7 @@ func TestIsConnectionError_超时错误(t *testing.T) { Net: "tcp", Err: &timeoutError{}, } - if !isConnectionError(err) { + if !IsConnectionError(err) { t.Error("超时错误应判定为连接错误") } } @@ -356,7 +356,7 @@ func TestIsConnectionError_netOpError(t *testing.T) { Net: "tcp", Err: fmt.Errorf("connection refused"), } - if !isConnectionError(err) { + if !IsConnectionError(err) { t.Error("net.OpError 应判定为连接错误") } } @@ -367,14 +367,14 @@ func TestIsConnectionError_urlError(t *testing.T) { URL: "https://example.com", Err: fmt.Errorf("some error"), } - if !isConnectionError(err) { + if !IsConnectionError(err) { t.Error("url.Error 应判定为连接错误") } } func TestIsConnectionError_普通错误(t *testing.T) { err := fmt.Errorf("some random error") - if isConnectionError(err) { + if IsConnectionError(err) { t.Error("普通错误不应判定为连接错误") } } @@ -386,7 +386,7 @@ func TestIsConnectionError_包装的netOpError(t *testing.T) { Err: fmt.Errorf("connection refused"), } err := fmt.Errorf("wrapping: %w", inner) - if !isConnectionError(err) { + if !IsConnectionError(err) { t.Error("被包装的 net.OpError 应判定为连接错误") } } diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 55cdd78650..1b45e507fe 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -275,21 +275,6 @@ func filterOpenCodePrompt(text string) string { return "" } -// systemBlockFilterPrefixes 需要从 system 中过滤的文本前缀列表 -var systemBlockFilterPrefixes = []string{ - "x-anthropic-billing-header", -} - -// filterSystemBlockByPrefix 如果文本匹配过滤前缀,返回空字符串 -func filterSystemBlockByPrefix(text string) string { - for _, prefix := range systemBlockFilterPrefixes { - if strings.HasPrefix(text, prefix) { - return "" - } - } - return text -} - // buildSystemInstruction 构建 systemInstruction(与 Antigravity-Manager 保持一致) func buildSystemInstruction(system json.RawMessage, modelName string, opts TransformOptions, tools []ClaudeTool) *GeminiContent { var parts []GeminiPart @@ -306,8 +291,8 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans if strings.Contains(sysStr, "You are Antigravity") { userHasAntigravityIdentity = true } - // 过滤 OpenCode 默认提示词和黑名单前缀 - filtered := filterSystemBlockByPrefix(filterOpenCodePrompt(sysStr)) + // 过滤 OpenCode 默认提示词 + filtered := filterOpenCodePrompt(sysStr) if filtered != "" { userSystemParts = append(userSystemParts, GeminiPart{Text: filtered}) } @@ -321,8 +306,8 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans if strings.Contains(block.Text, "You are Antigravity") { userHasAntigravityIdentity = true } - // 过滤 OpenCode 默认提示词和黑名单前缀 - filtered := filterSystemBlockByPrefix(filterOpenCodePrompt(block.Text)) + // 过滤 OpenCode 默认提示词 + filtered := filterOpenCodePrompt(block.Text) if filtered != "" { userSystemParts = append(userSystemParts, GeminiPart{Text: filtered}) } diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index f267e0e1ea..9e46295a8d 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -2,7 +2,10 @@ package antigravity import ( "encoding/json" + "strings" "testing" + + "github.com/stretchr/testify/require" ) // TestBuildParts_ThinkingBlockWithoutSignature 测试thinking block无signature时的处理 @@ -349,3 +352,51 @@ func TestBuildGenerationConfig_ThinkingDynamicBudget(t *testing.T) { }) } } + +func TestTransformClaudeToGeminiWithOptions_PreservesBillingHeaderSystemBlock(t *testing.T) { + tests := []struct { + name string + system json.RawMessage + }{ + { + name: "system array", + system: json.RawMessage(`[{"type":"text","text":"x-anthropic-billing-header keep"}]`), + }, + { + name: "system string", + system: json.RawMessage(`"x-anthropic-billing-header keep"`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claudeReq := &ClaudeRequest{ + Model: "claude-3-5-sonnet-latest", + System: tt.system, + Messages: []ClaudeMessage{ + { + Role: "user", + Content: json.RawMessage(`[{"type":"text","text":"hello"}]`), + }, + }, + } + + body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "gemini-2.5-flash", DefaultTransformOptions()) + require.NoError(t, err) + + var req V1InternalRequest + require.NoError(t, json.Unmarshal(body, &req)) + require.NotNil(t, req.Request.SystemInstruction) + + found := false + for _, part := range req.Request.SystemInstruction.Parts { + if strings.Contains(part.Text, "x-anthropic-billing-header keep") { + found = true + break + } + } + + require.True(t, found, "转换后的 systemInstruction 应保留 x-anthropic-billing-header 内容") + }) + } +} diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index 2db65572c1..34f5b60cde 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -1008,3 +1008,114 @@ func TestAnthropicToResponses_ImageEmptyMediaType(t *testing.T) { // Should default to image/png when media_type is empty. assert.Equal(t, "data:image/png;base64,iVBOR", parts[0].ImageURL) } + +// --------------------------------------------------------------------------- +// normalizeToolParameters tests +// --------------------------------------------------------------------------- + +func TestNormalizeToolParameters(t *testing.T) { + tests := []struct { + name string + input json.RawMessage + expected string + }{ + { + name: "nil input", + input: nil, + expected: `{"type":"object","properties":{}}`, + }, + { + name: "empty input", + input: json.RawMessage(``), + expected: `{"type":"object","properties":{}}`, + }, + { + name: "null input", + input: json.RawMessage(`null`), + expected: `{"type":"object","properties":{}}`, + }, + { + name: "object without properties", + input: json.RawMessage(`{"type":"object"}`), + expected: `{"type":"object","properties":{}}`, + }, + { + name: "object with properties", + input: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}}}`), + expected: `{"type":"object","properties":{"city":{"type":"string"}}}`, + }, + { + name: "non-object type", + input: json.RawMessage(`{"type":"string"}`), + expected: `{"type":"string"}`, + }, + { + name: "object with additional fields preserved", + input: json.RawMessage(`{"type":"object","required":["name"]}`), + expected: `{"type":"object","required":["name"],"properties":{}}`, + }, + { + name: "invalid JSON passthrough", + input: json.RawMessage(`not json`), + expected: `not json`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeToolParameters(tt.input) + if tt.name == "invalid JSON passthrough" { + assert.Equal(t, tt.expected, string(result)) + } else { + assert.JSONEq(t, tt.expected, string(result)) + } + }) + } +} + +func TestAnthropicToResponses_ToolWithoutProperties(t *testing.T) { + req := &AnthropicRequest{ + Model: "gpt-5.2", + MaxTokens: 1024, + Messages: []AnthropicMessage{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + Tools: []AnthropicTool{ + {Name: "mcp__pencil__get_style_guide_tags", Description: "Get style tags", InputSchema: json.RawMessage(`{"type":"object"}`)}, + }, + } + + resp, err := AnthropicToResponses(req) + require.NoError(t, err) + + require.Len(t, resp.Tools, 1) + assert.Equal(t, "function", resp.Tools[0].Type) + assert.Equal(t, "mcp__pencil__get_style_guide_tags", resp.Tools[0].Name) + + // Parameters must have "properties" field after normalization. + var params map[string]json.RawMessage + require.NoError(t, json.Unmarshal(resp.Tools[0].Parameters, ¶ms)) + assert.Contains(t, params, "properties") +} + +func TestAnthropicToResponses_ToolWithNilSchema(t *testing.T) { + req := &AnthropicRequest{ + Model: "gpt-5.2", + MaxTokens: 1024, + Messages: []AnthropicMessage{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + Tools: []AnthropicTool{ + {Name: "simple_tool", Description: "A tool"}, + }, + } + + resp, err := AnthropicToResponses(req) + require.NoError(t, err) + + require.Len(t, resp.Tools, 1) + var params map[string]json.RawMessage + require.NoError(t, json.Unmarshal(resp.Tools[0].Parameters, ¶ms)) + assert.JSONEq(t, `"object"`, string(params["type"])) + assert.JSONEq(t, `{}`, string(params["properties"])) +} diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index 0a747869a1..fca3cf1fed 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -409,8 +409,41 @@ func convertAnthropicToolsToResponses(tools []AnthropicTool) []ResponsesTool { Type: "function", Name: t.Name, Description: t.Description, - Parameters: t.InputSchema, + Parameters: normalizeToolParameters(t.InputSchema), }) } return out } + +// normalizeToolParameters ensures the tool parameter schema is valid for +// OpenAI's Responses API, which requires "properties" on object schemas. +// +// - nil/empty → {"type":"object","properties":{}} +// - type=object without properties → adds "properties": {} +// - otherwise → returned unchanged +func normalizeToolParameters(schema json.RawMessage) json.RawMessage { + if len(schema) == 0 || string(schema) == "null" { + return json.RawMessage(`{"type":"object","properties":{}}`) + } + + var m map[string]json.RawMessage + if err := json.Unmarshal(schema, &m); err != nil { + return schema + } + + typ := m["type"] + if string(typ) != `"object"` { + return schema + } + + if _, ok := m["properties"]; ok { + return schema + } + + m["properties"] = json.RawMessage(`{}`) + out, err := json.Marshal(m) + if err != nil { + return schema + } + return out +} diff --git a/backend/internal/pkg/httpclient/pool.go b/backend/internal/pkg/httpclient/pool.go index 32e4bc5b20..12804cc67d 100644 --- a/backend/internal/pkg/httpclient/pool.go +++ b/backend/internal/pkg/httpclient/pool.go @@ -17,6 +17,7 @@ package httpclient import ( "fmt" + "net" "net/http" "strings" "sync" @@ -32,6 +33,8 @@ const ( defaultMaxIdleConns = 100 // 最大空闲连接数 defaultMaxIdleConnsPerHost = 10 // 每个主机最大空闲连接数 defaultIdleConnTimeout = 90 * time.Second // 空闲连接超时时间(建议小于上游 LB 超时) + defaultDialTimeout = 5 * time.Second // TCP 连接超时(含代理握手),代理不通时快速失败 + defaultTLSHandshakeTimeout = 5 * time.Second // TLS 握手超时 validatedHostTTL = 30 * time.Second // DNS Rebinding 校验缓存 TTL ) @@ -107,6 +110,10 @@ func buildTransport(opts Options) (*http.Transport, error) { } transport := &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: defaultDialTimeout, + }).DialContext, + TLSHandshakeTimeout: defaultTLSHandshakeTimeout, MaxIdleConns: maxIdleConns, MaxIdleConnsPerHost: maxIdleConnsPerHost, MaxConnsPerHost: opts.MaxConnsPerHost, // 0 表示无限制 diff --git a/backend/internal/pkg/logger/logger_test.go b/backend/internal/pkg/logger/logger_test.go index 74aae0613a..06a277a490 100644 --- a/backend/internal/pkg/logger/logger_test.go +++ b/backend/internal/pkg/logger/logger_test.go @@ -10,7 +10,13 @@ import ( ) func TestInit_DualOutput(t *testing.T) { - tmpDir := t.TempDir() + // Use os.MkdirTemp instead of t.TempDir to avoid cleanup failures + // when lumberjack holds file handles on Windows. + tmpDir, err := os.MkdirTemp("", "logger-test-*") + if err != nil { + t.Fatalf("create temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) logPath := filepath.Join(tmpDir, "logs", "sub2api.log") origStdout := os.Stdout @@ -57,7 +63,9 @@ func TestInit_DualOutput(t *testing.T) { L().Info("dual-output-info") L().Warn("dual-output-warn") - Sync() + + // Skip Sync() — on Windows, fsync on pipes deadlocks (FlushFileBuffers). + // The log data is already in the pipe buffer; closing writers is sufficient. _ = stdoutW.Close() _ = stderrW.Close() @@ -166,7 +174,9 @@ func TestInit_CallerShouldPointToCallsite(t *testing.T) { } L().Info("caller-check") - Sync() + // Skip Sync() — on Windows, fsync on pipes deadlocks (FlushFileBuffers). + os.Stdout = origStdout + os.Stderr = origStderr _ = stdoutW.Close() logBytes, _ := io.ReadAll(stdoutR) diff --git a/backend/internal/pkg/logger/stdlog_bridge_test.go b/backend/internal/pkg/logger/stdlog_bridge_test.go index 4482a2ecd3..30d25b333f 100644 --- a/backend/internal/pkg/logger/stdlog_bridge_test.go +++ b/backend/internal/pkg/logger/stdlog_bridge_test.go @@ -77,7 +77,7 @@ func TestStdLogBridgeRoutesLevels(t *testing.T) { log.Printf("service started") log.Printf("Warning: queue full") log.Printf("Forward request failed: timeout") - Sync() + // Skip Sync() — on Windows, fsync on pipes deadlocks (FlushFileBuffers). _ = stdoutW.Close() _ = stderrW.Close() @@ -139,7 +139,7 @@ func TestLegacyPrintfRoutesLevels(t *testing.T) { LegacyPrintf("service.test", "request started") LegacyPrintf("service.test", "Warning: queue full") LegacyPrintf("service.test", "forward failed: timeout") - Sync() + // Skip Sync() — on Windows, fsync on pipes deadlocks (FlushFileBuffers). _ = stdoutW.Close() _ = stderrW.Close() diff --git a/backend/internal/pkg/response/response.go b/backend/internal/pkg/response/response.go index 0519c2cc1c..b1d6c2d059 100644 --- a/backend/internal/pkg/response/response.go +++ b/backend/internal/pkg/response/response.go @@ -47,6 +47,15 @@ func Created(c *gin.Context, data any) { }) } +// Accepted 返回异步接受响应 (HTTP 202) +func Accepted(c *gin.Context, data any) { + c.JSON(http.StatusAccepted, Response{ + Code: 0, + Message: "accepted", + Data: data, + }) +} + // Error 返回错误响应 func Error(c *gin.Context, statusCode int, message string) { c.JSON(statusCode, Response{ diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index 99c9cda7c5..44cddb6ab6 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -3,6 +3,28 @@ package usagestats import "time" +const ( + ModelSourceRequested = "requested" + ModelSourceUpstream = "upstream" + ModelSourceMapping = "mapping" +) + +func IsValidModelSource(source string) bool { + switch source { + case ModelSourceRequested, ModelSourceUpstream, ModelSourceMapping: + return true + default: + return false + } +} + +func NormalizeModelSource(source string) string { + if IsValidModelSource(source) { + return source + } + return ModelSourceRequested +} + // DashboardStats 仪表盘统计 type DashboardStats struct { // 用户统计 @@ -90,6 +112,13 @@ type EndpointStat struct { ActualCost float64 `json:"actual_cost"` // 实际扣除 } +// GroupUsageSummary represents today's and cumulative cost for a single group. +type GroupUsageSummary struct { + GroupID int64 `json:"group_id"` + TodayCost float64 `json:"today_cost"` + TotalCost float64 `json:"total_cost"` +} + // GroupStat represents usage statistics for a single group type GroupStat struct { GroupID int64 `json:"group_id"` @@ -129,6 +158,25 @@ type UserSpendingRankingResponse struct { TotalTokens int64 `json:"total_tokens"` } +// UserBreakdownItem represents per-user usage breakdown within a dimension (group, model, endpoint). +type UserBreakdownItem struct { + UserID int64 `json:"user_id"` + Email string `json:"email"` + Requests int64 `json:"requests"` + TotalTokens int64 `json:"total_tokens"` + Cost float64 `json:"cost"` // 标准计费 + ActualCost float64 `json:"actual_cost"` // 实际扣除 +} + +// UserBreakdownDimension specifies the dimension to filter for user breakdown. +type UserBreakdownDimension struct { + GroupID int64 // filter by group_id (>0 to enable) + Model string // filter by model name (non-empty to enable) + ModelType string // "requested", "upstream", or "mapping" + Endpoint string // filter by endpoint value (non-empty to enable) + EndpointType string // "inbound", "upstream", or "path" +} + // APIKeyUsageTrendPoint represents API key usage trend data point type APIKeyUsageTrendPoint struct { Date string `json:"date"` diff --git a/backend/internal/pkg/usagestats/usage_log_types_test.go b/backend/internal/pkg/usagestats/usage_log_types_test.go new file mode 100644 index 0000000000..95cf606913 --- /dev/null +++ b/backend/internal/pkg/usagestats/usage_log_types_test.go @@ -0,0 +1,47 @@ +package usagestats + +import "testing" + +func TestIsValidModelSource(t *testing.T) { + tests := []struct { + name string + source string + want bool + }{ + {name: "requested", source: ModelSourceRequested, want: true}, + {name: "upstream", source: ModelSourceUpstream, want: true}, + {name: "mapping", source: ModelSourceMapping, want: true}, + {name: "invalid", source: "foobar", want: false}, + {name: "empty", source: "", want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := IsValidModelSource(tc.source); got != tc.want { + t.Fatalf("IsValidModelSource(%q)=%v want %v", tc.source, got, tc.want) + } + }) + } +} + +func TestNormalizeModelSource(t *testing.T) { + tests := []struct { + name string + source string + want string + }{ + {name: "requested", source: ModelSourceRequested, want: ModelSourceRequested}, + {name: "upstream", source: ModelSourceUpstream, want: ModelSourceUpstream}, + {name: "mapping", source: ModelSourceMapping, want: ModelSourceMapping}, + {name: "invalid falls back", source: "foobar", want: ModelSourceRequested}, + {name: "empty falls back", source: "", want: ModelSourceRequested}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := NormalizeModelSource(tc.source); got != tc.want { + t.Fatalf("NormalizeModelSource(%q)=%q want %q", tc.source, got, tc.want) + } + }) + } +} diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 20ff7373ed..35b908de0b 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -56,6 +56,7 @@ var schedulerNeutralExtraKeyPrefixes = []string{ "codex_secondary_", "codex_5h_", "codex_7d_", + "passive_usage_", } var schedulerNeutralExtraKeys = map[string]struct{}{ @@ -473,7 +474,9 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati if search != "" { q = q.Where(dbaccount.NameContainsFold(search)) } - if groupID > 0 { + if groupID == service.AccountListGroupUngrouped { + q = q.Where(dbaccount.Not(dbaccount.HasAccountGroups())) + } else if groupID > 0 { q = q.Where(dbaccount.HasAccountGroupsWith(dbaccountgroup.GroupIDEQ(groupID))) } diff --git a/backend/internal/repository/account_repo_integration_test.go b/backend/internal/repository/account_repo_integration_test.go index e697802e3a..d6f0e33762 100644 --- a/backend/internal/repository/account_repo_integration_test.go +++ b/backend/internal/repository/account_repo_integration_test.go @@ -214,6 +214,7 @@ func (s *AccountRepoSuite) TestListWithFilters() { accType string status string search string + groupID int64 wantCount int validate func(accounts []service.Account) }{ @@ -265,6 +266,21 @@ func (s *AccountRepoSuite) TestListWithFilters() { s.Require().Contains(accounts[0].Name, "alpha") }, }, + { + name: "filter_by_ungrouped", + setup: func(client *dbent.Client) { + group := mustCreateGroup(s.T(), client, &service.Group{Name: "g-ungrouped"}) + grouped := mustCreateAccount(s.T(), client, &service.Account{Name: "grouped-account"}) + mustCreateAccount(s.T(), client, &service.Account{Name: "ungrouped-account"}) + mustBindAccountToGroup(s.T(), client, grouped.ID, group.ID, 1) + }, + groupID: service.AccountListGroupUngrouped, + wantCount: 1, + validate: func(accounts []service.Account) { + s.Require().Equal("ungrouped-account", accounts[0].Name) + s.Require().Empty(accounts[0].GroupIDs) + }, + }, } for _, tt := range tests { @@ -277,7 +293,7 @@ func (s *AccountRepoSuite) TestListWithFilters() { tt.setup(client) - accounts, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, tt.platform, tt.accType, tt.status, tt.search, 0) + accounts, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, tt.platform, tt.accType, tt.status, tt.search, tt.groupID) s.Require().NoError(err) s.Require().Len(accounts, tt.wantCount) if tt.validate != nil { diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index a45a83a37d..36f4a05eef 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -410,6 +410,16 @@ func (r *apiKeyRepository) ClearGroupIDByGroupID(ctx context.Context, groupID in return int64(n), err } +// UpdateGroupIDByUserAndGroup 将用户下绑定 oldGroupID 的所有 Key 迁移到 newGroupID +func (r *apiKeyRepository) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) { + client := clientFromContext(ctx, r.client) + n, err := client.APIKey.Update(). + Where(apikey.UserIDEQ(userID), apikey.GroupIDEQ(oldGroupID), apikey.DeletedAtIsNil()). + SetGroupID(newGroupID). + Save(ctx) + return int64(n), err +} + // CountByGroupID 获取分组的 API Key 数量 func (r *apiKeyRepository) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { count, err := r.activeQuery().Where(apikey.GroupIDEQ(groupID)).Count(ctx) diff --git a/backend/internal/repository/backup_s3_store.go b/backend/internal/repository/backup_s3_store.go index ba5434f591..5d419f574b 100644 --- a/backend/internal/repository/backup_s3_store.go +++ b/backend/internal/repository/backup_s3_store.go @@ -57,6 +57,7 @@ func NewS3BackupStoreFactory() service.BackupObjectStoreFactory { func (s *S3BackupStore) Upload(ctx context.Context, key string, body io.Reader, contentType string) (int64, error) { // 读取全部内容以获取大小(S3 PutObject 需要知道内容长度) + // 注意:阿里云 OSS 不兼容 s3manager 分片上传的签名方式,因此使用 PutObject data, err := io.ReadAll(body) if err != nil { return 0, fmt.Errorf("read body: %w", err) diff --git a/backend/internal/repository/gateway_cache.go b/backend/internal/repository/gateway_cache.go index ec4bf40e3e..e40baffdc0 100644 --- a/backend/internal/repository/gateway_cache.go +++ b/backend/internal/repository/gateway_cache.go @@ -5,6 +5,7 @@ import ( _ "embed" "fmt" "strconv" + "strings" "time" "github.com/Wei-Shaw/sub2api/internal/service" @@ -12,9 +13,9 @@ import ( ) const ( - stickySessionPrefix = "sticky_session:" - clientAffinityPrefix = "client_affinity:" - clientAffinityReversePrefix = "client_affinity_rev:" + stickySessionPrefix = "sticky_session:" + affinityKeyPrefix = "affinity:" + affinityRevKeyPrefix = "affinity_rev:" ) var ( @@ -30,6 +31,8 @@ var ( getAffinityClientsWithScoresLua string //go:embed lua/clear_account_affinity.lua clearAccountAffinityLua string + //go:embed lua/get_affinity_multi_count.lua + getAffinityMultiCountLua string getAffinityScript = redis.NewScript(getAffinityLua) updateAffinityScript = redis.NewScript(updateAffinityLua) @@ -37,6 +40,7 @@ var ( getAffinityClientsScript = redis.NewScript(getAffinityClientsLua) getAffinityClientsWithScoresScript = redis.NewScript(getAffinityClientsWithScoresLua) clearAccountAffinityScript = redis.NewScript(clearAccountAffinityLua) + getAffinityMultiCountScript = redis.NewScript(getAffinityMultiCountLua) ) type gatewayCache struct { @@ -84,20 +88,39 @@ func (c *gatewayCache) DeleteSessionAccountID(ctx context.Context, groupID int64 return c.rdb.Del(ctx, key).Err() } -// buildAffinityKey 构建正向亲和 key(client → accounts) -// 格式: client_affinity:{groupID}:{clientID} -func buildAffinityKey(groupID int64, clientID string) string { - return fmt.Sprintf("%s%d:%s", clientAffinityPrefix, groupID, clientID) +// buildAffinityKey 构建正向亲和 key(member → accounts) +// 格式: affinity:{groupID}:{userID}/{clientID} +func buildAffinityKey(groupID int64, userID int64, clientID string) string { + return fmt.Sprintf("%s%d:%s", affinityKeyPrefix, groupID, buildAffinityMember(userID, clientID)) } -// buildAffinityReverseKey 构建反向亲和 key(account → clients) -// 格式: client_affinity_rev:{groupID}:{accountID} +// buildAffinityReverseKey 构建反向亲和 key(account → members) +// 格式: affinity_rev:{groupID}:{accountID} func buildAffinityReverseKey(groupID int64, accountID int64) string { - return fmt.Sprintf("%s%d:%d", clientAffinityReversePrefix, groupID, accountID) + return fmt.Sprintf("%s%d:%d", affinityRevKeyPrefix, groupID, accountID) } -func (c *gatewayCache) GetClientAffinityAccounts(ctx context.Context, groupID int64, clientID string, ttl time.Duration) ([]int64, error) { - key := buildAffinityKey(groupID, clientID) +// buildAffinityMember 构建亲和成员标识 +// 格式: {userID}/{clientID} +func buildAffinityMember(userID int64, clientID string) string { + return fmt.Sprintf("%d/%s", userID, clientID) +} + +// parseAffinityMember 解析亲和成员标识为 userID 和 clientID +func parseAffinityMember(member string) (userID int64, clientID string) { + idx := strings.IndexByte(member, '/') + if idx < 0 { + // 兼容旧格式(纯 clientID,无 userID 前缀) + return 0, member + } + userID, _ = strconv.ParseInt(member[:idx], 10, 64) + clientID = member[idx+1:] + return userID, clientID +} + +// GetAffinityAccounts 获取亲和账号列表(按最近使用降序),同时清理过期成员 +func (c *gatewayCache) GetAffinityAccounts(ctx context.Context, groupID int64, userID int64, clientID string, ttl time.Duration) ([]int64, error) { + key := buildAffinityKey(groupID, userID, clientID) now := time.Now().Unix() expireThreshold := now - int64(ttl.Seconds()) @@ -120,19 +143,21 @@ func (c *gatewayCache) GetClientAffinityAccounts(ctx context.Context, groupID in return accountIDs, nil } -func (c *gatewayCache) UpdateClientAffinity(ctx context.Context, groupID int64, clientID string, accountID int64, ttl time.Duration) error { - fwdKey := buildAffinityKey(groupID, clientID) +// UpdateAffinity 添加/更新亲和关系(更新 score 为当前时间戳,刷新 key TTL) +func (c *gatewayCache) UpdateAffinity(ctx context.Context, groupID int64, userID int64, clientID string, accountID int64, ttl time.Duration) error { + fwdKey := buildAffinityKey(groupID, userID, clientID) revKey := buildAffinityReverseKey(groupID, accountID) now := time.Now().Unix() ttlSeconds := int64(ttl.Seconds()) expireThreshold := now - ttlSeconds + member := buildAffinityMember(userID, clientID) return updateAffinityScript.Run(ctx, c.rdb, []string{fwdKey, revKey}, - now, ttlSeconds, accountID, expireThreshold, clientID, + now, ttlSeconds, accountID, expireThreshold, member, ).Err() } -// GetAccountAffinityCountBatch 批量获取账号的亲和客户端数量(惰性清理过期成员) +// GetAccountAffinityCountBatch 批量获取账号的亲和成员数量(惰性清理过期成员) func (c *gatewayCache) GetAccountAffinityCountBatch(ctx context.Context, groupID int64, accountIDs []int64, ttl time.Duration) (map[int64]int64, error) { if len(accountIDs) == 0 { return map[int64]int64{}, nil @@ -162,8 +187,9 @@ func (c *gatewayCache) GetAccountAffinityCountBatch(ctx context.Context, groupID return result, nil } -// GetAccountAffinityClientsBatch 批量获取每个账号跨所有分组的亲和客户端列表(去重)。 +// GetAccountAffinityClientsBatch 批量获取每个账号跨所有分组的亲和成员列表(去重)。 // accountGroups: map[accountID][]groupID,对每个 (groupID, accountID) 组合查询反向索引。 +// 返回值成员格式为 {userID}/{clientID}。 func (c *gatewayCache) GetAccountAffinityClientsBatch(ctx context.Context, accountGroups map[int64][]int64, ttl time.Duration) (map[int64][]string, error) { if len(accountGroups) == 0 { return map[int64][]string{}, nil @@ -197,21 +223,21 @@ func (c *gatewayCache) GetAccountAffinityClientsBatch(ctx context.Context, accou return nil, err } - // 合并结果:同一个 accountID 跨多个 group 的 clientID 去重 + // 合并结果:同一个 accountID 跨多个 group 的成员去重 result := make(map[int64][]string, len(accountGroups)) seen := make(map[int64]map[string]struct{}, len(accountGroups)) for i, q := range queries { - clients, _ := cmds[i].StringSlice() - if len(clients) == 0 { + members, _ := cmds[i].StringSlice() + if len(members) == 0 { continue } if seen[q.accountID] == nil { seen[q.accountID] = make(map[string]struct{}) } - for _, clientID := range clients { - if _, exists := seen[q.accountID][clientID]; !exists { - seen[q.accountID][clientID] = struct{}{} - result[q.accountID] = append(result[q.accountID], clientID) + for _, member := range members { + if _, exists := seen[q.accountID][member]; !exists { + seen[q.accountID][member] = struct{}{} + result[q.accountID] = append(result[q.accountID], member) } } } @@ -245,25 +271,32 @@ func (c *gatewayCache) GetAccountAffinityClientsWithScores( return nil, err } - // 合并跨组结果,同一 clientID 取最近的 lastActive - seen := make(map[string]int64) // clientID → max timestamp + // 合并跨组结果,同一 member 取最近的 lastActive + type memberInfo struct { + userID int64 + clientID string + ts int64 + } + seen := make(map[string]*memberInfo) // member string → info for _, cmd := range cmds { vals, _ := cmd.StringSlice() - // vals 格式: [clientID1, score1, clientID2, score2, ...] + // vals 格式: [member1, score1, member2, score2, ...] for j := 0; j+1 < len(vals); j += 2 { - clientID := vals[j] + member := vals[j] ts, _ := strconv.ParseInt(vals[j+1], 10, 64) - if existing, ok := seen[clientID]; !ok || ts > existing { - seen[clientID] = ts + if existing, ok := seen[member]; !ok || ts > existing.ts { + uid, cid := parseAffinityMember(member) + seen[member] = &memberInfo{userID: uid, clientID: cid, ts: ts} } } } result := make([]service.AffinityClient, 0, len(seen)) - for clientID, ts := range seen { + for _, info := range seen { result = append(result, service.AffinityClient{ - ClientID: clientID, - LastActive: time.Unix(ts, 0), + UserID: info.userID, + ClientID: info.clientID, + LastActive: time.Unix(info.ts, 0), }) } @@ -274,8 +307,8 @@ func (c *gatewayCache) GetAccountAffinityClientsWithScores( } // ClearAccountAffinity 清除指定账号在所有分组的亲和记录(正向+反向索引)。 -// 对每个 groupID 执行 Lua 脚本:读取反向索引获取所有客户端, -// 从每个客户端的正向索引中移除该账号,然后删除反向索引。 +// 对每个 groupID 执行 Lua 脚本:读取反向索引获取所有成员, +// 从每个成员的正向索引中移除该账号,然后删除反向索引。 func (c *gatewayCache) ClearAccountAffinity(ctx context.Context, accountID int64, groupIDs []int64) error { if len(groupIDs) == 0 { return nil @@ -294,3 +327,37 @@ func (c *gatewayCache) ClearAccountAffinity(ctx context.Context, accountID int64 } return nil } + +// GetAffinityMultiCount 获取账号的多维度亲和计数。 +// 返回: uniqueUsers(独立用户数), uniqueClients(独立客户端数), perUserClients(目标用户的客户端数) +func (c *gatewayCache) GetAffinityMultiCount( + ctx context.Context, + groupID int64, + accountID int64, + targetUserID int64, + ttl time.Duration, +) (users, clients, perUser int64, err error) { + key := buildAffinityReverseKey(groupID, accountID) + now := time.Now().Unix() + expireThreshold := now - int64(ttl.Seconds()) + + targetUserStr := "" + if targetUserID > 0 { + targetUserStr = strconv.FormatInt(targetUserID, 10) + } + + result, err := getAffinityMultiCountScript.Run(ctx, c.rdb, []string{key}, expireThreshold, targetUserStr).Int64Slice() + if err != nil { + if err == redis.Nil { + return 0, 0, 0, nil + } + return 0, 0, 0, err + } + + if len(result) < 4 { + return 0, 0, 0, nil + } + + // result: {totalMembers, uniqueUsers, uniqueClients, perUserClients} + return result[1], result[2], result[3], nil +} diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 27d683544d..8d24bc6f14 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -89,8 +89,9 @@ func (r *groupRepository) GetByID(ctx context.Context, id int64) (*service.Group if err != nil { return nil, err } - count, _ := r.GetAccountCount(ctx, out.ID) - out.AccountCount = count + total, active, _ := r.GetAccountCount(ctx, out.ID) + out.AccountCount = total + out.ActiveAccountCount = active return out, nil } @@ -258,7 +259,10 @@ func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination counts, err := r.loadAccountCounts(ctx, groupIDs) if err == nil { for i := range outGroups { - outGroups[i].AccountCount = counts[outGroups[i].ID] + c := counts[outGroups[i].ID] + outGroups[i].AccountCount = c.Total + outGroups[i].ActiveAccountCount = c.Active + outGroups[i].RateLimitedAccountCount = c.RateLimited } } @@ -285,7 +289,10 @@ func (r *groupRepository) ListActive(ctx context.Context) ([]service.Group, erro counts, err := r.loadAccountCounts(ctx, groupIDs) if err == nil { for i := range outGroups { - outGroups[i].AccountCount = counts[outGroups[i].ID] + c := counts[outGroups[i].ID] + outGroups[i].AccountCount = c.Total + outGroups[i].ActiveAccountCount = c.Active + outGroups[i].RateLimitedAccountCount = c.RateLimited } } @@ -312,7 +319,10 @@ func (r *groupRepository) ListActiveByPlatform(ctx context.Context, platform str counts, err := r.loadAccountCounts(ctx, groupIDs) if err == nil { for i := range outGroups { - outGroups[i].AccountCount = counts[outGroups[i].ID] + c := counts[outGroups[i].ID] + outGroups[i].AccountCount = c.Total + outGroups[i].ActiveAccountCount = c.Active + outGroups[i].RateLimitedAccountCount = c.RateLimited } } @@ -371,12 +381,20 @@ func (r *groupRepository) ExistsByIDs(ctx context.Context, ids []int64) (map[int return result, nil } -func (r *groupRepository) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { - var count int64 - if err := scanSingleRow(ctx, r.sql, "SELECT COUNT(*) FROM account_groups WHERE group_id = $1", []any{groupID}, &count); err != nil { - return 0, err - } - return count, nil +func (r *groupRepository) GetAccountCount(ctx context.Context, groupID int64) (total int64, active int64, err error) { + var rateLimited int64 + err = scanSingleRow(ctx, r.sql, + `SELECT COUNT(*), + COUNT(*) FILTER (WHERE a.status = 'active' AND a.schedulable = true), + COUNT(*) FILTER (WHERE a.status = 'active' AND ( + a.rate_limit_reset_at > NOW() OR + a.overload_until > NOW() OR + a.temp_unschedulable_until > NOW() + )) + FROM account_groups ag JOIN accounts a ON a.id = ag.account_id + WHERE ag.group_id = $1`, + []any{groupID}, &total, &active, &rateLimited) + return } func (r *groupRepository) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) { @@ -502,15 +520,32 @@ func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64, return affectedUserIDs, nil } -func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int64) (counts map[int64]int64, err error) { - counts = make(map[int64]int64, len(groupIDs)) +type groupAccountCounts struct { + Total int64 + Active int64 + RateLimited int64 +} + +func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int64) (counts map[int64]groupAccountCounts, err error) { + counts = make(map[int64]groupAccountCounts, len(groupIDs)) if len(groupIDs) == 0 { return counts, nil } rows, err := r.sql.QueryContext( ctx, - "SELECT group_id, COUNT(*) FROM account_groups WHERE group_id = ANY($1) GROUP BY group_id", + `SELECT ag.group_id, + COUNT(*) AS total, + COUNT(*) FILTER (WHERE a.status = 'active' AND a.schedulable = true) AS active, + COUNT(*) FILTER (WHERE a.status = 'active' AND ( + a.rate_limit_reset_at > NOW() OR + a.overload_until > NOW() OR + a.temp_unschedulable_until > NOW() + )) AS rate_limited + FROM account_groups ag + JOIN accounts a ON a.id = ag.account_id + WHERE ag.group_id = ANY($1) + GROUP BY ag.group_id`, pq.Array(groupIDs), ) if err != nil { @@ -525,11 +560,11 @@ func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int6 for rows.Next() { var groupID int64 - var count int64 - if err = rows.Scan(&groupID, &count); err != nil { + var c groupAccountCounts + if err = rows.Scan(&groupID, &c.Total, &c.Active, &c.RateLimited); err != nil { return nil, err } - counts[groupID] = count + counts[groupID] = c } if err = rows.Err(); err != nil { return nil, err diff --git a/backend/internal/repository/group_repo_integration_test.go b/backend/internal/repository/group_repo_integration_test.go index 4a849a4602..eccf5ceacb 100644 --- a/backend/internal/repository/group_repo_integration_test.go +++ b/backend/internal/repository/group_repo_integration_test.go @@ -603,7 +603,7 @@ func (s *GroupRepoSuite) TestGetAccountCount() { _, err = s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", a2, group.ID, 2) s.Require().NoError(err) - count, err := s.repo.GetAccountCount(s.ctx, group.ID) + count, _, err := s.repo.GetAccountCount(s.ctx, group.ID) s.Require().NoError(err, "GetAccountCount") s.Require().Equal(int64(2), count) } @@ -619,7 +619,7 @@ func (s *GroupRepoSuite) TestGetAccountCount_Empty() { } s.Require().NoError(s.repo.Create(s.ctx, group)) - count, err := s.repo.GetAccountCount(s.ctx, group.ID) + count, _, err := s.repo.GetAccountCount(s.ctx, group.ID) s.Require().NoError(err) s.Require().Zero(count) } @@ -651,7 +651,7 @@ func (s *GroupRepoSuite) TestDeleteAccountGroupsByGroupID() { s.Require().NoError(err, "DeleteAccountGroupsByGroupID") s.Require().Equal(int64(1), affected, "expected 1 affected row") - count, err := s.repo.GetAccountCount(s.ctx, g.ID) + count, _, err := s.repo.GetAccountCount(s.ctx, g.ID) s.Require().NoError(err, "GetAccountCount") s.Require().Equal(int64(0), count, "expected 0 account groups") } @@ -692,7 +692,7 @@ func (s *GroupRepoSuite) TestDeleteAccountGroupsByGroupID_MultipleAccounts() { s.Require().NoError(err) s.Require().Equal(int64(3), affected) - count, _ := s.repo.GetAccountCount(s.ctx, g.ID) + count, _, _ := s.repo.GetAccountCount(s.ctx, g.ID) s.Require().Zero(count) } diff --git a/backend/internal/repository/lua/clear_account_affinity.lua b/backend/internal/repository/lua/clear_account_affinity.lua index e125be1690..d2f0af0338 100644 --- a/backend/internal/repository/lua/clear_account_affinity.lua +++ b/backend/internal/repository/lua/clear_account_affinity.lua @@ -1,21 +1,21 @@ -- 清除单个账号在指定分组的所有亲和记录(正向+反向) --- KEYS[1] = client_affinity_rev:{groupID}:{accountID} (反向索引) +-- KEYS[1] = affinity_rev:{groupID}:{accountID} (反向索引) -- ARGV[1] = groupID (用于构建正向 key) -- ARGV[2] = accountID (正向索引中要移除的成员) --- 返回: 清理的客户端数量 +-- 返回: 清理的成员数量 local rev_key = KEYS[1] local group_id = ARGV[1] local account_id = ARGV[2] --- 获取反向索引中所有客户端 ID -local clients = redis.call('ZRANGE', rev_key, 0, -1) -if #clients == 0 then +-- 获取反向索引中所有成员 ({userID}/{clientID}) +local members = redis.call('ZRANGE', rev_key, 0, -1) +if #members == 0 then return 0 end --- 从每个客户端的正向索引中移除该账号 -for _, client_id in ipairs(clients) do - local fwd_key = 'client_affinity:' .. group_id .. ':' .. client_id +-- 从每个成员的正向索引中移除该账号 +for _, member in ipairs(members) do + local fwd_key = 'affinity:' .. group_id .. ':' .. member redis.call('ZREM', fwd_key, account_id) -- 如果正向索引为空,删除 key if redis.call('ZCARD', fwd_key) == 0 then @@ -26,4 +26,4 @@ end -- 删除反向索引 redis.call('DEL', rev_key) -return #clients +return #members diff --git a/backend/internal/repository/lua/get_affinity.lua b/backend/internal/repository/lua/get_affinity.lua index 7e3971dbea..9db85be2a9 100644 --- a/backend/internal/repository/lua/get_affinity.lua +++ b/backend/internal/repository/lua/get_affinity.lua @@ -1,5 +1,5 @@ -- 清理过期成员后返回亲和账号列表(按最近使用降序) --- KEYS[1] = client_affinity:{groupID}:{clientID} +-- KEYS[1] = affinity:{groupID}:{userID}/{clientID} -- ARGV[1] = 过期阈值时间戳 (now - ttl) redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]) return redis.call('ZREVRANGE', KEYS[1], 0, -1) diff --git a/backend/internal/repository/lua/get_affinity_clients.lua b/backend/internal/repository/lua/get_affinity_clients.lua index 049b1c5b7c..7290b63758 100644 --- a/backend/internal/repository/lua/get_affinity_clients.lua +++ b/backend/internal/repository/lua/get_affinity_clients.lua @@ -1,5 +1,6 @@ --- 清理过期成员后返回反向索引的 clientID 列表(按最近使用降序) --- KEYS[1] = client_affinity_rev:{groupID}:{accountID} +-- 清理过期成员后返回反向索引的成员列表(按最近使用降序) +-- 成员格式: {userID}/{clientID} +-- KEYS[1] = affinity_rev:{groupID}:{accountID} -- ARGV[1] = 过期阈值时间戳 (now - ttl) redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]) return redis.call('ZREVRANGE', KEYS[1], 0, -1) diff --git a/backend/internal/repository/lua/get_affinity_clients_with_scores.lua b/backend/internal/repository/lua/get_affinity_clients_with_scores.lua index 8e70708efb..bff6b616ed 100644 --- a/backend/internal/repository/lua/get_affinity_clients_with_scores.lua +++ b/backend/internal/repository/lua/get_affinity_clients_with_scores.lua @@ -1,6 +1,7 @@ --- 清理过期成员后返回反向索引的 clientID 列表及其 score(最后活跃时间戳) --- KEYS[1] = client_affinity_rev:{groupID}:{accountID} +-- 清理过期成员后返回反向索引的成员列表及其 score(最后活跃时间戳) +-- 成员格式: {userID}/{clientID} +-- KEYS[1] = affinity_rev:{groupID}:{accountID} -- ARGV[1] = 过期阈值时间戳 (now - ttl) --- 返回: {clientID1, score1, clientID2, score2, ...}(按最近使用降序) +-- 返回: {member1, score1, member2, score2, ...}(按最近使用降序) redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]) return redis.call('ZREVRANGEBYSCORE', KEYS[1], '+inf', '-inf', 'WITHSCORES') diff --git a/backend/internal/repository/lua/get_affinity_count.lua b/backend/internal/repository/lua/get_affinity_count.lua index 4b00c22daa..7cf9b5cb51 100644 --- a/backend/internal/repository/lua/get_affinity_count.lua +++ b/backend/internal/repository/lua/get_affinity_count.lua @@ -1,5 +1,5 @@ -- 清理过期成员后返回反向索引的成员数量 --- KEYS[1] = client_affinity_rev:{groupID}:{accountID} +-- KEYS[1] = affinity_rev:{groupID}:{accountID} -- ARGV[1] = 过期阈值时间戳 (now - ttl) redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]) return redis.call('ZCARD', KEYS[1]) diff --git a/backend/internal/repository/lua/get_affinity_multi_count.lua b/backend/internal/repository/lua/get_affinity_multi_count.lua new file mode 100644 index 0000000000..166cf4f9a3 --- /dev/null +++ b/backend/internal/repository/lua/get_affinity_multi_count.lua @@ -0,0 +1,40 @@ +-- 从反向索引解析多维度计数(用户/客户端/每用户客户端) +-- KEYS[1] = affinity_rev:{groupID}:{accountID} +-- ARGV[1] = 过期阈值时间戳 (now - ttl) +-- ARGV[2] = 目标 userID(传 "" 则不计算 perUserClients) +-- 返回: {totalMembers, uniqueUsers, uniqueClients, perUserClients} +redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]) + +local members = redis.call('ZRANGE', KEYS[1], 0, -1) +local total = #members +if total == 0 then + return {0, 0, 0, 0} +end + +local target_user = ARGV[2] +local users = {} +local clients = {} +local user_count = 0 +local client_count = 0 +local per_user_count = 0 + +for _, member in ipairs(members) do + local sep = string.find(member, '/', 1, true) + if sep then + local uid = string.sub(member, 1, sep - 1) + local cid = string.sub(member, sep + 1) + if not users[uid] then + users[uid] = true + user_count = user_count + 1 + end + if not clients[cid] then + clients[cid] = true + client_count = client_count + 1 + end + if target_user ~= '' and uid == target_user then + per_user_count = per_user_count + 1 + end + end +end + +return {total, user_count, client_count, per_user_count} diff --git a/backend/internal/repository/lua/update_affinity.lua b/backend/internal/repository/lua/update_affinity.lua index 27fe8cb217..6e9b38fcb8 100644 --- a/backend/internal/repository/lua/update_affinity.lua +++ b/backend/internal/repository/lua/update_affinity.lua @@ -1,11 +1,11 @@ -- 原子双写正向+反向索引 --- KEYS[1] = client_affinity:{groupID}:{clientID} (正向: client → accounts) --- KEYS[2] = client_affinity_rev:{groupID}:{accountID} (反向: account → clients) +-- KEYS[1] = affinity:{groupID}:{userID}/{clientID} (正向: member → accounts) +-- KEYS[2] = affinity_rev:{groupID}:{accountID} (反向: account → members) -- ARGV[1] = 当前时间戳 (score) -- ARGV[2] = TTL 秒数 -- ARGV[3] = accountID (正向索引的成员) -- ARGV[4] = 过期阈值时间戳 (now - ttl) --- ARGV[5] = clientID (反向索引的成员) +-- ARGV[5] = {userID}/{clientID} (反向索引的成员) redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[4]) redis.call('ZADD', KEYS[1], ARGV[1], ARGV[3]) redis.call('EXPIRE', KEYS[1], ARGV[2]) diff --git a/backend/internal/repository/proxy_probe_service.go b/backend/internal/repository/proxy_probe_service.go index b4aeab7185..d877abde59 100644 --- a/backend/internal/repository/proxy_probe_service.go +++ b/backend/internal/repository/proxy_probe_service.go @@ -40,7 +40,7 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber { } const ( - defaultProxyProbeTimeout = 30 * time.Second + defaultProxyProbeTimeout = 10 * time.Second defaultProxyProbeResponseMaxBytes = int64(1024 * 1024) ) diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 002281ec21..94875eb577 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -28,7 +28,7 @@ import ( gocache "github.com/patrickmn/go-cache" ) -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, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, media_type, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, created_at" +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, upstream_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, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, media_type, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, created_at" var usageLogInsertArgTypes = [...]string{ "bigint", @@ -36,6 +36,7 @@ var usageLogInsertArgTypes = [...]string{ "bigint", "text", "text", + "text", "bigint", "bigint", "integer", @@ -277,6 +278,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, account_id, request_id, model, + upstream_model, group_id, subscription_id, input_tokens, @@ -311,12 +313,12 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, cache_ttl_overridden, created_at ) VALUES ( - $1, $2, $3, $4, $5, - $6, $7, - $8, $9, $10, $11, - $12, $13, - $14, $15, $16, $17, $18, $19, - $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38 + $1, $2, $3, $4, $5, $6, + $7, $8, + $9, $10, $11, $12, + $13, $14, + $15, $16, $17, $18, $19, $20, + $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -707,6 +709,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage account_id, request_id, model, + upstream_model, group_id, subscription_id, input_tokens, @@ -742,7 +745,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage created_at ) AS (VALUES `) - args := make([]any, 0, len(keys)*38) + args := make([]any, 0, len(keys)*39) argPos := 1 for idx, key := range keys { if idx > 0 { @@ -776,6 +779,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage account_id, request_id, model, + upstream_model, group_id, subscription_id, input_tokens, @@ -816,6 +820,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage account_id, request_id, model, + upstream_model, group_id, subscription_id, input_tokens, @@ -896,6 +901,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( account_id, request_id, model, + upstream_model, group_id, subscription_id, input_tokens, @@ -931,7 +937,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( created_at ) AS (VALUES `) - args := make([]any, 0, len(preparedList)*38) + args := make([]any, 0, len(preparedList)*39) argPos := 1 for idx, prepared := range preparedList { if idx > 0 { @@ -962,6 +968,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( account_id, request_id, model, + upstream_model, group_id, subscription_id, input_tokens, @@ -1002,6 +1009,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( account_id, request_id, model, + upstream_model, group_id, subscription_id, input_tokens, @@ -1050,6 +1058,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared account_id, request_id, model, + upstream_model, group_id, subscription_id, input_tokens, @@ -1084,12 +1093,12 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared cache_ttl_overridden, created_at ) VALUES ( - $1, $2, $3, $4, $5, - $6, $7, - $8, $9, $10, $11, - $12, $13, - $14, $15, $16, $17, $18, $19, - $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38 + $1, $2, $3, $4, $5, $6, + $7, $8, + $9, $10, $11, $12, + $13, $14, + $15, $16, $17, $18, $19, $20, + $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39 ) ON CONFLICT (request_id, api_key_id) DO NOTHING `, prepared.args...) @@ -1121,6 +1130,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { reasoningEffort := nullString(log.ReasoningEffort) inboundEndpoint := nullString(log.InboundEndpoint) upstreamEndpoint := nullString(log.UpstreamEndpoint) + upstreamModel := nullString(log.UpstreamModel) var requestIDArg any if requestID != "" { @@ -1138,6 +1148,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { log.AccountID, requestIDArg, log.Model, + upstreamModel, groupID, subscriptionID, log.InputTokens, @@ -2864,15 +2875,26 @@ func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, st // GetModelStatsWithFilters returns model statistics with optional filters func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []ModelStat, err error) { + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, usagestats.ModelSourceRequested) +} + +// GetModelStatsWithFiltersBySource returns model statistics with optional filters and model source dimension. +// source: requested | upstream | mapping. +func (r *usageLogRepository) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) (results []ModelStat, err error) { + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, source) +} + +func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) (results []ModelStat, err error) { actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost" // 当仅按 account_id 聚合时,实际费用使用账号倍率(total_cost * account_rate_multiplier)。 if accountID > 0 && userID == 0 && apiKeyID == 0 { actualCostExpr = "COALESCE(SUM(total_cost * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost" } + modelExpr := resolveModelDimensionExpression(source) query := fmt.Sprintf(` SELECT - model, + %s as model, COUNT(*) as requests, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens, @@ -2883,7 +2905,7 @@ func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, start %s FROM usage_logs WHERE created_at >= $1 AND created_at < $2 - `, actualCostExpr) + `, modelExpr, actualCostExpr) args := []any{startTime, endTime} if userID > 0 { @@ -2907,7 +2929,7 @@ func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, start query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1) args = append(args, int16(*billingType)) } - query += " GROUP BY model ORDER BY total_tokens DESC" + query += fmt.Sprintf(" GROUP BY %s ORDER BY total_tokens DESC", modelExpr) rows, err := r.sql.QueryContext(ctx, query, args...) if err != nil { @@ -3000,6 +3022,132 @@ func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, start return results, nil } +// GetUserBreakdownStats returns per-user usage breakdown within a specific dimension. +func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) (results []usagestats.UserBreakdownItem, err error) { + query := ` + SELECT + COALESCE(ul.user_id, 0) as user_id, + COALESCE(u.email, '') as email, + COUNT(*) as requests, + COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens, + COALESCE(SUM(ul.total_cost), 0) as cost, + COALESCE(SUM(ul.actual_cost), 0) as actual_cost + FROM usage_logs ul + LEFT JOIN users u ON u.id = ul.user_id + WHERE ul.created_at >= $1 AND ul.created_at < $2 + ` + args := []any{startTime, endTime} + + if dim.GroupID > 0 { + query += fmt.Sprintf(" AND ul.group_id = $%d", len(args)+1) + args = append(args, dim.GroupID) + } + if dim.Model != "" { + query += fmt.Sprintf(" AND %s = $%d", resolveModelDimensionExpression(dim.ModelType), len(args)+1) + args = append(args, dim.Model) + } + if dim.Endpoint != "" { + col := resolveEndpointColumn(dim.EndpointType) + query += fmt.Sprintf(" AND %s = $%d", col, len(args)+1) + args = append(args, dim.Endpoint) + } + + query += " GROUP BY ul.user_id, u.email ORDER BY actual_cost DESC" + if limit > 0 { + query += fmt.Sprintf(" LIMIT %d", limit) + } + + rows, err := r.sql.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + results = nil + } + }() + + results = make([]usagestats.UserBreakdownItem, 0) + for rows.Next() { + var row usagestats.UserBreakdownItem + if err := rows.Scan( + &row.UserID, + &row.Email, + &row.Requests, + &row.TotalTokens, + &row.Cost, + &row.ActualCost, + ); err != nil { + return nil, err + } + results = append(results, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + +// GetAllGroupUsageSummary returns today's and cumulative actual_cost for every group. +// todayStart is the start-of-day in the caller's timezone (UTC-based). +// TODO(perf): This query scans ALL usage_logs rows for total_cost aggregation. +// When usage_logs exceeds ~1M rows, consider adding a short-lived cache (30s) +// or a materialized view / pre-aggregation table for cumulative costs. +func (r *usageLogRepository) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) { + query := ` + SELECT + g.id AS group_id, + COALESCE(SUM(ul.actual_cost), 0) AS total_cost, + COALESCE(SUM(CASE WHEN ul.created_at >= $1 THEN ul.actual_cost ELSE 0 END), 0) AS today_cost + FROM groups g + LEFT JOIN usage_logs ul ON ul.group_id = g.id + GROUP BY g.id + ` + + rows, err := r.sql.QueryContext(ctx, query, todayStart) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var results []usagestats.GroupUsageSummary + for rows.Next() { + var row usagestats.GroupUsageSummary + if err := rows.Scan(&row.GroupID, &row.TotalCost, &row.TodayCost); err != nil { + return nil, err + } + results = append(results, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + +// resolveModelDimensionExpression maps model source type to a safe SQL expression. +func resolveModelDimensionExpression(modelType string) string { + switch usagestats.NormalizeModelSource(modelType) { + case usagestats.ModelSourceUpstream: + return "COALESCE(NULLIF(TRIM(upstream_model), ''), model)" + case usagestats.ModelSourceMapping: + return "(model || ' -> ' || COALESCE(NULLIF(TRIM(upstream_model), ''), model))" + default: + return "model" + } +} + +// resolveEndpointColumn maps endpoint type to the corresponding DB column name. +func resolveEndpointColumn(endpointType string) string { + switch endpointType { + case "upstream": + return "ul.upstream_endpoint" + case "path": + return "ul.inbound_endpoint || ' -> ' || ul.upstream_endpoint" + default: + return "ul.inbound_endpoint" + } +} + // GetGlobalStats gets usage statistics for all users within a time range func (r *usageLogRepository) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*UsageStats, error) { query := ` @@ -3740,6 +3888,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e accountID int64 requestID sql.NullString model string + upstreamModel sql.NullString groupID sql.NullInt64 subscriptionID sql.NullInt64 inputTokens int @@ -3782,6 +3931,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &accountID, &requestID, &model, + &upstreamModel, &groupID, &subscriptionID, &inputTokens, @@ -3894,6 +4044,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e if upstreamEndpoint.Valid { log.UpstreamEndpoint = &upstreamEndpoint.String } + if upstreamModel.Valid { + log.UpstreamModel = &upstreamModel.String + } return log, nil } diff --git a/backend/internal/repository/usage_log_repo_breakdown_test.go b/backend/internal/repository/usage_log_repo_breakdown_test.go new file mode 100644 index 0000000000..5d908bfdf8 --- /dev/null +++ b/backend/internal/repository/usage_log_repo_breakdown_test.go @@ -0,0 +1,50 @@ +//go:build unit + +package repository + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" + "github.com/stretchr/testify/require" +) + +func TestResolveEndpointColumn(t *testing.T) { + tests := []struct { + endpointType string + want string + }{ + {"inbound", "ul.inbound_endpoint"}, + {"upstream", "ul.upstream_endpoint"}, + {"path", "ul.inbound_endpoint || ' -> ' || ul.upstream_endpoint"}, + {"", "ul.inbound_endpoint"}, // default + {"unknown", "ul.inbound_endpoint"}, // fallback + } + + for _, tc := range tests { + t.Run(tc.endpointType, func(t *testing.T) { + got := resolveEndpointColumn(tc.endpointType) + require.Equal(t, tc.want, got) + }) + } +} + +func TestResolveModelDimensionExpression(t *testing.T) { + tests := []struct { + modelType string + want string + }{ + {usagestats.ModelSourceRequested, "model"}, + {usagestats.ModelSourceUpstream, "COALESCE(NULLIF(TRIM(upstream_model), ''), model)"}, + {usagestats.ModelSourceMapping, "(model || ' -> ' || COALESCE(NULLIF(TRIM(upstream_model), ''), model))"}, + {"", "model"}, + {"invalid", "model"}, + } + + for _, tc := range tests { + t.Run(tc.modelType, func(t *testing.T) { + got := resolveModelDimensionExpression(tc.modelType) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index 27ae457181..76827c31ff 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -44,6 +44,7 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) { log.AccountID, log.RequestID, log.Model, + sqlmock.AnyArg(), // upstream_model sqlmock.AnyArg(), // group_id sqlmock.AnyArg(), // subscription_id log.InputTokens, @@ -116,6 +117,7 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) { log.Model, sqlmock.AnyArg(), sqlmock.AnyArg(), + sqlmock.AnyArg(), log.InputTokens, log.OutputTokens, log.CacheCreationTokens, @@ -353,6 +355,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { int64(30), // account_id sql.NullString{Valid: true, String: "req-1"}, "gpt-5", // model + sql.NullString{}, // upstream_model sql.NullInt64{}, // group_id sql.NullInt64{}, // subscription_id 1, // input_tokens @@ -404,6 +407,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { int64(31), sql.NullString{Valid: true, String: "req-2"}, "gpt-5", + sql.NullString{}, sql.NullInt64{}, sql.NullInt64{}, 1, 2, 3, 4, 5, 6, @@ -445,6 +449,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { int64(32), sql.NullString{Valid: true, String: "req-3"}, "gpt-5.4", + sql.NullString{}, sql.NullInt64{}, sql.NullInt64{}, 1, 2, 3, 4, 5, 6, diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index b56aaaf9d6..575754e03b 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -11,6 +11,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/apikey" + dbgroup "github.com/Wei-Shaw/sub2api/ent/group" dbuser "github.com/Wei-Shaw/sub2api/ent/user" "github.com/Wei-Shaw/sub2api/ent/userallowedgroup" "github.com/Wei-Shaw/sub2api/ent/usersubscription" @@ -200,6 +201,12 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination. ) } + if filters.GroupName != "" { + q = q.Where(dbuser.HasAllowedGroupsWith( + dbgroup.NameContainsFold(filters.GroupName), + )) + } + // If attribute filters are specified, we need to filter by user IDs first var allowedUserIDs []int64 if len(filters.Attributes) > 0 { @@ -453,6 +460,15 @@ func (r *userRepository) RemoveGroupFromAllowedGroups(ctx context.Context, group return int64(affected), nil } +// RemoveGroupFromUserAllowedGroups 移除单个用户的指定分组权限 +func (r *userRepository) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error { + client := clientFromContext(ctx, r.client) + _, err := client.UserAllowedGroup.Delete(). + Where(userallowedgroup.UserIDEQ(userID), userallowedgroup.GroupIDEQ(groupID)). + Exec(ctx) + return err +} + func (r *userRepository) GetFirstAdmin(ctx context.Context) (*service.User, error) { m, err := r.client.User.Query(). Where( diff --git a/backend/internal/repository/user_subscription_repo.go b/backend/internal/repository/user_subscription_repo.go index 5a649846c5..e3f64a5f6a 100644 --- a/backend/internal/repository/user_subscription_repo.go +++ b/backend/internal/repository/user_subscription_repo.go @@ -5,6 +5,7 @@ import ( "time" dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/usersubscription" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/service" @@ -190,7 +191,7 @@ func (r *userSubscriptionRepository) ListByGroupID(ctx context.Context, groupID return userSubscriptionEntitiesToService(subs), paginationResultFromTotal(int64(total), params), nil } -func (r *userSubscriptionRepository) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { +func (r *userSubscriptionRepository) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { client := clientFromContext(ctx, r.client) q := client.UserSubscription.Query() if userID != nil { @@ -199,6 +200,9 @@ func (r *userSubscriptionRepository) List(ctx context.Context, params pagination if groupID != nil { q = q.Where(usersubscription.GroupIDEQ(*groupID)) } + if platform != "" { + q = q.Where(usersubscription.HasGroupWith(group.PlatformEQ(platform))) + } // Status filtering with real-time expiration check now := time.Now() diff --git a/backend/internal/repository/user_subscription_repo_integration_test.go b/backend/internal/repository/user_subscription_repo_integration_test.go index 60a5a37884..a74860e318 100644 --- a/backend/internal/repository/user_subscription_repo_integration_test.go +++ b/backend/internal/repository/user_subscription_repo_integration_test.go @@ -271,7 +271,7 @@ func (s *UserSubscriptionRepoSuite) TestList_NoFilters() { group := s.mustCreateGroup("g-list") s.mustCreateSubscription(user.ID, group.ID, nil) - subs, page, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, nil, nil, "", "", "") + subs, page, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, nil, nil, "", "", "", "") s.Require().NoError(err, "List") s.Require().Len(subs, 1) s.Require().Equal(int64(1), page.Total) @@ -285,7 +285,7 @@ func (s *UserSubscriptionRepoSuite) TestList_FilterByUserID() { s.mustCreateSubscription(user1.ID, group.ID, nil) s.mustCreateSubscription(user2.ID, group.ID, nil) - subs, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, &user1.ID, nil, "", "", "") + subs, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, &user1.ID, nil, "", "", "", "") s.Require().NoError(err) s.Require().Len(subs, 1) s.Require().Equal(user1.ID, subs[0].UserID) @@ -299,7 +299,7 @@ func (s *UserSubscriptionRepoSuite) TestList_FilterByGroupID() { s.mustCreateSubscription(user.ID, g1.ID, nil) s.mustCreateSubscription(user.ID, g2.ID, nil) - subs, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, nil, &g1.ID, "", "", "") + subs, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, nil, &g1.ID, "", "", "", "") s.Require().NoError(err) s.Require().Len(subs, 1) s.Require().Equal(g1.ID, subs[0].GroupID) @@ -320,7 +320,7 @@ func (s *UserSubscriptionRepoSuite) TestList_FilterByStatus() { c.SetExpiresAt(time.Now().Add(-24 * time.Hour)) }) - subs, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, nil, nil, service.SubscriptionStatusExpired, "", "") + subs, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, nil, nil, service.SubscriptionStatusExpired, "", "", "") s.Require().NoError(err) s.Require().Len(subs, 1) s.Require().Equal(service.SubscriptionStatusExpired, subs[0].Status) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 24cb6bedc0..55a5ccd5a2 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -536,6 +536,7 @@ func TestAPIContracts(t *testing.T) { "purchase_subscription_enabled": false, "purchase_subscription_url": "", "min_claude_code_version": "", + "max_claude_code_version": "", "allow_ungrouped_key_scheduling": false, "backend_mode_enabled": false, "custom_menu_items": [] @@ -806,6 +807,10 @@ func (r *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID return 0, errors.New("not implemented") } +func (r *stubUserRepo) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error { + return errors.New("not implemented") +} + func (r *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error { return errors.New("not implemented") } @@ -923,8 +928,8 @@ func (stubGroupRepo) ExistsByName(ctx context.Context, name string) (bool, error return false, errors.New("not implemented") } -func (stubGroupRepo) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { - return 0, errors.New("not implemented") +func (stubGroupRepo) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) { + return 0, 0, errors.New("not implemented") } func (stubGroupRepo) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) { @@ -1288,7 +1293,7 @@ func (r *stubUserSubscriptionRepo) ListActiveByUserID(ctx context.Context, userI func (stubUserSubscriptionRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } -func (stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { +func (stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } func (stubUserSubscriptionRepo) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) { @@ -1508,6 +1513,22 @@ func (r *stubApiKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int6 return 0, errors.New("not implemented") } +func (r *stubApiKeyRepo) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) { + var updated int64 + for id, key := range r.byID { + if key.UserID != userID || key.GroupID == nil || *key.GroupID != oldGroupID { + continue + } + clone := *key + gid := newGroupID + clone.GroupID = &gid + r.byID[id] = &clone + r.byKey[clone.Key] = &clone + updated++ + } + return updated, nil +} + func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, errors.New("not implemented") } @@ -1636,6 +1657,10 @@ func (r *stubUsageLogRepo) GetGroupStatsWithFilters(ctx context.Context, startTi return nil, errors.New("not implemented") } +func (r *stubUsageLogRepo) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) { + return nil, errors.New("not implemented") +} + func (r *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) { return nil, errors.New("not implemented") } @@ -1781,6 +1806,9 @@ func (r *stubUsageLogRepo) GetAccountUsageStats(ctx context.Context, accountID i func (r *stubUsageLogRepo) GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) { return nil, errors.New("not implemented") } +func (r *stubUsageLogRepo) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) { + return nil, errors.New("not implemented") +} type stubSettingRepo struct { all map[string]string diff --git a/backend/internal/server/middleware/admin_auth_test.go b/backend/internal/server/middleware/admin_auth_test.go index 138663c4a5..aafe4a58d5 100644 --- a/backend/internal/server/middleware/admin_auth_test.go +++ b/backend/internal/server/middleware/admin_auth_test.go @@ -181,6 +181,10 @@ func (s *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID panic("unexpected RemoveGroupFromAllowedGroups call") } +func (s *stubUserRepo) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error { + panic("unexpected RemoveGroupFromUserAllowedGroups call") +} + func (s *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error { panic("unexpected AddGroupToAllowedGroups call") } diff --git a/backend/internal/server/middleware/api_key_auth_google_test.go b/backend/internal/server/middleware/api_key_auth_google_test.go index 49db5f1998..f8e50fcdef 100644 --- a/backend/internal/server/middleware/api_key_auth_google_test.go +++ b/backend/internal/server/middleware/api_key_auth_google_test.go @@ -104,6 +104,9 @@ func (f fakeAPIKeyRepo) ResetRateLimitWindows(ctx context.Context, id int64) err func (f fakeAPIKeyRepo) GetRateLimitData(ctx context.Context, id int64) (*service.APIKeyRateLimitData, error) { return &service.APIKeyRateLimitData{}, nil } +func (f fakeAPIKeyRepo) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) { + return 0, errors.New("not implemented") +} func (f fakeGoogleSubscriptionRepo) Create(ctx context.Context, sub *service.UserSubscription) error { return errors.New("not implemented") @@ -135,7 +138,7 @@ func (f fakeGoogleSubscriptionRepo) ListActiveByUserID(ctx context.Context, user func (f fakeGoogleSubscriptionRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } -func (f fakeGoogleSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { +func (f fakeGoogleSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } func (f fakeGoogleSubscriptionRepo) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) { diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index 22befa2afb..4a4ab0f981 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -565,6 +565,10 @@ func (r *stubApiKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int6 return 0, errors.New("not implemented") } +func (r *stubApiKeyRepo) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) { + return 0, errors.New("not implemented") +} + func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, errors.New("not implemented") } @@ -646,7 +650,7 @@ func (r *stubUserSubscriptionRepo) ListByGroupID(ctx context.Context, groupID in return nil, nil, errors.New("not implemented") } -func (r *stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { +func (r *stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index c566347221..338fd8fa87 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -198,6 +198,7 @@ func registerDashboardRoutes(admin *gin.RouterGroup, h *handler.Handlers) { dashboard.GET("/users-ranking", h.Admin.Dashboard.GetUserSpendingRanking) dashboard.POST("/users-usage", h.Admin.Dashboard.GetBatchUsersUsage) dashboard.POST("/api-keys-usage", h.Admin.Dashboard.GetBatchAPIKeysUsage) + dashboard.GET("/user-breakdown", h.Admin.Dashboard.GetUserBreakdown) dashboard.POST("/aggregation/backfill", h.Admin.Dashboard.BackfillAggregation) } } @@ -214,6 +215,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) { users.GET("/:id/api-keys", h.Admin.User.GetUserAPIKeys) users.GET("/:id/usage", h.Admin.User.GetUserUsage) users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory) + users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup) // User attribute values users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes) @@ -226,6 +228,8 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) { { groups.GET("", h.Admin.Group.List) groups.GET("/all", h.Admin.Group.GetAll) + groups.GET("/usage-summary", h.Admin.Group.GetUsageSummary) + groups.GET("/capacity-summary", h.Admin.Group.GetCapacitySummary) groups.PUT("/sort-order", h.Admin.Group.UpdateSortOrder) groups.GET("/:id", h.Admin.Group.GetByID) groups.POST("", h.Admin.Group.Create) @@ -262,6 +266,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.POST("/:id/clear-rate-limit", h.Admin.Account.ClearRateLimit) accounts.POST("/:id/reset-quota", h.Admin.Account.ResetQuota) accounts.GET("/:id/affinity-clients", h.Admin.Account.GetAffinityClients) + accounts.GET("/:id/affinity-details", h.Admin.Account.GetAffinityDetails) accounts.GET("/:id/temp-unschedulable", h.Admin.Account.GetTempUnschedulable) accounts.DELETE("/:id/temp-unschedulable", h.Admin.Account.ClearTempUnschedulable) accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable) @@ -400,6 +405,9 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { adminSettings.GET("/admin-api-key", h.Admin.Setting.GetAdminAPIKey) adminSettings.POST("/admin-api-key/regenerate", h.Admin.Setting.RegenerateAdminAPIKey) adminSettings.DELETE("/admin-api-key", h.Admin.Setting.DeleteAdminAPIKey) + // 529过载冷却配置 + adminSettings.GET("/overload-cooldown", h.Admin.Setting.GetOverloadCooldownSettings) + adminSettings.PUT("/overload-cooldown", h.Admin.Setting.UpdateOverloadCooldownSettings) // 流超时处理配置 adminSettings.GET("/stream-timeout", h.Admin.Setting.GetStreamTimeoutSettings) adminSettings.PUT("/stream-timeout", h.Admin.Setting.UpdateStreamTimeoutSettings) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index e25446a16d..976d940e21 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1204,16 +1204,21 @@ func (a *Account) IsSessionIDMaskingEnabled() bool { return false } -// IsClientAffinityEnabled 检查是否启用客户端亲和调度 -// 仅适用于 Anthropic 账号(OAuth/SetupToken/APIKey) -// 启用后,新会话会优先调度到之前使用过的账号 -func (a *Account) IsClientAffinityEnabled() bool { +// IsAffinityEnabled 检查是否启用亲和调度(统一入口) +// 仅适用于 Anthropic 账号,同时检查新字段 affinity_enabled 和旧字段 client_affinity_enabled(向后兼容) +func (a *Account) IsAffinityEnabled() bool { if a.Platform != PlatformAnthropic { return false } if a.Extra == nil { return false } + if v, ok := a.Extra["affinity_enabled"]; ok { + if enabled, ok := v.(bool); ok { + return enabled + } + return false + } if v, ok := a.Extra["client_affinity_enabled"]; ok { if enabled, ok := v.(bool); ok { return enabled @@ -1222,6 +1227,11 @@ func (a *Account) IsClientAffinityEnabled() bool { return false } +// IsClientAffinityEnabled 向后兼容别名,内部调用 IsAffinityEnabled +func (a *Account) IsClientAffinityEnabled() bool { + return a.IsAffinityEnabled() +} + // AffinityZone 表示账号的客户端亲和分区 type AffinityZone int @@ -1288,6 +1298,150 @@ func (a *Account) GetAffinityZone(clientCount int64) AffinityZone { return AffinityZoneRed } +// IsAffinityAllowSwitch 检查亲和账号是否允许在无绿区时切换到其他账号 +// 默认 true(允许切换),设为 false 时即使全部红区也不切换 +func (a *Account) IsAffinityAllowSwitch() bool { + if a.Extra == nil { + return true + } + if v, ok := a.Extra["affinity_allow_switch"]; ok { + if allow, ok := v.(bool); ok { + return allow + } + } + return true +} + +// GetPinnedUsers 获取指定亲和用户列表 +func (a *Account) GetPinnedUsers() []int64 { + if a.Extra == nil { + return nil + } + v, ok := a.Extra["pinned_users"] + if !ok || v == nil { + return nil + } + arr, ok := v.([]any) + if !ok || len(arr) == 0 { + return nil + } + result := make([]int64, 0, len(arr)) + for _, item := range arr { + switch id := item.(type) { + case float64: + result = append(result, int64(id)) + case int64: + result = append(result, id) + case int: + result = append(result, int64(id)) + case json.Number: + if i, err := id.Int64(); err == nil { + result = append(result, i) + } + } + } + return result +} + +// IsPinnedUser 检查 userID 是否在指定亲和用户列表中 +func (a *Account) IsPinnedUser(userID int64) bool { + for _, id := range a.GetPinnedUsers() { + if id == userID { + return true + } + } + return false +} + +// GetAffinityUserBase 获取用户维度亲和基础限制(绿区上限),0 表示未配置 +func (a *Account) GetAffinityUserBase() int { + if a.Extra == nil { + return 0 + } + if v, ok := a.Extra["affinity_user_base"]; ok { + return parseExtraInt(v) + } + return 0 +} + +// GetAffinityUserBuffer 获取用户维度亲和缓冲区大小(黄区范围) +// 返回 (value, configured),语义与 GetAffinityBuffer 相同 +func (a *Account) GetAffinityUserBuffer() (int, bool) { + if a.Extra == nil { + return 0, false + } + v, ok := a.Extra["affinity_user_buffer"] + if !ok { + return 0, false + } + if v == nil { + return 0, false + } + return parseExtraInt(v), true +} + +// GetPerUserClientLimit 获取每用户客户端限制,0 表示不限制 +func (a *Account) GetPerUserClientLimit() int { + if a.Extra == nil { + return 0 + } + if v, ok := a.Extra["per_user_client_limit"]; ok { + return parseExtraInt(v) + } + return 0 +} + +// getAffinityZoneForDim 根据单一维度的计数和三区参数计算亲和分区 +func getAffinityZoneForDim(count int64, base int, buffer int, bufferConfigured bool) AffinityZone { + if base <= 0 { + return AffinityZoneGreen + } + if count <= int64(base) { + return AffinityZoneGreen + } + if !bufferConfigured { + return AffinityZoneYellow + } + if buffer == 0 { + return AffinityZoneRed + } + if count <= int64(base+buffer) { + return AffinityZoneYellow + } + return AffinityZoneRed +} + +// GetMultiDimAffinityZone 根据多维度计数计算亲和分区,取所有维度中最严格的区域。 +// 未开启亲和的账号永远返回绿区。 +func (a *Account) GetMultiDimAffinityZone(userCount, clientCount, perUserCount int64) AffinityZone { + if !a.IsAffinityEnabled() { + return AffinityZoneGreen + } + worst := AffinityZoneGreen + + // 客户端维度 + clientBase := a.GetAffinityBase() + clientBuffer, clientBufCfg := a.GetAffinityBuffer() + if z := getAffinityZoneForDim(clientCount, clientBase, clientBuffer, clientBufCfg); z > worst { + worst = z + } + + // 用户维度 + userBase := a.GetAffinityUserBase() + userBuffer, userBufCfg := a.GetAffinityUserBuffer() + if z := getAffinityZoneForDim(userCount, userBase, userBuffer, userBufCfg); z > worst { + worst = z + } + + // 每用户客户端维度 + perUserLimit := a.GetPerUserClientLimit() + if perUserLimit > 0 && perUserCount > int64(perUserLimit) { + worst = AffinityZoneRed + } + + return worst +} + // IsCacheTTLOverrideEnabled 检查是否启用缓存 TTL 强制替换 // 仅适用于 Anthropic OAuth/SetupToken 类型账号 // 启用后将所有 cache creation tokens 归入指定的 TTL 类型(5m 或 1h) diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index a06d804817..2e91db6b07 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -14,6 +14,8 @@ var ( ErrAccountNilInput = infraerrors.BadRequest("ACCOUNT_NIL_INPUT", "account input cannot be nil") ) +const AccountListGroupUngrouped int64 = -1 + type AccountRepository interface { Create(ctx context.Context, account *Account) error GetByID(ctx context.Context, id int64) (*Account, error) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 482d22b15a..126173369a 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -113,15 +113,18 @@ func (s *AccountTestService) validateUpstreamBaseURL(raw string) (string, error) return normalized, nil } -// generateSessionString generates a Claude Code style session string +// generateSessionString generates a Claude Code style session string. +// The output format is determined by the UA version in claude.DefaultHeaders, +// ensuring consistency between the user_id format and the UA sent to upstream. func generateSessionString() (string, error) { - bytes := make([]byte, 32) - if _, err := rand.Read(bytes); err != nil { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { return "", err } - hex64 := hex.EncodeToString(bytes) + hex64 := hex.EncodeToString(b) sessionUUID := uuid.New().String() - return fmt.Sprintf("user_%s_account__session_%s", hex64, sessionUUID), nil + uaVersion := ExtractCLIVersion(claude.DefaultHeaders["User-Agent"]) + return FormatMetadataUserID(hex64, "", sessionUUID, uaVersion), nil } // createTestPayload creates a Claude Code style test request payload @@ -305,7 +308,14 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body))) + errMsg := fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body)) + + // 403 表示账号被上游封禁,标记为 error 状态 + if resp.StatusCode == http.StatusForbidden { + _ = s.accountRepo.SetError(ctx, account.ID, errMsg) + } + + return s.sendErrorAndEnd(c, errMsg) } // Process SSE stream diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 959c11827e..2761d9c8e5 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -48,6 +48,8 @@ type UsageLogRepository interface { GetEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]usagestats.EndpointStat, error) GetUpstreamEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]usagestats.EndpointStat, error) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) + GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) + GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (*usagestats.UserSpendingRankingResponse, error) @@ -175,6 +177,7 @@ type AICredit struct { // UsageInfo 账号使用量信息 type UsageInfo struct { + Source string `json:"source,omitempty"` // "passive" or "active" UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间 FiveHour *UsageProgress `json:"five_hour"` // 5小时窗口 SevenDay *UsageProgress `json:"seven_day,omitempty"` // 7天窗口 @@ -391,6 +394,9 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U // 4. 添加窗口统计(有独立缓存,1 分钟) s.addWindowStats(ctx, account, usage) + // 5. 将主动查询结果同步到被动缓存,下次 passive 加载即为最新值 + s.syncActiveToPassive(ctx, account.ID, usage) + s.tryClearRecoverableAccountError(ctx, account) return usage, nil } @@ -407,6 +413,81 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U return nil, fmt.Errorf("account type %s does not support usage query", account.Type) } +// GetPassiveUsage 从 Account.Extra 中的被动采样数据构建 UsageInfo,不调用外部 API。 +// 仅适用于 Anthropic OAuth / SetupToken 账号。 +func (s *AccountUsageService) GetPassiveUsage(ctx context.Context, accountID int64) (*UsageInfo, error) { + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("get account failed: %w", err) + } + + if !account.IsAnthropicOAuthOrSetupToken() { + return nil, fmt.Errorf("passive usage only supported for Anthropic OAuth/SetupToken accounts") + } + + // 复用 estimateSetupTokenUsage 构建 5h 窗口(OAuth 和 SetupToken 逻辑一致) + info := s.estimateSetupTokenUsage(account) + info.Source = "passive" + + // 设置采样时间 + if raw, ok := account.Extra["passive_usage_sampled_at"]; ok { + if str, ok := raw.(string); ok { + if t, err := time.Parse(time.RFC3339, str); err == nil { + info.UpdatedAt = &t + } + } + } + + // 构建 7d 窗口(从被动采样数据) + util7d := parseExtraFloat64(account.Extra["passive_usage_7d_utilization"]) + reset7dRaw := parseExtraFloat64(account.Extra["passive_usage_7d_reset"]) + if util7d > 0 || reset7dRaw > 0 { + var resetAt *time.Time + var remaining int + if reset7dRaw > 0 { + t := time.Unix(int64(reset7dRaw), 0) + resetAt = &t + remaining = int(time.Until(t).Seconds()) + if remaining < 0 { + remaining = 0 + } + } + info.SevenDay = &UsageProgress{ + Utilization: util7d * 100, + ResetsAt: resetAt, + RemainingSeconds: remaining, + } + } + + // 添加窗口统计 + s.addWindowStats(ctx, account, info) + + return info, nil +} + +// syncActiveToPassive 将主动查询的最新数据回写到 Extra 被动缓存, +// 这样下次被动加载时能看到最新值。 +func (s *AccountUsageService) syncActiveToPassive(ctx context.Context, accountID int64, usage *UsageInfo) { + extraUpdates := make(map[string]any, 4) + + if usage.FiveHour != nil { + extraUpdates["session_window_utilization"] = usage.FiveHour.Utilization / 100 + } + if usage.SevenDay != nil { + extraUpdates["passive_usage_7d_utilization"] = usage.SevenDay.Utilization / 100 + if usage.SevenDay.ResetsAt != nil { + extraUpdates["passive_usage_7d_reset"] = usage.SevenDay.ResetsAt.Unix() + } + } + + if len(extraUpdates) > 0 { + extraUpdates["passive_usage_sampled_at"] = time.Now().UTC().Format(time.RFC3339) + if err := s.accountRepo.UpdateExtra(ctx, accountID, extraUpdates); err != nil { + slog.Warn("sync_active_to_passive_failed", "account_id", accountID, "error", err) + } + } +} + func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Account) (*UsageInfo, error) { now := time.Now() usage := &UsageInfo{UpdatedAt: &now} @@ -1042,6 +1123,11 @@ func buildCodexUsageProgressFromExtra(extra map[string]any, window string, now t } } + // 窗口已过期(resetAt 在 now 之前)→ 额度已重置,归零 + if progress.ResetsAt != nil && !now.Before(*progress.ResetsAt) { + progress.Utilization = 0 + } + return progress } diff --git a/backend/internal/service/account_usage_service_test.go b/backend/internal/service/account_usage_service_test.go index a063fe26db..fe2552251d 100644 --- a/backend/internal/service/account_usage_service_test.go +++ b/backend/internal/service/account_usage_service_test.go @@ -148,3 +148,54 @@ func TestAccountUsageService_PersistOpenAICodexProbeSnapshotSetsRateLimit(t *tes t.Fatal("waiting for codex probe rate limit persistence timed out") } } + +func TestBuildCodexUsageProgressFromExtra_ZerosExpiredWindow(t *testing.T) { + t.Parallel() + now := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC) + + t.Run("expired 5h window zeroes utilization", func(t *testing.T) { + extra := map[string]any{ + "codex_5h_used_percent": 42.0, + "codex_5h_reset_at": "2026-03-16T10:00:00Z", // 2h ago + } + progress := buildCodexUsageProgressFromExtra(extra, "5h", now) + if progress == nil { + t.Fatal("expected non-nil progress") + } + if progress.Utilization != 0 { + t.Fatalf("expected Utilization=0 for expired window, got %v", progress.Utilization) + } + if progress.RemainingSeconds != 0 { + t.Fatalf("expected RemainingSeconds=0, got %v", progress.RemainingSeconds) + } + }) + + t.Run("active 5h window keeps utilization", func(t *testing.T) { + resetAt := now.Add(2 * time.Hour).Format(time.RFC3339) + extra := map[string]any{ + "codex_5h_used_percent": 42.0, + "codex_5h_reset_at": resetAt, + } + progress := buildCodexUsageProgressFromExtra(extra, "5h", now) + if progress == nil { + t.Fatal("expected non-nil progress") + } + if progress.Utilization != 42.0 { + t.Fatalf("expected Utilization=42, got %v", progress.Utilization) + } + }) + + t.Run("expired 7d window zeroes utilization", func(t *testing.T) { + extra := map[string]any{ + "codex_7d_used_percent": 88.0, + "codex_7d_reset_at": "2026-03-15T00:00:00Z", // yesterday + } + progress := buildCodexUsageProgressFromExtra(extra, "7d", now) + if progress == nil { + t.Fatal("expected non-nil progress") + } + if progress.Utilization != 0 { + t.Fatalf("expected Utilization=0 for expired 7d window, got %v", progress.Utilization) + } + }) +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 0c4c3072fc..308b993699 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -50,6 +50,9 @@ type AdminService interface { // API Key management (admin) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*AdminUpdateAPIKeyGroupIDResult, error) + // ReplaceUserGroup 替换用户的专属分组:授予新分组权限、迁移 Key、移除旧分组权限 + ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*ReplaceUserGroupResult, error) + // Account management ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]Account, int64, error) GetAccount(ctx context.Context, id int64) (*Account, error) @@ -272,6 +275,11 @@ type AdminUpdateAPIKeyGroupIDResult struct { GrantedGroupName string // the group name that was auto-granted } +// ReplaceUserGroupResult 分组替换操作的结果 +type ReplaceUserGroupResult struct { + MigratedKeys int64 // 迁移的 Key 数量 +} + // BulkUpdateAccountsResult is the aggregated response for bulk updates. type BulkUpdateAccountsResult struct { Success int `json:"success"` @@ -1396,6 +1404,71 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i return result, nil } +// ReplaceUserGroup 替换用户的专属分组 +func (s *adminServiceImpl) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*ReplaceUserGroupResult, error) { + if oldGroupID == newGroupID { + return nil, infraerrors.BadRequest("SAME_GROUP", "old and new group must be different") + } + + // 验证新分组存在且为活跃的专属标准分组 + newGroup, err := s.groupRepo.GetByID(ctx, newGroupID) + if err != nil { + return nil, err + } + if newGroup.Status != StatusActive { + return nil, infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active") + } + if !newGroup.IsExclusive { + return nil, infraerrors.BadRequest("GROUP_NOT_EXCLUSIVE", "target group is not exclusive") + } + if newGroup.IsSubscriptionType() { + return nil, infraerrors.BadRequest("GROUP_IS_SUBSCRIPTION", "subscription groups are not supported for replacement") + } + + // 事务保证原子性 + if s.entClient == nil { + return nil, fmt.Errorf("entClient is nil, cannot perform group replacement") + } + tx, err := s.entClient.Tx(ctx) + if err != nil { + return nil, fmt.Errorf("begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + opCtx := dbent.NewTxContext(ctx, tx) + + // 1. 授予新分组权限 + if err := s.userRepo.AddGroupToAllowedGroups(opCtx, userID, newGroupID); err != nil { + return nil, fmt.Errorf("add new group to allowed groups: %w", err) + } + + // 2. 迁移绑定旧分组的 Key 到新分组 + migrated, err := s.apiKeyRepo.UpdateGroupIDByUserAndGroup(opCtx, userID, oldGroupID, newGroupID) + if err != nil { + return nil, fmt.Errorf("migrate api keys: %w", err) + } + + // 3. 移除旧分组权限 + if err := s.userRepo.RemoveGroupFromUserAllowedGroups(opCtx, userID, oldGroupID); err != nil { + return nil, fmt.Errorf("remove old group from allowed groups: %w", err) + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit transaction: %w", err) + } + + // 失效该用户所有 Key 的认证缓存 + if s.authCacheInvalidator != nil { + keys, keyErr := s.apiKeyRepo.ListKeysByUserID(ctx, userID) + if keyErr == nil { + for _, k := range keys { + s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, k) + } + } + } + + return &ReplaceUserGroupResult{MigratedKeys: migrated}, nil +} + // Account management implementations func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]Account, int64, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize} diff --git a/backend/internal/service/admin_service_apikey_test.go b/backend/internal/service/admin_service_apikey_test.go index 88d2f492e0..f9fd67423e 100644 --- a/backend/internal/service/admin_service_apikey_test.go +++ b/backend/internal/service/admin_service_apikey_test.go @@ -65,6 +65,9 @@ func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (boo func (s *userRepoStubForGroupUpdate) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { panic("unexpected") } +func (s *userRepoStubForGroupUpdate) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error { + panic("unexpected") +} func (s *userRepoStubForGroupUpdate) UpdateTotpSecret(context.Context, int64, *string) error { panic("unexpected") } @@ -128,6 +131,9 @@ func (s *apiKeyRepoStubForGroupUpdate) SearchAPIKeys(context.Context, int64, str func (s *apiKeyRepoStubForGroupUpdate) ClearGroupIDByGroupID(context.Context, int64) (int64, error) { panic("unexpected") } +func (s *apiKeyRepoStubForGroupUpdate) UpdateGroupIDByUserAndGroup(context.Context, int64, int64, int64) (int64, error) { + panic("unexpected") +} func (s *apiKeyRepoStubForGroupUpdate) CountByGroupID(context.Context, int64) (int64, error) { panic("unexpected") } @@ -194,7 +200,7 @@ func (s *groupRepoStubForGroupUpdate) ListActiveByPlatform(context.Context, stri func (s *groupRepoStubForGroupUpdate) ExistsByName(context.Context, string) (bool, error) { panic("unexpected") } -func (s *groupRepoStubForGroupUpdate) GetAccountCount(context.Context, int64) (int64, error) { +func (s *groupRepoStubForGroupUpdate) GetAccountCount(context.Context, int64) (int64, int64, error) { panic("unexpected") } func (s *groupRepoStubForGroupUpdate) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index 2e0f7d9070..fbc856cf3c 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -93,6 +93,10 @@ func (s *userRepoStub) RemoveGroupFromAllowedGroups(ctx context.Context, groupID panic("unexpected RemoveGroupFromAllowedGroups call") } +func (s *userRepoStub) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error { + panic("unexpected RemoveGroupFromUserAllowedGroups call") +} + func (s *userRepoStub) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error { panic("unexpected AddGroupToAllowedGroups call") } @@ -160,7 +164,7 @@ func (s *groupRepoStub) ExistsByName(ctx context.Context, name string) (bool, er panic("unexpected ExistsByName call") } -func (s *groupRepoStub) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { +func (s *groupRepoStub) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) { panic("unexpected GetAccountCount call") } diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 0e6fe0842b..51d9b3d2fe 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -100,7 +100,7 @@ func (s *groupRepoStubForAdmin) ExistsByName(_ context.Context, _ string) (bool, panic("unexpected ExistsByName call") } -func (s *groupRepoStubForAdmin) GetAccountCount(_ context.Context, _ int64) (int64, error) { +func (s *groupRepoStubForAdmin) GetAccountCount(_ context.Context, _ int64) (int64, int64, error) { panic("unexpected GetAccountCount call") } @@ -383,7 +383,7 @@ func (s *groupRepoStubForFallbackCycle) ExistsByName(_ context.Context, _ string panic("unexpected ExistsByName call") } -func (s *groupRepoStubForFallbackCycle) GetAccountCount(_ context.Context, _ int64) (int64, error) { +func (s *groupRepoStubForFallbackCycle) GetAccountCount(_ context.Context, _ int64) (int64, int64, error) { panic("unexpected GetAccountCount call") } @@ -458,7 +458,7 @@ func (s *groupRepoStubForInvalidRequestFallback) ExistsByName(_ context.Context, panic("unexpected ExistsByName call") } -func (s *groupRepoStubForInvalidRequestFallback) GetAccountCount(_ context.Context, _ int64) (int64, error) { +func (s *groupRepoStubForInvalidRequestFallback) GetAccountCount(_ context.Context, _ int64) (int64, int64, error) { panic("unexpected GetAccountCount call") } diff --git a/backend/internal/service/antigravity_credits_overages.go b/backend/internal/service/antigravity_credits_overages.go index 1521dfcd20..ec3650859c 100644 --- a/backend/internal/service/antigravity_credits_overages.go +++ b/backend/internal/service/antigravity_credits_overages.go @@ -45,6 +45,7 @@ var ( "minimumcreditamountforusage", "minimum credit amount for usage", "minimum credit", + "resource has been exhausted", } ) @@ -147,9 +148,9 @@ func shouldMarkCreditsExhausted(resp *http.Response, respBody []byte, reqErr err if resp.StatusCode >= 500 || resp.StatusCode == http.StatusRequestTimeout { return false } - if isURLLevelRateLimit(respBody) { - return false - } + // 注意:不再检查 isURLLevelRateLimit。此函数仅在积分重试失败后调用, + // 如果注入 enabledCreditTypes 后仍返回 "Resource has been exhausted", + // 说明积分也已耗尽,应该标记。clearCreditsExhausted 会在后续成功时自动清除。 if info := parseAntigravitySmartRetryInfo(respBody); info != nil { return false } diff --git a/backend/internal/service/antigravity_credits_overages_test.go b/backend/internal/service/antigravity_credits_overages_test.go index bc679494b5..7a5224daf3 100644 --- a/backend/internal/service/antigravity_credits_overages_test.go +++ b/backend/internal/service/antigravity_credits_overages_test.go @@ -406,10 +406,16 @@ func TestShouldMarkCreditsExhausted(t *testing.T) { require.False(t, shouldMarkCreditsExhausted(resp, []byte(`{"error":"Insufficient credits"}`), nil)) }) - t.Run("URL 级限流不标记", func(t *testing.T) { + t.Run("Resource has been exhausted 应标记为积分耗尽", func(t *testing.T) { resp := &http.Response{StatusCode: http.StatusTooManyRequests} body := []byte(`{"error":{"message":"Resource has been exhausted"}}`) - require.False(t, shouldMarkCreditsExhausted(resp, body, nil)) + require.True(t, shouldMarkCreditsExhausted(resp, body, nil)) + }) + + t.Run("Resource has been exhausted (check quota) 完整格式应标记", func(t *testing.T) { + resp := &http.Response{StatusCode: http.StatusTooManyRequests} + body := []byte(`{"error":{"code":429,"message":"Resource has been exhausted (e.g. check quota).","status":"RESOURCE_EXHAUSTED"}}`) + require.True(t, shouldMarkCreditsExhausted(resp, body, nil)) }) t.Run("结构化限流不标记", func(t *testing.T) { diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index fda26b53ca..5393ca91d9 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -930,7 +930,7 @@ func (s *AntigravityGatewayService) applyErrorPolicy(p antigravityRetryLoopParam case ErrorPolicyTempUnscheduled: slog.Info("temp_unschedulable_matched", "prefix", p.prefix, "status_code", statusCode, "account_id", p.account.ID) - return true, statusCode, &AntigravityAccountSwitchError{OriginalAccountID: p.account.ID, IsStickySession: p.isStickySession} + return true, statusCode, &AntigravityAccountSwitchError{OriginalAccountID: p.account.ID, RateLimitedModel: p.requestedModel, IsStickySession: p.isStickySession} } return false, statusCode, nil } @@ -1001,8 +1001,9 @@ type TestConnectionResult struct { MappedModel string // 实际使用的模型 } -// TestConnection 测试 Antigravity 账号连接(非流式,无重试、无计费) -// 支持 Claude 和 Gemini 两种协议,根据 modelID 前缀自动选择 +// TestConnection 测试 Antigravity 账号连接。 +// 复用 antigravityRetryLoop 的完整重试 / credits overages / 智能重试逻辑, +// 与真实调度行为一致。差异:不做账号切换(测试指定账号)、不记录 ops 错误。 func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account *Account, modelID string) (*TestConnectionResult, error) { // 获取 token @@ -1026,10 +1027,8 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account // 构建请求体 var requestBody []byte if strings.HasPrefix(modelID, "gemini-") { - // Gemini 模型:直接使用 Gemini 格式 requestBody, err = s.buildGeminiTestRequest(projectID, mappedModel) } else { - // Claude 模型:使用协议转换 requestBody, err = s.buildClaudeTestRequest(projectID, mappedModel) } if err != nil { @@ -1042,64 +1041,63 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account proxyURL = account.Proxy.URL() } - baseURL := resolveAntigravityForwardBaseURL() - if baseURL == "" { - return nil, errors.New("no antigravity forward base url configured") - } - availableURLs := []string{baseURL} - - var lastErr error - for urlIdx, baseURL := range availableURLs { - // 构建 HTTP 请求(总是使用流式 endpoint,与官方客户端一致) - req, err := antigravity.NewAPIRequestWithURL(ctx, baseURL, "streamGenerateContent", accessToken, requestBody) - if err != nil { - lastErr = err - continue - } - - // 调试日志:Test 请求信息 - logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Test] account=%s request_size=%d url=%s", account.Name, len(requestBody), req.URL.String()) - - // 发送请求 - resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) - if err != nil { - lastErr = fmt.Errorf("请求失败: %w", err) - if shouldAntigravityFallbackToNextURL(err, 0) && urlIdx < len(availableURLs)-1 { - logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Test] URL fallback: %s -> %s", baseURL, availableURLs[urlIdx+1]) - continue - } - return nil, lastErr - } - - // 读取响应 - respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) - _ = resp.Body.Close() // 立即关闭,避免循环内 defer 导致的资源泄漏 - if err != nil { - return nil, fmt.Errorf("读取响应失败: %w", err) - } - - // 检查是否需要 URL 降级 - if shouldAntigravityFallbackToNextURL(nil, resp.StatusCode) && urlIdx < len(availableURLs)-1 { - logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Test] URL fallback (HTTP %d): %s -> %s", resp.StatusCode, baseURL, availableURLs[urlIdx+1]) - continue - } - - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("API 返回 %d: %s", resp.StatusCode, string(respBody)) - } - - // 解析流式响应,提取文本 - text := extractTextFromSSEResponse(respBody) - - // 标记成功的 URL,下次优先使用 - antigravity.DefaultURLAvailability.MarkSuccess(baseURL) - return &TestConnectionResult{ - Text: text, - MappedModel: mappedModel, - }, nil + // 复用 antigravityRetryLoop:完整的重试 / credits overages / 智能重试 + prefix := fmt.Sprintf("[antigravity-Test] account=%d(%s)", account.ID, account.Name) + p := antigravityRetryLoopParams{ + ctx: ctx, + prefix: prefix, + account: account, + proxyURL: proxyURL, + accessToken: accessToken, + action: "streamGenerateContent", + body: requestBody, + c: nil, // 无 gin.Context → 跳过 ops 追踪 + httpUpstream: s.httpUpstream, + settingService: s.settingService, + accountRepo: s.accountRepo, + requestedModel: modelID, + handleError: testConnectionHandleError, } - return nil, lastErr + result, err := s.antigravityRetryLoop(p) + if err != nil { + // AccountSwitchError → 测试时不切换账号,返回友好提示 + var switchErr *AntigravityAccountSwitchError + if errors.As(err, &switchErr) { + return nil, fmt.Errorf("该账号模型 %s 当前限流中,请稍后重试", switchErr.RateLimitedModel) + } + return nil, err + } + + if result == nil || result.resp == nil { + return nil, errors.New("upstream returned empty response") + } + defer func() { _ = result.resp.Body.Close() }() + + respBody, err := io.ReadAll(io.LimitReader(result.resp.Body, 2<<20)) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + + if result.resp.StatusCode >= 400 { + return nil, fmt.Errorf("API 返回 %d: %s", result.resp.StatusCode, string(respBody)) + } + + text := extractTextFromSSEResponse(respBody) + return &TestConnectionResult{Text: text, MappedModel: mappedModel}, nil +} + +// testConnectionHandleError 是 TestConnection 使用的轻量 handleError 回调。 +// 仅记录日志,不做 ops 错误追踪或粘性会话清除。 +func testConnectionHandleError( + _ context.Context, prefix string, account *Account, + statusCode int, _ http.Header, body []byte, + requestedModel string, _ int64, _ string, _ bool, +) *handleModelRateLimitResult { + logger.LegacyPrintf("service.antigravity_gateway", + "%s test_handle_error status=%d model=%s account=%d body=%s", + prefix, statusCode, requestedModel, account.ID, truncateForLog(body, 200)) + return nil } // buildGeminiTestRequest 构建 Gemini 格式测试请求 @@ -1361,7 +1359,10 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, } accessToken, err := s.tokenProvider.GetAccessToken(ctx, account) if err != nil { - return nil, s.writeClaudeError(c, http.StatusBadGateway, "authentication_error", "Failed to get upstream access token") + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + ResponseBody: []byte(`{"error":{"type":"authentication_error","message":"Failed to get upstream access token"},"type":"error"}`), + } } // 获取 project_id(部分账户类型可能没有) @@ -2106,7 +2107,10 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co } accessToken, err := s.tokenProvider.GetAccessToken(ctx, account) if err != nil { - return nil, s.writeGoogleError(c, http.StatusBadGateway, "Failed to get upstream access token") + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + ResponseBody: []byte(`{"error":{"message":"Failed to get upstream access token","status":"UNAVAILABLE"}}`), + } } // 获取 project_id(部分账户类型可能没有) @@ -3082,6 +3086,22 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context intervalCh = intervalTicker.C } + // 下游 keepalive:防止代理/Cloudflare Tunnel 因连接空闲而断开 + keepaliveInterval := time.Duration(0) + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 { + keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second + } + var keepaliveTicker *time.Ticker + if keepaliveInterval > 0 { + keepaliveTicker = time.NewTicker(keepaliveInterval) + defer keepaliveTicker.Stop() + } + var keepaliveCh <-chan time.Time + if keepaliveTicker != nil { + keepaliveCh = keepaliveTicker.C + } + lastDataAt := time.Now() + cw := newAntigravityClientWriter(c.Writer, flusher, "antigravity gemini") // 仅发送一次错误事件,避免多次写入导致协议混乱 @@ -3114,6 +3134,8 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context return nil, ev.err } + lastDataAt = time.Now() + line := ev.line trimmed := strings.TrimRight(line, "\r\n") if strings.HasPrefix(trimmed, "data:") { @@ -3173,6 +3195,19 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity)") sendErrorEvent("stream_timeout") return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout") + + case <-keepaliveCh: + if cw.Disconnected() { + continue + } + if time.Since(lastDataAt) < keepaliveInterval { + continue + } + // SSE ping/keepalive:保持连接活跃防止 Cloudflare Tunnel 等代理断开 + if !cw.Fprintf(":\n\n") { + logger.LegacyPrintf("service.antigravity_gateway", "Client disconnected during keepalive ping (antigravity gemini), continuing to drain upstream for billing") + continue + } } } } @@ -3903,6 +3938,22 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context intervalCh = intervalTicker.C } + // 下游 keepalive:防止代理/Cloudflare Tunnel 因连接空闲而断开 + keepaliveInterval := time.Duration(0) + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 { + keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second + } + var keepaliveTicker *time.Ticker + if keepaliveInterval > 0 { + keepaliveTicker = time.NewTicker(keepaliveInterval) + defer keepaliveTicker.Stop() + } + var keepaliveCh <-chan time.Time + if keepaliveTicker != nil { + keepaliveCh = keepaliveTicker.C + } + lastDataAt := time.Now() + cw := newAntigravityClientWriter(c.Writer, flusher, "antigravity claude") // 仅发送一次错误事件,避免多次写入导致协议混乱 @@ -3955,6 +4006,8 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context return nil, fmt.Errorf("stream read error: %w", ev.err) } + lastDataAt = time.Now() + // 处理 SSE 行,转换为 Claude 格式 claudeEvents := processor.ProcessLine(strings.TrimRight(ev.line, "\r\n")) if len(claudeEvents) > 0 { @@ -3977,6 +4030,20 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity)") sendErrorEvent("stream_timeout") return &antigravityStreamResult{usage: convertUsage(nil), firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout") + + case <-keepaliveCh: + if cw.Disconnected() { + continue + } + if time.Since(lastDataAt) < keepaliveInterval { + continue + } + // SSE ping 事件:Anthropic 原生格式,客户端会正确处理, + // 同时保持连接活跃防止 Cloudflare Tunnel 等代理断开 + if !cw.Fprintf("event: ping\ndata: {\"type\": \"ping\"}\n\n") { + logger.LegacyPrintf("service.antigravity_gateway", "Client disconnected during keepalive ping (antigravity claude), continuing to drain upstream for billing") + continue + } } } } @@ -4307,6 +4374,22 @@ func (s *AntigravityGatewayService) streamUpstreamResponse(c *gin.Context, resp intervalCh = intervalTicker.C } + // 下游 keepalive:防止代理/Cloudflare Tunnel 因连接空闲而断开 + keepaliveInterval := time.Duration(0) + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 { + keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second + } + var keepaliveTicker *time.Ticker + if keepaliveInterval > 0 { + keepaliveTicker = time.NewTicker(keepaliveInterval) + defer keepaliveTicker.Stop() + } + var keepaliveCh <-chan time.Time + if keepaliveTicker != nil { + keepaliveCh = keepaliveTicker.C + } + lastDataAt := time.Now() + flusher, _ := c.Writer.(http.Flusher) cw := newAntigravityClientWriter(c.Writer, flusher, "antigravity upstream") @@ -4324,6 +4407,8 @@ func (s *AntigravityGatewayService) streamUpstreamResponse(c *gin.Context, resp return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs} } + lastDataAt = time.Now() + line := ev.line // 记录首 token 时间 @@ -4349,6 +4434,20 @@ func (s *AntigravityGatewayService) streamUpstreamResponse(c *gin.Context, resp } logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity upstream)") return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs} + + case <-keepaliveCh: + if cw.Disconnected() { + continue + } + if time.Since(lastDataAt) < keepaliveInterval { + continue + } + // SSE ping 事件:Anthropic 原生格式,客户端会正确处理, + // 同时保持连接活跃防止 Cloudflare Tunnel 等代理断开 + if !cw.Fprintf("event: ping\ndata: {\"type\": \"ping\"}\n\n") { + logger.LegacyPrintf("service.antigravity_gateway", "Client disconnected during keepalive ping (antigravity upstream), continuing to drain upstream for billing") + continue + } } } } diff --git a/backend/internal/service/antigravity_model_mapping_test.go b/backend/internal/service/antigravity_model_mapping_test.go index 71939d262c..1dbe987073 100644 --- a/backend/internal/service/antigravity_model_mapping_test.go +++ b/backend/internal/service/antigravity_model_mapping_test.go @@ -57,16 +57,16 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) { expected: "claude-opus-4-6-thinking", }, { - name: "默认映射 - claude-haiku-4-5 → claude-sonnet-4-5", + name: "默认映射 - claude-haiku-4-5 → claude-sonnet-4-6", requestedModel: "claude-haiku-4-5", accountMapping: nil, - expected: "claude-sonnet-4-5", + expected: "claude-sonnet-4-6", }, { - name: "默认映射 - claude-haiku-4-5-20251001 → claude-sonnet-4-5", + name: "默认映射 - claude-haiku-4-5-20251001 → claude-sonnet-4-6", requestedModel: "claude-haiku-4-5-20251001", accountMapping: nil, - expected: "claude-sonnet-4-5", + expected: "claude-sonnet-4-6", }, { name: "默认映射 - claude-sonnet-4-5-20250929 → claude-sonnet-4-5", diff --git a/backend/internal/service/antigravity_oauth_service.go b/backend/internal/service/antigravity_oauth_service.go index 5f6691be2b..3d5ae524cb 100644 --- a/backend/internal/service/antigravity_oauth_service.go +++ b/backend/internal/service/antigravity_oauth_service.go @@ -192,6 +192,10 @@ func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken if isNonRetryableAntigravityOAuthError(err) { return nil, err } + // 代理连接错误(TCP 超时、连接拒绝、DNS 失败)不重试,立即返回 + if antigravity.IsConnectionError(err) { + return nil, fmt.Errorf("proxy unavailable: %w", err) + } lastErr = err } diff --git a/backend/internal/service/antigravity_single_account_retry_test.go b/backend/internal/service/antigravity_single_account_retry_test.go index 8b01cc3136..675e9c0cbf 100644 --- a/backend/internal/service/antigravity_single_account_retry_test.go +++ b/backend/internal/service/antigravity_single_account_retry_test.go @@ -260,14 +260,15 @@ func TestHandleSmartRetry_429_LongDelay_SingleAccountRetry_StillSwitches(t *test // TestHandleSmartRetry_503_ShortDelay_SingleAccountRetry_NoRateLimit // 503 + retryDelay < 7s + SingleAccountRetry → 智能重试耗尽后直接返回 503,不设限流 +// 使用 RATE_LIMIT_EXCEEDED(走 1 次智能重试),避免 MODEL_CAPACITY_EXHAUSTED 的 60 次重试导致测试超时 func TestHandleSmartRetry_503_ShortDelay_SingleAccountRetry_NoRateLimit(t *testing.T) { // 智能重试也返回 503 failRespBody := `{ "error": { "code": 503, - "status": "UNAVAILABLE", + "status": "RESOURCE_EXHAUSTED", "details": [ - {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "MODEL_CAPACITY_EXHAUSTED"}, + {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "RATE_LIMIT_EXCEEDED"}, {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.1s"} ] } @@ -278,8 +279,9 @@ func TestHandleSmartRetry_503_ShortDelay_SingleAccountRetry_NoRateLimit(t *testi Body: io.NopCloser(strings.NewReader(failRespBody)), } upstream := &mockSmartRetryUpstream{ - responses: []*http.Response{failResp}, - errors: []error{nil}, + responses: []*http.Response{failResp}, + errors: []error{nil}, + repeatLast: true, } repo := &stubAntigravityAccountRepo{} @@ -294,9 +296,9 @@ func TestHandleSmartRetry_503_ShortDelay_SingleAccountRetry_NoRateLimit(t *testi respBody := []byte(`{ "error": { "code": 503, - "status": "UNAVAILABLE", + "status": "RESOURCE_EXHAUSTED", "details": [ - {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "MODEL_CAPACITY_EXHAUSTED"}, + {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "RATE_LIMIT_EXCEEDED"}, {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.1s"} ] } @@ -569,8 +571,9 @@ func TestHandleSingleAccountRetryInPlace_WaitDurationClamped(t *testing.T) { svc := &AntigravityGatewayService{} - // 等待时间过大应被 clamp 到 antigravitySingleAccountSmartRetryMaxWait - result := svc.handleSingleAccountRetryInPlace(params, resp, nil, "https://ag-1.test", 999*time.Second, "gemini-3-pro") + // waitDuration=0 会被 clamp 到 antigravitySmartRetryMinWait=1s。 + // 首次重试即成功(200),总耗时 ~1s。 + result := svc.handleSingleAccountRetryInPlace(params, resp, nil, "https://ag-1.test", 0, "gemini-3-pro") require.NotNil(t, result) require.Equal(t, smartRetryActionBreakWithResp, result.action) require.NotNil(t, result.resp) diff --git a/backend/internal/service/antigravity_smart_retry_test.go b/backend/internal/service/antigravity_smart_retry_test.go index 90150a0e48..0f1b0215fd 100644 --- a/backend/internal/service/antigravity_smart_retry_test.go +++ b/backend/internal/service/antigravity_smart_retry_test.go @@ -30,12 +30,15 @@ func (c *stubSmartRetryCache) DeleteSessionAccountID(_ context.Context, groupID c.deleteCalls = append(c.deleteCalls, deleteSessionCall{groupID: groupID, sessionHash: sessionHash}) return nil } -func (c *stubSmartRetryCache) GetClientAffinityAccounts(_ context.Context, _ int64, _ string, _ time.Duration) ([]int64, error) { +func (c *stubSmartRetryCache) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { return nil, nil } -func (c *stubSmartRetryCache) UpdateClientAffinity(_ context.Context, _ int64, _ string, _ int64, _ time.Duration) error { +func (c *stubSmartRetryCache) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error { return nil } +func (c *stubSmartRetryCache) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) { + return 0, 0, 0, nil +} func (c *stubSmartRetryCache) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) { return map[int64]int64{}, nil } @@ -51,11 +54,13 @@ func (c *stubSmartRetryCache) ClearAccountAffinity(_ context.Context, _ int64, _ // mockSmartRetryUpstream 用于 handleSmartRetry 测试的 mock upstream type mockSmartRetryUpstream struct { - responses []*http.Response - errors []error - callIdx int - calls []string - requestBodies [][]byte + responses []*http.Response + responseBodies [][]byte // 缓存的 response body 字节(用于 repeatLast 重建) + errors []error + callIdx int + calls []string + requestBodies [][]byte + repeatLast bool // 超出范围时重复最后一个响应 } func (m *mockSmartRetryUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) { @@ -69,10 +74,40 @@ func (m *mockSmartRetryUpstream) Do(req *http.Request, proxyURL string, accountI m.requestBodies = append(m.requestBodies, nil) } m.callIdx++ - if idx < len(m.responses) { - return m.responses[idx], m.errors[idx] + + // 确定使用哪个索引 + respIdx := idx + if respIdx >= len(m.responses) { + if !m.repeatLast || len(m.responses) == 0 { + return nil, nil + } + respIdx = len(m.responses) - 1 } - return nil, nil + + resp := m.responses[respIdx] + respErr := m.errors[respIdx] + if resp == nil { + return nil, respErr + } + + // 首次调用时缓存 body 字节 + if respIdx >= len(m.responseBodies) { + for len(m.responseBodies) <= respIdx { + m.responseBodies = append(m.responseBodies, nil) + } + } + if m.responseBodies[respIdx] == nil && resp.Body != nil { + bodyBytes, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + m.responseBodies[respIdx] = bodyBytes + } + + // 用缓存的 body 重建 reader(支持重试场景多次读取) + cloned := *resp + if m.responseBodies[respIdx] != nil { + cloned.Body = io.NopCloser(bytes.NewReader(m.responseBodies[respIdx])) + } + return &cloned, respErr } func (m *mockSmartRetryUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) { diff --git a/backend/internal/service/antigravity_token_provider.go b/backend/internal/service/antigravity_token_provider.go index 9cdc49aafa..5e53f434c1 100644 --- a/backend/internal/service/antigravity_token_provider.go +++ b/backend/internal/service/antigravity_token_provider.go @@ -14,6 +14,10 @@ const ( antigravityTokenRefreshSkew = 3 * time.Minute antigravityTokenCacheSkew = 5 * time.Minute antigravityBackfillCooldown = 5 * time.Minute + // antigravityRequestRefreshTimeout 请求路径上 token 刷新的最大等待时间。 + // 超过此时间直接放弃刷新、标记账号临时不可调度并触发 failover, + // 让后台 TokenRefreshService 在下个周期继续重试。 + antigravityRequestRefreshTimeout = 8 * time.Second ) // AntigravityTokenCache token cache interface. @@ -28,6 +32,7 @@ type AntigravityTokenProvider struct { refreshAPI *OAuthRefreshAPI executor OAuthRefreshExecutor refreshPolicy ProviderRefreshPolicy + tempUnschedCache TempUnschedCache // 用于同步更新 Redis 临时不可调度缓存 } func NewAntigravityTokenProvider( @@ -54,6 +59,11 @@ func (p *AntigravityTokenProvider) SetRefreshPolicy(policy ProviderRefreshPolicy p.refreshPolicy = policy } +// SetTempUnschedCache injects temp unschedulable cache for immediate scheduler sync. +func (p *AntigravityTokenProvider) SetTempUnschedCache(cache TempUnschedCache) { + p.tempUnschedCache = cache +} + // GetAccessToken returns a valid access_token. func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *Account) (string, error) { if account == nil { @@ -88,8 +98,13 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account * expiresAt := account.GetCredentialAsTime("expires_at") needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= antigravityTokenRefreshSkew if needsRefresh && p.refreshAPI != nil && p.executor != nil { - result, err := p.refreshAPI.RefreshIfNeeded(ctx, account, p.executor, antigravityTokenRefreshSkew) + // 请求路径使用短超时,避免代理不通时阻塞过久(后台刷新服务会继续重试) + refreshCtx, cancel := context.WithTimeout(ctx, antigravityRequestRefreshTimeout) + defer cancel() + result, err := p.refreshAPI.RefreshIfNeeded(refreshCtx, account, p.executor, antigravityTokenRefreshSkew) if err != nil { + // 标记账号临时不可调度,避免后续请求继续命中 + p.markTempUnschedulable(account, err) if p.refreshPolicy.OnRefreshError == ProviderRefreshErrorReturn { return "", err } @@ -172,6 +187,45 @@ func (p *AntigravityTokenProvider) shouldAttemptBackfill(accountID int64) bool { return true } +// markTempUnschedulable 在请求路径上 token 刷新失败时标记账号临时不可调度。 +// 同时写 DB 和 Redis 缓存,确保调度器立即跳过该账号。 +// 使用 background context 因为请求 context 可能已超时。 +func (p *AntigravityTokenProvider) markTempUnschedulable(account *Account, refreshErr error) { + if p.accountRepo == nil || account == nil { + return + } + now := time.Now() + until := now.Add(tokenRefreshTempUnschedDuration) + reason := "token refresh failed on request path: " + refreshErr.Error() + bgCtx := context.Background() + if err := p.accountRepo.SetTempUnschedulable(bgCtx, account.ID, until, reason); err != nil { + slog.Warn("antigravity_token_provider.set_temp_unschedulable_failed", + "account_id", account.ID, + "error", err, + ) + return + } + slog.Warn("antigravity_token_provider.temp_unschedulable_set", + "account_id", account.ID, + "until", until.Format(time.RFC3339), + "reason", reason, + ) + // 同步写 Redis 缓存,调度器立即生效 + if p.tempUnschedCache != nil { + state := &TempUnschedState{ + UntilUnix: until.Unix(), + TriggeredAtUnix: now.Unix(), + ErrorMessage: reason, + } + if err := p.tempUnschedCache.SetTempUnsched(bgCtx, account.ID, state); err != nil { + slog.Warn("antigravity_token_provider.temp_unsched_cache_set_failed", + "account_id", account.ID, + "error", err, + ) + } + } +} + func (p *AntigravityTokenProvider) markBackfillAttempted(accountID int64) { p.backfillCooldown.Store(accountID, time.Now()) } diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index 18e9ff7a6c..48e0ab2f3b 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -63,6 +63,8 @@ type APIKeyRepository interface { ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]APIKey, *pagination.PaginationResult, error) SearchAPIKeys(ctx context.Context, userID int64, keyword string, limit int) ([]APIKey, error) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) + // UpdateGroupIDByUserAndGroup 将用户下绑定 oldGroupID 的所有 Key 迁移到 newGroupID + UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) CountByGroupID(ctx context.Context, groupID int64) (int64, error) ListKeysByUserID(ctx context.Context, userID int64) ([]string, error) ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error) diff --git a/backend/internal/service/api_key_service_cache_test.go b/backend/internal/service/api_key_service_cache_test.go index 97b8e229dc..357f8deff7 100644 --- a/backend/internal/service/api_key_service_cache_test.go +++ b/backend/internal/service/api_key_service_cache_test.go @@ -80,6 +80,9 @@ func (s *authRepoStub) SearchAPIKeys(ctx context.Context, userID int64, keyword func (s *authRepoStub) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) { panic("unexpected ClearGroupIDByGroupID call") } +func (s *authRepoStub) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) { + panic("unexpected UpdateGroupIDByUserAndGroup call") +} func (s *authRepoStub) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { panic("unexpected CountByGroupID call") diff --git a/backend/internal/service/api_key_service_delete_test.go b/backend/internal/service/api_key_service_delete_test.go index dfd481e882..392d52b92c 100644 --- a/backend/internal/service/api_key_service_delete_test.go +++ b/backend/internal/service/api_key_service_delete_test.go @@ -108,6 +108,9 @@ func (s *apiKeyRepoStub) SearchAPIKeys(ctx context.Context, userID int64, keywor func (s *apiKeyRepoStub) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) { panic("unexpected ClearGroupIDByGroupID call") } +func (s *apiKeyRepoStub) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) { + panic("unexpected UpdateGroupIDByUserAndGroup call") +} func (s *apiKeyRepoStub) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { panic("unexpected CountByGroupID call") diff --git a/backend/internal/service/api_key_service_quota_test.go b/backend/internal/service/api_key_service_quota_test.go index 2e2f6f78c9..cf05e16c48 100644 --- a/backend/internal/service/api_key_service_quota_test.go +++ b/backend/internal/service/api_key_service_quota_test.go @@ -122,6 +122,9 @@ func (s *quotaBaseAPIKeyRepoStub) SearchAPIKeys(context.Context, int64, string, func (s *quotaBaseAPIKeyRepoStub) ClearGroupIDByGroupID(context.Context, int64) (int64, error) { panic("unexpected ClearGroupIDByGroupID call") } +func (s *quotaBaseAPIKeyRepoStub) UpdateGroupIDByUserAndGroup(context.Context, int64, int64, int64) (int64, error) { + panic("unexpected UpdateGroupIDByUserAndGroup call") +} func (s *quotaBaseAPIKeyRepoStub) CountByGroupID(context.Context, int64) (int64, error) { panic("unexpected CountByGroupID call") } diff --git a/backend/internal/service/backup_service.go b/backend/internal/service/backup_service.go index 25f1e9a159..2fcf2da89f 100644 --- a/backend/internal/service/backup_service.go +++ b/backend/internal/service/backup_service.go @@ -4,11 +4,13 @@ import ( "compress/gzip" "context" "encoding/json" + "errors" "fmt" "io" "sort" "strings" "sync" + "sync/atomic" "time" "github.com/google/uuid" @@ -84,17 +86,21 @@ type BackupScheduleConfig struct { // BackupRecord 备份记录 type BackupRecord struct { - ID string `json:"id"` - Status string `json:"status"` // pending, running, completed, failed - BackupType string `json:"backup_type"` // postgres - FileName string `json:"file_name"` - S3Key string `json:"s3_key"` - SizeBytes int64 `json:"size_bytes"` - TriggeredBy string `json:"triggered_by"` // manual, scheduled - ErrorMsg string `json:"error_message,omitempty"` - StartedAt string `json:"started_at"` - FinishedAt string `json:"finished_at,omitempty"` - ExpiresAt string `json:"expires_at,omitempty"` // 过期时间 + ID string `json:"id"` + Status string `json:"status"` // pending, running, completed, failed + BackupType string `json:"backup_type"` // postgres + FileName string `json:"file_name"` + S3Key string `json:"s3_key"` + SizeBytes int64 `json:"size_bytes"` + TriggeredBy string `json:"triggered_by"` // manual, scheduled + ErrorMsg string `json:"error_message,omitempty"` + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` // 过期时间 + Progress string `json:"progress,omitempty"` // "dumping", "uploading", "" + RestoreStatus string `json:"restore_status,omitempty"` // "", "running", "completed", "failed" + RestoreError string `json:"restore_error,omitempty"` + RestoredAt string `json:"restored_at,omitempty"` } // BackupService 数据库备份恢复服务 @@ -105,17 +111,24 @@ type BackupService struct { storeFactory BackupObjectStoreFactory dumper DBDumper - mu sync.Mutex - store BackupObjectStore - s3Cfg *BackupS3Config + opMu sync.Mutex // 保护 backingUp/restoring 标志 backingUp bool restoring bool + storeMu sync.Mutex // 保护 store/s3Cfg 缓存 + store BackupObjectStore + s3Cfg *BackupS3Config + recordsMu sync.Mutex // 保护 records 的 load/save 操作 cronMu sync.Mutex cronSched *cron.Cron cronEntryID cron.EntryID + + wg sync.WaitGroup // 追踪活跃的备份/恢复 goroutine + shuttingDown atomic.Bool // 阻止新备份启动 + bgCtx context.Context // 所有后台操作的 parent context + bgCancel context.CancelFunc // 取消所有活跃后台操作 } func NewBackupService( @@ -125,20 +138,26 @@ func NewBackupService( storeFactory BackupObjectStoreFactory, dumper DBDumper, ) *BackupService { + bgCtx, bgCancel := context.WithCancel(context.Background()) return &BackupService{ settingRepo: settingRepo, dbCfg: &cfg.Database, encryptor: encryptor, storeFactory: storeFactory, dumper: dumper, + bgCtx: bgCtx, + bgCancel: bgCancel, } } -// Start 启动定时备份调度器 +// Start 启动定时备份调度器并清理孤立记录 func (s *BackupService) Start() { s.cronSched = cron.New() s.cronSched.Start() + // 清理重启后孤立的 running 记录 + s.recoverStaleRecords() + // 加载已有的定时配置 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -154,13 +173,65 @@ func (s *BackupService) Start() { } } -// Stop 停止定时备份 +// recoverStaleRecords 启动时将孤立的 running 记录标记为 failed +func (s *BackupService) recoverStaleRecords() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + records, err := s.loadRecords(ctx) + if err != nil { + return + } + for i := range records { + if records[i].Status == "running" { + records[i].Status = "failed" + records[i].ErrorMsg = "interrupted by server restart" + records[i].Progress = "" + records[i].FinishedAt = time.Now().Format(time.RFC3339) + _ = s.saveRecord(ctx, &records[i]) + logger.LegacyPrintf("service.backup", "[Backup] recovered stale running record: %s", records[i].ID) + } + if records[i].RestoreStatus == "running" { + records[i].RestoreStatus = "failed" + records[i].RestoreError = "interrupted by server restart" + _ = s.saveRecord(ctx, &records[i]) + logger.LegacyPrintf("service.backup", "[Backup] recovered stale restoring record: %s", records[i].ID) + } + } +} + +// Stop 停止定时备份并等待活跃操作完成 func (s *BackupService) Stop() { + s.shuttingDown.Store(true) + s.cronMu.Lock() - defer s.cronMu.Unlock() if s.cronSched != nil { s.cronSched.Stop() } + s.cronMu.Unlock() + + // 等待活跃备份/恢复完成(最多 5 分钟) + done := make(chan struct{}) + go func() { + s.wg.Wait() + close(done) + }() + select { + case <-done: + logger.LegacyPrintf("service.backup", "[Backup] all active operations finished") + case <-time.After(5 * time.Minute): + logger.LegacyPrintf("service.backup", "[Backup] shutdown timeout after 5min, cancelling active operations") + if s.bgCancel != nil { + s.bgCancel() // 取消所有后台操作 + } + // 给 goroutine 时间响应取消并完成清理 + select { + case <-done: + logger.LegacyPrintf("service.backup", "[Backup] active operations cancelled and cleaned up") + case <-time.After(10 * time.Second): + logger.LegacyPrintf("service.backup", "[Backup] goroutine cleanup timed out") + } + } } // ─── S3 配置管理 ─── @@ -203,10 +274,10 @@ func (s *BackupService) UpdateS3Config(ctx context.Context, cfg BackupS3Config) } // 清除缓存的 S3 客户端 - s.mu.Lock() + s.storeMu.Lock() s.store = nil s.s3Cfg = nil - s.mu.Unlock() + s.storeMu.Unlock() cfg.SecretAccessKey = "" return &cfg, nil @@ -314,7 +385,10 @@ func (s *BackupService) removeCronSchedule() { } func (s *BackupService) runScheduledBackup() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + s.wg.Add(1) + defer s.wg.Done() + + ctx, cancel := context.WithTimeout(s.bgCtx, 30*time.Minute) defer cancel() // 读取定时备份配置中的过期天数 @@ -327,7 +401,11 @@ func (s *BackupService) runScheduledBackup() { logger.LegacyPrintf("service.backup", "[Backup] 开始执行定时备份, 过期天数: %d", expireDays) record, err := s.CreateBackup(ctx, "scheduled", expireDays) if err != nil { - logger.LegacyPrintf("service.backup", "[Backup] 定时备份失败: %v", err) + if errors.Is(err, ErrBackupInProgress) { + logger.LegacyPrintf("service.backup", "[Backup] 定时备份跳过: 已有备份正在进行中") + } else { + logger.LegacyPrintf("service.backup", "[Backup] 定时备份失败: %v", err) + } return } logger.LegacyPrintf("service.backup", "[Backup] 定时备份完成: id=%s size=%d", record.ID, record.SizeBytes) @@ -346,17 +424,21 @@ func (s *BackupService) runScheduledBackup() { // CreateBackup 创建全量数据库备份并上传到 S3(流式处理) // expireDays: 备份过期天数,0=永不过期,默认14天 func (s *BackupService) CreateBackup(ctx context.Context, triggeredBy string, expireDays int) (*BackupRecord, error) { - s.mu.Lock() + if s.shuttingDown.Load() { + return nil, infraerrors.ServiceUnavailable("SERVER_SHUTTING_DOWN", "server is shutting down") + } + + s.opMu.Lock() if s.backingUp { - s.mu.Unlock() + s.opMu.Unlock() return nil, ErrBackupInProgress } s.backingUp = true - s.mu.Unlock() + s.opMu.Unlock() defer func() { - s.mu.Lock() + s.opMu.Lock() s.backingUp = false - s.mu.Unlock() + s.opMu.Unlock() }() s3Cfg, err := s.loadS3Config(ctx) @@ -405,36 +487,47 @@ func (s *BackupService) CreateBackup(ctx context.Context, triggeredBy string, ex // 使用 io.Pipe 将 gzip 压缩数据流式传递给 S3 上传 pr, pw := io.Pipe() - var gzipErr error + gzipDone := make(chan error, 1) go func() { + defer func() { + if r := recover(); r != nil { + pw.CloseWithError(fmt.Errorf("gzip goroutine panic: %v", r)) //nolint:errcheck + gzipDone <- fmt.Errorf("gzip goroutine panic: %v", r) + } + }() gzWriter := gzip.NewWriter(pw) - _, gzipErr = io.Copy(gzWriter, dumpReader) - if closeErr := gzWriter.Close(); closeErr != nil && gzipErr == nil { - gzipErr = closeErr + var gzErr error + _, gzErr = io.Copy(gzWriter, dumpReader) + if closeErr := gzWriter.Close(); closeErr != nil && gzErr == nil { + gzErr = closeErr } - if closeErr := dumpReader.Close(); closeErr != nil && gzipErr == nil { - gzipErr = closeErr + if closeErr := dumpReader.Close(); closeErr != nil && gzErr == nil { + gzErr = closeErr } - if gzipErr != nil { - _ = pw.CloseWithError(gzipErr) + if gzErr != nil { + _ = pw.CloseWithError(gzErr) } else { _ = pw.Close() } + gzipDone <- gzErr }() contentType := "application/gzip" sizeBytes, err := objectStore.Upload(ctx, s3Key, pr, contentType) if err != nil { + _ = pr.CloseWithError(err) // 确保 gzip goroutine 不会悬挂 + gzErr := <-gzipDone // 安全等待 gzip goroutine 完成 record.Status = "failed" errMsg := fmt.Sprintf("S3 upload failed: %v", err) - if gzipErr != nil { - errMsg = fmt.Sprintf("gzip/dump failed: %v", gzipErr) + if gzErr != nil { + errMsg = fmt.Sprintf("gzip/dump failed: %v", gzErr) } record.ErrorMsg = errMsg record.FinishedAt = time.Now().Format(time.RFC3339) _ = s.saveRecord(ctx, record) return record, fmt.Errorf("backup upload: %w", err) } + <-gzipDone // 确保 gzip goroutine 已退出 record.SizeBytes = sizeBytes record.Status = "completed" @@ -446,19 +539,187 @@ func (s *BackupService) CreateBackup(ctx context.Context, triggeredBy string, ex return record, nil } +// StartBackup 异步创建备份,立即返回 running 状态的记录 +func (s *BackupService) StartBackup(ctx context.Context, triggeredBy string, expireDays int) (*BackupRecord, error) { + if s.shuttingDown.Load() { + return nil, infraerrors.ServiceUnavailable("SERVER_SHUTTING_DOWN", "server is shutting down") + } + + s.opMu.Lock() + if s.backingUp { + s.opMu.Unlock() + return nil, ErrBackupInProgress + } + s.backingUp = true + s.opMu.Unlock() + + // 初始化阶段出错时自动重置标志 + launched := false + defer func() { + if !launched { + s.opMu.Lock() + s.backingUp = false + s.opMu.Unlock() + } + }() + + // 在返回前加载 S3 配置和创建 store,避免 goroutine 中配置被修改 + s3Cfg, err := s.loadS3Config(ctx) + if err != nil { + return nil, err + } + if s3Cfg == nil || !s3Cfg.IsConfigured() { + return nil, ErrBackupS3NotConfigured + } + + objectStore, err := s.getOrCreateStore(ctx, s3Cfg) + if err != nil { + return nil, fmt.Errorf("init object store: %w", err) + } + + now := time.Now() + backupID := uuid.New().String()[:8] + fileName := fmt.Sprintf("%s_%s.sql.gz", s.dbCfg.DBName, now.Format("20060102_150405")) + s3Key := s.buildS3Key(s3Cfg, fileName) + + var expiresAt string + if expireDays > 0 { + expiresAt = now.AddDate(0, 0, expireDays).Format(time.RFC3339) + } + + record := &BackupRecord{ + ID: backupID, + Status: "running", + BackupType: "postgres", + FileName: fileName, + S3Key: s3Key, + TriggeredBy: triggeredBy, + StartedAt: now.Format(time.RFC3339), + ExpiresAt: expiresAt, + Progress: "pending", + } + + if err := s.saveRecord(ctx, record); err != nil { + return nil, fmt.Errorf("save initial record: %w", err) + } + + launched = true + // 在启动 goroutine 前完成拷贝,避免数据竞争 + result := *record + + s.wg.Add(1) + go func() { + defer s.wg.Done() + defer func() { + s.opMu.Lock() + s.backingUp = false + s.opMu.Unlock() + }() + defer func() { + if r := recover(); r != nil { + logger.LegacyPrintf("service.backup", "[Backup] panic recovered: %v", r) + record.Status = "failed" + record.ErrorMsg = fmt.Sprintf("internal panic: %v", r) + record.Progress = "" + record.FinishedAt = time.Now().Format(time.RFC3339) + _ = s.saveRecord(context.Background(), record) + } + }() + s.executeBackup(record, objectStore) + }() + + return &result, nil +} + +// executeBackup 后台执行备份(独立于 HTTP context) +func (s *BackupService) executeBackup(record *BackupRecord, objectStore BackupObjectStore) { + ctx, cancel := context.WithTimeout(s.bgCtx, 30*time.Minute) + defer cancel() + + // 阶段1: pg_dump + record.Progress = "dumping" + _ = s.saveRecord(ctx, record) + + dumpReader, err := s.dumper.Dump(ctx) + if err != nil { + record.Status = "failed" + record.ErrorMsg = fmt.Sprintf("pg_dump failed: %v", err) + record.Progress = "" + record.FinishedAt = time.Now().Format(time.RFC3339) + _ = s.saveRecord(context.Background(), record) + return + } + + // 阶段2: gzip + upload + record.Progress = "uploading" + _ = s.saveRecord(ctx, record) + + pr, pw := io.Pipe() + gzipDone := make(chan error, 1) + go func() { + defer func() { + if r := recover(); r != nil { + pw.CloseWithError(fmt.Errorf("gzip goroutine panic: %v", r)) //nolint:errcheck + gzipDone <- fmt.Errorf("gzip goroutine panic: %v", r) + } + }() + gzWriter := gzip.NewWriter(pw) + var gzErr error + _, gzErr = io.Copy(gzWriter, dumpReader) + if closeErr := gzWriter.Close(); closeErr != nil && gzErr == nil { + gzErr = closeErr + } + if closeErr := dumpReader.Close(); closeErr != nil && gzErr == nil { + gzErr = closeErr + } + if gzErr != nil { + _ = pw.CloseWithError(gzErr) + } else { + _ = pw.Close() + } + gzipDone <- gzErr + }() + + contentType := "application/gzip" + sizeBytes, err := objectStore.Upload(ctx, record.S3Key, pr, contentType) + if err != nil { + _ = pr.CloseWithError(err) // 确保 gzip goroutine 不会悬挂 + gzErr := <-gzipDone // 安全等待 gzip goroutine 完成 + record.Status = "failed" + errMsg := fmt.Sprintf("S3 upload failed: %v", err) + if gzErr != nil { + errMsg = fmt.Sprintf("gzip/dump failed: %v", gzErr) + } + record.ErrorMsg = errMsg + record.Progress = "" + record.FinishedAt = time.Now().Format(time.RFC3339) + _ = s.saveRecord(context.Background(), record) + return + } + <-gzipDone // 确保 gzip goroutine 已退出 + + record.SizeBytes = sizeBytes + record.Status = "completed" + record.Progress = "" + record.FinishedAt = time.Now().Format(time.RFC3339) + if err := s.saveRecord(context.Background(), record); err != nil { + logger.LegacyPrintf("service.backup", "[Backup] 保存备份记录失败: %v", err) + } +} + // RestoreBackup 从 S3 下载备份并流式恢复到数据库 func (s *BackupService) RestoreBackup(ctx context.Context, backupID string) error { - s.mu.Lock() + s.opMu.Lock() if s.restoring { - s.mu.Unlock() + s.opMu.Unlock() return ErrRestoreInProgress } s.restoring = true - s.mu.Unlock() + s.opMu.Unlock() defer func() { - s.mu.Lock() + s.opMu.Lock() s.restoring = false - s.mu.Unlock() + s.opMu.Unlock() }() record, err := s.GetBackupRecord(ctx, backupID) @@ -500,6 +761,112 @@ func (s *BackupService) RestoreBackup(ctx context.Context, backupID string) erro return nil } +// StartRestore 异步恢复备份,立即返回 +func (s *BackupService) StartRestore(ctx context.Context, backupID string) (*BackupRecord, error) { + if s.shuttingDown.Load() { + return nil, infraerrors.ServiceUnavailable("SERVER_SHUTTING_DOWN", "server is shutting down") + } + + s.opMu.Lock() + if s.restoring { + s.opMu.Unlock() + return nil, ErrRestoreInProgress + } + s.restoring = true + s.opMu.Unlock() + + // 初始化阶段出错时自动重置标志 + launched := false + defer func() { + if !launched { + s.opMu.Lock() + s.restoring = false + s.opMu.Unlock() + } + }() + + record, err := s.GetBackupRecord(ctx, backupID) + if err != nil { + return nil, err + } + if record.Status != "completed" { + return nil, infraerrors.BadRequest("BACKUP_NOT_COMPLETED", "can only restore from a completed backup") + } + + s3Cfg, err := s.loadS3Config(ctx) + if err != nil { + return nil, err + } + objectStore, err := s.getOrCreateStore(ctx, s3Cfg) + if err != nil { + return nil, fmt.Errorf("init object store: %w", err) + } + + record.RestoreStatus = "running" + _ = s.saveRecord(ctx, record) + + launched = true + result := *record + + s.wg.Add(1) + go func() { + defer s.wg.Done() + defer func() { + s.opMu.Lock() + s.restoring = false + s.opMu.Unlock() + }() + defer func() { + if r := recover(); r != nil { + logger.LegacyPrintf("service.backup", "[Backup] restore panic recovered: %v", r) + record.RestoreStatus = "failed" + record.RestoreError = fmt.Sprintf("internal panic: %v", r) + _ = s.saveRecord(context.Background(), record) + } + }() + s.executeRestore(record, objectStore) + }() + + return &result, nil +} + +// executeRestore 后台执行恢复 +func (s *BackupService) executeRestore(record *BackupRecord, objectStore BackupObjectStore) { + ctx, cancel := context.WithTimeout(s.bgCtx, 30*time.Minute) + defer cancel() + + body, err := objectStore.Download(ctx, record.S3Key) + if err != nil { + record.RestoreStatus = "failed" + record.RestoreError = fmt.Sprintf("S3 download failed: %v", err) + _ = s.saveRecord(context.Background(), record) + return + } + defer func() { _ = body.Close() }() + + gzReader, err := gzip.NewReader(body) + if err != nil { + record.RestoreStatus = "failed" + record.RestoreError = fmt.Sprintf("gzip reader: %v", err) + _ = s.saveRecord(context.Background(), record) + return + } + defer func() { _ = gzReader.Close() }() + + if err := s.dumper.Restore(ctx, gzReader); err != nil { + record.RestoreStatus = "failed" + record.RestoreError = fmt.Sprintf("pg restore: %v", err) + _ = s.saveRecord(context.Background(), record) + return + } + + record.RestoreStatus = "completed" + record.RestoredAt = time.Now().Format(time.RFC3339) + if err := s.saveRecord(context.Background(), record); err != nil { + logger.LegacyPrintf("service.backup", "[Backup] 保存恢复记录失败: %v", err) + } +} + // ─── 备份记录管理 ─── func (s *BackupService) ListBackups(ctx context.Context) ([]BackupRecord, error) { @@ -614,8 +981,8 @@ func (s *BackupService) loadS3Config(ctx context.Context) (*BackupS3Config, erro } func (s *BackupService) getOrCreateStore(ctx context.Context, cfg *BackupS3Config) (BackupObjectStore, error) { - s.mu.Lock() - defer s.mu.Unlock() + s.storeMu.Lock() + defer s.storeMu.Unlock() if s.store != nil && s.s3Cfg != nil { return s.store, nil diff --git a/backend/internal/service/backup_service_test.go b/backend/internal/service/backup_service_test.go index e752997c35..b308e6d09d 100644 --- a/backend/internal/service/backup_service_test.go +++ b/backend/internal/service/backup_service_test.go @@ -134,6 +134,30 @@ func (m *mockDumper) Restore(_ context.Context, data io.Reader) error { return nil } +// blockingDumper 可控延迟的 dumper,用于测试异步行为 +type blockingDumper struct { + blockCh chan struct{} + data []byte + restErr error +} + +func (d *blockingDumper) Dump(ctx context.Context) (io.ReadCloser, error) { + select { + case <-d.blockCh: + case <-ctx.Done(): + return nil, ctx.Err() + } + return io.NopCloser(bytes.NewReader(d.data)), nil +} + +func (d *blockingDumper) Restore(_ context.Context, data io.Reader) error { + if d.restErr != nil { + return d.restErr + } + _, _ = io.ReadAll(data) + return nil +} + type mockObjectStore struct { objects map[string][]byte mu sync.Mutex @@ -179,7 +203,7 @@ func (m *mockObjectStore) HeadBucket(_ context.Context) error { return nil } -func newTestBackupService(repo *mockSettingRepo, dumper *mockDumper, store *mockObjectStore) *BackupService { +func newTestBackupService(repo *mockSettingRepo, dumper DBDumper, store *mockObjectStore) *BackupService { cfg := &config.Config{ Database: config.DatabaseConfig{ Host: "localhost", @@ -361,9 +385,9 @@ func TestBackupService_CreateBackup_ConcurrentBlocked(t *testing.T) { svc := newTestBackupService(repo, dumper, store) // 手动设置 backingUp 标志 - svc.mu.Lock() + svc.opMu.Lock() svc.backingUp = true - svc.mu.Unlock() + svc.opMu.Unlock() _, err := svc.CreateBackup(context.Background(), "manual", 14) require.ErrorIs(t, err, ErrBackupInProgress) @@ -526,3 +550,154 @@ func TestBackupService_LoadS3Config_Corrupted(t *testing.T) { require.Error(t, err) require.Nil(t, cfg) } + +// ─── Async Backup Tests ─── + +func TestStartBackup_ReturnsImmediately(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + + dumper := &blockingDumper{blockCh: make(chan struct{}), data: []byte("data")} + store := newMockObjectStore() + svc := newTestBackupService(repo, dumper, store) + + record, err := svc.StartBackup(context.Background(), "manual", 14) + require.NoError(t, err) + require.Equal(t, "running", record.Status) + require.NotEmpty(t, record.ID) + + // 释放 dumper 让后台完成 + close(dumper.blockCh) + svc.wg.Wait() + + // 验证最终状态 + final, err := svc.GetBackupRecord(context.Background(), record.ID) + require.NoError(t, err) + require.Equal(t, "completed", final.Status) + require.Greater(t, final.SizeBytes, int64(0)) +} + +func TestStartBackup_ConcurrentBlocked(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + + dumper := &blockingDumper{blockCh: make(chan struct{}), data: []byte("data")} + store := newMockObjectStore() + svc := newTestBackupService(repo, dumper, store) + + // 第一次启动 + _, err := svc.StartBackup(context.Background(), "manual", 14) + require.NoError(t, err) + + // 第二次应被阻塞 + _, err = svc.StartBackup(context.Background(), "manual", 14) + require.ErrorIs(t, err, ErrBackupInProgress) + + close(dumper.blockCh) + svc.wg.Wait() +} + +func TestStartBackup_ShuttingDown(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + svc := newTestBackupService(repo, &mockDumper{dumpData: []byte("data")}, newMockObjectStore()) + + svc.shuttingDown.Store(true) + + _, err := svc.StartBackup(context.Background(), "manual", 14) + require.Error(t, err) + require.Contains(t, err.Error(), "shutting down") +} + +func TestRecoverStaleRecords(t *testing.T) { + repo := newMockSettingRepo() + svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) + + // 模拟一条孤立的 running 记录 + _ = svc.saveRecord(context.Background(), &BackupRecord{ + ID: "stale-1", + Status: "running", + StartedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), + }) + // 模拟一条孤立的恢复中记录 + _ = svc.saveRecord(context.Background(), &BackupRecord{ + ID: "stale-2", + Status: "completed", + RestoreStatus: "running", + StartedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), + }) + + svc.recoverStaleRecords() + + r1, _ := svc.GetBackupRecord(context.Background(), "stale-1") + require.Equal(t, "failed", r1.Status) + require.Contains(t, r1.ErrorMsg, "server restart") + + r2, _ := svc.GetBackupRecord(context.Background(), "stale-2") + require.Equal(t, "failed", r2.RestoreStatus) + require.Contains(t, r2.RestoreError, "server restart") +} + +func TestGracefulShutdown(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + + dumper := &blockingDumper{blockCh: make(chan struct{}), data: []byte("data")} + store := newMockObjectStore() + svc := newTestBackupService(repo, dumper, store) + + _, err := svc.StartBackup(context.Background(), "manual", 14) + require.NoError(t, err) + + // Stop 应该等待备份完成 + done := make(chan struct{}) + go func() { + svc.Stop() + close(done) + }() + + // 短暂等待确认 Stop 还在等待 + select { + case <-done: + t.Fatal("Stop returned before backup finished") + case <-time.After(100 * time.Millisecond): + // 预期:Stop 还在等待 + } + + // 释放备份 + close(dumper.blockCh) + + // 现在 Stop 应该完成 + select { + case <-done: + // 预期 + case <-time.After(5 * time.Second): + t.Fatal("Stop did not return after backup finished") + } +} + +func TestStartRestore_Async(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + + dumpContent := "-- PostgreSQL dump\nCREATE TABLE test (id int);\n" + dumper := &mockDumper{dumpData: []byte(dumpContent)} + store := newMockObjectStore() + svc := newTestBackupService(repo, dumper, store) + + // 先创建一个备份(同步方式) + record, err := svc.CreateBackup(context.Background(), "manual", 14) + require.NoError(t, err) + + // 异步恢复 + restored, err := svc.StartRestore(context.Background(), record.ID) + require.NoError(t, err) + require.Equal(t, "running", restored.RestoreStatus) + + svc.wg.Wait() + + // 验证最终状态 + final, err := svc.GetBackupRecord(context.Background(), record.ID) + require.NoError(t, err) + require.Equal(t, "completed", final.RestoreStatus) +} diff --git a/backend/internal/service/claude_code_validator.go b/backend/internal/service/claude_code_validator.go index f71098b169..4e8ced6795 100644 --- a/backend/internal/service/claude_code_validator.go +++ b/backend/internal/service/claude_code_validator.go @@ -21,9 +21,6 @@ var ( // 带捕获组的版本提取正则 claudeCodeUAVersionPattern = regexp.MustCompile(`(?i)^claude-cli/(\d+\.\d+\.\d+)`) - // metadata.user_id 格式: user_{64位hex}_account__session_{uuid} - userIDPattern = regexp.MustCompile(`^user_[a-fA-F0-9]{64}_account__session_[\w-]+$`) - // System prompt 相似度阈值(默认 0.5,和 claude-relay-service 一致) systemPromptThreshold = 0.5 ) @@ -124,7 +121,7 @@ func (v *ClaudeCodeValidator) Validate(r *http.Request, body map[string]any) boo return false } - if !userIDPattern.MatchString(userID) { + if ParseMetadataUserID(userID) == nil { return false } @@ -278,11 +275,7 @@ func SetClaudeCodeClient(ctx context.Context, isClaudeCode bool) context.Context // ExtractVersion 从 User-Agent 中提取 Claude Code 版本号 // 返回 "2.1.22" 形式的版本号,如果不匹配返回空字符串 func (v *ClaudeCodeValidator) ExtractVersion(ua string) string { - matches := claudeCodeUAVersionPattern.FindStringSubmatch(ua) - if len(matches) >= 2 { - return matches[1] - } - return "" + return ExtractCLIVersion(ua) } // SetClaudeCodeVersion 将 Claude Code 版本号设置到 context 中 diff --git a/backend/internal/service/dashboard_service.go b/backend/internal/service/dashboard_service.go index 63cad24312..3e059e3069 100644 --- a/backend/internal/service/dashboard_service.go +++ b/backend/internal/service/dashboard_service.go @@ -140,6 +140,27 @@ func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTi return stats, nil } +func (s *DashboardService) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, modelSource string) ([]usagestats.ModelStat, error) { + normalizedSource := usagestats.NormalizeModelSource(modelSource) + if normalizedSource == usagestats.ModelSourceRequested { + return s.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType) + } + + type modelStatsBySourceRepo interface { + GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) ([]usagestats.ModelStat, error) + } + + if sourceRepo, ok := s.usageRepo.(modelStatsBySourceRepo); ok { + stats, err := sourceRepo.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, normalizedSource) + if err != nil { + return nil, fmt.Errorf("get model stats with filters by source: %w", err) + } + return stats, nil + } + + return s.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType) +} + func (s *DashboardService) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) { stats, err := s.usageRepo.GetGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType) if err != nil { @@ -148,6 +169,15 @@ func (s *DashboardService) GetGroupStatsWithFilters(ctx context.Context, startTi return stats, nil } +// GetGroupUsageSummary returns today's and cumulative cost for all groups. +func (s *DashboardService) GetGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) { + results, err := s.usageRepo.GetAllGroupUsageSummary(ctx, todayStart) + if err != nil { + return nil, fmt.Errorf("get group usage summary: %w", err) + } + return results, nil +} + func (s *DashboardService) getCachedDashboardStats(ctx context.Context) (*usagestats.DashboardStats, bool, error) { data, err := s.cache.GetDashboardStats(ctx) if err != nil { @@ -335,6 +365,14 @@ func (s *DashboardService) GetUserSpendingRanking(ctx context.Context, startTime return ranking, nil } +func (s *DashboardService) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) { + stats, err := s.usageRepo.GetUserBreakdownStats(ctx, startTime, endTime, dim, limit) + if err != nil { + return nil, fmt.Errorf("get user breakdown stats: %w", err) + } + return stats, nil +} + func (s *DashboardService) GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchUserUsageStats, error) { stats, err := s.usageRepo.GetBatchUserUsageStats(ctx, userIDs, startTime, endTime) if err != nil { diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 2d8681d44a..384d5159d7 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -170,6 +170,13 @@ const ( // SettingKeyOpsRuntimeLogConfig stores JSON config for runtime log settings. SettingKeyOpsRuntimeLogConfig = "ops_runtime_log_config" + // ========================= + // Overload Cooldown (529) + // ========================= + + // SettingKeyOverloadCooldownSettings stores JSON config for 529 overload cooldown handling. + SettingKeyOverloadCooldownSettings = "overload_cooldown_settings" + // ========================= // Stream Timeout Handling // ========================= @@ -219,6 +226,9 @@ const ( // SettingKeyMinClaudeCodeVersion 最低 Claude Code 版本号要求 (semver, 如 "2.1.0",空值=不检查) SettingKeyMinClaudeCodeVersion = "min_claude_code_version" + // SettingKeyMaxClaudeCodeVersion 最高 Claude Code 版本号限制 (semver, 如 "3.0.0",空值=不检查) + SettingKeyMaxClaudeCodeVersion = "max_claude_code_version" + // SettingKeyAllowUngroupedKeyScheduling 允许未分组 API Key 调度(默认 false:未分组 Key 返回 403) SettingKeyAllowUngroupedKeyScheduling = "allow_ungrouped_key_scheduling" diff --git a/backend/internal/service/gateway_affinity_flow.go b/backend/internal/service/gateway_affinity_flow.go new file mode 100644 index 0000000000..cf6e7e6e26 --- /dev/null +++ b/backend/internal/service/gateway_affinity_flow.go @@ -0,0 +1,239 @@ +package service + +import "context" + +// gatewayAffinityFlow encapsulates affinity-specific scheduling steps so the +// main account selection flow can stay focused on generic scheduling. +type gatewayAffinityFlow struct { + svc *GatewayService + ctx context.Context + groupID *int64 + sessionHash string + requestedModel string + affinityClientID string + affinityUserID int64 + platform string + useMixed bool + accountByID map[int64]*Account + isExcluded func(int64) bool +} + +type affinityWaitCandidate struct { + account *Account +} + +func newGatewayAffinityFlow( + svc *GatewayService, + ctx context.Context, + groupID *int64, + sessionHash string, + requestedModel string, + affinityClientID string, + affinityUserID int64, + platform string, + useMixed bool, + accountByID map[int64]*Account, + isExcluded func(int64) bool, +) *gatewayAffinityFlow { + return &gatewayAffinityFlow{ + svc: svc, + ctx: ctx, + groupID: groupID, + sessionHash: sessionHash, + requestedModel: requestedModel, + affinityClientID: affinityClientID, + affinityUserID: affinityUserID, + platform: platform, + useMixed: useMixed, + accountByID: accountByID, + isExcluded: isExcluded, + } +} + +// shouldFilterAccountWithoutClientID excludes affinity-enabled Anthropic accounts +// when metadata.user_id does not provide a usable client_id. +func shouldFilterAccountWithoutClientID(account *Account, affinityClientID string) bool { + if account == nil || affinityClientID != "" { + return false + } + if account.Platform != PlatformAnthropic { + return false + } + return account.IsAffinityEnabled() +} + +func filterAccountsWithoutClientID(accounts []Account, affinityClientID string) []Account { + if affinityClientID != "" { + return accounts + } + filtered := make([]Account, 0, len(accounts)) + for _, acc := range accounts { + if shouldFilterAccountWithoutClientID(&acc, affinityClientID) { + continue + } + filtered = append(filtered, acc) + } + return filtered +} + +func (f *gatewayAffinityFlow) preprocessPinnedUsers(accounts []Account) { + if f.affinityUserID <= 0 || f.affinityClientID == "" || f.svc.cache == nil { + return + } + for i := range accounts { + if accounts[i].IsPinnedUser(f.affinityUserID) && accounts[i].IsAffinityEnabled() { + _ = f.svc.cache.UpdateAffinity( + f.ctx, + derefGroupID(f.groupID), + f.affinityUserID, + f.affinityClientID, + accounts[i].ID, + ClientAffinityTTL, + ) + } + } +} + +// trySelectAffinityAccount runs Layer 1.4 and returns: +// - result != nil: affinity path selected an account or wait plan +// - affinityHit == true: an effective affinity-enabled record was considered and should suppress sticky fallback +func (f *gatewayAffinityFlow) trySelectAffinityAccount() (*AccountSelectionResult, bool, error) { + if f.affinityClientID == "" || f.affinityUserID <= 0 || f.svc.cache == nil { + return nil, false, nil + } + + gid := derefGroupID(f.groupID) + affinityAccountIDs, err := f.svc.cache.GetAffinityAccounts(f.ctx, gid, f.affinityUserID, f.affinityClientID, ClientAffinityTTL) + if err != nil || len(affinityAccountIDs) == 0 { + return nil, false, nil + } + + noSwitchBlocked := false + anyAllowSwitch := false + effectiveAffinityHit := false + waitCandidates := make(map[int64]*affinityWaitCandidate) + + for _, affinityAccID := range affinityAccountIDs { + account, ok := f.accountByID[affinityAccID] + + if f.isExcluded != nil && f.isExcluded(affinityAccID) { + checkAcc := account + if !ok && f.svc.accountRepo != nil { + if acc, repoErr := f.svc.accountRepo.GetByID(f.ctx, affinityAccID); repoErr == nil && acc != nil { + checkAcc = acc + } + } + if checkAcc != nil && checkAcc.IsAffinityEnabled() { + effectiveAffinityHit = true + if !checkAcc.IsAffinityAllowSwitch() { + noSwitchBlocked = true + } else { + anyAllowSwitch = true + } + } + continue + } + + if !ok || !f.svc.isAccountSchedulableForSelection(account) { + checkAcc := account + if !ok && f.svc.accountRepo != nil { + if acc, repoErr := f.svc.accountRepo.GetByID(f.ctx, affinityAccID); repoErr == nil && acc != nil { + checkAcc = acc + } + } + if checkAcc != nil && checkAcc.IsAffinityEnabled() { + effectiveAffinityHit = true + if !checkAcc.IsAffinityAllowSwitch() { + noSwitchBlocked = true + } else { + anyAllowSwitch = true + } + } + continue + } + + if !account.IsAffinityEnabled() { + continue + } + effectiveAffinityHit = true + if account.IsAffinityAllowSwitch() { + anyAllowSwitch = true + } + + if !f.svc.isAccountAllowedForPlatform(account, f.platform, f.useMixed) || + (f.requestedModel != "" && !f.svc.isModelSupportedByAccountWithContext(f.ctx, account, f.requestedModel)) || + !f.svc.isAccountSchedulableForModelSelection(f.ctx, account, f.requestedModel) || + !f.svc.isAccountSchedulableForQuota(account) || + !f.svc.isAccountSchedulableForWindowCost(f.ctx, account, false) || + !f.svc.isAccountSchedulableForRPM(f.ctx, account, false) { + if !account.IsAffinityAllowSwitch() { + noSwitchBlocked = true + } + continue + } + + userCount, clientCount, perUserCount, multiErr := f.svc.cache.GetAffinityMultiCount( + f.ctx, gid, affinityAccID, f.affinityUserID, ClientAffinityTTL, + ) + if multiErr == nil { + zone := account.GetMultiDimAffinityZone(userCount, clientCount, perUserCount) + if zone == AffinityZoneRed { + if !account.IsAffinityAllowSwitch() { + noSwitchBlocked = true + } + continue + } + } + + result, acquireErr := f.svc.tryAcquireAccountSlot(f.ctx, affinityAccID, account.Concurrency) + if acquireErr == nil && result.Acquired { + if !f.svc.checkAndRegisterSession(f.ctx, account, f.sessionHash) { + result.ReleaseFunc() + continue + } + _ = f.svc.cache.UpdateAffinity(f.ctx, gid, f.affinityUserID, f.affinityClientID, affinityAccID, ClientAffinityTTL) + if f.sessionHash != "" { + _ = f.svc.cache.SetSessionAccountID(f.ctx, gid, f.sessionHash, affinityAccID, stickySessionTTL) + } + return &AccountSelectionResult{ + Account: account, + Acquired: true, + ReleaseFunc: result.ReleaseFunc, + }, true, nil + } + if acquireErr == nil && !result.Acquired && !account.IsAffinityAllowSwitch() { + noSwitchBlocked = true + waitCandidates[affinityAccID] = &affinityWaitCandidate{account: account} + } + } + + if noSwitchBlocked && !anyAllowSwitch && f.svc.concurrencyService != nil { + for _, waitAccID := range affinityAccountIDs { + candidate, ok := waitCandidates[waitAccID] + if !ok || candidate == nil || candidate.account == nil { + continue + } + acc := candidate.account + waitingCount, _ := f.svc.concurrencyService.GetAccountWaitingCount(f.ctx, waitAccID) + if waitingCount >= f.svc.schedulingConfig().StickySessionMaxWaiting { + continue + } + if !f.svc.checkAndRegisterSession(f.ctx, acc, f.sessionHash) { + continue + } + cfg := f.svc.schedulingConfig() + return &AccountSelectionResult{ + Account: acc, + WaitPlan: &AccountWaitPlan{ + AccountID: waitAccID, + MaxConcurrency: acc.Concurrency, + Timeout: cfg.StickySessionWaitTimeout, + MaxWaiting: cfg.StickySessionMaxWaiting, + }, + }, true, nil + } + return nil, effectiveAffinityHit, ErrAffinityNoSwitch + } + + return nil, effectiveAffinityHit, nil +} diff --git a/backend/internal/service/gateway_affinity_scheduling_test.go b/backend/internal/service/gateway_affinity_scheduling_test.go index 0650081195..f5bf974c0a 100644 --- a/backend/internal/service/gateway_affinity_scheduling_test.go +++ b/backend/internal/service/gateway_affinity_scheduling_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -36,12 +37,15 @@ func (m *mockAffinityCache) RefreshSessionTTL(_ context.Context, _ int64, _ stri func (m *mockAffinityCache) DeleteSessionAccountID(_ context.Context, _ int64, _ string) error { return nil } -func (m *mockAffinityCache) GetClientAffinityAccounts(_ context.Context, _ int64, _ string, _ time.Duration) ([]int64, error) { +func (m *mockAffinityCache) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { return nil, nil } -func (m *mockAffinityCache) UpdateClientAffinity(_ context.Context, _ int64, _ string, _ int64, _ time.Duration) error { +func (m *mockAffinityCache) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error { return nil } +func (m *mockAffinityCache) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) { + return 0, 0, 0, nil +} func (m *mockAffinityCache) GetAccountAffinityCountBatch(ctx context.Context, groupID int64, accountIDs []int64, ttl time.Duration) (map[int64]int64, error) { m.getCountBatchCalls++ if m.getCountBatchFunc != nil { @@ -613,6 +617,28 @@ func TestAffinityIsClientAffinityEnabled(t *testing.T) { } assert.False(t, acc.IsClientAffinityEnabled(), "string 'true' should not enable affinity") }) + + t.Run("new flag false overrides legacy flag true", func(t *testing.T) { + acc := &Account{ + Platform: PlatformAnthropic, + Extra: map[string]any{ + "affinity_enabled": false, + "client_affinity_enabled": true, + }, + } + assert.False(t, acc.IsClientAffinityEnabled()) + }) + + t.Run("new flag true overrides legacy flag false", func(t *testing.T) { + acc := &Account{ + Platform: PlatformAnthropic, + Extra: map[string]any{ + "affinity_enabled": true, + "client_affinity_enabled": false, + }, + } + assert.True(t, acc.IsClientAffinityEnabled()) + }) } // =========================================================================== @@ -798,3 +824,936 @@ func TestClassifyByAffinityZone(t *testing.T) { require.Len(t, result, 2) }) } + +// =========================================================================== +// GetMultiDimAffinityZone 测试 +// =========================================================================== + +func TestGetMultiDimAffinityZone(t *testing.T) { + makeAccount := func(opts map[string]any) *Account { + extra := map[string]any{"affinity_enabled": true} + for k, v := range opts { + extra[k] = v + } + return &Account{ + Platform: PlatformAnthropic, + Extra: extra, + } + } + + t.Run("user green + client green = green", func(t *testing.T) { + acc := makeAccount(map[string]any{ + "affinity_base": 10, + "affinity_buffer": 5, + "affinity_user_base": 3, + "affinity_user_buffer": 2, + }) + assert.Equal(t, AffinityZoneGreen, acc.GetMultiDimAffinityZone(2, 5, 0)) + }) + + t.Run("user green + client yellow = yellow", func(t *testing.T) { + acc := makeAccount(map[string]any{ + "affinity_base": 10, + "affinity_buffer": 5, + "affinity_user_base": 3, + "affinity_user_buffer": 2, + }) + assert.Equal(t, AffinityZoneYellow, acc.GetMultiDimAffinityZone(2, 12, 0)) + }) + + t.Run("user yellow + client green = yellow", func(t *testing.T) { + acc := makeAccount(map[string]any{ + "affinity_base": 10, + "affinity_buffer": 5, + "affinity_user_base": 3, + "affinity_user_buffer": 2, + }) + assert.Equal(t, AffinityZoneYellow, acc.GetMultiDimAffinityZone(4, 5, 0)) + }) + + t.Run("user red + client green = red", func(t *testing.T) { + acc := makeAccount(map[string]any{ + "affinity_base": 10, + "affinity_buffer": 5, + "affinity_user_base": 3, + "affinity_user_buffer": 0, // no yellow, direct red + }) + assert.Equal(t, AffinityZoneRed, acc.GetMultiDimAffinityZone(4, 5, 0)) + }) + + t.Run("user green + client red = red", func(t *testing.T) { + acc := makeAccount(map[string]any{ + "affinity_base": 10, + "affinity_buffer": 0, // no yellow, direct red + "affinity_user_base": 3, + "affinity_user_buffer": 2, + }) + assert.Equal(t, AffinityZoneRed, acc.GetMultiDimAffinityZone(2, 11, 0)) + }) + + t.Run("user_base=0 only checks client dimension", func(t *testing.T) { + acc := makeAccount(map[string]any{ + "affinity_base": 10, + "affinity_buffer": 5, + // no affinity_user_base → 0 → user dimension always green + }) + // client in yellow range + assert.Equal(t, AffinityZoneYellow, acc.GetMultiDimAffinityZone(100, 12, 0)) + }) + + t.Run("perUserCount exceeds limit = red", func(t *testing.T) { + acc := makeAccount(map[string]any{ + "affinity_base": 10, + "affinity_buffer": 5, + "per_user_client_limit": 3, + }) + // client green, user green, but perUser > limit + assert.Equal(t, AffinityZoneRed, acc.GetMultiDimAffinityZone(1, 5, 4)) + }) + + t.Run("perUserCount within limit = green", func(t *testing.T) { + acc := makeAccount(map[string]any{ + "affinity_base": 10, + "affinity_buffer": 5, + "per_user_client_limit": 3, + }) + assert.Equal(t, AffinityZoneGreen, acc.GetMultiDimAffinityZone(1, 5, 3)) + }) + + t.Run("affinity disabled always green", func(t *testing.T) { + acc := &Account{ + Platform: PlatformAnthropic, + Extra: map[string]any{ + "affinity_enabled": false, + "affinity_base": 1, + "affinity_buffer": 0, + }, + } + assert.Equal(t, AffinityZoneGreen, acc.GetMultiDimAffinityZone(100, 100, 100)) + }) +} + +// =========================================================================== +// IsAffinityAllowSwitch 测试 +// =========================================================================== + +func TestIsAffinityAllowSwitch(t *testing.T) { + t.Run("default true when no field in Extra", func(t *testing.T) { + acc := &Account{ + Platform: PlatformAnthropic, + Extra: map[string]any{"affinity_enabled": true}, + } + assert.True(t, acc.IsAffinityAllowSwitch()) + }) + + t.Run("explicit false", func(t *testing.T) { + acc := &Account{ + Platform: PlatformAnthropic, + Extra: map[string]any{"affinity_allow_switch": false}, + } + assert.False(t, acc.IsAffinityAllowSwitch()) + }) + + t.Run("Extra nil defaults to true", func(t *testing.T) { + acc := &Account{ + Platform: PlatformAnthropic, + Extra: nil, + } + assert.True(t, acc.IsAffinityAllowSwitch()) + }) + + t.Run("explicit true", func(t *testing.T) { + acc := &Account{ + Platform: PlatformAnthropic, + Extra: map[string]any{"affinity_allow_switch": true}, + } + assert.True(t, acc.IsAffinityAllowSwitch()) + }) +} + +// =========================================================================== +// GetPinnedUsers / IsPinnedUser 测试 +// =========================================================================== + +func TestGetPinnedUsers(t *testing.T) { + t.Run("normal list", func(t *testing.T) { + acc := &Account{ + Platform: PlatformAnthropic, + Extra: map[string]any{ + "pinned_users": []any{float64(10), float64(20), float64(30)}, + }, + } + result := acc.GetPinnedUsers() + assert.Equal(t, []int64{10, 20, 30}, result) + }) + + t.Run("empty list", func(t *testing.T) { + acc := &Account{ + Platform: PlatformAnthropic, + Extra: map[string]any{ + "pinned_users": []any{}, + }, + } + result := acc.GetPinnedUsers() + assert.Nil(t, result) + }) + + t.Run("Extra nil", func(t *testing.T) { + acc := &Account{ + Platform: PlatformAnthropic, + Extra: nil, + } + result := acc.GetPinnedUsers() + assert.Nil(t, result) + }) + + t.Run("pinned_users not set", func(t *testing.T) { + acc := &Account{ + Platform: PlatformAnthropic, + Extra: map[string]any{}, + } + result := acc.GetPinnedUsers() + assert.Nil(t, result) + }) +} + +func TestIsPinnedUser(t *testing.T) { + acc := &Account{ + Platform: PlatformAnthropic, + Extra: map[string]any{ + "pinned_users": []any{float64(10), float64(20), float64(30)}, + }, + } + + t.Run("hit", func(t *testing.T) { + assert.True(t, acc.IsPinnedUser(20)) + }) + + t.Run("miss", func(t *testing.T) { + assert.False(t, acc.IsPinnedUser(99)) + }) + + t.Run("nil Extra", func(t *testing.T) { + nilAcc := &Account{Platform: PlatformAnthropic, Extra: nil} + assert.False(t, nilAcc.IsPinnedUser(10)) + }) +} + +// =========================================================================== +// Enhanced mock for integration tests (supports affinity lookups + tracking) +// =========================================================================== + +type affinityIntegrationCache struct { + mockAffinityCache + getAffinityAccountsFunc func(ctx context.Context, groupID int64, userID int64, clientID string, ttl time.Duration) ([]int64, error) + updateAffinityCalls []affinityUpdateCall + getMultiCountFunc func(ctx context.Context, groupID int64, accountID int64, targetUserID int64, ttl time.Duration) (int64, int64, int64, error) + sessionBindings map[string]int64 + getAffinityAccountsCalled bool // 标记 GetAffinityAccounts 是否已被调用,用于区分预处理/Layer 2 阶段 +} + +type affinityUpdateCall struct { + groupID int64 + userID int64 + clientID string + accountID int64 + beforeAffinityLookup bool // true 表示该调用发生在 GetAffinityAccounts 之前(预处理阶段) +} + +func (c *affinityIntegrationCache) GetSessionAccountID(_ context.Context, _ int64, hash string) (int64, error) { + if c.sessionBindings != nil { + if id, ok := c.sessionBindings[hash]; ok { + return id, nil + } + } + return 0, errors.New("not found") +} + +func (c *affinityIntegrationCache) GetAffinityAccounts(ctx context.Context, groupID int64, userID int64, clientID string, ttl time.Duration) ([]int64, error) { + c.getAffinityAccountsCalled = true + if c.getAffinityAccountsFunc != nil { + return c.getAffinityAccountsFunc(ctx, groupID, userID, clientID, ttl) + } + return nil, nil +} + +func (c *affinityIntegrationCache) UpdateAffinity(_ context.Context, groupID int64, userID int64, clientID string, accountID int64, _ time.Duration) error { + c.updateAffinityCalls = append(c.updateAffinityCalls, affinityUpdateCall{ + groupID: groupID, userID: userID, clientID: clientID, accountID: accountID, + beforeAffinityLookup: !c.getAffinityAccountsCalled, + }) + return nil +} + +func (c *affinityIntegrationCache) GetAffinityMultiCount(ctx context.Context, groupID int64, accountID int64, targetUserID int64, ttl time.Duration) (int64, int64, int64, error) { + if c.getMultiCountFunc != nil { + return c.getMultiCountFunc(ctx, groupID, accountID, targetUserID, ttl) + } + return 0, 0, 0, nil +} + +// =========================================================================== +// TestAffinityPreprocessPinnedUsers — 验证 pinned_users 预处理逻辑 +// =========================================================================== + +func TestAffinityPreprocessPinnedUsers(t *testing.T) { + ctx := context.Background() + testUserID := int64(42) + testClientID := "user_" + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + "_account_test" + + t.Run("pinned user triggers UpdateAffinity", func(t *testing.T) { + cache := &affinityIntegrationCache{} + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + Extra: map[string]any{ + "affinity_enabled": true, + "pinned_users": []any{float64(42)}, + }, + }, + { + ID: 2, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + Extra: map[string]any{"affinity_enabled": true}, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{1: true, 2: true}} + concSvc := NewConcurrencyService(concCache) + cfg := &config.Config{RunMode: config.RunModeStandard} + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + svc := &GatewayService{ + accountRepo: repo, + cache: cache, + cfg: cfg, + concurrencyService: concSvc, + } + + _, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID) + require.NoError(t, err) + + // 验证 UpdateAffinity 至少被调用了一次(pinned preprocess for account 1) + found := false + for _, call := range cache.updateAffinityCalls { + if call.accountID == 1 && call.userID == testUserID { + found = true + break + } + } + assert.True(t, found, "UpdateAffinity should be called for pinned account 1 with userID %d", testUserID) + }) + + t.Run("non-pinned user does not trigger UpdateAffinity preprocess", func(t *testing.T) { + cache := &affinityIntegrationCache{ + getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { + return nil, nil // 无亲和记录,直接进入 Layer 2 + }, + } + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + Extra: map[string]any{ + "affinity_enabled": true, + "pinned_users": []any{float64(99)}, // different user + }, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{1: true}} + concSvc := NewConcurrencyService(concCache) + cfg := &config.Config{RunMode: config.RunModeStandard} + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + svc := &GatewayService{ + accountRepo: repo, + cache: cache, + cfg: cfg, + concurrencyService: concSvc, + } + + _, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID) + require.NoError(t, err) + + // 验证预处理阶段(beforeAffinityLookup=true)没有为 userID=42 在 account 1 上调用 UpdateAffinity + for _, call := range cache.updateAffinityCalls { + if call.beforeAffinityLookup && call.accountID == 1 && call.userID == testUserID { + t.Errorf("UpdateAffinity should NOT be called in preprocess for non-pinned user %d on account %d", testUserID, call.accountID) + } + } + }) +} + +// =========================================================================== +// TestAffinityNoSwitchError — 验证 ErrAffinityNoSwitch 行为 +// =========================================================================== + +func TestAffinityNoSwitchError(t *testing.T) { + ctx := context.Background() + testUserID := int64(42) + testClientID := "user_" + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + "_account_test" + futureTime := time.Now().Add(1 * time.Hour) + + t.Run("affinity hit but unschedulable + allow_switch=false returns ErrAffinityNoSwitch", func(t *testing.T) { + cache := &affinityIntegrationCache{ + getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { + return []int64{1}, nil + }, + } + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, + RateLimitResetAt: &futureTime, // rate limited = unschedulable for selection + Concurrency: 5, + Extra: map[string]any{ + "affinity_enabled": true, + "affinity_allow_switch": false, + }, + }, + { + ID: 2, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}} + concSvc := NewConcurrencyService(concCache) + cfg := &config.Config{RunMode: config.RunModeStandard} + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + svc := &GatewayService{ + accountRepo: repo, + cache: cache, + cfg: cfg, + concurrencyService: concSvc, + } + + _, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID) + require.Error(t, err) + assert.ErrorIs(t, err, ErrAffinityNoSwitch) + }) + + t.Run("affinity hit but unschedulable + allow_switch=true continues to Layer 2", func(t *testing.T) { + cache := &affinityIntegrationCache{ + getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { + return []int64{1}, nil + }, + } + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, + RateLimitResetAt: &futureTime, // rate limited = unschedulable + Concurrency: 5, + Extra: map[string]any{ + "affinity_enabled": true, + "affinity_allow_switch": true, + }, + }, + { + ID: 2, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}} + concSvc := NewConcurrencyService(concCache) + cfg := &config.Config{RunMode: config.RunModeStandard} + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + svc := &GatewayService{ + accountRepo: repo, + cache: cache, + cfg: cfg, + concurrencyService: concSvc, + } + + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, int64(2), result.Account.ID, "should fall through to Layer 2 and select account 2") + }) + + t.Run("no affinity records - not affected by allow_switch", func(t *testing.T) { + cache := &affinityIntegrationCache{} // returns nil from GetAffinityAccounts + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + Extra: map[string]any{ + "affinity_enabled": true, + "affinity_allow_switch": false, + }, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{1: true}} + concSvc := NewConcurrencyService(concCache) + cfg := &config.Config{RunMode: config.RunModeStandard} + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + svc := &GatewayService{ + accountRepo: repo, + cache: cache, + cfg: cfg, + concurrencyService: concSvc, + } + + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, int64(1), result.Account.ID, "should select normally via Layer 2") + }) + + t.Run("affinity disabled account ignores allow_switch=false", func(t *testing.T) { + cache := &affinityIntegrationCache{ + getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { + return []int64{1}, nil + }, + } + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + Extra: map[string]any{ + "affinity_enabled": false, + "affinity_allow_switch": false, + }, + }, + { + ID: 2, Platform: PlatformAnthropic, Priority: 2, + Status: StatusActive, Schedulable: true, Concurrency: 5, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}} + concSvc := NewConcurrencyService(concCache) + cfg := &config.Config{RunMode: config.RunModeStandard} + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + svc := &GatewayService{ + accountRepo: repo, + cache: cache, + cfg: cfg, + concurrencyService: concSvc, + } + + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + assert.NotEqual(t, int64(0), result.Account.ID) + }) + + t.Run("allow_switch=false + model_unsupported returns ErrAffinityNoSwitch", func(t *testing.T) { + cache := &affinityIntegrationCache{ + getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { + return []int64{1}, nil + }, + } + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + Extra: map[string]any{ + "affinity_enabled": true, + "affinity_allow_switch": false, + }, + // model_mapping 仅包含 claude-3-haiku → 请求 claude-3-5-sonnet 将被拒绝 + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "claude-3-haiku-20240307": "claude-3-haiku-20240307", + }, + }, + }, + { + ID: 2, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}} + concSvc := NewConcurrencyService(concCache) + cfg := &config.Config{RunMode: config.RunModeStandard} + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + svc := &GatewayService{ + accountRepo: repo, + cache: cache, + cfg: cfg, + concurrencyService: concSvc, + } + + _, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID) + require.Error(t, err) + assert.ErrorIs(t, err, ErrAffinityNoSwitch) + }) + + t.Run("allow_switch=false + red_zone returns ErrAffinityNoSwitch", func(t *testing.T) { + cache := &affinityIntegrationCache{ + getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { + return []int64{1}, nil + }, + getMultiCountFunc: func(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) { + // userCount=5, clientCount=5, perUserCount=0 + // 账号 affinity_base=1, affinity_buffer=0 → clientCount(5) > base(1) + buffer(0) → 红区 + return 5, 5, 0, nil + }, + } + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + Extra: map[string]any{ + "affinity_enabled": true, + "affinity_allow_switch": false, + "affinity_base": 1, + "affinity_buffer": 0, // buffer=0 → 超过 base 直接红区 + }, + }, + { + ID: 2, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}} + concSvc := NewConcurrencyService(concCache) + cfg := &config.Config{RunMode: config.RunModeStandard} + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + svc := &GatewayService{ + accountRepo: repo, + cache: cache, + cfg: cfg, + concurrencyService: concSvc, + } + + _, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID) + require.Error(t, err) + assert.ErrorIs(t, err, ErrAffinityNoSwitch) + }) + + t.Run("one_allow_switch=true overrides another allow_switch=false (一票放行)", func(t *testing.T) { + // 账号 1: allow_switch=false + rate limited(不可调度) + // 账号 3: allow_switch=true + rate limited(不可调度) + // 一票放行:账号 3 允许切换 → 不阻断 → 降级到 Layer 2 选中账号 2 + cache := &affinityIntegrationCache{ + getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { + return []int64{1, 3}, nil + }, + } + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, + RateLimitResetAt: &futureTime, // rate limited + Concurrency: 5, + Extra: map[string]any{ + "affinity_enabled": true, + "affinity_allow_switch": false, + }, + }, + { + ID: 2, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + }, + { + ID: 3, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, + RateLimitResetAt: &futureTime, // rate limited + Concurrency: 5, + Extra: map[string]any{ + "affinity_enabled": true, + "affinity_allow_switch": true, + }, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}} + concSvc := NewConcurrencyService(concCache) + cfg := &config.Config{RunMode: config.RunModeStandard} + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + svc := &GatewayService{ + accountRepo: repo, + cache: cache, + cfg: cfg, + concurrencyService: concSvc, + } + + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, int64(2), result.Account.ID, "一票放行: should fall through to Layer 2") + }) + + t.Run("excluded affinity account with allow_switch=false returns ErrAffinityNoSwitch", func(t *testing.T) { + cache := &affinityIntegrationCache{ + getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { + return []int64{1}, nil + }, + } + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + Extra: map[string]any{ + "affinity_enabled": true, + "affinity_allow_switch": false, + }, + }, + { + ID: 2, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}} + concSvc := NewConcurrencyService(concCache) + cfg := &config.Config{RunMode: config.RunModeStandard} + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + svc := &GatewayService{ + accountRepo: repo, + cache: cache, + cfg: cfg, + concurrencyService: concSvc, + } + + excluded := map[int64]struct{}{1: {}} + _, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", excluded, testClientID, testUserID) + require.Error(t, err) + assert.ErrorIs(t, err, ErrAffinityNoSwitch) + }) + + t.Run("disabled affinity record does not suppress sticky fallback", func(t *testing.T) { + sessionHash := "sticky-disabled-affinity" + cache := &affinityIntegrationCache{ + getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { + return []int64{1}, nil + }, + sessionBindings: map[string]int64{sessionHash: 2}, + } + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + Extra: map[string]any{ + "affinity_enabled": false, + }, + }, + { + ID: 2, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}} + concSvc := NewConcurrencyService(concCache) + cfg := &config.Config{RunMode: config.RunModeStandard} + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + svc := &GatewayService{ + accountRepo: repo, + cache: cache, + cfg: cfg, + concurrencyService: concSvc, + } + + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, sessionHash, "claude-3-5-sonnet-20241022", nil, testClientID, testUserID) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + assert.Equal(t, int64(2), result.Account.ID) + }) + + t.Run("historical affinity record with disabled account still falls back to sticky account", func(t *testing.T) { + sessionHash := "sticky-disabled-status-affinity" + cache := &affinityIntegrationCache{ + getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { + return []int64{1}, nil + }, + sessionBindings: map[string]int64{sessionHash: 2}, + } + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, Platform: PlatformAnthropic, Priority: 1, + Status: StatusDisabled, Schedulable: true, Concurrency: 5, + Extra: map[string]any{ + "affinity_enabled": true, + }, + }, + { + ID: 2, Platform: PlatformAnthropic, Priority: 1, + Status: StatusActive, Schedulable: true, Concurrency: 5, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}} + concSvc := NewConcurrencyService(concCache) + cfg := &config.Config{RunMode: config.RunModeStandard} + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + svc := &GatewayService{ + accountRepo: repo, + cache: cache, + cfg: cfg, + concurrencyService: concSvc, + } + + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, sessionHash, "claude-3-5-sonnet-20241022", nil, testClientID, testUserID) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + assert.Equal(t, int64(2), result.Account.ID) + }) +} + +// =========================================================================== +// TestAffinityMultiDimClassify — 验证 classifyByAffinityZone 与多维度计数 +// =========================================================================== + +func TestAffinityMultiDimClassify(t *testing.T) { + t.Run("multi-dim zone classification with user and client dimensions", func(t *testing.T) { + makeAWL := func(id int64, extra map[string]any, count int64) accountWithLoad { + return accountWithLoad{ + account: &Account{ID: id, Platform: PlatformAnthropic, Extra: extra}, + loadInfo: &AccountLoadInfo{AccountID: id}, + affinityCount: count, + } + } + + // Account 1: client green (3 <= 5), classified as green by old classifyByAffinityZone + // Account 2: client red (10 > 8), classified as red + accs := []accountWithLoad{ + makeAWL(1, map[string]any{ + "affinity_enabled": true, + "affinity_base": 5, + "affinity_buffer": 3, + }, 3), + makeAWL(2, map[string]any{ + "affinity_enabled": true, + "affinity_base": 5, + "affinity_buffer": 3, + }, 10), + } + + result := classifyByAffinityZone(accs) + require.Len(t, result, 1) + assert.Equal(t, int64(1), result[0].account.ID, "only green account should remain") + }) + + t.Run("all green returns all", func(t *testing.T) { + makeAWL := func(id int64, count int64) accountWithLoad { + return accountWithLoad{ + account: &Account{ + ID: id, + Platform: PlatformAnthropic, + Extra: map[string]any{ + "affinity_enabled": true, + "affinity_base": 10, + "affinity_buffer": 5, + }, + }, + loadInfo: &AccountLoadInfo{AccountID: id}, + affinityCount: count, + } + } + + accs := []accountWithLoad{ + makeAWL(1, 3), + makeAWL(2, 5), + makeAWL(3, 10), // exactly at base + } + + result := classifyByAffinityZone(accs) + require.Len(t, result, 3) + }) +} diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 789cbab8ea..a01dd02aad 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -688,6 +688,83 @@ func TestGatewayService_AnthropicOAuth_NotAffectedByAPIKeyPassthroughToggle(t *t require.Contains(t, req.Header.Get("anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta") } +func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + body string + }{ + { + name: "system array", + body: `{"model":"claude-3-5-sonnet-latest","system":[{"type":"text","text":"x-anthropic-billing-header keep"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`, + }, + { + name: "system string", + body: `{"model":"claude-3-5-sonnet-latest","system":"x-anthropic-billing-header keep","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + parsed, err := ParseGatewayRequest([]byte(tt.body), PlatformAnthropic) + require.NoError(t, err) + + upstream := &anthropicHTTPUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "x-request-id": []string{"rid-oauth-preserve"}, + }, + Body: io.NopCloser(strings.NewReader(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-3-5-sonnet-20241022","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":12,"output_tokens":7}}`)), + }, + } + + cfg := &config.Config{ + Gateway: config.GatewayConfig{ + MaxLineSize: defaultMaxLineSize, + }, + } + svc := &GatewayService{ + cfg: cfg, + responseHeaderFilter: compileResponseHeaderFilter(cfg), + httpUpstream: upstream, + rateLimitService: &RateLimitService{}, + deferredService: &DeferredService{}, + } + + account := &Account{ + ID: 301, + Name: "anthropic-oauth-preserve", + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + }, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, parsed) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("authorization")) + require.Contains(t, upstream.lastReq.Header.Get("anthropic-beta"), claude.BetaOAuth) + + system := gjson.GetBytes(upstream.lastBody, "system") + require.True(t, system.Exists()) + require.Contains(t, system.Raw, "x-anthropic-billing-header keep") + }) + } +} + func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingStillCollectsUsageAfterClientDisconnect(t *testing.T) { gin.SetMode(gin.TestMode) @@ -788,7 +865,7 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_NonStreamingSuc rateLimitService: &RateLimitService{}, } - result, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, newAnthropicAPIKeyAccountForTest(), body, "claude-3-5-sonnet-latest", false, time.Now()) + result, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, newAnthropicAPIKeyAccountForTest(), body, "claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest", false, time.Now()) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 12, result.Usage.InputTokens) @@ -815,7 +892,7 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_InvalidTokenTyp } svc := &GatewayService{} - result, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, account, []byte(`{}`), "claude-3-5-sonnet-latest", false, time.Now()) + result, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, account, []byte(`{}`), "claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest", false, time.Now()) require.Nil(t, result) require.Error(t, err) require.Contains(t, err.Error(), "requires apikey token") @@ -840,7 +917,7 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_UpstreamRequest } account := newAnthropicAPIKeyAccountForTest() - result, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, account, []byte(`{"model":"x"}`), "x", false, time.Now()) + result, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, account, []byte(`{"model":"x"}`), "x", "x", false, time.Now()) require.Nil(t, result) require.Error(t, err) require.Contains(t, err.Error(), "upstream request failed") @@ -873,7 +950,7 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_EmptyResponseBo httpUpstream: upstream, } - result, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, newAnthropicAPIKeyAccountForTest(), []byte(`{"model":"x"}`), "x", false, time.Now()) + result, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, newAnthropicAPIKeyAccountForTest(), []byte(`{"model":"x"}`), "x", "x", false, time.Now()) require.Nil(t, result) require.Error(t, err) require.Contains(t, err.Error(), "empty response") diff --git a/backend/internal/service/gateway_body_order_test.go b/backend/internal/service/gateway_body_order_test.go new file mode 100644 index 0000000000..641522f092 --- /dev/null +++ b/backend/internal/service/gateway_body_order_test.go @@ -0,0 +1,72 @@ +package service + +import ( + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/stretchr/testify/require" +) + +func assertJSONTokenOrder(t *testing.T, body string, tokens ...string) { + t.Helper() + + last := -1 + for _, token := range tokens { + pos := strings.Index(body, token) + require.NotEqualf(t, -1, pos, "missing token %s in body %s", token, body) + require.Greaterf(t, pos, last, "token %s should appear after previous tokens in body %s", token, body) + last = pos + } +} + +func TestReplaceModelInBody_PreservesTopLevelFieldOrder(t *testing.T) { + svc := &GatewayService{} + body := []byte(`{"alpha":1,"model":"claude-3-5-sonnet-latest","messages":[],"omega":2}`) + + result := svc.replaceModelInBody(body, "claude-3-5-sonnet-20241022") + resultStr := string(result) + + assertJSONTokenOrder(t, resultStr, `"alpha"`, `"model"`, `"messages"`, `"omega"`) + require.Contains(t, resultStr, `"model":"claude-3-5-sonnet-20241022"`) +} + +func TestNormalizeClaudeOAuthRequestBody_PreservesTopLevelFieldOrder(t *testing.T) { + body := []byte(`{"alpha":1,"model":"claude-3-5-sonnet-latest","temperature":0.2,"system":"You are OpenCode, the best coding agent on the planet.","messages":[],"tool_choice":{"type":"auto"},"omega":2}`) + + result, modelID := normalizeClaudeOAuthRequestBody(body, "claude-3-5-sonnet-latest", claudeOAuthNormalizeOptions{ + injectMetadata: true, + metadataUserID: "user-1", + }) + resultStr := string(result) + + require.Equal(t, claude.NormalizeModelID("claude-3-5-sonnet-latest"), modelID) + assertJSONTokenOrder(t, resultStr, `"alpha"`, `"model"`, `"system"`, `"messages"`, `"omega"`, `"tools"`, `"metadata"`) + require.NotContains(t, resultStr, `"temperature"`) + require.NotContains(t, resultStr, `"tool_choice"`) + require.Contains(t, resultStr, `"system":"`+claudeCodeSystemPrompt+`"`) + require.Contains(t, resultStr, `"tools":[]`) + require.Contains(t, resultStr, `"metadata":{"user_id":"user-1"}`) +} + +func TestInjectClaudeCodePrompt_PreservesFieldOrder(t *testing.T) { + body := []byte(`{"alpha":1,"system":[{"id":"block-1","type":"text","text":"Custom"}],"messages":[],"omega":2}`) + + result := injectClaudeCodePrompt(body, []any{ + map[string]any{"id": "block-1", "type": "text", "text": "Custom"}, + }) + resultStr := string(result) + + assertJSONTokenOrder(t, resultStr, `"alpha"`, `"system"`, `"messages"`, `"omega"`) + require.Contains(t, resultStr, `{"id":"block-1","type":"text","text":"`+claudeCodeSystemPrompt+`\n\nCustom"}`) +} + +func TestEnforceCacheControlLimit_PreservesTopLevelFieldOrder(t *testing.T) { + body := []byte(`{"alpha":1,"system":[{"type":"text","text":"s1","cache_control":{"type":"ephemeral"}},{"type":"text","text":"s2","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":[{"type":"text","text":"m1","cache_control":{"type":"ephemeral"}},{"type":"text","text":"m2","cache_control":{"type":"ephemeral"}},{"type":"text","text":"m3","cache_control":{"type":"ephemeral"}}]}],"omega":2}`) + + result := enforceCacheControlLimit(body) + resultStr := string(result) + + assertJSONTokenOrder(t, resultStr, `"alpha"`, `"system"`, `"messages"`, `"omega"`) + require.Equal(t, 4, strings.Count(resultStr, `"cache_control"`)) +} diff --git a/backend/internal/service/gateway_debug_env_test.go b/backend/internal/service/gateway_debug_env_test.go new file mode 100644 index 0000000000..4f48dc7030 --- /dev/null +++ b/backend/internal/service/gateway_debug_env_test.go @@ -0,0 +1,34 @@ +package service + +import "testing" + +func TestDebugGatewayBodyLoggingEnabled(t *testing.T) { + t.Run("default disabled", func(t *testing.T) { + t.Setenv(debugGatewayBodyEnv, "") + if debugGatewayBodyLoggingEnabled() { + t.Fatalf("expected debug gateway body logging to be disabled by default") + } + }) + + t.Run("enabled with true-like values", func(t *testing.T) { + for _, value := range []string{"1", "true", "TRUE", "yes", "on"} { + t.Run(value, func(t *testing.T) { + t.Setenv(debugGatewayBodyEnv, value) + if !debugGatewayBodyLoggingEnabled() { + t.Fatalf("expected debug gateway body logging to be enabled for %q", value) + } + }) + } + }) + + t.Run("disabled with other values", func(t *testing.T) { + for _, value := range []string{"0", "false", "off", "debug"} { + t.Run(value, func(t *testing.T) { + t.Setenv(debugGatewayBodyEnv, value) + if debugGatewayBodyLoggingEnabled() { + t.Fatalf("expected debug gateway body logging to be disabled for %q", value) + } + }) + } + }) +} diff --git a/backend/internal/service/gateway_hotpath_optimization_test.go b/backend/internal/service/gateway_hotpath_optimization_test.go index 0203c3311f..af108c6fc2 100644 --- a/backend/internal/service/gateway_hotpath_optimization_test.go +++ b/backend/internal/service/gateway_hotpath_optimization_test.go @@ -143,12 +143,15 @@ func (s *stickyGatewayCacheHotpathStub) RefreshSessionTTL(ctx context.Context, g func (s *stickyGatewayCacheHotpathStub) DeleteSessionAccountID(ctx context.Context, groupID int64, sessionHash string) error { return nil } -func (s *stickyGatewayCacheHotpathStub) GetClientAffinityAccounts(_ context.Context, _ int64, _ string, _ time.Duration) ([]int64, error) { +func (s *stickyGatewayCacheHotpathStub) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { return nil, nil } -func (s *stickyGatewayCacheHotpathStub) UpdateClientAffinity(_ context.Context, _ int64, _ string, _ int64, _ time.Duration) error { +func (s *stickyGatewayCacheHotpathStub) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error { return nil } +func (s *stickyGatewayCacheHotpathStub) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) { + return 0, 0, 0, nil +} func (s *stickyGatewayCacheHotpathStub) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) { return map[int64]int64{}, nil } @@ -750,7 +753,7 @@ func TestSelectAccountWithLoadAwareness_StickyReadReuse(t *testing.T) { modelsListCacheTTL: time.Minute, } - result, err := svc.SelectAccountWithLoadAwareness(baseCtx, nil, "sess-hash", "", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(baseCtx, nil, "sess-hash", "", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -772,7 +775,7 @@ func TestSelectAccountWithLoadAwareness_StickyReadReuse(t *testing.T) { ctx := context.WithValue(baseCtx, ctxkey.PrefetchedStickyAccountID, account.ID) ctx = context.WithValue(ctx, ctxkey.PrefetchedStickyGroupID, int64(0)) - result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sess-hash", "", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sess-hash", "", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -794,7 +797,7 @@ func TestSelectAccountWithLoadAwareness_StickyReadReuse(t *testing.T) { ctx := context.WithValue(baseCtx, ctxkey.PrefetchedStickyAccountID, int64(999)) ctx = context.WithValue(ctx, ctxkey.PrefetchedStickyGroupID, int64(77)) - result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sess-hash", "", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sess-hash", "", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index fcec495738..c258d1a5fb 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -235,12 +235,15 @@ func (m *mockGatewayCacheForPlatform) DeleteSessionAccountID(ctx context.Context return nil } -func (m *mockGatewayCacheForPlatform) GetClientAffinityAccounts(_ context.Context, _ int64, _ string, _ time.Duration) ([]int64, error) { +func (m *mockGatewayCacheForPlatform) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { return nil, nil } -func (m *mockGatewayCacheForPlatform) UpdateClientAffinity(_ context.Context, _ int64, _ string, _ int64, _ time.Duration) error { +func (m *mockGatewayCacheForPlatform) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error { return nil } +func (m *mockGatewayCacheForPlatform) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) { + return 0, 0, 0, nil +} func (m *mockGatewayCacheForPlatform) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) { return map[int64]int64{}, nil } @@ -297,8 +300,8 @@ func (m *mockGroupRepoForGateway) ListActiveByPlatform(ctx context.Context, plat func (m *mockGroupRepoForGateway) ExistsByName(ctx context.Context, name string) (bool, error) { return false, nil } -func (m *mockGroupRepoForGateway) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { - return 0, nil +func (m *mockGroupRepoForGateway) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) { + return 0, 0, nil } func (m *mockGroupRepoForGateway) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, nil @@ -2050,7 +2053,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: nil, // No concurrency service } - result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -2103,7 +2106,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: nil, // legacy path } - result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, sessionHash, "claude-b", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, sessionHash, "claude-b", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -2135,7 +2138,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: nil, } - result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -2167,13 +2170,314 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { } excludedIDs := map[int64]struct{}{1: {}} - result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", excludedIDs, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", excludedIDs, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) require.Equal(t, int64(2), result.Account.ID, "不应选择被排除的账号") }) + t.Run("无客户端ID时过滤Anthropic OAuth亲和账号-load-aware路径", func(t *testing.T) { + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Priority: 1, + Status: StatusActive, + Schedulable: true, + Concurrency: 5, + Extra: map[string]any{ + "client_affinity_enabled": true, + }, + }, + { + ID: 2, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Priority: 2, + Status: StatusActive, + Schedulable: true, + Concurrency: 5, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + cfg := testConfig() + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + svc := &GatewayService{ + accountRepo: repo, + cache: &mockGatewayCacheForPlatform{}, + cfg: cfg, + concurrencyService: NewConcurrencyService(&mockConcurrencyCache{}), + } + + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "", 0) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(2), result.Account.ID, "无客户端ID时应过滤亲和OAuth账号") + }) + + t.Run("无客户端ID且亲和关闭时-load-aware路径恢复旧逻辑", func(t *testing.T) { + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Priority: 1, + Status: StatusActive, + Schedulable: true, + Concurrency: 5, + Extra: map[string]any{ + "affinity_enabled": false, + }, + }, + { + ID: 2, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Priority: 2, + Status: StatusActive, + Schedulable: true, + Concurrency: 5, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + cfg := testConfig() + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + svc := &GatewayService{ + accountRepo: repo, + cache: &mockGatewayCacheForPlatform{}, + cfg: cfg, + concurrencyService: NewConcurrencyService(&mockConcurrencyCache{}), + } + + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "", 0) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(1), result.Account.ID, "关闭亲和后不应再被无clientID过滤") + }) + + t.Run("有客户端ID时不过滤Anthropic OAuth亲和账号", func(t *testing.T) { + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Priority: 1, + Status: StatusActive, + Schedulable: true, + Concurrency: 5, + Extra: map[string]any{ + "client_affinity_enabled": true, + }, + }, + { + ID: 2, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Priority: 2, + Status: StatusActive, + Schedulable: true, + Concurrency: 5, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + cfg := testConfig() + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + svc := &GatewayService{ + accountRepo: repo, + cache: &mockGatewayCacheForPlatform{}, + cfg: cfg, + concurrencyService: NewConcurrencyService(&mockConcurrencyCache{}), + } + + metadataUserID := "user_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef_account_test_session_test" + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, metadataUserID, 123) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(1), result.Account.ID, "有客户端ID时应允许亲和OAuth账号参与调度") + }) + + t.Run("过滤对Anthropic全类型生效-SetupToken也受影响", func(t *testing.T) { + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, + Platform: PlatformAnthropic, + Type: AccountTypeSetupToken, + Priority: 1, + Status: StatusActive, + Schedulable: true, + Concurrency: 5, + Extra: map[string]any{ + "client_affinity_enabled": true, + }, + }, + { + ID: 2, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Priority: 2, + Status: StatusActive, + Schedulable: true, + Concurrency: 5, + Extra: map[string]any{ + "client_affinity_enabled": true, + }, + }, + { + ID: 3, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Priority: 3, + Status: StatusActive, + Schedulable: true, + Concurrency: 5, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + cfg := testConfig() + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + svc := &GatewayService{ + accountRepo: repo, + cache: &mockGatewayCacheForPlatform{}, + cfg: cfg, + concurrencyService: NewConcurrencyService(&mockConcurrencyCache{}), + } + + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "", 0) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(3), result.Account.ID, "无客户端ID时,Anthropic SetupToken/OAuth(开启亲和)都应被过滤") + }) + + t.Run("无客户端ID时过滤Anthropic OAuth亲和账号-legacy路径", func(t *testing.T) { + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Priority: 1, + Status: StatusActive, + Schedulable: true, + Concurrency: 5, + Extra: map[string]any{ + "client_affinity_enabled": true, + }, + }, + { + ID: 2, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Priority: 2, + Status: StatusActive, + Schedulable: true, + Concurrency: 5, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + cfg := testConfig() + cfg.Gateway.Scheduling.LoadBatchEnabled = false + + svc := &GatewayService{ + accountRepo: repo, + cache: &mockGatewayCacheForPlatform{}, + cfg: cfg, + concurrencyService: nil, + } + + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "", 0) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(2), result.Account.ID, "legacy路径也应过滤亲和OAuth账号") + }) + + t.Run("无客户端ID且亲和关闭时-legacy路径恢复旧逻辑", func(t *testing.T) { + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Priority: 1, + Status: StatusActive, + Schedulable: true, + Concurrency: 5, + Extra: map[string]any{ + "client_affinity_enabled": false, + }, + }, + { + ID: 2, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Priority: 2, + Status: StatusActive, + Schedulable: true, + Concurrency: 5, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + cfg := testConfig() + cfg.Gateway.Scheduling.LoadBatchEnabled = false + + svc := &GatewayService{ + accountRepo: repo, + cache: &mockGatewayCacheForPlatform{}, + cfg: cfg, + concurrencyService: nil, + } + + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "", 0) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(1), result.Account.ID, "关闭亲和后 legacy 路径不应再被无clientID过滤") + }) + t.Run("粘性命中-不调用GetByID", func(t *testing.T) { repo := &mockAccountRepoForPlatform{ accounts: []Account{ @@ -2201,7 +2505,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: NewConcurrencyService(concurrencyCache), } - result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sticky", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sticky", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -2237,7 +2541,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: NewConcurrencyService(concurrencyCache), } - result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sticky", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sticky", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -2278,7 +2582,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: NewConcurrencyService(concurrencyCache), } - result, err := svc.SelectAccountWithLoadAwareness(testCtx, nil, "sticky", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(testCtx, nil, "sticky", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -2306,7 +2610,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: nil, } - result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.Error(t, err) require.Nil(t, result) require.ErrorIs(t, err, ErrNoAvailableAccounts) @@ -2338,7 +2642,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: nil, } - result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -2371,7 +2675,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: nil, } - result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -2409,7 +2713,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: NewConcurrencyService(concurrencyCache), } - result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sticky", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sticky", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.WaitPlan) @@ -2445,7 +2749,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: NewConcurrencyService(concurrencyCache), } - result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "legacy", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "legacy", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -2504,7 +2808,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: NewConcurrencyService(concurrencyCache), } - result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, sessionHash, "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, sessionHash, "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.WaitPlan) @@ -2558,7 +2862,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: NewConcurrencyService(concurrencyCache), } - result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, sessionHash, "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, sessionHash, "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -2612,7 +2916,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: NewConcurrencyService(concurrencyCache), } - result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, sessionHash, "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, sessionHash, "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -2670,7 +2974,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: NewConcurrencyService(concurrencyCache), } - result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, "route", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, "route", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -2728,7 +3032,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: NewConcurrencyService(concurrencyCache), } - result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, "route-full", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, "route-full", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.WaitPlan) @@ -2786,7 +3090,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: NewConcurrencyService(concurrencyCache), } - result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, "fallback", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, "fallback", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -2823,7 +3127,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: NewConcurrencyService(concurrencyCache), } - result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.WaitPlan) @@ -2875,7 +3179,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: NewConcurrencyService(concurrencyCache), } - result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, "gemini", "gemini-2.5-pro", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, "gemini", "gemini-2.5-pro", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -2953,7 +3257,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { } excluded := map[int64]struct{}{1: {}} - result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, "", "claude-3-5-sonnet-20241022", excluded, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, "", "claude-3-5-sonnet-20241022", excluded, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -3007,7 +3311,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: nil, } - result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, "", "gemini-2.5-pro", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, "", "gemini-2.5-pro", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) @@ -3040,7 +3344,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: nil, } - result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, "", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, "", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.Error(t, err) require.Nil(t, result) require.ErrorIs(t, err, ErrClaudeCodeOnly) @@ -3078,7 +3382,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: NewConcurrencyService(concurrencyCache), } - result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "wait", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "wait", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.WaitPlan) @@ -3116,7 +3420,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { concurrencyService: NewConcurrencyService(concurrencyCache), } - result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "missing-load", "claude-3-5-sonnet-20241022", nil, "") + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "missing-load", "claude-3-5-sonnet-20241022", nil, "", int64(0)) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Account) diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index 3816aea9a9..29b6cfd6e1 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -28,6 +28,12 @@ var ( patternEmptyContentSpaced = []byte(`"content": []`) patternEmptyContentSp1 = []byte(`"content" : []`) patternEmptyContentSp2 = []byte(`"content" :[]`) + + // Fast-path patterns for empty text blocks: {"type":"text","text":""} + patternEmptyText = []byte(`"text":""`) + patternEmptyTextSpaced = []byte(`"text": ""`) + patternEmptyTextSp1 = []byte(`"text" : ""`) + patternEmptyTextSp2 = []byte(`"text" :""`) ) // SessionContext 粘性会话上下文,用于区分不同来源的请求。 @@ -233,15 +239,22 @@ func FilterThinkingBlocksForRetry(body []byte) []byte { bytes.Contains(body, patternThinkingField) || bytes.Contains(body, patternThinkingFieldSpaced) - // Also check for empty content arrays that need fixing. + // Also check for empty content arrays and empty text blocks that need fixing. // Note: This is a heuristic check; the actual empty content handling is done below. hasEmptyContent := bytes.Contains(body, patternEmptyContent) || bytes.Contains(body, patternEmptyContentSpaced) || bytes.Contains(body, patternEmptyContentSp1) || bytes.Contains(body, patternEmptyContentSp2) + // Check for empty text blocks: {"type":"text","text":""} + // These cause upstream 400: "text content blocks must be non-empty" + hasEmptyTextBlock := bytes.Contains(body, patternEmptyText) || + bytes.Contains(body, patternEmptyTextSpaced) || + bytes.Contains(body, patternEmptyTextSp1) || + bytes.Contains(body, patternEmptyTextSp2) + // Fast path: nothing to process - if !hasThinkingContent && !hasEmptyContent { + if !hasThinkingContent && !hasEmptyContent && !hasEmptyTextBlock { return body } @@ -260,7 +273,7 @@ func FilterThinkingBlocksForRetry(body []byte) []byte { bytes.Contains(body, patternTypeRedactedThinking) || bytes.Contains(body, patternTypeRedactedSpaced) || bytes.Contains(body, patternThinkingFieldSpaced) - if !hasEmptyContent && !containsThinkingBlocks { + if !hasEmptyContent && !hasEmptyTextBlock && !containsThinkingBlocks { if topThinking := gjson.Get(jsonStr, "thinking"); topThinking.Exists() { if out, err := sjson.DeleteBytes(body, "thinking"); err == nil { out = removeThinkingDependentContextStrategies(out) @@ -320,6 +333,16 @@ func FilterThinkingBlocksForRetry(body []byte) []byte { blockType, _ := blockMap["type"].(string) + // Strip empty text blocks: {"type":"text","text":""} + // Upstream rejects these with 400: "text content blocks must be non-empty" + if blockType == "text" { + if txt, _ := blockMap["text"].(string); txt == "" { + modifiedThisMsg = true + ensureNewContent(bi) + continue + } + } + // Convert thinking blocks to text (preserve content) and drop redacted_thinking. switch blockType { case "thinking": diff --git a/backend/internal/service/gateway_request_test.go b/backend/internal/service/gateway_request_test.go index f60ed9fbc8..b11fee9b13 100644 --- a/backend/internal/service/gateway_request_test.go +++ b/backend/internal/service/gateway_request_test.go @@ -404,6 +404,51 @@ func TestFilterThinkingBlocksForRetry_EmptyContentGetsPlaceholder(t *testing.T) require.NotEmpty(t, content0["text"]) } +func TestFilterThinkingBlocksForRetry_StripsEmptyTextBlocks(t *testing.T) { + // Empty text blocks cause upstream 400: "text content blocks must be non-empty" + input := []byte(`{ + "messages":[ + {"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":""}]}, + {"role":"assistant","content":[{"type":"text","text":""}]} + ] + }`) + + out := FilterThinkingBlocksForRetry(input) + + var req map[string]any + require.NoError(t, json.Unmarshal(out, &req)) + msgs, ok := req["messages"].([]any) + require.True(t, ok) + + // First message: empty text block stripped, "hello" preserved + msg0 := msgs[0].(map[string]any) + content0 := msg0["content"].([]any) + require.Len(t, content0, 1) + require.Equal(t, "hello", content0[0].(map[string]any)["text"]) + + // Second message: only had empty text block → gets placeholder + msg1 := msgs[1].(map[string]any) + content1 := msg1["content"].([]any) + require.Len(t, content1, 1) + block1 := content1[0].(map[string]any) + require.Equal(t, "text", block1["type"]) + require.NotEmpty(t, block1["text"]) +} + +func TestFilterThinkingBlocksForRetry_PreservesNonEmptyTextBlocks(t *testing.T) { + // Non-empty text blocks should pass through unchanged + input := []byte(`{ + "messages":[ + {"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":"world"}]} + ] + }`) + + out := FilterThinkingBlocksForRetry(input) + + // Fast path: no thinking content, no empty content, no empty text blocks → unchanged + require.Equal(t, input, out) +} + func TestFilterSignatureSensitiveBlocksForRetry_DowngradesTools(t *testing.T) { input := []byte(`{ "thinking":{"type":"enabled","budget_tokens":1024}, diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 1204e71314..c464005862 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -52,6 +52,7 @@ const ( defaultUserGroupRateCacheTTL = 30 * time.Second defaultModelsListCacheTTL = 15 * time.Second postUsageBillingTimeout = 15 * time.Second + debugGatewayBodyEnv = "SUB2API_DEBUG_GATEWAY_BODY" ) const ( @@ -334,7 +335,6 @@ func isClaudeCodeCredentialScopeError(msg string) bool { // Some upstream APIs return non-standard "data:" without space (should be "data: "). var ( sseDataRe = regexp.MustCompile(`^data:\s*`) - sessionIDRegex = regexp.MustCompile(`session_([a-f0-9-]{36})`) claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`) // clientIDFromMetadataRegex 从 metadata.user_id 中提取客户端 ID(64位 hex) @@ -352,18 +352,18 @@ var ( } ) -// systemBlockFilterPrefixes 需要从 system 中过滤的文本前缀列表 -// OAuth/SetupToken 账号转发时,匹配这些前缀的 system 元素会被移除 -var systemBlockFilterPrefixes = []string{ - "x-anthropic-billing-header", -} - // ErrNoAvailableAccounts 表示没有可用的账号 var ErrNoAvailableAccounts = errors.New("no available accounts") // ErrClaudeCodeOnly 表示分组仅允许 Claude Code 客户端访问 var ErrClaudeCodeOnly = errors.New("this group only allows Claude Code clients") +// ErrAffinityNoSwitch 表示亲和账号不可用且不允许切换到其他账号 +var ErrAffinityNoSwitch = errors.New("affinity account unavailable and switching is disabled") + +// ErrAffinityLimitExceeded 表示亲和客户端限制已达上限 +var ErrAffinityLimitExceeded = errors.New("affinity client limit exceeded") + // allowedHeaders 白名单headers(参考CRS项目) var allowedHeaders = map[string]bool{ "accept": true, @@ -405,24 +405,29 @@ type GatewayCache interface { // Delete sticky session binding, used to proactively clean up when account becomes unavailable DeleteSessionAccountID(ctx context.Context, groupID int64, sessionHash string) error - // GetClientAffinityAccounts 获取客户端亲和账号列表(按最近使用降序),同时清理过期成员 - GetClientAffinityAccounts(ctx context.Context, groupID int64, clientID string, ttl time.Duration) ([]int64, error) - // UpdateClientAffinity 添加/更新客户端亲和关系(更新 score 为当前时间戳,刷新 key TTL) - UpdateClientAffinity(ctx context.Context, groupID int64, clientID string, accountID int64, ttl time.Duration) error - // GetAccountAffinityCountBatch 批量获取账号的亲和客户端数量(惰性清理过期成员) + // GetAffinityAccounts 获取亲和账号列表(按最近使用降序),同时清理过期成员 + GetAffinityAccounts(ctx context.Context, groupID int64, userID int64, clientID string, ttl time.Duration) ([]int64, error) + // UpdateAffinity 添加/更新亲和关系(更新 score 为当前时间戳,刷新 key TTL) + UpdateAffinity(ctx context.Context, groupID int64, userID int64, clientID string, accountID int64, ttl time.Duration) error + // GetAccountAffinityCountBatch 批量获取账号的亲和成员数量(惰性清理过期成员) GetAccountAffinityCountBatch(ctx context.Context, groupID int64, accountIDs []int64, ttl time.Duration) (map[int64]int64, error) - // GetAccountAffinityClientsBatch 批量获取每个账号跨所有分组的亲和客户端列表(去重) + // GetAccountAffinityClientsBatch 批量获取每个账号跨所有分组的亲和成员列表(去重) // accountGroups: map[accountID][]groupID + // 返回值成员格式为 {userID}/{clientID} GetAccountAffinityClientsBatch(ctx context.Context, accountGroups map[int64][]int64, ttl time.Duration) (map[int64][]string, error) // GetAccountAffinityClientsWithScores 获取单个账号跨所有分组的亲和客户端列表(含最后活跃时间) GetAccountAffinityClientsWithScores(ctx context.Context, accountID int64, groupIDs []int64, ttl time.Duration) ([]AffinityClient, error) // ClearAccountAffinity 清除指定账号在所有分组的亲和记录(正向+反向索引) - // 用于账号关闭客户端亲和时立即清理旧绑定 + // 用于账号关闭亲和时立即清理旧绑定 ClearAccountAffinity(ctx context.Context, accountID int64, groupIDs []int64) error + // GetAffinityMultiCount 获取账号的多维度亲和计数 + // 返回: uniqueUsers, uniqueClients, perUserClients + GetAffinityMultiCount(ctx context.Context, groupID int64, accountID int64, targetUserID int64, ttl time.Duration) (users, clients, perUser int64, err error) } -// AffinityClient 亲和客户端信息(含最后活跃时间) +// AffinityClient 亲和客户端信息(含用户 ID 和最后活跃时间) type AffinityClient struct { + UserID int64 `json:"user_id"` ClientID string `json:"client_id"` LastActive time.Time `json:"last_active"` } @@ -545,6 +550,7 @@ type ForwardResult struct { RequestID string Usage ClaudeUsage Model string + UpstreamModel string // Actual upstream model after mapping (empty = no mapping) Stream bool Duration time.Duration FirstTokenMs *int // 首字时间(流式请求) @@ -698,8 +704,8 @@ func (s *GatewayService) GenerateSessionHash(parsed *ParsedRequest) string { // 1. 最高优先级:从 metadata.user_id 提取 session_xxx if parsed.MetadataUserID != "" { - if match := sessionIDRegex.FindStringSubmatch(parsed.MetadataUserID); len(match) > 1 { - return match[1] + if uid := ParseMetadataUserID(parsed.MetadataUserID); uid != nil && uid.SessionID != "" { + return uid.SessionID } } @@ -894,20 +900,30 @@ func (s *GatewayService) hashContent(content string) string { return strconv.FormatUint(h, 36) } +type anthropicCacheControlPayload struct { + Type string `json:"type"` +} + +type anthropicSystemTextBlockPayload struct { + Type string `json:"type"` + Text string `json:"text"` + CacheControl *anthropicCacheControlPayload `json:"cache_control,omitempty"` +} + +type anthropicMetadataPayload struct { + UserID string `json:"user_id"` +} + // replaceModelInBody 替换请求体中的model字段 -// 使用 json.RawMessage 保留其他字段的原始字节,避免 thinking 块等内容被修改 +// 优先使用定点修改,尽量保持客户端原始字段顺序。 func (s *GatewayService) replaceModelInBody(body []byte, newModel string) []byte { - var req map[string]json.RawMessage - if err := json.Unmarshal(body, &req); err != nil { + if len(body) == 0 { return body } - // 只序列化 model 字段 - modelBytes, err := json.Marshal(newModel) - if err != nil { + if current := gjson.GetBytes(body, "model"); current.Exists() && current.String() == newModel { return body } - req["model"] = modelBytes - newBody, err := json.Marshal(req) + newBody, err := sjson.SetBytes(body, "model", newModel) if err != nil { return body } @@ -938,24 +954,146 @@ func sanitizeSystemText(text string) string { return text } -func stripCacheControlFromSystemBlocks(system any) bool { - blocks, ok := system.([]any) - if !ok { - return false +func marshalAnthropicSystemTextBlock(text string, includeCacheControl bool) ([]byte, error) { + block := anthropicSystemTextBlockPayload{ + Type: "text", + Text: text, } - changed := false - for _, item := range blocks { - block, ok := item.(map[string]any) - if !ok { - continue - } - if _, exists := block["cache_control"]; !exists { - continue - } - delete(block, "cache_control") - changed = true + if includeCacheControl { + block.CacheControl = &anthropicCacheControlPayload{Type: "ephemeral"} } - return changed + return json.Marshal(block) +} + +func marshalAnthropicMetadata(userID string) ([]byte, error) { + return json.Marshal(anthropicMetadataPayload{UserID: userID}) +} + +func buildJSONArrayRaw(items [][]byte) []byte { + if len(items) == 0 { + return []byte("[]") + } + + total := 2 + for _, item := range items { + total += len(item) + } + total += len(items) - 1 + + buf := make([]byte, 0, total) + buf = append(buf, '[') + for i, item := range items { + if i > 0 { + buf = append(buf, ',') + } + buf = append(buf, item...) + } + buf = append(buf, ']') + return buf +} + +func setJSONValueBytes(body []byte, path string, value any) ([]byte, bool) { + next, err := sjson.SetBytes(body, path, value) + if err != nil { + return body, false + } + return next, true +} + +func setJSONRawBytes(body []byte, path string, raw []byte) ([]byte, bool) { + next, err := sjson.SetRawBytes(body, path, raw) + if err != nil { + return body, false + } + return next, true +} + +func deleteJSONPathBytes(body []byte, path string) ([]byte, bool) { + next, err := sjson.DeleteBytes(body, path) + if err != nil { + return body, false + } + return next, true +} + +func normalizeClaudeOAuthSystemBody(body []byte, opts claudeOAuthNormalizeOptions) ([]byte, bool) { + sys := gjson.GetBytes(body, "system") + if !sys.Exists() { + return body, false + } + + out := body + modified := false + + switch { + case sys.Type == gjson.String: + sanitized := sanitizeSystemText(sys.String()) + if sanitized != sys.String() { + if next, ok := setJSONValueBytes(out, "system", sanitized); ok { + out = next + modified = true + } + } + case sys.IsArray(): + index := 0 + sys.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "text" { + textResult := item.Get("text") + if textResult.Exists() && textResult.Type == gjson.String { + text := textResult.String() + sanitized := sanitizeSystemText(text) + if sanitized != text { + if next, ok := setJSONValueBytes(out, fmt.Sprintf("system.%d.text", index), sanitized); ok { + out = next + modified = true + } + } + } + } + + if opts.stripSystemCacheControl && item.Get("cache_control").Exists() { + if next, ok := deleteJSONPathBytes(out, fmt.Sprintf("system.%d.cache_control", index)); ok { + out = next + modified = true + } + } + + index++ + return true + }) + } + + return out, modified +} + +func ensureClaudeOAuthMetadataUserID(body []byte, userID string) ([]byte, bool) { + if strings.TrimSpace(userID) == "" { + return body, false + } + + metadata := gjson.GetBytes(body, "metadata") + if !metadata.Exists() || metadata.Type == gjson.Null { + raw, err := marshalAnthropicMetadata(userID) + if err != nil { + return body, false + } + return setJSONRawBytes(body, "metadata", raw) + } + + trimmedRaw := strings.TrimSpace(metadata.Raw) + if strings.HasPrefix(trimmedRaw, "{") { + existing := metadata.Get("user_id") + if existing.Exists() && existing.Type == gjson.String && existing.String() != "" { + return body, false + } + return setJSONValueBytes(body, "metadata.user_id", userID) + } + + raw, err := marshalAnthropicMetadata(userID) + if err != nil { + return body, false + } + return setJSONRawBytes(body, "metadata", raw) } func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAuthNormalizeOptions) ([]byte, string) { @@ -963,96 +1101,59 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu return body, modelID } - // 解析为 map[string]any 用于修改字段 - var req map[string]any - if err := json.Unmarshal(body, &req); err != nil { - return body, modelID - } - + out := body modified := false - if system, ok := req["system"]; ok { - switch v := system.(type) { - case string: - sanitized := sanitizeSystemText(v) - if sanitized != v { - req["system"] = sanitized - modified = true - } - case []any: - for _, item := range v { - block, ok := item.(map[string]any) - if !ok { - continue - } - if blockType, _ := block["type"].(string); blockType != "text" { - continue - } - text, ok := block["text"].(string) - if !ok || text == "" { - continue - } - sanitized := sanitizeSystemText(text) - if sanitized != text { - block["text"] = sanitized - modified = true - } - } - } + if next, changed := normalizeClaudeOAuthSystemBody(out, opts); changed { + out = next + modified = true } - if rawModel, ok := req["model"].(string); ok { - normalized := claude.NormalizeModelID(rawModel) - if normalized != rawModel { - req["model"] = normalized + rawModel := gjson.GetBytes(out, "model") + if rawModel.Exists() && rawModel.Type == gjson.String { + normalized := claude.NormalizeModelID(rawModel.String()) + if normalized != rawModel.String() { + if next, ok := setJSONValueBytes(out, "model", normalized); ok { + out = next + modified = true + } modelID = normalized - modified = true } } // 确保 tools 字段存在(即使为空数组) - if _, exists := req["tools"]; !exists { - req["tools"] = []any{} - modified = true - } - - if opts.stripSystemCacheControl { - if system, ok := req["system"]; ok { - _ = stripCacheControlFromSystemBlocks(system) + if !gjson.GetBytes(out, "tools").Exists() { + if next, ok := setJSONRawBytes(out, "tools", []byte("[]")); ok { + out = next modified = true } } if opts.injectMetadata && opts.metadataUserID != "" { - metadata, ok := req["metadata"].(map[string]any) - if !ok { - metadata = map[string]any{} - req["metadata"] = metadata - } - if existing, ok := metadata["user_id"].(string); !ok || existing == "" { - metadata["user_id"] = opts.metadataUserID + if next, changed := ensureClaudeOAuthMetadataUserID(out, opts.metadataUserID); changed { + out = next modified = true } } - if _, hasTemp := req["temperature"]; hasTemp { - delete(req, "temperature") - modified = true + if gjson.GetBytes(out, "temperature").Exists() { + if next, ok := deleteJSONPathBytes(out, "temperature"); ok { + out = next + modified = true + } } - if _, hasChoice := req["tool_choice"]; hasChoice { - delete(req, "tool_choice") - modified = true + if gjson.GetBytes(out, "tool_choice").Exists() { + if next, ok := deleteJSONPathBytes(out, "tool_choice"); ok { + out = next + modified = true + } } if !modified { return body, modelID } - newBody, err := json.Marshal(req) - if err != nil { - return body, modelID - } - return newBody, modelID + return out, modelID } func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account *Account, fp *Fingerprint) string { @@ -1080,13 +1181,13 @@ func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account sessionID = generateSessionUUID(seed) } - // Prefer the newer format that includes account_uuid (if present), - // otherwise fall back to the legacy Claude Code format. - accountUUID := strings.TrimSpace(account.GetExtraString("account_uuid")) - if accountUUID != "" { - return fmt.Sprintf("user_%s_account_%s_session_%s", userID, accountUUID, sessionID) + // 根据指纹 UA 版本选择输出格式 + var uaVersion string + if fp != nil { + uaVersion = ExtractCLIVersion(fp.UserAgent) } - return fmt.Sprintf("user_%s_account__session_%s", userID, sessionID) + accountUUID := strings.TrimSpace(account.GetExtraString("account_uuid")) + return FormatMetadataUserID(userID, accountUUID, sessionID, uaVersion) } // GenerateSessionUUID creates a deterministic UUID4 from a seed string. @@ -1148,8 +1249,10 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context } // SelectAccountWithLoadAwareness selects account with load-awareness and wait plan. +// 调度流程文档见 docs/ACCOUNT_SCHEDULING_FLOW.md 。 // metadataUserID: 用于客户端亲和调度,从中提取客户端 ID -func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, metadataUserID string) (*AccountSelectionResult, error) { +// sub2apiUserID: 系统用户 ID,用于二维亲和调度 +func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, metadataUserID string, sub2apiUserID int64) (*AccountSelectionResult, error) { // 调试日志:记录调度入口参数 excludedIDsList := make([]int64, 0, len(excludedIDs)) for id := range excludedIDs { @@ -1181,6 +1284,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro // 提取客户端 ID(用于客户端亲和调度) affinityClientID := extractClientIDFromMetadata(metadataUserID) + affinityUserID := sub2apiUserID if s.debugModelRoutingEnabled() && requestedModel != "" { groupPlatform := "" @@ -1203,6 +1307,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if err != nil { return nil, err } + if shouldFilterAccountWithoutClientID(account, affinityClientID) { + localExcluded[account.ID] = struct{}{} + continue + } result, err := s.tryAcquireAccountSlot(ctx, account.ID, account.Concurrency) if err == nil && result.Acquired { @@ -1264,12 +1372,18 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if err != nil { return nil, err } + accounts = filterAccountsWithoutClientID(accounts, affinityClientID) if len(accounts) == 0 { return nil, ErrNoAvailableAccounts } ctx = s.withWindowCostPrefetch(ctx, accounts) ctx = s.withRPMPrefetch(ctx, accounts) + // 提前构建 accountByID(供 Layer 1 和 Layer 1.5 使用) + accountByID := make(map[int64]*Account, len(accounts)) + for i := range accounts { + accountByID[accounts[i].ID] = &accounts[i] + } isExcluded := func(accountID int64) bool { if excludedIDs == nil { return false @@ -1277,12 +1391,19 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro _, excluded := excludedIDs[accountID] return excluded } - - // 提前构建 accountByID(供 Layer 1 和 Layer 1.5 使用) - accountByID := make(map[int64]*Account, len(accounts)) - for i := range accounts { - accountByID[accounts[i].ID] = &accounts[i] - } + affinityFlow := newGatewayAffinityFlow( + s, + ctx, + groupID, + sessionHash, + requestedModel, + affinityClientID, + affinityUserID, + platform, + useMixed, + accountByID, + isExcluded, + ) // 获取模型路由配置(仅 anthropic 平台) var routingAccountIDs []int64 @@ -1485,8 +1606,8 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if sessionHash != "" && s.cache != nil { _ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, item.account.ID, stickySessionTTL) } - if affinityClientID != "" && s.cache != nil && item.account.IsClientAffinityEnabled() { - _ = s.cache.UpdateClientAffinity(ctx, derefGroupID(groupID), affinityClientID, item.account.ID, ClientAffinityTTL) + if affinityClientID != "" && affinityUserID > 0 && s.cache != nil && item.account.IsAffinityEnabled() { + _ = s.cache.UpdateAffinity(ctx, derefGroupID(groupID), affinityUserID, affinityClientID, item.account.ID, ClientAffinityTTL) } if s.debugModelRoutingEnabled() { logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed select: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), item.account.ID) @@ -1525,14 +1646,27 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro } } - // ============ Layer 1.5: 粘性会话(仅在无模型路由配置时生效) ============ - if len(routingAccountIDs) == 0 && sessionHash != "" && stickyAccountID > 0 && !isExcluded(stickyAccountID) { + // ============ Layer 1.3: 用户亲和预处理(pinned_users 自动注入) ============ + affinityFlow.preprocessPinnedUsers(accounts) + + // ============ Layer 1.4: 客户端亲和调度(优先于粘性会话) ============ + affinityHit := false + if affinityResult, hit, err := affinityFlow.trySelectAffinityAccount(); err != nil { + return nil, err + } else { + affinityHit = hit + if affinityResult != nil { + return affinityResult, nil + } + } + + // ============ Layer 1.5: 粘性会话(仅在无模型路由配置 且 亲和未命中时生效) ============ + if !affinityHit && len(routingAccountIDs) == 0 && sessionHash != "" && stickyAccountID > 0 && !isExcluded(stickyAccountID) { accountID := stickyAccountID if accountID > 0 && !isExcluded(accountID) { account, ok := accountByID[accountID] if ok { // 检查账户是否需要清理粘性会话绑定 - // Check if the account needs sticky session cleanup clearSticky := shouldClearStickySession(account, requestedModel) if clearSticky { _ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash) @@ -1548,7 +1682,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency) if err == nil && result.Acquired { // 会话数量限制检查 - // Session count limit check if !s.checkAndRegisterSession(ctx, account, sessionHash) { result.ReleaseFunc() // 释放槽位,继续到 Layer 2 } else { @@ -1563,10 +1696,8 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, accountID) if waitingCount < cfg.StickySessionMaxWaiting { // 会话数量限制检查(等待计划也需要占用会话配额) - // Session count limit check (wait plan also requires session quota) if !s.checkAndRegisterSession(ctx, account, sessionHash) { // 会话限制已满,继续到 Layer 2 - // Session limit full, continue to Layer 2 } else { return &AccountSelectionResult{ Account: account, @@ -1584,76 +1715,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro } } - // ============ Layer 1.6: 客户端亲和(仅在粘性会话未命中时生效) ============ - if affinityClientID != "" && s.cache != nil && stickyAccountID <= 0 { - affinityAccountIDs, err := s.cache.GetClientAffinityAccounts(ctx, derefGroupID(groupID), affinityClientID, ClientAffinityTTL) - if err == nil && len(affinityAccountIDs) > 0 { - for _, affinityAccID := range affinityAccountIDs { - if isExcluded(affinityAccID) { - continue - } - account, ok := accountByID[affinityAccID] - if !ok || !s.isAccountSchedulableForSelection(account) { - continue - } - if !account.IsClientAffinityEnabled() { - continue - } - if !s.isAccountAllowedForPlatform(account, platform, useMixed) { - continue - } - if requestedModel != "" && !s.isModelSupportedByAccountWithContext(ctx, account, requestedModel) { - continue - } - if !s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) { - continue - } - if !s.isAccountSchedulableForQuota(account) { - continue - } - if !s.isAccountSchedulableForWindowCost(ctx, account, false) { - continue - } - if !s.isAccountSchedulableForRPM(ctx, account, false) { - continue - } - // 亲和三区检查:红区账号不可通过亲和命中调度 - if account.GetAffinityBase() > 0 && s.cache != nil { - countMap, err := s.cache.GetAccountAffinityCountBatch(ctx, derefGroupID(groupID), []int64{affinityAccID}, ClientAffinityTTL) - if err == nil { - zone := account.GetAffinityZone(countMap[affinityAccID]) - if zone == AffinityZoneRed { - continue - } - } - } - - result, err := s.tryAcquireAccountSlot(ctx, affinityAccID, account.Concurrency) - if err == nil && result.Acquired { - if !s.checkAndRegisterSession(ctx, account, sessionHash) { - result.ReleaseFunc() - continue - } - // 亲和命中:更新亲和 score + 绑定粘性会话 - _ = s.cache.UpdateClientAffinity(ctx, derefGroupID(groupID), affinityClientID, affinityAccID, ClientAffinityTTL) - if sessionHash != "" { - _ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, affinityAccID, stickySessionTTL) - } - slog.Debug("client_affinity_hit", - "group_id", derefGroupID(groupID), - "client_id", affinityClientID[:8]+"...", - "account_id", affinityAccID) - return &AccountSelectionResult{ - Account: account, - Acquired: true, - ReleaseFunc: result.ReleaseFunc, - }, nil - } - } - // 所有亲和账号不可用,继续到 Layer 2 - } - } - // ============ Layer 2: 负载感知选择 ============ candidates := make([]*Account, 0, len(accounts)) for i := range accounts { @@ -1706,8 +1767,8 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro loadMap, err := s.concurrencyService.GetAccountsLoadBatch(ctx, accountLoads) if err != nil { if result, ok := s.tryAcquireByLegacyOrder(ctx, candidates, groupID, sessionHash, preferOAuth); ok { - if affinityClientID != "" && s.cache != nil && result.Account != nil && result.Account.IsClientAffinityEnabled() { - _ = s.cache.UpdateClientAffinity(ctx, derefGroupID(groupID), affinityClientID, result.Account.ID, ClientAffinityTTL) + if affinityClientID != "" && affinityUserID > 0 && s.cache != nil && result.Account != nil && result.Account.IsAffinityEnabled() { + _ = s.cache.UpdateAffinity(ctx, derefGroupID(groupID), affinityUserID, affinityClientID, result.Account.ID, ClientAffinityTTL) } return result, nil } @@ -1771,9 +1832,9 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if sessionHash != "" && s.cache != nil { _ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.account.ID, stickySessionTTL) } - // 更新客户端亲和关系 - if affinityClientID != "" && s.cache != nil && selected.account.IsClientAffinityEnabled() { - _ = s.cache.UpdateClientAffinity(ctx, derefGroupID(groupID), affinityClientID, selected.account.ID, ClientAffinityTTL) + // 更新亲和关系 + if affinityClientID != "" && affinityUserID > 0 && s.cache != nil && selected.account.IsAffinityEnabled() { + _ = s.cache.UpdateAffinity(ctx, derefGroupID(groupID), affinityUserID, affinityClientID, selected.account.ID, ClientAffinityTTL) } return &AccountSelectionResult{ Account: selected.account, @@ -2541,7 +2602,7 @@ func (s *GatewayService) populateAffinityCounts(ctx context.Context, accounts [] // 快速检查:是否有任何账号开启了亲和 hasAffinity := false for _, acc := range accounts { - if acc.account.IsClientAffinityEnabled() { + if acc.account.IsAffinityEnabled() { hasAffinity = true break } @@ -2632,7 +2693,7 @@ func classifyByAffinityZone(accounts []accountWithLoad) []accountWithLoad { // 快速检查:是否有任何账号配置了 affinity_base hasZoneConfig := false for _, acc := range accounts { - if acc.account.IsClientAffinityEnabled() && acc.account.GetAffinityBase() > 0 { + if acc.account.IsAffinityEnabled() && acc.account.GetAffinityBase() > 0 { hasZoneConfig = true break } @@ -3935,82 +3996,28 @@ func hasClaudeCodePrefix(text string) bool { return false } -// matchesFilterPrefix 检查文本是否匹配任一过滤前缀 -func matchesFilterPrefix(text string) bool { - for _, prefix := range systemBlockFilterPrefixes { - if strings.HasPrefix(text, prefix) { - return true - } - } - return false -} - -// filterSystemBlocksByPrefix 从 body 的 system 中移除文本匹配 systemBlockFilterPrefixes 前缀的元素 -// 直接从 body 解析 system,不依赖外部传入的 parsed.System(因为前置步骤可能已修改 body 中的 system) -func filterSystemBlocksByPrefix(body []byte) []byte { - sys := gjson.GetBytes(body, "system") - if !sys.Exists() { - return body - } - - switch { - case sys.Type == gjson.String: - if matchesFilterPrefix(sys.Str) { - result, err := sjson.DeleteBytes(body, "system") - if err != nil { - return body - } - return result - } - case sys.IsArray(): - var parsed []any - if err := json.Unmarshal([]byte(sys.Raw), &parsed); err != nil { - return body - } - filtered := make([]any, 0, len(parsed)) - changed := false - for _, item := range parsed { - if m, ok := item.(map[string]any); ok { - if text, ok := m["text"].(string); ok && matchesFilterPrefix(text) { - changed = true - continue - } - } - filtered = append(filtered, item) - } - if changed { - result, err := sjson.SetBytes(body, "system", filtered) - if err != nil { - return body - } - return result - } - } - return body -} - // injectClaudeCodePrompt 在 system 开头注入 Claude Code 提示词 // 处理 null、字符串、数组三种格式 func injectClaudeCodePrompt(body []byte, system any) []byte { - claudeCodeBlock := map[string]any{ - "type": "text", - "text": claudeCodeSystemPrompt, - "cache_control": map[string]string{"type": "ephemeral"}, + claudeCodeBlock, err := marshalAnthropicSystemTextBlock(claudeCodeSystemPrompt, true) + if err != nil { + logger.LegacyPrintf("service.gateway", "Warning: failed to build Claude Code prompt block: %v", err) + return body } // Opencode plugin applies an extra safeguard: it not only prepends the Claude Code // banner, it also prefixes the next system instruction with the same banner plus // a blank line. This helps when upstream concatenates system instructions. claudeCodePrefix := strings.TrimSpace(claudeCodeSystemPrompt) - var newSystem []any + var items [][]byte switch v := system.(type) { case nil: - newSystem = []any{claudeCodeBlock} + items = [][]byte{claudeCodeBlock} case string: // Be tolerant of older/newer clients that may differ only by trailing whitespace/newlines. if strings.TrimSpace(v) == "" || strings.TrimSpace(v) == strings.TrimSpace(claudeCodeSystemPrompt) { - newSystem = []any{claudeCodeBlock} + items = [][]byte{claudeCodeBlock} } else { // Mirror opencode behavior: keep the banner as a separate system entry, // but also prefix the next system text with the banner. @@ -4018,18 +4025,54 @@ func injectClaudeCodePrompt(body []byte, system any) []byte { if !strings.HasPrefix(v, claudeCodePrefix) { merged = claudeCodePrefix + "\n\n" + v } - newSystem = []any{claudeCodeBlock, map[string]any{"type": "text", "text": merged}} + nextBlock, buildErr := marshalAnthropicSystemTextBlock(merged, false) + if buildErr != nil { + logger.LegacyPrintf("service.gateway", "Warning: failed to build prefixed Claude Code system block: %v", buildErr) + return body + } + items = [][]byte{claudeCodeBlock, nextBlock} } case []any: - newSystem = make([]any, 0, len(v)+1) - newSystem = append(newSystem, claudeCodeBlock) + items = make([][]byte, 0, len(v)+1) + items = append(items, claudeCodeBlock) prefixedNext := false - for _, item := range v { - if m, ok := item.(map[string]any); ok { + systemResult := gjson.GetBytes(body, "system") + if systemResult.IsArray() { + systemResult.ForEach(func(_, item gjson.Result) bool { + textResult := item.Get("text") + if textResult.Exists() && textResult.Type == gjson.String && + strings.TrimSpace(textResult.String()) == strings.TrimSpace(claudeCodeSystemPrompt) { + return true + } + + raw := []byte(item.Raw) + // Prefix the first subsequent text system block once. + if !prefixedNext && item.Get("type").String() == "text" && textResult.Exists() && textResult.Type == gjson.String { + text := textResult.String() + if strings.TrimSpace(text) != "" && !strings.HasPrefix(text, claudeCodePrefix) { + next, setErr := sjson.SetBytes(raw, "text", claudeCodePrefix+"\n\n"+text) + if setErr == nil { + raw = next + prefixedNext = true + } + } + } + items = append(items, raw) + return true + }) + } else { + for _, item := range v { + m, ok := item.(map[string]any) + if !ok { + raw, marshalErr := json.Marshal(item) + if marshalErr == nil { + items = append(items, raw) + } + continue + } if text, ok := m["text"].(string); ok && strings.TrimSpace(text) == strings.TrimSpace(claudeCodeSystemPrompt) { continue } - // Prefix the first subsequent text system block once. if !prefixedNext { if blockType, _ := m["type"].(string); blockType == "text" { if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" && !strings.HasPrefix(text, claudeCodePrefix) { @@ -4038,197 +4081,150 @@ func injectClaudeCodePrompt(body []byte, system any) []byte { } } } + raw, marshalErr := json.Marshal(m) + if marshalErr == nil { + items = append(items, raw) + } } - newSystem = append(newSystem, item) } default: - newSystem = []any{claudeCodeBlock} + items = [][]byte{claudeCodeBlock} } - result, err := sjson.SetBytes(body, "system", newSystem) - if err != nil { - logger.LegacyPrintf("service.gateway", "Warning: failed to inject Claude Code prompt: %v", err) + result, ok := setJSONRawBytes(body, "system", buildJSONArrayRaw(items)) + if !ok { + logger.LegacyPrintf("service.gateway", "Warning: failed to inject Claude Code prompt") return body } return result } +type cacheControlPath struct { + path string + log string +} + +func collectCacheControlPaths(body []byte) (invalidThinking []cacheControlPath, messagePaths []string, systemPaths []string) { + system := gjson.GetBytes(body, "system") + if system.IsArray() { + sysIndex := 0 + system.ForEach(func(_, item gjson.Result) bool { + if item.Get("cache_control").Exists() { + path := fmt.Sprintf("system.%d.cache_control", sysIndex) + if item.Get("type").String() == "thinking" { + invalidThinking = append(invalidThinking, cacheControlPath{ + path: path, + log: "[Warning] Removed illegal cache_control from thinking block in system", + }) + } else { + systemPaths = append(systemPaths, path) + } + } + sysIndex++ + return true + }) + } + + messages := gjson.GetBytes(body, "messages") + if messages.IsArray() { + msgIndex := 0 + messages.ForEach(func(_, msg gjson.Result) bool { + content := msg.Get("content") + if content.IsArray() { + contentIndex := 0 + content.ForEach(func(_, item gjson.Result) bool { + if item.Get("cache_control").Exists() { + path := fmt.Sprintf("messages.%d.content.%d.cache_control", msgIndex, contentIndex) + if item.Get("type").String() == "thinking" { + invalidThinking = append(invalidThinking, cacheControlPath{ + path: path, + log: fmt.Sprintf("[Warning] Removed illegal cache_control from thinking block in messages[%d].content[%d]", msgIndex, contentIndex), + }) + } else { + messagePaths = append(messagePaths, path) + } + } + contentIndex++ + return true + }) + } + msgIndex++ + return true + }) + } + + return invalidThinking, messagePaths, systemPaths +} + // 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 { + if len(body) == 0 { return body } - // 清理 thinking 块中的非法 cache_control(thinking 块不支持该字段) - removeCacheControlFromThinkingBlocks(data) + invalidThinking, messagePaths, systemPaths := collectCacheControlPaths(body) + out := body + modified := false - // 计算当前 cache_control 块数量 - count := countCacheControlBlocks(data) + // 先清理 thinking 块中的非法 cache_control(thinking 块不支持该字段) + for _, item := range invalidThinking { + if !gjson.GetBytes(out, item.path).Exists() { + continue + } + next, ok := deleteJSONPathBytes(out, item.path) + if !ok { + continue + } + out = next + modified = true + logger.LegacyPrintf("service.gateway", "%s", item.log) + } + + count := len(messagePaths) + len(systemPaths) if count <= maxCacheControlBlocks { + if modified { + return out + } return body } // 超限:优先从 messages 中移除,再从 system 中移除 - for count > maxCacheControlBlocks { - if removeCacheControlFromMessages(data) { - count-- + remaining := count - maxCacheControlBlocks + for _, path := range messagePaths { + if remaining <= 0 { + break + } + if !gjson.GetBytes(out, path).Exists() { continue } - if removeCacheControlFromSystem(data) { - count-- - continue - } - break - } - - result, err := json.Marshal(data) - if err != nil { - return body - } - return result -} - -// countCacheControlBlocks 统计 system 和 messages 中的 cache_control 块数量 -// 注意:thinking 块不支持 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 { - // thinking 块不支持 cache_control,跳过 - if blockType, _ := m["type"].(string); blockType == "thinking" { - continue - } - 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 { - // thinking 块不支持 cache_control,跳过 - if blockType, _ := m["type"].(string); blockType == "thinking" { - continue - } - if _, has := m["cache_control"]; has { - count++ - } - } - } - } - } - } - } - - return count -} - -// removeCacheControlFromMessages 从 messages 中移除一个 cache_control(从头开始) -// 返回 true 表示成功移除,false 表示没有可移除的 -// 注意:跳过 thinking 块(它不支持 cache_control) -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) + next, ok := deleteJSONPathBytes(out, path) if !ok { continue } - content, ok := msgMap["content"].([]any) + out = next + modified = true + remaining-- + } + + for i := len(systemPaths) - 1; i >= 0 && remaining > 0; i-- { + path := systemPaths[i] + if !gjson.GetBytes(out, path).Exists() { + continue + } + next, ok := deleteJSONPathBytes(out, path) if !ok { continue } - for _, item := range content { - if m, ok := item.(map[string]any); ok { - // thinking 块不支持 cache_control,跳过 - if blockType, _ := m["type"].(string); blockType == "thinking" { - continue - } - if _, has := m["cache_control"]; has { - delete(m, "cache_control") - return true - } - } - } - } - return false -} - -// removeCacheControlFromSystem 从 system 中移除一个 cache_control(从尾部开始,保护注入的 prompt) -// 返回 true 表示成功移除,false 表示没有可移除的 -// 注意:跳过 thinking 块(它不支持 cache_control) -func removeCacheControlFromSystem(data map[string]any) bool { - system, ok := data["system"].([]any) - if !ok { - return false + out = next + modified = true + remaining-- } - // 从尾部开始移除,保护开头注入的 Claude Code prompt - for i := len(system) - 1; i >= 0; i-- { - if m, ok := system[i].(map[string]any); ok { - // thinking 块不支持 cache_control,跳过 - if blockType, _ := m["type"].(string); blockType == "thinking" { - continue - } - if _, has := m["cache_control"]; has { - delete(m, "cache_control") - return true - } - } - } - return false -} - -// removeCacheControlFromThinkingBlocks 强制清理所有 thinking 块中的非法 cache_control -// thinking 块不支持 cache_control 字段,这个函数确保所有 thinking 块都不含该字段 -func removeCacheControlFromThinkingBlocks(data map[string]any) { - // 清理 system 中的 thinking 块 - if system, ok := data["system"].([]any); ok { - for _, item := range system { - if m, ok := item.(map[string]any); ok { - if blockType, _ := m["type"].(string); blockType == "thinking" { - if _, has := m["cache_control"]; has { - delete(m, "cache_control") - logger.LegacyPrintf("service.gateway", "[Warning] Removed illegal cache_control from thinking block in system") - } - } - } - } - } - - // 清理 messages 中的 thinking 块 - if messages, ok := data["messages"].([]any); ok { - for msgIdx, msg := range messages { - if msgMap, ok := msg.(map[string]any); ok { - if content, ok := msgMap["content"].([]any); ok { - for contentIdx, item := range content { - if m, ok := item.(map[string]any); ok { - if blockType, _ := m["type"].(string); blockType == "thinking" { - if _, has := m["cache_control"]; has { - delete(m, "cache_control") - logger.LegacyPrintf("service.gateway", "[Warning] Removed illegal cache_control from thinking block in messages[%d].content[%d]", msgIdx, contentIdx) - } - } - } - } - } - } - } + if modified { + return out } + return body } // Forward 转发请求到Claude API @@ -4248,7 +4244,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A passthroughModel = mappedModel } } - return s.forwardAnthropicAPIKeyPassthrough(ctx, c, account, passthroughBody, passthroughModel, parsed.Stream, startTime) + return s.forwardAnthropicAPIKeyPassthroughWithInput(ctx, c, account, anthropicPassthroughForwardInput{ + Body: passthroughBody, + RequestModel: passthroughModel, + OriginalModel: parsed.Model, + RequestStream: parsed.Stream, + StartTime: startTime, + }) } if account != nil && account.IsBedrock() { @@ -4274,6 +4276,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A reqStream := parsed.Stream originalModel := reqModel + // === DEBUG: 打印客户端原始请求 body === + debugLogRequestBody("CLIENT_ORIGINAL", body) + isClaudeCode := isClaudeCodeRequest(ctx, c, parsed) shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode @@ -4299,12 +4304,6 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A body, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts) } - // OAuth/SetupToken 账号:移除黑名单前缀匹配的 system 元素(如客户端注入的计费元数据) - // 放在 inject/normalize 之后,确保不会被覆盖 - if account.IsOAuth() { - body = filterSystemBlocksByPrefix(body) - } - // 强制执行 cache_control 块数量限制(最多 4 个) body = enforceCacheControlLimit(body) @@ -4773,6 +4772,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A RequestID: resp.Header.Get("x-request-id"), Usage: *usage, Model: originalModel, // 使用原始模型用于计费和日志 + UpstreamModel: mappedModel, Stream: reqStream, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, @@ -4780,14 +4780,38 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A }, nil } +type anthropicPassthroughForwardInput struct { + Body []byte + RequestModel string + OriginalModel string + RequestStream bool + StartTime time.Time +} + func (s *GatewayService) forwardAnthropicAPIKeyPassthrough( ctx context.Context, c *gin.Context, account *Account, body []byte, reqModel string, + originalModel string, reqStream bool, startTime time.Time, +) (*ForwardResult, error) { + return s.forwardAnthropicAPIKeyPassthroughWithInput(ctx, c, account, anthropicPassthroughForwardInput{ + Body: body, + RequestModel: reqModel, + OriginalModel: originalModel, + RequestStream: reqStream, + StartTime: startTime, + }) +} + +func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( + ctx context.Context, + c *gin.Context, + account *Account, + input anthropicPassthroughForwardInput, ) (*ForwardResult, error) { token, tokenType, err := s.GetAccessToken(ctx, account) if err != nil { @@ -4803,19 +4827,19 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthrough( } logger.LegacyPrintf("service.gateway", "[Anthropic 自动透传] 命中 API Key 透传分支: account=%d name=%s model=%s stream=%v", - account.ID, account.Name, reqModel, reqStream) + account.ID, account.Name, input.RequestModel, input.RequestStream) if c != nil { c.Set("anthropic_passthrough", true) } // 重试间复用同一请求体,避免每次 string(body) 产生额外分配。 - setOpsUpstreamRequestBody(c, body) + setOpsUpstreamRequestBody(c, input.Body) var resp *http.Response retryStart := time.Now() for attempt := 1; attempt <= maxRetryAttempts; attempt++ { - upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) - upstreamReq, err := s.buildUpstreamRequestAnthropicAPIKeyPassthrough(upstreamCtx, c, account, body, token) + upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, input.RequestStream) + upstreamReq, err := s.buildUpstreamRequestAnthropicAPIKeyPassthrough(upstreamCtx, c, account, input.Body, token) releaseUpstreamCtx() if err != nil { return nil, err @@ -4973,8 +4997,8 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthrough( var usage *ClaudeUsage var firstTokenMs *int var clientDisconnect bool - if reqStream { - streamResult, err := s.handleStreamingResponseAnthropicAPIKeyPassthrough(ctx, resp, c, account, startTime, reqModel) + if input.RequestStream { + streamResult, err := s.handleStreamingResponseAnthropicAPIKeyPassthrough(ctx, resp, c, account, input.StartTime, input.RequestModel) if err != nil { return nil, err } @@ -4994,9 +5018,10 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthrough( return &ForwardResult{ RequestID: resp.Header.Get("x-request-id"), Usage: *usage, - Model: reqModel, - Stream: reqStream, - Duration: time.Since(startTime), + Model: input.OriginalModel, + UpstreamModel: input.RequestModel, + Stream: input.RequestStream, + Duration: time.Since(input.StartTime), FirstTokenMs: firstTokenMs, ClientDisconnect: clientDisconnect, }, nil @@ -5501,6 +5526,7 @@ func (s *GatewayService) forwardBedrock( RequestID: resp.Header.Get("x-amzn-requestid"), Usage: *usage, Model: reqModel, + UpstreamModel: mappedModel, Stream: reqStream, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, @@ -5793,13 +5819,16 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值 accountUUID := account.GetExtraString("account_uuid") if accountUUID != "" && fp.ClientID != "" { - if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID); err == nil && len(newBody) > 0 { + if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 { body = newBody } } } } + // === DEBUG: 打印转发给上游的 body(metadata 已重写) === + debugLogRequestBody("UPSTREAM_FORWARD", body) + req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { return nil, err @@ -6328,9 +6357,11 @@ func (s *GatewayService) isThinkingBlockSignatureError(respBody []byte) bool { return true } - // 检测空消息内容错误(可能是过滤 thinking blocks 后导致的) + // 检测空消息内容错误(可能是过滤 thinking blocks 后导致的,或客户端发送了空 text block) // 例如: "all messages must have non-empty content" - if strings.Contains(msg, "non-empty content") || strings.Contains(msg, "empty content") { + // "messages: text content blocks must be non-empty" + if strings.Contains(msg, "non-empty content") || strings.Contains(msg, "empty content") || + strings.Contains(msg, "content blocks must be non-empty") { logger.LegacyPrintf("service.gateway", "[SignatureCheck] Detected empty content error") return true } @@ -7814,6 +7845,7 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu AccountID: account.ID, RequestID: requestID, Model: result.Model, + UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, result.Model), ReasoningEffort: result.ReasoningEffort, InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), @@ -7995,6 +8027,7 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input * AccountID: account.ID, RequestID: requestID, Model: result.Model, + UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, result.Model), ReasoningEffort: result.ReasoningEffort, InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), @@ -8446,7 +8479,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con if err == nil { accountUUID := account.GetExtraString("account_uuid") if accountUUID != "" && fp.ClientID != "" { - if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID); err == nil && len(newBody) > 0 { + if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 { body = newBody } } @@ -8695,3 +8728,43 @@ func reconcileCachedTokens(usage map[string]any) bool { usage["cache_read_input_tokens"] = cached return true } + +func debugGatewayBodyLoggingEnabled() bool { + raw := strings.TrimSpace(os.Getenv(debugGatewayBodyEnv)) + if raw == "" { + return false + } + + switch strings.ToLower(raw) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +// debugLogRequestBody 打印请求 body 用于调试 metadata.user_id 重写。 +// 默认关闭,仅在设置环境变量时启用: +// +// SUB2API_DEBUG_GATEWAY_BODY=1 +func debugLogRequestBody(tag string, body []byte) { + if !debugGatewayBodyLoggingEnabled() { + return + } + + if len(body) == 0 { + logger.LegacyPrintf("service.gateway", "[DEBUG_%s] body is empty", tag) + return + } + + // 提取 metadata 字段完整打印 + metadataResult := gjson.GetBytes(body, "metadata") + if metadataResult.Exists() { + logger.LegacyPrintf("service.gateway", "[DEBUG_%s] metadata = %s", tag, metadataResult.Raw) + } else { + logger.LegacyPrintf("service.gateway", "[DEBUG_%s] metadata field not found", tag) + } + + // 全量打印 body + logger.LegacyPrintf("service.gateway", "[DEBUG_%s] body (%d bytes) = %s", tag, len(body), string(body)) +} diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index 7299261429..f86e7656ef 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -230,8 +230,8 @@ func (m *mockGroupRepoForGemini) ListActiveByPlatform(ctx context.Context, platf func (m *mockGroupRepoForGemini) ExistsByName(ctx context.Context, name string) (bool, error) { return false, nil } -func (m *mockGroupRepoForGemini) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { - return 0, nil +func (m *mockGroupRepoForGemini) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) { + return 0, 0, nil } func (m *mockGroupRepoForGemini) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, nil @@ -288,12 +288,15 @@ func (m *mockGatewayCacheForGemini) DeleteSessionAccountID(ctx context.Context, return nil } -func (m *mockGatewayCacheForGemini) GetClientAffinityAccounts(_ context.Context, _ int64, _ string, _ time.Duration) ([]int64, error) { +func (m *mockGatewayCacheForGemini) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { return nil, nil } -func (m *mockGatewayCacheForGemini) UpdateClientAffinity(_ context.Context, _ int64, _ string, _ int64, _ time.Duration) error { +func (m *mockGatewayCacheForGemini) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error { return nil } +func (m *mockGatewayCacheForGemini) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) { + return 0, 0, 0, nil +} func (m *mockGatewayCacheForGemini) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) { return map[int64]int64{}, nil } diff --git a/backend/internal/service/generate_session_hash_test.go b/backend/internal/service/generate_session_hash_test.go index 8aa358a520..f91fb4c920 100644 --- a/backend/internal/service/generate_session_hash_test.go +++ b/backend/internal/service/generate_session_hash_test.go @@ -24,7 +24,7 @@ func TestGenerateSessionHash_MetadataHasHighestPriority(t *testing.T) { svc := &GatewayService{} parsed := &ParsedRequest{ - MetadataUserID: "session_123e4567-e89b-12d3-a456-426614174000", + MetadataUserID: "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2_account__session_123e4567-e89b-12d3-a456-426614174000", System: "You are a helpful assistant.", HasSystem: true, Messages: []any{ @@ -196,7 +196,7 @@ func TestGenerateSessionHash_MetadataOverridesSessionContext(t *testing.T) { svc := &GatewayService{} parsed := &ParsedRequest{ - MetadataUserID: "session_123e4567-e89b-12d3-a456-426614174000", + MetadataUserID: "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2_account__session_123e4567-e89b-12d3-a456-426614174000", Messages: []any{ map[string]any{"role": "user", "content": "hello"}, }, @@ -212,6 +212,22 @@ func TestGenerateSessionHash_MetadataOverridesSessionContext(t *testing.T) { "metadata session_id should take priority over SessionContext") } +func TestGenerateSessionHash_MetadataJSON_HasHighestPriority(t *testing.T) { + svc := &GatewayService{} + + parsed := &ParsedRequest{ + MetadataUserID: `{"device_id":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2","account_uuid":"","session_id":"c72554f2-1234-5678-abcd-123456789abc"}`, + System: "You are a helpful assistant.", + HasSystem: true, + Messages: []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + } + + hash := svc.GenerateSessionHash(parsed) + require.Equal(t, "c72554f2-1234-5678-abcd-123456789abc", hash, "JSON format metadata session_id should have highest priority") +} + func TestGenerateSessionHash_NilSessionContextBackwardCompatible(t *testing.T) { svc := &GatewayService{} diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index c9851bd88c..b4d7845afd 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -67,8 +67,10 @@ type Group struct { CreatedAt time.Time UpdatedAt time.Time - AccountGroups []AccountGroup - AccountCount int64 + AccountGroups []AccountGroup + AccountCount int64 + ActiveAccountCount int64 + RateLimitedAccountCount int64 } func (g *Group) IsActive() bool { diff --git a/backend/internal/service/group_capacity_service.go b/backend/internal/service/group_capacity_service.go new file mode 100644 index 0000000000..459084dc59 --- /dev/null +++ b/backend/internal/service/group_capacity_service.go @@ -0,0 +1,131 @@ +package service + +import ( + "context" + "time" +) + +// GroupCapacitySummary holds aggregated capacity for a single group. +type GroupCapacitySummary struct { + GroupID int64 `json:"group_id"` + ConcurrencyUsed int `json:"concurrency_used"` + ConcurrencyMax int `json:"concurrency_max"` + SessionsUsed int `json:"sessions_used"` + SessionsMax int `json:"sessions_max"` + RPMUsed int `json:"rpm_used"` + RPMMax int `json:"rpm_max"` +} + +// GroupCapacityService aggregates per-group capacity from runtime data. +type GroupCapacityService struct { + accountRepo AccountRepository + groupRepo GroupRepository + concurrencyService *ConcurrencyService + sessionLimitCache SessionLimitCache + rpmCache RPMCache +} + +// NewGroupCapacityService creates a new GroupCapacityService. +func NewGroupCapacityService( + accountRepo AccountRepository, + groupRepo GroupRepository, + concurrencyService *ConcurrencyService, + sessionLimitCache SessionLimitCache, + rpmCache RPMCache, +) *GroupCapacityService { + return &GroupCapacityService{ + accountRepo: accountRepo, + groupRepo: groupRepo, + concurrencyService: concurrencyService, + sessionLimitCache: sessionLimitCache, + rpmCache: rpmCache, + } +} + +// GetAllGroupCapacity returns capacity summary for all active groups. +func (s *GroupCapacityService) GetAllGroupCapacity(ctx context.Context) ([]GroupCapacitySummary, error) { + groups, err := s.groupRepo.ListActive(ctx) + if err != nil { + return nil, err + } + + results := make([]GroupCapacitySummary, 0, len(groups)) + for i := range groups { + cap, err := s.getGroupCapacity(ctx, groups[i].ID) + if err != nil { + // Skip groups with errors, return partial results + continue + } + cap.GroupID = groups[i].ID + results = append(results, cap) + } + return results, nil +} + +func (s *GroupCapacityService) getGroupCapacity(ctx context.Context, groupID int64) (GroupCapacitySummary, error) { + accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, groupID) + if err != nil { + return GroupCapacitySummary{}, err + } + if len(accounts) == 0 { + return GroupCapacitySummary{}, nil + } + + // Collect account IDs and config values + accountIDs := make([]int64, 0, len(accounts)) + sessionTimeouts := make(map[int64]time.Duration) + var concurrencyMax, sessionsMax, rpmMax int + + for i := range accounts { + acc := &accounts[i] + accountIDs = append(accountIDs, acc.ID) + concurrencyMax += acc.Concurrency + + if ms := acc.GetMaxSessions(); ms > 0 { + sessionsMax += ms + timeout := time.Duration(acc.GetSessionIdleTimeoutMinutes()) * time.Minute + if timeout <= 0 { + timeout = 5 * time.Minute + } + sessionTimeouts[acc.ID] = timeout + } + + if rpm := acc.GetBaseRPM(); rpm > 0 { + rpmMax += rpm + } + } + + // Batch query runtime data from Redis + concurrencyMap, _ := s.concurrencyService.GetAccountConcurrencyBatch(ctx, accountIDs) + + var sessionsMap map[int64]int + if sessionsMax > 0 && s.sessionLimitCache != nil { + sessionsMap, _ = s.sessionLimitCache.GetActiveSessionCountBatch(ctx, accountIDs, sessionTimeouts) + } + + var rpmMap map[int64]int + if rpmMax > 0 && s.rpmCache != nil { + rpmMap, _ = s.rpmCache.GetRPMBatch(ctx, accountIDs) + } + + // Aggregate + var concurrencyUsed, sessionsUsed, rpmUsed int + for _, id := range accountIDs { + concurrencyUsed += concurrencyMap[id] + if sessionsMap != nil { + sessionsUsed += sessionsMap[id] + } + if rpmMap != nil { + rpmUsed += rpmMap[id] + } + } + + return GroupCapacitySummary{ + ConcurrencyUsed: concurrencyUsed, + ConcurrencyMax: concurrencyMax, + SessionsUsed: sessionsUsed, + SessionsMax: sessionsMax, + RPMUsed: rpmUsed, + RPMMax: rpmMax, + }, nil +} diff --git a/backend/internal/service/group_service.go b/backend/internal/service/group_service.go index 22a67edac8..87174e0376 100644 --- a/backend/internal/service/group_service.go +++ b/backend/internal/service/group_service.go @@ -27,7 +27,7 @@ type GroupRepository interface { ListActiveByPlatform(ctx context.Context, platform string) ([]Group, error) ExistsByName(ctx context.Context, name string) (bool, error) - GetAccountCount(ctx context.Context, groupID int64) (int64, error) + GetAccountCount(ctx context.Context, groupID int64) (total int64, active int64, err error) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) // GetAccountIDsByGroupIDs 获取多个分组的所有账号 ID(去重) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) @@ -202,7 +202,7 @@ func (s *GroupService) GetStats(ctx context.Context, id int64) (map[string]any, } // 获取账号数量 - accountCount, err := s.groupRepo.GetAccountCount(ctx, id) + accountCount, _, err := s.groupRepo.GetAccountCount(ctx, id) if err != nil { return nil, fmt.Errorf("get account count: %w", err) } diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index f6a94d156d..428f5bfdc8 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -5,7 +5,6 @@ import ( "crypto/rand" "crypto/sha256" "encoding/hex" - "encoding/json" "fmt" "log/slog" "net/http" @@ -15,14 +14,12 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) // 预编译正则表达式(避免每次调用重新编译) var ( - // 匹配 user_id 格式: - // 旧格式: user_{64位hex}_account__session_{uuid} (account 后无 UUID) - // 新格式: user_{64位hex}_account_{uuid}_session_{uuid} (account 后有 UUID) - userIDRegex = regexp.MustCompile(`^user_[a-f0-9]{64}_account_([a-f0-9-]*)_session_([a-f0-9-]{36})$`) // 匹配 User-Agent 版本号: xxx/x.y.z userAgentVersionRegex = regexp.MustCompile(`/(\d+)\.(\d+)\.(\d+)`) ) @@ -209,67 +206,57 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) { } // RewriteUserID 重写body中的metadata.user_id -// 输入格式:user_{clientId}_account__session_{sessionUUID} -// 输出格式:user_{cachedClientID}_account_{accountUUID}_session_{newHash} +// 支持旧拼接格式和新 JSON 格式的 user_id 解析, +// 根据 fingerprintUA 版本选择输出格式。 // // 重要:此函数使用 json.RawMessage 保留其他字段的原始字节, // 避免重新序列化导致 thinking 块等内容被修改。 -func (s *IdentityService) RewriteUserID(body []byte, accountID int64, accountUUID, cachedClientID string) ([]byte, error) { +func (s *IdentityService) RewriteUserID(body []byte, accountID int64, accountUUID, cachedClientID, fingerprintUA string) ([]byte, error) { if len(body) == 0 || accountUUID == "" || cachedClientID == "" { return body, nil } - // 使用 RawMessage 保留其他字段的原始字节 - var reqMap map[string]json.RawMessage - if err := json.Unmarshal(body, &reqMap); err != nil { + metadata := gjson.GetBytes(body, "metadata") + if !metadata.Exists() || metadata.Type == gjson.Null { + return body, nil + } + if !strings.HasPrefix(strings.TrimSpace(metadata.Raw), "{") { return body, nil } - // 解析 metadata 字段 - metadataRaw, ok := reqMap["metadata"] - if !ok { + userIDResult := metadata.Get("user_id") + if !userIDResult.Exists() || userIDResult.Type != gjson.String { + return body, nil + } + userID := userIDResult.String() + if userID == "" { return body, nil } - var metadata map[string]any - if err := json.Unmarshal(metadataRaw, &metadata); err != nil { + // 解析 user_id(兼容旧拼接格式和新 JSON 格式) + parsed := ParseMetadataUserID(userID) + if parsed == nil { return body, nil } - userID, ok := metadata["user_id"].(string) - if !ok || userID == "" { - return body, nil - } - - // 匹配格式: - // 旧格式: user_{64位hex}_account__session_{uuid} - // 新格式: user_{64位hex}_account_{uuid}_session_{uuid} - matches := userIDRegex.FindStringSubmatch(userID) - if matches == nil { - return body, nil - } - - // matches[1] = account UUID (可能为空), matches[2] = session UUID - sessionTail := matches[2] // 原始session UUID + sessionTail := parsed.SessionID // 原始session UUID // 生成新的session hash: SHA256(accountID::sessionTail) -> UUID格式 seed := fmt.Sprintf("%d::%s", accountID, sessionTail) newSessionHash := generateUUIDFromSeed(seed) - // 构建新的user_id - // 格式: user_{cachedClientID}_account_{account_uuid}_session_{newSessionHash} - newUserID := fmt.Sprintf("user_%s_account_%s_session_%s", cachedClientID, accountUUID, newSessionHash) + // 根据客户端版本选择输出格式 + version := ExtractCLIVersion(fingerprintUA) + newUserID := FormatMetadataUserID(cachedClientID, accountUUID, newSessionHash, version) + if newUserID == userID { + return body, nil + } - metadata["user_id"] = newUserID - - // 只重新序列化 metadata 字段 - newMetadataRaw, err := json.Marshal(metadata) + newBody, err := sjson.SetBytes(body, "metadata.user_id", newUserID) if err != nil { return body, nil } - reqMap["metadata"] = newMetadataRaw - - return json.Marshal(reqMap) + return newBody, nil } // RewriteUserIDWithMasking 重写body中的metadata.user_id,支持会话ID伪装 @@ -278,9 +265,9 @@ func (s *IdentityService) RewriteUserID(body []byte, accountID int64, accountUUI // // 重要:此函数使用 json.RawMessage 保留其他字段的原始字节, // 避免重新序列化导致 thinking 块等内容被修改。 -func (s *IdentityService) RewriteUserIDWithMasking(ctx context.Context, body []byte, account *Account, accountUUID, cachedClientID string) ([]byte, error) { +func (s *IdentityService) RewriteUserIDWithMasking(ctx context.Context, body []byte, account *Account, accountUUID, cachedClientID, fingerprintUA string) ([]byte, error) { // 先执行常规的 RewriteUserID 逻辑 - newBody, err := s.RewriteUserID(body, account.ID, accountUUID, cachedClientID) + newBody, err := s.RewriteUserID(body, account.ID, accountUUID, cachedClientID, fingerprintUA) if err != nil { return newBody, err } @@ -290,32 +277,26 @@ func (s *IdentityService) RewriteUserIDWithMasking(ctx context.Context, body []b return newBody, nil } - // 使用 RawMessage 保留其他字段的原始字节 - var reqMap map[string]json.RawMessage - if err := json.Unmarshal(newBody, &reqMap); err != nil { + metadata := gjson.GetBytes(newBody, "metadata") + if !metadata.Exists() || metadata.Type == gjson.Null { + return newBody, nil + } + if !strings.HasPrefix(strings.TrimSpace(metadata.Raw), "{") { return newBody, nil } - // 解析 metadata 字段 - metadataRaw, ok := reqMap["metadata"] - if !ok { + userIDResult := metadata.Get("user_id") + if !userIDResult.Exists() || userIDResult.Type != gjson.String { + return newBody, nil + } + userID := userIDResult.String() + if userID == "" { return newBody, nil } - var metadata map[string]any - if err := json.Unmarshal(metadataRaw, &metadata); err != nil { - return newBody, nil - } - - userID, ok := metadata["user_id"].(string) - if !ok || userID == "" { - return newBody, nil - } - - // 查找 _session_ 的位置,替换其后的内容 - const sessionMarker = "_session_" - idx := strings.LastIndex(userID, sessionMarker) - if idx == -1 { + // 解析已重写的 user_id + uidParsed := ParseMetadataUserID(userID) + if uidParsed == nil { return newBody, nil } @@ -337,8 +318,9 @@ func (s *IdentityService) RewriteUserIDWithMasking(ctx context.Context, body []b logger.LegacyPrintf("service.identity", "Warning: failed to set masked session ID for account %d: %v", account.ID, err) } - // 替换 session 部分:保留 _session_ 之前的内容,替换之后的内容 - newUserID := userID[:idx+len(sessionMarker)] + maskedSessionID + // 用 FormatMetadataUserID 重建(保持与 RewriteUserID 相同的格式) + version := ExtractCLIVersion(fingerprintUA) + newUserID := FormatMetadataUserID(uidParsed.DeviceID, uidParsed.AccountUUID, maskedSessionID, version) slog.Debug("session_id_masking_applied", "account_id", account.ID, @@ -346,16 +328,15 @@ func (s *IdentityService) RewriteUserIDWithMasking(ctx context.Context, body []b "after", newUserID, ) - metadata["user_id"] = newUserID - - // 只重新序列化 metadata 字段 - newMetadataRaw, marshalErr := json.Marshal(metadata) - if marshalErr != nil { + if newUserID == userID { return newBody, nil } - reqMap["metadata"] = newMetadataRaw - return json.Marshal(reqMap) + maskedBody, setErr := sjson.SetBytes(newBody, "metadata.user_id", newUserID) + if setErr != nil { + return newBody, nil + } + return maskedBody, nil } // generateRandomUUID 生成随机 UUID v4 格式字符串 diff --git a/backend/internal/service/identity_service_order_test.go b/backend/internal/service/identity_service_order_test.go new file mode 100644 index 0000000000..d1e1227412 --- /dev/null +++ b/backend/internal/service/identity_service_order_test.go @@ -0,0 +1,82 @@ +package service + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type identityCacheStub struct { + maskedSessionID string +} + +func (s *identityCacheStub) GetFingerprint(_ context.Context, _ int64) (*Fingerprint, error) { + return nil, nil +} +func (s *identityCacheStub) SetFingerprint(_ context.Context, _ int64, _ *Fingerprint) error { + return nil +} +func (s *identityCacheStub) GetMaskedSessionID(_ context.Context, _ int64) (string, error) { + return s.maskedSessionID, nil +} +func (s *identityCacheStub) SetMaskedSessionID(_ context.Context, _ int64, sessionID string) error { + s.maskedSessionID = sessionID + return nil +} + +func TestIdentityService_RewriteUserID_PreservesTopLevelFieldOrder(t *testing.T) { + cache := &identityCacheStub{} + svc := NewIdentityService(cache) + + originalUserID := FormatMetadataUserID( + "d61f76d0730d2b920763648949bad5c79742155c27037fc77ac3f9805cb90169", + "", + "7578cf37-aaca-46e4-a45c-71285d9dbb83", + "2.1.78", + ) + body := []byte(`{"alpha":1,"messages":[],"metadata":{"user_id":` + strconvQuote(originalUserID) + `},"max_tokens":64000,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"},"stream":true}`) + + result, err := svc.RewriteUserID(body, 123, "acc-uuid", "client-xyz", "claude-cli/2.1.78 (external, cli)") + require.NoError(t, err) + resultStr := string(result) + + assertJSONTokenOrder(t, resultStr, `"alpha"`, `"messages"`, `"metadata"`, `"max_tokens"`, `"thinking"`, `"output_config"`, `"stream"`) + require.NotContains(t, resultStr, originalUserID) + require.Contains(t, resultStr, `"metadata":{"user_id":"`) +} + +func TestIdentityService_RewriteUserIDWithMasking_PreservesTopLevelFieldOrder(t *testing.T) { + cache := &identityCacheStub{maskedSessionID: "11111111-2222-4333-8444-555555555555"} + svc := NewIdentityService(cache) + + originalUserID := FormatMetadataUserID( + "d61f76d0730d2b920763648949bad5c79742155c27037fc77ac3f9805cb90169", + "", + "7578cf37-aaca-46e4-a45c-71285d9dbb83", + "2.1.78", + ) + body := []byte(`{"alpha":1,"messages":[],"metadata":{"user_id":` + strconvQuote(originalUserID) + `},"max_tokens":64000,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"},"stream":true}`) + + account := &Account{ + ID: 123, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "session_id_masking_enabled": true, + }, + } + + result, err := svc.RewriteUserIDWithMasking(context.Background(), body, account, "acc-uuid", "client-xyz", "claude-cli/2.1.78 (external, cli)") + require.NoError(t, err) + resultStr := string(result) + + assertJSONTokenOrder(t, resultStr, `"alpha"`, `"messages"`, `"metadata"`, `"max_tokens"`, `"thinking"`, `"output_config"`, `"stream"`) + require.Contains(t, resultStr, cache.maskedSessionID) + require.True(t, strings.Contains(resultStr, `"metadata":{"user_id":"`)) +} + +func strconvQuote(v string) string { + return `"` + strings.ReplaceAll(strings.ReplaceAll(v, `\`, `\\`), `"`, `\"`) + `"` +} diff --git a/backend/internal/service/metadata_userid.go b/backend/internal/service/metadata_userid.go new file mode 100644 index 0000000000..ee1ef64ad7 --- /dev/null +++ b/backend/internal/service/metadata_userid.go @@ -0,0 +1,104 @@ +package service + +import ( + "encoding/json" + "regexp" + "strings" +) + +// NewMetadataFormatMinVersion is the minimum Claude Code version that uses +// JSON-formatted metadata.user_id instead of the legacy concatenated string. +const NewMetadataFormatMinVersion = "2.1.78" + +// ParsedUserID represents the components extracted from a metadata.user_id value. +type ParsedUserID struct { + DeviceID string // 64-char hex (or arbitrary client id) + AccountUUID string // may be empty + SessionID string // UUID + IsNewFormat bool // true if the original was JSON format +} + +// legacyUserIDRegex matches the legacy user_id format: +// +// user_{64hex}_account_{optional_uuid}_session_{uuid} +var legacyUserIDRegex = regexp.MustCompile(`^user_([a-fA-F0-9]{64})_account_([a-fA-F0-9-]*)_session_([a-fA-F0-9-]{36})$`) + +// jsonUserID is the JSON structure for the new metadata.user_id format. +type jsonUserID struct { + DeviceID string `json:"device_id"` + AccountUUID string `json:"account_uuid"` + SessionID string `json:"session_id"` +} + +// ParseMetadataUserID parses a metadata.user_id string in either format. +// Returns nil if the input cannot be parsed. +func ParseMetadataUserID(raw string) *ParsedUserID { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + // Try JSON format first (starts with '{') + if raw[0] == '{' { + var j jsonUserID + if err := json.Unmarshal([]byte(raw), &j); err != nil { + return nil + } + if j.DeviceID == "" || j.SessionID == "" { + return nil + } + return &ParsedUserID{ + DeviceID: j.DeviceID, + AccountUUID: j.AccountUUID, + SessionID: j.SessionID, + IsNewFormat: true, + } + } + + // Try legacy format + matches := legacyUserIDRegex.FindStringSubmatch(raw) + if matches == nil { + return nil + } + return &ParsedUserID{ + DeviceID: matches[1], + AccountUUID: matches[2], + SessionID: matches[3], + IsNewFormat: false, + } +} + +// FormatMetadataUserID builds a metadata.user_id string in the format +// appropriate for the given CLI version. Components are the rewritten values +// (not necessarily the originals). +func FormatMetadataUserID(deviceID, accountUUID, sessionID, uaVersion string) string { + if IsNewMetadataFormatVersion(uaVersion) { + b, _ := json.Marshal(jsonUserID{ + DeviceID: deviceID, + AccountUUID: accountUUID, + SessionID: sessionID, + }) + return string(b) + } + // Legacy format + return "user_" + deviceID + "_account_" + accountUUID + "_session_" + sessionID +} + +// IsNewMetadataFormatVersion returns true if the given CLI version uses the +// new JSON metadata.user_id format (>= 2.1.78). +func IsNewMetadataFormatVersion(version string) bool { + if version == "" { + return false + } + return CompareVersions(version, NewMetadataFormatMinVersion) >= 0 +} + +// ExtractCLIVersion extracts the Claude Code version from a User-Agent string. +// Returns "" if the UA doesn't match the expected pattern. +func ExtractCLIVersion(ua string) string { + matches := claudeCodeUAVersionPattern.FindStringSubmatch(ua) + if len(matches) >= 2 { + return matches[1] + } + return "" +} diff --git a/backend/internal/service/metadata_userid_test.go b/backend/internal/service/metadata_userid_test.go new file mode 100644 index 0000000000..40ad7087a5 --- /dev/null +++ b/backend/internal/service/metadata_userid_test.go @@ -0,0 +1,183 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// ============ ParseMetadataUserID Tests ============ + +func TestParseMetadataUserID_LegacyFormat_WithoutAccountUUID(t *testing.T) { + raw := "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2_account__session_123e4567-e89b-12d3-a456-426614174000" + parsed := ParseMetadataUserID(raw) + require.NotNil(t, parsed) + require.Equal(t, "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", parsed.DeviceID) + require.Equal(t, "", parsed.AccountUUID) + require.Equal(t, "123e4567-e89b-12d3-a456-426614174000", parsed.SessionID) + require.False(t, parsed.IsNewFormat) +} + +func TestParseMetadataUserID_LegacyFormat_WithAccountUUID(t *testing.T) { + raw := "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2_account_550e8400-e29b-41d4-a716-446655440000_session_123e4567-e89b-12d3-a456-426614174000" + parsed := ParseMetadataUserID(raw) + require.NotNil(t, parsed) + require.Equal(t, "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", parsed.DeviceID) + require.Equal(t, "550e8400-e29b-41d4-a716-446655440000", parsed.AccountUUID) + require.Equal(t, "123e4567-e89b-12d3-a456-426614174000", parsed.SessionID) + require.False(t, parsed.IsNewFormat) +} + +func TestParseMetadataUserID_JSONFormat_WithoutAccountUUID(t *testing.T) { + raw := `{"device_id":"d61f76d0aabbccdd00112233445566778899aabbccddeeff0011223344556677","account_uuid":"","session_id":"c72554f2-1234-5678-abcd-123456789abc"}` + parsed := ParseMetadataUserID(raw) + require.NotNil(t, parsed) + require.Equal(t, "d61f76d0aabbccdd00112233445566778899aabbccddeeff0011223344556677", parsed.DeviceID) + require.Equal(t, "", parsed.AccountUUID) + require.Equal(t, "c72554f2-1234-5678-abcd-123456789abc", parsed.SessionID) + require.True(t, parsed.IsNewFormat) +} + +func TestParseMetadataUserID_JSONFormat_WithAccountUUID(t *testing.T) { + raw := `{"device_id":"d61f76d0aabbccdd00112233445566778899aabbccddeeff0011223344556677","account_uuid":"550e8400-e29b-41d4-a716-446655440000","session_id":"c72554f2-1234-5678-abcd-123456789abc"}` + parsed := ParseMetadataUserID(raw) + require.NotNil(t, parsed) + require.Equal(t, "d61f76d0aabbccdd00112233445566778899aabbccddeeff0011223344556677", parsed.DeviceID) + require.Equal(t, "550e8400-e29b-41d4-a716-446655440000", parsed.AccountUUID) + require.Equal(t, "c72554f2-1234-5678-abcd-123456789abc", parsed.SessionID) + require.True(t, parsed.IsNewFormat) +} + +func TestParseMetadataUserID_InvalidInputs(t *testing.T) { + tests := []struct { + name string + raw string + }{ + {"empty string", ""}, + {"whitespace only", " "}, + {"random text", "not-a-valid-user-id"}, + {"partial legacy format", "session_123e4567-e89b-12d3-a456-426614174000"}, + {"invalid JSON", `{"device_id":}`}, + {"JSON missing device_id", `{"account_uuid":"","session_id":"c72554f2-1234-5678-abcd-123456789abc"}`}, + {"JSON missing session_id", `{"device_id":"d61f76d0aabbccdd00112233445566778899aabbccddeeff0011223344556677","account_uuid":""}`}, + {"JSON empty device_id", `{"device_id":"","account_uuid":"","session_id":"c72554f2-1234-5678-abcd-123456789abc"}`}, + {"JSON empty session_id", `{"device_id":"d61f76d0aabbccdd00112233445566778899aabbccddeeff0011223344556677","account_uuid":"","session_id":""}`}, + {"legacy format short hex", "user_a1b2c3d4_account__session_123e4567-e89b-12d3-a456-426614174000"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Nil(t, ParseMetadataUserID(tt.raw), "should return nil for: %s", tt.raw) + }) + } +} + +func TestParseMetadataUserID_HexCaseInsensitive(t *testing.T) { + // Legacy format should accept both upper and lower case hex + rawUpper := "user_A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2_account__session_123e4567-e89b-12d3-a456-426614174000" + parsed := ParseMetadataUserID(rawUpper) + require.NotNil(t, parsed, "legacy format should accept uppercase hex") + require.Equal(t, "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2", parsed.DeviceID) +} + +// ============ FormatMetadataUserID Tests ============ + +func TestFormatMetadataUserID_LegacyVersion(t *testing.T) { + result := FormatMetadataUserID("deadbeef"+"00112233445566778899aabbccddeeff0011223344556677", "acc-uuid", "sess-uuid", "2.1.77") + require.Equal(t, "user_deadbeef00112233445566778899aabbccddeeff0011223344556677_account_acc-uuid_session_sess-uuid", result) +} + +func TestFormatMetadataUserID_NewVersion(t *testing.T) { + result := FormatMetadataUserID("deadbeef"+"00112233445566778899aabbccddeeff0011223344556677", "acc-uuid", "sess-uuid", "2.1.78") + require.Equal(t, `{"device_id":"deadbeef00112233445566778899aabbccddeeff0011223344556677","account_uuid":"acc-uuid","session_id":"sess-uuid"}`, result) +} + +func TestFormatMetadataUserID_EmptyVersion_Legacy(t *testing.T) { + result := FormatMetadataUserID("deadbeef"+"00112233445566778899aabbccddeeff0011223344556677", "", "sess-uuid", "") + require.Equal(t, "user_deadbeef00112233445566778899aabbccddeeff0011223344556677_account__session_sess-uuid", result) +} + +func TestFormatMetadataUserID_EmptyAccountUUID(t *testing.T) { + // Legacy format with empty account UUID → double underscore + result := FormatMetadataUserID("deadbeef"+"00112233445566778899aabbccddeeff0011223344556677", "", "sess-uuid", "2.1.22") + require.Contains(t, result, "_account__session_") + + // New format with empty account UUID → empty string in JSON + result = FormatMetadataUserID("deadbeef"+"00112233445566778899aabbccddeeff0011223344556677", "", "sess-uuid", "2.1.78") + require.Contains(t, result, `"account_uuid":""`) +} + +// ============ IsNewMetadataFormatVersion Tests ============ + +func TestIsNewMetadataFormatVersion(t *testing.T) { + tests := []struct { + version string + want bool + }{ + {"", false}, + {"2.1.77", false}, + {"2.1.78", true}, + {"2.1.79", true}, + {"2.2.0", true}, + {"3.0.0", true}, + {"2.0.100", false}, + {"1.9.99", false}, + } + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + require.Equal(t, tt.want, IsNewMetadataFormatVersion(tt.version)) + }) + } +} + +// ============ Round-trip Tests ============ + +func TestParseFormat_RoundTrip_Legacy(t *testing.T) { + deviceID := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + accountUUID := "550e8400-e29b-41d4-a716-446655440000" + sessionID := "123e4567-e89b-12d3-a456-426614174000" + + formatted := FormatMetadataUserID(deviceID, accountUUID, sessionID, "2.1.22") + parsed := ParseMetadataUserID(formatted) + require.NotNil(t, parsed) + require.Equal(t, deviceID, parsed.DeviceID) + require.Equal(t, accountUUID, parsed.AccountUUID) + require.Equal(t, sessionID, parsed.SessionID) + require.False(t, parsed.IsNewFormat) +} + +func TestParseFormat_RoundTrip_JSON(t *testing.T) { + deviceID := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + accountUUID := "550e8400-e29b-41d4-a716-446655440000" + sessionID := "123e4567-e89b-12d3-a456-426614174000" + + formatted := FormatMetadataUserID(deviceID, accountUUID, sessionID, "2.1.78") + parsed := ParseMetadataUserID(formatted) + require.NotNil(t, parsed) + require.Equal(t, deviceID, parsed.DeviceID) + require.Equal(t, accountUUID, parsed.AccountUUID) + require.Equal(t, sessionID, parsed.SessionID) + require.True(t, parsed.IsNewFormat) +} + +func TestParseFormat_RoundTrip_EmptyAccountUUID(t *testing.T) { + deviceID := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + sessionID := "123e4567-e89b-12d3-a456-426614174000" + + // Legacy round-trip with empty account UUID + formatted := FormatMetadataUserID(deviceID, "", sessionID, "2.1.22") + parsed := ParseMetadataUserID(formatted) + require.NotNil(t, parsed) + require.Equal(t, deviceID, parsed.DeviceID) + require.Equal(t, "", parsed.AccountUUID) + require.Equal(t, sessionID, parsed.SessionID) + + // JSON round-trip with empty account UUID + formatted = FormatMetadataUserID(deviceID, "", sessionID, "2.1.78") + parsed = ParseMetadataUserID(formatted) + require.NotNil(t, parsed) + require.Equal(t, deviceID, parsed.DeviceID) + require.Equal(t, "", parsed.AccountUUID) + require.Equal(t, sessionID, parsed.SessionID) +} diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 29f2b67215..0ae55ad3b7 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -172,6 +172,11 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact result.PromptCacheKey = strings.TrimSpace(v) } + // 提取 input 中 role:"system" 消息至 instructions(OAuth 上游不支持 system role)。 + if extractSystemMessagesFromInput(reqBody) { + result.Modified = true + } + // instructions 处理逻辑:根据是否是 Codex CLI 分别调用不同方法 if applyInstructions(reqBody, isCodexCLI) { result.Modified = true @@ -301,6 +306,73 @@ func getNormalizedCodexModel(modelID string) string { return "" } +// extractTextFromContent extracts plain text from a content value that is either +// a Go string or a []any of content-part maps with type:"text". +func extractTextFromContent(content any) string { + switch v := content.(type) { + case string: + return v + case []any: + var parts []string + for _, part := range v { + m, ok := part.(map[string]any) + if !ok { + continue + } + if t, _ := m["type"].(string); t == "text" { + if text, ok := m["text"].(string); ok { + parts = append(parts, text) + } + } + } + return strings.Join(parts, "") + default: + return "" + } +} + +// extractSystemMessagesFromInput scans the input array for items with role=="system", +// removes them, and merges their content into reqBody["instructions"]. +// If instructions is already non-empty, extracted content is prepended with "\n\n". +// Returns true if any system messages were extracted. +func extractSystemMessagesFromInput(reqBody map[string]any) bool { + input, ok := reqBody["input"].([]any) + if !ok || len(input) == 0 { + return false + } + + var systemTexts []string + remaining := make([]any, 0, len(input)) + + for _, item := range input { + m, ok := item.(map[string]any) + if !ok { + remaining = append(remaining, item) + continue + } + if role, _ := m["role"].(string); role != "system" { + remaining = append(remaining, item) + continue + } + if text := extractTextFromContent(m["content"]); text != "" { + systemTexts = append(systemTexts, text) + } + } + + if len(systemTexts) == 0 { + return false + } + + extracted := strings.Join(systemTexts, "\n\n") + if existing, ok := reqBody["instructions"].(string); ok && strings.TrimSpace(existing) != "" { + reqBody["instructions"] = extracted + "\n\n" + existing + } else { + reqBody["instructions"] = extracted + } + reqBody["input"] = remaining + return true +} + // applyInstructions 处理 instructions 字段:仅在 instructions 为空时填充默认值。 func applyInstructions(reqBody map[string]any, isCodexCLI bool) bool { if !isInstructionsEmpty(reqBody) { diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index ae6f855594..b52f0566c5 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -344,6 +344,135 @@ func TestApplyCodexOAuthTransform_StringInputWithToolsField(t *testing.T) { require.Len(t, input, 1) } +func TestExtractSystemMessagesFromInput(t *testing.T) { + t.Run("no system messages", func(t *testing.T) { + reqBody := map[string]any{ + "input": []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + } + result := extractSystemMessagesFromInput(reqBody) + require.False(t, result) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 1) + _, hasInstructions := reqBody["instructions"] + require.False(t, hasInstructions) + }) + + t.Run("string content system message", func(t *testing.T) { + reqBody := map[string]any{ + "input": []any{ + map[string]any{"role": "system", "content": "You are an assistant."}, + map[string]any{"role": "user", "content": "hello"}, + }, + } + result := extractSystemMessagesFromInput(reqBody) + require.True(t, result) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 1) + msg, ok := input[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "user", msg["role"]) + require.Equal(t, "You are an assistant.", reqBody["instructions"]) + }) + + t.Run("array content system message", func(t *testing.T) { + reqBody := map[string]any{ + "input": []any{ + map[string]any{ + "role": "system", + "content": []any{ + map[string]any{"type": "text", "text": "Be helpful."}, + }, + }, + }, + } + result := extractSystemMessagesFromInput(reqBody) + require.True(t, result) + require.Equal(t, "Be helpful.", reqBody["instructions"]) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 0) + }) + + t.Run("multiple system messages concatenated", func(t *testing.T) { + reqBody := map[string]any{ + "input": []any{ + map[string]any{"role": "system", "content": "First."}, + map[string]any{"role": "system", "content": "Second."}, + map[string]any{"role": "user", "content": "hi"}, + }, + } + result := extractSystemMessagesFromInput(reqBody) + require.True(t, result) + require.Equal(t, "First.\n\nSecond.", reqBody["instructions"]) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 1) + }) + + t.Run("mixed system and non-system preserves non-system", func(t *testing.T) { + reqBody := map[string]any{ + "input": []any{ + map[string]any{"role": "user", "content": "hello"}, + map[string]any{"role": "system", "content": "Sys prompt."}, + map[string]any{"role": "assistant", "content": "Hi there"}, + }, + } + result := extractSystemMessagesFromInput(reqBody) + require.True(t, result) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 2) + first, ok := input[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "user", first["role"]) + second, ok := input[1].(map[string]any) + require.True(t, ok) + require.Equal(t, "assistant", second["role"]) + }) + + t.Run("existing instructions prepended", func(t *testing.T) { + reqBody := map[string]any{ + "input": []any{ + map[string]any{"role": "system", "content": "Extracted."}, + map[string]any{"role": "user", "content": "hi"}, + }, + "instructions": "Existing instructions.", + } + result := extractSystemMessagesFromInput(reqBody) + require.True(t, result) + require.Equal(t, "Extracted.\n\nExisting instructions.", reqBody["instructions"]) + }) +} + +func TestApplyCodexOAuthTransform_ExtractsSystemMessages(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.1", + "input": []any{ + map[string]any{"role": "system", "content": "You are a coding assistant."}, + map[string]any{"role": "user", "content": "Write a function."}, + }, + } + + result := applyCodexOAuthTransform(reqBody, false, false) + + require.True(t, result.Modified) + + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 1) + msg, ok := input[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "user", msg["role"]) + + instructions, ok := reqBody["instructions"].(string) + require.True(t, ok) + require.Equal(t, "You are a coding assistant.", instructions) +} + func TestIsInstructionsEmpty(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/service/openai_compat_prompt_cache_key.go b/backend/internal/service/openai_compat_prompt_cache_key.go new file mode 100644 index 0000000000..88e16a4db0 --- /dev/null +++ b/backend/internal/service/openai_compat_prompt_cache_key.go @@ -0,0 +1,81 @@ +package service + +import ( + "encoding/json" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" +) + +const compatPromptCacheKeyPrefix = "compat_cc_" + +func shouldAutoInjectPromptCacheKeyForCompat(model string) bool { + switch normalizeCodexModel(strings.TrimSpace(model)) { + case "gpt-5.4", "gpt-5.3-codex": + return true + default: + return false + } +} + +func deriveCompatPromptCacheKey(req *apicompat.ChatCompletionsRequest, mappedModel string) string { + if req == nil { + return "" + } + + normalizedModel := normalizeCodexModel(strings.TrimSpace(mappedModel)) + if normalizedModel == "" { + normalizedModel = normalizeCodexModel(strings.TrimSpace(req.Model)) + } + if normalizedModel == "" { + normalizedModel = strings.TrimSpace(req.Model) + } + + seedParts := []string{"model=" + normalizedModel} + if req.ReasoningEffort != "" { + seedParts = append(seedParts, "reasoning_effort="+strings.TrimSpace(req.ReasoningEffort)) + } + if len(req.ToolChoice) > 0 { + seedParts = append(seedParts, "tool_choice="+normalizeCompatSeedJSON(req.ToolChoice)) + } + if len(req.Tools) > 0 { + if raw, err := json.Marshal(req.Tools); err == nil { + seedParts = append(seedParts, "tools="+normalizeCompatSeedJSON(raw)) + } + } + if len(req.Functions) > 0 { + if raw, err := json.Marshal(req.Functions); err == nil { + seedParts = append(seedParts, "functions="+normalizeCompatSeedJSON(raw)) + } + } + + firstUserCaptured := false + for _, msg := range req.Messages { + switch strings.TrimSpace(msg.Role) { + case "system": + seedParts = append(seedParts, "system="+normalizeCompatSeedJSON(msg.Content)) + case "user": + if !firstUserCaptured { + seedParts = append(seedParts, "first_user="+normalizeCompatSeedJSON(msg.Content)) + firstUserCaptured = true + } + } + } + + return compatPromptCacheKeyPrefix + hashSensitiveValueForLog(strings.Join(seedParts, "|")) +} + +func normalizeCompatSeedJSON(v json.RawMessage) string { + if len(v) == 0 { + return "" + } + var tmp any + if err := json.Unmarshal(v, &tmp); err != nil { + return string(v) + } + out, err := json.Marshal(tmp) + if err != nil { + return string(v) + } + return string(out) +} diff --git a/backend/internal/service/openai_compat_prompt_cache_key_test.go b/backend/internal/service/openai_compat_prompt_cache_key_test.go new file mode 100644 index 0000000000..eb9148de2d --- /dev/null +++ b/backend/internal/service/openai_compat_prompt_cache_key_test.go @@ -0,0 +1,64 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/stretchr/testify/require" +) + +func mustRawJSON(t *testing.T, s string) json.RawMessage { + t.Helper() + return json.RawMessage(s) +} + +func TestShouldAutoInjectPromptCacheKeyForCompat(t *testing.T) { + require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.4")) + require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.3")) + require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.3-codex")) + require.False(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-4o")) +} + +func TestDeriveCompatPromptCacheKey_StableAcrossLaterTurns(t *testing.T) { + base := &apicompat.ChatCompletionsRequest{ + Model: "gpt-5.4", + Messages: []apicompat.ChatMessage{ + {Role: "system", Content: mustRawJSON(t, `"You are helpful."`)}, + {Role: "user", Content: mustRawJSON(t, `"Hello"`)}, + }, + } + extended := &apicompat.ChatCompletionsRequest{ + Model: "gpt-5.4", + Messages: []apicompat.ChatMessage{ + {Role: "system", Content: mustRawJSON(t, `"You are helpful."`)}, + {Role: "user", Content: mustRawJSON(t, `"Hello"`)}, + {Role: "assistant", Content: mustRawJSON(t, `"Hi there!"`)}, + {Role: "user", Content: mustRawJSON(t, `"How are you?"`)}, + }, + } + + k1 := deriveCompatPromptCacheKey(base, "gpt-5.4") + k2 := deriveCompatPromptCacheKey(extended, "gpt-5.4") + require.Equal(t, k1, k2, "cache key should be stable across later turns") + require.NotEmpty(t, k1) +} + +func TestDeriveCompatPromptCacheKey_DiffersAcrossSessions(t *testing.T) { + req1 := &apicompat.ChatCompletionsRequest{ + Model: "gpt-5.4", + Messages: []apicompat.ChatMessage{ + {Role: "user", Content: mustRawJSON(t, `"Question A"`)}, + }, + } + req2 := &apicompat.ChatCompletionsRequest{ + Model: "gpt-5.4", + Messages: []apicompat.ChatMessage{ + {Role: "user", Content: mustRawJSON(t, `"Question B"`)}, + }, + } + + k1 := deriveCompatPromptCacheKey(req1, "gpt-5.4") + k2 := deriveCompatPromptCacheKey(req2, "gpt-5.4") + require.NotEqual(t, k1, k2, "different first user messages should yield different keys") +} diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 9529f6be46..a442da33bb 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -43,23 +43,38 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( clientStream := chatReq.Stream includeUsage := chatReq.StreamOptions != nil && chatReq.StreamOptions.IncludeUsage - // 2. Convert to Responses and forward + // 2. Resolve model mapping early so compat prompt_cache_key injection can + // derive a stable seed from the final upstream model family. + mappedModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) + + promptCacheKey = strings.TrimSpace(promptCacheKey) + compatPromptCacheInjected := false + if promptCacheKey == "" && account.Type == AccountTypeOAuth && shouldAutoInjectPromptCacheKeyForCompat(mappedModel) { + promptCacheKey = deriveCompatPromptCacheKey(&chatReq, mappedModel) + compatPromptCacheInjected = promptCacheKey != "" + } + + // 3. Convert to Responses and forward // ChatCompletionsToResponses always sets Stream=true (upstream always streams). responsesReq, err := apicompat.ChatCompletionsToResponses(&chatReq) if err != nil { return nil, fmt.Errorf("convert chat completions to responses: %w", err) } - - // 3. Model mapping - mappedModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) responsesReq.Model = mappedModel - logger.L().Debug("openai chat_completions: model mapping applied", + logFields := []zap.Field{ zap.Int64("account_id", account.ID), zap.String("original_model", originalModel), zap.String("mapped_model", mappedModel), zap.Bool("stream", clientStream), - ) + } + if compatPromptCacheInjected { + logFields = append(logFields, + zap.Bool("compat_prompt_cache_key_injected", true), + zap.String("compat_prompt_cache_key_sha256", hashSensitiveValueForLog(promptCacheKey)), + ) + } + logger.L().Debug("openai chat_completions: model mapping applied", logFields...) // 4. Marshal Responses request body, then apply OAuth codex transform responsesBody, err := json.Marshal(responsesReq) @@ -277,12 +292,13 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( c.JSON(http.StatusOK, chatResp) return &OpenAIForwardResult{ - RequestID: requestID, - Usage: usage, - Model: originalModel, - BillingModel: mappedModel, - Stream: false, - Duration: time.Since(startTime), + RequestID: requestID, + Usage: usage, + Model: originalModel, + BillingModel: mappedModel, + UpstreamModel: mappedModel, + Stream: false, + Duration: time.Since(startTime), }, nil } @@ -324,13 +340,14 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( resultWithUsage := func() *OpenAIForwardResult { return &OpenAIForwardResult{ - RequestID: requestID, - Usage: usage, - Model: originalModel, - BillingModel: mappedModel, - Stream: true, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + RequestID: requestID, + Usage: usage, + Model: originalModel, + BillingModel: mappedModel, + UpstreamModel: mappedModel, + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, } } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 587145715d..6a29823aee 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -299,12 +299,13 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( c.JSON(http.StatusOK, anthropicResp) return &OpenAIForwardResult{ - RequestID: requestID, - Usage: usage, - Model: originalModel, - BillingModel: mappedModel, - Stream: false, - Duration: time.Since(startTime), + RequestID: requestID, + Usage: usage, + Model: originalModel, + BillingModel: mappedModel, + UpstreamModel: mappedModel, + Stream: false, + Duration: time.Since(startTime), }, nil } @@ -347,13 +348,14 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( // resultWithUsage builds the final result snapshot. resultWithUsage := func() *OpenAIForwardResult { return &OpenAIForwardResult{ - RequestID: requestID, - Usage: usage, - Model: originalModel, - BillingModel: mappedModel, - Stream: true, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + RequestID: requestID, + Usage: usage, + Model: originalModel, + BillingModel: mappedModel, + UpstreamModel: mappedModel, + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, } } diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index ada7d805d6..a35f9127fb 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -846,7 +846,7 @@ func TestExtractOpenAIServiceTierFromBody(t *testing.T) { require.Nil(t, extractOpenAIServiceTierFromBody(nil)) } -func TestOpenAIGatewayServiceRecordUsage_UsesBillingModelAndMetadataFields(t *testing.T) { +func TestOpenAIGatewayServiceRecordUsage_UsesRequestedModelAndUpstreamModelMetadataFields(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} subRepo := &openAIRecordUsageSubRepoStub{} @@ -859,6 +859,7 @@ func TestOpenAIGatewayServiceRecordUsage_UsesBillingModelAndMetadataFields(t *te RequestID: "resp_billing_model_override", BillingModel: "gpt-5.1-codex", Model: "gpt-5.1", + UpstreamModel: "gpt-5.1-codex", ServiceTier: &serviceTier, ReasoningEffort: &reasoning, Usage: OpenAIUsage{ @@ -877,7 +878,9 @@ func TestOpenAIGatewayServiceRecordUsage_UsesBillingModelAndMetadataFields(t *te require.NoError(t, err) require.NotNil(t, usageRepo.lastLog) - require.Equal(t, "gpt-5.1-codex", usageRepo.lastLog.Model) + require.Equal(t, "gpt-5.1", usageRepo.lastLog.Model) + require.NotNil(t, usageRepo.lastLog.UpstreamModel) + require.Equal(t, "gpt-5.1-codex", *usageRepo.lastLog.UpstreamModel) require.NotNil(t, usageRepo.lastLog.ServiceTier) require.Equal(t, serviceTier, *usageRepo.lastLog.ServiceTier) require.NotNil(t, usageRepo.lastLog.ReasoningEffort) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index ddce6b8ed0..53f7e099e6 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -216,6 +216,9 @@ type OpenAIForwardResult struct { // This is set by the Anthropic Messages conversion path where // the mapped upstream model differs from the client-facing model. BillingModel string + // UpstreamModel is the actual model sent to the upstream provider after mapping. + // Empty when no mapping was applied (requested model was used as-is). + UpstreamModel string // ServiceTier records the OpenAI Responses API service tier, e.g. "priority" / "flex". // Nil means the request did not specify a recognized tier. ServiceTier *string @@ -2128,6 +2131,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco firstTokenMs, wsAttempts, ) + wsResult.UpstreamModel = mappedModel return wsResult, nil } s.writeOpenAIWSFallbackErrorResponse(c, account, wsErr) @@ -2263,6 +2267,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco RequestID: resp.Header.Get("x-request-id"), Usage: *usage, Model: originalModel, + UpstreamModel: mappedModel, ServiceTier: serviceTier, ReasoningEffort: reasoningEffort, Stream: reqStream, @@ -4134,7 +4139,8 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec APIKeyID: apiKey.ID, AccountID: account.ID, RequestID: requestID, - Model: billingModel, + Model: result.Model, + UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, result.Model), ServiceTier: result.ServiceTier, ReasoningEffort: result.ReasoningEffort, InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), @@ -4700,11 +4706,3 @@ func normalizeOpenAIReasoningEffort(raw string) string { return "" } } - -func optionalTrimmedStringPtr(raw string) *string { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return nil - } - return &trimmed -} diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 889a4cd3e9..2828802fe6 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -282,12 +282,15 @@ func (c *stubGatewayCache) DeleteSessionAccountID(ctx context.Context, groupID i return nil } -func (c *stubGatewayCache) GetClientAffinityAccounts(_ context.Context, _ int64, _ string, _ time.Duration) ([]int64, error) { +func (c *stubGatewayCache) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { return nil, nil } -func (c *stubGatewayCache) UpdateClientAffinity(_ context.Context, _ int64, _ string, _ int64, _ time.Duration) error { +func (c *stubGatewayCache) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error { return nil } +func (c *stubGatewayCache) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) { + return 0, 0, 0, nil +} func (c *stubGatewayCache) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) { return map[int64]int64{}, nil } diff --git a/backend/internal/service/openai_ws_state_store_test.go b/backend/internal/service/openai_ws_state_store_test.go index 40cce0dd47..9f35cd7ec6 100644 --- a/backend/internal/service/openai_ws_state_store_test.go +++ b/backend/internal/service/openai_ws_state_store_test.go @@ -193,12 +193,15 @@ func (c *openAIWSStateStoreTimeoutProbeCache) DeleteSessionAccountID(ctx context return nil } -func (c *openAIWSStateStoreTimeoutProbeCache) GetClientAffinityAccounts(_ context.Context, _ int64, _ string, _ time.Duration) ([]int64, error) { +func (c *openAIWSStateStoreTimeoutProbeCache) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { return nil, nil } -func (c *openAIWSStateStoreTimeoutProbeCache) UpdateClientAffinity(_ context.Context, _ int64, _ string, _ int64, _ time.Duration) error { +func (c *openAIWSStateStoreTimeoutProbeCache) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error { return nil } +func (c *openAIWSStateStoreTimeoutProbeCache) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) { + return 0, 0, 0, nil +} func (c *openAIWSStateStoreTimeoutProbeCache) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) { return map[int64]int64{}, nil } diff --git a/backend/internal/service/ops_retry.go b/backend/internal/service/ops_retry.go index fdabbafde9..c0e814ab7b 100644 --- a/backend/internal/service/ops_retry.go +++ b/backend/internal/service/ops_retry.go @@ -519,7 +519,7 @@ func (s *OpsService) selectAccountForRetry(ctx context.Context, reqType opsRetry if s.gatewayService == nil { return nil, fmt.Errorf("gateway service not available") } - return s.gatewayService.SelectAccountWithLoadAwareness(ctx, groupID, "", model, excludedIDs, "") // 重试不使用会话限制 + return s.gatewayService.SelectAccountWithLoadAwareness(ctx, groupID, "", model, excludedIDs, "", int64(0)) // 重试不使用会话限制 default: return nil, fmt.Errorf("unsupported retry type: %s", reqType) } diff --git a/backend/internal/service/ops_upstream_context.go b/backend/internal/service/ops_upstream_context.go index 21e09c43e7..9adf5896c9 100644 --- a/backend/internal/service/ops_upstream_context.go +++ b/backend/internal/service/ops_upstream_context.go @@ -53,6 +53,13 @@ func SetOpsLatencyMs(c *gin.Context, key string, value int64) { c.Set(key, value) } +// SetOpsUpstreamError is the exported wrapper for setOpsUpstreamError, used by +// handler-layer code (e.g. failover-exhausted paths) that needs to record the +// original upstream status code before mapping it to a client-facing code. +func SetOpsUpstreamError(c *gin.Context, upstreamStatusCode int, upstreamMessage, upstreamDetail string) { + setOpsUpstreamError(c, upstreamStatusCode, upstreamMessage, upstreamDetail) +} + func setOpsUpstreamError(c *gin.Context, upstreamStatusCode int, upstreamMessage, upstreamDetail string) { if c == nil { return diff --git a/backend/internal/service/overload_cooldown_test.go b/backend/internal/service/overload_cooldown_test.go new file mode 100644 index 0000000000..ef5e7fd122 --- /dev/null +++ b/backend/internal/service/overload_cooldown_test.go @@ -0,0 +1,298 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// errSettingRepo: a SettingRepository that always returns errors on read +// --------------------------------------------------------------------------- + +type errSettingRepo struct { + mockSettingRepo // embed the existing mock from backup_service_test.go + readErr error +} + +func (r *errSettingRepo) GetValue(_ context.Context, _ string) (string, error) { + return "", r.readErr +} + +func (r *errSettingRepo) Get(_ context.Context, _ string) (*Setting, error) { + return nil, r.readErr +} + +// --------------------------------------------------------------------------- +// overloadAccountRepoStub: records SetOverloaded calls +// --------------------------------------------------------------------------- + +type overloadAccountRepoStub struct { + mockAccountRepoForGemini + overloadCalls int + lastOverloadID int64 + lastOverloadEnd time.Time +} + +func (r *overloadAccountRepoStub) SetOverloaded(_ context.Context, id int64, until time.Time) error { + r.overloadCalls++ + r.lastOverloadID = id + r.lastOverloadEnd = until + return nil +} + +// =========================================================================== +// SettingService: GetOverloadCooldownSettings +// =========================================================================== + +func TestGetOverloadCooldownSettings_DefaultsWhenNotSet(t *testing.T) { + repo := newMockSettingRepo() + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetOverloadCooldownSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.Enabled) + require.Equal(t, 10, settings.CooldownMinutes) +} + +func TestGetOverloadCooldownSettings_ReadsFromDB(t *testing.T) { + repo := newMockSettingRepo() + data, _ := json.Marshal(OverloadCooldownSettings{Enabled: false, CooldownMinutes: 30}) + repo.data[SettingKeyOverloadCooldownSettings] = string(data) + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetOverloadCooldownSettings(context.Background()) + require.NoError(t, err) + require.False(t, settings.Enabled) + require.Equal(t, 30, settings.CooldownMinutes) +} + +func TestGetOverloadCooldownSettings_ClampsMinValue(t *testing.T) { + repo := newMockSettingRepo() + data, _ := json.Marshal(OverloadCooldownSettings{Enabled: true, CooldownMinutes: 0}) + repo.data[SettingKeyOverloadCooldownSettings] = string(data) + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetOverloadCooldownSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, settings.CooldownMinutes) +} + +func TestGetOverloadCooldownSettings_ClampsMaxValue(t *testing.T) { + repo := newMockSettingRepo() + data, _ := json.Marshal(OverloadCooldownSettings{Enabled: true, CooldownMinutes: 999}) + repo.data[SettingKeyOverloadCooldownSettings] = string(data) + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetOverloadCooldownSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, 120, settings.CooldownMinutes) +} + +func TestGetOverloadCooldownSettings_InvalidJSON_ReturnsDefaults(t *testing.T) { + repo := newMockSettingRepo() + repo.data[SettingKeyOverloadCooldownSettings] = "not-json" + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetOverloadCooldownSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.Enabled) + require.Equal(t, 10, settings.CooldownMinutes) +} + +func TestGetOverloadCooldownSettings_EmptyValue_ReturnsDefaults(t *testing.T) { + repo := newMockSettingRepo() + repo.data[SettingKeyOverloadCooldownSettings] = "" + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetOverloadCooldownSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.Enabled) + require.Equal(t, 10, settings.CooldownMinutes) +} + +// =========================================================================== +// SettingService: SetOverloadCooldownSettings +// =========================================================================== + +func TestSetOverloadCooldownSettings_Success(t *testing.T) { + repo := newMockSettingRepo() + svc := NewSettingService(repo, &config.Config{}) + + err := svc.SetOverloadCooldownSettings(context.Background(), &OverloadCooldownSettings{ + Enabled: false, + CooldownMinutes: 25, + }) + require.NoError(t, err) + + // Verify round-trip + settings, err := svc.GetOverloadCooldownSettings(context.Background()) + require.NoError(t, err) + require.False(t, settings.Enabled) + require.Equal(t, 25, settings.CooldownMinutes) +} + +func TestSetOverloadCooldownSettings_RejectsNil(t *testing.T) { + svc := NewSettingService(newMockSettingRepo(), &config.Config{}) + err := svc.SetOverloadCooldownSettings(context.Background(), nil) + require.Error(t, err) +} + +func TestSetOverloadCooldownSettings_EnabledRejectsOutOfRange(t *testing.T) { + svc := NewSettingService(newMockSettingRepo(), &config.Config{}) + + for _, minutes := range []int{0, -1, 121, 999} { + err := svc.SetOverloadCooldownSettings(context.Background(), &OverloadCooldownSettings{ + Enabled: true, CooldownMinutes: minutes, + }) + require.Error(t, err, "should reject enabled=true + cooldown_minutes=%d", minutes) + require.Contains(t, err.Error(), "cooldown_minutes must be between 1-120") + } +} + +func TestSetOverloadCooldownSettings_DisabledNormalizesOutOfRange(t *testing.T) { + repo := newMockSettingRepo() + svc := NewSettingService(repo, &config.Config{}) + + // enabled=false + cooldown_minutes=0 应该保存成功,值被归一化为10 + err := svc.SetOverloadCooldownSettings(context.Background(), &OverloadCooldownSettings{ + Enabled: false, CooldownMinutes: 0, + }) + require.NoError(t, err, "disabled with invalid minutes should NOT be rejected") + + // 验证持久化后读回来的值 + settings, err := svc.GetOverloadCooldownSettings(context.Background()) + require.NoError(t, err) + require.False(t, settings.Enabled) + require.Equal(t, 10, settings.CooldownMinutes, "should be normalized to default") +} + +func TestSetOverloadCooldownSettings_AcceptsBoundaries(t *testing.T) { + svc := NewSettingService(newMockSettingRepo(), &config.Config{}) + + for _, minutes := range []int{1, 60, 120} { + err := svc.SetOverloadCooldownSettings(context.Background(), &OverloadCooldownSettings{ + Enabled: true, CooldownMinutes: minutes, + }) + require.NoError(t, err, "should accept cooldown_minutes=%d", minutes) + } +} + +// =========================================================================== +// RateLimitService: handle529 behaviour +// =========================================================================== + +func TestHandle529_EnabledFromDB_PausesAccount(t *testing.T) { + accountRepo := &overloadAccountRepoStub{} + settingRepo := newMockSettingRepo() + data, _ := json.Marshal(OverloadCooldownSettings{Enabled: true, CooldownMinutes: 15}) + settingRepo.data[SettingKeyOverloadCooldownSettings] = string(data) + + settingSvc := NewSettingService(settingRepo, &config.Config{}) + svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + svc.SetSettingService(settingSvc) + + account := &Account{ID: 42, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + before := time.Now() + svc.handle529(context.Background(), account) + + require.Equal(t, 1, accountRepo.overloadCalls) + require.Equal(t, int64(42), accountRepo.lastOverloadID) + require.WithinDuration(t, before.Add(15*time.Minute), accountRepo.lastOverloadEnd, 2*time.Second) +} + +func TestHandle529_DisabledFromDB_SkipsAccount(t *testing.T) { + accountRepo := &overloadAccountRepoStub{} + settingRepo := newMockSettingRepo() + data, _ := json.Marshal(OverloadCooldownSettings{Enabled: false, CooldownMinutes: 15}) + settingRepo.data[SettingKeyOverloadCooldownSettings] = string(data) + + settingSvc := NewSettingService(settingRepo, &config.Config{}) + svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + svc.SetSettingService(settingSvc) + + account := &Account{ID: 42, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + svc.handle529(context.Background(), account) + + require.Equal(t, 0, accountRepo.overloadCalls, "should NOT pause when disabled") +} + +func TestHandle529_NilSettingService_FallsBackToConfig(t *testing.T) { + accountRepo := &overloadAccountRepoStub{} + cfg := &config.Config{} + cfg.RateLimit.OverloadCooldownMinutes = 20 + svc := NewRateLimitService(accountRepo, nil, cfg, nil, nil) + // NOT calling SetSettingService — remains nil + + account := &Account{ID: 77, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + before := time.Now() + svc.handle529(context.Background(), account) + + require.Equal(t, 1, accountRepo.overloadCalls) + require.WithinDuration(t, before.Add(20*time.Minute), accountRepo.lastOverloadEnd, 2*time.Second) +} + +func TestHandle529_NilSettingService_ZeroConfig_DefaultsTen(t *testing.T) { + accountRepo := &overloadAccountRepoStub{} + svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + + account := &Account{ID: 88, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + before := time.Now() + svc.handle529(context.Background(), account) + + require.Equal(t, 1, accountRepo.overloadCalls) + require.WithinDuration(t, before.Add(10*time.Minute), accountRepo.lastOverloadEnd, 2*time.Second) +} + +func TestHandle529_DBReadError_FallsBackToConfig(t *testing.T) { + accountRepo := &overloadAccountRepoStub{} + errRepo := &errSettingRepo{readErr: context.DeadlineExceeded} + errRepo.data = make(map[string]string) + + cfg := &config.Config{} + cfg.RateLimit.OverloadCooldownMinutes = 7 + settingSvc := NewSettingService(errRepo, cfg) + svc := NewRateLimitService(accountRepo, nil, cfg, nil, nil) + svc.SetSettingService(settingSvc) + + account := &Account{ID: 99, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + before := time.Now() + svc.handle529(context.Background(), account) + + require.Equal(t, 1, accountRepo.overloadCalls) + require.WithinDuration(t, before.Add(7*time.Minute), accountRepo.lastOverloadEnd, 2*time.Second) +} + +// =========================================================================== +// Model: defaults & JSON round-trip +// =========================================================================== + +func TestDefaultOverloadCooldownSettings(t *testing.T) { + d := DefaultOverloadCooldownSettings() + require.True(t, d.Enabled) + require.Equal(t, 10, d.CooldownMinutes) +} + +func TestOverloadCooldownSettings_JSONRoundTrip(t *testing.T) { + original := OverloadCooldownSettings{Enabled: false, CooldownMinutes: 42} + data, err := json.Marshal(original) + require.NoError(t, err) + + var decoded OverloadCooldownSettings + require.NoError(t, json.Unmarshal(data, &decoded)) + require.Equal(t, original, decoded) + + // Verify JSON uses snake_case field names + var raw map[string]any + require.NoError(t, json.Unmarshal(data, &raw)) + _, hasEnabled := raw["enabled"] + _, hasCooldown := raw["cooldown_minutes"] + require.True(t, hasEnabled, "JSON must use 'enabled'") + require.True(t, hasCooldown, "JSON must use 'cooldown_minutes'") +} diff --git a/backend/internal/service/proxy.go b/backend/internal/service/proxy.go index fc449091ac..a2896d6c14 100644 --- a/backend/internal/service/proxy.go +++ b/backend/internal/service/proxy.go @@ -1,7 +1,9 @@ package service import ( - "fmt" + "net" + "net/url" + "strconv" "time" ) @@ -23,10 +25,14 @@ func (p *Proxy) IsActive() bool { } func (p *Proxy) URL() string { - if p.Username != "" && p.Password != "" { - return fmt.Sprintf("%s://%s:%s@%s:%d", p.Protocol, p.Username, p.Password, p.Host, p.Port) + u := &url.URL{ + Scheme: p.Protocol, + Host: net.JoinHostPort(p.Host, strconv.Itoa(p.Port)), } - return fmt.Sprintf("%s://%s:%d", p.Protocol, p.Host, p.Port) + if p.Username != "" && p.Password != "" { + u.User = url.UserPassword(p.Username, p.Password) + } + return u.String() } type ProxyWithAccountCount struct { diff --git a/backend/internal/service/proxy_test.go b/backend/internal/service/proxy_test.go new file mode 100644 index 0000000000..da6d1236a1 --- /dev/null +++ b/backend/internal/service/proxy_test.go @@ -0,0 +1,95 @@ +package service + +import ( + "net/url" + "testing" +) + +func TestProxyURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + proxy Proxy + want string + }{ + { + name: "without auth", + proxy: Proxy{ + Protocol: "http", + Host: "proxy.example.com", + Port: 8080, + }, + want: "http://proxy.example.com:8080", + }, + { + name: "with auth", + proxy: Proxy{ + Protocol: "socks5", + Host: "socks.example.com", + Port: 1080, + Username: "user", + Password: "pass", + }, + want: "socks5://user:pass@socks.example.com:1080", + }, + { + name: "username only keeps no auth for compatibility", + proxy: Proxy{ + Protocol: "http", + Host: "proxy.example.com", + Port: 8080, + Username: "user-only", + }, + want: "http://proxy.example.com:8080", + }, + { + name: "with special characters in credentials", + proxy: Proxy{ + Protocol: "http", + Host: "proxy.example.com", + Port: 3128, + Username: "first last@corp", + Password: "p@ ss:#word", + }, + want: "http://first%20last%40corp:p%40%20ss%3A%23word@proxy.example.com:3128", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := tc.proxy.URL(); got != tc.want { + t.Fatalf("Proxy.URL() mismatch: got=%q want=%q", got, tc.want) + } + }) + } +} + +func TestProxyURL_SpecialCharactersRoundTrip(t *testing.T) { + t.Parallel() + + proxy := Proxy{ + Protocol: "http", + Host: "proxy.example.com", + Port: 3128, + Username: "first last@corp", + Password: "p@ ss:#word", + } + + parsed, err := url.Parse(proxy.URL()) + if err != nil { + t.Fatalf("parse proxy URL failed: %v", err) + } + if got := parsed.User.Username(); got != proxy.Username { + t.Fatalf("username mismatch after parse: got=%q want=%q", got, proxy.Username) + } + pass, ok := parsed.User.Password() + if !ok { + t.Fatal("password missing after parse") + } + if pass != proxy.Password { + t.Fatalf("password mismatch after parse: got=%q want=%q", pass, proxy.Password) + } +} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 5861a8111c..5c6c26e167 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -1023,11 +1023,34 @@ func parseOpenAIRateLimitResetTime(body []byte) *int64 { } // handle529 处理529过载错误 -// 根据配置设置过载冷却时间 +// 根据配置决定是否暂停账号调度及冷却时长 func (s *RateLimitService) handle529(ctx context.Context, account *Account) { - cooldownMinutes := s.cfg.RateLimit.OverloadCooldownMinutes + var settings *OverloadCooldownSettings + if s.settingService != nil { + var err error + settings, err = s.settingService.GetOverloadCooldownSettings(ctx) + if err != nil { + slog.Warn("overload_settings_read_failed", "account_id", account.ID, "error", err) + settings = nil + } + } + // 回退到配置文件 + if settings == nil { + cooldown := s.cfg.RateLimit.OverloadCooldownMinutes + if cooldown <= 0 { + cooldown = 10 + } + settings = &OverloadCooldownSettings{Enabled: true, CooldownMinutes: cooldown} + } + + if !settings.Enabled { + slog.Info("account_529_ignored", "account_id", account.ID, "reason", "overload_cooldown_disabled") + return + } + + cooldownMinutes := settings.CooldownMinutes if cooldownMinutes <= 0 { - cooldownMinutes = 10 // 默认10分钟 + cooldownMinutes = 10 } until := time.Now().Add(time.Duration(cooldownMinutes) * time.Minute) @@ -1051,18 +1074,49 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc var windowStart, windowEnd *time.Time needInitWindow := account.SessionWindowEnd == nil || time.Now().After(*account.SessionWindowEnd) - if needInitWindow && (status == "allowed" || status == "allowed_warning") { - // 预测时间窗口:从当前时间的整点开始,+5小时为结束 - // 例如:现在是 14:30,窗口为 14:00 ~ 19:00 + // 优先使用响应头中的真实重置时间(比预测更准确) + if resetStr := headers.Get("anthropic-ratelimit-unified-5h-reset"); resetStr != "" { + if ts, err := strconv.ParseInt(resetStr, 10, 64); err == nil { + // 检测可能的毫秒时间戳(秒级约为 1e9,毫秒约为 1e12) + if ts > 1e11 { + slog.Warn("account_session_window_header_millis_detected", "account_id", account.ID, "raw_reset", resetStr) + ts = ts / 1000 + } + end := time.Unix(ts, 0) + // 校验时间戳是否在合理范围内(不早于 5h 前,不晚于 7 天后) + minAllowed := time.Now().Add(-5 * time.Hour) + maxAllowed := time.Now().Add(7 * 24 * time.Hour) + if end.Before(minAllowed) || end.After(maxAllowed) { + slog.Warn("account_session_window_header_out_of_range", "account_id", account.ID, "raw_reset", resetStr, "parsed_end", end) + } else if needInitWindow || account.SessionWindowEnd == nil || !end.Equal(*account.SessionWindowEnd) { + // 窗口需要初始化,或者真实重置时间与已存储的不同,则更新 + start := end.Add(-5 * time.Hour) + windowStart = &start + windowEnd = &end + slog.Info("account_session_window_from_header", "account_id", account.ID, "window_start", start, "window_end", end, "status", status) + } + } else { + slog.Warn("account_session_window_header_parse_failed", "account_id", account.ID, "raw_reset", resetStr, "error", err) + } + } + + // 回退:如果没有真实重置时间且需要初始化窗口,使用预测 + if windowEnd == nil && needInitWindow && (status == "allowed" || status == "allowed_warning") { now := time.Now() start := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location()) end := start.Add(5 * time.Hour) windowStart = &start windowEnd = &end slog.Info("account_session_window_initialized", "account_id", account.ID, "window_start", start, "window_end", end, "status", status) - // 窗口重置时清除旧的 utilization,避免残留上个窗口的数据 + } + + // 窗口重置时清除旧的 utilization 和被动采样数据,避免残留上个窗口的数据 + if windowEnd != nil && needInitWindow { _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ - "session_window_utilization": nil, + "session_window_utilization": nil, + "passive_usage_7d_utilization": nil, + "passive_usage_7d_reset": nil, + "passive_usage_sampled_at": nil, }) } @@ -1070,14 +1124,33 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc slog.Warn("session_window_update_failed", "account_id", account.ID, "error", err) } - // 存储真实的 utilization 值(0-1 小数),供 estimateSetupTokenUsage 使用 + // 被动采样:从响应头收集 5h + 7d utilization,合并为一次 DB 写入 + extraUpdates := make(map[string]any, 4) + // 5h utilization(0-1 小数),供 estimateSetupTokenUsage 使用 if utilStr := headers.Get("anthropic-ratelimit-unified-5h-utilization"); utilStr != "" { if util, err := strconv.ParseFloat(utilStr, 64); err == nil { - if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ - "session_window_utilization": util, - }); err != nil { - slog.Warn("session_window_utilization_update_failed", "account_id", account.ID, "error", err) + extraUpdates["session_window_utilization"] = util + } + } + // 7d utilization(0-1 小数) + if utilStr := headers.Get("anthropic-ratelimit-unified-7d-utilization"); utilStr != "" { + if util, err := strconv.ParseFloat(utilStr, 64); err == nil { + extraUpdates["passive_usage_7d_utilization"] = util + } + } + // 7d reset timestamp + if resetStr := headers.Get("anthropic-ratelimit-unified-7d-reset"); resetStr != "" { + if ts, err := strconv.ParseInt(resetStr, 10, 64); err == nil { + if ts > 1e11 { + ts = ts / 1000 } + extraUpdates["passive_usage_7d_reset"] = ts + } + } + if len(extraUpdates) > 0 { + extraUpdates["passive_usage_sampled_at"] = time.Now().UTC().Format(time.RFC3339) + if err := s.accountRepo.UpdateExtra(ctx, account.ID, extraUpdates); err != nil { + slog.Warn("passive_usage_update_failed", "account_id", account.ID, "error", err) } } diff --git a/backend/internal/service/ratelimit_session_window_test.go b/backend/internal/service/ratelimit_session_window_test.go new file mode 100644 index 0000000000..1e990e3a6d --- /dev/null +++ b/backend/internal/service/ratelimit_session_window_test.go @@ -0,0 +1,370 @@ +package service + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" +) + +// sessionWindowMockRepo is a minimal AccountRepository mock that records calls +// made by UpdateSessionWindow. Unrelated methods panic if invoked. +type sessionWindowMockRepo struct { + // captured calls + sessionWindowCalls []swCall + updateExtraCalls []ueCall + clearRateLimitIDs []int64 +} + +var _ AccountRepository = (*sessionWindowMockRepo)(nil) + +type swCall struct { + ID int64 + Start *time.Time + End *time.Time + Status string +} + +type ueCall struct { + ID int64 + Updates map[string]any +} + +func (m *sessionWindowMockRepo) UpdateSessionWindow(_ context.Context, id int64, start, end *time.Time, status string) error { + m.sessionWindowCalls = append(m.sessionWindowCalls, swCall{ID: id, Start: start, End: end, Status: status}) + return nil +} +func (m *sessionWindowMockRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error { + m.updateExtraCalls = append(m.updateExtraCalls, ueCall{ID: id, Updates: updates}) + return nil +} +func (m *sessionWindowMockRepo) ClearRateLimit(_ context.Context, id int64) error { + m.clearRateLimitIDs = append(m.clearRateLimitIDs, id) + return nil +} +func (m *sessionWindowMockRepo) ClearAntigravityQuotaScopes(_ context.Context, _ int64) error { + return nil +} +func (m *sessionWindowMockRepo) ClearModelRateLimits(_ context.Context, _ int64) error { + return nil +} +func (m *sessionWindowMockRepo) ClearTempUnschedulable(_ context.Context, _ int64) error { + return nil +} + +// --- Unused interface methods (panic on unexpected call) --- + +func (m *sessionWindowMockRepo) Create(context.Context, *Account) error { panic("unexpected") } +func (m *sessionWindowMockRepo) GetByID(context.Context, int64) (*Account, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) GetByIDs(context.Context, []int64) ([]*Account, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) ExistsByID(context.Context, int64) (bool, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) GetByCRSAccountID(context.Context, string) (*Account, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) FindByExtraField(context.Context, string, any) ([]Account, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) ListCRSAccountIDs(context.Context) (map[string]int64, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) Update(context.Context, *Account) error { panic("unexpected") } +func (m *sessionWindowMockRepo) Delete(context.Context, int64) error { panic("unexpected") } +func (m *sessionWindowMockRepo) List(context.Context, pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string, string, int64) ([]Account, *pagination.PaginationResult, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) ListByGroup(context.Context, int64) ([]Account, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) ListActive(context.Context) ([]Account, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) ListByPlatform(context.Context, string) ([]Account, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) UpdateLastUsed(context.Context, int64) error { panic("unexpected") } +func (m *sessionWindowMockRepo) BatchUpdateLastUsed(context.Context, map[int64]time.Time) error { + panic("unexpected") +} +func (m *sessionWindowMockRepo) SetError(context.Context, int64, string) error { + panic("unexpected") +} +func (m *sessionWindowMockRepo) ClearError(context.Context, int64) error { panic("unexpected") } +func (m *sessionWindowMockRepo) SetSchedulable(context.Context, int64, bool) error { + panic("unexpected") +} +func (m *sessionWindowMockRepo) AutoPauseExpiredAccounts(context.Context, time.Time) (int64, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) BindGroups(context.Context, int64, []int64) error { + panic("unexpected") +} +func (m *sessionWindowMockRepo) ListSchedulable(context.Context) ([]Account, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) ListSchedulableByGroupID(context.Context, int64) ([]Account, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) ListSchedulableByPlatform(context.Context, string) ([]Account, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) ListSchedulableByGroupIDAndPlatform(context.Context, int64, string) ([]Account, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) ListSchedulableByPlatforms(context.Context, []string) ([]Account, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) ListSchedulableByGroupIDAndPlatforms(context.Context, int64, []string) ([]Account, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) ListSchedulableUngroupedByPlatform(context.Context, string) ([]Account, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) ListSchedulableUngroupedByPlatforms(context.Context, []string) ([]Account, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) SetRateLimited(context.Context, int64, time.Time) error { + panic("unexpected") +} +func (m *sessionWindowMockRepo) SetModelRateLimit(context.Context, int64, string, time.Time) error { + panic("unexpected") +} +func (m *sessionWindowMockRepo) SetOverloaded(context.Context, int64, time.Time) error { + panic("unexpected") +} +func (m *sessionWindowMockRepo) SetTempUnschedulable(context.Context, int64, time.Time, string) error { + panic("unexpected") +} +func (m *sessionWindowMockRepo) BulkUpdate(context.Context, []int64, AccountBulkUpdate) (int64, error) { + panic("unexpected") +} +func (m *sessionWindowMockRepo) IncrementQuotaUsed(context.Context, int64, float64) error { + panic("unexpected") +} +func (m *sessionWindowMockRepo) ResetQuotaUsed(context.Context, int64) error { panic("unexpected") } + +// newRateLimitServiceForTest creates a RateLimitService with the given mock repo. +func newRateLimitServiceForTest(repo AccountRepository) *RateLimitService { + return &RateLimitService{accountRepo: repo} +} + +func TestUpdateSessionWindow_UsesResetHeader(t *testing.T) { + // The reset header provides the real window end as a Unix timestamp. + // UpdateSessionWindow should use it instead of the hour-truncated prediction. + resetUnix := time.Now().Add(3 * time.Hour).Unix() + wantEnd := time.Unix(resetUnix, 0) + wantStart := wantEnd.Add(-5 * time.Hour) + + repo := &sessionWindowMockRepo{} + svc := newRateLimitServiceForTest(repo) + + account := &Account{ID: 42} // no existing window → needInitWindow=true + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-5h-status", "allowed") + headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", resetUnix)) + + svc.UpdateSessionWindow(context.Background(), account, headers) + + if len(repo.sessionWindowCalls) != 1 { + t.Fatalf("expected 1 UpdateSessionWindow call, got %d", len(repo.sessionWindowCalls)) + } + + call := repo.sessionWindowCalls[0] + if call.ID != 42 { + t.Errorf("expected account ID 42, got %d", call.ID) + } + if call.End == nil || !call.End.Equal(wantEnd) { + t.Errorf("expected window end %v, got %v", wantEnd, call.End) + } + if call.Start == nil || !call.Start.Equal(wantStart) { + t.Errorf("expected window start %v, got %v", wantStart, call.Start) + } + if call.Status != "allowed" { + t.Errorf("expected status 'allowed', got %q", call.Status) + } +} + +func TestUpdateSessionWindow_FallbackPredictionWhenNoResetHeader(t *testing.T) { + // When the reset header is absent, should fall back to hour-truncated prediction. + repo := &sessionWindowMockRepo{} + svc := newRateLimitServiceForTest(repo) + + account := &Account{ID: 10} // no existing window + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-5h-status", "allowed_warning") + // No anthropic-ratelimit-unified-5h-reset header + + // Capture now before the call to avoid hour-boundary races + now := time.Now() + expectedStart := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location()) + expectedEnd := expectedStart.Add(5 * time.Hour) + + svc.UpdateSessionWindow(context.Background(), account, headers) + + if len(repo.sessionWindowCalls) != 1 { + t.Fatalf("expected 1 UpdateSessionWindow call, got %d", len(repo.sessionWindowCalls)) + } + + call := repo.sessionWindowCalls[0] + if call.End == nil { + t.Fatal("expected window end to be set (fallback prediction)") + } + // Fallback: start = current hour truncated, end = start + 5h + + if !call.End.Equal(expectedEnd) { + t.Errorf("expected fallback end %v, got %v", expectedEnd, *call.End) + } + if call.Start == nil || !call.Start.Equal(expectedStart) { + t.Errorf("expected fallback start %v, got %v", expectedStart, call.Start) + } +} + +func TestUpdateSessionWindow_CorrectsStalePrediction(t *testing.T) { + // When the stored SessionWindowEnd is wrong (from a previous prediction), + // and the reset header provides the real time, it should update the window. + staleEnd := time.Now().Add(2 * time.Hour) // existing prediction: 2h from now + realResetUnix := time.Now().Add(4 * time.Hour).Unix() // real reset: 4h from now + wantEnd := time.Unix(realResetUnix, 0) + + repo := &sessionWindowMockRepo{} + svc := newRateLimitServiceForTest(repo) + + account := &Account{ + ID: 55, + SessionWindowEnd: &staleEnd, + } + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-5h-status", "allowed") + headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", realResetUnix)) + + svc.UpdateSessionWindow(context.Background(), account, headers) + + if len(repo.sessionWindowCalls) != 1 { + t.Fatalf("expected 1 UpdateSessionWindow call, got %d", len(repo.sessionWindowCalls)) + } + + call := repo.sessionWindowCalls[0] + if call.End == nil || !call.End.Equal(wantEnd) { + t.Errorf("expected corrected end %v, got %v", wantEnd, call.End) + } +} + +func TestUpdateSessionWindow_NoUpdateWhenHeaderMatchesStored(t *testing.T) { + // If the reset header matches the stored SessionWindowEnd, no window update needed. + futureUnix := time.Now().Add(3 * time.Hour).Unix() + existingEnd := time.Unix(futureUnix, 0) + + repo := &sessionWindowMockRepo{} + svc := newRateLimitServiceForTest(repo) + + account := &Account{ + ID: 77, + SessionWindowEnd: &existingEnd, + } + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-5h-status", "allowed") + headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", futureUnix)) // same as stored + + svc.UpdateSessionWindow(context.Background(), account, headers) + + if len(repo.sessionWindowCalls) != 1 { + t.Fatalf("expected 1 UpdateSessionWindow call, got %d", len(repo.sessionWindowCalls)) + } + + call := repo.sessionWindowCalls[0] + // windowStart and windowEnd should be nil (no update needed) + if call.Start != nil || call.End != nil { + t.Errorf("expected nil start/end (no window change needed), got start=%v end=%v", call.Start, call.End) + } + // Status is still updated + if call.Status != "allowed" { + t.Errorf("expected status 'allowed', got %q", call.Status) + } +} + +func TestUpdateSessionWindow_ClearsUtilizationOnWindowReset(t *testing.T) { + // When needInitWindow=true and window is set, utilization should be cleared. + resetUnix := time.Now().Add(3 * time.Hour).Unix() + + repo := &sessionWindowMockRepo{} + svc := newRateLimitServiceForTest(repo) + + account := &Account{ID: 33} // no existing window → needInitWindow=true + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-5h-status", "allowed") + headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", resetUnix)) + headers.Set("anthropic-ratelimit-unified-5h-utilization", "0.15") + + svc.UpdateSessionWindow(context.Background(), account, headers) + + // Should have 2 UpdateExtra calls: one to clear utilization, one to store new utilization + if len(repo.updateExtraCalls) != 2 { + t.Fatalf("expected 2 UpdateExtra calls, got %d", len(repo.updateExtraCalls)) + } + + // First call: clear utilization (nil value) + clearCall := repo.updateExtraCalls[0] + if clearCall.Updates["session_window_utilization"] != nil { + t.Errorf("expected utilization cleared to nil, got %v", clearCall.Updates["session_window_utilization"]) + } + + // Second call: store new utilization + storeCall := repo.updateExtraCalls[1] + if val, ok := storeCall.Updates["session_window_utilization"].(float64); !ok || val != 0.15 { + t.Errorf("expected utilization stored as 0.15, got %v", storeCall.Updates["session_window_utilization"]) + } +} + +func TestUpdateSessionWindow_NoClearUtilizationOnCorrection(t *testing.T) { + // When correcting a stale prediction (needInitWindow=false), utilization should NOT be cleared. + staleEnd := time.Now().Add(2 * time.Hour) + realResetUnix := time.Now().Add(4 * time.Hour).Unix() + + repo := &sessionWindowMockRepo{} + svc := newRateLimitServiceForTest(repo) + + account := &Account{ + ID: 66, + SessionWindowEnd: &staleEnd, + } + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-5h-status", "allowed") + headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", realResetUnix)) + headers.Set("anthropic-ratelimit-unified-5h-utilization", "0.30") + + svc.UpdateSessionWindow(context.Background(), account, headers) + + // Only 1 UpdateExtra call (store utilization), no clear call + if len(repo.updateExtraCalls) != 1 { + t.Fatalf("expected 1 UpdateExtra call (no clear), got %d", len(repo.updateExtraCalls)) + } + + if val, ok := repo.updateExtraCalls[0].Updates["session_window_utilization"].(float64); !ok || val != 0.30 { + t.Errorf("expected utilization 0.30, got %v", repo.updateExtraCalls[0].Updates["session_window_utilization"]) + } +} + +func TestUpdateSessionWindow_NoStatusHeader(t *testing.T) { + // Should return immediately if no status header. + repo := &sessionWindowMockRepo{} + svc := newRateLimitServiceForTest(repo) + + account := &Account{ID: 1} + + svc.UpdateSessionWindow(context.Background(), account, http.Header{}) + + if len(repo.sessionWindowCalls) != 0 { + t.Errorf("expected no calls when status header absent, got %d", len(repo.sessionWindowCalls)) + } +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 0268ec4242..cded98443f 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -44,26 +44,27 @@ type SettingRepository interface { Delete(ctx context.Context, key string) error } -// cachedMinVersion 缓存最低 Claude Code 版本号(进程内缓存,60s TTL) -type cachedMinVersion struct { - value string // 空字符串 = 不检查 +// cachedVersionBounds 缓存 Claude Code 版本号上下限(进程内缓存,60s TTL) +type cachedVersionBounds struct { + min string // 空字符串 = 不检查 + max string // 空字符串 = 不检查 expiresAt int64 // unix nano } -// minVersionCache 最低版本号进程内缓存 -var minVersionCache atomic.Value // *cachedMinVersion +// versionBoundsCache 版本号上下限进程内缓存 +var versionBoundsCache atomic.Value // *cachedVersionBounds -// minVersionSF 防止缓存过期时 thundering herd -var minVersionSF singleflight.Group +// versionBoundsSF 防止缓存过期时 thundering herd +var versionBoundsSF singleflight.Group -// minVersionCacheTTL 缓存有效期 -const minVersionCacheTTL = 60 * time.Second +// versionBoundsCacheTTL 缓存有效期 +const versionBoundsCacheTTL = 60 * time.Second -// minVersionErrorTTL DB 错误时的短缓存,快速重试 -const minVersionErrorTTL = 5 * time.Second +// versionBoundsErrorTTL DB 错误时的短缓存,快速重试 +const versionBoundsErrorTTL = 5 * time.Second -// minVersionDBTimeout singleflight 内 DB 查询超时,独立于请求 context -const minVersionDBTimeout = 5 * time.Second +// versionBoundsDBTimeout singleflight 内 DB 查询超时,独立于请求 context +const versionBoundsDBTimeout = 5 * time.Second // cachedBackendMode Backend Mode cache (in-process, 60s TTL) type cachedBackendMode struct { @@ -527,6 +528,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet // Claude Code version check updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion + updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion // 分组隔离 updates[SettingKeyAllowUngroupedKeyScheduling] = strconv.FormatBool(settings.AllowUngroupedKeyScheduling) @@ -537,10 +539,11 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet err = s.settingRepo.SetMultiple(ctx, updates) if err == nil { // 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口 - minVersionSF.Forget("min_version") - minVersionCache.Store(&cachedMinVersion{ - value: settings.MinClaudeCodeVersion, - expiresAt: time.Now().Add(minVersionCacheTTL).UnixNano(), + versionBoundsSF.Forget("version_bounds") + versionBoundsCache.Store(&cachedVersionBounds{ + min: settings.MinClaudeCodeVersion, + max: settings.MaxClaudeCodeVersion, + expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(), }) backendModeSF.Forget("backend_mode") backendModeCache.Store(&cachedBackendMode{ @@ -803,6 +806,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // Claude Code version check (default: empty = disabled) SettingKeyMinClaudeCodeVersion: "", + SettingKeyMaxClaudeCodeVersion: "", // 分组隔离(默认不允许未分组 Key 调度) SettingKeyAllowUngroupedKeyScheduling: "false", @@ -938,6 +942,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // Claude Code version check result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion] + result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion] // 分组隔离 result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true" @@ -1215,6 +1220,57 @@ func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (conf return effective, nil } +// GetOverloadCooldownSettings 获取529过载冷却配置 +func (s *SettingService) GetOverloadCooldownSettings(ctx context.Context) (*OverloadCooldownSettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyOverloadCooldownSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultOverloadCooldownSettings(), nil + } + return nil, fmt.Errorf("get overload cooldown settings: %w", err) + } + if value == "" { + return DefaultOverloadCooldownSettings(), nil + } + + var settings OverloadCooldownSettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return DefaultOverloadCooldownSettings(), nil + } + + // 修正配置值范围 + if settings.CooldownMinutes < 1 { + settings.CooldownMinutes = 1 + } + if settings.CooldownMinutes > 120 { + settings.CooldownMinutes = 120 + } + + return &settings, nil +} + +// SetOverloadCooldownSettings 设置529过载冷却配置 +func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settings *OverloadCooldownSettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + // 禁用时修正为合法值即可,不拒绝请求 + if settings.CooldownMinutes < 1 || settings.CooldownMinutes > 120 { + if settings.Enabled { + return fmt.Errorf("cooldown_minutes must be between 1-120") + } + settings.CooldownMinutes = 10 // 禁用状态下归一化为默认值 + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal overload cooldown settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data)) +} + // GetStreamTimeoutSettings 获取流超时处理配置 func (s *SettingService) GetStreamTimeoutSettings(ctx context.Context) (*StreamTimeoutSettings, error) { value, err := s.settingRepo.GetValue(ctx, SettingKeyStreamTimeoutSettings) @@ -1273,51 +1329,61 @@ func (s *SettingService) IsUngroupedKeySchedulingAllowed(ctx context.Context) bo return value == "true" } -// GetMinClaudeCodeVersion 获取最低 Claude Code 版本号要求 +// GetClaudeCodeVersionBounds 获取 Claude Code 版本号上下限要求 // 使用进程内 atomic.Value 缓存,60 秒 TTL,热路径零锁开销 // singleflight 防止缓存过期时 thundering herd -// 返回空字符串表示不做版本检查 -func (s *SettingService) GetMinClaudeCodeVersion(ctx context.Context) string { - if cached, ok := minVersionCache.Load().(*cachedMinVersion); ok { +// 返回空字符串表示不做对应方向的版本检查 +func (s *SettingService) GetClaudeCodeVersionBounds(ctx context.Context) (min, max string) { + if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok { if time.Now().UnixNano() < cached.expiresAt { - return cached.value + return cached.min, cached.max } } // singleflight: 同一时刻只有一个 goroutine 查询 DB,其余复用结果 - result, err, _ := minVersionSF.Do("min_version", func() (any, error) { + type bounds struct{ min, max string } + result, err, _ := versionBoundsSF.Do("version_bounds", func() (any, error) { // 二次检查,避免排队的 goroutine 重复查询 - if cached, ok := minVersionCache.Load().(*cachedMinVersion); ok { + if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok { if time.Now().UnixNano() < cached.expiresAt { - return cached.value, nil + return bounds{cached.min, cached.max}, nil } } // 使用独立 context:断开请求取消链,避免客户端断连导致空值被长期缓存 - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), minVersionDBTimeout) + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), versionBoundsDBTimeout) defer cancel() - value, err := s.settingRepo.GetValue(dbCtx, SettingKeyMinClaudeCodeVersion) + values, err := s.settingRepo.GetMultiple(dbCtx, []string{ + SettingKeyMinClaudeCodeVersion, + SettingKeyMaxClaudeCodeVersion, + }) if err != nil { // fail-open: DB 错误时不阻塞请求,但记录日志并使用短 TTL 快速重试 - slog.Warn("failed to get min claude code version setting, skipping version check", "error", err) - minVersionCache.Store(&cachedMinVersion{ - value: "", - expiresAt: time.Now().Add(minVersionErrorTTL).UnixNano(), + slog.Warn("failed to get claude code version bounds setting, skipping version check", "error", err) + versionBoundsCache.Store(&cachedVersionBounds{ + min: "", + max: "", + expiresAt: time.Now().Add(versionBoundsErrorTTL).UnixNano(), }) - return "", nil + return bounds{"", ""}, nil } - minVersionCache.Store(&cachedMinVersion{ - value: value, - expiresAt: time.Now().Add(minVersionCacheTTL).UnixNano(), + b := bounds{ + min: values[SettingKeyMinClaudeCodeVersion], + max: values[SettingKeyMaxClaudeCodeVersion], + } + versionBoundsCache.Store(&cachedVersionBounds{ + min: b.min, + max: b.max, + expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(), }) - return value, nil + return b, nil }) if err != nil { - return "" + return "", "" } - ver, ok := result.(string) + b, ok := result.(bounds) if !ok { - return "" + return "", "" } - return ver + return b.min, b.max } // GetRectifierSettings 获取请求整流器配置 diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index bb3df35dc0..836ce80972 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -67,6 +67,7 @@ type SystemSettings struct { // Claude Code version check MinClaudeCodeVersion string + MaxClaudeCodeVersion string // 分组隔离:允许未分组 Key 调度(默认 false → 403) AllowUngroupedKeyScheduling bool @@ -243,6 +244,22 @@ type BetaPolicySettings struct { Rules []BetaPolicyRule `json:"rules"` } +// OverloadCooldownSettings 529过载冷却配置 +type OverloadCooldownSettings struct { + // Enabled 是否在收到529时暂停账号调度 + Enabled bool `json:"enabled"` + // CooldownMinutes 冷却时长(分钟) + CooldownMinutes int `json:"cooldown_minutes"` +} + +// DefaultOverloadCooldownSettings 返回默认的过载冷却配置(启用,10分钟) +func DefaultOverloadCooldownSettings() *OverloadCooldownSettings { + return &OverloadCooldownSettings{ + Enabled: true, + CooldownMinutes: 10, + } +} + // DefaultBetaPolicySettings 返回默认的 Beta 策略配置 func DefaultBetaPolicySettings() *BetaPolicySettings { return &BetaPolicySettings{ diff --git a/backend/internal/service/sora_generation_service_test.go b/backend/internal/service/sora_generation_service_test.go index 0bde211f6c..68177a9759 100644 --- a/backend/internal/service/sora_generation_service_test.go +++ b/backend/internal/service/sora_generation_service_test.go @@ -181,6 +181,9 @@ func (r *stubUserRepoForQuota) ExistsByEmail(context.Context, string) (bool, err func (r *stubUserRepoForQuota) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil } +func (r *stubUserRepoForQuota) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error { + return nil +} func (r *stubUserRepoForQuota) UpdateTotpSecret(context.Context, int64, *string) error { return nil } func (r *stubUserRepoForQuota) EnableTotp(context.Context, int64) error { return nil } func (r *stubUserRepoForQuota) DisableTotp(context.Context, int64) error { return nil } diff --git a/backend/internal/service/sora_quota_service_test.go b/backend/internal/service/sora_quota_service_test.go index 040e427d8f..da8efe77e9 100644 --- a/backend/internal/service/sora_quota_service_test.go +++ b/backend/internal/service/sora_quota_service_test.go @@ -52,8 +52,8 @@ func (r *stubGroupRepoForQuota) ListActiveByPlatform(context.Context, string) ([ func (r *stubGroupRepoForQuota) ExistsByName(context.Context, string) (bool, error) { return false, nil } -func (r *stubGroupRepoForQuota) GetAccountCount(context.Context, int64) (int64, error) { - return 0, nil +func (r *stubGroupRepoForQuota) GetAccountCount(context.Context, int64) (int64, int64, error) { + return 0, 0, nil } func (r *stubGroupRepoForQuota) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { return 0, nil diff --git a/backend/internal/service/subscription_assign_idempotency_test.go b/backend/internal/service/subscription_assign_idempotency_test.go index 0defafba95..40bab20606 100644 --- a/backend/internal/service/subscription_assign_idempotency_test.go +++ b/backend/internal/service/subscription_assign_idempotency_test.go @@ -40,7 +40,7 @@ func (groupRepoNoop) ListActiveByPlatform(context.Context, string) ([]Group, err func (groupRepoNoop) ExistsByName(context.Context, string) (bool, error) { panic("unexpected ExistsByName call") } -func (groupRepoNoop) GetAccountCount(context.Context, int64) (int64, error) { +func (groupRepoNoop) GetAccountCount(context.Context, int64) (int64, int64, error) { panic("unexpected GetAccountCount call") } func (groupRepoNoop) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { @@ -92,7 +92,7 @@ func (userSubRepoNoop) ListActiveByUserID(context.Context, int64) ([]UserSubscri func (userSubRepoNoop) ListByGroupID(context.Context, int64, pagination.PaginationParams) ([]UserSubscription, *pagination.PaginationResult, error) { panic("unexpected ListByGroupID call") } -func (userSubRepoNoop) List(context.Context, pagination.PaginationParams, *int64, *int64, string, string, string) ([]UserSubscription, *pagination.PaginationResult, error) { +func (userSubRepoNoop) List(context.Context, pagination.PaginationParams, *int64, *int64, string, string, string, string) ([]UserSubscription, *pagination.PaginationResult, error) { panic("unexpected List call") } func (userSubRepoNoop) ExistsByUserIDAndGroupID(context.Context, int64, int64) (bool, error) { diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index af548509ce..f0a5540e0a 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -634,9 +634,9 @@ func (s *SubscriptionService) ListGroupSubscriptions(ctx context.Context, groupI } // List 获取所有订阅(分页,支持筛选和排序) -func (s *SubscriptionService) List(ctx context.Context, page, pageSize int, userID, groupID *int64, status, sortBy, sortOrder string) ([]UserSubscription, *pagination.PaginationResult, error) { +func (s *SubscriptionService) List(ctx context.Context, page, pageSize int, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]UserSubscription, *pagination.PaginationResult, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize} - subs, pag, err := s.userSubRepo.List(ctx, params, userID, groupID, status, sortBy, sortOrder) + subs, pag, err := s.userSubRepo.List(ctx, params, userID, groupID, status, platform, sortBy, sortOrder) if err != nil { return nil, nil, err } diff --git a/backend/internal/service/token_refresh_service.go b/backend/internal/service/token_refresh_service.go index cb8841b0a4..582afcd3b2 100644 --- a/backend/internal/service/token_refresh_service.go +++ b/backend/internal/service/token_refresh_service.go @@ -12,6 +12,9 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" ) +// tokenRefreshTempUnschedDuration token 刷新重试耗尽后临时不可调度的持续时间 +const tokenRefreshTempUnschedDuration = 10 * time.Minute + // TokenRefreshService OAuth token自动刷新服务 // 定期检查并刷新即将过期的token type TokenRefreshService struct { @@ -317,7 +320,7 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc } } - // 可重试错误耗尽:仅记录日志,不标记 error(可能是临时网络问题,下个周期继续重试) + // 可重试错误耗尽:临时标记账号不可调度,避免请求路径反复命中已知失败的账号 slog.Warn("token_refresh.retry_exhausted", "account_id", account.ID, "platform", account.Platform, @@ -325,6 +328,21 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc "error", lastErr, ) + // 设置临时不可调度 10 分钟(不标记 error,保持 status=active 让下个刷新周期能继续尝试) + until := time.Now().Add(tokenRefreshTempUnschedDuration) + reason := fmt.Sprintf("token refresh retry exhausted: %v", lastErr) + if setErr := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); setErr != nil { + slog.Warn("token_refresh.set_temp_unschedulable_failed", + "account_id", account.ID, + "error", setErr, + ) + } else { + slog.Info("token_refresh.temp_unschedulable_set", + "account_id", account.ID, + "until", until.Format(time.RFC3339), + ) + } + return lastErr } diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index 7f1bef7f9c..5a498676df 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -98,6 +98,9 @@ type UsageLog struct { AccountID int64 RequestID string Model string + // UpstreamModel is the actual model sent to the upstream provider after mapping. + // Nil means no mapping was applied (requested model was used as-is). + UpstreamModel *string // ServiceTier records the OpenAI service tier used for billing, e.g. "priority" / "flex". ServiceTier *string // ReasoningEffort is the request's reasoning effort level. diff --git a/backend/internal/service/usage_log_helpers.go b/backend/internal/service/usage_log_helpers.go new file mode 100644 index 0000000000..2ab5184989 --- /dev/null +++ b/backend/internal/service/usage_log_helpers.go @@ -0,0 +1,21 @@ +package service + +import "strings" + +func optionalTrimmedStringPtr(raw string) *string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + return &trimmed +} + +// optionalNonEqualStringPtr returns a pointer to value if it is non-empty and +// differs from compare; otherwise nil. Used to store upstream_model only when +// it differs from the requested model. +func optionalNonEqualStringPtr(value, compare string) *string { + if value == "" || value == compare { + return nil + } + return &value +} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 49ba3645eb..4045c0aaab 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -21,6 +21,7 @@ type UserListFilters struct { Status string // User status filter Role string // User role filter Search string // Search in email, username + GroupName string // Filter by allowed group name (fuzzy match) Attributes map[int64]string // Custom attribute filters: attributeID -> value // IncludeSubscriptions controls whether ListWithFilters should load active subscriptions. // For large datasets this can be expensive; admin list pages should enable it on demand. @@ -46,6 +47,8 @@ type UserRepository interface { RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) // AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error + // RemoveGroupFromUserAllowedGroups 移除单个用户的指定分组权限 + RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error // TOTP 双因素认证 UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index 7f6c748fa6..e88694f5fd 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -46,9 +46,12 @@ func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int return 0, nil } func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil } -func (m *mockUserRepo) UpdateTotpSecret(context.Context, int64, *string) error { return nil } -func (m *mockUserRepo) EnableTotp(context.Context, int64) error { return nil } -func (m *mockUserRepo) DisableTotp(context.Context, int64) error { return nil } +func (m *mockUserRepo) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error { + return nil +} +func (m *mockUserRepo) UpdateTotpSecret(context.Context, int64, *string) error { return nil } +func (m *mockUserRepo) EnableTotp(context.Context, int64) error { return nil } +func (m *mockUserRepo) DisableTotp(context.Context, int64) error { return nil } // --- mock: APIKeyAuthCacheInvalidator --- diff --git a/backend/internal/service/user_subscription_port.go b/backend/internal/service/user_subscription_port.go index 2dfc8d028a..4484fae8f4 100644 --- a/backend/internal/service/user_subscription_port.go +++ b/backend/internal/service/user_subscription_port.go @@ -18,7 +18,7 @@ type UserSubscriptionRepository interface { ListByUserID(ctx context.Context, userID int64) ([]UserSubscription, error) ListActiveByUserID(ctx context.Context, userID int64) ([]UserSubscription, error) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]UserSubscription, *pagination.PaginationResult, error) - List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]UserSubscription, *pagination.PaginationResult, error) + List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]UserSubscription, *pagination.PaginationResult, error) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) ExtendExpiry(ctx context.Context, subscriptionID int64, newExpiresAt time.Time) error diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 7da72630a3..fca8fd6d60 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -114,11 +114,13 @@ func ProvideAntigravityTokenProvider( tokenCache GeminiTokenCache, antigravityOAuthService *AntigravityOAuthService, refreshAPI *OAuthRefreshAPI, + tempUnschedCache TempUnschedCache, ) *AntigravityTokenProvider { p := NewAntigravityTokenProvider(accountRepo, tokenCache, antigravityOAuthService) executor := NewAntigravityTokenRefresher(antigravityOAuthService) p.SetRefreshAPI(refreshAPI, executor) p.SetRefreshPolicy(AntigravityProviderRefreshPolicy()) + p.SetTempUnschedCache(tempUnschedCache) return p } @@ -486,4 +488,5 @@ var ProviderSet = wire.NewSet( ProvideIdempotencyCleanupService, ProvideScheduledTestService, ProvideScheduledTestRunnerService, + NewGroupCapacityService, ) diff --git a/backend/internal/setup/handler.go b/backend/internal/setup/handler.go index 1531c97bc3..c2944cedfb 100644 --- a/backend/internal/setup/handler.go +++ b/backend/internal/setup/handler.go @@ -247,6 +247,12 @@ func install(c *gin.Context) { return } + req.Admin.Email = strings.TrimSpace(req.Admin.Email) + req.Database.Host = strings.TrimSpace(req.Database.Host) + req.Database.User = strings.TrimSpace(req.Database.User) + req.Database.DBName = strings.TrimSpace(req.Database.DBName) + req.Redis.Host = strings.TrimSpace(req.Redis.Host) + // ========== COMPREHENSIVE INPUT VALIDATION ========== // Database validation if !validateHostname(req.Database.Host) { @@ -319,13 +325,6 @@ func install(c *gin.Context) { return } - // Trim whitespace from string inputs - req.Admin.Email = strings.TrimSpace(req.Admin.Email) - req.Database.Host = strings.TrimSpace(req.Database.Host) - req.Database.User = strings.TrimSpace(req.Database.User) - req.Database.DBName = strings.TrimSpace(req.Database.DBName) - req.Redis.Host = strings.TrimSpace(req.Redis.Host) - cfg := &SetupConfig{ Database: req.Database, Redis: req.Redis, diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index de3b765a01..9256d24575 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -164,8 +164,8 @@ func NeedsSetup() bool { func TestDatabaseConnection(cfg *DatabaseConfig) error { // First, connect to the default 'postgres' database to check/create target database defaultDSN := fmt.Sprintf( - "host=%s port=%d user=%s password=%s dbname=postgres sslmode=%s", - cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.SSLMode, + "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode, ) db, err := sql.Open("postgres", defaultDSN) diff --git a/backend/internal/testutil/stubs.go b/backend/internal/testutil/stubs.go index 229985434a..1ae72127bc 100644 --- a/backend/internal/testutil/stubs.go +++ b/backend/internal/testutil/stubs.go @@ -100,12 +100,15 @@ func (c StubGatewayCache) RefreshSessionTTL(_ context.Context, _ int64, _ string func (c StubGatewayCache) DeleteSessionAccountID(_ context.Context, _ int64, _ string) error { return nil } -func (c StubGatewayCache) GetClientAffinityAccounts(_ context.Context, _ int64, _ string, _ time.Duration) ([]int64, error) { +func (c StubGatewayCache) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) { return nil, nil } -func (c StubGatewayCache) UpdateClientAffinity(_ context.Context, _ int64, _ string, _ int64, _ time.Duration) error { +func (c StubGatewayCache) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error { return nil } +func (c StubGatewayCache) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) { + return 0, 0, 0, nil +} func (c StubGatewayCache) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) { return map[int64]int64{}, nil } diff --git a/backend/internal/web/embed_on.go b/backend/internal/web/embed_on.go index aa5bb60dc1..c8ef4c33a3 100644 --- a/backend/internal/web/embed_on.go +++ b/backend/internal/web/embed_on.go @@ -205,7 +205,37 @@ func (s *FrontendServer) injectSettings(settingsJSON []byte) []byte { // Inject before headClose := []byte("") - return bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1) + result := bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1) + + // Replace
+ {{ t('admin.accounts.affinityConfigHint') }} +
++ {{ t('admin.accounts.affinityToggleHint') }} +
+- {{ t('admin.accounts.affinityToggleHint') }} + {{ t('admin.accounts.affinityAllowSwitchHint') }}
+ {{ t('admin.accounts.affinityAllowSwitchWarning') }} +
-+ {{ t('admin.accounts.allowOveragesTooltip') }} +
+- {{ t('admin.accounts.quotaLimitHint') }} + {{ t('admin.accounts.quotaControl.hint') }}
- {{ t('admin.accounts.quotaLimitHint') }} + {{ t('admin.accounts.quotaControl.hint') }}
+ {{ t('admin.users.replaceGroupHint', { old: oldGroup.name }) }} +
+ + +