fix(miniprogram): Analytics PII 清理 — 移除 userId/patientId 字段 + sanitizeProperties (S2-1)
- 移除 AnalyticsEvent 接口中的 userId/patientId 字段 - 新增 sanitizeProperties 运行时过滤 14 种 PII 标识字段 - trackEvent 自动清理 properties 中的 PII - 3 个单元测试覆盖 PII 过滤场景
This commit is contained in:
99
apps/miniprogram/__tests__/services/analytics-pii.test.ts
Normal file
99
apps/miniprogram/__tests__/services/analytics-pii.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@tarojs/taro', () => ({
|
||||||
|
default: {
|
||||||
|
getStorageSync: vi.fn(() => []),
|
||||||
|
setStorage: vi.fn(),
|
||||||
|
removeStorageSync: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/services/request', () => ({
|
||||||
|
api: { post: vi.fn().mockResolvedValue({ success: true }) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Analytics PII 清理', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushEvents 发送的 batch 不含 PII', async () => {
|
||||||
|
const { trackEvent, flushEvents } = await import('@/services/analytics');
|
||||||
|
const { api } = await import('@/services/request');
|
||||||
|
|
||||||
|
trackEvent('page_view', {
|
||||||
|
page: 'health',
|
||||||
|
userId: 'should-be-removed',
|
||||||
|
patientId: 'should-be-removed',
|
||||||
|
user_name: 'should-be-removed',
|
||||||
|
phone: 'should-be-removed',
|
||||||
|
id_card: 'should-be-removed',
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushEvents();
|
||||||
|
|
||||||
|
const postCall = vi.mocked(api.post).mock.calls[0];
|
||||||
|
const body = postCall[1] as { events: Array<Record<string, unknown>> };
|
||||||
|
const evt = body.events[0];
|
||||||
|
|
||||||
|
// 事件级别不应有 userId/patientId
|
||||||
|
expect(evt).not.toHaveProperty('userId');
|
||||||
|
expect(evt).not.toHaveProperty('patientId');
|
||||||
|
|
||||||
|
// properties 中不应有 PII 字段
|
||||||
|
const props = evt.properties as Record<string, unknown>;
|
||||||
|
expect(props).not.toHaveProperty('userId');
|
||||||
|
expect(props).not.toHaveProperty('patientId');
|
||||||
|
expect(props).not.toHaveProperty('user_name');
|
||||||
|
expect(props).not.toHaveProperty('phone');
|
||||||
|
expect(props).not.toHaveProperty('id_card');
|
||||||
|
|
||||||
|
// 正常字段保留
|
||||||
|
expect(props.page).toBe('health');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trackEvent 不在事件级别包含 userId/patientId', async () => {
|
||||||
|
const { trackEvent, flushEvents } = await import('@/services/analytics');
|
||||||
|
const { api } = await import('@/services/request');
|
||||||
|
|
||||||
|
trackEvent('test_event');
|
||||||
|
await flushEvents();
|
||||||
|
|
||||||
|
const postCall = vi.mocked(api.post).mock.calls[0];
|
||||||
|
const body = postCall[1] as { events: Array<Record<string, unknown>> };
|
||||||
|
const evt = body.events[0];
|
||||||
|
|
||||||
|
expect(evt).not.toHaveProperty('userId');
|
||||||
|
expect(evt).not.toHaveProperty('patientId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizeProperties 过滤所有 PII 标识字段', async () => {
|
||||||
|
const { trackEvent, flushEvents } = await import('@/services/analytics');
|
||||||
|
const { api } = await import('@/services/request');
|
||||||
|
|
||||||
|
trackEvent('test', {
|
||||||
|
openid: 'oXXX',
|
||||||
|
access_token: 'tok',
|
||||||
|
refresh_token: 'ref',
|
||||||
|
email: 'test@test.com',
|
||||||
|
address: '某地',
|
||||||
|
mobile: '13800001111',
|
||||||
|
page: 'settings', // 非 PII 字段
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushEvents();
|
||||||
|
|
||||||
|
const postCall = vi.mocked(api.post).mock.calls[0];
|
||||||
|
const body = postCall[1] as { events: Array<Record<string, unknown>> };
|
||||||
|
const props = body.events[0].properties as Record<string, unknown>;
|
||||||
|
|
||||||
|
// 全部 PII 被过滤,只剩 page
|
||||||
|
expect(props).not.toHaveProperty('openid');
|
||||||
|
expect(props).not.toHaveProperty('access_token');
|
||||||
|
expect(props).not.toHaveProperty('refresh_token');
|
||||||
|
expect(props).not.toHaveProperty('email');
|
||||||
|
expect(props).not.toHaveProperty('address');
|
||||||
|
expect(props).not.toHaveProperty('mobile');
|
||||||
|
expect(props.page).toBe('settings');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,12 +17,30 @@ type EventName =
|
|||||||
| 'family_add'
|
| 'family_add'
|
||||||
| 'profile_edit';
|
| 'profile_edit';
|
||||||
|
|
||||||
|
const PII_KEYS = new Set([
|
||||||
|
'userId', 'user_id', 'patientId', 'patient_id',
|
||||||
|
'user_name', 'username', 'phone', 'mobile',
|
||||||
|
'id_card', 'id_number', 'email', 'address',
|
||||||
|
'openid', 'access_token', 'refresh_token',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function sanitizeProperties(
|
||||||
|
properties?: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
if (!properties) return undefined;
|
||||||
|
const cleaned: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
|
if (!PII_KEYS.has(key)) {
|
||||||
|
cleaned[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
interface AnalyticsEvent {
|
interface AnalyticsEvent {
|
||||||
event: EventName | string;
|
event: EventName | string;
|
||||||
properties?: Record<string, unknown>;
|
properties?: Record<string, unknown>;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
userId?: string;
|
|
||||||
patientId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'analytics_queue';
|
const STORAGE_KEY = 'analytics_queue';
|
||||||
@@ -51,7 +69,7 @@ export function trackEvent(event: EventName | string, properties?: Record<string
|
|||||||
loadQueue();
|
loadQueue();
|
||||||
const evt: AnalyticsEvent = {
|
const evt: AnalyticsEvent = {
|
||||||
event,
|
event,
|
||||||
properties,
|
properties: sanitizeProperties(properties),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
memoryQueue.push(evt);
|
memoryQueue.push(evt);
|
||||||
|
|||||||
Reference in New Issue
Block a user