Skip to content

Commit ebbdc70

Browse files
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): 错误请求状态码排序对齐展示/过滤,筛选项改固定常用码
1 parent 2649573 commit ebbdc70

25 files changed

Lines changed: 1108 additions & 459 deletions

backend/internal/handler/admin/ops_handler.go

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ func NewOpsHandler(opsService *service.OpsService) *OpsHandler {
7373
}
7474

7575
// GetErrorLogs lists ops error logs.
76+
// applyOpsErrorSortParams reads sort_by/sort_order query params into the filter.
77+
// Column whitelist and order normalization live in the repository; unknown
78+
// values degrade to the default (created_at DESC), mirroring the usage list.
79+
func applyOpsErrorSortParams(c *gin.Context, filter *service.OpsErrorLogFilter) {
80+
filter.SetSort(c.Query("sort_by"), c.Query("sort_order"))
81+
}
82+
7683
// GET /api/v1/admin/ops/errors
7784
func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
7885
if h.opsService == nil {
@@ -114,10 +121,17 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
114121
// buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
115122
filter.Model = strings.TrimSpace(c.Query("model"))
116123

117-
// Force request errors: client-visible status >= 400.
118-
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
119-
if strings.EqualFold(strings.TrimSpace(filter.Phase), "upstream") {
120-
filter.Phase = ""
124+
// 请求错误语义:client-visible status>=400 守卫恒生效(未设
125+
// IncludeRecoveredUpstream 时 phase=upstream 不再绕过守卫),故
126+
// phase=upstream 作为普通过滤条件保留——此前这里清空该值,导致
127+
// 错误类型下拉选「上游」等于不过滤。
128+
129+
// 分类(用户侧粗分类码)→ phase/type ANY 条件,与用户端 /usage/errors 同一映射;
130+
// 未知分类返回空切片 = 不过滤。与 phase 参数可同时设置(AND 语义)。
131+
if cat := strings.TrimSpace(c.Query("category")); cat != "" {
132+
phases, types := service.CategoryToFilter(cat)
133+
filter.ErrorPhasesAny = phases
134+
filter.ErrorTypesAny = types
121135
}
122136

123137
if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
@@ -187,6 +201,8 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
187201
filter.StatusCodes = out
188202
}
189203

204+
applyOpsErrorSortParams(c, filter)
205+
190206
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
191207
if err != nil {
192208
response.ErrorFrom(c, err)
@@ -234,10 +250,17 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) {
234250
// buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
235251
filter.Model = strings.TrimSpace(c.Query("model"))
236252

237-
// Force request errors: client-visible status >= 400.
238-
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
239-
if strings.EqualFold(strings.TrimSpace(filter.Phase), "upstream") {
240-
filter.Phase = ""
253+
// 请求错误语义:client-visible status>=400 守卫恒生效(未设
254+
// IncludeRecoveredUpstream 时 phase=upstream 不再绕过守卫),故
255+
// phase=upstream 作为普通过滤条件保留——此前这里清空该值,导致
256+
// 错误类型下拉选「上游」等于不过滤。
257+
258+
// 分类(用户侧粗分类码)→ phase/type ANY 条件,与用户端 /usage/errors 同一映射;
259+
// 未知分类返回空切片 = 不过滤。与 phase 参数可同时设置(AND 语义)。
260+
if cat := strings.TrimSpace(c.Query("category")); cat != "" {
261+
phases, types := service.CategoryToFilter(cat)
262+
filter.ErrorPhasesAny = phases
263+
filter.ErrorTypesAny = types
241264
}
242265

243266
if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
@@ -291,6 +314,8 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) {
291314
filter.StatusCodes = out
292315
}
293316

317+
applyOpsErrorSortParams(c, filter)
318+
294319
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
295320
if err != nil {
296321
response.ErrorFrom(c, err)
@@ -362,6 +387,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) {
362387
}
363388
filter.View = "all"
364389
filter.Phase = "upstream"
390+
// 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。
391+
filter.IncludeRecoveredUpstream = true
365392
filter.Owner = "provider"
366393
filter.Source = strings.TrimSpace(c.Query("error_source"))
367394
filter.Query = strings.TrimSpace(c.Query("q"))
@@ -377,6 +404,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) {
377404
filter.ClientRequestID = clientRequestID
378405
}
379406

407+
applyOpsErrorSortParams(c, filter)
408+
380409
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
381410
if err != nil {
382411
response.ErrorFrom(c, err)
@@ -442,6 +471,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) {
442471

443472
filter.View = parseOpsViewParam(c)
444473
filter.Phase = "upstream"
474+
// 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。
475+
filter.IncludeRecoveredUpstream = true
445476
filter.Owner = "provider"
446477
filter.Source = strings.TrimSpace(c.Query("error_source"))
447478
filter.Query = strings.TrimSpace(c.Query("q"))
@@ -497,6 +528,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) {
497528
filter.StatusCodes = out
498529
}
499530

531+
applyOpsErrorSortParams(c, filter)
532+
500533
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
501534
if err != nil {
502535
response.ErrorFrom(c, err)

backend/internal/handler/usage_handler.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,9 @@ func (h *UsageHandler) ListErrors(c *gin.Context) {
322322
filter.ErrorTypesAny = types
323323
}
324324

325+
// 排序对齐用量明细:列白名单与方向归一在 repo 层,非法值回退 created_at DESC。
326+
filter.SetSort(c.Query("sort_by"), c.Query("sort_order"))
327+
325328
result, err := h.opsService.ListUserErrorRequests(c.Request.Context(), subject.UserID, filter)
326329
if err != nil {
327330
response.ErrorFrom(c, err)

backend/internal/repository/ops_error_where_test.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,21 @@ func TestBuildOpsErrorLogsWhere_CyberPolicyStatusExemption(t *testing.T) {
8585
t.Fatalf("default filter must still include the status >= 400 guard for non-cyber rows\nfull: %s", where)
8686
}
8787

88-
// phase=upstream skips the status guard entirely — exemption is irrelevant there.
88+
// phase=upstream WITHOUT the recovered-upstream opt-in keeps the status guard:
89+
// request-error list endpoints filter by phase=upstream as a plain condition.
8990
whereUpstream, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{Phase: "upstream"})
90-
if strings.Contains(whereUpstream, "status_code") {
91-
t.Fatalf("upstream phase filter must not add any status_code clause\nfull: %s", whereUpstream)
91+
if !strings.Contains(whereUpstream, "COALESCE(e.status_code, 0) >= 400") {
92+
t.Fatalf("upstream phase without IncludeRecoveredUpstream must keep the status guard\nfull: %s", whereUpstream)
93+
}
94+
if !strings.Contains(whereUpstream, "e.error_phase = $") {
95+
t.Fatalf("upstream phase filter must emit the error_phase condition\nfull: %s", whereUpstream)
96+
}
97+
98+
// phase=upstream WITH IncludeRecoveredUpstream (ops 上游列表) skips the guard,
99+
// exposing recovered (<400) upstream rows.
100+
whereRecovered, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{Phase: "upstream", IncludeRecoveredUpstream: true})
101+
if strings.Contains(whereRecovered, "status_code") {
102+
t.Fatalf("upstream phase with IncludeRecoveredUpstream must not add any status_code clause\nfull: %s", whereRecovered)
92103
}
93104
}
94105

backend/internal/repository/ops_repo.go

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,37 @@ func opsInsertErrorLogArgs(input *service.OpsInsertErrorLogInput) []any {
177177
}
178178
}
179179

180+
// opsErrorLogsOrderBy builds the ORDER BY clause from a whitelist, mirroring
181+
// usageLogOrderBy semantics. Unknown SortBy falls back to created_at; e.id is
182+
// always appended as tiebreaker for stable pagination.
183+
func opsErrorLogsOrderBy(filter *service.OpsErrorLogFilter) string {
184+
sortBy := ""
185+
sortOrder := ""
186+
if filter != nil {
187+
sortBy = strings.ToLower(strings.TrimSpace(filter.SortBy))
188+
sortOrder = strings.ToLower(strings.TrimSpace(filter.SortOrder))
189+
}
190+
191+
var column string
192+
switch sortBy {
193+
case "model":
194+
column = "COALESCE(NULLIF(TRIM(e.requested_model), ''), e.model)"
195+
case "status_code":
196+
// 与展示列/过滤保持同义:列表展示 COALESCE(upstream_status_code, status_code, 0),
197+
// status_code 过滤也用同一表达式,故排序必须一致——否则 recovered upstream 行
198+
//(status_code<400 但展示上游 5xx)排序键与显示值/分页切分不符。
199+
column = "COALESCE(e.upstream_status_code, e.status_code, 0)"
200+
default:
201+
column = "e.created_at"
202+
}
203+
204+
dir := "DESC"
205+
if sortOrder == "asc" {
206+
dir = "ASC"
207+
}
208+
return fmt.Sprintf("%s %s, e.id %s", column, dir, dir)
209+
}
210+
180211
func (r *opsRepository) ListErrorLogs(ctx context.Context, filter *service.OpsErrorLogFilter) (*service.OpsErrorLogList, error) {
181212
if r == nil || r.db == nil {
182213
return nil, fmt.Errorf("nil ops repository")
@@ -233,25 +264,29 @@ SELECT
233264
COALESCE(a.name, ''),
234265
e.group_id,
235266
COALESCE(g.name, ''),
236-
CASE WHEN e.client_ip IS NULL THEN NULL ELSE e.client_ip::text END,
267+
CASE WHEN e.client_ip IS NULL THEN NULL ELSE host(e.client_ip) END,
237268
COALESCE(e.request_path, ''),
238269
e.stream,
239270
COALESCE(e.inbound_endpoint, ''),
240271
COALESCE(e.upstream_endpoint, ''),
241272
COALESCE(e.requested_model, ''),
242273
COALESCE(e.upstream_model, ''),
274+
COALESCE(e.user_agent, ''),
243275
e.request_type,
244276
COALESCE(ak.name, ''),
245277
ak.deleted_at,
246-
COALESCE(e.deleted_key_name, '')
278+
COALESCE(e.deleted_key_name, ''),
279+
e.deleted_key_owner_user_id,
280+
COALESCE(du.email, '')
247281
FROM ops_error_logs e
248282
LEFT JOIN accounts a ON e.account_id = a.id
249283
LEFT JOIN groups g ON e.group_id = g.id
250284
LEFT JOIN users u ON e.user_id = u.id
251285
LEFT JOIN users u2 ON e.resolved_by_user_id = u2.id
286+
LEFT JOIN users du ON e.deleted_key_owner_user_id = du.id
252287
LEFT JOIN api_keys ak ON ak.id = e.api_key_id
253288
` + where + `
254-
ORDER BY e.created_at DESC
289+
ORDER BY ` + opsErrorLogsOrderBy(filter) + `
255290
LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
256291

257292
rows, err := r.db.QueryContext(ctx, selectSQL, argsWithLimit...)
@@ -279,6 +314,8 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
279314
var apiKeyName string
280315
var apiKeyDeletedAt sql.NullTime
281316
var deletedKeyName string
317+
var deletedKeyOwnerID sql.NullInt64
318+
var deletedKeyOwnerEmail string
282319
if err := rows.Scan(
283320
&item.ID,
284321
&item.CreatedAt,
@@ -311,10 +348,13 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
311348
&item.UpstreamEndpoint,
312349
&item.RequestedModel,
313350
&item.UpstreamModel,
351+
&item.UserAgent,
314352
&requestType,
315353
&apiKeyName,
316354
&apiKeyDeletedAt,
317355
&deletedKeyName,
356+
&deletedKeyOwnerID,
357+
&deletedKeyOwnerEmail,
318358
); err != nil {
319359
return nil, err
320360
}
@@ -364,6 +404,12 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
364404
}
365405
// 已删除:ak.deleted_at 非空(软删),或仅命中 deleted_key_name 兜底。
366406
item.APIKeyDeleted = apiKeyDeletedAt.Valid || (apiKeyName == "" && deletedKeyName != "")
407+
// 已删除 KEY 所有者快照:认证失败行 user_id 为空,列表用户列以此回退。
408+
if deletedKeyOwnerID.Valid {
409+
v := deletedKeyOwnerID.Int64
410+
item.DeletedKeyOwnerUserID = &v
411+
item.DeletedKeyOwnerEmail = deletedKeyOwnerEmail
412+
}
367413
out = append(out, &item)
368414
}
369415
if err := rows.Err(); err != nil {
@@ -417,7 +463,7 @@ SELECT
417463
COALESCE(a.name, ''),
418464
e.group_id,
419465
COALESCE(g.name, ''),
420-
CASE WHEN e.client_ip IS NULL THEN NULL ELSE e.client_ip::text END,
466+
CASE WHEN e.client_ip IS NULL THEN NULL ELSE host(e.client_ip) END,
421467
COALESCE(e.request_path, ''),
422468
e.stream,
423469
COALESCE(e.inbound_endpoint, ''),
@@ -927,12 +973,14 @@ func buildOpsErrorLogsWhere(filter *service.OpsErrorLogFilter) (string, []any) {
927973
if filter != nil {
928974
resolvedFilter = filter.Resolved
929975
}
930-
// Keep list endpoints scoped to client errors unless explicitly filtering upstream phase.
976+
// Keep list endpoints scoped to client errors unless the caller explicitly opts
977+
// into recovered upstream rows (Phase=="upstream" + IncludeRecoveredUpstream,
978+
// ops 专用上游列表)。请求错误语义的端点即便过滤 phase=upstream 也保留该守卫。
931979
// cyber_policy is exempt from the status >= 400 guard: streaming cyber hits arrive with
932980
// status 200 (the SSE stream opened successfully before upstream returned response.failed),
933981
// but they are always client-visible blocked requests that belong in admin + user error
934982
// lists. Without the exemption the entire streaming-path cyber sink would be invisible.
935-
if phaseFilter != "upstream" {
983+
if phaseFilter != "upstream" || filter == nil || !filter.IncludeRecoveredUpstream {
936984
clauses = append(clauses, "(COALESCE(e.status_code, 0) >= 400 OR e.error_type = 'cyber_policy')")
937985
}
938986

backend/internal/service/ops_models.go

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package service
22

3-
import "time"
3+
import (
4+
"strings"
5+
"time"
6+
)
47

58
type OpsSystemLog struct {
69
ID int64 `json:"id"`
@@ -65,17 +68,22 @@ type OpsErrorLog struct {
6568
RequestedModel string `json:"requested_model"`
6669
UpstreamModel string `json:"upstream_model"`
6770
RequestType *int16 `json:"request_type"`
71+
UserAgent string `json:"user_agent"`
6872

6973
// 关联 api_key 名称(LEFT JOIN api_keys 取得;软删只覆盖 key 列,name 保留,故已删 key 仍有原名)。
7074
APIKeyName string `json:"api_key_name,omitempty"`
7175
APIKeyDeleted bool `json:"api_key_deleted,omitempty"`
76+
77+
// 已删除 KEY 所有者(INVALID_API_KEY 且该 key 曾存在时的归因快照)。
78+
// 认证失败行 user_id 为空,列表用户列以此回退显示所有者。
79+
DeletedKeyOwnerUserID *int64 `json:"deleted_key_owner_user_id,omitempty"`
80+
DeletedKeyOwnerEmail string `json:"deleted_key_owner_email,omitempty"`
7281
}
7382

7483
type OpsErrorLogDetail struct {
7584
OpsErrorLog
7685

7786
ErrorBody string `json:"error_body"`
78-
UserAgent string `json:"user_agent"`
7987

8088
// Upstream context (optional)
8189
UpstreamStatusCode *int `json:"upstream_status_code,omitempty"`
@@ -93,11 +101,10 @@ type OpsErrorLogDetail struct {
93101
// vNext metric semantics
94102
IsBusinessLimited bool `json:"is_business_limited"`
95103

96-
// Deleted key owner info (populated when INVALID_API_KEY and key was previously deleted)
97-
AttemptedKeyPrefix string `json:"attempted_key_prefix,omitempty"`
98-
DeletedKeyOwnerUserID *int64 `json:"deleted_key_owner_user_id,omitempty"`
99-
DeletedKeyOwnerEmail string `json:"deleted_key_owner_email,omitempty"`
100-
DeletedKeyName string `json:"deleted_key_name,omitempty"`
104+
// Deleted key owner info (populated when INVALID_API_KEY and key was previously deleted).
105+
// OwnerUserID/OwnerEmail 已上移到 OpsErrorLog(列表用户列回退需要)。
106+
AttemptedKeyPrefix string `json:"attempted_key_prefix,omitempty"`
107+
DeletedKeyName string `json:"deleted_key_name,omitempty"`
101108

102109
// Bound (non-deleted) key prefix, snapshotted at error time; mutually exclusive with AttemptedKeyPrefix.
103110
APIKeyPrefix string `json:"api_key_prefix,omitempty"`
@@ -142,8 +149,14 @@ type OpsErrorLogFilter struct {
142149
// ExcludeCountTokens drops count_tokens probe errors (is_count_tokens=true).
143150
ExcludeCountTokens bool
144151

152+
// IncludeRecoveredUpstream 显式豁免 status>=400 守卫(仅在 Phase=="upstream" 时生效):
153+
// ops 专用上游错误列表需要看到 status<400 的 recovered upstream 行。
154+
// 请求错误语义的端点不设此开关,phase=upstream 过滤照常生效且守卫保留。
155+
IncludeRecoveredUpstream bool
156+
145157
// ErrorPhasesAny / ErrorTypesAny add plain ANY() filters WITHOUT touching the
146-
// special-cased single `Phase` field (only Phase=="upstream" bypasses the status>=400 clause).
158+
// special-cased single `Phase` field (only Phase=="upstream" with
159+
// IncludeRecoveredUpstream bypasses the status>=400 clause).
147160
// NOTE: these ANY filters do NOT bypass status>=400; records with error_phase='upstream'
148161
// but status_code<400 (recovered upstream errors) remain excluded.
149162
// Used to map user-facing coarse categories to backend conditions.
@@ -158,6 +171,19 @@ type OpsErrorLogFilter struct {
158171

159172
Page int
160173
PageSize int
174+
175+
// SortBy/SortOrder: server-side sorting aligned with the usage-log list.
176+
// Repo whitelists columns (created_at/model/status_code); anything else
177+
// falls back to created_at. SortOrder is "asc"/"desc" (default desc).
178+
SortBy string
179+
SortOrder string
180+
}
181+
182+
// SetSort normalizes raw sort_by/sort_order query values into the filter.
183+
// Shared by the admin and user-facing error list handlers.
184+
func (f *OpsErrorLogFilter) SetSort(sortBy, sortOrder string) {
185+
f.SortBy = strings.TrimSpace(sortBy)
186+
f.SortOrder = strings.TrimSpace(sortOrder)
161187
}
162188

163189
type OpsErrorLogList struct {

backend/internal/service/ops_service.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -359,10 +359,12 @@ func (s *OpsService) ListUserErrorRequests(ctx context.Context, userID int64, fi
359359
filter.UserQuery = ""
360360
filter.Owner = ""
361361
filter.Source = ""
362-
// 清空 Phase 是防御:Phase 是单值特殊字段,仅当其 == "upstream" 时 buildOpsErrorLogsWhere 才跳过 status>=400 子句。
363-
// 用户端一律改走 category→ErrorPhasesAny/ErrorTypesAny(纯 ANY 过滤,不影响 status>=400 子句),
364-
// 因此 recovered upstream(error_phase='upstream' 但 status<400,最终成功返回)记录对用户不可见——符合预期。
362+
// 清空 Phase 是防御:用户端一律改走 category→ErrorPhasesAny/ErrorTypesAny
363+
//(纯 ANY 过滤,不影响 status>=400 子句)。守卫豁免现在还需要
364+
// IncludeRecoveredUpstream(用户端永不设置),recovered upstream
365+
//(error_phase='upstream' 但 status<400,最终成功返回)记录对用户不可见——符合预期。
365366
filter.Phase = ""
367+
filter.IncludeRecoveredUpstream = false
366368

367369
list, err := s.opsRepo.ListErrorLogs(ctx, filter)
368370
if err != nil {

0 commit comments

Comments
 (0)