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:
2026-08-02 16:27:08 +08:00
parent 3328c52cb4
commit 205c1110c2
11 changed files with 520 additions and 0 deletions
@@ -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);
}