From e888fe6035f434efe2d65c3c1b88f198cdec458c Mon Sep 17 00:00:00 2001 From: tbphp Date: Mon, 7 Sep 2026 21:21:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(credentials):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E6=93=8D=E4=BD=9C=E5=B9=B6=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=BA=94=E5=8D=83=E6=9D=A1=E5=AF=86=E9=92=A5=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=20(#587)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(credentials): 统一全量操作并支持五千条密钥导入 * fix(web): 明确恢复全部的凭据范围 * fix(web): 同步密钥导入错误位置上限 * test(requestlog): 修正延迟刷盘测试的同步时序 --- internal/control/credential_actions.go | 65 ++++- internal/control/credential_actions_test.go | 10 - .../control/credential_batch_restore_test.go | 268 ++++++++++++++++++ .../control/credential_download_text_test.go | 132 +++++++++ .../control/credential_import_limit_test.go | 126 ++++++++ internal/control/credential_mutations.go | 37 ++- internal/control/credential_stages.go | 3 +- internal/control/credentials.go | 1 + internal/control/group_write.go | 2 +- internal/requestlog/worker_test.go | 9 + web/src/api/control/types.ts | 3 +- web/src/app/resources/credentials.ts | 29 +- web/src/app/resources/groups.ts | 2 +- .../credentials/GroupCredentialsTab.vue | 59 ++-- .../features/import/credential-analysis.ts | 2 +- web/src/i18n/locales/en-US/group.ts | 13 +- web/src/i18n/locales/en-US/import.ts | 4 +- web/src/i18n/locales/ja-JP/group.ts | 13 +- web/src/i18n/locales/ja-JP/import.ts | 4 +- web/src/i18n/locales/zh-CN/group.ts | 12 +- web/src/i18n/locales/zh-CN/import.ts | 4 +- 21 files changed, 730 insertions(+), 68 deletions(-) create mode 100644 internal/control/credential_batch_restore_test.go create mode 100644 internal/control/credential_download_text_test.go create mode 100644 internal/control/credential_import_limit_test.go diff --git a/internal/control/credential_actions.go b/internal/control/credential_actions.go index d94ed047..594fd040 100644 --- a/internal/control/credential_actions.go +++ b/internal/control/credential_actions.go @@ -1,6 +1,7 @@ package control import ( + "bytes" "context" "encoding/json" "errors" @@ -13,17 +14,17 @@ import ( "gpt-load/internal/storage/models" ) -// CredentialDownloadResult contains the canonical subscription credential and -// the safe filename suggested to the management UI. +// CredentialDownloadResult 返回订阅 JSON 或 API Key 文本文件及其安全文件名。 type CredentialDownloadResult struct { Filename string `json:"filename"` - Credential json.RawMessage `json:"credential"` + Credential json.RawMessage `json:"credential,omitempty"` + Content *string `json:"content,omitempty"` } -// CredentialDownloadAllResult contains one downloadable file per subscription -// credential in a Group, ordered by credential ID. +// CredentialDownloadAllResult 按凭据 ID 顺序导出,并独立统计凭据数量。 type CredentialDownloadAllResult struct { - Files []CredentialDownloadResult `json:"files"` + Files []CredentialDownloadResult `json:"files"` + CredentialCount int `json:"credential_count"` } // RefreshGroupCredential forces only the subscription token refresh. Account @@ -76,8 +77,7 @@ func (s *Service) DownloadGroupCredential( }, nil } -// DownloadAllGroupCredentials returns every subscription credential in the -// Group without applying collection pagination or filters. +// DownloadAllGroupCredentials 导出整个分组,不应用列表分页或筛选。 func (s *Service) DownloadAllGroupCredentials( ctx context.Context, groupID uint, @@ -91,9 +91,6 @@ func (s *Service) DownloadAllGroupCredentials( if err := tx.Take(&group, groupID).Error; err != nil { return err } - if normalizeGroupConnectionType(group.ConnectionType) != models.ConnectionTypeSubscription { - return app_errors.ErrForbidden - } if group.ChannelID == "" { return app_errors.ErrValidation } @@ -109,8 +106,17 @@ func (s *Service) DownloadAllGroupCredentials( } return CredentialDownloadAllResult{}, app_errors.ParseDBError(err) } - files := make([]CredentialDownloadResult, 0, len(rows)) + apiKeyGroup := normalizeGroupConnectionType(group.ConnectionType) == models.ConnectionTypeAPIKey + fileCount := len(rows) + if apiKeyGroup { + fileCount = 1 + } + files := make([]CredentialDownloadResult, 0, fileCount) + var content strings.Builder for _, row := range rows { + if err := ctx.Err(); err != nil { + return CredentialDownloadAllResult{}, err + } canonical, email, err := s.decodeCredential(group, row) if err != nil { return CredentialDownloadAllResult{}, err @@ -118,12 +124,45 @@ func (s *Service) DownloadAllGroupCredentials( if len(canonical) == 0 { return CredentialDownloadAllResult{}, app_errors.ErrInternalServer } + if apiKeyGroup { + line, err := credentialTextLine(canonical) + if err != nil { + return CredentialDownloadAllResult{}, err + } + content.WriteString(line) + content.WriteByte('\n') + continue + } files = append(files, CredentialDownloadResult{ Filename: subscriptionCredentialFilename(group.ChannelID, email, row.ID), Credential: json.RawMessage(append([]byte(nil), canonical...)), }) } - return CredentialDownloadAllResult{Files: files}, nil + if apiKeyGroup { + text := content.String() + files = append(files, CredentialDownloadResult{ + Filename: fmt.Sprintf("%s-group-%d.txt", sanitizeCredentialFilenamePart(group.ChannelID), group.ID), + Content: &text, + }) + } + return CredentialDownloadAllResult{Files: files, CredentialCount: len(rows)}, nil +} + +// credentialTextLine 保持普通 Key 一行一个,结构化凭据保留完整 JSON 以便重新导入。 +func credentialTextLine(canonical json.RawMessage) (string, error) { + var fields map[string]string + if err := json.Unmarshal(canonical, &fields); err != nil { + return "", app_errors.ErrInternalServer + } + if key, ok := fields["api_key"]; ok && len(fields) == 1 && + !strings.ContainsAny(key, "\r\n") && !strings.HasPrefix(key, "{") { + return key, nil + } + var compact bytes.Buffer + if err := json.Compact(&compact, canonical); err != nil { + return "", app_errors.ErrInternalServer + } + return compact.String(), nil } func (s *Service) loadSubscriptionCredentialTarget( diff --git a/internal/control/credential_actions_test.go b/internal/control/credential_actions_test.go index 8f0fb20d..6285374a 100644 --- a/internal/control/credential_actions_test.go +++ b/internal/control/credential_actions_test.go @@ -151,16 +151,6 @@ func TestDownloadAllGroupCredentialsReturnsEveryAccountAndNoStoreHeaders(t *test } } -func TestDownloadAllGroupCredentialsRejectsAPIKeyGroup(t *testing.T) { - t.Parallel() - - fixture := newServiceFixture(t) - groupID := createGroupForCredentialImport(t, fixture, "sk-download-all-forbidden") - if _, err := fixture.service.DownloadAllGroupCredentials(t.Context(), groupID); !errors.Is(err, app_errors.ErrForbidden) { - t.Fatalf("DownloadAllGroupCredentials() error = %v, want forbidden", err) - } -} - func TestRefreshGroupCredentialOnlyRefreshesToken(t *testing.T) { t.Parallel() diff --git a/internal/control/credential_batch_restore_test.go b/internal/control/credential_batch_restore_test.go new file mode 100644 index 00000000..4cd9def3 --- /dev/null +++ b/internal/control/credential_batch_restore_test.go @@ -0,0 +1,268 @@ +package control + +import ( + "encoding/json" + "errors" + "reflect" + "testing" + "time" + + "gpt-load/internal/channel" + "gpt-load/internal/health" + app_errors "gpt-load/internal/platform/errors" + "gpt-load/internal/state" + "gpt-load/internal/storage/models" + providerobservation "gpt-load/internal/subscription/providers/observation" +) + +func TestBatchRestoreAllCredentialsPreservesConfigurationAndHistory(t *testing.T) { + t.Parallel() + fixture := newServiceFixture(t) + groupID := createGroupWithCredentials(t, fixture, "cooldown-secret\nblacklisted-secret\ndisabled-secret\navailable-secret") + other, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{ + Name: stringPointer("other restore group"), ChannelID: channel.OpenAI, + Params: json.RawMessage(`{}`), Models: optionalGroupModels{Set: true}, + Credentials: "other-secret", ConnectionType: models.ConnectionTypeAPIKey, ConfirmSameTarget: true, + }) + if err != nil { + t.Fatal(err) + } + rows := batchRestoreRows(t, fixture, groupID) + otherID := batchRestoreRows(t, fixture, other.GroupID)[0].ID + now := time.Now().UTC() + fixture.service.now = func() time.Time { return now } + weight := 17 + if _, err := fixture.service.UpdateGroupCredential(t.Context(), groupID, rows[0].ID, CredentialUpdateRequest{ + WeightManual: optionalField[int]{Set: true, Value: weight}, + }); err != nil { + t.Fatal(err) + } + if _, err := fixture.service.BatchGroupCredentials(t.Context(), groupID, CredentialBatchRequest{ + Action: CredentialBatchDisable, CredentialIDs: []uint{rows[2].ID}, + }); err != nil { + t.Fatal(err) + } + fixture.registry.SetCooldown(rows[0].ID, now.Add(time.Hour)) + for _, id := range []uint{rows[1].ID, rows[2].ID, otherID} { + fixture.registry.SetBlacklisted(id) + } + for _, row := range rows { + fixture.registry.IncrFailure(row.ID) + fixture.registry.SetAutoWeight(row.ID, 1) + fixture.stats.RecordSuccess(row.ID, now.Add(-time.Second)) + fixture.stats.RecordFailure(row.ID, health.FailureCategoryInvalidKey, 401, now) + } + beforeRows := batchRestoreRows(t, fixture, groupID) + beforeViews := fixture.registry.Snapshot() + executor := &credentialProbeTestExecutor{} + fixture.service.executor = executor + result, err := fixture.service.BatchGroupCredentials(t.Context(), groupID, CredentialBatchRequest{ + Action: CredentialBatchAction("restore"), Scope: CredentialBatchScopeAll, + }) + if err != nil { + t.Fatalf("restore all: %v", err) + } + if !reflect.DeepEqual(result.AffectedCredentialIDs, []uint{rows[0].ID, rows[1].ID}) || + result.Summary != (CredentialSummaryResponse{Total: 4, Available: 3, Disabled: 1}) { + t.Fatalf("restore response = %#v", result) + } + if !reflect.DeepEqual(beforeRows, batchRestoreRows(t, fixture, groupID)) { + t.Fatal("restore changed persisted credentials") + } + for _, before := range beforeViews { + after, exists := findRuntimeCredential(fixture.registry.Snapshot(), before.ID) + if !exists { + t.Fatalf("credential %d missing after restore", before.ID) + } + if before.ID != rows[0].ID && before.ID != rows[1].ID { + if !reflect.DeepEqual(after, before) { + t.Fatalf("non-target credential %d changed", before.ID) + } + continue + } + stats := fixture.stats.Snapshot(before.ID, now) + if stats.Success != 1 || stats.Failure != 1 || stats.ConsecutiveFailure != 0 || + stats.ConsecutiveProblem != 0 || stats.LastStatusCode != 0 || stats.LastFailureCategory != health.FailureCategoryAmbiguous { + t.Fatalf("credential %d history = %#v", before.ID, stats) + } + want := before + want.CooldownUntil = time.Time{} + want.Blacklisted = false + want.FailureCount = 0 + want.WeightAuto = calculateAutoWeight(stats) + if !reflect.DeepEqual(after, want) { + t.Fatalf("credential %d runtime = %#v, want %#v", before.ID, after, want) + } + } + if calls := executor.recordedCalls(); len(calls) != 0 { + t.Fatalf("restore sent %d upstream requests", len(calls)) + } +} + +func TestBatchRestoreAllSubscriptionCredentialsPreservesQuotaAndAuth(t *testing.T) { + t.Parallel() + for _, authState := range []models.CredentialAuthState{ + models.CredentialAuthStateReady, models.CredentialAuthStateRefreshing, + models.CredentialAuthStateReauthorizationRequired, models.CredentialAuthStateOutcomeUnknown, + } { + t.Run(string(authState), func(t *testing.T) { + t.Parallel() + fixture, groupID, credentialID := newSubscriptionCredentialFixture(t) + markStoredCredentialAuthState(t, fixture, credentialID, authState, "test_auth_state") + remaining, utilization := 30.0, 0.7 + resetAt := time.Now().Add(time.Hour).UnixMilli() + if !fixture.registry.ApplyQuotaWindows(credentialID, []providerobservation.QuotaWindow{{ + ID: "primary", Scope: "account", State: "available", Remaining: &remaining, + Utilization: &utilization, ResetAtMS: &resetAt, + }}) { + t.Fatal("publish quota failed") + } + fixture.registry.SetBlacklisted(credentialID) + before, _ := findRuntimeCredential(fixture.registry.Snapshot(), credentialID) + beforeRows := batchRestoreRows(t, fixture, groupID) + fixture.service.prepareSubscriptionCredential = nil + fixture.service.recoverSubscriptionCredential = nil + executor := &credentialProbeTestExecutor{} + fixture.service.executor = executor + result, err := fixture.service.BatchGroupCredentials(t.Context(), groupID, CredentialBatchRequest{ + Action: CredentialBatchAction("restore"), Scope: CredentialBatchScopeAll, + }) + if err != nil { + t.Fatalf("restore subscription group: %v", err) + } + after, _ := findRuntimeCredential(fixture.registry.Snapshot(), credentialID) + if authState == models.CredentialAuthStateReady { + if !reflect.DeepEqual(result.AffectedCredentialIDs, []uint{credentialID}) || after.Blacklisted { + t.Fatalf("ready credential was not restored: %#v", result) + } + } else if len(result.AffectedCredentialIDs) != 0 || !reflect.DeepEqual(before, after) { + t.Fatalf("restore changed unavailable auth state %q", authState) + } + if !reflect.DeepEqual(before.QuotaRemaining, after.QuotaRemaining) || !before.QuotaResetAt.Equal(after.QuotaResetAt) || + before.AuthState != after.AuthState || !reflect.DeepEqual(beforeRows, batchRestoreRows(t, fixture, groupID)) { + t.Fatal("restore changed quota, auth, or persisted credentials") + } + if calls := executor.recordedCalls(); len(calls) != 0 { + t.Fatalf("restore sent %d upstream requests", len(calls)) + } + }) + } +} + +func TestBatchRestoreAllCredentialsWithoutEligibleTargets(t *testing.T) { + t.Parallel() + for _, scenario := range []string{"available", "empty", "disabled group", "zero weight group"} { + t.Run(scenario, func(t *testing.T) { + t.Parallel() + fixture := newServiceFixture(t) + groupID := createGroupWithCredentials(t, fixture, "no-target-secret") + credentialID := batchRestoreRows(t, fixture, groupID)[0].ID + switch scenario { + case "empty": + if err := fixture.service.DeleteGroupCredential(t.Context(), groupID, credentialID); err != nil { + t.Fatal(err) + } + case "disabled group", "zero weight group": + fixture.registry.SetBlacklisted(credentialID) + field, value := "enabled", any(false) + if scenario == "zero weight group" { + field, value = "weight_manual", 0 + } + if err := fixture.db.Model(&models.Group{}).Where("id = ?", groupID).Update(field, value).Error; err != nil { + t.Fatal(err) + } + } + before := fixture.registry.Snapshot() + result, err := fixture.service.BatchGroupCredentials(t.Context(), groupID, CredentialBatchRequest{ + Action: CredentialBatchAction("restore"), Scope: CredentialBatchScopeAll, + }) + if err != nil || result.AffectedCredentialIDs == nil || len(result.AffectedCredentialIDs) != 0 { + t.Fatalf("restore response = %#v, error = %v", result, err) + } + if !reflect.DeepEqual(before, fixture.registry.Snapshot()) { + t.Fatal("restore changed a group without eligible targets") + } + }) + } +} + +func TestBatchRestoreRequiresAllScope(t *testing.T) { + t.Parallel() + fixture := newServiceFixture(t) + groupID := createGroupWithCredentials(t, fixture, "scope-secret") + credentialID := batchRestoreRows(t, fixture, groupID)[0].ID + for _, request := range []CredentialBatchRequest{ + {Action: CredentialBatchAction("restore"), CredentialIDs: []uint{credentialID}}, + {Action: CredentialBatchAction("restore")}, + {Action: CredentialBatchAction("restore"), Scope: CredentialBatchScopeAll, CredentialIDs: []uint{credentialID}}, + } { + if _, err := fixture.service.BatchGroupCredentials(t.Context(), groupID, request); !errors.Is(err, app_errors.ErrValidation) { + t.Fatalf("restore scope error = %v, want validation", err) + } + } +} + +func TestBatchRestoreUsesStateAtMutationBoundary(t *testing.T) { + t.Parallel() + fixture := newServiceFixture(t) + groupID := createGroupWithCredentials(t, fixture, "recovered-secret\nnew-problem-secret") + rows := batchRestoreRows(t, fixture, groupID) + fixture.registry.SetBlacklisted(rows[0].ID) + fixture.service.mutations = &batchRestoreBoundaryCoordinator{ + MutationCoordinator: health.NewMutationCoordinator(), + before: func() { + fixture.registry.RestoreRuntimeState(rows[0].ID, state.MaxWeight) + fixture.registry.SetBlacklisted(rows[1].ID) + }, + } + result, err := fixture.service.BatchGroupCredentials(t.Context(), groupID, CredentialBatchRequest{ + Action: CredentialBatchAction("restore"), Scope: CredentialBatchScopeAll, + }) + if err != nil || !reflect.DeepEqual(result.AffectedCredentialIDs, []uint{rows[1].ID}) { + t.Fatalf("restore response = %#v, error = %v", result, err) + } +} + +func TestBatchRestoreWaitsForOperationRecovery(t *testing.T) { + t.Parallel() + fixture := newServiceFixture(t) + groupID := createGroupWithCredentials(t, fixture, "recovery-barrier-secret") + credentialID := batchRestoreRows(t, fixture, groupID)[0].ID + fixture.registry.SetBlacklisted(credentialID) + fixture.service.reconcileRegistryGroup = func(uint, []state.CredentialEntry) (bool, error) { + return false, errors.New("registry remains unavailable") + } + mutations := 0 + operation := newDurableGroupOperationInput(t, fixture, "048f47a2-9c35-4d6e-8b1a-1234567890ab", &mutations) + _, err := fixture.service.executeIdempotentOperation(t.Context(), operation) + assertAPIErrorCode(t, err, app_errors.ErrControlOperationIncomplete.Code) + before := fixture.registry.Snapshot() + _, err = fixture.service.BatchGroupCredentials(t.Context(), groupID, CredentialBatchRequest{ + Action: CredentialBatchAction("restore"), Scope: CredentialBatchScopeAll, + }) + assertAPIErrorCode(t, err, app_errors.ErrControlRecoveryPending.Code) + if !reflect.DeepEqual(before, fixture.registry.Snapshot()) { + t.Fatal("restore changed runtime state before pending recovery completed") + } +} + +type batchRestoreBoundaryCoordinator struct { + *health.MutationCoordinator + before func() +} + +func (coordinator *batchRestoreBoundaryCoordinator) DoMany(ids []uint, fn func()) { + coordinator.MutationCoordinator.DoMany(ids, func() { + coordinator.before() + fn() + }) +} + +func batchRestoreRows(t *testing.T, fixture serviceFixture, groupID uint) []models.Credential { + t.Helper() + var rows []models.Credential + if err := fixture.db.Where("group_id = ?", groupID).Order("id ASC").Find(&rows).Error; err != nil { + t.Fatal(err) + } + return rows +} diff --git a/internal/control/credential_download_text_test.go b/internal/control/credential_download_text_test.go new file mode 100644 index 00000000..4bfdc6a6 --- /dev/null +++ b/internal/control/credential_download_text_test.go @@ -0,0 +1,132 @@ +package control + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "gpt-load/internal/channel" + "gpt-load/internal/platform/config" +) + +func TestDownloadAllAPIKeysReturnsOneTextFileAcrossAllStatuses(t *testing.T) { + t.Parallel() + initControlI18n(t) + fixture := newServiceFixture(t) + lines := make([]string, 121) + for index := range lines { + lines[index] = fmt.Sprintf("synthetic-export-key-%03d", index) + } + groupID := createGroupForCredentialImport(t, fixture, strings.Join(lines, "\n")) + refs := fixture.registry.CaptureActiveCredentialRefs([]uint{groupID}) + if _, err := fixture.service.BatchGroupCredentials(t.Context(), groupID, CredentialBatchRequest{ + Action: CredentialBatchDisable, CredentialIDs: []uint{refs[0].ID}, + }); err != nil { + t.Fatal(err) + } + fixture.registry.SetBlacklisted(refs[1].ID) + if _, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{ + Name: stringPointer("other export group"), ChannelID: channel.OpenAI, + ConnectionType: "api_key", Params: json.RawMessage(`{}`), Models: optionalGroupModels{Set: true}, + Credentials: "synthetic-other-group-key", ConfirmSameTarget: true, + }); err != nil { + t.Fatal(err) + } + server := NewServer(&config.Config{AuthKey: "synthetic-export-auth"}, fixture.service) + engine := gin.New() + server.RegisterRoutes(engine) + response := serveCredentialRequest(t, engine, http.MethodPost, + fmt.Sprintf("/api/groups/%d/credentials/download-all", groupID), "{}", "synthetic-export-auth", "") + if response.Code != http.StatusOK { + t.Fatalf("download status = %d, want 200", response.Code) + } + if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Pragma") != "no-cache" { + t.Fatal("download must disable caching") + } + var envelope struct { + Data struct { + CredentialCount int `json:"credential_count"` + Files []struct { + Filename string `json:"filename"` + Content string `json:"content"` + Credential json.RawMessage `json:"credential"` + } `json:"files"` + } `json:"data"` + } + if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if envelope.Data.CredentialCount != len(lines) || len(envelope.Data.Files) != 1 { + t.Fatalf("download count=%d files=%d", envelope.Data.CredentialCount, len(envelope.Data.Files)) + } + file := envelope.Data.Files[0] + if !strings.HasSuffix(file.Filename, ".txt") || len(file.Credential) != 0 { + t.Fatal("API Key download must contain only a TXT file") + } + if file.Content != strings.Join(lines, "\n")+"\n" { + t.Fatal("download must contain every group credential once in ID order") + } + imported, err := fixture.service.ImportGroupCredentials(t.Context(), groupID, CredentialImportRequest{Credentials: file.Content}) + if err != nil || imported.CredentialsAdded != 0 || imported.CredentialsDuplicated != len(lines) { + t.Fatalf("reimport added=%d duplicates=%d error=%v", imported.CredentialsAdded, imported.CredentialsDuplicated, err) + } +} + +func TestDownloadAllAPIKeysPreservesStructuredCredentials(t *testing.T) { + t.Parallel() + tests := []struct { + name string + channel channel.ID + params string + credential string + }{ + {"azure", channel.AzureOpenAI, `{"endpoint":"https://resource.openai.azure.com"}`, `{"tenant_id":"tenant","client_secret":"synthetic-secret","client_id":"client"}`}, + {"bedrock", channel.AWSBedrock, `{"region":"us-east-1"}`, `{"access_key":"synthetic-access","secret_key":"synthetic-secret","session_token":"synthetic-session"}`}, + {"vertex", channel.GoogleVertex, `{}`, `{"service_account_json":"{\"type\":\"service_account\",\"project_id\":\"project-one\",\"client_email\":\"svc@example.iam.gserviceaccount.com\",\"private_key\":\"synthetic-private\\nkey\"}"}`}, + {"multiline_key", channel.OpenAI, `{}`, `{"api_key":"synthetic-line-one\nline-two"}`}, + {"json_prefix_key", channel.OpenAI, `{}`, `{"api_key":"{synthetic-key}"}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := newServiceFixture(t) + created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{ + Name: stringPointer(test.name), ChannelID: test.channel, Params: json.RawMessage(test.params), + ConnectionType: "api_key", Models: optionalGroupModels{Set: true}, Credentials: test.credential, + }) + if err != nil { + t.Fatal(err) + } + download, err := fixture.service.DownloadAllGroupCredentials(t.Context(), created.GroupID) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(download) + if err != nil { + t.Fatal(err) + } + var data struct { + Files []struct { + Content string `json:"content"` + } `json:"files"` + } + if err := json.Unmarshal(encoded, &data); err != nil { + t.Fatal(err) + } + if len(data.Files) != 1 { + t.Fatalf("files=%d, want 1", len(data.Files)) + } + content := data.Files[0].Content + if strings.Count(content, "\n") != 1 || !json.Valid([]byte(strings.TrimSpace(content))) { + t.Fatal("structured credential must occupy one complete JSON line") + } + imported, err := fixture.service.ImportGroupCredentials(t.Context(), created.GroupID, CredentialImportRequest{Credentials: content}) + if err != nil || imported.CredentialsAdded != 0 || imported.CredentialsDuplicated != 1 { + t.Fatalf("round trip added=%d duplicates=%d error=%v", imported.CredentialsAdded, imported.CredentialsDuplicated, err) + } + }) + } +} diff --git a/internal/control/credential_import_limit_test.go b/internal/control/credential_import_limit_test.go new file mode 100644 index 00000000..e6ea79bc --- /dev/null +++ b/internal/control/credential_import_limit_test.go @@ -0,0 +1,126 @@ +package control + +import ( + "errors" + "fmt" + "strings" + "testing" + "time" + + app_errors "gpt-load/internal/platform/errors" +) + +func TestAPIKeyImportAccepts5000AndReplaysWithoutDuplicates(t *testing.T) { + fixture, _ := newFileServiceFixture(t) + groupID := createGroupForCredentialImport(t, fixture, "sk-synthetic-existing") + if err := fixture.service.DeleteGroupCredential(t.Context(), groupID, fixture.registry.Snapshot()[0].ID); err != nil { + t.Fatalf("remove seed credential: %v", err) + } + credentials := syntheticImportCredentials(5000) + request := CredentialImportRequest{Credentials: "\n \r\n" + credentials + "\n\n"} + const idempotencyKey = "00000000-0000-4000-8000-000000005000" + beforeSnapshot := fixture.manager.Current() + + started := time.Now() + first, err := fixture.service.ImportGroupCredentialsIdempotent(t.Context(), idempotencyKey, groupID, request) + elapsed := time.Since(started) + if err != nil { + t.Fatalf("import 5000 API keys: %v", err) + } + t.Logf("file-backed SQLite synchronous import of 5000 synthetic API keys: %s", elapsed) + if first.GroupID != groupID || first.CredentialsAdded != 5000 || first.CredentialsDuplicated != 0 { + t.Fatalf("import result = %#v, want 5000 added and 0 duplicates", first) + } + assertImportedCredentialState(t, fixture, groupID, 5000) + if fixture.manager.Current() != beforeSnapshot { + t.Fatal("credential import republished the configuration snapshot") + } + beforeReplay := countCreateImportRows(t, fixture) + replayed, err := fixture.service.ImportGroupCredentialsIdempotent(t.Context(), idempotencyKey, groupID, request) + if err != nil || replayed != first { + t.Fatalf("replay result/error = %#v/%v, want original result %#v", replayed, err, first) + } + if afterReplay := countCreateImportRows(t, fixture); afterReplay != beforeReplay { + t.Fatalf("replay changed resource counts: before=%#v after=%#v", beforeReplay, afterReplay) + } + duplicate, err := fixture.service.ImportGroupCredentialsIdempotent( + t.Context(), "00000000-0000-4000-8000-000000005002", groupID, request, + ) + if err != nil || duplicate.CredentialsAdded != 0 || duplicate.CredentialsDuplicated != 5000 { + t.Fatalf("duplicate import result/error = %#v/%v, want 0 added and 5000 duplicates", duplicate, err) + } + assertImportedCredentialState(t, fixture, groupID, 5000) + + started = time.Now() + download, err := fixture.service.DownloadAllGroupCredentials(t.Context(), groupID) + elapsed = time.Since(started) + if err != nil { + t.Fatalf("download 5000 API keys: %v", err) + } + t.Logf("file-backed SQLite synchronous download of 5000 synthetic API keys: %s", elapsed) + if download.CredentialCount != 5000 || len(download.Files) != 1 { + t.Fatalf("download credential/file count = %d/%d, want 5000/1", download.CredentialCount, len(download.Files)) + } + content := download.Files[0].Content + if content == nil || strings.Count(*content, "\n") != 5000 || *content != credentials+"\n" { + t.Fatal("download content must contain the same 5000 API keys in order, each followed by a newline") + } +} + +func TestAPIKeyImportRejects5001NonEmptyEntriesWithoutWriting(t *testing.T) { + t.Parallel() + for _, idempotent := range []bool{false, true} { + for _, repeated := range []bool{false, true} { + t.Run(fmt.Sprintf("idempotent=%t/repeated=%t", idempotent, repeated), func(t *testing.T) { + fixture := newServiceFixture(t) + groupID := createGroupForCredentialImport(t, fixture, "sk-synthetic-existing") + raw := syntheticImportCredentials(5001) + if repeated { + raw = strings.Repeat("sk-synthetic-repeated\n", 5001) + } + request := CredentialImportRequest{Credentials: raw} + beforeRows := countCreateImportRows(t, fixture) + beforeSnapshot := fixture.manager.Current() + var err error + if idempotent { + _, err = fixture.service.ImportGroupCredentialsIdempotent( + t.Context(), "00000000-0000-4000-8000-000000005001", groupID, request, + ) + } else { + _, err = fixture.service.ImportGroupCredentials(t.Context(), groupID, request) + } + if !errors.Is(err, app_errors.ErrValidation) { + t.Fatalf("import 5001 non-empty entries error = %v, want validation error", err) + } + if afterRows := countCreateImportRows(t, fixture); afterRows != beforeRows { + t.Fatalf("rejected import changed resource counts: before=%#v after=%#v", beforeRows, afterRows) + } + if fixture.manager.Current() != beforeSnapshot || len(fixture.registry.Snapshot()) != 1 { + t.Fatal("rejected import changed runtime state") + } + }) + } + } +} + +func TestSubscriptionCredentialStageLimitStays1000(t *testing.T) { + t.Parallel() + stages := make([]string, 1001) + for index := range stages { + stages[index] = fmt.Sprintf("synthetic-stage-%04d", index) + } + if normalized, err := normalizeCredentialStageIDs(stages[:1000]); err != nil || len(normalized) != 1000 { + t.Fatalf("normalize 1000 subscription stages count/error = %d/%v", len(normalized), err) + } + if _, err := normalizeCredentialStageIDs(stages); !errors.Is(err, app_errors.ErrValidation) { + t.Fatalf("normalize 1001 subscription stages error = %v, want validation error", err) + } +} + +func syntheticImportCredentials(count int) string { + lines := make([]string, count) + for index := range lines { + lines[index] = fmt.Sprintf("sk-synthetic-import-%048d", index) + } + return strings.Join(lines, "\n") +} diff --git a/internal/control/credential_mutations.go b/internal/control/credential_mutations.go index 1ca20062..3765c9d2 100644 --- a/internal/control/credential_mutations.go +++ b/internal/control/credential_mutations.go @@ -494,7 +494,8 @@ func (s *Service) mapCredentialItem( } func normalizeCredentialBatchRequest(request CredentialBatchRequest) ([]uint, bool, error) { - if request.Action != CredentialBatchEnable && request.Action != CredentialBatchDisable && request.Action != CredentialBatchDelete { + if request.Action != CredentialBatchEnable && request.Action != CredentialBatchDisable && + request.Action != CredentialBatchDelete && request.Action != CredentialBatchRestore { return nil, false, app_errors.ErrValidation } if request.Scope == CredentialBatchScopeAll { @@ -503,7 +504,7 @@ func normalizeCredentialBatchRequest(request CredentialBatchRequest) ([]uint, bo } return nil, true, nil } - if request.Scope != "" { + if request.Scope != "" || request.Action == CredentialBatchRestore { return nil, false, app_errors.ErrValidation } if len(request.CredentialIDs) < 1 || len(request.CredentialIDs) > 100 { @@ -594,6 +595,10 @@ func (s *Service) BatchGroupCredentials( mutationErr = fmt.Errorf("snapshot credential registry entries: %w", app_errors.ErrInternalServer) return } + if request.Action == CredentialBatchRestore { + ids, mutationErr = s.restoreCredentialBatchRuntime(group, before) + return + } desired := make([]state.CredentialEntry, len(before)) for index, entry := range before { desired[index] = entry @@ -698,6 +703,34 @@ func (s *Service) BatchGroupCredentials( }, nil } +func (s *Service) restoreCredentialBatchRuntime(group models.Group, entries []state.CredentialEntry) ([]uint, error) { + groupView := state.GroupCatalogView{ID: group.ID, Enabled: group.Enabled, WeightManual: group.WeightManual} + now := s.now().UTC() + restored := make([]uint, 0, len(entries)) + for _, entry := range entries { + view := state.CredentialRuntimeView{ + Status: entry.Status, AuthState: entry.AuthState, WeightManual: entry.WeightManual, + CooldownUntil: entry.CooldownUntil, Blacklisted: entry.Blacklisted, + } + bucket := classifyHealthKey(groupView, view, now) + if bucket != healthBucketCooldown && bucket != healthBucketBlacklisted { + continue + } + stats := s.stats.Snapshot(entry.ID, now) + stats.ConsecutiveFailure = 0 + stats.ConsecutiveProblem = 0 + stats.LastFailureCategory = 0 + stats.LastStatusCode = 0 + // 只修改健康字段,保留并发发布的订阅额度与授权状态。 + if !s.registry.RestoreRuntimeState(entry.ID, calculateAutoWeight(stats)) { + return nil, dbRegistryMismatch(mismatchMissingRegistry, group.ID, entry.ID) + } + s.stats.ClearProblemState(entry.ID) + restored = append(restored, entry.ID) + } + return restored, nil +} + func (s *Service) applyCredentialBatchRegistryMutation( groupID uint, credentialIDs []uint, diff --git a/internal/control/credential_stages.go b/internal/control/credential_stages.go index f562345c..1e8619be 100644 --- a/internal/control/credential_stages.go +++ b/internal/control/credential_stages.go @@ -28,6 +28,7 @@ const ( credentialStageAuthTTL = 5 * time.Minute credentialStageDeviceMaxTTL = 30 * time.Minute credentialStageTombstoneTTL = 24 * time.Hour + maxCredentialStageIDs = 1000 maxOAuthFileBytes = 64 * 1024 maxDeviceAuthorizationURL = 4096 maxDeviceAuthorizationCode = 128 @@ -1364,7 +1365,7 @@ func (s *Service) subscriptionIdentityFingerprint(channelID channel.ID, accountI } func normalizeCredentialStageIDs(values []string) ([]string, error) { - if len(values) == 0 || len(values) > maxCredentialLines { + if len(values) == 0 || len(values) > maxCredentialStageIDs { return nil, app_errors.ErrValidation } normalized := make([]string, 0, len(values)) diff --git a/internal/control/credentials.go b/internal/control/credentials.go index 11d83f10..72d3df59 100644 --- a/internal/control/credentials.go +++ b/internal/control/credentials.go @@ -129,6 +129,7 @@ const ( CredentialBatchEnable CredentialBatchAction = "enable" CredentialBatchDisable CredentialBatchAction = "disable" CredentialBatchDelete CredentialBatchAction = "delete" + CredentialBatchRestore CredentialBatchAction = "restore" CredentialBatchScopeAll CredentialBatchScope = "all" ) diff --git a/internal/control/group_write.go b/internal/control/group_write.go index 792988d1..7b43b28d 100644 --- a/internal/control/group_write.go +++ b/internal/control/group_write.go @@ -19,7 +19,7 @@ import ( "gpt-load/internal/storage/models" ) -const maxCredentialLines = 1000 +const maxCredentialLines = 5000 type GroupModel struct { ID string `json:"id"` diff --git a/internal/requestlog/worker_test.go b/internal/requestlog/worker_test.go index cf247908..6feaae14 100644 --- a/internal/requestlog/worker_test.go +++ b/internal/requestlog/worker_test.go @@ -7,6 +7,7 @@ import ( "math" "net/http" "reflect" + "runtime" "strings" "sync" "sync/atomic" @@ -128,6 +129,14 @@ func TestServiceFlushesAtBatchSizeAndDelayInFIFOOrder(t *testing.T) { service.Emit(testEvent("delay-1")) service.Emit(testEvent("delay-2")) secondTimer := receiveValue(t, timers.created) + // 等两条事件均被 worker 取走,再触发刷盘,避免队列与定时器同时就绪。 + deadline := time.Now().Add(5 * time.Second) + for len(service.queue) != 0 { + if time.Now().After(deadline) { + t.Fatal("worker did not consume both delay events") + } + runtime.Gosched() + } secondTimer.Fire() secondRows := receiveValue(t, writes) if len(secondRows) != 2 || secondRows[0].ID != "delay-1" || secondRows[1].ID != "delay-2" { diff --git a/web/src/api/control/types.ts b/web/src/api/control/types.ts index 2c2a27a9..b816f702 100644 --- a/web/src/api/control/types.ts +++ b/web/src/api/control/types.ts @@ -317,7 +317,8 @@ export interface CredentialDownloadDto { } export interface CredentialDownloadAllDto { - files: CredentialDownloadDto[] + credential_count: number + files: (CredentialDownloadDto | { filename: string; content: string })[] } export type CredentialTestOutcome = 'passed' | 'failed' | 'inconclusive' diff --git a/web/src/app/resources/credentials.ts b/web/src/app/resources/credentials.ts index 6dfd1aa6..355ec97a 100644 --- a/web/src/app/resources/credentials.ts +++ b/web/src/app/resources/credentials.ts @@ -75,7 +75,7 @@ export interface CredentialPatch { } export interface CredentialBatchRequest { - action: 'enable' | 'disable' | 'delete' + action: 'enable' | 'disable' | 'delete' | 'restore' credential_ids?: number[] scope?: 'all' } @@ -120,7 +120,8 @@ const credentialItemFields = [ ] as const const credentialDetailFields = ['credential', 'observation'] as const const credentialDownloadFields = ['filename', 'credential'] as const -const credentialDownloadAllFields = ['files'] as const +const credentialTextDownloadFields = ['filename', 'content'] as const +const credentialDownloadAllFields = ['credential_count', 'files'] as const const credentialDailyUsageFields = [ 'window_seconds', 'success_count', @@ -819,8 +820,25 @@ function projectCredentialDownload(value: unknown): CredentialDownloadDto { function projectCredentialDownloadAll(value: unknown): CredentialDownloadAllDto { const record = projectRecord(value) assertNoSecretLikeFields(record, credentialDownloadAllFields) + const credentialCount = projectSafeInteger(record.credential_count, { minimum: 0 }) + const files = projectArray(record.files, (value): CredentialDownloadAllDto['files'][number] => { + const file = projectRecord(value) + if (!Object.prototype.hasOwnProperty.call(file, 'content')) { + return projectCredentialDownload(file) + } + assertNoSecretLikeFields(file, credentialTextDownloadFields) + const filename = projectString(file.filename) + if (!/^[a-z0-9][a-z0-9._-]{0,191}\.txt$/u.test(filename)) invalidResponse() + return { filename, content: projectString(file.content, { allowEmpty: true }) } + }) + if (files.some((file) => 'content' in file)) { + if (files.length !== 1) invalidResponse() + } else if (files.length !== credentialCount) { + invalidResponse() + } return { - files: projectArray(record.files, projectCredentialDownload), + credential_count: credentialCount, + files, } } @@ -1007,10 +1025,11 @@ export async function batchCredentials( const ids = body.credential_ids ?? [] const all = body.scope === 'all' if ( - !['enable', 'disable', 'delete'].includes(body.action) || + !['enable', 'disable', 'delete', 'restore'].includes(body.action) || (all ? body.action === 'delete' || body.credential_ids !== undefined - : body.scope !== undefined || + : body.action === 'restore' || + body.scope !== undefined || ids.length < 1 || ids.length > 100 || ids.some((id) => !Number.isSafeInteger(id) || id < 1) || diff --git a/web/src/app/resources/groups.ts b/web/src/app/resources/groups.ts index 4b5e8a7e..925ec4e5 100644 --- a/web/src/app/resources/groups.ts +++ b/web/src/app/resources/groups.ts @@ -248,7 +248,7 @@ export function readCredentialValidationData(value: unknown): CredentialValidati const record = projectRecord(value) assertNoSecretLikeFields(record, ['entry', 'field', 'reason_code']) return { - entry: projectSafeInteger(record.entry, { minimum: 1, maximum: 1_000 }), + entry: projectSafeInteger(record.entry, { minimum: 1, maximum: 5_000 }), field: projectNonBlankString(record.field), reason_code: projectEnum(record.reason_code, credentialValidationReasonCodes), } diff --git a/web/src/features/groups/credentials/GroupCredentialsTab.vue b/web/src/features/groups/credentials/GroupCredentialsTab.vue index d1735c63..01b59624 100644 --- a/web/src/features/groups/credentials/GroupCredentialsTab.vue +++ b/web/src/features/groups/credentials/GroupCredentialsTab.vue @@ -7,6 +7,7 @@ import { KeyRound, ListChecks, Plus, + RotateCcw, Search, } from '@lucide/vue' import { useQuery, useQueryClient } from '@tanstack/vue-query' @@ -86,7 +87,7 @@ import { } from '../group-route' const batchCredentialConcurrency = 4 -type FullCredentialAction = 'enable' | 'disable' | 'download' +type FullCredentialAction = 'download' | 'enable' | 'disable' | 'restore' type CredentialTestRestoreError = 'failed' | 'conflict' | 'conflict_refresh_failed' const props = defineProps<{ @@ -218,9 +219,12 @@ const fullActionCopy = computed(() => { title: t(`group.credentials.full.confirmTitle.${action}`, { kind: fullCredentialKind.value, }), - description: t('group.credentials.full.confirmDescription', { - kind: fullCredentialKind.value, - }), + description: t( + action === 'restore' + ? 'group.credentials.full.restoreDescription' + : 'group.credentials.full.confirmDescription', + { kind: fullCredentialKind.value }, + ), confirm: t(`group.credentials.full.confirm.${action}`), } }) @@ -721,10 +725,8 @@ async function refreshCredentialToken(item: CredentialItemDto): Promise { } } -function downloadJSONFile(filename: string, value: unknown): void { - const blob = new Blob([`${JSON.stringify(value, null, 2)}\n`], { - type: 'application/json;charset=utf-8', - }) +function downloadFile(filename: string, content: string, type: string): void { + const blob = new Blob([content], { type }) const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url @@ -735,6 +737,10 @@ function downloadJSONFile(filename: string, value: unknown): void { window.setTimeout(() => URL.revokeObjectURL(url), 0) } +function downloadJSONFile(filename: string, value: unknown): void { + downloadFile(filename, `${JSON.stringify(value, null, 2)}\n`, 'application/json;charset=utf-8') +} + async function downloadCredentialFile(item: CredentialItemDto): Promise { if (pending(item.credential_id)) return feedback.value = '' @@ -757,7 +763,6 @@ async function downloadCredentialFile(item: CredentialItemDto): Promise { function openFullAction(action: FullCredentialAction): void { if (bulkActionsBusy.value || (collection.value?.summary.total ?? 0) === 0) return - if (action === 'download' && props.connectionType !== 'subscription') return fullActionsOpen.value = false fullActionTarget.value = action } @@ -771,8 +776,14 @@ async function confirmFullAction(): Promise { let affected = 0 if (action === 'download') { const result = await downloadAllCredentials(client, props.groupId) - for (const file of result.files) downloadJSONFile(file.filename, file.credential) - affected = result.files.length + for (const file of result.files) { + if ('content' in file) { + downloadFile(file.filename, file.content, 'text/plain;charset=utf-8') + } else { + downloadJSONFile(file.filename, file.credential) + } + } + affected = result.credential_count } else { const result = await batchCredentials(client, props.groupId, { action, @@ -1182,10 +1193,23 @@ onBeforeUnmount(resetConnectionInspection) onBeforeUnmount(resetCredentialTestState) async function reconcileBatch( - action: 'enable' | 'disable' | 'delete', + action: 'enable' | 'disable' | 'delete' | 'restore', result: Awaited>, ): Promise { try { + if (action === 'restore') { + const affected = new Set(result.affected_credential_ids) + loadedDetails.value = new Map([...loadedDetails.value].filter(([id]) => !affected.has(id))) + detailErrors.value = new Map([...detailErrors.value].filter(([id]) => !affected.has(id))) + await Promise.all( + [ + controlQueryKeys.groups.collectionAll, + controlQueryKeys.health(), + controlQueryKeys.home.base(), + controlQueryKeys.home.subscriptionAccounts(), + ].map((queryKey) => queryClient.invalidateQueries({ queryKey, refetchType: 'active' })), + ) + } await cacheCredentialBatch(queryClient, props.groupId, action, result) await refetchActiveCredentialPage() await refetchGroupSummary() @@ -1461,12 +1485,7 @@ async function runBatch(
- @@ -1478,6 +1497,10 @@ async function runBatch(
/^sk-gl-/i.test(line)).length, - tooManyCredentials: nonEmpty.length > 1_000, + tooManyCredentials: nonEmpty.length > 5_000, } } diff --git a/web/src/i18n/locales/en-US/group.ts b/web/src/i18n/locales/en-US/group.ts index b40afa58..4b71d9f6 100644 --- a/web/src/i18n/locales/en-US/group.ts +++ b/web/src/i18n/locales/en-US/group.ts @@ -689,22 +689,29 @@ export default { download: 'Download all', enable: 'Enable all', disable: 'Disable all', + restore: 'Restore all', kind: { account: 'accounts', key: 'keys' }, confirmTitle: { - download: 'Download every account in this Group?', + download: 'Download all {kind} in this Group?', enable: 'Enable all {kind} in this Group?', disable: 'Disable all {kind} in this Group?', + restore: 'Restore all {kind} in this Group?', }, - confirmDescription: 'This affects every {kind} in the current Group.', + confirmDescription: + 'This affects all {kind} in the current Group, regardless of search, filters, or pagination.', + restoreDescription: + 'Restore only this Group’s {kind} that are in cooldown or blacklisted, and clear their consecutive failures. Disabled credentials and those whose authorization is not ready remain unchanged. Historical statistics are preserved. This does not test the upstream, reset quotas, or reauthorize accounts, and applies regardless of search, filters, or pagination.', confirm: { download: 'Download all', enable: 'Enable all', disable: 'Disable all', + restore: 'Restore all', }, succeeded: { - download: 'Downloaded {count} account files', + download: 'Downloaded {count} {kind}', enable: 'Enabled {count} {kind}', disable: 'Disabled {count} {kind}', + restore: 'Restored {count} {kind}', }, failed: 'Unable to complete the full-Group operation', }, diff --git a/web/src/i18n/locales/en-US/import.ts b/web/src/i18n/locales/en-US/import.ts index 0858f38b..782c9f70 100644 --- a/web/src/i18n/locales/en-US/import.ts +++ b/web/src/i18n/locales/en-US/import.ts @@ -207,7 +207,7 @@ export default { }, credentials: { title: 'API keys', - description: 'One per line, up to 1,000; duplicates are not imported twice', + description: 'One per line, up to 5,000; duplicates are not imported twice', label: 'Key content', placeholder: 'One key per line', structuredTitle: 'Channel credentials', @@ -232,7 +232,7 @@ export default { duplicates: 'Duplicates', }, accessKeyWarning: '{count} lines look like GPT-Load access keys; verify before continuing', - tooMany: 'At most 1,000 non-empty keys can be submitted', + tooMany: 'At most 5,000 non-empty keys can be submitted', required: 'Enter at least one credential first', channelCredentialNotice: 'These are channel API keys; create client credentials on the Access Keys page', diff --git a/web/src/i18n/locales/ja-JP/group.ts b/web/src/i18n/locales/ja-JP/group.ts index cee136f5..821cc6e8 100644 --- a/web/src/i18n/locales/ja-JP/group.ts +++ b/web/src/i18n/locales/ja-JP/group.ts @@ -695,22 +695,29 @@ export default { download: 'すべてダウンロード', enable: 'すべて有効化', disable: 'すべて無効化', + restore: 'すべて回復', kind: { account: 'アカウント', key: 'キー' }, confirmTitle: { - download: 'このグループの全アカウントをダウンロードしますか?', + download: 'このグループの全{kind}をダウンロードしますか?', enable: 'このグループの全{kind}を有効化しますか?', disable: 'このグループの全{kind}を無効化しますか?', + restore: 'このグループの全{kind}を回復しますか?', }, - confirmDescription: '現在のグループにあるすべての{kind}が対象です。', + confirmDescription: + '検索、フィルター、ページに関係なく、現在のグループにあるすべての{kind}が対象です。', + restoreDescription: + '現在のグループでクールダウン中またはブラックリストに登録されている{kind}のみを復旧し、その連続失敗状態をクリアします。無効化されている認証情報と認証が準備できていない項目は変更しません。過去の統計は保持します。上流のテスト、クォータのリセット、再認証は行いません。検索、フィルター、ページに関係なく適用されます。', confirm: { download: 'すべてダウンロード', enable: 'すべて有効化', disable: 'すべて無効化', + restore: 'すべて回復', }, succeeded: { - download: '{count} 件のアカウントファイルをダウンロードしました', + download: '{count} 件の{kind}をダウンロードしました', enable: '{count} 件の{kind}を有効化しました', disable: '{count} 件の{kind}を無効化しました', + restore: '{count} 件の{kind}を回復しました', }, failed: '全件操作を完了できません', }, diff --git a/web/src/i18n/locales/ja-JP/import.ts b/web/src/i18n/locales/ja-JP/import.ts index a33cbe51..3427d4e7 100644 --- a/web/src/i18n/locales/ja-JP/import.ts +++ b/web/src/i18n/locales/ja-JP/import.ts @@ -203,7 +203,7 @@ export default { }, credentials: { title: 'API キー', - description: '1 行に 1 つ、最大 1,000 件 — 重複は二重に追加されません', + description: '1 行に 1 つ、最大 5,000 件 — 重複は二重に追加されません', label: 'キー内容', placeholder: '1 行に 1 つのキー', structuredTitle: 'チャネル認証情報', @@ -229,7 +229,7 @@ export default { duplicates: '重複', }, accessKeyWarning: '{count} 行が GPT-Load アクセスキーのようです — 続行前に確認してください', - tooMany: '空でないキーは最大 1,000 件です', + tooMany: '空でないキーは最大 5,000 件です', required: '先に認証情報を 1 件以上入力してください', channelCredentialNotice: 'ここにはチャネルの API キーを入力します — クライアント認証情報はアクセスキーページで作成してください', diff --git a/web/src/i18n/locales/zh-CN/group.ts b/web/src/i18n/locales/zh-CN/group.ts index ad5968fa..c9e91f1a 100644 --- a/web/src/i18n/locales/zh-CN/group.ts +++ b/web/src/i18n/locales/zh-CN/group.ts @@ -661,22 +661,28 @@ export default { download: '下载全部', enable: '启用全部', disable: '停用全部', + restore: '恢复全部', kind: { account: '账号', key: '密钥' }, confirmTitle: { - download: '下载当前分组全部账号?', + download: '下载当前分组全部{kind}?', enable: '启用当前分组全部{kind}?', disable: '停用当前分组全部{kind}?', + restore: '恢复当前分组全部{kind}?', }, - confirmDescription: '此操作将作用于当前分组的全部{kind}。', + confirmDescription: '此操作将作用于当前分组的全部{kind},不受搜索、筛选和分页影响。', + restoreDescription: + '仅恢复当前分组中处于冷却或黑名单状态的{kind},并清除其连续失败状态。停用或授权未就绪项保持不变。保留历史统计,不检测上游、不重置额度或重新授权,不受搜索、筛选和分页影响。', confirm: { download: '确认下载', enable: '确认启用', disable: '确认停用', + restore: '确认恢复', }, succeeded: { - download: '已下载 {count} 个账号文件', + download: '已下载 {count} 个{kind}', enable: '已启用 {count} 个{kind}', disable: '已停用 {count} 个{kind}', + restore: '已恢复 {count} 个{kind}', }, failed: '无法完成全量操作', }, diff --git a/web/src/i18n/locales/zh-CN/import.ts b/web/src/i18n/locales/zh-CN/import.ts index 84c84f98..5f4ac682 100644 --- a/web/src/i18n/locales/zh-CN/import.ts +++ b/web/src/i18n/locales/zh-CN/import.ts @@ -193,7 +193,7 @@ export default { }, credentials: { title: 'API 密钥', - description: '每行一个,最多 1,000 条;重复项不会重复导入', + description: '每行一个,最多 5,000 条;重复项不会重复导入', label: '密钥内容', placeholder: '每行一个密钥', structuredTitle: '渠道凭据', @@ -215,7 +215,7 @@ export default { duplicates: '重复项', }, accessKeyWarning: '{count} 行看起来像 GPT-Load 访问密钥,请确认后继续', - tooMany: '最多可提交 1,000 个非空密钥', + tooMany: '最多可提交 5,000 个非空密钥', required: '请先填写至少一条凭据', channelCredentialNotice: '这里导入的是渠道 API 密钥;客户端凭证请在“访问密钥”页面创建', result: '新增 {added} 条凭据,跳过 {duplicated} 条重复凭据',