From 36e709d7a63b80ad877be4d89b60e7de38bfbe6c Mon Sep 17 00:00:00 2001 From: Nanin <7017924+nanin@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:17:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=83=A8=E5=88=86=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E8=A1=A8=E6=83=85=E5=AF=BC=E5=87=BA=E5=90=8E?= =?UTF-8?q?=E5=9B=BE=E8=A3=82=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 校验应用缓存、微信 Emoticon 缓存及 CDN 响应的真实图片格式,无效缓存自动回退下载。 增量导出会识别并替换旧归档中的无效表情资源;刷新失败时移除破图引用并保留明确错误。 补充 StickerService 单元测试和 HTML 媒体增量导出回归测试。 --- src/main/export-service.ts | 47 +++++++++-- src/main/sticker-service.ts | 36 ++++---- tests/integration/export-media-flow.test.ts | 80 +++++++++++++++++- tests/unit/sticker-service.test.ts | 93 +++++++++++++++++++++ 4 files changed, 230 insertions(+), 26 deletions(-) create mode 100644 tests/unit/sticker-service.test.ts diff --git a/src/main/export-service.ts b/src/main/export-service.ts index 36d7693..4af0bf6 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -584,6 +584,19 @@ const detectAssetExtension = (buffer: Buffer): string | null => { return 'webp' return null } +const htmlArchiveStickerResourceIsValid = async ( + outputDir: string, + value?: string +): Promise => { + const filePath = htmlArchiveResourcePath(outputDir, value) + if (!filePath) return false + try { + return detectAssetExtension(await fs.readFile(filePath)) !== null + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false + throw error + } +} async function readAvatarAsset( source: string ): Promise<{ extension: string; buffer: Buffer } | null> { @@ -928,7 +941,10 @@ async function runSingleExport( request.format === 'html' ? options.outputFolderName || safeFilePart(request.outputName) : `${safeFilePart(request.outputName)}_${exportStamp()}` - const root = options.outputRoot || request.outputDirectory || (await resolveDefaultExportRoot(outputFolder)) + const root = + options.outputRoot || + request.outputDirectory || + (await resolveDefaultExportRoot(outputFolder)) await fs.mkdir(root, { recursive: true }) const outputDir = join(root, outputFolder) const outputPath = @@ -1329,12 +1345,28 @@ async function runSingleExport( const reusableImageQuality = previous?.exportMediaQuality === 'original' || (request.preferOriginal === false && previous?.exportMediaQuality === 'thumbnail') + let reusableMediaExists = false + if (reusableMediaType && previous?.exportMediaUrl) { + reusableMediaExists = await resourceExists(previous.exportMediaUrl) + if ( + reusableMediaExists && + reusableMediaType === 'sticker' && + !(await htmlArchiveStickerResourceIsValid(outputDir, previous.exportMediaUrl)) + ) { + reusableMediaExists = false + resourceExistence.set(previous.exportMediaUrl, Promise.resolve(false)) + delete previous.exportMediaUrl + delete previous.exportMediaType + delete previous.exportMediaName + delete previous.exportMediaQuality + } + } if ( reusableMediaType && previous?.exportMediaUrl && (!previous.exportMediaType || previous.exportMediaType === reusableMediaType) && (reusableMediaType !== 'image' || reusableImageQuality) && - (await resourceExists(previous.exportMediaUrl)) + reusableMediaExists ) { message.exportMediaUrl = previous.exportMediaUrl message.exportMediaType = reusableMediaType @@ -1446,13 +1478,10 @@ async function runSingleExport( } else if (message.contentData.type === 'sticker' && stickerService) { const stickerSource = message.contentData.url || message.contentData.thumbUrl const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5) - const decoded = result.data - ? decodeDataUrl(result.data) - : stickerSource - ? await readAvatarAsset(stickerSource) - : null - if (decoded) { - const name = `sticker_${bufferHashPart(decoded.buffer)}.${decoded.extension}` + const decoded = result.data ? decodeDataUrl(result.data) : null + const stickerExtension = decoded ? detectAssetExtension(decoded.buffer) : null + if (decoded && stickerExtension) { + const name = `sticker_${bufferHashPart(decoded.buffer)}.${stickerExtension}` const mediaUrl = `media/${name}` if (!(await resourceExists(mediaUrl))) { await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) diff --git a/src/main/sticker-service.ts b/src/main/sticker-service.ts index b0207d5..5bff2e0 100644 --- a/src/main/sticker-service.ts +++ b/src/main/sticker-service.ts @@ -70,16 +70,18 @@ export class StickerService { private async readCached(cacheKey: string): Promise { const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg'] - const cacheDirs = [ - this.cacheDir, - this.legacyCacheDir - ] + const cacheDirs = [this.cacheDir, this.legacyCacheDir] for (const cacheDir of cacheDirs) { for (const ext of extensions) { const filePath = path.join(cacheDir, `${cacheKey}${ext}`) if (!fs.existsSync(filePath)) continue const buffer = await fs.readFile(filePath) - return this.toDataUrl(buffer, ext) + const detectedExtension = this.detectExtension(buffer) + if (!detectedExtension) { + console.warn(`[StickerService] ignored invalid local cache key=${cacheKey}`) + continue + } + return this.toDataUrl(buffer, detectedExtension) } } return null @@ -108,7 +110,11 @@ export class StickerService { const filePath = path.join(cacheRoot, month, 'Emoticon', prefix, md5) if (!fs.existsSync(filePath)) continue const buffer = await fs.readFile(filePath) - const ext = this.detectExtension(buffer) || '.gif' + const ext = this.detectExtension(buffer) + if (!ext) { + console.warn(`[StickerService] ignored invalid WeChat cache md5=${md5} month=${month}`) + continue + } return this.toDataUrl(buffer, ext) } @@ -169,7 +175,14 @@ export class StickerService { return } - const ext = this.detectExtension(buffer) || this.getExtFromUrl(url) || '.gif' + const ext = this.detectExtension(buffer) + if (!ext) { + console.warn( + `[StickerService] download returned invalid image md5=${cacheKey} host=${this.getUrlHost(url)}` + ) + resolve({ success: false, error: '表情包响应不是支持的图片格式' }) + return + } try { await fs.ensureDir(this.cacheDir) await fs.writeFile(path.join(this.cacheDir, `${cacheKey}${ext}`), buffer) @@ -208,15 +221,6 @@ export class StickerService { return null } - private getExtFromUrl(url: string): string | null { - try { - const ext = path.extname(new URL(url).pathname).toLowerCase() - return ['.gif', '.png', '.webp', '.jpg', '.jpeg'].includes(ext) ? ext : null - } catch { - return null - } - } - private getUrlHost(url: string): string { try { return new URL(url).hostname || 'unknown' diff --git a/tests/integration/export-media-flow.test.ts b/tests/integration/export-media-flow.test.ts index ced086b..b36cc1a 100644 --- a/tests/integration/export-media-flow.test.ts +++ b/tests/integration/export-media-flow.test.ts @@ -44,6 +44,9 @@ const state = vi.hoisted(() => ({ >, voiceLookups: [] as number[], voiceBatches: [] as number[][], + stickerLookups: [] as Array<{ cdnUrl?: string; md5?: string }>, + stickerData: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', + stickerError: undefined as string | undefined, videoLookups: [] as { createTime?: number byteLength?: number @@ -214,7 +217,16 @@ vi.mock('../../src/main/video-asset-service', () => ({ } })) vi.mock('../../src/main/sticker-service', () => ({ - StickerService: class {} + StickerService: class { + async resolveSticker( + cdnUrl?: string, + md5?: string + ): Promise<{ success: boolean; data?: string; error?: string }> { + state.stickerLookups.push({ cdnUrl, md5 }) + if (state.stickerError) return { success: false, error: state.stickerError } + return { success: true, data: state.stickerData } + } + } })) const message = (overrides: Partial): Message => ({ @@ -273,6 +285,8 @@ describe('media export flow', () => { state.groupSnapshots = {} state.voiceLookups = [] state.voiceBatches = [] + state.stickerLookups = [] + state.stickerError = undefined const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08') mkdirSync(fileMonth, { recursive: true }) writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容') @@ -720,6 +734,70 @@ describe('media export flow', () => { expect(existsSync(imagePath)).toBe(true) }) + it('replaces an invalid sticker file during an incremental export', async () => { + const { runExport } = await import('../../src/main/export-service') + const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } + const request = { + targets: [target('fixture-user', '表情修复会话')], + format: 'html' as const, + outputName: 'sticker-repair-fixture', + kinds: ['sticker'] as const, + includeMedia: true, + keepMissing: true + } + state.messages = [ + message({ + id: 'sticker-invalid-cache', + type: '表情包', + contentData: { + type: 'sticker', + md5: '4671f60b074db0a2cc8ace8281c8c0f0', + url: 'https://fixture.invalid/sticker.gif' + } + }) + ] + + const first = await runExport( + { ...request, jobId: 'sticker-repair-first', kinds: [...request.kinds] }, + win as never + ) + expect(first.success, first.error).toBe(true) + const firstSticker = readArchive(first.outputPath!).messages[0] + const stickerPath = join(dirname(first.outputPath!), firstSticker.exportMediaUrl!) + expect(readFileSync(stickerPath).subarray(0, 3).toString('ascii')).toBe('GIF') + + writeFileSync(stickerPath, Buffer.from('encrypted-wechat-emoticon-cache')) + state.stickerLookups = [] + const second = await runExport( + { ...request, jobId: 'sticker-repair-second', kinds: [...request.kinds] }, + win as never + ) + + expect(second.success, second.error).toBe(true) + expect(state.stickerLookups).toEqual([ + { + cdnUrl: 'https://fixture.invalid/sticker.gif', + md5: '4671f60b074db0a2cc8ace8281c8c0f0' + } + ]) + const repairedSticker = readArchive(second.outputPath!).messages[0] + expect(repairedSticker.exportMediaUrl).toBe(firstSticker.exportMediaUrl) + expect(readFileSync(stickerPath).subarray(0, 3).toString('ascii')).toBe('GIF') + + writeFileSync(stickerPath, Buffer.from('encrypted-wechat-emoticon-cache')) + state.stickerError = '表情链接已过期' + const third = await runExport( + { ...request, jobId: 'sticker-repair-third', kinds: [...request.kinds] }, + win as never + ) + + expect(third.success, third.error).toBe(true) + const unavailableSticker = readArchive(third.outputPath!).messages[0] + expect(unavailableSticker.exportMediaUrl).toBeUndefined() + expect(unavailableSticker.exportMediaError).toBe('表情链接已过期') + expect(existsSync(stickerPath)).toBe(false) + }) + it('rechecks a previous low-quality image and upgrades it when an original appears', async () => { const { runExport } = await import('../../src/main/export-service') const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } diff --git a/tests/unit/sticker-service.test.ts b/tests/unit/sticker-service.test.ts new file mode 100644 index 0000000..c32b358 --- /dev/null +++ b/tests/unit/sticker-service.test.ts @@ -0,0 +1,93 @@ +import crypto from 'crypto' +import fs from 'fs-extra' +import http from 'http' +import os from 'os' +import path from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { StickerService } from '../../src/main/sticker-service' +import type { Wcdb4Client } from '../../src/main/wcdb4-client' + +const validGif = Buffer.from( + '47494638396101000100800000000000ffffff21f90401000000002c00000000010001000002024401003b', + 'hex' +) + +describe('StickerService', () => { + let homeDir = '' + const servers: http.Server[] = [] + + beforeEach(() => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sticker-service-')) + vi.stubEnv('HOME', homeDir) + }) + + afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all( + servers + .splice(0) + .map( + (server) => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + ) + ) + fs.removeSync(homeDir) + }) + + const serve = async (body: Buffer): Promise<{ url: string; requests: () => number }> => { + let requestCount = 0 + const server = http.createServer((_request, response) => { + requestCount += 1 + response.writeHead(200) + response.end(body) + }) + servers.push(server) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('HTTP fixture did not bind a port') + return { + url: `http://127.0.0.1:${address.port}/sticker.gif`, + requests: () => requestCount + } + } + + it('ignores invalid app and WeChat caches and falls back to the CDN', async () => { + const md5 = crypto.createHash('md5').update(validGif).digest('hex') + const invalidCache = Buffer.from('encrypted-wechat-emoticon-cache') + const appCache = path.join(homeDir, 'Documents', 'TraceMemo', 'Emojis', `${md5}.gif`) + fs.ensureDirSync(path.dirname(appCache)) + fs.writeFileSync(appCache, invalidCache) + + const accountRoot = path.join(homeDir, 'account') + const wechatCache = path.join(accountRoot, 'cache', '2026-08', 'Emoticon', md5.slice(0, 2), md5) + fs.ensureDirSync(path.dirname(wechatCache)) + fs.writeFileSync(wechatCache, invalidCache) + + const fixture = await serve(validGif) + const client = { getAccountRoot: () => accountRoot } as unknown as Wcdb4Client + const result = await new StickerService(client).resolveSticker(fixture.url, md5) + + expect(result.success, result.error).toBe(true) + expect(result.data).toBe(`data:image/gif;base64,${validGif.toString('base64')}`) + expect(fixture.requests()).toBe(1) + expect(fs.readFileSync(appCache)).toEqual(validGif) + expect(fs.readFileSync(wechatCache)).toEqual(invalidCache) + }) + + it('rejects an HTTP 200 response whose body is not an image', async () => { + const md5 = 'a'.repeat(32) + const fixture = await serve(Buffer.from('expired sticker response')) + const result = await new StickerService().resolveSticker(fixture.url, md5) + + expect(result).toMatchObject({ + success: false, + error: '表情包响应不是支持的图片格式' + }) + expect(fixture.requests()).toBe(1) + expect( + fs.existsSync(path.join(homeDir, 'Documents', 'TraceMemo', 'Emojis', `${md5}.gif`)) + ).toBe(false) + }) +})