risk.service.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. /**
  2. * 群风控与异常干预模块 — 业务服务层
  3. *
  4. * 对应规范文档 §八「模块 5:群风控与异常干预模块」
  5. *
  6. * 业务流程:
  7. * §8.1 实时监听群消息:由 qiwei 模块的 webhook 消费者完成
  8. * §8.2 风险关键词库配置:CRUD 管理风险词
  9. * §8.3 命中敏感词自动预警:消息内容与词库匹配 → 生成 alert
  10. * §8.4 人数骤降等阈值异常:统计退群事件、零互动天数 → 生成 alert
  11. * §8.5 异常提醒到企微:通过 API-14 发送
  12. * §8.6 预警自动生成干预工单:alert 创建时联动生成 work_order
  13. * §8.7 工单处理与关闭:更新处理记录 → 计时统计响应时长
  14. * §8.8 异常处理案例知识库:已关闭工单归档为 kb_case(二期)
  15. */
  16. import { AppError } from '../../../../shared/errors/app-error.js';
  17. import type {
  18. RiskKeyword,
  19. Alert,
  20. AlertType,
  21. WorkOrder,
  22. WorkOrderStatus,
  23. } from '../models/risk.model.js';
  24. // ---------- 内存存储(TODO: 替换为数据库)----------
  25. const keywordStore = new Map<number, RiskKeyword>();
  26. const alertStore = new Map<number, Alert>();
  27. const workOrderStore = new Map<number, WorkOrder>();
  28. let nextKeywordId = 1;
  29. let nextAlertId = 1;
  30. let nextWorkOrderId = 1;
  31. // ============================================================
  32. // 风险关键词库(§8.2)
  33. // ============================================================
  34. /** 创建关键词 */
  35. export function createKeyword(data: Omit<RiskKeyword, 'id' | 'createdAt'>): RiskKeyword {
  36. const kw: RiskKeyword = {
  37. id: nextKeywordId++,
  38. ...data,
  39. createdAt: new Date().toISOString(),
  40. };
  41. keywordStore.set(kw.id, kw);
  42. return kw;
  43. }
  44. /** 查询关键词列表 */
  45. export function listKeywords(category?: string, enabled?: number): RiskKeyword[] {
  46. let result = Array.from(keywordStore.values());
  47. if (category) result = result.filter((k) => k.category === category);
  48. if (enabled !== undefined) result = result.filter((k) => k.enabled === enabled);
  49. return result;
  50. }
  51. /** 更新关键词 */
  52. export function updateKeyword(id: number, data: Partial<RiskKeyword>): RiskKeyword {
  53. const kw = keywordStore.get(id);
  54. if (!kw) throw new AppError(404, 'KEYWORD_NOT_FOUND', `关键词 ID=${id} 不存在`);
  55. Object.assign(kw, data);
  56. keywordStore.set(id, kw);
  57. return kw;
  58. }
  59. /** 删除关键词 */
  60. export function deleteKeyword(id: number): void {
  61. if (!keywordStore.has(id)) throw new AppError(404, 'KEYWORD_NOT_FOUND', `关键词 ID=${id} 不存在`);
  62. keywordStore.delete(id);
  63. }
  64. /**
  65. * 扫描消息内容,匹配风险关键词
  66. *
  67. * 实际生产环境在 webhook 消费者中对每条消息实时执行。
  68. *
  69. * @param content - 消息文本内容
  70. * @param roomId - 群 ID
  71. * @param messageId - 消息 ID
  72. * @returns 命中的预警列表(如无命中则返回空数组)
  73. */
  74. export function scanMessageForKeywords(
  75. content: string,
  76. roomId: string,
  77. messageId: number,
  78. ): Alert[] {
  79. const alerts: Alert[] = [];
  80. const activeKeywords = listKeywords(undefined, 1);
  81. for (const kw of activeKeywords) {
  82. if (content.includes(kw.keyword)) {
  83. const alert = createAlert({
  84. roomId,
  85. type: 'keyword',
  86. level: kw.severity,
  87. title: `命中风险关键词"${kw.keyword}"`,
  88. description: `群 ${roomId} 的消息命中风险关键词"${kw.keyword}"(分类:${kw.category})`,
  89. matchedKeyword: kw.keyword,
  90. messageId,
  91. });
  92. alerts.push(alert);
  93. }
  94. }
  95. return alerts;
  96. }
  97. // ============================================================
  98. // 预警管理(§8.3 ~ §8.5)
  99. // ============================================================
  100. interface CreateAlertParams {
  101. roomId: string;
  102. type: AlertType;
  103. level: 1 | 2 | 3;
  104. title: string;
  105. description: string;
  106. matchedKeyword?: string;
  107. messageId?: number;
  108. }
  109. /** 创建预警记录 */
  110. export function createAlert(params: CreateAlertParams): Alert {
  111. const now = new Date().toISOString();
  112. const alert: Alert = {
  113. id: nextAlertId++,
  114. roomId: params.roomId,
  115. type: params.type,
  116. level: params.level,
  117. title: params.title,
  118. description: params.description,
  119. matchedKeyword: params.matchedKeyword || '',
  120. messageId: params.messageId || 0,
  121. status: 'open',
  122. handlerId: 0,
  123. handleNote: '',
  124. workOrderId: 0,
  125. createdAt: now,
  126. handledAt: '',
  127. };
  128. alertStore.set(alert.id, alert);
  129. // ---- §8.6:高危预警自动生成干预工单 ----
  130. if (params.level >= 3) {
  131. const order = createWorkOrder({
  132. alertId: alert.id,
  133. title: `【自动】${params.title}`,
  134. description: params.description,
  135. assigneeId: 0, // 待分配
  136. });
  137. alert.workOrderId = order.id;
  138. alertStore.set(alert.id, alert);
  139. }
  140. return alert;
  141. }
  142. /** 查询预警列表 */
  143. export function listAlerts(
  144. roomId?: string,
  145. type?: AlertType,
  146. status?: 'open' | 'handled' | 'ignored',
  147. ): Alert[] {
  148. let result = Array.from(alertStore.values());
  149. if (roomId) result = result.filter((a) => a.roomId === roomId);
  150. if (type) result = result.filter((a) => a.type === type);
  151. if (status) result = result.filter((a) => a.status === status);
  152. return result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
  153. }
  154. /** 处理预警 */
  155. export function handleAlert(
  156. id: number,
  157. status: 'handled' | 'ignored',
  158. handlerId: number,
  159. note: string,
  160. ): Alert {
  161. const alert = alertStore.get(id);
  162. if (!alert) throw new AppError(404, 'ALERT_NOT_FOUND', `预警 ID=${id} 不存在`);
  163. alert.status = status;
  164. alert.handlerId = handlerId;
  165. alert.handleNote = note;
  166. alert.handledAt = new Date().toISOString();
  167. alertStore.set(id, alert);
  168. return alert;
  169. }
  170. // ============================================================
  171. // 干预工单(§8.6 ~ §8.7)
  172. // ============================================================
  173. interface CreateWorkOrderParams {
  174. alertId: number;
  175. title: string;
  176. description: string;
  177. assigneeId: number;
  178. deadline?: string;
  179. }
  180. /** 创建工单 */
  181. export function createWorkOrder(params: CreateWorkOrderParams): WorkOrder {
  182. const now = new Date().toISOString();
  183. // 默认截止时间为 24h 后
  184. const deadline = params.deadline || new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
  185. const order: WorkOrder = {
  186. id: nextWorkOrderId++,
  187. alertId: params.alertId,
  188. title: params.title,
  189. description: params.description,
  190. assigneeId: params.assigneeId,
  191. deadline,
  192. status: 'pending',
  193. handleRecord: '',
  194. createdAt: now,
  195. completedAt: '',
  196. };
  197. workOrderStore.set(order.id, order);
  198. return order;
  199. }
  200. /** 查询工单列表 */
  201. export function listWorkOrders(status?: WorkOrderStatus, assigneeId?: number): WorkOrder[] {
  202. let result = Array.from(workOrderStore.values());
  203. if (status) result = result.filter((o) => o.status === status);
  204. if (assigneeId !== undefined) result = result.filter((o) => o.assigneeId === assigneeId);
  205. return result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
  206. }
  207. /** 更新工单状态 */
  208. export function updateWorkOrder(
  209. id: number,
  210. status: WorkOrderStatus,
  211. handleRecord?: string,
  212. ): WorkOrder {
  213. const order = workOrderStore.get(id);
  214. if (!order) throw new AppError(404, 'WORK_ORDER_NOT_FOUND', `工单 ID=${id} 不存在`);
  215. order.status = status;
  216. if (handleRecord) order.handleRecord = handleRecord;
  217. if (status === 'completed') order.completedAt = new Date().toISOString();
  218. workOrderStore.set(id, order);
  219. return order;
  220. }