import * as crypto from 'crypto'; import axios from 'axios'; import { Provide } from '@midwayjs/core'; import { BaseService, CoolCommException } from '@cool-midway/core'; import { CrmLeadEntity } from '../entity/lead'; /** * HubSpot Contacts v3 API 格式 */ export interface HubSpotContactPayload { properties: { email: string; firstname: string; lastname: string; phone: string; company: string; lifecyclestage: 'lead'; }; } import { SalesforceLeadPayload } from './salesforce'; /** * Webhook 发送配置选项 */ export interface WebhookSendOptions { maxRetries?: number; delayMs?: number; timeoutMs?: number; } /** * Webhook 发送响应结果 */ export interface WebhookResult { success: boolean; attempts: number; data?: unknown; error?: string; } /** * Slack Incoming Webhook 消息体(组包格式) */ export interface SlackMessagePayload { text: string; blocks?: Array<{ type: string; text?: { type: string; text: string }; }>; } /** * 飞书自定义机器人消息体(组包格式) */ export interface FeishuMessagePayload { msg_type: 'text'; content: { text: string }; } /** * Slack / 飞书外发结果:仅 HTTP 明确成功才 delivered:true */ export interface ChannelNotifyResult { delivered: boolean; channel: 'slack' | 'feishu'; payload: SlackMessagePayload | FeishuMessagePayload; reason?: string; error?: string; attempts?: number; } /** * 国际主流 CRM (HubSpot / Salesforce / Webhook) 对接服务 */ @Provide() export class CrmSyncService extends BaseService { /** * 智能拆分姓名 */ private splitName(name?: string): { firstName: string; lastName: string } { const parts = (name || '').trim().split(/\s+/).filter(Boolean); if (parts.length === 0) { return { firstName: '', lastName: '' }; } if (parts.length === 1) { return { firstName: parts[0], lastName: '' }; } return { firstName: parts[0], lastName: parts.slice(1).join(' '), }; } /** * 异步延时工具 */ private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } /** * 转换成 HubSpot Contacts v3 API 格式 */ formatToHubSpot(lead: Partial): HubSpotContactPayload { const { firstName, lastName } = this.splitName(lead?.name); return { properties: { email: lead?.email || '', firstname: firstName, lastname: lastName, phone: lead?.phone || '', company: lead?.company || '', lifecyclestage: 'lead', }, }; } /** * 转换成 Salesforce Lead sObject 格式 */ formatToSalesforce(lead: Partial): SalesforceLeadPayload { const { firstName, lastName } = this.splitName(lead?.name); let finalFirstName = firstName; let finalLastName = lastName; // Salesforce 规范:LastName 必填;若仅单名,LastName 取该名,FirstName 为空;若全无,LastName 为 'Unknown' if (!lastName) { finalFirstName = ''; finalLastName = firstName || 'Unknown'; } return { FirstName: finalFirstName, LastName: finalLastName, Email: lead?.email || '', Company: lead?.company || 'Unknown', Phone: lead?.phone || '', Status: 'Open - Not Contacted', }; } /** * 组装 Slack Incoming Webhook 消息(仅组包,不代表已送达) */ formatToSlack(lead: Partial): SlackMessagePayload { const name = (lead?.name || '').trim() || 'Unknown'; const company = (lead?.company || '').trim() || '-'; const email = (lead?.email || '').trim() || '-'; const phone = (lead?.phone || '').trim() || '-'; const text = `OmniCard Lead: ${name} @ ${company} | ${email} | ${phone}`; return { text, blocks: [ { type: 'section', text: { type: 'mrkdwn', text: `*${name}* · ${company}\n${email} · ${phone}` }, }, ], }; } /** * 组装飞书自定义机器人文本消息(仅组包,不代表已送达) */ formatToFeishu(lead: Partial): FeishuMessagePayload { const name = (lead?.name || '').trim() || 'Unknown'; const company = (lead?.company || '').trim() || '-'; const email = (lead?.email || '').trim() || '-'; const phone = (lead?.phone || '').trim() || '-'; return { msg_type: 'text', content: { text: `OmniCard 线索:${name} @ ${company} | ${email} | ${phone}`, }, }; } /** * 发送 Slack:无 URL 只组包且 delivered:false;仅公网 HTTP 成功才 delivered:true */ async sendSlack( webhookUrl: string | undefined, lead: Partial, options?: WebhookSendOptions ): Promise { const payload = this.formatToSlack(lead); const url = (webhookUrl || '').trim(); if (!url) { return { delivered: false, channel: 'slack', payload, reason: '未配置 Slack Webhook', }; } if (this.isDisallowedWebhookUrl(url)) { throw new CoolCommException('拒绝内网或非 http(s) Webhook URL', 400); } const maxRetries = options?.maxRetries ?? 3; const baseDelay = options?.delayMs ?? 100; const timeout = options?.timeoutMs ?? 5000; let lastError: Error | null = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await axios.post(url, payload, { headers: { 'Content-Type': 'application/json' }, timeout, validateStatus: status => status >= 200 && status < 300, }); return { delivered: true, channel: 'slack', payload, attempts: attempt, }; } catch (err: unknown) { lastError = err instanceof Error ? err : new Error(String(err)); if (attempt < maxRetries) { await this.sleep(baseDelay * Math.pow(2, attempt - 1)); } } } return { delivered: false, channel: 'slack', payload, attempts: maxRetries, error: lastError?.message || 'Slack request failed', }; } /** * 发送飞书:无 URL 只组包且 delivered:false;仅公网 HTTP 成功才 delivered:true */ async sendFeishu( webhookUrl: string | undefined, lead: Partial, options?: WebhookSendOptions ): Promise { const payload = this.formatToFeishu(lead); const url = (webhookUrl || '').trim(); if (!url) { return { delivered: false, channel: 'feishu', payload, reason: '未配置飞书 Webhook', }; } if (this.isDisallowedWebhookUrl(url)) { throw new CoolCommException('拒绝内网或非 http(s) Webhook URL', 400); } const maxRetries = options?.maxRetries ?? 3; const baseDelay = options?.delayMs ?? 100; const timeout = options?.timeoutMs ?? 5000; let lastError: Error | null = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await axios.post(url, payload, { headers: { 'Content-Type': 'application/json' }, timeout, validateStatus: status => status >= 200 && status < 300, }); return { delivered: true, channel: 'feishu', payload, attempts: attempt, }; } catch (err: unknown) { lastError = err instanceof Error ? err : new Error(String(err)); if (attempt < maxRetries) { await this.sleep(baseDelay * Math.pow(2, attempt - 1)); } } } return { delivered: false, channel: 'feishu', payload, attempts: maxRetries, error: lastError?.message || 'Feishu request failed', }; } /** * 计算 HMAC-SHA256 签名 */ generateSignature(payload: unknown, secret: string): string { const content = typeof payload === 'string' ? payload : JSON.stringify(payload); return crypto.createHmac('sha256', secret).update(content).digest('hex'); } /** * 拒绝内网 / 环回 / 非 http(s) Webhook URL(与雷达推送同等规则) */ private isDisallowedWebhookUrl(urlStr: string): boolean { let parsed: URL; try { parsed = new URL(urlStr); } catch { return true; } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return true; } const host = parsed.hostname.toLowerCase(); if (host === 'localhost' || host === '127.0.0.1') { return true; } if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) { return true; } const m172 = /^172\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/.exec(host); if (m172) { const second = Number(m172[1]); if (second >= 16 && second <= 31) { return true; } } if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(host)) { return true; } return false; } /** * 发送带 HMAC-SHA256 签名的 Webhook,具备模拟退避重试(最多 3 次) */ async sendWebhook( url: string, secret: string, lead: Partial, options?: WebhookSendOptions ): Promise { if (!url || !url.trim()) { throw new CoolCommException('缺少目标 Webhook URL', 400); } if (this.isDisallowedWebhookUrl(url.trim())) { throw new CoolCommException('拒绝内网或非 http(s) Webhook URL', 400); } const payload = { event: 'crm.lead.sync', timestamp: Date.now(), data: lead, }; const signature = this.generateSignature(payload, secret || ''); const headers = { 'Content-Type': 'application/json', 'X-OmniCard-Signature': signature, }; const maxRetries = options?.maxRetries ?? 3; const baseDelay = options?.delayMs ?? 100; const timeout = options?.timeoutMs ?? 5000; let lastError: Error | null = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const response = await axios.post(url, payload, { headers, timeout }); return { success: true, attempts: attempt, data: response.data, }; } catch (err: unknown) { lastError = err instanceof Error ? err : new Error(String(err)); if (attempt < maxRetries) { const delay = baseDelay * Math.pow(2, attempt - 1); await this.sleep(delay); } } } return { success: false, attempts: maxRetries, error: lastError?.message || 'Webhook request failed', }; } }