feat(round-14): GDPR脱敏被遗忘权/展会海报排版/全球雷达热力散点/DeepLink深度路由 (95套件1053测试全绿)

这个提交包含在:
wikiboss
2026-09-21 02:11:17 +08:00
父节点 749ccf2e01
当前提交 45f5f74805
共有 8 个文件被更改,包括 892 次插入 和 0 次删除

查看文件

@@ -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');
});
});
});

查看文件

@@ -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}`;
}