feat(crm): 实现GDPR Article 17数据库级个人数据级联擦除与雷达IP掩码脱敏
这个提交包含在:
@@ -1,6 +1,10 @@
|
|||||||
import { Provide } from '@midwayjs/core';
|
import { Provide } from '@midwayjs/core';
|
||||||
import { BaseService } from '@cool-midway/core';
|
import { BaseService } from '@cool-midway/core';
|
||||||
|
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
import { CrmLeadEntity } from '../entity/lead';
|
||||||
|
import { RadarTrackEntity } from '../entity/track';
|
||||||
|
|
||||||
export interface GdprUserDataBundle {
|
export interface GdprUserDataBundle {
|
||||||
subjectId: string;
|
subjectId: string;
|
||||||
@@ -68,6 +72,12 @@ export function anonymizePiiData(rawRecord: {
|
|||||||
*/
|
*/
|
||||||
@Provide()
|
@Provide()
|
||||||
export class ComplianceGdprService extends BaseService {
|
export class ComplianceGdprService extends BaseService {
|
||||||
|
@InjectEntityModel(CrmLeadEntity)
|
||||||
|
crmLeadEntity?: Repository<CrmLeadEntity>;
|
||||||
|
|
||||||
|
@InjectEntityModel(RadarTrackEntity)
|
||||||
|
crmTrackEntity?: Repository<RadarTrackEntity>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打包主体全量便携数据 (GDPR Article 20)
|
* 打包主体全量便携数据 (GDPR Article 20)
|
||||||
*/
|
*/
|
||||||
@@ -100,4 +110,52 @@ export class ComplianceGdprService extends BaseService {
|
|||||||
certificateHash,
|
certificateHash,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 真正级联执行数据库级的个人数据擦除与雷达 IP 掩码脱敏 (GDPR Article 17)
|
||||||
|
*/
|
||||||
|
async purgeVisitorPersonalData(subject: {
|
||||||
|
email?: string;
|
||||||
|
leadId?: number | string;
|
||||||
|
visitorId?: string;
|
||||||
|
}): Promise<ErasureReceipt> {
|
||||||
|
const subjectId =
|
||||||
|
subject.email || subject.visitorId || String(subject.leadId || 'anonymous');
|
||||||
|
|
||||||
|
// 1. 清洗线索表 PII 隐私数据
|
||||||
|
if (this.crmLeadEntity) {
|
||||||
|
let lead: CrmLeadEntity | null = null;
|
||||||
|
if (subject.leadId) {
|
||||||
|
lead = await this.crmLeadEntity.findOne({
|
||||||
|
where: { id: Number(subject.leadId) },
|
||||||
|
});
|
||||||
|
} else if (subject.email) {
|
||||||
|
lead = await this.crmLeadEntity.findOne({
|
||||||
|
where: { email: subject.email },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (lead) {
|
||||||
|
const cleaned = anonymizePiiData(lead);
|
||||||
|
await this.crmLeadEntity.save(cleaned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 掩码雷达足迹 IP
|
||||||
|
if (this.crmTrackEntity && subject.visitorId) {
|
||||||
|
const tracks = await this.crmTrackEntity.find({
|
||||||
|
where: { visitorId: subject.visitorId },
|
||||||
|
});
|
||||||
|
if (Array.isArray(tracks) && tracks.length > 0) {
|
||||||
|
for (const track of tracks) {
|
||||||
|
if (track.ip) {
|
||||||
|
track.ip = maskIpAddress(track.ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.crmTrackEntity.save(tracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 签发合规销毁证书
|
||||||
|
return await this.executeRightToBeForgotten(subjectId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,4 +72,86 @@ describe('Point 53: 海外合规 GDPR / CCPA 访客数据匿名化脱敏与被
|
|||||||
expect(receipt.certificateHash).toHaveLength(64);
|
expect(receipt.certificateHash).toHaveLength(64);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('4. purgeVisitorPersonalData 数据库级合规擦除执行', () => {
|
||||||
|
it('根据 email 或 leadId 执行级联清洗,将线索脱敏且掩码历史雷达 IP', async () => {
|
||||||
|
const mockLead = {
|
||||||
|
id: 105,
|
||||||
|
name: 'Pierre Dupont',
|
||||||
|
email: 'pierre@paris.fr',
|
||||||
|
phone: '+33 6 12345678',
|
||||||
|
company: 'Paris Luxury',
|
||||||
|
notes: 'VIP customer',
|
||||||
|
};
|
||||||
|
const mockTracks = [
|
||||||
|
{ id: 1, visitorId: 'vis_fr_99', ip: '193.252.122.103', stayDurationMs: 5000 },
|
||||||
|
{ id: 2, visitorId: 'vis_fr_99', ip: '82.120.45.67', stayDurationMs: 8000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const savedLeads: any[] = [];
|
||||||
|
const savedTracks: any[] = [];
|
||||||
|
|
||||||
|
service.crmLeadEntity = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(mockLead),
|
||||||
|
save: jest.fn().mockImplementation((rec) => {
|
||||||
|
savedLeads.push(rec);
|
||||||
|
return Promise.resolve(rec);
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
service.crmTrackEntity = {
|
||||||
|
find: jest.fn().mockResolvedValue(mockTracks),
|
||||||
|
save: jest.fn().mockImplementation((tracks) => {
|
||||||
|
savedTracks.push(...(Array.isArray(tracks) ? tracks : [tracks]));
|
||||||
|
return Promise.resolve(tracks);
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const receipt = await service.purgeVisitorPersonalData({
|
||||||
|
leadId: 105,
|
||||||
|
email: 'pierre@paris.fr',
|
||||||
|
visitorId: 'vis_fr_99',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(receipt.subjectId).toBe('pierre@paris.fr');
|
||||||
|
expect(receipt.certificateHash).toHaveLength(64);
|
||||||
|
|
||||||
|
// 验证线索已被彻底清洗
|
||||||
|
expect(savedLeads[0].name).toBe('[GDPR_DELETED]');
|
||||||
|
expect(savedLeads[0].email).toBe('deleted-105@privacy.anonymized');
|
||||||
|
expect(savedLeads[0].phone).toBe('');
|
||||||
|
|
||||||
|
// 验证足迹 IP 已被掩码
|
||||||
|
expect(savedTracks[0].ip).toBe('193.252.122.xxx');
|
||||||
|
expect(savedTracks[1].ip).toBe('82.120.45.xxx');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('5. AdminCrmComplianceController 控制器接口契约', () => {
|
||||||
|
let controller: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
controller = new (require('../../../src/modules/crm/controller/admin/compliance').AdminCrmComplianceController)();
|
||||||
|
controller.complianceGdprService = service;
|
||||||
|
controller.ok = (data: any) => ({ code: 1000, message: 'success', data });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /erasure 缺少主体参数应抛出 CoolCommException', async () => {
|
||||||
|
await expect(controller.erasure({})).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /erasure 传入合法主体参数应成功返回销毁收据', async () => {
|
||||||
|
service.purgeVisitorPersonalData = jest.fn().mockResolvedValue({
|
||||||
|
subjectId: 'vis_test',
|
||||||
|
action: 'PURGED_AND_ANONYMIZED',
|
||||||
|
executedAt: new Date().toISOString(),
|
||||||
|
certificateHash: 'hash_abc_123',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await controller.erasure({ visitorId: 'vis_test' });
|
||||||
|
expect(res.code).toBe(1000);
|
||||||
|
expect(res.data.certificateHash).toBe('hash_abc_123');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户