添加埋点管理台
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
export const ANALYTICS_RANGE_OPTIONS = [1, 7, 30, 90] as const;
|
||||
export const TRACK_FLUSH_INTERVAL_MS = 5000;
|
||||
export const TRACK_BATCH_SIZE = 12;
|
||||
|
||||
export type AnalyticsPlatform = "WEB" | "ANDROID";
|
||||
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
export interface AnalyticsSessionPayload {
|
||||
sessionKey: string;
|
||||
platform: AnalyticsPlatform;
|
||||
domain?: string;
|
||||
channel?: string;
|
||||
visitorId?: string;
|
||||
userId?: string;
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
referrer?: string;
|
||||
userAgent?: string;
|
||||
ipAddress?: string;
|
||||
appVersion?: string;
|
||||
browser?: string;
|
||||
browserVersion?: string;
|
||||
deviceType?: string;
|
||||
deviceBrand?: string;
|
||||
deviceModel?: string;
|
||||
osName?: string;
|
||||
osVersion?: string;
|
||||
screenWidth?: number;
|
||||
screenHeight?: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsEventPayload {
|
||||
name: string;
|
||||
type: string;
|
||||
pageUrl?: string;
|
||||
pathname?: string;
|
||||
title?: string;
|
||||
referrer?: string;
|
||||
elementTag?: string;
|
||||
elementText?: string;
|
||||
elementId?: string;
|
||||
elementRole?: string;
|
||||
component?: string;
|
||||
value?: string;
|
||||
durationMs?: number;
|
||||
sequence?: number;
|
||||
occurredAt?: string;
|
||||
metadata?: JsonValue;
|
||||
}
|
||||
|
||||
export interface AnalyticsBatchPayload {
|
||||
session: AnalyticsSessionPayload;
|
||||
events: AnalyticsEventPayload[];
|
||||
}
|
||||
|
||||
export interface DashboardQuery {
|
||||
days: number;
|
||||
}
|
||||
|
||||
export interface DashboardStat {
|
||||
label: string;
|
||||
value: number;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export interface DashboardTrendPoint {
|
||||
bucket: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DashboardTopItem {
|
||||
label: string;
|
||||
total: number;
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
export interface DashboardRecentEvent {
|
||||
id: string;
|
||||
sessionKey: string;
|
||||
platform: AnalyticsPlatform;
|
||||
name: string;
|
||||
type: string;
|
||||
pathname: string | null;
|
||||
component: string | null;
|
||||
elementText: string | null;
|
||||
occurredAt: string;
|
||||
metadata: JsonValue | null;
|
||||
}
|
||||
|
||||
export interface DashboardRecentSession {
|
||||
sessionKey: string;
|
||||
platform: AnalyticsPlatform;
|
||||
eventCount: number;
|
||||
locale: string | null;
|
||||
appVersion: string | null;
|
||||
deviceLabel: string;
|
||||
lastSeenAt: string;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
stats: DashboardStat[];
|
||||
activityTrend: DashboardTrendPoint[];
|
||||
topEvents: DashboardTopItem[];
|
||||
topPaths: DashboardTopItem[];
|
||||
recentEvents: DashboardRecentEvent[];
|
||||
recentSessions: DashboardRecentSession[];
|
||||
}
|
||||
|
||||
export interface PublicAnalyticsSnapshot {
|
||||
events24h: number;
|
||||
sessions24h: number;
|
||||
topAction: string;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
screen_view: "打开页面",
|
||||
voice_clone_entry_tap: "进入音色克隆",
|
||||
avatar_selected: "选择数字人形象",
|
||||
call_page_opened: "进入陪伴通话",
|
||||
base_config_download_started: "开始准备基础资源",
|
||||
base_config_download_completed: "基础资源准备完成",
|
||||
base_config_download_failed: "基础资源准备失败",
|
||||
avatar_download_started: "开始准备数字人资源",
|
||||
avatar_download_completed: "数字人资源准备完成",
|
||||
avatar_download_failed: "数字人资源准备失败",
|
||||
clone_avatar_selected: "选择克隆形象",
|
||||
voice_sample_discarded: "删除录音样本",
|
||||
voice_rerecord_requested: "重新录音",
|
||||
voice_record_started: "开始录音",
|
||||
voice_record_start_failed: "录音启动失败",
|
||||
voice_record_too_short: "录音时长过短",
|
||||
voice_record_finished: "录音完成",
|
||||
voice_record_stop_failed: "录音结束失败",
|
||||
voice_clone_create_started: "开始创建专属音色",
|
||||
voice_clone_create_succeeded: "专属音色创建成功",
|
||||
voice_clone_create_failed: "专属音色创建失败",
|
||||
voice_clone_chat_started: "开始体验专属音色",
|
||||
call_activity_opened: "打开通话页面",
|
||||
ai_conversation_unavailable: "陪伴通话暂不可用",
|
||||
clone_voice_unavailable: "专属音色暂不可用",
|
||||
ai_conversation_connecting: "陪伴通话连接中",
|
||||
ai_conversation_connected: "陪伴通话已连接",
|
||||
ai_conversation_disconnected: "陪伴通话已断开",
|
||||
ai_conversation_error: "陪伴通话异常",
|
||||
user_transcript_received: "收到用户语音文本",
|
||||
assistant_transcript_completed: "陪伴回复已生成",
|
||||
assistant_response_done: "陪伴回复结束",
|
||||
ai_conversation_stopped: "陪伴通话已结束",
|
||||
clone_tts_started: "开始播放专属音色",
|
||||
clone_tts_finished: "专属音色播放完成",
|
||||
clone_tts_error: "专属音色播放失败",
|
||||
camera_enabled: "打开摄像头",
|
||||
camera_disabled: "关闭摄像头",
|
||||
camera_capture_error: "摄像头采集失败",
|
||||
};
|
||||
|
||||
const PATH_LABELS: Record<string, string> = {
|
||||
"/android/main": "首页",
|
||||
"/android/call": "陪伴通话",
|
||||
"/android/voice-clone-setup": "音色克隆",
|
||||
"(未标记页面)": "未标记功能",
|
||||
};
|
||||
|
||||
function humanizeFallback(value: string) {
|
||||
const normalized = value.trim().replace(/[_-]+/g, " ");
|
||||
return normalized || "未命名记录";
|
||||
}
|
||||
|
||||
export function describeAnalyticsEventName(name: string) {
|
||||
return EVENT_LABELS[name] ?? humanizeFallback(name);
|
||||
}
|
||||
|
||||
export function describeAnalyticsPath(pathname: string | null | undefined) {
|
||||
if (!pathname) {
|
||||
return "未标记功能";
|
||||
}
|
||||
|
||||
if (PATH_LABELS[pathname]) {
|
||||
return PATH_LABELS[pathname];
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/android/")) {
|
||||
const label = pathname.replace("/android/", "").replace(/-/g, " ").trim();
|
||||
return label ? `Android 功能 ${label}` : "Android 功能";
|
||||
}
|
||||
|
||||
return pathname;
|
||||
}
|
||||
|
||||
export function describeAnalyticsType(type: string) {
|
||||
switch (type.toLowerCase()) {
|
||||
case "navigation":
|
||||
return "页面浏览";
|
||||
case "interaction":
|
||||
return "操作记录";
|
||||
case "error":
|
||||
return "异常提醒";
|
||||
default:
|
||||
return humanizeFallback(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user