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'; import { SupportTicketService } from './support-ticket.service'; 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, private readonly supportTicket: SupportTicketService, ) {} 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 anonymous = user?.actorId == null; const skipWecom = shouldSkipWecomClientErrorAlert({ message, pagePath, clientApp, anonymous, category: dto.category, }); 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, skipWecom, }; 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, skipWecom, ...(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) && !skipWecom) { 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, }); } else if (skipWecom && WECOM_LEVELS.has(dto.level)) { this.logger.log( `[client_error] skip WeCom alert (likely mini-program audit noise): ${message.slice(0, 160)}`, ); } if (dto.category === 'validation_error') { const apiPath = dto.extra && typeof dto.extra.url === 'string' ? dto.extra.url.slice(0, 256) : undefined; void this.supportTicket .createFromClientValidation({ clientApp, message, pagePath, apiPath, actorLabel: user?.actorId != null ? `${user.actorType}:${String(user.actorId)}` : undefined, }) .catch((e) => { this.logger.warn( `auto support ticket failed: ${e instanceof Error ? e.message : String(e)}`, ); }); } return { ok: true }; } } /** * 过滤企微推送:微信小程序审核机 / 自动化探测常见噪声。 * 仍落库与写服务端日志,仅跳过 webhook。 * * 典型特征(与本次协议页报错一致): * - 匿名 USER_MINI * - navigateTo/redirectTo 等 page … is not found(常带 .html,Taro H5 路径形态) */ export function shouldSkipWecomClientErrorAlert(input: { message: string; pagePath?: string | null; clientApp: string; anonymous: boolean; category?: string; }): boolean { const msg = input.message || ''; const app = input.clientApp || ''; const isMini = app === 'USER_MINI' || app === 'PARTNER_MINI' || app === 'HQ_MINI'; // 路由页不存在:审核机点协议/隐私链接触发最常见 const isNavPageMissing = /(navigateTo|redirectTo|reLaunch|switchTab):fail/i.test(msg) && /is not found/i.test(msg); // Taro 把路径拼成 *.html 的形态,几乎不可能是真·原生页路径 const isTaroHtmlPagePath = /\.html(\b|"|')/i.test(msg) && /is not found|page /i.test(msg); if (isMini && input.anonymous && (isNavPageMissing || isTaroHtmlPagePath)) { return true; } // 即使带登录态,纯 *.html not found 也视为框架/探测噪声 if (isMini && isTaroHtmlPagePath) { return true; } return false; } function isClientApp(v: string): v is ClientApp { return [ 'USER_MINI', 'USER_H5', 'PARTNER_MINI', 'PARTNER_H5', 'HQ_MINI', 'HQ_WEB', 'SHOP_H5', ].includes(v); }