mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
feat: 新增导入已解密目录功能,支持不走密钥流程导入已解密的数据库和资源
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
+149
-12
@@ -16,13 +16,31 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-[#000000e6]">数据库解密</h2>
|
||||
<p class="text-sm text-[#7F7F7F]">输入密钥和路径开始解密</p>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-bold text-[#000000e6]">数据获取</h2>
|
||||
<p class="text-sm text-[#7F7F7F]">选择解密新数据或导入已解密目录</p>
|
||||
</div>
|
||||
<!-- 模式切换 -->
|
||||
<div class="bg-gray-100 p-1 rounded-lg flex">
|
||||
<button
|
||||
@click="decryptMode = 'standard'"
|
||||
:class="decryptMode === 'standard' ? 'bg-white shadow-sm text-[#07C160]' : 'text-[#7F7F7F]'"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-all"
|
||||
>
|
||||
标准解密
|
||||
</button>
|
||||
<button
|
||||
@click="decryptMode = 'import'"
|
||||
:class="decryptMode === 'import' ? 'bg-white shadow-sm text-[#07C160]' : 'text-[#7F7F7F]'"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-all"
|
||||
>
|
||||
直接导入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleDecrypt" class="space-y-6">
|
||||
<!-- 标准解密模式 -->
|
||||
<form v-if="decryptMode === 'standard'" @submit.prevent="handleDecrypt" class="space-y-6">
|
||||
<!-- 密钥输入 -->
|
||||
<div>
|
||||
<label for="key" class="block text-sm font-medium text-[#000000e6] mb-2">
|
||||
@@ -160,6 +178,72 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 直接导入模式 -->
|
||||
<form v-else @submit.prevent="handleImport" class="space-y-6">
|
||||
<div class="bg-blue-50 border border-blue-100 rounded-xl p-5 mb-6">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-blue-500 mt-0.5 mr-3" 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 class="text-sm text-blue-700">
|
||||
<p class="font-bold mb-1">什么是直接导入?</p>
|
||||
<p>如果您已经有了已解密的数据库文件(扁平化目录结构,含 contact.db, session.db 等)以及 resource 资源目录,可以直接导入。此过程<strong>不校验密钥</strong>,也不进行实时同步。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导入路径输入 -->
|
||||
<div>
|
||||
<label for="importPath" class="block text-sm font-medium text-[#000000e6] mb-2">
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
已解密目录路径 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="importPath"
|
||||
v-model="importData.path"
|
||||
type="text"
|
||||
placeholder="例如: D:\MyBackups\wxid_xxxx"
|
||||
class="w-full px-4 py-3 bg-white border border-[#EDEDED] rounded-lg font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[#07C160] focus:border-transparent transition-all duration-200"
|
||||
:class="{ 'border-red-500': formErrors.import_path }"
|
||||
required
|
||||
/>
|
||||
<p v-if="formErrors.import_path" class="mt-1 text-sm text-red-600 flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ formErrors.import_path }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-[#7F7F7F] flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-[#10AEEF]" 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>
|
||||
该目录应包含已解密的 .db 文件,若有 resource 文件夹也会一并导入。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<div class="pt-4 border-t border-[#EDEDED]">
|
||||
<div class="flex items-center justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center px-8 py-3 bg-[#07C160] text-white rounded-lg text-base font-medium hover:bg-[#06AD56] transform hover:scale-105 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="!loading" class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
<svg v-if="loading" class="w-5 h-5 mr-2 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ loading ? '导入中...' : '开始导入' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)} 个数据库"
|
||||
}
|
||||
Reference in New Issue
Block a user