diff --git a/backend/src/modules/crm/service/analytics-export.ts b/backend/src/modules/crm/service/analytics-export.ts new file mode 100644 index 0000000..ef6c242 --- /dev/null +++ b/backend/src/modules/crm/service/analytics-export.ts @@ -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 = { + 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 { + 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, + }; + } +} diff --git a/backend/src/modules/crm/service/enrichment.ts b/backend/src/modules/crm/service/enrichment.ts new file mode 100644 index 0000000..d53d598 --- /dev/null +++ b/backend/src/modules/crm/service/enrichment.ts @@ -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 { + 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(), + }; + } +} diff --git a/backend/src/modules/crm/service/webhook-dispatcher.ts b/backend/src/modules/crm/service/webhook-dispatcher.ts new file mode 100644 index 0000000..59ba9b2 --- /dev/null +++ b/backend/src/modules/crm/service/webhook-dispatcher.ts @@ -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; +} + +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; + 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 { + 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, + }; + } +} diff --git a/backend/src/modules/team/service/data-scope.ts b/backend/src/modules/team/service/data-scope.ts new file mode 100644 index 0000000..2021ee0 --- /dev/null +++ b/backend/src/modules/team/service/data-scope.ts @@ -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; + } + } +} diff --git a/backend/test/modules/crm/analytics-export.test.ts b/backend/test/modules/crm/analytics-export.test.ts new file mode 100644 index 0000000..1c069aa --- /dev/null +++ b/backend/test/modules/crm/analytics-export.test.ts @@ -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); + }); + }); +}); diff --git a/backend/test/modules/crm/enrichment.test.ts b/backend/test/modules/crm/enrichment.test.ts new file mode 100644 index 0000000..f565e30 --- /dev/null +++ b/backend/test/modules/crm/enrichment.test.ts @@ -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(); + }); + }); +}); diff --git a/backend/test/modules/crm/webhook-dispatcher.test.ts b/backend/test/modules/crm/webhook-dispatcher.test.ts new file mode 100644 index 0000000..d36c0b1 --- /dev/null +++ b/backend/test/modules/crm/webhook-dispatcher.test.ts @@ -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); + }); + }); +}); diff --git a/backend/test/modules/team/data-scope.test.ts b/backend/test/modules/team/data-scope.test.ts new file mode 100644 index 0000000..5f76b8d --- /dev/null +++ b/backend/test/modules/team/data-scope.test.ts @@ -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 + ); + }); + }); +});