feat: 新增导入已解密目录功能,支持不走密钥流程导入已解密的数据库和资源

This commit is contained in:
H3CoF6
2026-04-08 06:39:06 +08:00
Unverified
parent 655a1fd7f5
commit d64a2e46f7
4 changed files with 294 additions and 12 deletions
+8
View File
@@ -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
View File
@@ -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()) {
+2
View File
@@ -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)} 个数据库"
}