import { DEV } from 'cc/env'; interface PfRealtimeLogger { error?: (m: string) => void; warn?: (m: string) => void; } /** 小游戏 pf / tt 上的 RealtimeLog 工厂 */ interface PfPlatformApi { getRealtimeLogManager?: (opts?: { syncToConsole?: boolean }) => PfRealtimeLogger; } interface ClientErrorGg { sdk?: { pf?: PfPlatformApi; pfLog?: (msg: string) => void; }; } /** * 客户端已捕获异常:本地始终 console.error;平台仅 pfLog + RealtimeLog(不走 ZYSDK / PF-ERR)。 */ export class ClientErrorReporter { /** 同一 capture + 消息摘要的上平台间隔 */ private static readonly MSG_COOLDOWN_MS = 60000; /** 同一 capture 任意内容上平台间隔 */ private static readonly CAPTURE_GAP_MS = 60000; /** 每个 capture 整会话最多上平台次数 */ private static readonly CAPTURE_SESSION_MAX = 3; /** 全会话滚动窗口(毫秒) */ private static readonly GLOBAL_WINDOW_MS = 600000; /** 滚动窗口内平台上限 */ private static readonly GLOBAL_WINDOW_MAX = 20; private static _lastMsgMsByKey: Record = {}; private static _lastCapturePlatformMs: Record = {}; private static _capturePlatformCount: Record = {}; private static _globalWindowStart = 0; private static _globalWindowCount = 0; private static _silencedCaptureWarned: Record = {}; private static _realtime: PfRealtimeLogger | null = null; private static _realtimeReady = false; /** * 上报捕获点异常:本地必打;平台上报(pfLog)受会话/窗口上限约束,不上 ZYSDK。 * @param capture 捕获点标识 * @param err 异常或消息 */ static report(capture: string, err: unknown): void { const detail = ClientErrorReporter._stringify(err); console.error(`[${capture}]`, detail); const cap = ClientErrorReporter._sanitizeToken(capture, 28); const msgCore = ClientErrorReporter._messageCore(detail); if (!ClientErrorReporter._allowPlatformUpload(cap, msgCore)) return; const det = ClientErrorReporter._sanitizeDetail(detail); const platformMsg = `ERR-${cap}-${det}`.slice(0, 250); ClientErrorReporter._recordPlatformUpload(cap, msgCore); ClientErrorReporter._platformLog(platformMsg); } private static _allowPlatformUpload(cap: string, msgCore: string): boolean { const now = Date.now(); if (ClientErrorReporter._globalWindowStart <= 0 || now - ClientErrorReporter._globalWindowStart > ClientErrorReporter.GLOBAL_WINDOW_MS) { ClientErrorReporter._globalWindowStart = now; ClientErrorReporter._globalWindowCount = 0; } if (ClientErrorReporter._globalWindowCount >= ClientErrorReporter.GLOBAL_WINDOW_MAX) return false; const count = ClientErrorReporter._capturePlatformCount[cap] ?? 0; if (count >= ClientErrorReporter.CAPTURE_SESSION_MAX) { if (!ClientErrorReporter._silencedCaptureWarned[cap]) { ClientErrorReporter._silencedCaptureWarned[cap] = true; console.warn('[ClientErrorReporter] capture 平台上报已达上限,后续仅本地日志', cap); } return false; } const lastCap = ClientErrorReporter._lastCapturePlatformMs[cap] ?? 0; if (now - lastCap < ClientErrorReporter.CAPTURE_GAP_MS) return false; const msgKey = `${cap}:${msgCore}`; const lastMsg = ClientErrorReporter._lastMsgMsByKey[msgKey] ?? 0; if (now - lastMsg < ClientErrorReporter.MSG_COOLDOWN_MS) return false; return true; } private static _recordPlatformUpload(cap: string, msgCore: string): void { const now = Date.now(); ClientErrorReporter._globalWindowCount++; ClientErrorReporter._capturePlatformCount[cap] = (ClientErrorReporter._capturePlatformCount[cap] ?? 0) + 1; ClientErrorReporter._lastCapturePlatformMs[cap] = now; ClientErrorReporter._lastMsgMsByKey[`${cap}:${msgCore}`] = now; } private static _gg(): ClientErrorGg | undefined { return (typeof globalThis !== 'undefined' ? (globalThis as { gg?: ClientErrorGg }).gg : undefined); } private static _platformLog(msg: string): void { try { ClientErrorReporter._gg()?.sdk?.pfLog?.(msg); } catch { /* ignore */ } const logger = ClientErrorReporter._realtimeLogger(); if (logger?.error) { try { logger.error(msg); } catch { /* ignore */ } } } private static _realtimeLogger(): PfRealtimeLogger | null { if (ClientErrorReporter._realtimeReady) return ClientErrorReporter._realtime; ClientErrorReporter._realtimeReady = true; const pf: PfPlatformApi | null | undefined = ClientErrorReporter._gg()?.sdk?.pf ?? (typeof globalThis !== 'undefined' ? (globalThis as { tt?: PfPlatformApi }).tt : null); if (!pf?.getRealtimeLogManager) { ClientErrorReporter._realtime = null; return null; } try { ClientErrorReporter._realtime = pf.getRealtimeLogManager({ syncToConsole: !!DEV }); } catch { ClientErrorReporter._realtime = null; } return ClientErrorReporter._realtime; } private static _stringify(err: unknown): string { if (err instanceof Error) { const stack = err.stack ? `\n${err.stack}` : ''; return `${err.message}${stack}`; } if (typeof err === 'string') return err; try { return JSON.stringify(err); } catch { return String(err); } } /** 去重用消息摘要(不含 stack 行,避免每帧变体) */ private static _messageCore(detail: string): string { const oneLine = String(detail ?? '').replace(/[\r\n]+/g, ' ').trim(); const bar = oneLine.indexOf(' | '); const core = bar >= 0 ? oneLine.slice(0, bar) : oneLine; return core.slice(0, 80); } private static _sanitizeToken(raw: string, maxLen: number): string { const s = String(raw ?? '').replace(/[^a-zA-Z0-9_]/g, '_'); return s.length > 0 ? s.slice(0, maxLen) : 'unknown'; } private static _sanitizeDetail(raw: string): string { return String(raw ?? '') .replace(/[\r\n]+/g, ' ') .replace(/[^a-zA-Z0-9_\u4e00-\u9fa5 :|.\-_]/g, '_') .slice(0, 160); } }