From 5db8e9543a09ac7c1e01d4d37b50e71ec641939f Mon Sep 17 00:00:00 2001 From: kw0ng Date: Sat, 4 Apr 2026 19:08:50 +0800 Subject: [PATCH 01/23] feat: adapt fork installer and add API key health tooling --- Dockerfile | 4 +- Dockerfile.goreleaser | 4 +- README.md | 18 +- README_CN.md | 18 +- README_JA.md | 18 +- .../internal/handler/admin/account_apikey.go | 445 ++++++++++++++++++ .../handler/admin/account_apikey_test.go | 32 ++ backend/internal/server/routes/admin.go | 2 + backend/internal/service/apikey_health.go | 267 +++++++++++ .../internal/service/apikey_health_test.go | 58 +++ backend/internal/service/ratelimit_service.go | 4 + .../service/ratelimit_service_apikey_test.go | 33 ++ deploy/DOCKER.md | 8 +- deploy/Dockerfile | 4 +- deploy/README.md | 10 +- deploy/docker-deploy.sh | 2 +- deploy/install.sh | 56 ++- deploy/sub2api.service | 2 +- frontend/src/api/admin/accounts.ts | 65 +++ .../admin/account/RawKeyImportModal.vue | 172 +++++++ frontend/src/i18n/locales/en.ts | 25 + frontend/src/i18n/locales/zh.ts | 25 + frontend/src/views/admin/AccountsView.vue | 38 ++ 23 files changed, 1262 insertions(+), 48 deletions(-) create mode 100644 backend/internal/handler/admin/account_apikey.go create mode 100644 backend/internal/handler/admin/account_apikey_test.go create mode 100644 backend/internal/service/apikey_health.go create mode 100644 backend/internal/service/apikey_health_test.go create mode 100644 backend/internal/service/ratelimit_service_apikey_test.go create mode 100644 frontend/src/components/admin/account/RawKeyImportModal.vue diff --git a/Dockerfile b/Dockerfile index a16eb958f2..42331e32b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -84,9 +84,9 @@ FROM ${POSTGRES_IMAGE} AS pg-client FROM ${ALPINE_IMAGE} # Labels -LABEL maintainer="Wei-Shaw " +LABEL maintainer="kw0ngr " LABEL description="Sub2API - AI API Gateway Platform" -LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api" +LABEL org.opencontainers.image.source="https://github.com/kw0ngr/sub2api" # Install runtime dependencies RUN apk add --no-cache \ diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser index f251d154c6..6f6d8d110a 100644 --- a/Dockerfile.goreleaser +++ b/Dockerfile.goreleaser @@ -12,9 +12,9 @@ FROM ${POSTGRES_IMAGE} AS pg-client FROM ${ALPINE_IMAGE} -LABEL maintainer="Wei-Shaw " +LABEL maintainer="kw0ngr " LABEL description="Sub2API - AI API Gateway Platform" -LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api" +LABEL org.opencontainers.image.source="https://github.com/kw0ngr/sub2api" # Install runtime dependencies RUN apk add --no-cache \ diff --git a/README.md b/README.md index 99753e4569..e5d31ffc87 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ One-click installation script that downloads pre-built binaries from GitHub Rele #### Installation Steps ```bash -curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash +curl -sSL https://raw.githubusercontent.com/kw0ngr/sub2api/main/deploy/install.sh | sudo bash ``` The script will: @@ -156,7 +156,7 @@ sudo journalctl -u sub2api -f sudo systemctl restart sub2api # Uninstall -curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y +curl -sSL https://raw.githubusercontent.com/kw0ngr/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y ``` --- @@ -179,7 +179,7 @@ Use the automated deployment script for easy setup: mkdir -p sub2api-deploy && cd sub2api-deploy # Download and run deployment preparation script -curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash +curl -sSL https://raw.githubusercontent.com/kw0ngr/sub2api/main/deploy/docker-deploy.sh | bash # Start services docker compose up -d @@ -201,7 +201,7 @@ If you prefer manual setup: ```bash # 1. Clone the repository -git clone https://github.com/Wei-Shaw/sub2api.git +git clone https://github.com/kw0ngr/sub2api.git cd sub2api/deploy # 2. Copy environment configuration @@ -340,7 +340,7 @@ Build and run from source code for development or customization. ```bash # 1. Clone the repository -git clone https://github.com/Wei-Shaw/sub2api.git +git clone https://github.com/kw0ngr/sub2api.git cd sub2api # 2. Install pnpm (if not already installed) @@ -566,11 +566,11 @@ sub2api/ ## Star History - + - - - Star History Chart + + + Star History Chart diff --git a/README_CN.md b/README_CN.md index 8b6feaba0d..2fc1ec4b07 100644 --- a/README_CN.md +++ b/README_CN.md @@ -105,7 +105,7 @@ Nginx 默认会丢弃名称中含下划线的请求头(如 `session_id`), #### 安装步骤 ```bash -curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash +curl -sSL https://raw.githubusercontent.com/kw0ngr/sub2api/main/deploy/install.sh | sudo bash ``` 脚本会自动: @@ -155,7 +155,7 @@ sudo journalctl -u sub2api -f sudo systemctl restart sub2api # 卸载 -curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y +curl -sSL https://raw.githubusercontent.com/kw0ngr/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y ``` --- @@ -178,7 +178,7 @@ curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install mkdir -p sub2api-deploy && cd sub2api-deploy # 下载并运行部署准备脚本 -curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash +curl -sSL https://raw.githubusercontent.com/kw0ngr/sub2api/main/deploy/docker-deploy.sh | bash # 启动服务 docker compose up -d @@ -200,7 +200,7 @@ docker compose logs -f sub2api ```bash # 1. 克隆仓库 -git clone https://github.com/Wei-Shaw/sub2api.git +git clone https://github.com/kw0ngr/sub2api.git cd sub2api/deploy # 2. 复制环境配置文件 @@ -351,7 +351,7 @@ rm -rf data/ postgres_data/ redis_data/ ```bash # 1. 克隆仓库 -git clone https://github.com/Wei-Shaw/sub2api.git +git clone https://github.com/kw0ngr/sub2api.git cd sub2api # 2. 安装 pnpm(如果还没有安装) @@ -627,11 +627,11 @@ sub2api/ ## Star History - + - - - Star History Chart + + + Star History Chart diff --git a/README_JA.md b/README_JA.md index 1266bd845c..6bdb4fe52d 100644 --- a/README_JA.md +++ b/README_JA.md @@ -106,7 +106,7 @@ GitHub Releases からビルド済みバイナリをダウンロードするワ #### インストール手順 ```bash -curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash +curl -sSL https://raw.githubusercontent.com/kw0ngr/sub2api/main/deploy/install.sh | sudo bash ``` スクリプトは以下を実行します: @@ -156,7 +156,7 @@ sudo journalctl -u sub2api -f sudo systemctl restart sub2api # アンインストール -curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y +curl -sSL https://raw.githubusercontent.com/kw0ngr/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y ``` --- @@ -179,7 +179,7 @@ PostgreSQL と Redis のコンテナを含む Docker Compose でデプロイし mkdir -p sub2api-deploy && cd sub2api-deploy # デプロイ準備スクリプトをダウンロードして実行 -curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash +curl -sSL https://raw.githubusercontent.com/kw0ngr/sub2api/main/deploy/docker-deploy.sh | bash # サービスを起動 docker compose up -d @@ -201,7 +201,7 @@ docker compose logs -f sub2api ```bash # 1. リポジトリをクローン -git clone https://github.com/Wei-Shaw/sub2api.git +git clone https://github.com/kw0ngr/sub2api.git cd sub2api/deploy # 2. 環境設定ファイルをコピー @@ -340,7 +340,7 @@ rm -rf data/ postgres_data/ redis_data/ ```bash # 1. リポジトリをクローン -git clone https://github.com/Wei-Shaw/sub2api.git +git clone https://github.com/kw0ngr/sub2api.git cd sub2api # 2. pnpm をインストール(未インストールの場合) @@ -566,11 +566,11 @@ sub2api/ ## スター履歴 - + - - - Star History Chart + + + Star History Chart diff --git a/backend/internal/handler/admin/account_apikey.go b/backend/internal/handler/admin/account_apikey.go new file mode 100644 index 0000000000..1597db3582 --- /dev/null +++ b/backend/internal/handler/admin/account_apikey.go @@ -0,0 +1,445 @@ +package admin + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "strings" + "sync" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "golang.org/x/sync/errgroup" +) + +const rawAPIKeyImportPageSize = 500 + +type RawAPIKeyImportRequest struct { + RawText string `json:"raw_text" binding:"required"` + ValidateAfterImport bool `json:"validate_after_import"` + SkipDefaultGroupBind bool `json:"skip_default_group_bind"` +} + +type RawAPIKeyImportLineResult struct { + Line int `json:"line"` + KeyPreview string `json:"key_preview,omitempty"` + Platform string `json:"platform,omitempty"` + AccountID int64 `json:"account_id,omitempty"` + StatusCode int `json:"status_code,omitempty"` + Created bool `json:"created"` + Checked bool `json:"checked"` + Valid bool `json:"valid"` + InvalidDisabled bool `json:"invalid_disabled"` + Error string `json:"error,omitempty"` + Message string `json:"message,omitempty"` +} + +type RawAPIKeyImportResult struct { + TotalLines int `json:"total_lines"` + Created int `json:"created"` + Checked int `json:"checked"` + Valid int `json:"valid"` + InvalidDisabled int `json:"invalid_disabled"` + Failed int `json:"failed"` + Results []RawAPIKeyImportLineResult `json:"results"` +} + +type APIKeyHealthCheckRequest struct { + AccountIDs []int64 `json:"account_ids"` +} + +type APIKeyHealthCheckItem struct { + AccountID int64 `json:"account_id"` + Name string `json:"name"` + Platform string `json:"platform"` + StatusCode int `json:"status_code,omitempty"` + Valid bool `json:"valid"` + InvalidDisabled bool `json:"invalid_disabled"` + Error string `json:"error,omitempty"` + Message string `json:"message,omitempty"` +} + +type APIKeyHealthCheckResult struct { + Total int `json:"total"` + Checked int `json:"checked"` + Valid int `json:"valid"` + InvalidDisabled int `json:"invalid_disabled"` + Failed int `json:"failed"` + Results []APIKeyHealthCheckItem `json:"results"` +} + +type rawAPIKeyImportLine struct { + Line int + Key string + BaseURL string + Platform string +} + +func (h *AccountHandler) ImportRawAPIKeys(c *gin.Context) { + var req RawAPIKeyImportRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if req.ValidateAfterImport && h.accountTestService == nil { + response.Error(c, http.StatusServiceUnavailable, "API key health check service is unavailable") + return + } + + totalLines, lines, parseResults, err := parseRawAPIKeyImportLines(req.RawText) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + if totalLines == 0 { + response.BadRequest(c, "No API key lines found") + return + } + + result := RawAPIKeyImportResult{ + TotalLines: totalLines, + Results: make([]RawAPIKeyImportLineResult, 0, len(parseResults)), + } + + result.Results = append(result.Results, parseResults...) + for _, item := range parseResults { + if item.Error != "" { + result.Failed++ + } + } + + existingByIdentity, err := h.loadExistingAPIKeyIndex(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + for _, line := range lines { + identity := buildAPIKeyIdentity(line.Platform, line.Key, line.BaseURL) + if existing, ok := existingByIdentity[identity]; ok && existing != nil { + result.Failed++ + result.Results = append(result.Results, RawAPIKeyImportLineResult{ + Line: line.Line, + KeyPreview: maskRawAPIKey(line.Key), + Platform: line.Platform, + AccountID: existing.ID, + Error: "duplicate key already exists", + }) + continue + } + + credentials := map[string]any{ + "api_key": line.Key, + } + if line.BaseURL != "" { + credentials["base_url"] = line.BaseURL + } else if defaultBaseURL := service.DefaultAPIKeyBaseURL(line.Platform); defaultBaseURL != "" && line.Platform != service.PlatformAnthropic { + credentials["base_url"] = defaultBaseURL + } + + account, createErr := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{ + Name: buildRawAPIKeyAccountName(line.Platform, line.Key), + Platform: line.Platform, + Type: service.AccountTypeAPIKey, + Credentials: credentials, + Concurrency: 3, + Priority: 50, + SkipDefaultGroupBind: req.SkipDefaultGroupBind, + }) + + item := RawAPIKeyImportLineResult{ + Line: line.Line, + KeyPreview: maskRawAPIKey(line.Key), + Platform: line.Platform, + } + + if createErr != nil { + item.Error = createErr.Error() + result.Failed++ + result.Results = append(result.Results, item) + continue + } + + item.Created = true + item.AccountID = account.ID + result.Created++ + existingByIdentity[identity] = account + + if req.ValidateAfterImport { + item.Checked = true + result.Checked++ + health, healthErr := h.accountTestService.CheckAPIKeyValidity(c.Request.Context(), account) + if healthErr != nil { + item.Error = healthErr.Error() + result.Failed++ + } else { + item.StatusCode = health.StatusCode + item.Message = health.Message + item.Valid = health.Valid + if health.Valid { + result.Valid++ + } + if health.Invalid { + item.InvalidDisabled = true + result.InvalidDisabled++ + if err := h.adminService.SetAccountError(c.Request.Context(), account.ID, buildInvalidAPIKeyErrorMessage(account.Platform, health.Message)); err != nil { + item.Error = err.Error() + item.InvalidDisabled = false + result.InvalidDisabled-- + result.Failed++ + } + } + } + } + + result.Results = append(result.Results, item) + } + + response.Success(c, result) +} + +func (h *AccountHandler) CheckAPIKeysHealth(c *gin.Context) { + var req APIKeyHealthCheckRequest + if err := c.ShouldBindJSON(&req); err != nil && err.Error() != "EOF" { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if h.accountTestService == nil { + response.Error(c, http.StatusServiceUnavailable, "API key health check service is unavailable") + return + } + + accounts, err := h.resolveAPIKeyHealthCheckAccounts(c.Request.Context(), req.AccountIDs) + if err != nil { + response.ErrorFrom(c, err) + return + } + + result := APIKeyHealthCheckResult{ + Total: len(accounts), + Results: make([]APIKeyHealthCheckItem, 0, len(accounts)), + } + + const maxConcurrency = 8 + g, ctx := errgroup.WithContext(c.Request.Context()) + g.SetLimit(maxConcurrency) + + var mu sync.Mutex + for _, account := range accounts { + acc := account + g.Go(func() error { + item := APIKeyHealthCheckItem{ + AccountID: acc.ID, + Name: acc.Name, + Platform: acc.Platform, + } + + health, healthErr := h.accountTestService.CheckAPIKeyValidity(ctx, acc) + + mu.Lock() + defer mu.Unlock() + + result.Checked++ + if healthErr != nil { + item.Error = healthErr.Error() + result.Failed++ + result.Results = append(result.Results, item) + return nil + } + + item.StatusCode = health.StatusCode + item.Message = health.Message + item.Valid = health.Valid + if health.Valid { + result.Valid++ + } + + if health.Invalid { + if err := h.adminService.SetAccountError(ctx, acc.ID, buildInvalidAPIKeyErrorMessage(acc.Platform, health.Message)); err != nil { + item.Error = err.Error() + result.Failed++ + } else { + item.InvalidDisabled = true + result.InvalidDisabled++ + } + } + + result.Results = append(result.Results, item) + return nil + }) + } + + if err := g.Wait(); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, result) +} + +func (h *AccountHandler) resolveAPIKeyHealthCheckAccounts(ctx context.Context, accountIDs []int64) ([]*service.Account, error) { + if len(accountIDs) > 0 { + accounts, err := h.adminService.GetAccountsByIDs(ctx, accountIDs) + if err != nil { + return nil, err + } + return filterSupportedAPIKeyAccounts(accounts), nil + } + + var allAccounts []*service.Account + page := 1 + for { + items, total, err := h.adminService.ListAccounts(ctx, page, rawAPIKeyImportPageSize, "", service.AccountTypeAPIKey, "", "", 0, "") + if err != nil { + return nil, err + } + for i := range items { + account := items[i] + accCopy := account + allAccounts = append(allAccounts, &accCopy) + } + if len(allAccounts) >= int(total) || len(items) == 0 { + break + } + page++ + } + + return filterSupportedAPIKeyAccounts(allAccounts), nil +} + +func filterSupportedAPIKeyAccounts(accounts []*service.Account) []*service.Account { + result := make([]*service.Account, 0, len(accounts)) + for _, account := range accounts { + if account == nil || account.Type != service.AccountTypeAPIKey { + continue + } + switch account.Platform { + case service.PlatformAnthropic, service.PlatformOpenAI, service.PlatformGemini: + result = append(result, account) + } + } + return result +} + +func (h *AccountHandler) loadExistingAPIKeyIndex(ctx context.Context) (map[string]*service.Account, error) { + index := make(map[string]*service.Account) + page := 1 + for { + items, total, err := h.adminService.ListAccounts(ctx, page, rawAPIKeyImportPageSize, "", service.AccountTypeAPIKey, "", "", 0, "") + if err != nil { + return nil, err + } + for i := range items { + account := items[i] + if account.Type != service.AccountTypeAPIKey { + continue + } + switch account.Platform { + case service.PlatformAnthropic, service.PlatformOpenAI, service.PlatformGemini: + default: + continue + } + accCopy := account + index[buildAPIKeyIdentity(account.Platform, account.GetCredential("api_key"), account.GetCredential("base_url"))] = &accCopy + } + if len(index) >= int(total) || len(items) == 0 { + break + } + page++ + } + return index, nil +} + +func parseRawAPIKeyImportLines(raw string) (int, []rawAPIKeyImportLine, []RawAPIKeyImportLineResult, error) { + normalized := strings.ReplaceAll(raw, "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, ",", ",") + rawLines := strings.Split(normalized, "\n") + + lines := make([]rawAPIKeyImportLine, 0, len(rawLines)) + results := make([]RawAPIKeyImportLineResult, 0, len(rawLines)) + total := 0 + + for idx, rawLine := range rawLines { + lineNo := idx + 1 + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { + continue + } + total++ + + parts := strings.SplitN(line, ",", 3) + if len(parts) > 2 { + results = append(results, RawAPIKeyImportLineResult{ + Line: lineNo, + Error: "invalid line format, expected key or key,base_url", + }) + continue + } + + key := strings.TrimSpace(parts[0]) + baseURL := "" + if len(parts) == 2 { + baseURL = strings.TrimSpace(parts[1]) + } + if key == "" { + results = append(results, RawAPIKeyImportLineResult{ + Line: lineNo, + Error: "key cannot be empty", + }) + continue + } + + platform, ok := service.DetectAPIKeyPlatform(key) + if !ok { + results = append(results, RawAPIKeyImportLineResult{ + Line: lineNo, + KeyPreview: maskRawAPIKey(key), + Error: "unsupported key format, could not detect platform", + }) + continue + } + + lines = append(lines, rawAPIKeyImportLine{ + Line: lineNo, + Key: key, + BaseURL: baseURL, + Platform: platform, + }) + } + + return total, lines, results, nil +} + +func buildRawAPIKeyAccountName(platform, key string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(key))) + return fmt.Sprintf("%s-apikey-%s", platform, hex.EncodeToString(sum[:])[:10]) +} + +func buildAPIKeyIdentity(platform, key, baseURL string) string { + normalizedPlatform := strings.TrimSpace(platform) + normalizedKey := strings.TrimSpace(key) + normalizedBaseURL := strings.TrimSuffix(strings.TrimSpace(baseURL), "/") + if normalizedBaseURL == "" { + normalizedBaseURL = service.DefaultAPIKeyBaseURL(normalizedPlatform) + } + return normalizedPlatform + "|" + normalizedKey + "|" + normalizedBaseURL +} + +func maskRawAPIKey(key string) string { + key = strings.TrimSpace(key) + if len(key) <= 10 { + return key + } + return key[:6] + "..." + key[len(key)-4:] +} + +func buildInvalidAPIKeyErrorMessage(platform, message string) string { + prefix := fmt.Sprintf("API key auto-disabled after health check (%s)", platform) + if strings.TrimSpace(message) == "" { + return prefix + } + return prefix + ": " + strings.TrimSpace(message) +} diff --git a/backend/internal/handler/admin/account_apikey_test.go b/backend/internal/handler/admin/account_apikey_test.go new file mode 100644 index 0000000000..41c21ad2f5 --- /dev/null +++ b/backend/internal/handler/admin/account_apikey_test.go @@ -0,0 +1,32 @@ +package admin + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestParseRawAPIKeyImportLines(t *testing.T) { + total, lines, results, err := parseRawAPIKeyImportLines(` +# comment +sk-proj-123 +sk-ant-456,https://api.anthropic.com +AIzaSy789 +bad-key +`) + require.NoError(t, err) + require.Equal(t, 4, total) + require.Len(t, lines, 3) + require.Len(t, results, 1) + require.Equal(t, service.PlatformOpenAI, lines[0].Platform) + require.Equal(t, service.PlatformAnthropic, lines[1].Platform) + require.Equal(t, service.PlatformGemini, lines[2].Platform) + require.Contains(t, results[0].Error, "could not detect platform") +} + +func TestBuildAPIKeyIdentityUsesDefaultBaseURL(t *testing.T) { + a := buildAPIKeyIdentity(service.PlatformOpenAI, "sk-proj-1", "") + b := buildAPIKeyIdentity(service.PlatformOpenAI, "sk-proj-1", "https://api.openai.com/") + require.Equal(t, a, b) +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index e04dae8521..e52b586c44 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -276,6 +276,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.POST("/batch", h.Admin.Account.BatchCreate) accounts.GET("/data", h.Admin.Account.ExportData) accounts.POST("/data", h.Admin.Account.ImportData) + accounts.POST("/raw-import", h.Admin.Account.ImportRawAPIKeys) + accounts.POST("/apikey-health-check", h.Admin.Account.CheckAPIKeysHealth) accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials) accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier) accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate) diff --git a/backend/internal/service/apikey_health.go b/backend/internal/service/apikey_health.go new file mode 100644 index 0000000000..b205828a2d --- /dev/null +++ b/backend/internal/service/apikey_health.go @@ -0,0 +1,267 @@ +package service + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" +) + +type APIKeyHealthCheckResult struct { + Platform string `json:"platform"` + StatusCode int `json:"status_code"` + Valid bool `json:"valid"` + Invalid bool `json:"invalid"` + Message string `json:"message,omitempty"` +} + +func DetectAPIKeyPlatform(rawKey string) (string, bool) { + key := strings.TrimSpace(rawKey) + switch { + case strings.HasPrefix(key, "sk-ant-"): + return PlatformAnthropic, true + case strings.HasPrefix(key, "AIza"): + return PlatformGemini, true + case strings.HasPrefix(strings.ToLower(key), "sk-"): + return PlatformOpenAI, true + default: + return "", false + } +} + +func DefaultAPIKeyBaseURL(platform string) string { + switch strings.TrimSpace(platform) { + case PlatformAnthropic: + return "https://api.anthropic.com" + case PlatformOpenAI: + return "https://api.openai.com" + case PlatformGemini: + return "https://generativelanguage.googleapis.com" + default: + return "" + } +} + +func ShouldDisableAPIKeyAuthFailure(account *Account, statusCode int, responseBody []byte) bool { + if account == nil || account.Type != AccountTypeAPIKey { + return false + } + + msg := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(responseBody))) + code := strings.ToLower(strings.TrimSpace(extractUpstreamErrorCode(responseBody))) + + switch account.Platform { + case PlatformOpenAI: + if statusCode == http.StatusUnauthorized { + return true + } + if statusCode != http.StatusForbidden { + return false + } + if code == "invalid_api_key" || code == "token_invalidated" || code == "token_revoked" || code == "account_deactivated" || code == "deactivated_workspace" { + return true + } + return containsAny(msg, + "invalid api key", + "incorrect api key", + "token invalidated", + "token revoked", + "account deactivated", + "workspace has been deactivated", + "organization has been disabled", + "project has been disabled", + "key is disabled", + "api key disabled", + ) + case PlatformAnthropic: + if statusCode == http.StatusUnauthorized { + return true + } + if statusCode != http.StatusForbidden { + return false + } + return true + case PlatformGemini: + return statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden + default: + return statusCode == http.StatusUnauthorized + } +} + +func ClassifyAPIKeyProbeResponse(account *Account, statusCode int, responseBody []byte) (valid bool, invalid bool, message string) { + if account == nil || account.Type != AccountTypeAPIKey { + return false, false, "unsupported account type" + } + + message = strings.TrimSpace(extractUpstreamErrorMessage(responseBody)) + if message == "" { + message = http.StatusText(statusCode) + } + message = sanitizeUpstreamErrorMessage(message) + + switch account.Platform { + case PlatformAnthropic: + switch statusCode { + case http.StatusOK, http.StatusBadRequest, http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusTooManyRequests, 529: + return true, false, message + case http.StatusUnauthorized, http.StatusForbidden: + return false, true, message + default: + return false, false, message + } + case PlatformOpenAI: + switch statusCode { + case http.StatusOK, http.StatusTooManyRequests: + return true, false, message + case http.StatusPaymentRequired: + return false, true, message + case http.StatusUnauthorized: + return false, true, message + case http.StatusForbidden: + return false, ShouldDisableAPIKeyAuthFailure(account, statusCode, responseBody), message + default: + return false, false, message + } + case PlatformGemini: + switch statusCode { + case http.StatusOK, http.StatusTooManyRequests: + return true, false, message + case http.StatusBadRequest: + bodyUpper := strings.ToUpper(string(responseBody)) + msgLower := strings.ToLower(message) + if strings.Contains(bodyUpper, "API_KEY_INVALID") || containsAny(msgLower, "api key not valid", "invalid api key", "api_key_invalid") { + return false, true, message + } + return false, false, message + case http.StatusUnauthorized, http.StatusForbidden: + return false, true, message + default: + return false, false, message + } + default: + return false, false, message + } +} + +func (s *AccountTestService) CheckAPIKeyValidity(ctx context.Context, account *Account) (*APIKeyHealthCheckResult, error) { + if account == nil { + return nil, fmt.Errorf("account is required") + } + if account.Type != AccountTypeAPIKey { + return nil, fmt.Errorf("account %d is not an apikey account", account.ID) + } + if s == nil || s.httpUpstream == nil { + return nil, fmt.Errorf("account test service is not configured") + } + + req, err := s.buildAPIKeyProbeRequest(ctx, account) + if err != nil { + return nil, err + } + + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + valid, invalid, message := ClassifyAPIKeyProbeResponse(account, resp.StatusCode, respBody) + return &APIKeyHealthCheckResult{ + Platform: account.Platform, + StatusCode: resp.StatusCode, + Valid: valid, + Invalid: invalid, + Message: message, + }, nil +} + +func (s *AccountTestService) buildAPIKeyProbeRequest(ctx context.Context, account *Account) (*http.Request, error) { + switch account.Platform { + case PlatformAnthropic: + return s.buildAnthropicAPIKeyProbeRequest(ctx, account) + case PlatformOpenAI: + return s.buildOpenAIAPIKeyProbeRequest(ctx, account) + case PlatformGemini: + return s.buildGeminiAPIKeyProbeRequest(ctx, account) + default: + return nil, fmt.Errorf("unsupported apikey platform: %s", account.Platform) + } +} + +func (s *AccountTestService) buildAnthropicAPIKeyProbeRequest(ctx context.Context, account *Account) (*http.Request, error) { + baseURL := strings.TrimSpace(account.GetBaseURL()) + if baseURL == "" { + baseURL = DefaultAPIKeyBaseURL(account.Platform) + } + normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) + if err != nil { + return nil, fmt.Errorf("invalid anthropic base url: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSuffix(normalizedBaseURL, "/")+"/v1/messages", nil) + if err != nil { + return nil, err + } + req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader) + req.Header.Set("x-api-key", account.GetCredential("api_key")) + req.Header.Set("User-Agent", proxyQualityClientUserAgent) + return req, nil +} + +func (s *AccountTestService) buildOpenAIAPIKeyProbeRequest(ctx context.Context, account *Account) (*http.Request, error) { + baseURL := strings.TrimSpace(account.GetOpenAIBaseURL()) + if baseURL == "" { + baseURL = DefaultAPIKeyBaseURL(account.Platform) + } + normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) + if err != nil { + return nil, fmt.Errorf("invalid openai base url: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSuffix(normalizedBaseURL, "/")+"/v1/models", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+account.GetCredential("api_key")) + req.Header.Set("User-Agent", proxyQualityClientUserAgent) + return req, nil +} + +func (s *AccountTestService) buildGeminiAPIKeyProbeRequest(ctx context.Context, account *Account) (*http.Request, error) { + baseURL := strings.TrimSpace(account.GetBaseURL()) + if baseURL == "" { + baseURL = DefaultAPIKeyBaseURL(account.Platform) + } + normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) + if err != nil { + return nil, fmt.Errorf("invalid gemini base url: %w", err) + } + + endpoint := strings.TrimSuffix(normalizedBaseURL, "/") + "/v1beta/models?key=" + url.QueryEscape(account.GetCredential("api_key")) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", proxyQualityClientUserAgent) + return req, nil +} + +func containsAny(haystack string, needles ...string) bool { + for _, needle := range needles { + if needle != "" && strings.Contains(haystack, needle) { + return true + } + } + return false +} diff --git a/backend/internal/service/apikey_health_test.go b/backend/internal/service/apikey_health_test.go new file mode 100644 index 0000000000..fa1cf914f4 --- /dev/null +++ b/backend/internal/service/apikey_health_test.go @@ -0,0 +1,58 @@ +//go:build unit + +package service + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDetectAPIKeyPlatform(t *testing.T) { + tests := []struct { + key string + platform string + ok bool + }{ + {key: "sk-ant-api03-abc", platform: PlatformAnthropic, ok: true}, + {key: "AIzaSyD-example", platform: PlatformGemini, ok: true}, + {key: "sk-proj-123", platform: PlatformOpenAI, ok: true}, + {key: "unknown-key", platform: "", ok: false}, + } + + for _, tt := range tests { + platform, ok := DetectAPIKeyPlatform(tt.key) + require.Equal(t, tt.platform, platform) + require.Equal(t, tt.ok, ok) + } +} + +func TestShouldDisableAPIKeyAuthFailure_OpenAI403RequiresExplicitSignals(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + + require.True(t, ShouldDisableAPIKeyAuthFailure(account, http.StatusForbidden, []byte(`{"error":{"message":"organization has been disabled","code":"account_deactivated"}}`))) + require.False(t, ShouldDisableAPIKeyAuthFailure(account, http.StatusForbidden, []byte(`{"error":{"message":"model not allowed for this project","code":"forbidden"}}`))) +} + +func TestClassifyAPIKeyProbeResponse(t *testing.T) { + openAIAccount := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + geminiAccount := &Account{Platform: PlatformGemini, Type: AccountTypeAPIKey} + anthropicAccount := &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey} + + valid, invalid, _ := ClassifyAPIKeyProbeResponse(openAIAccount, http.StatusOK, []byte(`{}`)) + require.True(t, valid) + require.False(t, invalid) + + valid, invalid, _ = ClassifyAPIKeyProbeResponse(openAIAccount, http.StatusPaymentRequired, []byte(`{"error":{"message":"insufficient balance"}}`)) + require.False(t, valid) + require.True(t, invalid) + + valid, invalid, _ = ClassifyAPIKeyProbeResponse(geminiAccount, http.StatusBadRequest, []byte(`{"error":{"message":"API key not valid. Please pass a valid API key.","status":"API_KEY_INVALID"}}`)) + require.False(t, valid) + require.True(t, invalid) + + valid, invalid, _ = ClassifyAPIKeyProbeResponse(anthropicAccount, http.StatusMethodNotAllowed, []byte(`method not allowed`)) + require.True(t, valid) + require.False(t, invalid) +} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 4f5b57cc97..8e93821862 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -652,6 +652,10 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst if account.Platform == PlatformAntigravity { return s.handleAntigravity403(ctx, account, upstreamMsg, responseBody) } + if account.Type == AccountTypeAPIKey && !ShouldDisableAPIKeyAuthFailure(account, http.StatusForbidden, responseBody) { + slog.Info("apikey_403_not_disabled", "account_id", account.ID, "platform", account.Platform) + return false + } // 非 Antigravity 平台:保持原有行为 msg := "Access forbidden (403): account may be suspended or lack permissions" if upstreamMsg != "" { diff --git a/backend/internal/service/ratelimit_service_apikey_test.go b/backend/internal/service/ratelimit_service_apikey_test.go new file mode 100644 index 0000000000..bc4bfd773e --- /dev/null +++ b/backend/internal/service/ratelimit_service_apikey_test.go @@ -0,0 +1,33 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestRateLimitService_HandleUpstreamError_OpenAIAPIKey403ModelAccessDoesNotDisable(t *testing.T) { + repo := &rateLimitAccountRepoStub{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := &Account{ + ID: 104, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + } + + shouldDisable := service.HandleUpstreamError( + context.Background(), + account, + http.StatusForbidden, + http.Header{}, + []byte(`{"error":{"message":"model not allowed for this project","code":"forbidden"}}`), + ) + + require.False(t, shouldDisable) + require.Equal(t, 0, repo.setErrorCalls) +} diff --git a/deploy/DOCKER.md b/deploy/DOCKER.md index 156b6c97a9..3794f6dc23 100644 --- a/deploy/DOCKER.md +++ b/deploy/DOCKER.md @@ -10,7 +10,7 @@ docker run -d \ -p 8080:8080 \ -e DATABASE_URL="postgres://user:pass@host:5432/sub2api" \ -e REDIS_URL="redis://host:6379" \ - weishaw/sub2api:latest + ghcr.io/kw0ngr/sub2api:latest ``` ## Docker Compose @@ -20,7 +20,7 @@ version: '3.8' services: sub2api: - image: weishaw/sub2api:latest + image: ghcr.io/kw0ngr/sub2api:latest ports: - "8080:8080" environment: @@ -72,5 +72,5 @@ volumes: ## Links -- [GitHub Repository](https://github.com/weishaw/sub2api) -- [Documentation](https://github.com/weishaw/sub2api#readme) +- [GitHub Repository](https://github.com/kw0ngr/sub2api) +- [Documentation](https://github.com/kw0ngr/sub2api#readme) diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 7caa5ca63e..b925d59dd5 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -73,9 +73,9 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ FROM ${ALPINE_IMAGE} # Labels -LABEL maintainer="Wei-Shaw " +LABEL maintainer="kw0ngr " LABEL description="Sub2API - AI API Gateway Platform" -LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api" +LABEL org.opencontainers.image.source="https://github.com/kw0ngr/sub2api" # Install runtime dependencies RUN apk add --no-cache \ diff --git a/deploy/README.md b/deploy/README.md index dd311721d9..cdf5248203 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -35,10 +35,10 @@ Use the automated preparation script for the easiest setup: ```bash # Download and run the preparation script -curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash +curl -sSL https://raw.githubusercontent.com/kw0ngr/sub2api/main/deploy/docker-deploy.sh | bash # Or download first, then run -curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh -o docker-deploy.sh +curl -sSL https://raw.githubusercontent.com/kw0ngr/sub2api/main/deploy/docker-deploy.sh -o docker-deploy.sh chmod +x docker-deploy.sh ./docker-deploy.sh ``` @@ -71,7 +71,7 @@ If you prefer manual control: ```bash # Clone repository -git clone https://github.com/Wei-Shaw/sub2api.git +git clone https://github.com/kw0ngr/sub2api.git cd sub2api/deploy # Configure environment @@ -353,12 +353,12 @@ For production servers using systemd. ### One-Line Installation ```bash -curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash +curl -sSL https://raw.githubusercontent.com/kw0ngr/sub2api/main/deploy/install.sh | sudo bash ``` ### Manual Installation -1. Download the latest release from [GitHub Releases](https://github.com/Wei-Shaw/sub2api/releases) +1. Download the latest release from [GitHub Releases](https://github.com/kw0ngr/sub2api/releases) 2. Extract and copy the binary to `/opt/sub2api/` 3. Copy `sub2api.service` to `/etc/systemd/system/` 4. Run: diff --git a/deploy/docker-deploy.sh b/deploy/docker-deploy.sh index a07f4f417a..9048e4c96c 100644 --- a/deploy/docker-deploy.sh +++ b/deploy/docker-deploy.sh @@ -21,7 +21,7 @@ BLUE='\033[0;34m' NC='\033[0m' # No Color # GitHub raw content base URL -GITHUB_RAW_URL="https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy" +GITHUB_RAW_URL="https://raw.githubusercontent.com/kw0ngr/sub2api/main/deploy" # Print colored message print_info() { diff --git a/deploy/install.sh b/deploy/install.sh index 6dcf41238e..2facc73090 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -2,7 +2,7 @@ # # Sub2API Installation Script # Sub2API 安装脚本 -# Usage: curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | bash +# Usage: curl -sSL https://raw.githubusercontent.com/kw0ngr/sub2api/main/deploy/install.sh | bash # set -e @@ -16,7 +16,7 @@ CYAN='\033[0;36m' NC='\033[0m' # No Color # Configuration -GITHUB_REPO="Wei-Shaw/sub2api" +GITHUB_REPO="kw0ngr/sub2api" INSTALL_DIR="/opt/sub2api" SERVICE_NAME="sub2api" SERVICE_USER="sub2api" @@ -112,6 +112,9 @@ declare -A MSG_ZH=( ["fetching_versions"]="正在获取可用版本..." ["not_installed"]="Sub2API 尚未安装,请先执行全新安装" ["fresh_install_hint"]="用法" + ["fork_no_releases"]="Fork 仓库尚未发布任何 GitHub Releases" + ["fork_only_release_source"]="当前安装脚本仅从该 Fork 仓库下载发布包" + ["fork_publish_release_hint"]="请先在 GitHub 上创建如 vX.Y.Z 的 tag,并等待 release workflow 发布资产后重试" # Uninstall ["uninstall_confirm"]="这将从系统中移除 Sub2API。" @@ -237,6 +240,9 @@ declare -A MSG_EN=( ["fetching_versions"]="Fetching available versions..." ["not_installed"]="Sub2API is not installed. Please run a fresh install first" ["fresh_install_hint"]="Usage" + ["fork_no_releases"]="No GitHub Releases have been published for this fork yet" + ["fork_only_release_source"]="This installer only downloads release artifacts from this fork" + ["fork_publish_release_hint"]="Create a tag such as vX.Y.Z on GitHub and wait for the release workflow to publish assets before retrying" # Uninstall ["uninstall_confirm"]="This will remove Sub2API from your system." @@ -465,9 +471,37 @@ check_dependencies() { fi } +get_releases_json() { + curl -s --connect-timeout 10 --max-time 30 "https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=20" 2>/dev/null +} + +print_no_releases_guidance() { + print_error "$(msg 'fork_no_releases'): ${GITHUB_REPO}" + print_info "$(msg 'fork_only_release_source')" + print_info "$(msg 'fork_publish_release_hint')" +} + +ensure_releases_available() { + local releases_json="$1" + + if echo "$releases_json" | grep -q '"message"[[:space:]]*:[[:space:]]*"Not Found"'; then + print_no_releases_guidance + exit 1 + fi + + if ! echo "$releases_json" | grep -q '"tag_name"'; then + print_no_releases_guidance + exit 1 + fi +} + # Get latest release version get_latest_version() { print_info "$(msg 'fetching_version')" + local releases_json + releases_json=$(get_releases_json) + ensure_releases_available "$releases_json" + LATEST_VERSION=$(curl -s --connect-timeout 10 --max-time 30 "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" 2>/dev/null | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') if [ -z "$LATEST_VERSION" ]; then @@ -484,7 +518,10 @@ list_versions() { print_info "$(msg 'fetching_versions')" local versions - versions=$(curl -s --connect-timeout 10 --max-time 30 "https://api.github.com/repos/${GITHUB_REPO}/releases" 2>/dev/null | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/' | head -20) + local releases_json + releases_json=$(get_releases_json) + ensure_releases_available "$releases_json" + versions=$(echo "$releases_json" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/' | head -20) if [ -z "$versions" ]; then print_error "$(msg 'failed_get_version')" @@ -517,6 +554,17 @@ validate_version() { version="v$version" fi + local releases_json + releases_json=$(get_releases_json) + if echo "$releases_json" | grep -q '"message"[[:space:]]*:[[:space:]]*"Not Found"'; then + print_no_releases_guidance >&2 + exit 1 + fi + if ! echo "$releases_json" | grep -q '"tag_name"'; then + print_no_releases_guidance >&2 + exit 1 + fi + print_info "$(msg 'validating_version') $version" >&2 # Check if the release exists @@ -655,7 +703,7 @@ install_service() { cat > /etc/systemd/system/sub2api.service << EOF [Unit] Description=Sub2API - AI API Gateway Platform -Documentation=https://github.com/Wei-Shaw/sub2api +Documentation=https://github.com/kw0ngr/sub2api After=network.target postgresql.service redis.service Wants=postgresql.service redis.service diff --git a/deploy/sub2api.service b/deploy/sub2api.service index 1a59ad032c..2444f77828 100644 --- a/deploy/sub2api.service +++ b/deploy/sub2api.service @@ -1,6 +1,6 @@ [Unit] Description=Sub2API - AI API Gateway Platform -Documentation=https://github.com/Wei-Shaw/sub2api +Documentation=https://github.com/kw0ngr/sub2api After=network.target postgresql.service redis.service Wants=postgresql.service redis.service diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index fd93fe7ef0..034e92fca1 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -402,6 +402,50 @@ export interface BatchTodayStatsResponse { stats: Record } +export interface RawAPIKeyImportLineResult { + line: number + key_preview?: string + platform?: string + account_id?: number + created: boolean + checked: boolean + valid: boolean + invalid_disabled: boolean + error?: string + message?: string + status_code?: number +} + +export interface RawAPIKeyImportResult { + total_lines: number + created: number + checked: number + valid: number + invalid_disabled: number + failed: number + results: RawAPIKeyImportLineResult[] +} + +export interface APIKeyHealthCheckItem { + account_id: number + name: string + platform: string + status_code?: number + valid: boolean + invalid_disabled: boolean + error?: string + message?: string +} + +export interface APIKeyHealthCheckResult { + total: number + checked: number + valid: number + invalid_disabled: number + failed: number + results: APIKeyHealthCheckItem[] +} + /** * 批量获取多个账号的今日统计 * @param accountIds - 账号 ID 列表 @@ -532,6 +576,25 @@ export async function importData(payload: { return data } +export async function importRawAPIKeys(payload: { + raw_text: string + validate_after_import?: boolean + skip_default_group_bind?: boolean +}): Promise { + const { data } = await apiClient.post('/admin/accounts/raw-import', payload, { + timeout: 120000 + }) + return data +} + +export async function checkAPIKeysHealth(accountIds?: number[]): Promise { + const payload = accountIds && accountIds.length > 0 ? { account_ids: accountIds } : {} + const { data } = await apiClient.post('/admin/accounts/apikey-health-check', payload, { + timeout: 120000 + }) + return data +} + /** * Get Antigravity default model mapping from backend * @returns Default model mapping (from -> to) @@ -671,6 +734,8 @@ export const accountsAPI = { syncFromCrs, exportData, importData, + importRawAPIKeys, + checkAPIKeysHealth, getAntigravityDefaultModelMapping, batchClearError, batchRefresh, diff --git a/frontend/src/components/admin/account/RawKeyImportModal.vue b/frontend/src/components/admin/account/RawKeyImportModal.vue new file mode 100644 index 0000000000..5bb4dfbd87 --- /dev/null +++ b/frontend/src/components/admin/account/RawKeyImportModal.vue @@ -0,0 +1,172 @@ +