272 lines
10 KiB
TypeScript
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();
|
|
}
|
|
}
|