Compare commits

...

10 Commits

27 changed files with 6608 additions and 759 deletions
+34 -53
View File
@@ -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>
+48 -18
View File
@@ -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>
+5 -1
View File
@@ -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>
+102 -11
View File
@@ -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,
+41 -32
View File
@@ -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
})
}
}
+8 -2
View File
@@ -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)
}
// 枚举服务号信息
+76
View File
@@ -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 {}
}
}
File diff suppressed because it is too large Load Diff
+27 -7
View File
@@ -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>
+232
View File
@@ -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
}
})
})
File diff suppressed because it is too large Load Diff
+173 -18
View File
@@ -30,6 +30,85 @@ from .media_helpers import _resolve_account_dir, _resolve_account_wxid_dir
logger = logging.getLogger(__name__)
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)})"
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:
@@ -143,11 +222,13 @@ def get_wechat_internal_global_config(wx_dir: Path, file_name1) -> 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)
@@ -165,13 +246,23 @@ def try_get_local_image_keys() -> List[Dict[str, Any]]:
"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 get_image_key_integrated_workflow(account: Optional[str] = None) -> Dict[str, Any]:
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. 优先尝试本地算法提取
@@ -181,30 +272,55 @@ async def get_image_key_integrated_workflow(account: Optional[str] = None) -> Di
local_keys = try_get_local_image_keys()
target_account_wxid = None
if account:
if account or wxid_dir or db_storage_path:
try:
account_dir = _resolve_account_dir(account)
wx_id_dir = _resolve_account_wxid_dir(account_dir)
target_account_wxid = wx_id_dir.name
except:
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:
if k['wxid'] == target_account_wxid:
logger.info(f"成功通过本地算法匹配到账号 {target_account_wxid} 的图片密钥")
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=k['wxid'],
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(f"本地算法提取成功 (未指定账号,返回首个): {k['wxid']}")
logger.info(
"[image_key] 未指定账号,返回本地首个结果:payload=%s",
_summarize_key_payload(k),
)
upsert_account_keys_in_store(
account=k['wxid'],
image_xor_key=k['xor_key'],
@@ -213,25 +329,49 @@ async def get_image_key_integrated_workflow(account: Optional[str] = None) -> Di
return k
# 2. 本地提取失败或不匹配,尝试远程解析
logger.info("本地算法提取未命中,尝试远程 API 解析...")
return await fetch_and_save_remote_keys(account)
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) -> Dict[str, Any]:
account_dir = _resolve_account_dir(account)
wx_id_dir = _resolve_account_wxid_dir(account_dir)
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'),
@@ -239,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:
@@ -248,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", "")))
@@ -267,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
+30
View File
@@ -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
+117
View File
@@ -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,
@@ -3998,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]
@@ -4122,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 ""
@@ -4133,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:
@@ -4164,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
@@ -4212,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"
@@ -4299,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"]
@@ -4416,6 +4464,10 @@ def list_chat_sessions(
}
)
trace(
"response:ready",
sessionCount=len(sessions),
)
return {
"status": "success",
"account": account_dir.name,
@@ -5169,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,
@@ -5199,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
@@ -5337,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 (
@@ -5352,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)
@@ -5362,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(
@@ -5373,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
(
@@ -5393,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)
@@ -5886,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,
@@ -5961,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.
@@ -5997,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.
@@ -6155,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,
+137 -10
View File
@@ -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)
+61 -2
View File
@@ -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, 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 get_image_key_integrated_workflow(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,
@@ -110,6 +157,12 @@ async def get_image_key(account: Optional[str] = None):
}
}
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
+133 -47
View File
@@ -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()
+130
View File
@@ -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()
+134
View File
@@ -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()
+190
View File
@@ -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()
+109
View File
@@ -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()
@@ -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()