feat(round-13): 潜客企业域名富化/雷达CSV报表导出/商机Webhook签名分发/销售管辖权限隔离 (91套件1033测试全绿)
这个提交包含在:
@@ -0,0 +1,98 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService } from '@cool-midway/core';
|
||||
|
||||
export interface RadarExportRow {
|
||||
visitorId: string;
|
||||
name?: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
stayDurationSeconds?: number;
|
||||
radarScore?: number;
|
||||
trafficChannel?: string;
|
||||
visitedAt?: string;
|
||||
}
|
||||
|
||||
export interface CsvExportPackage {
|
||||
fileName: string;
|
||||
rowCount: number;
|
||||
csvContent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 4180 CSV 单元格内容转义规范
|
||||
*/
|
||||
export function escapeCsvCell(cellValue: any): string {
|
||||
if (cellValue == null) return '';
|
||||
const str = String(cellValue);
|
||||
// 如果包含逗号、换行符或双引号,则需要整体包裹双引号,并将内部双引号双写
|
||||
if (str.includes(',') || str.includes('\n') || str.includes('\r') || str.includes('"')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* 多语言表头定义
|
||||
*/
|
||||
const CSV_HEADERS: Record<string, string[]> = {
|
||||
zh: ['访客标识', '姓名', '国家', '城市', '停留时长(秒)', '意向评分', '访问渠道', '访问时间'],
|
||||
en: ['Visitor ID', 'Name', 'Country', 'City', 'Stay (Seconds)', 'Radar Score', 'Channel', 'Visited Time'],
|
||||
ar: ['معرف الزائر', 'الاسم', 'الدولة', 'المدينة', 'مدة البقاء (ث)', 'نقاط الرادار', 'القناة', 'وقت الزيارة'],
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成带 UTF-8 BOM 的 RFC 4180 CSV 字符串
|
||||
*/
|
||||
export function generateCsvContent(
|
||||
rows: RadarExportRow[],
|
||||
lang = 'en'
|
||||
): string {
|
||||
const normLang = (lang || 'en').toLowerCase().split('-')[0];
|
||||
const headers = CSV_HEADERS[normLang] || CSV_HEADERS.en;
|
||||
|
||||
const lines: string[] = [];
|
||||
// 表头
|
||||
lines.push(headers.map(escapeCsvCell).join(','));
|
||||
|
||||
// 数据行
|
||||
for (const r of rows) {
|
||||
const rowValues = [
|
||||
r.visitorId,
|
||||
r.name || '',
|
||||
r.country || '',
|
||||
r.city || '',
|
||||
r.stayDurationSeconds != null ? r.stayDurationSeconds : '',
|
||||
r.radarScore != null ? r.radarScore : '',
|
||||
r.trafficChannel || '',
|
||||
r.visitedAt || '',
|
||||
];
|
||||
lines.push(rowValues.map(escapeCsvCell).join(','));
|
||||
}
|
||||
|
||||
// UTF-8 BOM (\uFEFF)
|
||||
return '\uFEFF' + lines.join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* 名片雷达多维数据报表 CSV 导出服务
|
||||
*/
|
||||
@Provide()
|
||||
export class CardAnalyticsExportService extends BaseService {
|
||||
/**
|
||||
* 导出访客雷达数据集
|
||||
*/
|
||||
async exportRadarDataset(
|
||||
rows: RadarExportRow[],
|
||||
lang = 'en'
|
||||
): Promise<CsvExportPackage> {
|
||||
const csvContent = generateCsvContent(rows, lang);
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
const fileName = `radar_export_${dateStr}_${Math.random().toString(36).slice(2, 6)}.csv`;
|
||||
|
||||
return {
|
||||
fileName,
|
||||
rowCount: rows.length,
|
||||
csvContent,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService } from '@cool-midway/core';
|
||||
|
||||
/**
|
||||
* 常见免费/个人邮箱服务商域名黑名单列表
|
||||
*/
|
||||
const PUBLIC_FREE_EMAIL_DOMAINS = new Set([
|
||||
'gmail.com',
|
||||
'googlemail.com',
|
||||
'yahoo.com',
|
||||
'yahoo.co.uk',
|
||||
'hotmail.com',
|
||||
'outlook.com',
|
||||
'live.com',
|
||||
'msn.com',
|
||||
'icloud.com',
|
||||
'me.com',
|
||||
'mac.com',
|
||||
'aol.com',
|
||||
'mail.com',
|
||||
'zoho.com',
|
||||
'proton.me',
|
||||
'protonmail.com',
|
||||
'163.com',
|
||||
'126.com',
|
||||
'qq.com',
|
||||
'sina.com',
|
||||
'foxmail.com',
|
||||
'sohu.com',
|
||||
'yandex.com',
|
||||
'yandex.ru',
|
||||
]);
|
||||
|
||||
export interface CompanyProfileResult {
|
||||
isEnterpriseDomain: boolean;
|
||||
domain: string;
|
||||
brandName?: string;
|
||||
websiteUrl?: string;
|
||||
linkedinCompanyUrl?: string;
|
||||
}
|
||||
|
||||
export interface LeadEnrichmentInput {
|
||||
leadId: number;
|
||||
name: string;
|
||||
email: string;
|
||||
company?: string;
|
||||
}
|
||||
|
||||
export interface EnrichedLeadOutput {
|
||||
leadId: number;
|
||||
name: string;
|
||||
email: string;
|
||||
companyProfile: CompanyProfileResult;
|
||||
suggestedIndustry?: string;
|
||||
enrichedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从邮箱地址提取规范域名
|
||||
*/
|
||||
export function extractDomainFromEmail(email?: string): string {
|
||||
if (!email || typeof email !== 'string') return '';
|
||||
const clean = email.trim().toLowerCase();
|
||||
const atIndex = clean.lastIndexOf('@');
|
||||
if (atIndex === -1 || atIndex === clean.length - 1) {
|
||||
return '';
|
||||
}
|
||||
const domainPart = clean.slice(atIndex + 1);
|
||||
// 简单验证域名格式
|
||||
if (!domainPart.includes('.') || domainPart.startsWith('.') || domainPart.endsWith('.')) {
|
||||
return '';
|
||||
}
|
||||
return domainPart;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否属于公共免费个人邮箱
|
||||
*/
|
||||
export function isPublicFreeMailbox(domain: string): boolean {
|
||||
if (!domain) return false;
|
||||
return PUBLIC_FREE_EMAIL_DOMAINS.has(domain.toLowerCase().trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 富化企业画像
|
||||
*/
|
||||
export function enrichCompanyProfile(
|
||||
email: string,
|
||||
companyName?: string
|
||||
): CompanyProfileResult {
|
||||
const domain = extractDomainFromEmail(email);
|
||||
if (!domain || isPublicFreeMailbox(domain)) {
|
||||
return {
|
||||
isEnterpriseDomain: false,
|
||||
domain: domain || 'unknown',
|
||||
brandName: companyName,
|
||||
};
|
||||
}
|
||||
|
||||
// 提取域名基础主干名称,用于生成 LinkedIn 公司猜测 URL
|
||||
const hostParts = domain.split('.');
|
||||
// 例如 emiratessteel.ae -> emiratessteel; siemens.com -> siemens
|
||||
const brandSlug = hostParts.length > 2 && hostParts[hostParts.length - 1].length === 2 && hostParts[hostParts.length - 2].length <= 3
|
||||
? hostParts[0]
|
||||
: hostParts[0];
|
||||
|
||||
const brand = companyName || brandSlug.charAt(0).toUpperCase() + brandSlug.slice(1);
|
||||
|
||||
return {
|
||||
isEnterpriseDomain: true,
|
||||
domain,
|
||||
brandName: brand,
|
||||
websiteUrl: `https://${domain}`,
|
||||
linkedinCompanyUrl: `https://www.linkedin.com/company/${brandSlug}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 出海潜客企业邮箱域名解析与商业主页自动富化服务
|
||||
*/
|
||||
@Provide()
|
||||
export class LeadCompanyEnrichmentService extends BaseService {
|
||||
/**
|
||||
* 富化潜客信息
|
||||
*/
|
||||
async enrichLead(input: LeadEnrichmentInput): Promise<EnrichedLeadOutput> {
|
||||
const profile = enrichCompanyProfile(input.email, input.company);
|
||||
|
||||
let suggestedIndustry = 'International Commerce & Services';
|
||||
if (profile.domain.includes('steel') || profile.domain.includes('metal')) {
|
||||
suggestedIndustry = 'Industrial Manufacturing & Materials';
|
||||
} else if (profile.domain.includes('air') || profile.domain.includes('flight')) {
|
||||
suggestedIndustry = 'Aviation & Logistics';
|
||||
} else if (profile.domain.includes('tech') || profile.domain.includes('ai')) {
|
||||
suggestedIndustry = 'Technology & Software SaaS';
|
||||
}
|
||||
|
||||
return {
|
||||
leadId: input.leadId,
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
companyProfile: profile,
|
||||
suggestedIndustry,
|
||||
enrichedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService } from '@cool-midway/core';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export interface LeadWebhookPayload {
|
||||
event: 'lead.created' | 'lead.updated' | 'meeting.booked';
|
||||
timestamp: number;
|
||||
tenantId: string;
|
||||
data: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface WebhookDispatchOptions {
|
||||
targetUrl: string;
|
||||
secret: string;
|
||||
payload: LeadWebhookPayload;
|
||||
maxRetries?: number;
|
||||
initialBackoffMs?: number;
|
||||
}
|
||||
|
||||
export interface WebhookDispatchResult {
|
||||
success: boolean;
|
||||
attempts: number;
|
||||
statusCode?: number;
|
||||
error?: string;
|
||||
isDeadLetter?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认有效时钟容差:5分钟
|
||||
*/
|
||||
const DEFAULT_WEBHOOK_MAX_AGE_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 计算 Webhook HMAC-SHA256 签名串
|
||||
*/
|
||||
export function computeWebhookSignature(
|
||||
payloadStr: string,
|
||||
secret: string,
|
||||
timestamp: number
|
||||
): string {
|
||||
const dataToSign = `${timestamp}.${payloadStr}`;
|
||||
return crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(dataToSign)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 恒定时间比对验签 Webhook 请求
|
||||
*/
|
||||
export function verifyWebhookSignature(
|
||||
payloadStr: string,
|
||||
signature: string,
|
||||
secret: string,
|
||||
timestamp: number,
|
||||
maxAgeMs = DEFAULT_WEBHOOK_MAX_AGE_MS
|
||||
): boolean {
|
||||
if (!payloadStr || !signature || !secret || !timestamp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. 时钟过期与重放保护
|
||||
const now = Date.now();
|
||||
if (Math.abs(now - timestamp) > maxAgeMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 计算预期签名并恒定时间比对
|
||||
const expectedSig = computeWebhookSignature(payloadStr, secret, timestamp);
|
||||
const bufA = Buffer.from(signature.toLowerCase(), 'hex');
|
||||
const bufB = Buffer.from(expectedSig.toLowerCase(), 'hex');
|
||||
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return crypto.timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
export type HttpPosterFn = (
|
||||
url: string,
|
||||
options: {
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
) => Promise<{ statusCode: number; data?: any }>;
|
||||
|
||||
/**
|
||||
* 出海商机实时 Webhook 触发器与签名分发服务
|
||||
*/
|
||||
@Provide()
|
||||
export class LeadWebhookDispatcher extends BaseService {
|
||||
private httpPoster: HttpPosterFn = async (url, options) => {
|
||||
// 生产默认简单占位,真实运行时可由 axios / midway http client 替换
|
||||
return { statusCode: 200 };
|
||||
};
|
||||
|
||||
setHttpPoster(fn: HttpPosterFn) {
|
||||
this.httpPoster = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分发 Webhook 事件并进行退避重试
|
||||
*/
|
||||
async dispatch(options: WebhookDispatchOptions): Promise<WebhookDispatchResult> {
|
||||
const {
|
||||
targetUrl,
|
||||
secret,
|
||||
payload,
|
||||
maxRetries = 3,
|
||||
initialBackoffMs = 50,
|
||||
} = options;
|
||||
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
const signature = computeWebhookSignature(payloadStr, secret, payload.timestamp);
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-OmniCard-Signature': signature,
|
||||
'X-OmniCard-Timestamp': String(payload.timestamp),
|
||||
};
|
||||
|
||||
let attempts = 0;
|
||||
let lastError = '';
|
||||
let lastStatusCode = 0;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
attempts++;
|
||||
try {
|
||||
const res = await this.httpPoster(targetUrl, { headers, body: payloadStr });
|
||||
lastStatusCode = res.statusCode;
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
return {
|
||||
success: true,
|
||||
attempts,
|
||||
statusCode: res.statusCode,
|
||||
};
|
||||
}
|
||||
} catch (err: any) {
|
||||
lastError = err?.message || 'Network error';
|
||||
}
|
||||
|
||||
// 等待退避
|
||||
if (i < maxRetries - 1) {
|
||||
const delay = initialBackoffMs * Math.pow(2, i);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
attempts,
|
||||
statusCode: lastStatusCode,
|
||||
error: lastError,
|
||||
isDeadLetter: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService } from '@cool-midway/core';
|
||||
|
||||
export type TeamUserRole = 'owner' | 'admin' | 'sales_manager' | 'sales_rep' | 'member';
|
||||
|
||||
export interface UserDataContext {
|
||||
userId: number;
|
||||
tenantId: string;
|
||||
role: TeamUserRole;
|
||||
departmentId?: string;
|
||||
}
|
||||
|
||||
export interface LeadDataScopeFilter {
|
||||
tenantId: string;
|
||||
assignedUserId?: number;
|
||||
departmentId?: string;
|
||||
}
|
||||
|
||||
export interface LeadEntityRecord {
|
||||
id: number;
|
||||
tenantId: string;
|
||||
assignedUserId?: number;
|
||||
departmentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户当前身份构建线索数据列表的过滤条件 (SQL WHERE 映射)
|
||||
*/
|
||||
export function buildLeadDataScopeFilter(ctx: UserDataContext): LeadDataScopeFilter {
|
||||
const filter: LeadDataScopeFilter = {
|
||||
tenantId: ctx.tenantId,
|
||||
};
|
||||
|
||||
// 租户所有者与管理员具备全量租户数据权限
|
||||
if (ctx.role === 'owner' || ctx.role === 'admin') {
|
||||
return filter;
|
||||
}
|
||||
|
||||
// 销售经理管辖本部门内全部销售
|
||||
if (ctx.role === 'sales_manager') {
|
||||
if (ctx.departmentId) {
|
||||
filter.departmentId = ctx.departmentId;
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
// 一线销售代表或普通成员仅能查看分配给自己的线索
|
||||
filter.assignedUserId = ctx.userId;
|
||||
return filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条线索访问鉴权校验,防止跨租户与同租户横向越权访问
|
||||
*/
|
||||
export function assertLeadAccessPermission(
|
||||
ctx: UserDataContext,
|
||||
lead: LeadEntityRecord
|
||||
): void {
|
||||
// 1. 租户隔离校验
|
||||
if (ctx.tenantId !== lead.tenantId) {
|
||||
throw new Error('无权访问该客户线索 (Tenant mismatch / Forbidden)');
|
||||
}
|
||||
|
||||
// 2. 超级管理员与 Owner 拥有全权
|
||||
if (ctx.role === 'owner' || ctx.role === 'admin') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 销售组长:同部门可看
|
||||
if (ctx.role === 'sales_manager') {
|
||||
if (ctx.departmentId && ctx.departmentId === lead.departmentId) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 一线销售:必须是线索负责人
|
||||
if (lead.assignedUserId === ctx.userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('无权访问该客户线索,权限不足 (Forbidden: Insufficient permissions)');
|
||||
}
|
||||
|
||||
/**
|
||||
* 出海多租户团队成员细粒度数据隔离服务
|
||||
*/
|
||||
@Provide()
|
||||
export class TeamDataScopeService extends BaseService {
|
||||
/**
|
||||
* 获取线索查询范围
|
||||
*/
|
||||
getLeadScope(ctx: UserDataContext): LeadDataScopeFilter {
|
||||
return buildLeadDataScopeFilter(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检验单条线索是否具备读取权限
|
||||
*/
|
||||
checkAccess(ctx: UserDataContext, lead: LeadEntityRecord): boolean {
|
||||
try {
|
||||
assertLeadAccessPermission(ctx, lead);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户