fix: 完善聊天解析与导出体验

- 修复引用消息名称和图片布局
- 明确单会话图片测试日志范围
- 支持导出文件附件
- 完善图片批测、会话刷新和安全退出
This commit is contained in:
Wxw-Gu
2026-08-04 12:03:07 +08:00
parent d76727875d
commit c70e49bf16
41 changed files with 2320 additions and 221 deletions
+32 -1
View File
@@ -1,6 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WechatDb } from '../../src/main/wechat-db'
import { listContactsAsync, setChatDb } from '../../src/main/services/chat-service'
import {
closeChatDbForQuit,
isReady,
listContactsAsync,
setChatDb
} from '../../src/main/services/chat-service'
describe('chat service contacts', () => {
afterEach(() => setChatDb(null))
@@ -36,4 +41,30 @@ describe('chat service contacts', () => {
})
expect(contacts[0]?.m_nsNickName).toBe('测试群聊')
})
it('detaches the database immediately and awaits native cleanup on quit', async () => {
let finishClose: ((value: boolean) => void) | undefined
const closeAsync = vi.fn(
() =>
new Promise<boolean>((resolve) => {
finishClose = resolve
})
)
const fakeDb = {
close: vi.fn(),
closeAsync
} as unknown as WechatDb
setChatDb(fakeDb)
const closing = closeChatDbForQuit()
expect(isReady()).toBe(false)
expect(closeAsync).toHaveBeenCalledOnce()
finishClose?.(true)
await expect(closing).resolves.toBe(true)
const lateDb = { close: vi.fn() } as unknown as WechatDb
expect(setChatDb(lateDb)).toBe(false)
expect(lateDb.close).toHaveBeenCalledOnce()
})
})
+9
View File
@@ -38,6 +38,14 @@ describe('export media', () => {
id: 'missing',
type: '语音',
exportMediaError: '语音文件缺失:本地未找到语音数据'
}),
baseMessage({
id: 'file',
type: '文件',
contentData: { type: 'share', typeVal: '6', title: '示例附件.zip', url: '' },
exportMediaType: 'file',
exportMediaName: '示例附件.zip',
exportMediaUrl: 'media/file_4_示例附件.zip'
})
])
@@ -46,6 +54,7 @@ describe('export media', () => {
)
expect(html).toContain('video class="media-image" controls src="media/video_2.mp4"')
expect(html).toContain('语音文件缺失:本地未找到语音数据')
expect(html).toContain('class="file-attachment" href="media/file_4_示例附件.zip" download')
expect(html).not.toMatch(/(?:src|href)="[A-Za-z]:\\/)
})
+30
View File
@@ -0,0 +1,30 @@
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { afterEach, describe, expect, it } from 'vitest'
import { FileAssetService } from '../../src/main/file-asset-service'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('file asset service', () => {
it('prefers the message month and supports duplicate download suffixes', () => {
const accountRoot = mkdtempSync(join(tmpdir(), 'wxe-file-asset-'))
roots.push(accountRoot)
const august = join(accountRoot, 'msg', 'file', '2026-08')
const july = join(accountRoot, 'msg', 'file', '2026-07')
mkdirSync(august, { recursive: true })
mkdirSync(july, { recursive: true })
writeFileSync(join(july, '产品说明.txt'), 'old')
writeFileSync(join(august, '产品说明(1).txt'), 'current')
const service = new FileAssetService({ getAccountRoot: () => accountRoot })
const result = service.resolve('产品说明.txt', new Date(2026, 7, 4).getTime() / 1000)
expect(result).toMatchObject({ success: true, fileName: '产品说明(1).txt' })
expect(result.filePath).toBe(realpathSync(join(august, '产品说明(1).txt')))
})
})
+80 -6
View File
@@ -16,6 +16,7 @@ import { ImageDecryptService } from '../../src/main/image-decrypt-service'
const aesKey = '0123456789abcdef'
const xorKey = 0x40
const originalResourcesPath = process.resourcesPath
function writeV2Dat(file: string): Buffer {
const aesPlain = Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
@@ -35,22 +36,95 @@ function writeV2Dat(file: string): Buffer {
}
describe('DAT image decryption', () => {
beforeAll(() => mkdirSync(root, { recursive: true }))
afterAll(() => rmSync(root, { recursive: true, force: true }))
beforeAll(() => {
mkdirSync(root, { recursive: true })
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: root })
})
afterAll(() => {
Object.defineProperty(process, 'resourcesPath', {
configurable: true,
value: originalResourcesPath
})
rmSync(root, { recursive: true, force: true })
})
it('decrypts a synthetic V2 AES/raw/XOR fixture', () => {
const file = join(root, 'fixture.dat')
const expected = writeV2Dat(file)
expect(new ImageDecryptService('0x40', aesKey).decryptImage(file)).toEqual(expected)
const service = new ImageDecryptService('0x40', aesKey)
expect(service.decryptImage(file)).toEqual(expected)
expect(service.decryptImageToBase64(file)).toMatch(/^data:image\/png;base64,/)
expect(service.getLastDecodeDiagnostic()).toMatchObject({
code: 'SUCCESS',
datVersion: 2,
imageFormat: 'PNG'
})
})
it('rejects the wrong AES key and unsupported legacy signatures accurately', () => {
const file = join(root, 'fixture.dat')
writeV2Dat(file)
expect(new ImageDecryptService('0x40', 'fedcba9876543210').decryptImage(file)).toBeNull()
const wrongKeyService = new ImageDecryptService('0x40', 'fedcba9876543210')
expect(wrongKeyService.decryptImage(file)).toBeNull()
expect(wrongKeyService.getLastDecodeDiagnostic()).toMatchObject({
code: 'AES_DECRYPT_FAILED',
datVersion: 2
})
const legacy = join(root, 'legacy.dat')
writeFileSync(legacy, Buffer.from([0xff, 0xd8, 0xff, 0x00]))
expect(new ImageDecryptService('0x40', aesKey).decryptImage(legacy)).toBeNull()
writeFileSync(legacy, Buffer.from([0x12, 0x34, 0x56, 0x78]))
const legacyService = new ImageDecryptService('0x40', aesKey)
expect(legacyService.decryptImage(legacy)).toBeNull()
expect(legacyService.getLastDecodeDiagnostic()).toMatchObject({
code: 'UNSUPPORTED_DAT_VERSION',
datVersion: 0
})
})
it('finds modern _M variants and reads plain images stored with a DAT extension', async () => {
const accountRoot = join(root, 'modern-account')
const sessionId = '77705c31c50e8a4242a9d527fe9433de'
const imageDirectory = join(accountRoot, 'msg', 'attach', sessionId, '2025-10', 'Img')
mkdirSync(imageDirectory, { recursive: true })
const imageBase = '9718e38ad90f57f9e833d17ff2373abd'
const mediumFile = join(imageDirectory, `${imageBase}_M.dat`)
writeFileSync(mediumFile, Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]))
const service = new ImageDecryptService('0x40', aesKey)
await expect(
service.findImageFileAsync(undefined, imageBase, {
allowThumbnail: false,
accountDir: accountRoot,
sessionId
})
).resolves.toBe(mediumFile)
expect(service.decryptImageToBase64(mediumFile)).toMatch(/^data:image\/png;base64,/)
expect(service.getLastDecodeDiagnostic()).toMatchObject({
code: 'DIRECT_IMAGE',
imageFormat: 'PNG'
})
await expect(service.decryptImageToBase64WithFallbackAsync(mediumFile, true)).resolves.toEqual(
expect.objectContaining({ filePath: mediumFile })
)
const thumbnailBase = '37a9000000000000000000000000ceaa'
const thumbnailFile = join(imageDirectory, `${thumbnailBase}_t_M.dat`)
writeFileSync(thumbnailFile, Buffer.from([0xff, 0xd8, 0xff, 0x00]))
expect(service.isThumbnailFile(thumbnailFile)).toBe(true)
await expect(
service.findImageFileAsync(undefined, `${thumbnailBase}_t_M.dat`, {
allowThumbnail: false,
accountDir: accountRoot,
sessionId
})
).resolves.toBeNull()
await expect(
service.findImageFileAsync(undefined, `${thumbnailBase}_t_M.dat`, {
allowThumbnail: true,
accountDir: accountRoot,
sessionId
})
).resolves.toBe(thumbnailFile)
})
})
@@ -0,0 +1,117 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
const root = mkdtempSync(join(tmpdir(), 'wxe-image-diagnostic-'))
vi.mock('electron', () => ({
app: {
getPath: () => root,
getVersion: () => '2.1.7-test'
}
}))
vi.mock('../../src/main/image-decrypt-service', () => ({
ImageDecryptService: class {},
inspectImageDecoderStatus: vi.fn()
}))
vi.mock('../../src/main/services/chat-service', () => ({}))
vi.mock('../../src/main/services/image-key-config-service', () => ({
validateImageKeyRequest: vi.fn()
}))
vi.mock('../../src/main/services/wechat-process-status', () => ({
isWechatRunning: vi.fn()
}))
import { buildImageTestDiagnosticLog } from '../../src/main/services/image-decryption-status-service'
describe('image decryption diagnostic log', () => {
beforeAll(() => mkdirSync(join(root, 'private-account', 'msg', 'attach'), { recursive: true }))
afterAll(() => rmSync(root, { recursive: true, force: true }))
it('keeps useful failure details without exposing keys or absolute paths', () => {
const resourceRoot = join(root, 'private-account')
const aesKey = '0123456789abcdef'
const imageMd5 = '1234567890abcdef1234567890abcdef'
const log = buildImageTestDiagnosticLog({
request: {
userMd5: 'conversation-secret',
resourceRoot,
xorKey: '0x40',
aesKey
},
result: {
success: false,
code: 'DECRYPT_FAILED',
error: '图片密钥与当前账号不匹配,或图片文件已损坏',
fileFound: true,
decrypted: false,
readable: false,
isThumbnail: false
},
startedAt: Date.now() - 25,
testedImage: {
md5: imageMd5,
datName: `${imageMd5}_h.dat`,
sessionId: 'wxid_private_session',
selection: '自动测试样本'
},
filePath: join(
resourceRoot,
'msg',
'attach',
imageMd5,
'2026-08',
'Img',
`${imageMd5}_h.dat`
),
decodeDiagnostic: {
code: 'AES_DECRYPT_FAILED',
detail: 'AES 解密校验失败,密钥可能与当前账号不匹配',
datVersion: 2,
fileSize: 2048
}
})
expect(log).toContain('AES_DECRYPT_FAILED')
expect(log).toContain('WeChat 4.0 V2')
expect(log).toContain('内容未记录')
expect(log).not.toContain(aesKey)
expect(log).not.toContain(resourceRoot)
expect(log).not.toContain(imageMd5)
expect(log).not.toContain('conversation-secret')
expect(log).not.toContain('wxid_private_session')
})
it('describes plain images with a DAT extension without reporting an unsupported protocol', () => {
const resourceRoot = join(root, 'private-account')
const filePath = join(resourceRoot, 'msg', 'attach', 'plain-image_M.dat')
writeFileSync(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]))
const log = buildImageTestDiagnosticLog({
request: {
userMd5: 'conversation-secret',
resourceRoot,
xorKey: '0x40',
aesKey: '0123456789abcdef'
},
result: {
success: true,
fileFound: true,
decrypted: true,
readable: true,
isThumbnail: false
},
startedAt: Date.now() - 10,
filePath,
decodeDiagnostic: {
code: 'DIRECT_IMAGE',
detail: 'DAT 文件内容是可直接读取的图片',
fileSize: 8,
imageFormat: 'PNG'
}
})
expect(log).toContain('DAT 协议:明文图片(无需 DAT 解密)')
expect(log).not.toContain('不受支持/旧版格式')
})
})
+13
View File
@@ -28,6 +28,19 @@ describe('message parser', () => {
}
})
it('uses the quoted group member id instead of the chatroom id', () => {
const parsed = parseMessageContent(
'<appmsg><type>57</type><title>回复内容</title><refermsg><type>1</type><fromusr>123456789@chatroom</fromusr><chatusr>wxid_fixture_member</chatusr><content>被引用内容</content></refermsg></appmsg>',
49
)
expect(parsed).toMatchObject({
type: 'quote',
quotedSender: 'wxid_fixture_member',
quotedContent: '被引用内容'
})
})
it('uses an explicit unknown type for unsupported messages', () => {
expect(parseMessageContent('opaque fixture payload', 999)).toEqual({
type: 'unknown',
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import type { Message } from '../../src/shared/types'
import { enrichQuotedMessages } from '../../src/renderer/src/utils/quoted-messages'
const message = (overrides: Partial<Message>): Message => ({
id: 'fixture',
from: 'user',
type: '普通文本',
datetime: '2026-08-04 12:00:00',
content: '',
isSender: false,
createTime: 1_785_816_000,
...overrides
})
describe('quoted message enrichment', () => {
it('maps an internal quoted sender id to the loaded group member name', () => {
const quoted = message({
id: 'quote',
contentData: {
type: 'quote',
content: '回复',
quotedContent: '[图片]',
quotedSender: 'wxid_fixture_member',
quotedImageMd5: 'a'.repeat(32)
}
})
const [result] = enrichQuotedMessages([quoted], [quoted], (senderId) =>
senderId === 'wxid_fixture_member' ? '测试群成员' : undefined
)
expect(result.contentData).toMatchObject({ quotedSender: '测试群成员' })
})
})
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest'
import { Wcdb4Client } from '../../src/main/wcdb4-client'
function setPrivate(target: object, key: string, value: unknown): void {
Reflect.set(target, key, value)
}
describe('Wcdb4Client shutdown', () => {
it('waits for tracked Koffi calls before shutting down the native runtime', async () => {
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
const shutdown = vi.fn(() => 0)
const inFlight = new Set<Promise<unknown>>()
let finishCall: (() => void) | undefined
const pending = new Promise<void>((resolve) => {
finishCall = resolve
})
inFlight.add(pending)
void pending.then(() => inFlight.delete(pending))
setPrivate(client, 'nativeCallsInFlight', inFlight)
setPrivate(client, 'handle', 1)
setPrivate(client, 'wcdbShutdown', shutdown)
setPrivate(client, 'monitorStarted', false)
setPrivate(client, 'displayNameCache', new Map())
setPrivate(client, 'avatarCache', new Map())
setPrivate(client, 'sessionStatusCache', new Map())
setPrivate(client, 'groupNicknameCache', new Map())
const closing = client.closeAsync(1_000)
expect(shutdown).not.toHaveBeenCalled()
finishCall?.()
await expect(closing).resolves.toBe(true)
if (process.platform === 'win32') {
expect(shutdown).not.toHaveBeenCalled()
} else {
expect(shutdown).toHaveBeenCalledOnce()
}
})
})