import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { WECOM_BIZ_TODO_PUSH_NAME, WECOM_DEAL_BROADCAST_PUSH_NAME, WECOM_PUSH_CONDITIONS, WECOM_PUSH_DEFAULT_ALERT_CONDITIONS, WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS, WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS, WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS, WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS, WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK, WECOM_STORE_AUDIT_PUSH_NAME, WECOM_TEMPLATE_EVENT_KEYS, maskWecomWebhookUrl, parseWecomPushConditions, type WecomMessagePushDto, type WecomPushCondition, type WecomPushTemplateDto, type WecomTemplateEventKey, } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util'; import { WECOM_PUSH_TEMPLATE_DEFAULTS, buildHqHandleUrl, getDefaultTemplate, renderWecomTemplate, } from './wecom-push-template.defaults'; type PushRow = { id: bigint; name: string; avatarUrl: string | null; webhookUrl: string; enabled: boolean; mentionWecomUserId: string | null; pushConditions: string; sortOrder: number; createdAt: Date; updatedAt: Date; }; type TemplateRow = { id: bigint; eventKey: string; title: string; body: string; handleLabel: string; 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 / 旧设置迁移;并确保门店审核 / 业务待办 / 成交播报 / 模板缺行 */ async ensureDefaults(): Promise { const count = await this.prisma.wecomMessagePush.count(); if (count === 0) { 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: 开发任务派发'); } } await this.ensureNamedPush( WECOM_STORE_AUDIT_PUSH_NAME, WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS, 20, process.env.WECOM_STORE_AUDIT_WEBHOOK_URL, ); await this.ensureNamedPush( WECOM_BIZ_TODO_PUSH_NAME, WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS, 25, process.env.WECOM_BIZ_TODO_WEBHOOK_URL, ); await this.ensureNamedPush( WECOM_DEAL_BROADCAST_PUSH_NAME, WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS, 30, process.env.WECOM_DEAL_BROADCAST_WEBHOOK_URL, ); await this.ensureTemplates(); } private async ensureNamedPush( name: string, conditions: WecomPushCondition[], sortOrder: number, envUrl?: string, ): Promise { const existing = await this.prisma.wecomMessagePush.findFirst({ where: { name } }); if (existing) return; const conditionsJson = JSON.stringify(conditions); const url = (envUrl || '').trim(); if (url) { await this.prisma.wecomMessagePush.create({ data: { name, webhookUrl: url, enabled: true, pushConditions: conditionsJson, sortOrder, }, }); this.logger.log(`seeded wecom message push: ${name} (from env)`); return; } await this.prisma.wecomMessagePush.create({ data: { name, webhookUrl: WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK, enabled: false, pushConditions: conditionsJson, sortOrder, }, }); this.logger.log(`seeded wecom message push: ${name} (placeholder, disabled)`); } /** 仅插入缺失 eventKey,不覆盖已有文案 */ async ensureTemplates(): Promise { for (const def of WECOM_PUSH_TEMPLATE_DEFAULTS) { const existing = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey: def.eventKey }, }); if (existing) continue; await this.prisma.wecomPushTemplate.create({ data: { eventKey: def.eventKey, title: def.title, body: def.body, handleLabel: def.handleLabel, }, }); this.logger.log(`seeded wecom push template: ${def.eventKey}`); } } 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; } /** * 读 HQ 模板 → 插值 → 补快链 → 按条件路由推送。 * 失败只打日志,不抛给业务。 */ async dispatchEvent( eventKey: WecomTemplateEventKey, vars: Record, options?: { applyMention?: boolean; handlePath?: string }, ): Promise { try { const content = await this.renderEventContent(eventKey, vars, options?.handlePath); return await this.dispatchMarkdown(eventKey, content, { applyMention: options?.applyMention ?? false, }); } catch (e) { this.logger.warn( `dispatchEvent(${eventKey}) failed: ${e instanceof Error ? e.message : String(e)}`, ); return 0; } } async renderEventContent( eventKey: WecomTemplateEventKey, vars: Record, handlePath?: string, ): Promise { const row = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey } }); const def = getDefaultTemplate(eventKey); const body = row?.body || def?.body || `**${eventKey}**`; const handleLabel = row?.handleLabel || def?.handleLabel || '去处理'; const merged: Record = { ...vars, handleLabel: vars.handleLabel ?? handleLabel, time: vars.time ?? new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }), }; if (handlePath && !merged.handleUrl) { merged.handleUrl = buildHqHandleUrl(handlePath); } let content = renderWecomTemplate(body, merged).trim(); const url = String(merged.handleUrl || '').trim(); if (url && !content.includes(url) && !/\{\{handleUrl\}\}/.test(body)) { content = `${content}\n[${handleLabel}](${url})`; } return content; } /** 向所有匹配 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 日志' }; } // ── templates CRUD ── async listTemplates(): Promise { await this.ensureTemplates(); const rows = await this.prisma.wecomPushTemplate.findMany({ orderBy: { eventKey: 'asc' }, }); return rows.map((r) => this.templateToDto(r)); } async getTemplate(eventKey: string): Promise { this.assertTemplateKey(eventKey); await this.ensureTemplates(); const row = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey } }); if (!row) throw new BadRequestException('模板不存在'); return this.templateToDto(row); } async updateTemplate( eventKey: string, data: { title?: string; body?: string; handleLabel?: string }, ): Promise { this.assertTemplateKey(eventKey); await this.ensureTemplates(); const existing = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey } }); if (!existing) throw new BadRequestException('模板不存在'); const title = data.title != null ? String(data.title).trim() : undefined; const body = data.body != null ? String(data.body).trim() : undefined; const handleLabel = data.handleLabel != null ? String(data.handleLabel).trim() || '去处理' : undefined; if (title !== undefined && !title) throw new BadRequestException('标题不能为空'); if (body !== undefined && !body) throw new BadRequestException('正文不能为空'); const row = await this.prisma.wecomPushTemplate.update({ where: { eventKey }, data: { ...(title !== undefined ? { title } : {}), ...(body !== undefined ? { body } : {}), ...(handleLabel !== undefined ? { handleLabel } : {}), }, }); return this.templateToDto(row); } async resetTemplate(eventKey: string): Promise { this.assertTemplateKey(eventKey); const def = getDefaultTemplate(eventKey); if (!def) throw new BadRequestException('无默认模板'); await this.ensureTemplates(); const row = await this.prisma.wecomPushTemplate.upsert({ where: { eventKey }, create: { eventKey: def.eventKey, title: def.title, body: def.body, handleLabel: def.handleLabel, }, update: { title: def.title, body: def.body, handleLabel: def.handleLabel, }, }); return this.templateToDto(row); } /** 用示例变量渲染并推到勾选了该事件的启用群 */ async testTemplate(eventKey: string): Promise<{ ok: boolean; message: string; preview: string }> { this.assertTemplateKey(eventKey); const sample = this.sampleVars(eventKey); const preview = await this.renderEventContent( eventKey, sample.vars, sample.handlePath, ); const sent = await this.dispatchMarkdown(eventKey, preview, { applyMention: false }); if (sent === 0) { return { ok: false, message: '没有已启用且勾选该事件的消息推送,请先配置 webhook', preview, }; } return { ok: true, message: `已发送至 ${sent} 个推送`, preview }; } private sampleVars(eventKey: WecomTemplateEventKey): { vars: Record; handlePath: string; } { const samples: Record; handlePath: string }> = { 'order.paid': { vars: { orderNo: 'DK202608200001', payAmount: '199.00', cityName: '郑州', skuSummary: '杜康原浆 ×2', phoneMasked: '138****8000', }, handlePath: '/orders?orderNo=DK202608200001', }, 'redeem.success': { vars: { redeemNo: 'RD202608200001', amount: '88.00', storeName: '示例门店', channel: '扫码', }, handlePath: '/redeem-records?redeemNo=RD202608200001', }, 'store.audit_pending': { vars: { storeName: '示例门店', cityName: '郑州', partnerLabel: '示例合伙人', action: '新建', storeId: '1', }, handlePath: '/stores?auditStatus=PENDING&storeId=1', }, 'store.package_audit_pending': { vars: { storeName: '示例门店', cityName: '郑州', submitter: '合伙人', packageCount: '3', requestId: '1', }, handlePath: '/store-package-audits?requestId=1', }, 'store.info_change_pending': { vars: { storeName: '示例门店', cityName: '郑州', submitter: '门店', changedFields: '门店名称、详细地址', requestId: '1', }, handlePath: '/store-package-audits?tab=info&infoRequestId=1', }, 'store.withdraw_pending': { vars: { storeName: '示例门店', withdrawNo: 'SW202608200001', amount: '500.00', payoutCount: '5', storeId: '1', }, handlePath: '/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=1', }, 'invoice.pending': { vars: { invoiceNo: 'IV202608200001', orderNo: 'DK202608200001', payAmount: '199.00', titleName: '示例公司', phoneMasked: '138****8000', }, handlePath: '/invoices?status=PENDING&invoiceNo=IV202608200001', }, }; return samples[eventKey]; } private assertTemplateKey(eventKey: string): asserts eventKey is WecomTemplateEventKey { if (!(WECOM_TEMPLATE_EVENT_KEYS as readonly string[]).includes(eventKey)) { throw new BadRequestException(`无效模板事件:${eventKey}`); } } templateToDto(row: TemplateRow): WecomPushTemplateDto { return { id: row.id.toString(), eventKey: row.eventKey as WecomTemplateEventKey, title: row.title, body: row.body, handleLabel: row.handleLabel, updatedAt: row.updatedAt.toISOString(), }; } 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; } }