mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
improvement(import): 支持 wxdump 目录导入并增加导入保护
- 兼容 wxdump 的 output 目录、database/ 和 media/ 结构 - 缺少 account.json 时自动推断账号信息并补充导入预览 - 导入前展示目标账号状态,并拦截源目录与目标目录重叠的情况 - 支持取消导入、已有账号自动备份,以及失败/取消后的回滚恢复 - 补充资源查找兼容逻辑,适配 wxdump 导入后的媒体文件布局
This commit is contained in:
+143
-41
@@ -2,7 +2,7 @@
|
||||
<div class="import-page min-h-screen relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5 pointer-events-none"></div>
|
||||
|
||||
<div class="relative z-10 mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div class="relative z-10 mx-auto flex min-h-screen w-full max-w-4xl items-center justify-center px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div class="w-full rounded-[28px] border border-[#EDEDED] bg-white/92 backdrop-blur-sm">
|
||||
<div class="px-5 py-5 sm:px-7 sm:py-7">
|
||||
<div class="mb-5 flex items-start justify-between gap-3">
|
||||
@@ -13,9 +13,9 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">Import backup</p>
|
||||
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">导入备份</p>
|
||||
<h1 class="mt-1 text-[24px] font-semibold leading-none text-[#000000e6]">数据导入</h1>
|
||||
<p class="mt-2 text-sm text-[#7F7F7F]">导入已解密的微信备份目录,确认账号后即可写入当前工具。</p>
|
||||
<p class="mt-2 text-sm text-[#7F7F7F]">导入已解密的微信备份目录,支持本项目导出和 wxdump 的 output/wxid_xxx 结构。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,27 +30,26 @@
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 rounded-[22px] border border-[#E8EFE8] bg-[#F8FBF8] px-4 py-4">
|
||||
<div class="flex items-center gap-2 text-[13px] font-semibold text-[#000000d9]">
|
||||
<svg class="h-4 w-4 text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>目录要求</span>
|
||||
</div>
|
||||
<div class="mt-3 grid gap-2 sm:grid-cols-3">
|
||||
<div class="rounded-2xl border border-white bg-white/80 px-3 py-3">
|
||||
<p class="text-[11px] uppercase tracking-[0.08em] text-[#7F7F7F]">Target</p>
|
||||
<p class="mt-1 text-sm leading-6 text-[#000000d9]">请选择 `output / wxid_xxxxx` 这一层目录。</p>
|
||||
<div class="mb-5 rounded-[22px] border border-[#E8EFE8] bg-[#F8FBF8] px-4 py-4 sm:px-5">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white text-[#07C160] ring-1 ring-[#E7F1E8]">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-semibold text-[#000000d9]">目录要求</div>
|
||||
<p class="mt-1 text-sm leading-6 text-[#6F6F6F]">支持本项目导出和 wxdump 导出。优先选择账号目录;若 output 下只有一个账号,也可直接选 output。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white bg-white/80 px-3 py-3">
|
||||
<p class="text-[11px] uppercase tracking-[0.08em] text-[#7F7F7F]">Database</p>
|
||||
<p class="mt-1 text-sm leading-6 text-[#000000d9]">目录内需要包含 `databases/`,用于存放 `.db` 文件。</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white bg-white/80 px-3 py-3">
|
||||
<p class="text-[11px] uppercase tracking-[0.08em] text-[#7F7F7F]">Account</p>
|
||||
<p class="mt-1 text-sm leading-6 text-[#000000d9]">`account.json` 会作为账号识别与信息校验依据。</p>
|
||||
<div class="flex shrink-0 flex-wrap gap-2 sm:justify-end">
|
||||
<span class="inline-flex items-center rounded-full border border-[#DDEBE0] bg-white px-3 py-1 text-xs font-medium text-[#4A4A4A]">databases/</span>
|
||||
<span class="inline-flex items-center rounded-full border border-[#DDEBE0] bg-white px-3 py-1 text-xs font-medium text-[#4A4A4A]">database/</span>
|
||||
<span class="inline-flex items-center rounded-full border border-[#DDEBE0] bg-white px-3 py-1 text-xs font-medium text-[#4A4A4A]">media/</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="!importPreview && !importError && !importing" class="animate-fade-in">
|
||||
@@ -64,7 +63,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold text-[#000000e6]">选择解密备份目录</h3>
|
||||
<p class="mt-2 text-sm text-[#7F7F7F]">建议直接选择 `wxid_xxxxx` 层级,减少后续校验失败。</p>
|
||||
<p class="mt-2 text-sm text-[#7F7F7F]">建议选择 `wxid_xxxxx` 层级;wxdump 的 `output` 根目录在单账号时也支持。</p>
|
||||
<div class="mt-5 inline-flex items-center rounded-full bg-[#07C160] px-4 py-2 text-sm font-medium text-white transition-colors duration-200 group-hover:bg-[#06AD56]">
|
||||
点击开始选择
|
||||
</div>
|
||||
@@ -81,7 +80,7 @@
|
||||
|
||||
<div class="mt-5 text-center">
|
||||
<p class="text-xl font-semibold text-[#000000e6]">{{ importMessage }}</p>
|
||||
<p class="mt-2 text-sm text-[#7F7F7F]">请保持程序运行,导入完成后会自动进入聊天页面。</p>
|
||||
<p class="mt-2 text-sm text-[#7F7F7F]">请保持程序运行,导入完成后可手动进入聊天页面。</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-hidden rounded-full bg-[#EDF3EE]">
|
||||
@@ -95,19 +94,62 @@
|
||||
<span>已连接导入任务</span>
|
||||
<span>{{ importProgress }}%</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="mt-5 inline-flex w-full items-center justify-center rounded-2xl border border-[#F0D7D7] bg-white px-4 py-3 text-sm font-medium text-[#D64A4A] transition-colors hover:bg-[#FFF7F7]"
|
||||
@click="cancelImport"
|
||||
>
|
||||
取消导入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="importPreview && !importing" class="animate-fade-in space-y-4">
|
||||
<div v-if="importComplete && !importing" class="animate-fade-in space-y-4">
|
||||
<div class="rounded-[24px] border border-[#DCEFE2] bg-[#F7FCF8] px-5 py-7 text-center sm:px-6">
|
||||
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-[#07C160]/10 text-[#07C160]">
|
||||
<svg class="h-7 w-7" 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>
|
||||
</div>
|
||||
<h2 class="mt-4 text-xl font-semibold text-[#000000e6]">导入完成</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-[#6F6F6F]">{{ importComplete.message || '账号数据已成功导入。' }}</p>
|
||||
<div class="mt-4 rounded-2xl border border-[#E2EFE5] bg-white px-4 py-3 text-left text-sm text-[#4A4A4A]">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-[#7F7F7F]">账号</span>
|
||||
<span class="min-w-0 truncate font-mono text-xs">{{ importComplete.account }}</span>
|
||||
</div>
|
||||
<div v-if="importComplete.backup_dir" class="mt-2 flex items-start justify-between gap-3">
|
||||
<span class="shrink-0 text-[#7F7F7F]">旧数据备份</span>
|
||||
<span class="min-w-0 break-all text-right text-xs">{{ importComplete.backup_dir }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-2xl border border-[#E2E2E2] bg-white px-4 py-3 text-sm font-medium text-[#4A4A4A] transition-colors hover:bg-[#F8F8F8]"
|
||||
@click="retryPickDirectory"
|
||||
>
|
||||
继续导入其他目录
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-2xl bg-[#07C160] px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-[#06AD56]"
|
||||
@click="navigateTo('/chat')"
|
||||
>
|
||||
进入聊天页面
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="importPreview && !importing && !importComplete" class="animate-fade-in space-y-4">
|
||||
<div class="rounded-[24px] border border-[#EDEDED] bg-[#FCFDFC] px-5 py-5 sm:px-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<div class="h-16 w-16 shrink-0 overflow-hidden rounded-2xl border border-[#EDEDED] bg-white">
|
||||
<img :src="importPreview.avatar_url || '/Contact.png'" class="h-full w-full object-cover" alt="Avatar" />
|
||||
<img :src="importPreview.avatar_url || '/Contact.png'" class="h-full w-full object-cover" alt="头像" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">Detected account</p>
|
||||
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">检测到的账号</p>
|
||||
<div class="mt-1 truncate text-xl font-semibold text-[#000000e6]">{{ importPreview.nick || '未命名账号' }}</div>
|
||||
<div class="mt-2 inline-flex max-w-full items-center rounded-full border border-[#EDEDED] bg-white px-3 py-1 text-xs font-mono text-[#7F7F7F]">
|
||||
<span class="truncate">{{ importPreview.username }}</span>
|
||||
@@ -121,7 +163,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="selectedImportPath" class="mt-4 rounded-[18px] border border-[#EDEDED] bg-white px-3 py-3">
|
||||
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">Import path</p>
|
||||
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">导入路径</p>
|
||||
<p class="mt-1 break-all text-sm text-[#000000d9]">{{ selectedImportPath }}</p>
|
||||
</div>
|
||||
|
||||
@@ -145,7 +187,7 @@
|
||||
重新选择目录
|
||||
</button>
|
||||
<button
|
||||
:disabled="importing"
|
||||
:disabled="importing || importPreview?.source_overlaps_target"
|
||||
class="inline-flex items-center justify-center rounded-2xl bg-[#07C160] px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-[#06AD56] disabled:cursor-not-allowed disabled:bg-[#8FD9AE]"
|
||||
@click="confirmImport"
|
||||
>
|
||||
@@ -169,7 +211,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="selectedImportPath" class="mt-4 rounded-[18px] border border-[#F1E3E3] bg-white/80 px-3 py-3">
|
||||
<p class="text-[11px] uppercase tracking-[0.12em] text-[#B26B6B]">Current path</p>
|
||||
<p class="text-[11px] uppercase tracking-[0.12em] text-[#B26B6B]">当前路径</p>
|
||||
<p class="mt-1 break-all text-sm text-[#7A4B4B]">{{ selectedImportPath }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,7 +239,9 @@ const importProgress = ref(0)
|
||||
const importMessage = ref('正在准备...')
|
||||
const importPreview = ref(null)
|
||||
const importError = ref('')
|
||||
const importComplete = ref(null)
|
||||
const selectedImportPath = ref('')
|
||||
const importJobId = ref('')
|
||||
|
||||
let eventSource = null
|
||||
|
||||
@@ -220,23 +264,24 @@ const isDesktopShell = () => {
|
||||
const resetImport = () => {
|
||||
closeEventSource()
|
||||
importPreview.value = null
|
||||
importComplete.value = null
|
||||
importError.value = ''
|
||||
selectedImportPath.value = ''
|
||||
importing.value = false
|
||||
importProgress.value = 0
|
||||
importMessage.value = '正在准备...'
|
||||
importJobId.value = ''
|
||||
}
|
||||
|
||||
const { importDecryptedPreview, pickSystemDirectory } = useApi()
|
||||
const apiBase = useApiBase()
|
||||
|
||||
const handlePickDirectory = async () => {
|
||||
let path = ''
|
||||
|
||||
if (isDesktopShell()) {
|
||||
try {
|
||||
const res = await window.wechatDesktop.chooseDirectory({
|
||||
title: '请选择解密输出目录 (如: output/wxid_xxxxx)'
|
||||
title: '请选择解密输出目录 (如: output/wxid_xxxxx 或单账号 output)'
|
||||
})
|
||||
if (!res || res.canceled || !res.filePaths?.length) return
|
||||
path = res.filePaths[0]
|
||||
@@ -246,7 +291,7 @@ const handlePickDirectory = async () => {
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const res = await pickSystemDirectory({ title: '请选择解密输出目录 (需选到 wxid_xxx 层级)' })
|
||||
const res = await pickSystemDirectory({ title: '请选择解密输出目录 (建议选到 wxid_xxx 层级)' })
|
||||
if (!res || !res.path) return
|
||||
path = res.path
|
||||
} catch (e) {
|
||||
@@ -260,7 +305,7 @@ const handlePickDirectory = async () => {
|
||||
const isOk = window.confirm(`你选择的目录为:
|
||||
${path}
|
||||
|
||||
该目录似乎不符合 "wxid_xxxxx" 的格式。确定要继续吗?`)
|
||||
该目录似乎不是 "wxid_xxxxx" 账号目录。如果这是 wxdump 的单账号 output 根目录,可以继续。确定要继续吗?`)
|
||||
if (!isOk) return
|
||||
}
|
||||
|
||||
@@ -271,7 +316,7 @@ ${path}
|
||||
try {
|
||||
importPreview.value = await importDecryptedPreview({ import_path: path })
|
||||
} catch (e) {
|
||||
importError.value = e.message || '目录格式不正确,请确保包含 databases 目录和 account.json'
|
||||
importError.value = e.message || '目录格式不正确,请确保包含 databases/database 目录;wxdump 格式可不含 account.json'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,16 +325,65 @@ const retryPickDirectory = async () => {
|
||||
await handlePickDirectory()
|
||||
}
|
||||
|
||||
const makeImportJobId = () => {
|
||||
const randomPart = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
return `import-${randomPart}`
|
||||
}
|
||||
|
||||
const cancelImport = async () => {
|
||||
const jobId = importJobId.value
|
||||
closeEventSource()
|
||||
importing.value = false
|
||||
importProgress.value = 0
|
||||
importMessage.value = '正在准备...'
|
||||
importComplete.value = null
|
||||
importError.value = ''
|
||||
|
||||
if (!jobId) return
|
||||
try {
|
||||
const url = new URL(`${apiBase.replace(/\/$/, '')}/import_decrypted/cancel`, window.location.origin)
|
||||
url.searchParams.set('job_id', jobId)
|
||||
await fetch(url.toString(), { method: 'POST' })
|
||||
} catch (e) {
|
||||
console.error('取消导入失败:', e)
|
||||
} finally {
|
||||
importJobId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const confirmImport = async () => {
|
||||
if (!selectedImportPath.value) return
|
||||
|
||||
if (importPreview.value?.source_overlaps_target) {
|
||||
importError.value = '导入源目录与目标数据目录相同或相互包含,请重新选择外部备份目录。'
|
||||
return
|
||||
}
|
||||
|
||||
if (importPreview.value?.target_exists) {
|
||||
const ok = window.confirm(`当前账号已存在:${importPreview.value.username}
|
||||
|
||||
继续导入会先自动备份旧目录,然后导入新数据。
|
||||
旧数据库数量:${importPreview.value.existing_db_count || 0}
|
||||
新数据库数量:${importPreview.value.incoming_db_count || 0}
|
||||
|
||||
确定继续吗?`)
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
|
||||
importComplete.value = null
|
||||
importError.value = ''
|
||||
importProgress.value = 0
|
||||
importMessage.value = '启动导入程序...'
|
||||
importJobId.value = makeImportJobId()
|
||||
|
||||
const url = new URL(`${apiBase.replace(/\/$/, '')}/import_decrypted`, window.location.origin)
|
||||
url.searchParams.set('import_path', selectedImportPath.value)
|
||||
url.searchParams.set('job_id', importJobId.value)
|
||||
|
||||
closeEventSource()
|
||||
eventSource = new EventSource(url.toString())
|
||||
@@ -304,15 +398,17 @@ const confirmImport = async () => {
|
||||
} else if (data.type === 'complete') {
|
||||
importProgress.value = 100
|
||||
importMessage.value = '导入完成!'
|
||||
closeEventSource()
|
||||
|
||||
setTimeout(async () => {
|
||||
await navigateTo('/chat')
|
||||
}, 1000)
|
||||
} else if (data.type === 'error') {
|
||||
importError.value = data.message || '导入失败'
|
||||
importComplete.value = data
|
||||
importError.value = ''
|
||||
importing.value = false
|
||||
closeEventSource()
|
||||
importJobId.value = ''
|
||||
} else if (data.type === 'error') {
|
||||
importError.value = data.message || '导入失败'
|
||||
importComplete.value = null
|
||||
importing.value = false
|
||||
closeEventSource()
|
||||
importJobId.value = ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析 SSE 数据失败:', e)
|
||||
@@ -321,9 +417,15 @@ const confirmImport = async () => {
|
||||
|
||||
eventSource.onerror = (e) => {
|
||||
console.error('EventSource 错误:', e)
|
||||
if (!importing.value) {
|
||||
closeEventSource()
|
||||
return
|
||||
}
|
||||
importComplete.value = null
|
||||
importError.value = '与服务器连接断开或发生错误'
|
||||
importing.value = false
|
||||
closeEventSource()
|
||||
importJobId.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3051,14 +3051,26 @@ def _try_find_decrypted_resource(account_dir: Path, md5: str) -> Optional[Path]:
|
||||
if not resource_dir.exists():
|
||||
return None
|
||||
sub_dir = md5[:2] if len(md5) >= 2 else "00"
|
||||
|
||||
# Prefer the standard layout: resource/{md5-prefix}/{md5}.{ext}
|
||||
target_dir = resource_dir / sub_dir
|
||||
if not target_dir.exists():
|
||||
return None
|
||||
# 查找匹配MD5的文件(可能有不同扩展名)
|
||||
for ext in ["jpg", "png", "gif", "webp", "mp4", "dat"]:
|
||||
p = target_dir / f"{md5}.{ext}"
|
||||
if p.exists():
|
||||
return p
|
||||
search_dirs = [target_dir]
|
||||
|
||||
# Support wxdump flat media layout after it is imported as resource.
|
||||
# Typical files: resource/{md5}.jpg, resource/{md5}_t.jpg, or resource/{md5}.wxgf.
|
||||
if resource_dir not in search_dirs:
|
||||
search_dirs.append(resource_dir)
|
||||
|
||||
exts = ["jpg", "png", "gif", "webp", "mp4", "dat", "wxgf", "wxgf.jpg"]
|
||||
suffixes = ["", "_t", "_b", "_h"]
|
||||
for directory in search_dirs:
|
||||
if not directory.exists():
|
||||
continue
|
||||
for suffix in suffixes:
|
||||
for ext in exts:
|
||||
candidate = directory / f"{md5}{suffix}.{ext}"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
import sqlite3
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
@@ -20,6 +22,12 @@ logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
|
||||
_IMPORT_CANCEL_EVENTS: dict[str, asyncio.Event] = {}
|
||||
|
||||
|
||||
class ImportCancelled(Exception):
|
||||
pass
|
||||
|
||||
class ImportRequest(BaseModel):
|
||||
import_path: str = Field(..., description="已解密的数据库和资源所在目录的绝对路径")
|
||||
|
||||
@@ -33,46 +41,180 @@ def _is_valid_sqlite(path: Path) -> bool:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _validate_import_structure(import_path: Path) -> dict:
|
||||
"""
|
||||
验证导入目录结构:
|
||||
- databases/ (必须包含 contact.db, session.db)
|
||||
- resource/ (可选)
|
||||
- account.json (必须包含 username, nick)
|
||||
"""
|
||||
db_dir = import_path / "databases"
|
||||
account_json_path = import_path / "account.json"
|
||||
|
||||
if not db_dir.exists() or not db_dir.is_dir():
|
||||
raise HTTPException(status_code=400, detail="未找到 databases 目录")
|
||||
|
||||
if not account_json_path.exists():
|
||||
raise HTTPException(status_code=400, detail="未找到 account.json 文件")
|
||||
|
||||
# 验证关键数据库
|
||||
required_dbs = ["contact.db", "session.db"]
|
||||
for db_name in required_dbs:
|
||||
if not _is_valid_sqlite(db_dir / db_name):
|
||||
raise HTTPException(status_code=400, detail=f"databases 目录中未找到有效的 {db_name}")
|
||||
|
||||
# 解析 account.json
|
||||
def _clean_profile_text(value: object) -> str:
|
||||
text = str(value or "").replace("\u3164", "").strip()
|
||||
return text
|
||||
|
||||
|
||||
def _pick_import_account_dir(import_path: Path) -> Path:
|
||||
"""Resolve the actual account directory; supports selecting output root or wxid_xxx."""
|
||||
if (import_path / "databases").is_dir() or (import_path / "database").is_dir():
|
||||
return import_path
|
||||
account_dirs: list[Path] = []
|
||||
try:
|
||||
account_info = json.loads(account_json_path.read_text(encoding="utf-8"))
|
||||
for child in import_path.iterdir():
|
||||
if child.is_dir() and ((child / "databases").is_dir() or (child / "database").is_dir()):
|
||||
account_dirs.append(child)
|
||||
except Exception:
|
||||
account_dirs = []
|
||||
if len(account_dirs) == 1:
|
||||
return account_dirs[0]
|
||||
if len(account_dirs) > 1:
|
||||
names = ", ".join(p.name for p in account_dirs[:5])
|
||||
raise HTTPException(status_code=400, detail=f"Multiple account directories found. Please select one account directory: {names}")
|
||||
return import_path
|
||||
|
||||
|
||||
def _pick_database_dir(account_dir: Path) -> Path:
|
||||
"""Support both this app's databases/ and wxdump's database/ directory names."""
|
||||
for name in ("databases", "database"):
|
||||
db_dir = account_dir / name
|
||||
if db_dir.exists() and db_dir.is_dir():
|
||||
return db_dir
|
||||
raise HTTPException(status_code=400, detail="Missing databases or database directory")
|
||||
|
||||
|
||||
def _pick_resource_dir(account_dir: Path) -> Optional[Path]:
|
||||
"""Support both this app's resource/ and wxdump's media/ directory names."""
|
||||
for name in ("resource", "media"):
|
||||
resource_dir = account_dir / name
|
||||
if resource_dir.exists() and resource_dir.is_dir():
|
||||
return resource_dir
|
||||
return None
|
||||
|
||||
|
||||
def _read_contact_profile(db_dir: Path, username: str) -> dict:
|
||||
"""Best-effort account profile inference from contact.db."""
|
||||
contact_db = db_dir / "contact.db"
|
||||
if not _is_valid_sqlite(contact_db):
|
||||
return {}
|
||||
try:
|
||||
conn = sqlite3.connect(str(contact_db))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
row = conn.execute("""
|
||||
SELECT username, remark, nick_name, alias, big_head_url, small_head_url
|
||||
FROM contact
|
||||
WHERE username = ?
|
||||
LIMIT 1
|
||||
""", (username,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
return {}
|
||||
nick = _clean_profile_text(row["nick_name"]) or _clean_profile_text(row["remark"]) or _clean_profile_text(row["alias"]) or username
|
||||
return {"username": _clean_profile_text(row["username"]) or username, "nick": nick, "avatar_url": str(row["big_head_url"] or row["small_head_url"] or "").strip(), "alias": _clean_profile_text(row["alias"])}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"解析 account.json 失败: {e}")
|
||||
|
||||
username = account_info.get("username")
|
||||
nick = account_info.get("nick")
|
||||
|
||||
if not username or not nick:
|
||||
raise HTTPException(status_code=400, detail="account.json 中缺少 username 或 nick")
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"nick": nick,
|
||||
"avatar_url": account_info.get("avatar_url", ""),
|
||||
"has_resource": (import_path / "resource").exists()
|
||||
}
|
||||
logger.warning(f"Failed to read account profile from contact.db: {contact_db}, {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _load_or_infer_account_info(account_dir: Path, db_dir: Path) -> tuple[dict, Optional[Path], bool]:
|
||||
"""Read account.json; if missing in wxdump output, infer from folder name and contact.db."""
|
||||
account_json_path = account_dir / "account.json"
|
||||
if account_json_path.exists():
|
||||
try:
|
||||
account_info = json.loads(account_json_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse account.json: {e}")
|
||||
username = _clean_profile_text(account_info.get("username"))
|
||||
nick = _clean_profile_text(account_info.get("nick") or account_info.get("nickname"))
|
||||
if not username or not nick:
|
||||
raise HTTPException(status_code=400, detail="account.json is missing username or nick")
|
||||
account_info["username"] = username
|
||||
account_info["nick"] = nick
|
||||
account_info.setdefault("avatar_url", "")
|
||||
return account_info, account_json_path, False
|
||||
inferred_username = _clean_profile_text(account_dir.name)
|
||||
if not inferred_username:
|
||||
raise HTTPException(status_code=400, detail="Missing account.json and cannot infer account from directory name")
|
||||
profile = _read_contact_profile(db_dir, inferred_username)
|
||||
username = _clean_profile_text(profile.get("username")) or inferred_username
|
||||
nick = _clean_profile_text(profile.get("nick")) or _clean_profile_text(profile.get("alias")) or username
|
||||
return {"username": username, "nick": nick, "avatar_url": str(profile.get("avatar_url") or ""), "alias": str(profile.get("alias") or "")}, None, True
|
||||
|
||||
|
||||
def _validate_import_structure(import_path: Path) -> dict:
|
||||
account_dir = _pick_import_account_dir(import_path)
|
||||
db_dir = _pick_database_dir(account_dir)
|
||||
resource_dir = _pick_resource_dir(account_dir)
|
||||
for db_name in ["contact.db", "session.db"]:
|
||||
if not _is_valid_sqlite(db_dir / db_name):
|
||||
raise HTTPException(status_code=400, detail=f"Missing valid {db_name} in {db_dir.name}")
|
||||
account_info, account_json_path, inferred_account = _load_or_infer_account_info(account_dir, db_dir)
|
||||
return {"username": account_info["username"], "nick": account_info["nick"], "avatar_url": account_info.get("avatar_url", ""), "alias": account_info.get("alias", ""), "has_resource": resource_dir is not None, "source_format": "wxdump" if db_dir.name == "database" or inferred_account else "wechat_data_analysis", "inferred_account": inferred_account, "account_dir": str(account_dir), "db_dir": str(db_dir), "resource_dir": str(resource_dir) if resource_dir else "", "account_json_path": str(account_json_path) if account_json_path else ""}
|
||||
|
||||
|
||||
def _count_db_files(db_dir: Path) -> int:
|
||||
try:
|
||||
return sum(1 for f in db_dir.iterdir() if f.is_file() and f.suffix.lower() == ".db")
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _is_dir_nonempty(path: Path) -> bool:
|
||||
try:
|
||||
return path.exists() and path.is_dir() and any(path.iterdir())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _paths_overlap(a: Path, b: Path) -> bool:
|
||||
try:
|
||||
ar = a.resolve()
|
||||
br = b.resolve()
|
||||
except Exception:
|
||||
ar = a.absolute()
|
||||
br = b.absolute()
|
||||
return ar == br or ar in br.parents or br in ar.parents
|
||||
|
||||
|
||||
def _build_target_state(info: dict) -> dict:
|
||||
output_base = get_output_databases_dir()
|
||||
account_name = str(info.get("username") or "").strip()
|
||||
target_dir = output_base / account_name if account_name else output_base
|
||||
resource_dir = target_dir / "resource"
|
||||
db_files: list[str] = []
|
||||
try:
|
||||
if target_dir.exists() and target_dir.is_dir():
|
||||
db_files = sorted(f.name for f in target_dir.iterdir() if f.is_file() and f.suffix.lower() == ".db")
|
||||
except Exception:
|
||||
db_files = []
|
||||
paths = [Path(str(info.get("account_dir") or "")), Path(str(info.get("db_dir") or ""))]
|
||||
if info.get("resource_dir"):
|
||||
paths.append(Path(str(info.get("resource_dir"))))
|
||||
return {"target_dir": str(target_dir), "target_exists": target_dir.exists(), "target_nonempty": _is_dir_nonempty(target_dir), "existing_db_count": len(db_files), "existing_db_files": db_files[:50], "incoming_db_count": _count_db_files(Path(str(info.get("db_dir") or ""))), "target_has_resource": resource_dir.exists(), "will_replace_resource": bool(resource_dir.exists() and info.get("resource_dir")), "source_overlaps_target": any(_paths_overlap(x, target_dir) for x in paths if str(x))}
|
||||
|
||||
|
||||
def _next_backup_dir(account_output_dir: Path) -> Path:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
base = account_output_dir.with_name(f"{account_output_dir.name}.backup-{stamp}")
|
||||
candidate = base
|
||||
i = 1
|
||||
while candidate.exists():
|
||||
candidate = account_output_dir.with_name(f"{base.name}-{i}")
|
||||
i += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _backup_existing_account_dir(account_output_dir: Path) -> Optional[Path]:
|
||||
if not account_output_dir.exists():
|
||||
return None
|
||||
backup_dir = _next_backup_dir(account_output_dir)
|
||||
shutil.move(str(account_output_dir), str(backup_dir))
|
||||
return backup_dir
|
||||
|
||||
|
||||
def _rollback_account_backup(account_output_dir: Path, backup_dir: Optional[Path]) -> None:
|
||||
if not backup_dir or not backup_dir.exists():
|
||||
return
|
||||
if account_output_dir.exists():
|
||||
if account_output_dir.is_symlink() or account_output_dir.is_file():
|
||||
account_output_dir.unlink()
|
||||
else:
|
||||
shutil.rmtree(account_output_dir)
|
||||
shutil.move(str(backup_dir), str(account_output_dir))
|
||||
|
||||
|
||||
@router.post("/api/import_decrypted/preview", summary="预览待导入的账号信息")
|
||||
async def preview_import(request: ImportRequest):
|
||||
@@ -80,18 +222,42 @@ async def preview_import(request: ImportRequest):
|
||||
if not import_path.exists() or not import_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="导入路径不存在或不是目录")
|
||||
|
||||
return _validate_import_structure(import_path)
|
||||
info = _validate_import_structure(import_path)
|
||||
info.update(_build_target_state(info))
|
||||
return info
|
||||
|
||||
@router.post("/api/import_decrypted/cancel", summary="取消正在执行的导入任务")
|
||||
async def cancel_import_decrypted(job_id: str = Query(..., description="导入任务 ID")):
|
||||
cancel_event = _IMPORT_CANCEL_EVENTS.get(str(job_id or ""))
|
||||
if cancel_event:
|
||||
cancel_event.set()
|
||||
return {"status": "cancel_requested"}
|
||||
return {"status": "not_found"}
|
||||
|
||||
@router.get("/api/import_decrypted", summary="执行导入已解密的数据库和资源目录 (SSE)")
|
||||
async def import_decrypted_directory(
|
||||
import_path: str = Query(..., description="已解密的数据库和资源所在目录的绝对路径")
|
||||
import_path: str = Query(..., description="已解密的数据库和资源所在目录的绝对路径"),
|
||||
job_id: str = Query("", description="导入任务 ID,用于取消导入")
|
||||
):
|
||||
import_path_obj = Path(import_path.strip())
|
||||
account_output_dir: Optional[Path] = None
|
||||
backup_dir: Optional[Path] = None
|
||||
backup_restored = False
|
||||
job_key = str(job_id or "").strip()
|
||||
cancel_event: Optional[asyncio.Event] = None
|
||||
if job_key:
|
||||
cancel_event = _IMPORT_CANCEL_EVENTS.setdefault(job_key, asyncio.Event())
|
||||
cancel_event.clear()
|
||||
|
||||
def _sse(data: dict):
|
||||
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
|
||||
def _check_cancel():
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise ImportCancelled("用户已取消导入")
|
||||
|
||||
async def generate_progress():
|
||||
nonlocal account_output_dir, backup_dir, backup_restored
|
||||
try:
|
||||
if not import_path_obj.exists() or not import_path_obj.is_dir():
|
||||
yield _sse({"type": "error", "message": "导入路径不存在或不是目录"})
|
||||
@@ -108,22 +274,34 @@ async def import_decrypted_directory(
|
||||
yield _sse({"type": "error", "message": f"验证失败: {e}"})
|
||||
return
|
||||
|
||||
_check_cancel()
|
||||
info.update(_build_target_state(info))
|
||||
if info.get("source_overlaps_target"):
|
||||
yield _sse({"type": "error", "message": "导入源目录与目标数据目录相同或相互包含,请选择外部备份目录。"})
|
||||
return
|
||||
|
||||
account_name = info["username"]
|
||||
yield _sse({"type": "progress", "percent": 10, "message": f"验证成功: {account_name}"})
|
||||
yield _sse({"type": "progress", "percent": 10, "message": f"验证成功:{account_name}"})
|
||||
|
||||
# 2. 准备输出目录
|
||||
# 2. 准备目标目录;如果已有账号数据,先整体备份再替换。
|
||||
output_base = get_output_databases_dir()
|
||||
account_output_dir = output_base / account_name
|
||||
if account_output_dir.exists():
|
||||
yield _sse({"type": "progress", "percent": 12, "message": "检测到已有账号数据,正在创建备份..."})
|
||||
backup_dir = await asyncio.to_thread(_backup_existing_account_dir, account_output_dir)
|
||||
if backup_dir:
|
||||
yield _sse({"type": "progress", "percent": 14, "message": f"已创建备份:{backup_dir.name}"})
|
||||
await asyncio.to_thread(account_output_dir.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
yield _sse({"type": "progress", "percent": 15, "message": "正在准备目标目录..."})
|
||||
|
||||
# 3. 导入 databases 目录下的 .db 文件
|
||||
db_src_dir = import_path_obj / "databases"
|
||||
db_src_dir = Path(info["db_dir"])
|
||||
db_files = [f for f in db_src_dir.iterdir() if f.is_file() and f.suffix == ".db"]
|
||||
imported_files = []
|
||||
|
||||
for i, item in enumerate(db_files):
|
||||
_check_cancel()
|
||||
target = account_output_dir / item.name
|
||||
def _do_import_db(src, dst):
|
||||
if dst.exists():
|
||||
@@ -143,24 +321,79 @@ async def import_decrypted_directory(
|
||||
yield _sse({"type": "progress", "percent": percent, "message": f"正在导入数据库: {item.name}"})
|
||||
|
||||
# 4. 导入 resource 目录
|
||||
resource_src = import_path_obj / "resource"
|
||||
if resource_src.exists() and resource_src.is_dir():
|
||||
resource_src = Path(info["resource_dir"]) if info.get("resource_dir") else None
|
||||
if resource_src and resource_src.exists() and resource_src.is_dir():
|
||||
yield _sse({"type": "progress", "percent": 30, "message": "正在导入资源文件 (这可能需要一些时间)..."})
|
||||
resource_dst = account_output_dir / "resource"
|
||||
|
||||
def _do_import_resource(src, dst):
|
||||
def _reset_resource_dst(dst: Path) -> None:
|
||||
if dst.exists():
|
||||
if dst.is_symlink() or dst.is_file():
|
||||
dst.unlink()
|
||||
else:
|
||||
shutil.rmtree(dst)
|
||||
|
||||
def _try_link_resource(src: Path, dst: Path) -> bool:
|
||||
try:
|
||||
os.symlink(src, dst, target_is_directory=True)
|
||||
return True
|
||||
except Exception:
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
|
||||
return False
|
||||
|
||||
def _collect_resource_files(src: Path) -> list[tuple[Path, Path]]:
|
||||
files: list[tuple[Path, Path]] = []
|
||||
for root, _, names in os.walk(src):
|
||||
root_path = Path(root)
|
||||
for name in names:
|
||||
file_path = root_path / name
|
||||
try:
|
||||
if file_path.is_file():
|
||||
files.append((file_path, file_path.relative_to(src)))
|
||||
except Exception:
|
||||
continue
|
||||
return files
|
||||
|
||||
def _copy_resource_batch(batch: list[tuple[Path, Path]], dst_root: Path) -> int:
|
||||
copied = 0
|
||||
for src_file, rel_path in batch:
|
||||
dst_file = dst_root / rel_path
|
||||
dst_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src_file, dst_file)
|
||||
copied += 1
|
||||
return copied
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_do_import_resource, resource_src, resource_dst)
|
||||
prefer_copy_resource = info.get("source_format") == "wxdump"
|
||||
await asyncio.to_thread(_reset_resource_dst, resource_dst)
|
||||
|
||||
if not prefer_copy_resource:
|
||||
linked = await asyncio.to_thread(_try_link_resource, resource_src, resource_dst)
|
||||
if linked:
|
||||
yield _sse({"type": "progress", "percent": 48, "message": "资源目录已通过快捷链接导入。"})
|
||||
else:
|
||||
prefer_copy_resource = True
|
||||
|
||||
if prefer_copy_resource:
|
||||
yield _sse({"type": "progress", "percent": 31, "message": "正在扫描资源文件数量..."})
|
||||
resource_files = await asyncio.to_thread(_collect_resource_files, resource_src)
|
||||
total_resources = len(resource_files)
|
||||
if total_resources <= 0:
|
||||
await asyncio.to_thread(resource_dst.mkdir, parents=True, exist_ok=True)
|
||||
yield _sse({"type": "progress", "percent": 48, "message": "资源目录为空,已跳过资源复制。"})
|
||||
else:
|
||||
await asyncio.to_thread(resource_dst.mkdir, parents=True, exist_ok=True)
|
||||
batch_size = 300
|
||||
copied_resources = 0
|
||||
for batch_start in range(0, total_resources, batch_size):
|
||||
_check_cancel()
|
||||
batch = resource_files[batch_start:batch_start + batch_size]
|
||||
copied_resources += await asyncio.to_thread(_copy_resource_batch, batch, resource_dst)
|
||||
percent = 31 + int(min(copied_resources, total_resources) / total_resources * 17)
|
||||
yield _sse({
|
||||
"type": "progress",
|
||||
"percent": min(percent, 48),
|
||||
"message": f"正在复制资源文件:{copied_resources}/{total_resources}"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"导入 resource 目录失败: {e}")
|
||||
|
||||
@@ -183,6 +416,7 @@ async def import_decrypted_directory(
|
||||
total_wxgf = len(wxgf_files)
|
||||
converted_count = 0
|
||||
for i, wxgf_path in enumerate(wxgf_files):
|
||||
_check_cancel()
|
||||
def _convert_one(p):
|
||||
jpg_p = p.with_suffix(".wxgf.jpg")
|
||||
if not jpg_p.exists():
|
||||
@@ -209,10 +443,26 @@ async def import_decrypted_directory(
|
||||
|
||||
logger.info(f"账号 {account_name} 转换完成: {converted_count}/{total_wxgf} 个 .wxgf 文件")
|
||||
|
||||
# 6. 复制 account.json
|
||||
# 6. Copy or generate account.json
|
||||
def _write_imported_account_json(dst: Path, info: dict) -> None:
|
||||
src = Path(str(info.get("account_json_path") or ""))
|
||||
target = dst / "account.json"
|
||||
if src.exists() and src.is_file():
|
||||
shutil.copy2(src, target)
|
||||
return
|
||||
payload = {
|
||||
"username": info.get("username") or dst.name,
|
||||
"nick": info.get("nick") or info.get("username") or dst.name,
|
||||
"avatar_url": info.get("avatar_url") or "",
|
||||
"alias": info.get("alias") or "",
|
||||
"generated_by": "manual_import",
|
||||
"source_format": info.get("source_format") or "unknown",
|
||||
}
|
||||
target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
yield _sse({"type": "progress", "percent": 85, "message": "正在更新账号配置..."})
|
||||
try:
|
||||
await asyncio.to_thread(shutil.copy2, import_path_obj / "account.json", account_output_dir / "account.json")
|
||||
await asyncio.to_thread(_write_imported_account_json, account_output_dir, info)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -233,7 +483,7 @@ async def import_decrypted_directory(
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_save_source_info, account_output_dir, import_path_obj, info)
|
||||
await asyncio.to_thread(_save_source_info, account_output_dir, Path(info.get("account_dir") or import_path_obj), info)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -255,12 +505,32 @@ async def import_decrypted_directory(
|
||||
"status": "success",
|
||||
"account": account_name,
|
||||
"nick": info["nick"],
|
||||
"message": f"成功导入账号 {info['nick']} ({account_name})"
|
||||
"message": f"成功导入账号 {info['nick']} ({account_name})",
|
||||
"backup_dir": str(backup_dir) if backup_dir else ""
|
||||
})
|
||||
|
||||
except ImportCancelled:
|
||||
try:
|
||||
if account_output_dir is not None and backup_dir is not None:
|
||||
await asyncio.to_thread(_rollback_account_backup, account_output_dir, backup_dir)
|
||||
backup_restored = True
|
||||
except Exception as rollback_error:
|
||||
logger.error(f"取消导入后恢复备份失败: {rollback_error}", exc_info=True)
|
||||
suffix = ",已恢复导入前备份" if backup_restored else ""
|
||||
yield _sse({"type": "error", "message": f"导入已取消{suffix}"})
|
||||
except Exception as e:
|
||||
logger.error(f"导入过程中发生异常: {e}", exc_info=True)
|
||||
yield _sse({"type": "error", "message": f"导入失败: {str(e)}"})
|
||||
logger.error(f"导入失败: {e}", exc_info=True)
|
||||
try:
|
||||
if account_output_dir is not None and backup_dir is not None:
|
||||
await asyncio.to_thread(_rollback_account_backup, account_output_dir, backup_dir)
|
||||
backup_restored = True
|
||||
except Exception as rollback_error:
|
||||
logger.error(f"导入失败后恢复备份失败: {rollback_error}", exc_info=True)
|
||||
suffix = ",已恢复导入前备份" if backup_restored else ""
|
||||
yield _sse({"type": "error", "message": f"导入失败: {str(e)}{suffix}"})
|
||||
finally:
|
||||
if job_key:
|
||||
_IMPORT_CANCEL_EVENTS.pop(job_key, None)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "text/event-stream",
|
||||
|
||||
Reference in New Issue
Block a user