mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
16 Commits
@@ -1440,39 +1440,12 @@
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-800 mb-2">消息类型(导出内容)</div>
|
||||
<div class="mt-2 p-3 bg-gray-50 rounded-md border border-gray-200">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50"
|
||||
@click="exportMessageTypes = exportMessageTypeOptions.map((x) => x.value)"
|
||||
<div class="grid grid-cols-2 gap-x-2 gap-y-2 text-[13px] text-gray-700 md:grid-cols-[repeat(13,max-content)] md:justify-between md:gap-x-3 md:gap-y-0">
|
||||
<label
|
||||
v-for="opt in exportMessageTypeOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-center gap-1.5 whitespace-nowrap md:flex-shrink-0"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50"
|
||||
@click="exportMessageTypes = ['voice']"
|
||||
>
|
||||
只语音
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50"
|
||||
@click="exportMessageTypes = ['transfer']"
|
||||
>
|
||||
只转账
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50"
|
||||
@click="exportMessageTypes = ['redPacket']"
|
||||
>
|
||||
只红包
|
||||
</button>
|
||||
<div class="ml-auto text-xs text-gray-500">已选 {{ exportMessageTypes.length }} 项</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 md:grid-cols-4 gap-2 text-sm text-gray-700">
|
||||
<label v-for="opt in exportMessageTypeOptions" :key="opt.value" class="flex items-center gap-2">
|
||||
<input type="checkbox" :value="opt.value" v-model="exportMessageTypes" />
|
||||
<span>{{ opt.label }}</span>
|
||||
</label>
|
||||
@@ -1512,7 +1485,7 @@
|
||||
v-if="exportFolder"
|
||||
type="button"
|
||||
class="text-sm px-3 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50"
|
||||
@click="exportFolder = ''; exportFolderHandle = null; exportSaveMsg = ''"
|
||||
@click="clearExportFolderSelection"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
@@ -1563,34 +1536,32 @@
|
||||
<div>消息:{{ exportJob.progress?.messagesExported || 0 }};媒体:{{ exportJob.progress?.mediaCopied || 0 }};缺失:{{ exportJob.progress?.mediaMissing || 0 }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
v-if="exportJob.status === 'done' && hasWebExportFolder"
|
||||
class="text-sm px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650] disabled:opacity-60"
|
||||
type="button"
|
||||
:disabled="exportSaveBusy"
|
||||
@click="saveExportToSelectedFolder"
|
||||
>
|
||||
{{ exportSaveBusy ? '保存中...' : '保存到已选目录' }}
|
||||
</button>
|
||||
<div v-if="exportJob.status === 'done'" class="mt-3 rounded-md border border-gray-200 bg-white/80 px-3 py-2 text-xs text-gray-700 space-y-2">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900">实际生成位置:</span>
|
||||
<div class="mt-1 break-all">{{ exportBackendZipPath || '未生成' }}</div>
|
||||
</div>
|
||||
<div v-if="hasWebExportFolder">
|
||||
<span class="font-medium text-gray-900">浏览器目录:</span>
|
||||
<div class="mt-1 break-all">{{ exportFolder || '未选择' }}</div>
|
||||
</div>
|
||||
<div v-if="exportSaveState === 'saving'" class="text-sky-600 whitespace-pre-wrap">{{ exportSaveProgressText }}</div>
|
||||
<div v-else-if="exportSaveMsg" class="text-green-600 whitespace-pre-wrap">{{ exportSaveMsg }}</div>
|
||||
<div v-else-if="exportSaveError" class="text-red-600 whitespace-pre-wrap">{{ exportSaveError }}</div>
|
||||
<div v-if="hasWebExportFolder" class="text-gray-500">
|
||||
浏览器模式通常会在写入完成后才显示文件,且出于安全限制,这里只能显示目录名,不能显示完整磁盘路径。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="exportJob.status === 'done' && !hasWebExportFolder" class="mt-3 flex items-center gap-2">
|
||||
<a
|
||||
v-if="exportJob.status === 'done' && !hasWebExportFolder"
|
||||
class="text-sm px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650]"
|
||||
:href="getExportDownloadUrl(exportJob.exportId)"
|
||||
target="_blank"
|
||||
>
|
||||
下载 ZIP
|
||||
</a>
|
||||
<button
|
||||
v-if="exportJob.status === 'running'"
|
||||
class="text-sm px-3 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50"
|
||||
type="button"
|
||||
@click="cancelCurrentExport"
|
||||
>
|
||||
取消任务
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="exportSaveMsg" class="mt-2 text-xs text-green-600 whitespace-pre-wrap">{{ exportSaveMsg }}</div>
|
||||
|
||||
<div v-if="exportJob.status === 'error'" class="mt-2 text-sm text-red-600 whitespace-pre-wrap">
|
||||
{{ exportJob.error || '导出失败' }}
|
||||
@@ -1603,6 +1574,7 @@
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
v-if="!(exportJob && (exportJob.status === 'queued' || exportJob.status === 'running'))"
|
||||
class="text-sm px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650] disabled:opacity-60"
|
||||
type="button"
|
||||
@click="startChatExport"
|
||||
@@ -1610,6 +1582,15 @@
|
||||
>
|
||||
{{ isExportCreating ? '创建中...' : '开始导出' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="text-sm px-3 py-2 rounded-md bg-white border border-red-200 text-red-600 hover:bg-red-50 disabled:opacity-60"
|
||||
type="button"
|
||||
@click="cancelCurrentExport"
|
||||
:disabled="exportCancelRequested"
|
||||
>
|
||||
{{ exportCancelRequested ? '取消中...' : '取消任务' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,8 +30,17 @@
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'image'"
|
||||
class="max-w-sm">
|
||||
<div class="msg-radius overflow-hidden cursor-pointer" :class="message.isSent ? '' : ''" @click="message.imageUrl && openImagePreview(message.imageUrl)" @contextmenu="openMediaContextMenu($event, message, 'image')">
|
||||
<img v-if="message.imageUrl" :src="message.imageUrl" alt="图片" class="max-w-[240px] max-h-[240px] object-cover hover:opacity-90 transition-opacity">
|
||||
<div class="msg-radius overflow-hidden cursor-pointer" :class="message.isSent ? '' : ''" @click="message.imageUrl && openImagePreview(message.imageUrl)" @contextmenu="openMediaContextMenu($event, message, 'image')">
|
||||
<img
|
||||
v-if="message.imageUrl"
|
||||
v-chat-lazy-src="message.imageUrl"
|
||||
alt="图片"
|
||||
class="block min-w-[96px] min-h-[96px] max-w-[240px] max-h-[240px] object-cover bg-gray-100 hover:opacity-90 transition-opacity"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
v-chat-media-perf="{ kind: 'message-image', meta: { conversation: selectedContact?.username || '', messageId: message.id, serverId: message.serverIdStr || '', imageMd5: message.imageMd5 || '', imageFileId: message.imageFileId || '' } }"
|
||||
>
|
||||
<div v-else class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
|
||||
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
|
||||
{{ message.content }}
|
||||
@@ -40,7 +49,16 @@
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'video'" class="max-w-sm">
|
||||
<div class="msg-radius overflow-hidden relative bg-black/5" @contextmenu="openMediaContextMenu($event, message, 'video')">
|
||||
<img v-if="message.videoThumbUrl" :src="message.videoThumbUrl" alt="视频" class="block w-[220px] max-w-[260px] h-auto max-h-[260px] object-cover">
|
||||
<img
|
||||
v-if="message.videoThumbUrl"
|
||||
v-chat-lazy-src="message.videoThumbUrl"
|
||||
alt="视频"
|
||||
class="block w-[220px] min-h-[120px] max-w-[260px] h-auto max-h-[260px] object-cover bg-gray-100"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
v-chat-media-perf="{ kind: 'message-video-thumb', meta: { conversation: selectedContact?.username || '', messageId: message.id, serverId: message.serverIdStr || '', videoThumbMd5: message.videoThumbMd5 || '', videoThumbFileId: message.videoThumbFileId || '' } }"
|
||||
>
|
||||
<div v-else class="px-3 py-2 text-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
|
||||
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
|
||||
{{ message.content }}
|
||||
@@ -100,7 +118,15 @@
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'emoji'" class="max-w-sm flex items-center group" :class="message.isSent ? 'flex-row-reverse' : ''">
|
||||
<template v-if="message.emojiUrl">
|
||||
<img :src="message.emojiUrl" alt="表情" class="w-24 h-24 object-contain" @contextmenu="openMediaContextMenu($event, message, 'emoji')">
|
||||
<img
|
||||
v-chat-lazy-src="message.emojiUrl"
|
||||
alt="表情"
|
||||
class="w-24 h-24 object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
@contextmenu="openMediaContextMenu($event, message, 'emoji')"
|
||||
>
|
||||
<button
|
||||
v-if="shouldShowEmojiDownload(message)"
|
||||
class="text-xs px-2 py-1 rounded bg-white border border-gray-200 text-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@@ -122,7 +148,7 @@
|
||||
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
|
||||
<span v-for="(seg, idx) in parseTextWithEmoji(message.content)" :key="idx">
|
||||
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
|
||||
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px">
|
||||
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" loading="lazy" decoding="async">
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -189,13 +215,15 @@
|
||||
class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer"
|
||||
@click.stop="openImagePreview(message.quoteThumbUrl)"
|
||||
>
|
||||
<img
|
||||
:src="message.quoteThumbUrl"
|
||||
alt="引用链接缩略图"
|
||||
class="max-h-[49px] w-auto max-w-[98px] object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
<img
|
||||
v-chat-lazy-src="message.quoteThumbUrl"
|
||||
alt="引用链接缩略图"
|
||||
class="max-h-[49px] w-auto max-w-[98px] object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
referrerpolicy="no-referrer"
|
||||
v-chat-media-perf="{ kind: 'quote-thumb', meta: { conversation: selectedContact?.username || '', messageId: message.id, quoteServerId: message.quoteServerId || '' } }"
|
||||
@error="onQuoteThumbError(message)"
|
||||
/>
|
||||
</div>
|
||||
@@ -204,12 +232,14 @@
|
||||
class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer"
|
||||
@click.stop="openImagePreview(message.quoteImageUrl)"
|
||||
>
|
||||
<img
|
||||
:src="message.quoteImageUrl"
|
||||
alt="引用图片"
|
||||
class="max-h-[49px] w-auto max-w-[98px] object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
<img
|
||||
v-chat-lazy-src="message.quoteImageUrl"
|
||||
alt="引用图片"
|
||||
class="max-h-[49px] w-auto max-w-[98px] object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
v-chat-media-perf="{ kind: 'quote-image', meta: { conversation: selectedContact?.username || '', messageId: message.id, quoteServerId: message.quoteServerId || '' } }"
|
||||
@error="onQuoteImageError(message)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -31,10 +31,14 @@
|
||||
<div class="w-[calc(42px/var(--dpr))] h-[calc(42px/var(--dpr))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="[message.isSent ? 'ml-3' : 'mr-3', { 'privacy-blur': privacyMode }]">
|
||||
<div v-if="message.avatar" class="w-full h-full">
|
||||
<img
|
||||
:src="message.avatar"
|
||||
v-chat-lazy-src="message.avatar"
|
||||
:alt="message.sender + '的头像'"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
referrerpolicy="no-referrer"
|
||||
v-chat-media-perf="{ kind: 'message-avatar', meta: { conversation: selectedContact?.username || '', messageId: message.id, serverId: message.serverIdStr || '', senderUsername: message.senderUsername || '' } }"
|
||||
@error="onAvatarError($event, message)"
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
const exportFolderHandle = ref(null)
|
||||
const exportSaveBusy = ref(false)
|
||||
const exportSaveMsg = ref('')
|
||||
const exportSaveError = ref('')
|
||||
const exportSaveState = ref('idle')
|
||||
const exportSaveBytesWritten = ref(0)
|
||||
const exportSaveBytesTotal = ref(0)
|
||||
const exportAutoSavedFor = ref('')
|
||||
const exportCancelRequested = ref(false)
|
||||
|
||||
const exportSearchQuery = ref('')
|
||||
const exportListTab = ref('all')
|
||||
@@ -50,6 +55,27 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
const next = Number(value)
|
||||
return Number.isFinite(next) ? next : 0
|
||||
}
|
||||
const formatBytes = (value) => {
|
||||
const bytes = Number(value)
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let size = bytes
|
||||
let index = 0
|
||||
while (size >= 1024 && index < units.length - 1) {
|
||||
size /= 1024
|
||||
index += 1
|
||||
}
|
||||
const digits = size >= 100 || index === 0 ? 0 : size >= 10 ? 1 : 2
|
||||
return `${size.toFixed(digits)} ${units[index]}`
|
||||
}
|
||||
const resetExportSaveFeedback = ({ resetAutoSavedFor = false } = {}) => {
|
||||
exportSaveMsg.value = ''
|
||||
exportSaveError.value = ''
|
||||
exportSaveState.value = 'idle'
|
||||
exportSaveBytesWritten.value = 0
|
||||
exportSaveBytesTotal.value = 0
|
||||
if (resetAutoSavedFor) exportAutoSavedFor.value = ''
|
||||
}
|
||||
|
||||
const exportOverallPercent = computed(() => {
|
||||
const job = exportJob.value
|
||||
@@ -72,6 +98,17 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
if (total <= 0) return null
|
||||
return Math.round(clamp01(done / total) * 100)
|
||||
})
|
||||
const exportBackendZipPath = computed(() => {
|
||||
return String(exportJob.value?.zipPath || '').trim()
|
||||
})
|
||||
const exportSaveProgressText = computed(() => {
|
||||
if (exportSaveState.value !== 'saving') return ''
|
||||
const fileName = guessExportZipName(exportJob.value)
|
||||
if (exportSaveBytesTotal.value > 0) {
|
||||
return `正在保存到浏览器目录:${fileName}(${formatBytes(exportSaveBytesWritten.value)} / ${formatBytes(exportSaveBytesTotal.value)})`
|
||||
}
|
||||
return `正在保存到浏览器目录:${fileName}(${formatBytes(exportSaveBytesWritten.value)})`
|
||||
})
|
||||
|
||||
const normalizeExportSelectedUsernames = (list) => {
|
||||
const seen = new Set()
|
||||
@@ -179,7 +216,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
|
||||
const chooseExportFolder = async () => {
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
resetExportSaveFeedback()
|
||||
try {
|
||||
if (!process.client) {
|
||||
exportError.value = '当前环境不支持选择导出目录'
|
||||
@@ -206,6 +243,10 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
|
||||
exportError.value = '当前浏览器不支持目录选择,请使用桌面端或 Chromium 新版浏览器'
|
||||
} catch (error) {
|
||||
const message = String(error?.message || '').trim()
|
||||
if (error?.name === 'AbortError' || message.includes('The user aborted a request')) {
|
||||
return
|
||||
}
|
||||
exportError.value = error?.message || '选择导出目录失败'
|
||||
}
|
||||
}
|
||||
@@ -227,7 +268,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
const saveExportToSelectedFolder = async (options = {}) => {
|
||||
const autoSave = !!options?.auto
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
resetExportSaveFeedback()
|
||||
if (!process.client || !isWebDirectoryPickerSupported()) {
|
||||
exportError.value = '当前环境不支持保存到浏览器目录'
|
||||
return
|
||||
@@ -245,6 +286,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
}
|
||||
|
||||
exportSaveBusy.value = true
|
||||
exportSaveState.value = 'saving'
|
||||
try {
|
||||
const response = await fetch(getExportDownloadUrl(exportId))
|
||||
if (!response.ok) {
|
||||
@@ -256,18 +298,46 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
})
|
||||
throw new Error(`下载导出文件失败(${response.status})`)
|
||||
}
|
||||
const blob = await response.blob()
|
||||
exportSaveBytesTotal.value = asNumber(response.headers.get('Content-Length'))
|
||||
const fileName = guessExportZipName(exportJob.value)
|
||||
const fileHandle = await handle.getFileHandle(fileName, { create: true })
|
||||
const writable = await fileHandle.createWritable()
|
||||
await writable.write(blob)
|
||||
await writable.close()
|
||||
if (response.body && typeof response.body.getReader === 'function') {
|
||||
const reader = response.body.getReader()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (!value || !value.byteLength) continue
|
||||
await writable.write(value)
|
||||
exportSaveBytesWritten.value += value.byteLength
|
||||
}
|
||||
await writable.close()
|
||||
} catch (error) {
|
||||
try {
|
||||
await reader.cancel()
|
||||
} catch {}
|
||||
try {
|
||||
await writable.abort()
|
||||
} catch {}
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
const blob = await response.blob()
|
||||
exportSaveBytesWritten.value = asNumber(blob.size)
|
||||
if (exportSaveBytesTotal.value <= 0) exportSaveBytesTotal.value = exportSaveBytesWritten.value
|
||||
await writable.write(blob)
|
||||
await writable.close()
|
||||
}
|
||||
exportAutoSavedFor.value = String(exportId)
|
||||
exportSaveState.value = 'success'
|
||||
const folderLabel = String(exportFolder.value || '').trim() || '已选目录'
|
||||
exportSaveMsg.value = autoSave
|
||||
? `已自动保存到已选目录:${fileName}`
|
||||
: `已保存到已选目录:${fileName}`
|
||||
? `浏览器目录自动保存成功:${fileName}\n位置:${folderLabel}`
|
||||
: `浏览器目录保存成功:${fileName}\n位置:${folderLabel}`
|
||||
} catch (error) {
|
||||
exportError.value = error?.message || '保存到浏览器目录失败'
|
||||
exportSaveState.value = 'error'
|
||||
exportSaveError.value = `浏览器目录保存失败:${error?.message || '未知错误'}`
|
||||
} finally {
|
||||
exportSaveBusy.value = false
|
||||
}
|
||||
@@ -337,7 +407,8 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
const openExportModal = () => {
|
||||
exportModalOpen.value = true
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
resetExportSaveFeedback({ resetAutoSavedFor: true })
|
||||
exportCancelRequested.value = false
|
||||
exportSearchQuery.value = ''
|
||||
exportListTab.value = 'all'
|
||||
exportSelectedUsernames.value = []
|
||||
@@ -356,6 +427,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
exportError.value = ''
|
||||
}
|
||||
|
||||
const clearExportFolderSelection = () => {
|
||||
exportFolder.value = ''
|
||||
exportFolderHandle.value = null
|
||||
resetExportSaveFeedback({ resetAutoSavedFor: true })
|
||||
}
|
||||
|
||||
watch(exportModalOpen, (open) => {
|
||||
if (!process.client) return
|
||||
if (!open) {
|
||||
@@ -382,6 +459,9 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
status: String(exportJob.value?.status || '')
|
||||
}),
|
||||
async ({ exportId, status }) => {
|
||||
if (status !== 'queued' && status !== 'running') {
|
||||
exportCancelRequested.value = false
|
||||
}
|
||||
if (!process.client || status !== 'done' || !exportId) return
|
||||
if (!hasWebExportFolder.value) return
|
||||
if (exportAutoSavedFor.value === exportId) return
|
||||
@@ -392,7 +472,8 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
|
||||
const startChatExport = async () => {
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
resetExportSaveFeedback({ resetAutoSavedFor: true })
|
||||
exportCancelRequested.value = false
|
||||
if (!selectedAccount.value) {
|
||||
exportError.value = '未选择账号'
|
||||
return
|
||||
@@ -490,13 +571,17 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
|
||||
const cancelCurrentExport = async () => {
|
||||
const exportId = exportJob.value?.exportId
|
||||
if (!exportId) return
|
||||
const status = String(exportJob.value?.status || '')
|
||||
if (!exportId || (status !== 'queued' && status !== 'running') || exportCancelRequested.value) return
|
||||
|
||||
exportError.value = ''
|
||||
exportCancelRequested.value = true
|
||||
try {
|
||||
await api.cancelChatExport(exportId)
|
||||
const response = await api.getChatExport(exportId)
|
||||
exportJob.value = response?.job || exportJob.value
|
||||
} catch (error) {
|
||||
exportCancelRequested.value = false
|
||||
exportError.value = error?.message || '取消导出失败'
|
||||
}
|
||||
}
|
||||
@@ -518,7 +603,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
exportFolderHandle,
|
||||
exportSaveBusy,
|
||||
exportSaveMsg,
|
||||
exportSaveError,
|
||||
exportSaveState,
|
||||
exportSaveProgressText,
|
||||
exportBackendZipPath,
|
||||
exportAutoSavedFor,
|
||||
exportCancelRequested,
|
||||
exportSearchQuery,
|
||||
exportListTab,
|
||||
exportSelectedUsernames,
|
||||
@@ -532,6 +622,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
isExportContactSelected,
|
||||
hasWebExportFolder,
|
||||
chooseExportFolder,
|
||||
clearExportFolderSelection,
|
||||
getExportDownloadUrl,
|
||||
saveExportToSelectedFolder,
|
||||
openExportModal,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getVoiceDurationInSeconds,
|
||||
getVoiceWidth
|
||||
} from '~/lib/chat/formatters'
|
||||
import { createPerfTrace } from '~/lib/chat/perf-logger'
|
||||
import { createMessageNormalizer, dedupeMessagesById } from '~/lib/chat/message-normalizer'
|
||||
|
||||
export const useChatMessages = ({
|
||||
@@ -410,9 +411,15 @@ export const useChatMessages = ({
|
||||
const loadMessages = async ({ username, reset }) => {
|
||||
if (!username || !selectedAccount.value) return
|
||||
|
||||
logMessagePhase('loadMessages:enter', {
|
||||
username,
|
||||
reset
|
||||
const trace = createPerfTrace('chat-messages', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
selectedUsername: String(selectedContact.value?.username || '').trim(),
|
||||
username: String(username || '').trim(),
|
||||
reset: !!reset
|
||||
})
|
||||
|
||||
trace.log('loadMessages:enter', {
|
||||
activeMessagesFor: String(activeMessagesFor.value || '').trim()
|
||||
})
|
||||
messagesError.value = ''
|
||||
isLoadingMessages.value = true
|
||||
@@ -438,46 +445,37 @@ export const useChatMessages = ({
|
||||
if (realtimeEnabled.value) {
|
||||
params.source = 'realtime'
|
||||
}
|
||||
logMessagePhase('loadMessages:request:start', {
|
||||
username,
|
||||
reset,
|
||||
trace.log('loadMessages:request:start', {
|
||||
offset,
|
||||
existingCount: existing.length,
|
||||
renderTypeFilter: messageTypeFilter.value,
|
||||
realtime: !!realtimeEnabled.value
|
||||
})
|
||||
const response = await api.listChatMessages(params)
|
||||
logMessagePhase('loadMessages:request:end', {
|
||||
username,
|
||||
reset,
|
||||
trace.log('loadMessages:request:end', {
|
||||
rawCount: Array.isArray(response?.messages) ? response.messages.length : 0,
|
||||
total: Number(response?.total || 0),
|
||||
hasMore: response?.hasMore
|
||||
})
|
||||
|
||||
const raw = response?.messages || []
|
||||
logMessagePhase('loadMessages:normalize:start', {
|
||||
username,
|
||||
trace.log('loadMessages:normalize:start', {
|
||||
rawCount: raw.length
|
||||
})
|
||||
const mapped = dedupeMessagesById(raw.map(normalizeMessage))
|
||||
logMessagePhase('loadMessages:normalize:end', {
|
||||
username,
|
||||
trace.log('loadMessages:normalize:end', {
|
||||
mappedCount: mapped.length,
|
||||
renderTypeCounts: summarizeRenderTypes(mapped)
|
||||
})
|
||||
|
||||
if (activeMessagesFor.value !== username) {
|
||||
logMessagePhase('loadMessages:abort-stale', {
|
||||
username,
|
||||
trace.log('loadMessages:abort-stale', {
|
||||
activeMessagesFor: activeMessagesFor.value
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logMessagePhase('loadMessages:state-commit:start', {
|
||||
username,
|
||||
reset,
|
||||
trace.log('loadMessages:state-commit:start', {
|
||||
mappedCount: mapped.length
|
||||
})
|
||||
if (reset) {
|
||||
@@ -496,8 +494,7 @@ export const useChatMessages = ({
|
||||
[username]: [...older, ...existing]
|
||||
}
|
||||
}
|
||||
logMessagePhase('loadMessages:state-commit:end', {
|
||||
username,
|
||||
trace.log('loadMessages:state-commit:end', {
|
||||
storedCount: (allMessages.value[username] || []).length
|
||||
})
|
||||
|
||||
@@ -508,18 +505,14 @@ export const useChatMessages = ({
|
||||
hasMore: response?.hasMore
|
||||
}
|
||||
}
|
||||
logMessagePhase('loadMessages:meta-commit:end', {
|
||||
username,
|
||||
trace.log('loadMessages:meta-commit:end', {
|
||||
total: Number(response?.total || 0),
|
||||
hasMore: response?.hasMore
|
||||
})
|
||||
|
||||
logMessagePhase('loadMessages:nextTick:start', {
|
||||
username
|
||||
})
|
||||
trace.log('loadMessages:nextTick:start')
|
||||
await nextTick()
|
||||
logMessagePhase('loadMessages:nextTick:end', {
|
||||
username,
|
||||
trace.log('loadMessages:nextTick:end', {
|
||||
renderedCount: (allMessages.value[username] || []).length
|
||||
})
|
||||
const nextContainer = messageContainerRef.value
|
||||
@@ -532,13 +525,16 @@ export const useChatMessages = ({
|
||||
}
|
||||
}
|
||||
updateJumpToBottomState()
|
||||
logMessagePhase('loadMessages:scroll:end', {
|
||||
username,
|
||||
trace.log('loadMessages:scroll:end', {
|
||||
hasContainer: !!nextContainer,
|
||||
scrollTop: nextContainer ? nextContainer.scrollTop : null,
|
||||
scrollHeight: nextContainer ? nextContainer.scrollHeight : null
|
||||
})
|
||||
} catch (error) {
|
||||
trace.log('loadMessages:error', {
|
||||
message: String(error?.message || ''),
|
||||
errorName: String(error?.name || '')
|
||||
})
|
||||
console.error('[chat-messages] loadMessages:error', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
username: String(username || '').trim(),
|
||||
@@ -548,9 +544,7 @@ export const useChatMessages = ({
|
||||
messagesError.value = error?.message || '加载聊天记录失败'
|
||||
} finally {
|
||||
isLoadingMessages.value = false
|
||||
logMessagePhase('loadMessages:exit', {
|
||||
username,
|
||||
reset,
|
||||
trace.log('loadMessages:exit', {
|
||||
loading: isLoadingMessages.value,
|
||||
error: messagesError.value
|
||||
})
|
||||
@@ -571,9 +565,24 @@ export const useChatMessages = ({
|
||||
|
||||
const refreshCurrentMessageMedia = async () => {
|
||||
if (!selectedContact.value?.username) return
|
||||
const trace = createPerfTrace('chat-messages', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
username: String(selectedContact.value?.username || '').trim(),
|
||||
action: 'refreshCurrentMessageMedia'
|
||||
})
|
||||
trace.log('refreshCurrentMessageMedia:start', {
|
||||
localMediaVersion: Number(localMediaVersion.value || 0)
|
||||
})
|
||||
bumpLocalMediaVersion()
|
||||
trace.log('refreshCurrentMessageMedia:version-bumped', {
|
||||
localMediaVersion: Number(localMediaVersion.value || 0)
|
||||
})
|
||||
renormalizeLoadedMessages(selectedContact.value.username)
|
||||
trace.log('refreshCurrentMessageMedia:renormalized', {
|
||||
renderedCount: (allMessages.value[selectedContact.value.username] || []).length
|
||||
})
|
||||
await nextTick()
|
||||
trace.log('refreshCurrentMessageMedia:end')
|
||||
}
|
||||
|
||||
const refreshRealtimeIncremental = async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { normalizeSessionPreview } from '~/lib/chat/formatters'
|
||||
import { createPerfTrace } from '~/lib/chat/perf-logger'
|
||||
|
||||
const SESSION_LIST_WIDTH_KEY = 'ui.chat.session_list_width_physical'
|
||||
const SESSION_LIST_WIDTH_KEY_LEGACY = 'ui.chat.session_list_width'
|
||||
@@ -170,6 +171,14 @@ export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled
|
||||
return []
|
||||
}
|
||||
|
||||
const trace = createPerfTrace('chat-sessions', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
action: 'loadSessionsForSelectedAccount'
|
||||
})
|
||||
trace.log('loadSessions:start', {
|
||||
realtimeEnabled: !!realtimeEnabled?.value
|
||||
})
|
||||
|
||||
const fetchSessions = async (source) => {
|
||||
const params = {
|
||||
account: selectedAccount.value,
|
||||
@@ -184,18 +193,38 @@ export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled
|
||||
let sessionsResp = null
|
||||
if (realtimeEnabled?.value) {
|
||||
try {
|
||||
trace.log('loadSessions:request:start', {
|
||||
source: 'realtime'
|
||||
})
|
||||
sessionsResp = await fetchSessions('realtime')
|
||||
trace.log('loadSessions:request:end', {
|
||||
source: 'realtime',
|
||||
rawCount: Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions.length : 0
|
||||
})
|
||||
} catch {
|
||||
sessionsResp = null
|
||||
trace.log('loadSessions:request:error', {
|
||||
source: 'realtime'
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!sessionsResp) {
|
||||
trace.log('loadSessions:request:start', {
|
||||
source: 'default'
|
||||
})
|
||||
sessionsResp = await fetchSessions('')
|
||||
trace.log('loadSessions:request:end', {
|
||||
source: 'default',
|
||||
rawCount: Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions.length : 0
|
||||
})
|
||||
}
|
||||
|
||||
const sessions = Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions : []
|
||||
contacts.value = mapSessions(sessions)
|
||||
contactsError.value = ''
|
||||
trace.log('loadSessions:end', {
|
||||
contactCount: contacts.value.length
|
||||
})
|
||||
return contacts.value
|
||||
}
|
||||
|
||||
@@ -208,6 +237,14 @@ export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled
|
||||
const desiredSource = (sourceOverride != null)
|
||||
? String(sourceOverride || '').trim()
|
||||
: (realtimeEnabled?.value ? 'realtime' : '')
|
||||
const trace = createPerfTrace('chat-sessions', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
action: 'refreshSessionsForSelectedAccount',
|
||||
desiredSource
|
||||
})
|
||||
trace.log('refreshSessions:start', {
|
||||
previousUsername
|
||||
})
|
||||
|
||||
const params = {
|
||||
account: selectedAccount.value,
|
||||
@@ -219,15 +256,35 @@ export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled
|
||||
let sessionsResp = null
|
||||
if (desiredSource) {
|
||||
try {
|
||||
trace.log('refreshSessions:request:start', {
|
||||
source: desiredSource
|
||||
})
|
||||
sessionsResp = await api.listChatSessions({ ...params, source: desiredSource })
|
||||
trace.log('refreshSessions:request:end', {
|
||||
source: desiredSource,
|
||||
rawCount: Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions.length : 0
|
||||
})
|
||||
} catch {
|
||||
sessionsResp = null
|
||||
trace.log('refreshSessions:request:error', {
|
||||
source: desiredSource
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!sessionsResp) {
|
||||
try {
|
||||
trace.log('refreshSessions:request:start', {
|
||||
source: 'default'
|
||||
})
|
||||
sessionsResp = await api.listChatSessions(params)
|
||||
trace.log('refreshSessions:request:end', {
|
||||
source: 'default',
|
||||
rawCount: Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions.length : 0
|
||||
})
|
||||
} catch {
|
||||
trace.log('refreshSessions:request:error', {
|
||||
source: 'default'
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -240,6 +297,10 @@ export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled
|
||||
const matched = nextContacts.find((contact) => contact.username === previousUsername)
|
||||
if (matched) selectedContact.value = matched
|
||||
}
|
||||
trace.log('refreshSessions:end', {
|
||||
contactCount: nextContacts.length,
|
||||
selectedUsername: String(selectedContact.value?.username || '').trim()
|
||||
})
|
||||
}
|
||||
|
||||
const loadContacts = async () => {
|
||||
@@ -249,25 +310,50 @@ export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled
|
||||
|
||||
isLoadingContacts.value = true
|
||||
contactsError.value = ''
|
||||
const trace = createPerfTrace('chat-sessions', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
action: 'loadContacts'
|
||||
})
|
||||
trace.log('loadContacts:start', {
|
||||
cachedContacts: contacts.value.length
|
||||
})
|
||||
try {
|
||||
const hadLoadedAccountSnapshot = !!chatAccounts.loaded
|
||||
await chatAccounts.ensureLoaded()
|
||||
trace.log('loadContacts:accounts-ready', {
|
||||
hadLoadedAccountSnapshot,
|
||||
availableAccounts: Array.isArray(chatAccounts?.accounts) ? chatAccounts.accounts.length : 0
|
||||
})
|
||||
if (!selectedAccount.value && hadLoadedAccountSnapshot) {
|
||||
await chatAccounts.ensureLoaded({ force: true })
|
||||
trace.log('loadContacts:accounts-refreshed')
|
||||
}
|
||||
|
||||
if (!selectedAccount.value) {
|
||||
clearContactsState(chatAccounts.error || '未检测到已解密账号,请先解密数据库。')
|
||||
trace.log('loadContacts:no-account', {
|
||||
error: contactsError.value
|
||||
})
|
||||
return { usedPrefetched: false }
|
||||
}
|
||||
|
||||
await loadSessionsForSelectedAccount()
|
||||
trace.log('loadContacts:end', {
|
||||
contactCount: contacts.value.length
|
||||
})
|
||||
return { usedPrefetched: false }
|
||||
} catch (error) {
|
||||
clearContactsState(error?.message || '加载联系人失败')
|
||||
trace.log('loadContacts:error', {
|
||||
message: String(error?.message || '')
|
||||
})
|
||||
return { usedPrefetched: false }
|
||||
} finally {
|
||||
isLoadingContacts.value = false
|
||||
trace.log('loadContacts:exit', {
|
||||
loading: isLoadingContacts.value,
|
||||
error: contactsError.value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -575,8 +575,14 @@ export const useApi = () => {
|
||||
}
|
||||
|
||||
// 获取图片密钥
|
||||
const getImageKey = async () => {
|
||||
return await request('/get_image_key')
|
||||
const getImageKey = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.db_storage_path) query.set('db_storage_path', params.db_storage_path)
|
||||
if (params && params.wxid_dir) query.set('wxid_dir', params.wxid_dir)
|
||||
const url = '/get_image_key' + (query.toString() ? `?${query.toString()}` : '')
|
||||
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 枚举服务号信息
|
||||
|
||||
@@ -227,6 +227,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
|
||||
_quoteThumbError: false,
|
||||
amount: msg.amount || '',
|
||||
coverUrl: msg.coverUrl || '',
|
||||
objectId: String(msg.objectId || '').trim(),
|
||||
objectNonceId: String(msg.objectNonceId || '').trim(),
|
||||
fileSize: msg.fileSize || '',
|
||||
fileMd5: msg.fileMd5 || '',
|
||||
paySubType: msg.paySubType || '',
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
const roundPerfMs = (value) => {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return null
|
||||
return Number(numeric.toFixed(1))
|
||||
}
|
||||
|
||||
const isDesktopShell = () => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
export const nowPerfMs = () => {
|
||||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||
return performance.now()
|
||||
}
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
export const logPerfChannel = (channel, phase, details = {}) => {
|
||||
const payload = { ...details }
|
||||
if (isDesktopShell()) {
|
||||
try {
|
||||
window.wechatDesktop?.logDebug?.(channel, phase, payload)
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
console.info(`[${channel}] ${phase}`, payload)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export const createPerfTrace = (channel, baseDetails = {}) => {
|
||||
const traceId = `${channel}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const startedAt = nowPerfMs()
|
||||
let lastAt = startedAt
|
||||
|
||||
return {
|
||||
id: traceId,
|
||||
log(phase, details = {}) {
|
||||
const now = nowPerfMs()
|
||||
const payload = {
|
||||
...baseDetails,
|
||||
...details,
|
||||
traceId,
|
||||
elapsedMs: roundPerfMs(now - startedAt),
|
||||
deltaMs: roundPerfMs(now - lastAt)
|
||||
}
|
||||
lastAt = now
|
||||
logPerfChannel(channel, phase, payload)
|
||||
return payload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getLatestResourceTiming = (resourceUrl) => {
|
||||
const url = String(resourceUrl || '').trim()
|
||||
if (!url || typeof performance === 'undefined' || typeof performance.getEntriesByName !== 'function') {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = performance.getEntriesByName(url)
|
||||
if (!entries?.length) return {}
|
||||
const entry = entries[entries.length - 1]
|
||||
return {
|
||||
resourceDurationMs: roundPerfMs(entry.duration),
|
||||
fetchStartMs: roundPerfMs(entry.fetchStart),
|
||||
responseEndMs: roundPerfMs(entry.responseEnd),
|
||||
transferSize: Number.isFinite(entry.transferSize) ? Number(entry.transferSize) : null,
|
||||
encodedBodySize: Number.isFinite(entry.encodedBodySize) ? Number(entry.encodedBodySize) : null,
|
||||
decodedBodySize: Number.isFinite(entry.decodedBodySize) ? Number(entry.decodedBodySize) : null,
|
||||
initiatorType: String(entry.initiatorType || '').trim()
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
+707
-90
File diff suppressed because it is too large
Load Diff
@@ -41,9 +41,19 @@
|
||||
<svg v-if="!loading" class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
<svg v-else class="animate-spin w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg v-else class="w-4 h-4 mr-2 animate-spin" fill="none" viewBox="0 0 48 48" aria-hidden="true">
|
||||
<circle class="opacity-20" cx="24" cy="24" r="18" stroke="currentColor" stroke-width="6"></circle>
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="18"
|
||||
stroke="currentColor"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="28 72"
|
||||
pathLength="100"
|
||||
transform="rotate(-90 24 24)"
|
||||
></circle>
|
||||
</svg>
|
||||
{{ loading ? '检测中...' : '手动选择目录检测' }}
|
||||
</button>
|
||||
@@ -53,9 +63,19 @@
|
||||
<div>
|
||||
<!-- 检测中状态 -->
|
||||
<div v-if="loading" class="absolute inset-0 bg-white/80 backdrop-blur-sm z-20 rounded-2xl flex flex-col items-center justify-center border border-[#EDEDED]">
|
||||
<svg class="w-16 h-16 animate-spin text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg class="w-16 h-16 animate-spin text-[#07C160]" fill="none" viewBox="0 0 48 48" aria-hidden="true">
|
||||
<circle class="opacity-20" cx="24" cy="24" r="18" stroke="currentColor" stroke-width="6"></circle>
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="18"
|
||||
stroke="currentColor"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="28 72"
|
||||
pathLength="100"
|
||||
transform="rotate(-90 24 24)"
|
||||
></circle>
|
||||
</svg>
|
||||
<p class="mt-4 text-lg text-[#7F7F7F]">正在检测微信数据...</p>
|
||||
</div>
|
||||
@@ -395,4 +415,4 @@ onMounted(() => {
|
||||
}
|
||||
startDetection()
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { createPerfTrace, getLatestResourceTiming } from '~/lib/chat/perf-logger'
|
||||
|
||||
const CHAT_LAZY_SRC_EVENT = 'chat-lazy-src:start'
|
||||
const CHAT_LAZY_ROOT_MARGIN = '240px 0px 520px 0px'
|
||||
|
||||
const nextRenderTick = (callback) => {
|
||||
if (typeof window === 'undefined') {
|
||||
setTimeout(callback, 0)
|
||||
return
|
||||
}
|
||||
if (typeof window.requestAnimationFrame !== 'function') {
|
||||
window.setTimeout(callback, 0)
|
||||
return
|
||||
}
|
||||
window.requestAnimationFrame(() => {
|
||||
window.setTimeout(callback, 0)
|
||||
})
|
||||
}
|
||||
|
||||
const readImageSrc = (element) => {
|
||||
return String(
|
||||
element?.currentSrc
|
||||
|| element?.getAttribute?.('src')
|
||||
|| element?.src
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
const normalizeBindingValue = (value) => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return { kind: 'image', meta: {} }
|
||||
}
|
||||
return {
|
||||
kind: String(value.kind || 'image').trim() || 'image',
|
||||
meta: value.meta && typeof value.meta === 'object' ? { ...value.meta } : {}
|
||||
}
|
||||
}
|
||||
|
||||
const ensurePerfState = (element) => {
|
||||
if (!element.__chatMediaPerfState) {
|
||||
element.__chatMediaPerfState = {
|
||||
src: '',
|
||||
trace: null,
|
||||
finalized: true,
|
||||
onLoad: null,
|
||||
onError: null,
|
||||
onLazyStart: null
|
||||
}
|
||||
}
|
||||
return element.__chatMediaPerfState
|
||||
}
|
||||
|
||||
const normalizeLazySrc = (value) => {
|
||||
if (value == null) return ''
|
||||
if (typeof value === 'string') return value.trim()
|
||||
if (typeof value === 'object') return String(value.src || '').trim()
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
const ensureLazySrcState = (element) => {
|
||||
if (!element.__chatLazySrcState) {
|
||||
element.__chatLazySrcState = {
|
||||
src: '',
|
||||
loadedSrc: '',
|
||||
observer: null,
|
||||
timer: null
|
||||
}
|
||||
}
|
||||
return element.__chatLazySrcState
|
||||
}
|
||||
|
||||
const cleanupLazySrcObserver = (element) => {
|
||||
const state = element?.__chatLazySrcState
|
||||
if (!state) return
|
||||
if (state.observer) {
|
||||
try { state.observer.disconnect() } catch {}
|
||||
state.observer = null
|
||||
}
|
||||
if (state.timer) {
|
||||
try { clearTimeout(state.timer) } catch {}
|
||||
state.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
const applyLazySrc = (element, reason = '') => {
|
||||
const state = element?.__chatLazySrcState
|
||||
const src = String(state?.src || '').trim()
|
||||
if (!element || !src) return
|
||||
if (state.loadedSrc === src && readImageSrc(element) === src) return
|
||||
|
||||
state.loadedSrc = src
|
||||
element.setAttribute('src', src)
|
||||
try {
|
||||
element.dispatchEvent(new CustomEvent(CHAT_LAZY_SRC_EVENT, {
|
||||
detail: { src, reason }
|
||||
}))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const updateLazySrc = (element, binding, reason = '') => {
|
||||
const state = ensureLazySrcState(element)
|
||||
const nextSrc = normalizeLazySrc(binding?.value)
|
||||
|
||||
cleanupLazySrcObserver(element)
|
||||
state.src = nextSrc
|
||||
|
||||
if (!nextSrc) {
|
||||
state.loadedSrc = ''
|
||||
try { element.removeAttribute('src') } catch {}
|
||||
return
|
||||
}
|
||||
|
||||
if (state.loadedSrc !== nextSrc || readImageSrc(element) !== nextSrc) {
|
||||
state.loadedSrc = ''
|
||||
try { element.removeAttribute('src') } catch {}
|
||||
try { element.setAttribute('data-chat-lazy-src', nextSrc) } catch {}
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined' || typeof window.IntersectionObserver !== 'function') {
|
||||
state.timer = setTimeout(() => applyLazySrc(element, `${reason}:fallback`), 0)
|
||||
return
|
||||
}
|
||||
|
||||
state.observer = new window.IntersectionObserver((entries) => {
|
||||
const entry = entries?.[0]
|
||||
if (!entry?.isIntersecting) return
|
||||
cleanupLazySrcObserver(element)
|
||||
applyLazySrc(element, `${reason}:intersect`)
|
||||
}, {
|
||||
root: null,
|
||||
rootMargin: CHAT_LAZY_ROOT_MARGIN,
|
||||
threshold: 0.01
|
||||
})
|
||||
state.observer.observe(element)
|
||||
}
|
||||
|
||||
const finalizeTracking = (element, status, reason = '') => {
|
||||
const state = element?.__chatMediaPerfState
|
||||
if (!state?.trace || state.finalized) return
|
||||
|
||||
const currentSrc = readImageSrc(element) || state.src
|
||||
state.trace.log(status === 'load' ? 'resource:load' : 'resource:error', {
|
||||
reason,
|
||||
currentSrc,
|
||||
complete: !!element?.complete,
|
||||
naturalWidth: Number(element?.naturalWidth || 0),
|
||||
naturalHeight: Number(element?.naturalHeight || 0),
|
||||
...getLatestResourceTiming(currentSrc)
|
||||
})
|
||||
state.finalized = true
|
||||
}
|
||||
|
||||
const beginTracking = (element, binding, reason = '') => {
|
||||
const state = ensurePerfState(element)
|
||||
const src = readImageSrc(element)
|
||||
if (!src) return
|
||||
if (state.src === src && state.trace && !state.finalized) return
|
||||
|
||||
const { kind, meta } = normalizeBindingValue(binding?.value)
|
||||
state.src = src
|
||||
state.finalized = false
|
||||
state.trace = createPerfTrace('chat-media-ui', {
|
||||
kind,
|
||||
src,
|
||||
...meta
|
||||
})
|
||||
state.trace.log('resource:start', {
|
||||
reason,
|
||||
complete: !!element?.complete,
|
||||
loading: String(element?.getAttribute?.('loading') || '').trim(),
|
||||
decoding: String(element?.getAttribute?.('decoding') || '').trim()
|
||||
})
|
||||
|
||||
if (element?.complete) {
|
||||
nextRenderTick(() => finalizeTracking(element, 'load', 'complete-sync'))
|
||||
}
|
||||
}
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.directive('chat-media-perf', {
|
||||
mounted(element, binding) {
|
||||
const state = ensurePerfState(element)
|
||||
state.onLoad = () => finalizeTracking(element, 'load', 'load-event')
|
||||
state.onError = () => finalizeTracking(element, 'error', 'error-event')
|
||||
state.onLazyStart = () => beginTracking(element, binding, 'lazy-src')
|
||||
element.addEventListener('load', state.onLoad)
|
||||
element.addEventListener('error', state.onError)
|
||||
element.addEventListener(CHAT_LAZY_SRC_EVENT, state.onLazyStart)
|
||||
beginTracking(element, binding, 'mounted')
|
||||
},
|
||||
updated(element, binding) {
|
||||
const state = ensurePerfState(element)
|
||||
const nextSrc = readImageSrc(element)
|
||||
if (!nextSrc) return
|
||||
if (nextSrc !== state.src) {
|
||||
beginTracking(element, binding, 'updated-src')
|
||||
return
|
||||
}
|
||||
if (element?.complete && !state.finalized) {
|
||||
nextRenderTick(() => finalizeTracking(element, 'load', 'updated-complete'))
|
||||
}
|
||||
},
|
||||
beforeUnmount(element) {
|
||||
const state = element?.__chatMediaPerfState
|
||||
if (state?.onLoad) element.removeEventListener('load', state.onLoad)
|
||||
if (state?.onError) element.removeEventListener('error', state.onError)
|
||||
if (state?.onLazyStart) element.removeEventListener(CHAT_LAZY_SRC_EVENT, state.onLazyStart)
|
||||
if (state?.trace && !state.finalized) {
|
||||
finalizeTracking(element, element?.complete ? 'load' : 'error', 'before-unmount')
|
||||
}
|
||||
delete element.__chatMediaPerfState
|
||||
}
|
||||
})
|
||||
|
||||
nuxtApp.vueApp.directive('chat-lazy-src', {
|
||||
mounted(element, binding) {
|
||||
updateLazySrc(element, binding, 'mounted')
|
||||
},
|
||||
updated(element, binding) {
|
||||
const state = ensureLazySrcState(element)
|
||||
const nextSrc = normalizeLazySrc(binding?.value)
|
||||
if (nextSrc === state.src && (state.loadedSrc === nextSrc || !readImageSrc(element))) {
|
||||
return
|
||||
}
|
||||
updateLazySrc(element, binding, 'updated')
|
||||
},
|
||||
beforeUnmount(element) {
|
||||
cleanupLazySrcObserver(element)
|
||||
delete element.__chatLazySrcState
|
||||
}
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -20,7 +20,7 @@ dependencies = [
|
||||
"pilk>=0.2.4",
|
||||
"pypinyin>=0.53.0",
|
||||
"jieba>=0.42.1",
|
||||
"wx_key>=1.1.0",
|
||||
"wx_key>=2.0.0",
|
||||
"packaging",
|
||||
"httpx",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1238,6 +1238,14 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
or (_extract_xml_tag_text(finder_feed, "username") if finder_feed else "")
|
||||
or (_extract_xml_tag_text(finder_feed, "finderusername") if finder_feed else "")
|
||||
)
|
||||
object_id = (
|
||||
(_extract_xml_tag_or_attr(finder_feed, "objectid") if finder_feed else "")
|
||||
or _extract_xml_tag_or_attr(text, "objectid")
|
||||
)
|
||||
object_nonce_id = (
|
||||
(_extract_xml_tag_or_attr(finder_feed, "objectnonceid") if finder_feed else "")
|
||||
or _extract_xml_tag_or_attr(text, "objectnonceid")
|
||||
)
|
||||
|
||||
thumb_url = _normalize_xml_url(
|
||||
_extract_xml_tag_or_attr(text, "thumburl")
|
||||
@@ -1277,6 +1285,8 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
"fromUsername": from_u,
|
||||
"linkType": "finder",
|
||||
"linkStyle": "finder",
|
||||
"objectId": str(object_id or "").strip(),
|
||||
"objectNonceId": str(object_nonce_id or "").strip(),
|
||||
}
|
||||
|
||||
if app_type in (33, 36):
|
||||
@@ -2418,6 +2428,8 @@ def _row_to_search_hit(
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
object_id = ""
|
||||
object_nonce_id = ""
|
||||
amount = ""
|
||||
pay_sub_type = ""
|
||||
transfer_status = ""
|
||||
@@ -2441,6 +2453,8 @@ def _row_to_search_hit(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
object_id = str(parsed.get("objectId") or "")
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
amount = str(parsed.get("amount") or "")
|
||||
pay_sub_type = str(parsed.get("paySubType") or "")
|
||||
@@ -2526,6 +2540,8 @@ def _row_to_search_hit(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
object_id = str(parsed.get("objectId") or object_id)
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or object_nonce_id)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
|
||||
quote_username = str(parsed.get("quoteUsername") or quote_username)
|
||||
@@ -2567,6 +2583,8 @@ def _row_to_search_hit(
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"objectId": object_id,
|
||||
"objectNonceId": object_nonce_id,
|
||||
"quoteUsername": quote_username,
|
||||
"quoteTitle": quote_title,
|
||||
"quoteContent": quote_content,
|
||||
|
||||
@@ -30,83 +30,92 @@ from .media_helpers import _resolve_account_dir, _resolve_account_wxid_dir
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ====================== 以下是hook逻辑 ======================================
|
||||
def _summarize_aes_key(value: Any) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if len(raw) <= 8:
|
||||
return raw
|
||||
return f"{raw[:4]}...{raw[-4:]}(len={len(raw)})"
|
||||
|
||||
@dataclass
|
||||
class HookConfig:
|
||||
min_version: str
|
||||
pattern: str
|
||||
mask: str
|
||||
offset: int
|
||||
md5_pattern: str = ""
|
||||
md5_mask: str = ""
|
||||
md5_offset: int = 0
|
||||
|
||||
def _summarize_key_payload(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
payload = payload or {}
|
||||
return {
|
||||
"wxid": str(payload.get("wxid") or "").strip(),
|
||||
"xor_key": str(payload.get("xor_key") or "").strip(),
|
||||
"aes_key": _summarize_aes_key(payload.get("aes_key")),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_wxid_dir_for_image_key(
|
||||
account: Optional[str] = None,
|
||||
*,
|
||||
wxid_dir: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
) -> Path:
|
||||
explicit_wxid_dir = str(wxid_dir or "").strip()
|
||||
if explicit_wxid_dir:
|
||||
candidate = Path(explicit_wxid_dir).expanduser()
|
||||
if candidate.exists() and candidate.is_dir():
|
||||
logger.info("[image_key] 使用显式 wxid_dir: %s", str(candidate))
|
||||
return candidate
|
||||
raise FileNotFoundError(f"指定的 wxid_dir 不存在或不是目录: {candidate}")
|
||||
|
||||
explicit_db_storage_path = str(db_storage_path or "").strip()
|
||||
if explicit_db_storage_path:
|
||||
db_storage_dir = Path(explicit_db_storage_path).expanduser()
|
||||
if db_storage_dir.exists() and db_storage_dir.is_dir():
|
||||
if db_storage_dir.name.lower() == "db_storage":
|
||||
candidate = db_storage_dir.parent
|
||||
if candidate.exists() and candidate.is_dir():
|
||||
logger.info(
|
||||
"[image_key] 通过 db_storage_path 反推出 wxid_dir: db_storage_path=%s wxid_dir=%s",
|
||||
str(db_storage_dir),
|
||||
str(candidate),
|
||||
)
|
||||
return candidate
|
||||
nested_db_storage = db_storage_dir / "db_storage"
|
||||
if nested_db_storage.exists() and nested_db_storage.is_dir():
|
||||
logger.info(
|
||||
"[image_key] db_storage_path 指向 wxid_dir,自动使用其子目录: wxid_dir=%s",
|
||||
str(db_storage_dir),
|
||||
)
|
||||
return db_storage_dir
|
||||
logger.info(
|
||||
"[image_key] 提供的 db_storage_path 无法解析 wxid_dir: %s",
|
||||
explicit_db_storage_path,
|
||||
)
|
||||
|
||||
if account:
|
||||
try:
|
||||
account_dir = _resolve_account_dir(account)
|
||||
wx_id_dir = _resolve_account_wxid_dir(account_dir)
|
||||
if wx_id_dir:
|
||||
logger.info(
|
||||
"[image_key] 通过已解密账号目录解析 wxid_dir: account=%s account_dir=%s wxid_dir=%s",
|
||||
str(account).strip(),
|
||||
str(account_dir),
|
||||
str(wx_id_dir),
|
||||
)
|
||||
return wx_id_dir
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"[image_key] 无法通过已解密账号目录解析 wxid_dir: account=%s error=%s",
|
||||
str(account).strip(),
|
||||
str(e),
|
||||
)
|
||||
|
||||
raise FileNotFoundError("无法定位该账号的 wxid_dir,请传入有效的 db_storage_path 或先完成数据库解密")
|
||||
|
||||
|
||||
# ====================== 以下是hook逻辑 ======================================
|
||||
|
||||
class WeChatKeyFetcher:
|
||||
def __init__(self):
|
||||
self.process_name = "Weixin.exe"
|
||||
self.timeout_seconds = 60
|
||||
|
||||
@staticmethod
|
||||
def _hex_array_to_str(hex_array: List[int]) -> str:
|
||||
return " ".join([f"{b:02X}" for b in hex_array])
|
||||
|
||||
def _get_hook_config(self, version_str: str) -> Optional[HookConfig]:
|
||||
try:
|
||||
v_curr = pkg_version.parse(version_str)
|
||||
except Exception as e:
|
||||
logger.error(f"版本号解析失败: {version_str} || {e}")
|
||||
return None
|
||||
|
||||
|
||||
if v_curr > pkg_version.parse("4.1.6.14"):
|
||||
return HookConfig(
|
||||
min_version=">4.1.6.14",
|
||||
pattern=self._hex_array_to_str([
|
||||
0x24, 0x50, 0x48, 0xC7, 0x45, 0x00, 0xFE, 0xFF, 0xFF, 0xFF,
|
||||
0x44, 0x89, 0xCF, 0x44, 0x89, 0xC3, 0x49, 0x89, 0xD6, 0x48,
|
||||
0x89, 0xCE, 0x48, 0x89
|
||||
]),
|
||||
mask="xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
offset=-3,
|
||||
md5_pattern="48 8D 4D 00 48 89 4D B0 48 89 45 B8 48 8D 7D 00 48 8D 55 B0 48 89 F9",
|
||||
md5_mask="xxx?xxxxxxxxxxx?xxxxxxx",
|
||||
md5_offset=4
|
||||
)
|
||||
|
||||
if pkg_version.parse("4.1.4") <= v_curr <= pkg_version.parse("4.1.6.14"):
|
||||
return HookConfig(
|
||||
min_version="4.1.4-4.1.6.14",
|
||||
pattern=self._hex_array_to_str([
|
||||
0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74,
|
||||
0x00, 0x18, 0x48, 0x89, 0x7c, 0x00, 0x20, 0x41, 0x56, 0x48,
|
||||
0x83, 0xec, 0x50, 0x41
|
||||
]),
|
||||
mask="xxxxxxxxxx?xxxx?xxxxxxxx",
|
||||
offset=-3,
|
||||
md5_pattern="48 8D 4D 00 48 89 4D B0 48 89 45 B8 48 8D 7D 00 48 8D 55 B0 48 89 F9",
|
||||
md5_mask="xxx?xxxxxxxxxxx?xxxxxxx",
|
||||
md5_offset=4
|
||||
)
|
||||
|
||||
if v_curr < pkg_version.parse("4.1.4"):
|
||||
"""图片密钥可能是错的,版本过低没有测试"""
|
||||
return HookConfig(
|
||||
min_version="<4.1.4",
|
||||
pattern=self._hex_array_to_str([
|
||||
0x24, 0x50, 0x48, 0xc7, 0x45, 0x00, 0xfe, 0xff, 0xff, 0xff,
|
||||
0x44, 0x89, 0xcf, 0x44, 0x89, 0xc3, 0x49, 0x89, 0xd6, 0x48,
|
||||
0x89, 0xce, 0x48, 0x89
|
||||
]),
|
||||
mask="xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
offset=-15, # -0xf
|
||||
md5_pattern="48 8D 4D 00 48 89 4D B0 48 89 45 B8 48 8D 7D 00 48 8D 55 B0 48 89 F9",
|
||||
md5_mask="xxx?xxxxxxxxxxx?xxxxxxx",
|
||||
md5_offset=4
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def kill_wechat(self):
|
||||
"""检测并查杀微信进程"""
|
||||
killed = False
|
||||
@@ -125,9 +134,7 @@ class WeChatKeyFetcher:
|
||||
def launch_wechat(self, exe_path: str) -> int:
|
||||
"""启动微信并返回 PID"""
|
||||
try:
|
||||
|
||||
process = subprocess.Popen(exe_path)
|
||||
|
||||
time.sleep(2)
|
||||
candidates = []
|
||||
for proc in psutil.process_iter(['pid', 'name', 'create_time']):
|
||||
@@ -135,7 +142,6 @@ class WeChatKeyFetcher:
|
||||
candidates.append(proc)
|
||||
|
||||
if candidates:
|
||||
|
||||
candidates.sort(key=lambda x: x.info['create_time'], reverse=True)
|
||||
target_pid = candidates[0].info['pid']
|
||||
return target_pid
|
||||
@@ -146,8 +152,8 @@ class WeChatKeyFetcher:
|
||||
logger.error(f"启动微信失败: {e}")
|
||||
raise RuntimeError(f"无法启动微信: {e}")
|
||||
|
||||
def fetch_key(self) -> dict:
|
||||
"""调用 wx_key 获取双密钥"""
|
||||
def fetch_db_key(self) -> dict:
|
||||
"""调用 wx_key 仅获取数据库密钥 (Hook 模式)"""
|
||||
if wx_key is None:
|
||||
raise RuntimeError("wx_key 模块未安装或加载失败")
|
||||
|
||||
@@ -160,36 +166,26 @@ class WeChatKeyFetcher:
|
||||
|
||||
logger.info(f"Detect WeChat: {version} at {exe_path}")
|
||||
|
||||
config = self._get_hook_config(version)
|
||||
if not config:
|
||||
raise RuntimeError(f"原生获取失败:当前微信版本 ({version}) 过低,为保证稳定性,仅支持 4.1.5 及以上版本使用原生获取。")
|
||||
|
||||
self.kill_wechat()
|
||||
pid = self.launch_wechat(exe_path)
|
||||
logger.info(f"WeChat launched, PID: {pid}")
|
||||
|
||||
if not wx_key.initialize_hook(pid, "", config.pattern, config.mask, config.offset,
|
||||
config.md5_pattern, config.md5_mask, config.md5_offset):
|
||||
# 仅传入 PID,触发数据库密钥自动 Hook
|
||||
if not wx_key.initialize_hook(pid):
|
||||
err = wx_key.get_last_error_msg()
|
||||
raise RuntimeError(f"Hook初始化失败: {err}")
|
||||
raise RuntimeError(f"数据库 Hook 初始化失败: {err}")
|
||||
|
||||
start_time = time.time()
|
||||
found_db_key = None
|
||||
found_md5_data = None
|
||||
|
||||
try:
|
||||
while True:
|
||||
if time.time() - start_time > self.timeout_seconds:
|
||||
raise TimeoutError("获取密钥超时 (60s),请确保在弹出的微信中完成登录。")
|
||||
raise TimeoutError("获取数据库密钥超时 (60s),请确保在弹出的微信中完成登录。")
|
||||
|
||||
key_data = wx_key.poll_key_data()
|
||||
if key_data:
|
||||
if 'key' in key_data:
|
||||
found_db_key = key_data['key']
|
||||
if 'md5' in key_data:
|
||||
found_md5_data = key_data['md5']
|
||||
|
||||
if found_db_key and found_md5_data:
|
||||
if key_data and 'key' in key_data:
|
||||
found_db_key = key_data['key']
|
||||
break
|
||||
|
||||
while True:
|
||||
@@ -204,22 +200,13 @@ class WeChatKeyFetcher:
|
||||
logger.info("Cleaning up hook...")
|
||||
wx_key.cleanup_hook()
|
||||
|
||||
aes_key = None # gemini !!! ???
|
||||
xor_key = None
|
||||
|
||||
if found_md5_data and "|" in found_md5_data:
|
||||
aes_key, xor_key_dec = found_md5_data.split("|")
|
||||
xor_key = f"0x{int(xor_key_dec):02X}"
|
||||
|
||||
return {
|
||||
"db_key": found_db_key,
|
||||
"aes_key": aes_key,
|
||||
"xor_key": xor_key
|
||||
"db_key": found_db_key
|
||||
}
|
||||
|
||||
def get_db_key_workflow():
|
||||
fetcher = WeChatKeyFetcher()
|
||||
return fetcher.fetch_key()
|
||||
return fetcher.fetch_db_key()
|
||||
|
||||
|
||||
# ============================== 以下是图片密钥逻辑 =====================================
|
||||
@@ -232,22 +219,159 @@ def get_wechat_internal_global_config(wx_dir: Path, file_name1) -> bytes:
|
||||
return Path(target_path).read_bytes()
|
||||
|
||||
|
||||
def try_get_local_image_keys() -> List[Dict[str, Any]]:
|
||||
"""尝试通过本地算法提取图片密钥 (无需 Hook)"""
|
||||
if wx_key is None or not hasattr(wx_key, 'get_image_key'):
|
||||
logger.info("[image_key] 本地算法不可用:wx_key.get_image_key 缺失")
|
||||
return []
|
||||
|
||||
try:
|
||||
res_json = wx_key.get_image_key()
|
||||
if not res_json:
|
||||
logger.info("[image_key] 本地算法返回空结果")
|
||||
return []
|
||||
|
||||
data = json.loads(res_json)
|
||||
accounts = data.get('accounts', [])
|
||||
results = []
|
||||
for acc in accounts:
|
||||
wxid = acc.get('wxid')
|
||||
keys = acc.get('keys', [])
|
||||
for k in keys:
|
||||
xor_key = k.get('xorKey')
|
||||
aes_key = k.get('aesKey')
|
||||
if xor_key is not None:
|
||||
results.append({
|
||||
"wxid": wxid,
|
||||
"xor_key": f"0x{int(xor_key):02X}",
|
||||
"aes_key": aes_key
|
||||
})
|
||||
logger.info(
|
||||
"[image_key] 本地算法完成:accounts=%s results=%s",
|
||||
len(accounts),
|
||||
[_summarize_key_payload(item) for item in results],
|
||||
)
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"本地提取图片密钥失败: {e}")
|
||||
return []
|
||||
|
||||
async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str, Any]:
|
||||
account_dir = _resolve_account_dir(account)
|
||||
wx_id_dir = _resolve_account_wxid_dir(account_dir)
|
||||
|
||||
async def get_image_key_integrated_workflow(
|
||||
account: Optional[str] = None,
|
||||
*,
|
||||
wxid_dir: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
集成图片密钥获取流程:
|
||||
1. 优先尝试本地算法提取
|
||||
2. 如果本地提取失败或未匹配到指定账号,尝试远程 API 解析
|
||||
"""
|
||||
# 1. 尝试本地提取
|
||||
local_keys = try_get_local_image_keys()
|
||||
|
||||
target_account_wxid = None
|
||||
if account or wxid_dir or db_storage_path:
|
||||
try:
|
||||
resolved_wxid_dir = _resolve_wxid_dir_for_image_key(
|
||||
account,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_path=db_storage_path,
|
||||
)
|
||||
target_account_wxid = resolved_wxid_dir.name
|
||||
except Exception:
|
||||
target_account_wxid = account
|
||||
target_account_wxid = str(target_account_wxid or "").strip().lower()
|
||||
logger.info(
|
||||
"[image_key] 开始集成流程:request_account=%s target_wxid=%s local_key_count=%s db_storage_path=%s wxid_dir=%s",
|
||||
str(account or "").strip(),
|
||||
target_account_wxid,
|
||||
len(local_keys),
|
||||
str(db_storage_path or "").strip(),
|
||||
str(wxid_dir or "").strip(),
|
||||
)
|
||||
|
||||
if local_keys:
|
||||
# 如果指定了账号,尝试在本地结果中找匹配的
|
||||
if target_account_wxid:
|
||||
for k in local_keys:
|
||||
local_wxid = str(k.get("wxid") or "").strip().lower()
|
||||
if local_wxid and local_wxid == target_account_wxid:
|
||||
logger.info(
|
||||
"[image_key] 本地算法精确匹配成功:target_wxid=%s payload=%s",
|
||||
target_account_wxid,
|
||||
_summarize_key_payload(k),
|
||||
)
|
||||
upsert_account_keys_in_store(
|
||||
account=str(k.get("wxid") or "").strip(),
|
||||
image_xor_key=k['xor_key'],
|
||||
image_aes_key=k['aes_key']
|
||||
)
|
||||
return k
|
||||
logger.info(
|
||||
"[image_key] 本地算法未匹配到目标账号:target_wxid=%s local_wxids=%s",
|
||||
target_account_wxid,
|
||||
[str(item.get("wxid") or "").strip() for item in local_keys],
|
||||
)
|
||||
else:
|
||||
# 如果没指定账号,返回第一个发现的并存入 store (如果有的话)
|
||||
k = local_keys[0]
|
||||
logger.info(
|
||||
"[image_key] 未指定账号,返回本地首个结果:payload=%s",
|
||||
_summarize_key_payload(k),
|
||||
)
|
||||
upsert_account_keys_in_store(
|
||||
account=k['wxid'],
|
||||
image_xor_key=k['xor_key'],
|
||||
image_aes_key=k['aes_key']
|
||||
)
|
||||
return k
|
||||
|
||||
# 2. 本地提取失败或不匹配,尝试远程解析
|
||||
logger.info("[image_key] 本地算法未命中,尝试远程 API 解析")
|
||||
return await fetch_and_save_remote_keys(
|
||||
account,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_path=db_storage_path,
|
||||
)
|
||||
|
||||
|
||||
async def fetch_and_save_remote_keys(
|
||||
account: Optional[str] = None,
|
||||
*,
|
||||
wxid_dir: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
wx_id_dir = _resolve_wxid_dir_for_image_key(
|
||||
account,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_path=db_storage_path,
|
||||
)
|
||||
wxid = wx_id_dir.name
|
||||
|
||||
url = "https://view.free.c3o.re/api/key"
|
||||
data = {"weixinIDFolder": wxid}
|
||||
|
||||
logger.info(f"正在为账号 {wxid} 获取云端备选图片密钥...")
|
||||
logger.info(
|
||||
"[image_key] 准备请求远程密钥:request_account=%s resolved_account=%s wxid_dir=%s db_storage_path=%s",
|
||||
str(account or "").strip(),
|
||||
wxid,
|
||||
str(wx_id_dir),
|
||||
str(db_storage_path or "").strip(),
|
||||
)
|
||||
|
||||
try:
|
||||
blob1_bytes = get_wechat_internal_global_config(wx_id_dir, file_name1="global_config")
|
||||
blob2_bytes = get_wechat_internal_global_config(wx_id_dir, file_name1="global_config.crc")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取微信内部文件失败: {e}")
|
||||
logger.info(
|
||||
"[image_key] 远程请求输入文件已读取:wxid=%s global_config_bytes=%s crc_bytes=%s",
|
||||
wxid,
|
||||
len(blob1_bytes),
|
||||
len(blob2_bytes),
|
||||
)
|
||||
|
||||
files = {
|
||||
'fileBytes': ('file', blob1_bytes, 'application/octet-stream'),
|
||||
@@ -255,7 +379,7 @@ async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
logger.info("向云端 API 发送请求...")
|
||||
logger.info("[image_key] 向云端 API 发送请求:url=%s wxid=%s", url, wxid)
|
||||
response = await client.post(url, data=data, files=files)
|
||||
|
||||
if response.status_code != 200:
|
||||
@@ -264,6 +388,15 @@ async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str,
|
||||
config = response.json()
|
||||
if not config:
|
||||
raise RuntimeError("云端解析失败: 返回数据为空")
|
||||
logger.info(
|
||||
"[image_key] 收到远程响应:status_code=%s keys=%s nick_name=%s",
|
||||
response.status_code,
|
||||
{
|
||||
"xor_key": str(config.get("xorKey", config.get("xor_key", ""))),
|
||||
"aes_key": _summarize_aes_key(config.get("aesKey", config.get("aes_key", ""))),
|
||||
},
|
||||
str(config.get("nickName", config.get("nick_name", ""))),
|
||||
)
|
||||
|
||||
# 新 API 的字段兼容处理
|
||||
xor_raw = str(config.get("xorKey", config.get("xor_key", "")))
|
||||
@@ -283,10 +416,16 @@ async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str,
|
||||
image_xor_key=xor_hex_str,
|
||||
image_aes_key=aes_val
|
||||
)
|
||||
logger.info(
|
||||
"[image_key] 远程密钥已保存:account=%s xor_key=%s aes_key=%s",
|
||||
wxid,
|
||||
xor_hex_str,
|
||||
_summarize_aes_key(aes_val),
|
||||
)
|
||||
|
||||
return {
|
||||
"wxid": wxid,
|
||||
"xor_key": xor_hex_str,
|
||||
"aes_key": aes_val,
|
||||
"nick_name": config.get("nickName", config.get("nick_name", ""))
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def create_perf_trace(logger: Any, category: str, **base_fields: Any) -> tuple[str, Callable[[str], None]]:
|
||||
trace_id = f"{category}-{int(time.time() * 1000)}-{threading.get_ident()}"
|
||||
started_at = time.perf_counter()
|
||||
last_at = started_at
|
||||
|
||||
def log(phase: str, **fields: Any) -> None:
|
||||
nonlocal last_at
|
||||
now = time.perf_counter()
|
||||
payload = {
|
||||
**base_fields,
|
||||
**fields,
|
||||
"elapsedMs": round((now - started_at) * 1000.0, 1),
|
||||
"deltaMs": round((now - last_at) * 1000.0, 1),
|
||||
}
|
||||
last_at = now
|
||||
try:
|
||||
payload_text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
payload_text = str(payload)
|
||||
logger.info("[%s] %s %s %s", trace_id, category, phase, payload_text)
|
||||
|
||||
return trace_id, log
|
||||
@@ -73,6 +73,7 @@ from ..app_paths import get_output_dir
|
||||
from ..database_filters import list_countable_database_names
|
||||
from ..key_store import remove_account_keys_from_store
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..perf_trace import create_perf_trace
|
||||
from ..session_last_message import (
|
||||
build_session_last_message_table,
|
||||
get_session_last_message_status,
|
||||
@@ -3049,6 +3050,8 @@ def _append_full_messages_from_rows(
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
object_id = ""
|
||||
object_nonce_id = ""
|
||||
quote_server_id = ""
|
||||
quote_type = ""
|
||||
quote_voice_length = ""
|
||||
@@ -3082,6 +3085,8 @@ def _append_full_messages_from_rows(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
object_id = str(parsed.get("objectId") or "")
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
quote_server_id = str(parsed.get("quoteServerId") or "")
|
||||
quote_type = str(parsed.get("quoteType") or "")
|
||||
@@ -3324,6 +3329,8 @@ def _append_full_messages_from_rows(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
object_id = str(parsed.get("objectId") or object_id)
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or object_nonce_id)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
cover_url = str(parsed.get("coverUrl") or cover_url)
|
||||
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
|
||||
@@ -3382,6 +3389,8 @@ def _append_full_messages_from_rows(
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"objectId": object_id,
|
||||
"objectNonceId": object_nonce_id,
|
||||
"from": from_name,
|
||||
"fromUsername": from_username,
|
||||
"recordItem": record_item,
|
||||
@@ -3990,6 +3999,17 @@ def list_chat_sessions(
|
||||
contact_db_path = account_dir / "contact.db"
|
||||
head_image_db_path = account_dir / "head_image.db"
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
_trace_id, trace = create_perf_trace(
|
||||
logger,
|
||||
"chat.sessions",
|
||||
account=account_dir.name,
|
||||
source=source_norm or "default",
|
||||
limit=int(limit),
|
||||
includeHidden=bool(include_hidden),
|
||||
includeOfficial=bool(include_official),
|
||||
preview=str(preview or ""),
|
||||
)
|
||||
trace("request:start")
|
||||
|
||||
rt_conn = None
|
||||
rows: list[Any]
|
||||
@@ -4114,6 +4134,12 @@ def list_chat_sessions(
|
||||
finally:
|
||||
sconn.close()
|
||||
|
||||
trace(
|
||||
"rows:loaded",
|
||||
rawCount=len(rows or []),
|
||||
realtime=bool(source_norm == "realtime"),
|
||||
)
|
||||
|
||||
filtered: list[Any] = []
|
||||
for r in rows:
|
||||
username = _session_row_get(r, "username", "") or ""
|
||||
@@ -4125,8 +4151,18 @@ def list_chat_sessions(
|
||||
continue
|
||||
filtered.append(r)
|
||||
|
||||
trace(
|
||||
"rows:filtered",
|
||||
filteredCount=len(filtered),
|
||||
)
|
||||
|
||||
raw_usernames = [str(_session_row_get(r, "username", "") or "").strip() for r in filtered]
|
||||
top_flags = _load_contact_top_flags(contact_db_path, raw_usernames)
|
||||
trace(
|
||||
"top-flags:loaded",
|
||||
usernameCount=len(raw_usernames),
|
||||
topCount=sum(1 for value in top_flags.values() if value),
|
||||
)
|
||||
|
||||
def _to_int(v: Any) -> int:
|
||||
try:
|
||||
@@ -4156,6 +4192,12 @@ def list_chat_sessions(
|
||||
|
||||
contact_rows = _load_contact_rows(contact_db_path, usernames)
|
||||
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, usernames)
|
||||
trace(
|
||||
"contacts:loaded",
|
||||
usernameCount=len(usernames),
|
||||
contactRowCount=len(contact_rows),
|
||||
localAvatarCount=len(local_avatar_usernames),
|
||||
)
|
||||
|
||||
# Some sessions (notably enterprise groups / openim-related IDs) may be missing from decrypted contact.db
|
||||
# (or lack nickname/avatar columns). In that case, fall back to WCDB APIs (same as WeFlow) to resolve
|
||||
@@ -4204,6 +4246,12 @@ def list_chat_sessions(
|
||||
wcdb_display_names = {}
|
||||
wcdb_avatar_urls = {}
|
||||
|
||||
trace(
|
||||
"wcdb-fallback:loaded",
|
||||
displayNameCount=len(wcdb_display_names),
|
||||
avatarUrlCount=len(wcdb_avatar_urls),
|
||||
)
|
||||
|
||||
preview_mode = str(preview or "").strip().lower()
|
||||
if preview_mode not in {"latest", "index", "session", "db", "none"}:
|
||||
preview_mode = "latest"
|
||||
@@ -4291,6 +4339,14 @@ def list_chat_sessions(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
trace(
|
||||
"previews:resolved",
|
||||
previewMode=preview_mode,
|
||||
previewCount=len(last_previews),
|
||||
groupSenderDisplayCount=len(group_sender_display_names),
|
||||
unresolvedGroupSenderCount=len(unresolved),
|
||||
)
|
||||
|
||||
sessions: list[dict[str, Any]] = []
|
||||
for r in filtered:
|
||||
username = r["username"]
|
||||
@@ -4408,6 +4464,10 @@ def list_chat_sessions(
|
||||
}
|
||||
)
|
||||
|
||||
trace(
|
||||
"response:ready",
|
||||
sessionCount=len(sessions),
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_dir.name,
|
||||
@@ -4584,6 +4644,8 @@ def _collect_chat_messages(
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
object_id = ""
|
||||
object_nonce_id = ""
|
||||
quote_server_id = ""
|
||||
quote_type = ""
|
||||
quote_voice_length = ""
|
||||
@@ -4617,6 +4679,8 @@ def _collect_chat_messages(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
object_id = str(parsed.get("objectId") or "")
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
quote_server_id = str(parsed.get("quoteServerId") or "")
|
||||
quote_type = str(parsed.get("quoteType") or "")
|
||||
@@ -4838,6 +4902,8 @@ def _collect_chat_messages(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
object_id = str(parsed.get("objectId") or object_id)
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or object_nonce_id)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
cover_url = str(parsed.get("coverUrl") or cover_url)
|
||||
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
|
||||
@@ -4901,6 +4967,8 @@ def _collect_chat_messages(
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"objectId": object_id,
|
||||
"objectNonceId": object_nonce_id,
|
||||
"from": from_name,
|
||||
"fromUsername": from_username,
|
||||
"recordItem": record_item,
|
||||
@@ -5153,11 +5221,24 @@ def list_chat_messages(
|
||||
head_image_db_path = account_dir / "head_image.db"
|
||||
message_resource_db_path = account_dir / "message_resource.db"
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
_trace_id, trace = create_perf_trace(
|
||||
logger,
|
||||
"chat.messages",
|
||||
account=account_dir.name,
|
||||
username=username,
|
||||
source=source_norm or "default",
|
||||
limit=int(limit),
|
||||
offset=int(offset),
|
||||
order=str(order or ""),
|
||||
renderTypes=str(render_types or ""),
|
||||
)
|
||||
trace("request:start")
|
||||
|
||||
db_paths: list[Path] = []
|
||||
if source_norm != "realtime":
|
||||
db_paths = _iter_message_db_paths(account_dir)
|
||||
if not db_paths:
|
||||
trace("response:error", reason="no-message-dbs")
|
||||
return {
|
||||
"status": "error",
|
||||
"account": account_dir.name,
|
||||
@@ -5183,6 +5264,12 @@ def list_chat_messages(
|
||||
resource_conn = None
|
||||
resource_chat_id = None
|
||||
|
||||
trace(
|
||||
"resource-db:resolved",
|
||||
hasResourceDb=bool(resource_conn is not None),
|
||||
resourceChatId=int(resource_chat_id or 0),
|
||||
)
|
||||
|
||||
want_asc = str(order or "").lower() != "desc"
|
||||
|
||||
want_types: Optional[set[str]] = None
|
||||
@@ -5321,6 +5408,16 @@ def list_chat_messages(
|
||||
break
|
||||
scan_take = next_take
|
||||
|
||||
trace(
|
||||
"messages:collected",
|
||||
scanTake=int(scan_take),
|
||||
mergedCount=len(merged),
|
||||
hasMoreAny=bool(has_more_any),
|
||||
senderUsernameCount=len(sender_usernames),
|
||||
quoteUsernameCount=len(quote_usernames),
|
||||
patUsernameCount=len(pat_usernames),
|
||||
)
|
||||
|
||||
# Self-heal (default source only): if the decrypted snapshot has no conversation table yet (new session),
|
||||
# do a one-shot realtime->decrypted sync and re-query once. This avoids "暂无聊天记录" after turning off realtime.
|
||||
if (
|
||||
@@ -5336,6 +5433,7 @@ def list_chat_messages(
|
||||
missing_table = True
|
||||
|
||||
if missing_table:
|
||||
trace("self-heal:missing-table")
|
||||
rt_conn2 = None
|
||||
try:
|
||||
rt_conn2 = WCDB_REALTIME.ensure_connected(account_dir)
|
||||
@@ -5346,6 +5444,7 @@ def list_chat_messages(
|
||||
|
||||
if rt_conn2 is not None:
|
||||
try:
|
||||
trace("self-heal:sync:start")
|
||||
with _realtime_sync_lock(account_dir.name, username):
|
||||
msg_db_path2, table_name2 = _ensure_decrypted_message_table(account_dir, username)
|
||||
_sync_chat_realtime_messages_for_table(
|
||||
@@ -5357,7 +5456,9 @@ def list_chat_messages(
|
||||
max_scan=max(200, int(limit) + 50),
|
||||
backfill_limit=0,
|
||||
)
|
||||
trace("self-heal:sync:end")
|
||||
except Exception:
|
||||
trace("self-heal:sync:error")
|
||||
pass
|
||||
|
||||
(
|
||||
@@ -5377,6 +5478,11 @@ def list_chat_messages(
|
||||
)
|
||||
if want_types is not None:
|
||||
merged = [m for m in merged if _normalize_render_type_key(m.get("renderType")) in want_types]
|
||||
trace(
|
||||
"self-heal:requery:end",
|
||||
mergedCount=len(merged),
|
||||
hasMoreAny=bool(has_more_any),
|
||||
)
|
||||
|
||||
r"""
|
||||
take = int(limit) + int(offset)
|
||||
@@ -5502,6 +5608,8 @@ def list_chat_messages(
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
object_id = ""
|
||||
object_nonce_id = ""
|
||||
quote_server_id = ""
|
||||
quote_type = ""
|
||||
quote_voice_length = ""
|
||||
@@ -5531,6 +5639,8 @@ def list_chat_messages(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
object_id = str(parsed.get("objectId") or "")
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
quote_server_id = str(parsed.get("quoteServerId") or "")
|
||||
quote_type = str(parsed.get("quoteType") or "")
|
||||
@@ -5736,6 +5846,8 @@ def list_chat_messages(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
object_id = str(parsed.get("objectId") or object_id)
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or object_nonce_id)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
cover_url = str(parsed.get("coverUrl") or cover_url)
|
||||
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
|
||||
@@ -5788,6 +5900,8 @@ def list_chat_messages(
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"objectId": object_id,
|
||||
"objectNonceId": object_nonce_id,
|
||||
"from": from_name,
|
||||
"fromUsername": from_username,
|
||||
"recordItem": record_item,
|
||||
@@ -5862,8 +5976,17 @@ def list_chat_messages(
|
||||
if want_asc:
|
||||
page = list(reversed(page))
|
||||
|
||||
trace(
|
||||
"page:sliced",
|
||||
mergedCount=len(merged),
|
||||
pageCount=len(page),
|
||||
hasMore=bool(has_more_global),
|
||||
orderAsc=bool(want_asc),
|
||||
)
|
||||
|
||||
# Hot path optimization: only enrich the page we return.
|
||||
if not page:
|
||||
trace("response:ready", pageCount=0)
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_dir.name,
|
||||
@@ -5937,6 +6060,12 @@ def list_chat_messages(
|
||||
)
|
||||
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
|
||||
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
|
||||
trace(
|
||||
"senders:loaded",
|
||||
uniqSenderCount=len(uniq_senders),
|
||||
senderContactRowCount=len(sender_contact_rows),
|
||||
localSenderAvatarCount=len(local_sender_avatars),
|
||||
)
|
||||
|
||||
# contact.db may not include enterprise/openim contacts (or group chatroom records). WCDB has a more complete
|
||||
# view of display names + avatar URLs, so we use it as a best-effort fallback.
|
||||
@@ -5973,6 +6102,12 @@ def list_chat_messages(
|
||||
chatroom_id=username,
|
||||
sender_usernames=uniq_senders,
|
||||
)
|
||||
trace(
|
||||
"sender-fallbacks:loaded",
|
||||
wcdbDisplayNameCount=len(wcdb_display_names),
|
||||
wcdbAvatarUrlCount=len(wcdb_avatar_urls),
|
||||
groupNicknameCount=len(group_nicknames),
|
||||
)
|
||||
|
||||
for m in messages_window:
|
||||
# If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
|
||||
@@ -6131,6 +6266,12 @@ def list_chat_messages(
|
||||
wcdb_display_names=wcdb_display_names,
|
||||
)
|
||||
|
||||
trace(
|
||||
"response:ready",
|
||||
pageCount=len(page),
|
||||
total=int(offset) + len(page) + (1 if has_more_global else 0),
|
||||
hasMore=bool(has_more_global),
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_dir.name,
|
||||
@@ -7796,6 +7937,8 @@ async def resolve_app_message(
|
||||
"fromUsername": str(parsed.get("fromUsername") or "").strip(),
|
||||
"linkType": str(parsed.get("linkType") or "").strip(),
|
||||
"linkStyle": str(parsed.get("linkStyle") or "").strip(),
|
||||
"objectId": str(parsed.get("objectId") or "").strip(),
|
||||
"objectNonceId": str(parsed.get("objectNonceId") or "").strip(),
|
||||
"size": str(parsed.get("size") or "").strip(),
|
||||
"baseUrl": base_url,
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ from ..media_helpers import (
|
||||
)
|
||||
from ..chat_helpers import _extract_md5_from_packed_info, _load_contact_rows, _pick_avatar_url
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..perf_trace import create_perf_trace
|
||||
from ..wcdb_realtime import WCDB_REALTIME, get_avatar_urls as _wcdb_get_avatar_urls
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -424,6 +425,13 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
account_dir = _resolve_account_dir(account)
|
||||
account_name = str(account_dir.name or "").strip()
|
||||
user_key = str(username or "").strip()
|
||||
_trace_id, trace = create_perf_trace(
|
||||
logger,
|
||||
"chat.avatar",
|
||||
account=account_name,
|
||||
username=user_key,
|
||||
)
|
||||
trace("request:start")
|
||||
|
||||
# 1) Try on-disk cache first (fast path)
|
||||
user_entry = None
|
||||
@@ -436,17 +444,25 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
logger.info(f"[avatar_cache_hit] kind=user account={account_name} username={user_key}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[avatar_cache_error] read user cache failed account={account_name} username={user_key} err={e}")
|
||||
trace(
|
||||
"user-cache:checked",
|
||||
cacheEnabled=bool(is_avatar_cache_enabled()),
|
||||
hasEntry=bool(user_entry),
|
||||
hasFile=bool(cached_file is not None),
|
||||
)
|
||||
|
||||
head_image_db_path = account_dir / "head_image.db"
|
||||
if not head_image_db_path.exists():
|
||||
# No local head_image.db: allow fallback from cached/remote URL path.
|
||||
if cached_file is not None and user_entry:
|
||||
headers = build_avatar_cache_response_headers(user_entry)
|
||||
trace("response:ready", result="user-cache-hit-no-head-image", mediaType=str(user_entry.get("media_type") or ""))
|
||||
return FileResponse(
|
||||
str(cached_file),
|
||||
media_type=str(user_entry.get("media_type") or "application/octet-stream"),
|
||||
headers=headers,
|
||||
)
|
||||
trace("response:error", result="head-image-db-missing")
|
||||
raise HTTPException(status_code=404, detail="head_image.db not found.")
|
||||
|
||||
conn = sqlite3.connect(str(head_image_db_path))
|
||||
@@ -455,6 +471,7 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
"SELECT md5, update_time FROM head_image WHERE username = ? ORDER BY update_time DESC LIMIT 1",
|
||||
(username,),
|
||||
).fetchone()
|
||||
trace("head-image:meta", hasMeta=bool(meta and meta[0] is not None))
|
||||
if meta and meta[0] is not None:
|
||||
db_md5 = str(meta[0] or "").strip().lower()
|
||||
try:
|
||||
@@ -472,6 +489,11 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
if cached_md5 == db_md5 and cached_update == db_update_time:
|
||||
touch_avatar_cache_entry(account_name, str(user_entry.get("cache_key") or ""))
|
||||
headers = build_avatar_cache_response_headers(user_entry)
|
||||
trace(
|
||||
"response:ready",
|
||||
result="user-cache-hit-head-image-matched",
|
||||
mediaType=str(user_entry.get("media_type") or ""),
|
||||
)
|
||||
return FileResponse(
|
||||
str(cached_file),
|
||||
media_type=str(user_entry.get("media_type") or "application/octet-stream"),
|
||||
@@ -487,6 +509,7 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
data = bytes(row[0]) if isinstance(row[0], (memoryview, bytearray)) else row[0]
|
||||
if not isinstance(data, (bytes, bytearray)):
|
||||
data = bytes(data)
|
||||
trace("head-image:blob", bytes=len(data or b""))
|
||||
if data:
|
||||
media_type = _detect_image_media_type(data)
|
||||
media_type = media_type if media_type.startswith("image/") else "application/octet-stream"
|
||||
@@ -505,12 +528,14 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
f"[avatar_cache_download] kind=user account={account_name} username={user_key} src=head_image"
|
||||
)
|
||||
headers = build_avatar_cache_response_headers(entry)
|
||||
trace("response:ready", result="head-image-blob-cache-write", mediaType=media_type, bytes=len(data))
|
||||
return FileResponse(str(out_path), media_type=media_type, headers=headers)
|
||||
|
||||
# cache write failed: fallback to response bytes
|
||||
logger.warning(
|
||||
f"[avatar_cache_error] kind=user account={account_name} username={user_key} action=write_fallback"
|
||||
)
|
||||
trace("response:ready", result="head-image-blob-direct", mediaType=media_type, bytes=len(data))
|
||||
return Response(content=bytes(data), media_type=media_type)
|
||||
|
||||
# meta not found (no local avatar blob)
|
||||
@@ -520,9 +545,16 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
|
||||
# 2) Fallback: remote avatar URL (contact/WCDB), cache by URL.
|
||||
remote_url = _resolve_avatar_remote_url(account_dir=account_dir, username=user_key)
|
||||
trace("remote-url:resolved", hasRemoteUrl=bool(remote_url))
|
||||
if remote_url and is_avatar_cache_enabled():
|
||||
url_entry = get_avatar_cache_url_entry(account_name, remote_url)
|
||||
url_file = avatar_cache_entry_file_exists(account_name, url_entry)
|
||||
trace(
|
||||
"url-cache:checked",
|
||||
hasEntry=bool(url_entry),
|
||||
hasFile=bool(url_file),
|
||||
isFresh=bool(avatar_cache_entry_is_fresh(url_entry) if url_entry else False),
|
||||
)
|
||||
if url_entry and url_file and avatar_cache_entry_is_fresh(url_entry):
|
||||
logger.info(f"[avatar_cache_hit] kind=url account={account_name} username={user_key}")
|
||||
touch_avatar_cache_entry(account_name, str(url_entry.get("cache_key") or ""))
|
||||
@@ -548,6 +580,7 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
except Exception:
|
||||
pass
|
||||
headers = build_avatar_cache_response_headers(url_entry)
|
||||
trace("response:ready", result="url-cache-hit", mediaType=str(url_entry.get("media_type") or ""))
|
||||
return FileResponse(
|
||||
str(url_file),
|
||||
media_type=str(url_entry.get("media_type") or "application/octet-stream"),
|
||||
@@ -624,21 +657,31 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
etag0 = str((url_entry or {}).get("etag") or "").strip()
|
||||
lm0 = str((url_entry or {}).get("last_modified") or "").strip()
|
||||
try:
|
||||
trace("remote-download:start", hasEtag=bool(etag0), hasLastModified=bool(lm0))
|
||||
payload, ct, etag_new, lm_new, not_modified = await asyncio.to_thread(
|
||||
_download_remote_avatar,
|
||||
remote_url,
|
||||
etag=etag0,
|
||||
last_modified=lm0,
|
||||
)
|
||||
trace(
|
||||
"remote-download:end",
|
||||
bytes=len(payload or b""),
|
||||
contentType=str(ct or ""),
|
||||
notModified=bool(not_modified),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[avatar_cache_error] kind=url account={account_name} username={user_key} err={e}")
|
||||
trace("remote-download:error", error=str(e))
|
||||
if url_entry and url_file:
|
||||
headers = build_avatar_cache_response_headers(url_entry)
|
||||
trace("response:ready", result="stale-url-cache-after-download-error")
|
||||
return FileResponse(
|
||||
str(url_file),
|
||||
media_type=str(url_entry.get("media_type") or "application/octet-stream"),
|
||||
headers=headers,
|
||||
)
|
||||
trace("response:error", result="remote-download-failed")
|
||||
raise HTTPException(status_code=404, detail="Avatar not found.")
|
||||
|
||||
if not_modified and url_entry and url_file:
|
||||
@@ -663,6 +706,7 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
pass
|
||||
logger.info(f"[avatar_cache_revalidate] kind=url account={account_name} username={user_key} status=304")
|
||||
headers = build_avatar_cache_response_headers(url_entry)
|
||||
trace("response:ready", result="remote-not-modified", mediaType=str(url_entry.get("media_type") or ""))
|
||||
return FileResponse(
|
||||
str(url_file),
|
||||
media_type=str(url_entry.get("media_type") or "application/octet-stream"),
|
||||
@@ -714,16 +758,19 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
pass
|
||||
logger.info(f"[avatar_cache_download] kind=url account={account_name} username={user_key}")
|
||||
headers = build_avatar_cache_response_headers(entry)
|
||||
trace("response:ready", result="remote-download-cache-write", mediaType=media_type, bytes=len(payload2))
|
||||
return FileResponse(str(out_path), media_type=media_type, headers=headers)
|
||||
|
||||
if cached_file is not None and user_entry:
|
||||
headers = build_avatar_cache_response_headers(user_entry)
|
||||
trace("response:ready", result="stale-user-cache-fallback", mediaType=str(user_entry.get("media_type") or ""))
|
||||
return FileResponse(
|
||||
str(cached_file),
|
||||
media_type=str(user_entry.get("media_type") or "application/octet-stream"),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
trace("response:error", result="not-found")
|
||||
raise HTTPException(status_code=404, detail="Avatar not found.")
|
||||
|
||||
|
||||
@@ -1387,6 +1434,7 @@ async def get_chat_image(
|
||||
account: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
deep_scan: bool = False,
|
||||
prefer_live: bool = False,
|
||||
):
|
||||
if (not md5) and (not file_id) and (not server_id):
|
||||
raise HTTPException(status_code=400, detail="Missing md5/file_id/server_id.")
|
||||
@@ -1396,6 +1444,18 @@ async def get_chat_image(
|
||||
file_id = str(md5)
|
||||
md5 = None
|
||||
account_dir = _resolve_account_dir(account)
|
||||
_trace_id, trace = create_perf_trace(
|
||||
logger,
|
||||
"chat.image",
|
||||
account=account_dir.name,
|
||||
username=str(username or ""),
|
||||
md5=str(md5 or ""),
|
||||
fileId=str(file_id or ""),
|
||||
serverId=int(server_id or 0),
|
||||
deepScan=bool(deep_scan),
|
||||
preferLive=bool(prefer_live),
|
||||
)
|
||||
trace("request:start")
|
||||
|
||||
# Prefer resource md5 derived from message_resource.db for chat history / app messages.
|
||||
# This matches how regular image messages are resolved elsewhere in the codebase.
|
||||
@@ -1409,6 +1469,11 @@ async def get_chat_image(
|
||||
)
|
||||
if md5_from_msg:
|
||||
md5 = md5_from_msg
|
||||
trace(
|
||||
"server-id:resolved",
|
||||
resourceMd5Found=bool(resource_md5),
|
||||
finalMd5=str(md5 or ""),
|
||||
)
|
||||
|
||||
cached_path: Optional[Path] = None
|
||||
cached_data = b""
|
||||
@@ -1430,12 +1495,33 @@ async def get_chat_image(
|
||||
decrypted_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
trace(
|
||||
"decrypted-cache:checked",
|
||||
hasCachedPath=bool(cached_path),
|
||||
cachedBytes=len(cached_data or b""),
|
||||
cachedMediaType=cached_media_type,
|
||||
)
|
||||
|
||||
if cached_path and (not prefer_live):
|
||||
trace(
|
||||
"response:ready",
|
||||
result="decrypted-cache-hit",
|
||||
mediaType=cached_media_type,
|
||||
bytes=len(cached_data or b""),
|
||||
)
|
||||
return _build_cached_media_response(request, cached_data, cached_media_type)
|
||||
|
||||
# 回退:从微信数据目录实时定位并解密
|
||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||
hardlink_db_path = account_dir / "hardlink.db"
|
||||
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
|
||||
hardlink_has_image_table = _hardlink_has_table_prefix(str(hardlink_db_path), "image_hardlink_info")
|
||||
trace(
|
||||
"roots:resolved",
|
||||
hasWxidDir=bool(wxid_dir),
|
||||
hasDbStorageDir=bool(db_storage_dir),
|
||||
hardlinkHasImageTable=bool(hardlink_has_image_table),
|
||||
)
|
||||
|
||||
roots: list[Path] = []
|
||||
if wxid_dir:
|
||||
@@ -1455,9 +1541,11 @@ async def get_chat_image(
|
||||
|
||||
p: Optional[Path] = None
|
||||
candidates: list[Path] = []
|
||||
allow_deep_scan = False
|
||||
|
||||
if md5:
|
||||
p = _resolve_media_path_from_hardlink(
|
||||
p = await asyncio.to_thread(
|
||||
_resolve_media_path_from_hardlink,
|
||||
hardlink_db_path,
|
||||
roots[0],
|
||||
md5=str(md5),
|
||||
@@ -1471,7 +1559,8 @@ async def get_chat_image(
|
||||
for r in [wxid_dir, db_storage_dir]:
|
||||
if not r:
|
||||
continue
|
||||
hit = _fallback_search_media_by_file_id(
|
||||
hit = await asyncio.to_thread(
|
||||
_fallback_search_media_by_file_id,
|
||||
str(r),
|
||||
str(file_id),
|
||||
kind="image",
|
||||
@@ -1483,7 +1572,8 @@ async def get_chat_image(
|
||||
|
||||
# Fast fallback for thumbnails not indexed by hardlink.db: scan only this chat's attach directory.
|
||||
if (not p) and wxid_dir and username:
|
||||
hit = _fast_probe_image_path_in_chat_attach(
|
||||
hit = await asyncio.to_thread(
|
||||
_fast_probe_image_path_in_chat_attach,
|
||||
wxid_dir_str=str(wxid_dir),
|
||||
username=str(username),
|
||||
md5=str(md5),
|
||||
@@ -1496,11 +1586,11 @@ async def get_chat_image(
|
||||
# - hardlink.db doesn't have the image table (older/partial data).
|
||||
allow_deep_scan = bool(deep_scan) or (not hardlink_has_image_table)
|
||||
if (not p) and wxid_dir and allow_deep_scan:
|
||||
hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5), kind="image")
|
||||
hit = await asyncio.to_thread(_fallback_search_media_by_md5, str(wxid_dir), str(md5), kind="image")
|
||||
if hit:
|
||||
p = Path(hit)
|
||||
try:
|
||||
candidates.extend(_iter_media_source_candidates(Path(hit)))
|
||||
candidates.extend(await asyncio.to_thread(_iter_media_source_candidates, Path(hit)))
|
||||
except Exception:
|
||||
pass
|
||||
elif file_id:
|
||||
@@ -1508,7 +1598,8 @@ async def get_chat_image(
|
||||
for r in [wxid_dir, db_storage_dir]:
|
||||
if not r:
|
||||
continue
|
||||
hit = _fallback_search_media_by_file_id(
|
||||
hit = await asyncio.to_thread(
|
||||
_fallback_search_media_by_file_id,
|
||||
str(r),
|
||||
str(file_id),
|
||||
kind="image",
|
||||
@@ -1520,11 +1611,25 @@ async def get_chat_image(
|
||||
|
||||
if not p:
|
||||
if cached_path:
|
||||
trace("response:ready", result="decrypted-cache-fallback", mediaType=cached_media_type, bytes=len(cached_data or b""))
|
||||
return _build_cached_media_response(request, cached_data, cached_media_type)
|
||||
trace(
|
||||
"response:error",
|
||||
result="source-not-found",
|
||||
allowDeepScan=bool(allow_deep_scan),
|
||||
candidateCount=len(candidates),
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Image not found.")
|
||||
|
||||
candidates.extend(_iter_media_source_candidates(p))
|
||||
candidates = _order_media_candidates(candidates)
|
||||
candidates.extend(await asyncio.to_thread(_iter_media_source_candidates, p))
|
||||
candidates = await asyncio.to_thread(_order_media_candidates, candidates)
|
||||
trace(
|
||||
"candidates:resolved",
|
||||
sourcePath=str(p),
|
||||
candidateCount=len(candidates),
|
||||
hasCachedPath=bool(cached_path),
|
||||
allowDeepScan=bool(allow_deep_scan),
|
||||
)
|
||||
|
||||
if cached_path:
|
||||
try:
|
||||
@@ -1554,9 +1659,17 @@ async def get_chat_image(
|
||||
data = b""
|
||||
media_type = "application/octet-stream"
|
||||
chosen: Optional[Path] = None
|
||||
decode_attempts = 0
|
||||
trace("decode:start", candidateCount=len(candidates))
|
||||
for src_path in candidates:
|
||||
decode_attempts += 1
|
||||
try:
|
||||
data, media_type = _read_and_maybe_decrypt_media(src_path, account_dir=account_dir, weixin_root=wxid_dir)
|
||||
data, media_type = await asyncio.to_thread(
|
||||
_read_and_maybe_decrypt_media,
|
||||
src_path,
|
||||
account_dir=account_dir,
|
||||
weixin_root=wxid_dir,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
@@ -1568,18 +1681,32 @@ async def get_chat_image(
|
||||
break
|
||||
|
||||
if not chosen:
|
||||
trace("response:error", result="decode-failed", decodeAttempts=decode_attempts)
|
||||
raise HTTPException(status_code=422, detail="Image found but failed to decode/decrypt.")
|
||||
|
||||
trace(
|
||||
"decode:chosen",
|
||||
decodeAttempts=decode_attempts,
|
||||
chosen=str(chosen),
|
||||
mediaType=media_type,
|
||||
bytes=len(data or b""),
|
||||
)
|
||||
|
||||
# 仅在 md5 有效时缓存到 resource 目录;file_id 可能非常长,避免写入超长文件名
|
||||
if md5 and media_type.startswith("image/"):
|
||||
try:
|
||||
_write_cached_chat_image(account_dir, str(md5), data)
|
||||
await asyncio.to_thread(_write_cached_chat_image, account_dir, str(md5), data)
|
||||
trace("decrypted-cache:write", skipped=False)
|
||||
except Exception:
|
||||
trace("decrypted-cache:write", skipped=False, error=True)
|
||||
pass
|
||||
else:
|
||||
trace("decrypted-cache:write", skipped=True)
|
||||
|
||||
logger.info(
|
||||
f"chat_image: md5={md5} file_id={file_id} chosen={chosen} media_type={media_type} bytes={len(data)}"
|
||||
)
|
||||
trace("response:ready", result="decoded", mediaType=media_type, bytes=len(data or b""))
|
||||
return _build_cached_media_response(request, data, media_type)
|
||||
|
||||
|
||||
|
||||
@@ -2,12 +2,23 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..logging_config import get_logger
|
||||
from ..key_store import get_account_keys_from_store
|
||||
from ..key_service import get_db_key_workflow, fetch_and_save_remote_keys
|
||||
from ..key_service import get_db_key_workflow, get_image_key_integrated_workflow
|
||||
from ..media_helpers import _load_media_keys, _resolve_account_dir
|
||||
from ..path_fix import PathFixRoute
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _summarize_aes_key(value: str) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if len(raw) <= 8:
|
||||
return raw
|
||||
return f"{raw[:4]}...{raw[-4:]}(len={len(raw)})"
|
||||
|
||||
|
||||
@router.get("/api/keys", summary="获取账号已保存的密钥")
|
||||
@@ -23,6 +34,13 @@ async def get_saved_keys(account: Optional[str] = None):
|
||||
# 账号可能尚未解密;仍允许从全局 store 读取(如果传入了 account)
|
||||
account_name = str(account or "").strip() or None
|
||||
|
||||
logger.info(
|
||||
"[keys] get_saved_keys start: request_account=%s resolved_account=%s account_dir=%s",
|
||||
str(account or "").strip(),
|
||||
str(account_name or ""),
|
||||
str(account_dir) if account_dir else "",
|
||||
)
|
||||
|
||||
keys: dict = {}
|
||||
if account_name:
|
||||
keys = get_account_keys_from_store(account_name)
|
||||
@@ -45,6 +63,14 @@ async def get_saved_keys(account: Optional[str] = None):
|
||||
"image_aes_key": str(keys.get("image_aes_key") or "").strip(),
|
||||
"updated_at": str(keys.get("updated_at") or "").strip(),
|
||||
}
|
||||
logger.info(
|
||||
"[keys] get_saved_keys done: account=%s db_key_present=%s xor_key=%s aes_key=%s updated_at=%s",
|
||||
str(account_name or ""),
|
||||
bool(result["db_key"]),
|
||||
result["image_xor_key"],
|
||||
_summarize_aes_key(result["image_aes_key"]),
|
||||
result["updated_at"],
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -87,7 +113,11 @@ async def get_wechat_db_key():
|
||||
|
||||
|
||||
@router.get("/api/get_image_key", summary="获取并保存微信图片密钥")
|
||||
async def get_image_key(account: Optional[str] = None):
|
||||
async def get_image_key(
|
||||
account: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
wxid_dir: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
通过模拟 Next.js Server Action 协议,利用本地微信配置文件换取 AES/XOR 密钥。
|
||||
|
||||
@@ -97,7 +127,24 @@ async def get_image_key(account: Optional[str] = None):
|
||||
4. 解析返回流,自动存入本地数据库
|
||||
"""
|
||||
try:
|
||||
result = await fetch_and_save_remote_keys(account)
|
||||
logger.info(
|
||||
"[keys] get_image_key start: request_account=%s db_storage_path=%s wxid_dir=%s",
|
||||
str(account or "").strip(),
|
||||
str(db_storage_path or "").strip(),
|
||||
str(wxid_dir or "").strip(),
|
||||
)
|
||||
result = await get_image_key_integrated_workflow(
|
||||
account,
|
||||
db_storage_path=db_storage_path,
|
||||
wxid_dir=wxid_dir,
|
||||
)
|
||||
logger.info(
|
||||
"[keys] get_image_key done: request_account=%s response_account=%s xor_key=%s aes_key=%s",
|
||||
str(account or "").strip(),
|
||||
str(result.get("wxid") or "").strip(),
|
||||
str(result.get("xor_key") or "").strip(),
|
||||
_summarize_aes_key(str(result.get("aes_key") or "").strip()),
|
||||
)
|
||||
|
||||
return {
|
||||
"status": 0,
|
||||
@@ -105,11 +152,17 @@ async def get_image_key(account: Optional[str] = None):
|
||||
"data": {
|
||||
"xor_key": result["xor_key"],
|
||||
"aes_key": result["aes_key"],
|
||||
"nick_name": result.get("nick_name"),
|
||||
"account": result["wxid"]
|
||||
"nick_name": result.get("nick_name", ""),
|
||||
"account": result.get("wxid", "")
|
||||
}
|
||||
}
|
||||
except FileNotFoundError as e:
|
||||
logger.exception(
|
||||
"[keys] get_image_key file missing: request_account=%s db_storage_path=%s wxid_dir=%s",
|
||||
str(account or "").strip(),
|
||||
str(db_storage_path or "").strip(),
|
||||
str(wxid_dir or "").strip(),
|
||||
)
|
||||
return {
|
||||
"status": -1,
|
||||
"errmsg": f"文件缺失: {str(e)}",
|
||||
@@ -118,6 +171,12 @@ async def get_image_key(account: Optional[str] = None):
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.exception(
|
||||
"[keys] get_image_key failed: request_account=%s db_storage_path=%s wxid_dir=%s",
|
||||
str(account or "").strip(),
|
||||
str(db_storage_path or "").strip(),
|
||||
str(wxid_dir or "").strip(),
|
||||
)
|
||||
return {
|
||||
"status": -1,
|
||||
"errmsg": f"获取失败: {str(e)}",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ import binascii
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -128,6 +130,10 @@ _loaded_wcdb_api_dll: Optional[Path] = None
|
||||
_preloaded_native_libs: list[ctypes.CDLL] = []
|
||||
_protection_checked = False
|
||||
_protection_result: Optional[tuple[int, str]] = None
|
||||
_AUTO_SIDECAR_LOCK = threading.Lock()
|
||||
_AUTO_SIDECAR_PROC: Optional[subprocess.Popen] = None
|
||||
_AUTO_SIDECAR_URL = ""
|
||||
_AUTO_SIDECAR_TOKEN = ""
|
||||
|
||||
|
||||
def _is_windows() -> bool:
|
||||
@@ -238,6 +244,197 @@ def _sidecar_enabled() -> bool:
|
||||
return bool(_sidecar_url())
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _source_sidecar_assets() -> tuple[Path, Path, Path] | None:
|
||||
if getattr(sys, "frozen", False):
|
||||
return None
|
||||
|
||||
repo_root = _repo_root()
|
||||
electron_exe = repo_root / "desktop" / "node_modules" / "electron" / "dist" / "electron.exe"
|
||||
sidecar_script = repo_root / "desktop" / "src" / "wcdb-sidecar.cjs"
|
||||
koffi_dir = repo_root / "desktop" / "vendor" / "koffi"
|
||||
|
||||
try:
|
||||
if electron_exe.is_file() and sidecar_script.is_file() and koffi_dir.exists():
|
||||
return electron_exe, sidecar_script, koffi_dir
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _auto_sidecar_started_here() -> bool:
|
||||
with _AUTO_SIDECAR_LOCK:
|
||||
return bool(_AUTO_SIDECAR_URL and _AUTO_SIDECAR_TOKEN)
|
||||
|
||||
|
||||
def _parse_port(value: object) -> Optional[int]:
|
||||
try:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
port = int(raw, 10)
|
||||
except Exception:
|
||||
return None
|
||||
if 1 <= port <= 65535:
|
||||
return port
|
||||
return None
|
||||
|
||||
|
||||
def _pick_free_port() -> int:
|
||||
requested = _parse_port(os.environ.get("WECHAT_TOOL_WCDB_SIDECAR_PORT"))
|
||||
if requested is not None:
|
||||
return requested
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen(1)
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _build_auto_sidecar_resource_paths(wcdb_api_dll: Path) -> list[str]:
|
||||
items: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(path: str | Path | None) -> None:
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
resolved = Path(path).resolve()
|
||||
except Exception:
|
||||
resolved = Path(path)
|
||||
key = str(resolved).replace("/", "\\").rstrip("\\").lower()
|
||||
if not key or key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
items.append(str(resolved))
|
||||
|
||||
repo_root = _repo_root()
|
||||
dll_dir = wcdb_api_dll.parent
|
||||
add(dll_dir)
|
||||
add(dll_dir.parent)
|
||||
add(repo_root)
|
||||
add(repo_root / "resources")
|
||||
|
||||
data_dir = str(os.environ.get("WECHAT_TOOL_DATA_DIR", "") or "").strip()
|
||||
if data_dir:
|
||||
add(data_dir)
|
||||
add(Path(data_dir) / "resources")
|
||||
else:
|
||||
add(Path.cwd())
|
||||
add(Path.cwd() / "resources")
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _stop_auto_sidecar() -> None:
|
||||
global _AUTO_SIDECAR_PROC, _AUTO_SIDECAR_URL, _AUTO_SIDECAR_TOKEN
|
||||
|
||||
with _AUTO_SIDECAR_LOCK:
|
||||
proc = _AUTO_SIDECAR_PROC
|
||||
owned_url = _AUTO_SIDECAR_URL
|
||||
owned_token = _AUTO_SIDECAR_TOKEN
|
||||
_AUTO_SIDECAR_PROC = None
|
||||
_AUTO_SIDECAR_URL = ""
|
||||
_AUTO_SIDECAR_TOKEN = ""
|
||||
|
||||
if owned_url and os.environ.get("WECHAT_TOOL_WCDB_SIDECAR_URL") == owned_url:
|
||||
os.environ.pop("WECHAT_TOOL_WCDB_SIDECAR_URL", None)
|
||||
if owned_token and os.environ.get("WECHAT_TOOL_WCDB_SIDECAR_TOKEN") == owned_token:
|
||||
os.environ.pop("WECHAT_TOOL_WCDB_SIDECAR_TOKEN", None)
|
||||
|
||||
if proc is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5.0)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _maybe_start_auto_sidecar() -> bool:
|
||||
global _AUTO_SIDECAR_PROC, _AUTO_SIDECAR_URL, _AUTO_SIDECAR_TOKEN
|
||||
|
||||
if _sidecar_enabled() or not _is_windows():
|
||||
return False
|
||||
|
||||
assets = _source_sidecar_assets()
|
||||
if not assets:
|
||||
return False
|
||||
|
||||
wcdb_api_dll = _resolve_wcdb_api_dll_path()
|
||||
try:
|
||||
if not wcdb_api_dll.exists():
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
electron_exe, sidecar_script, koffi_dir = assets
|
||||
repo_root = _repo_root()
|
||||
|
||||
with _AUTO_SIDECAR_LOCK:
|
||||
proc = _AUTO_SIDECAR_PROC
|
||||
if proc is not None and proc.poll() is None and _AUTO_SIDECAR_URL and _AUTO_SIDECAR_TOKEN:
|
||||
os.environ["WECHAT_TOOL_WCDB_SIDECAR_URL"] = _AUTO_SIDECAR_URL
|
||||
os.environ["WECHAT_TOOL_WCDB_SIDECAR_TOKEN"] = _AUTO_SIDECAR_TOKEN
|
||||
return True
|
||||
|
||||
if proc is not None and proc.poll() is not None:
|
||||
_AUTO_SIDECAR_PROC = None
|
||||
_AUTO_SIDECAR_URL = ""
|
||||
_AUTO_SIDECAR_TOKEN = ""
|
||||
|
||||
port = _pick_free_port()
|
||||
token = os.urandom(24).hex()
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"ELECTRON_RUN_AS_NODE": "1",
|
||||
"WECHAT_TOOL_WCDB_SIDECAR_HOST": "127.0.0.1",
|
||||
"WECHAT_TOOL_WCDB_SIDECAR_PORT": str(port),
|
||||
"WECHAT_TOOL_WCDB_SIDECAR_TOKEN": token,
|
||||
"WECHAT_TOOL_WCDB_API_DLL_PATH": str(wcdb_api_dll),
|
||||
"WECHAT_TOOL_WCDB_DLL_DIR": str(wcdb_api_dll.parent),
|
||||
"WECHAT_TOOL_WCDB_RESOURCE_PATHS": json.dumps(
|
||||
_build_auto_sidecar_resource_paths(wcdb_api_dll), ensure_ascii=False
|
||||
),
|
||||
"WECHAT_TOOL_KOFFI_DIR": str(koffi_dir),
|
||||
}
|
||||
)
|
||||
|
||||
creationflags = int(getattr(subprocess, "CREATE_NO_WINDOW", 0) or 0)
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[str(electron_exe), str(sidecar_script)],
|
||||
cwd=str(repo_root),
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[wcdb] auto sidecar start failed: %s", exc)
|
||||
return False
|
||||
|
||||
_AUTO_SIDECAR_PROC = proc
|
||||
_AUTO_SIDECAR_URL = url
|
||||
_AUTO_SIDECAR_TOKEN = token
|
||||
os.environ["WECHAT_TOOL_WCDB_SIDECAR_URL"] = url
|
||||
os.environ["WECHAT_TOOL_WCDB_SIDECAR_TOKEN"] = token
|
||||
|
||||
logger.info("[wcdb] auto-started electron sidecar url=%s dll=%s", _AUTO_SIDECAR_URL, wcdb_api_dll)
|
||||
return True
|
||||
|
||||
|
||||
def _sidecar_call(action: str, payload: Optional[dict[str, Any]] = None, *, timeout: float = 30.0) -> dict[str, Any]:
|
||||
base_url = _sidecar_url()
|
||||
if not base_url:
|
||||
@@ -476,30 +673,37 @@ def _load_wcdb_lib() -> ctypes.CDLL:
|
||||
|
||||
def _ensure_initialized() -> None:
|
||||
global _initialized, _loaded_wcdb_api_dll, _protection_result
|
||||
_maybe_start_auto_sidecar()
|
||||
if _sidecar_enabled():
|
||||
with _lib_lock:
|
||||
if _initialized:
|
||||
return
|
||||
result = _sidecar_call("init", timeout=30.0)
|
||||
dll_path = str(result.get("dllPath") or "").strip()
|
||||
if dll_path:
|
||||
try:
|
||||
_loaded_wcdb_api_dll = Path(dll_path)
|
||||
except Exception:
|
||||
pass
|
||||
protection = result.get("protection")
|
||||
if isinstance(protection, list):
|
||||
for item in protection:
|
||||
if isinstance(item, dict) and "rc" in item:
|
||||
try:
|
||||
_protection_result = (int(item.get("rc")), str(item.get("path") or ""))
|
||||
if int(item.get("rc")) == 0:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
with _lib_lock:
|
||||
_initialized = True
|
||||
return
|
||||
try:
|
||||
result = _sidecar_call("init", timeout=30.0)
|
||||
dll_path = str(result.get("dllPath") or "").strip()
|
||||
if dll_path:
|
||||
try:
|
||||
_loaded_wcdb_api_dll = Path(dll_path)
|
||||
except Exception:
|
||||
pass
|
||||
protection = result.get("protection")
|
||||
if isinstance(protection, list):
|
||||
for item in protection:
|
||||
if isinstance(item, dict) and "rc" in item:
|
||||
try:
|
||||
_protection_result = (int(item.get("rc")), str(item.get("path") or ""))
|
||||
if int(item.get("rc")) == 0:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
with _lib_lock:
|
||||
_initialized = True
|
||||
return
|
||||
except Exception:
|
||||
if not _auto_sidecar_started_here():
|
||||
raise
|
||||
logger.warning("[wcdb] auto sidecar init failed, fallback to in-process wcdb")
|
||||
_stop_auto_sidecar()
|
||||
|
||||
lib = _load_wcdb_lib()
|
||||
with _lib_lock:
|
||||
@@ -1188,13 +1392,15 @@ def shutdown() -> None:
|
||||
global _initialized
|
||||
if _sidecar_enabled():
|
||||
with _lib_lock:
|
||||
if not _initialized:
|
||||
return
|
||||
should_shutdown = bool(_initialized)
|
||||
try:
|
||||
_sidecar_call("shutdown", timeout=5.0)
|
||||
if should_shutdown:
|
||||
_sidecar_call("shutdown", timeout=5.0)
|
||||
finally:
|
||||
with _lib_lock:
|
||||
_initialized = False
|
||||
if _auto_sidecar_started_here():
|
||||
_stop_auto_sidecar()
|
||||
return
|
||||
|
||||
lib = _load_wcdb_lib()
|
||||
@@ -1205,6 +1411,8 @@ def shutdown() -> None:
|
||||
lib.wcdb_shutdown()
|
||||
finally:
|
||||
_initialized = False
|
||||
if _auto_sidecar_started_here():
|
||||
_stop_auto_sidecar()
|
||||
|
||||
|
||||
def _resolve_session_db_path(db_storage_dir: Path) -> Path:
|
||||
|
||||
@@ -16,6 +16,37 @@ from datetime import datetime
|
||||
from .database_filters import should_skip_source_database
|
||||
|
||||
|
||||
COMMON_WECHAT_PATTERNS = [
|
||||
"WeChat Files",
|
||||
"Weixin Files",
|
||||
"wechat_files",
|
||||
"xwechat_files",
|
||||
"wechatMSG",
|
||||
"WeChat",
|
||||
"微信",
|
||||
"Weixin",
|
||||
"wechat",
|
||||
]
|
||||
|
||||
SYSTEM_SCAN_SKIP_NAMES = {
|
||||
"$recycle.bin",
|
||||
"$winreagent",
|
||||
"config.msi",
|
||||
"documents and settings",
|
||||
"intel",
|
||||
"onedrivetemp",
|
||||
"perflogs",
|
||||
"program files",
|
||||
"program files (x86)",
|
||||
"programdata",
|
||||
"recovery",
|
||||
"system volume information",
|
||||
"windows",
|
||||
"windows.old",
|
||||
"windows.old(1)",
|
||||
}
|
||||
|
||||
|
||||
def get_wx_db(msg_dir: str = None,
|
||||
db_types: Union[List[str], str] = None,
|
||||
wxids: Union[List[str], str] = None) -> List[dict]:
|
||||
@@ -285,6 +316,87 @@ def get_process_list():
|
||||
return process_list
|
||||
|
||||
|
||||
def _is_wechat_dir_candidate_name(name: str) -> bool:
|
||||
normalized = str(name or "").strip().lower()
|
||||
if not normalized:
|
||||
return False
|
||||
return any(pattern.lower() in normalized for pattern in COMMON_WECHAT_PATTERNS)
|
||||
|
||||
|
||||
def _safe_iter_subdirs(directory: str) -> List[tuple[str, str]]:
|
||||
items: List[tuple[str, str]] = []
|
||||
try:
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
try:
|
||||
if entry.is_dir():
|
||||
items.append((entry.name, entry.path))
|
||||
except OSError:
|
||||
continue
|
||||
except (PermissionError, OSError):
|
||||
return []
|
||||
return items
|
||||
|
||||
|
||||
def _append_detected_dir(detected_dirs: List[str], candidate: str) -> None:
|
||||
if not candidate:
|
||||
return
|
||||
normalized = os.path.normpath(candidate)
|
||||
if normalized not in detected_dirs:
|
||||
detected_dirs.append(normalized)
|
||||
|
||||
|
||||
def _build_auto_detect_scan_paths() -> List[str]:
|
||||
scan_paths: List[str] = []
|
||||
seen_paths = set()
|
||||
|
||||
def add(path_value: str | None) -> None:
|
||||
raw = str(path_value or "").strip()
|
||||
if not raw:
|
||||
return
|
||||
normalized = os.path.normpath(raw)
|
||||
key = normalized.lower()
|
||||
if key in seen_paths:
|
||||
return
|
||||
seen_paths.add(key)
|
||||
scan_paths.append(normalized)
|
||||
|
||||
home_dir = str(Path.home())
|
||||
add(home_dir)
|
||||
add(os.path.join(home_dir, "Documents"))
|
||||
add(os.path.join(home_dir, "Desktop"))
|
||||
add(os.path.join(home_dir, "Downloads"))
|
||||
|
||||
user_profile = str(os.environ.get("USERPROFILE") or "").strip()
|
||||
if user_profile:
|
||||
add(user_profile)
|
||||
add(os.path.join(user_profile, "Documents"))
|
||||
add(os.path.join(user_profile, "Desktop"))
|
||||
add(os.path.join(user_profile, "Downloads"))
|
||||
|
||||
for drive in ("C:", "D:", "E:", "F:"):
|
||||
drive_root = drive + os.sep
|
||||
if not os.path.exists(drive_root):
|
||||
continue
|
||||
|
||||
add(drive_root)
|
||||
|
||||
for child_name, child_path in _safe_iter_subdirs(drive_root):
|
||||
if child_name.strip().lower() in SYSTEM_SCAN_SKIP_NAMES:
|
||||
continue
|
||||
add(child_path)
|
||||
|
||||
users_dir = os.path.join(drive_root, "Users")
|
||||
add(users_dir)
|
||||
for _user_name, user_dir in _safe_iter_subdirs(users_dir):
|
||||
add(user_dir)
|
||||
add(os.path.join(user_dir, "Documents"))
|
||||
add(os.path.join(user_dir, "Desktop"))
|
||||
add(os.path.join(user_dir, "Downloads"))
|
||||
|
||||
return scan_paths
|
||||
|
||||
|
||||
def auto_detect_wechat_data_dirs():
|
||||
"""
|
||||
自动检测微信数据目录 - 多策略组合检测
|
||||
@@ -292,52 +404,27 @@ def auto_detect_wechat_data_dirs():
|
||||
"""
|
||||
detected_dirs = []
|
||||
|
||||
# 策略1:注册表检测已移除
|
||||
|
||||
# 策略2和策略3:注册表相关检测已移除
|
||||
|
||||
# 策略1:常见驱动器扫描微信相关目录
|
||||
common_wechat_patterns = [
|
||||
"WeChat Files", "wechat_files", "xwechat_files", "wechatMSG",
|
||||
"WeChat", "微信", "Weixin", "wechat"
|
||||
]
|
||||
|
||||
# 扫描常见驱动器
|
||||
drives = ['C:', 'D:', 'E:', 'F:']
|
||||
for drive in drives:
|
||||
if not os.path.exists(drive):
|
||||
# 策略1:常见驱动器 / 用户目录 / 自定义目录的浅层扫描。
|
||||
# 这里既检查扫描根目录本身,也检查其直接子目录,兼容:
|
||||
# - C:\Users\<user>\Documents\WeChat Files
|
||||
# - D:\wechatMSG\xwechat_files
|
||||
# - D:\abc\wechatMSG\xwechat_files
|
||||
for scan_path in _build_auto_detect_scan_paths():
|
||||
if not os.path.exists(scan_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
# 扫描驱动器根目录和常见目录
|
||||
scan_paths = [
|
||||
drive + os.sep,
|
||||
os.path.join(drive + os.sep, "Users"),
|
||||
]
|
||||
scan_name = os.path.basename(os.path.normpath(scan_path))
|
||||
if _is_wechat_dir_candidate_name(scan_name) and has_wxid_directories(scan_path):
|
||||
_append_detected_dir(detected_dirs, scan_path)
|
||||
print(f"[DEBUG] 目录扫描检测成功: {scan_path}")
|
||||
|
||||
for scan_path in scan_paths:
|
||||
if not os.path.exists(scan_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
for item in os.listdir(scan_path):
|
||||
item_path = os.path.join(scan_path, item)
|
||||
if not os.path.isdir(item_path):
|
||||
continue
|
||||
|
||||
# 检查是否匹配微信目录模式
|
||||
for pattern in common_wechat_patterns:
|
||||
if pattern.lower() in item.lower():
|
||||
# 检查是否包含wxid目录
|
||||
if has_wxid_directories(item_path):
|
||||
if item_path not in detected_dirs:
|
||||
detected_dirs.append(item_path)
|
||||
print(f"[DEBUG] 目录扫描检测成功: {item_path}")
|
||||
break
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
for item_name, item_path in _safe_iter_subdirs(scan_path):
|
||||
if not _is_wechat_dir_candidate_name(item_name):
|
||||
continue
|
||||
if not has_wxid_directories(item_path):
|
||||
continue
|
||||
_append_detected_dir(detected_dirs, item_path)
|
||||
print(f"[DEBUG] 目录扫描检测成功: {item_path}")
|
||||
|
||||
# 策略2:进程内存分析(简化版)
|
||||
try:
|
||||
@@ -361,12 +448,11 @@ def auto_detect_wechat_data_dirs():
|
||||
break
|
||||
|
||||
for parent_dir in parent_dirs:
|
||||
for pattern in common_wechat_patterns:
|
||||
for pattern in COMMON_WECHAT_PATTERNS:
|
||||
potential_dir = os.path.join(parent_dir, pattern)
|
||||
if os.path.exists(potential_dir) and has_wxid_directories(potential_dir):
|
||||
if potential_dir not in detected_dirs:
|
||||
detected_dirs.append(potential_dir)
|
||||
print(f"[DEBUG] 进程分析检测成功: {potential_dir}")
|
||||
_append_detected_dir(detected_dirs, potential_dir)
|
||||
print(f"[DEBUG] 进程分析检测成功: {potential_dir}")
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import importlib
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestChatExportCancelResponsiveness(unittest.TestCase):
|
||||
def _reload_export_module(self):
|
||||
import wechat_decrypt_tool.chat_export_service as chat_export_service
|
||||
|
||||
importlib.reload(chat_export_service)
|
||||
return chat_export_service
|
||||
|
||||
def test_json_writer_checks_cancel_between_messages(self):
|
||||
svc = self._reload_export_module()
|
||||
job = svc.ExportJob(export_id="exp_cancel", account="wxid_test", status="running")
|
||||
rows = [
|
||||
svc._Row(
|
||||
db_stem="message_0",
|
||||
table_name="msg_demo",
|
||||
local_id=1,
|
||||
server_id=1001,
|
||||
local_type=1,
|
||||
sort_seq=1,
|
||||
create_time=1735689601,
|
||||
raw_text="第一条",
|
||||
sender_username="wxid_friend",
|
||||
is_sent=False,
|
||||
),
|
||||
svc._Row(
|
||||
db_stem="message_0",
|
||||
table_name="msg_demo",
|
||||
local_id=2,
|
||||
server_id=1002,
|
||||
local_type=1,
|
||||
sort_seq=2,
|
||||
create_time=1735689602,
|
||||
raw_text="第二条",
|
||||
sender_username="wxid_friend",
|
||||
is_sent=False,
|
||||
),
|
||||
]
|
||||
|
||||
original_iter = svc._iter_rows_for_conversation
|
||||
original_parse = svc._parse_message_for_export
|
||||
try:
|
||||
def fake_iter_rows_for_conversation(**_kwargs):
|
||||
yield rows[0]
|
||||
job.cancel_requested = True
|
||||
yield rows[1]
|
||||
|
||||
def fake_parse_message_for_export(**kwargs):
|
||||
row = kwargs["row"]
|
||||
return {
|
||||
"id": f"{row.db_stem}:{row.table_name}:{row.local_id}",
|
||||
"localId": row.local_id,
|
||||
"serverId": row.server_id,
|
||||
"createTime": row.create_time,
|
||||
"createTimeText": "2025-01-01 08:00:00",
|
||||
"sortSeq": row.sort_seq,
|
||||
"type": row.local_type,
|
||||
"renderType": "text",
|
||||
"isSent": bool(row.is_sent),
|
||||
"senderUsername": row.sender_username,
|
||||
"conversationUsername": kwargs["conv_username"],
|
||||
"isGroup": False,
|
||||
"content": row.raw_text,
|
||||
"title": "",
|
||||
"url": "",
|
||||
"from": "",
|
||||
"fromUsername": "",
|
||||
"linkType": "",
|
||||
"linkStyle": "",
|
||||
"objectId": "",
|
||||
"objectNonceId": "",
|
||||
"recordItem": "",
|
||||
"thumbUrl": "",
|
||||
"imageMd5": "",
|
||||
"imageFileId": "",
|
||||
"imageMd5Candidates": [],
|
||||
"imageFileIdCandidates": [],
|
||||
"imageUrl": "",
|
||||
"emojiMd5": "",
|
||||
"emojiUrl": "",
|
||||
"videoMd5": "",
|
||||
"videoThumbMd5": "",
|
||||
"videoFileId": "",
|
||||
"videoThumbFileId": "",
|
||||
"videoUrl": "",
|
||||
"videoThumbUrl": "",
|
||||
"voiceLength": "",
|
||||
"quoteUsername": "",
|
||||
"quoteServerId": "",
|
||||
"quoteType": "",
|
||||
"quoteThumbUrl": "",
|
||||
"quoteVoiceLength": "",
|
||||
"quoteTitle": "",
|
||||
"quoteContent": "",
|
||||
"amount": "",
|
||||
"coverUrl": "",
|
||||
"fileSize": "",
|
||||
"fileMd5": "",
|
||||
"paySubType": "",
|
||||
"transferStatus": "",
|
||||
"transferId": "",
|
||||
"voipType": "",
|
||||
"locationLat": None,
|
||||
"locationLng": None,
|
||||
"locationPoiname": "",
|
||||
"locationLabel": "",
|
||||
}
|
||||
|
||||
svc._iter_rows_for_conversation = fake_iter_rows_for_conversation
|
||||
svc._parse_message_for_export = fake_parse_message_for_export
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
zip_path = Path(td) / "out.zip"
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
with self.assertRaises(svc._JobCancelled):
|
||||
svc._write_conversation_json(
|
||||
zf=zf,
|
||||
conv_dir="conversations/demo",
|
||||
account_dir=Path(td),
|
||||
conv_username="wxid_friend",
|
||||
conv_name="测试好友",
|
||||
conv_avatar_path="",
|
||||
conv_is_group=False,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
want_types=None,
|
||||
local_types=None,
|
||||
resource_conn=None,
|
||||
resource_chat_id=None,
|
||||
head_image_conn=None,
|
||||
resolve_display_name=lambda username: username,
|
||||
privacy_mode=False,
|
||||
include_media=False,
|
||||
media_kinds=[],
|
||||
media_written={},
|
||||
avatar_written={},
|
||||
report={"errors": [], "missingMedia": []},
|
||||
allow_process_key_extract=False,
|
||||
media_db_path=Path(td) / "media_0.db",
|
||||
media_index=None,
|
||||
job=job,
|
||||
lock=threading.Lock(),
|
||||
)
|
||||
self.assertEqual(job.progress.messages_exported, 1)
|
||||
self.assertTrue(job.cancel_requested)
|
||||
finally:
|
||||
svc._iter_rows_for_conversation = original_iter
|
||||
svc._parse_message_for_export = original_parse
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,130 @@
|
||||
import importlib
|
||||
import io
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
class TestChatExportEmojiFastMiss(unittest.TestCase):
|
||||
def _reload_export_module(self):
|
||||
import wechat_decrypt_tool.chat_export_service as chat_export_service
|
||||
|
||||
importlib.reload(chat_export_service)
|
||||
return chat_export_service
|
||||
|
||||
def test_emoji_miss_skips_fallback_scan_and_caches_negative_result(self):
|
||||
svc = self._reload_export_module()
|
||||
md5 = "8f436b616da7832e5c206ba3d781a714"
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
original_try_find = svc._try_find_decrypted_resource
|
||||
original_resolve_kind = svc._resolve_media_path_for_kind
|
||||
try:
|
||||
svc._try_find_decrypted_resource = lambda *args, **kwargs: None
|
||||
|
||||
def fake_resolve_kind(account_dir, kind, md5, username, allow_fallback_scan=True):
|
||||
calls.append(
|
||||
{
|
||||
"account_dir": account_dir,
|
||||
"kind": kind,
|
||||
"md5": md5,
|
||||
"username": username,
|
||||
"allow_fallback_scan": allow_fallback_scan,
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
svc._resolve_media_path_for_kind = fake_resolve_kind
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
media_written: dict[str, str] = {}
|
||||
with io.BytesIO() as buf, zipfile.ZipFile(buf, "w") as zf:
|
||||
arc1, is_new1 = svc._materialize_media(
|
||||
zf=zf,
|
||||
account_dir=Path(td),
|
||||
conv_username="room@chatroom",
|
||||
kind="emoji",
|
||||
md5=md5,
|
||||
file_id="",
|
||||
media_written=media_written,
|
||||
suggested_name="",
|
||||
media_index=None,
|
||||
)
|
||||
arc2, is_new2 = svc._materialize_media(
|
||||
zf=zf,
|
||||
account_dir=Path(td),
|
||||
conv_username="room@chatroom",
|
||||
kind="emoji",
|
||||
md5=md5,
|
||||
file_id="",
|
||||
media_written=media_written,
|
||||
suggested_name="",
|
||||
media_index=None,
|
||||
)
|
||||
|
||||
self.assertEqual(arc1, "")
|
||||
self.assertEqual(arc2, "")
|
||||
self.assertFalse(is_new1)
|
||||
self.assertFalse(is_new2)
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(calls[0]["kind"], "emoji")
|
||||
self.assertEqual(calls[0]["md5"], md5)
|
||||
self.assertEqual(calls[0]["username"], "room@chatroom")
|
||||
self.assertFalse(bool(calls[0]["allow_fallback_scan"]))
|
||||
self.assertIn("emoji:" + md5, media_written)
|
||||
self.assertEqual(media_written["emoji:" + md5], "")
|
||||
finally:
|
||||
svc._try_find_decrypted_resource = original_try_find
|
||||
svc._resolve_media_path_for_kind = original_resolve_kind
|
||||
|
||||
def test_image_lookup_keeps_fallback_scan_enabled(self):
|
||||
svc = self._reload_export_module()
|
||||
md5 = "80793a35a19810699a03579c654f4c50"
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
original_try_find = svc._try_find_decrypted_resource
|
||||
original_resolve_kind = svc._resolve_media_path_for_kind
|
||||
try:
|
||||
svc._try_find_decrypted_resource = lambda *args, **kwargs: None
|
||||
|
||||
def fake_resolve_kind(account_dir, kind, md5, username, allow_fallback_scan=True):
|
||||
calls.append(
|
||||
{
|
||||
"kind": kind,
|
||||
"md5": md5,
|
||||
"allow_fallback_scan": allow_fallback_scan,
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
svc._resolve_media_path_for_kind = fake_resolve_kind
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
with io.BytesIO() as buf, zipfile.ZipFile(buf, "w") as zf:
|
||||
arc, is_new = svc._materialize_media(
|
||||
zf=zf,
|
||||
account_dir=Path(td),
|
||||
conv_username="friend",
|
||||
kind="image",
|
||||
md5=md5,
|
||||
file_id="",
|
||||
media_written={},
|
||||
suggested_name="",
|
||||
media_index=None,
|
||||
)
|
||||
|
||||
self.assertEqual(arc, "")
|
||||
self.assertFalse(is_new)
|
||||
self.assertEqual(len(calls), 2)
|
||||
self.assertEqual(calls[0]["kind"], "image")
|
||||
self.assertFalse(bool(calls[0]["allow_fallback_scan"]))
|
||||
self.assertEqual(calls[1]["kind"], "image")
|
||||
self.assertTrue(bool(calls[1]["allow_fallback_scan"]))
|
||||
finally:
|
||||
svc._try_find_decrypted_resource = original_try_find
|
||||
svc._resolve_media_path_for_kind = original_resolve_kind
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,154 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest import mock
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
import wechat_decrypt_tool.key_service as key_service
|
||||
|
||||
|
||||
class TestKeyServiceImageKeyAccountMatch(unittest.TestCase):
|
||||
def test_local_image_keys_do_not_match_by_substring(self) -> None:
|
||||
remote_result = {
|
||||
"wxid": "wxid_demo_extra",
|
||||
"xor_key": "0x8A",
|
||||
"aes_key": "BBBBBBBBBBBBBBBB",
|
||||
}
|
||||
|
||||
with mock.patch.object(
|
||||
key_service,
|
||||
"try_get_local_image_keys",
|
||||
return_value=[
|
||||
{"wxid": "wxid_demo", "xor_key": "0x01", "aes_key": "AAAAAAAAAAAAAAAA"},
|
||||
],
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"_resolve_account_dir",
|
||||
return_value=Path("D:/tmp/output/databases/wxid_demo_extra"),
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"_resolve_account_wxid_dir",
|
||||
return_value=Path("D:/tmp/xwechat_files/wxid_demo_extra"),
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"upsert_account_keys_in_store",
|
||||
) as upsert_mock, mock.patch.object(
|
||||
key_service,
|
||||
"fetch_and_save_remote_keys",
|
||||
new=mock.AsyncMock(return_value=remote_result),
|
||||
) as remote_mock:
|
||||
result = asyncio.run(key_service.get_image_key_integrated_workflow("wxid_demo_extra"))
|
||||
|
||||
self.assertEqual(result, remote_result)
|
||||
remote_mock.assert_awaited_once_with("wxid_demo_extra", wxid_dir=None, db_storage_path=None)
|
||||
upsert_mock.assert_not_called()
|
||||
|
||||
def test_local_image_keys_require_exact_account_match(self) -> None:
|
||||
with mock.patch.object(
|
||||
key_service,
|
||||
"try_get_local_image_keys",
|
||||
return_value=[
|
||||
{"wxid": "wxid_demo", "xor_key": "0x01", "aes_key": "AAAAAAAAAAAAAAAA"},
|
||||
{"wxid": "wxid_demo_extra", "xor_key": "0x8A", "aes_key": "BBBBBBBBBBBBBBBB"},
|
||||
],
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"_resolve_account_dir",
|
||||
return_value=Path("D:/tmp/output/databases/wxid_demo_extra"),
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"_resolve_account_wxid_dir",
|
||||
return_value=Path("D:/tmp/xwechat_files/wxid_demo_extra"),
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"upsert_account_keys_in_store",
|
||||
) as upsert_mock, mock.patch.object(
|
||||
key_service,
|
||||
"fetch_and_save_remote_keys",
|
||||
new=mock.AsyncMock(side_effect=AssertionError("remote should not be called")),
|
||||
):
|
||||
result = asyncio.run(key_service.get_image_key_integrated_workflow("wxid_demo_extra"))
|
||||
|
||||
self.assertEqual(result["wxid"], "wxid_demo_extra")
|
||||
self.assertEqual(result["xor_key"], "0x8A")
|
||||
self.assertEqual(result["aes_key"], "BBBBBBBBBBBBBBBB")
|
||||
upsert_mock.assert_called_once_with(
|
||||
account="wxid_demo_extra",
|
||||
image_xor_key="0x8A",
|
||||
image_aes_key="BBBBBBBBBBBBBBBB",
|
||||
)
|
||||
|
||||
def test_fetch_remote_keys_can_use_db_storage_path_without_decrypted_output(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
wxid_dir = Path(temp_dir) / "xwechat_files" / "wxid_v4mbduwqtzpt22"
|
||||
db_storage_dir = wxid_dir / "db_storage"
|
||||
db_storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
class _FakeResponse:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return {
|
||||
"xorKey": "138",
|
||||
"aesKey": "c3f3366e23628242",
|
||||
"nickName": "demo",
|
||||
}
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def post(self, url, data=None, files=None):
|
||||
self.last_url = url
|
||||
self.last_data = data
|
||||
self.last_files = files
|
||||
return _FakeResponse()
|
||||
|
||||
with mock.patch.object(
|
||||
key_service,
|
||||
"_resolve_account_dir",
|
||||
side_effect=AssertionError("should not require decrypted account dir"),
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"get_wechat_internal_global_config",
|
||||
side_effect=[b"global-config", b"crc-bytes"],
|
||||
), mock.patch.object(
|
||||
key_service.httpx,
|
||||
"AsyncClient",
|
||||
_FakeAsyncClient,
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"upsert_account_keys_in_store",
|
||||
) as upsert_mock:
|
||||
result = asyncio.run(
|
||||
key_service.fetch_and_save_remote_keys(
|
||||
"wxid_v4mbduwqtzpt22",
|
||||
db_storage_path=str(db_storage_dir),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(result["wxid"], "wxid_v4mbduwqtzpt22")
|
||||
self.assertEqual(result["xor_key"], "0x8A")
|
||||
self.assertEqual(result["aes_key"], "c3f3366e23628242")
|
||||
upsert_mock.assert_called_once_with(
|
||||
account="wxid_v4mbduwqtzpt22",
|
||||
image_xor_key="0x8A",
|
||||
image_aes_key="c3f3366e23628242",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,134 @@
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest import mock
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
from wechat_decrypt_tool.routers import media as media_router # noqa: E402 pylint: disable=wrong-import-position
|
||||
|
||||
|
||||
class _FakeDisconnectingRequest:
|
||||
def __init__(self, disconnect_after: int):
|
||||
self._disconnect_after = disconnect_after
|
||||
self._calls = 0
|
||||
|
||||
async def is_disconnected(self):
|
||||
self._calls += 1
|
||||
return self._calls >= self._disconnect_after
|
||||
|
||||
|
||||
async def _read_sse_events(response) -> list[dict]:
|
||||
chunks = []
|
||||
async for chunk in response.body_iterator:
|
||||
chunks.append(chunk.decode("utf-8") if isinstance(chunk, bytes) else str(chunk))
|
||||
|
||||
events = []
|
||||
for chunk in chunks:
|
||||
for line in chunk.splitlines():
|
||||
if line.startswith("data: "):
|
||||
events.append(json.loads(line[len("data: ") :]))
|
||||
return events
|
||||
|
||||
|
||||
class TestMediaDecryptStreamCancel(unittest.TestCase):
|
||||
def test_stream_uses_default_concurrency(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account_dir = root / "account"
|
||||
wxid_dir = root / "wxid"
|
||||
dat_path = wxid_dir / "image.dat"
|
||||
resource_dir = account_dir / "resource"
|
||||
wxid_dir.mkdir(parents=True, exist_ok=True)
|
||||
dat_path.write_bytes(b"encrypted")
|
||||
|
||||
with mock.patch.object(media_router, "_resolve_account_dir", return_value=account_dir):
|
||||
with mock.patch.object(media_router, "_resolve_account_wxid_dir", return_value=wxid_dir):
|
||||
with mock.patch.object(media_router, "_load_media_keys", return_value={"xor": 0xA5, "aes": ""}):
|
||||
with mock.patch.object(media_router, "_collect_all_dat_files", return_value=[(dat_path, "abc123")]):
|
||||
with mock.patch.object(media_router, "_get_resource_dir", return_value=resource_dir):
|
||||
with mock.patch.object(media_router, "_try_find_decrypted_resource", return_value=None):
|
||||
with mock.patch.object(media_router, "_decrypt_and_save_resource", return_value=(True, "ok")):
|
||||
response = asyncio.run(
|
||||
media_router.decrypt_all_media_stream(
|
||||
request=_FakeDisconnectingRequest(disconnect_after=999),
|
||||
account="wxid_demo",
|
||||
)
|
||||
)
|
||||
events = asyncio.run(_read_sse_events(response))
|
||||
|
||||
self.assertEqual([event.get("type") for event in events], ["scanning", "start", "progress", "complete"])
|
||||
self.assertEqual(events[1].get("concurrency"), 10)
|
||||
self.assertEqual(events[2].get("concurrency"), 10)
|
||||
self.assertEqual(events[3].get("concurrency"), 10)
|
||||
|
||||
def test_stream_uses_requested_concurrency(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account_dir = root / "account"
|
||||
wxid_dir = root / "wxid"
|
||||
dat_path = wxid_dir / "image.dat"
|
||||
resource_dir = account_dir / "resource"
|
||||
wxid_dir.mkdir(parents=True, exist_ok=True)
|
||||
dat_path.write_bytes(b"encrypted")
|
||||
|
||||
with mock.patch.object(media_router, "_resolve_account_dir", return_value=account_dir):
|
||||
with mock.patch.object(media_router, "_resolve_account_wxid_dir", return_value=wxid_dir):
|
||||
with mock.patch.object(media_router, "_load_media_keys", return_value={"xor": 0xA5, "aes": ""}):
|
||||
with mock.patch.object(media_router, "_collect_all_dat_files", return_value=[(dat_path, "abc123")]):
|
||||
with mock.patch.object(media_router, "_get_resource_dir", return_value=resource_dir):
|
||||
with mock.patch.object(media_router, "_try_find_decrypted_resource", return_value=None):
|
||||
with mock.patch.object(media_router, "_decrypt_and_save_resource", return_value=(True, "ok")):
|
||||
response = asyncio.run(
|
||||
media_router.decrypt_all_media_stream(
|
||||
request=_FakeDisconnectingRequest(disconnect_after=999),
|
||||
account="wxid_demo",
|
||||
concurrency=7,
|
||||
)
|
||||
)
|
||||
events = asyncio.run(_read_sse_events(response))
|
||||
|
||||
self.assertEqual(events[1].get("concurrency"), 7)
|
||||
self.assertEqual(events[2].get("concurrency"), 7)
|
||||
self.assertEqual(events[3].get("concurrency"), 7)
|
||||
|
||||
def test_stream_stops_processing_when_client_disconnects(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account_dir = root / "account"
|
||||
wxid_dir = root / "wxid"
|
||||
dat_path = wxid_dir / "image.dat"
|
||||
resource_dir = account_dir / "resource"
|
||||
wxid_dir.mkdir(parents=True, exist_ok=True)
|
||||
dat_path.write_bytes(b"encrypted")
|
||||
|
||||
request = _FakeDisconnectingRequest(disconnect_after=3)
|
||||
decrypt_mock = mock.Mock(return_value=(True, "ok"))
|
||||
|
||||
with mock.patch.object(media_router, "_resolve_account_dir", return_value=account_dir):
|
||||
with mock.patch.object(media_router, "_resolve_account_wxid_dir", return_value=wxid_dir):
|
||||
with mock.patch.object(media_router, "_load_media_keys", return_value={"xor": 0xA5, "aes": ""}):
|
||||
with mock.patch.object(media_router, "_collect_all_dat_files", return_value=[(dat_path, "abc123")]):
|
||||
with mock.patch.object(media_router, "_get_resource_dir", return_value=resource_dir):
|
||||
with mock.patch.object(media_router, "_try_find_decrypted_resource", return_value=None):
|
||||
with mock.patch.object(media_router, "_decrypt_and_save_resource", decrypt_mock):
|
||||
response = asyncio.run(
|
||||
media_router.decrypt_all_media_stream(
|
||||
request=request,
|
||||
account="wxid_demo",
|
||||
)
|
||||
)
|
||||
events = asyncio.run(_read_sse_events(response))
|
||||
|
||||
self.assertEqual([event.get("type") for event in events], ["scanning", "start"])
|
||||
decrypt_mock.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,190 @@
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest import mock
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
from wechat_decrypt_tool.routers import media as media_router # noqa: E402 pylint: disable=wrong-import-position
|
||||
|
||||
|
||||
PNG_1X1 = bytes.fromhex(
|
||||
"89504E470D0A1A0A"
|
||||
"0000000D49484452000000010000000108060000001F15C489"
|
||||
"0000000D49444154789C6360606060000000050001A5F64540"
|
||||
"0000000049454E44AE426082"
|
||||
)
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
async def is_disconnected(self):
|
||||
return False
|
||||
|
||||
|
||||
class _FakeDisconnectingRequest:
|
||||
def __init__(self, disconnect_after: int):
|
||||
self._disconnect_after = disconnect_after
|
||||
self._calls = 0
|
||||
|
||||
async def is_disconnected(self):
|
||||
self._calls += 1
|
||||
return self._calls >= self._disconnect_after
|
||||
|
||||
|
||||
def _emoji_catalog(md5: str):
|
||||
return (
|
||||
{
|
||||
md5: {
|
||||
"md5": md5,
|
||||
"urls": [f"https://example.com/{md5}.png"],
|
||||
"aes_keys": [],
|
||||
"sources": ["message_xml"],
|
||||
}
|
||||
},
|
||||
{
|
||||
"total_candidates": 1,
|
||||
"total_candidates_with_url": 1,
|
||||
"source_counts": {"message_xml": 1},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _read_sse_events(response) -> list[dict]:
|
||||
chunks = []
|
||||
async for chunk in response.body_iterator:
|
||||
chunks.append(chunk.decode("utf-8") if isinstance(chunk, bytes) else str(chunk))
|
||||
|
||||
events = []
|
||||
for chunk in chunks:
|
||||
for line in chunk.splitlines():
|
||||
if line.startswith("data: "):
|
||||
events.append(json.loads(line[len("data: ") :]))
|
||||
return events
|
||||
|
||||
|
||||
class TestMediaEmojiDownloadStream(unittest.TestCase):
|
||||
def test_stream_downloads_missing_emoji_and_saves_resource(self):
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "account"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
md5 = "a" * 32
|
||||
|
||||
with mock.patch.object(media_router, "_resolve_account_dir", return_value=account_dir):
|
||||
with mock.patch.object(
|
||||
media_router,
|
||||
"_collect_emoticon_download_catalog",
|
||||
return_value=_emoji_catalog(md5),
|
||||
):
|
||||
with mock.patch.object(
|
||||
media_router,
|
||||
"_try_fetch_emoticon_from_remote",
|
||||
return_value=(PNG_1X1, "image/png"),
|
||||
) as fetch_mock:
|
||||
response = asyncio.run(
|
||||
media_router.download_all_emojis_stream(
|
||||
request=_FakeRequest(),
|
||||
account="wxid_demo",
|
||||
)
|
||||
)
|
||||
events = asyncio.run(_read_sse_events(response))
|
||||
|
||||
self.assertEqual([event.get("type") for event in events], ["scanning", "start", "progress", "complete"])
|
||||
self.assertEqual(events[2].get("status"), "success")
|
||||
self.assertEqual(events[3].get("success_count"), 1)
|
||||
self.assertEqual(events[1].get("concurrency"), 20)
|
||||
self.assertTrue((account_dir / "resource" / md5[:2] / f"{md5}.png").exists())
|
||||
fetch_mock.assert_called_once()
|
||||
|
||||
def test_stream_uses_requested_concurrency(self):
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "account"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
md5 = "d" * 32
|
||||
|
||||
with mock.patch.object(media_router, "_resolve_account_dir", return_value=account_dir):
|
||||
with mock.patch.object(
|
||||
media_router,
|
||||
"_collect_emoticon_download_catalog",
|
||||
return_value=_emoji_catalog(md5),
|
||||
):
|
||||
with mock.patch.object(
|
||||
media_router,
|
||||
"_try_fetch_emoticon_from_remote",
|
||||
return_value=(PNG_1X1, "image/png"),
|
||||
):
|
||||
response = asyncio.run(
|
||||
media_router.download_all_emojis_stream(
|
||||
request=_FakeRequest(),
|
||||
account="wxid_demo",
|
||||
concurrency=7,
|
||||
)
|
||||
)
|
||||
events = asyncio.run(_read_sse_events(response))
|
||||
|
||||
self.assertEqual(events[1].get("concurrency"), 7)
|
||||
self.assertEqual(events[2].get("concurrency"), 7)
|
||||
self.assertEqual(events[3].get("concurrency"), 7)
|
||||
|
||||
def test_stream_skips_existing_downloaded_emoji(self):
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "account"
|
||||
md5 = "b" * 32
|
||||
resource_dir = account_dir / "resource" / md5[:2]
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
resource_dir.mkdir(parents=True, exist_ok=True)
|
||||
cached = resource_dir / f"{md5}.png"
|
||||
cached.write_bytes(PNG_1X1)
|
||||
|
||||
with mock.patch.object(media_router, "_resolve_account_dir", return_value=account_dir):
|
||||
with mock.patch.object(
|
||||
media_router,
|
||||
"_collect_emoticon_download_catalog",
|
||||
return_value=_emoji_catalog(md5),
|
||||
):
|
||||
with mock.patch.object(media_router, "_try_fetch_emoticon_from_remote") as fetch_mock:
|
||||
response = asyncio.run(
|
||||
media_router.download_all_emojis_stream(
|
||||
request=_FakeRequest(),
|
||||
account="wxid_demo",
|
||||
)
|
||||
)
|
||||
events = asyncio.run(_read_sse_events(response))
|
||||
|
||||
self.assertEqual([event.get("type") for event in events], ["scanning", "start", "progress", "complete"])
|
||||
self.assertEqual(events[2].get("status"), "skip")
|
||||
self.assertEqual(events[3].get("skip_count"), 1)
|
||||
fetch_mock.assert_not_called()
|
||||
|
||||
def test_stream_stops_before_processing_when_client_disconnects(self):
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "account"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
md5 = "c" * 32
|
||||
|
||||
with mock.patch.object(media_router, "_resolve_account_dir", return_value=account_dir):
|
||||
with mock.patch.object(
|
||||
media_router,
|
||||
"_collect_emoticon_download_catalog",
|
||||
return_value=_emoji_catalog(md5),
|
||||
):
|
||||
with mock.patch.object(media_router, "_try_fetch_emoticon_from_remote") as fetch_mock:
|
||||
response = asyncio.run(
|
||||
media_router.download_all_emojis_stream(
|
||||
request=_FakeDisconnectingRequest(disconnect_after=3),
|
||||
account="wxid_demo",
|
||||
)
|
||||
)
|
||||
events = asyncio.run(_read_sse_events(response))
|
||||
|
||||
self.assertEqual([event.get("type") for event in events], ["scanning", "start"])
|
||||
fetch_mock.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,109 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
from wechat_decrypt_tool.media_helpers import ( # noqa: E402 pylint: disable=wrong-import-position
|
||||
_collect_emoticon_download_catalog,
|
||||
_lookup_emoticon_info,
|
||||
)
|
||||
|
||||
|
||||
class TestMediaEmoticonCatalog(unittest.TestCase):
|
||||
def test_catalog_merges_emoticon_db_extern_md5_and_message_xml(self):
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "account"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
primary_md5 = "a" * 32
|
||||
extern_md5 = "b" * 32
|
||||
message_md5 = "c" * 32
|
||||
no_url_md5 = "d" * 32
|
||||
message_extern_md5 = "e" * 32
|
||||
aes_key = "1" * 32
|
||||
|
||||
conn = sqlite3.connect(str(account_dir / "emoticon.db"))
|
||||
conn.execute(
|
||||
"CREATE TABLE kNonStoreEmoticonTable ("
|
||||
"md5 TEXT, extern_md5 TEXT, aes_key TEXT, cdn_url TEXT, encrypt_url TEXT, "
|
||||
"extern_url TEXT, thumb_url TEXT, tp_url TEXT)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO kNonStoreEmoticonTable VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
primary_md5,
|
||||
extern_md5,
|
||||
aes_key,
|
||||
f"https://example.com/{primary_md5}.gif",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
conn = sqlite3.connect(str(account_dir / "message_0.db"))
|
||||
conn.execute(
|
||||
"CREATE TABLE Msg_demo ("
|
||||
"local_type INTEGER, compress_content BLOB, message_content BLOB, packed_info_data BLOB)"
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO Msg_demo VALUES (?, ?, ?, ?)",
|
||||
[
|
||||
(
|
||||
47,
|
||||
None,
|
||||
(
|
||||
f'<msg><emoji md5="{message_md5}" externmd5="{message_extern_md5}" '
|
||||
f'aeskey="{aes_key}" cdnurl="https://example.com/{message_md5}.png" /></msg>'
|
||||
),
|
||||
bytes([0x10, 0x45]),
|
||||
),
|
||||
(
|
||||
47,
|
||||
None,
|
||||
f'<msg><emoji md5="{primary_md5}" cdnurl="https://example.com/{primary_md5}-2.png" /></msg>',
|
||||
bytes([0x10, 0x45]),
|
||||
),
|
||||
(
|
||||
47,
|
||||
None,
|
||||
f'<msg><emoji md5="{no_url_md5}" /></msg>',
|
||||
bytes([0x10, 0x45]),
|
||||
),
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
catalog, stats = _collect_emoticon_download_catalog(account_dir)
|
||||
|
||||
self.assertEqual(set(catalog), {primary_md5, extern_md5, message_md5})
|
||||
self.assertIn("emoticon_db_md5", catalog[primary_md5]["sources"])
|
||||
self.assertIn("message_xml", catalog[primary_md5]["sources"])
|
||||
self.assertIn("emoticon_db_extern_md5", catalog[extern_md5]["sources"])
|
||||
self.assertIn("message_xml", catalog[message_md5]["sources"])
|
||||
self.assertNotIn(no_url_md5, catalog)
|
||||
self.assertEqual(stats["emoticon_db_md5"], 1)
|
||||
self.assertEqual(stats["emoticon_db_extern_md5"], 1)
|
||||
self.assertEqual(stats["message_xml_rows"], 3)
|
||||
self.assertEqual(stats["message_xml_md5"], 3)
|
||||
self.assertEqual(stats["message_xml_md5_with_url"], 2)
|
||||
self.assertEqual(stats["message_xml_extern_md5"], 1)
|
||||
self.assertEqual(stats["message_builtin_expr_ids"], 1)
|
||||
self.assertEqual(stats["source_counts"]["message_xml"], 2)
|
||||
|
||||
info = _lookup_emoticon_info(str(account_dir), extern_md5)
|
||||
self.assertEqual(info["md5"], primary_md5)
|
||||
self.assertEqual(info["extern_md5"], extern_md5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -148,6 +148,26 @@ class TestParseAppMessage(unittest.TestCase):
|
||||
self.assertEqual(parsed.get("thumbUrl"), "https://finder.video.qq.com/cover.jpg")
|
||||
self.assertEqual(parsed.get("url"), "https://channels.weixin.qq.com/web/pages/feed?feedid=abc")
|
||||
|
||||
def test_finder_type_51_exposes_object_fields(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>当前版本不支持展示该内容,请升级至最新版本。</title>'
|
||||
'<des></des>'
|
||||
'<type>51</type>'
|
||||
'<finderFeed>'
|
||||
'<nickname><![CDATA[央视新闻]]></nickname>'
|
||||
'<objectId><![CDATA[1234567890]]></objectId>'
|
||||
'<objectNonceId><![CDATA[nonce-abc]]></objectNonceId>'
|
||||
'</finderFeed>'
|
||||
'</appmsg></msg>'
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("linkType"), "finder")
|
||||
self.assertEqual(parsed.get("objectId"), "1234567890")
|
||||
self.assertEqual(parsed.get("objectNonceId"), "nonce-abc")
|
||||
|
||||
def test_quote_type_5_nested_xml_refermsg_uses_inner_title(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestWechatDetectionAutoDetect(unittest.TestCase):
|
||||
def test_detect_wechat_installation_finds_nested_custom_data_root(self):
|
||||
from wechat_decrypt_tool import wechat_detection as wd
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
nested_scan_root = Path(td) / "abc"
|
||||
wechat_parent = nested_scan_root / "wechatMSG"
|
||||
xwechat_root = wechat_parent / "xwechat_files"
|
||||
|
||||
login_dir = xwechat_root / "all_users" / "login" / "wxid_demo"
|
||||
login_dir.mkdir(parents=True, exist_ok=True)
|
||||
(login_dir / "key_info.db").write_bytes(b"demo")
|
||||
|
||||
account_dir = xwechat_root / "wxid_demo_nested"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
(account_dir / "contact.db").write_bytes(b"demo")
|
||||
|
||||
with (
|
||||
patch.object(wd, "_build_auto_detect_scan_paths", return_value=[str(nested_scan_root)]),
|
||||
patch.object(wd, "get_process_list", return_value=[]),
|
||||
):
|
||||
detected_dirs = wd.auto_detect_wechat_data_dirs()
|
||||
result = wd.detect_wechat_installation()
|
||||
|
||||
self.assertEqual(detected_dirs, [str(wechat_parent)])
|
||||
self.assertEqual(result["total_accounts"], 1)
|
||||
self.assertEqual(result["accounts"][0]["account_name"], "wxid_demo")
|
||||
self.assertEqual(result["accounts"][0]["data_dir"], str(account_dir))
|
||||
self.assertEqual(result["total_databases"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
@@ -919,7 +919,7 @@ requires-dist = [
|
||||
{ name = "requests", specifier = ">=2.32.4" },
|
||||
{ name = "typing-extensions", specifier = ">=4.8.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
|
||||
{ name = "wx-key", specifier = ">=1.1.0" },
|
||||
{ name = "wx-key", specifier = ">=2.0.0" },
|
||||
{ name = "zstandard", specifier = ">=0.23.0" },
|
||||
]
|
||||
provides-extras = ["build"]
|
||||
@@ -935,13 +935,13 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "wx-key"
|
||||
version = "1.1.0"
|
||||
version = "2.0.0"
|
||||
source = { registry = "tools/key_wheels" }
|
||||
wheels = [
|
||||
{ path = "wx_key-1.1.0-cp311-cp311-win_amd64.whl" },
|
||||
{ path = "wx_key-1.1.0-cp312-cp312-win_amd64.whl" },
|
||||
{ path = "wx_key-1.1.0-cp313-cp313-win_amd64.whl" },
|
||||
{ path = "wx_key-1.1.0-cp314-cp314-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.0-cp311-cp311-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.0-cp312-cp312-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.0-cp313-cp313-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.0-cp314-cp314-win_amd64.whl" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user