feat(credentials): 统一全量操作并支持五千条密钥导入 (#587)

* feat(credentials): 统一全量操作并支持五千条密钥导入

* fix(web): 明确恢复全部的凭据范围

* fix(web): 同步密钥导入错误位置上限

* test(requestlog): 修正延迟刷盘测试的同步时序
This commit is contained in:
tbphp authored and GitHub committed 2026-09-07 21:21:16 +08:00
1 parent 33fb54bfe6
commit e888fe6035
21 files changed
+730 -68

No files matched your search

+52 -13
View File
@@ -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(
@@ -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()
@@ -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
}
@@ -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)
}
})
}
}
@@ -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")
}
+35 -2
View File
@@ -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,
+2 -1
View File
@@ -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))
+1
View File
@@ -129,6 +129,7 @@ const (
CredentialBatchEnable CredentialBatchAction = "enable"
CredentialBatchDisable CredentialBatchAction = "disable"
CredentialBatchDelete CredentialBatchAction = "delete"
CredentialBatchRestore CredentialBatchAction = "restore"
CredentialBatchScopeAll CredentialBatchScope = "all"
)
+1 -1
View File
@@ -19,7 +19,7 @@ import (
"gpt-load/internal/storage/models"
)
const maxCredentialLines = 1000
const maxCredentialLines = 5000
type GroupModel struct {
ID string `json:"id"`
+9
View File
@@ -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" {
+2 -1
View File
@@ -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'
+24 -5
View File
@@ -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) ||
+1 -1
View File
@@ -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),
}
@@ -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<void> {
}
}
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<void> {
if (pending(item.credential_id)) return
feedback.value = ''
@@ -757,7 +763,6 @@ async function downloadCredentialFile(item: CredentialItemDto): Promise<void> {
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<void> {
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<ReturnType<typeof batchCredentials>>,
): Promise<void> {
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(
</AppButton>
</template>
<div class="group-credentials__full-menu">
<button
v-if="connectionType === 'subscription'"
type="button"
:disabled="bulkActionsBusy"
@click="openFullAction('download')"
>
<button type="button" :disabled="bulkActionsBusy" @click="openFullAction('download')">
<Download :size="15" aria-hidden="true" />
{{ t('group.credentials.full.download') }}
</button>
@@ -1478,6 +1497,10 @@ async function runBatch(
<CircleOff :size="15" aria-hidden="true" />
{{ t('group.credentials.full.disable') }}
</button>
<button type="button" :disabled="bulkActionsBusy" @click="openFullAction('restore')">
<RotateCcw :size="15" aria-hidden="true" />
{{ t('group.credentials.full.restore') }}
</button>
</div>
</AppPopover>
<AppButton
@@ -38,6 +38,6 @@ export function analyzeCredentials(raw: string, channelID = ''): CredentialAnaly
emptyLineCount: raw ? lines.length - nonEmpty.length : 0,
duplicateCount,
likelyAccessKeyCount: nonEmpty.filter((line) => /^sk-gl-/i.test(line)).length,
tooManyCredentials: nonEmpty.length > 1_000,
tooManyCredentials: nonEmpty.length > 5_000,
}
}
+10 -3
View File
@@ -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',
},
+2 -2
View File
@@ -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',
+10 -3
View File
@@ -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: '全件操作を完了できません',
},
+2 -2
View File
@@ -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 キーを入力します — クライアント認証情報はアクセスキーページで作成してください',
+9 -3
View File
@@ -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: '无法完成全量操作',
},
+2 -2
View File
@@ -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} 条重复凭据',