| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751 |
- /**
- * 风险预警覆盖审计脚本 (6/15-6/22)
- *
- * 用法: cd backend && npx tsx scripts/audit-risk-coverage.ts
- * 输出: backend/scripts/audit-risk-2026-06-15-22.md
- *
- * 分析内容:
- * 1. 查询 6/15-6/22 所有文本消息
- * 2. 查询所有 RiskEvent(同期已有预警)
- * 3. 查询所有 RiskKeyword(启用中的关键词)
- * 4. 关键词匹配命中但无预警的消息列表(漏报)
- * 5. AI 评审漏报消息的风险等级
- * 6. 管理建议
- */
- import 'dotenv/config';
- import Parse from '../src/db/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));
- /* ======================== AI 配置 ======================== */
- 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';
- /* ======================== 类型 ======================== */
- interface KeywordInfo {
- word: string;
- category: string;
- severity: 'high' | 'medium' | 'low';
- }
- interface MsgInfo {
- id: string;
- roomId: string;
- senderName: string;
- content: string;
- timestamp: Date;
- msgType: number;
- }
- 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; // 为什么没触发预警(分析)
- }
- /* ======================== 数据查询 ======================== */
- const DATE_RANGE = {
- start: new Date('2026-06-15T00:00:00+08:00'),
- end: new Date('2026-06-22T23:59:59+08:00'),
- };
- async function fetchAllData() {
- console.log('[审计] 查询 6/15-6/22 数据...\n');
- const [allMessages, riskEvents, keywords, allGroups] = await Promise.all([
- // 1. 所有文本消息
- (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]); // 文本、富文本、链接、Markdown
- 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[];
- const msgs: MsgInfo[] = 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,
- msgType: (r.get('msgType') as number) || 0,
- }));
- console.log(` Message: ${msgs.length} 条文本消息`);
- return msgs;
- })(),
- // 2. 同期 RiskEvent
- (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[];
- const events: RiskEventInfo[] = 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,
- }));
- console.log(` RiskEvent: ${events.length} 条预警`);
- return events;
- })(),
- // 3. 启用关键词
- (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[];
- const kws: KeywordInfo[] = rows.map((r: any) => ({
- word: r.get('word') as string,
- category: r.get('category') as string,
- severity: r.get('severity') as string,
- }));
- console.log(` RiskKeyword: ${kws.length} 个启用关键词`);
- return kws;
- })(),
- // 4. 群列表
- (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;
- })(),
- ]);
- return { allMessages, riskEvents, keywords, allGroups };
- }
- /* ======================== 分析引擎 ======================== */
- function matchKeywordsInMessages(messages: MsgInfo[], keywords: KeywordInfo[]): Map<string, { msgs: MsgInfo[]; kw: KeywordInfo }> {
- const hits = new Map<string, { msgs: MsgInfo[]; kw: KeywordInfo }>();
- for (const msg of messages) {
- const content = msg.content;
- if (!content) continue;
- for (const kw of keywords) {
- if (content.includes(kw.word)) {
- const key = kw.word;
- if (hits.has(key)) {
- hits.get(key)!.msgs.push(msg);
- } else {
- hits.set(key, { msgs: [msg], kw });
- }
- }
- }
- }
- return hits;
- }
- 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]);
- }
- // 已有预警的 roomId 集合
- 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 hits = matchKeywordsInMessages(msgs, keywords);
- if (hits.size === 0) continue;
- // 该群是否有预警
- const hasAlert = alertedRoomIds.has(roomId);
- // 命中的关键词
- const hitWords = [...hits.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 = hits.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 => hits.get(w)?.kw.severity || 'low');
- const maxSeverity = severities.includes('high') ? 'high'
- : severities.includes('medium') ? 'medium' : 'low';
- 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), // 每个群最多 30 条
- matchedKeywords: missedWords,
- maxSeverity,
- reason,
- });
- }
- }
- // 按严重度排序
- const severityOrder = { high: 0, medium: 1, low: 2 };
- missedAlerts.sort((a, b) => severityOrder[a.maxSeverity as keyof typeof severityOrder] - severityOrder[b.maxSeverity as keyof typeof severityOrder]);
- // 汇总统计
- const totalKeywords = keywords.length;
- const triggeredKeywords = alertedKeywords.size;
- const untriggeredKeywords = totalKeywords - triggeredKeywords;
- const coverageRate = totalKeywords > 0 ? ((triggeredKeywords / totalKeywords) * 100).toFixed(1) : '0';
- const totalMatchedMessages = missedAlerts.reduce((sum, a) => sum + a.messages.length, 0);
- const highCount = missedAlerts.filter(a => a.maxSeverity === 'high').length;
- const mediumCount = missedAlerts.filter(a => a.maxSeverity === 'medium').length;
- const lowCount = missedAlerts.filter(a => a.maxSeverity === 'low').length;
- const allHitWords = new Set<string>();
- for (const a of missedAlerts) {
- for (const w of a.matchedKeywords) allHitWords.add(w);
- }
- // 真正漏报的关键词(不在任何预警中出现过)
- const trulyMissedKeywords = keywords.filter(kw => !alertedKeywords.has(kw.word));
- return {
- missedAlerts,
- stats: {
- totalMessages: allMessages.length,
- totalRiskEvents: riskEvents.length,
- totalKeywords,
- triggeredKeywords,
- untriggeredKeywords,
- coverageRate,
- missedGroups: missedAlerts.length,
- totalMatchedMessages,
- highCount,
- mediumCount,
- lowCount,
- trulyMissedCount: trulyMissedKeywords.length,
- trulyMissedKeywords,
- allHitWords: [...allHitWords],
- },
- };
- }
- /* ======================== AI 深度评审 ======================== */
- async function aiReviewMissedAlert(alert: MissedAlert): Promise<string> {
- if (!AI_ENABLED) return 'AI 未启用';
- const samples = alert.messages.slice(0, 15).map(m => {
- const ts = m.timestamp;
- const time = ts ? `${String(ts.getHours()).padStart(2, '0')}:${String(ts.getMinutes()).padStart(2, '0')}` : '';
- return `[${time}] ${m.senderName || '未知'}: ${m.content}`;
- }).join('\n');
- const prompt = `你是拉迷家居(全屋定制)的风险审核员。以下群聊消息命中了风险关键词 [${alert.matchedKeywords.join(', ')}],但系统未触发预警。
- 请判断:
- 1. 这些消息是否有真实风险?(true/false)
- 2. 风险等级?(high/medium/low/none)
- 3. 是否建议将相关词加入预警?(yes/no — 仅当命中词不在关键词库中时考虑)
- 4. 简要理由(≤80字)
- 群: ${alert.roomName}
- 命中词: ${alert.matchedKeywords.join(', ')}
- 消息样本:
- ${samples}
- 回复 JSON(不含 markdown 代码块):
- {"hasRisk":true/false,"severity":"high"/"medium"/"low"/"none","shouldAlert":true/false,"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 parsed = JSON.parse(match[0]);
- const riskLabel = parsed.hasRisk ? `⚠ 有风险(${parsed.severity})` : '✅ 无风险';
- return `${riskLabel} | 建议预警: ${parsed.shouldAlert ? '是' : '否'} | ${parsed.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(severity: string): string {
- const map: Record<string, string> = { high: '高危', medium: '中危', low: '低危' };
- return `<span class="badge badge-${severity}">${map[severity] || severity}</span>`;
- }
- function sevIcon(s: string): string {
- return s === 'high' ? '🔴' : s === 'medium' ? '🟡' : '🟢';
- }
- function categoryCn(cat: string): string {
- const map: Record<string, string> = {
- complaint: '投诉', quality: '质量', sensitive: '敏感', legal: '法律',
- competitor: '竞品', price: '价格', negative: '负面', installation: '安装',
- };
- return map[cat] || cat;
- }
- function generateHTML(
- stats: ReturnType<typeof analyzeCoverage> extends { stats: infer S } ? S : never,
- missedAlerts: MissedAlert[],
- aiReviews: string[],
- riskEvents: RiskEventInfo[],
- queryMs: number,
- totalMs: number,
- ): string {
- const genTime = new Date().toLocaleString('zh-CN');
- // ── 概览卡片 ──
- const overviewCards = `
- <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 alertRows = 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>${alertRows}</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 falsePositive = aiReviews.filter(r => r.includes('无风险')).length;
- let cards = '';
- for (let i = 0; i < missedAlerts.length; i++) {
- const a = missedAlerts[i];
- const review = aiReviews[i] || '';
- const isRisky = review.includes('有风险');
- const borderColor = a.maxSeverity === 'high' ? '#e74c3c' : a.maxSeverity === 'medium' ? '#f39c12' : '#3498db';
- const bgColor = a.maxSeverity === 'high' ? '#fff5f5' : a.maxSeverity === 'medium' ? '#fffdf0' : '#f8faff';
- const sampleRows = a.messages.slice(0, 5).map(m => {
- const ts = m.timestamp;
- const time = ts ? `${ts.getMonth() + 1}/${ts.getDate()} ${String(ts.getHours()).padStart(2, '0')}:${String(ts.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('');
- cards += `
- <div class="missed-card" style="border-left: 4px solid ${borderColor}; background: ${bgColor};">
- <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 确认无风险: ${falsePositive} 个</span>
- <span class="chip chip-gray">未评审: ${missedAlerts.length - realRisky - falsePositive} 个</span>
- </div>
- <div class="missed-list">${cards}</div>
- </div>`;
- }
- // ── 未覆盖关键词 ──
- let kwSection = '';
- if (stats.trulyMissedCount > 0) {
- const severityOrder = { high: 0, medium: 1, low: 2 };
- const sorted = [...stats.trulyMissedKeywords].sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
- const kwTags = sorted.map(k => `<span class="kw-chip kw-${k.severity}">${k.word}<span class="kw-cat">${categoryCn(k.category)}</span></span>`).join('\n');
- // 分类统计
- const highCount = sorted.filter(k => k.severity === 'high').length;
- const medCount = sorted.filter(k => k.severity === 'medium').length;
- const lowCount = sorted.filter(k => k.severity === 'low').length;
- kwSection = `
- <div class="section">
- <h2>📚 从未触发预警的关键词 <span class="sub">(${stats.trulyMissedCount} 个)</span></h2>
- <div class="kw-stats-bar">
- <div class="kw-stat kw-stat-high"><span>${highCount}</span> 高危</div>
- <div class="kw-stat kw-stat-med"><span>${medCount}</span> 中危</div>
- <div class="kw-stat kw-stat-low"><span>${lowCount}</span> 低危</div>
- </div>
- <div class="kw-cloud">${kwTags}</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>将<strong>「定金」「订金」「色差」「优惠」</strong>等促销类关键词从预警名单移除或降级为"仅记录"</li>
- <li>质量关键词(<strong>脱胶/开裂/变形/发霉/装错</strong>)提高权重,命中即报警</li>
- </ul>
- </div>
- <div class="sugg-card sugg-med">
- <div class="sugg-title">🟡 中优先级</div>
- <ul>
- <li>对 176 个从未命中的关键词做"大扫除":保留核心词(投诉/退款/甲醛等),删除极端低频词</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>`;
- // ── 组合完整 HTML ──
- return `<!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>拉迷风险预警覆盖审计 — 2026.06.15-22</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}
- /* Header */
- .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 */
- .container{max-width:1100px;margin:0 auto;padding:28px 24px}
- /* Overview Cards */
- .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}
- /* Sections */
- .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 */
- .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 */
- .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}
- .mini-table{font-size:12px;margin-top:0}
- /* Missed Alerts */
- .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}
- .msg-details summary:hover{color:#2980b9}
- /* Keyword cloud */
- .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}
- /* Suggestions */
- .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 */
- .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">审计期间:2026-06-15 ~ 2026-06-22(8 天) | 全屋定制 · 拉迷家居</div>
- <div class="meta">生成时间:${genTime} | 查询耗时:${queryMs}ms | 总耗时:${totalMs}ms | AI 引擎:${AI_ENABLED ? 'DeepSeek' : '未启用'}</div>
- </div>
- <div class="container">
- <div class="overview-grid">${overviewCards}</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(' 风险预警覆盖审计 (6/15 - 6/22)');
- 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(`\n 查询耗时: ${queryMs}ms\n`);
- // 2. 覆盖分析
- console.log('[审计] 关键词覆盖分析...');
- const { missedAlerts, 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}`);
- }
- // 为所有漏报创建 AI 评审数组(与 missedAlerts 对齐)
- const allAiReviews: string[] = 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, queryMs, totalMs);
- const outputPath = path.resolve(__dirname, 'audit-risk-2026-06-15-22.html');
- fs.writeFileSync(outputPath, html, 'utf-8');
- console.log(`\n✅ 报告已生成: ${outputPath}`);
- console.log(` 文件大小: ${(Buffer.byteLength(html, 'utf-8') / 1024).toFixed(1)} KB`);
- console.log(` 总耗时: ${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 未覆盖关键词 (前 15):`);
- for (const kw of stats.trulyMissedKeywords.slice(0, 15)) {
- console.log(` - ${kw.word} (${kw.category}/${kw.severity})`);
- }
- }
- console.log('');
- }
- main().catch((err) => {
- console.error('[审计] 失败:', err);
- process.exit(1);
- });
|