|
|
@@ -0,0 +1,632 @@
|
|
|
+/**
|
|
|
+ * 风险预警覆盖审计脚本
|
|
|
+ *
|
|
|
+ * 用法: cp .env.example .env → 编辑 .env → npx tsx audit-coverage.ts
|
|
|
+ * 输出: audit-risk-YYYY-MM-DD.html(浏览器打开)
|
|
|
+ *
|
|
|
+ * 分析内容:
|
|
|
+ * 1. 查询指定时间段内所有文本消息
|
|
|
+ * 2. 查询所有 RiskEvent(同期已有预警)
|
|
|
+ * 3. 查询所有 RiskKeyword(启用中的关键词)
|
|
|
+ * 4. 关键词匹配命中但无预警的消息 → 漏报
|
|
|
+ * 5. AI 评审漏报消息的风险等级
|
|
|
+ * 6. 可视化 HTML 报告
|
|
|
+ */
|
|
|
+
|
|
|
+import 'dotenv/config';
|
|
|
+import Parse from './parse-client.js';
|
|
|
+import * as fs from 'fs';
|
|
|
+import * as path from 'path';
|
|
|
+import { fileURLToPath } from 'url';
|
|
|
+
|
|
|
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
+
|
|
|
+/* ======================== 配置 ======================== */
|
|
|
+
|
|
|
+const GUID = process.env.QIWE_GUID || '';
|
|
|
+
|
|
|
+const AI_KEY = process.env.DEEPSEEK_API_KEY || '';
|
|
|
+const AI_URL = process.env.DEEPSEEK_API_URL || 'https://api.deepseek.com/v1/chat/completions';
|
|
|
+const AI_MODEL = process.env.DEEPSEEK_MODEL || 'deepseek-chat';
|
|
|
+const AI_ENABLED = AI_KEY && AI_KEY !== 'sk-xxx';
|
|
|
+
|
|
|
+// 默认审计最近 7 天,可通过环境变量覆盖
|
|
|
+const DATE_RANGE = {
|
|
|
+ start: new Date(process.env.AUDIT_START || (() => { const d = new Date(); d.setDate(d.getDate() - 8); d.setHours(0, 0, 0, 0); return d.toISOString(); })()),
|
|
|
+ end: new Date(process.env.AUDIT_END || (() => { const d = new Date(); d.setHours(23, 59, 59, 999); return d.toISOString(); })()),
|
|
|
+};
|
|
|
+
|
|
|
+/* ======================== 类型 ======================== */
|
|
|
+
|
|
|
+interface KeywordInfo {
|
|
|
+ word: string;
|
|
|
+ category: string;
|
|
|
+ severity: 'high' | 'medium' | 'low';
|
|
|
+}
|
|
|
+
|
|
|
+interface MsgInfo {
|
|
|
+ id: string;
|
|
|
+ roomId: string;
|
|
|
+ senderName: string;
|
|
|
+ content: string;
|
|
|
+ timestamp: Date;
|
|
|
+}
|
|
|
+
|
|
|
+interface RiskEventInfo {
|
|
|
+ id: string;
|
|
|
+ groupId: string;
|
|
|
+ keywords: string[];
|
|
|
+ severity: string;
|
|
|
+ title: string;
|
|
|
+ description: string;
|
|
|
+ createdAt: Date;
|
|
|
+}
|
|
|
+
|
|
|
+interface MissedAlert {
|
|
|
+ roomId: string;
|
|
|
+ roomName: string;
|
|
|
+ messages: MsgInfo[];
|
|
|
+ matchedKeywords: string[];
|
|
|
+ maxSeverity: string;
|
|
|
+ reason: string;
|
|
|
+}
|
|
|
+
|
|
|
+/* ======================== 数据查询 ======================== */
|
|
|
+
|
|
|
+async function fetchAllData() {
|
|
|
+ console.log('[审计] 查询数据...\n');
|
|
|
+
|
|
|
+ const [allMessages, riskEvents, keywords, allGroups] = await Promise.all([
|
|
|
+ (async () => {
|
|
|
+ const q = new Parse.Query('Message');
|
|
|
+ q.greaterThanOrEqualTo('timestamp', DATE_RANGE.start);
|
|
|
+ q.lessThanOrEqualTo('timestamp', DATE_RANGE.end);
|
|
|
+ q.containedIn('msgType', [0, 2, 13, 29]);
|
|
|
+ q.notEqualTo('content', '');
|
|
|
+ q.select(['roomId', 'senderName', 'content', 'timestamp', 'msgType']);
|
|
|
+ q.limit(200000);
|
|
|
+ q.ascending('timestamp');
|
|
|
+ const rows = await q.find({ useMasterKey: true }) as any[];
|
|
|
+ return rows.map(r => ({
|
|
|
+ id: r.id,
|
|
|
+ roomId: (r.get('roomId') as string) || '',
|
|
|
+ senderName: (r.get('senderName') as string) || '',
|
|
|
+ content: (r.get('content') as string) || '',
|
|
|
+ timestamp: r.get('timestamp') as Date,
|
|
|
+ })) as MsgInfo[];
|
|
|
+ })(),
|
|
|
+
|
|
|
+ (async () => {
|
|
|
+ const q = new Parse.Query('RiskEvent');
|
|
|
+ q.greaterThanOrEqualTo('createdAt', DATE_RANGE.start);
|
|
|
+ q.lessThanOrEqualTo('createdAt', DATE_RANGE.end);
|
|
|
+ q.select(['groupId', 'keywords', 'severity', 'title', 'description', 'createdAt']);
|
|
|
+ q.limit(5000);
|
|
|
+ const rows = await q.find({ useMasterKey: true }) as any[];
|
|
|
+ return rows.map(r => ({
|
|
|
+ id: r.id,
|
|
|
+ groupId: (r.get('groupId') as string) || '',
|
|
|
+ keywords: (r.get('keywords') as string[]) || [],
|
|
|
+ severity: (r.get('severity') as string) || '',
|
|
|
+ title: (r.get('title') as string) || '',
|
|
|
+ description: (r.get('description') as string) || '',
|
|
|
+ createdAt: r.get('createdAt') as Date,
|
|
|
+ })) as RiskEventInfo[];
|
|
|
+ })(),
|
|
|
+
|
|
|
+ (async () => {
|
|
|
+ const q = new Parse.Query('RiskKeyword');
|
|
|
+ q.equalTo('enabled', true);
|
|
|
+ q.select(['word', 'category', 'severity']);
|
|
|
+ q.limit(5000);
|
|
|
+ const rows = await q.find({ useMasterKey: true }) as any[];
|
|
|
+ return rows.map((r: any) => ({
|
|
|
+ word: r.get('word') as string,
|
|
|
+ category: r.get('category') as string,
|
|
|
+ severity: r.get('severity') as string,
|
|
|
+ })) as KeywordInfo[];
|
|
|
+ })(),
|
|
|
+
|
|
|
+ (async () => {
|
|
|
+ const q = new Parse.Query('GroupChat');
|
|
|
+ q.select(['roomId', 'roomName']);
|
|
|
+ q.limit(5000);
|
|
|
+ const rows = await q.find({ useMasterKey: true }) as any[];
|
|
|
+ const map = new Map<string, string>();
|
|
|
+ for (const r of rows) {
|
|
|
+ map.set((r.get('roomId') as string) || '', (r.get('roomName') as string) || '未知群');
|
|
|
+ }
|
|
|
+ return map;
|
|
|
+ })(),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ console.log(` Message: ${allMessages.length} | RiskEvent: ${riskEvents.length} | RiskKeyword: ${keywords.length} | GroupChat: ${allGroups.size}`);
|
|
|
+ return { allMessages, riskEvents, keywords, allGroups };
|
|
|
+}
|
|
|
+
|
|
|
+/* ======================== 覆盖分析 ======================== */
|
|
|
+
|
|
|
+function analyzeCoverage(
|
|
|
+ allMessages: MsgInfo[],
|
|
|
+ riskEvents: RiskEventInfo[],
|
|
|
+ keywords: KeywordInfo[],
|
|
|
+ groupMap: Map<string, string>,
|
|
|
+) {
|
|
|
+ const msgsByRoom = new Map<string, MsgInfo[]>();
|
|
|
+ for (const m of allMessages) {
|
|
|
+ if (!m.roomId) continue;
|
|
|
+ const arr = msgsByRoom.get(m.roomId);
|
|
|
+ if (arr) arr.push(m);
|
|
|
+ else msgsByRoom.set(m.roomId, [m]);
|
|
|
+ }
|
|
|
+
|
|
|
+ const alertedRoomIds = new Set(riskEvents.map(e => e.groupId));
|
|
|
+ const alertedKeywords = new Set(riskEvents.flatMap(e => e.keywords));
|
|
|
+
|
|
|
+ const missedAlerts: MissedAlert[] = [];
|
|
|
+
|
|
|
+ for (const [roomId, msgs] of msgsByRoom) {
|
|
|
+ const hitMap = new Map<string, { msgs: MsgInfo[]; kw: KeywordInfo }>();
|
|
|
+ for (const msg of msgs) {
|
|
|
+ for (const kw of keywords) {
|
|
|
+ if (msg.content.includes(kw.word)) {
|
|
|
+ if (hitMap.has(kw.word)) {
|
|
|
+ hitMap.get(kw.word)!.msgs.push(msg);
|
|
|
+ } else {
|
|
|
+ hitMap.set(kw.word, { msgs: [msg], kw });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (hitMap.size === 0) continue;
|
|
|
+
|
|
|
+ const hitWords = [...hitMap.keys()];
|
|
|
+ const missedWords = hitWords.filter(w => !alertedKeywords.has(w));
|
|
|
+ const alertedWords = hitWords.filter(w => alertedKeywords.has(w));
|
|
|
+
|
|
|
+ if (missedWords.length > 0) {
|
|
|
+ const allMissedMsgs: MsgInfo[] = [];
|
|
|
+ for (const w of missedWords) {
|
|
|
+ const entry = hitMap.get(w);
|
|
|
+ if (entry) allMissedMsgs.push(...entry.msgs);
|
|
|
+ }
|
|
|
+ const uniqueMsgs = [...new Map(allMissedMsgs.map(m => [m.id, m])).values()]
|
|
|
+ .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
|
+
|
|
|
+ const severities = missedWords.map(w => hitMap.get(w)?.kw.severity || 'low');
|
|
|
+ const maxSeverity = severities.includes('high') ? 'high'
|
|
|
+ : severities.includes('medium') ? 'medium' : 'low';
|
|
|
+
|
|
|
+ const hasAlert = alertedRoomIds.has(roomId);
|
|
|
+ let reason: string;
|
|
|
+ if (!hasAlert && alertedWords.length === 0) {
|
|
|
+ reason = '该群无任何预警记录,所有关键词命中均未触发预警';
|
|
|
+ } else if (!hasAlert) {
|
|
|
+ reason = `该群无预警记录;命中关键词: [${hitWords.join(', ')}],其中 [${alertedWords.join(', ')}] 在其他群触发过预警`;
|
|
|
+ } else {
|
|
|
+ reason = `该群有预警但未覆盖以下关键词: [${missedWords.join(', ')}]`;
|
|
|
+ }
|
|
|
+
|
|
|
+ missedAlerts.push({
|
|
|
+ roomId,
|
|
|
+ roomName: groupMap.get(roomId) || `未知群(${roomId})`,
|
|
|
+ messages: uniqueMsgs.slice(0, 30),
|
|
|
+ matchedKeywords: missedWords,
|
|
|
+ maxSeverity,
|
|
|
+ reason,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const sevOrder = { high: 0, medium: 1, low: 2 };
|
|
|
+ missedAlerts.sort((a, b) => sevOrder[a.maxSeverity as keyof typeof sevOrder] - sevOrder[b.maxSeverity as keyof typeof sevOrder]);
|
|
|
+
|
|
|
+ const trulyMissedKeywords = keywords.filter(kw => !alertedKeywords.has(kw.word));
|
|
|
+
|
|
|
+ return {
|
|
|
+ missedAlerts,
|
|
|
+ trulyMissedKeywords,
|
|
|
+ stats: {
|
|
|
+ totalMessages: allMessages.length,
|
|
|
+ totalRiskEvents: riskEvents.length,
|
|
|
+ totalKeywords: keywords.length,
|
|
|
+ triggeredKeywords: alertedKeywords.size,
|
|
|
+ untriggeredKeywords: keywords.length - alertedKeywords.size,
|
|
|
+ coverageRate: keywords.length > 0 ? ((alertedKeywords.size / keywords.length) * 100).toFixed(1) : '0',
|
|
|
+ missedGroups: missedAlerts.length,
|
|
|
+ highCount: missedAlerts.filter(a => a.maxSeverity === 'high').length,
|
|
|
+ mediumCount: missedAlerts.filter(a => a.maxSeverity === 'medium').length,
|
|
|
+ lowCount: missedAlerts.filter(a => a.maxSeverity === 'low').length,
|
|
|
+ trulyMissedCount: trulyMissedKeywords.length,
|
|
|
+ },
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/* ======================== AI 评审 ======================== */
|
|
|
+
|
|
|
+async function aiReviewMissedAlert(alert: MissedAlert): Promise<string> {
|
|
|
+ if (!AI_ENABLED) return 'AI 未启用';
|
|
|
+
|
|
|
+ const samples = alert.messages.slice(0, 15).map(m => {
|
|
|
+ const t = m.timestamp;
|
|
|
+ const time = t ? `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}` : '';
|
|
|
+ return `[${time}] ${m.senderName || '未知'}: ${m.content}`;
|
|
|
+ }).join('\n');
|
|
|
+
|
|
|
+ const prompt = `你是拉迷家居(全屋定制)的风险审核员。以下群聊消息命中了风险关键词 [${alert.matchedKeywords.join(', ')}],但系统未触发预警。
|
|
|
+
|
|
|
+请判断:1.这些消息是否有真实风险?2.风险等级?3.简要理由(≤80字)
|
|
|
+
|
|
|
+群: ${alert.roomName}
|
|
|
+命中词: ${alert.matchedKeywords.join(', ')}
|
|
|
+消息样本:
|
|
|
+${samples}
|
|
|
+
|
|
|
+回复 JSON: {"hasRisk":true/false,"severity":"high"/"medium"/"low"/"none","reason":"≤80字"}`;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(AI_URL, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${AI_KEY}` },
|
|
|
+ body: JSON.stringify({
|
|
|
+ model: AI_MODEL,
|
|
|
+ messages: [
|
|
|
+ { role: 'system', content: '你是全屋定制行业风险审核员。只回复 JSON,不含 markdown。' },
|
|
|
+ { role: 'user', content: prompt },
|
|
|
+ ],
|
|
|
+ temperature: 0.2,
|
|
|
+ max_tokens: 256,
|
|
|
+ }),
|
|
|
+ });
|
|
|
+ if (!res.ok) return `AI 调用失败: ${res.status}`;
|
|
|
+ const json = await res.json() as any;
|
|
|
+ const content = json?.choices?.[0]?.message?.content || '';
|
|
|
+ const match = content.match(/\{[\s\S]*\}/);
|
|
|
+ if (match) {
|
|
|
+ const p = JSON.parse(match[0]);
|
|
|
+ const label = p.hasRisk ? `⚠ 有风险(${p.severity})` : '✅ 无风险';
|
|
|
+ return `${label} | ${p.reason || ''}`;
|
|
|
+ }
|
|
|
+ return `AI 响应异常: ${content.slice(0, 100)}`;
|
|
|
+ } catch (err: any) {
|
|
|
+ return `AI 错误: ${err.message}`;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* ======================== HTML 报告生成 ======================== */
|
|
|
+
|
|
|
+function escapeHtml(s: string): string {
|
|
|
+ return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
|
+}
|
|
|
+
|
|
|
+function sevBadge(s: string): string {
|
|
|
+ const m: Record<string, string> = { high: '高危', medium: '中危', low: '低危' };
|
|
|
+ return `<span class="badge badge-${s}">${m[s] || s}</span>`;
|
|
|
+}
|
|
|
+
|
|
|
+function sevIcon(s: string): string {
|
|
|
+ return s === 'high' ? '🔴' : s === 'medium' ? '🟡' : '🟢';
|
|
|
+}
|
|
|
+
|
|
|
+function categoryCn(c: string): string {
|
|
|
+ const m: Record<string, string> = {
|
|
|
+ complaint: '投诉', quality: '质量', sensitive: '敏感', legal: '法律',
|
|
|
+ competitor: '竞品', price: '价格', negative: '负面', installation: '安装',
|
|
|
+ };
|
|
|
+ return m[c] || c;
|
|
|
+}
|
|
|
+
|
|
|
+function generateHTML(
|
|
|
+ stats: ReturnType<typeof analyzeCoverage>['stats'],
|
|
|
+ missedAlerts: MissedAlert[],
|
|
|
+ aiReviews: string[],
|
|
|
+ riskEvents: RiskEventInfo[],
|
|
|
+ trulyMissedKeywords: KeywordInfo[],
|
|
|
+ queryMs: number,
|
|
|
+ totalMs: number,
|
|
|
+): string {
|
|
|
+ const genTime = new Date().toLocaleString('zh-CN');
|
|
|
+ const dateLabel = `${DATE_RANGE.start.toLocaleDateString('zh-CN')} ~ ${DATE_RANGE.end.toLocaleDateString('zh-CN')}`;
|
|
|
+
|
|
|
+ // 概览卡片
|
|
|
+ const cards = `
|
|
|
+ <div class="card card-blue"><div class="card-num">${stats.totalMessages.toLocaleString()}</div><div class="card-label">文本消息总量</div></div>
|
|
|
+ <div class="card card-purple"><div class="card-num">${stats.totalRiskEvents}</div><div class="card-label">已有风险事件</div></div>
|
|
|
+ <div class="card card-teal"><div class="card-num">${stats.totalKeywords}</div><div class="card-label">启用关键词</div></div>
|
|
|
+ <div class="card card-orange"><div class="card-num">${stats.coverageRate}%</div><div class="card-label">关键词覆盖率</div></div>
|
|
|
+ <div class="card card-red"><div class="card-num">${stats.missedGroups}</div><div class="card-label">漏报群数量</div></div>
|
|
|
+ <div class="card card-green"><div class="card-num">${stats.trulyMissedCount}</div><div class="card-label">未覆盖关键词</div></div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ // 已触发预警
|
|
|
+ let alertSection = '';
|
|
|
+ if (riskEvents.length > 0) {
|
|
|
+ const rows = riskEvents.map(e => `
|
|
|
+ <tr>
|
|
|
+ <td><span class="badge badge-${e.severity}">${e.severity}</span></td>
|
|
|
+ <td>${escapeHtml(e.title)}</td>
|
|
|
+ <td>${(e.keywords || []).map(k => `<code>${escapeHtml(k)}</code>`).join(' ')}</td>
|
|
|
+ <td>${escapeHtml(e.description?.slice(0, 60) || '')}</td>
|
|
|
+ <td class="time-cell">${e.createdAt ? new Date(e.createdAt).toLocaleString('zh-CN') : ''}</td>
|
|
|
+ </tr>`).join('');
|
|
|
+ alertSection = `
|
|
|
+ <div class="section"><h2>🛡️ 已触发预警事件 <span class="sub">(${riskEvents.length} 条)</span></h2>
|
|
|
+ <div class="table-wrap"><table>
|
|
|
+ <thead><tr><th>严重度</th><th>标题</th><th>关键词</th><th>描述</th><th>时间</th></tr></thead>
|
|
|
+ <tbody>${rows}</tbody>
|
|
|
+ </table></div></div>`;
|
|
|
+ } else {
|
|
|
+ alertSection = `<div class="section"><h2>🛡️ 已触发预警事件</h2><div class="empty">该期间无风险事件</div></div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 漏报详情
|
|
|
+ let missedSection = '';
|
|
|
+ if (missedAlerts.length === 0) {
|
|
|
+ missedSection = `<div class="section"><h2>🔍 漏报检查</h2><div class="empty success">✅ 未发现漏报</div></div>`;
|
|
|
+ } else {
|
|
|
+ const realRisky = aiReviews.filter(r => r.includes('有风险')).length;
|
|
|
+ const safeCount = aiReviews.filter(r => r.includes('无风险')).length;
|
|
|
+
|
|
|
+ let cardsHtml = '';
|
|
|
+ for (let i = 0; i < missedAlerts.length; i++) {
|
|
|
+ const a = missedAlerts[i];
|
|
|
+ const review = aiReviews[i] || '';
|
|
|
+ const isRisky = review.includes('有风险');
|
|
|
+ const border = a.maxSeverity === 'high' ? '#e74c3c' : a.maxSeverity === 'medium' ? '#f39c12' : '#3498db';
|
|
|
+ const bg = a.maxSeverity === 'high' ? '#fff5f5' : a.maxSeverity === 'medium' ? '#fffdf0' : '#f8faff';
|
|
|
+
|
|
|
+ const sampleRows = a.messages.slice(0, 5).map(m => {
|
|
|
+ const t = m.timestamp;
|
|
|
+ const time = t ? `${t.getMonth() + 1}/${t.getDate()} ${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}` : '';
|
|
|
+ return `<tr><td class="time-cell">${time}</td><td>${escapeHtml(m.senderName || '未知')}</td><td class="msg-cell">${escapeHtml(m.content.length > 120 ? m.content.slice(0, 120) + '...' : m.content)}</td></tr>`;
|
|
|
+ }).join('');
|
|
|
+
|
|
|
+ const kwTags = a.matchedKeywords.map(k => `<span class="kw-tag">${sevIcon(a.maxSeverity)} ${escapeHtml(k)}</span>`).join('');
|
|
|
+
|
|
|
+ cardsHtml += `
|
|
|
+ <div class="missed-card" style="border-left:4px solid ${border};background:${bg}">
|
|
|
+ <div class="missed-header">
|
|
|
+ <span class="missed-title">${i + 1}. ${escapeHtml(a.roomName)}</span>
|
|
|
+ <span>${sevBadge(a.maxSeverity)}</span>
|
|
|
+ </div>
|
|
|
+ <div class="missed-body">
|
|
|
+ <div class="missed-meta">
|
|
|
+ <div class="meta-item"><span class="meta-label">命中关键词</span>${kwTags}</div>
|
|
|
+ <div class="meta-item"><span class="meta-label">匹配消息</span>${a.messages.length} 条</div>
|
|
|
+ <div class="meta-item"><span class="meta-label">原因</span>${escapeHtml(a.reason)}</div>
|
|
|
+ </div>
|
|
|
+ ${review ? `<div class="ai-review ${isRisky ? 'ai-risky' : 'ai-safe'}">🤖 AI 评审:${escapeHtml(review)}</div>` : ''}
|
|
|
+ <details class="msg-details">
|
|
|
+ <summary>📋 消息样本(点击展开)</summary>
|
|
|
+ <div class="table-wrap mini-table"><table>
|
|
|
+ <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
|
|
|
+ <tbody>${sampleRows}</tbody>
|
|
|
+ </table></div>
|
|
|
+ </details>
|
|
|
+ </div>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ missedSection = `
|
|
|
+ <div class="section"><h2>🔍 漏报详情 <span class="sub">(${missedAlerts.length} 个群)</span></h2>
|
|
|
+ <div class="missed-summary">
|
|
|
+ <span class="chip chip-red">⚠️ AI 确认有风险: ${realRisky} 个</span>
|
|
|
+ <span class="chip chip-green">✅ AI 确认无风险: ${safeCount} 个</span>
|
|
|
+ <span class="chip chip-gray">未评审: ${missedAlerts.length - realRisky - safeCount} 个</span>
|
|
|
+ </div>
|
|
|
+ <div class="missed-list">${cardsHtml}</div>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 未覆盖关键词云
|
|
|
+ let kwSection = '';
|
|
|
+ if (trulyMissedKeywords.length > 0) {
|
|
|
+ const sevOrder = { high: 0, medium: 1, low: 2 };
|
|
|
+ const sorted = [...trulyMissedKeywords].sort((a, b) => sevOrder[a.severity] - sevOrder[b.severity]);
|
|
|
+ const tags = sorted.map(k => `<span class="kw-chip kw-${k.severity}">${k.word}<span class="kw-cat">${categoryCn(k.category)}</span></span>`).join('\n');
|
|
|
+ const hi = sorted.filter(k => k.severity === 'high').length;
|
|
|
+ const md = sorted.filter(k => k.severity === 'medium').length;
|
|
|
+ const lo = sorted.filter(k => k.severity === 'low').length;
|
|
|
+
|
|
|
+ kwSection = `
|
|
|
+ <div class="section"><h2>📚 从未触发预警的关键词 <span class="sub">(${trulyMissedKeywords.length} 个)</span></h2>
|
|
|
+ <div class="kw-stats-bar">
|
|
|
+ <div class="kw-stat kw-stat-high"><span>${hi}</span> 高危</div>
|
|
|
+ <div class="kw-stat kw-stat-med"><span>${md}</span> 中危</div>
|
|
|
+ <div class="kw-stat kw-stat-low"><span>${lo}</span> 低危</div>
|
|
|
+ </div>
|
|
|
+ <div class="kw-cloud">${tags}</div>
|
|
|
+ <p class="hint">这些关键词在审计期间被消息命中,但从未在任何群触发过预警。</p>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 管理建议
|
|
|
+ const suggestions = `
|
|
|
+ <div class="section"><h2>💡 管理建议</h2>
|
|
|
+ <div class="suggestion-grid">
|
|
|
+ <div class="sugg-card sugg-high">
|
|
|
+ <div class="sugg-title">🔴 高优先级</div>
|
|
|
+ <ul>
|
|
|
+ <li>将「定金/订金/优惠」等促销词从预警列表移除或降级为"仅记录"</li>
|
|
|
+ <li>质量词(脱胶/开裂/变形/发霉/装错/色差)提高权重,命中即报</li>
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
+ <div class="sugg-card sugg-med">
|
|
|
+ <div class="sugg-title">🟡 中优先级</div>
|
|
|
+ <ul>
|
|
|
+ <li>精简关键词库:181 → 60~80 个高质量词</li>
|
|
|
+ <li>同一群短时间内命中多个低价关键词 → 聚合升级预警</li>
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
+ <div class="sugg-card sugg-low">
|
|
|
+ <div class="sugg-title">🟢 持续改进</div>
|
|
|
+ <ul>
|
|
|
+ <li>每周运行本审计脚本,跟踪漏报趋势</li>
|
|
|
+ <li>建立"关键词→预警→确认→反馈"闭环</li>
|
|
|
+ <li>竞品类关键词命中频次统计,作为市场情报</li>
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>`;
|
|
|
+
|
|
|
+ return `<!DOCTYPE html>
|
|
|
+<html lang="zh-CN">
|
|
|
+<head>
|
|
|
+<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
|
+<title>拉迷风险预警覆盖审计 — ${dateLabel}</title>
|
|
|
+<style>
|
|
|
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
|
+body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif;background:#f0f2f5;color:#1a1a2e;line-height:1.6}
|
|
|
+.hero{background:linear-gradient(135deg,#0f0c29 0%,#1a1a2e 40%,#16213e 100%);color:#fff;padding:40px 48px;position:relative;overflow:hidden}
|
|
|
+.hero::after{content:'';position:absolute;top:-50%;right:-10%;width:400px;height:400px;background:radial-gradient(circle,rgba(231,76,60,.15),transparent 70%);border-radius:50%}
|
|
|
+.hero h1{font-size:26px;font-weight:700;position:relative;z-index:1}
|
|
|
+.hero .subtitle{font-size:14px;color:#8892b0;margin-top:6px;position:relative;z-index:1}
|
|
|
+.hero .meta{font-size:12px;color:#5a6785;margin-top:4px;position:relative;z-index:1}
|
|
|
+.container{max-width:1100px;margin:0 auto;padding:28px 24px}
|
|
|
+.overview-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:16px;margin-bottom:36px}
|
|
|
+.card{background:#fff;border-radius:12px;padding:24px 20px;text-align:center;box-shadow:0 2px 12px rgba(0,0,0,.06);transition:transform .15s}
|
|
|
+.card:hover{transform:translateY(-2px)}
|
|
|
+.card-num{font-size:36px;font-weight:800;line-height:1.2}
|
|
|
+.card-label{font-size:13px;color:#7f8c8d;margin-top:6px}
|
|
|
+.card-blue .card-num{color:#2980b9} .card-purple .card-num{color:#8e44ad}
|
|
|
+.card-teal .card-num{color:#16a085} .card-orange .card-num{color:#e67e22}
|
|
|
+.card-red .card-num{color:#e74c3c} .card-green .card-num{color:#27ae60}
|
|
|
+.section{margin-bottom:36px}
|
|
|
+.section h2{font-size:18px;font-weight:700;color:#1a1a2e;margin-bottom:16px;padding-bottom:10px;border-bottom:2px solid #e8ecf4}
|
|
|
+.sub{font-size:13px;color:#95a5a6;font-weight:400}
|
|
|
+.empty{padding:40px;text-align:center;color:#95a5a6;background:#fff;border-radius:10px}
|
|
|
+.empty.success{color:#27ae60;background:#eafaf1}
|
|
|
+.hint{font-size:13px;color:#95a5a6;margin-top:12px;line-height:1.6}
|
|
|
+.badge{font-size:11px;font-weight:700;padding:3px 10px;border-radius:12px;color:#fff;white-space:nowrap}
|
|
|
+.badge-high{background:#e74c3c} .badge-medium{background:#f39c12} .badge-low{background:#3498db}
|
|
|
+.table-wrap{overflow-x:auto;background:#fff;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,.04)}
|
|
|
+table{width:100%;border-collapse:collapse;font-size:13px}
|
|
|
+thead th{background:#f8f9fb;padding:12px 14px;text-align:left;font-weight:600;color:#555;font-size:12px;text-transform:uppercase;letter-spacing:.5px;border-bottom:2px solid #e8ecf4}
|
|
|
+tbody td{padding:10px 14px;border-bottom:1px solid #f0f2f5;vertical-align:top}
|
|
|
+tbody tr:hover{background:#f8f9fb}
|
|
|
+.time-cell{white-space:nowrap;color:#95a5a6;font-size:12px}
|
|
|
+.msg-cell{max-width:400px;word-break:break-all}
|
|
|
+code{background:#f0f2f5;padding:1px 6px;border-radius:3px;font-size:12px;color:#e74c3c}
|
|
|
+.missed-summary{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:18px}
|
|
|
+.chip{font-size:13px;padding:6px 14px;border-radius:20px;font-weight:600}
|
|
|
+.chip-red{background:#fde8e8;color:#c0392b} .chip-green{background:#e8f8f5;color:#1e8449} .chip-gray{background:#f0f2f5;color:#7f8c8d}
|
|
|
+.missed-list{display:flex;flex-direction:column;gap:14px}
|
|
|
+.missed-card{border-radius:10px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.04)}
|
|
|
+.missed-header{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;background:rgba(0,0,0,.02);border-bottom:1px solid rgba(0,0,0,.05)}
|
|
|
+.missed-title{font-size:15px;font-weight:700}
|
|
|
+.missed-body{padding:16px 18px}
|
|
|
+.missed-meta{display:flex;flex-direction:column;gap:8px;margin-bottom:10px}
|
|
|
+.meta-item{font-size:13px} .meta-label{font-weight:600;color:#555;margin-right:8px}
|
|
|
+.kw-tag{display:inline-block;background:#f0f2f5;padding:2px 10px;border-radius:12px;font-size:12px;margin-right:4px;margin-bottom:2px}
|
|
|
+.ai-review{font-size:13px;padding:10px 14px;border-radius:8px;margin:10px 0}
|
|
|
+.ai-risky{background:#fde8e8;border:1px solid #f5c6cb;color:#721c24}
|
|
|
+.ai-safe{background:#e8f8f5;border:1px solid #b7e4cf;color:#155724}
|
|
|
+.msg-details{margin-top:8px} .msg-details summary{font-size:13px;color:#3498db;cursor:pointer;font-weight:600}
|
|
|
+.kw-stats-bar{display:flex;gap:12px;margin-bottom:14px}
|
|
|
+.kw-stat{padding:6px 16px;border-radius:8px;font-size:13px;font-weight:600}
|
|
|
+.kw-stat span{font-size:20px;font-weight:800}
|
|
|
+.kw-stat-high{background:#fde8e8;color:#c0392b} .kw-stat-med{background:#fef5e7;color:#d68910} .kw-stat-low{background:#eaf2f8;color:#2980b9}
|
|
|
+.kw-cloud{display:flex;flex-wrap:wrap;gap:8px;background:#fff;padding:18px;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,.04)}
|
|
|
+.kw-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:16px;font-size:13px;font-weight:600}
|
|
|
+.kw-high{background:#fde8e8;color:#c0392b} .kw-medium{background:#fef5e7;color:#d68910} .kw-low{background:#eaf2f8;color:#2980b9}
|
|
|
+.kw-cat{font-size:10px;opacity:.7;font-weight:400}
|
|
|
+.suggestion-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
|
|
|
+.sugg-card{padding:20px;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,.04)}
|
|
|
+.sugg-title{font-size:15px;font-weight:700;margin-bottom:10px}
|
|
|
+.sugg-card ul{padding-left:18px} .sugg-card li{font-size:13px;margin-bottom:8px;line-height:1.6}
|
|
|
+.sugg-high{background:#fff5f5;border:1px solid #fecaca}
|
|
|
+.sugg-med{background:#fffdf0;border:1px solid #fde68a}
|
|
|
+.sugg-low{background:#f5f9ff;border:1px solid #bfdbfe}
|
|
|
+.footer{text-align:center;padding:20px;color:#95a5a6;font-size:12px}
|
|
|
+@media(max-width:768px){.hero{padding:24px}.container{padding:16px}.overview-grid{grid-template-columns:repeat(3,1fr);gap:10px}.card{padding:16px 10px}.card-num{font-size:26px}.suggestion-grid{grid-template-columns:1fr}}
|
|
|
+@media print{body{background:#fff}.hero{background:#1a1a2e!important;-webkit-print-color-adjust:exact}.missed-card{break-inside:avoid;border:1px solid #ddd}}
|
|
|
+</style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+<div class="hero">
|
|
|
+ <h1>🛡️ 风险预警覆盖审计报告</h1>
|
|
|
+ <div class="subtitle">审计期间:${dateLabel}(${Math.ceil((DATE_RANGE.end.getTime() - DATE_RANGE.start.getTime()) / 86400000)} 天) | 全屋定制 · 拉迷家居</div>
|
|
|
+ <div class="meta">生成时间:${genTime} | 查询耗时:${queryMs}ms | 总耗时:${totalMs}ms | AI:${AI_ENABLED ? `DeepSeek(${AI_MODEL})` : '未启用'}</div>
|
|
|
+</div>
|
|
|
+<div class="container">
|
|
|
+ <div class="overview-grid">${cards}</div>
|
|
|
+ ${alertSection}
|
|
|
+ ${missedSection}
|
|
|
+ ${kwSection}
|
|
|
+ ${suggestions}
|
|
|
+ <div class="footer">拉迷家居 · 风险预警覆盖审计 · 自动生成</div>
|
|
|
+</div>
|
|
|
+</body>
|
|
|
+</html>`;
|
|
|
+}
|
|
|
+
|
|
|
+/* ======================== 主流程 ======================== */
|
|
|
+
|
|
|
+async function main(): Promise<void> {
|
|
|
+ const t0 = Date.now();
|
|
|
+ console.log('═══════════════════════════════════════');
|
|
|
+ console.log(' 风险预警覆盖审计');
|
|
|
+ console.log(` 时间: ${DATE_RANGE.start.toLocaleDateString('zh-CN')} ~ ${DATE_RANGE.end.toLocaleDateString('zh-CN')}`);
|
|
|
+ console.log(` AI: ${AI_ENABLED ? `DeepSeek (${AI_MODEL})` : '禁用'}`);
|
|
|
+ console.log('═══════════════════════════════════════\n');
|
|
|
+
|
|
|
+ // 1. 查询
|
|
|
+ const t1 = Date.now();
|
|
|
+ const { allMessages, riskEvents, keywords, allGroups } = await fetchAllData();
|
|
|
+ const queryMs = Date.now() - t1;
|
|
|
+ console.log(` 查询耗时: ${queryMs}ms\n`);
|
|
|
+
|
|
|
+ // 2. 覆盖分析
|
|
|
+ console.log('[审计] 关键词覆盖分析...');
|
|
|
+ const { missedAlerts, trulyMissedKeywords, stats } = analyzeCoverage(allMessages, riskEvents, keywords, allGroups);
|
|
|
+ console.log(` 漏报群: ${stats.missedGroups},高危 ${stats.highCount} / 中危 ${stats.mediumCount} / 低危 ${stats.lowCount}`);
|
|
|
+ console.log(` 完全未触发预警的关键词: ${stats.trulyMissedCount}\n`);
|
|
|
+
|
|
|
+ // 3. AI 评审(限前 20 个高危/中危)
|
|
|
+ console.log('[审计] AI 深度评审漏报...');
|
|
|
+ const toReview = missedAlerts.filter(a => a.maxSeverity === 'high' || a.maxSeverity === 'medium').slice(0, 20);
|
|
|
+ const aiReviews: string[] = [];
|
|
|
+ for (let i = 0; i < toReview.length; i++) {
|
|
|
+ const a = toReview[i];
|
|
|
+ console.log(` → AI 评审 ${i + 1}/${toReview.length}: ${a.roomName} (${a.maxSeverity})`);
|
|
|
+ const review = await aiReviewMissedAlert(a);
|
|
|
+ aiReviews.push(review);
|
|
|
+ console.log(` ← ${review}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ const allAiReviews = missedAlerts.map(a => {
|
|
|
+ const idx = toReview.indexOf(a);
|
|
|
+ return idx >= 0 ? aiReviews[idx] : '(低危,未提交 AI 评审)';
|
|
|
+ });
|
|
|
+
|
|
|
+ // 4. HTML 报告
|
|
|
+ const totalMs = Date.now() - t0;
|
|
|
+ const html = generateHTML(stats, missedAlerts, allAiReviews, riskEvents, trulyMissedKeywords, queryMs, totalMs);
|
|
|
+
|
|
|
+ const dateStr = DATE_RANGE.start.toISOString().slice(0, 10);
|
|
|
+ const outputPath = path.resolve(__dirname, `audit-risk-${dateStr}.html`);
|
|
|
+ fs.writeFileSync(outputPath, html, 'utf-8');
|
|
|
+
|
|
|
+ console.log(`\n✅ 报告已生成: ${outputPath}`);
|
|
|
+ console.log(` 文件大小: ${(Buffer.byteLength(html, 'utf-8') / 1024).toFixed(1)} KB | 总耗时: ${totalMs}ms\n`);
|
|
|
+
|
|
|
+ // 控制台摘要
|
|
|
+ console.log('═══════════════════════════════════════');
|
|
|
+ console.log(' 关键发现');
|
|
|
+ console.log('═══════════════════════════════════════');
|
|
|
+ console.log(` 关键词覆盖率: ${stats.coverageRate}% (${stats.triggeredKeywords}/${stats.totalKeywords})`);
|
|
|
+ console.log(` 完全未覆盖关键词: ${stats.trulyMissedCount}`);
|
|
|
+ console.log(` 漏报群: ${stats.missedGroups} (高危 ${stats.highCount} / 中危 ${stats.mediumCount} / 低危 ${stats.lowCount})`);
|
|
|
+ if (stats.trulyMissedCount > 0) {
|
|
|
+ console.log(`\n 未覆盖关键词 TOP 15:`);
|
|
|
+ for (const kw of trulyMissedKeywords.slice(0, 15)) {
|
|
|
+ console.log(` - ${kw.word} (${kw.category}/${kw.severity})`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ console.log('');
|
|
|
+}
|
|
|
+
|
|
|
+main().catch((err) => {
|
|
|
+ console.error('[审计] 失败:', err);
|
|
|
+ process.exit(1);
|
|
|
+});
|