From 263ebe15468d231ca9ed1617c01cea328211284b Mon Sep 17 00:00:00 2001 From: tbphp Date: Wed, 9 Sep 2026 20:28:03 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(access-keys):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E5=AF=86=E9=92=A5=E4=B8=8E=E5=BC=BA?= =?UTF-8?q?=E5=BA=A6=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../access_key_collection_query_test.go | 2 +- internal/control/access_key_custom_test.go | 165 +++++++++++++++++ internal/control/access_key_idempotency.go | 12 +- internal/control/access_keys.go | 46 +++-- internal/control/access_keys_phase1_test.go | 4 +- internal/control/access_keys_test.go | 4 +- internal/control/bootstrap.go | 2 +- internal/control/home_test.go | 4 +- internal/control/server.go | 14 ++ internal/platform/errors/errors.go | 2 + internal/platform/i18n/locales/en-US.go | 3 + internal/platform/i18n/locales/ja-JP.go | 3 + internal/platform/i18n/locales/zh-CN.go | 3 + .../custom_access_key_migration_test.go | 127 +++++++++++++ internal/storage/database_integration_test.go | 6 +- internal/storage/db_test.go | 1 + internal/storage/migration.go | 15 +- internal/storage/migration_sqlite.go | 72 ++++++++ internal/storage/migration_sqlite_test.go | 44 +++++ internal/storage/migration_test.go | 1 + .../migrations/0011_custom_access_keys.go | 139 +++++++++++++++ internal/storage/models/access_key.go | 2 +- web/src/app/resources/access-keys.ts | 1 + web/src/components/ui/AppConfirmDialog.vue | 18 +- web/src/components/ui/AppDialog.vue | 6 +- web/src/components/ui/AppTextInput.vue | 3 +- .../access-keys/AccessKeyCredentialField.vue | 168 ++++++++++++++++++ .../features/access-keys/AccessKeyDrawer.vue | 84 ++++++++- .../access-keys/AccessKeyFormFields.vue | 2 + .../access-key-create-operation.ts | 1 + .../features/access-keys/access-key-patch.ts | 6 + .../access-keys/access-key-strength.ts | 29 +++ web/src/i18n/locales/en-US/access-keys.ts | 22 +++ web/src/i18n/locales/ja-JP/access-keys.ts | 22 +++ web/src/i18n/locales/zh-CN/access-keys.ts | 22 +++ 35 files changed, 1008 insertions(+), 47 deletions(-) create mode 100644 internal/control/access_key_custom_test.go create mode 100644 internal/storage/custom_access_key_migration_test.go create mode 100644 internal/storage/migration_sqlite.go create mode 100644 internal/storage/migration_sqlite_test.go create mode 100644 internal/storage/migrations/0011_custom_access_keys.go create mode 100644 web/src/features/access-keys/AccessKeyCredentialField.vue create mode 100644 web/src/features/access-keys/access-key-strength.ts diff --git a/internal/control/access_key_collection_query_test.go b/internal/control/access_key_collection_query_test.go index 1872688e5..def7dc2f3 100644 --- a/internal/control/access_key_collection_query_test.go +++ b/internal/control/access_key_collection_query_test.go @@ -182,7 +182,7 @@ func TestListAccessKeyCollectionRejectsCanceledContextAndInvalidMappedMetadata(t } row := models.AccessKey{ - Name: "invalid", KeyValue: "ciphertext", KeyHash: "hash", KeySuffix: "ZZZZ", + Name: "invalid", KeyValue: "ciphertext", KeyHash: "hash", KeySuffix: "bad ", Status: string(state.AccessKeyStatusActive), Filters: models.JSON(`{}`), } if err := fixture.db.Exec("PRAGMA ignore_check_constraints = ON").Error; err != nil { diff --git a/internal/control/access_key_custom_test.go b/internal/control/access_key_custom_test.go new file mode 100644 index 000000000..7befde1f6 --- /dev/null +++ b/internal/control/access_key_custom_test.go @@ -0,0 +1,165 @@ +package control + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gpt-load/internal/storage/models" +) + +func TestCustomAccessKeyCreationAndReplay(t *testing.T) { + t.Parallel() + initControlI18n(t) + for _, key := range []string{"x", "123456", "imported-key_Z9-", "symbols-!\"\\'~", strings.Repeat("A", 256)} { + t.Run(fmt.Sprintf("length-%d", len(key)), func(t *testing.T) { + fixture := newServiceFixture(t) + engine := newAccessKeyLifecycleEngine(t, fixture) + body, err := json.Marshal(map[string]string{"name": "custom", "key": key}) + if err != nil { + t.Fatal(err) + } + const operation = "00000000-0000-4000-8000-000000008101" + first := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", string(body), operation) + if first.Code != http.StatusOK { + t.Fatalf("custom key creation status = %d", first.Code) + } + var response struct { + Data AccessKeyCreateResult `json:"data"` + } + if err := json.Unmarshal(first.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + created := response.Data + if created.Key != key || created.ID == 0 { + t.Fatal("creation did not preserve the custom key") + } + if len(key) <= 8 && created.MaskedKey != "********" { + t.Fatal("short key was not fully masked") + } + if len(key) > 8 && created.MaskedKey != "****"+key[len(key)-4:] { + t.Fatal("custom key suffix was not preserved") + } + row := loadAccessKeyRow(t, fixture.db, created.ID) + if row.KeyValue == key || row.KeyHash != fixture.encryption.Hash(key) { + t.Fatal("custom key storage is not encrypted and hashed") + } + revealed, err := fixture.service.RevealAccessKey(t.Context(), created.ID) + if err != nil || revealed.Key != key { + t.Fatal("custom key reveal failed") + } + if _, ok := fixture.manager.Current().AccessKeysByHash[row.KeyHash]; !ok { + t.Fatal("custom key not published") + } + request := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil) + request.Header.Set("Authorization", "Bearer "+key) + login := httptest.NewRecorder() + engine.ServeHTTP(login, request) + if login.Code != http.StatusOK { + t.Fatalf("custom key login status = %d", login.Code) + } + replay := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", string(body), operation) + if replay.Code != http.StatusOK { + t.Fatalf("replay status = %d", replay.Code) + } + data := decodeAccessKeyLifecycleData(t, replay) + if _, exists := data["key"]; exists { + t.Fatal("replay exposed a secret") + } + assertJSONRawEqual(t, data["replayed"], "true") + otherBody, err := json.Marshal(map[string]string{"name": "custom", "key": "different-custom-key"}) + if err != nil { + t.Fatal(err) + } + conflict := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", string(otherBody), operation) + if conflict.Code != http.StatusConflict { + t.Fatalf("changed key replay status = %d", conflict.Code) + } + duplicate := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", string(body), "00000000-0000-4000-8000-000000008102") + if duplicate.Code != http.StatusConflict { + t.Fatalf("duplicate key status = %d", duplicate.Code) + } + var count int64 + if err := fixture.db.Model(&models.AccessKey{}).Count(&count).Error; err != nil || count != 1 { + t.Fatal("replay or duplicate created another key") + } + var operations []models.ControlOperation + if err := fixture.db.Find(&operations).Error; err != nil { + t.Fatal(err) + } + for _, operation := range operations { + var metadata map[string]json.RawMessage + if len(operation.CanonicalResult) > 0 { + if err := json.Unmarshal(operation.CanonicalResult, &metadata); err != nil { + t.Fatal(err) + } + if _, exists := metadata["key"]; exists { + t.Fatal("operation metadata persisted the secret") + } + } + } + }) + } +} + +func TestCustomAccessKeyEmptyUsesAutomaticGeneration(t *testing.T) { + t.Parallel() + initControlI18n(t) + fixture := newServiceFixture(t) + engine := newAccessKeyLifecycleEngine(t, fixture) + const operation = "00000000-0000-4000-8000-000000008201" + response := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", `{"name":"automatic","key":""}`, operation) + if response.Code != http.StatusOK { + t.Fatalf("empty key creation status = %d", response.Code) + } + data := decodeAccessKeyLifecycleData(t, response) + var key string + if err := json.Unmarshal(data["key"], &key); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(key, "sk-gl-") || len(key) != 38 { + t.Fatal("empty input did not generate a random key") + } + replay := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", `{"name":"automatic"}`, operation) + if replay.Code != http.StatusOK { + t.Fatalf("omitted key replay status = %d", replay.Code) + } + assertJSONRawEqual(t, decodeAccessKeyLifecycleData(t, replay)["replayed"], "true") +} + +func TestCustomAccessKeyRejectsUnusableValues(t *testing.T) { + t.Parallel() + initControlI18n(t) + fixture := newServiceFixture(t) + engine := newAccessKeyLifecycleEngine(t, fixture) + for index, key := range []string{" ", "leading ", "two words", "line\nbreak", "tab\tkey", "nul\x00key", "中文", strings.Repeat("x", 257), authTestKey} { + body, err := json.Marshal(map[string]string{"name": "invalid", "key": key}) + if err != nil { + t.Fatal(err) + } + response := serveAccessKeyLifecycleRequest(t, engine, http.MethodPost, "/api/access-keys", string(body), fmt.Sprintf("00000000-0000-4000-8000-%012d", index+8200)) + if response.Code != http.StatusBadRequest { + t.Fatalf("case %d status = %d", index, response.Code) + } + var envelope struct { + Code string `json:"code"` + } + if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + wantCode := "INVALID_CUSTOM_ACCESS_KEY" + if key == authTestKey { + wantCode = "ACCESS_KEY_ADMIN_CONFLICT" + } + if envelope.Code != wantCode { + t.Fatalf("case %d error code = %s", index, envelope.Code) + } + } + var count int64 + if err := fixture.db.Model(&models.AccessKey{}).Count(&count).Error; err != nil || count != 0 { + t.Fatal("invalid input created a key") + } +} diff --git a/internal/control/access_key_idempotency.go b/internal/control/access_key_idempotency.go index 04e8991a0..59644f11d 100644 --- a/internal/control/access_key_idempotency.go +++ b/internal/control/access_key_idempotency.go @@ -24,6 +24,7 @@ type accessKeyFilterDigestBody struct { } type accessKeyCreateDigestBody struct { + KeyHash string `json:"key_hash,omitempty"` PriceMultiplier string `json:"price_multiplier,omitempty"` Name string `json:"name"` Status *state.AccessKeyStatus `json:"status,omitempty"` @@ -38,6 +39,14 @@ func (s *Service) CreateAccessKeyIdempotent( idempotencyKey string, request AccessKeyCreateRequest, ) (AccessKeyCreateResult, error) { + keyHash := "" + if request.Key != "" { + if !validAccessKeyPlaintext(request.Key) { + return AccessKeyCreateResult{}, app_errors.ErrInvalidCustomAccessKey + } + // 使用带密钥的指纹区分请求,避免幂等摘要成为弱密钥的离线猜测凭据。 + keyHash = s.encryption.Hash(request.Key) + } name, err := normalizeAccessKeyName(request.Name) if err != nil { return AccessKeyCreateResult{}, err @@ -74,6 +83,7 @@ func (s *Service) CreateAccessKeyIdempotent( } digestFilters := canonicalAccessKeyFilterSet(filters) canonicalBody, err := canonicalIdempotencyBody(accessKeyCreateDigestBody{ + KeyHash: keyHash, PriceMultiplier: priceMultiplierDigest(priceMultiplier), Name: name, Status: digestStatus, Filters: digestFilters, RPMLimit: rpmLimit, CostLimitRules: costLimitRuleRequestsForDigest(costLimitRules), @@ -111,7 +121,7 @@ func (s *Service) CreateAccessKeyIdempotent( if err := validateFilterGroupReferences(tx, filters.Groups); err != nil { return idempotentMutationResult{}, err } - row, plaintext, err := s.newAccessKeyRow(name, filters, rpmLimit) + row, plaintext, err := s.newAccessKeyRow(name, filters, rpmLimit, request.Key) if err != nil { return idempotentMutationResult{}, err } diff --git a/internal/control/access_keys.go b/internal/control/access_keys.go index 313832814..45c3108f8 100644 --- a/internal/control/access_keys.go +++ b/internal/control/access_keys.go @@ -137,6 +137,7 @@ func (value *OptionalRPMLimit) UnmarshalJSON(data []byte) error { } type AccessKeyCreateRequest struct { + Key string `json:"key"` PriceMultiplier optionalField[string] `json:"price_multiplier"` Name string `json:"name"` Status *state.AccessKeyStatus `json:"status"` @@ -220,12 +221,13 @@ func (s *Service) newAccessKeyRow( name string, filters AccessKeyFilters, rpmLimit int64, + customKey string, ) (models.AccessKey, string, error) { encodedFilters, err := encodeStoredAccessKeyFilters(filters) if err != nil { return models.AccessKey{}, "", fmt.Errorf("encode access key filters: %w", err) } - credential, err := s.generateAccessKeyCredential() + credential, err := s.prepareAccessKeyCredential(customKey) if err != nil { return models.AccessKey{}, "", err } @@ -241,20 +243,34 @@ func (s *Service) newAccessKeyRow( } func (s *Service) generateAccessKeyCredential() (generatedAccessKeyCredential, error) { - randomBytes := make([]byte, 16) - if _, err := io.ReadFull(s.random, randomBytes); err != nil { - return generatedAccessKeyCredential{}, fmt.Errorf("generate access key: %w", err) + return s.prepareAccessKeyCredential("") +} + +func (s *Service) prepareAccessKeyCredential(plaintext string) (generatedAccessKeyCredential, error) { + if plaintext == "" { + randomBytes := make([]byte, 16) + if _, err := io.ReadFull(s.random, randomBytes); err != nil { + return generatedAccessKeyCredential{}, fmt.Errorf("generate access key: %w", err) + } + plaintext = accessKeyPrefix + hex.EncodeToString(randomBytes) + } + if !validAccessKeyPlaintext(plaintext) { + return generatedAccessKeyCredential{}, app_errors.ErrInvalidCustomAccessKey } - plaintext := accessKeyPrefix + hex.EncodeToString(randomBytes) ciphertext, err := s.encryption.Encrypt(plaintext) if err != nil { return generatedAccessKeyCredential{}, fmt.Errorf("encrypt access key: %w", err) } + // 短密钥全部隐藏,避免尾号披露全部或大部分凭据。 + suffix := "****" + if len(plaintext) > 8 { + suffix = plaintext[len(plaintext)-4:] + } return generatedAccessKeyCredential{ Plaintext: plaintext, KeyValue: ciphertext, KeyHash: s.encryption.Hash(plaintext), - KeySuffix: plaintext[len(plaintext)-4:], + KeySuffix: suffix, }, nil } @@ -301,7 +317,7 @@ func (s *Service) CreateAccessKey( if err := validateFilterGroupReferences(tx, filters.Groups); err != nil { return err } - row, plaintext, err := s.newAccessKeyRow(name, filters, rpmLimit) + row, plaintext, err := s.newAccessKeyRow(name, filters, rpmLimit, request.Key) if err != nil { return err } @@ -616,28 +632,22 @@ func mapAccessKeyMetadataRow(row accessKeyMetadataRow) (AccessKeyMetadata, error } func maskedAccessKey(suffix string) string { - return accessKeyPrefix + "****" + suffix + return "****" + suffix } func validAccessKeySuffix(value string) bool { if len(value) != 4 { return false } - for _, character := range []byte(value) { - if character < '0' || character > '9' && character < 'a' || character > 'f' { - return false - } - } - return true + return validAccessKeyPlaintext(value) } func validAccessKeyPlaintext(value string) bool { - if len(value) != len(accessKeyPrefix)+32 || - !strings.HasPrefix(value, accessKeyPrefix) { + if len(value) == 0 || len(value) > 256 { return false } - for _, character := range []byte(strings.TrimPrefix(value, accessKeyPrefix)) { - if character < '0' || character > '9' && character < 'a' || character > 'f' { + for _, character := range []byte(value) { + if character < '!' || character > '~' { return false } } diff --git a/internal/control/access_keys_phase1_test.go b/internal/control/access_keys_phase1_test.go index c1454a5e5..bd448501c 100644 --- a/internal/control/access_keys_phase1_test.go +++ b/internal/control/access_keys_phase1_test.go @@ -52,7 +52,7 @@ func TestAccessKeyMetadataListAndUpdateNeverDecryptCiphertext(t *testing.T) { t.Fatalf("ListAccessKeyCollection() error = %v", err) } if len(collection.Items) != 1 || collection.Items[0].ID != created.ID || - collection.Items[0].MaskedKey != "sk-gl-****0000" { + collection.Items[0].MaskedKey != "****0000" { t.Fatalf("ListAccessKeyCollection() = %#v", collection) } encoded, err := json.Marshal(collection) @@ -145,7 +145,7 @@ func TestAccessKeyMetadataFailsClosedForInvalidPersistedSuffix(t *testing.T) { } if err := fixture.db.Model(&models.AccessKey{}). Where("id = ?", created.ID). - UpdateColumn("key_suffix", "ZZZZ").Error; err != nil { + UpdateColumn("key_suffix", "bad ").Error; err != nil { t.Fatalf("set invalid suffix: %v", err) } if err := fixture.db.Exec("PRAGMA ignore_check_constraints = OFF").Error; err != nil { diff --git a/internal/control/access_keys_test.go b/internal/control/access_keys_test.go index 1c6909a24..566f8a6ec 100644 --- a/internal/control/access_keys_test.go +++ b/internal/control/access_keys_test.go @@ -238,9 +238,9 @@ func TestListAccessKeyCollectionReturnsMaskedMetadataWithoutDecrypting(t *testin } if len(listed.Items) != 2 || listed.Items[0].ID != second.ID || - listed.Items[0].MaskedKey != "sk-gl-****1e1f" || + listed.Items[0].MaskedKey != "****1e1f" || listed.Items[1].ID != first.ID || - listed.Items[1].MaskedKey != "sk-gl-****0e0f" { + listed.Items[1].MaskedKey != "****0e0f" { t.Fatalf("ListAccessKeyCollection() = %#v", listed) } diff --git a/internal/control/bootstrap.go b/internal/control/bootstrap.go index 11d379d03..8ae2759eb 100644 --- a/internal/control/bootstrap.go +++ b/internal/control/bootstrap.go @@ -60,7 +60,7 @@ func (s *Service) EnsureInitialState(ctx context.Context) error { if err != nil { return fmt.Errorf("build default access key filters: %w", err) } - row, _, err := s.newAccessKeyRow("Default", filters, 0) + row, _, err := s.newAccessKeyRow("Default", filters, 0, "") if err != nil { return err } diff --git a/internal/control/home_test.go b/internal/control/home_test.go index 3cc3b414a..d91a0067a 100644 --- a/internal/control/home_test.go +++ b/internal/control/home_test.go @@ -156,7 +156,7 @@ func TestReadHomeBaseUsesPersistedAndRuntimeSnapshots(t *testing.T) { AccessKeys: []HomeAccessKey{ { ID: 3, Name: "filtered", - MaskedKey: "sk-gl-****88ab", + MaskedKey: "****88ab", Protocols: []protocol.Protocol{ protocol.OpenAICompletions, protocol.Gemini, @@ -164,7 +164,7 @@ func TestReadHomeBaseUsesPersistedAndRuntimeSnapshots(t *testing.T) { }, { ID: 9, Name: "all protocols", - MaskedKey: "sk-gl-****c0de", + MaskedKey: "****c0de", Protocols: protocol.DataPlaneProtocols(), }, }, diff --git a/internal/control/server.go b/internal/control/server.go index 79d9c7489..742c95428 100644 --- a/internal/control/server.go +++ b/internal/control/server.go @@ -886,6 +886,13 @@ func (s *Server) handleCreateAccessKey(c *gin.Context) { writeServiceError(c, "create_access_key", mapControlJSONError(err)) return } + if request.Key != "" { + digest := sha256.Sum256([]byte(request.Key)) + if s.compareDigest(digest[:], s.authDigest[:]) == 1 { + writeServiceError(c, "create_access_key", app_errors.ErrAccessKeyAdminConflict) + return + } + } result, err := s.service.CreateAccessKeyIdempotent( c.Request.Context(), idempotencyKey, @@ -1215,7 +1222,14 @@ func serviceErrorMessageID( } case app_errors.ErrNoActiveCredential.Code: return "group.no_active_credential" + case app_errors.ErrInvalidCustomAccessKey.Code: + return "access_key.custom_invalid" + case app_errors.ErrAccessKeyAdminConflict.Code: + return "access_key.admin_conflict" case app_errors.ErrDuplicateResource.Code: + if operation == "create_access_key" { + return "access_key.exists" + } if operation == "create_group" || operation == "update_group_settings" { return "group.name_exists" } diff --git a/internal/platform/errors/errors.go b/internal/platform/errors/errors.go index 8c1da709e..090240db1 100644 --- a/internal/platform/errors/errors.go +++ b/internal/platform/errors/errors.go @@ -27,6 +27,8 @@ var ( ErrInvalidJSON = &APIError{HTTPStatus: http.StatusBadRequest, Code: "INVALID_JSON", Message: "Invalid JSON format"} ErrRequestTooLarge = &APIError{HTTPStatus: http.StatusRequestEntityTooLarge, Code: "REQUEST_TOO_LARGE", Message: "Request body is too large"} ErrValidation = &APIError{HTTPStatus: http.StatusBadRequest, Code: "VALIDATION_FAILED", Message: "Input validation failed"} + ErrInvalidCustomAccessKey = &APIError{HTTPStatus: http.StatusBadRequest, Code: "INVALID_CUSTOM_ACCESS_KEY", Message: "Custom access key contains unsupported characters or exceeds 256 bytes"} + ErrAccessKeyAdminConflict = &APIError{HTTPStatus: http.StatusBadRequest, Code: "ACCESS_KEY_ADMIN_CONFLICT", Message: "Access key must differ from the administrator key"} ErrDuplicateResource = &APIError{HTTPStatus: http.StatusConflict, Code: "DUPLICATE_RESOURCE", Message: "Resource already exists"} ErrResourceNotFound = &APIError{HTTPStatus: http.StatusNotFound, Code: "NOT_FOUND", Message: "Resource not found"} ErrGroupInUse = &APIError{HTTPStatus: http.StatusConflict, Code: "GROUP_IN_USE", Message: "Group is referenced by access keys"} diff --git a/internal/platform/i18n/locales/en-US.go b/internal/platform/i18n/locales/en-US.go index 7a04fa568..fbd49e641 100644 --- a/internal/platform/i18n/locales/en-US.go +++ b/internal/platform/i18n/locales/en-US.go @@ -2,6 +2,9 @@ package locales // MessagesEnUS contains English (US) control-plane translations. var MessagesEnUS = map[string]string{ + "access_key.custom_invalid": "Use up to 256 visible ASCII characters without spaces or control characters.", + "access_key.admin_conflict": "The administrator key cannot be used as an access key.", + "access_key.exists": "This access key already exists. Use a different key.", "common.success": "Success", "route.not_found": "Route not found", "route.method_not_allowed": "Method not allowed", diff --git a/internal/platform/i18n/locales/ja-JP.go b/internal/platform/i18n/locales/ja-JP.go index 3cd392512..27cef37d8 100644 --- a/internal/platform/i18n/locales/ja-JP.go +++ b/internal/platform/i18n/locales/ja-JP.go @@ -2,6 +2,9 @@ package locales // MessagesJaJP contains Japanese control-plane translations. var MessagesJaJP = map[string]string{ + "access_key.custom_invalid": "空白や制御文字を含まない、256 文字以内の表示可能な ASCII 文字を使用してください。", + "access_key.admin_conflict": "管理者キーをアクセスキーとして使用することはできません。", + "access_key.exists": "このアクセスキーは既に存在します。別のキーを使用してください。", "common.success": "成功", "route.not_found": "ルートが見つかりません", "route.method_not_allowed": "許可されていないHTTPメソッドです", diff --git a/internal/platform/i18n/locales/zh-CN.go b/internal/platform/i18n/locales/zh-CN.go index 20e3dbde2..9dfa77478 100644 --- a/internal/platform/i18n/locales/zh-CN.go +++ b/internal/platform/i18n/locales/zh-CN.go @@ -2,6 +2,9 @@ package locales // MessagesZhCN contains Simplified Chinese control-plane translations. var MessagesZhCN = map[string]string{ + "access_key.custom_invalid": "密钥最多 256 个字符,仅支持英文字母、数字和英文符号,不允许空白。", + "access_key.admin_conflict": "不能使用管理员密钥作为访问密钥。", + "access_key.exists": "此访问密钥已存在,请使用其他密钥。", "common.success": "操作成功", "route.not_found": "路由不存在", "route.method_not_allowed": "请求方法不被允许", diff --git a/internal/storage/custom_access_key_migration_test.go b/internal/storage/custom_access_key_migration_test.go new file mode 100644 index 000000000..ca7cc4d5f --- /dev/null +++ b/internal/storage/custom_access_key_migration_test.go @@ -0,0 +1,127 @@ +package storage + +import ( + "fmt" + "os" + "testing" + + "gorm.io/gorm" + + "gpt-load/internal/storage/models" +) + +func TestCustomAccessKeyMigrationContract(t *testing.T) { + testCustomAccessKeyMigration(t, openInternalMigrationTestDatabase) +} + +func TestExternalCustomAccessKeyMigrationContract(t *testing.T) { + dsn := os.Getenv("GPT_LOAD_DATABASE_TEST_DSN") + if dsn == "" { + t.Skip("GPT_LOAD_DATABASE_TEST_DSN is not set") + } + testCustomAccessKeyMigration(t, func(t *testing.T) *gorm.DB { return openExternalIncrementalMigrationDatabase(t, dsn) }) +} + +func testCustomAccessKeyMigration(t *testing.T, open func(*testing.T) *gorm.DB) { + for _, scenario := range []string{"fresh", "upgrade", "interrupted"} { + t.Run(scenario, func(t *testing.T) { + db := open(t) + var key models.AccessKey + var rule models.AccessKeyCostLimitRule + var deletedID uint + if scenario != "fresh" { + if err := applyMigrationRegistry(db, migrations[:10]); err != nil { + t.Fatal(err) + } + key = models.AccessKey{Name: "existing", KeyValue: "encrypted-test-value", KeyHash: "existing-hash", KeySuffix: "cafe", Status: "active", Filters: models.JSON(`{}`)} + if err := db.Create(&key).Error; err != nil { + t.Fatal(err) + } + rule = models.AccessKeyCostLimitRule{AccessKeyID: key.ID, Kind: models.AccessKeyCostLimitKindTotal, LimitNanoUSD: 100, RuleRevision: 1} + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + state := models.AccessKeyCostLimitState{RuleID: rule.ID, RuleRevision: 1, UsedNanoUSD: 17, SnapshotVersion: 1} + if err := db.Create(&state).Error; err != nil { + t.Fatal(err) + } + // 删除过的较大 ID 也不能因重建表而被再次分配。 + deleted := models.AccessKey{Name: "deleted", KeyValue: "cipher", KeyHash: "deleted-hash", KeySuffix: "dead", Status: "active", Filters: models.JSON(`{}`)} + if err := db.Create(&deleted).Error; err != nil { + t.Fatal(err) + } + deletedID = deleted.ID + if err := db.Delete(&deleted).Error; err != nil { + t.Fatal(err) + } + } + if scenario == "interrupted" && len(migrations) > 10 { + entry := migrations[10] + up := entry.Up + entry.Up = func(tx *gorm.DB) error { + if err := up(tx); err != nil { + return err + } + return fmt.Errorf("interrupt after custom access key DDL") + } + registry := append(append([]migration(nil), migrations[:10]...), entry) + if err := applyMigrationRegistry(db, registry); err == nil { + t.Fatal("interruption succeeded") + } + if db.Dialector.Name() == "sqlite" { + var enabled int + if err := db.Raw("PRAGMA foreign_keys").Scan(&enabled).Error; err != nil || enabled != 1 { + t.Fatal("interrupted migration did not restore foreign keys") + } + } + } + if err := AutoMigrate(db); err != nil { + t.Fatal(err) + } + if err := AutoMigrate(db); err != nil { + t.Fatal(err) + } + if scenario != "fresh" { + var current models.AccessKey + if err := db.Take(¤t, key.ID).Error; err != nil { + t.Fatal(err) + } + if current.KeyValue != key.KeyValue || current.KeyHash != key.KeyHash || current.KeySuffix != key.KeySuffix { + t.Fatal("existing credential changed") + } + var currentRule models.AccessKeyCostLimitRule + if err := db.Take(¤tRule, rule.ID).Error; err != nil { + t.Fatal(err) + } + var state models.AccessKeyCostLimitState + if err := db.Take(&state, rule.ID).Error; err != nil { + t.Fatal(err) + } + if currentRule.LimitNanoUSD != 100 || state.UsedNanoUSD != 17 { + t.Fatal("quota data changed") + } + } + custom := models.AccessKey{Name: "custom", KeyValue: "encrypted-custom", KeyHash: "custom-hash", KeySuffix: "Z9-_", Status: "active", Filters: models.JSON(`{}`)} + if err := db.Create(&custom).Error; err != nil { + t.Fatalf("custom suffix rejected: %v", err) + } + if scenario != "fresh" && custom.ID <= deletedID { + t.Fatal("migration reused a deleted access key ID") + } + if err := db.Model(&custom).Update("key_suffix", "****").Error; err != nil { + t.Fatal(err) + } + for _, invalid := range []string{"", "abc", "12345"} { + if err := db.Model(&custom).Update("key_suffix", invalid).Error; err == nil { + t.Fatal("invalid suffix length accepted") + } + } + if db.Dialector.Name() == "sqlite" { + var foreignKeys int + if err := db.Raw("PRAGMA foreign_keys").Scan(&foreignKeys).Error; err != nil || foreignKeys != 1 { + t.Fatal("foreign keys not restored") + } + } + }) + } +} diff --git a/internal/storage/database_integration_test.go b/internal/storage/database_integration_test.go index dc4b8eae9..9a0d52c26 100644 --- a/internal/storage/database_integration_test.go +++ b/internal/storage/database_integration_test.go @@ -123,7 +123,7 @@ func TestExternalDatabaseLifecycle(t *testing.T) { if err := db.Table("schema_migrations").Order("id").Pluck("id", &migrationIDs).Error; err != nil { t.Fatalf("read migration ledger: %v", err) } - if len(migrationIDs) != 10 || migrationIDs[0] != "0001_initial" || + if len(migrationIDs) != 11 || migrationIDs[0] != "0001_initial" || migrationIDs[1] != "0002_access_key_cost_limits" || migrationIDs[2] != "0003_remove_observation_fresh_until" || migrationIDs[3] != "0004_usage_stats_group_activity_index" || @@ -131,8 +131,8 @@ func TestExternalDatabaseLifecycle(t *testing.T) { migrationIDs[5] != "0006_error_decision" || migrationIDs[6] != "0007_access_key_lifecycle" || migrationIDs[7] != "0008_remove_inject_usage_options" || - migrationIDs[8] != "0009_price_multipliers" || migrationIDs[9] != "0010_model_cooldown" { - t.Fatalf("migration ledger = %v, want complete 0001-0010 chain", migrationIDs) + migrationIDs[8] != "0009_price_multipliers" || migrationIDs[9] != "0010_model_cooldown" || migrationIDs[10] != "0011_custom_access_keys" { + t.Fatalf("migration ledger = %v, want complete 0001-0011 chain", migrationIDs) } if !db.Migrator().HasIndex("usage_stats", "idx_usage_stats_group_bucket") { t.Fatal("usage_stats group activity index is missing") diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 0437dcd14..ebfd5a832 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -659,6 +659,7 @@ func TestAutoMigrateCreatesUsageJournalAndMigrationLedger(t *testing.T) { "0008_remove_inject_usage_options", "0009_price_multipliers", "0010_model_cooldown", + "0011_custom_access_keys", } if !reflect.DeepEqual(migrationIDs, wantMigrationIDs) { t.Fatalf("schema_migrations IDs = %v, want %v", migrationIDs, wantMigrationIDs) diff --git a/internal/storage/migration.go b/internal/storage/migration.go index 3b1d121fa..3966f7915 100644 --- a/internal/storage/migration.go +++ b/internal/storage/migration.go @@ -1,7 +1,6 @@ package storage import ( - "context" "errors" "fmt" "regexp" @@ -10,7 +9,6 @@ import ( "gorm.io/gorm" - "gpt-load/internal/storage/dbtx" migrationfiles "gpt-load/internal/storage/migrations" ) @@ -97,6 +95,10 @@ var migrations = []migration{ ID: migrationfiles.ID0010, Up: migrationfiles.Up0010, Validate: migrationfiles.Validate0010, ValidateRecoverable: migrationfiles.ValidateRecoverable0010, }, + { + ID: migrationfiles.ID0011, Up: migrationfiles.Up0011, + Validate: migrationfiles.Validate0011, ValidateRecoverable: migrationfiles.ValidateRecoverable0011, + }, } func applyMigrations(db *gorm.DB) error { @@ -116,14 +118,7 @@ func applyMigrationRegistry(db *gorm.DB, entries []migration) error { switch strings.ToLower(db.Dialector.Name()) { case "sqlite": - // SQLite has no advisory-lock API. BEGIN IMMEDIATE pins a connection and - // serializes competing writers before any schema inspection occurs. - return dbtx.Run(context.Background(), db, dbtx.Options{ - Mode: dbtx.Write, - Operation: "database migration", - }, func(transaction *gorm.DB) error { - return applyMigrationsLocked(transaction, entries, false) - }) + return applySQLiteMigrationRegistry(db, entries) case "mysql", "postgres", "postgresql": return db.Connection(func(connection *gorm.DB) error { if err := acquireMigrationLock(connection); err != nil { diff --git a/internal/storage/migration_sqlite.go b/internal/storage/migration_sqlite.go new file mode 100644 index 000000000..9544d97f7 --- /dev/null +++ b/internal/storage/migration_sqlite.go @@ -0,0 +1,72 @@ +package storage + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "time" + + "gorm.io/gorm" +) + +// SQLite 重建被外键引用的表前,必须在事务外关闭外键执行,避免 DROP 级联删除子表数据。 +// 整条迁移链仍在 BEGIN IMMEDIATE 中串行执行,提交前统一校验全部外键。 +func applySQLiteMigrationRegistry(db *gorm.DB, entries []migration) error { + return db.Connection(func(connection *gorm.DB) (resultErr error) { + conn, ok := connection.Statement.ConnPool.(*sql.Conn) + if !ok { + return fmt.Errorf("SQLite migration connection is not pinned") + } + ctx := context.Background() + var foreignKeys int + if err := conn.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&foreignKeys); err != nil { + return err + } + active := false + defer func() { + cleanup, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + var cleanupErr error + if active { + _, cleanupErr = conn.ExecContext(cleanup, "ROLLBACK") + } + restore := "PRAGMA foreign_keys = OFF" + if foreignKeys != 0 { + restore = "PRAGMA foreign_keys = ON" + } + _, err := conn.ExecContext(cleanup, restore) + cleanupErr = errors.Join(cleanupErr, err) + var restored int + if err := conn.QueryRowContext(cleanup, "PRAGMA foreign_keys").Scan(&restored); err != nil { + cleanupErr = errors.Join(cleanupErr, err) + } else if restored != foreignKeys { + cleanupErr = errors.Join(cleanupErr, fmt.Errorf("restore SQLite foreign key enforcement failed")) + } + if cleanupErr != nil { + // 丢弃清理失败的连接,不能将未知事务或外键状态交还连接池。 + if err := conn.Raw(func(any) error { return driver.ErrBadConn }); err != nil && !errors.Is(err, driver.ErrBadConn) { + cleanupErr = errors.Join(cleanupErr, err) + } + } + resultErr = errors.Join(resultErr, cleanupErr) + }() + if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil { + return err + } + if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil { + return err + } + active = true + tx := connection.Session(&gorm.Session{NewDB: true, SkipDefaultTransaction: true, Context: ctx}) + if err := applyMigrationsLocked(tx, entries, false); err != nil { + return err + } + if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil { + return err + } + active = false + return nil + }) +} diff --git a/internal/storage/migration_sqlite_test.go b/internal/storage/migration_sqlite_test.go new file mode 100644 index 000000000..704132789 --- /dev/null +++ b/internal/storage/migration_sqlite_test.go @@ -0,0 +1,44 @@ +package storage + +import ( + "testing" + + "gorm.io/gorm" +) + +func TestSQLiteMigrationRejectsOrphansAndRestoresForeignKeys(t *testing.T) { + db := openInternalMigrationTestDatabase(t) + entry := migration{ + ID: "0001_foreign_key_probe", + Up: func(tx *gorm.DB) error { + for _, statement := range []string{ + "CREATE TABLE parents (id INTEGER PRIMARY KEY)", + "CREATE TABLE children (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parents(id))", + "INSERT INTO children (id, parent_id) VALUES (1, 99)", + } { + if err := tx.Exec(statement).Error; err != nil { + return err + } + } + return nil + }, + Validate: func(*gorm.DB) error { return nil }, + ValidateRecoverable: func(*gorm.DB) error { return nil }, + } + if err := applyMigrationRegistry(db, []migration{entry}); err == nil { + t.Fatal("migration committed an orphan") + } + if db.Migrator().HasTable("parents") || db.Migrator().HasTable("children") || db.Migrator().HasTable("schema_migrations") { + t.Fatal("failed migration did not roll back") + } + var enabled int + if err := db.Raw("PRAGMA foreign_keys").Scan(&enabled).Error; err != nil || enabled != 1 { + t.Fatal("failed migration left foreign key enforcement disabled") + } + if err := AutoMigrate(db); err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO access_key_cost_limit_rules (access_key_id, kind, limit_nano_usd, period_seconds, rule_revision, created_at_ms, updated_at_ms) VALUES (999, 'total', 1, 0, 1, 1, 1)").Error; err == nil { + t.Fatal("writes after migration accepted an orphan") + } +} diff --git a/internal/storage/migration_test.go b/internal/storage/migration_test.go index 51ca8894e..6ee9d8521 100644 --- a/internal/storage/migration_test.go +++ b/internal/storage/migration_test.go @@ -22,6 +22,7 @@ func TestMigrationRegistryContainsOrderedMigrations(t *testing.T) { migrationfiles.ID0008, migrationfiles.ID0009, migrationfiles.ID0010, + migrationfiles.ID0011, } if len(migrations) != len(wantIDs) { t.Fatalf("migration registry length = %d, want %d", len(migrations), len(wantIDs)) diff --git a/internal/storage/migrations/0011_custom_access_keys.go b/internal/storage/migrations/0011_custom_access_keys.go new file mode 100644 index 000000000..35aeef8db --- /dev/null +++ b/internal/storage/migrations/0011_custom_access_keys.go @@ -0,0 +1,139 @@ +package migrations + +import ( + "fmt" + "strings" + + gormmysql "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ID0011 = "0011_custom_access_keys" +const accessKeySuffixCheck0011 = "chk_access_key_suffix" +const accessKeySuffixExpression0011 = "length(key_suffix) = 4" +const priorAccessKeySuffixExpression0011 = "length(key_suffix) = 4 AND substr(key_suffix, 1, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f') AND substr(key_suffix, 2, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f') AND substr(key_suffix, 3, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f') AND substr(key_suffix, 4, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f')" + +// Up0011 保留四位尾号字段,允许自定义字符及短密钥的全遮罩。 +func Up0011(db *gorm.DB) error { + if err := ValidateRecoverable0011(db); err != nil { + return err + } + if Validate0011(db) == nil { + return nil + } + if strings.EqualFold(db.Dialector.Name(), "sqlite") { + if err := rebuildAccessKeysSQLite0011(db); err != nil { + return err + } + } else { + drop := "DROP CONSTRAINT" + if dialector, ok := db.Dialector.(*gormmysql.Dialector); ok && dialector.Config != nil && mysqlRequiresCheckDropSyntax0003(dialector.ServerVersion) { + drop = "DROP CHECK" + } + // MySQL 在同一条 DDL 内原子替换,building 标记可安全恢复。 + if err := db.Exec("ALTER TABLE access_keys " + drop + " chk_access_key_suffix, ADD CONSTRAINT chk_access_key_suffix CHECK (" + accessKeySuffixExpression0011 + ")").Error; err != nil { + return err + } + } + return Validate0011(db) +} + +func ValidateRecoverable0011(db *gorm.DB) error { + if !db.Migrator().HasTable("access_keys") || !db.Migrator().HasColumn("access_keys", "key_suffix") || !db.Migrator().HasConstraint("access_keys", accessKeySuffixCheck0011) { + return fmt.Errorf("custom access key suffix schema is incomplete") + } + return nil +} + +func Validate0011(db *gorm.DB) error { + if err := ValidateRecoverable0011(db); err != nil { + return err + } + definition, err := accessKeySuffixConstraint0011(db) + if err != nil { + return err + } + normalized := strings.NewReplacer(" ", "", "\n", "", "\t", "", "`", "", `"`, "", "(", "", ")", "", "::text", "").Replace(strings.ToLower(definition)) + if strings.EqualFold(db.Dialector.Name(), "sqlite") { + if !strings.Contains(normalized, "constraintchk_access_key_suffixchecklengthkey_suffix=4,") && !strings.HasSuffix(normalized, "constraintchk_access_key_suffixchecklengthkey_suffix=4") { + return fmt.Errorf("custom access key suffix constraint is invalid") + } + } else if normalized != "lengthkey_suffix=4" && normalized != "checklengthkey_suffix=4" { + return fmt.Errorf("custom access key suffix constraint is invalid") + } + return nil +} + +func accessKeySuffixConstraint0011(db *gorm.DB) (string, error) { + var definition string + var err error + switch strings.ToLower(db.Dialector.Name()) { + case "sqlite": + err = db.Raw("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'access_keys'").Scan(&definition).Error + case "mysql": + err = db.Raw("SELECT CHECK_CLAUSE FROM information_schema.check_constraints WHERE constraint_schema = DATABASE() AND constraint_name = ?", accessKeySuffixCheck0011).Scan(&definition).Error + case "postgres", "postgresql": + err = db.Raw("SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = ? AND conrelid = 'access_keys'::regclass", accessKeySuffixCheck0011).Scan(&definition).Error + default: + return "", fmt.Errorf("unsupported custom access key migration driver") + } + return definition, err +} + +func rebuildAccessKeysSQLite0011(db *gorm.DB) error { + var foreignKeys int + if err := db.Raw("PRAGMA foreign_keys").Scan(&foreignKeys).Error; err != nil { + return err + } + if foreignKeys != 0 { + return fmt.Errorf("access key rebuild requires migration foreign key isolation") + } + ddl, err := accessKeySuffixConstraint0011(db) + if err != nil { + return err + } + if strings.Count(ddl, priorAccessKeySuffixExpression0011) != 1 { + return fmt.Errorf("unexpected prior access key suffix constraint") + } + start := strings.Index(ddl, "(") + if start < 0 { + return fmt.Errorf("invalid access key table definition") + } + ddl = "CREATE TABLE access_keys__0011 " + strings.Replace(ddl[start:], priorAccessKeySuffixExpression0011, accessKeySuffixExpression0011, 1) + var objects []string + if err := db.Raw("SELECT sql FROM sqlite_master WHERE tbl_name = 'access_keys' AND type IN ('index','trigger') AND sql IS NOT NULL ORDER BY type, name").Scan(&objects).Error; err != nil { + return err + } + var sequence int64 + if err := db.Raw("SELECT COALESCE(MAX(seq), 0) FROM sqlite_sequence WHERE name = 'access_keys'").Scan(&sequence).Error; err != nil { + return err + } + columns, err := db.Migrator().ColumnTypes("access_keys") + if err != nil { + return err + } + names := make([]string, 0, len(columns)) + for _, column := range columns { + if strings.ContainsAny(column.Name(), "\"`\x00") { + return fmt.Errorf("invalid access key column") + } + names = append(names, `"`+column.Name()+`"`) + } + projection := strings.Join(names, ",") + for _, statement := range []string{ddl, + "INSERT INTO access_keys__0011 (" + projection + ") SELECT " + projection + " FROM access_keys", + "DROP TABLE access_keys", "ALTER TABLE access_keys__0011 RENAME TO access_keys"} { + if err := db.Exec(statement).Error; err != nil { + return err + } + } + if err := db.Exec("UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = 'access_keys'", sequence).Error; err != nil { + return err + } + for _, statement := range objects { + if err := db.Exec(statement).Error; err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/models/access_key.go b/internal/storage/models/access_key.go index b180358e8..d3600c90c 100644 --- a/internal/storage/models/access_key.go +++ b/internal/storage/models/access_key.go @@ -7,7 +7,7 @@ type AccessKey struct { Name string `gorm:"type:varchar(255);not null"` KeyValue string `gorm:"type:text;not null"` KeyHash string `gorm:"type:varchar(128);not null;uniqueIndex"` - KeySuffix string `gorm:"type:char(4);not null;check:chk_access_key_suffix,length(key_suffix) = 4 AND substr(key_suffix, 1, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f') AND substr(key_suffix, 2, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f') AND substr(key_suffix, 3, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f') AND substr(key_suffix, 4, 1) IN ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f')"` + KeySuffix string `gorm:"type:char(4);not null;check:chk_access_key_suffix,length(key_suffix) = 4"` Status string `gorm:"type:varchar(32);not null;default:'active';check:chk_access_key_status,status IN ('active','disabled')"` Filters JSON `gorm:"type:json"` RPMLimit int64 `gorm:"not null;default:0"` diff --git a/web/src/app/resources/access-keys.ts b/web/src/app/resources/access-keys.ts index 0fd0baa90..6036c894f 100644 --- a/web/src/app/resources/access-keys.ts +++ b/web/src/app/resources/access-keys.ts @@ -53,6 +53,7 @@ export type { } from '@/api/control/types' export interface CreateAccessKeyRequest { + key?: string name: string status: AccessKeyDto['status'] filters: AccessKeyFiltersDto diff --git a/web/src/components/ui/AppConfirmDialog.vue b/web/src/components/ui/AppConfirmDialog.vue index f18372f18..6b63b23b4 100644 --- a/web/src/components/ui/AppConfirmDialog.vue +++ b/web/src/components/ui/AppConfirmDialog.vue @@ -1,8 +1,10 @@ diff --git a/web/src/features/access-keys/AccessKeysView.vue b/web/src/features/access-keys/AccessKeysView.vue index 9eb7a0593..703930051 100644 --- a/web/src/features/access-keys/AccessKeysView.vue +++ b/web/src/features/access-keys/AccessKeysView.vue @@ -52,7 +52,6 @@ import { type AccessKeyDrawerRoute, } from './access-key-collection-route' import type { PendingAccessKeyEditOperation } from './access-key-edit-operation' -import type { PendingAccessKeyRotateOperation } from './access-key-rotate-operation' const client = useApiClient() const route = useRoute() @@ -67,14 +66,13 @@ const drawerOpen = computed(() => drawerRoute.value !== undefined) const selected = ref(null) const createOperation = ref(null) const editOperation = ref(null) -const rotateOperation = ref(null) const viewRoot = ref(null) const collection = ref | null>(null) const deletionAnnouncement = ref('') const pendingStatusIDs = ref(new Set()) const optimisticEnabled = ref(new Map()) const lockedAccessKeyIDs = computed>(() => - rotateOperation.value ? new Set([rotateOperation.value.base.id]) : new Set(), + editOperation.value ? new Set([editOperation.value.base.id]) : new Set(), ) const statusControllers = new Map() const accessKeysQuery = useQuery(accessKeyCollectionQueryOptions(client, filters)) @@ -165,11 +163,6 @@ const editOperationNoticeKey = computed(() => const editOperationName = computed( () => editOperation.value?.patch.name ?? editOperation.value?.base.name ?? '', ) -const rotateOperationNoticeKey = computed(() => - rotateOperation.value?.state === 'reconciling' - ? 'accessKeys.operation.rotateReconciling' - : 'accessKeys.operation.rotateIndeterminate', -) let restoreFocus: HTMLElement | null = null const searchDebounce = useDebouncedAction(300) let mounted = true @@ -193,14 +186,8 @@ watch( watch(filters, () => collection.value?.conceal()) watch( - [ - drawerRoute, - data, - editOperation, - rotateOperation, - () => accessKeysQuery.isPlaceholderData.value, - ], - ([drawer, pageData, pendingEdit, pendingRotate, placeholder]) => { + [drawerRoute, data, editOperation, () => accessKeysQuery.isPlaceholderData.value], + ([drawer, pageData, pendingEdit, placeholder]) => { if (drawer === undefined || drawer.mode === 'create') { selected.value = null if (drawer?.mode === 'create' && drawer.sourceAccessKeyID !== undefined) { @@ -221,10 +208,6 @@ watch( selected.value = pendingEdit.base return } - if (pendingRotate?.base.id === drawer.accessKeyID) { - selected.value = pendingRotate.base - return - } if (pageData && !placeholder) void setDrawerRoute(undefined, true) }, { immediate: true }, @@ -325,10 +308,6 @@ function openKey(accessKey: AccessKeyDto, trigger: HTMLElement): void { checkEditOperation() return } - if (rotateOperation.value && rotateOperation.value.base.id !== accessKey.id) { - checkRotateOperation() - return - } selected.value = accessKey restoreFocus = trigger void setDrawerRoute({ mode: 'edit', accessKeyID: accessKey.id }) @@ -342,10 +321,6 @@ function setEditOperation(operation: PendingAccessKeyEditOperation | null): void editOperation.value = operation } -function setRotateOperation(operation: PendingAccessKeyRotateOperation | null): void { - rotateOperation.value = operation -} - function checkCreateOperation(): void { if (!createOperation.value) return selected.value = null @@ -362,15 +337,6 @@ function checkEditOperation(): void { void setDrawerRoute({ mode: 'edit', accessKeyID: operation.base.id }) } -function checkRotateOperation(): void { - const operation = rotateOperation.value - if (!operation) return - selected.value = - data.value?.items.find((accessKey) => accessKey.id === operation.base.id) ?? operation.base - restoreFocus = null - void setDrawerRoute({ mode: 'edit', accessKeyID: operation.base.id }) -} - async function setDrawerOpen(open: boolean): Promise { if (open) return collection.value?.conceal() @@ -418,10 +384,6 @@ async function handleCostLimitsReset(name: string): Promise { if (mounted) toast.show({ message: t('accessKeys.toast.reset', { name }) }) } -function handleRotated(name: string): void { - toast.show({ message: t('accessKeys.toast.rotated', { name }) }) -} - function setStatusPending(id: number, pending: boolean): void { const next = new Set(pendingStatusIDs.value) if (pending) next.add(id) @@ -516,13 +478,10 @@ async function toggleStatus(accessKey: AccessKeyDto, enabled: boolean): Promise< :group-catalog-state="groupCatalogState" :create-operation="createOperation" :edit-operation="selected?.id === editOperation?.base.id ? editOperation : null" - :rotate-operation="selected?.id === rotateOperation?.base.id ? rotateOperation : null" @update:create-operation="setCreateOperation" @update:edit-operation="setEditOperation" - @update:rotate-operation="setRotateOperation" @update:open="setDrawerOpen" @saved="handleSaved" - @rotated="handleRotated" @deleted="handleDeleted" /> @@ -542,15 +501,6 @@ async function toggleStatus(accessKey: AccessKeyDto, enabled: boolean): Promise< -
- {{ - t(rotateOperationNoticeKey, { name: rotateOperation.base.name }) - }} - - {{ t('accessKeys.operation.checkResult') }} - -
-

{{ deletionAnnouncement }}

Date: Wed, 9 Sep 2026 21:27:42 +0800 Subject: [PATCH 4/6] =?UTF-8?q?style(access-keys):=20=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E9=AA=B0=E5=AD=90=E5=9B=BE=E6=A0=87=E7=94=9F=E6=88=90=E5=AF=86?= =?UTF-8?q?=E9=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../access-keys/AccessKeyCredentialField.vue | 113 +++++++++++++----- 1 file changed, 80 insertions(+), 33 deletions(-) diff --git a/web/src/features/access-keys/AccessKeyCredentialField.vue b/web/src/features/access-keys/AccessKeyCredentialField.vue index 96bddddb9..4512998ca 100644 --- a/web/src/features/access-keys/AccessKeyCredentialField.vue +++ b/web/src/features/access-keys/AccessKeyCredentialField.vue @@ -1,10 +1,10 @@