diff --git a/backend/internal/handler/admin/ops_system_log_handler.go b/backend/internal/handler/admin/ops_system_log_handler.go index 31fd51eb6d..9f3c8b893a 100644 --- a/backend/internal/handler/admin/ops_system_log_handler.go +++ b/backend/internal/handler/admin/ops_system_log_handler.go @@ -21,6 +21,7 @@ type opsSystemLogCleanupRequest struct { RequestID string `json:"request_id"` ClientRequestID string `json:"client_request_id"` UserID *int64 `json:"user_id"` + APIKeyID *int64 `json:"api_key_id"` AccountID *int64 `json:"account_id"` Platform string `json:"platform"` Model string `json:"model"` @@ -71,6 +72,14 @@ func (h *OpsHandler) ListSystemLogs(c *gin.Context) { } filter.UserID = &id } + if v := strings.TrimSpace(c.Query("api_key_id")); v != "" { + id, parseErr := strconv.ParseInt(v, 10, 64) + if parseErr != nil || id <= 0 { + response.BadRequest(c, "Invalid api_key_id") + return + } + filter.APIKeyID = &id + } if v := strings.TrimSpace(c.Query("account_id")); v != "" { id, parseErr := strconv.ParseInt(v, 10, 64) if parseErr != nil || id <= 0 { @@ -136,6 +145,10 @@ func (h *OpsHandler) CleanupSystemLogs(c *gin.Context) { response.BadRequest(c, "Invalid end_time") return } + if req.APIKeyID != nil && *req.APIKeyID <= 0 { + response.BadRequest(c, "Invalid api_key_id") + return + } filter := &service.OpsSystemLogCleanupFilter{ StartTime: start, @@ -145,6 +158,7 @@ func (h *OpsHandler) CleanupSystemLogs(c *gin.Context) { RequestID: strings.TrimSpace(req.RequestID), ClientRequestID: strings.TrimSpace(req.ClientRequestID), UserID: req.UserID, + APIKeyID: req.APIKeyID, AccountID: req.AccountID, Platform: strings.TrimSpace(req.Platform), Model: strings.TrimSpace(req.Model), diff --git a/backend/internal/handler/admin/ops_system_log_handler_test.go b/backend/internal/handler/admin/ops_system_log_handler_test.go index 7528acd849..9557fce442 100644 --- a/backend/internal/handler/admin/ops_system_log_handler_test.go +++ b/backend/internal/handler/admin/ops_system_log_handler_test.go @@ -72,6 +72,19 @@ func TestOpsSystemLogHandler_ListInvalidAccountID(t *testing.T) { } } +func TestOpsSystemLogHandler_ListInvalidAPIKeyID(t *testing.T) { + svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + h := NewOpsHandler(svc) + r := newOpsSystemLogTestRouter(h, false) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/logs?api_key_id=abc", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("status=%d, want 400", w.Code) + } +} + func TestOpsSystemLogHandler_ListMonitoringDisabled(t *testing.T) { svc := service.NewOpsService(nil, nil, &config.Config{ Ops: config.OpsConfig{Enabled: false}, @@ -178,6 +191,34 @@ func TestOpsSystemLogHandler_CleanupServiceUnavailable(t *testing.T) { } } +func TestOpsSystemLogHandler_CleanupAcceptsAPIKeyID(t *testing.T) { + svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + h := NewOpsHandler(svc) + r := newOpsSystemLogTestRouter(h, true) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/logs/cleanup", bytes.NewBufferString(`{"api_key_id":123}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status=%d, want 503", w.Code) + } +} + +func TestOpsSystemLogHandler_CleanupInvalidAPIKeyID(t *testing.T) { + svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + h := NewOpsHandler(svc) + r := newOpsSystemLogTestRouter(h, true) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/logs/cleanup", bytes.NewBufferString(`{"api_key_id":0}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("status=%d, want 400", w.Code) + } +} + func TestOpsSystemLogHandler_CleanupMonitoringDisabled(t *testing.T) { svc := service.NewOpsService(nil, nil, &config.Config{ Ops: config.OpsConfig{Enabled: false}, diff --git a/backend/internal/repository/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go index b1ea0f990f..d39ac39cba 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -101,6 +101,10 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { requireColumn(t, tx, "scheduler_outbox", "dedup_key", "text", 0, true) requireIndex(t, tx, "scheduler_outbox", "idx_scheduler_outbox_pending_dedup_key") + // ops_system_logs: API key id index for operational log triage + requireColumn(t, tx, "ops_system_logs", "api_key_id", "bigint", 0, true) + requireIndex(t, tx, "ops_system_logs", "idx_ops_system_logs_api_key_id_created_at") + // user_allowed_groups table should exist var uagRegclass sql.NullString require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT to_regclass('public.user_allowed_groups')").Scan(&uagRegclass)) diff --git a/backend/internal/repository/ops_repo.go b/backend/internal/repository/ops_repo.go index f272812512..9923c08d99 100644 --- a/backend/internal/repository/ops_repo.go +++ b/backend/internal/repository/ops_repo.go @@ -678,6 +678,7 @@ func (r *opsRepository) BatchInsertSystemLogs(ctx context.Context, inputs []*ser "request_id", "client_request_id", "user_id", + "api_key_id", "account_id", "platform", "model", @@ -719,6 +720,7 @@ func (r *opsRepository) BatchInsertSystemLogs(ctx context.Context, inputs []*ser opsNullString(input.RequestID), opsNullString(input.ClientRequestID), opsNullInt64(input.UserID), + opsNullInt64(input.APIKeyID), opsNullInt64(input.AccountID), opsNullString(input.Platform), opsNullString(input.Model), @@ -785,6 +787,7 @@ SELECT COALESCE(l.request_id, ''), COALESCE(l.client_request_id, ''), l.user_id, + l.api_key_id, l.account_id, COALESCE(l.platform, ''), COALESCE(l.model, ''), @@ -804,6 +807,7 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) for rows.Next() { item := &service.OpsSystemLog{} var userID sql.NullInt64 + var apiKeyID sql.NullInt64 var accountID sql.NullInt64 var extraRaw string if err := rows.Scan( @@ -815,6 +819,7 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) &item.RequestID, &item.ClientRequestID, &userID, + &apiKeyID, &accountID, &item.Platform, &item.Model, @@ -826,6 +831,10 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) v := userID.Int64 item.UserID = &v } + if apiKeyID.Valid { + v := apiKeyID.Int64 + item.APIKeyID = &v + } if accountID.Valid { v := accountID.Int64 item.AccountID = &v @@ -1098,6 +1107,11 @@ func buildOpsSystemLogsWhere(filter *service.OpsSystemLogFilter) (string, []any, clauses = append(clauses, "l.user_id = $"+itoa(len(args))) hasConstraint = true } + if filter.APIKeyID != nil && *filter.APIKeyID > 0 { + args = append(args, *filter.APIKeyID) + clauses = append(clauses, "l.api_key_id = $"+itoa(len(args))) + hasConstraint = true + } if filter.AccountID != nil && *filter.AccountID > 0 { args = append(args, *filter.AccountID) clauses = append(clauses, "l.account_id = $"+itoa(len(args))) @@ -1137,6 +1151,7 @@ func buildOpsSystemLogsCleanupWhere(filter *service.OpsSystemLogCleanupFilter) ( RequestID: filter.RequestID, ClientRequestID: filter.ClientRequestID, UserID: filter.UserID, + APIKeyID: filter.APIKeyID, AccountID: filter.AccountID, Platform: filter.Platform, Model: filter.Model, diff --git a/backend/internal/repository/ops_repo_system_logs_test.go b/backend/internal/repository/ops_repo_system_logs_test.go index c3524fe4d1..98199f4828 100644 --- a/backend/internal/repository/ops_repo_system_logs_test.go +++ b/backend/internal/repository/ops_repo_system_logs_test.go @@ -12,6 +12,7 @@ func TestBuildOpsSystemLogsWhere_WithClientRequestIDAndUserID(t *testing.T) { start := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) end := time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC) userID := int64(12) + apiKeyID := int64(56) accountID := int64(34) filter := &service.OpsSystemLogFilter{ @@ -22,6 +23,7 @@ func TestBuildOpsSystemLogsWhere_WithClientRequestIDAndUserID(t *testing.T) { RequestID: "req-1", ClientRequestID: "creq-1", UserID: &userID, + APIKeyID: &apiKeyID, AccountID: &accountID, Platform: "openai", Model: "gpt-5", @@ -35,8 +37,8 @@ func TestBuildOpsSystemLogsWhere_WithClientRequestIDAndUserID(t *testing.T) { if where == "" { t.Fatalf("where should not be empty") } - if len(args) != 11 { - t.Fatalf("args len = %d, want 11", len(args)) + if len(args) != 12 { + t.Fatalf("args len = %d, want 12", len(args)) } if !contains(where, "COALESCE(l.client_request_id,'') = $") { t.Fatalf("where should include client_request_id condition: %s", where) @@ -44,6 +46,9 @@ func TestBuildOpsSystemLogsWhere_WithClientRequestIDAndUserID(t *testing.T) { if !contains(where, "l.user_id = $") { t.Fatalf("where should include user_id condition: %s", where) } + if !contains(where, "l.api_key_id = $") { + t.Fatalf("where should include api_key_id condition: %s", where) + } } func TestBuildOpsSystemLogsCleanupWhere_RequireConstraint(t *testing.T) { @@ -61,17 +66,19 @@ func TestBuildOpsSystemLogsCleanupWhere_RequireConstraint(t *testing.T) { func TestBuildOpsSystemLogsCleanupWhere_WithClientRequestIDAndUserID(t *testing.T) { userID := int64(9) + apiKeyID := int64(10) filter := &service.OpsSystemLogCleanupFilter{ ClientRequestID: "creq-9", UserID: &userID, + APIKeyID: &apiKeyID, } where, args, hasConstraint := buildOpsSystemLogsCleanupWhere(filter) if !hasConstraint { t.Fatalf("expected hasConstraint=true") } - if len(args) != 2 { - t.Fatalf("args len = %d, want 2", len(args)) + if len(args) != 3 { + t.Fatalf("args len = %d, want 3", len(args)) } if !contains(where, "COALESCE(l.client_request_id,'') = $") { t.Fatalf("where should include client_request_id condition: %s", where) @@ -79,6 +86,9 @@ func TestBuildOpsSystemLogsCleanupWhere_WithClientRequestIDAndUserID(t *testing. if !contains(where, "l.user_id = $") { t.Fatalf("where should include user_id condition: %s", where) } + if !contains(where, "l.api_key_id = $") { + t.Fatalf("where should include api_key_id condition: %s", where) + } } func contains(s string, sub string) bool { diff --git a/backend/internal/service/ops_models.go b/backend/internal/service/ops_models.go index 0bbe422060..4fc6a9266e 100644 --- a/backend/internal/service/ops_models.go +++ b/backend/internal/service/ops_models.go @@ -11,6 +11,7 @@ type OpsSystemLog struct { RequestID string `json:"request_id"` ClientRequestID string `json:"client_request_id"` UserID *int64 `json:"user_id"` + APIKeyID *int64 `json:"api_key_id"` AccountID *int64 `json:"account_id"` Platform string `json:"platform"` Model string `json:"model"` diff --git a/backend/internal/service/ops_port.go b/backend/internal/service/ops_port.go index 0cba300d11..46d171c7c3 100644 --- a/backend/internal/service/ops_port.go +++ b/backend/internal/service/ops_port.go @@ -200,6 +200,7 @@ type OpsInsertSystemLogInput struct { RequestID string ClientRequestID string UserID *int64 + APIKeyID *int64 AccountID *int64 Platform string Model string @@ -216,6 +217,7 @@ type OpsSystemLogFilter struct { RequestID string ClientRequestID string UserID *int64 + APIKeyID *int64 AccountID *int64 Platform string Model string @@ -235,6 +237,7 @@ type OpsSystemLogCleanupFilter struct { RequestID string ClientRequestID string UserID *int64 + APIKeyID *int64 AccountID *int64 Platform string Model string diff --git a/backend/internal/service/ops_system_log_service.go b/backend/internal/service/ops_system_log_service.go index f5a648036a..b3be37e8ae 100644 --- a/backend/internal/service/ops_system_log_service.go +++ b/backend/internal/service/ops_system_log_service.go @@ -100,6 +100,9 @@ func marshalSystemLogCleanupConditions(filter *OpsSystemLogCleanupFilter) string if filter.UserID != nil { payload["user_id"] = *filter.UserID } + if filter.APIKeyID != nil { + payload["api_key_id"] = *filter.APIKeyID + } if filter.AccountID != nil { payload["account_id"] = *filter.AccountID } diff --git a/backend/internal/service/ops_system_log_service_test.go b/backend/internal/service/ops_system_log_service_test.go index cc9ddefee7..8b5a84c1f0 100644 --- a/backend/internal/service/ops_system_log_service_test.go +++ b/backend/internal/service/ops_system_log_service_test.go @@ -97,6 +97,7 @@ func TestOpsServiceCleanupSystemLogs_SuccessAndAudit(t *testing.T) { } svc := NewOpsService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) userID := int64(7) + apiKeyID := int64(8) now := time.Now().UTC() filter := &OpsSystemLogCleanupFilter{ StartTime: &now, @@ -104,6 +105,7 @@ func TestOpsServiceCleanupSystemLogs_SuccessAndAudit(t *testing.T) { RequestID: "req-1", ClientRequestID: "creq-1", UserID: &userID, + APIKeyID: &apiKeyID, Query: "timeout", } @@ -123,6 +125,9 @@ func TestOpsServiceCleanupSystemLogs_SuccessAndAudit(t *testing.T) { if !strings.Contains(audit.Conditions, `"user_id":7`) { t.Fatalf("audit conditions should include user_id: %s", audit.Conditions) } + if !strings.Contains(audit.Conditions, `"api_key_id":8`) { + t.Fatalf("audit conditions should include api_key_id: %s", audit.Conditions) + } } func TestOpsServiceCleanupSystemLogs_RepoUnavailableAndInvalidOperator(t *testing.T) { @@ -216,13 +221,15 @@ func TestMarshalSystemLogCleanupConditions_NilAndMarshalError(t *testing.T) { now := time.Now().UTC() userID := int64(1) + apiKeyID := int64(2) filter := &OpsSystemLogCleanupFilter{ StartTime: &now, EndTime: &now, UserID: &userID, + APIKeyID: &apiKeyID, } got := marshalSystemLogCleanupConditions(filter) - if !strings.Contains(got, `"start_time"`) || !strings.Contains(got, `"user_id":1`) { + if !strings.Contains(got, `"start_time"`) || !strings.Contains(got, `"user_id":1`) || !strings.Contains(got, `"api_key_id":2`) { t.Fatalf("unexpected marshal payload: %s", got) } } diff --git a/backend/internal/service/ops_system_log_sink.go b/backend/internal/service/ops_system_log_sink.go index c50a30d5c9..2ff273be53 100644 --- a/backend/internal/service/ops_system_log_sink.go +++ b/backend/internal/service/ops_system_log_sink.go @@ -206,6 +206,7 @@ func (s *OpsSystemLogSink) flushBatch(baseCtx context.Context, batch []*logger.L } userID := asInt64Ptr(fields["user_id"]) + apiKeyID := asInt64Ptr(fields["api_key_id"]) accountID := asInt64Ptr(fields["account_id"]) // 统一脱敏后写入索引。 @@ -225,6 +226,7 @@ func (s *OpsSystemLogSink) flushBatch(baseCtx context.Context, batch []*logger.L RequestID: requestID, ClientRequestID: clientRequestID, UserID: userID, + APIKeyID: apiKeyID, AccountID: accountID, Platform: platform, Model: model, diff --git a/backend/internal/service/ops_system_log_sink_test.go b/backend/internal/service/ops_system_log_sink_test.go index 137ee33c72..b43d44c32e 100644 --- a/backend/internal/service/ops_system_log_sink_test.go +++ b/backend/internal/service/ops_system_log_sink_test.go @@ -155,6 +155,7 @@ func TestOpsSystemLogSink_StartStopAndFlushSuccess(t *testing.T) { "request_id": "req-1", "client_request_id": "creq-1", "user_id": "12", + "api_key_id": int64(56), "account_id": json.Number("34"), "platform": "openai", "model": "gpt-5", @@ -177,6 +178,9 @@ func TestOpsSystemLogSink_StartStopAndFlushSuccess(t *testing.T) { if item.UserID == nil || *item.UserID != 12 { t.Fatalf("unexpected user_id: %+v", item.UserID) } + if item.APIKeyID == nil || *item.APIKeyID != 56 { + t.Fatalf("unexpected api_key_id: %+v", item.APIKeyID) + } if item.AccountID == nil || *item.AccountID != 34 { t.Fatalf("unexpected account_id: %+v", item.AccountID) } diff --git a/backend/migrations/154_add_ops_system_logs_api_key_id.sql b/backend/migrations/154_add_ops_system_logs_api_key_id.sql new file mode 100644 index 0000000000..dbb2a084de --- /dev/null +++ b/backend/migrations/154_add_ops_system_logs_api_key_id.sql @@ -0,0 +1,5 @@ +-- 154_add_ops_system_logs_api_key_id.sql +-- Persist API key database id as a queryable system log index column. + +ALTER TABLE ops_system_logs + ADD COLUMN IF NOT EXISTS api_key_id BIGINT; diff --git a/backend/migrations/155_add_ops_system_logs_api_key_id_index_notx.sql b/backend/migrations/155_add_ops_system_logs_api_key_id_index_notx.sql new file mode 100644 index 0000000000..8db1a4ac3f --- /dev/null +++ b/backend/migrations/155_add_ops_system_logs_api_key_id_index_notx.sql @@ -0,0 +1,5 @@ +-- 155_add_ops_system_logs_api_key_id_index_notx.sql +-- Non-transactional migration: CREATE INDEX CONCURRENTLY cannot run in a transaction. + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ops_system_logs_api_key_id_created_at + ON ops_system_logs (api_key_id, created_at DESC); diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index dd39426b80..25179d4bce 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -833,6 +833,7 @@ export interface OpsSystemLog { request_id?: string client_request_id?: string user_id?: number | null + api_key_id?: number | null account_id?: number | null platform?: string model?: string @@ -852,6 +853,7 @@ export interface OpsSystemLogQuery { request_id?: string client_request_id?: string user_id?: number | null + api_key_id?: number | null account_id?: number | null platform?: string model?: string @@ -866,6 +868,7 @@ export interface OpsSystemLogCleanupRequest { request_id?: string client_request_id?: string user_id?: number | null + api_key_id?: number | null account_id?: number | null platform?: string model?: string diff --git a/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue b/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue index b80e8e8a23..e73785a642 100644 --- a/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue +++ b/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue @@ -51,6 +51,7 @@ const filters = reactive({ request_id: '', client_request_id: '', user_id: '', + api_key_id: '', account_id: '', platform: '', model: '', @@ -138,6 +139,7 @@ const formatSystemLogDetail = (row: OpsSystemLog) => { if (row.request_id) corrParts.push(`req=${row.request_id}`) if (row.client_request_id) corrParts.push(`client_req=${row.client_request_id}`) if (row.user_id != null) corrParts.push(`user=${row.user_id}`) + if (row.api_key_id != null) corrParts.push(`key=${row.api_key_id}`) if (row.account_id != null) corrParts.push(`acc=${row.account_id}`) if (row.platform) corrParts.push(`platform=${row.platform}`) if (row.model) corrParts.push(`model=${row.model}`) @@ -179,6 +181,10 @@ const buildQuery = () => { const v = Number.parseInt(filters.user_id.trim(), 10) if (Number.isFinite(v) && v > 0) query.user_id = v } + if (filters.api_key_id.trim()) { + const v = Number.parseInt(filters.api_key_id.trim(), 10) + if (Number.isFinite(v) && v > 0) query.api_key_id = v + } if (filters.account_id.trim()) { const v = Number.parseInt(filters.account_id.trim(), 10) if (Number.isFinite(v) && v > 0) query.account_id = v @@ -285,6 +291,7 @@ const cleanupCurrentFilter = async () => { request_id: filters.request_id.trim() || undefined, client_request_id: filters.client_request_id.trim() || undefined, user_id: filters.user_id.trim() ? Number.parseInt(filters.user_id.trim(), 10) : undefined, + api_key_id: filters.api_key_id.trim() ? Number.parseInt(filters.api_key_id.trim(), 10) : undefined, account_id: filters.account_id.trim() ? Number.parseInt(filters.account_id.trim(), 10) : undefined, platform: filters.platform.trim() || undefined, model: filters.model.trim() || undefined, @@ -309,6 +316,7 @@ const resetFilters = () => { filters.request_id = '' filters.client_request_id = '' filters.user_id = '' + filters.api_key_id = '' filters.account_id = '' filters.platform = props.platformFilter || '' filters.model = '' @@ -456,6 +464,10 @@ onMounted(async () => { user_id +