improvement(import): 支持 wxdump 目录导入并增加导入保护

- 兼容 wxdump 的 output 目录、database/ 和 media/ 结构

- 缺少 account.json 时自动推断账号信息并补充导入预览

- 导入前展示目标账号状态,并拦截源目录与目标目录重叠的情况

- 支持取消导入、已有账号自动备份,以及失败/取消后的回滚恢复

- 补充资源查找兼容逻辑,适配 wxdump 导入后的媒体文件布局
This commit is contained in:
2977094657
2026-04-24 18:04:21 +08:00
Unverified
parent 8c0eeca4ed
commit 751c252e88
3 changed files with 487 additions and 103 deletions
+143 -41
View File
@@ -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>
+19 -7
View File
@@ -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",