feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user