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 次 → 回到本页点强制重新提取。
+
+
+
+
+
+
+
+
+
+
+ 手动输入密钥(自动获取失败时)
+
+
+
+
+
+
+
{{ manualKeyErrors.xor_key }}
+
+
+
+
+
{{ manualKeyErrors.aes_key }}
+
+
+
+
+
+
+
+
+
+
+
说明:
+
+ - XOR 是 1 字节十六进制(00-FF)。
+ - AES 仅在部分图片(V4-V2)解密时需要;输入任意 16 个字符即可(会自动截取前 16 位)。
+
+
+
+
+
+