补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
393 行
10 KiB
TypeScript
393 行
10 KiB
TypeScript
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<void> {
|
||
return new Promise(resolve => setTimeout(resolve, ms));
|
||
}
|
||
|
||
/**
|
||
* 转换成 HubSpot Contacts v3 API 格式
|
||
*/
|
||
formatToHubSpot(lead: Partial<CrmLeadEntity>): 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<CrmLeadEntity>): 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<CrmLeadEntity>): 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<CrmLeadEntity>): 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<CrmLeadEntity>,
|
||
options?: WebhookSendOptions
|
||
): Promise<ChannelNotifyResult> {
|
||
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<CrmLeadEntity>,
|
||
options?: WebhookSendOptions
|
||
): Promise<ChannelNotifyResult> {
|
||
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<CrmLeadEntity>,
|
||
options?: WebhookSendOptions
|
||
): Promise<WebhookResult> {
|
||
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',
|
||
};
|
||
}
|
||
}
|