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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, string> = {
|
||||||
|
'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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,9 +5,12 @@ import { getRouterBasename } from '@dukang/weixin-sdk';
|
|||||||
import App from './App';
|
import App from './App';
|
||||||
import { PartnerSessionProvider } from './contexts/PartnerSessionContext';
|
import { PartnerSessionProvider } from './contexts/PartnerSessionContext';
|
||||||
import { PartnerToastProvider } from './contexts/PartnerToastContext';
|
import { PartnerToastProvider } from './contexts/PartnerToastContext';
|
||||||
|
import { installClientErrorReporting } from './lib/client-error';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
import './styles/legal.css';
|
import './styles/legal.css';
|
||||||
|
|
||||||
|
installClientErrorReporting();
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter basename={getRouterBasename()}>
|
<BrowserRouter basename={getRouterBasename()}>
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, string> = {
|
||||||
|
'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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,10 +3,13 @@ import ReactDOM from 'react-dom/client';
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { getRouterBasename } from '@dukang/weixin-sdk';
|
import { getRouterBasename } from '@dukang/weixin-sdk';
|
||||||
import { StoreSessionProvider } from './contexts/StoreSessionContext';
|
import { StoreSessionProvider } from './contexts/StoreSessionContext';
|
||||||
|
import { installClientErrorReporting } from './lib/client-error';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
import './styles/legal.css';
|
import './styles/legal.css';
|
||||||
|
|
||||||
|
installClientErrorReporting();
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter basename={getRouterBasename()}>
|
<BrowserRouter basename={getRouterBasename()}>
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import Taro, { useDidShow } from '@tarojs/taro';
|
|||||||
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
||||||
import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
|
import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
|
||||||
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
|
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
|
||||||
|
import { installClientErrorReporting } from './lib/client-error';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
|
|
||||||
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
|
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
|
||||||
patchTaroH5Hooks();
|
patchTaroH5Hooks();
|
||||||
|
installClientErrorReporting();
|
||||||
|
|
||||||
function App({ children }: PropsWithChildren) {
|
function App({ children }: PropsWithChildren) {
|
||||||
const handlingRef = useRef(false);
|
const handlingRef = useRef(false);
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, string> = {
|
||||||
|
'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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import { WechatController } from './wechat.controller';
|
|||||||
import { ClientConfigController } from './client-config.controller';
|
import { ClientConfigController } from './client-config.controller';
|
||||||
import { LbsController } from './lbs.controller';
|
import { LbsController } from './lbs.controller';
|
||||||
import { WechatLocationService } from './wechat-location.service';
|
import { WechatLocationService } from './wechat-location.service';
|
||||||
|
import { ClientErrorService } from './client-error.service';
|
||||||
|
import { ClientErrorController } from './client-error.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => IamModule), IntegrationsModule, SystemConfigModule, forwardRef(() => AnalyticsModule)],
|
imports: [forwardRef(() => IamModule), IntegrationsModule, SystemConfigModule, forwardRef(() => AnalyticsModule)],
|
||||||
@@ -27,6 +29,7 @@ import { WechatLocationService } from './wechat-location.service';
|
|||||||
WechatController,
|
WechatController,
|
||||||
ClientConfigController,
|
ClientConfigController,
|
||||||
LbsController,
|
LbsController,
|
||||||
|
ClientErrorController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ResourceService,
|
ResourceService,
|
||||||
@@ -35,6 +38,7 @@ import { WechatLocationService } from './wechat-location.service';
|
|||||||
SupportTicketService,
|
SupportTicketService,
|
||||||
ThirdPartyLogService,
|
ThirdPartyLogService,
|
||||||
WechatLocationService,
|
WechatLocationService,
|
||||||
|
ClientErrorService,
|
||||||
],
|
],
|
||||||
exports: [ResourceService, EventService, TicketService, SupportTicketService],
|
exports: [ResourceService, EventService, TicketService, SupportTicketService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
+11
@@ -917,6 +917,17 @@ C 端「联系客服 → 在线客服」跳转企业微信 **微信客服** 链
|
|||||||
| 核销 | 金额 `<1` 或 `>1000` 元;1 分钟尝试 `>3`;1 分钟失败 `>5`;弱网达阈值;新建补核销待办 |
|
| 核销 | 金额 `<1` 或 `>1000` 元;1 分钟尝试 `>3`;1 分钟失败 `>5`;弱网达阈值;新建补核销待办 |
|
||||||
| 订单 | 超时未关待付款;待发货 >24h;配送中 >48h(每 5 分钟扫描) |
|
| 订单 | 超时未关待付款;待发货 >24h;配送中 >48h(每 5 分钟扫描) |
|
||||||
| 运维 | MySQL/Redis 探活失败;结算 Cron 失败;新建售后/技术支持工单 |
|
| 运维 | 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`。
|
告警经 Redis 去重(同指纹默认 10 分钟内不重复推);**只通知、不拦截**交易与核销。Webhook key 泄露时在企微后台重置机器人并更新 `.env`。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user