feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口

补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
wikiboss
2026-09-23 18:01:47 +08:00
父节点 8ea9c25386
当前提交 62e0ae4435
共有 227 个文件被更改,包括 21855 次插入 和 5264 次删除

查看文件

@@ -18,17 +18,7 @@ export interface HubSpotContactPayload {
};
}
/**
* Salesforce Lead sObject 格式
*/
export interface SalesforceLeadPayload {
FirstName: string;
LastName: string;
Email: string;
Company: string;
Phone: string;
Status: 'Open - Not Contacted';
}
import { SalesforceLeadPayload } from './salesforce';
/**
* Webhook 发送配置选项
@@ -49,6 +39,37 @@ export interface WebhookResult {
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) 对接服务
*/
@@ -119,6 +140,156 @@ export class CrmSyncService extends BaseService {
};
}
/**
* 组装 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 签名
*/
@@ -128,6 +299,39 @@ export class CrmSyncService extends BaseService {
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 次)
*/
@@ -140,6 +344,9 @@ export class CrmSyncService extends BaseService {
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',