import { and, desc, gte, sql, type SQL } from "drizzle-orm"; import { NextResponse } from "next/server"; import { db } from "@/lib/db"; import { accessIpEvent } from "@/lib/db/schema"; import { ChatSDKError } from "@/lib/errors"; import { requireAdmin } from "@/lib/middleware/admin"; export const runtime = "nodejs"; export const maxDuration = 10; type Severity = "info" | "watch" | "high" | "critical"; const severityByRank: Record = { 0: "info", 1: "watch", 2: "high", 3: "critical", }; function clampInt(value: string | null, fallback: number, min: number, max: number) { const parsed = Number.parseInt(value || "", 10); if (!Number.isFinite(parsed)) return fallback; return Math.min(Math.max(parsed, min), max); } function normalizeSearch(value: string | null) { return String(value || "").trim().slice(0, 120); } function buildSeverityFromAggregate(params: { eventSeverityRank: number; total: number; failureCount: number; rateLimitedCount: number; authFailureCount: number; suspiciousCount: number; }) { let severity = severityByRank[params.eventSeverityRank] || "info"; const failureRate = params.total > 0 ? params.failureCount / Math.max(params.total, 1) : 0; if (params.rateLimitedCount > 0 || params.suspiciousCount > 0) { severity = "high"; } else if (params.authFailureCount >= 5 || params.total >= 250) { severity = severity === "critical" ? severity : "high"; } else if ( params.failureCount >= 20 || (params.total >= 10 && failureRate >= 0.45) ) { severity = severity === "critical" ? severity : "high"; } else if (params.authFailureCount > 0 || params.failureCount > 0) { severity = severity === "info" ? "watch" : severity; } return severity; } function buildAggregateReasons(params: { total: number; failureCount: number; rateLimitedCount: number; authFailureCount: number; suspiciousCount: number; loginSuccessCount: number; }) { const reasons: string[] = []; const failureRate = params.total > 0 ? params.failureCount / Math.max(params.total, 1) : 0; if (params.rateLimitedCount > 0) reasons.push("触发限流"); if (params.authFailureCount >= 5) reasons.push("认证失败偏多"); if (params.suspiciousCount > 0) reasons.push("命中异常规则"); if (params.total >= 250) reasons.push("请求量偏高"); if (params.failureCount >= 20 || (params.total >= 10 && failureRate >= 0.45)) { reasons.push("失败率偏高"); } if (params.loginSuccessCount > 0 && reasons.length === 0) { reasons.push("有成功登录记录"); } return reasons; } export async function GET(request: Request) { try { await requireAdmin(); const { searchParams } = new URL(request.url); const hours = clampInt(searchParams.get("hours"), 24, 1, 168); const limit = clampInt(searchParams.get("limit"), 120, 25, 500); const severityFilter = searchParams.get("severity") || "all"; const search = normalizeSearch(searchParams.get("q")); const since = new Date(Date.now() - hours * 60 * 60 * 1000); const whereParts: SQL[] = [gte(accessIpEvent.createdAt, since)]; if (severityFilter === "abnormal") { whereParts.push(sql`${accessIpEvent.severity} <> 'info'`); } else if (["info", "watch", "high", "critical"].includes(severityFilter)) { whereParts.push(sql`${accessIpEvent.severity} = ${severityFilter}`); } if (search) { const pattern = `%${search}%`; whereParts.push(sql`( ${accessIpEvent.ip} ILIKE ${pattern} OR ${accessIpEvent.path} ILIKE ${pattern} OR ${accessIpEvent.userEmail} ILIKE ${pattern} OR ${accessIpEvent.userAgent} ILIKE ${pattern} )`); } const whereClause = and(...whereParts); const [overview] = await db .select({ totalEvents: sql`count(*)::int`, uniqueIps: sql`count(distinct ${accessIpEvent.ip})::int`, abnormalEvents: sql`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`, abnormalIps: sql`count(distinct ${accessIpEvent.ip}) filter (where ${accessIpEvent.severity} <> 'info')::int`, rateLimitedEvents: sql`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`, manualBlockedEvents: sql`count(*) filter (where ${accessIpEvent.outcome} = 'manual_blocked')::int`, loginSuccessEvents: sql`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`, unauthorizedEvents: sql`count(*) filter (where ${accessIpEvent.outcome} = 'unauthorized' or ${accessIpEvent.statusCode} in (401, 403))::int`, }) .from(accessIpEvent) .where(whereClause); const recentEvents = await db .select({ id: accessIpEvent.id, eventType: accessIpEvent.eventType, ip: accessIpEvent.ip, userId: accessIpEvent.userId, userEmail: accessIpEvent.userEmail, userRole: accessIpEvent.userRole, method: accessIpEvent.method, path: accessIpEvent.path, routeGroup: accessIpEvent.routeGroup, statusCode: accessIpEvent.statusCode, outcome: accessIpEvent.outcome, severity: accessIpEvent.severity, reasons: accessIpEvent.reasons, userAgent: accessIpEvent.userAgent, referer: accessIpEvent.referer, occurredAt: accessIpEvent.occurredAt, createdAt: accessIpEvent.createdAt, }) .from(accessIpEvent) .where(whereClause) .orderBy(desc(accessIpEvent.createdAt)) .limit(limit); const topIpRows = await db .select({ ip: accessIpEvent.ip, total: sql`count(*)::int`, uniqueUsers: sql`count(distinct ${accessIpEvent.userId}) filter (where ${accessIpEvent.userId} is not null)::int`, failureCount: sql`count(*) filter (where ${accessIpEvent.statusCode} >= 400)::int`, rateLimitedCount: sql`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`, authFailureCount: sql`count(*) filter (where ${accessIpEvent.path} like '/api/auth%' and ${accessIpEvent.statusCode} >= 400)::int`, loginSuccessCount: sql`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`, suspiciousCount: sql`count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical'))::int`, firstSeen: sql`min(${accessIpEvent.createdAt})::text`, lastSeen: sql`max(${accessIpEvent.createdAt})::text`, eventSeverityRank: sql`max(case ${accessIpEvent.severity} when 'critical' then 3 when 'high' then 2 when 'watch' then 1 else 0 end)::int`, }) .from(accessIpEvent) .where(whereClause) .groupBy(accessIpEvent.ip) .orderBy( desc(sql`( count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical')) * 8 + count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429) * 12 + count(*) filter (where ${accessIpEvent.statusCode} >= 400) * 3 + count(*) )`) ) .limit(24); const topIps = topIpRows.map((row) => { const severity = buildSeverityFromAggregate({ eventSeverityRank: Number(row.eventSeverityRank || 0), total: Number(row.total || 0), failureCount: Number(row.failureCount || 0), rateLimitedCount: Number(row.rateLimitedCount || 0), authFailureCount: Number(row.authFailureCount || 0), suspiciousCount: Number(row.suspiciousCount || 0), }); return { ...row, severity, failureRate: Number(row.total || 0) > 0 ? Number(row.failureCount || 0) / Number(row.total || 1) : 0, reasons: buildAggregateReasons({ total: Number(row.total || 0), failureCount: Number(row.failureCount || 0), rateLimitedCount: Number(row.rateLimitedCount || 0), authFailureCount: Number(row.authFailureCount || 0), suspiciousCount: Number(row.suspiciousCount || 0), loginSuccessCount: Number(row.loginSuccessCount || 0), }), }; }); const hourlyStats = await db .select({ hour: sql`to_char(date_trunc('hour', ${accessIpEvent.createdAt}), 'MM-DD HH24:00')`, count: sql`count(*)::int`, abnormalCount: sql`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`, }) .from(accessIpEvent) .where(whereClause) .groupBy(sql`date_trunc('hour', ${accessIpEvent.createdAt})`) .orderBy(sql`date_trunc('hour', ${accessIpEvent.createdAt})`); const pathStats = await db .select({ path: accessIpEvent.path, routeGroup: accessIpEvent.routeGroup, count: sql`count(*)::int`, abnormalCount: sql`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`, }) .from(accessIpEvent) .where(whereClause) .groupBy(accessIpEvent.path, accessIpEvent.routeGroup) .orderBy(desc(sql`count(*)`)) .limit(12); return NextResponse.json({ window: { hours, since: since.toISOString(), }, overview: { totalEvents: overview?.totalEvents || 0, uniqueIps: overview?.uniqueIps || 0, abnormalEvents: overview?.abnormalEvents || 0, abnormalIps: overview?.abnormalIps || 0, rateLimitedEvents: overview?.rateLimitedEvents || 0, manualBlockedEvents: overview?.manualBlockedEvents || 0, loginSuccessEvents: overview?.loginSuccessEvents || 0, unauthorizedEvents: overview?.unauthorizedEvents || 0, }, topIps, hourlyStats, pathStats, recentEvents, rules: [ "短时间请求量偏高", "4xx/5xx 或失败率偏高", "认证失败偏多", "触发限流", "命中敏感路径或异常 User-Agent", ], }); } catch (error) { if (error instanceof ChatSDKError) { return error.toResponse(); } console.error("[AccessIpEvent] failed to load admin analytics", error); return new ChatSDKError("offline:admin").toResponse(); } }