Files
dukang/server/dukang-api/src/integrations/wecom/wecom-message-push.service.ts
T
jacy 26334ed072
CI / verify (pull_request) Has been cancelled
v3.5.3版本更新2
2026-08-20 20:09:05 +08:00

563 lines
18 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_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<void> {
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<void> {
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<void> {
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<void> {
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<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;
}
/**
* 读 HQ 模板 → 插值 → 补快链 → 按条件路由推送。
* 失败只打日志,不抛给业务。
*/
async dispatchEvent(
eventKey: WecomTemplateEventKey,
vars: Record<string, string | number | null | undefined>,
options?: { applyMention?: boolean; handlePath?: string },
): Promise<number> {
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<string, string | number | null | undefined>,
handlePath?: string,
): Promise<string> {
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<string, string | number | null | undefined> = {
...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<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 日志' };
}
// ── templates CRUD ──
async listTemplates(): Promise<WecomPushTemplateDto[]> {
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<WecomPushTemplateDto> {
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<WecomPushTemplateDto> {
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<WecomPushTemplateDto> {
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<string, string>;
handlePath: string;
} {
const samples: Record<WecomTemplateEventKey, { vars: Record<string, string>; 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<string>(WECOM_PUSH_CONDITIONS);
for (const c of conditions) {
if (!valid.has(c)) throw new BadRequestException(`无效推送条件:${c}`);
}
return parsed;
}
}