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 = 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; 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 { 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>(` SELECT COUNT(*)::bigint AS total FROM "AnalyticsSession" WHERE ${sessionFilter} `), prisma.$queryRawUnsafe>(` SELECT COUNT(DISTINCT COALESCE(NULLIF("visitorId", ''), "sessionKey"))::bigint AS total FROM "AnalyticsSession" WHERE ${sessionFilter} `), prisma.$queryRawUnsafe>(` SELECT COUNT(*)::bigint AS total FROM "AnalyticsEvent" WHERE ${eventFilter} `), prisma.$queryRawUnsafe>(` 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>(` 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>(` 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>(` 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 { 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>(` SELECT COUNT(*)::bigint AS total FROM "AnalyticsEvent" WHERE ${eventFilter} `), prisma.$queryRawUnsafe>(` SELECT COUNT(*)::bigint AS total FROM "AnalyticsSession" WHERE ${sessionFilter} `), prisma.$queryRawUnsafe>(` 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;