improvement(media): 聊天媒体支持 file_id 兜底定位

- 图片/视频消息无 MD5 时,解析并下发 file_id,用于本地资源兜底定位与展示
- 后端 chat_media/open_folder 支持 md5/file_id;视频优先可 Range 的文件响应,并在需要时解密落盘
- 前端聊天页与 API 调用适配 file_id;补充媒体 URL 可用性判断
- 解密页补充“获取密钥”提示,支持手动输入/保存密钥;README 同步说明;更新音频图标资源
This commit is contained in:
2977094657
2025-12-23 16:39:38 +08:00
parent a4d652230f
commit 36f5067730
9 changed files with 790 additions and 126 deletions

View File

@@ -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 || '',

View File

@@ -128,6 +128,25 @@
</div>
</div>
<!-- 获取密钥说明 -->
<div class="mb-6">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="text-sm text-blue-800">
<div class="font-medium mb-2">获取密钥小提示</div>
<ul class="list-disc list-inside space-y-1">
<li><strong>AES 密钥</strong>仅在部分图片V4-V2解密时需要仅有 XOR 也可以先继续下一步失败原因会提示</li>
<li>获取 AES 需要微信正在运行部分环境需<strong>以管理员身份运行后端</strong>否则可能无法读取微信进程内存</li>
<li>若一直获取不到 AES完全退出微信 重新启动并登录 打开朋友圈图片并点开大图 2-3 回到本页点<strong>强制重新提取</strong></li>
</ul>
</div>
</div>
</div>
</div>
<!-- 密钥信息显示 -->
<div class="space-y-4 mb-6">
<div class="bg-gray-50 rounded-lg p-4">
@@ -172,6 +191,72 @@
</div>
</div>
<!-- 手动输入密钥自动获取失败时 -->
<div class="mb-6">
<details class="text-sm">
<summary class="cursor-pointer text-[#7F7F7F] hover:text-[#000000e6]">
<span class="ml-1">手动输入密钥自动获取失败时</span>
</summary>
<div class="mt-3 bg-gray-50 rounded-lg p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-[#000000e6] mb-2">XOR 密钥 <span class="text-red-500">*</span></label>
<input
v-model="manualKeys.xor_key"
type="text"
placeholder="例如0xA5 或 A5"
class="w-full px-4 py-2 border border-[#EDEDED] rounded-lg focus:ring-2 focus:ring-[#10AEEF] focus:border-transparent font-mono"
/>
<p v-if="manualKeyErrors.xor_key" class="text-xs text-red-500 mt-1">{{ manualKeyErrors.xor_key }}</p>
</div>
<div>
<label class="block text-sm font-medium text-[#000000e6] mb-2">AES 密钥可选</label>
<input
v-model="manualKeys.aes_key"
type="text"
placeholder="16 个字符V4-V2 需要)"
class="w-full px-4 py-2 border border-[#EDEDED] rounded-lg focus:ring-2 focus:ring-[#10AEEF] focus:border-transparent font-mono"
/>
<p v-if="manualKeyErrors.aes_key" class="text-xs text-red-500 mt-1">{{ manualKeyErrors.aes_key }}</p>
</div>
</div>
<div class="flex flex-wrap gap-3 mt-4">
<button
type="button"
@click="applyManualKeys({ save: false })"
class="inline-flex items-center px-4 py-2 bg-white text-[#10AEEF] border border-[#10AEEF] rounded-lg font-medium hover:bg-gray-50 transition-all duration-200"
>
使用手动密钥
</button>
<button
type="button"
@click="applyManualKeys({ save: true })"
:disabled="manualSaving"
class="inline-flex items-center px-4 py-2 bg-[#10AEEF] text-white rounded-lg font-medium hover:bg-[#0D9BD9] transition-all duration-200 disabled:opacity-50"
>
{{ manualSaving ? '保存中...' : '保存并使用' }}
</button>
<button
type="button"
@click="clearManualKeys"
class="inline-flex items-center px-4 py-2 bg-white text-[#7F7F7F] border border-[#EDEDED] rounded-lg font-medium hover:bg-gray-50 transition-all duration-200"
>
清空
</button>
</div>
<div class="text-xs text-[#7F7F7F] mt-3">
<p>说明</p>
<ul class="list-disc list-inside space-y-1 mt-1">
<li>XOR 1 字节十六进制00-FF</li>
<li>AES 仅在部分图片V4-V2解密时需要输入任意 16 个字符即可会自动截取前 16 </li>
</ul>
</div>
</div>
</details>
</div>
<!-- 操作按钮 -->
<div class="flex gap-3 justify-center pt-4 border-t border-[#EDEDED]">
<button
@@ -196,7 +281,6 @@
强制重新提取
</button>
<button
v-if="mediaKeys.xor_key"
@click="goToStep(2)"
class="inline-flex items-center px-6 py-3 bg-[#07C160] text-white rounded-lg font-medium hover:bg-[#06AD56] transition-all duration-200"
>
@@ -319,7 +403,7 @@
<p class="mb-2">可能的失败原因</p>
<ul class="list-disc list-inside space-y-1">
<li><strong>解密后非有效图片</strong>文件不是图片格式(如视频缩略图损坏)</li>
<li><strong>V4-V2版本需要AES密钥</strong>需要微信运行且部分环境需以管理员身份运行后端才能提取</li>
<li><strong>V4-V2版本需要AES密钥</strong>需要微信运行且部分环境需以管理员身份运行后端才能提取可尝试打开朋友圈图片并点开大图 2-3 次后再提取</li>
<li><strong>未知加密版本</strong>新版微信使用了不支持的加密方式</li>
<li><strong>文件为空</strong>原始文件损坏或为空文件</li>
</ul>
@@ -398,7 +482,7 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
const { decryptDatabase, getMediaKeys, decryptAllMedia } = useApi()
const { decryptDatabase, getMediaKeys, decryptAllMedia, saveMediaKeys } = useApi()
const loading = ref(false)
const error = ref('')
@@ -433,6 +517,81 @@ const mediaLoading = ref(false)
const copyMessage = ref('')
let copyMessageTimer = null
// 手动输入密钥(自动获取失败时使用)
const manualKeys = reactive({
xor_key: '',
aes_key: ''
})
const manualKeyErrors = reactive({
xor_key: '',
aes_key: ''
})
const manualSaving = ref(false)
const normalizeXorKey = (value) => {
const raw = String(value || '').trim()
if (!raw) return { ok: false, value: '', message: '请输入 XOR 密钥' }
const hex = raw.toLowerCase().replace(/^0x/, '')
if (!/^[0-9a-f]{1,2}$/.test(hex)) return { ok: false, value: '', message: 'XOR 密钥格式无效(如 0xA5 或 A5' }
const n = parseInt(hex, 16)
if (!Number.isFinite(n) || n < 0 || n > 255) return { ok: false, value: '', message: 'XOR 密钥必须在 0x00-0xFF 范围' }
return { ok: true, value: `0x${n.toString(16).toUpperCase().padStart(2, '0')}`, message: '' }
}
const normalizeAesKey = (value) => {
const raw = String(value || '').trim()
if (!raw) return { ok: true, value: '', message: '' }
if (raw.length < 16) return { ok: false, value: '', message: 'AES 密钥长度不足(至少 16 个字符)' }
return { ok: true, value: raw.slice(0, 16), message: '' }
}
const applyManualKeys = async (options = { save: false }) => {
manualKeyErrors.xor_key = ''
manualKeyErrors.aes_key = ''
error.value = ''
const xor = normalizeXorKey(manualKeys.xor_key)
if (!xor.ok) {
manualKeyErrors.xor_key = xor.message
return
}
const aes = normalizeAesKey(manualKeys.aes_key)
if (!aes.ok) {
manualKeyErrors.aes_key = aes.message
return
}
mediaKeys.xor_key = xor.value
mediaKeys.aes_key = aes.value
mediaKeys.message = options?.save ? '已保存并使用手动密钥' : '已使用手动密钥(仅本次)'
if (!options?.save) return
if (!aes.value) {
mediaKeys.message = '已使用手动密钥未保存AES 为空)'
return
}
try {
manualSaving.value = true
await saveMediaKeys({
xor_key: xor.value,
aes_key: aes.value
})
} catch (e) {
mediaKeys.message = '已使用手动密钥(保存失败,可继续解密)'
} finally {
manualSaving.value = false
}
}
const clearManualKeys = () => {
manualKeys.xor_key = ''
manualKeys.aes_key = ''
manualKeyErrors.xor_key = ''
manualKeyErrors.aes_key = ''
}
// 图片解密相关
const mediaDecryptResult = ref(null)
const mediaDecrypting = ref(false)