From ddf063352a300a3be462cd2b92fe0a28207c2042 Mon Sep 17 00:00:00 2001 From: DaydreamCoding Date: Mon, 1 Jun 2026 16:10:35 +0800 Subject: [PATCH 1/4] =?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 168fc271..b86c7f69 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 00000000..9c68d845 --- /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 d4e1177e..cf1685f2 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 7db35ecc..18f6878b 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 e926ed86..fdf9bc83 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 4371b8a2..a7773713 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 00000000..470b1c0d --- /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 00000000..c77aefb9 --- /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 6bb87995..d4901da1 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 d33ccbf5..ba43d126 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 596bed52..97f3936c 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 feadd27d..32e7e70f 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 76a24192..5d48bed2 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 d42eacec..9efe78a3 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 f26fadb8..ccc8d221 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 48e0ab2f..dc008b8a 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 eaac9a1c..a1dfbcb0 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 392d52b9..b8511c35 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 cf05e16c..4d1d6f00 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 ba735346..63c58cad 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 30145ed3..0cba300d 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 4138ea77..5e33bffb 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 2d7c5bd4..3d234b28 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 00000000..1364c094 --- /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 00000000..bfc07489 --- /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 847fc8c9..557fd00f 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 b4e231e7..1cca8b05 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 70a7f6df..3edeb52b 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 d29607e5..c346c547 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') }} + +
+
From cfb195c7b2c91c6fa7ffb2bbcd8f816e0efcd3c9 Mon Sep 17 00:00:00 2001 From: DaydreamCoding Date: Thu, 4 Jun 2026 19:06:24 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(usage):=20=E8=AE=B0=E5=BD=95=E5=B9=B6?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=A4=B1=E8=B4=A5=E8=AF=B7=E6=B1=82(?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=AB=AF+=E7=AE=A1=E7=90=86=E7=AB=AF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 记录失败请求并在用户端/管理端展示;分类下拉改用统一 Select 组件 - 模型过滤改后端 ILIKE 模糊匹配;新增「Key 名称」列(含已删除标记)与按 Key 过滤;时间列移至末列 - 用户可见「已删除 key 失败请求」:OpsErrorLogFilter 加 MatchDeletedKeyOwner,用户侧归属 放宽为 (user_id OR deleted_key_owner_user_id),让 key 原所有者能看到删除 key 后继续请求 导致的认证失败记录(他人仍 NotFound,不泄露存在性) - 迁移 148:ops_error_logs 用户+时间索引 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/cmd/server/wire_gen.go | 104 ++++---- backend/internal/handler/admin/ops_handler.go | 16 ++ .../internal/handler/admin/setting_handler.go | 13 +- backend/internal/handler/dto/settings.go | 5 + backend/internal/handler/setting_handler.go | 2 + backend/internal/handler/usage_handler.go | 131 ++++++++++- .../handler/usage_handler_daily_test.go | 2 +- .../usage_handler_request_type_test.go | 2 +- .../repository/ops_error_where_test.go | 95 ++++++++ backend/internal/repository/ops_repo.go | 81 ++++++- backend/internal/server/api_contract_test.go | 8 +- backend/internal/server/routes/user.go | 2 + backend/internal/service/domain_constants.go | 4 + backend/internal/service/ops_models.go | 32 ++- backend/internal/service/ops_service.go | 69 ++++++ .../service/ops_service_user_error_test.go | 222 ++++++++++++++++++ backend/internal/service/ops_user_error.go | 123 ++++++++++ .../internal/service/ops_user_error_test.go | 167 +++++++++++++ backend/internal/service/setting_service.go | 22 ++ .../service/setting_service_public_test.go | 13 + ...setting_service_user_error_persist_test.go | 31 +++ .../service/setting_user_error_view_test.go | 9 + backend/internal/service/settings_view.go | 6 + ...dd_ops_error_logs_user_time_index_notx.sql | 6 + frontend/src/api/admin/ops.ts | 2 + frontend/src/api/admin/settings.ts | 5 + frontend/src/api/usage.ts | 26 +- frontend/src/components/common/Select.vue | 30 +++ .../components/user/UserErrorDetailModal.vue | 127 ++++++++++ .../user/UserErrorRequestsTable.vue | 173 ++++++++++++++ frontend/src/i18n/locales/en.ts | 29 ++- frontend/src/i18n/locales/zh.ts | 29 ++- frontend/src/stores/app.ts | 1 + frontend/src/types/index.ts | 31 +++ frontend/src/views/admin/SettingsView.vue | 32 +++ frontend/src/views/admin/UsageView.vue | 93 +++++++- frontend/src/views/user/UsageView.vue | 87 ++++++- 37 files changed, 1746 insertions(+), 84 deletions(-) create mode 100644 backend/internal/repository/ops_error_where_test.go create mode 100644 backend/internal/service/ops_service_user_error_test.go create mode 100644 backend/internal/service/ops_user_error.go create mode 100644 backend/internal/service/ops_user_error_test.go create mode 100644 backend/internal/service/setting_service_user_error_persist_test.go create mode 100644 backend/internal/service/setting_user_error_view_test.go create mode 100644 backend/migrations/148_add_ops_error_logs_user_time_index_notx.sql create mode 100644 frontend/src/components/user/UserErrorDetailModal.vue create mode 100644 frontend/src/components/user/UserErrorRequestsTable.vue diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 10643215..814c07e6 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -91,36 +91,15 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageLogRepository := repository.NewUsageLogRepository(client, db) usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator) - usageHandler := handler.NewUsageHandler(usageService, apiKeyService) - redeemHandler := handler.NewRedeemHandler(redeemService) - subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService) - announcementRepository := repository.NewAnnouncementRepository(client) - announcementReadRepository := repository.NewAnnouncementReadRepository(client) - announcementService := service.NewAnnouncementService(announcementRepository, announcementReadRepository, userRepository, userSubscriptionRepository) - announcementHandler := handler.NewAnnouncementHandler(announcementService) - channelMonitorRepository := repository.NewChannelMonitorRepository(client, db) - channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor) - channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService) - dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db) - dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig) - dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig) - timingWheelService, err := service.ProvideTimingWheelService() - if err != nil { - return nil, err - } - dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, configConfig) - dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService) + opsRepository := repository.NewOpsRepository(db) schedulerCache := repository.ProvideSchedulerCache(redisClient, configConfig) accountRepository := repository.NewAccountRepository(client, db, schedulerCache) - proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) - proxyLatencyCache := repository.NewProxyLatencyCache(redisClient) - privacyClientFactory := providePrivacyClientFactory() + concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig) + concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig) usageBillingRepository := repository.NewUsageBillingRepository(client, db) gatewayCache := repository.NewGatewayCache(redisClient) schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db) schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig) - concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig) - concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig) pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig) pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient) if err != nil { @@ -134,44 +113,72 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { geminiTokenCache := repository.NewGeminiTokenCache(redisClient) compositeTokenCacheInvalidator := service.NewCompositeTokenCacheInvalidator(geminiTokenCache) rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, openAI403CounterCache, settingService, compositeTokenCacheInvalidator) + identityCache := repository.NewIdentityCache(redisClient) + identityService := service.NewIdentityService(identityCache) httpUpstream := repository.NewHTTPUpstream(configConfig) + timingWheelService, err := service.ProvideTimingWheelService() + if err != nil { + return nil, err + } deferredService := service.ProvideDeferredService(accountRepository, timingWheelService) - openAIOAuthClient := repository.NewOpenAIOAuthClient() - openAIOAuthService := service.ProvideOpenAIOAuthService(proxyRepository, openAIOAuthClient, privacyClientFactory) + claudeOAuthClient := repository.NewClaudeOAuthClient() + oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient) oAuthRefreshAPI := service.ProvideOAuthRefreshAPI(accountRepository, geminiTokenCache) - openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI) + claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oAuthRefreshAPI) + sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig) + rpmCache := repository.NewRPMCache(redisClient) + digestSessionStore := service.NewDigestSessionStore() + tlsFingerprintProfileRepository := repository.NewTLSFingerprintProfileRepository(client) + tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient) + tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache) channelRepository := repository.NewChannelRepository(db) channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService) modelPricingResolver := service.NewModelPricingResolver(channelService, billingService) notificationEmailService := service.NewNotificationEmailService(settingRepository, emailService) balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository, notificationEmailService) + gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService, serviceUserPlatformQuotaRepository) + openAIOAuthClient := repository.NewOpenAIOAuthClient() + privacyClientFactory := providePrivacyClientFactory() + openAIOAuthService := service.ProvideOpenAIOAuthService(proxyRepository, openAIOAuthClient, privacyClientFactory) + openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI) openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository) - adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService) - adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache) - sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig) - rpmCache := repository.NewRPMCache(redisClient) - groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) - groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) - claudeOAuthClient := repository.NewClaudeOAuthClient() - oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient) geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig) geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient() driveClient := repository.NewGeminiDriveClient() geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, driveClient, configConfig) - antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository) - claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream) - antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository) - usageCache := service.NewUsageCache() - identityCache := repository.NewIdentityCache(redisClient) - tlsFingerprintProfileRepository := repository.NewTLSFingerprintProfileRepository(client) - tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient) - tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache) - accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService) geminiTokenProvider := service.ProvideGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService, oAuthRefreshAPI) - claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oAuthRefreshAPI) + antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository) antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oAuthRefreshAPI, tempUnschedCache) internal500CounterCache := repository.NewInternal500CounterCache(redisClient) antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache) + geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig) + opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository) + opsService := service.ProvideOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink, settingService) + usageHandler := handler.NewUsageHandler(usageService, apiKeyService, opsService, settingService) + redeemHandler := handler.NewRedeemHandler(redeemService) + subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService) + announcementRepository := repository.NewAnnouncementRepository(client) + announcementReadRepository := repository.NewAnnouncementReadRepository(client) + announcementService := service.NewAnnouncementService(announcementRepository, announcementReadRepository, userRepository, userSubscriptionRepository) + announcementHandler := handler.NewAnnouncementHandler(announcementService) + channelMonitorRepository := repository.NewChannelMonitorRepository(client, db) + channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor) + channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService) + dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db) + dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig) + dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig) + dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, configConfig) + dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService) + proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) + proxyLatencyCache := repository.NewProxyLatencyCache(redisClient) + adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService) + adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache) + groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) + groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) + claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream) + antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository) + usageCache := service.NewUsageCache() + accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService) accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator) @@ -189,13 +196,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { proxyHandler := admin.NewProxyHandler(adminService) adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService) promoHandler := admin.NewPromoHandler(promoService) - opsRepository := repository.NewOpsRepository(db) - identityService := service.NewIdentityService(identityCache) - digestSessionStore := service.NewDigestSessionStore() - gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService, serviceUserPlatformQuotaRepository) - geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig) - opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository) - opsService := service.ProvideOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink, settingService) encryptionKey, err := payment.ProvideEncryptionKey(configConfig) if err != nil { return nil, err diff --git a/backend/internal/handler/admin/ops_handler.go b/backend/internal/handler/admin/ops_handler.go index 418c302f..0ae93e65 100644 --- a/backend/internal/handler/admin/ops_handler.go +++ b/backend/internal/handler/admin/ops_handler.go @@ -137,6 +137,22 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) { filter.AccountID = &id } + if v := strings.TrimSpace(c.Query("user_id")); v != "" { + id, err := strconv.ParseInt(v, 10, 64) + if err != nil || id <= 0 { + response.BadRequest(c, "Invalid user_id") + return + } + filter.UserID = &id + } + if v := strings.TrimSpace(c.Query("api_key_id")); v != "" { + id, err := strconv.ParseInt(v, 10, 64) + if err != nil || id <= 0 { + response.BadRequest(c, "Invalid api_key_id") + return + } + filter.APIKeyID = &id + } if v := strings.TrimSpace(c.Query("resolved")); v != "" { switch strings.ToLower(v) { case "1", "true", "yes": diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index c229d340..0beb15d3 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -297,6 +297,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { AvailableChannelsEnabled: settings.AvailableChannelsEnabled, AffiliateEnabled: settings.AffiliateEnabled, + + AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests, } // OpenAI fast policy (stored under a dedicated setting key) @@ -658,6 +660,8 @@ type UpdateSettingsRequest struct { AuthSourceGitHubPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_github_platform_quotas"` AuthSourceGooglePlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_google_platform_quotas"` AuthSourceDingTalkPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_dingtalk_platform_quotas"` + + AllowUserViewErrorRequests *bool `json:"allow_user_view_error_requests"` } // UpdateSettings 更新系统设置 @@ -1591,6 +1595,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { MaxClaudeCodeVersion: req.MaxClaudeCodeVersion, AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling, BackendModeEnabled: req.BackendModeEnabled, + AllowUserViewErrorRequests: func() bool { + if req.AllowUserViewErrorRequests != nil { + return *req.AllowUserViewErrorRequests + } + return previousSettings.AllowUserViewErrorRequests + }(), OpsMonitoringEnabled: func() bool { if req.OpsMonitoringEnabled != nil { return *req.OpsMonitoringEnabled @@ -2080,7 +2090,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { AffiliateEnabled: updatedSettings.AffiliateEnabled, - RiskControlEnabled: updatedSettings.RiskControlEnabled, + RiskControlEnabled: updatedSettings.RiskControlEnabled, + AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests, } if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil { slog.Error("openai_fast_policy_settings_get_failed", "error", err) diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 17772a2e..da89ac23 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -252,6 +252,9 @@ type SystemSettings struct { // 系统全局默认平台配额(key = platform,nil/缺省 = 不限制) DefaultPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"default_platform_quotas,omitempty"` + + // 允许终端用户在用量页查看自己的失败请求 + AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` } type DefaultSubscriptionSetting struct { @@ -316,6 +319,8 @@ type PublicSettings struct { AffiliateEnabled bool `json:"affiliate_enabled"` RiskControlEnabled bool `json:"risk_control_enabled"` + + AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` } type LoginAgreementDocument struct { diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 7413b840..7c79a19e 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -98,6 +98,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { AffiliateEnabled: settings.AffiliateEnabled, RiskControlEnabled: settings.RiskControlEnabled, + + AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests, }) } diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index daa5695d..23bb62dd 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -1,6 +1,7 @@ package handler import ( + "net/http" "strconv" "strings" "time" @@ -18,15 +19,24 @@ import ( // UsageHandler handles usage-related requests type UsageHandler struct { - usageService *service.UsageService - apiKeyService *service.APIKeyService + usageService *service.UsageService + apiKeyService *service.APIKeyService + opsService *service.OpsService + settingService *service.SettingService } // NewUsageHandler creates a new UsageHandler -func NewUsageHandler(usageService *service.UsageService, apiKeyService *service.APIKeyService) *UsageHandler { +func NewUsageHandler( + usageService *service.UsageService, + apiKeyService *service.APIKeyService, + opsService *service.OpsService, + settingService *service.SettingService, +) *UsageHandler { return &UsageHandler{ - usageService: usageService, - apiKeyService: apiKeyService, + usageService: usageService, + apiKeyService: apiKeyService, + opsService: opsService, + settingService: settingService, } } @@ -149,6 +159,117 @@ func (h *UsageHandler) List(c *gin.Context) { response.Paginated(c, out, result.Total, page, pageSize) } +// ListErrors handles listing the current user's failed requests (redacted). +// GET /api/v1/usage/errors +func (h *UsageHandler) ListErrors(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + // Visibility switch (fail-closed). Defense-in-depth: frontend also hides the tab. + if h.settingService == nil || !h.settingService.IsUserErrorViewAllowed(c.Request.Context()) { + response.Forbidden(c, "Error requests view is disabled") + return + } + if h.opsService == nil { + response.Error(c, http.StatusServiceUnavailable, "Ops service not available") + return + } + + page, pageSize := response.ParsePagination(c) + if pageSize > 100 { + pageSize = 100 + } + + filter := &service.OpsErrorLogFilter{Page: page, PageSize: pageSize} + + // Date range (half-open [start, end)), reuse usage-list semantics. + userTZ := c.Query("timezone") + if startDateStr := c.Query("start_date"); startDateStr != "" { + t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ) + if err != nil { + response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD") + return + } + filter.StartTime = &t + } + if endDateStr := c.Query("end_date"); endDateStr != "" { + t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ) + if err != nil { + response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") + return + } + t = t.AddDate(0, 0, 1) + filter.EndTime = &t + } + + filter.Model = strings.TrimSpace(c.Query("model")) + + if k := strings.TrimSpace(c.Query("api_key_id")); k != "" { + n, err := strconv.ParseInt(k, 10, 64) + if err != nil || n < 0 { + response.BadRequest(c, "Invalid api_key_id") + return + } + if n > 0 { + filter.APIKeyID = &n + } + } + + if sc := strings.TrimSpace(c.Query("status_code")); sc != "" { + n, err := strconv.Atoi(sc) + if err != nil || n < 0 { + response.BadRequest(c, "Invalid status_code") + return + } + filter.StatusCodes = []int{n} + } + + if cat := strings.TrimSpace(c.Query("category")); cat != "" { + phases, types := service.CategoryToFilter(cat) + filter.ErrorPhasesAny = phases + filter.ErrorTypesAny = types + } + + result, err := h.opsService.ListUserErrorRequests(c.Request.Context(), subject.UserID, filter) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Paginated(c, result.Items, int64(result.Total), result.Page, result.PageSize) +} + +// GetErrorDetail handles fetching one of the current user's failed-request details (redacted). +// GET /api/v1/usage/errors/:id +func (h *UsageHandler) GetErrorDetail(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + if h.settingService == nil || !h.settingService.IsUserErrorViewAllowed(c.Request.Context()) { + response.Forbidden(c, "Error requests view is disabled") + return + } + if h.opsService == nil { + response.Error(c, http.StatusServiceUnavailable, "Ops service not available") + return + } + id, err := strconv.ParseInt(strings.TrimSpace(c.Param("id")), 10, 64) + if err != nil || id <= 0 { + response.BadRequest(c, "Invalid id") + return + } + detail, err := h.opsService.GetUserErrorRequestDetail(c.Request.Context(), subject.UserID, id) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, detail) +} + // GetByID handles getting a single usage record // GET /api/v1/usage/:id func (h *UsageHandler) GetByID(c *gin.Context) { diff --git a/backend/internal/handler/usage_handler_daily_test.go b/backend/internal/handler/usage_handler_daily_test.go index 36311fac..2a9186cf 100644 --- a/backend/internal/handler/usage_handler_daily_test.go +++ b/backend/internal/handler/usage_handler_daily_test.go @@ -64,7 +64,7 @@ func newDailyUsageTestRouter(usageRepo *dailyUsageRepoStub, apiKeyRepo *dailyUsa gin.SetMode(gin.TestMode) usageSvc := service.NewUsageService(usageRepo, nil, nil, nil) apiKeySvc := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, nil) - handler := NewUsageHandler(usageSvc, apiKeySvc) + handler := NewUsageHandler(usageSvc, apiKeySvc, nil, nil) router := gin.New() router.Use(func(c *gin.Context) { c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: userID}) diff --git a/backend/internal/handler/usage_handler_request_type_test.go b/backend/internal/handler/usage_handler_request_type_test.go index b49ed59b..ed08c5a8 100644 --- a/backend/internal/handler/usage_handler_request_type_test.go +++ b/backend/internal/handler/usage_handler_request_type_test.go @@ -34,7 +34,7 @@ func (s *userUsageRepoCapture) ListWithFilters(ctx context.Context, params pagin func newUserUsageRequestTypeTestRouter(repo *userUsageRepoCapture) *gin.Engine { gin.SetMode(gin.TestMode) usageSvc := service.NewUsageService(repo, nil, nil, nil) - handler := NewUsageHandler(usageSvc, nil) + handler := NewUsageHandler(usageSvc, nil, nil, nil) router := gin.New() router.Use(func(c *gin.Context) { c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 42}) diff --git a/backend/internal/repository/ops_error_where_test.go b/backend/internal/repository/ops_error_where_test.go new file mode 100644 index 00000000..9bebb158 --- /dev/null +++ b/backend/internal/repository/ops_error_where_test.go @@ -0,0 +1,95 @@ +package repository + +import ( + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +func TestBuildOpsErrorLogsWhere_UserScopedFilters(t *testing.T) { + uid := int64(42) + kid := int64(7) + filter := &service.OpsErrorLogFilter{ + UserID: &uid, + APIKeyID: &kid, + Model: "claude-sonnet-4-5", + ExcludeCountTokens: true, + ErrorPhasesAny: []string{"auth"}, + ErrorTypesAny: []string{"rate_limit_error"}, + View: "all", + } + where, args := buildOpsErrorLogsWhere(filter) + + for _, want := range []string{ + "e.user_id = $", + "e.api_key_id = $", + "COALESCE(e.requested_model, e.model, '') = $", + "COALESCE(e.is_count_tokens, false) = false", + "e.error_phase = ANY($", + "e.error_type = ANY($", + } { + if !strings.Contains(where, want) { + t.Fatalf("where missing %q\nfull: %s", want, where) + } + } + if len(args) != 5 { + t.Fatalf("expected 5 args, got %d", len(args)) + } +} + +func TestBuildOpsErrorLogsWhere_ModelFuzzy(t *testing.T) { + // 默认(ModelFuzzy=false)保持精确匹配 + exact := &service.OpsErrorLogFilter{Model: "claude"} + whereExact, _ := buildOpsErrorLogsWhere(exact) + if !strings.Contains(whereExact, "COALESCE(e.requested_model, e.model, '') = $") { + t.Fatalf("default should be exact match, got: %s", whereExact) + } + + // ModelFuzzy=true → ILIKE + fuzzy := &service.OpsErrorLogFilter{Model: "claude", ModelFuzzy: true} + whereFuzzy, args := buildOpsErrorLogsWhere(fuzzy) + if !strings.Contains(whereFuzzy, "COALESCE(e.requested_model, e.model, '') ILIKE $") { + t.Fatalf("ModelFuzzy should use ILIKE, got: %s", whereFuzzy) + } + if len(args) != 1 || args[0] != "%claude%" { + t.Fatalf("expected arg \"%%claude%%\", got %v", args) + } + + // 通配符转义:输入含 % 应被转义为字面量 + esc := &service.OpsErrorLogFilter{Model: "50%off", ModelFuzzy: true} + _, escArgs := buildOpsErrorLogsWhere(esc) + if len(escArgs) != 1 || escArgs[0] != `%50\%off%` { + t.Fatalf("expected escaped arg, got %v", escArgs) + } + + esc2 := &service.OpsErrorLogFilter{Model: "gpt_4o", ModelFuzzy: true} + _, escArgs2 := buildOpsErrorLogsWhere(esc2) + if len(escArgs2) != 1 || escArgs2[0] != `%gpt\_4o%` { + t.Fatalf("underscore should be escaped, got %v", escArgs2) + } +} + +func TestBuildOpsErrorLogsWhere_MatchDeletedKeyOwner(t *testing.T) { + uid := int64(42) + + // 开关开启 → 归属放宽为 OR(user_id 或 deleted_key_owner_user_id),且共用同一占位符 + on := &service.OpsErrorLogFilter{UserID: &uid, MatchDeletedKeyOwner: true} + whereOn, argsOn := buildOpsErrorLogsWhere(on) + if !strings.Contains(whereOn, "(e.user_id = $1 OR e.deleted_key_owner_user_id = $1)") { + t.Fatalf("MatchDeletedKeyOwner=true should widen to OR, got: %s", whereOn) + } + if len(argsOn) != 1 || argsOn[0] != uid { + t.Fatalf("expected single reused arg %d, got %v", uid, argsOn) + } + + // 开关关闭(默认)→ 仅精确 user_id,绝不出现 deleted_key_owner_user_id(admin 回归) + off := &service.OpsErrorLogFilter{UserID: &uid} + whereOff, _ := buildOpsErrorLogsWhere(off) + if !strings.Contains(whereOff, "e.user_id = $1") { + t.Fatalf("default should match user_id exactly, got: %s", whereOff) + } + if strings.Contains(whereOff, "deleted_key_owner_user_id") { + t.Fatalf("default must NOT include deleted_key_owner_user_id, got: %s", whereOff) + } +} diff --git a/backend/internal/repository/ops_repo.go b/backend/internal/repository/ops_repo.go index a7773713..f300a171 100644 --- a/backend/internal/repository/ops_repo.go +++ b/backend/internal/repository/ops_repo.go @@ -240,12 +240,16 @@ SELECT COALESCE(e.upstream_endpoint, ''), COALESCE(e.requested_model, ''), COALESCE(e.upstream_model, ''), - e.request_type + e.request_type, + COALESCE(ak.name, ''), + ak.deleted_at, + COALESCE(e.deleted_key_name, '') FROM ops_error_logs e LEFT JOIN accounts a ON e.account_id = a.id LEFT JOIN groups g ON e.group_id = g.id LEFT JOIN users u ON e.user_id = u.id LEFT JOIN users u2 ON e.resolved_by_user_id = u2.id +LEFT JOIN api_keys ak ON ak.id = e.api_key_id ` + where + ` ORDER BY e.created_at DESC LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) @@ -272,6 +276,9 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) var resolvedBy sql.NullInt64 var resolvedByName string var requestType sql.NullInt64 + var apiKeyName string + var apiKeyDeletedAt sql.NullTime + var deletedKeyName string if err := rows.Scan( &item.ID, &item.CreatedAt, @@ -305,6 +312,9 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) &item.RequestedModel, &item.UpstreamModel, &requestType, + &apiKeyName, + &apiKeyDeletedAt, + &deletedKeyName, ); err != nil { return nil, err } @@ -345,6 +355,15 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) v := int16(requestType.Int64) item.RequestType = &v } + // Key 名称:优先关联到的 ak.name(已软删的 key name 仍保留); + // 关联不到(api_key_id 为空 / 历史硬删)时回退错误记录里快照的 deleted_key_name。 + if apiKeyName != "" { + item.APIKeyName = apiKeyName + } else { + item.APIKeyName = deletedKeyName + } + // 已删除:ak.deleted_at 非空(软删),或仅命中 deleted_key_name 兜底。 + item.APIKeyDeleted = apiKeyDeletedAt.Valid || (apiKeyName == "" && deletedKeyName != "") out = append(out, &item) } if err := rows.Err(); err != nil { @@ -416,12 +435,15 @@ SELECT e.deleted_key_owner_user_id, COALESCE(du.email, ''), COALESCE(e.deleted_key_name, ''), - COALESCE(e.api_key_prefix, '') + COALESCE(e.api_key_prefix, ''), + COALESCE(ak.name, ''), + ak.deleted_at 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 +LEFT JOIN api_keys ak ON ak.id = e.api_key_id WHERE e.id = $1 LIMIT 1` @@ -442,6 +464,8 @@ LIMIT 1` var ttft sql.NullInt64 var requestType sql.NullInt64 var deletedKeyOwnerUserID sql.NullInt64 + var detailAPIKeyName string + var detailAPIKeyDeletedAt sql.NullTime err := r.db.QueryRowContext(ctx, q, id).Scan( &out.ID, @@ -492,6 +516,8 @@ LIMIT 1` &out.DeletedKeyOwnerEmail, &out.DeletedKeyName, &out.APIKeyPrefix, + &detailAPIKeyName, + &detailAPIKeyDeletedAt, ) if err != nil { return nil, err @@ -558,6 +584,14 @@ LIMIT 1` v := deletedKeyOwnerUserID.Int64 out.DeletedKeyOwnerUserID = &v } + // Key 名称:优先关联到的 ak.name;关联不到时回退快照的 deleted_key_name。 + if detailAPIKeyName != "" { + out.APIKeyName = detailAPIKeyName + } else { + out.APIKeyName = out.DeletedKeyName + } + // 已删除:ak.deleted_at 非空(软删),或仅命中 deleted_key_name 兜底。 + out.APIKeyDeleted = detailAPIKeyDeletedAt.Valid || (detailAPIKeyName == "" && out.DeletedKeyName != "") // Normalize upstream_errors to empty string when stored as JSON null. out.UpstreamErrors = strings.TrimSpace(out.UpstreamErrors) @@ -860,6 +894,14 @@ INSERT INTO ops_system_log_cleanup_audits ( return err } +var likePatternReplacer = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + +// escapeLikePattern 转义 LIKE/ILIKE 通配符(\ % _),避免用户输入被当作通配符。 +// Postgres 默认以反斜杠为转义符,无需额外 ESCAPE 子句。 +func escapeLikePattern(s string) string { + return likePatternReplacer.Replace(s) +} + func buildOpsErrorLogsWhere(filter *service.OpsErrorLogFilter) (string, []any) { clauses := make([]string, 0, 12) args := make([]any, 0, 12) @@ -972,6 +1014,41 @@ func buildOpsErrorLogsWhere(filter *service.OpsErrorLogFilter) (string, []any) { clauses = append(clauses, "EXISTS (SELECT 1 FROM users u WHERE u.id = e.user_id AND u.email ILIKE $"+n+")") } + if filter.UserID != nil && *filter.UserID > 0 { + args = append(args, *filter.UserID) + n := itoa(len(args)) + if filter.MatchDeletedKeyOwner { + // 用户侧:把「删 key 后认证失败」(user_id=NULL,靠 deleted_key_owner 归因)的记录也纳入。 + clauses = append(clauses, "(e.user_id = $"+n+" OR e.deleted_key_owner_user_id = $"+n+")") + } else { + clauses = append(clauses, "e.user_id = $"+n) + } + } + if filter.APIKeyID != nil && *filter.APIKeyID > 0 { + args = append(args, *filter.APIKeyID) + clauses = append(clauses, "e.api_key_id = $"+itoa(len(args))) + } + if m := strings.TrimSpace(filter.Model); m != "" { + if filter.ModelFuzzy { + args = append(args, "%"+escapeLikePattern(m)+"%") + clauses = append(clauses, "COALESCE(e.requested_model, e.model, '') ILIKE $"+itoa(len(args))) + } else { + args = append(args, m) + clauses = append(clauses, "COALESCE(e.requested_model, e.model, '') = $"+itoa(len(args))) + } + } + if filter.ExcludeCountTokens { + clauses = append(clauses, "COALESCE(e.is_count_tokens, false) = false") + } + if len(filter.ErrorPhasesAny) > 0 { + args = append(args, pq.Array(filter.ErrorPhasesAny)) + clauses = append(clauses, "e.error_phase = ANY($"+itoa(len(args))+")") + } + if len(filter.ErrorTypesAny) > 0 { + args = append(args, pq.Array(filter.ErrorTypesAny)) + clauses = append(clauses, "e.error_type = ANY($"+itoa(len(args))+")") + } + return "WHERE " + strings.Join(clauses, " AND "), args } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index d4901da1..766225ff 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -896,7 +896,8 @@ func TestAPIContracts(t *testing.T) { "wechat_connect_mobile_app_secret_configured": false, "wechat_connect_redirect_url": "", "wechat_connect_frontend_redirect_url": "/auth/wechat/callback", - "wechat_connect_scopes": "snsapi_login" + "wechat_connect_scopes": "snsapi_login", + "allow_user_view_error_requests": false } }`, }, @@ -1167,7 +1168,8 @@ func TestAPIContracts(t *testing.T) { "auth_source_default_dingtalk_subscriptions": [], "auth_source_default_dingtalk_grant_on_signup": false, "auth_source_default_dingtalk_grant_on_first_bind": false, - "force_email_on_third_party_signup": false + "force_email_on_third_party_signup": false, + "allow_user_view_error_requests": false } }`, }, @@ -1279,7 +1281,7 @@ func newContractDeps(t *testing.T) *contractDeps { adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil, nil) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) - usageHandler := handler.NewUsageHandler(usageService, apiKeyService) + usageHandler := handler.NewUsageHandler(usageService, apiKeyService, nil, nil) adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil, nil, nil, nil) adminAccountHandler := adminhandler.NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index 07ae33de..0f3758f7 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -82,6 +82,8 @@ func RegisterUserRoutes( usage := authenticated.Group("/usage") { usage.GET("", h.Usage.List) + usage.GET("/errors", h.Usage.ListErrors) + usage.GET("/errors/:id", h.Usage.GetErrorDetail) usage.GET("/:id", h.Usage.GetByID) usage.GET("/stats", h.Usage.Stats) // User dashboard endpoints diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index b6441238..11245d00 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -463,3 +463,7 @@ func SettingKeyAuthSourcePlatformQuotas(source string) string { // AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys). const AdminAPIKeyPrefix = "admin-" + +// SettingKeyAllowUserViewErrorRequests controls whether end users can view +// their own failed requests on the usage page. Default false (opt-in). +const SettingKeyAllowUserViewErrorRequests = "allow_user_view_error_requests" diff --git a/backend/internal/service/ops_models.go b/backend/internal/service/ops_models.go index 63c58cad..0bbe4220 100644 --- a/backend/internal/service/ops_models.go +++ b/backend/internal/service/ops_models.go @@ -64,6 +64,10 @@ type OpsErrorLog struct { RequestedModel string `json:"requested_model"` UpstreamModel string `json:"upstream_model"` RequestType *int16 `json:"request_type"` + + // 关联 api_key 名称(LEFT JOIN api_keys 取得;软删只覆盖 key 列,name 保留,故已删 key 仍有原名)。 + APIKeyName string `json:"api_key_name,omitempty"` + APIKeyDeleted bool `json:"api_key_deleted,omitempty"` } type OpsErrorLogDetail struct { @@ -108,7 +112,7 @@ type OpsErrorLogFilter struct { StatusCodes []int StatusCodesOther bool - Phase string + Phase string // Special: Phase=="upstream" bypasses status>=400 clause; do not set together with ErrorPhasesAny. Owner string Source string Resolved *bool @@ -119,6 +123,32 @@ type OpsErrorLogFilter struct { RequestID string ClientRequestID string + // User-scoped filters (used by the user-facing error requests endpoint and + // by admin drill-down from the usage page). + UserID *int64 + APIKeyID *int64 + + // MatchDeletedKeyOwner: 用户侧专用。UserID 设置且为 true 时,归属从 user_id=UserID + // 放宽为 (user_id=UserID OR deleted_key_owner_user_id=UserID),使原所有者能看到 + // 自己「已删除 key 认证失败」的记录。admin 路径不设此开关 → 行为不变。 + MatchDeletedKeyOwner bool + + // Model matches against requested_model first, then model. + Model string + // ModelFuzzy 为 true 时 Model 走 ILIKE 模糊匹配(仅用户端启用);false(默认)保持精确 =,管理端语义不变。 + ModelFuzzy bool + + // ExcludeCountTokens drops count_tokens probe errors (is_count_tokens=true). + ExcludeCountTokens bool + + // ErrorPhasesAny / ErrorTypesAny add plain ANY() filters WITHOUT touching the + // special-cased single `Phase` field (only Phase=="upstream" bypasses the status>=400 clause). + // NOTE: these ANY filters do NOT bypass status>=400; records with error_phase='upstream' + // but status_code<400 (recovered upstream errors) remain excluded. + // Used to map user-facing coarse categories to backend conditions. + ErrorPhasesAny []string + ErrorTypesAny []string + // View controls error categorization for list endpoints. // - errors: show actionable errors (exclude business-limited / 429 / 529) // - excluded: only show excluded errors diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go index 3d234b28..a8c8a4bb 100644 --- a/backend/internal/service/ops_service.go +++ b/backend/internal/service/ops_service.go @@ -338,6 +338,50 @@ func (s *OpsService) GetErrorLogs(ctx context.Context, filter *OpsErrorLogFilter return result, nil } +// ListUserErrorRequests 返回某个用户自己的错误请求(精简脱敏)。 +// 强制:仅当前用户、View=all(含业务限流/余额类)、排除 count_tokens 噪声。 +func (s *OpsService) ListUserErrorRequests(ctx context.Context, userID int64, filter *OpsErrorLogFilter) (*UserErrorRequestList, error) { + if filter == nil { + filter = &OpsErrorLogFilter{} + } + f := *filter // 拷贝快照,避免原地篡改调用方的 filter(slice 字段只读,浅拷贝足够) + filter = &f + uid := userID + filter.UserID = &uid + // 用户侧放宽归属:纳入「删 key 后认证失败」(user_id=NULL,靠 deleted_key_owner 归因)的记录。 + filter.MatchDeletedKeyOwner = true + // APIKeyID 透传:保留 handler 传入的值。安全由 buildOpsErrorLogsWhere 的 + // "user_id = 自己 AND api_key_id = X" 双重约束保证——传入他人 key 只会得到空集,无泄露。 + filter.View = "all" + filter.ExcludeCountTokens = true + filter.ModelFuzzy = true // 用户端模型过滤走 ILIKE 模糊;管理端不设此字段,保持精确 + // 防御:用户端不接受这些 admin-only / 特殊维度 + filter.UserQuery = "" + filter.Owner = "" + filter.Source = "" + // 清空 Phase 是防御:Phase 是单值特殊字段,仅当其 == "upstream" 时 buildOpsErrorLogsWhere 才跳过 status>=400 子句。 + // 用户端一律改走 category→ErrorPhasesAny/ErrorTypesAny(纯 ANY 过滤,不影响 status>=400 子句), + // 因此 recovered upstream(error_phase='upstream' 但 status<400,最终成功返回)记录对用户不可见——符合预期。 + filter.Phase = "" + + list, err := s.opsRepo.ListErrorLogs(ctx, filter) + if err != nil { + return nil, err + } + items := make([]*UserErrorRequest, 0, len(list.Errors)) + for _, e := range list.Errors { + if r := ToUserErrorRequest(e); r != nil { + items = append(items, r) + } + } + return &UserErrorRequestList{ + Items: items, + Total: list.Total, + Page: list.Page, + PageSize: list.PageSize, + }, nil +} + func (s *OpsService) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLogDetail, error) { if err := s.RequireMonitoringEnabled(ctx); err != nil { return nil, err @@ -355,6 +399,31 @@ func (s *OpsService) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLo return detail, nil } +// GetUserErrorRequestDetail 返回某用户自己某条错误请求的脱敏详情(含 error_body)。 +// 安全:强制按用户归属校验;非本人记录一律返回 NotFound(不泄露存在性)。 +func (s *OpsService) GetUserErrorRequestDetail(ctx context.Context, userID, id int64) (*UserErrorRequestDetail, error) { + if s.opsRepo == nil { + return nil, infraerrors.NotFound("OPS_ERROR_NOT_FOUND", "ops error log not found") + } + if id <= 0 { + return nil, infraerrors.BadRequest("OPS_ERROR_INVALID_ID", "invalid error id") + } + detail, err := s.opsRepo.GetErrorLogByID(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, infraerrors.NotFound("OPS_ERROR_NOT_FOUND", "ops error log not found") + } + return nil, infraerrors.InternalServer("OPS_ERROR_LOAD_FAILED", "Failed to load ops error log").WithCause(err) + } + // 归属:直接归属(user_id)或经「已删除 key 归因」(deleted_key_owner_user_id)二者之一即可。 + ownedDirectly := detail.UserID != nil && *detail.UserID == userID + ownedViaDeletedKey := detail.DeletedKeyOwnerUserID != nil && *detail.DeletedKeyOwnerUserID == userID + if !ownedDirectly && !ownedViaDeletedKey { + return nil, infraerrors.NotFound("OPS_ERROR_NOT_FOUND", "ops error log not found") + } + return ToUserErrorRequestDetail(detail), nil +} + // LookupDeletedKeyAudit 按明文 key 反查已删除 key 的原所有者;未命中或未启用返回 (nil, nil)。 func (s *OpsService) LookupDeletedKeyAudit(ctx context.Context, key string) (*DeletedKeyAuditResult, error) { if s.opsRepo == nil { diff --git a/backend/internal/service/ops_service_user_error_test.go b/backend/internal/service/ops_service_user_error_test.go new file mode 100644 index 00000000..9027ff07 --- /dev/null +++ b/backend/internal/service/ops_service_user_error_test.go @@ -0,0 +1,222 @@ +package service + +import ( + "context" + "database/sql" + "testing" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +type stubOpsRepoForUserErr struct { + OpsRepository // 嵌入接口,未实现的方法 panic,仅覆盖 ListErrorLogs + gotFilter *OpsErrorLogFilter + + // GetErrorLogByID 控制字段 + detailToReturn *OpsErrorLogDetail + detailErrToReturn error +} + +func (s *stubOpsRepoForUserErr) ListErrorLogs(ctx context.Context, f *OpsErrorLogFilter) (*OpsErrorLogList, error) { + s.gotFilter = f + return &OpsErrorLogList{ + Errors: []*OpsErrorLog{{ + Phase: "request", Type: "rate_limit_error", + Model: "m", RequestedModel: "rm", StatusCode: 429, + Message: "secret", UserEmail: "a@b.c", + }}, + Total: 1, Page: 1, PageSize: 20, + }, nil +} + +func (s *stubOpsRepoForUserErr) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLogDetail, error) { + if s.detailErrToReturn != nil { + return nil, s.detailErrToReturn + } + return s.detailToReturn, nil +} + +func TestListUserErrorRequests_ForcesScopeAndRedacts(t *testing.T) { + stub := &stubOpsRepoForUserErr{} + svc := &OpsService{opsRepo: stub} + uid := int64(42) + kid := int64(7) + in := &OpsErrorLogFilter{UserID: nil, View: "errors", Phase: "upstream", APIKeyID: &kid} + out, err := svc.ListUserErrorRequests(context.Background(), uid, in) + if err != nil { + t.Fatal(err) + } + // 强制按用户 + if stub.gotFilter.UserID == nil || *stub.gotFilter.UserID != uid { + t.Fatalf("UserID not forced: %+v", stub.gotFilter.UserID) + } + // 强制 View=all(含业务限流/余额) + if stub.gotFilter.View != "all" { + t.Fatalf("View not forced to all: %q", stub.gotFilter.View) + } + // 强制排除 count_tokens + if !stub.gotFilter.ExcludeCountTokens { + t.Fatal("ExcludeCountTokens not forced") + } + // 强制清空 Phase(防止 "upstream" 绕过 status>=400 子句 + 与 ErrorPhasesAny 双重约束) + if stub.gotFilter.Phase != "" { + t.Fatalf("Phase not cleared: %q", stub.gotFilter.Phase) + } + // APIKeyID 透传保留(用户可按自己 key 过滤;越权由 user_id AND api_key_id 双重防护) + if stub.gotFilter.APIKeyID == nil || *stub.gotFilter.APIKeyID != kid { + t.Fatalf("APIKeyID should be preserved, got %v", stub.gotFilter.APIKeyID) + } + // 调用方传入的 filter 不应被原地篡改(验证 shallow copy 隔离生效) + if in.View != "errors" || in.UserID != nil || in.Phase != "upstream" { + t.Fatalf("caller filter was mutated: View=%q UserID=%v Phase=%q", in.View, in.UserID, in.Phase) + } + // 脱敏:返回条目含 message 字段 + if len(out.Items) != 1 || out.Items[0].Category != "rate_limit" || out.Items[0].Model != "rm" { + t.Fatalf("bad item: %+v", out.Items) + } +} + +func TestGetUserErrorRequestDetail_OwnershipEnforced(t *testing.T) { + ownerUID := int64(999) + callerUID := int64(1) + upstreamStatus := 503 + + detail := &OpsErrorLogDetail{ + OpsErrorLog: OpsErrorLog{ + ID: 42, + Phase: "upstream", + Type: "api_error", + Model: "gpt-4", + RequestedModel: "gpt-4-turbo", + InboundEndpoint: "/v1/chat/completions", + StatusCode: 502, + Platform: "openai", + Message: "upstream failed", + UserID: &ownerUID, + }, + ErrorBody: `{"error":"upstream"}`, + UpstreamStatusCode: &upstreamStatus, + } + + stub := &stubOpsRepoForUserErr{detailToReturn: detail} + svc := &OpsService{opsRepo: stub} + + // 越权调用(callerUID=1,但记录属于 ownerUID=999)→ 应返回 NotFound,detail 为 nil + got, err := svc.GetUserErrorRequestDetail(context.Background(), callerUID, 42) + if err == nil { + t.Fatal("expected error for unauthorized access, got nil") + } + if got != nil { + t.Fatalf("expected nil detail for unauthorized access, got %+v", got) + } + // 验证错误为 NotFound(不暴露存在性) + if !infraerrors.IsNotFound(err) { + t.Fatalf("expected NotFound error, got: %v", err) + } + + // 合法调用(callerUID=999 = ownerUID)→ 应返回 non-nil detail + got2, err2 := svc.GetUserErrorRequestDetail(context.Background(), ownerUID, 42) + if err2 != nil { + t.Fatalf("expected no error for legitimate access, got %v", err2) + } + if got2 == nil { + t.Fatal("expected non-nil detail for legitimate access") + } + if got2.ID != 42 { + t.Errorf("want ID=42, got %d", got2.ID) + } + if got2.ErrorBody != `{"error":"upstream"}` { + t.Errorf("want ErrorBody=%q, got %q", `{"error":"upstream"}`, got2.ErrorBody) + } + if got2.UpstreamStatusCode == nil || *got2.UpstreamStatusCode != 503 { + t.Errorf("want UpstreamStatusCode=503, got %v", got2.UpstreamStatusCode) + } + if got2.Message != "upstream failed" { + t.Errorf("want Message=%q, got %q", "upstream failed", got2.Message) + } +} + +func TestGetUserErrorRequestDetail_NotFound(t *testing.T) { + stub := &stubOpsRepoForUserErr{detailErrToReturn: sql.ErrNoRows} + svc := &OpsService{opsRepo: stub} + + got, err := svc.GetUserErrorRequestDetail(context.Background(), 1, 999) + if err == nil { + t.Fatal("expected error for not found, got nil") + } + if got != nil { + t.Fatalf("expected nil detail, got %+v", got) + } +} + +func TestGetUserErrorRequestDetail_InvalidID(t *testing.T) { + stub := &stubOpsRepoForUserErr{} + svc := &OpsService{opsRepo: stub} + + _, err := svc.GetUserErrorRequestDetail(context.Background(), 1, 0) + if err == nil { + t.Fatal("expected error for id=0") + } + _, err = svc.GetUserErrorRequestDetail(context.Background(), 1, -5) + if err == nil { + t.Fatal("expected error for id=-5") + } +} + +func TestListUserErrorRequests_EnablesMatchDeletedKeyOwner(t *testing.T) { + stub := &stubOpsRepoForUserErr{} + svc := &OpsService{opsRepo: stub} + uid := int64(42) + + if _, err := svc.ListUserErrorRequests(context.Background(), uid, &OpsErrorLogFilter{}); err != nil { + t.Fatal(err) + } + if stub.gotFilter == nil || !stub.gotFilter.MatchDeletedKeyOwner { + t.Fatal("ListUserErrorRequests should enable MatchDeletedKeyOwner for the user scope") + } +} + +func TestGetUserErrorRequestDetail_DeletedKeyOwnerAccess(t *testing.T) { + ownerUID := int64(777) + otherUID := int64(2) + + // 情况2:user_id=NULL,靠 deleted_key_owner_user_id 归因到 ownerUID + mk := func() *OpsErrorLogDetail { + return &OpsErrorLogDetail{ + OpsErrorLog: OpsErrorLog{ + ID: 55, + Phase: "auth", + Type: "api_error", + StatusCode: 401, + Message: "Invalid API key", + UserID: nil, + APIKeyName: "my-old-key", + APIKeyDeleted: true, + }, + DeletedKeyOwnerUserID: &ownerUID, + } + } + + // 原所有者(经 deleted_key 归因)→ 放行 + svcOwner := &OpsService{opsRepo: &stubOpsRepoForUserErr{detailToReturn: mk()}} + got, err := svcOwner.GetUserErrorRequestDetail(context.Background(), ownerUID, 55) + if err != nil { + t.Fatalf("owner via deleted_key should be allowed, got err: %v", err) + } + if got == nil || got.ID != 55 { + t.Fatalf("expected detail ID=55, got %+v", got) + } + if !got.KeyDeleted || got.KeyName != "my-old-key" { + t.Fatalf("expected KeyDeleted=true KeyName=my-old-key, got %+v", got) + } + + // 他人 → NotFound,不泄露存在性 + svcOther := &OpsService{opsRepo: &stubOpsRepoForUserErr{detailToReturn: mk()}} + got2, err2 := svcOther.GetUserErrorRequestDetail(context.Background(), otherUID, 55) + if err2 == nil || got2 != nil { + t.Fatalf("non-owner should get (nil, NotFound), got detail=%+v err=%v", got2, err2) + } + if !infraerrors.IsNotFound(err2) { + t.Fatalf("expected NotFound, got %v", err2) + } +} diff --git a/backend/internal/service/ops_user_error.go b/backend/internal/service/ops_user_error.go new file mode 100644 index 00000000..817c139a --- /dev/null +++ b/backend/internal/service/ops_user_error.go @@ -0,0 +1,123 @@ +package service + +import "time" + +// UserErrorRequest 是面向终端用户的"错误请求"精简脱敏视图(白名单)。 +// 严禁包含 client_ip / user_agent / account / api_key_prefix / upstream_endpoint / +// user_email 等敏感或内部字段。注:message(网关标准化错误描述)与 key_name +// (用户自有 API Key 名称,KeysView 中本就可见)经产品决策对该用户开放; +// error_body 仅在详情接口(GetUserErrorRequestDetail)按归属校验后返回。 +type UserErrorRequest struct { + ID int64 `json:"id"` + CreatedAt time.Time `json:"created_at"` + Model string `json:"model"` + InboundEndpoint string `json:"inbound_endpoint"` + StatusCode int `json:"status_code"` + Category string `json:"category"` + Platform string `json:"platform"` + Message string `json:"message"` + KeyName string `json:"key_name"` + KeyDeleted bool `json:"key_deleted"` +} + +// UserErrorRequestList 是用户错误请求分页结果。 +type UserErrorRequestList struct { + Items []*UserErrorRequest `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// MapUserErrorCategory 把后端 error_phase + error_type 映射为用户侧粗分类码。 +// 返回的是稳定的分类 code(前端做 i18n),不是展示文案。 +func MapUserErrorCategory(phase, errType string) string { + switch phase { + case "auth": + return "auth" + case "routing": + return "service_unavailable" + case "upstream", "network": + return "upstream" + case "internal": + return "internal" + case "request": + switch errType { + case "rate_limit_error": + return "rate_limit" + case "billing_error", "subscription_error": + return "quota" + case "invalid_request_error": + return "invalid_request" + } + } + return "other" +} + +// CategoryToFilter 把用户侧分类码反向映射为后端过滤条件(plain ANY)。 +// 未知分类返回两个空切片(即不施加分类过滤)。 +// 注意:"other" 与未知分类都走 default 返回空切片——"other" 无对应的 phase/type 组合,无法精确反查,因此等价于不过滤。 +func CategoryToFilter(category string) (phases []string, errorTypes []string) { + switch category { + case "auth": + return []string{"auth"}, nil + case "service_unavailable": + return []string{"routing"}, nil + case "upstream": + return []string{"upstream", "network"}, nil + case "internal": + return []string{"internal"}, nil + case "rate_limit": + return nil, []string{"rate_limit_error"} + case "quota": + return nil, []string{"billing_error", "subscription_error"} + case "invalid_request": + return nil, []string{"invalid_request_error"} + default: + return nil, nil + } +} + +// ToUserErrorRequest 把内部 OpsErrorLog 裁剪为用户安全视图。 +func ToUserErrorRequest(e *OpsErrorLog) *UserErrorRequest { + if e == nil { + return nil + } + model := e.RequestedModel + if model == "" { + model = e.Model + } + return &UserErrorRequest{ + ID: e.ID, + CreatedAt: e.CreatedAt, + Model: model, + InboundEndpoint: e.InboundEndpoint, + StatusCode: e.StatusCode, + Category: MapUserErrorCategory(e.Phase, e.Type), + Platform: e.Platform, + Message: e.Message, + KeyName: e.APIKeyName, + KeyDeleted: e.APIKeyDeleted, + } +} + +// UserErrorRequestDetail 是错误请求详情的脱敏视图(点击单行查看)。 +// 在 UserErrorRequest 基础上额外暴露 error_body(上游错误响应正文)与 upstream_status_code; +// 仍严禁任何内部/敏感字段。 +type UserErrorRequestDetail struct { + UserErrorRequest + ErrorBody string `json:"error_body"` + UpstreamStatusCode *int `json:"upstream_status_code,omitempty"` +} + +// ToUserErrorRequestDetail 把内部 OpsErrorLogDetail 裁剪为用户安全详情视图。 +func ToUserErrorRequestDetail(e *OpsErrorLogDetail) *UserErrorRequestDetail { + if e == nil { + return nil + } + base := ToUserErrorRequest(&e.OpsErrorLog) + return &UserErrorRequestDetail{ + UserErrorRequest: *base, + ErrorBody: e.ErrorBody, + UpstreamStatusCode: e.UpstreamStatusCode, + } +} diff --git a/backend/internal/service/ops_user_error_test.go b/backend/internal/service/ops_user_error_test.go new file mode 100644 index 00000000..31b0c269 --- /dev/null +++ b/backend/internal/service/ops_user_error_test.go @@ -0,0 +1,167 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestMapUserErrorCategory(t *testing.T) { + cases := []struct { + phase, etype, want string + }{ + {"auth", "authentication_error", "auth"}, + {"request", "rate_limit_error", "rate_limit"}, + {"request", "billing_error", "quota"}, + {"request", "subscription_error", "quota"}, + {"request", "invalid_request_error", "invalid_request"}, + {"routing", "api_error", "service_unavailable"}, + {"upstream", "upstream_error", "upstream"}, + {"network", "api_error", "upstream"}, + {"internal", "api_error", "internal"}, + {"weird", "weird", "other"}, + } + for _, c := range cases { + if got := MapUserErrorCategory(c.phase, c.etype); got != c.want { + t.Errorf("MapUserErrorCategory(%q,%q)=%q want %q", c.phase, c.etype, got, c.want) + } + } +} + +func TestCategoryToFilter(t *testing.T) { + phases, types := CategoryToFilter("rate_limit") + if len(types) != 1 || types[0] != "rate_limit_error" || len(phases) != 0 { + t.Fatalf("rate_limit => phases=%v types=%v", phases, types) + } + phases, types = CategoryToFilter("auth") + if len(phases) != 1 || phases[0] != "auth" || len(types) != 0 { + t.Fatalf("auth => phases=%v types=%v", phases, types) + } + phases, types = CategoryToFilter("service_unavailable") + if len(phases) != 1 || phases[0] != "routing" || len(types) != 0 { + t.Fatalf("service_unavailable => phases=%v types=%v", phases, types) + } + phases, types = CategoryToFilter("upstream") + if len(phases) != 2 || phases[0] != "upstream" || phases[1] != "network" || len(types) != 0 { + t.Fatalf("upstream => phases=%v types=%v", phases, types) + } + phases, types = CategoryToFilter("internal") + if len(phases) != 1 || phases[0] != "internal" || len(types) != 0 { + t.Fatalf("internal => phases=%v types=%v", phases, types) + } + phases, types = CategoryToFilter("quota") + if len(types) != 2 || types[0] != "billing_error" || types[1] != "subscription_error" || len(phases) != 0 { + t.Fatalf("quota => phases=%v types=%v", phases, types) + } + phases, types = CategoryToFilter("invalid_request") + if len(types) != 1 || types[0] != "invalid_request_error" || len(phases) != 0 { + t.Fatalf("invalid_request => phases=%v types=%v", phases, types) + } + phases, types = CategoryToFilter("other") + if len(phases) != 0 || len(types) != 0 { + t.Fatalf("other => phases=%v types=%v", phases, types) + } +} + +func TestToUserErrorRequest_RedactsSensitiveFields(t *testing.T) { + src := &OpsErrorLog{ + ID: 123, + CreatedAt: time.Unix(0, 0).UTC(), + Model: "m", + RequestedModel: "rm", + InboundEndpoint: "/v1/chat/completions", + StatusCode: 429, + Platform: "openai", + Phase: "request", + Type: "rate_limit_error", + Message: "rate limit exceeded", + APIKeyName: "my-key", + APIKeyDeleted: true, + } + out := ToUserErrorRequest(src) + if out.ID != 123 { + t.Errorf("want ID=123, got %d", out.ID) + } + if out.Model != "rm" { + t.Errorf("want requested_model preferred, got %q", out.Model) + } + if out.Category != "rate_limit" { + t.Errorf("category=%q", out.Category) + } + if out.StatusCode != 429 || out.InboundEndpoint != "/v1/chat/completions" || out.Platform != "openai" { + t.Errorf("basic fields wrong: %+v", out) + } + if out.Message != "rate limit exceeded" { + t.Errorf("want message=%q, got %q", "rate limit exceeded", out.Message) + } + if out.KeyName != "my-key" { + t.Errorf("want key_name=my-key, got %q", out.KeyName) + } + if !out.KeyDeleted { + t.Error("want key_deleted=true") + } +} + +func TestToUserErrorRequestDetail_WhitelistAndRedacts(t *testing.T) { + uid := int64(42) + upstreamStatus := 503 + src := &OpsErrorLogDetail{ + OpsErrorLog: OpsErrorLog{ + ID: 999, + CreatedAt: time.Unix(1000, 0).UTC(), + Model: "gpt-4", + RequestedModel: "gpt-4-turbo", + InboundEndpoint: "/v1/chat/completions", + StatusCode: 502, + Platform: "openai", + Phase: "upstream", + Type: "api_error", + Message: "upstream error", + UserID: &uid, + UserEmail: "secret@example.com", + ClientIP: func() *string { s := "1.2.3.4"; return &s }(), + UpstreamEndpoint: "https://api.openai.com/v1/chat/completions", + }, + ErrorBody: `{"error":{"message":"upstream failed","type":"server_error"}}`, + UserAgent: "Mozilla/5.0 secret-agent", + UpstreamStatusCode: &upstreamStatus, + } + + out := ToUserErrorRequestDetail(src) + if out == nil { + t.Fatal("expected non-nil detail") + } + + // 基础字段正确映射 + if out.ID != 999 { + t.Errorf("want ID=999, got %d", out.ID) + } + if out.Message != "upstream error" { + t.Errorf("want message=%q, got %q", "upstream error", out.Message) + } + if out.ErrorBody != src.ErrorBody { + t.Errorf("ErrorBody mismatch") + } + if out.UpstreamStatusCode == nil || *out.UpstreamStatusCode != 503 { + t.Errorf("UpstreamStatusCode mismatch") + } + + // 序列化后不含敏感字段 + b, err := json.Marshal(out) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + raw := string(b) + for _, forbidden := range []string{"user_email", "client_ip", "upstream_endpoint", "user_agent"} { + if strings.Contains(raw, forbidden) { + t.Errorf("sensitive field %q leaked in JSON output: %s", forbidden, raw) + } + } +} + +func TestToUserErrorRequestDetail_Nil(t *testing.T) { + if out := ToUserErrorRequestDetail(nil); out != nil { + t.Errorf("expected nil for nil input, got %+v", out) + } +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 98acdb80..7043736a 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -761,6 +761,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyAvailableChannelsEnabled, SettingKeyAffiliateEnabled, SettingKeyRiskControlEnabled, + SettingKeyAllowUserViewErrorRequests, } settings, err := s.settingRepo.GetMultiple(ctx, keys) @@ -873,6 +874,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true", RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true", + + AllowUserViewErrorRequests: settings[SettingKeyAllowUserViewErrorRequests] == "true", }, nil } @@ -950,6 +953,17 @@ func (s *SettingService) GetAvailableChannelsRuntime(ctx context.Context) Availa } } +// IsUserErrorViewAllowed reads the user-facing error-requests visibility switch +// directly from the settings store. Fail-closed: on error returns false (opt-in default). +func (s *SettingService) IsUserErrorViewAllowed(ctx context.Context) bool { + vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAllowUserViewErrorRequests}) + if err != nil { + slog.Warn("failed to get allow_user_view_error_requests setting, defaulting to false", "error", err) + return false + } + return vals[SettingKeyAllowUserViewErrorRequests] == "true" +} + // GetAntigravityUserAgentVersion 返回 Antigravity 上游请求使用的版本号。 // 后台设置优先;为空、缺失或非法时回退到 ANTIGRAVITY_USER_AGENT_VERSION / 内置默认值。 func (s *SettingService) GetAntigravityUserAgentVersion(ctx context.Context) string { @@ -1175,6 +1189,7 @@ type PublicSettingsInjectionPayload struct { AvailableChannelsEnabled bool `json:"available_channels_enabled"` AffiliateEnabled bool `json:"affiliate_enabled"` RiskControlEnabled bool `json:"risk_control_enabled"` + AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` } // GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection. @@ -1237,6 +1252,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any AvailableChannelsEnabled: settings.AvailableChannelsEnabled, AffiliateEnabled: settings.AffiliateEnabled, RiskControlEnabled: settings.RiskControlEnabled, + AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests, }, nil } @@ -1924,6 +1940,8 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyDefaultPlatformQuotas] = string(blob) } + updates[SettingKeyAllowUserViewErrorRequests] = strconv.FormatBool(settings.AllowUserViewErrorRequests) + return updates, nil } @@ -2816,6 +2834,8 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingPaymentVisibleMethodAlipayEnabled: "false", SettingPaymentVisibleMethodWxpayEnabled: "false", openAIAdvancedSchedulerSettingKey: "false", + + SettingKeyAllowUserViewErrorRequests: "false", } return s.settingRepo.SetMultiple(ctx, defaults) @@ -3373,6 +3393,8 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } } + result.AllowUserViewErrorRequests = settings[SettingKeyAllowUserViewErrorRequests] == "true" // default false + return result } diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 2faa4d82..621620fd 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -91,6 +91,19 @@ func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t require.True(t, settings.ForceEmailOnThirdPartySignup) } +func TestSettingService_GetPublicSettings_ExposesAllowUserViewErrorRequests(t *testing.T) { + repo := &settingPublicRepoStub{ + values: map[string]string{ + SettingKeyAllowUserViewErrorRequests: "true", + }, + } + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetPublicSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.AllowUserViewErrorRequests) +} + func TestSettingService_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) { svc := NewSettingService(&settingPublicRepoStub{ values: map[string]string{ diff --git a/backend/internal/service/setting_service_user_error_persist_test.go b/backend/internal/service/setting_service_user_error_persist_test.go new file mode 100644 index 00000000..1ec76d46 --- /dev/null +++ b/backend/internal/service/setting_service_user_error_persist_test.go @@ -0,0 +1,31 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +// TestAllowUserViewErrorRequests_PersistsToDB 验证 buildSystemSettingsUpdates 会将 +// AllowUserViewErrorRequests 写入 updates map(即最终落库),这是对 bug 的回归测试: +// 该字段曾因漏写而永远无法持久化。 +func TestAllowUserViewErrorRequests_PersistsToDB(t *testing.T) { + // bmUpdateRepoStub 已在 setting_service_backend_mode_test.go 中定义(同 package)。 + // 本测试不触及需要 GetValue 的设置项,getValueFn 设为 nil 即可,无需 stub。 + repo := &bmUpdateRepoStub{} + svc := NewSettingService(repo, &config.Config{}) + + err := svc.UpdateSettings(context.Background(), &SystemSettings{ + AllowUserViewErrorRequests: true, + }) + require.NoError(t, err) + + // 断言 updates 中含有该 key,且值为 "true" + val, ok := repo.updates[SettingKeyAllowUserViewErrorRequests] + require.True(t, ok, "updates map 中应包含 SettingKeyAllowUserViewErrorRequests,但未找到(bug:buildSystemSettingsUpdates 漏写)") + require.Equal(t, "true", val) +} diff --git a/backend/internal/service/setting_user_error_view_test.go b/backend/internal/service/setting_user_error_view_test.go new file mode 100644 index 00000000..264a5a79 --- /dev/null +++ b/backend/internal/service/setting_user_error_view_test.go @@ -0,0 +1,9 @@ +package service + +import "testing" + +func TestSettingKeyAllowUserViewErrorRequests_Constant(t *testing.T) { + if SettingKeyAllowUserViewErrorRequests != "allow_user_view_error_requests" { + t.Fatalf("unexpected key: %s", SettingKeyAllowUserViewErrorRequests) + } +} diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 7b45ef1a..97217202 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -223,6 +223,9 @@ type SystemSettings struct { // 系统全局默认平台配额(key = platform,nil/缺省 = 不限制) DefaultPlatformQuotas map[string]*DefaultPlatformQuotaSetting `json:"default_platform_quotas"` + + // 允许终端用户在用量页查看自己的失败请求 + AllowUserViewErrorRequests bool } type DefaultSubscriptionSetting struct { @@ -293,6 +296,9 @@ type PublicSettings struct { // 风控中心功能开关 RiskControlEnabled bool `json:"risk_control_enabled"` + + // 允许终端用户在用量页查看自己的失败请求 + AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` } type LoginAgreementDocument struct { diff --git a/backend/migrations/148_add_ops_error_logs_user_time_index_notx.sql b/backend/migrations/148_add_ops_error_logs_user_time_index_notx.sql new file mode 100644 index 00000000..54d73ba5 --- /dev/null +++ b/backend/migrations/148_add_ops_error_logs_user_time_index_notx.sql @@ -0,0 +1,6 @@ +-- 148_add_ops_error_logs_user_time_index_notx.sql +-- 用户侧"错误请求"按 user_id 时间倒序分页所需的部分索引。 +-- 非事务迁移(_notx):CREATE INDEX CONCURRENTLY 不可在事务内执行。 +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ops_error_logs_user_time + ON ops_error_logs (user_id, created_at DESC) + WHERE user_id IS NOT NULL; diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index 557fd00f..defdcf2e 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -1084,6 +1084,8 @@ export type OpsErrorListQueryParams = { platform?: string group_id?: number | null account_id?: number | null + user_id?: number + api_key_id?: number phase?: string error_owner?: string diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 6d8e6cee..5be63076 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -612,6 +612,9 @@ export interface SystemSettings { // OpenAI fast/flex policy openai_fast_policy_settings?: OpenAIFastPolicySettings; + + // Allow user view error requests + allow_user_view_error_requests: boolean; } export interface UpdateSettingsRequest { @@ -842,6 +845,8 @@ export interface UpdateSettingsRequest { // OpenAI fast/flex policy openai_fast_policy_settings?: OpenAIFastPolicySettings; + + allow_user_view_error_requests?: boolean; } /** diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index ee08ee9d..f0aec3d6 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -10,7 +10,10 @@ import type { UsageStatsResponse, PaginatedResponse, TrendDataPoint, - ModelStat + ModelStat, + UserErrorRequest, + UserErrorRequestDetail, + UserErrorListParams } from '@/types' // ==================== Dashboard Types ==================== @@ -304,6 +307,22 @@ export async function getDashboardApiKeysUsage( return data } +export async function listMyErrorRequests( + params: UserErrorListParams, + config: { signal?: AbortSignal } = {} +): Promise> { + const { data } = await apiClient.get>('/usage/errors', { + ...config, + params + }) + return data +} + +export async function getMyErrorDetail(id: number): Promise { + const { data } = await apiClient.get(`/usage/errors/${id}`) + return data +} + export const usageAPI = { list, query, @@ -316,7 +335,10 @@ export const usageAPI = { getDashboardTrend, getDashboardModels, getMyApiKeyDailyUsage, - getDashboardApiKeysUsage + getDashboardApiKeysUsage, + // Error requests + listMyErrorRequests, + getMyErrorDetail, } export default usageAPI diff --git a/frontend/src/components/common/Select.vue b/frontend/src/components/common/Select.vue index a8948145..fbb5c545 100644 --- a/frontend/src/components/common/Select.vue +++ b/frontend/src/components/common/Select.vue @@ -22,6 +22,18 @@ {{ selectedLabel }} + + + (), { searchable: 'auto', creatable: false, creatablePrefix: '', + clearable: false, valueKey: 'value', labelKey: 'label' }) @@ -239,6 +253,10 @@ const selectedLabel = computed(() => { return placeholderText.value }) +const hasValue = computed( + () => props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== '' +) + const filteredOptions = computed(() => { let opts = props.options as any[] if (isSearchable.value && searchQuery.value) { @@ -355,6 +373,12 @@ const selectOption = (option: any) => { triggerRef.value?.focus() } +const clearSelection = () => { + if (props.disabled) return + emit('update:modelValue', null) + emit('change', null, null) +} + // Keyboards const onTriggerKeyDown = () => { if (!isOpen.value) { @@ -461,6 +485,12 @@ onUnmounted(() => { .select-icon { @apply flex-shrink-0 text-gray-400 dark:text-dark-400; } + +.select-clear { + @apply flex flex-shrink-0 cursor-pointer items-center justify-center; + @apply rounded text-gray-400 transition-colors; + @apply hover:text-gray-600 dark:hover:text-gray-200; +}