From ddf063352a300a3be462cd2b92fe0a28207c2042 Mon Sep 17 00:00:00 2001 From: DaydreamCoding Date: Mon, 1 Jun 2026 16:10:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=E9=94=99=E8=AF=AF=E6=97=A5?= =?UTF-8?q?=E5=BF=97=20key=20=E5=BD=92=E5=9B=A0=E4=B8=8E=E6=97=A9=E9=80=80?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 /admin/ops 错误详情正确归因 API key 并补全早退场景字段,合并三项改动: - 鉴权早退补全用户/分组/平台字段:引入 ops fallback key(ContextKeyOpsFallbackAPIKey), apiKey 一加载成功即写入,覆盖分组停用/删除、Key 停用/过期/额度、用户停用、IP 限制等早退 路径;ops 错误日志改用 getOpsAPIKey(正式 key 优先、回退键兜底),不改「已鉴权」语义。 - 已删除 key 归因(迁移 145):删除 key 时同一事务写 deleted_api_key_audits 映射,认证失败 时用明文反查命中原所有者,错误详情展示「已删除 Key 所有者」「尝试的 Key 前缀」。 - 有效 key 报错快照前缀(迁移 147):对绑定有效 key 的错误,落库时快照明文前 8 位到 api_key_prefix(与 attempted_key_prefix 互斥),key 之后被删仍保留报错当时真实前缀。 均仅对上线后新产生的错误/删除生效。 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/internal/handler/ops_error_logger.go | 80 ++++++++++- .../ops_error_logger_attribution_test.go | 118 +++++++++++++++ .../internal/handler/ops_error_logger_test.go | 42 ++++++ backend/internal/repository/api_key_repo.go | 61 ++++++++ .../api_key_repo_integration_test.go | 43 ++++++ backend/internal/repository/ops_repo.go | 51 ++++++- ...po_get_error_log_by_id_integration_test.go | 94 ++++++++++++ ...okup_deleted_key_audit_integration_test.go | 36 +++++ backend/internal/server/api_contract_test.go | 4 + .../server/middleware/api_key_auth.go | 24 ++++ .../server/middleware/api_key_auth_google.go | 4 + .../middleware/api_key_auth_google_test.go | 3 + .../server/middleware/api_key_auth_test.go | 136 ++++++++++++++++++ .../internal/server/middleware/middleware.go | 5 + .../service/admin_service_apikey_test.go | 3 + backend/internal/service/api_key_service.go | 13 +- .../service/api_key_service_cache_test.go | 4 + .../service/api_key_service_delete_test.go | 16 ++- .../service/api_key_service_quota_test.go | 3 + backend/internal/service/ops_models.go | 9 ++ backend/internal/service/ops_port.go | 17 +++ .../internal/service/ops_repo_mock_test.go | 8 ++ backend/internal/service/ops_service.go | 8 ++ .../migrations/145_deleted_api_key_audit.sql | 22 +++ .../147_ops_error_log_api_key_prefix.sql | 12 ++ frontend/src/api/admin/ops.ts | 9 ++ frontend/src/i18n/locales/en.ts | 6 +- frontend/src/i18n/locales/zh.ts | 6 +- .../ops/components/OpsErrorDetailModal.vue | 25 ++++ 29 files changed, 845 insertions(+), 17 deletions(-) create mode 100644 backend/internal/handler/ops_error_logger_attribution_test.go create mode 100644 backend/internal/repository/ops_repo_get_error_log_by_id_integration_test.go create mode 100644 backend/internal/repository/ops_repo_lookup_deleted_key_audit_integration_test.go create mode 100644 backend/migrations/145_deleted_api_key_audit.sql create mode 100644 backend/migrations/147_ops_error_log_api_key_prefix.sql diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 168fc271d0..b86c7f6991 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -71,6 +71,49 @@ const ( opsErrorLogBatchSize = 32 ) +// looksLikeSystemKey 粗筛"形似本系统 key"的输入:长度 16-128 且仅含 [a-zA-Z0-9_-]。 +// 不用前缀匹配(APIKeyPrefix 可配置)。用于反查审计表前挡掉随机扫描的乱码输入。 +func looksLikeSystemKey(key string) bool { + if len(key) < 16 || len(key) > 128 { + return false + } + for _, c := range key { + allowed := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '-' + if !allowed { + return false + } + } + return true +} + +// keyPrefix 返回脱敏前缀(前 n 个字符);不足 n 则原样返回。 +func keyPrefix(key string, n int) string { + if len(key) <= n { + return key + } + return key[:n] +} + +// extractAttemptedKey 按认证中间件同样的顺序从请求头提取提交的 key 明文。 +// 与 api_key_auth.go:43-59 一致:Authorization 仅取 Bearer scheme,非 Bearer 则忽略并继续 x-api-key → x-goog-api-key。 +func extractAttemptedKey(c *gin.Context) string { + if h := c.GetHeader("Authorization"); h != "" { + parts := strings.SplitN(h, " ", 2) + if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { + return strings.TrimSpace(parts[1]) + } + // 非 Bearer:与中间件一致,忽略 Authorization,继续尝试其它 header(不在此 return)。 + } + if k := c.GetHeader("x-api-key"); k != "" { + return strings.TrimSpace(k) + } + if k := c.GetHeader("x-goog-api-key"); k != "" { + return strings.TrimSpace(k) + } + return "" +} + type opsErrorLogJob struct { ops *service.OpsService entry *service.OpsInsertErrorLogInput @@ -546,7 +589,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { return } - apiKey, _ := middleware2.GetAPIKeyFromContext(c) + apiKey := getOpsAPIKey(c) clientRequestID, _ := c.Request.Context().Value(ctxkey.ClientRequestID).(string) model, _ := c.Get(opsModelKey) @@ -721,6 +764,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { if apiKey != nil { entry.APIKeyID = &apiKey.ID + entry.APIKeyPrefix = keyPrefix(apiKey.Key, 8) if apiKey.User != nil { entry.UserID = &apiKey.User.ID } @@ -765,7 +809,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { return } - apiKey, _ := middleware2.GetAPIKeyFromContext(c) + apiKey := getOpsAPIKey(c) clientRequestID, _ := c.Request.Context().Value(ctxkey.ClientRequestID).(string) @@ -911,6 +955,8 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { if apiKey != nil { entry.APIKeyID = &apiKey.ID + // 有效(未删除)key 报错时快照前缀,key 之后被删也保留;与 INVALID_API_KEY 的 attempted_key_prefix 互斥。 + entry.APIKeyPrefix = keyPrefix(apiKey.Key, 8) if apiKey.User != nil { entry.UserID = &apiKey.User.ID } @@ -929,6 +975,22 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { entry.ClientIP = &clientIP } + // 已删除 key 归因:仅 INVALID_API_KEY 才尝试。响应已写出,此处不阻塞客户端。 + if parsed.Code == opsCodeInvalidAPIKey { + if attemptedKey := extractAttemptedKey(c); attemptedKey != "" { + entry.AttemptedKeyPrefix = keyPrefix(attemptedKey, 8) + if looksLikeSystemKey(attemptedKey) { + if res, lookupErr := ops.LookupDeletedKeyAudit(c.Request.Context(), attemptedKey); lookupErr != nil { + log.Printf("[OpsErrorLogger] LookupDeletedKeyAudit failed: %v", lookupErr) + } else if res != nil { + owner := res.UserID + entry.DeletedKeyOwnerUserID = &owner + entry.DeletedKeyName = res.KeyName + } + } + } + } + enqueueOpsErrorLog(ops, entry) } } @@ -1035,6 +1097,20 @@ func parseOpsErrorResponse(body []byte) parsedOpsError { return parsedOpsError{Message: truncateString(string(body), 1024)} } +// getOpsAPIKey 返回用于 Ops 错误日志的 API Key:优先取已鉴权写入的正式 key; +// 鉴权早退(分组停用/删除、Key 停用/过期/额度、用户停用、IP 限制等)时, +// 正式 key 尚未写入,回退到 middleware 写入的 ops fallback key +// (含 User/Group/Platform),从而让日志能展示 用户/分组/平台。 +func getOpsAPIKey(c *gin.Context) *service.APIKey { + if apiKey, ok := middleware2.GetAPIKeyFromContext(c); ok && apiKey != nil { + return apiKey + } + if apiKey, ok := middleware2.GetOpsFallbackAPIKey(c); ok && apiKey != nil { + return apiKey + } + return nil +} + func resolveOpsPlatform(apiKey *service.APIKey, fallback string) string { if apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform != "" { return apiKey.Group.Platform diff --git a/backend/internal/handler/ops_error_logger_attribution_test.go b/backend/internal/handler/ops_error_logger_attribution_test.go new file mode 100644 index 0000000000..9c68d84589 --- /dev/null +++ b/backend/internal/handler/ops_error_logger_attribution_test.go @@ -0,0 +1,118 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestLooksLikeSystemKey(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"sk-abcdef0123456789", true}, + {"ABCdef_-0123456789", true}, + {"short", false}, + {"with space xxxxxxxxxx", false}, + {"汉字key1234567890", false}, + {"", false}, + } + for _, c := range cases { + if got := looksLikeSystemKey(c.in); got != c.want { + t.Errorf("looksLikeSystemKey(%q)=%v want %v", c.in, got, c.want) + } + } + long := make([]byte, 129) + for i := range long { + long[i] = 'a' + } + if looksLikeSystemKey(string(long)) { + t.Errorf("129-char key should be rejected") + } +} + +func TestKeyPrefix(t *testing.T) { + if got := keyPrefix("sk-3f2a9c7e", 8); got != "sk-3f2a9" { + t.Errorf("keyPrefix=%q want %q", got, "sk-3f2a9") + } + if got := keyPrefix("abc", 8); got != "abc" { + t.Errorf("short key should be returned as-is, got %q", got) + } +} + +func TestExtractAttemptedKey(t *testing.T) { + gin.SetMode(gin.TestMode) + + cases := []struct { + name string + headers map[string]string + want string + }{ + { + name: "Bearer in Authorization", + headers: map[string]string{"Authorization": "Bearer sk-testkey0123456789"}, + want: "sk-testkey0123456789", + }, + { + name: "Bearer case-insensitive", + headers: map[string]string{"Authorization": "BEARER sk-testkey0123456789"}, + want: "sk-testkey0123456789", + }, + { + name: "x-api-key header", + headers: map[string]string{"x-api-key": "sk-xapikey0123456789"}, + want: "sk-xapikey0123456789", + }, + { + name: "x-goog-api-key header", + headers: map[string]string{"x-goog-api-key": "sk-goog0123456789"}, + want: "sk-goog0123456789", + }, + { + name: "Authorization takes priority over x-api-key", + headers: map[string]string{"Authorization": "Bearer sk-auth0123456789", "x-api-key": "sk-xapi0123456789"}, + want: "sk-auth0123456789", + }, + { + name: "x-api-key takes priority over x-goog-api-key", + headers: map[string]string{"x-api-key": "sk-xapi0123456789", "x-goog-api-key": "sk-goog0123456789"}, + want: "sk-xapi0123456789", + }, + { + name: "no key headers", + headers: map[string]string{}, + want: "", + }, + { + name: "Bearer with leading/trailing spaces trimmed", + headers: map[string]string{"Authorization": "Bearer sk-trimmed0123456789 "}, + want: "sk-trimmed0123456789", + }, + { + // 非 Bearer Authorization 应被忽略,继续 fall-through 到 x-api-key(与认证中间件一致) + name: "non-Bearer Authorization falls through to x-api-key", + headers: map[string]string{"Authorization": "junk-not-bearer", "x-api-key": "sk-realkey1234567"}, + want: "sk-realkey1234567", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + for k, v := range tc.headers { + req.Header.Set(k, v) + } + c.Request = req + + got := extractAttemptedKey(c) + if got != tc.want { + t.Errorf("extractAttemptedKey(%v) = %q, want %q", tc.headers, got, tc.want) + } + }) + } +} diff --git a/backend/internal/handler/ops_error_logger_test.go b/backend/internal/handler/ops_error_logger_test.go index d4e1177e92..cf1685f2a4 100644 --- a/backend/internal/handler/ops_error_logger_test.go +++ b/backend/internal/handler/ops_error_logger_test.go @@ -931,3 +931,45 @@ func TestSetOpsEndpointContext_NilContext(t *testing.T) { setOpsEndpointContext(nil, "model", int16(1)) }) } + +func TestGetOpsAPIKeyFallsBackToOpsFallbackKey(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + + // 主 key 缺席(鉴权早退场景):返回 nil。 + require.Nil(t, getOpsAPIKey(c)) + + // 写入 ops 专用 fallback key 后应能取到,且带齐 user/group。 + groupID := int64(55) + apiKey := &service.APIKey{ + ID: 100, + GroupID: &groupID, + User: &service.User{ID: 7}, + Group: &service.Group{ID: groupID, Platform: service.PlatformAnthropic}, + } + c.Set(string(middleware2.ContextKeyOpsFallbackAPIKey), apiKey) + + got := getOpsAPIKey(c) + require.NotNil(t, got) + require.Equal(t, int64(100), got.ID) + require.NotNil(t, got.User) + require.Equal(t, int64(7), got.User.ID) + require.NotNil(t, got.Group) + require.Equal(t, service.PlatformAnthropic, got.Group.Platform) +} + +func TestGetOpsAPIKeyPrefersPrimaryContextKey(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + + primary := &service.APIKey{ID: 1} + fallback := &service.APIKey{ID: 2} + c.Set(string(middleware2.ContextKeyAPIKey), primary) + c.Set(string(middleware2.ContextKeyOpsFallbackAPIKey), fallback) + + got := getOpsAPIKey(c) + require.NotNil(t, got) + require.Equal(t, int64(1), got.ID, "已鉴权请求应优先使用正式 api key") +} diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 7db35ecc7f..18f6878b64 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "errors" "fmt" "strings" "time" @@ -304,6 +305,66 @@ func (r *apiKeyRepository) Delete(ctx context.Context, id int64) error { return nil } +// DeleteWithAudit 在同一事务内: +// 1. 把(明文 key、所有者、key 名称)写入 deleted_api_key_audits; +// 2. 软删除该 key(tombstone 覆盖 key 列以释放唯一约束)。 +// +// 保证"被删除的 key 一定能反查到所有者"。事务模式与 group_repo.DeleteCascade 一致。 +func (r *apiKeyRepository) DeleteWithAudit(ctx context.Context, id int64) error { + tombstoneKey := fmt.Sprintf("__deleted__%d__%d", id, time.Now().UnixNano()) + + tx, err := r.client.Tx(ctx) + if err != nil && !errors.Is(err, dbent.ErrTxStarted) { + return err + } + exec := r.client + if err == nil { + defer func() { _ = tx.Rollback() }() + exec = tx.Client() + } + // err == dbent.ErrTxStarted 时复用当前事务(exec = r.client)。 + + // 1. 审计:数据源即 api_keys 当前行;WHERE deleted_at IS NULL 保证只对未删除行写一次。 + if _, err := exec.ExecContext(ctx, ` + INSERT INTO deleted_api_key_audits (key, api_key_id, user_id, key_name, deleted_at) + SELECT key, id, user_id, name, NOW() + FROM api_keys + WHERE id = $1 AND deleted_at IS NULL`, id); err != nil { + return err + } + + // 2. 软删除(tombstone 覆盖 key)。 + res, err := exec.ExecContext(ctx, ` + UPDATE api_keys + SET key = $1, deleted_at = NOW(), updated_at = NOW() + WHERE id = $2 AND deleted_at IS NULL`, tombstoneKey, id) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + // 并发/重复删除:记录已存在(已软删)则幂等返回 nil(defer 回滚空事务),否则 NotFound。 + exists, existErr := r.client.APIKey.Query(). + Where(apikey.IDEQ(id)). + Exist(mixins.SkipSoftDelete(ctx)) + if existErr != nil { + return existErr + } + if exists { + return nil + } + return service.ErrAPIKeyNotFound + } + + if tx != nil { + return tx.Commit() + } + return nil +} + func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters service.APIKeyListFilters) ([]service.APIKey, *pagination.PaginationResult, error) { q := r.activeQuery().Where(apikey.UserIDEQ(userID)) diff --git a/backend/internal/repository/api_key_repo_integration_test.go b/backend/internal/repository/api_key_repo_integration_test.go index e926ed8602..fdf9bc832b 100644 --- a/backend/internal/repository/api_key_repo_integration_test.go +++ b/backend/internal/repository/api_key_repo_integration_test.go @@ -555,3 +555,46 @@ func TestIncrementQuotaUsed_Concurrent(t *testing.T) { require.Equal(t, float64(goroutines)*increment, got.QuotaUsed, "并发递增后总和应为 %v,实际为 %v", float64(goroutines)*increment, got.QuotaUsed) } + +func (s *APIKeyRepoSuite) TestDeleteWithAudit_WritesAuditAndSoftDeletes() { + user := s.mustCreateUser("delwithaudit@test.com") + key := &service.APIKey{ + UserID: user.ID, + Key: "sk-del-audit-1", + Name: "Audit Me", + Status: service.StatusActive, + } + s.Require().NoError(s.repo.Create(s.ctx, key)) + + s.Require().NoError(s.repo.DeleteWithAudit(s.ctx, key.ID)) + + _, err := s.repo.GetByID(s.ctx, key.ID) + s.Require().Error(err) + + rows, qErr := s.client.QueryContext(s.ctx, + `SELECT key, key_name, user_id, api_key_id FROM deleted_api_key_audits WHERE api_key_id = $1`, key.ID) + s.Require().NoError(qErr) + defer rows.Close() + s.Require().True(rows.Next(), "expected one audit row") + var auditKey, auditName string + var auditUserID, auditAPIKeyID int64 + s.Require().NoError(rows.Scan(&auditKey, &auditName, &auditUserID, &auditAPIKeyID)) + s.Require().Equal("sk-del-audit-1", auditKey) + s.Require().Equal("Audit Me", auditName) + s.Require().Equal(user.ID, auditUserID) + s.Require().Equal(key.ID, auditAPIKeyID) +} + +func (s *APIKeyRepoSuite) TestDeleteWithAudit_RepeatIsIdempotent() { + user := s.mustCreateUser("delwithaudit-idem@test.com") + key := &service.APIKey{UserID: user.ID, Key: "sk-del-audit-2", Name: "K", Status: service.StatusActive} + s.Require().NoError(s.repo.Create(s.ctx, key)) + + s.Require().NoError(s.repo.DeleteWithAudit(s.ctx, key.ID)) + s.Require().NoError(s.repo.DeleteWithAudit(s.ctx, key.ID)) +} + +func (s *APIKeyRepoSuite) TestDeleteWithAudit_NotFound() { + err := s.repo.DeleteWithAudit(s.ctx, 999999) + s.Require().ErrorIs(err, service.ErrAPIKeyNotFound) +} diff --git a/backend/internal/repository/ops_repo.go b/backend/internal/repository/ops_repo.go index 4371b8a28f..a77737133b 100644 --- a/backend/internal/repository/ops_repo.go +++ b/backend/internal/repository/ops_repo.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "strings" "time" @@ -54,9 +55,13 @@ INSERT INTO ops_error_logs ( upstream_latency_ms, response_latency_ms, time_to_first_token_ms, - created_at + created_at, + attempted_key_prefix, + deleted_key_owner_user_id, + deleted_key_name, + api_key_prefix ) VALUES ( - $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37 + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,$41 )` func NewOpsRepository(db *sql.DB) service.OpsRepository { @@ -165,6 +170,10 @@ func opsInsertErrorLogArgs(input *service.OpsInsertErrorLogInput) []any { opsNullInt64(input.ResponseLatencyMs), opsNullInt64(input.TimeToFirstTokenMs), input.CreatedAt, + opsNullString(input.AttemptedKeyPrefix), + opsNullInt64(input.DeletedKeyOwnerUserID), + opsNullString(input.DeletedKeyName), + opsNullString(input.APIKeyPrefix), } } @@ -402,11 +411,17 @@ SELECT e.routing_latency_ms, e.upstream_latency_ms, e.response_latency_ms, - e.time_to_first_token_ms + e.time_to_first_token_ms, + COALESCE(e.attempted_key_prefix, ''), + e.deleted_key_owner_user_id, + COALESCE(du.email, ''), + COALESCE(e.deleted_key_name, ''), + COALESCE(e.api_key_prefix, '') FROM ops_error_logs e LEFT JOIN users u ON e.user_id = u.id LEFT JOIN accounts a ON e.account_id = a.id LEFT JOIN groups g ON e.group_id = g.id +LEFT JOIN users du ON e.deleted_key_owner_user_id = du.id WHERE e.id = $1 LIMIT 1` @@ -426,6 +441,7 @@ LIMIT 1` var responseLatency sql.NullInt64 var ttft sql.NullInt64 var requestType sql.NullInt64 + var deletedKeyOwnerUserID sql.NullInt64 err := r.db.QueryRowContext(ctx, q, id).Scan( &out.ID, @@ -471,6 +487,11 @@ LIMIT 1` &upstreamLatency, &responseLatency, &ttft, + &out.AttemptedKeyPrefix, + &deletedKeyOwnerUserID, + &out.DeletedKeyOwnerEmail, + &out.DeletedKeyName, + &out.APIKeyPrefix, ) if err != nil { return nil, err @@ -533,6 +554,10 @@ LIMIT 1` v := int16(requestType.Int64) out.RequestType = &v } + if deletedKeyOwnerUserID.Valid { + v := deletedKeyOwnerUserID.Int64 + out.DeletedKeyOwnerUserID = &v + } // Normalize upstream_errors to empty string when stored as JSON null. out.UpstreamErrors = strings.TrimSpace(out.UpstreamErrors) @@ -543,6 +568,26 @@ LIMIT 1` return &out, nil } +// LookupDeletedKeyAudit 按明文 key 反查最近一条已删除 key 审计。 +// 同一 key 可能有多条历史(反复创建/删除),取 deleted_at 最近一条(id 作同毫秒 tiebreaker)。 +// 未命中返回 (nil, nil)。 +func (r *opsRepository) LookupDeletedKeyAudit(ctx context.Context, key string) (*service.DeletedKeyAuditResult, error) { + var res service.DeletedKeyAuditResult + err := r.db.QueryRowContext(ctx, ` + SELECT user_id, key_name + FROM deleted_api_key_audits + WHERE key = $1 + ORDER BY deleted_at DESC, id DESC + LIMIT 1`, key).Scan(&res.UserID, &res.KeyName) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &res, nil +} + func (r *opsRepository) UpdateErrorResolution(ctx context.Context, errorID int64, resolved bool, resolvedByUserID *int64, resolvedAt *time.Time) error { if r == nil || r.db == nil { return fmt.Errorf("nil ops repository") diff --git a/backend/internal/repository/ops_repo_get_error_log_by_id_integration_test.go b/backend/internal/repository/ops_repo_get_error_log_by_id_integration_test.go new file mode 100644 index 0000000000..470b1c0d0b --- /dev/null +++ b/backend/internal/repository/ops_repo_get_error_log_by_id_integration_test.go @@ -0,0 +1,94 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +// TestGetErrorLogByID_DeletedKeyOwner 验证: +// 1. 带 deleted_key_owner_user_id 的记录能正确 JOIN users 返回 DeletedKeyOwnerEmail +// 2. 新列全为 NULL 的普通记录 Scan 不报错,这些字段为空/nil +func TestGetErrorLogByID_DeletedKeyOwner(t *testing.T) { + ctx := context.Background() + _, _ = integrationDB.ExecContext(ctx, "TRUNCATE ops_error_logs RESTART IDENTITY CASCADE") + + repo := NewOpsRepository(integrationDB).(*opsRepository) + + // ── Case 1: 带 deleted_key_owner 信息的记录 ────────────────────────────── + owner := mustCreateUser(t, integrationEntClient, &service.User{ + Email: "deleted-key-owner-" + time.Now().Format("150405.000000000") + "@example.com", + }) + + var insertedID int64 + err := integrationDB.QueryRowContext(ctx, ` + INSERT INTO ops_error_logs ( + error_phase, error_type, severity, status_code, created_at, + attempted_key_prefix, deleted_key_owner_user_id, deleted_key_name + ) VALUES ( + 'auth', 'INVALID_API_KEY', 'error', 401, NOW(), + 'sk-test-abc', $1, 'my-deleted-key' + ) RETURNING id`, + owner.ID, + ).Scan(&insertedID) + require.NoError(t, err) + require.Positive(t, insertedID) + + detail, err := repo.GetErrorLogByID(ctx, insertedID) + require.NoError(t, err) + require.NotNil(t, detail) + + require.Equal(t, "sk-test-abc", detail.AttemptedKeyPrefix) + require.NotNil(t, detail.DeletedKeyOwnerUserID) + require.Equal(t, owner.ID, *detail.DeletedKeyOwnerUserID) + require.Equal(t, owner.Email, detail.DeletedKeyOwnerEmail) + require.Equal(t, "my-deleted-key", detail.DeletedKeyName) + + // ── Case 2: 新列全为 NULL 的普通错误记录 ────────────────────────────────── + var plainID int64 + err = integrationDB.QueryRowContext(ctx, ` + INSERT INTO ops_error_logs ( + error_phase, error_type, severity, status_code, created_at + ) VALUES ( + 'upstream', 'upstream_error', 'error', 500, NOW() + ) RETURNING id`, + ).Scan(&plainID) + require.NoError(t, err) + require.Positive(t, plainID) + + plain, err := repo.GetErrorLogByID(ctx, plainID) + require.NoError(t, err) + require.NotNil(t, plain) + + require.Empty(t, plain.AttemptedKeyPrefix, "no prefix for plain error") + require.Nil(t, plain.DeletedKeyOwnerUserID, "no owner for plain error") + require.Empty(t, plain.DeletedKeyOwnerEmail, "no owner email for plain error") + require.Empty(t, plain.DeletedKeyName, "no key name for plain error") + require.Empty(t, plain.APIKeyPrefix, "no api key prefix for plain error") + + // ── Case 3: 有效(未删除)key 报错,经 InsertErrorLog 快照 api_key_prefix ────── + // 走真实 InsertErrorLog 写入路径(覆盖新列 + $41 占位符),再 GetErrorLogByID 读回。 + validID, err := repo.InsertErrorLog(ctx, &service.OpsInsertErrorLogInput{ + ErrorPhase: "request", + ErrorType: "api_error", + Severity: "error", + StatusCode: 402, + CreatedAt: time.Now(), + APIKeyPrefix: "sk-valid", + }) + require.NoError(t, err) + require.Positive(t, validID) + + valid, err := repo.GetErrorLogByID(ctx, validID) + require.NoError(t, err) + require.NotNil(t, valid) + + require.Equal(t, "sk-valid", valid.APIKeyPrefix) + require.Empty(t, valid.AttemptedKeyPrefix, "attempted prefix and api key prefix are mutually exclusive") + require.Nil(t, valid.DeletedKeyOwnerUserID, "valid key error has no deleted owner") +} diff --git a/backend/internal/repository/ops_repo_lookup_deleted_key_audit_integration_test.go b/backend/internal/repository/ops_repo_lookup_deleted_key_audit_integration_test.go new file mode 100644 index 0000000000..c77aefb9e8 --- /dev/null +++ b/backend/internal/repository/ops_repo_lookup_deleted_key_audit_integration_test.go @@ -0,0 +1,36 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestOpsRepositoryLookupDeletedKeyAudit(t *testing.T) { + ctx := context.Background() + _, _ = integrationDB.ExecContext(ctx, "TRUNCATE deleted_api_key_audits RESTART IDENTITY") + repo := NewOpsRepository(integrationDB).(*opsRepository) + + // 同一 key 两条审计,取最近一条(deleted_at DESC, id DESC) + _, err := integrationDB.ExecContext(ctx, ` + INSERT INTO deleted_api_key_audits (key, api_key_id, user_id, key_name, deleted_at) + VALUES ('sk-lookup-1', 10, 100, 'old', $1), + ('sk-lookup-1', 11, 200, 'new', $2)`, + time.Now().Add(-time.Hour), time.Now()) + require.NoError(t, err) + + res, err := repo.LookupDeletedKeyAudit(ctx, "sk-lookup-1") + require.NoError(t, err) + require.NotNil(t, res) + require.Equal(t, int64(200), res.UserID) + require.Equal(t, "new", res.KeyName) + + // 未命中返回 nil + miss, err := repo.LookupDeletedKeyAudit(ctx, "sk-never-existed") + require.NoError(t, err) + require.Nil(t, miss) +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 6bb8799549..d4901da18c 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -2102,6 +2102,10 @@ func (r *stubApiKeyRepo) Delete(ctx context.Context, id int64) error { return nil } +func (r *stubApiKeyRepo) DeleteWithAudit(ctx context.Context, id int64) error { + return r.Delete(ctx, id) +} + func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, _ service.APIKeyListFilters) ([]service.APIKey, *pagination.PaginationResult, error) { ids := make([]int64, 0, len(r.byID)) for id := range r.byID { diff --git a/backend/internal/server/middleware/api_key_auth.go b/backend/internal/server/middleware/api_key_auth.go index d33ccbf565..ba43d12647 100644 --- a/backend/internal/server/middleware/api_key_auth.go +++ b/backend/internal/server/middleware/api_key_auth.go @@ -76,6 +76,10 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti return } + // apiKey 已加载(含 User/Group)。即便后续因分组停用/Key 停用/用户停用/ + // IP 限制等早退中断,也让 Ops 错误日志能回退取到 user/group/platform。 + SetOpsFallbackAPIKey(c, apiKey) + // ── 3. 基础鉴权(始终执行) ───────────────────────────────── // disabled / 未知状态 → 无条件拦截(expired 和 quota_exhausted 留给计费阶段) @@ -237,6 +241,26 @@ func GetAPIKeyFromContext(c *gin.Context) (*service.APIKey, bool) { return apiKey, ok } +// SetOpsFallbackAPIKey 记录已加载的 API Key,供 Ops 错误日志在鉴权早退时回退使用。 +// 与 ContextKeyAPIKey 区分:写入它不代表请求已通过鉴权,因此不影响 handler、 +// 审计日志等对“已鉴权”的判断。 +func SetOpsFallbackAPIKey(c *gin.Context, apiKey *service.APIKey) { + if c == nil || apiKey == nil { + return + } + c.Set(string(ContextKeyOpsFallbackAPIKey), apiKey) +} + +// GetOpsFallbackAPIKey 读取 Ops 错误日志专用的回退 API Key。 +func GetOpsFallbackAPIKey(c *gin.Context) (*service.APIKey, bool) { + value, exists := c.Get(string(ContextKeyOpsFallbackAPIKey)) + if !exists { + return nil, false + } + apiKey, ok := value.(*service.APIKey) + return apiKey, ok +} + // GetSubscriptionFromContext 从上下文中获取订阅信息 func GetSubscriptionFromContext(c *gin.Context) (*service.UserSubscription, bool) { value, exists := c.Get(string(ContextKeySubscription)) diff --git a/backend/internal/server/middleware/api_key_auth_google.go b/backend/internal/server/middleware/api_key_auth_google.go index 596bed52e5..97f3936c0c 100644 --- a/backend/internal/server/middleware/api_key_auth_google.go +++ b/backend/internal/server/middleware/api_key_auth_google.go @@ -42,6 +42,10 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs return } + // 同 api_key_auth.go:早退中断前也写入 Ops 回退 key,便于错误日志展示 + // user/group/platform。 + SetOpsFallbackAPIKey(c, apiKey) + if !apiKey.IsActive() { abortWithGoogleError(c, 401, "API key is disabled") return diff --git a/backend/internal/server/middleware/api_key_auth_google_test.go b/backend/internal/server/middleware/api_key_auth_google_test.go index feadd27dee..32e7e70fd0 100644 --- a/backend/internal/server/middleware/api_key_auth_google_test.go +++ b/backend/internal/server/middleware/api_key_auth_google_test.go @@ -56,6 +56,9 @@ func (f fakeAPIKeyRepo) Update(ctx context.Context, key *service.APIKey) error { func (f fakeAPIKeyRepo) Delete(ctx context.Context, id int64) error { return errors.New("not implemented") } +func (f fakeAPIKeyRepo) DeleteWithAudit(ctx context.Context, id int64) error { + return errors.New("not implemented") +} func (f fakeAPIKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, _ service.APIKeyListFilters) ([]service.APIKey, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index 76a24192d7..5d48bed2a9 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -419,6 +419,138 @@ func TestAPIKeyAuthRejectsUnavailableGroup(t *testing.T) { } } +func TestAPIKeyAuthSetsOpsFallbackKeyOnEarlyAbort(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(101) + user := &service.User{ + ID: 7, + Role: service.RoleUser, + Status: service.StatusActive, + Balance: 10, + Concurrency: 3, + } + apiKey := &service.APIKey{ + ID: 100, + UserID: user.ID, + GroupID: &groupID, + Key: "test-key", + Status: service.StatusActive, + User: user, + Group: &service.Group{ + ID: groupID, + Name: "disabled", + Status: service.StatusDisabled, + Platform: service.PlatformAnthropic, + Hydrated: true, + }, + } + apiKeyRepo := &stubApiKeyRepo{ + getByKey: func(ctx context.Context, key string) (*service.APIKey, error) { + if key != apiKey.Key { + return nil, service.ErrAPIKeyNotFound + } + clone := *apiKey + return &clone, nil + }, + } + cfg := &config.Config{RunMode: config.RunModeStandard} + apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg) + + router := gin.New() + var fallback *service.APIKey + var fallbackOK bool + router.Use(func(c *gin.Context) { + c.Next() + fallback, fallbackOK = GetOpsFallbackAPIKey(c) + }) + router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg))) + router.GET("/t", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/t", nil) + req.Header.Set("x-api-key", apiKey.Key) + router.ServeHTTP(w, req) + + // 分组停用 → 早退中断,但 ops fallback key 仍应写入,含 user/group/platform。 + require.Equal(t, http.StatusForbidden, w.Code) + require.Contains(t, w.Body.String(), "GROUP_DISABLED") + require.True(t, fallbackOK, "鉴权早退时也应写入 ops fallback api key") + require.NotNil(t, fallback) + require.Equal(t, apiKey.ID, fallback.ID) + require.NotNil(t, fallback.User) + require.Equal(t, user.ID, fallback.User.ID) + require.NotNil(t, fallback.GroupID) + require.Equal(t, groupID, *fallback.GroupID) + require.NotNil(t, fallback.Group) + require.Equal(t, service.PlatformAnthropic, fallback.Group.Platform) +} + +func TestAPIKeyAuthGoogleSetsOpsFallbackKeyOnEarlyAbort(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(202) + user := &service.User{ + ID: 9, + Role: service.RoleUser, + Status: service.StatusActive, + Balance: 10, + Concurrency: 3, + } + apiKey := &service.APIKey{ + ID: 200, + UserID: user.ID, + GroupID: &groupID, + Key: "g-key", + Status: service.StatusActive, + User: user, + Group: &service.Group{ + ID: groupID, + Name: "disabled", + Status: service.StatusDisabled, + Platform: service.PlatformGemini, + Hydrated: true, + }, + } + apiKeyRepo := &stubApiKeyRepo{ + getByKey: func(ctx context.Context, key string) (*service.APIKey, error) { + if key != apiKey.Key { + return nil, service.ErrAPIKeyNotFound + } + clone := *apiKey + return &clone, nil + }, + } + cfg := &config.Config{RunMode: config.RunModeStandard} + apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg) + + router := gin.New() + var fallback *service.APIKey + var fallbackOK bool + router.Use(func(c *gin.Context) { + c.Next() + fallback, fallbackOK = GetOpsFallbackAPIKey(c) + }) + router.Use(gin.HandlerFunc(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg))) + router.GET("/t", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/t", nil) + req.Header.Set("x-goog-api-key", apiKey.Key) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusForbidden, w.Code) + require.True(t, fallbackOK, "Google 鉴权早退时也应写入 ops fallback api key") + require.NotNil(t, fallback) + require.Equal(t, apiKey.ID, fallback.ID) + require.NotNil(t, fallback.User) + require.Equal(t, user.ID, fallback.User.ID) +} + func TestRequireGroupAssignmentMarksUngroupedKeyBusinessLimited(t *testing.T) { gin.SetMode(gin.TestMode) @@ -761,6 +893,10 @@ func (r *stubApiKeyRepo) Delete(ctx context.Context, id int64) error { return errors.New("not implemented") } +func (r *stubApiKeyRepo) DeleteWithAudit(ctx context.Context, id int64) error { + return errors.New("not implemented") +} + func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, _ service.APIKeyListFilters) ([]service.APIKey, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } diff --git a/backend/internal/server/middleware/middleware.go b/backend/internal/server/middleware/middleware.go index d42eacec27..9efe78a332 100644 --- a/backend/internal/server/middleware/middleware.go +++ b/backend/internal/server/middleware/middleware.go @@ -24,6 +24,11 @@ const ( ContextKeySubscription ContextKey = "subscription" // ContextKeyForcePlatform 强制平台(用于 /antigravity 路由) ContextKeyForcePlatform ContextKey = "force_platform" + // ContextKeyOpsFallbackAPIKey 运维错误日志专用回退键。 + // 鉴权早退(分组停用/删除、Key 停用/过期/额度、用户停用、IP 限制等)时, + // apiKey 已加载但尚未写入 ContextKeyAPIKey;该键让 Ops 错误日志仍能取到 + // user/group/platform。仅供 Ops 错误日志读取,不代表请求已通过鉴权。 + ContextKeyOpsFallbackAPIKey ContextKey = "ops_fallback_api_key" ) // ForcePlatform 返回设置强制平台的中间件 diff --git a/backend/internal/service/admin_service_apikey_test.go b/backend/internal/service/admin_service_apikey_test.go index f26fadb8c8..ccc8d221b9 100644 --- a/backend/internal/service/admin_service_apikey_test.go +++ b/backend/internal/service/admin_service_apikey_test.go @@ -146,6 +146,9 @@ func (s *apiKeyRepoStubForGroupUpdate) GetByKeyForAuth(context.Context, string) panic("unexpected") } func (s *apiKeyRepoStubForGroupUpdate) Delete(context.Context, int64) error { panic("unexpected") } +func (s *apiKeyRepoStubForGroupUpdate) DeleteWithAudit(context.Context, int64) error { + panic("unexpected") +} func (s *apiKeyRepoStubForGroupUpdate) ListByUserID(context.Context, int64, pagination.PaginationParams, APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) { panic("unexpected") } diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index 48e0ab2f3b..dc008b8a73 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -55,6 +55,8 @@ type APIKeyRepository interface { GetByKeyForAuth(ctx context.Context, key string) (*APIKey, error) Update(ctx context.Context, key *APIKey) error Delete(ctx context.Context, id int64) error + // DeleteWithAudit 在同一事务内先写 deleted_api_key_audits 审计、再软删除该 key。 + DeleteWithAudit(ctx context.Context, id int64) error ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) @@ -648,15 +650,16 @@ func (s *APIKeyService) Delete(ctx context.Context, id int64, userID int64) erro return ErrInsufficientPerms } - // 清除Redis缓存(使用 userID 而非 apiKey.UserID) + // 事务内:写审计 + 软删除(tombstone)。 + if err := s.apiKeyRepo.DeleteWithAudit(ctx, id); err != nil { + return fmt.Errorf("delete api key: %w", err) + } + + // 删除成功后再清理缓存,避免"缓存已清但删除失败"的竞态。 if s.cache != nil { _ = s.cache.DeleteCreateAttemptCount(ctx, userID) } s.InvalidateAuthCacheByKey(ctx, key) - - if err := s.apiKeyRepo.Delete(ctx, id); err != nil { - return fmt.Errorf("delete api key: %w", err) - } s.lastUsedTouchL1.Delete(id) return nil diff --git a/backend/internal/service/api_key_service_cache_test.go b/backend/internal/service/api_key_service_cache_test.go index eaac9a1c89..a1dfbcb01f 100644 --- a/backend/internal/service/api_key_service_cache_test.go +++ b/backend/internal/service/api_key_service_cache_test.go @@ -53,6 +53,10 @@ func (s *authRepoStub) Delete(ctx context.Context, id int64) error { panic("unexpected Delete call") } +func (s *authRepoStub) DeleteWithAudit(ctx context.Context, id int64) error { + panic("unexpected DeleteWithAudit call") +} + func (s *authRepoStub) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) { panic("unexpected ListByUserID call") } diff --git a/backend/internal/service/api_key_service_delete_test.go b/backend/internal/service/api_key_service_delete_test.go index 392d52b92c..b8511c35b9 100644 --- a/backend/internal/service/api_key_service_delete_test.go +++ b/backend/internal/service/api_key_service_delete_test.go @@ -79,6 +79,12 @@ func (s *apiKeyRepoStub) Delete(ctx context.Context, id int64) error { return s.deleteErr } +// DeleteWithAudit 与 Delete 一样记录被删除的 ID,供 service 测试断言。 +func (s *apiKeyRepoStub) DeleteWithAudit(ctx context.Context, id int64) error { + s.deletedIDs = append(s.deletedIDs, id) + return s.deleteErr +} + // 以下是接口要求实现但本测试不关心的方法 func (s *apiKeyRepoStub) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) { @@ -274,8 +280,8 @@ func TestApiKeyService_Delete_NotFound(t *testing.T) { // 预期行为: // - GetKeyAndOwnerID 返回正确的所有者 ID // - 所有权验证通过 -// - 缓存被清除(在删除之前) -// - Delete 被调用但返回错误 +// - DeleteWithAudit 被调用但返回错误 +// - 删除失败时缓存不被清除(缓存清理在删除成功后执行,消除竞态) // - 返回包含 "delete api key" 的错误信息 func TestApiKeyService_Delete_DeleteFails(t *testing.T) { repo := &apiKeyRepoStub{ @@ -288,7 +294,7 @@ func TestApiKeyService_Delete_DeleteFails(t *testing.T) { err := svc.Delete(context.Background(), 3, 3) // API Key ID=3, 调用者 userID=3 require.Error(t, err) require.ErrorContains(t, err, "delete api key") - require.Equal(t, []int64{3}, repo.deletedIDs) // 验证删除操作被调用 - require.Equal(t, []int64{3}, cache.invalidated) // 验证缓存已被清除(即使删除失败) - require.Equal(t, []string{svc.authCacheKey("k")}, cache.deleteAuthKeys) + require.Equal(t, []int64{3}, repo.deletedIDs) // 验证 DeleteWithAudit 被调用 + require.Empty(t, cache.invalidated) // 验证删除失败时缓存未被清除(新顺序:先删后清) + require.Empty(t, cache.deleteAuthKeys) // 验证删除失败时 auth 缓存未被清除 } diff --git a/backend/internal/service/api_key_service_quota_test.go b/backend/internal/service/api_key_service_quota_test.go index cf05e16c48..4d1d6f0060 100644 --- a/backend/internal/service/api_key_service_quota_test.go +++ b/backend/internal/service/api_key_service_quota_test.go @@ -101,6 +101,9 @@ func (s *quotaBaseAPIKeyRepoStub) Update(context.Context, *APIKey) error { func (s *quotaBaseAPIKeyRepoStub) Delete(context.Context, int64) error { panic("unexpected Delete call") } +func (s *quotaBaseAPIKeyRepoStub) DeleteWithAudit(context.Context, int64) error { + panic("unexpected DeleteWithAudit call") +} func (s *quotaBaseAPIKeyRepoStub) ListByUserID(context.Context, int64, pagination.PaginationParams, APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) { panic("unexpected ListByUserID call") } diff --git a/backend/internal/service/ops_models.go b/backend/internal/service/ops_models.go index ba735346bc..63c58cad85 100644 --- a/backend/internal/service/ops_models.go +++ b/backend/internal/service/ops_models.go @@ -87,6 +87,15 @@ type OpsErrorLogDetail struct { // vNext metric semantics IsBusinessLimited bool `json:"is_business_limited"` + + // Deleted key owner info (populated when INVALID_API_KEY and key was previously deleted) + AttemptedKeyPrefix string `json:"attempted_key_prefix,omitempty"` + DeletedKeyOwnerUserID *int64 `json:"deleted_key_owner_user_id,omitempty"` + DeletedKeyOwnerEmail string `json:"deleted_key_owner_email,omitempty"` + DeletedKeyName string `json:"deleted_key_name,omitempty"` + + // Bound (non-deleted) key prefix, snapshotted at error time; mutually exclusive with AttemptedKeyPrefix. + APIKeyPrefix string `json:"api_key_prefix,omitempty"` } type OpsErrorLogFilter struct { diff --git a/backend/internal/service/ops_port.go b/backend/internal/service/ops_port.go index 30145ed39d..0cba300d11 100644 --- a/backend/internal/service/ops_port.go +++ b/backend/internal/service/ops_port.go @@ -10,6 +10,8 @@ type OpsRepository interface { BatchInsertErrorLogs(ctx context.Context, inputs []*OpsInsertErrorLogInput) (int64, error) ListErrorLogs(ctx context.Context, filter *OpsErrorLogFilter) (*OpsErrorLogList, error) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLogDetail, error) + // LookupDeletedKeyAudit 按明文 key 反查最近一条已删除 key 审计;未命中返回 (nil, nil)。 + LookupDeletedKeyAudit(ctx context.Context, key string) (*DeletedKeyAuditResult, error) ListRequestDetails(ctx context.Context, filter *OpsRequestDetailFilter) ([]*OpsRequestDetail, int64, error) BatchInsertSystemLogs(ctx context.Context, inputs []*OpsInsertSystemLogInput) (int64, error) ListSystemLogs(ctx context.Context, filter *OpsSystemLogFilter) (*OpsSystemLogList, error) @@ -61,6 +63,12 @@ type OpsRepository interface { GetLatestDailyBucketDate(ctx context.Context) (time.Time, bool, error) } +// DeletedKeyAuditResult 是按明文 key 反查 deleted_api_key_audits 的结果。 +type DeletedKeyAuditResult struct { + UserID int64 + KeyName string +} + type OpsInsertErrorLogInput struct { RequestID string ClientRequestID string @@ -118,6 +126,15 @@ type OpsInsertErrorLogInput struct { TimeToFirstTokenMs *int64 CreatedAt time.Time + + // 已删除 key 归因(仅 INVALID_API_KEY 认证失败时可能非空) + AttemptedKeyPrefix string // 提交 key 的脱敏前缀(前 8 位) + DeletedKeyOwnerUserID *int64 // 反查命中的原所有者 user_id + DeletedKeyName string // 反查命中的 key 名称 + + // 有效(未删除)key 报错时快照的 key 脱敏前缀(前 8 位);与 AttemptedKeyPrefix 互斥。 + // 落库快照而非读时 JOIN:key 之后被删(key 列被 tombstone 覆盖)仍保留当时前缀。 + APIKeyPrefix string } type OpsInsertSystemMetricsInput struct { diff --git a/backend/internal/service/ops_repo_mock_test.go b/backend/internal/service/ops_repo_mock_test.go index 4138ea77a2..5e33bffb1e 100644 --- a/backend/internal/service/ops_repo_mock_test.go +++ b/backend/internal/service/ops_repo_mock_test.go @@ -13,6 +13,7 @@ type opsRepoMock struct { ListSystemLogsFn func(ctx context.Context, filter *OpsSystemLogFilter) (*OpsSystemLogList, error) DeleteSystemLogsFn func(ctx context.Context, filter *OpsSystemLogCleanupFilter) (int64, error) InsertSystemLogCleanupAuditFn func(ctx context.Context, input *OpsSystemLogCleanupAudit) error + LookupDeletedKeyAuditFn func(ctx context.Context, key string) (*DeletedKeyAuditResult, error) } func (m *opsRepoMock) InsertErrorLog(ctx context.Context, input *OpsInsertErrorLogInput) (int64, error) { @@ -189,4 +190,11 @@ func (m *opsRepoMock) GetLatestDailyBucketDate(ctx context.Context) (time.Time, return time.Time{}, false, nil } +func (m *opsRepoMock) LookupDeletedKeyAudit(ctx context.Context, key string) (*DeletedKeyAuditResult, error) { + if m.LookupDeletedKeyAuditFn != nil { + return m.LookupDeletedKeyAuditFn(ctx, key) + } + return nil, nil +} + var _ OpsRepository = (*opsRepoMock)(nil) diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go index 2d7c5bd42d..3d234b28a3 100644 --- a/backend/internal/service/ops_service.go +++ b/backend/internal/service/ops_service.go @@ -355,6 +355,14 @@ func (s *OpsService) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLo return detail, nil } +// LookupDeletedKeyAudit 按明文 key 反查已删除 key 的原所有者;未命中或未启用返回 (nil, nil)。 +func (s *OpsService) LookupDeletedKeyAudit(ctx context.Context, key string) (*DeletedKeyAuditResult, error) { + if s.opsRepo == nil { + return nil, nil + } + return s.opsRepo.LookupDeletedKeyAudit(ctx, key) +} + func (s *OpsService) UpdateErrorResolution(ctx context.Context, errorID int64, resolved bool, resolvedByUserID *int64) error { if err := s.RequireMonitoringEnabled(ctx); err != nil { return err diff --git a/backend/migrations/145_deleted_api_key_audit.sql b/backend/migrations/145_deleted_api_key_audit.sql new file mode 100644 index 0000000000..1364c0942a --- /dev/null +++ b/backend/migrations/145_deleted_api_key_audit.sql @@ -0,0 +1,22 @@ +-- 已删除 API key 审计表:删除 key 时同步留存(明文 key、所有者、key 信息), +-- 供认证失败(INVALID_API_KEY)反查"这个失效 key 曾属于谁"。 +-- 仅对本表上线后删除的 key 生效;此前已删的 key 原值已被 tombstone 覆盖,无法补录。 +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '10min'; + +CREATE TABLE IF NOT EXISTS deleted_api_key_audits ( + id BIGSERIAL PRIMARY KEY, + key VARCHAR(128) NOT NULL, -- 原 key 明文(复用 api_keys 策略),非唯一 + api_key_id BIGINT NOT NULL, -- 原 api_keys.id + user_id BIGINT NOT NULL, -- 原所有者(不加外键,与 ops 表设计哲学一致) + key_name VARCHAR(100) NOT NULL DEFAULT '', -- 原 key 名称,便于展示 + deleted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS deletedapikeyaudit_key ON deleted_api_key_audits (key); +CREATE INDEX IF NOT EXISTS deletedapikeyaudit_user_id ON deleted_api_key_audits (user_id); + +ALTER TABLE ops_error_logs + ADD COLUMN IF NOT EXISTS attempted_key_prefix VARCHAR(32), + ADD COLUMN IF NOT EXISTS deleted_key_owner_user_id BIGINT, + ADD COLUMN IF NOT EXISTS deleted_key_name VARCHAR(100); diff --git a/backend/migrations/147_ops_error_log_api_key_prefix.sql b/backend/migrations/147_ops_error_log_api_key_prefix.sql new file mode 100644 index 0000000000..bfc0748996 --- /dev/null +++ b/backend/migrations/147_ops_error_log_api_key_prefix.sql @@ -0,0 +1,12 @@ +-- 有效(未删除)key 报错时,在 ops 落库层快照该 key 的脱敏前缀(前 8 位), +-- 便于在 /admin/ops 错误详情识别是用户的哪一个 key 出的错。 +-- 与 attempted_key_prefix 互补且互斥: +-- api_key_id 非空(有效 key 报错) => api_key_prefix +-- api_key_id 为空(INVALID_API_KEY 无效) => attempted_key_prefix +-- 落库快照(而非读时 JOIN api_keys):key 之后被删时 api_keys.key 会被 tombstone +-- 覆盖,快照可保留报错当时的真实前缀。 +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '10min'; + +ALTER TABLE ops_error_logs + ADD COLUMN IF NOT EXISTS api_key_prefix VARCHAR(32); diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index 847fc8c92b..557fd00f00 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -941,6 +941,15 @@ export interface OpsErrorDetail extends OpsErrorLog { time_to_first_token_ms?: number | null is_business_limited: boolean + + // Deleted key owner info (INVALID_API_KEY attribution) + attempted_key_prefix?: string | null + deleted_key_owner_user_id?: number | null + deleted_key_owner_email?: string | null + deleted_key_name?: string | null + + // Bound (non-deleted) key prefix, snapshotted at error time + api_key_prefix?: string | null } export type OpsErrorLogsResponse = PaginatedResponse diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index b4e231e73f..1cca8b0502 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4883,7 +4883,11 @@ export default { suggestRequest: 'Client request error: ask customer to fix request parameters', suggestAuth: 'Auth failed: verify API key/credentials', suggestPlatform: 'Platform error: prioritize investigation and fix', - suggestGeneric: 'See details for more context' + suggestGeneric: 'See details for more context', + apiKeyPrefix: 'Key Prefix', + attemptedKeyPrefix: 'Attempted Key Prefix', + deletedKeyOwner: 'Deleted Key Owner', + keyDeletedBadge: 'Key Deleted' }, requestDetails: { title: 'Request Details', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 70a7f6dfcb..3edeb52ba7 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5042,7 +5042,11 @@ export default { suggestRequest: '⚠️ 客户端请求错误,建议:联系客户修正请求参数 / 手动标记已解决', suggestAuth: '⚠️ 认证失败,建议:检查 API Key 是否有效 / 联系客户更新凭证', suggestPlatform: '🚨 平台错误,建议立即排查修复', - suggestGeneric: '查看详情了解更多信息' + suggestGeneric: '查看详情了解更多信息', + apiKeyPrefix: 'Key 前缀', + attemptedKeyPrefix: '尝试的 Key 前缀', + deletedKeyOwner: '已删除 Key 所有者', + keyDeletedBadge: 'Key 已删除' }, requestDetails: { title: '请求明细', diff --git a/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue b/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue index d29607e5b4..c346c547bb 100644 --- a/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue +++ b/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue @@ -106,6 +106,31 @@ {{ detail.message || '—' }} + +
+
{{ t('admin.ops.errorDetail.apiKeyPrefix') }}
+
+ {{ detail.api_key_prefix }} +
+
+ +
+
{{ t('admin.ops.errorDetail.attemptedKeyPrefix') }}
+
+ {{ detail.attempted_key_prefix }} +
+
+ +
+
{{ t('admin.ops.errorDetail.deletedKeyOwner') }}
+
+ {{ detail.deleted_key_owner_email }} + ({{ detail.deleted_key_name }}) + + {{ t('admin.ops.errorDetail.keyDeletedBadge') }} + +
+