Files
dukang/server/dukang-api/src/common/alert/alert.service.ts
T
2026-08-04 21:38:49 +08:00

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');
}