272 lines
10 KiB
TypeScript

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<number, Severity> = {
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<number>`count(*)::int`,
uniqueIps: sql<number>`count(distinct ${accessIpEvent.ip})::int`,
abnormalEvents: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
abnormalIps: sql<number>`count(distinct ${accessIpEvent.ip}) filter (where ${accessIpEvent.severity} <> 'info')::int`,
rateLimitedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`,
manualBlockedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'manual_blocked')::int`,
loginSuccessEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`,
unauthorizedEvents: sql<number>`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<number>`count(*)::int`,
uniqueUsers: sql<number>`count(distinct ${accessIpEvent.userId}) filter (where ${accessIpEvent.userId} is not null)::int`,
failureCount: sql<number>`count(*) filter (where ${accessIpEvent.statusCode} >= 400)::int`,
rateLimitedCount: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`,
authFailureCount: sql<number>`count(*) filter (where ${accessIpEvent.path} like '/api/auth%' and ${accessIpEvent.statusCode} >= 400)::int`,
loginSuccessCount: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`,
suspiciousCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical'))::int`,
firstSeen: sql<string>`min(${accessIpEvent.createdAt})::text`,
lastSeen: sql<string>`max(${accessIpEvent.createdAt})::text`,
eventSeverityRank: sql<number>`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<string>`to_char(date_trunc('hour', ${accessIpEvent.createdAt}), 'MM-DD HH24:00')`,
count: sql<number>`count(*)::int`,
abnormalCount: sql<number>`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<number>`count(*)::int`,
abnormalCount: sql<number>`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();
}
}