feat(round-14): GDPR脱敏被遗忘权/展会海报排版/全球雷达热力散点/DeepLink深度路由 (95套件1053测试全绿)
这个提交包含在:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
}
|
||||
|
||||
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<string, any> {
|
||||
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<string, any>
|
||||
): Promise<GdprUserDataBundle> {
|
||||
return {
|
||||
subjectId,
|
||||
exportedAt: new Date().toISOString(),
|
||||
complianceStandard: 'GDPR_ARTICLE_20',
|
||||
dataPayload,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行被遗忘权销毁并签发销毁证书 (GDPR Article 17)
|
||||
*/
|
||||
async executeRightToBeForgotten(subjectId: string): Promise<ErasureReceipt> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<string, [number, number]> = {
|
||||
'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<string, [number, number]> = {
|
||||
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<string, {
|
||||
country: string;
|
||||
city: string;
|
||||
coordinates: [number, number];
|
||||
visitorCount: number;
|
||||
heatValue: number;
|
||||
}>();
|
||||
|
||||
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<GeoHeatmapReport> {
|
||||
const points = calculateGeoHeatPoints(logs);
|
||||
|
||||
const countryMap = new Map<string, { count: number; totalHeat: number }>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
120
mpuniapp/common/deeplink-router.uts
普通文件
120
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<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
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}`;
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户