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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
CardAnalyticsExportService,
|
||||||
|
escapeCsvCell,
|
||||||
|
generateCsvContent,
|
||||||
|
RadarExportRow,
|
||||||
|
} from '../../../src/modules/crm/service/analytics-export';
|
||||||
|
|
||||||
|
describe('Point 50: 访客雷达多维数据报表 CSV 导出引擎 (RFC 4180)', () => {
|
||||||
|
let service: CardAnalyticsExportService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new CardAnalyticsExportService();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('1. escapeCsvCell (RFC 4180 单元格转义规范)', () => {
|
||||||
|
it('包含逗号、换行或双引号的单元格必须用双引号包裹且内部双引号翻倍', () => {
|
||||||
|
expect(escapeCsvCell('Hello, World')).toBe('"Hello, World"');
|
||||||
|
expect(escapeCsvCell('Line 1\nLine 2')).toBe('"Line 1\nLine 2"');
|
||||||
|
expect(escapeCsvCell('She said "Hi"')).toBe('"She said ""Hi"""');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('普通纯文本单元格无需包裹双引号', () => {
|
||||||
|
expect(escapeCsvCell('Arthur Pendelton')).toBe('Arthur Pendelton');
|
||||||
|
expect(escapeCsvCell('Dubai')).toBe('Dubai');
|
||||||
|
expect(escapeCsvCell(100)).toBe('100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空值、null 或 undefined 应返回空字符串', () => {
|
||||||
|
expect(escapeCsvCell('')).toBe('');
|
||||||
|
expect(escapeCsvCell(null)).toBe('');
|
||||||
|
expect(escapeCsvCell(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('2. generateCsvContent (UTF-8 BOM 与多语言表头自适应)', () => {
|
||||||
|
const mockRows: RadarExportRow[] = [
|
||||||
|
{
|
||||||
|
visitorId: 'vis-001',
|
||||||
|
name: 'Sheikh Mansour',
|
||||||
|
country: 'AE',
|
||||||
|
city: 'Abu Dhabi',
|
||||||
|
stayDurationSeconds: 154,
|
||||||
|
radarScore: 92,
|
||||||
|
trafficChannel: 'nfc_tap',
|
||||||
|
visitedAt: '2026-09-20 14:30:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
visitorId: 'vis-002',
|
||||||
|
name: 'Elena, Rostova',
|
||||||
|
country: 'SE',
|
||||||
|
city: 'Stockholm',
|
||||||
|
stayDurationSeconds: 65,
|
||||||
|
radarScore: 68,
|
||||||
|
trafficChannel: 'whatsapp_chat',
|
||||||
|
visitedAt: '2026-09-20 15:10:00',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('导出的 CSV 必须以 UTF-8 BOM (\\uFEFF) 开头以防止 Excel 乱码', () => {
|
||||||
|
const csv = generateCsvContent(mockRows, 'en');
|
||||||
|
expect(csv.startsWith('\uFEFF')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('英语表头应包含准确的 RFC 4180 列名', () => {
|
||||||
|
const csv = generateCsvContent(mockRows, 'en');
|
||||||
|
expect(csv).toContain('Visitor ID,Name,Country,City,Stay (Seconds),Radar Score,Channel,Visited Time');
|
||||||
|
expect(csv).toContain('"Elena, Rostova"'); // 包含逗号被正确转义
|
||||||
|
});
|
||||||
|
|
||||||
|
it('阿拉伯语表头应输出规范阿语文案', () => {
|
||||||
|
const csv = generateCsvContent(mockRows, 'ar');
|
||||||
|
expect(csv).toContain('معرف الزائر,الاسم,الدولة');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('中文表头应输出清晰中文列名', () => {
|
||||||
|
const csv = generateCsvContent(mockRows, 'zh');
|
||||||
|
expect(csv).toContain('访客标识,姓名,国家,城市,停留时长(秒),意向评分,访问渠道,访问时间');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('3. CardAnalyticsExportService 报表导出元数据', () => {
|
||||||
|
it('应生成包含文件名与行数统计的导出包', async () => {
|
||||||
|
const result = await service.exportRadarDataset([
|
||||||
|
{
|
||||||
|
visitorId: 'vis-101',
|
||||||
|
name: 'David',
|
||||||
|
country: 'GB',
|
||||||
|
city: 'London',
|
||||||
|
stayDurationSeconds: 200,
|
||||||
|
radarScore: 85,
|
||||||
|
trafficChannel: 'linkedin_share',
|
||||||
|
visitedAt: '2026-09-20 16:00:00',
|
||||||
|
},
|
||||||
|
], 'en');
|
||||||
|
|
||||||
|
expect(result.fileName).toMatch(/radar_export_.*\.csv/);
|
||||||
|
expect(result.rowCount).toBe(1);
|
||||||
|
expect(result.csvContent.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
LeadCompanyEnrichmentService,
|
||||||
|
extractDomainFromEmail,
|
||||||
|
isPublicFreeMailbox,
|
||||||
|
enrichCompanyProfile,
|
||||||
|
CompanyProfileResult,
|
||||||
|
} from '../../../src/modules/crm/service/enrichment';
|
||||||
|
|
||||||
|
describe('Point 49: 出海潜客企业邮箱域名解析与商业主页自动富化', () => {
|
||||||
|
let service: LeadCompanyEnrichmentService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new LeadCompanyEnrichmentService();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('1. 邮箱域名解析与公用免费邮箱过滤 (extractDomainFromEmail & isPublicFreeMailbox)', () => {
|
||||||
|
it('应正确提取企业邮箱域名', () => {
|
||||||
|
expect(extractDomainFromEmail('alex@siemens.com')).toBe('siemens.com');
|
||||||
|
expect(extractDomainFromEmail('tariq@aramco.com.sa')).toBe('aramco.com.sa');
|
||||||
|
expect(extractDomainFromEmail(' JOHN.DOE@VOLVO.SE ')).toBe('volvo.se');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非法或无效邮箱应安全返回空字符串', () => {
|
||||||
|
expect(extractDomainFromEmail('')).toBe('');
|
||||||
|
expect(extractDomainFromEmail('invalid-email-format')).toBe('');
|
||||||
|
expect(extractDomainFromEmail(null as any)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应准确判定常见公用免费邮箱 (Gmail, Hotmail, 163, QQ, Yahoo 等)', () => {
|
||||||
|
expect(isPublicFreeMailbox('gmail.com')).toBe(true);
|
||||||
|
expect(isPublicFreeMailbox('yahoo.com')).toBe(true);
|
||||||
|
expect(isPublicFreeMailbox('outlook.com')).toBe(true);
|
||||||
|
expect(isPublicFreeMailbox('163.com')).toBe(true);
|
||||||
|
expect(isPublicFreeMailbox('qq.com')).toBe(true);
|
||||||
|
|
||||||
|
// 企业独立域名应判定为 false
|
||||||
|
expect(isPublicFreeMailbox('bmw.de')).toBe(false);
|
||||||
|
expect(isPublicFreeMailbox('omnicard.ai')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('2. enrichCompanyProfile (企业画像与社交网络自动补全)', () => {
|
||||||
|
it('针对企业域名应提取标准官网、LinkedIn 公司页与品牌展示名称', () => {
|
||||||
|
const profile = enrichCompanyProfile('faisal@emiratessteel.ae', 'Emirates Steel Arkan');
|
||||||
|
|
||||||
|
expect(profile.isEnterpriseDomain).toBe(true);
|
||||||
|
expect(profile.domain).toBe('emiratessteel.ae');
|
||||||
|
expect(profile.websiteUrl).toBe('https://emiratessteel.ae');
|
||||||
|
expect(profile.linkedinCompanyUrl).toBe('https://www.linkedin.com/company/emiratessteel');
|
||||||
|
expect(profile.brandName).toBe('Emirates Steel Arkan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('针对免费个人邮箱应标记非企业域名,并提取备用展示', () => {
|
||||||
|
const profile = enrichCompanyProfile('freelancer2026@gmail.com');
|
||||||
|
|
||||||
|
expect(profile.isEnterpriseDomain).toBe(false);
|
||||||
|
expect(profile.domain).toBe('gmail.com');
|
||||||
|
expect(profile.websiteUrl).toBeUndefined();
|
||||||
|
expect(profile.linkedinCompanyUrl).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('3. LeadCompanyEnrichmentService 综合线索富化', () => {
|
||||||
|
it('应能为潜客线索生成完整的企业富化数据对象', async () => {
|
||||||
|
const enriched = await service.enrichLead({
|
||||||
|
leadId: 301,
|
||||||
|
name: 'Carlos Mendez',
|
||||||
|
email: 'carlos@iberia.es',
|
||||||
|
company: 'Iberia Airlines',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(enriched.leadId).toBe(301);
|
||||||
|
expect(enriched.companyProfile.isEnterpriseDomain).toBe(true);
|
||||||
|
expect(enriched.companyProfile.domain).toBe('iberia.es');
|
||||||
|
expect(enriched.suggestedIndustry).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
LeadWebhookDispatcher,
|
||||||
|
computeWebhookSignature,
|
||||||
|
verifyWebhookSignature,
|
||||||
|
LeadWebhookPayload,
|
||||||
|
} from '../../../src/modules/crm/service/webhook-dispatcher';
|
||||||
|
|
||||||
|
describe('Point 51: 出海商机实时 Webhook 触发器与 HMAC-SHA256 签名分发', () => {
|
||||||
|
let dispatcher: LeadWebhookDispatcher;
|
||||||
|
const mockSecret = 'whsec_8f9a2b4c6e1d7g5h3j8k9l0m';
|
||||||
|
const mockPayload: LeadWebhookPayload = {
|
||||||
|
event: 'lead.created',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
tenantId: 'tenant-uae-101',
|
||||||
|
data: {
|
||||||
|
leadId: 501,
|
||||||
|
name: 'Sheikh Khalid',
|
||||||
|
company: 'Dubai Financial Group',
|
||||||
|
score: 95,
|
||||||
|
channel: 'nfc_tap',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPayload.timestamp = Date.now();
|
||||||
|
dispatcher = new LeadWebhookDispatcher();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('1. computeWebhookSignature & verifyWebhookSignature (HMAC-SHA256 签名验签)', () => {
|
||||||
|
it('应计算规范的 sha256 签名串', () => {
|
||||||
|
const payloadStr = JSON.stringify(mockPayload);
|
||||||
|
const sig = computeWebhookSignature(payloadStr, mockSecret, mockPayload.timestamp);
|
||||||
|
|
||||||
|
expect(sig).toHaveLength(64);
|
||||||
|
expect(/^[0-9a-f]{64}$/i.test(sig)).toBe(true);
|
||||||
|
|
||||||
|
// 验签通过
|
||||||
|
const isValid = verifyWebhookSignature(
|
||||||
|
payloadStr,
|
||||||
|
sig,
|
||||||
|
mockSecret,
|
||||||
|
mockPayload.timestamp
|
||||||
|
);
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('篡改 Payload 任何内容时验签应失败', () => {
|
||||||
|
const payloadStr = JSON.stringify(mockPayload);
|
||||||
|
const sig = computeWebhookSignature(payloadStr, mockSecret, mockPayload.timestamp);
|
||||||
|
|
||||||
|
const tamperedStr = JSON.stringify({ ...mockPayload, tenantId: 'tampered-tenant' });
|
||||||
|
expect(verifyWebhookSignature(tamperedStr, sig, mockSecret, mockPayload.timestamp)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('超出 5 分钟 (300,000ms) 时钟窗口的请求应被拒绝', () => {
|
||||||
|
const oldTs = Date.now() - 6 * 60 * 1000;
|
||||||
|
const payloadStr = JSON.stringify({ ...mockPayload, timestamp: oldTs });
|
||||||
|
const sig = computeWebhookSignature(payloadStr, mockSecret, oldTs);
|
||||||
|
|
||||||
|
expect(verifyWebhookSignature(payloadStr, sig, mockSecret, oldTs)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('2. LeadWebhookDispatcher 异步分发与重试逻辑', () => {
|
||||||
|
it('目标端成功接收 (200 OK) 时标记成功', async () => {
|
||||||
|
const mockHttpPoster = jest.fn(async () => ({ statusCode: 200 }));
|
||||||
|
dispatcher.setHttpPoster(mockHttpPoster);
|
||||||
|
|
||||||
|
const result = await dispatcher.dispatch({
|
||||||
|
targetUrl: 'https://api.partner.com/omnicard/webhook',
|
||||||
|
secret: mockSecret,
|
||||||
|
payload: mockPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.attempts).toBe(1);
|
||||||
|
expect(mockHttpPoster).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('遇到 500 异常时应自动重试,全部失败后记入死信', async () => {
|
||||||
|
const mockHttpPoster = jest.fn(async () => ({ statusCode: 500 }));
|
||||||
|
dispatcher.setHttpPoster(mockHttpPoster);
|
||||||
|
|
||||||
|
const result = await dispatcher.dispatch({
|
||||||
|
targetUrl: 'https://api.partner.com/failing/webhook',
|
||||||
|
secret: mockSecret,
|
||||||
|
payload: mockPayload,
|
||||||
|
maxRetries: 2,
|
||||||
|
initialBackoffMs: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.attempts).toBe(2);
|
||||||
|
expect(result.isDeadLetter).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
TeamDataScopeService,
|
||||||
|
UserDataContext,
|
||||||
|
buildLeadDataScopeFilter,
|
||||||
|
assertLeadAccessPermission,
|
||||||
|
} from '../../../src/modules/team/service/data-scope';
|
||||||
|
|
||||||
|
describe('Point 52: 出海多租户团队成员细粒度数据隔离与销售管辖范围保护', () => {
|
||||||
|
let service: TeamDataScopeService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new TeamDataScopeService();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('1. buildLeadDataScopeFilter (数据权限范围查询条件构建)', () => {
|
||||||
|
it('租户拥有者 (owner) 与管理员 (admin) 应具备租户级全量权限', () => {
|
||||||
|
const ownerCtx: UserDataContext = {
|
||||||
|
userId: 1,
|
||||||
|
tenantId: 'tenant-100',
|
||||||
|
role: 'owner',
|
||||||
|
};
|
||||||
|
const adminCtx: UserDataContext = {
|
||||||
|
userId: 2,
|
||||||
|
tenantId: 'tenant-100',
|
||||||
|
role: 'admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ownerFilter = buildLeadDataScopeFilter(ownerCtx);
|
||||||
|
expect(ownerFilter.tenantId).toBe('tenant-100');
|
||||||
|
expect(ownerFilter.assignedUserId).toBeUndefined(); // 全员无限制
|
||||||
|
expect(ownerFilter.departmentId).toBeUndefined();
|
||||||
|
|
||||||
|
const adminFilter = buildLeadDataScopeFilter(adminCtx);
|
||||||
|
expect(adminFilter.tenantId).toBe('tenant-100');
|
||||||
|
expect(adminFilter.assignedUserId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('部门区域销售总监 (sales_manager) 应锁定为本部门管辖范围', () => {
|
||||||
|
const managerCtx: UserDataContext = {
|
||||||
|
userId: 10,
|
||||||
|
tenantId: 'tenant-100',
|
||||||
|
role: 'sales_manager',
|
||||||
|
departmentId: 'dept-middle-east',
|
||||||
|
};
|
||||||
|
|
||||||
|
const filter = buildLeadDataScopeFilter(managerCtx);
|
||||||
|
expect(filter.tenantId).toBe('tenant-100');
|
||||||
|
expect(filter.departmentId).toBe('dept-middle-east');
|
||||||
|
expect(filter.assignedUserId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('一线销售 (sales_rep/member) 必须严格隔离只能查看本人线索', () => {
|
||||||
|
const repCtx: UserDataContext = {
|
||||||
|
userId: 88,
|
||||||
|
tenantId: 'tenant-100',
|
||||||
|
role: 'sales_rep',
|
||||||
|
departmentId: 'dept-middle-east',
|
||||||
|
};
|
||||||
|
|
||||||
|
const filter = buildLeadDataScopeFilter(repCtx);
|
||||||
|
expect(filter.tenantId).toBe('tenant-100');
|
||||||
|
expect(filter.assignedUserId).toBe(88);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('2. assertLeadAccessPermission (单条线索详情读取防越权校验)', () => {
|
||||||
|
const mockLead = {
|
||||||
|
id: 999,
|
||||||
|
tenantId: 'tenant-100',
|
||||||
|
assignedUserId: 88,
|
||||||
|
departmentId: 'dept-middle-east',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('允许线索归属人本人 (userId=88) 访问', () => {
|
||||||
|
const repCtx: UserDataContext = {
|
||||||
|
userId: 88,
|
||||||
|
tenantId: 'tenant-100',
|
||||||
|
role: 'sales_rep',
|
||||||
|
};
|
||||||
|
expect(() => assertLeadAccessPermission(repCtx, mockLead)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('允许所属部门主管 (dept-middle-east) 与企业 Owner 跨权限访问', () => {
|
||||||
|
const managerCtx: UserDataContext = {
|
||||||
|
userId: 10,
|
||||||
|
tenantId: 'tenant-100',
|
||||||
|
role: 'sales_manager',
|
||||||
|
departmentId: 'dept-middle-east',
|
||||||
|
};
|
||||||
|
const ownerCtx: UserDataContext = {
|
||||||
|
userId: 1,
|
||||||
|
tenantId: 'tenant-100',
|
||||||
|
role: 'owner',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => assertLeadAccessPermission(managerCtx, mockLead)).not.toThrow();
|
||||||
|
expect(() => assertLeadAccessPermission(ownerCtx, mockLead)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同租户其他销售 (userId=89) 越权访问时应抛出 403 明确拒绝', () => {
|
||||||
|
const otherRepCtx: UserDataContext = {
|
||||||
|
userId: 89,
|
||||||
|
tenantId: 'tenant-100',
|
||||||
|
role: 'sales_rep',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => assertLeadAccessPermission(otherRepCtx, mockLead)).toThrow(
|
||||||
|
/无权访问该客户线索|Forbidden|权限不足/i
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('跨租户访问应坚决阻断', () => {
|
||||||
|
const crossTenantCtx: UserDataContext = {
|
||||||
|
userId: 1,
|
||||||
|
tenantId: 'tenant-200', // 跨租户
|
||||||
|
role: 'owner',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => assertLeadAccessPermission(crossTenantCtx, mockLead)).toThrow(
|
||||||
|
/无权访问该客户线索|Forbidden|权限不足/i
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户