"use client"; import { useCallback } from "react"; /** * useErrorReport - 客户端错误上报 Hook(对齐 CICD use-error-report.ts) * * 通过 navigator.sendBeacon 上报到 /api/log(fallback 到 fetch keepalive) * 节流:基于 error.digest 在 sessionStorage 中记录,1 分钟内同 digest 只上报一次 * 上报失败静默降级,不影响用户体验 * * 上报 payload 结构: * { * level: "error" | "warning", * message: string, * stack?: string, * digest?: string, // Next.js 自动生成的错误摘要 * path: string, // window.location.pathname * userAgent: string, * timestamp: string, // ISO 8601 * pluginId?: string, // 插件级错误标识 * userId?: string, // 当前用户 ID(从 localStorage 读取) * context?: Record // 额外上下文 * } * * 关联:portal-shell README v2.0 §5.4 三级错误处理 */ /** 错误上报 payload */ export interface ErrorReportPayload { level: "error" | "warning"; message: string; stack?: string; digest?: string; path: string; userAgent: string; timestamp: string; pluginId?: string; userId?: string; context?: Record; } /** * 上报端点 * * P3:生产环境使用 api-gateway 的 /api/v1/log(通过 next.config.js rewrites 代理) * 开发环境 DEV_MODE=true 时回退到 Next.js API Route /api/log(mock 端点) */ const REPORT_ENDPOINT = typeof process !== "undefined" && process.env.NODE_ENV === "production" ? "/api/v1/log" : "/api/log"; /** 节流窗口(1 分钟内同 digest 只上报一次) */ const THROTTLE_WINDOW_MS = 60_000; /** sessionStorage key 前缀 */ const THROTTLE_KEY_PREFIX = "edu_err_reported_"; /** * 读取当前用户 ID(从 localStorage,避免引入 auth 依赖) */ function readUserId(): string | undefined { try { const raw = localStorage.getItem("edu_user_id"); return raw ?? undefined; } catch { return undefined; } } /** * 生成错误 digest(简单 hash,用于节流去重) * * 优先使用 error.digest(Next.js 自动生成), * 否则基于 message + stack 前 200 字符生成简单 hash */ function makeDigest(error: Error): string { const nextDigest = (error as Error & { digest?: string }).digest; if (nextDigest) return nextDigest; const stackSnippet = (error.stack ?? "").slice(0, 200); const input = `${error.message}::${stackSnippet}`; // 简单 FNV-1a hash let hash = 2166136261; for (let i = 0; i < input.length; i++) { hash ^= input.charCodeAt(i); hash = Math.imul(hash, 16777619); } return (hash >>> 0).toString(36); } /** * 检查并更新节流记录 * * @returns true 表示应该上报,false 表示已被节流 */ function checkThrottle(digest: string): boolean { try { const key = `${THROTTLE_KEY_PREFIX}${digest}`; const now = Date.now(); const last = sessionStorage.getItem(key); if (last) { const lastTime = parseInt(last, 10); if (Number.isFinite(lastTime) && now - lastTime < THROTTLE_WINDOW_MS) { return false; // 节流窗口内,跳过 } } sessionStorage.setItem(key, String(now)); return true; } catch { // sessionStorage 不可用时不过节流,直接上报 return true; } } /** * 实际执行上报 */ function sendReport(payload: ErrorReportPayload): void { const body = JSON.stringify(payload); // 优先 sendBeacon(不阻塞页面卸载) if (typeof navigator !== "undefined" && navigator.sendBeacon) { try { const blob = new Blob([body], { type: "application/json" }); if (navigator.sendBeacon(REPORT_ENDPOINT, blob)) { return; } } catch { // sendBeacon 失败,降级到 fetch } } // 降级到 fetch keepalive try { void fetch(REPORT_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true, credentials: "include", }).catch(() => { // 上报失败静默降级 }); } catch { // 完全失败,静默 } } /** * 错误上报 Hook * * @example * function MyComponent() { * const reportError = useErrorReport(); * try { riskyOperation(); } * catch (e) { reportError(e, { pluginId: "grades-widget" }); } * } * * @example 与 ErrorBoundary 配合 * reportError(err)}> * * */ export function useErrorReport() { const reportError = useCallback( ( error: Error, options?: { pluginId?: string; level?: "error" | "warning"; context?: Record; }, ): void => { const digest = makeDigest(error); if (!checkThrottle(digest)) return; const payload: ErrorReportPayload = { level: options?.level ?? "error", message: error.message, stack: error.stack, digest, path: typeof window !== "undefined" ? window.location.pathname : "/", userAgent: typeof navigator !== "undefined" ? navigator.userAgent : "unknown", timestamp: new Date().toISOString(), pluginId: options?.pluginId, userId: readUserId(), context: options?.context, }; sendReport(payload); }, [], ); return reportError; }