diff --git a/backend/internal/handler/admin/ops_handler.go b/backend/internal/handler/admin/ops_handler.go index b9558b97b1..e820aef0c6 100644 --- a/backend/internal/handler/admin/ops_handler.go +++ b/backend/internal/handler/admin/ops_handler.go @@ -73,6 +73,13 @@ func NewOpsHandler(opsService *service.OpsService) *OpsHandler { } // GetErrorLogs lists ops error logs. +// applyOpsErrorSortParams reads sort_by/sort_order query params into the filter. +// Column whitelist and order normalization live in the repository; unknown +// values degrade to the default (created_at DESC), mirroring the usage list. +func applyOpsErrorSortParams(c *gin.Context, filter *service.OpsErrorLogFilter) { + filter.SetSort(c.Query("sort_by"), c.Query("sort_order")) +} + // GET /api/v1/admin/ops/errors func (h *OpsHandler) GetErrorLogs(c *gin.Context) { if h.opsService == nil { @@ -114,10 +121,17 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) { // buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。 filter.Model = strings.TrimSpace(c.Query("model")) - // Force request errors: client-visible status >= 400. - // buildOpsErrorLogsWhere already applies this for non-upstream phase. - if strings.EqualFold(strings.TrimSpace(filter.Phase), "upstream") { - filter.Phase = "" + // 请求错误语义:client-visible status>=400 守卫恒生效(未设 + // IncludeRecoveredUpstream 时 phase=upstream 不再绕过守卫),故 + // phase=upstream 作为普通过滤条件保留——此前这里清空该值,导致 + // 错误类型下拉选「上游」等于不过滤。 + + // 分类(用户侧粗分类码)→ phase/type ANY 条件,与用户端 /usage/errors 同一映射; + // 未知分类返回空切片 = 不过滤。与 phase 参数可同时设置(AND 语义)。 + if cat := strings.TrimSpace(c.Query("category")); cat != "" { + phases, types := service.CategoryToFilter(cat) + filter.ErrorPhasesAny = phases + filter.ErrorTypesAny = types } if platform := strings.TrimSpace(c.Query("platform")); platform != "" { @@ -187,6 +201,8 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) { filter.StatusCodes = out } + applyOpsErrorSortParams(c, filter) + result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter) if err != nil { response.ErrorFrom(c, err) @@ -234,10 +250,17 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) { // buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。 filter.Model = strings.TrimSpace(c.Query("model")) - // Force request errors: client-visible status >= 400. - // buildOpsErrorLogsWhere already applies this for non-upstream phase. - if strings.EqualFold(strings.TrimSpace(filter.Phase), "upstream") { - filter.Phase = "" + // 请求错误语义:client-visible status>=400 守卫恒生效(未设 + // IncludeRecoveredUpstream 时 phase=upstream 不再绕过守卫),故 + // phase=upstream 作为普通过滤条件保留——此前这里清空该值,导致 + // 错误类型下拉选「上游」等于不过滤。 + + // 分类(用户侧粗分类码)→ phase/type ANY 条件,与用户端 /usage/errors 同一映射; + // 未知分类返回空切片 = 不过滤。与 phase 参数可同时设置(AND 语义)。 + if cat := strings.TrimSpace(c.Query("category")); cat != "" { + phases, types := service.CategoryToFilter(cat) + filter.ErrorPhasesAny = phases + filter.ErrorTypesAny = types } if platform := strings.TrimSpace(c.Query("platform")); platform != "" { @@ -291,6 +314,8 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) { filter.StatusCodes = out } + applyOpsErrorSortParams(c, filter) + result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter) if err != nil { response.ErrorFrom(c, err) @@ -362,6 +387,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) { } filter.View = "all" filter.Phase = "upstream" + // 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。 + filter.IncludeRecoveredUpstream = true filter.Owner = "provider" filter.Source = strings.TrimSpace(c.Query("error_source")) filter.Query = strings.TrimSpace(c.Query("q")) @@ -377,6 +404,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) { filter.ClientRequestID = clientRequestID } + applyOpsErrorSortParams(c, filter) + result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter) if err != nil { response.ErrorFrom(c, err) @@ -442,6 +471,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) { filter.View = parseOpsViewParam(c) filter.Phase = "upstream" + // 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。 + filter.IncludeRecoveredUpstream = true filter.Owner = "provider" filter.Source = strings.TrimSpace(c.Query("error_source")) filter.Query = strings.TrimSpace(c.Query("q")) @@ -497,6 +528,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) { filter.StatusCodes = out } + applyOpsErrorSortParams(c, filter) + result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index 9d0f1d8fac..be6dc917bb 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -322,6 +322,9 @@ func (h *UsageHandler) ListErrors(c *gin.Context) { filter.ErrorTypesAny = types } + // 排序对齐用量明细:列白名单与方向归一在 repo 层,非法值回退 created_at DESC。 + filter.SetSort(c.Query("sort_by"), c.Query("sort_order")) + result, err := h.opsService.ListUserErrorRequests(c.Request.Context(), subject.UserID, filter) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/repository/ops_error_where_test.go b/backend/internal/repository/ops_error_where_test.go index 5b9d7ab1c3..c997865ba4 100644 --- a/backend/internal/repository/ops_error_where_test.go +++ b/backend/internal/repository/ops_error_where_test.go @@ -85,10 +85,21 @@ func TestBuildOpsErrorLogsWhere_CyberPolicyStatusExemption(t *testing.T) { t.Fatalf("default filter must still include the status >= 400 guard for non-cyber rows\nfull: %s", where) } - // phase=upstream skips the status guard entirely — exemption is irrelevant there. + // phase=upstream WITHOUT the recovered-upstream opt-in keeps the status guard: + // request-error list endpoints filter by phase=upstream as a plain condition. whereUpstream, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{Phase: "upstream"}) - if strings.Contains(whereUpstream, "status_code") { - t.Fatalf("upstream phase filter must not add any status_code clause\nfull: %s", whereUpstream) + if !strings.Contains(whereUpstream, "COALESCE(e.status_code, 0) >= 400") { + t.Fatalf("upstream phase without IncludeRecoveredUpstream must keep the status guard\nfull: %s", whereUpstream) + } + if !strings.Contains(whereUpstream, "e.error_phase = $") { + t.Fatalf("upstream phase filter must emit the error_phase condition\nfull: %s", whereUpstream) + } + + // phase=upstream WITH IncludeRecoveredUpstream (ops 上游列表) skips the guard, + // exposing recovered (<400) upstream rows. + whereRecovered, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{Phase: "upstream", IncludeRecoveredUpstream: true}) + if strings.Contains(whereRecovered, "status_code") { + t.Fatalf("upstream phase with IncludeRecoveredUpstream must not add any status_code clause\nfull: %s", whereRecovered) } } diff --git a/backend/internal/repository/ops_repo.go b/backend/internal/repository/ops_repo.go index 9923c08d99..2129a451c4 100644 --- a/backend/internal/repository/ops_repo.go +++ b/backend/internal/repository/ops_repo.go @@ -177,6 +177,37 @@ func opsInsertErrorLogArgs(input *service.OpsInsertErrorLogInput) []any { } } +// opsErrorLogsOrderBy builds the ORDER BY clause from a whitelist, mirroring +// usageLogOrderBy semantics. Unknown SortBy falls back to created_at; e.id is +// always appended as tiebreaker for stable pagination. +func opsErrorLogsOrderBy(filter *service.OpsErrorLogFilter) string { + sortBy := "" + sortOrder := "" + if filter != nil { + sortBy = strings.ToLower(strings.TrimSpace(filter.SortBy)) + sortOrder = strings.ToLower(strings.TrimSpace(filter.SortOrder)) + } + + var column string + switch sortBy { + case "model": + column = "COALESCE(NULLIF(TRIM(e.requested_model), ''), e.model)" + case "status_code": + // 与展示列/过滤保持同义:列表展示 COALESCE(upstream_status_code, status_code, 0), + // status_code 过滤也用同一表达式,故排序必须一致——否则 recovered upstream 行 + //(status_code<400 但展示上游 5xx)排序键与显示值/分页切分不符。 + column = "COALESCE(e.upstream_status_code, e.status_code, 0)" + default: + column = "e.created_at" + } + + dir := "DESC" + if sortOrder == "asc" { + dir = "ASC" + } + return fmt.Sprintf("%s %s, e.id %s", column, dir, dir) +} + func (r *opsRepository) ListErrorLogs(ctx context.Context, filter *service.OpsErrorLogFilter) (*service.OpsErrorLogList, error) { if r == nil || r.db == nil { return nil, fmt.Errorf("nil ops repository") @@ -233,25 +264,29 @@ SELECT COALESCE(a.name, ''), e.group_id, COALESCE(g.name, ''), - CASE WHEN e.client_ip IS NULL THEN NULL ELSE e.client_ip::text END, + CASE WHEN e.client_ip IS NULL THEN NULL ELSE host(e.client_ip) END, COALESCE(e.request_path, ''), e.stream, COALESCE(e.inbound_endpoint, ''), COALESCE(e.upstream_endpoint, ''), COALESCE(e.requested_model, ''), COALESCE(e.upstream_model, ''), + COALESCE(e.user_agent, ''), e.request_type, COALESCE(ak.name, ''), ak.deleted_at, - COALESCE(e.deleted_key_name, '') + COALESCE(e.deleted_key_name, ''), + e.deleted_key_owner_user_id, + COALESCE(du.email, '') 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 users du ON e.deleted_key_owner_user_id = du.id LEFT JOIN api_keys ak ON ak.id = e.api_key_id ` + where + ` -ORDER BY e.created_at DESC +ORDER BY ` + opsErrorLogsOrderBy(filter) + ` LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) rows, err := r.db.QueryContext(ctx, selectSQL, argsWithLimit...) @@ -279,6 +314,8 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) var apiKeyName string var apiKeyDeletedAt sql.NullTime var deletedKeyName string + var deletedKeyOwnerID sql.NullInt64 + var deletedKeyOwnerEmail string if err := rows.Scan( &item.ID, &item.CreatedAt, @@ -311,10 +348,13 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) &item.UpstreamEndpoint, &item.RequestedModel, &item.UpstreamModel, + &item.UserAgent, &requestType, &apiKeyName, &apiKeyDeletedAt, &deletedKeyName, + &deletedKeyOwnerID, + &deletedKeyOwnerEmail, ); err != nil { return nil, err } @@ -364,6 +404,12 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) } // 已删除:ak.deleted_at 非空(软删),或仅命中 deleted_key_name 兜底。 item.APIKeyDeleted = apiKeyDeletedAt.Valid || (apiKeyName == "" && deletedKeyName != "") + // 已删除 KEY 所有者快照:认证失败行 user_id 为空,列表用户列以此回退。 + if deletedKeyOwnerID.Valid { + v := deletedKeyOwnerID.Int64 + item.DeletedKeyOwnerUserID = &v + item.DeletedKeyOwnerEmail = deletedKeyOwnerEmail + } out = append(out, &item) } if err := rows.Err(); err != nil { @@ -417,7 +463,7 @@ SELECT COALESCE(a.name, ''), e.group_id, COALESCE(g.name, ''), - CASE WHEN e.client_ip IS NULL THEN NULL ELSE e.client_ip::text END, + CASE WHEN e.client_ip IS NULL THEN NULL ELSE host(e.client_ip) END, COALESCE(e.request_path, ''), e.stream, COALESCE(e.inbound_endpoint, ''), @@ -927,12 +973,14 @@ func buildOpsErrorLogsWhere(filter *service.OpsErrorLogFilter) (string, []any) { if filter != nil { resolvedFilter = filter.Resolved } - // Keep list endpoints scoped to client errors unless explicitly filtering upstream phase. + // Keep list endpoints scoped to client errors unless the caller explicitly opts + // into recovered upstream rows (Phase=="upstream" + IncludeRecoveredUpstream, + // ops 专用上游列表)。请求错误语义的端点即便过滤 phase=upstream 也保留该守卫。 // cyber_policy is exempt from the status >= 400 guard: streaming cyber hits arrive with // status 200 (the SSE stream opened successfully before upstream returned response.failed), // but they are always client-visible blocked requests that belong in admin + user error // lists. Without the exemption the entire streaming-path cyber sink would be invisible. - if phaseFilter != "upstream" { + if phaseFilter != "upstream" || filter == nil || !filter.IncludeRecoveredUpstream { clauses = append(clauses, "(COALESCE(e.status_code, 0) >= 400 OR e.error_type = 'cyber_policy')") } diff --git a/backend/internal/service/ops_models.go b/backend/internal/service/ops_models.go index 4fc6a9266e..e33dcf82a8 100644 --- a/backend/internal/service/ops_models.go +++ b/backend/internal/service/ops_models.go @@ -1,6 +1,9 @@ package service -import "time" +import ( + "strings" + "time" +) type OpsSystemLog struct { ID int64 `json:"id"` @@ -65,17 +68,22 @@ type OpsErrorLog struct { RequestedModel string `json:"requested_model"` UpstreamModel string `json:"upstream_model"` RequestType *int16 `json:"request_type"` + UserAgent string `json:"user_agent"` // 关联 api_key 名称(LEFT JOIN api_keys 取得;软删只覆盖 key 列,name 保留,故已删 key 仍有原名)。 APIKeyName string `json:"api_key_name,omitempty"` APIKeyDeleted bool `json:"api_key_deleted,omitempty"` + + // 已删除 KEY 所有者(INVALID_API_KEY 且该 key 曾存在时的归因快照)。 + // 认证失败行 user_id 为空,列表用户列以此回退显示所有者。 + DeletedKeyOwnerUserID *int64 `json:"deleted_key_owner_user_id,omitempty"` + DeletedKeyOwnerEmail string `json:"deleted_key_owner_email,omitempty"` } type OpsErrorLogDetail struct { OpsErrorLog ErrorBody string `json:"error_body"` - UserAgent string `json:"user_agent"` // Upstream context (optional) UpstreamStatusCode *int `json:"upstream_status_code,omitempty"` @@ -93,11 +101,10 @@ 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"` + // Deleted key owner info (populated when INVALID_API_KEY and key was previously deleted). + // OwnerUserID/OwnerEmail 已上移到 OpsErrorLog(列表用户列回退需要)。 + AttemptedKeyPrefix string `json:"attempted_key_prefix,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"` @@ -142,8 +149,14 @@ type OpsErrorLogFilter struct { // ExcludeCountTokens drops count_tokens probe errors (is_count_tokens=true). ExcludeCountTokens bool + // IncludeRecoveredUpstream 显式豁免 status>=400 守卫(仅在 Phase=="upstream" 时生效): + // ops 专用上游错误列表需要看到 status<400 的 recovered upstream 行。 + // 请求错误语义的端点不设此开关,phase=upstream 过滤照常生效且守卫保留。 + IncludeRecoveredUpstream bool + // ErrorPhasesAny / ErrorTypesAny add plain ANY() filters WITHOUT touching the - // special-cased single `Phase` field (only Phase=="upstream" bypasses the status>=400 clause). + // special-cased single `Phase` field (only Phase=="upstream" with + // IncludeRecoveredUpstream 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. @@ -158,6 +171,19 @@ type OpsErrorLogFilter struct { Page int PageSize int + + // SortBy/SortOrder: server-side sorting aligned with the usage-log list. + // Repo whitelists columns (created_at/model/status_code); anything else + // falls back to created_at. SortOrder is "asc"/"desc" (default desc). + SortBy string + SortOrder string +} + +// SetSort normalizes raw sort_by/sort_order query values into the filter. +// Shared by the admin and user-facing error list handlers. +func (f *OpsErrorLogFilter) SetSort(sortBy, sortOrder string) { + f.SortBy = strings.TrimSpace(sortBy) + f.SortOrder = strings.TrimSpace(sortOrder) } type OpsErrorLogList struct { diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go index a8c8a4bb5c..61f85ef904 100644 --- a/backend/internal/service/ops_service.go +++ b/backend/internal/service/ops_service.go @@ -359,10 +359,12 @@ func (s *OpsService) ListUserErrorRequests(ctx context.Context, userID int64, fi 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,最终成功返回)记录对用户不可见——符合预期。 + // 清空 Phase 是防御:用户端一律改走 category→ErrorPhasesAny/ErrorTypesAny + //(纯 ANY 过滤,不影响 status>=400 子句)。守卫豁免现在还需要 + // IncludeRecoveredUpstream(用户端永不设置),recovered upstream + //(error_phase='upstream' 但 status<400,最终成功返回)记录对用户不可见——符合预期。 filter.Phase = "" + filter.IncludeRecoveredUpstream = false list, err := s.opsRepo.ListErrorLogs(ctx, filter) if err != nil { diff --git a/backend/internal/service/ops_service_user_error_test.go b/backend/internal/service/ops_service_user_error_test.go index 9027ff0788..c3b0967b67 100644 --- a/backend/internal/service/ops_service_user_error_test.go +++ b/backend/internal/service/ops_service_user_error_test.go @@ -184,16 +184,16 @@ func TestGetUserErrorRequestDetail_DeletedKeyOwnerAccess(t *testing.T) { 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, + ID: 55, + Phase: "auth", + Type: "api_error", + StatusCode: 401, + Message: "Invalid API key", + UserID: nil, + APIKeyName: "my-old-key", + APIKeyDeleted: true, + DeletedKeyOwnerUserID: &ownerUID, }, - DeletedKeyOwnerUserID: &ownerUID, } } diff --git a/backend/internal/service/ops_user_error.go b/backend/internal/service/ops_user_error.go index 7dd128afa7..e3055c2392 100644 --- a/backend/internal/service/ops_user_error.go +++ b/backend/internal/service/ops_user_error.go @@ -3,9 +3,12 @@ package service import "time" // UserErrorRequest 是面向终端用户的"错误请求"精简脱敏视图(白名单)。 -// 严禁包含 client_ip / user_agent / account / api_key_prefix / upstream_endpoint / -// user_email 等敏感或内部字段。注:message(网关标准化错误描述)与 key_name +// 严禁包含 account / api_key_prefix / upstream_endpoint / user_email 等 +// 敏感或内部字段。注:message(网关标准化错误描述)与 key_name // (用户自有 API Key 名称,KeysView 中本就可见)经产品决策对该用户开放; +// client_ip / user_agent / group_name / request_type / stream 均为该用户 +// 自己请求的属性,经产品决策(2026-07-03)开放, +// 与用量明细已向用户展示自身 ip_address/user_agent/分组/类型 的口径对齐; // error_body 仅在详情接口(GetUserErrorRequestDetail)按归属校验后返回。 type UserErrorRequest struct { ID int64 `json:"id"` @@ -18,6 +21,11 @@ type UserErrorRequest struct { Message string `json:"message"` KeyName string `json:"key_name"` KeyDeleted bool `json:"key_deleted"` + ClientIP string `json:"client_ip,omitempty"` + GroupName string `json:"group_name,omitempty"` + RequestType *int16 `json:"request_type,omitempty"` + Stream bool `json:"stream"` + UserAgent string `json:"user_agent,omitempty"` } // UserErrorRequestList 是用户错误请求分页结果。 @@ -90,6 +98,10 @@ func ToUserErrorRequest(e *OpsErrorLog) *UserErrorRequest { if model == "" { model = e.Model } + clientIP := "" + if e.ClientIP != nil { + clientIP = *e.ClientIP + } return &UserErrorRequest{ ID: e.ID, CreatedAt: e.CreatedAt, @@ -101,6 +113,11 @@ func ToUserErrorRequest(e *OpsErrorLog) *UserErrorRequest { Message: e.Message, KeyName: e.APIKeyName, KeyDeleted: e.APIKeyDeleted, + ClientIP: clientIP, + GroupName: e.GroupName, + RequestType: e.RequestType, + Stream: e.Stream, + UserAgent: e.UserAgent, } } diff --git a/backend/internal/service/ops_user_error_test.go b/backend/internal/service/ops_user_error_test.go index 31b0c26933..9e0bc164b4 100644 --- a/backend/internal/service/ops_user_error_test.go +++ b/backend/internal/service/ops_user_error_test.go @@ -122,9 +122,11 @@ func TestToUserErrorRequestDetail_WhitelistAndRedacts(t *testing.T) { UserEmail: "secret@example.com", ClientIP: func() *string { s := "1.2.3.4"; return &s }(), UpstreamEndpoint: "https://api.openai.com/v1/chat/completions", + UserAgent: "codex_cli_rs/0.125.0", + GroupName: "grp-a", + Stream: true, }, ErrorBody: `{"error":{"message":"upstream failed","type":"server_error"}}`, - UserAgent: "Mozilla/5.0 secret-agent", UpstreamStatusCode: &upstreamStatus, } @@ -147,13 +149,27 @@ func TestToUserErrorRequestDetail_WhitelistAndRedacts(t *testing.T) { t.Errorf("UpstreamStatusCode mismatch") } + // client_ip / user_agent / group_name / stream 经产品决策开放(与用量明细口径对齐) + if out.ClientIP != "1.2.3.4" { + t.Errorf("want client_ip=1.2.3.4, got %q", out.ClientIP) + } + if out.UserAgent != "codex_cli_rs/0.125.0" { + t.Errorf("want user_agent=codex_cli_rs/0.125.0, got %q", out.UserAgent) + } + if out.GroupName != "grp-a" { + t.Errorf("want group_name=grp-a, got %q", out.GroupName) + } + if !out.Stream { + t.Errorf("want stream=true") + } + // 序列化后不含敏感字段 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"} { + for _, forbidden := range []string{"user_email", "upstream_endpoint"} { if strings.Contains(raw, forbidden) { t.Errorf("sensitive field %q leaked in JSON output: %s", forbidden, raw) } diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts index b777b22e8e..9dad8c1f12 100644 --- a/frontend/src/__tests__/setup.ts +++ b/frontend/src/__tests__/setup.ts @@ -57,6 +57,20 @@ if (typeof globalThis.cancelIdleCallback === 'undefined') { }) as unknown as typeof cancelIdleCallback } +// Mock matchMedia (jsdom 未实现;DataTable 等组件依赖它做桌面/移动分支) +if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') { + window.matchMedia = ((query: string) => ({ + matches: true, // 测试默认按桌面视口渲染表格 + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia +} + // Mock IntersectionObserver class MockIntersectionObserver { observe = vi.fn() diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index b3e53893ce..c7cbc64a4b 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -930,11 +930,16 @@ export interface OpsErrorLog { requested_model?: string upstream_model?: string request_type?: number | null + user_agent?: string + + // 已删除 KEY 所有者(INVALID_API_KEY 归因快照):认证失败行 user_id 为空, + // 用户列以此回退显示所有者 + deleted_key_owner_user_id?: number | null + deleted_key_owner_email?: string | null } export interface OpsErrorDetail extends OpsErrorLog { error_body: string - user_agent: string // Upstream context (optional; enriched by gateway services) upstream_status_code?: number | null @@ -950,10 +955,9 @@ export interface OpsErrorDetail extends OpsErrorLog { is_business_limited: boolean - // Deleted key owner info (INVALID_API_KEY attribution) + // Deleted key owner info (INVALID_API_KEY attribution); + // owner user_id/email 已上移到 OpsErrorLog(列表用户列回退) 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 @@ -1098,6 +1102,8 @@ export type OpsErrorListQueryParams = { model?: string phase?: string + // 分类(用户侧粗分类码,如 auth/rate_limit/upstream),后端反查为 phase/type ANY 条件 + category?: string error_owner?: string error_source?: string resolved?: string @@ -1106,6 +1112,10 @@ export type OpsErrorListQueryParams = { q?: string status_codes?: string status_codes_other?: string + + // 服务端排序,列白名单见后端 opsErrorLogsOrderBy(created_at/model/status_code) + sort_by?: string + sort_order?: 'asc' | 'desc' } // Legacy unified endpoints diff --git a/frontend/src/api/admin/usage.ts b/frontend/src/api/admin/usage.ts index b37996d61a..83be033c11 100644 --- a/frontend/src/api/admin/usage.ts +++ b/frontend/src/api/admin/usage.ts @@ -86,6 +86,10 @@ export interface AdminUsageQueryParams extends UsageQueryParams { billing_mode?: string sort_by?: string sort_order?: 'asc' | 'desc' + // 错误请求 tab 专属筛选(仅传给错误列表接口;共用同一 filters 对象) + error_phase?: string | null + error_category?: string | null + status_code?: number | null } // ==================== API Functions ==================== diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue index f16012ccff..bb63d9b8ee 100644 --- a/frontend/src/components/admin/usage/UsageFilters.vue +++ b/frontend/src/components/admin/usage/UsageFilters.vue @@ -121,24 +121,42 @@ - -
+ +
- -
+ +
+
+ + +
+ + +
+
@@ -156,12 +174,14 @@ {{ t('common.reset') }} - - +
@@ -172,6 +192,7 @@ import { ref, onMounted, onUnmounted, toRef, watch, computed } from 'vue' import { useI18n } from 'vue-i18n' import { adminAPI } from '@/api/admin' import Select, { type SelectOption } from '@/components/common/Select.vue' +import { COMMON_ERROR_STATUS_CODES } from '@/utils/errorBadges' import type { SimpleApiKey, SimpleUser } from '@/api/admin/usage' type ModelValue = Record @@ -183,10 +204,13 @@ interface Props { endDate: string showActions?: boolean modelOptions?: string[] + /** errors 模式:隐藏用量专属字段/按钮,显示错误类型+状态码(错误请求 tab 用) */ + mode?: 'usage' | 'errors' } const props = withDefaults(defineProps(), { - showActions: true + showActions: true, + mode: 'usage' }) const emit = defineEmits([ 'update:modelValue', @@ -243,6 +267,29 @@ const billingTypeOptions = ref([ { value: 1, label: t('admin.usage.billingTypeSubscription') } ]) +// 错误类型对应后端 phase 参数(与错误表"类型"徽章同语义) +const errorPhaseOptions = computed(() => [ + { value: null, label: t('admin.usage.allTypes') }, + { value: 'upstream', label: t('admin.ops.errorLog.typeUpstream') }, + { value: 'request', label: t('admin.ops.errorLog.typeRequest') }, + { value: 'auth', label: t('admin.ops.errorLog.typeAuth') }, + { value: 'routing', label: t('admin.ops.errorLog.typeRouting') }, + { value: 'internal', label: t('admin.ops.errorLog.typeInternal') }, +]) + +// 分类码同用户端 /usage 错误筛选;"other" 无法反查为过滤条件,刻意不列 +const errorCategoryCodes = ['auth', 'rate_limit', 'quota', 'invalid_request', 'service_unavailable', 'upstream', 'internal', 'cyber'] + +const errorCategoryOptions = computed(() => [ + { value: null, label: t('usage.errors.allCategories') }, + ...errorCategoryCodes.map((c) => ({ value: c, label: t('usage.errors.categories.' + c) })), +]) + +const statusCodeOptions = computed(() => [ + { value: null, label: t('usage.errors.allStatuses') }, + ...COMMON_ERROR_STATUS_CODES.map((c) => ({ value: c, label: String(c) })), +]) + const billingModeOptions = ref([ { value: null, label: t('admin.usage.allBillingModes') }, { value: 'token', label: t('admin.usage.billingModeToken') }, diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index bb7315dc21..d38ebeb0e1 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -36,6 +36,8 @@ v-for="(row, index) in sortedData" :key="resolveRowKey(row, index)" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900" + :class="{ 'cursor-pointer': clickableRows }" + @click="clickableRows && emit('rowClick', row)" >
() // 表格容器引用 @@ -381,6 +386,8 @@ interface Props { * will emit 'sort' events instead of performing client-side sorting. */ serverSideSort?: boolean + /** Emit 'rowClick' on row/card click and show pointer cursor (interactive cells should @click.stop) */ + clickableRows?: boolean /** Estimated row height in px for the virtualizer (default 56) */ estimateRowHeight?: number /** Number of rows to render beyond the visible area (default 5) */ diff --git a/frontend/src/components/common/IpGeoBatchToolbar.vue b/frontend/src/components/common/IpGeoBatchToolbar.vue new file mode 100644 index 0000000000..07199f1b2a --- /dev/null +++ b/frontend/src/components/common/IpGeoBatchToolbar.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/components/user/UserErrorRequestsTable.vue b/frontend/src/components/user/UserErrorRequestsTable.vue index 42cb93ce04..2465fe2f66 100644 --- a/frontend/src/components/user/UserErrorRequestsTable.vue +++ b/frontend/src/components/user/UserErrorRequestsTable.vue @@ -1,91 +1,119 @@