Files
Edu/packages/hooks/src/use-error-report.ts
SpecialX f586a0b19e feat(portal-shell): v2.0 P1-P4 token migration + unit tests + prod endpoint + e2e
P1: 31 widget 旧纸感令牌批量迁移到 shadcn 标准(1104 次替换)
- bg-paper→bg-background / bg-surface→bg-card / text-ink→text-foreground
- 保留 button.tsx 中 bg-accent(shadcn 标准 hover 语义令牌)

P2: v2.0 新增组件单元测试补齐(5 文件 81 用例)
- permission-bitmap: 24 用例(含 GRADE_READ 重复去重)
- route-permissions: 26 用例(4 张表优先级 + AND/OR 语义)
- notify: 12 用例(sonner toast 双重性质 vi.hoisted mock)
- use-error-report: 9 用例(jsdom Blob vi.stubGlobal mock)
- plugin-boundary: 10 用例(错误边界 + 骨架变体)

P3: 错误上报端点生产替换(后端 /api/v1/log)
- api-gateway: internal/log/handler.go(slog 结构化日志,64KB 限制,204 返回)
- main.go: 注册 POST /api/v1/log 路由
- useErrorReport: 环境感知端点(prod→/api/v1/log,dev→/api/log)

P4: E2E 测试(3 文件 30 用例)
- streaming: 4 用例(React 19 use() + Suspense,act 包裹 render)
- error-boundaries: 6 用例(三级错误边界层级 L1/L2/L3)
- security-boundaries: 20 用例(L1 角色门禁 + L2 权限点 + L3 数据范围)
- vitest setup: IS_REACT_ACT_ENVIRONMENT + jest-dom matchers

验证:typecheck 0 错误 / lint 0 错误 / build 6 路由 / 206 测试全部通过
2026-07-17 16:49:00 +08:00

199 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback } from "react";
/**
* useErrorReport - 客户端错误上报 Hook对齐 CICD use-error-report.ts
*
* 通过 navigator.sendBeacon 上报到 /api/logfallback 到 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<string, unknown> // 额外上下文
* }
*
* 关联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<string, unknown>;
}
/**
* 上报端点
*
* P3生产环境使用 api-gateway 的 /api/v1/log通过 next.config.js rewrites 代理)
* 开发环境 DEV_MODE=true 时回退到 Next.js API Route /api/logmock 端点)
*/
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.digestNext.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 配合
* <ErrorBoundary onError={(err) => reportError(err)}>
* <Plugin />
* </ErrorBoundary>
*/
export function useErrorReport() {
const reportError = useCallback(
(
error: Error,
options?: {
pluginId?: string;
level?: "error" | "warning";
context?: Record<string, unknown>;
},
): 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;
}