/** * 风险预警覆盖审计脚本 * * 用法: 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(); 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, ) { const msgsByRoom = new Map(); 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(); 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 { 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, '"'); } function sevBadge(s: string): string { const m: Record = { high: '高危', medium: '中危', low: '低危' }; return `${m[s] || s}`; } function sevIcon(s: string): string { return s === 'high' ? '🔴' : s === 'medium' ? '🟡' : '🟢'; } function categoryCn(c: string): string { const m: Record = { complaint: '投诉', quality: '质量', sensitive: '敏感', legal: '法律', competitor: '竞品', price: '价格', negative: '负面', installation: '安装', }; return m[c] || c; } function generateHTML( stats: ReturnType['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 = `
${stats.totalMessages.toLocaleString()}
文本消息总量
${stats.totalRiskEvents}
已有风险事件
${stats.totalKeywords}
启用关键词
${stats.coverageRate}%
关键词覆盖率
${stats.missedGroups}
漏报群数量
${stats.trulyMissedCount}
未覆盖关键词
`; // 已触发预警 let alertSection = ''; if (riskEvents.length > 0) { const rows = riskEvents.map(e => ` ${e.severity} ${escapeHtml(e.title)} ${(e.keywords || []).map(k => `${escapeHtml(k)}`).join(' ')} ${escapeHtml(e.description?.slice(0, 60) || '')} ${e.createdAt ? new Date(e.createdAt).toLocaleString('zh-CN') : ''} `).join(''); alertSection = `

🛡️ 已触发预警事件 (${riskEvents.length} 条)

${rows}
严重度标题关键词描述时间
`; } else { alertSection = `

🛡️ 已触发预警事件

该期间无风险事件
`; } // 漏报详情 let missedSection = ''; if (missedAlerts.length === 0) { missedSection = `

🔍 漏报检查

✅ 未发现漏报
`; } 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 `${time}${escapeHtml(m.senderName || '未知')}${escapeHtml(m.content.length > 120 ? m.content.slice(0, 120) + '...' : m.content)}`; }).join(''); const kwTags = a.matchedKeywords.map(k => `${sevIcon(a.maxSeverity)} ${escapeHtml(k)}`).join(''); cardsHtml += `
${i + 1}. ${escapeHtml(a.roomName)} ${sevBadge(a.maxSeverity)}
命中关键词${kwTags}
匹配消息${a.messages.length} 条
原因${escapeHtml(a.reason)}
${review ? `
🤖 AI 评审:${escapeHtml(review)}
` : ''}
📋 消息样本(点击展开)
${sampleRows}
时间发送者内容
`; } missedSection = `

🔍 漏报详情 (${missedAlerts.length} 个群)

⚠️ AI 确认有风险: ${realRisky} 个 ✅ AI 确认无风险: ${safeCount} 个 未评审: ${missedAlerts.length - realRisky - safeCount} 个
${cardsHtml}
`; } // 未覆盖关键词云 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 => `${k.word}${categoryCn(k.category)}`).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 = `

📚 从未触发预警的关键词 (${trulyMissedKeywords.length} 个)

${hi} 高危
${md} 中危
${lo} 低危
${tags}

这些关键词在审计期间被消息命中,但从未在任何群触发过预警。

`; } // 管理建议 const suggestions = `

💡 管理建议

🔴 高优先级
  • 将「定金/订金/优惠」等促销词从预警列表移除或降级为"仅记录"
  • 质量词(脱胶/开裂/变形/发霉/装错/色差)提高权重,命中即报
🟡 中优先级
  • 精简关键词库:181 → 60~80 个高质量词
  • 同一群短时间内命中多个低价关键词 → 聚合升级预警
🟢 持续改进
  • 每周运行本审计脚本,跟踪漏报趋势
  • 建立"关键词→预警→确认→反馈"闭环
  • 竞品类关键词命中频次统计,作为市场情报
`; return ` 拉迷风险预警覆盖审计 — ${dateLabel}

🛡️ 风险预警覆盖审计报告

审计期间:${dateLabel}(${Math.ceil((DATE_RANGE.end.getTime() - DATE_RANGE.start.getTime()) / 86400000)} 天) |  全屋定制 · 拉迷家居
生成时间:${genTime}  |  查询耗时:${queryMs}ms  |  总耗时:${totalMs}ms  |  AI:${AI_ENABLED ? `DeepSeek(${AI_MODEL})` : '未启用'}
${cards}
${alertSection} ${missedSection} ${kwSection} ${suggestions}
`; } /* ======================== 主流程 ======================== */ async function main(): Promise { 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); });