mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat(usage): 错误请求对齐用量明细(UI/排序/筛选/列设置)
错误请求列表(/admin/usage 错误 tab、Ops 弹窗、用户端 /usage 错误 tab) 对齐用量明细的交互与信息密度。Squash of: - feat(usage): 错误请求全面对齐用量明细(UI/列序/排序/筛选/列设置/新列) - refactor(usage): 错误表提取共享徽章工具与 IP 批量工具条,后端排序解析归并 SetSort - feat(usage): /admin/usage 错误请求新增分类过滤 - fix(ops): 错误列表 phase=upstream 过滤生效,守卫豁免改为显式 opt-in - fix(ops): 错误列表用户列回退显示已删除 KEY 所有者 - fix(usage): 错误请求状态码排序对齐展示/过滤,筛选项改固定常用码
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user