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,
|
||||
};
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户