2026-04-15 11:07:19 +08:00

536 lines
17 KiB
TypeScript

import "server-only";
import { z } from "zod";
import type {
AnalyticsPlatform,
AnalyticsBatchPayload,
DashboardData,
DashboardQuery,
JsonValue,
PublicAnalyticsSnapshot,
} from "@/lib/analytics/contracts";
import { ANALYTICS_RANGE_OPTIONS } from "@/lib/analytics/contracts";
import {
describeAnalyticsEventName,
describeAnalyticsPath,
describeAnalyticsType,
} from "@/lib/analytics/presenter";
import { prisma } from "@/lib/prisma";
import { SITE_DOMAIN } from "@/lib/site";
const nullableStringField = (max: number) => z.string().trim().max(max).nullish();
const jsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
z.union([
z.string(),
z.number().finite(),
z.boolean(),
z.null(),
z.array(jsonValueSchema),
z.record(z.string(), jsonValueSchema),
]),
);
const analyticsSessionSchema = z.object({
sessionKey: z.string().trim().min(8).max(120),
platform: z.literal("ANDROID"),
domain: nullableStringField(255),
channel: nullableStringField(80),
visitorId: nullableStringField(120),
userId: nullableStringField(120),
locale: nullableStringField(40),
timezone: nullableStringField(80),
referrer: nullableStringField(500),
userAgent: nullableStringField(500),
ipAddress: nullableStringField(64),
appVersion: nullableStringField(40),
browser: nullableStringField(80),
browserVersion: nullableStringField(80),
deviceType: nullableStringField(40),
deviceBrand: nullableStringField(80),
deviceModel: nullableStringField(120),
osName: nullableStringField(80),
osVersion: nullableStringField(80),
screenWidth: z.number().int().positive().max(10000).nullish(),
screenHeight: z.number().int().positive().max(10000).nullish(),
});
const analyticsEventSchema = z.object({
name: z.string().trim().min(1).max(120),
type: z.string().trim().min(1).max(80),
pageUrl: nullableStringField(500),
pathname: nullableStringField(255),
title: nullableStringField(255),
referrer: nullableStringField(500),
elementTag: nullableStringField(80),
elementText: nullableStringField(240),
elementId: nullableStringField(120),
elementRole: nullableStringField(80),
component: nullableStringField(120),
value: nullableStringField(240),
durationMs: z.number().int().nonnegative().max(2_147_483_647).nullish(),
sequence: z.number().int().nonnegative().max(1_000_000).nullish(),
occurredAt: z.string().datetime().optional(),
metadata: jsonValueSchema.nullish(),
});
const analyticsBatchSchema = z.object({
session: analyticsSessionSchema,
events: z.array(analyticsEventSchema).min(1).max(100),
});
type ValidatedBatch = z.infer<typeof analyticsBatchSchema>;
function truncate(value: string | null | undefined, max: number) {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
}
function readClientIp(requestHeaders: Headers) {
const forwarded = requestHeaders.get("x-forwarded-for");
if (forwarded) {
return truncate(forwarded.split(",")[0] ?? undefined, 64);
}
return truncate(requestHeaders.get("x-real-ip"), 64);
}
function detectBrowser(userAgent: string | undefined) {
if (!userAgent) {
return undefined;
}
if (/edg\//i.test(userAgent)) {
return "Edge";
}
if (/chrome\//i.test(userAgent)) {
return "Chrome";
}
if (/safari\//i.test(userAgent) && !/chrome\//i.test(userAgent)) {
return "Safari";
}
if (/firefox\//i.test(userAgent)) {
return "Firefox";
}
if (/okhttp/i.test(userAgent)) {
return "OkHttp";
}
return "Unknown";
}
function detectOs(userAgent: string | undefined) {
if (!userAgent) {
return { osName: undefined, osVersion: undefined, deviceType: undefined };
}
const androidMatch = userAgent.match(/Android\s([\d.]+)/i);
if (androidMatch) {
return { osName: "Android", osVersion: androidMatch[1], deviceType: "mobile" };
}
const iosMatch = userAgent.match(/OS\s([\d_]+)/i);
if (iosMatch) {
return { osName: "iOS", osVersion: iosMatch[1]?.replaceAll("_", "."), deviceType: "mobile" };
}
if (/Windows/i.test(userAgent)) {
return { osName: "Windows", osVersion: undefined, deviceType: "desktop" };
}
if (/Mac OS X/i.test(userAgent)) {
return { osName: "macOS", osVersion: undefined, deviceType: "desktop" };
}
if (/Linux/i.test(userAgent)) {
return { osName: "Linux", osVersion: undefined, deviceType: "desktop" };
}
return { osName: undefined, osVersion: undefined, deviceType: undefined };
}
function buildRangeFilter(columnName: string, rangeStart: Date) {
return `"${columnName}" >= '${rangeStart.toISOString()}' AND "platform" = 'ANDROID'`;
}
function countResult(rows: Array<{ total: bigint | number | string | null | undefined }>) {
const total = rows[0]?.total;
return Number(total ?? 0);
}
function formatBucket(bucket: Date, days: number) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: days <= 2 ? "2-digit" : undefined,
}).format(bucket);
}
function buildDeviceLabel(session: {
deviceBrand: string | null;
deviceModel: string | null;
browser: string | null;
osName: string | null;
}) {
const device = [session.deviceBrand, session.deviceModel].filter(Boolean).join(" ").trim();
if (device) {
return device;
}
const browser = [session.browser, session.osName].filter(Boolean).join(" / ").trim();
return browser || "未标记设备";
}
export function parseDashboardQuery(raw: { range?: string }): DashboardQuery {
const parsedDays = Number(raw.range ?? 7);
const days = ANALYTICS_RANGE_OPTIONS.includes(parsedDays as (typeof ANALYTICS_RANGE_OPTIONS)[number])
? parsedDays
: 7;
return { days };
}
export function validateAnalyticsPayload(payload: unknown) {
return analyticsBatchSchema.parse(payload);
}
export async function ingestAnalyticsBatch(payload: unknown, requestHeaders: Headers) {
const validated = validateAnalyticsPayload(payload);
return saveAnalyticsBatch(validated, requestHeaders);
}
async function saveAnalyticsBatch(payload: ValidatedBatch, requestHeaders: Headers) {
const now = new Date();
const requestDomain = truncate(
requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? SITE_DOMAIN,
255,
);
const requestUserAgent = truncate(requestHeaders.get("user-agent"), 500);
const ipAddress = payload.session.ipAddress ?? readClientIp(requestHeaders);
const userAgent = payload.session.userAgent ?? requestUserAgent;
const osGuess = detectOs(userAgent ?? undefined);
const browser = payload.session.browser ?? detectBrowser(userAgent ?? undefined);
const sessionData = {
sessionKey: payload.session.sessionKey,
platform: payload.session.platform,
domain: truncate(payload.session.domain ?? requestDomain, 255),
channel: truncate(payload.session.channel ?? undefined, 80),
visitorId: truncate(payload.session.visitorId ?? undefined, 120),
userId: truncate(payload.session.userId ?? undefined, 120),
locale: truncate(payload.session.locale ?? undefined, 40),
timezone: truncate(payload.session.timezone ?? undefined, 80),
referrer: truncate(payload.session.referrer ?? undefined, 500),
userAgent,
ipAddress,
appVersion: truncate(payload.session.appVersion ?? undefined, 40),
browser: truncate(browser ?? undefined, 80),
browserVersion: truncate(payload.session.browserVersion ?? undefined, 80),
deviceType: truncate(payload.session.deviceType ?? osGuess.deviceType ?? undefined, 40),
deviceBrand: truncate(payload.session.deviceBrand ?? undefined, 80),
deviceModel: truncate(payload.session.deviceModel ?? undefined, 120),
osName: truncate(payload.session.osName ?? osGuess.osName ?? undefined, 80),
osVersion: truncate(payload.session.osVersion ?? osGuess.osVersion ?? undefined, 80),
screenWidth: payload.session.screenWidth ?? undefined,
screenHeight: payload.session.screenHeight ?? undefined,
lastSeenAt: now,
};
const events = payload.events.map((event) => ({
sessionKey: payload.session.sessionKey,
platform: payload.session.platform,
name: truncate(event.name, 120) ?? "unknown",
type: truncate(event.type, 80) ?? "custom",
pageUrl: truncate(event.pageUrl ?? undefined, 500),
pathname: truncate(event.pathname ?? undefined, 255),
title: truncate(event.title ?? undefined, 255),
referrer: truncate(event.referrer ?? payload.session.referrer ?? undefined, 500),
elementTag: truncate(event.elementTag ?? undefined, 80),
elementText: truncate(event.elementText ?? undefined, 240),
elementId: truncate(event.elementId ?? undefined, 120),
elementRole: truncate(event.elementRole ?? undefined, 80),
component: truncate(event.component ?? undefined, 120),
value: truncate(event.value ?? undefined, 240),
durationMs: event.durationMs ?? undefined,
sequence: event.sequence ?? undefined,
metadata: event.metadata == null ? undefined : event.metadata,
occurredAt: event.occurredAt ? new Date(event.occurredAt) : now,
}));
const session = await prisma.$transaction(async (tx: {
analyticsSession: typeof prisma.analyticsSession;
analyticsEvent: typeof prisma.analyticsEvent;
}) => {
const activeSession = await tx.analyticsSession.upsert({
where: { sessionKey: payload.session.sessionKey },
update: {
...sessionData,
eventCount: { increment: events.length },
},
create: {
...sessionData,
startedAt: now,
eventCount: events.length,
},
select: { id: true, sessionKey: true },
});
await tx.analyticsEvent.createMany({
data: events.map((event) => ({
...event,
sessionId: activeSession.id,
})),
});
return activeSession;
});
return {
accepted: events.length,
sessionKey: session.sessionKey,
};
}
export async function getDashboardData(query: DashboardQuery): Promise<DashboardData> {
const now = new Date();
const rangeStart = new Date(now.getTime() - query.days * 24 * 60 * 60 * 1000);
const sessionFilter = buildRangeFilter("lastSeenAt", rangeStart);
const eventFilter = buildRangeFilter("occurredAt", rangeStart);
const sessionWhere = {
lastSeenAt: { gte: rangeStart },
platform: "ANDROID" as const,
};
const eventWhere = {
occurredAt: { gte: rangeStart },
platform: "ANDROID" as const,
};
const [
sessionCountRows,
visitorCountRows,
eventCountRows,
errorCountRows,
activityTrendRows,
topEventRows,
topPathRows,
recentSessions,
recentEvents,
] = await Promise.all([
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
SELECT COUNT(*)::bigint AS total
FROM "AnalyticsSession"
WHERE ${sessionFilter}
`),
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
SELECT COUNT(DISTINCT COALESCE(NULLIF("visitorId", ''), "sessionKey"))::bigint AS total
FROM "AnalyticsSession"
WHERE ${sessionFilter}
`),
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
SELECT COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
`),
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
SELECT COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
AND (
lower("type") = 'error'
OR lower("name") LIKE '%error%'
OR lower("name") LIKE '%fail%'
)
`),
prisma.$queryRawUnsafe<Array<{ bucket: Date; total: bigint }>>(`
SELECT date_trunc('${query.days <= 2 ? "hour" : "day"}', "occurredAt") AS bucket,
COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
GROUP BY 1
ORDER BY 1 ASC
`),
prisma.$queryRawUnsafe<Array<{ label: string; detail: string | null; total: bigint }>>(`
SELECT "name" AS label,
MIN("type") AS detail,
COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
GROUP BY "name"
ORDER BY total DESC
LIMIT 8
`),
prisma.$queryRawUnsafe<Array<{ label: string; total: bigint }>>(`
SELECT COALESCE(NULLIF("pathname", ''), '(未标记页面)') AS label,
COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
GROUP BY 1
ORDER BY total DESC
LIMIT 8
`),
prisma.analyticsSession.findMany({
where: sessionWhere,
orderBy: { lastSeenAt: "desc" },
take: 12,
select: {
sessionKey: true,
platform: true,
eventCount: true,
locale: true,
appVersion: true,
deviceBrand: true,
deviceModel: true,
browser: true,
osName: true,
lastSeenAt: true,
},
}),
prisma.analyticsEvent.findMany({
where: eventWhere,
orderBy: { occurredAt: "desc" },
take: 18,
select: {
id: true,
sessionKey: true,
platform: true,
name: true,
type: true,
pathname: true,
component: true,
elementText: true,
occurredAt: true,
metadata: true,
},
}),
]);
const sessionCount = countResult(sessionCountRows);
const uniqueVisitors = countResult(visitorCountRows);
const eventCount = countResult(eventCountRows);
const errorCount = countResult(errorCountRows);
return {
stats: [
{
label: "活跃设备",
value: sessionCount,
hint: `${query.days} 天内仍在上报数据的 Android 设备`,
},
{
label: "活跃使用者",
value: uniqueVisitors,
hint: "按安装标识或会话编号去重后的实际使用主体",
},
{
label: "互动记录",
value: eventCount,
hint: sessionCount > 0 ? `平均每台设备 ${(eventCount / sessionCount).toFixed(1)}` : "当前还没有数据",
},
{
label: "异常提醒",
value: errorCount,
hint: "连接、播放、录音和资源准备失败都会在这里体现",
},
],
activityTrend: activityTrendRows.map((row: { bucket: Date; total: bigint | number | string }) => ({
bucket: formatBucket(row.bucket, query.days),
total: Number(row.total),
})),
topEvents: topEventRows.map((row: { label: string; detail: string | null; total: bigint | number | string }) => ({
label: describeAnalyticsEventName(row.label),
detail: row.detail ? describeAnalyticsType(row.detail) : row.detail,
total: Number(row.total),
})),
topPaths: topPathRows.map((row: { label: string; total: bigint | number | string }) => ({
label: describeAnalyticsPath(row.label),
total: Number(row.total),
})),
recentEvents: recentEvents.map((event: {
id: string;
sessionKey: string;
platform: AnalyticsPlatform;
name: string;
type: string;
pathname: string | null;
component: string | null;
elementText: string | null;
occurredAt: Date;
metadata: unknown;
}) => ({
id: event.id,
sessionKey: event.sessionKey,
platform: event.platform,
name: describeAnalyticsEventName(event.name),
type: describeAnalyticsType(event.type),
pathname: describeAnalyticsPath(event.pathname),
component: event.component,
elementText: event.elementText,
occurredAt: event.occurredAt.toISOString(),
metadata: (event.metadata ?? null) as DashboardData["recentEvents"][number]["metadata"],
})),
recentSessions: recentSessions.map((session: {
sessionKey: string;
platform: AnalyticsPlatform;
eventCount: number;
locale: string | null;
appVersion: string | null;
deviceBrand: string | null;
deviceModel: string | null;
browser: string | null;
osName: string | null;
lastSeenAt: Date;
}) => ({
sessionKey: session.sessionKey,
platform: session.platform,
eventCount: session.eventCount,
locale: session.locale,
appVersion: session.appVersion,
deviceLabel: buildDeviceLabel(session),
lastSeenAt: session.lastSeenAt.toISOString(),
})),
};
}
export async function getPublicAnalyticsSnapshot(): Promise<PublicAnalyticsSnapshot> {
const rangeStart = new Date(Date.now() - 24 * 60 * 60 * 1000);
const eventFilter = buildRangeFilter("occurredAt", rangeStart);
const sessionFilter = buildRangeFilter("lastSeenAt", rangeStart);
const [eventRows, sessionRows, topActionRows] = await Promise.all([
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
SELECT COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
`),
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
SELECT COUNT(*)::bigint AS total
FROM "AnalyticsSession"
WHERE ${sessionFilter}
`),
prisma.$queryRawUnsafe<Array<{ label: string; total: bigint }>>(`
SELECT "name" AS label, COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
GROUP BY 1
ORDER BY total DESC
LIMIT 1
`),
]);
const events24h = countResult(eventRows);
const sessions24h = countResult(sessionRows);
return {
events24h,
sessions24h,
topAction: topActionRows[0] ? describeAnalyticsEventName(topActionRows[0].label) : "等待第一条记录",
};
}
export type AnalyticsPayload = AnalyticsBatchPayload;