From 205c1110c2ee03b90892aff506c6113efc643851 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sun, 2 Aug 2026 16:27:08 +0800 Subject: [PATCH] feat(ops): add client error reporting API and frontend hooks Collect mini-user/shop/partner JS errors via POST /common/client-errors, persist logs, and push fatal/error to WeCom. Co-authored-by: Cursor --- apps/h5-partner/src/lib/client-error.ts | 90 +++++++++++++ apps/h5-partner/src/main.tsx | 3 + apps/h5-shop/src/lib/client-error.ts | 99 ++++++++++++++ apps/h5-shop/src/main.tsx | 3 + apps/mini-user/src/app.tsx | 2 + apps/mini-user/src/lib/client-error.ts | 125 ++++++++++++++++++ .../modules/common/client-error.controller.ts | 25 ++++ .../modules/common/client-error.service.ts | 110 +++++++++++++++ .../src/modules/common/common.module.ts | 4 + .../modules/common/dto/client-error.dto.ts | 48 +++++++ 杜康好客-知识库.md | 11 ++ 11 files changed, 520 insertions(+) create mode 100644 apps/h5-partner/src/lib/client-error.ts create mode 100644 apps/h5-shop/src/lib/client-error.ts create mode 100644 apps/mini-user/src/lib/client-error.ts create mode 100644 server/dukang-api/src/modules/common/client-error.controller.ts create mode 100644 server/dukang-api/src/modules/common/client-error.service.ts create mode 100644 server/dukang-api/src/modules/common/dto/client-error.dto.ts diff --git a/apps/h5-partner/src/lib/client-error.ts b/apps/h5-partner/src/lib/client-error.ts new file mode 100644 index 0000000..278bedc --- /dev/null +++ b/apps/h5-partner/src/lib/client-error.ts @@ -0,0 +1,90 @@ +import { apiBase, getToken } from './api'; + +const CLIENT_APP = 'PARTNER_H5'; + +export type ClientErrorLevel = 'fatal' | 'error' | 'warn'; +export type ClientErrorCategory = + | 'js_error' + | 'unhandled_rejection' + | 'api_error' + | 'network' + | 'render' + | 'bridge' + | 'other'; + +export type ClientErrorPayload = { + level: ClientErrorLevel; + category: ClientErrorCategory; + message: string; + stack?: string; + pagePath?: string; + extra?: Record; +}; + +function currentPagePath(): string | undefined { + try { + return typeof window !== 'undefined' ? window.location.pathname : undefined; + } catch { + return undefined; + } +} + +/** 上报客户端错误(失败静默,避免递归) */ +export function reportClientError(payload: ClientErrorPayload): void { + const body = { + level: payload.level, + category: payload.category, + message: String(payload.message || 'unknown').slice(0, 1000), + stack: payload.stack ? String(payload.stack).slice(0, 4000) : undefined, + pagePath: (payload.pagePath || currentPagePath() || '').slice(0, 128) || undefined, + clientApp: CLIENT_APP, + extra: payload.extra, + }; + + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Client-App': CLIENT_APP, + }; + const token = getToken(); + if (token) headers.Authorization = `Bearer ${token}`; + + void fetch(`${apiBase}/common/client-errors`, { + method: 'POST', + headers, + body: JSON.stringify(body), + keepalive: true, + }).catch(() => {}); +} + +let installed = false; + +/** 安装 H5 全局未捕获错误钩子(幂等) */ +export function installClientErrorReporting(): void { + if (installed || typeof window === 'undefined') return; + installed = true; + + window.addEventListener('error', (ev) => { + reportClientError({ + level: 'fatal', + category: 'js_error', + message: ev.message || 'window.error', + stack: ev.error instanceof Error ? ev.error.stack : undefined, + extra: { filename: ev.filename, lineno: ev.lineno, colno: ev.colno }, + }); + }); + + window.addEventListener('unhandledrejection', (ev) => { + const reason = ev.reason; + reportClientError({ + level: 'error', + category: 'unhandled_rejection', + message: + reason instanceof Error + ? reason.message + : typeof reason === 'string' + ? reason + : 'unhandledrejection', + stack: reason instanceof Error ? reason.stack : undefined, + }); + }); +} diff --git a/apps/h5-partner/src/main.tsx b/apps/h5-partner/src/main.tsx index 714c575..af09a1c 100644 --- a/apps/h5-partner/src/main.tsx +++ b/apps/h5-partner/src/main.tsx @@ -5,9 +5,12 @@ import { getRouterBasename } from '@dukang/weixin-sdk'; import App from './App'; import { PartnerSessionProvider } from './contexts/PartnerSessionContext'; import { PartnerToastProvider } from './contexts/PartnerToastContext'; +import { installClientErrorReporting } from './lib/client-error'; import './styles.css'; import './styles/legal.css'; +installClientErrorReporting(); + ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/apps/h5-shop/src/lib/client-error.ts b/apps/h5-shop/src/lib/client-error.ts new file mode 100644 index 0000000..af84868 --- /dev/null +++ b/apps/h5-shop/src/lib/client-error.ts @@ -0,0 +1,99 @@ +import { apiBase } from './api'; + +const CLIENT_APP = 'SHOP_H5'; +const ACCESS_TOKEN = 'accessToken'; + +export type ClientErrorLevel = 'fatal' | 'error' | 'warn'; +export type ClientErrorCategory = + | 'js_error' + | 'unhandled_rejection' + | 'api_error' + | 'network' + | 'render' + | 'bridge' + | 'other'; + +export type ClientErrorPayload = { + level: ClientErrorLevel; + category: ClientErrorCategory; + message: string; + stack?: string; + pagePath?: string; + extra?: Record; +}; + +function currentPagePath(): string | undefined { + try { + return typeof window !== 'undefined' ? window.location.pathname : undefined; + } catch { + return undefined; + } +} + +function getToken(): string { + try { + return localStorage.getItem(ACCESS_TOKEN) || ''; + } catch { + return ''; + } +} + +/** 上报客户端错误(失败静默,避免递归) */ +export function reportClientError(payload: ClientErrorPayload): void { + const body = { + level: payload.level, + category: payload.category, + message: String(payload.message || 'unknown').slice(0, 1000), + stack: payload.stack ? String(payload.stack).slice(0, 4000) : undefined, + pagePath: (payload.pagePath || currentPagePath() || '').slice(0, 128) || undefined, + clientApp: CLIENT_APP, + extra: payload.extra, + }; + + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Client-App': CLIENT_APP, + }; + const token = getToken(); + if (token) headers.Authorization = `Bearer ${token}`; + + void fetch(`${apiBase}/common/client-errors`, { + method: 'POST', + headers, + body: JSON.stringify(body), + keepalive: true, + }).catch(() => {}); +} + +let installed = false; + +/** 安装 H5 全局未捕获错误钩子(幂等) */ +export function installClientErrorReporting(): void { + if (installed || typeof window === 'undefined') return; + installed = true; + + window.addEventListener('error', (ev) => { + reportClientError({ + level: 'fatal', + category: 'js_error', + message: ev.message || 'window.error', + stack: ev.error instanceof Error ? ev.error.stack : undefined, + extra: { filename: ev.filename, lineno: ev.lineno, colno: ev.colno }, + }); + }); + + window.addEventListener('unhandledrejection', (ev) => { + const reason = ev.reason; + reportClientError({ + level: 'error', + category: 'unhandled_rejection', + message: + reason instanceof Error + ? reason.message + : typeof reason === 'string' + ? reason + : 'unhandledrejection', + stack: reason instanceof Error ? reason.stack : undefined, + }); + }); +} diff --git a/apps/h5-shop/src/main.tsx b/apps/h5-shop/src/main.tsx index 2e7a6da..39b4bd4 100644 --- a/apps/h5-shop/src/main.tsx +++ b/apps/h5-shop/src/main.tsx @@ -3,10 +3,13 @@ import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { getRouterBasename } from '@dukang/weixin-sdk'; import { StoreSessionProvider } from './contexts/StoreSessionContext'; +import { installClientErrorReporting } from './lib/client-error'; import App from './App'; import './styles.css'; import './styles/legal.css'; +installClientErrorReporting(); + ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/apps/mini-user/src/app.tsx b/apps/mini-user/src/app.tsx index 13b40d0..a65b356 100644 --- a/apps/mini-user/src/app.tsx +++ b/apps/mini-user/src/app.tsx @@ -4,10 +4,12 @@ import Taro, { useDidShow } from '@tarojs/taro'; import WechatShareBootstrap from './components/WechatShareBootstrap'; import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks'; import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm'; +import { installClientErrorReporting } from './lib/client-error'; import './app.css'; // H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime patchTaroH5Hooks(); +installClientErrorReporting(); function App({ children }: PropsWithChildren) { const handlingRef = useRef(false); diff --git a/apps/mini-user/src/lib/client-error.ts b/apps/mini-user/src/lib/client-error.ts new file mode 100644 index 0000000..0a1e627 --- /dev/null +++ b/apps/mini-user/src/lib/client-error.ts @@ -0,0 +1,125 @@ +import Taro from '@tarojs/taro'; +import { CLIENT_APP, getToken, API_BASE } from './api'; + +export type ClientErrorLevel = 'fatal' | 'error' | 'warn'; +export type ClientErrorCategory = + | 'js_error' + | 'unhandled_rejection' + | 'api_error' + | 'network' + | 'render' + | 'bridge' + | 'other'; + +export type ClientErrorPayload = { + level: ClientErrorLevel; + category: ClientErrorCategory; + message: string; + stack?: string; + pagePath?: string; + extra?: Record; +}; + +function currentPagePath(): string | undefined { + try { + const pages = Taro.getCurrentPages(); + const cur = pages[pages.length - 1] as { route?: string; $taroPath?: string } | undefined; + return cur?.$taroPath || cur?.route || undefined; + } catch { + return undefined; + } +} + +/** 上报客户端错误(失败静默,避免递归) */ +export function reportClientError(payload: ClientErrorPayload): void { + const body = { + level: payload.level, + category: payload.category, + message: String(payload.message || 'unknown').slice(0, 1000), + stack: payload.stack ? String(payload.stack).slice(0, 4000) : undefined, + pagePath: payload.pagePath || currentPagePath(), + clientApp: CLIENT_APP, + extra: payload.extra, + }; + + const header: Record = { + 'Content-Type': 'application/json', + 'X-Client-App': CLIENT_APP, + }; + const token = getToken(); + if (token) header.Authorization = `Bearer ${token}`; + + void Taro.request({ + url: `${API_BASE}/common/client-errors`, + method: 'POST', + data: body, + header, + }).catch(() => {}); +} + +let installed = false; + +/** 安装小程序/H5 全局未捕获错误钩子(幂等) */ +export function installClientErrorReporting(): void { + if (installed) return; + installed = true; + + try { + Taro.onError?.((msg) => { + reportClientError({ + level: 'fatal', + category: 'js_error', + message: typeof msg === 'string' ? msg : String(msg), + }); + }); + } catch { + /* ignore */ + } + + try { + Taro.onUnhandledRejection?.((res) => { + const reason = (res as { reason?: unknown })?.reason; + const message = + reason instanceof Error + ? reason.message + : typeof reason === 'string' + ? reason + : JSON.stringify(reason); + const stack = reason instanceof Error ? reason.stack : undefined; + reportClientError({ + level: 'error', + category: 'unhandled_rejection', + message: message || 'UnhandledRejection', + stack, + }); + }); + } catch { + /* ignore */ + } + + if (typeof window !== 'undefined') { + window.addEventListener('error', (ev) => { + reportClientError({ + level: 'fatal', + category: 'js_error', + message: ev.message || 'window.error', + stack: ev.error instanceof Error ? ev.error.stack : undefined, + extra: { filename: ev.filename, lineno: ev.lineno, colno: ev.colno }, + }); + }); + window.addEventListener('unhandledrejection', (ev) => { + const reason = ev.reason; + reportClientError({ + level: 'error', + category: 'unhandled_rejection', + message: + reason instanceof Error + ? reason.message + : typeof reason === 'string' + ? reason + : 'unhandledrejection', + stack: reason instanceof Error ? reason.stack : undefined, + }); + }); + } +} diff --git a/server/dukang-api/src/modules/common/client-error.controller.ts b/server/dukang-api/src/modules/common/client-error.controller.ts new file mode 100644 index 0000000..446ac85 --- /dev/null +++ b/server/dukang-api/src/modules/common/client-error.controller.ts @@ -0,0 +1,25 @@ +import { Body, Controller, Headers, Post, UseGuards } from '@nestjs/common'; +import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { AuthUser } from '../../common/guards/jwt-auth.guard'; +import { ClientErrorService } from './client-error.service'; +import { ReportClientErrorDto } from './dto/client-error.dto'; + +@Controller('common') +export class ClientErrorController { + constructor(private readonly clientErrors: ClientErrorService) {} + + /** + * 客户端报错上报(可匿名)。 + * fatal/error → Nest 日志 + 落库 + 企微;warn → 仅日志/落库。 + */ + @Post('client-errors') + @UseGuards(OptionalJwtAuthGuard) + report( + @Body() dto: ReportClientErrorDto, + @CurrentUser() user: AuthUser | undefined, + @Headers('x-client-app') clientApp?: string, + ) { + return this.clientErrors.report(dto, user, clientApp); + } +} diff --git a/server/dukang-api/src/modules/common/client-error.service.ts b/server/dukang-api/src/modules/common/client-error.service.ts new file mode 100644 index 0000000..15f4215 --- /dev/null +++ b/server/dukang-api/src/modules/common/client-error.service.ts @@ -0,0 +1,110 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { ClientApp, Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { AlertService } from '../../common/alert/alert.service'; +import type { AlertLevel } from '../../common/alert/alert.constants'; +import type { AuthUser } from '../../common/guards/jwt-auth.guard'; +import type { ReportClientErrorDto } from './dto/client-error.dto'; + +const WECOM_LEVELS = new Set(['fatal', 'error']); + +@Injectable() +export class ClientErrorService { + private readonly logger = new Logger(ClientErrorService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly alert: AlertService, + ) {} + + async report(dto: ReportClientErrorDto, user?: AuthUser, headerClientApp?: string) { + const clientApp = (dto.clientApp || headerClientApp || user?.clientApp || 'UNKNOWN').slice(0, 32); + const message = dto.message.trim().slice(0, 1000); + const stack = dto.stack?.trim().slice(0, 4000); + const pagePath = dto.pagePath?.trim().slice(0, 128); + const fingerprint = `${dto.level}|${dto.category}|${message.slice(0, 120)}`; + + const logLine = { + level: dto.level, + category: dto.category, + message, + pagePath, + clientApp, + actorType: user?.actorType, + actorId: user?.actorId != null ? String(user.actorId) : undefined, + stack: stack?.slice(0, 800), + extra: dto.extra, + }; + + if (dto.level === 'fatal') { + this.logger.error(`[client_error] ${JSON.stringify(logLine)}`); + } else if (dto.level === 'error') { + this.logger.error(`[client_error] ${JSON.stringify(logLine)}`); + } else { + this.logger.warn(`[client_error] ${JSON.stringify(logLine)}`); + } + + // 落库便于 HQ 排查(匿名也可写,userId 为空) + try { + await this.prisma.logUserAnalytics.create({ + data: { + userId: + user?.actorType === 'USER' && user.actorId != null ? user.actorId : null, + eventName: 'client_error', + clientApp: isClientApp(clientApp) ? clientApp : null, + pagePath: pagePath || null, + extraJson: { + level: dto.level, + category: dto.category, + message, + stack: stack || null, + actorType: user?.actorType ?? null, + actorId: user?.actorId != null ? String(user.actorId) : null, + storeId: user?.storeId != null ? String(user.storeId) : null, + ...(dto.extra && typeof dto.extra === 'object' ? { clientExtra: dto.extra } : {}), + } as Prisma.InputJsonValue, + }, + }); + } catch (e) { + this.logger.warn( + `client_error persist failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + + if (WECOM_LEVELS.has(dto.level)) { + const alertLevel: AlertLevel = dto.level === 'fatal' ? 'P0' : 'P1'; + this.alert.notify({ + level: alertLevel, + category: 'client_error', + title: `客户端报错 [${dto.level}/${dto.category}]`, + detail: [ + `端:${clientApp}`, + pagePath ? `页面:${pagePath}` : null, + user?.actorId != null + ? `用户:${user.actorType}:${String(user.actorId)}` + : '用户:匿名', + `消息:${message}`, + stack ? `堆栈:${stack.slice(0, 600)}` : null, + ] + .filter(Boolean) + .join('\n'), + dedupeKey: `client_error|${fingerprint}`, + dedupeTtlSec: 600, + }); + } + + return { ok: true }; + } +} + +function isClientApp(v: string): v is ClientApp { + return [ + 'USER_MINI', + 'USER_H5', + 'PARTNER_MINI', + 'PARTNER_H5', + 'HQ_MINI', + 'HQ_WEB', + 'SHOP_H5', + ].includes(v); +} diff --git a/server/dukang-api/src/modules/common/common.module.ts b/server/dukang-api/src/modules/common/common.module.ts index 8b4bc34..d042579 100644 --- a/server/dukang-api/src/modules/common/common.module.ts +++ b/server/dukang-api/src/modules/common/common.module.ts @@ -16,6 +16,8 @@ import { WechatController } from './wechat.controller'; import { ClientConfigController } from './client-config.controller'; import { LbsController } from './lbs.controller'; import { WechatLocationService } from './wechat-location.service'; +import { ClientErrorService } from './client-error.service'; +import { ClientErrorController } from './client-error.controller'; @Module({ imports: [forwardRef(() => IamModule), IntegrationsModule, SystemConfigModule, forwardRef(() => AnalyticsModule)], @@ -27,6 +29,7 @@ import { WechatLocationService } from './wechat-location.service'; WechatController, ClientConfigController, LbsController, + ClientErrorController, ], providers: [ ResourceService, @@ -35,6 +38,7 @@ import { WechatLocationService } from './wechat-location.service'; SupportTicketService, ThirdPartyLogService, WechatLocationService, + ClientErrorService, ], exports: [ResourceService, EventService, TicketService, SupportTicketService], }) diff --git a/server/dukang-api/src/modules/common/dto/client-error.dto.ts b/server/dukang-api/src/modules/common/dto/client-error.dto.ts new file mode 100644 index 0000000..6f7affd --- /dev/null +++ b/server/dukang-api/src/modules/common/dto/client-error.dto.ts @@ -0,0 +1,48 @@ +import { IsIn, IsObject, IsOptional, IsString, MaxLength } from 'class-validator'; + +/** 客户端报错严重级别 */ +export const CLIENT_ERROR_LEVELS = ['fatal', 'error', 'warn'] as const; +export type ClientErrorLevel = (typeof CLIENT_ERROR_LEVELS)[number]; + +/** 客户端报错类别 */ +export const CLIENT_ERROR_CATEGORIES = [ + 'js_error', + 'unhandled_rejection', + 'api_error', + 'network', + 'render', + 'bridge', + 'other', +] as const; +export type ClientErrorCategory = (typeof CLIENT_ERROR_CATEGORIES)[number]; + +export class ReportClientErrorDto { + @IsIn(CLIENT_ERROR_LEVELS) + level!: ClientErrorLevel; + + @IsIn(CLIENT_ERROR_CATEGORIES) + category!: ClientErrorCategory; + + @IsString() + @MaxLength(1000) + message!: string; + + @IsOptional() + @IsString() + @MaxLength(4000) + stack?: string; + + @IsOptional() + @IsString() + @MaxLength(128) + pagePath?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + clientApp?: string; + + @IsOptional() + @IsObject() + extra?: Record; +} diff --git a/杜康好客-知识库.md b/杜康好客-知识库.md index 538c0d3..484a3e8 100644 --- a/杜康好客-知识库.md +++ b/杜康好客-知识库.md @@ -917,6 +917,17 @@ C 端「联系客服 → 在线客服」跳转企业微信 **微信客服** 链 | 核销 | 金额 `<1` 或 `>1000` 元;1 分钟尝试 `>3`;1 分钟失败 `>5`;弱网达阈值;新建补核销待办 | | 订单 | 超时未关待付款;待发货 >24h;配送中 >48h(每 5 分钟扫描) | | 运维 | MySQL/Redis 探活失败;结算 Cron 失败;新建售后/技术支持工单 | +| 客户端 | `POST /api/v1/common/client-errors`:`fatal`→P0、`error`→P1;`warn` 只记日志/落库不推群 | + +#### 客户端报错上报 + +| 项 | 说明 | +|----|------| +| 接口 | `POST /api/v1/common/client-errors`(可匿名;带 JWT 时附带用户身份) | +| Body | `level`(fatal/error/warn)、`category`(js_error / unhandled_rejection / api_error / network / render / bridge / other)、`message`、可选 `stack` / `pagePath` / `clientApp` / `extra` | +| 日志 | Nest `Logger` 打 `[client_error]`;并写入 `log_user_analytics`(`event_name=client_error`) | +| 企微 | 仅 `fatal`/`error`;同指纹 10 分钟去重 | +| 前端 | mini-user、`h5-shop`、`h5-partner` 启动时 `installClientErrorReporting()`:全局 `error` / `unhandledrejection`(mini-user 另挂 Taro 钩子) | 告警经 Redis 去重(同指纹默认 10 分钟内不重复推);**只通知、不拦截**交易与核销。Webhook key 泄露时在企微后台重置机器人并更新 `.env`。