diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js index 13efb89..24bb518 100644 --- a/frontend/composables/useApi.js +++ b/frontend/composables/useApi.js @@ -61,6 +61,14 @@ export const useApi = () => { body: data }) } + + // 导入已解密目录API + const importDecrypted = async (data) => { + return await request('/import_decrypted', { + method: 'POST', + body: data + }) + } // 健康检查API const healthCheck = async () => { diff --git a/frontend/pages/decrypt.vue b/frontend/pages/decrypt.vue index 4e90eed..399ab33 100644 --- a/frontend/pages/decrypt.vue +++ b/frontend/pages/decrypt.vue @@ -16,13 +16,31 @@ -
-

数据库解密

-

输入密钥和路径开始解密

+
+

数据获取

+

选择解密新数据或导入已解密目录

+
+ +
+ +
-
+ +
+ + +
+
+
+ + + +
+

什么是直接导入?

+

如果您已经有了已解密的数据库文件(扁平化目录结构,含 contact.db, session.db 等)以及 resource 资源目录,可以直接导入。此过程不校验密钥,也不进行实时同步。

+
+
+
+ + +
+ + +

+ + + + {{ formErrors.import_path }} +

+

+ + + + 该目录应包含已解密的 .db 文件,若有 resource 文件夹也会一并导入。 +

+
+ + +
+
+ +
+
+
@@ -434,7 +518,7 @@ import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue' import { useApi } from '~/composables/useApi' -const { decryptDatabase, saveMediaKeys, getSavedKeys, getKeys, getImageKey, getWxStatus } = useApi() +const { decryptDatabase, importDecrypted, saveMediaKeys, getSavedKeys, getKeys, getImageKey, getWxStatus } = useApi() const loading = ref(false) const error = ref('') @@ -443,12 +527,8 @@ const currentStep = ref(0) const mediaAccount = ref('') const isGettingDbKey = ref(false) -// 步骤定义 -const steps = [ - { title: '数据库解密' }, - { title: '填写图片密钥' }, - { title: '图片解密' } -] +// 解密模式切换 +const decryptMode = ref('standard') // 'standard' or 'import' // 表单数据 const formData = reactive({ @@ -456,10 +536,16 @@ const formData = reactive({ db_storage_path: '' }) +// 导入数据 +const importData = reactive({ + path: '' +}) + // 表单错误 const formErrors = reactive({ key: '', - db_storage_path: '' + db_storage_path: '', + import_path: '' }) // 图片密钥相关 @@ -698,6 +784,57 @@ const resetDbDecryptProgress = () => { dbDecryptProgress.message = '' } +const handleImport = async () => { + if (!importData.path) { + formErrors.import_path = '请输入已解密目录路径' + return + } + + loading.value = true + error.value = '' + warning.value = '' + formErrors.import_path = '' + + try { + const res = await importDecrypted({ + import_path: importData.path + }) + + if (res.status === 'success') { + mediaAccount.value = res.account + // 模拟一个成功的结果 + decryptResult.value = { + status: 'completed', + success_count: res.imported_files.length, + total_databases: res.imported_files.length, + account_results: { + [res.account]: { + success: res.imported_files.length + } + } + } + + if (process.client && typeof window !== 'undefined') { + sessionStorage.setItem('decryptResult', JSON.stringify(decryptResult.value)) + } + + // 如果有 resource 目录,则提示用户可以跳过图片解密 + if (res.has_resource) { + warning.value = '检测到已包含 resource 资源目录,您可以直接跳转到聊天记录。' + } + + currentStep.value = 1 + await prefillKeysForAccount(mediaAccount.value) + } else { + error.value = res.message || '导入失败' + } + } catch (err) { + error.value = err.message || '导入过程中发生错误' + } finally { + loading.value = false + } +} + // 处理解密 const handleDecrypt = async () => { if (!validateForm()) { diff --git a/src/wechat_decrypt_tool/api.py b/src/wechat_decrypt_tool/api.py index bc2df4d..154d55d 100644 --- a/src/wechat_decrypt_tool/api.py +++ b/src/wechat_decrypt_tool/api.py @@ -25,6 +25,7 @@ from .routers.chat_contacts import router as _chat_contacts_router from .routers.chat_export import router as _chat_export_router from .routers.chat_media import router as _chat_media_router from .routers.decrypt import router as _decrypt_router +from .routers.import_decrypted import router as _import_decrypted_router from .routers.health import router as _health_router from .routers.admin import router as _admin_router from .routers.keys import router as _keys_router @@ -87,6 +88,7 @@ async def _log_server_errors(request: Request, call_next): app.include_router(_health_router) app.include_router(_admin_router) app.include_router(_wechat_detection_router) +app.include_router(_import_decrypted_router) app.include_router(_decrypt_router) app.include_router(_keys_router) app.include_router(_media_router) diff --git a/src/wechat_decrypt_tool/routers/import_decrypted.py b/src/wechat_decrypt_tool/routers/import_decrypted.py new file mode 100644 index 0000000..71143bf --- /dev/null +++ b/src/wechat_decrypt_tool/routers/import_decrypted.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import os +import shutil +import json +from pathlib import Path +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from ..app_paths import get_output_databases_dir +from ..logging_config import get_logger +from ..path_fix import PathFixRoute +from ..session_last_message import build_session_last_message_table + +logger = get_logger(__name__) + +router = APIRouter(route_class=PathFixRoute) + +class ImportRequest(BaseModel): + import_path: str = Field(..., description="已解密的数据库和资源所在目录的绝对路径") + +def _is_valid_sqlite(path: Path) -> bool: + SQLITE_HEADER = b"SQLite format 3\x00" + try: + if not path.exists() or not path.is_file(): + return False + with path.open("rb") as f: + return f.read(len(SQLITE_HEADER)) == SQLITE_HEADER + except Exception: + return False + +@router.post("/api/import_decrypted", summary="导入已解密的数据库和资源目录") +async def import_decrypted_directory(request: ImportRequest): + """ + 导入已解密的微信数据库和资源目录。 + 该功能不需要密钥,直接将现有的已解密文件链接或复制到输出目录。 + """ + import_path = Path(request.import_path.strip()) + if not import_path.exists() or not import_path.is_dir(): + raise HTTPException(status_code=400, detail="导入路径不存在或不是目录") + + # 1. 尝试识别账号名 + # 优先从路径名识别 (例如 .../wxid_xxxx) + from ..wechat_decrypt import _derive_account_name_from_path + account_name = _derive_account_name_from_path(import_path) + + # 2. 验证关键数据库文件 + # 必须包含 contact.db 和 session.db 才能在列表中正常显示 + required_dbs = ["contact.db", "session.db"] + for db_name in required_dbs: + if not _is_valid_sqlite(import_path / db_name): + # 兼容性检查:如果不在根目录,可能在 db_storage 子目录? + # 但用户说“和现在完全保存的目录一致”,所以应该在根目录。 + raise HTTPException(status_code=400, detail=f"导入目录中未找到有效的 {db_name},请确保是已解密的扁平化目录") + + # 3. 准备输出目录 + output_base = get_output_databases_dir() + account_output_dir = output_base / account_name + account_output_dir.mkdir(parents=True, exist_ok=True) + + logger.info(f"正在从 {import_path} 导入账号 {account_name} ...") + + # 4. 导入 .db 文件 + imported_files = [] + for item in import_path.iterdir(): + if item.is_file() and item.suffix == ".db": + target = account_output_dir / item.name + try: + # 优先尝试硬链接以节省空间 + if target.exists(): + target.unlink() + os.link(item, target) + imported_files.append(item.name) + except Exception as e: + logger.warning(f"硬链接失败,尝试复制: {item.name}, error: {e}") + try: + shutil.copy2(item, target) + imported_files.append(item.name) + except Exception as e2: + logger.error(f"复制失败: {item.name}, error: {e2}") + + # 5. 导入 resource 目录 + resource_src = import_path / "resource" + if resource_src.exists() and resource_src.is_dir(): + resource_dst = account_output_dir / "resource" + try: + if resource_dst.exists(): + if resource_dst.is_symlink() or resource_dst.is_file(): + resource_dst.unlink() + else: + shutil.rmtree(resource_dst) + + # 对目录尝试符号链接(Windows 下可能需要权限) + try: + os.symlink(resource_src, resource_dst, target_is_directory=True) + logger.info("已创建 resource 目录的符号链接") + except Exception: + # 符号链接失败则尝试硬链接或复制(对于资源目录,复制比较慢,建议用户手动移动) + logger.warning("符号链接失败,尝试复制 resource 目录(这可能需要较长时间)") + shutil.copytree(resource_src, resource_dst, dirs_exist_ok=True) + except Exception as e: + logger.error(f"导入 resource 目录失败: {e}") + + # 6. 保存来源信息 + try: + (account_output_dir / "_source.json").write_text( + json.dumps( + {"db_storage_path": str(import_path), "import_mode": "manual_import", "imported_at": __import__('datetime').datetime.now().isoformat()}, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + except Exception: + pass + + # 7. 构建缓存 + logger.info(f"正在为 {account_name} 构建会话缓存...") + try: + build_session_last_message_table( + account_output_dir, + rebuild=True, + include_hidden=True, + include_official=True, + ) + except Exception as e: + logger.error(f"构建会话缓存失败: {e}") + + return { + "status": "success", + "account": account_name, + "imported_files": imported_files, + "has_resource": resource_src.exists(), + "message": f"成功导入账号 {account_name},共 {len(imported_files)} 个数据库" + }