Files
dukang/server/dukang-api/src/integrations/wecom/wecom-message-push.service.ts
T
2026-08-04 21:38:49 +08:00

227 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import {
WECOM_PUSH_CONDITIONS,
WECOM_PUSH_DEFAULT_ALERT_CONDITIONS,
WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS,
maskWecomWebhookUrl,
parseWecomPushConditions,
type WecomMessagePushDto,
type WecomPushCondition,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util';
type PushRow = {
id: bigint;
name: string;
avatarUrl: string | null;
webhookUrl: string;
enabled: boolean;
mentionWecomUserId: string | null;
pushConditions: string;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
};
@Injectable()
export class WecomMessagePushService implements OnModuleInit {
private readonly logger = new Logger(WecomMessagePushService.name);
constructor(private readonly prisma: PrismaService) {}
async onModuleInit(): Promise<void> {
try {
await this.ensureDefaults();
} catch (e) {
this.logger.warn(
`wecom message push ensureDefaults failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
/** 表空时从 .env / 旧 dev_plan_settings 迁移默认推送(v3.4.11 */
async ensureDefaults(): Promise<void> {
const count = await this.prisma.wecomMessagePush.count();
if (count > 0) return;
const alertUrl = (process.env.WECOM_ALERT_WEBHOOK_URL || '').trim();
if (alertUrl) {
const alertEnabled = process.env.WECOM_ALERT_ENABLED !== 'false';
await this.prisma.wecomMessagePush.create({
data: {
name: '运营告警',
webhookUrl: alertUrl,
enabled: alertEnabled,
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_ALERT_CONDITIONS),
sortOrder: 0,
},
});
this.logger.log('seeded wecom message push: 运营告警');
}
let devWebhook: string | null = null;
let devUserId: string | null = null;
let devEnabled = false;
try {
const rows = await this.prisma.$queryRawUnsafe<
Array<{
task_dispatch_webhook_url: string | null;
task_dispatch_wecom_user_id: string | null;
task_dispatch_enabled: number | boolean | null;
}>
>(
'SELECT task_dispatch_webhook_url, task_dispatch_wecom_user_id, task_dispatch_enabled FROM dev_plan_settings LIMIT 1',
);
const row = rows[0];
if (row) {
devWebhook = row.task_dispatch_webhook_url;
devUserId = row.task_dispatch_wecom_user_id;
devEnabled = !!row.task_dispatch_enabled;
}
} catch {
// 列已迁移删除,跳过
}
if (devWebhook?.trim()) {
await this.prisma.wecomMessagePush.create({
data: {
name: '开发任务派发',
webhookUrl: devWebhook.trim(),
enabled: devEnabled,
mentionWecomUserId: devUserId?.trim() || null,
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS),
sortOrder: 10,
},
});
this.logger.log('seeded wecom message push: 开发任务派发');
}
}
async listMatchingPushes(eventKey: WecomPushCondition): Promise<PushRow[]> {
const rows = await this.prisma.wecomMessagePush.findMany({
where: { enabled: true },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return rows.filter((r) => parseWecomPushConditions(r.pushConditions).includes(eventKey));
}
async hasEnabledPushes(eventKey: WecomPushCondition): Promise<boolean> {
const pushes = await this.listMatchingPushes(eventKey);
return pushes.length > 0;
}
/** 向所有匹配 eventKey 的启用推送发送 markdown;返回成功发送数 */
async dispatchMarkdown(
eventKey: WecomPushCondition,
content: string,
options?: { applyMention?: boolean },
): Promise<number> {
const pushes = await this.listMatchingPushes(eventKey);
if (!pushes.length) return 0;
const applyMention = options?.applyMention !== false;
let sent = 0;
for (const push of pushes) {
let text = content.trim();
if (applyMention && push.mentionWecomUserId) {
text = applyWecomAtMentionInContent(text, push.mentionWecomUserId);
}
const ok = await this.sendMarkdownToWebhook(push.webhookUrl, text);
if (ok) sent += 1;
}
return sent;
}
async dispatchMarkdownOrThrow(
eventKey: WecomPushCondition,
content: string,
options?: { applyMention?: boolean },
): Promise<number> {
const sent = await this.dispatchMarkdown(eventKey, content, options);
if (sent === 0) {
throw new BadRequestException(
`没有已启用且勾选「${eventKey}」条件的消息推送,请在 HQ「企微机器人 → 消息推送」中配置`,
);
}
return sent;
}
async sendMarkdownToWebhook(webhookUrl: string, content: string): Promise<boolean> {
const url = (webhookUrl || '').trim();
if (!url) return false;
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
msgtype: 'markdown',
markdown: { content: content.slice(0, 4000) },
}),
});
const data = (await res.json().catch(() => ({}))) as {
errcode?: number;
errmsg?: string;
};
if (!res.ok || (data.errcode != null && data.errcode !== 0)) {
this.logger.warn(
`wecom message push failed: HTTP ${res.status} errcode=${data.errcode} ${data.errmsg ?? ''}`,
);
return false;
}
return true;
} catch (e) {
this.logger.warn(
`wecom message push network error: ${e instanceof Error ? e.message : String(e)}`,
);
return false;
}
}
async sendTest(id: bigint): Promise<{ ok: boolean; message: string }> {
const row = await this.prisma.wecomMessagePush.findUnique({ where: { id } });
if (!row) throw new BadRequestException('消息推送不存在');
if (!row.webhookUrl.trim()) {
return { ok: false, message: 'Webhook URL 未配置' };
}
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
let content = `**消息推送测试 · ${row.name}**\n时间:${now}`;
if (row.mentionWecomUserId) {
content = applyWecomAtMentionInContent(content, row.mentionWecomUserId);
}
const ok = await this.sendMarkdownToWebhook(row.webhookUrl, content);
return ok
? { ok: true, message: '已发送测试消息,请查看企微群' }
: { ok: false, message: 'Webhook 调用失败,请检查 URL 或 API 日志' };
}
toDto(row: PushRow): WecomMessagePushDto {
return {
id: row.id.toString(),
name: row.name,
avatarUrl: row.avatarUrl,
webhookUrl: row.webhookUrl,
webhookUrlMasked: maskWecomWebhookUrl(row.webhookUrl),
enabled: row.enabled,
mentionWecomUserId: row.mentionWecomUserId,
pushConditions: parseWecomPushConditions(row.pushConditions),
sortOrder: row.sortOrder,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
validatePushConditions(conditions: string[]): WecomPushCondition[] {
const parsed = parseWecomPushConditions(conditions);
if (!parsed.length) {
throw new BadRequestException('请至少勾选一项推送条件');
}
const valid = new Set<string>(WECOM_PUSH_CONDITIONS);
for (const c of conditions) {
if (!valid.has(c)) throw new BadRequestException(`无效推送条件:${c}`);
}
return parsed;
}
}