feat(ops): add api key filter to system logs

This commit is contained in:
Bestony
2026-06-27 14:35:19 +08:00
parent c275422251
commit bad87ff533
15 changed files with 134 additions and 5 deletions
@@ -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),
@@ -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},
@@ -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))
+15
View File
@@ -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,
@@ -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 {
+1
View File
@@ -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"`
+3
View File
@@ -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
@@ -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
}
@@ -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)
}
}
@@ -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,
@@ -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)
}
@@ -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;
@@ -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);
+3
View File
@@ -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
@@ -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
<input v-model="filters.user_id" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
KEY ID
<input v-model="filters.api_key_id" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
account_id
<input v-model="filters.account_id" type="text" class="input mt-1" />