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 { 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 { 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 { 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 { const pushes = await this.listMatchingPushes(eventKey); return pushes.length > 0; } /** 向所有匹配 eventKey 的启用推送发送 markdown;返回成功发送数 */ async dispatchMarkdown( eventKey: WecomPushCondition, content: string, options?: { applyMention?: boolean }, ): Promise { 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 { 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 { 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(WECOM_PUSH_CONDITIONS); for (const c of conditions) { if (!valid.has(c)) throw new BadRequestException(`无效推送条件:${c}`); } return parsed; } }