feat(export): 支持账号归档 ZIP 导出

- 新增账号归档导出后端接口,支持创建、查询、取消导出任务和下载 ZIP 文件
- 支持按账号打包数据库、元信息文件和资源文件,并使用 ZIP 存储模式提升大文件归档速度
- 前端接入账号归档导出 API,支持导出目录选择、导出内容勾选、进度轮询和取消导出
- 简化全局导出弹窗,将入口聚焦为数据库与资源文件归档导出
- 开启侧边栏导出入口,并补充 README 中的导出说明
This commit is contained in:
2977094657
2026-04-25 23:24:44 +08:00
Unverified
parent efdc74a948
commit 2f8af245ea
6 changed files with 965 additions and 1285 deletions
+4
View File
@@ -176,6 +176,10 @@ npm run dist
## 使用指南 ## 使用指南
### 导出
侧边栏提供“导出”入口,点击下载图标可打开统一导出弹窗。当前导出界面只保留两个选项:导出数据库、导出资源文件;用户选择导出目录后会生成一个账号归档 ZIP。当两个选项都勾选时,导出会按账号目录直接归档,使用 ZIP 存储模式打包,尽量避免二次压缩带来的耗时。
### 获取解密密钥 ### 获取解密密钥
在使用本工具之前,您需要先获取微信数据库的解密密钥。推荐使用以下工具: 在使用本工具之前,您需要先获取微信数据库的解密密钥。推荐使用以下工具:
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -148,7 +148,7 @@
<div <div
v-if="showGlobalExportEntry" v-if="showGlobalExportEntry"
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group" class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="批量导出" title="导出"
@click="openExportDialog" @click="openExportDialog"
> >
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent"> <div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
@@ -387,7 +387,7 @@ const { enabled: realtimeEnabled, available: realtimeAvailable, checking: realti
const { open: settingsDialogOpen, openDialog: openSettingsDialog } = useSettingsDialog() const { open: settingsDialogOpen, openDialog: openSettingsDialog } = useSettingsDialog()
const { getChatAccountInfo, deleteChatAccount } = useApi() const { getChatAccountInfo, deleteChatAccount } = useApi()
const showGlobalExportEntry = false const showGlobalExportEntry = true
const accountDialogOpen = ref(false) const accountDialogOpen = ref(false)
const exportDialogOpen = ref(false) const exportDialogOpen = ref(false)
const accountInfoLoading = ref(false) const accountInfoLoading = ref(false)
@@ -648,3 +648,4 @@ const toggleRealtime = async () => {
color: var(--sidebar-rail-icon-active-color); color: var(--sidebar-rail-icon-active-color);
} }
</style> </style>
+27
View File
@@ -510,6 +510,30 @@ export const useApi = () => {
}) })
} }
// Account archive export (databases + resource files)
const createAccountArchiveExport = async (payload = {}) => {
return await request('/account/archive_export', {
method: 'POST',
body: {
account: payload.account || null,
output_dir: payload.output_dir == null ? null : String(payload.output_dir || '').trim(),
include_databases: payload.include_databases == null ? true : !!payload.include_databases,
include_resources: payload.include_resources == null ? true : !!payload.include_resources,
file_name: payload.file_name || null
}
})
}
const getAccountArchiveExport = async (exportId) => {
if (!exportId) throw new Error('Missing exportId')
return await request(`/account/archive_export/${encodeURIComponent(String(exportId))}`)
}
const cancelAccountArchiveExport = async (exportId) => {
if (!exportId) throw new Error('Missing exportId')
return await request(`/account/archive_export/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
}
// WeChat Wrapped(年度总结) // WeChat Wrapped(年度总结)
const getWrappedAnnual = async (params = {}) => { const getWrappedAnnual = async (params = {}) => {
const query = new URLSearchParams() const query = new URLSearchParams()
@@ -662,6 +686,9 @@ export const useApi = () => {
cancelSnsExport, cancelSnsExport,
listChatContacts, listChatContacts,
exportChatContacts, exportChatContacts,
createAccountArchiveExport,
getAccountArchiveExport,
cancelAccountArchiveExport,
getWrappedAnnual, getWrappedAnnual,
getWrappedAnnualMeta, getWrappedAnnualMeta,
getWrappedAnnualCard, getWrappedAnnualCard,
+2
View File
@@ -28,6 +28,7 @@ from .routers.decrypt import router as _decrypt_router
from .routers.import_decrypted import router as _import_decrypted_router from .routers.import_decrypted import router as _import_decrypted_router
from .routers.health import router as _health_router from .routers.health import router as _health_router
from .routers.admin import router as _admin_router from .routers.admin import router as _admin_router
from .routers.account_archive_export import router as _account_archive_export_router
from .routers.keys import router as _keys_router from .routers.keys import router as _keys_router
from .routers.media import router as _media_router from .routers.media import router as _media_router
from .routers.sns import router as _sns_router from .routers.sns import router as _sns_router
@@ -65,6 +66,7 @@ async def _log_server_errors(request: Request, call_next):
app.include_router(_health_router) app.include_router(_health_router)
app.include_router(_admin_router) app.include_router(_admin_router)
app.include_router(_account_archive_export_router)
app.include_router(_wechat_detection_router) app.include_router(_wechat_detection_router)
app.include_router(_import_decrypted_router) app.include_router(_import_decrypted_router)
app.include_router(_decrypt_router) app.include_router(_decrypt_router)
@@ -0,0 +1,498 @@
import os
import re
import shutil
import stat
import threading
import time
import uuid
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from ..chat_helpers import _resolve_account_dir
from ..path_fix import PathFixRoute
router = APIRouter(route_class=PathFixRoute)
class AccountArchiveExportRequest(BaseModel):
account: Optional[str] = Field(None, description="Account directory name. Defaults to the first available account.")
output_dir: Optional[str] = Field(None, description="Absolute output directory. Defaults to output/exports/{account}.")
include_databases: bool = Field(True, description="Whether to include decrypted database files.")
include_resources: bool = Field(True, description="Whether to include resource folders.")
file_name: Optional[str] = Field(None, description="Optional zip file name, with or without .zip.")
class AccountArchiveCancelled(Exception):
pass
@dataclass(frozen=True)
class AccountArchiveFile:
path: Path
arcname: str
kind: str
size: int
mtime: float
mode: int
@dataclass
class AccountArchiveExportJob:
export_id: str
account: str = ""
status: str = "queued"
progress: int = 0
message: str = "Waiting to start..."
detail: str = ""
error: str = ""
zip_path: str = ""
file_name: str = ""
database_count: int = 0
resource_file_count: int = 0
total_bytes: int = 0
processed_bytes: int = 0
created_at: int = field(default_factory=lambda: int(time.time()))
updated_at: int = field(default_factory=lambda: int(time.time()))
cancel_requested: bool = False
def to_public_dict(self) -> dict[str, Any]:
return {
"exportId": self.export_id,
"account": self.account,
"status": self.status,
"progress": max(0, min(100, int(self.progress or 0))),
"message": self.message,
"detail": self.detail,
"error": self.error,
"zipPath": self.zip_path,
"fileName": self.file_name,
"databaseCount": int(self.database_count or 0),
"resourceFileCount": int(self.resource_file_count or 0),
"totalBytes": int(self.total_bytes or 0),
"processedBytes": int(self.processed_bytes or 0),
"createdAt": int(self.created_at or 0),
"updatedAt": int(self.updated_at or 0),
"cancelRequested": bool(self.cancel_requested),
}
_SAFE_NAME_RE = re.compile(r"[^0-9A-Za-z._-]+")
# 账号归档以账号目录为边界。数据库通常在账号目录顶层,资源文件通常在子目录中。
_DB_SUFFIXES = {".db", ".sqlite", ".sqlite3", ".db3"}
_META_FILE_NAMES = {"_source.json", "_media_keys.json", "_sns_realtime_sync_state.json"}
_JOBS: dict[str, AccountArchiveExportJob] = {}
_JOBS_LOCK = threading.RLock()
def _safe_file_name(value: object, fallback: str) -> str:
text = str(value or "").strip().replace("\\", "/").split("/")[-1]
text = _SAFE_NAME_RE.sub("_", text).strip("._-")
return text or fallback
def _normalize_zip_name(value: object, fallback: str) -> str:
name = _safe_file_name(value, fallback)
if not name.lower().endswith(".zip"):
name += ".zip"
return name
def _resolve_output_dir(account_dir: Path, output_dir_raw: object) -> Path:
raw = str(output_dir_raw or "").strip()
if raw:
return Path(raw).expanduser().resolve()
return (account_dir.parents[1] / "exports" / account_dir.name).resolve()
def _iter_database_files(account_dir: Path) -> list[Path]:
return sorted(
(
item
for item in account_dir.iterdir()
if item.is_file()
and (
item.suffix.lower() in _DB_SUFFIXES
or item.name in _META_FILE_NAMES
)
),
key=lambda p: p.name.lower(),
)
def _get_job(export_id: str) -> Optional[AccountArchiveExportJob]:
key = str(export_id or "").strip()
if not key:
return None
with _JOBS_LOCK:
return _JOBS.get(key)
def _update_job(export_id: str, **changes: Any) -> Optional[AccountArchiveExportJob]:
with _JOBS_LOCK:
job = _JOBS.get(str(export_id or "").strip())
if not job:
return None
for key, value in changes.items():
if hasattr(job, key):
setattr(job, key, value)
job.updated_at = int(time.time())
return job
def _check_cancel(job: AccountArchiveExportJob, tmp_path: Optional[Path] = None) -> None:
with _JOBS_LOCK:
cancelled = bool(job.cancel_requested)
if not cancelled:
return
if tmp_path is not None:
try:
if tmp_path.exists():
tmp_path.unlink()
except Exception:
pass
raise AccountArchiveCancelled()
def _add_file(zip_file: zipfile.ZipFile, item: AccountArchiveFile) -> Optional[int]:
try:
modified = time.localtime(item.mtime)[:6]
if modified[0] < 1980:
modified = (1980, 1, 1, 0, 0, 0)
info = zipfile.ZipInfo(item.arcname, modified)
info.compress_type = zipfile.ZIP_STORED
info.file_size = item.size
info.external_attr = (item.mode & 0xFFFF) << 16
with item.path.open("rb") as source, zip_file.open(info, "w", force_zip64=True) as target:
shutil.copyfileobj(source, target, length=1024 * 1024)
return int(item.size)
except (FileNotFoundError, OSError):
return None
def _is_database_or_meta_file(path: Path) -> bool:
return path.is_file() and (path.suffix.lower() in _DB_SUFFIXES or path.name in _META_FILE_NAMES)
def _is_relative_to(path: Path, parent: Path) -> bool:
try:
path.relative_to(parent)
return True
except ValueError:
return False
def _iter_selected_account_files(
*,
job: AccountArchiveExportJob,
account_dir: Path,
include_databases: bool,
include_resources: bool,
tmp_path: Optional[Path],
zip_path: Optional[Path],
output_dir: Optional[Path],
):
"""Fast metadata scan for selected account files.
Folder size is not stored as one reliable value by the filesystem. To show an
accurate total before packing, we still have to enumerate files, but os.scandir
reuses directory-entry metadata and avoids the heavier Path/os.walk/resolve path.
"""
account_prefix = _safe_file_name(account_dir.name, "account")
pack_whole_account_folder = include_databases and include_resources
account_dir_str = os.path.abspath(os.fspath(account_dir))
excluded_files = set()
for candidate in (tmp_path, zip_path):
if candidate is None:
continue
try:
excluded_files.add(os.path.normcase(os.path.abspath(os.fspath(candidate))))
except OSError:
pass
skipped_output_dir: Optional[str] = None
if output_dir is not None:
try:
output_dir_str = os.path.abspath(os.fspath(output_dir))
# 如果用户把导出目录选在账号目录内部,避免把正在生成的导出文件再次打包进去。
if output_dir_str != account_dir_str and os.path.commonpath([account_dir_str, output_dir_str]) == account_dir_str:
skipped_output_dir = os.path.normcase(output_dir_str)
except (OSError, ValueError):
skipped_output_dir = None
stack: list[tuple[str, bool]] = [(account_dir_str, True)]
while stack:
root, is_account_root = stack.pop()
_check_cancel(job, tmp_path)
normalized_root = os.path.normcase(os.path.abspath(root))
if skipped_output_dir is not None and normalized_root == skipped_output_dir:
continue
try:
with os.scandir(root) as entries:
entry_list = list(entries)
except OSError:
continue
for entry in entry_list:
try:
if entry.is_dir(follow_symlinks=False):
if is_account_root and not pack_whole_account_folder and not include_resources:
continue
stack.append((entry.path, False))
continue
if not entry.is_file(follow_symlinks=False):
continue
file_path_str = entry.path
if os.path.normcase(os.path.abspath(file_path_str)) in excluded_files:
continue
name = entry.name
suffix = os.path.splitext(name)[1].lower()
is_top_level_database = is_account_root and (suffix in _DB_SUFFIXES or name in _META_FILE_NAMES)
if pack_whole_account_folder:
kind = "database" if is_top_level_database else "resource"
elif include_databases and is_top_level_database:
kind = "database"
elif include_resources and not is_account_root:
kind = "resource"
else:
continue
try:
st = entry.stat(follow_symlinks=False)
except OSError:
continue
if not stat.S_ISREG(st.st_mode):
continue
try:
rel = os.path.relpath(file_path_str, account_dir_str).replace(os.sep, "/")
except ValueError:
continue
yield AccountArchiveFile(
path=Path(file_path_str),
arcname=f"{account_prefix}/{rel}",
kind=kind,
size=int(st.st_size),
mtime=float(st.st_mtime),
mode=int(st.st_mode),
)
except OSError:
continue
def _run_account_archive_export(export_id: str, payload: dict[str, Any]) -> None:
job = _update_job(export_id, status="running", progress=1, message="Preparing export...", detail="")
if not job:
return
zip_path: Optional[Path] = None
tmp_path: Optional[Path] = None
try:
include_databases = bool(payload.get("include_databases"))
include_resources = bool(payload.get("include_resources"))
if not include_databases and not include_resources:
raise ValueError("Please select at least one export option.")
_check_cancel(job)
account_dir = _resolve_account_dir(payload.get("account"))
account_name = account_dir.name
output_dir = _resolve_output_dir(account_dir, payload.get("output_dir"))
output_dir.mkdir(parents=True, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
fallback_name = f"wechat_archive_{_safe_file_name(account_name, 'account')}_{stamp}.zip"
zip_name = _normalize_zip_name(payload.get("file_name"), fallback_name)
zip_path = (output_dir / zip_name).resolve()
tmp_path = zip_path.with_suffix(zip_path.suffix + ".tmp")
_update_job(
export_id,
account=account_name,
file_name=zip_name,
zip_path=str(zip_path),
progress=1,
message="Scanning export content...",
detail="Calculating total archive size.",
total_bytes=0,
processed_bytes=0,
)
if tmp_path.exists():
tmp_path.unlink()
selected_files = list(_iter_selected_account_files(
job=job,
account_dir=account_dir,
include_databases=include_databases,
include_resources=include_resources,
tmp_path=tmp_path,
zip_path=zip_path,
output_dir=output_dir,
))
if not selected_files:
raise FileNotFoundError("No exportable files found for this account.")
planned_db_count = sum(1 for item in selected_files if item.kind == "database")
planned_resource_count = sum(1 for item in selected_files if item.kind != "database")
total_files = len(selected_files)
total_bytes = sum(max(0, int(item.size or 0)) for item in selected_files)
if include_databases and not include_resources and planned_db_count <= 0:
raise FileNotFoundError("No database files found for this account.")
if include_resources and not include_databases and planned_resource_count <= 0:
raise FileNotFoundError("No resource files found for this account.")
_update_job(
export_id,
progress=5,
database_count=planned_db_count,
resource_file_count=planned_resource_count,
total_bytes=total_bytes,
processed_bytes=0,
message="Writing ZIP archive...",
detail=f"Ready to pack {total_files} files ({total_bytes / 1024 / 1024:.1f} MB).",
)
db_count = 0
resource_file_count = 0
processed_bytes = 0
processed = 0
last_progress_at = time.monotonic()
# Use ZIP_STORED intentionally: account archives are mostly SQLite,
# images, videos and cache files. Re-compressing them is CPU-heavy and
# often saves little space. This makes archive export behave like a fast
# folder pack/copy operation.
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as zf:
for item in selected_files:
_check_cancel(job, tmp_path)
added_size = _add_file(zf, item)
if added_size is not None:
processed += 1
if item.kind == "database":
db_count += 1
else:
resource_file_count += 1
processed_bytes += added_size
now = time.monotonic()
if processed <= 5 or processed % 20 == 0 or (now - last_progress_at) >= 0.5:
last_progress_at = now
if total_bytes > 0:
progress = min(95, 5 + int((processed_bytes / total_bytes) * 90))
else:
progress = min(95, 5 + int((processed / max(1, total_files)) * 90))
_update_job(
export_id,
progress=progress,
database_count=db_count,
resource_file_count=resource_file_count,
total_bytes=total_bytes,
processed_bytes=processed_bytes,
message="Writing ZIP archive...",
detail=(
f"Packed {processed}/{total_files} files "
f"({processed_bytes / 1024 / 1024:.1f}/{total_bytes / 1024 / 1024:.1f} MB)."
),
)
_check_cancel(job, tmp_path)
_update_job(export_id, progress=97, message="Finalizing ZIP archive...", detail="Moving archive to target folder.")
if zip_path.exists():
zip_path.unlink()
shutil.move(str(tmp_path), str(zip_path))
_update_job(
export_id,
status="done",
progress=100,
message="Export completed.",
detail=f"Exported {db_count} database files and {resource_file_count} resource files.",
database_count=db_count,
resource_file_count=resource_file_count,
total_bytes=total_bytes,
processed_bytes=processed_bytes,
zip_path=str(zip_path),
file_name=zip_path.name,
)
except AccountArchiveCancelled:
try:
if tmp_path is not None and tmp_path.exists():
tmp_path.unlink()
except Exception:
pass
_update_job(export_id, status="cancelled", message="Export cancelled.", detail="Temporary archive has been removed.")
except Exception as exc:
try:
if tmp_path is not None and tmp_path.exists():
tmp_path.unlink()
except Exception:
pass
_update_job(export_id, status="error", error=str(exc), message="Export failed.", detail="")
@router.post("/api/account/archive_export", summary="Create account archive export job")
async def export_account_archive(req: AccountArchiveExportRequest):
if not req.include_databases and not req.include_resources:
raise HTTPException(status_code=400, detail="Please select at least one export option.")
payload = {
"account": req.account,
"output_dir": req.output_dir,
"include_databases": bool(req.include_databases),
"include_resources": bool(req.include_resources),
"file_name": req.file_name,
}
export_id = uuid.uuid4().hex
job = AccountArchiveExportJob(export_id=export_id)
with _JOBS_LOCK:
_JOBS[export_id] = job
thread = threading.Thread(target=_run_account_archive_export, args=(export_id, payload), daemon=True)
thread.start()
return {"status": "success", "job": job.to_public_dict()}
@router.get("/api/account/archive_export/download", summary="Download account archive by file path")
async def download_account_archive(path: str):
zip_path = Path(str(path or "").strip()).expanduser().resolve()
if not zip_path.exists() or not zip_path.is_file():
raise HTTPException(status_code=404, detail="Export file not found.")
if zip_path.suffix.lower() != ".zip":
raise HTTPException(status_code=400, detail="Invalid export file.")
return FileResponse(str(zip_path), media_type="application/zip", filename=zip_path.name)
@router.get("/api/account/archive_export/{export_id}", summary="Get account archive export job")
async def get_account_archive_export(export_id: str):
job = _get_job(export_id)
if not job:
raise HTTPException(status_code=404, detail="Export not found.")
return {"status": "success", "job": job.to_public_dict()}
@router.delete("/api/account/archive_export/{export_id}", summary="Cancel account archive export job")
async def cancel_account_archive_export(export_id: str):
job = _get_job(export_id)
if not job:
raise HTTPException(status_code=404, detail="Export not found.")
with _JOBS_LOCK:
if job.status in {"done", "error", "cancelled"}:
return {"status": "success", "job": job.to_public_dict()}
job.cancel_requested = True
job.message = "Cancelling export..."
job.detail = "Waiting for the current file operation to stop."
job.updated_at = int(time.time())
return {"status": "success", "job": job.to_public_dict()}