audit-risk-coverage.ts 31 KB

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