| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632 |
- /**
- * 风险预警覆盖审计脚本
- *
- * 用法: 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);
- });
|