89 lines
3.1 KiB
TypeScript
89 lines
3.1 KiB
TypeScript
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<boolean> {
|
|
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<boolean> {
|
|
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)}`,
|
|
`> 环境:<font color="comment">${escapeMd(envLabel)}</font>`,
|
|
`> 类别:<font color="comment">${escapeMd(input.category)}</font>`,
|
|
`> 时间:${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 的 <font> 标签可用 */
|
|
function escapeMd(s: string): string {
|
|
return s.replace(/([\\`*_[\]])/g, '\\$1');
|
|
}
|