From e89c6ba34cc7d6f8d292b28c818e8cfe335db498 Mon Sep 17 00:00:00 2001 From: wikiboss Date: Mon, 21 Sep 2026 11:23:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(crm):=20=E5=AE=9E=E7=8E=B0GDPR=20Article?= =?UTF-8?q?=2017=E6=95=B0=E6=8D=AE=E5=BA=93=E7=BA=A7=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=BA=A7=E8=81=94=E6=93=A6=E9=99=A4=E4=B8=8E?= =?UTF-8?q?=E9=9B=B7=E8=BE=BEIP=E6=8E=A9=E7=A0=81=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/modules/crm/service/compliance.ts | 58 +++++++++++++ backend/test/modules/crm/compliance.test.ts | 82 +++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/backend/src/modules/crm/service/compliance.ts b/backend/src/modules/crm/service/compliance.ts index 0cffcb1..774e87c 100644 --- a/backend/src/modules/crm/service/compliance.ts +++ b/backend/src/modules/crm/service/compliance.ts @@ -1,6 +1,10 @@ import { Provide } from '@midwayjs/core'; import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; import * as crypto from 'crypto'; +import { CrmLeadEntity } from '../entity/lead'; +import { RadarTrackEntity } from '../entity/track'; export interface GdprUserDataBundle { subjectId: string; @@ -68,6 +72,12 @@ export function anonymizePiiData(rawRecord: { */ @Provide() export class ComplianceGdprService extends BaseService { + @InjectEntityModel(CrmLeadEntity) + crmLeadEntity?: Repository; + + @InjectEntityModel(RadarTrackEntity) + crmTrackEntity?: Repository; + /** * 打包主体全量便携数据 (GDPR Article 20) */ @@ -100,4 +110,52 @@ export class ComplianceGdprService extends BaseService { certificateHash, }; } + + /** + * 真正级联执行数据库级的个人数据擦除与雷达 IP 掩码脱敏 (GDPR Article 17) + */ + async purgeVisitorPersonalData(subject: { + email?: string; + leadId?: number | string; + visitorId?: string; + }): Promise { + 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); + } } diff --git a/backend/test/modules/crm/compliance.test.ts b/backend/test/modules/crm/compliance.test.ts index cb47946..b16e56b 100644 --- a/backend/test/modules/crm/compliance.test.ts +++ b/backend/test/modules/crm/compliance.test.ts @@ -72,4 +72,86 @@ describe('Point 53: 海外合规 GDPR / CCPA 访客数据匿名化脱敏与被 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'); + }); + }); }); +