import { Injectable, Logger } from '@nestjs/common'; import type { WecomPushCondition } from '@dukang/shared-types'; import { RedisService } from '../redis/redis.service'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; import { ALERT_DEDUPE_TTL_SEC, type AlertLevel } from './alert.constants'; export type AlertNotifyInput = { level: AlertLevel; category: string; title: string; detail: string; dedupeKey: string; dedupeTtlSec?: number; /** 覆盖默认事件 key 映射 */ eventKeys?: WecomPushCondition[]; }; @Injectable() export class AlertService { private readonly logger = new Logger(AlertService.name); constructor( private readonly redis: RedisService, private readonly wecomPush: WecomMessagePushService, ) {} /** 异步告警,不阻塞调用方 */ notify(input: AlertNotifyInput): void { void this.notifyAsync(input).catch((e) => { this.logger.warn(`alert notify failed: ${e instanceof Error ? e.message : String(e)}`); }); } async notifyAsync(input: AlertNotifyInput): Promise { const eventKeys = resolveAlertEventKeys(input); const hasAny = await Promise.all(eventKeys.map((k) => this.wecomPush.hasEnabledPushes(k))); if (!hasAny.some(Boolean)) return false; const ttl = input.dedupeTtlSec ?? ALERT_DEDUPE_TTL_SEC; const dedupeRedisKey = `alert:dedupe:${input.dedupeKey}`; try { const ok = await this.redis.client.set(dedupeRedisKey, '1', 'EX', ttl, 'NX'); if (ok !== 'OK') return false; } catch (e) { this.logger.warn( `alert dedupe redis error, send anyway: ${e instanceof Error ? e.message : String(e)}`, ); } return this.sendMarkdownNow(input, eventKeys); } private async sendMarkdownNow( input: AlertNotifyInput, eventKeys: WecomPushCondition[], ): Promise { const envLabel = (process.env.WECOM_ALERT_ENV_LABEL || process.env.NODE_ENV || 'local').trim(); const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }); const content = [ `## [${input.level}] ${escapeMd(input.title)}`, `> 环境:${escapeMd(envLabel)}`, `> 类别:${escapeMd(input.category)}`, `> 时间:${escapeMd(now)}`, '', escapeMd(input.detail).slice(0, 3500), ].join('\n'); let sent = 0; for (const eventKey of eventKeys) { sent += await this.wecomPush.dispatchMarkdown(eventKey, content, { applyMention: false }); } return sent > 0; } } function resolveAlertEventKeys(input: AlertNotifyInput): WecomPushCondition[] { if (input.eventKeys?.length) return [...new Set(input.eventKeys)]; if (input.category === 'pay') return ['alert.pay']; if (input.category === 'redeem') return ['alert.redeem']; if (input.category === 'settlement') return ['alert.settlement']; if (input.category === 'ops') return ['alert.ops']; return ['alert.system']; } /** 轻量转义,保留企微 markdown 的 标签可用 */ function escapeMd(s: string): string { return s.replace(/([\\`*_[\]])/g, '\\$1'); }