From 7f2511d6dcb0be4299114949b8a2ccdfb4e4be8a Mon Sep 17 00:00:00 2001 From: AAEE86 Date: Tue, 23 Sep 2025 23:12:26 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=A0=81=E6=94=AF=E6=8C=81=E5=88=B0API=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E9=AA=8C=E8=AF=81=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/channel/anthropic_channel.go | 20 ++++---- internal/channel/channel.go | 2 +- internal/channel/gemini_channel.go | 20 ++++---- internal/channel/openai_channel.go | 20 ++++---- internal/db/migrations/migration.go | 7 ++- .../migrations/v1_2_0_AddStatusCodeColumn.go | 23 +++++++++ internal/keypool/cron_checker.go | 2 +- internal/keypool/validator.go | 38 +++++++++----- internal/models/types.go | 1 + .../services/key_manual_validation_service.go | 2 +- web/src/api/keys.ts | 7 +-- web/src/components/keys/KeyTable.vue | 50 ++++++++++++++++++- web/src/types/models.ts | 10 ++++ 13 files changed, 152 insertions(+), 50 deletions(-) create mode 100644 internal/db/migrations/v1_2_0_AddStatusCodeColumn.go diff --git a/internal/channel/anthropic_channel.go b/internal/channel/anthropic_channel.go index 3e6d20249..28eced94e 100644 --- a/internal/channel/anthropic_channel.go +++ b/internal/channel/anthropic_channel.go @@ -74,10 +74,10 @@ func (ch *AnthropicChannel) ExtractModel(c *gin.Context, bodyBytes []byte) strin } // ValidateKey checks if the given API key is valid by making a messages request. -func (ch *AnthropicChannel) ValidateKey(ctx context.Context, apiKey *models.APIKey, group *models.Group) (bool, error) { +func (ch *AnthropicChannel) ValidateKey(ctx context.Context, apiKey *models.APIKey, group *models.Group) (bool, int, error) { upstreamURL := ch.getUpstreamURL() if upstreamURL == nil { - return false, fmt.Errorf("no upstream URL configured for channel %s", ch.Name) + return false, 0, fmt.Errorf("no upstream URL configured for channel %s", ch.Name) } validationEndpoint := ch.ValidationEndpoint @@ -86,7 +86,7 @@ func (ch *AnthropicChannel) ValidateKey(ctx context.Context, apiKey *models.APIK } reqURL, err := url.JoinPath(upstreamURL.String(), validationEndpoint) if err != nil { - return false, fmt.Errorf("failed to join upstream URL and validation endpoint: %w", err) + return false, 0, fmt.Errorf("failed to join upstream URL and validation endpoint: %w", err) } // Use a minimal, low-cost payload for validation @@ -99,12 +99,12 @@ func (ch *AnthropicChannel) ValidateKey(ctx context.Context, apiKey *models.APIK } body, err := json.Marshal(payload) if err != nil { - return false, fmt.Errorf("failed to marshal validation payload: %w", err) + return false, 0, fmt.Errorf("failed to marshal validation payload: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewBuffer(body)) if err != nil { - return false, fmt.Errorf("failed to create validation request: %w", err) + return false, 0, fmt.Errorf("failed to create validation request: %w", err) } req.Header.Set("x-api-key", apiKey.KeyValue) req.Header.Set("anthropic-version", "2023-06-01") @@ -118,23 +118,25 @@ func (ch *AnthropicChannel) ValidateKey(ctx context.Context, apiKey *models.APIK resp, err := ch.HTTPClient.Do(req) if err != nil { - return false, fmt.Errorf("failed to send validation request: %w", err) + return false, 0, fmt.Errorf("failed to send validation request: %w", err) } defer resp.Body.Close() + statusCode := resp.StatusCode + // Any 2xx status code indicates the key is valid. if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return true, nil + return true, statusCode, nil } // For non-200 responses, parse the body to provide a more specific error reason. errorBody, err := io.ReadAll(resp.Body) if err != nil { - return false, fmt.Errorf("key is invalid (status %d), but failed to read error body: %w", resp.StatusCode, err) + return false, statusCode, fmt.Errorf("key is invalid (status %d), but failed to read error body: %w", resp.StatusCode, err) } // Use the new parser to extract a clean error message. parsedError := app_errors.ParseUpstreamError(errorBody) - return false, fmt.Errorf("[status %d] %s", resp.StatusCode, parsedError) + return false, statusCode, fmt.Errorf("[status %d] %s", resp.StatusCode, parsedError) } diff --git a/internal/channel/channel.go b/internal/channel/channel.go index 00bf5107f..55626a277 100644 --- a/internal/channel/channel.go +++ b/internal/channel/channel.go @@ -33,5 +33,5 @@ type ChannelProxy interface { ExtractModel(c *gin.Context, bodyBytes []byte) string // ValidateKey checks if the given API key is valid. - ValidateKey(ctx context.Context, apiKey *models.APIKey, group *models.Group) (bool, error) + ValidateKey(ctx context.Context, apiKey *models.APIKey, group *models.Group) (bool, int, error) } diff --git a/internal/channel/gemini_channel.go b/internal/channel/gemini_channel.go index 63225eb8e..99cd8d056 100644 --- a/internal/channel/gemini_channel.go +++ b/internal/channel/gemini_channel.go @@ -96,16 +96,16 @@ func (ch *GeminiChannel) ExtractModel(c *gin.Context, bodyBytes []byte) string { } // ValidateKey checks if the given API key is valid by making a generateContent request. -func (ch *GeminiChannel) ValidateKey(ctx context.Context, apiKey *models.APIKey, group *models.Group) (bool, error) { +func (ch *GeminiChannel) ValidateKey(ctx context.Context, apiKey *models.APIKey, group *models.Group) (bool, int, error) { upstreamURL := ch.getUpstreamURL() if upstreamURL == nil { - return false, fmt.Errorf("no upstream URL configured for channel %s", ch.Name) + return false, 0, fmt.Errorf("no upstream URL configured for channel %s", ch.Name) } // Safely join the path segments reqURL, err := url.JoinPath(upstreamURL.String(), "v1beta", "models", ch.TestModel+":generateContent") if err != nil { - return false, fmt.Errorf("failed to create gemini validation path: %w", err) + return false, 0, fmt.Errorf("failed to create gemini validation path: %w", err) } reqURL += "?key=" + apiKey.KeyValue @@ -118,12 +118,12 @@ func (ch *GeminiChannel) ValidateKey(ctx context.Context, apiKey *models.APIKey, } body, err := json.Marshal(payload) if err != nil { - return false, fmt.Errorf("failed to marshal validation payload: %w", err) + return false, 0, fmt.Errorf("failed to marshal validation payload: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewBuffer(body)) if err != nil { - return false, fmt.Errorf("failed to create validation request: %w", err) + return false, 0, fmt.Errorf("failed to create validation request: %w", err) } req.Header.Set("Content-Type", "application/json") @@ -135,23 +135,25 @@ func (ch *GeminiChannel) ValidateKey(ctx context.Context, apiKey *models.APIKey, resp, err := ch.HTTPClient.Do(req) if err != nil { - return false, fmt.Errorf("failed to send validation request: %w", err) + return false, 0, fmt.Errorf("failed to send validation request: %w", err) } defer resp.Body.Close() + statusCode := resp.StatusCode + // Any 2xx status code indicates the key is valid. if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return true, nil + return true, statusCode, nil } // For non-200 responses, parse the body to provide a more specific error reason. errorBody, err := io.ReadAll(resp.Body) if err != nil { - return false, fmt.Errorf("key is invalid (status %d), but failed to read error body: %w", resp.StatusCode, err) + return false, statusCode, fmt.Errorf("key is invalid (status %d), but failed to read error body: %w", resp.StatusCode, err) } // Use the new parser to extract a clean error message. parsedError := app_errors.ParseUpstreamError(errorBody) - return false, fmt.Errorf("[status %d] %s", resp.StatusCode, parsedError) + return false, statusCode, fmt.Errorf("[status %d] %s", resp.StatusCode, parsedError) } diff --git a/internal/channel/openai_channel.go b/internal/channel/openai_channel.go index 8cf85c0a5..dabe8522e 100644 --- a/internal/channel/openai_channel.go +++ b/internal/channel/openai_channel.go @@ -73,10 +73,10 @@ func (ch *OpenAIChannel) ExtractModel(c *gin.Context, bodyBytes []byte) string { } // ValidateKey checks if the given API key is valid by making a chat completion request. -func (ch *OpenAIChannel) ValidateKey(ctx context.Context, apiKey *models.APIKey, group *models.Group) (bool, error) { +func (ch *OpenAIChannel) ValidateKey(ctx context.Context, apiKey *models.APIKey, group *models.Group) (bool, int, error) { upstreamURL := ch.getUpstreamURL() if upstreamURL == nil { - return false, fmt.Errorf("no upstream URL configured for channel %s", ch.Name) + return false, 0, fmt.Errorf("no upstream URL configured for channel %s", ch.Name) } validationEndpoint := ch.ValidationEndpoint @@ -85,7 +85,7 @@ func (ch *OpenAIChannel) ValidateKey(ctx context.Context, apiKey *models.APIKey, } reqURL, err := url.JoinPath(upstreamURL.String(), validationEndpoint) if err != nil { - return false, fmt.Errorf("failed to join upstream URL and validation endpoint: %w", err) + return false, 0, fmt.Errorf("failed to join upstream URL and validation endpoint: %w", err) } // Use a minimal, low-cost payload for validation @@ -97,12 +97,12 @@ func (ch *OpenAIChannel) ValidateKey(ctx context.Context, apiKey *models.APIKey, } body, err := json.Marshal(payload) if err != nil { - return false, fmt.Errorf("failed to marshal validation payload: %w", err) + return false, 0, fmt.Errorf("failed to marshal validation payload: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewBuffer(body)) if err != nil { - return false, fmt.Errorf("failed to create validation request: %w", err) + return false, 0, fmt.Errorf("failed to create validation request: %w", err) } req.Header.Set("Authorization", "Bearer "+apiKey.KeyValue) req.Header.Set("Content-Type", "application/json") @@ -115,23 +115,25 @@ func (ch *OpenAIChannel) ValidateKey(ctx context.Context, apiKey *models.APIKey, resp, err := ch.HTTPClient.Do(req) if err != nil { - return false, fmt.Errorf("failed to send validation request: %w", err) + return false, 0, fmt.Errorf("failed to send validation request: %w", err) } defer resp.Body.Close() + statusCode := resp.StatusCode + // Any 2xx status code indicates the key is valid. if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return true, nil + return true, statusCode, nil } // For non-200 responses, parse the body to provide a more specific error reason. errorBody, err := io.ReadAll(resp.Body) if err != nil { - return false, fmt.Errorf("key is invalid (status %d), but failed to read error body: %w", resp.StatusCode, err) + return false, statusCode, fmt.Errorf("key is invalid (status %d), but failed to read error body: %w", resp.StatusCode, err) } // Use the new parser to extract a clean error message. parsedError := app_errors.ParseUpstreamError(errorBody) - return false, fmt.Errorf("[status %d] %s", resp.StatusCode, parsedError) + return false, statusCode, fmt.Errorf("[status %d] %s", resp.StatusCode, parsedError) } diff --git a/internal/db/migrations/migration.go b/internal/db/migrations/migration.go index d8886a48f..4eb93b542 100644 --- a/internal/db/migrations/migration.go +++ b/internal/db/migrations/migration.go @@ -11,7 +11,12 @@ func MigrateDatabase(db *gorm.DB) error { } // Run v1.1.0 migration - return V1_1_0_AddKeyHashColumn(db) + if err := V1_1_0_AddKeyHashColumn(db); err != nil { + return err + } + + // Run v1.2.0 migration + return V1_2_0_AddStatusCodeColumn(db) } // HandleLegacyIndexes removes old indexes from previous versions to prevent migration errors diff --git a/internal/db/migrations/v1_2_0_AddStatusCodeColumn.go b/internal/db/migrations/v1_2_0_AddStatusCodeColumn.go new file mode 100644 index 000000000..a71c91b58 --- /dev/null +++ b/internal/db/migrations/v1_2_0_AddStatusCodeColumn.go @@ -0,0 +1,23 @@ +package db + +import ( + "fmt" + "gpt-load/internal/models" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// V1_2_0_AddStatusCodeColumn adds status_code column to api_keys table +func V1_2_0_AddStatusCodeColumn(db *gorm.DB) error { + // Prefer GORM migrator for portability + if db.Migrator().HasColumn(&models.APIKey{}, "status_code") { + logrus.Info("status_code column already exists, skipping migration") + return nil + } + // Will honor gorm:"default:0" tag on models.APIKey.StatusCode + if err := db.Migrator().AddColumn(&models.APIKey{}, "StatusCode"); err != nil { + return fmt.Errorf("failed to add status_code column: %w", err) + } + logrus.Info("Successfully added status_code column to api_keys table") + return nil +} diff --git a/internal/keypool/cron_checker.go b/internal/keypool/cron_checker.go index 81786b11c..8726e23e4 100644 --- a/internal/keypool/cron_checker.go +++ b/internal/keypool/cron_checker.go @@ -159,7 +159,7 @@ func (s *CronChecker) validateGroupKeys(group *models.Group) { keyForValidation := *key keyForValidation.KeyValue = decryptedKey - isValid, _ := s.Validator.ValidateSingleKey(&keyForValidation, group) + isValid, _, _ := s.Validator.ValidateSingleKey(&keyForValidation, group) if isValid { atomic.AddInt32(&becameValidCount, 1) } diff --git a/internal/keypool/validator.go b/internal/keypool/validator.go index d873039fe..4e3aeae11 100644 --- a/internal/keypool/validator.go +++ b/internal/keypool/validator.go @@ -16,9 +16,10 @@ import ( // KeyTestResult holds the validation result for a single key. type KeyTestResult struct { - KeyValue string `json:"key_value"` - IsValid bool `json:"is_valid"` - Error string `json:"error,omitempty"` + KeyValue string `json:"key_value"` + IsValid bool `json:"is_valid"` + Error string `json:"error,omitempty"` + StatusCode int `json:"status_code,omitempty"` } // KeyValidator provides methods to validate API keys. @@ -51,7 +52,7 @@ func NewKeyValidator(params KeyValidatorParams) *KeyValidator { } // ValidateSingleKey performs a validation check on a single API key. -func (s *KeyValidator) ValidateSingleKey(key *models.APIKey, group *models.Group) (bool, error) { +func (s *KeyValidator) ValidateSingleKey(key *models.APIKey, group *models.Group) (bool, int, error) { if group.EffectiveConfig.AppUrl == "" { group.EffectiveConfig = s.SettingsManager.GetEffectiveConfig(group.Config) } @@ -60,15 +61,25 @@ func (s *KeyValidator) ValidateSingleKey(key *models.APIKey, group *models.Group ch, err := s.channelFactory.GetChannel(group) if err != nil { - return false, fmt.Errorf("failed to get channel for group %s: %w", group.Name, err) + return false, 0, fmt.Errorf("failed to get channel for group %s: %w", group.Name, err) } - isValid, validationErr := ch.ValidateKey(ctx, key, group) + isValid, statusCode, validationErr := ch.ValidateKey(ctx, key, group) var errorMsg string if !isValid && validationErr != nil { errorMsg = validationErr.Error() } + + // 更新状态码到数据库 + if statusCode != 0 { + // keep in-memory model consistent to avoid accidental overwrite + key.StatusCode = statusCode + if err := s.DB.Model(key).Update("status_code", statusCode).Error; err != nil { + logrus.WithError(err).WithField("key_id", key.ID).Error("Failed to update status_code") + } + } + s.keypoolProvider.UpdateStatus(key, group, isValid, errorMsg) if !isValid { @@ -76,16 +87,18 @@ func (s *KeyValidator) ValidateSingleKey(key *models.APIKey, group *models.Group "error": validationErr, "key_id": key.ID, "group_id": group.ID, + "status_code": statusCode, }).Debug("Key validation failed") - return false, validationErr + return false, statusCode, validationErr } logrus.WithFields(logrus.Fields{ "key_id": key.ID, "is_valid": isValid, + "status_code": statusCode, }).Debug("Key validation successful") - return true, nil + return true, statusCode, nil } // TestMultipleKeys performs a synchronous validation for a list of key values within a specific group. @@ -130,12 +143,13 @@ func (s *KeyValidator) TestMultipleKeys(group *models.Group, keyValues []string) apiKey.KeyValue = kv - isValid, validationErr := s.ValidateSingleKey(&apiKey, group) + isValid, statusCode, validationErr := s.ValidateSingleKey(&apiKey, group) results[i] = KeyTestResult{ - KeyValue: kv, - IsValid: isValid, - Error: "", + KeyValue: kv, + IsValid: isValid, + StatusCode: statusCode, + Error: "", } if validationErr != nil { results[i].Error = validationErr.Error() diff --git a/internal/models/types.go b/internal/models/types.go index 4035adeb7..f25c11058 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -81,6 +81,7 @@ type APIKey struct { KeyHash string `gorm:"type:varchar(128);index" json:"key_hash"` GroupID uint `gorm:"not null;index" json:"group_id"` Status string `gorm:"type:varchar(50);not null;default:'active'" json:"status"` + StatusCode int `gorm:"default:0" json:"status_code,omitempty"` RequestCount int64 `gorm:"not null;default:0" json:"request_count"` FailureCount int64 `gorm:"not null;default:0" json:"failure_count"` LastUsedAt *time.Time `json:"last_used_at"` diff --git a/internal/services/key_manual_validation_service.go b/internal/services/key_manual_validation_service.go index 4716fbe52..efda802ba 100644 --- a/internal/services/key_manual_validation_service.go +++ b/internal/services/key_manual_validation_service.go @@ -153,7 +153,7 @@ func (s *KeyManualValidationService) validationWorker(wg *sync.WaitGroup, group keyForValidation := key keyForValidation.KeyValue = decryptedKey - isValid, _ := s.Validator.ValidateSingleKey(&keyForValidation, group) + isValid, _, _ := s.Validator.ValidateSingleKey(&keyForValidation, group) results <- isValid } } diff --git a/web/src/api/keys.ts b/web/src/api/keys.ts index 5d2a7b366..85e673ffa 100644 --- a/web/src/api/keys.ts +++ b/web/src/api/keys.ts @@ -5,6 +5,7 @@ import type { GroupConfigOption, GroupStatsResponse, KeyStatus, + KeyTestResult, TaskInfo, } from "@/types/models"; import http from "@/utils/http"; @@ -112,11 +113,7 @@ export const keysApi = { group_id: number, keys_text: string ): Promise<{ - results: { - key_value: string; - is_valid: boolean; - error: string; - }[]; + results: KeyTestResult[]; total_duration: number; }> { const res = await http.post( diff --git a/web/src/components/keys/KeyTable.vue b/web/src/components/keys/KeyTable.vue index 987c9551a..b7118e603 100644 --- a/web/src/components/keys/KeyTable.vue +++ b/web/src/components/keys/KeyTable.vue @@ -237,6 +237,19 @@ async function testKey(_key: KeyRow) { try { const response = await keysApi.testKeys(props.selectedGroup.id, _key.key_value); const curValid = response.results?.[0] || {}; + + // 更新密钥的状态码(不再需要重新加载整个列表) + const keyIndex = keys.value.findIndex(k => k.id === _key.id); + if (keyIndex !== -1) { + keys.value[keyIndex].status_code = (curValid.status_code ?? undefined) as number | undefined; + // 同时更新状态,避免重新加载 + if (curValid.is_valid) { + keys.value[keyIndex].status = 'active'; + } else { + keys.value[keyIndex].status = 'invalid'; + } + } + if (curValid.is_valid) { window.$message.success( t("keys.testSuccess", { duration: formatDuration(response.total_duration) }) @@ -248,8 +261,8 @@ async function testKey(_key: KeyRow) { closable: true, }); } - await loadKeys(); - // 触发同步操作刷新 + + // 触发同步操作刷新,但不再重新加载密钥列表 triggerSyncOperationRefresh(props.selectedGroup.name, "TEST_SINGLE"); } catch (_error) { console.error("Test failed"); @@ -657,6 +670,19 @@ function resetPage() { class="key-card" :class="getStatusClass(key.status)" > + +
+ + {{ key.status_code }} + +
+
@@ -998,6 +1024,7 @@ function resetPage() { flex-direction: column; gap: 10px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + position: relative; } .key-card:hover { @@ -1128,6 +1155,25 @@ function resetPage() { /* 统计信息行 */ +.status-code-badge { + position: absolute; + top: -8px; + left: -8px; + z-index: 100; + animation: fadeInScale 0.3s ease-out; +} + +@keyframes fadeInScale { + 0% { + opacity: 0; + transform: scale(0.8) translateY(-2px); + } + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + .action-btn { padding: 2px 6px; border: 1px solid var(--border-color); diff --git a/web/src/types/models.ts b/web/src/types/models.ts index b0dfa766a..19ae42a60 100644 --- a/web/src/types/models.ts +++ b/web/src/types/models.ts @@ -19,6 +19,8 @@ export interface APIKey { last_used_at?: string; created_at: string; updated_at: string; + status_code?: number; + is_visible?: boolean; } // 类型别名,用于兼容 @@ -136,6 +138,14 @@ export interface RequestLog { request_body?: string; } +// 密钥测试结果 +export interface KeyTestResult { + key_value: string; + is_valid: boolean; + error?: string; + status_code?: number; +} + export interface Pagination { page: number; page_size: number; From 97a0a03c159074d6fa8665daf5bd1c855b886e2f Mon Sep 17 00:00:00 2001 From: AAEE86 Date: Wed, 24 Sep 2025 11:51:18 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E5=B0=86=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E7=9A=84=E8=AF=B7=E6=B1=82=E7=9A=84=E7=8A=B6=E6=80=81=E7=A0=81?= =?UTF-8?q?=E5=86=99=E5=85=A5=E5=88=B0=20apikeys=20=E8=A1=A8=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/keypool/provider.go | 10 ++++++++++ internal/proxy/server.go | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/internal/keypool/provider.go b/internal/keypool/provider.go index c66b26bb6..da8a0a7aa 100644 --- a/internal/keypool/provider.go +++ b/internal/keypool/provider.go @@ -112,6 +112,16 @@ func (p *KeyProvider) UpdateStatus(apiKey *models.APIKey, group *models.Group, i }() } +// UpdateStatusCode 更新密钥的状态码 +func (p *KeyProvider) UpdateStatusCode(apiKey *models.APIKey, statusCode int) error { + // keep in-memory model consistent to avoid accidental overwrite + apiKey.StatusCode = statusCode + if err := p.db.Model(apiKey).Update("status_code", statusCode).Error; err != nil { + return err + } + return nil +} + // executeTransactionWithRetry wraps a database transaction with a retry mechanism. func (p *KeyProvider) executeTransactionWithRetry(operation func(tx *gorm.DB) error) error { const maxRetries = 3 diff --git a/internal/proxy/server.go b/internal/proxy/server.go index 716717196..1e5bc4aa5 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -196,6 +196,13 @@ func (ps *ProxyServer) executeRequestWithRetry( // 使用解析后的错误信息更新密钥状态 ps.keyProvider.UpdateStatus(apiKey, group, false, parsedError) + + // 更新状态码到数据库 + if statusCode != 0 { + if err := ps.keyProvider.UpdateStatusCode(apiKey, statusCode); err != nil { + logrus.WithError(err).WithField("key_id", apiKey.ID).Error("Failed to update status_code") + } + } // 判断是否为最后一次尝试 isLastAttempt := retryCount >= cfg.MaxRetries From 3cd2fdd7fa6688e7528a71ebcadf2c2c06a74186 Mon Sep 17 00:00:00 2001 From: AAEE86 Date: Wed, 24 Sep 2025 11:52:23 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E7=8A=B6=E6=80=81=E7=A0=81=E5=8A=9F=E8=83=BD=E4=BB=A5?= =?UTF-8?q?=E5=8F=8A=E6=B8=85=E7=A9=BA=E5=BD=93=E5=89=8D=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E4=B8=8B=E5=AF=86=E9=92=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/key_handler.go | 36 ++++++- internal/router/router.go | 1 + internal/services/key_service.go | 29 +++++- web/src/api/keys.ts | 27 ++++++ web/src/components/keys/KeyTable.vue | 139 ++++++++++++++++++++++++++- web/src/locales/en-US.ts | 7 ++ web/src/locales/ja-JP.ts | 7 ++ web/src/locales/zh-CN.ts | 7 ++ 8 files changed, 249 insertions(+), 4 deletions(-) diff --git a/internal/handler/key_handler.go b/internal/handler/key_handler.go index 1ca0711f3..ed23c5c04 100644 --- a/internal/handler/key_handler.go +++ b/internal/handler/key_handler.go @@ -155,7 +155,8 @@ func (s *Server) ListKeysInGroup(c *gin.Context) { searchHash = s.EncryptionSvc.Hash(searchKeyword) } - query := s.KeyService.ListKeysInGroupQuery(groupID, statusFilter, searchHash) + statusCodeFilter := c.Query("status_code") + query := s.KeyService.ListKeysInGroupQuery(groupID, statusFilter, searchHash, statusCodeFilter) var keys []models.APIKey paginatedResult, err := response.Paginate(c, query, &keys) @@ -407,6 +408,39 @@ func (s *Server) ClearAllKeys(c *gin.Context) { response.SuccessI18n(c, "success.all_keys_cleared", nil, map[string]any{"count": rowsAffected}) } +// ClearCurrentQueryKeys deletes keys based on current query conditions. +func (s *Server) ClearCurrentQueryKeys(c *gin.Context) { + var req struct { + GroupID uint `json:"group_id" binding:"required"` + KeyValue *string `json:"key_value,omitempty"` + StatusCode *int `json:"status_code,omitempty"` + Status *string `json:"status,omitempty"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, app_errors.NewAPIError(app_errors.ErrInvalidJSON, err.Error())) + return + } + + if _, ok := s.findGroupByID(c, req.GroupID); !ok { + return + } + + // 构建查询条件 + var keyHash *string + if req.KeyValue != nil && *req.KeyValue != "" { + hash := s.EncryptionSvc.Hash(*req.KeyValue) + keyHash = &hash + } + + rowsAffected, err := s.KeyService.ClearCurrentQueryKeys(req.GroupID, keyHash, req.StatusCode, req.Status) + if err != nil { + response.Error(c, app_errors.ParseDBError(err)) + return + } + + response.SuccessI18n(c, "success.current_query_keys_cleared", nil, map[string]any{"count": rowsAffected}) +} + // ExportKeys handles exporting keys to a text file. func (s *Server) ExportKeys(c *gin.Context) { groupID, ok := validateGroupIDFromQuery(c) diff --git a/internal/router/router.go b/internal/router/router.go index 2d326c039..448ce6c56 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -131,6 +131,7 @@ func registerProtectedAPIRoutes(api *gin.RouterGroup, serverHandler *handler.Ser keys.POST("/restore-all-invalid", serverHandler.RestoreAllInvalidKeys) keys.POST("/clear-all-invalid", serverHandler.ClearAllInvalidKeys) keys.POST("/clear-all", serverHandler.ClearAllKeys) + keys.POST("/clear-current-query", serverHandler.ClearCurrentQueryKeys) keys.POST("/validate-group", serverHandler.ValidateGroupKeys) keys.POST("/test-multiple", serverHandler.TestMultipleKeys) } diff --git a/internal/services/key_service.go b/internal/services/key_service.go index ea18bb7dd..367fe2366 100644 --- a/internal/services/key_service.go +++ b/internal/services/key_service.go @@ -8,6 +8,7 @@ import ( "gpt-load/internal/models" "io" "regexp" + "strconv" "strings" "github.com/sirupsen/logrus" @@ -257,6 +258,26 @@ func (s *KeyService) ClearAllKeys(groupID uint) (int64, error) { return s.KeyProvider.RemoveAllKeys(groupID) } +// ClearCurrentQueryKeys deletes keys based on current query conditions. +func (s *KeyService) ClearCurrentQueryKeys(groupID uint, keyHash *string, statusCode *int, status *string) (int64, error) { + query := s.DB.Where("group_id = ?", groupID) + + if keyHash != nil && *keyHash != "" { + query = query.Where("key_hash = ?", *keyHash) + } + + if statusCode != nil && *statusCode != 0 { + query = query.Where("status_code = ?", *statusCode) + } + + if status != nil && *status != "" { + query = query.Where("status = ?", *status) + } + + result := query.Delete(&models.APIKey{}) + return result.RowsAffected, result.Error +} + // DeleteMultipleKeys handles the business logic of deleting keys from a text block. func (s *KeyService) DeleteMultipleKeys(groupID uint, keysText string) (*DeleteKeysResult, error) { keysToDelete := s.ParseKeysFromText(keysText) @@ -296,7 +317,7 @@ func (s *KeyService) DeleteMultipleKeys(groupID uint, keysText string) (*DeleteK } // ListKeysInGroupQuery builds a query to list all keys within a specific group, filtered by status. -func (s *KeyService) ListKeysInGroupQuery(groupID uint, statusFilter string, searchHash string) *gorm.DB { +func (s *KeyService) ListKeysInGroupQuery(groupID uint, statusFilter string, searchHash string, statusCodeFilter string) *gorm.DB { query := s.DB.Model(&models.APIKey{}).Where("group_id = ?", groupID) if statusFilter != "" { @@ -307,6 +328,12 @@ func (s *KeyService) ListKeysInGroupQuery(groupID uint, statusFilter string, sea query = query.Where("key_hash = ?", searchHash) } + if statusCodeFilter != "" { + if statusCode, err := strconv.Atoi(statusCodeFilter); err == nil { + query = query.Where("status_code = ?", statusCode) + } + } + query = query.Order("last_used_at desc, updated_at desc") return query diff --git a/web/src/api/keys.ts b/web/src/api/keys.ts index 85e673ffa..e15012ef4 100644 --- a/web/src/api/keys.ts +++ b/web/src/api/keys.ts @@ -72,6 +72,7 @@ export const keysApi = { page_size: number; key_value?: string; status?: KeyStatus; + status_code?: number; }): Promise<{ items: APIKey[]; pagination: { @@ -185,6 +186,32 @@ export const keysApi = { ); }, + // 清空当前查询条件下的密钥 + clearCurrentQueryKeys( + group_id: number, + key_value?: string, + status_code?: number, + status?: KeyStatus + ): Promise<{ data: { message: string } }> { + const params: any = { group_id }; + if (key_value) { + params.key_value = key_value; + } + if (status_code) { + params.status_code = status_code; + } + if (status) { + params.status = status; + } + return http.post( + "/keys/clear-current-query", + params, + { + hideMessage: true, + } + ); + }, + // 导出密钥 exportKeys(groupId: number, status: "all" | "active" | "invalid" = "all") { const authKey = localStorage.getItem("authKey"); diff --git a/web/src/components/keys/KeyTable.vue b/web/src/components/keys/KeyTable.vue index b7118e603..ae144c3d8 100644 --- a/web/src/components/keys/KeyTable.vue +++ b/web/src/components/keys/KeyTable.vue @@ -47,6 +47,7 @@ const keys = ref([]); const loading = ref(false); const searchText = ref(""); const statusFilter = ref<"all" | "active" | "invalid">("all"); +const searchMode = ref<"key" | "statusCode">("key"); const currentPage = ref(1); const pageSize = ref(12); const total = ref(0); @@ -68,6 +69,11 @@ const moreOptions = [ { label: t("keys.exportInvalidKeys"), key: "copyInvalid" }, { type: "divider" }, { label: t("keys.restoreAllInvalidKeys"), key: "restoreAll" }, + { + label: t("keys.clearCurrentQueryKeys"), + key: "clearCurrentQuery", + props: { style: { color: "#d03050" } }, + }, { label: t("keys.clearAllInvalidKeys"), key: "clearInvalid", @@ -180,6 +186,9 @@ function handleMoreAction(key: string) { case "clearAll": clearAll(); break; + case "clearCurrentQuery": + clearCurrentQueryKeys(); + break; } } @@ -195,7 +204,8 @@ async function loadKeys() { page: currentPage.value, page_size: pageSize.value, status: statusFilter.value === "all" ? undefined : (statusFilter.value as KeyStatus), - key_value: searchText.value.trim() || undefined, + key_value: searchMode.value === "key" ? (searchText.value.trim() || undefined) : undefined, + status_code: searchMode.value === "statusCode" ? (searchText.value.trim() ? Number(searchText.value.trim()) : undefined) : undefined, }); keys.value = result.items as KeyRow[]; total.value = result.pagination.total_items; @@ -580,6 +590,63 @@ async function clearAll() { }); } +async function clearCurrentQueryKeys() { + if (!props.selectedGroup?.id || isDeling.value) { + return; + } + + // 构建确认消息,显示当前查询条件 + let queryDesc = ""; + if (searchText.value) { + queryDesc += `${t("keys.keyExactMatch")}: ${searchText.value}`; + } + if (statusFilter.value !== "all") { + if (queryDesc) queryDesc += ", "; + queryDesc += `${t("common.status")}: ${t(`keys.${statusFilter.value}`)}`; + } + if (searchMode.value === "statusCode" && searchText.value) { + queryDesc = `${t("keys.statusCodeSearch")}: ${searchText.value}`; + } + + const d = dialog.warning({ + title: t("keys.clearCurrentQueryKeys"), + content: () => + h("div", null, [ + h("p", null, t("keys.confirmClearCurrentQueryKeys", { query: queryDesc || t("keys.allKeys") }).split("\n")[0]), + h("p", null, t("keys.confirmClearCurrentQueryKeys", { query: queryDesc || t("keys.allKeys") }).split("\n")[1]), + ]), + positiveText: t("common.confirm"), + negativeText: t("common.cancel"), + onPositiveClick: async () => { + if (!props.selectedGroup?.id) { + return; + } + + isDeling.value = true; + d.loading = true; + try { + // 调用API清空当前查询条件下的密钥 + await keysApi.clearCurrentQueryKeys( + props.selectedGroup.id, + searchMode.value === "key" ? (searchText.value.trim() || undefined) : undefined, + searchMode.value === "statusCode" ? (searchText.value.trim() ? parseInt(searchText.value.trim(), 10) : undefined) : undefined, + statusFilter.value === "all" ? undefined : (statusFilter.value as KeyStatus) + ); + window.$message.success(t("keys.clearCurrentQueryKeysSuccess")); + await loadKeys(); + // 触发同步操作刷新 + triggerSyncOperationRefresh(props.selectedGroup.name, "CLEAR_CURRENT_QUERY"); + } catch (_error) { + console.error("Clear current query keys failed", _error); + window.$message.error(t("keys.clearCurrentQueryKeysFailed")); + } finally { + d.loading = false; + isDeling.value = false; + } + }, + }); +} + function changePage(page: number) { currentPage.value = page; } @@ -623,10 +690,22 @@ function resetPage() { style="width: 120px" :placeholder="t('keys.allStatus')" /> +
+
+
+ + {{ t("keys.keySearch") }} +
+
+ + {{ t("keys.statusCodeSearch") }} +
+
+
Date: Mon, 13 Oct 2025 22:35:46 +0800 Subject: [PATCH 4/4] fix (#3) --- internal/channel/anthropic_channel.go | 2 +- internal/channel/openai_channel.go | 2 +- internal/models/types.go | 2 +- web/src/components/keys/KeyTable.vue | 14 ++++++++++++-- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/internal/channel/anthropic_channel.go b/internal/channel/anthropic_channel.go index 4e212d5ee..102016e30 100644 --- a/internal/channel/anthropic_channel.go +++ b/internal/channel/anthropic_channel.go @@ -82,7 +82,7 @@ func (ch *AnthropicChannel) ValidateKey(ctx context.Context, apiKey *models.APIK // Parse validation endpoint to extract path and query parameters endpointURL, err := url.Parse(ch.ValidationEndpoint) - if err != nil + if err != nil { return false, 0, fmt.Errorf("failed to join upstream URL and validation endpoint: %w", err) } diff --git a/internal/channel/openai_channel.go b/internal/channel/openai_channel.go index 7aee90177..b7aff4a0d 100644 --- a/internal/channel/openai_channel.go +++ b/internal/channel/openai_channel.go @@ -82,7 +82,7 @@ func (ch *OpenAIChannel) ValidateKey(ctx context.Context, apiKey *models.APIKey, // Parse validation endpoint to extract path and query parameters endpointURL, err := url.Parse(ch.ValidationEndpoint) if err != nil { - return false, 0, fmt.Errorf("failed to join upstream URL and validation endpoint: %w", er + return false, 0, fmt.Errorf("failed to join upstream URL and validation endpoint: %w", err) } // Build final URL with path and query parameters diff --git a/internal/models/types.go b/internal/models/types.go index 9ad10f577..462058899 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -114,7 +114,7 @@ type APIKey struct { GroupID uint `gorm:"not null;index" json:"group_id"` Status string `gorm:"type:varchar(50);not null;default:'active'" json:"status"` StatusCode int `gorm:"default:0" json:"status_code,omitempty"` - Notes string `gorm:"type:varchar(255);default:''" json:"notes + Notes string `gorm:"type:varchar(255);default:''" json:"notes"` RequestCount int64 `gorm:"not null;default:0" json:"request_count"` FailureCount int64 `gorm:"not null;default:0" json:"failure_count"` LastUsedAt *time.Time `json:"last_used_at"` diff --git a/web/src/components/keys/KeyTable.vue b/web/src/components/keys/KeyTable.vue index cc1d9e29d..5c1a5ec39 100644 --- a/web/src/components/keys/KeyTable.vue +++ b/web/src/components/keys/KeyTable.vue @@ -212,7 +212,9 @@ async function loadKeys() { page_size: pageSize.value, status: statusFilter.value === "all" ? undefined : (statusFilter.value as KeyStatus), key_value: searchMode.value === "key" ? (searchText.value.trim() || undefined) : undefined, - status_code: searchMode.value === "statusCode" ? (searchText.value.trim() ? Number(searchText.value.trim()) : undefined) : undefined, + status_code: searchMode.value === "statusCode" && searchText.value.trim() + ? (isNaN(Number(searchText.value.trim())) ? undefined : Number(searchText.value.trim())) + : undefined, }); keys.value = result.items as KeyRow[]; total.value = result.pagination.total_items; @@ -698,6 +700,13 @@ function resetPage() { searchText.value = ""; statusFilter.value = "all"; } + +function validateStatusCodeInput(value: string) { + if (searchMode.value === "statusCode") { + return value.replace(/[^0-9]/g, ''); + } + return value; +}