Skip to content

Commit 54cce18

Browse files
feat(usage): 错误请求全面对齐用量明细(UI/列序/排序/筛选/列设置/新列)
管理端(/admin/usage)与用户端(/usage)错误请求 tab 对齐用量明细: - 表格重写到 DataTable 底座,列序/徽章体系/骨架屏/移动端视图对齐 - 服务端排序(created_at/model/status_code 白名单,id tiebreak) - 新增列:IP(含地区获取)、分组、类型、User-Agent;管理端另增分类列 - 筛选:/usage 收进顶部筛选卡按 tab 切换;管理端错误 tab 专属 phase/状态码筛选 - 列设置:两端错误 tab 独立列设置(localStorage 独立存储) - 管理端用户邮箱可点击进余额弹窗;用户端白名单放行 group/type/UA(产品决策对齐用量明细口径) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 87dfc66 commit 54cce18

20 files changed

Lines changed: 931 additions & 416 deletions

File tree

backend/internal/handler/admin/ops_handler.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ 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.SortBy = strings.TrimSpace(c.Query("sort_by"))
81+
filter.SortOrder = strings.TrimSpace(c.Query("sort_order"))
82+
}
83+
7684
// GET /api/v1/admin/ops/errors
7785
func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
7886
if h.opsService == nil {
@@ -187,6 +195,8 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
187195
filter.StatusCodes = out
188196
}
189197

198+
applyOpsErrorSortParams(c, filter)
199+
190200
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
191201
if err != nil {
192202
response.ErrorFrom(c, err)
@@ -291,6 +301,8 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) {
291301
filter.StatusCodes = out
292302
}
293303

304+
applyOpsErrorSortParams(c, filter)
305+
294306
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
295307
if err != nil {
296308
response.ErrorFrom(c, err)
@@ -377,6 +389,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) {
377389
filter.ClientRequestID = clientRequestID
378390
}
379391

392+
applyOpsErrorSortParams(c, filter)
393+
380394
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
381395
if err != nil {
382396
response.ErrorFrom(c, err)
@@ -497,6 +511,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) {
497511
filter.StatusCodes = out
498512
}
499513

514+
applyOpsErrorSortParams(c, filter)
515+
500516
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
501517
if err != nil {
502518
response.ErrorFrom(c, err)

backend/internal/handler/usage_handler.go

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

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

backend/internal/repository/ops_repo.go

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,34 @@ 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+
column = "e.status_code"
197+
default:
198+
column = "e.created_at"
199+
}
200+
201+
dir := "DESC"
202+
if sortOrder == "asc" {
203+
dir = "ASC"
204+
}
205+
return fmt.Sprintf("%s %s, e.id %s", column, dir, dir)
206+
}
207+
180208
func (r *opsRepository) ListErrorLogs(ctx context.Context, filter *service.OpsErrorLogFilter) (*service.OpsErrorLogList, error) {
181209
if r == nil || r.db == nil {
182210
return nil, fmt.Errorf("nil ops repository")
@@ -233,13 +261,14 @@ SELECT
233261
COALESCE(a.name, ''),
234262
e.group_id,
235263
COALESCE(g.name, ''),
236-
CASE WHEN e.client_ip IS NULL THEN NULL ELSE e.client_ip::text END,
264+
CASE WHEN e.client_ip IS NULL THEN NULL ELSE host(e.client_ip) END,
237265
COALESCE(e.request_path, ''),
238266
e.stream,
239267
COALESCE(e.inbound_endpoint, ''),
240268
COALESCE(e.upstream_endpoint, ''),
241269
COALESCE(e.requested_model, ''),
242270
COALESCE(e.upstream_model, ''),
271+
COALESCE(e.user_agent, ''),
243272
e.request_type,
244273
COALESCE(ak.name, ''),
245274
ak.deleted_at,
@@ -251,7 +280,7 @@ LEFT JOIN users u ON e.user_id = u.id
251280
LEFT JOIN users u2 ON e.resolved_by_user_id = u2.id
252281
LEFT JOIN api_keys ak ON ak.id = e.api_key_id
253282
` + where + `
254-
ORDER BY e.created_at DESC
283+
ORDER BY ` + opsErrorLogsOrderBy(filter) + `
255284
LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
256285

257286
rows, err := r.db.QueryContext(ctx, selectSQL, argsWithLimit...)
@@ -311,6 +340,7 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
311340
&item.UpstreamEndpoint,
312341
&item.RequestedModel,
313342
&item.UpstreamModel,
343+
&item.UserAgent,
314344
&requestType,
315345
&apiKeyName,
316346
&apiKeyDeletedAt,
@@ -417,7 +447,7 @@ SELECT
417447
COALESCE(a.name, ''),
418448
e.group_id,
419449
COALESCE(g.name, ''),
420-
CASE WHEN e.client_ip IS NULL THEN NULL ELSE e.client_ip::text END,
450+
CASE WHEN e.client_ip IS NULL THEN NULL ELSE host(e.client_ip) END,
421451
COALESCE(e.request_path, ''),
422452
e.stream,
423453
COALESCE(e.inbound_endpoint, ''),

backend/internal/service/ops_models.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ type OpsErrorLog struct {
6565
RequestedModel string `json:"requested_model"`
6666
UpstreamModel string `json:"upstream_model"`
6767
RequestType *int16 `json:"request_type"`
68+
UserAgent string `json:"user_agent"`
6869

6970
// 关联 api_key 名称(LEFT JOIN api_keys 取得;软删只覆盖 key 列,name 保留,故已删 key 仍有原名)。
7071
APIKeyName string `json:"api_key_name,omitempty"`
@@ -75,7 +76,6 @@ type OpsErrorLogDetail struct {
7576
OpsErrorLog
7677

7778
ErrorBody string `json:"error_body"`
78-
UserAgent string `json:"user_agent"`
7979

8080
// Upstream context (optional)
8181
UpstreamStatusCode *int `json:"upstream_status_code,omitempty"`
@@ -158,6 +158,12 @@ type OpsErrorLogFilter struct {
158158

159159
Page int
160160
PageSize int
161+
162+
// SortBy/SortOrder: server-side sorting aligned with the usage-log list.
163+
// Repo whitelists columns (created_at/model/status_code); anything else
164+
// falls back to created_at. SortOrder is "asc"/"desc" (default desc).
165+
SortBy string
166+
SortOrder string
161167
}
162168

163169
type OpsErrorLogList struct {

backend/internal/service/ops_user_error.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ package service
33
import "time"
44

55
// UserErrorRequest 是面向终端用户的"错误请求"精简脱敏视图(白名单)。
6-
// 严禁包含 client_ip / user_agent / account / api_key_prefix / upstream_endpoint /
7-
// user_email 等敏感或内部字段。注:message(网关标准化错误描述)与 key_name
6+
// 严禁包含 account / api_key_prefix / upstream_endpoint / user_email 等
7+
// 敏感或内部字段。注:message(网关标准化错误描述)与 key_name
88
// (用户自有 API Key 名称,KeysView 中本就可见)经产品决策对该用户开放;
9+
// client_ip / user_agent / group_name / request_type / stream 均为该用户
10+
// 自己请求的属性,经产品决策(2026-07-03)开放,
11+
// 与用量明细已向用户展示自身 ip_address/user_agent/分组/类型 的口径对齐;
912
// error_body 仅在详情接口(GetUserErrorRequestDetail)按归属校验后返回。
1013
type UserErrorRequest struct {
1114
ID int64 `json:"id"`
@@ -18,6 +21,11 @@ type UserErrorRequest struct {
1821
Message string `json:"message"`
1922
KeyName string `json:"key_name"`
2023
KeyDeleted bool `json:"key_deleted"`
24+
ClientIP string `json:"client_ip,omitempty"`
25+
GroupName string `json:"group_name,omitempty"`
26+
RequestType *int16 `json:"request_type,omitempty"`
27+
Stream bool `json:"stream"`
28+
UserAgent string `json:"user_agent,omitempty"`
2129
}
2230

2331
// UserErrorRequestList 是用户错误请求分页结果。
@@ -90,6 +98,10 @@ func ToUserErrorRequest(e *OpsErrorLog) *UserErrorRequest {
9098
if model == "" {
9199
model = e.Model
92100
}
101+
clientIP := ""
102+
if e.ClientIP != nil {
103+
clientIP = *e.ClientIP
104+
}
93105
return &UserErrorRequest{
94106
ID: e.ID,
95107
CreatedAt: e.CreatedAt,
@@ -101,6 +113,11 @@ func ToUserErrorRequest(e *OpsErrorLog) *UserErrorRequest {
101113
Message: e.Message,
102114
KeyName: e.APIKeyName,
103115
KeyDeleted: e.APIKeyDeleted,
116+
ClientIP: clientIP,
117+
GroupName: e.GroupName,
118+
RequestType: e.RequestType,
119+
Stream: e.Stream,
120+
UserAgent: e.UserAgent,
104121
}
105122
}
106123

backend/internal/service/ops_user_error_test.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,11 @@ func TestToUserErrorRequestDetail_WhitelistAndRedacts(t *testing.T) {
122122
UserEmail: "secret@example.com",
123123
ClientIP: func() *string { s := "1.2.3.4"; return &s }(),
124124
UpstreamEndpoint: "https://api.openai.com/v1/chat/completions",
125+
UserAgent: "codex_cli_rs/0.125.0",
126+
GroupName: "grp-a",
127+
Stream: true,
125128
},
126129
ErrorBody: `{"error":{"message":"upstream failed","type":"server_error"}}`,
127-
UserAgent: "Mozilla/5.0 secret-agent",
128130
UpstreamStatusCode: &upstreamStatus,
129131
}
130132

@@ -147,13 +149,27 @@ func TestToUserErrorRequestDetail_WhitelistAndRedacts(t *testing.T) {
147149
t.Errorf("UpstreamStatusCode mismatch")
148150
}
149151

152+
// client_ip / user_agent / group_name / stream 经产品决策开放(与用量明细口径对齐)
153+
if out.ClientIP != "1.2.3.4" {
154+
t.Errorf("want client_ip=1.2.3.4, got %q", out.ClientIP)
155+
}
156+
if out.UserAgent != "codex_cli_rs/0.125.0" {
157+
t.Errorf("want user_agent=codex_cli_rs/0.125.0, got %q", out.UserAgent)
158+
}
159+
if out.GroupName != "grp-a" {
160+
t.Errorf("want group_name=grp-a, got %q", out.GroupName)
161+
}
162+
if !out.Stream {
163+
t.Errorf("want stream=true")
164+
}
165+
150166
// 序列化后不含敏感字段
151167
b, err := json.Marshal(out)
152168
if err != nil {
153169
t.Fatalf("json.Marshal failed: %v", err)
154170
}
155171
raw := string(b)
156-
for _, forbidden := range []string{"user_email", "client_ip", "upstream_endpoint", "user_agent"} {
172+
for _, forbidden := range []string{"user_email", "upstream_endpoint"} {
157173
if strings.Contains(raw, forbidden) {
158174
t.Errorf("sensitive field %q leaked in JSON output: %s", forbidden, raw)
159175
}

frontend/src/__tests__/setup.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@ if (typeof globalThis.cancelIdleCallback === 'undefined') {
5757
}) as unknown as typeof cancelIdleCallback
5858
}
5959

60+
// Mock matchMedia (jsdom 未实现;DataTable 等组件依赖它做桌面/移动分支)
61+
if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') {
62+
window.matchMedia = ((query: string) => ({
63+
matches: true, // 测试默认按桌面视口渲染表格
64+
media: query,
65+
onchange: null,
66+
addListener: vi.fn(),
67+
removeListener: vi.fn(),
68+
addEventListener: vi.fn(),
69+
removeEventListener: vi.fn(),
70+
dispatchEvent: vi.fn(),
71+
})) as unknown as typeof window.matchMedia
72+
}
73+
6074
// Mock IntersectionObserver
6175
class MockIntersectionObserver {
6276
observe = vi.fn()

frontend/src/api/admin/ops.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -930,11 +930,11 @@ export interface OpsErrorLog {
930930
requested_model?: string
931931
upstream_model?: string
932932
request_type?: number | null
933+
user_agent?: string
933934
}
934935

935936
export interface OpsErrorDetail extends OpsErrorLog {
936937
error_body: string
937-
user_agent: string
938938

939939
// Upstream context (optional; enriched by gateway services)
940940
upstream_status_code?: number | null
@@ -1106,6 +1106,10 @@ export type OpsErrorListQueryParams = {
11061106
q?: string
11071107
status_codes?: string
11081108
status_codes_other?: string
1109+
1110+
// 服务端排序,列白名单见后端 opsErrorLogsOrderBy(created_at/model/status_code)
1111+
sort_by?: string
1112+
sort_order?: 'asc' | 'desc'
11091113
}
11101114

11111115
// Legacy unified endpoints

frontend/src/api/admin/usage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
8686
billing_mode?: string
8787
sort_by?: string
8888
sort_order?: 'asc' | 'desc'
89+
// 错误请求 tab 专属筛选(仅传给错误列表接口;共用同一 filters 对象)
90+
error_phase?: string | null
91+
status_code?: number | null
8992
}
9093

9194
// ==================== API Functions ====================

0 commit comments

Comments
 (0)