02d89e6385
Add knowledge document GET/PUT and admin edit UI; auto-create support tickets on client 400 validation errors across user/shop/partner apps; batch create tasks and publish from support tickets; restore mini-user store env single-column layout. Co-authored-by: Cursor <cursoragent@cursor.com>
186 lines
6.1 KiB
TypeScript
186 lines
6.1 KiB
TypeScript
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);
|
||
}
|