Compare commits

..

2 Commits

11 changed files with 4843 additions and 584 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>
+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,
+457 -20
View File
@@ -233,13 +233,13 @@
<!-- 跳过按钮 -->
<div class="text-center mt-4">
<button @click="skipToChat" class="text-sm text-[#7F7F7F] hover:text-[#07C160] transition-colors">
跳过图片解密直接查看聊天记录
跳过后续媒体准备直接查看聊天记录
</button>
</div>
</div>
</div>
<!-- 步骤3: 批量解密图片 -->
<!-- 步骤3: 图片解密 -->
<div v-if="currentStep === 2" class="bg-white rounded-2xl border border-[#EDEDED]">
<div class="p-8">
<div class="flex items-center justify-between mb-6">
@@ -251,7 +251,7 @@
</div>
<div>
<h2 class="text-xl font-bold text-[#000000e6]">批量解密图片</h2>
<p class="text-sm text-[#7F7F7F]">仅解密加密图片文件(.dat)其他文件无需解密</p>
<p class="text-sm text-[#7F7F7F]">仅解密加密图片文件(.dat)完成后可继续进入表情下载步骤</p>
</div>
</div>
<!-- 进度计数 -->
@@ -261,12 +261,30 @@
</div>
</div>
<div class="mb-6 bg-lime-50 border border-lime-100 rounded-lg p-4">
<label class="block text-sm font-medium text-[#000000e6] mb-2">解密并发线程数</label>
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
<input
v-model.number="mediaDecryptConcurrency"
type="number"
min="1"
max="64"
step="1"
:disabled="mediaDecrypting"
class="w-40 px-3 py-2 border border-[#EDEDED] rounded-lg focus:outline-none focus:ring-2 focus:ring-[#91D300] disabled:bg-gray-100"
/>
<div class="text-xs text-[#7F7F7F]">
默认 10图片解密主要吃本地磁盘和 CPU机器较快可适度调高
</div>
</div>
</div>
<!-- 实时进度条 -->
<div v-if="mediaDecrypting || decryptProgress.total > 0" class="mb-6">
<!-- 进度条 -->
<div class="mb-3">
<div class="flex justify-between text-xs text-[#7F7F7F] mb-1">
<span>解密进度</span>
<span>{{ decryptProgress.message || '解密进度' }}</span>
<span>{{ progressPercent }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 overflow-hidden">
@@ -297,11 +315,15 @@
</div>
<!-- 实时统计 -->
<div class="grid grid-cols-4 gap-3 text-center bg-gray-50 rounded-lg p-3">
<div class="grid grid-cols-5 gap-3 text-center bg-gray-50 rounded-lg p-3">
<div>
<div class="text-xl font-bold text-[#10AEEF]">{{ decryptProgress.total }}</div>
<div class="text-xs text-[#7F7F7F]">总图片</div>
</div>
<div>
<div class="text-xl font-bold text-[#91D300]">{{ decryptProgress.concurrency || getMediaDecryptConcurrency() }}</div>
<div class="text-xs text-[#7F7F7F]">并发线程</div>
</div>
<div>
<div class="text-xl font-bold text-[#07C160]">{{ decryptProgress.success_count }}</div>
<div class="text-xs text-[#7F7F7F]">成功</div>
@@ -329,6 +351,12 @@
<div class="text-sm text-green-600">
输出目录: <code class="bg-white px-2 py-1 rounded text-xs">{{ mediaDecryptResult.output_dir }}</code>
</div>
<div class="mt-2 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-green-700">
<div>并发线程: {{ mediaDecryptResult.concurrency || decryptProgress.concurrency }}</div>
<div>平均解密: {{ mediaDecryptResult.decrypt_stats?.avg_decrypt_ms || 0 }} ms</div>
<div>最大解密: {{ mediaDecryptResult.decrypt_stats?.max_decrypt_ms || 0 }} ms</div>
<div>慢解密数: {{ mediaDecryptResult.decrypt_stats?.slow_decrypt_count || 0 }}</div>
</div>
</div>
</div>
@@ -373,6 +401,16 @@
>
停止解密
</button>
<button
@click="goToEmojiDownloadStep"
:disabled="mediaDecrypting"
class="inline-flex items-center px-6 py-3 bg-[#FA8C16] text-white rounded-lg font-medium hover:bg-[#E67E11] transition-all duration-200 disabled:opacity-50"
>
下一步下载表情
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
<button
@click="skipToChat"
:disabled="mediaDecrypting"
@@ -387,6 +425,183 @@
</div>
</div>
<!-- 步骤4: 表情下载 -->
<div v-if="currentStep === 3" class="bg-white rounded-2xl border border-[#EDEDED]">
<div class="p-8">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-[#FA8C16] rounded-lg flex items-center justify-center mr-4">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M12 21a9 9 0 100-18 9 9 0 000 18z"/>
</svg>
</div>
<div>
<h2 class="text-xl font-bold text-[#000000e6]">批量下载表情包</h2>
<p class="text-sm text-[#7F7F7F]"> `emoticon.db` 和聊天消息 XML 收集可下载表情下载过的会自动跳过</p>
</div>
</div>
<div v-if="emojiDownloading && emojiDownloadProgress.total > 0" class="text-right">
<div class="text-lg font-bold text-[#FA8C16]">{{ emojiDownloadProgress.current }} / {{ emojiDownloadProgress.total }}</div>
<div class="text-xs text-[#7F7F7F]">已处理 / 总表情</div>
</div>
</div>
<p class="mb-4 text-xs text-[#7F7F7F]">
表情会缓存到本地 `resource` 目录后续聊天导出时可直接复用不必再临时查找或下载
</p>
<div class="mb-4 bg-orange-50 border border-orange-100 rounded-lg p-4">
<label class="block text-sm font-medium text-[#000000e6] mb-2">下载并发线程数</label>
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
<input
v-model.number="emojiDownloadConcurrency"
type="number"
min="1"
max="100"
step="1"
:disabled="emojiDownloading"
class="w-40 px-3 py-2 border border-[#EDEDED] rounded-lg focus:outline-none focus:ring-2 focus:ring-[#FA8C16] disabled:bg-gray-100"
/>
<div class="text-xs text-[#7F7F7F]">
默认 20网络带宽足够可调高超时/失败变多时建议调低
</div>
</div>
</div>
<div v-if="emojiDownloading || emojiDownloadProgress.total > 0" class="mb-4">
<div class="mb-3">
<div class="flex justify-between text-xs text-[#7F7F7F] mb-1">
<span>{{ emojiDownloadProgress.message || '下载进度' }}</span>
<span>{{ emojiProgressPercent }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 overflow-hidden">
<div
class="h-2.5 rounded-full transition-all duration-300 ease-out"
:class="emojiDownloadProgress.status === 'complete' ? 'bg-[#07C160]' : emojiDownloadProgress.status === 'cancelled' ? 'bg-[#FAAD14]' : 'bg-[#FA8C16]'"
:style="{ width: emojiProgressPercent + '%' }"
></div>
</div>
</div>
<div v-if="emojiDownloadProgress.current_file" class="flex items-center text-sm text-[#7F7F7F] mb-3">
<svg class="w-4 h-4 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M12 21a9 9 0 100-18 9 9 0 000 18z"/>
</svg>
<span class="truncate font-mono text-xs">{{ emojiDownloadProgress.current_file }}</span>
<span
class="ml-2 px-2 py-0.5 rounded text-xs"
:class="{
'bg-green-100 text-green-700': emojiDownloadProgress.fileStatus === 'success',
'bg-gray-100 text-gray-600': emojiDownloadProgress.fileStatus === 'skip',
'bg-red-100 text-red-700': emojiDownloadProgress.fileStatus === 'fail'
}"
>
{{ emojiDownloadProgress.fileStatus === 'success' ? '下载成功' : emojiDownloadProgress.fileStatus === 'skip' ? '已存在' : emojiDownloadProgress.fileStatus === 'fail' ? '失败' : '' }}
</span>
</div>
<div class="grid grid-cols-5 gap-3 text-center bg-gray-50 rounded-lg p-3">
<div>
<div class="text-xl font-bold text-[#10AEEF]">{{ emojiDownloadProgress.total }}</div>
<div class="text-xs text-[#7F7F7F]">总表情</div>
</div>
<div>
<div class="text-xl font-bold text-[#FA8C16]">{{ emojiDownloadProgress.concurrency || getEmojiDownloadConcurrency() }}</div>
<div class="text-xs text-[#7F7F7F]">并发线程</div>
</div>
<div>
<div class="text-xl font-bold text-[#07C160]">{{ emojiDownloadProgress.success_count }}</div>
<div class="text-xs text-[#7F7F7F]">成功</div>
</div>
<div>
<div class="text-xl font-bold text-[#7F7F7F]">{{ emojiDownloadProgress.skip_count }}</div>
<div class="text-xs text-[#7F7F7F]">跳过(已下载)</div>
</div>
<div>
<div class="text-xl font-bold text-[#FA5151]">{{ emojiDownloadProgress.fail_count }}</div>
<div class="text-xs text-[#7F7F7F]">失败</div>
</div>
</div>
</div>
<div v-if="emojiDownloadResult && !emojiDownloading" class="mb-4">
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-center mb-2">
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<span class="font-medium text-green-700">表情下载完成</span>
</div>
<div class="text-sm text-green-600">
输出目录: <code class="bg-white px-2 py-1 rounded text-xs">{{ emojiDownloadResult.output_dir }}</code>
</div>
<div class="mt-2 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-green-700">
<div>并发线程: {{ emojiDownloadResult.concurrency || emojiDownloadProgress.concurrency }}</div>
<div>平均下载: {{ emojiDownloadResult.download_stats?.avg_fetch_ms || 0 }} ms</div>
<div>最大下载: {{ emojiDownloadResult.download_stats?.max_fetch_ms || 0 }} ms</div>
<div>慢下载数: {{ emojiDownloadResult.download_stats?.slow_fetch_count || 0 }}</div>
</div>
</div>
</div>
<div v-if="emojiDownloadProgress.fail_count > 0" class="mb-4">
<details class="text-sm">
<summary class="cursor-pointer text-[#7F7F7F] hover:text-[#000000e6]">
<span class="ml-1">查看表情下载失败说明</span>
</summary>
<div class="mt-2 bg-gray-50 rounded-lg p-3 text-xs text-[#7F7F7F]">
<ul class="list-disc list-inside space-y-1">
<li><strong>未找到可下载地址</strong>该表情在数据库里没有可用的 CDN 链接</li>
<li><strong>下载失败</strong>网络超时远端资源失效或微信 CDN 已回收文件</li>
<li><strong>写入失败</strong>本地目录无权限或目标文件被占用</li>
</ul>
</div>
</details>
</div>
<div class="flex gap-3 justify-center pt-4 border-t border-[#EDEDED]">
<button
@click="goBackToMediaDecryptStep"
:disabled="emojiDownloading"
class="inline-flex items-center px-6 py-3 bg-white text-[#000000e6] border border-[#EDEDED] rounded-lg font-medium hover:bg-gray-50 transition-all duration-200 disabled:opacity-50"
>
上一步
</button>
<button
@click="downloadAllEmojis"
:disabled="emojiDownloading"
class="inline-flex items-center px-6 py-3 bg-[#FA8C16] text-white rounded-lg font-medium hover:bg-[#E67E11] transition-all duration-200 disabled:opacity-50"
>
<svg v-if="emojiDownloading" class="w-5 h-5 mr-2 animate-spin" 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 12h4z"></path>
</svg>
<svg v-else class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M12 21a9 9 0 100-18 9 9 0 000 18z"/>
</svg>
{{ emojiDownloading ? '下载中...' : (emojiDownloadResult ? '重新检查表情' : '开始下载表情') }}
</button>
<button
v-if="emojiDownloading"
@click="cancelEmojiDownload"
class="inline-flex items-center px-6 py-3 bg-[#FA5151] text-white rounded-lg font-medium hover:bg-[#E54D4D] transition-all duration-200"
>
停止下载
</button>
<button
@click="skipToChat"
:disabled="emojiDownloading"
class="inline-flex items-center px-6 py-3 bg-[#07C160] text-white rounded-lg font-medium hover:bg-[#06AD56] transition-all duration-200 disabled:opacity-50"
>
查看聊天记录
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
</div>
</div>
</div>
<!-- 警告渲染 -->
<transition name="fade">
<div v-if="warning" class="bg-amber-50 border border-amber-200 rounded-lg p-4 mt-6 flex items-start">
@@ -455,7 +670,8 @@ const isGettingDbKey = ref(false)
const steps = [
{ title: '数据库解密' },
{ title: '填写图片密钥' },
{ title: '图片解密' }
{ title: '图片解密' },
{ title: '表情下载' }
]
// 表单数据
@@ -735,6 +951,10 @@ const clearManualKeys = () => {
// 图片解密相关
const mediaDecryptResult = ref(null)
const mediaDecrypting = ref(false)
const mediaDecryptConcurrency = ref(10)
const emojiDownloadResult = ref(null)
const emojiDownloading = ref(false)
const emojiDownloadConcurrency = ref(20)
// 数据库解密进度(SSE
const dbDecryptProgress = reactive({
@@ -756,12 +976,14 @@ const dbProgressPercent = computed(() => {
const decryptProgress = reactive({
current: 0,
total: 0,
concurrency: 0,
success_count: 0,
skip_count: 0,
fail_count: 0,
current_file: '',
fileStatus: '',
status: ''
status: '',
message: ''
})
// 进度百分比
@@ -770,6 +992,36 @@ const progressPercent = computed(() => {
return Math.round((decryptProgress.current / decryptProgress.total) * 100)
})
const emojiDownloadProgress = reactive({
current: 0,
total: 0,
concurrency: 0,
success_count: 0,
skip_count: 0,
fail_count: 0,
current_file: '',
fileStatus: '',
status: '',
message: ''
})
const emojiProgressPercent = computed(() => {
if (emojiDownloadProgress.total === 0) return 0
return Math.round((emojiDownloadProgress.current / emojiDownloadProgress.total) * 100)
})
const getEmojiDownloadConcurrency = () => {
const raw = Number.parseInt(String(emojiDownloadConcurrency.value || 20), 10)
if (!Number.isFinite(raw)) return 20
return Math.max(1, Math.min(100, raw))
}
const getMediaDecryptConcurrency = () => {
const raw = Number.parseInt(String(mediaDecryptConcurrency.value || 10), 10)
if (!Number.isFinite(raw)) return 10
return Math.max(1, Math.min(64, raw))
}
// 解密结果存储
const decryptResult = ref(null)
@@ -802,6 +1054,7 @@ const validateForm = () => {
let dbDecryptEventSource = null
let mediaDecryptEventSource = null
let emojiDownloadEventSource = null
const closeMediaDecryptEventSource = () => {
try {
@@ -813,6 +1066,16 @@ const closeMediaDecryptEventSource = () => {
}
}
const closeEmojiDownloadEventSource = () => {
try {
if (emojiDownloadEventSource) emojiDownloadEventSource.close()
} catch (e) {
// ignore
} finally {
emojiDownloadEventSource = null
}
}
onBeforeUnmount(() => {
try {
if (dbDecryptEventSource) dbDecryptEventSource.close()
@@ -823,6 +1086,7 @@ onBeforeUnmount(() => {
}
closeMediaDecryptEventSource()
closeEmojiDownloadEventSource()
})
const resetDbDecryptProgress = () => {
@@ -838,12 +1102,27 @@ const resetDbDecryptProgress = () => {
const resetMediaDecryptProgress = () => {
decryptProgress.current = 0
decryptProgress.total = 0
decryptProgress.concurrency = 0
decryptProgress.success_count = 0
decryptProgress.skip_count = 0
decryptProgress.fail_count = 0
decryptProgress.current_file = ''
decryptProgress.fileStatus = ''
decryptProgress.status = ''
decryptProgress.message = ''
}
const resetEmojiDownloadProgress = () => {
emojiDownloadProgress.current = 0
emojiDownloadProgress.total = 0
emojiDownloadProgress.concurrency = 0
emojiDownloadProgress.success_count = 0
emojiDownloadProgress.skip_count = 0
emojiDownloadProgress.fail_count = 0
emojiDownloadProgress.current_file = ''
emojiDownloadProgress.fileStatus = ''
emojiDownloadProgress.status = ''
emojiDownloadProgress.message = ''
}
// 处理解密
@@ -861,6 +1140,14 @@ const handleDecrypt = async () => {
warning.value = ''
resetDbDecryptProgress()
resetMediaDecryptProgress()
resetEmojiDownloadProgress()
mediaDecryptResult.value = null
emojiDownloadResult.value = null
mediaDecrypting.value = false
emojiDownloading.value = false
closeMediaDecryptEventSource()
closeEmojiDownloadEventSource()
try {
const canSse = process.client && typeof window !== 'undefined' && typeof EventSource !== 'undefined'
@@ -1024,8 +1311,11 @@ const decryptAllImages = async () => {
mediaDecryptResult.value = null
error.value = ''
warning.value = ''
const configuredConcurrency = getMediaDecryptConcurrency()
mediaDecryptConcurrency.value = configuredConcurrency
logDecryptDebug('media-decrypt:start', {
account: mediaAccount.value,
concurrency: configuredConcurrency,
keys: summarizeKeyStateForLog(mediaKeys.xor_key, mediaKeys.aes_key)
})
@@ -1038,6 +1328,7 @@ const decryptAllImages = async () => {
if (mediaAccount.value) params.set('account', mediaAccount.value)
if (mediaKeys.xor_key) params.set('xor_key', mediaKeys.xor_key)
if (mediaKeys.aes_key) params.set('aes_key', mediaKeys.aes_key)
params.set('concurrency', String(configuredConcurrency))
const apiBase = useApiBase()
const url = `${apiBase}/media/decrypt_all_stream?${params.toString()}`
@@ -1053,28 +1344,37 @@ const decryptAllImages = async () => {
if (data.type === 'scanning') {
decryptProgress.current_file = '正在扫描文件...'
decryptProgress.message = data.message || '正在扫描图片文件...'
} else if (data.type === 'start') {
decryptProgress.total = data.total
decryptProgress.total = data.total || 0
decryptProgress.concurrency = data.concurrency || configuredConcurrency
decryptProgress.message = data.message || ''
} else if (data.type === 'progress') {
decryptProgress.current = data.current
decryptProgress.total = data.total
decryptProgress.success_count = data.success_count
decryptProgress.skip_count = data.skip_count
decryptProgress.fail_count = data.fail_count
decryptProgress.current_file = data.current_file
decryptProgress.fileStatus = data.status
decryptProgress.current = data.current || 0
decryptProgress.total = data.total || 0
decryptProgress.concurrency = data.concurrency || configuredConcurrency
decryptProgress.success_count = data.success_count || 0
decryptProgress.skip_count = data.skip_count || 0
decryptProgress.fail_count = data.fail_count || 0
decryptProgress.current_file = data.current_file || ''
decryptProgress.fileStatus = data.status || ''
decryptProgress.message = data.message || ''
} else if (data.type === 'complete') {
decryptProgress.status = 'complete'
decryptProgress.current = data.total
decryptProgress.total = data.total
decryptProgress.success_count = data.success_count
decryptProgress.skip_count = data.skip_count
decryptProgress.fail_count = data.fail_count
decryptProgress.current = data.total || 0
decryptProgress.total = data.total || 0
decryptProgress.concurrency = data.concurrency || configuredConcurrency
decryptProgress.success_count = data.success_count || 0
decryptProgress.skip_count = data.skip_count || 0
decryptProgress.fail_count = data.fail_count || 0
decryptProgress.message = data.message || '解密完成'
mediaDecryptResult.value = data
mediaDecrypting.value = false
logDecryptDebug('media-decrypt:complete', {
account: mediaAccount.value,
total: data.total,
concurrency: data.concurrency,
decrypt_stats: data.decrypt_stats,
success_count: data.success_count,
skip_count: data.skip_count,
fail_count: data.fail_count
@@ -1115,11 +1415,148 @@ const cancelMediaDecrypt = () => {
if (!mediaDecrypting.value) return
decryptProgress.status = 'cancelled'
decryptProgress.message = '已停止图片解密'
mediaDecrypting.value = false
warning.value = '已停止图片解密,已完成的图片会保留。'
logDecryptDebug('media-decrypt:cancelled', {
account: mediaAccount.value,
current: decryptProgress.current,
total: decryptProgress.total,
concurrency: decryptProgress.concurrency || getMediaDecryptConcurrency()
})
closeMediaDecryptEventSource()
}
const downloadAllEmojis = async () => {
closeEmojiDownloadEventSource()
emojiDownloading.value = true
emojiDownloadResult.value = null
error.value = ''
warning.value = ''
const configuredConcurrency = getEmojiDownloadConcurrency()
emojiDownloadConcurrency.value = configuredConcurrency
logDecryptDebug('emoji-download:start', {
account: mediaAccount.value,
concurrency: configuredConcurrency
})
resetEmojiDownloadProgress()
try {
const params = new URLSearchParams()
if (mediaAccount.value) params.set('account', mediaAccount.value)
params.set('concurrency', String(configuredConcurrency))
const apiBase = useApiBase()
const url = `${apiBase}/media/emoji/download_all_stream?${params.toString()}`
const eventSource = new EventSource(url)
emojiDownloadEventSource = eventSource
eventSource.onmessage = (event) => {
if (emojiDownloadEventSource !== eventSource) return
try {
const data = JSON.parse(event.data)
if (data.type === 'scanning') {
emojiDownloadProgress.current_file = '正在扫描表情资源...'
emojiDownloadProgress.message = data.message || '正在扫描表情资源...'
} else if (data.type === 'start') {
emojiDownloadProgress.total = data.total || 0
emojiDownloadProgress.concurrency = data.concurrency || configuredConcurrency
emojiDownloadProgress.message = data.message || ''
} else if (data.type === 'progress') {
emojiDownloadProgress.current = data.current || 0
emojiDownloadProgress.total = data.total || 0
emojiDownloadProgress.concurrency = data.concurrency || configuredConcurrency
emojiDownloadProgress.success_count = data.success_count || 0
emojiDownloadProgress.skip_count = data.skip_count || 0
emojiDownloadProgress.fail_count = data.fail_count || 0
emojiDownloadProgress.current_file = data.current_file || ''
emojiDownloadProgress.fileStatus = data.status || ''
emojiDownloadProgress.message = data.message || ''
} else if (data.type === 'complete') {
emojiDownloadProgress.status = 'complete'
emojiDownloadProgress.current = data.total || 0
emojiDownloadProgress.total = data.total || 0
emojiDownloadProgress.concurrency = data.concurrency || configuredConcurrency
emojiDownloadProgress.success_count = data.success_count || 0
emojiDownloadProgress.skip_count = data.skip_count || 0
emojiDownloadProgress.fail_count = data.fail_count || 0
emojiDownloadProgress.message = data.message || '表情下载完成'
emojiDownloadResult.value = data
emojiDownloading.value = false
logDecryptDebug('emoji-download:complete', {
account: mediaAccount.value,
total: data.total,
concurrency: data.concurrency,
download_stats: data.download_stats,
success_count: data.success_count,
skip_count: data.skip_count,
fail_count: data.fail_count
})
closeEmojiDownloadEventSource()
} else if (data.type === 'error') {
error.value = data.message || '表情下载失败'
logDecryptDebug('emoji-download:error-event', {
account: mediaAccount.value,
message: data.message
})
emojiDownloading.value = false
closeEmojiDownloadEventSource()
}
} catch (e) {
console.error('解析表情下载SSE消息失败:', e)
}
}
eventSource.onerror = (e) => {
if (emojiDownloadEventSource !== eventSource) return
console.error('表情下载SSE连接错误:', e)
closeEmojiDownloadEventSource()
if (emojiDownloading.value) {
error.value = '表情下载连接中断,请重试'
emojiDownloading.value = false
}
}
} catch (err) {
error.value = err.message || '表情下载过程中发生错误'
emojiDownloading.value = false
closeEmojiDownloadEventSource()
}
}
const cancelEmojiDownload = () => {
if (!emojiDownloading.value) return
emojiDownloadProgress.status = 'cancelled'
emojiDownloading.value = false
warning.value = '已停止表情下载,已完成的表情会保留。'
logDecryptDebug('emoji-download:cancelled', {
account: mediaAccount.value,
current: emojiDownloadProgress.current,
total: emojiDownloadProgress.total
})
closeEmojiDownloadEventSource()
}
const goToEmojiDownloadStep = () => {
if (mediaDecrypting.value) return
error.value = ''
warning.value = ''
currentStep.value = 3
}
const goBackToMediaDecryptStep = () => {
if (emojiDownloading.value) return
error.value = ''
warning.value = ''
currentStep.value = 2
}
// 从密钥步骤进入图片解密步骤
const goToMediaDecryptStep = async () => {
error.value = ''
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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()
+81 -21
View File
@@ -24,7 +24,80 @@ class _FakeDisconnectingRequest:
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)
@@ -43,28 +116,15 @@ class TestMediaDecryptStreamCancel(unittest.TestCase):
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",
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",
)
)
)
async def _read_chunks():
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk.decode("utf-8") if isinstance(chunk, bytes) else str(chunk))
return chunks
chunks = asyncio.run(_read_chunks())
events = []
for chunk in chunks:
for line in chunk.splitlines():
if line.startswith("data: "):
events.append(json.loads(line[len("data: ") :]))
events = asyncio.run(_read_sse_events(response))
self.assertEqual([event.get("type") for event in events], ["scanning", "start"])
decrypt_mock.assert_not_called()
+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()