♻️ refactor: 移除外部二维码服务依赖,改用本地生成方案

- 将前端二维码生成从 `qrcode.show` 远程服务切换为 `qrcode-generator` 本地库(UMD 格式),消除对外部 CDN 渲染服务的依赖,降低隐私风险和第三方服务不可用时的故障点
- 重写 `generateQRCodeLocal` 函数适配 `qrcode-generator` API,使用 `qrcode(typeNumber, errorCorrectionLevel).addData().make().createDataURL()` 模式,并基于 QR 码模块数动态计算 `cellSize`,新增大尺寸预览图支持(400px)
- 移除所有页面中的 `qrcode.show` 域名引用,包括 CSP `connectSrc` 策略、preconnect 标签、resource-hints DNS 预解析配置及 Simyo/Giffgaff API 配置中的 `qrcode` 端点
- 完善二维码生成的日志与监控:在 CDN 加载成功后输出 `console.log`,本地/后端生成成功及失败时分别输出对应级别日志,并在 `trackQRCodeEvent` 中增加 `isBrowser` 守卫避免非浏览器环境报错
- 增加生成耗时和 QR 码长度(不含内容)到后端 BFF 日志,同时统一使用 `Date.now()` 计算请求耗时,避免 LPA 激活码等敏感信息进入日志
- 全面更新所有相关测试用例,适配新的 `window.qrcode` 工厂函数 mock,新增日志输出和 Sentry 上报断言,验证本地成功、本地失败降级、后端成功、后端失败四种场景的可观测性
This commit is contained in:
Abner
2026-06-23 21:22:16 +08:00
parent 1f001e36f1
commit 6379bbef90
14 changed files with 257 additions and 93 deletions

View File

@@ -115,7 +115,8 @@ describe('Giffgaff Session Restore - LPA Display', () => {
if (state.lpaString) {
const img = document.createElement('img');
img.src = `https://qrcode.show/${encodeURIComponent(state.lpaString)}`;
// 模拟本地 QR 码生成data URL
img.src = `data:image/png;base64,mock-qr-${encodeURIComponent(state.lpaString)}`;
img.alt = 'eSIM二维码';
qrcode.appendChild(img);
}
@@ -123,7 +124,7 @@ describe('Giffgaff Session Restore - LPA Display', () => {
// 验证二维码生成
expect(resultContainer.classList.contains('active')).toBe(true);
expect(qrcode.querySelector('img')).toBeTruthy();
expect(qrcode.querySelector('img').src).toContain('qrcode.show');
expect(qrcode.querySelector('img').src).toContain('data:image/png;base64');
});
test('session 恢复应该处理不完整状态(无 SSN', () => {

View File

@@ -3,30 +3,48 @@
*/
describe('qrcode-generator', () => {
/** 创建本地生成失败的 qrcode-generator mock */
function createFailingQRMock() {
return jest.fn(() => ({
addData: jest.fn(),
make: jest.fn(() => { throw new Error('local failed'); }),
getModuleCount: jest.fn(() => 25),
createDataURL: jest.fn()
}));
}
beforeEach(() => {
jest.resetModules();
fetch.mockReset();
delete window.QRCode;
delete window.qrcode;
});
it('应该使用浏览器本地 qrcode.js 生成 img 容器', async () => {
window.QRCode = {
toDataURL: jest.fn((data, options) => Promise.resolve(`data:image/png;base64,local-${options.width}`))
// qrcode-generator 包的 mockwindow.qrcode 是一个工厂函数
const mockQRInstance = {
addData: jest.fn(),
make: jest.fn(),
getModuleCount: jest.fn(() => 25), // 25x25 模块的 QR 码
createDataURL: jest.fn((cellSize, margin) => `data:image/png;base64,local-${cellSize}`)
};
window.qrcode = jest.fn(() => mockQRInstance);
const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js');
const result = await generateQRCodeLocal('LPA:1$example', 300);
expect(result.source).toBe('local');
expect(result.container.className).toBe('qrcode-container');
expect(result.container.querySelector('img').getAttribute('src')).toBe('data:image/png;base64,local-300');
expect(window.QRCode.toDataURL).toHaveBeenCalledTimes(2);
expect(result.container.querySelector('img').getAttribute('src')).toContain('data:image/png;base64,local-');
expect(window.qrcode).toHaveBeenCalledWith(0, 'M');
expect(mockQRInstance.addData).toHaveBeenCalledWith('LPA:1$example');
expect(mockQRInstance.make).toHaveBeenCalled();
expect(mockQRInstance.createDataURL).toHaveBeenCalledTimes(2); // 正常尺寸 + 大尺寸
});
it('本地生成失败时应该调用后端 BFF 降级', async () => {
window.QRCode = {
toDataURL: jest.fn(() => Promise.reject(new Error('local failed')))
};
window.qrcode = createFailingQRMock();
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, qrcode: 'data:image/png;base64,backend' })
@@ -43,9 +61,8 @@ describe('qrcode-generator', () => {
});
it('本地和后端都失败时应该抛出最终错误', async () => {
window.QRCode = {
toDataURL: jest.fn(() => Promise.reject(new Error('local failed')))
};
window.qrcode = createFailingQRMock();
fetch.mockResolvedValueOnce({
ok: false,
status: 500
@@ -58,20 +75,18 @@ describe('qrcode-generator', () => {
});
it('应该拒绝过长的二维码内容', async () => {
window.QRCode = {
toDataURL: jest.fn()
};
// 模拟 qrcode-generator 已加载
window.qrcode = jest.fn();
const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js');
await expect(generateQRCodeLocal('x'.repeat(2049), 300))
.rejects.toThrow('Local QR code generation failed');
.rejects.toThrow('QR code data length must be between');
});
it('应该拒绝非整数的 size', async () => {
window.QRCode = {
toDataURL: jest.fn()
};
// 模拟 qrcode-generator 已加载
window.qrcode = jest.fn();
const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js');
@@ -80,9 +95,8 @@ describe('qrcode-generator', () => {
});
it('应该拒绝低于最小值的 size', async () => {
window.QRCode = {
toDataURL: jest.fn()
};
// 模拟 qrcode-generator 已加载
window.qrcode = jest.fn();
const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js');
@@ -91,9 +105,8 @@ describe('qrcode-generator', () => {
});
it('应该拒绝高于最大值的 size', async () => {
window.QRCode = {
toDataURL: jest.fn()
};
// 模拟 qrcode-generator 已加载
window.qrcode = jest.fn();
const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js');
@@ -102,9 +115,8 @@ describe('qrcode-generator', () => {
});
it('应该拒绝非字符串的 data', async () => {
window.QRCode = {
toDataURL: jest.fn()
};
// 模拟 qrcode-generator 已加载
window.qrcode = jest.fn();
const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js');
@@ -113,9 +125,7 @@ describe('qrcode-generator', () => {
});
it('后端超时时应该抛出超时错误', async () => {
window.QRCode = {
toDataURL: jest.fn(() => Promise.reject(new Error('local failed')))
};
window.qrcode = createFailingQRMock();
// Mock fetch 超时AbortError
fetch.mockImplementationOnce(() =>
@@ -136,9 +146,7 @@ describe('qrcode-generator', () => {
});
it('后端返回无效 JSON 时应该抛出错误', async () => {
window.QRCode = {
toDataURL: jest.fn(() => Promise.reject(new Error('local failed')))
};
window.qrcode = createFailingQRMock();
fetch.mockResolvedValueOnce({
ok: true,
@@ -150,4 +158,144 @@ describe('qrcode-generator', () => {
await expect(generateQRCodeWithFallback('LPA:1$example', 300))
.rejects.toThrow('QR code generation failed after local and backend fallback');
});
// ========== 日志和 Sentry 上报测试 ==========
it('本地生成成功时应输出 console.log', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
const mockQRInstance = {
addData: jest.fn(),
make: jest.fn(),
getModuleCount: jest.fn(() => 25),
createDataURL: jest.fn((cellSize, margin) => `data:image/png;base64,local-${cellSize}`)
};
window.qrcode = jest.fn(() => mockQRInstance);
const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js');
await generateQRCodeLocal('LPA:1$example', 300);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('[QRCode] Local generation success')
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('size=300')
);
consoleSpy.mockRestore();
});
it('本地生成失败时应输出 console.warn 并上报 Sentry', async () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
window.Sentry = { captureMessage: jest.fn() };
window.qrcode = createFailingQRMock();
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, qrcode: 'data:image/png;base64,backend' })
});
const { generateQRCodeWithFallback } = await import('../../src/js/modules/qrcode-generator.js');
await generateQRCodeWithFallback('LPA:1$example', 300);
// 验证 console.warn 输出降级日志(第一个参数包含关键信息)
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('[QRCode] Local generation failed'),
expect.anything()
);
// 验证 Sentry captureMessage 被调用
expect(window.Sentry.captureMessage).toHaveBeenCalledWith(
'QR Code qr_fallback failed',
expect.objectContaining({
level: 'warning',
tags: expect.objectContaining({
module: 'qrcode-generation',
source: 'local',
type: 'qr_fallback'
})
})
);
delete window.Sentry;
consoleSpy.mockRestore();
});
it('后端生成成功时应输出 console.log', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
window.qrcode = createFailingQRMock();
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, qrcode: 'data:image/png;base64,backend-qr-data' })
});
const { generateQRCodeWithFallback } = await import('../../src/js/modules/qrcode-generator.js');
await generateQRCodeWithFallback('LPA:1$example', 300);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('[QRCode] Backend generation success')
);
consoleSpy.mockRestore();
});
it('后端生成失败时应输出 console.error 并上报 Sentry', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
window.Sentry = { captureMessage: jest.fn() };
window.qrcode = createFailingQRMock();
fetch.mockResolvedValueOnce({
ok: false,
status: 500
});
const { generateQRCodeWithFallback } = await import('../../src/js/modules/qrcode-generator.js');
await expect(generateQRCodeWithFallback('LPA:1$example', 300))
.rejects.toThrow('QR code generation failed after local and backend fallback');
// 验证 console.error 输出后端失败日志(第一个参数包含关键信息)
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('[QRCode] Backend fallback failed'),
expect.anything()
);
// 验证 Sentry 上报了后端失败事件
expect(window.Sentry.captureMessage).toHaveBeenCalledWith(
'QR Code qr_generation failed',
expect.objectContaining({
level: 'warning',
tags: expect.objectContaining({
source: 'failed'
})
})
);
delete window.Sentry;
consoleErrorSpy.mockRestore();
consoleWarnSpy.mockRestore();
});
it('CDN 加载成功时应输出 console.log', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
const mockQRInstance = {
addData: jest.fn(),
make: jest.fn(),
getModuleCount: jest.fn(() => 25),
createDataURL: jest.fn((cellSize, margin) => `data:image/png;base64,local-${cellSize}`)
};
window.qrcode = jest.fn(() => mockQRInstance);
const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js');
await generateQRCodeLocal('LPA:1$example', 300);
// 如果 window.qrcode 已存在CDN 不会被加载,所以检查是否有 CDN 或 Local 日志
const allCalls = consoleSpy.mock.calls.flat().join(' ');
expect(allCalls).toMatch(/\[QRCode\].*(CDN loaded|Local generation success)/);
consoleSpy.mockRestore();
});
});

View File

@@ -808,9 +808,9 @@
const lpaString = tokenResponse.data.eSimDownloadToken.lpaString;
testFramework.assertTrue(lpaString.startsWith('LPA:'), 'LPA string should start with LPA:');
// 模拟二维码生成
const qrUrl = `https://qrcode.show/${encodeURIComponent(lpaString)}?size=300`;
testFramework.assertTrue(qrUrl.includes('qrcode.show'), 'Should generate QR code URL');
// 模拟本地二维码生成data URL
const qrUrl = `data:image/png;base64,mock-qr-${encodeURIComponent(lpaString)}`;
testFramework.assertTrue(qrUrl.startsWith('data:image/png;base64'), 'Should generate QR code data URL');
return true;
});

View File

@@ -684,9 +684,9 @@
testFramework.assertTrue(lpaString.startsWith('LPA:'), 'Should generate valid LPA string');
// 模拟二维码URL生成
const qrUrl = `https://qrcode.show/${encodeURIComponent(lpaString)}?size=300`;
testFramework.assertTrue(qrUrl.includes('qrcode.show'), 'Should generate QR code URL');
// 模拟本地二维码生成data URL
const qrUrl = `data:image/png;base64,mock-qr-${encodeURIComponent(lpaString)}`;
testFramework.assertTrue(qrUrl.startsWith('data:image/png;base64'), 'Should generate QR code data URL');
await testFramework.sleep(500);
return true;