audit-coverage.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632
  1. /**
  2. * 风险预警覆盖审计脚本
  3. *
  4. * 用法: cp .env.example .env → 编辑 .env → npx tsx audit-coverage.ts
  5. * 输出: audit-risk-YYYY-MM-DD.html(浏览器打开)
  6. *
  7. * 分析内容:
  8. * 1. 查询指定时间段内所有文本消息
  9. * 2. 查询所有 RiskEvent(同期已有预警)
  10. * 3. 查询所有 RiskKeyword(启用中的关键词)
  11. * 4. 关键词匹配命中但无预警的消息 → 漏报
  12. * 5. AI 评审漏报消息的风险等级
  13. * 6. 可视化 HTML 报告
  14. */
  15. import 'dotenv/config';
  16. import Parse from './parse-client.js';
  17. import * as fs from 'fs';
  18. import * as path from 'path';
  19. import { fileURLToPath } from 'url';
  20. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  21. /* ======================== 配置 ======================== */
  22. const GUID = process.env.QIWE_GUID || '';
  23. const AI_KEY = process.env.DEEPSEEK_API_KEY || '';
  24. const AI_URL = process.env.DEEPSEEK_API_URL || 'https://api.deepseek.com/v1/chat/completions';
  25. const AI_MODEL = process.env.DEEPSEEK_MODEL || 'deepseek-chat';
  26. const AI_ENABLED = AI_KEY && AI_KEY !== 'sk-xxx';
  27. // 默认审计最近 7 天,可通过环境变量覆盖
  28. const DATE_RANGE = {
  29. 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(); })()),
  30. end: new Date(process.env.AUDIT_END || (() => { const d = new Date(); d.setHours(23, 59, 59, 999); return d.toISOString(); })()),
  31. };
  32. /* ======================== 类型 ======================== */
  33. interface KeywordInfo {
  34. word: string;
  35. category: string;
  36. severity: 'high' | 'medium' | 'low';
  37. }
  38. interface MsgInfo {
  39. id: string;
  40. roomId: string;
  41. senderName: string;
  42. content: string;
  43. timestamp: Date;
  44. }
  45. interface RiskEventInfo {
  46. id: string;
  47. groupId: string;
  48. keywords: string[];
  49. severity: string;
  50. title: string;
  51. description: string;
  52. createdAt: Date;
  53. }
  54. interface MissedAlert {
  55. roomId: string;
  56. roomName: string;
  57. messages: MsgInfo[];
  58. matchedKeywords: string[];
  59. maxSeverity: string;
  60. reason: string;
  61. }
  62. /* ======================== 数据查询 ======================== */
  63. async function fetchAllData() {
  64. console.log('[审计] 查询数据...\n');
  65. const [allMessages, riskEvents, keywords, allGroups] = await Promise.all([
  66. (async () => {
  67. const q = new Parse.Query('Message');
  68. q.greaterThanOrEqualTo('timestamp', DATE_RANGE.start);
  69. q.lessThanOrEqualTo('timestamp', DATE_RANGE.end);
  70. q.containedIn('msgType', [0, 2, 13, 29]);
  71. q.notEqualTo('content', '');
  72. q.select(['roomId', 'senderName', 'content', 'timestamp', 'msgType']);
  73. q.limit(200000);
  74. q.ascending('timestamp');
  75. const rows = await q.find({ useMasterKey: true }) as any[];
  76. return rows.map(r => ({
  77. id: r.id,
  78. roomId: (r.get('roomId') as string) || '',
  79. senderName: (r.get('senderName') as string) || '',
  80. content: (r.get('content') as string) || '',
  81. timestamp: r.get('timestamp') as Date,
  82. })) as MsgInfo[];
  83. })(),
  84. (async () => {
  85. const q = new Parse.Query('RiskEvent');
  86. q.greaterThanOrEqualTo('createdAt', DATE_RANGE.start);
  87. q.lessThanOrEqualTo('createdAt', DATE_RANGE.end);
  88. q.select(['groupId', 'keywords', 'severity', 'title', 'description', 'createdAt']);
  89. q.limit(5000);
  90. const rows = await q.find({ useMasterKey: true }) as any[];
  91. return rows.map(r => ({
  92. id: r.id,
  93. groupId: (r.get('groupId') as string) || '',
  94. keywords: (r.get('keywords') as string[]) || [],
  95. severity: (r.get('severity') as string) || '',
  96. title: (r.get('title') as string) || '',
  97. description: (r.get('description') as string) || '',
  98. createdAt: r.get('createdAt') as Date,
  99. })) as RiskEventInfo[];
  100. })(),
  101. (async () => {
  102. const q = new Parse.Query('RiskKeyword');
  103. q.equalTo('enabled', true);
  104. q.select(['word', 'category', 'severity']);
  105. q.limit(5000);
  106. const rows = await q.find({ useMasterKey: true }) as any[];
  107. return rows.map((r: any) => ({
  108. word: r.get('word') as string,
  109. category: r.get('category') as string,
  110. severity: r.get('severity') as string,
  111. })) as KeywordInfo[];
  112. })(),
  113. (async () => {
  114. const q = new Parse.Query('GroupChat');
  115. q.select(['roomId', 'roomName']);
  116. q.limit(5000);
  117. const rows = await q.find({ useMasterKey: true }) as any[];
  118. const map = new Map<string, string>();
  119. for (const r of rows) {
  120. map.set((r.get('roomId') as string) || '', (r.get('roomName') as string) || '未知群');
  121. }
  122. return map;
  123. })(),
  124. ]);
  125. console.log(` Message: ${allMessages.length} | RiskEvent: ${riskEvents.length} | RiskKeyword: ${keywords.length} | GroupChat: ${allGroups.size}`);
  126. return { allMessages, riskEvents, keywords, allGroups };
  127. }
  128. /* ======================== 覆盖分析 ======================== */
  129. function analyzeCoverage(
  130. allMessages: MsgInfo[],
  131. riskEvents: RiskEventInfo[],
  132. keywords: KeywordInfo[],
  133. groupMap: Map<string, string>,
  134. ) {
  135. const msgsByRoom = new Map<string, MsgInfo[]>();
  136. for (const m of allMessages) {
  137. if (!m.roomId) continue;
  138. const arr = msgsByRoom.get(m.roomId);
  139. if (arr) arr.push(m);
  140. else msgsByRoom.set(m.roomId, [m]);
  141. }
  142. const alertedRoomIds = new Set(riskEvents.map(e => e.groupId));
  143. const alertedKeywords = new Set(riskEvents.flatMap(e => e.keywords));
  144. const missedAlerts: MissedAlert[] = [];
  145. for (const [roomId, msgs] of msgsByRoom) {
  146. const hitMap = new Map<string, { msgs: MsgInfo[]; kw: KeywordInfo }>();
  147. for (const msg of msgs) {
  148. for (const kw of keywords) {
  149. if (msg.content.includes(kw.word)) {
  150. if (hitMap.has(kw.word)) {
  151. hitMap.get(kw.word)!.msgs.push(msg);
  152. } else {
  153. hitMap.set(kw.word, { msgs: [msg], kw });
  154. }
  155. }
  156. }
  157. }
  158. if (hitMap.size === 0) continue;
  159. const hitWords = [...hitMap.keys()];
  160. const missedWords = hitWords.filter(w => !alertedKeywords.has(w));
  161. const alertedWords = hitWords.filter(w => alertedKeywords.has(w));
  162. if (missedWords.length > 0) {
  163. const allMissedMsgs: MsgInfo[] = [];
  164. for (const w of missedWords) {
  165. const entry = hitMap.get(w);
  166. if (entry) allMissedMsgs.push(...entry.msgs);
  167. }
  168. const uniqueMsgs = [...new Map(allMissedMsgs.map(m => [m.id, m])).values()]
  169. .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
  170. const severities = missedWords.map(w => hitMap.get(w)?.kw.severity || 'low');
  171. const maxSeverity = severities.includes('high') ? 'high'
  172. : severities.includes('medium') ? 'medium' : 'low';
  173. const hasAlert = alertedRoomIds.has(roomId);
  174. let reason: string;
  175. if (!hasAlert && alertedWords.length === 0) {
  176. reason = '该群无任何预警记录,所有关键词命中均未触发预警';
  177. } else if (!hasAlert) {
  178. reason = `该群无预警记录;命中关键词: [${hitWords.join(', ')}],其中 [${alertedWords.join(', ')}] 在其他群触发过预警`;
  179. } else {
  180. reason = `该群有预警但未覆盖以下关键词: [${missedWords.join(', ')}]`;
  181. }
  182. missedAlerts.push({
  183. roomId,
  184. roomName: groupMap.get(roomId) || `未知群(${roomId})`,
  185. messages: uniqueMsgs.slice(0, 30),
  186. matchedKeywords: missedWords,
  187. maxSeverity,
  188. reason,
  189. });
  190. }
  191. }
  192. const sevOrder = { high: 0, medium: 1, low: 2 };
  193. missedAlerts.sort((a, b) => sevOrder[a.maxSeverity as keyof typeof sevOrder] - sevOrder[b.maxSeverity as keyof typeof sevOrder]);
  194. const trulyMissedKeywords = keywords.filter(kw => !alertedKeywords.has(kw.word));
  195. return {
  196. missedAlerts,
  197. trulyMissedKeywords,
  198. stats: {
  199. totalMessages: allMessages.length,
  200. totalRiskEvents: riskEvents.length,
  201. totalKeywords: keywords.length,
  202. triggeredKeywords: alertedKeywords.size,
  203. untriggeredKeywords: keywords.length - alertedKeywords.size,
  204. coverageRate: keywords.length > 0 ? ((alertedKeywords.size / keywords.length) * 100).toFixed(1) : '0',
  205. missedGroups: missedAlerts.length,
  206. highCount: missedAlerts.filter(a => a.maxSeverity === 'high').length,
  207. mediumCount: missedAlerts.filter(a => a.maxSeverity === 'medium').length,
  208. lowCount: missedAlerts.filter(a => a.maxSeverity === 'low').length,
  209. trulyMissedCount: trulyMissedKeywords.length,
  210. },
  211. };
  212. }
  213. /* ======================== AI 评审 ======================== */
  214. async function aiReviewMissedAlert(alert: MissedAlert): Promise<string> {
  215. if (!AI_ENABLED) return 'AI 未启用';
  216. const samples = alert.messages.slice(0, 15).map(m => {
  217. const t = m.timestamp;
  218. const time = t ? `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}` : '';
  219. return `[${time}] ${m.senderName || '未知'}: ${m.content}`;
  220. }).join('\n');
  221. const prompt = `你是拉迷家居(全屋定制)的风险审核员。以下群聊消息命中了风险关键词 [${alert.matchedKeywords.join(', ')}],但系统未触发预警。
  222. 请判断:1.这些消息是否有真实风险?2.风险等级?3.简要理由(≤80字)
  223. 群: ${alert.roomName}
  224. 命中词: ${alert.matchedKeywords.join(', ')}
  225. 消息样本:
  226. ${samples}
  227. 回复 JSON: {"hasRisk":true/false,"severity":"high"/"medium"/"low"/"none","reason":"≤80字"}`;
  228. try {
  229. const res = await fetch(AI_URL, {
  230. method: 'POST',
  231. headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${AI_KEY}` },
  232. body: JSON.stringify({
  233. model: AI_MODEL,
  234. messages: [
  235. { role: 'system', content: '你是全屋定制行业风险审核员。只回复 JSON,不含 markdown。' },
  236. { role: 'user', content: prompt },
  237. ],
  238. temperature: 0.2,
  239. max_tokens: 256,
  240. }),
  241. });
  242. if (!res.ok) return `AI 调用失败: ${res.status}`;
  243. const json = await res.json() as any;
  244. const content = json?.choices?.[0]?.message?.content || '';
  245. const match = content.match(/\{[\s\S]*\}/);
  246. if (match) {
  247. const p = JSON.parse(match[0]);
  248. const label = p.hasRisk ? `⚠ 有风险(${p.severity})` : '✅ 无风险';
  249. return `${label} | ${p.reason || ''}`;
  250. }
  251. return `AI 响应异常: ${content.slice(0, 100)}`;
  252. } catch (err: any) {
  253. return `AI 错误: ${err.message}`;
  254. }
  255. }
  256. /* ======================== HTML 报告生成 ======================== */
  257. function escapeHtml(s: string): string {
  258. return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  259. }
  260. function sevBadge(s: string): string {
  261. const m: Record<string, string> = { high: '高危', medium: '中危', low: '低危' };
  262. return `<span class="badge badge-${s}">${m[s] || s}</span>`;
  263. }
  264. function sevIcon(s: string): string {
  265. return s === 'high' ? '🔴' : s === 'medium' ? '🟡' : '🟢';
  266. }
  267. function categoryCn(c: string): string {
  268. const m: Record<string, string> = {
  269. complaint: '投诉', quality: '质量', sensitive: '敏感', legal: '法律',
  270. competitor: '竞品', price: '价格', negative: '负面', installation: '安装',
  271. };
  272. return m[c] || c;
  273. }
  274. function generateHTML(
  275. stats: ReturnType<typeof analyzeCoverage>['stats'],
  276. missedAlerts: MissedAlert[],
  277. aiReviews: string[],
  278. riskEvents: RiskEventInfo[],
  279. trulyMissedKeywords: KeywordInfo[],
  280. queryMs: number,
  281. totalMs: number,
  282. ): string {
  283. const genTime = new Date().toLocaleString('zh-CN');
  284. const dateLabel = `${DATE_RANGE.start.toLocaleDateString('zh-CN')} ~ ${DATE_RANGE.end.toLocaleDateString('zh-CN')}`;
  285. // 概览卡片
  286. const cards = `
  287. <div class="card card-blue"><div class="card-num">${stats.totalMessages.toLocaleString()}</div><div class="card-label">文本消息总量</div></div>
  288. <div class="card card-purple"><div class="card-num">${stats.totalRiskEvents}</div><div class="card-label">已有风险事件</div></div>
  289. <div class="card card-teal"><div class="card-num">${stats.totalKeywords}</div><div class="card-label">启用关键词</div></div>
  290. <div class="card card-orange"><div class="card-num">${stats.coverageRate}%</div><div class="card-label">关键词覆盖率</div></div>
  291. <div class="card card-red"><div class="card-num">${stats.missedGroups}</div><div class="card-label">漏报群数量</div></div>
  292. <div class="card card-green"><div class="card-num">${stats.trulyMissedCount}</div><div class="card-label">未覆盖关键词</div></div>
  293. `;
  294. // 已触发预警
  295. let alertSection = '';
  296. if (riskEvents.length > 0) {
  297. const rows = riskEvents.map(e => `
  298. <tr>
  299. <td><span class="badge badge-${e.severity}">${e.severity}</span></td>
  300. <td>${escapeHtml(e.title)}</td>
  301. <td>${(e.keywords || []).map(k => `<code>${escapeHtml(k)}</code>`).join(' ')}</td>
  302. <td>${escapeHtml(e.description?.slice(0, 60) || '')}</td>
  303. <td class="time-cell">${e.createdAt ? new Date(e.createdAt).toLocaleString('zh-CN') : ''}</td>
  304. </tr>`).join('');
  305. alertSection = `
  306. <div class="section"><h2>🛡️ 已触发预警事件 <span class="sub">(${riskEvents.length} 条)</span></h2>
  307. <div class="table-wrap"><table>
  308. <thead><tr><th>严重度</th><th>标题</th><th>关键词</th><th>描述</th><th>时间</th></tr></thead>
  309. <tbody>${rows}</tbody>
  310. </table></div></div>`;
  311. } else {
  312. alertSection = `<div class="section"><h2>🛡️ 已触发预警事件</h2><div class="empty">该期间无风险事件</div></div>`;
  313. }
  314. // 漏报详情
  315. let missedSection = '';
  316. if (missedAlerts.length === 0) {
  317. missedSection = `<div class="section"><h2>🔍 漏报检查</h2><div class="empty success">✅ 未发现漏报</div></div>`;
  318. } else {
  319. const realRisky = aiReviews.filter(r => r.includes('有风险')).length;
  320. const safeCount = aiReviews.filter(r => r.includes('无风险')).length;
  321. let cardsHtml = '';
  322. for (let i = 0; i < missedAlerts.length; i++) {
  323. const a = missedAlerts[i];
  324. const review = aiReviews[i] || '';
  325. const isRisky = review.includes('有风险');
  326. const border = a.maxSeverity === 'high' ? '#e74c3c' : a.maxSeverity === 'medium' ? '#f39c12' : '#3498db';
  327. const bg = a.maxSeverity === 'high' ? '#fff5f5' : a.maxSeverity === 'medium' ? '#fffdf0' : '#f8faff';
  328. const sampleRows = a.messages.slice(0, 5).map(m => {
  329. const t = m.timestamp;
  330. const time = t ? `${t.getMonth() + 1}/${t.getDate()} ${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}` : '';
  331. 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>`;
  332. }).join('');
  333. const kwTags = a.matchedKeywords.map(k => `<span class="kw-tag">${sevIcon(a.maxSeverity)} ${escapeHtml(k)}</span>`).join('');
  334. cardsHtml += `
  335. <div class="missed-card" style="border-left:4px solid ${border};background:${bg}">
  336. <div class="missed-header">
  337. <span class="missed-title">${i + 1}. ${escapeHtml(a.roomName)}</span>
  338. <span>${sevBadge(a.maxSeverity)}</span>
  339. </div>
  340. <div class="missed-body">
  341. <div class="missed-meta">
  342. <div class="meta-item"><span class="meta-label">命中关键词</span>${kwTags}</div>
  343. <div class="meta-item"><span class="meta-label">匹配消息</span>${a.messages.length} 条</div>
  344. <div class="meta-item"><span class="meta-label">原因</span>${escapeHtml(a.reason)}</div>
  345. </div>
  346. ${review ? `<div class="ai-review ${isRisky ? 'ai-risky' : 'ai-safe'}">🤖 AI 评审:${escapeHtml(review)}</div>` : ''}
  347. <details class="msg-details">
  348. <summary>📋 消息样本(点击展开)</summary>
  349. <div class="table-wrap mini-table"><table>
  350. <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
  351. <tbody>${sampleRows}</tbody>
  352. </table></div>
  353. </details>
  354. </div>
  355. </div>`;
  356. }
  357. missedSection = `
  358. <div class="section"><h2>🔍 漏报详情 <span class="sub">(${missedAlerts.length} 个群)</span></h2>
  359. <div class="missed-summary">
  360. <span class="chip chip-red">⚠️ AI 确认有风险: ${realRisky} 个</span>
  361. <span class="chip chip-green">✅ AI 确认无风险: ${safeCount} 个</span>
  362. <span class="chip chip-gray">未评审: ${missedAlerts.length - realRisky - safeCount} 个</span>
  363. </div>
  364. <div class="missed-list">${cardsHtml}</div>
  365. </div>`;
  366. }
  367. // 未覆盖关键词云
  368. let kwSection = '';
  369. if (trulyMissedKeywords.length > 0) {
  370. const sevOrder = { high: 0, medium: 1, low: 2 };
  371. const sorted = [...trulyMissedKeywords].sort((a, b) => sevOrder[a.severity] - sevOrder[b.severity]);
  372. 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');
  373. const hi = sorted.filter(k => k.severity === 'high').length;
  374. const md = sorted.filter(k => k.severity === 'medium').length;
  375. const lo = sorted.filter(k => k.severity === 'low').length;
  376. kwSection = `
  377. <div class="section"><h2>📚 从未触发预警的关键词 <span class="sub">(${trulyMissedKeywords.length} 个)</span></h2>
  378. <div class="kw-stats-bar">
  379. <div class="kw-stat kw-stat-high"><span>${hi}</span> 高危</div>
  380. <div class="kw-stat kw-stat-med"><span>${md}</span> 中危</div>
  381. <div class="kw-stat kw-stat-low"><span>${lo}</span> 低危</div>
  382. </div>
  383. <div class="kw-cloud">${tags}</div>
  384. <p class="hint">这些关键词在审计期间被消息命中,但从未在任何群触发过预警。</p>
  385. </div>`;
  386. }
  387. // 管理建议
  388. const suggestions = `
  389. <div class="section"><h2>💡 管理建议</h2>
  390. <div class="suggestion-grid">
  391. <div class="sugg-card sugg-high">
  392. <div class="sugg-title">🔴 高优先级</div>
  393. <ul>
  394. <li>将「定金/订金/优惠」等促销词从预警列表移除或降级为"仅记录"</li>
  395. <li>质量词(脱胶/开裂/变形/发霉/装错/色差)提高权重,命中即报</li>
  396. </ul>
  397. </div>
  398. <div class="sugg-card sugg-med">
  399. <div class="sugg-title">🟡 中优先级</div>
  400. <ul>
  401. <li>精简关键词库:181 → 60~80 个高质量词</li>
  402. <li>同一群短时间内命中多个低价关键词 → 聚合升级预警</li>
  403. </ul>
  404. </div>
  405. <div class="sugg-card sugg-low">
  406. <div class="sugg-title">🟢 持续改进</div>
  407. <ul>
  408. <li>每周运行本审计脚本,跟踪漏报趋势</li>
  409. <li>建立"关键词→预警→确认→反馈"闭环</li>
  410. <li>竞品类关键词命中频次统计,作为市场情报</li>
  411. </ul>
  412. </div>
  413. </div>
  414. </div>`;
  415. return `<!DOCTYPE html>
  416. <html lang="zh-CN">
  417. <head>
  418. <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
  419. <title>拉迷风险预警覆盖审计 — ${dateLabel}</title>
  420. <style>
  421. *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
  422. body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif;background:#f0f2f5;color:#1a1a2e;line-height:1.6}
  423. .hero{background:linear-gradient(135deg,#0f0c29 0%,#1a1a2e 40%,#16213e 100%);color:#fff;padding:40px 48px;position:relative;overflow:hidden}
  424. .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%}
  425. .hero h1{font-size:26px;font-weight:700;position:relative;z-index:1}
  426. .hero .subtitle{font-size:14px;color:#8892b0;margin-top:6px;position:relative;z-index:1}
  427. .hero .meta{font-size:12px;color:#5a6785;margin-top:4px;position:relative;z-index:1}
  428. .container{max-width:1100px;margin:0 auto;padding:28px 24px}
  429. .overview-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:16px;margin-bottom:36px}
  430. .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}
  431. .card:hover{transform:translateY(-2px)}
  432. .card-num{font-size:36px;font-weight:800;line-height:1.2}
  433. .card-label{font-size:13px;color:#7f8c8d;margin-top:6px}
  434. .card-blue .card-num{color:#2980b9} .card-purple .card-num{color:#8e44ad}
  435. .card-teal .card-num{color:#16a085} .card-orange .card-num{color:#e67e22}
  436. .card-red .card-num{color:#e74c3c} .card-green .card-num{color:#27ae60}
  437. .section{margin-bottom:36px}
  438. .section h2{font-size:18px;font-weight:700;color:#1a1a2e;margin-bottom:16px;padding-bottom:10px;border-bottom:2px solid #e8ecf4}
  439. .sub{font-size:13px;color:#95a5a6;font-weight:400}
  440. .empty{padding:40px;text-align:center;color:#95a5a6;background:#fff;border-radius:10px}
  441. .empty.success{color:#27ae60;background:#eafaf1}
  442. .hint{font-size:13px;color:#95a5a6;margin-top:12px;line-height:1.6}
  443. .badge{font-size:11px;font-weight:700;padding:3px 10px;border-radius:12px;color:#fff;white-space:nowrap}
  444. .badge-high{background:#e74c3c} .badge-medium{background:#f39c12} .badge-low{background:#3498db}
  445. .table-wrap{overflow-x:auto;background:#fff;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,.04)}
  446. table{width:100%;border-collapse:collapse;font-size:13px}
  447. 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}
  448. tbody td{padding:10px 14px;border-bottom:1px solid #f0f2f5;vertical-align:top}
  449. tbody tr:hover{background:#f8f9fb}
  450. .time-cell{white-space:nowrap;color:#95a5a6;font-size:12px}
  451. .msg-cell{max-width:400px;word-break:break-all}
  452. code{background:#f0f2f5;padding:1px 6px;border-radius:3px;font-size:12px;color:#e74c3c}
  453. .missed-summary{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:18px}
  454. .chip{font-size:13px;padding:6px 14px;border-radius:20px;font-weight:600}
  455. .chip-red{background:#fde8e8;color:#c0392b} .chip-green{background:#e8f8f5;color:#1e8449} .chip-gray{background:#f0f2f5;color:#7f8c8d}
  456. .missed-list{display:flex;flex-direction:column;gap:14px}
  457. .missed-card{border-radius:10px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.04)}
  458. .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)}
  459. .missed-title{font-size:15px;font-weight:700}
  460. .missed-body{padding:16px 18px}
  461. .missed-meta{display:flex;flex-direction:column;gap:8px;margin-bottom:10px}
  462. .meta-item{font-size:13px} .meta-label{font-weight:600;color:#555;margin-right:8px}
  463. .kw-tag{display:inline-block;background:#f0f2f5;padding:2px 10px;border-radius:12px;font-size:12px;margin-right:4px;margin-bottom:2px}
  464. .ai-review{font-size:13px;padding:10px 14px;border-radius:8px;margin:10px 0}
  465. .ai-risky{background:#fde8e8;border:1px solid #f5c6cb;color:#721c24}
  466. .ai-safe{background:#e8f8f5;border:1px solid #b7e4cf;color:#155724}
  467. .msg-details{margin-top:8px} .msg-details summary{font-size:13px;color:#3498db;cursor:pointer;font-weight:600}
  468. .kw-stats-bar{display:flex;gap:12px;margin-bottom:14px}
  469. .kw-stat{padding:6px 16px;border-radius:8px;font-size:13px;font-weight:600}
  470. .kw-stat span{font-size:20px;font-weight:800}
  471. .kw-stat-high{background:#fde8e8;color:#c0392b} .kw-stat-med{background:#fef5e7;color:#d68910} .kw-stat-low{background:#eaf2f8;color:#2980b9}
  472. .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)}
  473. .kw-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:16px;font-size:13px;font-weight:600}
  474. .kw-high{background:#fde8e8;color:#c0392b} .kw-medium{background:#fef5e7;color:#d68910} .kw-low{background:#eaf2f8;color:#2980b9}
  475. .kw-cat{font-size:10px;opacity:.7;font-weight:400}
  476. .suggestion-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
  477. .sugg-card{padding:20px;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,.04)}
  478. .sugg-title{font-size:15px;font-weight:700;margin-bottom:10px}
  479. .sugg-card ul{padding-left:18px} .sugg-card li{font-size:13px;margin-bottom:8px;line-height:1.6}
  480. .sugg-high{background:#fff5f5;border:1px solid #fecaca}
  481. .sugg-med{background:#fffdf0;border:1px solid #fde68a}
  482. .sugg-low{background:#f5f9ff;border:1px solid #bfdbfe}
  483. .footer{text-align:center;padding:20px;color:#95a5a6;font-size:12px}
  484. @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}}
  485. @media print{body{background:#fff}.hero{background:#1a1a2e!important;-webkit-print-color-adjust:exact}.missed-card{break-inside:avoid;border:1px solid #ddd}}
  486. </style>
  487. </head>
  488. <body>
  489. <div class="hero">
  490. <h1>🛡️ 风险预警覆盖审计报告</h1>
  491. <div class="subtitle">审计期间:${dateLabel}(${Math.ceil((DATE_RANGE.end.getTime() - DATE_RANGE.start.getTime()) / 86400000)} 天)&nbsp;|&nbsp; 全屋定制 · 拉迷家居</div>
  492. <div class="meta">生成时间:${genTime} &nbsp;|&nbsp; 查询耗时:${queryMs}ms &nbsp;|&nbsp; 总耗时:${totalMs}ms &nbsp;|&nbsp; AI:${AI_ENABLED ? `DeepSeek(${AI_MODEL})` : '未启用'}</div>
  493. </div>
  494. <div class="container">
  495. <div class="overview-grid">${cards}</div>
  496. ${alertSection}
  497. ${missedSection}
  498. ${kwSection}
  499. ${suggestions}
  500. <div class="footer">拉迷家居 · 风险预警覆盖审计 · 自动生成</div>
  501. </div>
  502. </body>
  503. </html>`;
  504. }
  505. /* ======================== 主流程 ======================== */
  506. async function main(): Promise<void> {
  507. const t0 = Date.now();
  508. console.log('═══════════════════════════════════════');
  509. console.log(' 风险预警覆盖审计');
  510. console.log(` 时间: ${DATE_RANGE.start.toLocaleDateString('zh-CN')} ~ ${DATE_RANGE.end.toLocaleDateString('zh-CN')}`);
  511. console.log(` AI: ${AI_ENABLED ? `DeepSeek (${AI_MODEL})` : '禁用'}`);
  512. console.log('═══════════════════════════════════════\n');
  513. // 1. 查询
  514. const t1 = Date.now();
  515. const { allMessages, riskEvents, keywords, allGroups } = await fetchAllData();
  516. const queryMs = Date.now() - t1;
  517. console.log(` 查询耗时: ${queryMs}ms\n`);
  518. // 2. 覆盖分析
  519. console.log('[审计] 关键词覆盖分析...');
  520. const { missedAlerts, trulyMissedKeywords, stats } = analyzeCoverage(allMessages, riskEvents, keywords, allGroups);
  521. console.log(` 漏报群: ${stats.missedGroups},高危 ${stats.highCount} / 中危 ${stats.mediumCount} / 低危 ${stats.lowCount}`);
  522. console.log(` 完全未触发预警的关键词: ${stats.trulyMissedCount}\n`);
  523. // 3. AI 评审(限前 20 个高危/中危)
  524. console.log('[审计] AI 深度评审漏报...');
  525. const toReview = missedAlerts.filter(a => a.maxSeverity === 'high' || a.maxSeverity === 'medium').slice(0, 20);
  526. const aiReviews: string[] = [];
  527. for (let i = 0; i < toReview.length; i++) {
  528. const a = toReview[i];
  529. console.log(` → AI 评审 ${i + 1}/${toReview.length}: ${a.roomName} (${a.maxSeverity})`);
  530. const review = await aiReviewMissedAlert(a);
  531. aiReviews.push(review);
  532. console.log(` ← ${review}`);
  533. }
  534. const allAiReviews = missedAlerts.map(a => {
  535. const idx = toReview.indexOf(a);
  536. return idx >= 0 ? aiReviews[idx] : '(低危,未提交 AI 评审)';
  537. });
  538. // 4. HTML 报告
  539. const totalMs = Date.now() - t0;
  540. const html = generateHTML(stats, missedAlerts, allAiReviews, riskEvents, trulyMissedKeywords, queryMs, totalMs);
  541. const dateStr = DATE_RANGE.start.toISOString().slice(0, 10);
  542. const outputPath = path.resolve(__dirname, `audit-risk-${dateStr}.html`);
  543. fs.writeFileSync(outputPath, html, 'utf-8');
  544. console.log(`\n✅ 报告已生成: ${outputPath}`);
  545. console.log(` 文件大小: ${(Buffer.byteLength(html, 'utf-8') / 1024).toFixed(1)} KB | 总耗时: ${totalMs}ms\n`);
  546. // 控制台摘要
  547. console.log('═══════════════════════════════════════');
  548. console.log(' 关键发现');
  549. console.log('═══════════════════════════════════════');
  550. console.log(` 关键词覆盖率: ${stats.coverageRate}% (${stats.triggeredKeywords}/${stats.totalKeywords})`);
  551. console.log(` 完全未覆盖关键词: ${stats.trulyMissedCount}`);
  552. console.log(` 漏报群: ${stats.missedGroups} (高危 ${stats.highCount} / 中危 ${stats.mediumCount} / 低危 ${stats.lowCount})`);
  553. if (stats.trulyMissedCount > 0) {
  554. console.log(`\n 未覆盖关键词 TOP 15:`);
  555. for (const kw of trulyMissedKeywords.slice(0, 15)) {
  556. console.log(` - ${kw.word} (${kw.category}/${kw.severity})`);
  557. }
  558. }
  559. console.log('');
  560. }
  561. main().catch((err) => {
  562. console.error('[审计] 失败:', err);
  563. process.exit(1);
  564. });