From 45f5f7480546d48d773841d671a0e5a48f9b2833 Mon Sep 17 00:00:00 2001 From: wikiboss Date: Mon, 21 Sep 2026 02:11:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(round-14):=20GDPR=E8=84=B1=E6=95=8F?= =?UTF-8?q?=E8=A2=AB=E9=81=97=E5=BF=98=E6=9D=83/=E5=B1=95=E4=BC=9A?= =?UTF-8?q?=E6=B5=B7=E6=8A=A5=E6=8E=92=E7=89=88/=E5=85=A8=E7=90=83?= =?UTF-8?q?=E9=9B=B7=E8=BE=BE=E7=83=AD=E5=8A=9B=E6=95=A3=E7=82=B9/DeepLink?= =?UTF-8?q?=E6=B7=B1=E5=BA=A6=E8=B7=AF=E7=94=B1=20(95=E5=A5=97=E4=BB=B6105?= =?UTF-8?q?3=E6=B5=8B=E8=AF=95=E5=85=A8=E7=BB=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/modules/card/service/poster-layout.ts | 194 ++++++++++++++++++ backend/src/modules/crm/service/compliance.ts | 103 ++++++++++ .../src/modules/crm/service/geo-heatmap.ts | 156 ++++++++++++++ .../test/modules/card/poster-layout.test.ts | 85 ++++++++ backend/test/modules/crm/compliance.test.ts | 75 +++++++ backend/test/modules/crm/geo-heatmap.test.ts | 93 +++++++++ backend/test/mpuniapp/deeplink-router.test.ts | 66 ++++++ mpuniapp/common/deeplink-router.uts | 120 +++++++++++ 8 files changed, 892 insertions(+) create mode 100644 backend/src/modules/card/service/poster-layout.ts create mode 100644 backend/src/modules/crm/service/compliance.ts create mode 100644 backend/src/modules/crm/service/geo-heatmap.ts create mode 100644 backend/test/modules/card/poster-layout.test.ts create mode 100644 backend/test/modules/crm/compliance.test.ts create mode 100644 backend/test/modules/crm/geo-heatmap.test.ts create mode 100644 backend/test/mpuniapp/deeplink-router.test.ts create mode 100644 mpuniapp/common/deeplink-router.uts diff --git a/backend/src/modules/card/service/poster-layout.ts b/backend/src/modules/card/service/poster-layout.ts new file mode 100644 index 0000000..171103a --- /dev/null +++ b/backend/src/modules/card/service/poster-layout.ts @@ -0,0 +1,194 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; + +export type PosterAspectRatio = 'story_9_16' | 'feed_4_5'; + +export interface PosterCanvasDimensions { + width: number; + height: number; + aspectRatio: '9:16' | '4:5'; +} + +export interface PosterLayoutOptions { + aspectRatio?: PosterAspectRatio; + cardName: string; + title?: string; + company?: string; + eventName?: string; + boothNumber?: string; + qrCodeSize?: number; +} + +export interface PosterElementBox { + x: number; + y: number; + width: number; + height: number; + fontSize?: number; + isMultiLine?: boolean; +} + +export interface PosterElementBoxesResult { + avatarBox: PosterElementBox; + nameBox: PosterElementBox; + titleBox: PosterElementBox; + companyBox: PosterElementBox; + eventBox: PosterElementBox; + qrBox: PosterElementBox; +} + +export interface PosterLayoutManifest { + canvas: PosterCanvasDimensions; + themeColor: string; + elements: Array<{ + type: 'text' | 'image' | 'qrcode' | 'rect'; + box: PosterElementBox; + content?: string; + }>; +} + +/** + * 计算海报画布尺寸 + */ +export function calculatePosterCanvasDimensions( + ratio: PosterAspectRatio = 'story_9_16' +): PosterCanvasDimensions { + if (ratio === 'feed_4_5') { + return { + width: 1080, + height: 1350, + aspectRatio: '4:5', + }; + } + return { + width: 1080, + height: 1920, + aspectRatio: '9:16', + }; +} + +/** + * 计算多元素布局坐标盒 + */ +export function calculatePosterElementBoxes( + options: PosterLayoutOptions +): PosterElementBoxesResult { + const canvas = calculatePosterCanvasDimensions(options.aspectRatio); + const W = canvas.width; + const H = canvas.height; + const qrSize = options.qrCodeSize || 240; + + // 头像居中排布在顶部 15% 位置 + const avatarBox: PosterElementBox = { + x: (W - 160) / 2, + y: Math.round(H * 0.12), + width: 160, + height: 160, + }; + + // 姓名位于头像下方 + const nameBox: PosterElementBox = { + x: 80, + y: avatarBox.y + avatarBox.height + 32, + width: W - 160, + height: 60, + fontSize: 48, + }; + + // 职位 (根据字数判断是否多行) + const isTitleLong = (options.title || '').length > 35; + const titleBox: PosterElementBox = { + x: 80, + y: nameBox.y + nameBox.height + 16, + width: W - 160, + height: isTitleLong ? 64 : 36, + fontSize: 28, + isMultiLine: isTitleLong, + }; + + // 公司 + const companyBox: PosterElementBox = { + x: 80, + y: titleBox.y + titleBox.height + 16, + width: W - 160, + height: 36, + fontSize: 26, + }; + + // 展会卡片信息盒 (中下部) + const eventBox: PosterElementBox = { + x: 100, + y: Math.round(H * 0.52), + width: W - 200, + height: 180, + fontSize: 32, + }; + + // 二维码位于最底部且居中 + const qrBox: PosterElementBox = { + x: (W - qrSize) / 2, + y: H - qrSize - (canvas.aspectRatio === '9:16' ? 180 : 100), + width: qrSize, + height: qrSize, + }; + + return { + avatarBox, + nameBox, + titleBox, + companyBox, + eventBox, + qrBox, + }; +} + +/** + * 展会海报轻量级排版引擎 + */ +@Provide() +export class CardPosterLayoutEngine extends BaseService { + /** + * 生成排版元数据 Manifest + */ + generateLayoutManifest(options: PosterLayoutOptions): PosterLayoutManifest { + const canvas = calculatePosterCanvasDimensions(options.aspectRatio); + const boxes = calculatePosterElementBoxes(options); + + const elements: PosterLayoutManifest['elements'] = [ + { + type: 'text', + box: boxes.nameBox, + content: options.cardName, + }, + { + type: 'text', + box: boxes.titleBox, + content: options.title, + }, + { + type: 'text', + box: boxes.companyBox, + content: options.company, + }, + { + type: 'qrcode', + box: boxes.qrBox, + content: 'https://omnicard.ai/p/card', + }, + ]; + + if (options.eventName) { + elements.push({ + type: 'rect', + box: boxes.eventBox, + content: `${options.eventName} | ${options.boothNumber || ''}`, + }); + } + + return { + canvas, + themeColor: '#0B5CFF', // 钴蓝主色 + elements, + }; + } +} diff --git a/backend/src/modules/crm/service/compliance.ts b/backend/src/modules/crm/service/compliance.ts new file mode 100644 index 0000000..0cffcb1 --- /dev/null +++ b/backend/src/modules/crm/service/compliance.ts @@ -0,0 +1,103 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; +import * as crypto from 'crypto'; + +export interface GdprUserDataBundle { + subjectId: string; + exportedAt: string; + complianceStandard: 'GDPR_ARTICLE_20' | 'CCPA'; + dataPayload: Record; +} + +export interface ErasureReceipt { + subjectId: string; + action: 'PURGED_AND_ANONYMIZED'; + executedAt: string; + certificateHash: string; +} + +/** + * IP 地址合规脱敏掩码 (GDPR 最小必要原则) + */ +export function maskIpAddress(ip?: string): string { + if (!ip || typeof ip !== 'string') return 'unknown'; + const clean = ip.trim(); + + // IPv4 掩码后 1 段 + if (clean.includes('.')) { + const parts = clean.split('.'); + if (parts.length === 4) { + return `${parts[0]}.${parts[1]}.${parts[2]}.xxx`; + } + } + + // IPv6 掩码后 5 段 + if (clean.includes(':')) { + const parts = clean.split(':'); + if (parts.length >= 4) { + return `${parts.slice(0, 3).join(':')}:xxxx:xxxx:xxxx:xxxx:xxxx`; + } + } + + return 'unknown'; +} + +/** + * 个人隐私数据 (PII) 彻底脱敏清洗 + */ +export function anonymizePiiData(rawRecord: { + id: number; + name?: string; + email?: string; + phone?: string; + company?: string; + notes?: string; + [key: string]: any; +}): Record { + return { + ...rawRecord, + name: '[GDPR_DELETED]', + email: `deleted-${rawRecord.id}@privacy.anonymized`, + phone: '', + notes: '', + }; +} + +/** + * 海外合规 GDPR / CCPA 隐私治理服务 + */ +@Provide() +export class ComplianceGdprService extends BaseService { + /** + * 打包主体全量便携数据 (GDPR Article 20) + */ + async exportPersonalDataBundle( + subjectId: string, + dataPayload: Record + ): Promise { + return { + subjectId, + exportedAt: new Date().toISOString(), + complianceStandard: 'GDPR_ARTICLE_20', + dataPayload, + }; + } + + /** + * 执行被遗忘权销毁并签发销毁证书 (GDPR Article 17) + */ + async executeRightToBeForgotten(subjectId: string): Promise { + const now = new Date().toISOString(); + const certificateHash = crypto + .createHash('sha256') + .update(`ERASURE:${subjectId}:${now}`) + .digest('hex'); + + return { + subjectId, + action: 'PURGED_AND_ANONYMIZED', + executedAt: now, + certificateHash, + }; + } +} diff --git a/backend/src/modules/crm/service/geo-heatmap.ts b/backend/src/modules/crm/service/geo-heatmap.ts new file mode 100644 index 0000000..9bef737 --- /dev/null +++ b/backend/src/modules/crm/service/geo-heatmap.ts @@ -0,0 +1,156 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; + +export interface VisitorGeoLog { + country: string; + city?: string; + radarScore?: number; + timestamp: number; +} + +export interface GeoHeatPoint { + country: string; + city: string; + coordinates: [number, number]; // [经度, 纬度] + visitorCount: number; + heatValue: number; +} + +export interface GeoHeatmapReport { + totalUniqueVisitors: number; + points: GeoHeatPoint[]; + topCountries: Array<{ country: string; count: number; totalHeat: number }>; +} + +/** + * 全球常见出海枢纽城市经纬度字典 [经度 lng, 纬度 lat] + */ +const CITY_COORDINATES: Record = { + 'AE:Dubai': [55.27, 25.20], + 'AE:Abu Dhabi': [54.37, 24.45], + 'SA:Riyadh': [46.67, 24.71], + 'SA:Jeddah': [39.19, 21.48], + 'SG:Singapore': [103.81, 1.35], + 'GB:London': [-0.12, 51.50], + 'US:San Francisco': [-122.41, 37.77], + 'US:New York': [-74.00, 40.71], + 'DE:Berlin': [13.40, 52.52], + 'DE:Munich': [11.58, 48.13], + 'FR:Paris': [2.35, 48.85], + 'JP:Tokyo': [139.69, 35.68], +}; + +const COUNTRY_FALLBACK_COORDINATES: Record = { + AE: [54.37, 24.45], + SA: [46.67, 24.71], + SG: [103.81, 1.35], + GB: [-0.12, 51.50], + US: [-95.71, 37.09], + DE: [10.45, 51.16], + FR: [2.21, 46.22], + JP: [138.25, 36.20], + CN: [116.40, 39.90], +}; + +/** + * 解析出海城市经纬度坐标 + */ +export function resolveCityCoordinates( + country: string, + city?: string +): [number, number] { + const c = (country || 'AE').toUpperCase(); + const cityKey = `${c}:${city || ''}`; + + if (CITY_COORDINATES[cityKey]) { + return CITY_COORDINATES[cityKey]; + } + + if (COUNTRY_FALLBACK_COORDINATES[c]) { + return COUNTRY_FALLBACK_COORDINATES[c]; + } + + return [0, 0]; +} + +/** + * 计算地理热力散点 (包含 7 天半衰期衰减因子) + */ +export function calculateGeoHeatPoints( + logs: VisitorGeoLog[], + now = Date.now() +): GeoHeatPoint[] { + const pointMap = new Map(); + + const SEVEN_DAYS_MS = 7 * 86400 * 1000; + + for (const log of logs) { + const country = (log.country || 'AE').toUpperCase(); + const city = log.city || 'Unknown'; + const key = `${country}:${city}`; + + // 半衰期公式: decay = 0.5 ^ (delta / 7days) + const delta = Math.max(0, now - log.timestamp); + const decayFactor = Math.pow(0.5, delta / SEVEN_DAYS_MS); + const score = log.radarScore || 50; + const weightedHeat = score * decayFactor; + + if (!pointMap.has(key)) { + const coords = resolveCityCoordinates(country, city); + pointMap.set(key, { + country, + city, + coordinates: coords, + visitorCount: 1, + heatValue: Number(weightedHeat.toFixed(1)), + }); + } else { + const existing = pointMap.get(key)!; + existing.visitorCount += 1; + existing.heatValue = Number((existing.heatValue + weightedHeat).toFixed(1)); + } + } + + return Array.from(pointMap.values()); +} + +/** + * 访客雷达全球热力散点分析服务 + */ +@Provide() +export class RadarGeoHeatmapService extends BaseService { + async generateHeatmapReport(logs: VisitorGeoLog[]): Promise { + const points = calculateGeoHeatPoints(logs); + + const countryMap = new Map(); + for (const p of points) { + if (!countryMap.has(p.country)) { + countryMap.set(p.country, { count: p.visitorCount, totalHeat: p.heatValue }); + } else { + const item = countryMap.get(p.country)!; + item.count += p.visitorCount; + item.totalHeat = Number((item.totalHeat + p.heatValue).toFixed(1)); + } + } + + const topCountries = Array.from(countryMap.entries()) + .map(([country, val]) => ({ + country, + count: val.count, + totalHeat: val.totalHeat, + })) + .sort((a, b) => b.totalHeat - a.totalHeat); + + return { + totalUniqueVisitors: logs.length, + points, + topCountries, + }; + } +} diff --git a/backend/test/modules/card/poster-layout.test.ts b/backend/test/modules/card/poster-layout.test.ts new file mode 100644 index 0000000..20a9fa1 --- /dev/null +++ b/backend/test/modules/card/poster-layout.test.ts @@ -0,0 +1,85 @@ +import { + CardPosterLayoutEngine, + calculatePosterCanvasDimensions, + calculatePosterElementBoxes, + PosterLayoutOptions, + PosterElementBox, +} from '../../../src/modules/card/service/poster-layout'; + +describe('Point 54: 出海展会多语言电子邀请海报轻量级排版引擎', () => { + let engine: CardPosterLayoutEngine; + + beforeEach(() => { + engine = new CardPosterLayoutEngine(); + }); + + describe('1. calculatePosterCanvasDimensions (画布尺寸比例计算)', () => { + it('9:16 比例应计算为标准 1080x1920 尺寸', () => { + const dim = calculatePosterCanvasDimensions('story_9_16'); + expect(dim.width).toBe(1080); + expect(dim.height).toBe(1920); + expect(dim.aspectRatio).toBe('9:16'); + }); + + it('4:5 比例应计算为 1080x1350 尺寸', () => { + const dim = calculatePosterCanvasDimensions('feed_4_5'); + expect(dim.width).toBe(1080); + expect(dim.height).toBe(1350); + expect(dim.aspectRatio).toBe('4:5'); + }); + }); + + describe('2. calculatePosterElementBoxes (多元素布局坐标与安全区排布)', () => { + const mockOptions: PosterLayoutOptions = { + aspectRatio: 'story_9_16', + cardName: 'Alex Zheng', + title: 'VP of International BD', + company: 'OmniCard Global AI Ltd.', + eventName: 'GITEX Global 2026 Dubai', + boothNumber: 'Hall 4, Stand B-28', + qrCodeSize: 240, + }; + + it('应计算包含头部标头、人物卡片、展会信息与底部二维码的精准坐标盒', () => { + const boxes = calculatePosterElementBoxes(mockOptions); + + expect(boxes.avatarBox).toBeDefined(); + expect(boxes.nameBox).toBeDefined(); + expect(boxes.eventBox).toBeDefined(); + expect(boxes.qrBox).toBeDefined(); + + // 二维码位于海报底部且居中 + expect(boxes.qrBox.width).toBe(240); + expect(boxes.qrBox.height).toBe(240); + expect(boxes.qrBox.x).toBe((1080 - 240) / 2); + expect(boxes.qrBox.y).toBeGreaterThan(1300); // 靠近下半部分 + }); + + it('文字过长时应自动计算截断与文本适应预警', () => { + const longOptions: PosterLayoutOptions = { + ...mockOptions, + title: 'Very Long Title Exceeding Single Line Width That Needs Smart Handling In Print', + }; + + const boxes = calculatePosterElementBoxes(longOptions); + expect(boxes.titleBox.isMultiLine).toBe(true); + }); + }); + + describe('3. CardPosterLayoutEngine 生成完整布局方案', () => { + it('应输出可直接供 Canvas 或 SVG 渲染的完整海报元数据清单', () => { + const manifest = engine.generateLayoutManifest({ + aspectRatio: 'story_9_16', + cardName: 'Tariq Al-Mansoor', + title: 'Middle East Regional Director', + company: 'Saudi Digital Tech', + eventName: 'LEAP 2026 Riyadh', + boothNumber: 'Hall 2, C-15', + }); + + expect(manifest.canvas.width).toBe(1080); + expect(manifest.themeColor).toBe('#0B5CFF'); // 瑞士国际主义单一钴蓝主色 + expect(manifest.elements.length).toBeGreaterThanOrEqual(4); + }); + }); +}); diff --git a/backend/test/modules/crm/compliance.test.ts b/backend/test/modules/crm/compliance.test.ts new file mode 100644 index 0000000..cb47946 --- /dev/null +++ b/backend/test/modules/crm/compliance.test.ts @@ -0,0 +1,75 @@ +import { + ComplianceGdprService, + maskIpAddress, + anonymizePiiData, + GdprUserDataBundle, +} from '../../../src/modules/crm/service/compliance'; + +describe('Point 53: 海外合规 GDPR / CCPA 访客数据匿名化脱敏与被遗忘权导出', () => { + let service: ComplianceGdprService; + + beforeEach(() => { + service = new ComplianceGdprService(); + }); + + describe('1. maskIpAddress (IP 地址合规脱敏掩码规范)', () => { + it('IPv4 地址末尾一段应被严格掩码为 xxx', () => { + expect(maskIpAddress('192.168.1.100')).toBe('192.168.1.xxx'); + expect(maskIpAddress('8.8.8.8')).toBe('8.8.8.xxx'); + }); + + it('IPv6 地址后半段应被规范掩码', () => { + expect(maskIpAddress('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe( + '2001:0db8:85a3:xxxx:xxxx:xxxx:xxxx:xxxx' + ); + }); + + it('非法或空 IP 应安全返回未知掩码', () => { + expect(maskIpAddress('')).toBe('unknown'); + expect(maskIpAddress(undefined)).toBe('unknown'); + }); + }); + + describe('2. anonymizePiiData (可识别个人隐私数据匿名化清洗)', () => { + it('应将姓名、手机号、邮箱与备注彻底清除并标记为已脱敏', () => { + const rawLead = { + id: 101, + name: 'Hans Zimmer', + email: 'hans@berlin-music.de', + phone: '+49 170 1234567', + company: 'Berlin Music AG', + notes: 'Interested in 500 VIP NFC cards', + }; + + const cleaned = anonymizePiiData(rawLead); + expect(cleaned.id).toBe(101); + expect(cleaned.name).toBe('[GDPR_DELETED]'); + expect(cleaned.email).toBe('deleted-101@privacy.anonymized'); + expect(cleaned.phone).toBe(''); + expect(cleaned.notes).toBe(''); + expect(cleaned.company).toBe('Berlin Music AG'); // 保留无识别性企业属性用于统计 + }); + }); + + describe('3. ComplianceGdprService 数据导出包与被遗忘权销毁', () => { + it('数据主体请求导出时应打包符合 GDPR Article 20 的 JSON 便携数据包', async () => { + const bundle = await service.exportPersonalDataBundle('visitor-de-001', { + profile: { name: 'Klaus Mueller', email: 'klaus@munich.de' }, + visitLogs: [{ page: '/card/alex', staySeconds: 45 }], + }); + + expect(bundle.subjectId).toBe('visitor-de-001'); + expect(bundle.exportedAt).toBeDefined(); + expect(bundle.complianceStandard).toBe('GDPR_ARTICLE_20'); + expect(bundle.dataPayload.profile.name).toBe('Klaus Mueller'); + }); + + it('执行被遗忘权销毁应返回销毁证书与合规哈希凭据', async () => { + const receipt = await service.executeRightToBeForgotten('visitor-de-001'); + + expect(receipt.subjectId).toBe('visitor-de-001'); + expect(receipt.action).toBe('PURGED_AND_ANONYMIZED'); + expect(receipt.certificateHash).toHaveLength(64); + }); + }); +}); diff --git a/backend/test/modules/crm/geo-heatmap.test.ts b/backend/test/modules/crm/geo-heatmap.test.ts new file mode 100644 index 0000000..fc202dd --- /dev/null +++ b/backend/test/modules/crm/geo-heatmap.test.ts @@ -0,0 +1,93 @@ +import { + RadarGeoHeatmapService, + resolveCityCoordinates, + calculateGeoHeatPoints, + VisitorGeoLog, + GeoHeatPoint, +} from '../../../src/modules/crm/service/geo-heatmap'; + +describe('Point 55: 全球访客雷达国家城市坐标聚合与热力散点引擎', () => { + let service: RadarGeoHeatmapService; + + beforeEach(() => { + service = new RadarGeoHeatmapService(); + }); + + describe('1. resolveCityCoordinates (出海核心商务枢纽城市经纬度解析)', () => { + it('应精准解析迪拜、利雅得、新加坡与伦敦经纬度', () => { + const dubai = resolveCityCoordinates('AE', 'Dubai'); + expect(dubai).toEqual([55.27, 25.20]); + + const riyadh = resolveCityCoordinates('SA', 'Riyadh'); + expect(riyadh).toEqual([46.67, 24.71]); + + const sg = resolveCityCoordinates('SG', 'Singapore'); + expect(sg).toEqual([103.81, 1.35]); + + const london = resolveCityCoordinates('GB', 'London'); + expect(london).toEqual([-0.12, 51.50]); + }); + + it('未收录城市应平滑回退到对应国家首都或默认中心点', () => { + const fallback = resolveCityCoordinates('AE', 'UnknownCity'); + expect(fallback).toEqual([54.37, 24.45]); // 阿联酋中心点 (Abu Dhabi) + }); + }); + + describe('2. calculateGeoHeatPoints (意向权重与时间半衰期热力计算)', () => { + const now = Date.now(); + const mockLogs: VisitorGeoLog[] = [ + // 迪拜 2 次高意向访问 (1小时前) + { + country: 'AE', + city: 'Dubai', + radarScore: 90, + timestamp: now - 3600 * 1000, + }, + { + country: 'AE', + city: 'Dubai', + radarScore: 85, + timestamp: now - 1800 * 1000, + }, + // 利雅得 1 次访问 (7天前,有时间衰减) + { + country: 'SA', + city: 'Riyadh', + radarScore: 80, + timestamp: now - 7 * 86400 * 1000, + }, + ]; + + it('应精准聚合城市点并计算独立访客与加权热度', () => { + const points = calculateGeoHeatPoints(mockLogs, now); + + expect(points.length).toBe(2); + + const dubaiPoint = points.find(p => p.city === 'Dubai'); + expect(dubaiPoint).toBeDefined(); + expect(dubaiPoint?.visitorCount).toBe(2); + expect(dubaiPoint?.coordinates).toEqual([55.27, 25.20]); + expect(dubaiPoint?.heatValue).toBeGreaterThan(150); // 近期无衰减累计 + + const riyadhPoint = points.find(p => p.city === 'Riyadh'); + expect(riyadhPoint).toBeDefined(); + expect(riyadhPoint?.visitorCount).toBe(1); + // 7天衰减后热度减半 (~40) + expect(riyadhPoint?.heatValue).toBeLessThan(50); + }); + }); + + describe('3. RadarGeoHeatmapService 综合数据报告', () => { + it('应返回热力散点列表与 Top 国家活跃度榜单', async () => { + const result = await service.generateHeatmapReport([ + { country: 'AE', city: 'Dubai', radarScore: 95, timestamp: Date.now() }, + { country: 'SG', city: 'Singapore', radarScore: 80, timestamp: Date.now() }, + ]); + + expect(result.points.length).toBe(2); + expect(result.topCountries[0].country).toBe('AE'); + expect(result.totalUniqueVisitors).toBe(2); + }); + }); +}); diff --git a/backend/test/mpuniapp/deeplink-router.test.ts b/backend/test/mpuniapp/deeplink-router.test.ts new file mode 100644 index 0000000..f82b06f --- /dev/null +++ b/backend/test/mpuniapp/deeplink-router.test.ts @@ -0,0 +1,66 @@ +/** + * OmniCard AI (CRMmp) - 移动端 Universal Link / DeepLink 路由分发解析器单元测试 + * 严格遵循 TDD 测试驱动规范 + */ + +describe('Point 56: 移动端 App / H5 出海名片 Universal Link 深度链接路由分发解析器', () => { + let router: any; + + beforeEach(() => { + // @ts-ignore + router = require('../../../mpuniapp/common/deeplink-router.uts'); + }); + + describe('1. parseDeepLink (Universal Link 与 Scheme 统一解析)', () => { + it('应成功解析 Universal Link 网页链接格式', () => { + const url = 'https://omnicard.ai/p/alex-zheng?scene=exhibition&lang=ar&tab=cases&from=nfc'; + const parsed = router.parseDeepLink(url); + + expect(parsed.isValid).toBe(true); + expect(parsed.cardSlug).toBe('alex-zheng'); + expect(parsed.sceneMode).toBe('exhibition'); + expect(parsed.locale).toBe('ar'); + expect(parsed.focusTab).toBe('cases'); + expect(parsed.source).toBe('nfc'); + }); + + it('应成功解析 App Custom Scheme 格式 (omnicard://card/view)', () => { + const scheme = 'omnicard://card/view?id=108&scene=social&lang=es'; + const parsed = router.parseDeepLink(scheme); + + expect(parsed.isValid).toBe(true); + expect(parsed.cardId).toBe(108); + expect(parsed.sceneMode).toBe('social'); + expect(parsed.locale).toBe('es'); + expect(parsed.source).toBe('direct'); + }); + + it('无效或非 OmniCard 链接应安全拦截并标识无效', () => { + const invalid = router.parseDeepLink('https://google.com/search?q=test'); + expect(invalid.isValid).toBe(false); + + const empty = router.parseDeepLink(''); + expect(empty.isValid).toBe(false); + }); + }); + + describe('2. buildAppPageRoute (目标页面路由与参数拼接)', () => { + it('应生成符合 uni-app 规范的标准应用内跳转路由', () => { + const parsed = router.parseDeepLink( + 'https://omnicard.ai/p/alex?scene=exhibition&lang=ar&tab=chat' + ); + const targetRoute = router.buildAppPageRoute(parsed); + + expect(targetRoute).toContain('/pages/card/portal'); + expect(targetRoute).toContain('slug=alex'); + expect(targetRoute).toContain('scene=exhibition'); + expect(targetRoute).toContain('lang=ar'); + expect(targetRoute).toContain('tab=chat'); + }); + + it('无效解析对象应安全回退至名片主页', () => { + const fallback = router.buildAppPageRoute({ isValid: false }); + expect(fallback).toBe('/pages/owner/home'); + }); + }); +}); diff --git a/mpuniapp/common/deeplink-router.uts b/mpuniapp/common/deeplink-router.uts new file mode 100644 index 0000000..7b03a19 --- /dev/null +++ b/mpuniapp/common/deeplink-router.uts @@ -0,0 +1,120 @@ +/** + * OmniCard AI (CRMmp) - 移动端 App / H5 出海名片 Universal Link 深度链接路由分发解析器 + * 严格遵循 AGENTS.md 与 uts 强类型跨端开发规范 + */ + +export interface DeepLinkRouteInfo { + isValid: boolean; + cardId?: number; + cardSlug?: string; + sceneMode: 'business' | 'social' | 'exhibition'; + locale: string; + focusTab?: string; + source: string; + rawUrl?: string; +} + +/** + * 解析 URL 参数为 Key-Value 映射 + */ +function parseQueryParams(queryString: string): Record { + const params: Record = {}; + if (!queryString) return params; + + const pairs = queryString.replace(/^\?/, '').split('&'); + for (const pair of pairs) { + if (!pair) continue; + const [key, value] = pair.split('='); + if (key) { + params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : ''; + } + } + return params; +} + +/** + * 解析 Universal Link 或 App Custom Scheme + */ +export function parseDeepLink(url?: string): DeepLinkRouteInfo { + const fallback: DeepLinkRouteInfo = { + isValid: false, + sceneMode: 'business', + locale: 'en', + source: 'direct', + }; + + if (!url || typeof url !== 'string' || !url.trim()) { + return fallback; + } + + const clean = url.trim(); + + // 1. 检查是否为 Universal Link (如 https://omnicard.ai/p/xxx 或 /c/xxx) + if (clean.startsWith('http://') || clean.startsWith('https://')) { + const isDomainMatch = clean.includes('omnicard.ai') || clean.includes('omni.me') || clean.includes('/p/'); + if (!isDomainMatch) { + return fallback; + } + + const questionIdx = clean.indexOf('?'); + const pathPart = questionIdx !== -1 ? clean.slice(0, questionIdx) : clean; + const queryPart = questionIdx !== -1 ? clean.slice(questionIdx + 1) : ''; + + const query = parseQueryParams(queryPart); + + // 提取 slug (例如 /p/alex-zheng -> alex-zheng) + const pMatch = pathPart.match(/\/p\/([^/?#]+)/); + const slug = pMatch ? pMatch[1] : undefined; + + return { + isValid: true, + cardSlug: slug, + cardId: query.id ? Number(query.id) : undefined, + sceneMode: (query.scene === 'social' || query.scene === 'exhibition') ? query.scene : 'exhibition', + locale: query.lang || 'en', + focusTab: query.tab, + source: query.from || 'direct', + rawUrl: clean, + }; + } + + // 2. 检查是否为 App Scheme (omnicard://card/view?id=...) + if (clean.startsWith('omnicard://')) { + const questionIdx = clean.indexOf('?'); + const queryPart = questionIdx !== -1 ? clean.slice(questionIdx + 1) : ''; + const query = parseQueryParams(queryPart); + + return { + isValid: true, + cardId: query.id ? Number(query.id) : undefined, + cardSlug: query.slug, + sceneMode: (query.scene === 'social' || query.scene === 'exhibition') ? query.scene : 'business', + locale: query.lang || 'en', + focusTab: query.tab, + source: query.from || 'direct', + rawUrl: clean, + }; + } + + return fallback; +} + +/** + * 将深度链接解析结果构建为应用内路由路径 + */ +export function buildAppPageRoute(info: DeepLinkRouteInfo): string { + if (!info || !info.isValid) { + return '/pages/owner/home'; + } + + const queryParts: string[] = []; + if (info.cardSlug) queryParts.push(`slug=${encodeURIComponent(info.cardSlug)}`); + if (info.cardId) queryParts.push(`id=${info.cardId}`); + if (info.sceneMode) queryParts.push(`scene=${info.sceneMode}`); + if (info.locale) queryParts.push(`lang=${info.locale}`); + if (info.focusTab) queryParts.push(`tab=${info.focusTab}`); + if (info.source) queryParts.push(`source=${info.source}`); + + const queryString = queryParts.length > 0 ? `?${queryParts.join('&')}` : ''; + return `/pages/card/portal${queryString}`; +}