From 36f50677309d99899fb06e3da593b1cfaa844bc4 Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Tue, 23 Dec 2025 16:39:38 +0800 Subject: [PATCH] =?UTF-8?q?improvement(media):=20=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E6=94=AF=E6=8C=81=20file=5Fid=20=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 图片/视频消息无 MD5 时,解析并下发 file_id,用于本地资源兜底定位与展示 - 后端 chat_media/open_folder 支持 md5/file_id;视频优先可 Range 的文件响应,并在需要时解密落盘 - 前端聊天页与 API 调用适配 file_id;补充媒体 URL 可用性判断 - 解密页补充“获取密钥”提示,支持手动输入/保存密钥;README 同步说明;更新音频图标资源 --- README.md | 3 + frontend/composables/useApi.js | 3 +- frontend/pages/chat/[[username]].vue | 57 +++- frontend/pages/decrypt.vue | 165 ++++++++++- .../images/wechat/wechat-audio-light.png | Bin 4491 -> 739 bytes src/wechat_decrypt_tool/media_helpers.py | 277 +++++++++++++++++- src/wechat_decrypt_tool/routers/chat.py | 148 ++++++++-- src/wechat_decrypt_tool/routers/chat_media.py | 261 ++++++++++++----- src/wechat_decrypt_tool/routers/media.py | 2 +- 9 files changed, 790 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 9fec6cc..9857595 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ - **Web界面**: 提供现代化的Web操作界面 - **聊天记录查看**: 支持查看解密后的聊天记录(基础功能) - **图片资源解密**: 支持批量解密微信图片(.dat文件),按MD5哈希存储便于快速访问 +- **聊天图片展示**: 支持部分版本图片消息无MD5时通过 file_id 兜底定位本地资源 ### 开发计划 @@ -183,6 +184,8 @@ curl http://localhost:8000/api/media/keys curl "http://localhost:8000/api/media/keys?force_extract=true" ``` +> 提示:部分版本的 AES 密钥可能需要微信触发过图片加载/解密后才会出现在进程内存中。可尝试:完全退出微信 → 重新启动并登录 → 打开朋友圈图片并点开大图 2-3 次 → 立刻回到工具获取密钥。 + 返回示例: ```json { diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js index b8df076..d6815ae 100644 --- a/frontend/composables/useApi.js +++ b/frontend/composables/useApi.js @@ -90,6 +90,7 @@ export const useApi = () => { if (params && params.username) query.set('username', params.username) if (params && params.kind) query.set('kind', params.kind) if (params && params.md5) query.set('md5', params.md5) + if (params && params.file_id) query.set('file_id', params.file_id) if (params && params.server_id != null) query.set('server_id', String(params.server_id)) const url = '/chat/media/open_folder' + (query.toString() ? `?${query.toString()}` : '') return await request(url, { method: 'POST' }) @@ -152,4 +153,4 @@ export const useApi = () => { saveMediaKeys, decryptAllMedia } -} \ No newline at end of file +} diff --git a/frontend/pages/chat/[[username]].vue b/frontend/pages/chat/[[username]].vue index 741547f..145e29a 100644 --- a/frontend/pages/chat/[[username]].vue +++ b/frontend/pages/chat/[[username]].vue @@ -591,14 +591,14 @@ const openMediaContextMenu = (e, message, kind) => { } else if (kind === 'file') { disabled = !message?.fileMd5 } else if (kind === 'image') { - disabled = !message?.imageMd5 + disabled = !(message?.imageMd5 || message?.imageFileId) } else if (kind === 'emoji') { disabled = !message?.emojiMd5 } else if (kind === 'video') { - if (message?.videoMd5) { + if (message?.videoMd5 || message?.videoFileId) { disabled = false actualKind = 'video' - } else if (message?.videoThumbMd5) { + } else if (message?.videoThumbMd5 || message?.videoThumbFileId) { disabled = false actualKind = 'video_thumb' } else { @@ -637,13 +637,16 @@ const onOpenFolderClick = async () => { } else if (kind === 'file') { params.md5 = m.fileMd5 } else if (kind === 'image') { - params.md5 = m.imageMd5 + if (m.imageMd5) params.md5 = m.imageMd5 + else if (m.imageFileId) params.file_id = m.imageFileId } else if (kind === 'emoji') { params.md5 = m.emojiMd5 } else if (kind === 'video') { params.md5 = m.videoMd5 + if (m.videoFileId) params.file_id = m.videoFileId } else if (kind === 'video_thumb') { params.md5 = m.videoThumbMd5 + if (m.videoThumbFileId) params.file_id = m.videoThumbFileId } await api.openChatMediaFolder(params) @@ -1054,11 +1057,47 @@ const normalizeMessage = (msg) => { const fallbackAvatar = (!isSent && !selectedContact.value?.isGroup) ? (selectedContact.value?.avatar || null) : null const mediaBase = process.client ? 'http://localhost:8000' : '' + const normalizeMaybeUrl = (u) => (typeof u === 'string' ? u.trim() : '') + const isUsableMediaUrl = (u) => { + const v = normalizeMaybeUrl(u) + if (!v) return false + return ( + /^https?:\/\//i.test(v) + || /^blob:/i.test(v) + || /^data:/i.test(v) + || /^\/api\/chat\/media\//i.test(v) + ) + } + const localEmojiUrl = msg.emojiMd5 ? `${mediaBase}/api/chat/media/emoji?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.emojiMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : '' - const normalizedImageUrl = msg.imageUrl || (msg.imageMd5 ? `${mediaBase}/api/chat/media/image?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.imageMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : '') + const localImageByMd5 = msg.imageMd5 ? `${mediaBase}/api/chat/media/image?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.imageMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : '' + const localImageByFileId = msg.imageFileId ? `${mediaBase}/api/chat/media/image?account=${encodeURIComponent(selectedAccount.value || '')}&file_id=${encodeURIComponent(msg.imageFileId)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : '' + const normalizedImageUrl = msg.imageUrl || localImageByMd5 || localImageByFileId || '' const normalizedEmojiUrl = msg.emojiUrl || localEmojiUrl - const normalizedVideoThumbUrl = msg.videoThumbUrl || (msg.videoThumbMd5 ? `${mediaBase}/api/chat/media/video_thumb?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.videoThumbMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : '') - const normalizedVideoUrl = msg.videoUrl || (msg.videoMd5 ? `${mediaBase}/api/chat/media/video?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.videoMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : '') + const localVideoThumbUrl = (() => { + if (!msg.videoThumbMd5 && !msg.videoThumbFileId) return '' + const parts = [ + `account=${encodeURIComponent(selectedAccount.value || '')}`, + msg.videoThumbMd5 ? `md5=${encodeURIComponent(msg.videoThumbMd5)}` : '', + msg.videoThumbFileId ? `file_id=${encodeURIComponent(msg.videoThumbFileId)}` : '', + `username=${encodeURIComponent(selectedContact.value?.username || '')}`, + ].filter(Boolean) + return `${mediaBase}/api/chat/media/video_thumb?${parts.join('&')}` + })() + + const localVideoUrl = (() => { + if (!msg.videoMd5 && !msg.videoFileId) return '' + const parts = [ + `account=${encodeURIComponent(selectedAccount.value || '')}`, + msg.videoMd5 ? `md5=${encodeURIComponent(msg.videoMd5)}` : '', + msg.videoFileId ? `file_id=${encodeURIComponent(msg.videoFileId)}` : '', + `username=${encodeURIComponent(selectedContact.value?.username || '')}`, + ].filter(Boolean) + return `${mediaBase}/api/chat/media/video?${parts.join('&')}` + })() + + const normalizedVideoThumbUrl = (isUsableMediaUrl(msg.videoThumbUrl) ? normalizeMaybeUrl(msg.videoThumbUrl) : '') || localVideoThumbUrl + const normalizedVideoUrl = (isUsableMediaUrl(msg.videoUrl) ? normalizeMaybeUrl(msg.videoUrl) : '') || localVideoUrl const normalizedVoiceUrl = msg.voiceUrl || (msg.serverId ? `${mediaBase}/api/chat/media/voice?account=${encodeURIComponent(selectedAccount.value || '')}&server_id=${encodeURIComponent(String(msg.serverId))}` : '') const remoteFromServer = ( @@ -1110,9 +1149,11 @@ const normalizeMessage = (msg) => { isSent, type: 'text', renderType: msg.renderType || 'text', + voipType: msg.voipType || '', title: msg.title || '', url: msg.url || '', imageMd5: msg.imageMd5 || '', + imageFileId: msg.imageFileId || '', emojiMd5: msg.emojiMd5 || '', emojiUrl: normalizedEmojiUrl || '', emojiLocalUrl: localEmojiUrl || '', @@ -1122,6 +1163,8 @@ const normalizeMessage = (msg) => { imageUrl: normalizedImageUrl || '', videoMd5: msg.videoMd5 || '', videoThumbMd5: msg.videoThumbMd5 || '', + videoFileId: msg.videoFileId || '', + videoThumbFileId: msg.videoThumbFileId || '', videoThumbUrl: normalizedVideoThumbUrl || '', videoUrl: normalizedVideoUrl || '', quoteTitle: msg.quoteTitle || '', diff --git a/frontend/pages/decrypt.vue b/frontend/pages/decrypt.vue index 1a370db..7210cbb 100644 --- a/frontend/pages/decrypt.vue +++ b/frontend/pages/decrypt.vue @@ -128,6 +128,25 @@ + +
+
+
+ + + +
+
获取密钥小提示
+
    +
  • AES 密钥仅在部分图片(V4-V2)解密时需要;仅有 XOR 也可以先继续下一步,失败原因会提示。
  • +
  • 获取 AES 需要微信正在运行;部分环境需以管理员身份运行后端(否则可能无法读取微信进程内存)。
  • +
  • 若一直获取不到 AES:完全退出微信 → 重新启动并登录 → 打开朋友圈图片并点开大图 2-3 次 → 回到本页点强制重新提取
  • +
+
+
+
+
+
@@ -172,6 +191,72 @@
+ +
+
+ + 手动输入密钥(自动获取失败时) + +
+
+
+ + +

{{ manualKeyErrors.xor_key }}

+
+
+ + +

{{ manualKeyErrors.aes_key }}

+
+
+ +
+ + + +
+ +
+

说明:

+
    +
  • XOR 是 1 字节十六进制(00-FF)。
  • +
  • AES 仅在部分图片(V4-V2)解密时需要;输入任意 16 个字符即可(会自动截取前 16 位)。
  • +
+
+
+
+
+