Merge pull request #41 from H3CoF6/feat/import

导入解密数据库
This commit is contained in:
2977094657
2026-04-09 02:12:29 +08:00
committed by GitHub
Unverified
9 changed files with 972 additions and 248 deletions
+27
View File
@@ -61,6 +61,22 @@ export const useApi = () => {
body: data
})
}
// 导入预览API
const importDecryptedPreview = async (data) => {
return await request('/import_decrypted/preview', {
method: 'POST',
body: data
})
}
// 导入已解密目录API
const importDecrypted = async (data) => {
return await request('/import_decrypted', {
method: 'POST',
body: data
})
}
// 健康检查API
const healthCheck = async () => {
@@ -601,10 +617,21 @@ export const useApi = () => {
return `${base}/biz/proxy_image?${query.toString()}`
}
const pickSystemDirectory = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.title) query.set('title', params.title)
if (params && params.initial_dir) query.set('initial_dir', params.initial_dir)
const url = '/system/pick_directory' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
return {
pickSystemDirectory,
detectWechat,
detectCurrentAccount,
decryptDatabase,
importDecryptedPreview,
importDecrypted,
healthCheck,
listChatAccounts,
getChatAccountInfo,
+175 -182
View File
@@ -24,26 +24,36 @@
</NuxtLink>
</div>
<!-- 兜底允许输入数据库路径再次检测 -->
<div class="bg-white rounded-xl p-4 border border-[#EDEDED] mb-4">
<label class="block text-sm text-[#000000e6] mb-2">数据库文件夹路径可选</label>
<div class="flex gap-2">
<input
v-model="customPath"
type="text"
placeholder="例如:D:\wechatMSG\xwechat_files"
class="flex-1 px-4 py-2 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"
/>
<button @click="startDetection" class="px-4 py-2 bg-[#07C160] text-white rounded-lg text-sm hover:bg-[#06AD56]">重新检测</button>
<!-- 兜底唤起原生目录选择器再次检测 -->
<div class="bg-white rounded-xl p-4 md:p-5 border border-[#EDEDED] mb-6 flex flex-col md:flex-row md:items-center justify-between gap-4 shadow-sm hover:shadow transition-shadow duration-200">
<div>
<h3 class="text-sm font-bold text-[#000000e6] flex items-center">
未找到想要的账号
<!-- <span class="ml-2 px-2 py-0.5 bg-gray-100 text-gray-500 rounded text-xs font-normal">深度检测兜底</span>-->
</h3>
<p class="text-xs text-[#7F7F7F] mt-1.5">
<span v-if="customPath">当前指定检测路径:<span class="font-mono bg-gray-50 px-1 rounded text-[#000000e6]">{{ customPath }}</span></span>
<span v-else>如果自动检测漏了您可以手动指定微信数据根目录 (通常名为 xwechat_files) 让系统重新扫描</span>
</p>
</div>
<p class="text-xs text-[#7F7F7F] mt-1">未找到时可填写 xwechat_files 根目录</p>
<button @click="handlePickDirectory" :disabled="loading"
class="shrink-0 px-5 py-2.5 bg-[#07C160] text-white rounded-xl text-sm font-medium hover:bg-[#06AD56] focus:ring-2 focus:ring-[#07C160] focus:ring-offset-1 disabled:opacity-50 transition-all duration-200 flex items-center justify-center">
<svg v-if="!loading" class="w-4 h-4 mr-2" 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>
<svg v-else class="animate-spin w-4 h-4 mr-2" fill="none" 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>
<!-- 检测中状态 -->
<div v-if="loading" class="bg-white rounded-2xl p-12 text-center">
<svg class="w-16 h-16 mx-auto animate-spin text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div v-if="loading" class="absolute inset-0 bg-white/80 backdrop-blur-sm z-20 rounded-2xl flex flex-col items-center justify-center border border-[#EDEDED]">
<svg class="w-16 h-16 animate-spin text-[#07C160]" 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>
@@ -51,16 +61,16 @@
</div>
<!-- 检测结果内容 -->
<div v-else-if="detectionResult">
<div v-if="detectionResult && !loading">
<!-- 错误信息 -->
<div v-if="detectionResult.error" class="bg-white rounded-2xl border border-red-200 p-8">
<div v-if="detectionResult.error" class="bg-red-50 rounded-2xl border border-red-100 p-8">
<div class="flex items-center">
<svg class="w-8 h-8 text-red-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-8 h-8 text-red-500 mr-3 shrink-0" 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>
<div>
<p class="text-lg font-medium text-red-600">检测失败</p>
<p class="text-red-500 mt-1">{{ detectionResult.error }}</p>
<p class="text-lg font-bold text-red-800">未找到微信数据</p>
<p class="text-red-600 mt-1 text-sm">{{ detectionResult.error }}</p>
</div>
</div>
</div>
@@ -114,89 +124,91 @@
</div>
<!-- 账户列表 -->
<div v-if="detectionResult.data?.accounts && detectionResult.data.accounts.length > 0"
class="bg-white rounded-2xl border border-[#EDEDED] overflow-hidden">
<div class="p-4 border-b border-[#EDEDED] bg-gray-50">
<h3 class="text-base font-semibold text-[#000000e6]">微信账户详情</h3>
</div>
<div class="divide-y divide-[#EDEDED] max-h-64 overflow-y-auto">
<!-- 将当前登录账号放在第一位 -->
<div v-for="(account, index) in sortedAccounts" :key="index"
:class="['p-4 hover:bg-gray-50 transition-all duration-200', isCurrentAccount(account.account_name) ? 'bg-[#07C160]/5' : '']">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center">
<div class="w-12 h-12 bg-gradient-to-br from-[#07C160]/10 to-[#91D300]/10 rounded-full flex items-center justify-center mr-4">
<span class="text-[#07C160] font-bold text-lg">{{ account.account_name?.charAt(0)?.toUpperCase() || 'U' }}</span>
</div>
<div>
<div class="flex items-center">
<p class="text-lg font-medium text-[#000000e6]">{{ account.account_name || '未知账户' }}</p>
<!-- 当前登录账号标识 -->
<span v-if="isCurrentAccount(account.account_name)"
class="ml-2 inline-flex items-center px-2 py-1 bg-[#07C160]/10 text-[#07C160] rounded text-xs font-medium">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/>
</svg>
当前登录
</span>
</div>
<div class="flex items-center mt-1 space-x-4 text-sm text-[#7F7F7F]">
<span class="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="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
</svg>
{{ account.database_count }} 个数据库
</span>
<span v-if="account.data_dir" class="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="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>
<div v-if="detectionResult.data?.accounts && detectionResult.data.accounts.length > 0"
class="bg-white rounded-2xl border border-[#EDEDED] overflow-hidden shadow-sm">
<div class="p-4 border-b border-[#EDEDED] bg-gray-50 flex items-center justify-between">
<h3 class="text-base font-bold text-[#000000e6]">可操作的微信账户</h3>
<span class="text-xs text-gray-500">点击解密即可提取数据</span>
</div>
<div class="divide-y divide-[#EDEDED] max-h-96 overflow-y-auto">
<div v-for="(account, index) in sortedAccounts" :key="index"
:class="['p-5 transition-all duration-200 relative overflow-hidden', isCurrentAccount(account.account_name) ? 'bg-[#07C160]/5 border border-[#07C160]/20' : 'hover:bg-[#F9F9F9]']">
<div v-if="isCurrentAccount(account.account_name)" class="absolute top-0 right-0 bg-gradient-to-l from-[#07C160]/20 to-transparent px-4 py-1 rounded-bl-xl flex items-center">
<span class="text-xs text-[#07C160] font-bold flex items-center">
<svg class="w-3 h-3 mr-1" 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>
最近登录账户
</span>
</div>
<div class="flex items-center justify-between mt-1">
<div class="flex-1">
<div class="flex items-center">
<template v-if="isCurrentAccount(account.account_name) && currentAccountInfo?.avatar">
<img :src="currentAccountInfo.avatar" class="w-14 h-14 rounded-xl border-2 border-[#07C160]/30 mr-4 shadow-sm object-cover bg-white" alt=""/>
</template>
<template v-else>
<div class="w-14 h-14 bg-gradient-to-br from-[#07C160]/10 to-[#91D300]/10 rounded-xl flex items-center justify-center mr-4 shadow-inner">
<span class="text-[#07C160] font-bold text-xl">{{ account.account_name?.charAt(0)?.toUpperCase() || 'U' }}</span>
</div>
</template>
<div>
<div class="flex flex-col">
<template v-if="isCurrentAccount(account.account_name) && currentAccountInfo?.nickname">
<p class="text-xl font-extrabold text-[#000000e6] leading-tight">{{ currentAccountInfo.nickname }}</p>
<p class="text-xs text-[#7F7F7F] mt-1 font-mono">wxid: {{ account.account_name }}</p>
</template>
<template v-else>
<p class="text-lg font-bold text-[#000000e6]">{{ account.account_name || '未知账户' }}</p>
</template>
</div>
<div class="flex items-center mt-2 space-x-4 text-sm text-[#7F7F7F]">
<span class="flex items-center">
<svg class="w-4 h-4 mr-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
</svg>
{{ account.database_count }} 个库文件
</span>
<span v-if="account.data_dir" class="flex items-center">
<svg class="w-4 h-4 mr-1 text-[#07C160]" 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>
路径已确认
</span>
</div>
</div>
</div>
</div>
<button @click="goToDecrypt(account)"
class="inline-flex items-center px-6 py-2.5 bg-[#07C160] text-white rounded-xl font-bold hover:bg-[#06AD56] hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 text-sm shrink-0 z-10">
解密提取
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
</div>
<div class="mt-4 pt-3 border-t border-dashed border-gray-200 text-sm text-gray-400">
<p v-if="account.data_dir" class="font-mono text-xs truncate" title="复制路径">
📂 {{ account.data_dir }}
</p>
</div>
<button @click="goToDecrypt(account)"
class="inline-flex items-center px-4 py-2 bg-[#07C160] text-white rounded-lg font-medium hover:bg-[#06AD56] transition-all duration-200 text-sm">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"/>
</svg>
解密
</button>
</div>
<!-- 展开更多信息 -->
<div class="mt-4 text-sm text-[#7F7F7F]">
<p v-if="account.data_dir" class="font-mono text-xs truncate">
数据路径{{ account.data_dir }}
</p>
</div>
</div>
</div>
<!-- 无账户提示 -->
<div v-else class="bg-white rounded-2xl p-12 text-center border border-[#EDEDED]">
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-lg text-[#000000e6] font-medium">没有在这台设备上发现微信数据</p>
<p class="text-sm text-gray-500 mt-2">您可以尝试通过上方的按钮手动指定 "xwechat_files" 文件夹路径</p>
</div>
</div>
<!-- 无账户提示 -->
<div v-else class="bg-white rounded-2xl p-12 text-center">
<svg class="w-16 h-16 mx-auto text-[#7F7F7F] mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-lg text-[#7F7F7F]">未检测到微信账户数据</p>
</div>
</div>
</div>
<!-- 未检测状态 -->
<div v-else class="bg-white rounded-2xl p-12 text-center">
<svg class="w-16 h-16 mx-auto text-[#7F7F7F] mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<p class="text-lg text-[#7F7F7F] mb-4">暂无检测结果</p>
<NuxtLink to="/"
class="inline-flex items-center px-6 py-3 bg-[#07C160] text-white rounded-lg font-medium hover:bg-[#06AD56] transition-colors">
返回首页开始检测
</NuxtLink>
</div>
</div>
</div>
@@ -204,34 +216,79 @@
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useApi } from '~/composables/useApi'
import { useAppStore } from '~/stores/app'
import {computed, onMounted, ref} from 'vue'
import {useApi} from '~/composables/useApi'
import {useAppStore} from '~/stores/app'
const { detectWechat } = useApi()
const { detectWechat, pickSystemDirectory } = useApi()
const appStore = useAppStore()
const loading = ref(false)
const detectionResult = ref(null)
const customPath = ref('')
const STORAGE_KEY = 'wechat_data_root_path'
const isDesktopShell = () => {
if (!process.client || typeof window === 'undefined') return false
return !!window.wechatDesktop?.__brand
}
// 唤起目录选择器并自动检测
const handlePickDirectory = async () => {
let path = ''
if (isDesktopShell()) {
try {
const res = await window.wechatDesktop.chooseDirectory({
title: '请选择微信数据根目录 (通常名为 xwechat_files)'
})
if (!res || res.canceled || !res.filePaths?.length) return
path = res.filePaths[0]
} catch (e) {
console.error('桌面端选择目录失败:', e)
return
}
} else {
try {
const res = await pickSystemDirectory({
title: '请选择微信数据根目录 (通常名为 xwechat_files)'
})
if (!res || !res.path) return // 用户取消
path = res.path
} catch (e) {
console.error('通过API唤起系统目录选择器失败:', e)
path = window.prompt('无法直接唤起窗口,请输入 xwechat_files 目录的绝对路径:')
if (!path) return
}
}
if (path) {
customPath.value = path
// 选完后立刻启动重新检测
startDetection()
}
}
// 计算属性:将当前登录账号排在第一位
const sortedAccounts = computed(() => {
if (!detectionResult.value?.data?.accounts) return []
const accounts = [...detectionResult.value.data.accounts]
const currentAccountName = detectionResult.value.data?.current_account?.current_account
if (!currentAccountName) return accounts
// 将当前登录账号移到第一位
const sorted = accounts.sort((a, b) => {
if (a.account_name === currentAccountName) return -1
if (b.account_name === currentAccountName) return 1
const current = detectionResult.value.data?.current_account
const currentTargetName = current?.matched_folder || current?.current_account
if (!currentTargetName) return accounts
// 置顶最近登录账号
return accounts.sort((a, b) => {
if (a.account_name === currentTargetName) return -1
if (b.account_name === currentTargetName) return 1
return 0
})
return sorted
})
const currentAccountInfo = computed(() => {
return detectionResult.value?.data?.current_account || null
})
// 开始检测
@@ -247,9 +304,10 @@ const startDetection = async () => {
// 检测微信安装信息
let result = await detectWechat(params)
// 如果用户提供/缓存的路径不可用,自动回退到“自动检测”(避免因错误缓存导致一直检测不到)
// 如果用户提供/缓存的路径不可用,自动回退到“自动检测”
const hasCustomPath = !!(params.data_root_path && String(params.data_root_path).trim())
const accounts0 = Array.isArray(result?.data?.accounts) ? result.data.accounts : []
if (hasCustomPath && (result?.status !== 'success' || accounts0.length === 0)) {
try {
const fallback = await detectWechat({})
@@ -267,7 +325,7 @@ const startDetection = async () => {
}
detectionResult.value = result
if (result.status === 'success') {
const current = result?.data?.current_account || null
if (current) {
@@ -288,6 +346,7 @@ const startDetection = async () => {
}
if (toSave) {
localStorage.setItem(STORAGE_KEY, toSave)
customPath.value = toSave
}
} catch {}
}
@@ -296,7 +355,7 @@ const startDetection = async () => {
console.error('检测过程中发生错误:', err)
detectionResult.value = {
status: 'error',
error: err.message || '检测过程中出现错误'
error: err.message || '未在常规路径下扫描到您的微信数据。'
}
} finally {
loading.value = false
@@ -305,7 +364,6 @@ const startDetection = async () => {
// 跳转到解密页面并传递账户信息
const goToDecrypt = (account) => {
// 将选中的账户信息存储到sessionStorage
if (process.client && typeof window !== 'undefined') {
sessionStorage.setItem('selectedAccount', JSON.stringify({
account_name: account.account_name,
@@ -314,7 +372,6 @@ const goToDecrypt = (account) => {
databases: account.databases
}))
}
// 跳转到解密页面
navigateTo('/decrypt')
}
@@ -323,29 +380,9 @@ const isCurrentAccount = (accountName) => {
if (!detectionResult.value?.data?.current_account) {
return false
}
return detectionResult.value.data.current_account.current_account === accountName
}
// 获取当前登录账号信息
const getCurrentAccountInfo = () => {
return detectionResult.value?.data?.current_account
}
// 格式化时间显示
const formatTime = (timeString) => {
if (!timeString) return ''
try {
const date = new Date(timeString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
} catch {
return timeString
}
const current = detectionResult.value.data.current_account
// 支持严格匹配或通过后缀兼容的匹配
return accountName === current.matched_folder || accountName === current.current_account
}
// 页面加载时自动检测
@@ -357,49 +394,5 @@ onMounted(() => {
} catch {}
}
startDetection()
// 调试:检查各元素高度(仅开发环境)
if (process.dev && process.client) {
setTimeout(() => {
const mainContainer = document.querySelector('.min-h-screen')
const contentContainer = document.querySelector('.max-w-6xl')
console.log('=== 高度调试信息 ===')
console.log('视口高度:', window.innerHeight)
console.log('主容器高度:', mainContainer?.scrollHeight)
console.log('内容容器高度:', contentContainer?.scrollHeight)
console.log('body滚动高度:', document.body.scrollHeight)
console.log('documentElement滚动高度:', document.documentElement.scrollHeight)
// 检查是否有滚动条
const hasVerticalScrollbar = document.documentElement.scrollHeight > window.innerHeight
console.log('是否有垂直滚动条:', hasVerticalScrollbar)
}, 1000)
}
})
</script>
<style scoped>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.8s ease-out;
}
/* 网格背景 */
.bg-grid-pattern {
background-image:
linear-gradient(rgba(7, 193, 96, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(7, 193, 96, 0.1) 1px, transparent 1px);
background-size: 50px 50px;
}
</style>
</script>
+276
View File
@@ -0,0 +1,276 @@
<template>
<div class="import-page min-h-screen flex items-center justify-center py-8">
<div class="max-w-2xl mx-auto px-6 w-full">
<div class="bg-white rounded-3xl border border-[#EDEDED] shadow-sm overflow-hidden">
<div class="p-8 md:p-12">
<!-- 标题部分 -->
<div class="flex items-center mb-8">
<div class="w-14 h-14 bg-[#91D300] rounded-2xl flex items-center justify-center mr-5 shadow-sm">
<svg class="w-8 h-8 text-white" 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>
</div>
<div>
<h2 class="text-2xl font-bold text-[#000000e6]">数据导入</h2>
<p class="text-[#7F7F7F] mt-1">导入已解密的数据库备份目录</p>
</div>
</div>
<div class="bg-blue-50 border border-blue-100 rounded-2xl p-6 mb-8">
<h3 class="text-blue-800 font-bold mb-3 flex items-center">
<svg 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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
标准目录结构要求
</h3>
<ul class="text-sm text-blue-700 space-y-2 list-disc list-inside opacity-90">
<li><strong>预期目标</strong>请选择形如 <strong>/output/wxid_xxxxx/</strong> 这一级目录</li>
<li><strong>databases/</strong> 目录存放扁平化的 .db 文件</li>
<li><strong>account.json</strong> 文件系统会自动生成</li>
</ul>
</div>
<!-- 初始状态选择目录 -->
<div v-if="!importPreview && !importError && !importing" class="flex flex-col items-center justify-center py-12 border-2 border-dashed border-[#EDEDED] rounded-3xl hover:border-[#91D300] transition-colors cursor-pointer group" @click="handlePickDirectory">
<div class="w-20 h-20 bg-gray-50 rounded-full flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
<svg class="w-10 h-10 text-gray-400 group-hover:text-[#91D300]" 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>
</div>
<div class="text-[#000000e6] font-medium text-lg">点击选择备份目录</div>
<p class="text-[#7F7F7F] text-sm mt-2">支持原生目录选择器</p>
</div>
<!-- 导入进度状态 -->
<div v-if="importing" class="animate-fade-in py-12">
<div class="flex flex-col items-center">
<div class="relative w-32 h-32 mb-8">
<svg class="w-full h-full" viewBox="0 0 100 100">
<circle class="text-gray-100" stroke-width="8" stroke="currentColor" fill="transparent" r="42" cx="50" cy="50"/>
<circle class="text-[#91D300] transition-all duration-500" stroke-width="8" :stroke-dasharray="263.89" :stroke-dashoffset="263.89 * (1 - importProgress / 100)" stroke-linecap="round" stroke="currentColor" fill="transparent" r="42" cx="50" cy="50" transform="rotate(-90 50 50)"/>
</svg>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-2xl font-bold text-gray-900">{{ importProgress }}%</span>
</div>
</div>
<h3 class="text-lg font-bold text-gray-900 mb-2">{{ importMessage }}</h3>
<p class="text-sm text-gray-500">正在为您处理数据请稍候...</p>
<div class="w-full max-w-xs bg-gray-100 h-1.5 rounded-full mt-8 overflow-hidden">
<div class="bg-[#91D300] h-full transition-all duration-500" :style="{ width: importProgress + '%' }"></div>
</div>
</div>
</div>
<!-- 预览状态显示账号信息 -->
<div v-if="importPreview && !importing" class="animate-fade-in">
<div class="flex flex-col items-center py-8 bg-[#FBFBFB] rounded-3xl border border-[#EDEDED] mb-8">
<div class="w-28 h-28 rounded-full overflow-hidden border-4 border-white shadow-md mb-5">
<img :src="importPreview.avatar_url || '/Contact.png'" class="w-full h-full object-cover" alt="头像">
</div>
<div class="text-center">
<div class="text-xl font-bold text-gray-900">{{ importPreview.nick }}</div>
<div class="text-sm text-gray-500 font-mono mt-1 bg-white px-3 py-1 rounded-full border border-[#EDEDED] inline-block">{{ importPreview.username }}</div>
</div>
<div class="mt-8 flex gap-6">
<div class="flex items-center text-sm text-gray-600">
<div class="w-2 h-2 rounded-full bg-[#07C160] mr-2"></div>
数据库已就绪
</div>
<div class="flex items-center text-sm text-gray-600">
<div class="w-2 h-2 rounded-full mr-2" :class="importPreview.has_resource ? 'bg-[#07C160]' : 'bg-gray-300'"></div>
资源文件{{ importPreview.has_resource ? '已发现' : '未发现' }}
</div>
</div>
</div>
<div class="flex gap-4">
<button @click="resetImport"
class="flex-1 px-8 py-4 border border-[#EDEDED] text-gray-600 rounded-2xl font-bold hover:bg-gray-50 transition-all">
重新选择
</button>
<button @click="confirmImport" :disabled="importing"
class="flex-[2] px-8 py-4 bg-[#91D300] text-white rounded-2xl font-bold hover:bg-[#82BD00] shadow-lg shadow-[#91D300]/20 disabled:opacity-50 transition-all flex items-center justify-center transform hover:scale-[1.02] active:scale-[0.98]">
<span v-if="!importing">确认导入此账号</span>
<span v-else>正在导入数据...</span>
</button>
</div>
</div>
<!-- 错误状态 -->
<div v-if="importError && !importing" class="animate-fade-in">
<div class="p-6 bg-red-50 border border-red-100 rounded-2xl flex items-start mb-8">
<svg class="w-6 h-6 text-red-500 mr-3 flex-shrink-0 mt-0.5" 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>
<div>
<p class="font-bold text-red-800 mb-1">导入失败</p>
<p class="text-sm text-red-600">{{ importError }}</p>
</div>
</div>
<button @click="resetImport"
class="w-full px-8 py-4 bg-white border border-[#EDEDED] text-[#07C160] rounded-2xl font-bold hover:bg-gray-50 transition-all flex items-center justify-center">
<svg 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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
重新选择目录
</button>
</div>
<!-- 返回首页 -->
<div class="mt-12 text-center">
<NuxtLink to="/" class="text-[#7F7F7F] hover:text-[#07C160] text-sm transition-colors inline-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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
返回首页
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {ref, onUnmounted} from 'vue'
import {useApi} from '~/composables/useApi'
import {useApiBase} from '~/composables/useApiBase'
const importing = ref(false)
const importProgress = ref(0)
const importMessage = ref('正在准备...')
const importPreview = ref(null)
const importError = ref('')
const selectedImportPath = ref('')
let eventSource = null
onUnmounted(() => {
if (eventSource) {
eventSource.close()
}
})
const isDesktopShell = () => {
if (!process.client || typeof window === 'undefined') return false
return !!window.wechatDesktop?.__brand
}
const resetImport = () => {
importPreview.value = null
importError.value = ''
selectedImportPath.value = ''
importing.value = false
importProgress.value = 0
importMessage.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)'
})
if (!res || res.canceled || !res.filePaths?.length) return
path = res.filePaths[0]
} catch (e) {
console.error('选择目录失败:', e)
return
}
} else {
try {
const res = await pickSystemDirectory({ title: '请选择解密输出目录 (需选到 wxid_xxx 层级)' })
if (!res || !res.path) return
path = res.path
} catch (e) {
console.error('唤起目录选择器失败:', e)
path = window.prompt('无法唤起选择器,请输入已解密目录的绝对路径:')
if (!path) return
}
}
if (path && !path.includes('wxid_')) {
const isOk = window.confirm(`你选择的目录为:\n${path}\n\n该目录似乎不符合 "wxid_xxxxx" 的格式。确定要继续吗?`)
if (!isOk) return
}
selectedImportPath.value = path
importError.value = ''
importPreview.value = null
try {
importPreview.value = await importDecryptedPreview({import_path: path})
} catch (e) {
importError.value = e.message || '目录格式不正确,请确保包含 databases 目录和 account.json'
}
}
const confirmImport = async () => {
if (!selectedImportPath.value) return
importing.value = true
importError.value = ''
importProgress.value = 0
importMessage.value = '启动导入程序...'
const url = new URL(`${apiBase.replace(/\/$/, '')}/import_decrypted`, window.location.origin)
url.searchParams.set('import_path', selectedImportPath.value)
if (eventSource) eventSource.close()
eventSource = new EventSource(url.toString())
eventSource.onmessage = async (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'progress') {
importProgress.value = data.percent || 0
importMessage.value = data.message || '正在处理...'
} else if (data.type === 'complete') {
importProgress.value = 100
importMessage.value = '导入完成!'
eventSource.close()
// 延迟跳转,让用户看到 100%
setTimeout(async () => {
await navigateTo('/chat')
}, 1000)
} else if (data.type === 'error') {
importError.value = data.message || '导入失败'
importing.value = false
eventSource.close()
}
} catch (e) {
console.error('解析 SSE 数据失败:', e)
}
}
eventSource.onerror = (e) => {
console.error('EventSource 错误:', e)
importError.value = '与服务器连接断开或发生错误'
importing.value = false
eventSource.close()
}
}
</script>
<style scoped>
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
+11 -2
View File
@@ -41,6 +41,14 @@
</svg>
<span>直接解密</span>
</NuxtLink>
<NuxtLink to="/import"
class="group inline-flex items-center px-12 py-4 bg-white text-[#91D300] border border-[#91D300] rounded-lg text-lg font-medium hover:bg-[#F7F7F7] transform hover:scale-105 transition-all duration-200">
<svg class="w-6 h-6 mr-3 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
<span>数据导入</span>
</NuxtLink>
<NuxtLink to="/chat"
class="group inline-flex items-center px-12 py-4 bg-white text-[#10AEEF] border border-[#10AEEF] rounded-lg text-lg font-medium hover:bg-[#F7F7F7] transform hover:scale-105 transition-all duration-200">
@@ -70,6 +78,8 @@ import { onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
const { listChatAccounts } = useApi()
onMounted(async () => {
if (!process.client || typeof window === 'undefined') return
@@ -77,8 +87,7 @@ onMounted(async () => {
if (!enabled) return
try {
const api = useApi()
const resp = await api.listChatAccounts()
const resp = await listChatAccounts()
const accounts = resp?.accounts || []
if (accounts.length) {
await navigateTo('/chat', { replace: true })
+4
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
@@ -37,6 +38,7 @@ from .request_logging import log_server_errors_middleware
from .sns_stage_timing import add_sns_stage_timing_headers
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
from .routers.biz import router as _biz_router
from .routers.system import router as _system_router
app = FastAPI(
title="微信数据库解密工具",
@@ -87,6 +89,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)
@@ -98,6 +101,7 @@ app.include_router(_sns_router)
app.include_router(_sns_export_router)
app.include_router(_wrapped_router)
app.include_router(_biz_router)
app.include_router(_system_router)
class _SPAStaticFiles(StaticFiles):
@@ -0,0 +1,271 @@
from __future__ import annotations
import os
import shutil
import json
import asyncio
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
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
from ..media_helpers import _wxgf_to_image_bytes
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
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
try:
account_info = json.loads(account_json_path.read_text(encoding="utf-8"))
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()
}
@router.post("/api/import_decrypted/preview", summary="预览待导入的账号信息")
async def preview_import(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="导入路径不存在或不是目录")
return _validate_import_structure(import_path)
@router.get("/api/import_decrypted", summary="执行导入已解密的数据库和资源目录 (SSE)")
async def import_decrypted_directory(
import_path: str = Query(..., description="已解密的数据库和资源所在目录的绝对路径")
):
import_path_obj = Path(import_path.strip())
def _sse(data: dict):
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
async def generate_progress():
try:
if not import_path_obj.exists() or not import_path_obj.is_dir():
yield _sse({"type": "error", "message": "导入路径不存在或不是目录"})
return
yield _sse({"type": "progress", "percent": 5, "message": "正在验证目录结构..."})
# 1. 验证并获取账号信息
try:
info = await asyncio.to_thread(_validate_import_structure, import_path_obj)
except HTTPException as e:
yield _sse({"type": "error", "message": e.detail})
return
except Exception as e:
yield _sse({"type": "error", "message": f"验证失败: {e}"})
return
account_name = info["username"]
yield _sse({"type": "progress", "percent": 10, "message": f"验证成功: {account_name}"})
# 2. 准备输出目录
output_base = get_output_databases_dir()
account_output_dir = output_base / account_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_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):
target = account_output_dir / item.name
def _do_import_db(src, dst):
if dst.exists():
dst.unlink()
try:
os.link(src, dst)
except Exception:
shutil.copy2(src, dst)
try:
await asyncio.to_thread(_do_import_db, item, target)
imported_files.append(item.name)
except Exception as e:
logger.error(f"导入数据库失败: {item.name}, error: {e}")
percent = 15 + int((i + 1) / (len(db_files) or 1) * 15)
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():
yield _sse({"type": "progress", "percent": 30, "message": "正在导入资源文件 (这可能需要一些时间)..."})
resource_dst = account_output_dir / "resource"
def _do_import_resource(src, dst):
if dst.exists():
if dst.is_symlink() or dst.is_file():
dst.unlink()
else:
shutil.rmtree(dst)
try:
os.symlink(src, dst, target_is_directory=True)
except Exception:
shutil.copytree(src, dst, dirs_exist_ok=True)
try:
await asyncio.to_thread(_do_import_resource, resource_src, resource_dst)
except Exception as e:
logger.error(f"导入 resource 目录失败: {e}")
# 5. 转换 .wxgf 资源 (新增加的流程)
yield _sse({"type": "progress", "percent": 50, "message": "正在搜索并转换 .wxgf 图片..."})
if resource_dst.exists():
# 搜索 wxgf 文件
def _find_wxgf(root_dir):
found = []
for root, _, files in os.walk(root_dir):
for f in files:
if f.lower().endswith(".wxgf"):
found.append(Path(root) / f)
return found
wxgf_files = await asyncio.to_thread(_find_wxgf, resource_dst)
if wxgf_files:
total_wxgf = len(wxgf_files)
converted_count = 0
for i, wxgf_path in enumerate(wxgf_files):
def _convert_one(p):
jpg_p = p.with_suffix(".wxgf.jpg")
if not jpg_p.exists():
data = p.read_bytes()
if data.startswith(b"wxgf"):
converted = _wxgf_to_image_bytes(data)
if converted:
jpg_p.write_bytes(converted)
return True
else:
return True # 已经存在视为成功
return False
try:
success = await asyncio.to_thread(_convert_one, wxgf_path)
if success:
converted_count += 1
except Exception as e:
logger.error(f"转换 wxgf 失败: {wxgf_path}, {e}")
if i % max(1, total_wxgf // 20) == 0 or i == total_wxgf - 1:
progress_val = 50 + int((i + 1) / total_wxgf * 30)
yield _sse({"type": "progress", "percent": progress_val, "message": f"转换 wxgf 图片: {i+1}/{total_wxgf}"})
logger.info(f"账号 {account_name} 转换完成: {converted_count}/{total_wxgf} 个 .wxgf 文件")
# 6. 复制 account.json
yield _sse({"type": "progress", "percent": 85, "message": "正在更新账号配置..."})
try:
await asyncio.to_thread(shutil.copy2, import_path_obj / "account.json", account_output_dir / "account.json")
except Exception:
pass
# 7. 保存来源信息
def _save_source_info(dst, path, info):
(dst / "_source.json").write_text(
json.dumps(
{
"db_storage_path": str(path),
"import_mode": "manual_import",
"imported_at": __import__('datetime').datetime.now().isoformat(),
"original_info": info
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
try:
await asyncio.to_thread(_save_source_info, account_output_dir, import_path_obj, info)
except Exception:
pass
# 8. 构建缓存
yield _sse({"type": "progress", "percent": 90, "message": "正在构建会话缓存 (这可能需要较长时间)..."})
try:
await asyncio.to_thread(
build_session_last_message_table,
account_output_dir,
rebuild=True,
include_hidden=True,
include_official=True,
)
except Exception as e:
logger.error(f"构建会话缓存失败: {e}")
yield _sse({
"type": "complete",
"status": "success",
"account": account_name,
"nick": info["nick"],
"message": f"成功导入账号 {info['nick']} ({account_name})"
})
except Exception as e:
logger.error(f"导入过程中发生异常: {e}", exc_info=True)
yield _sse({"type": "error", "message": f"导入失败: {str(e)}"})
headers = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
return StreamingResponse(generate_progress(), headers=headers)
+35
View File
@@ -0,0 +1,35 @@
from fastapi import APIRouter
from pydantic import BaseModel
import asyncio
from concurrent.futures import ThreadPoolExecutor
router = APIRouter()
def _open_folder_dialog(title: str, initial_dir: str) -> str:
# 延迟导入并放在独立线程运行,避免阻塞 FastAPI 主线程或发生 GUI 线程冲突
import tkinter as tk
from tkinter import filedialog
root = tk.Tk()
root.withdraw() # 隐藏主窗口
root.attributes('-topmost', True) # 确保弹窗在最前
folder_path = filedialog.askdirectory(
parent=root,
title=title,
initialdir=initial_dir
)
root.destroy()
return folder_path
@router.get("/api/system/pick_directory", summary="唤起本地原生目录选择器")
async def pick_directory(title: str = "请选择目录", initial_dir: str = ""):
loop = asyncio.get_running_loop()
with ThreadPoolExecutor() as pool:
# 在子线程中执行 GUI 操作
folder_path = await loop.run_in_executor(pool, _open_folder_dialog, title, initial_dir)
return {"path": folder_path}
@@ -21,7 +21,21 @@ async def detect_wechat_detailed(data_root_path: Optional[str] = None):
# 检测当前登录账号
current_account_info = detect_current_logged_in_account(data_root_path)
# 【新增特性】目录匹配校验:处理目录名 wxid_xxxx_yyyy 与真实 wxid_xxxx 的适配
if current_account_info and current_account_info.get("current_account"):
base_wxid = current_account_info["current_account"]
current_account_info["matched_folder"] = base_wxid # 默认兜底
# 遍历寻找以该 wxid 开头的用户文件夹(支持后缀匹配)
for acc in info.get("accounts", []):
acc_name = acc["account_name"]
if acc_name == base_wxid or acc_name.startswith(f"{base_wxid}_"):
current_account_info["matched_folder"] = acc_name
break
info['current_account'] = current_account_info
# logger.info(current_account_info)
# 添加一些统计信息
stats = {
+159 -64
View File
@@ -14,7 +14,6 @@ from ctypes import wintypes
from datetime import datetime
def get_wx_db(msg_dir: str = None,
db_types: Union[List[str], str] = None,
wxids: Union[List[str], str] = None) -> List[dict]:
@@ -49,7 +48,7 @@ def get_wx_db(msg_dir: str = None,
wxid_dirs[os.path.basename(sub_dir)] = os.path.join(msg_dir, sub_dir)
else:
wxid_dirs[os.path.basename(msg_dir)] = msg_dir
for wxid, wxid_dir in wxid_dirs.items():
if wxids and wxid not in wxids: # 如果指定wxid,则过滤掉其他wxid
continue
@@ -70,6 +69,7 @@ def get_wx_db(msg_dir: str = None,
result.append({"wxid": wxid, "db_type": db_type, "db_path": db_path, "wxid_dir": wxid_dir})
return result
# Windows API 常量和结构
PROCESS_QUERY_INFORMATION = 0x0400
PROCESS_VM_READ = 0x0010
@@ -87,6 +87,7 @@ CreateToolhelp32Snapshot = kernel32.CreateToolhelp32Snapshot
Process32FirstW = kernel32.Process32FirstW
Process32NextW = kernel32.Process32NextW
class PROCESSENTRY32W(ctypes.Structure):
_fields_ = [
('dwSize', wintypes.DWORD),
@@ -105,6 +106,98 @@ class PROCESSENTRY32W(ctypes.Structure):
# 删除了WeChatDecryptor类,解密功能已移至独立的wechat_decrypt.py脚本
def parse_global_config(base_path: str) -> dict:
"""
解析 all_users/config/global_config 获取最近登录用户信息
基于 AES-128-CFB 解密,并解析 MMKV 的 Varint 格式
"""
try:
import os
config_path = os.path.join(base_path, 'all_users', 'config', 'global_config')
if not os.path.exists(config_path):
return None
with open(config_path, 'rb') as f:
full_data = f.read()
if len(full_data) <= 4:
return None
encrypted_data = full_data[4:]
# 核心修复 1:强制截断取前 16 字节,等同于 Rust 中的 b"xwechat_crypt_ke"
key = b'xwechat_crypt_key'[:16]
iv = b'\0' * 16
# 尝试主流的两种密码库
try:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
cipher = Cipher(algorithms.AES(key), modes.CFB(iv), backend=default_backend())
decryptor = cipher.decryptor()
decrypted = decryptor.update(encrypted_data) + decryptor.finalize()
except ImportError:
from Crypto.Cipher import AES
# PyCryptodome 中 CFB 模式默认 segment_size 是 8,需要指定为 128
cipher = AES.new(key, AES.MODE_CFB, iv=iv, segment_size=128)
decrypted = cipher.decrypt(encrypted_data)
# MMKV Varint 长度解码
def decode_varint(data, offset):
result = 0
shift = 0
while offset < len(data):
byte = data[offset]
offset += 1
result |= (byte & 0x7f) << shift
if not (byte & 0x80):
break
shift += 7
return result, offset
def extract_mmkv_string(data: bytes, key_str: str) -> str:
key_bytes = key_str.encode('utf-8')
idx = data.find(key_bytes)
if idx == -1: return None
offset = idx + len(key_bytes)
try:
value_len, offset = decode_varint(data, offset)
if value_len <= 0 or offset >= len(data):
return None
str_len, offset = decode_varint(data, offset)
if str_len > 0 and offset + str_len <= len(data):
return data[offset:offset + str_len].decode('utf-8', errors='ignore')
except Exception:
pass
return None
wxid = extract_mmkv_string(decrypted, 'mmkv_key_user_name')
nickname = extract_mmkv_string(decrypted, 'mmkv_key_nick_name')
avatar_url = extract_mmkv_string(decrypted, 'mmkv_key_head_img_url')
# 核心修复 2:参考 Rust 逻辑,头像链接往往以 "/0" 结尾(微信头像的尺寸标识)
if not avatar_url and b'http' in decrypted:
http_idx = decrypted.find(b'http')
slash_zero_idx = decrypted.find(b'/0', http_idx)
if slash_zero_idx != -1:
# 包含 "/0" 这两个字符本身,所以是 +2
avatar_url = decrypted[http_idx:slash_zero_idx + 2].decode('utf-8', errors='ignore')
if wxid or nickname:
return {
"wxid": wxid,
"nickname": nickname,
"avatar": avatar_url
}
return None
except Exception as e:
print(f"[DEBUG] 解析 global_config 失败: {e}")
return None
def find_wechat_databases() -> List[str]:
"""在新的xwechat_files目录中查找微信数据库文件
@@ -119,13 +212,13 @@ def find_wechat_databases() -> List[str]:
# 检查新的微信4.0+目录结构
wechat_dirs = [
documents_dir / "xwechat_files", # 新版微信4.0+
documents_dir / "WeChat Files" # 旧版微信
documents_dir / "WeChat Files" # 旧版微信
]
for wechat_dir in wechat_dirs:
if not wechat_dir.exists():
continue
# 查找用户目录(wxid_*模式)
for user_dir in wechat_dir.iterdir():
if not user_dir.is_dir():
@@ -149,7 +242,7 @@ def find_wechat_databases() -> List[str]:
for db_file in multi_dir.glob("*.db"):
if db_file.is_file():
db_files.append(str(db_file))
return db_files
@@ -158,7 +251,7 @@ def get_process_exe_path(process_id):
h_process = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, process_id)
if not h_process:
return None
exe_path = ctypes.create_unicode_buffer(MAX_PATH)
if GetModuleFileNameExW(h_process, None, exe_path, MAX_PATH) > 0:
CloseHandle(h_process)
@@ -167,35 +260,37 @@ def get_process_exe_path(process_id):
CloseHandle(h_process)
return None
def get_process_list():
"""获取系统进程列表"""
h_process_snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
if h_process_snap == ctypes.wintypes.HANDLE(-1).value:
return []
pe32 = PROCESSENTRY32W()
pe32.dwSize = ctypes.sizeof(PROCESSENTRY32W)
process_list = []
if not Process32FirstW(h_process_snap, ctypes.byref(pe32)):
CloseHandle(h_process_snap)
return []
while True:
process_list.append((pe32.th32ProcessID, pe32.szExeFile))
if not Process32NextW(h_process_snap, ctypes.byref(pe32)):
break
CloseHandle(h_process_snap)
return process_list
def auto_detect_wechat_data_dirs():
"""
自动检测微信数据目录 - 多策略组合检测
:return: 检测到的微信数据目录列表
"""
detected_dirs = []
# 策略1:注册表检测已移除
# 策略2和策略3:注册表相关检测已移除
@@ -211,24 +306,24 @@ def auto_detect_wechat_data_dirs():
for drive in drives:
if not os.path.exists(drive):
continue
try:
# 扫描驱动器根目录和常见目录
scan_paths = [
drive + os.sep,
os.path.join(drive + os.sep, "Users"),
]
for scan_path in scan_paths:
if not os.path.exists(scan_path):
continue
try:
for item in os.listdir(scan_path):
item_path = os.path.join(scan_path, item)
if not os.path.isdir(item_path):
continue
# 检查是否匹配微信目录模式
for pattern in common_wechat_patterns:
if pattern.lower() in item.lower():
@@ -242,7 +337,7 @@ def auto_detect_wechat_data_dirs():
continue
except (PermissionError, OSError):
continue
# 策略2:进程内存分析(简化版)
try:
process_list = get_process_list()
@@ -263,7 +358,7 @@ def auto_detect_wechat_data_dirs():
current = parent
else:
break
for parent_dir in parent_dirs:
for pattern in common_wechat_patterns:
potential_dir = os.path.join(parent_dir, pattern)
@@ -275,7 +370,7 @@ def auto_detect_wechat_data_dirs():
pass
except:
pass
return detected_dirs
@@ -300,6 +395,7 @@ def has_wxid_directories(directory):
except:
return False
def get_wx_dir_by_reg(wxid="all"):
"""
通过多种方法获取微信目录 - 改进的自动检测
@@ -327,6 +423,7 @@ def get_wx_dir_by_reg(wxid="all"):
return wx_dir if os.path.exists(wx_dir) else None
def detect_wechat_accounts_from_backup(backup_base_path: str = None) -> List[Dict[str, Any]]:
"""
从指定的备份路径检测微信账号
@@ -393,8 +490,8 @@ def detect_wechat_accounts_from_backup(backup_base_path: str = None) -> List[Dic
for data_item in os.listdir(backup_base_path):
data_item_path = os.path.join(backup_base_path, data_item)
if (os.path.isdir(data_item_path) and
data_item.startswith(f"{account_name}_") and
data_item != "Backup"):
data_item.startswith(f"{account_name}_") and
data_item != "Backup"):
data_dir = data_item_path
break
except (PermissionError, OSError):
@@ -525,9 +622,9 @@ def detect_wechat_accounts_from_login(login_base_path: str = None) -> List[Dict[
for data_item in os.listdir(base_path):
data_item_path = os.path.join(base_path, data_item)
if (
os.path.isdir(data_item_path)
and data_item.startswith(f"{account_name}_")
and data_item not in ["Backup", "all_users"]
os.path.isdir(data_item_path)
and data_item.startswith(f"{account_name}_")
and data_item not in ["Backup", "all_users"]
):
data_dir = data_item_path
break
@@ -551,6 +648,7 @@ def detect_wechat_accounts_from_login(login_base_path: str = None) -> List[Dict[
return accounts
def collect_account_databases(data_dir: str, account_name: str) -> List[Dict[str, Any]]:
"""
收集指定账号数据目录下的所有数据库文件
@@ -801,40 +899,37 @@ def detect_wechat_installation(data_root_path: str | None = None) -> Dict[str, A
def detect_current_logged_in_account(base_path: str = None) -> Dict[str, Any]:
"""
通过key_info.db文件时间检测当前登录的微信账号
Args:
base_path: 微信数据根目录,如果为None则自动检测
Returns:
当前登录账号信息
通过 global_config 解析 或 key_info.db 时间检测当前登录的微信账号
"""
current_account = None
latest_time = None
# 添加调试信息
print(f"[DEBUG] 开始检测当前登录账号,提供的base_path: {base_path}")
# 如果没有指定路径,尝试自动检测
# print(f"[DEBUG] 开始检测当前登录账号,提供的base_path: {base_path}")
if base_path is None:
detected_dirs = auto_detect_wechat_data_dirs()
print(f"[DEBUG] 自动检测到的目录: {detected_dirs}")
if not detected_dirs:
return {
"current_account": None,
"latest_time": None,
"message": "未检测到微信数据目录"
}
return {"current_account": None, "message": "未检测到微信数据目录"}
base_path = detected_dirs[0]
print(f"[DEBUG] 使用的base_path: {base_path}")
# 查找登录信息目录 - 尝试多个可能的路径
# 1. 新特性:优先尝试从 global_config 解析完整用户信息
parsed_config = parse_global_config(base_path)
if parsed_config and parsed_config.get('wxid'):
print(f"[DEBUG] 从 global_config 成功解析出账号: {parsed_config['wxid']}")
return {
"current_account": parsed_config["wxid"], # 不带校验位的 wxid
"nickname": parsed_config.get("nickname"),
"avatar": parsed_config.get("avatar"),
"latest_time": None,
"message": f"通过 global_config 检测到最近登录账号: {parsed_config['wxid']}"
}
# 2. 降级回退机制:原先基于 key_info.db 的时间探测逻辑
latest_time = None
current_account = None
possible_login_paths = [
os.path.join(base_path, "all_users", "login"), # 标准路径
os.path.join(base_path, "login"), # 备选路径1
os.path.join(base_path, "all_users", "login"),
os.path.join(base_path, "login"),
]
# 也尝试在子目录中查找
try:
for item in os.listdir(base_path):
@@ -842,11 +937,11 @@ def detect_current_logged_in_account(base_path: str = None) -> Dict[str, Any]:
if os.path.isdir(item_path):
possible_login_paths.extend([
os.path.join(item_path, "all_users", "login"), # 子目录中的标准路径
os.path.join(item_path, "login"), # 子目录中的备选路径
os.path.join(item_path, "login"), # 子目录中的备选路径
])
except (PermissionError, OSError):
pass
login_dir = None
for path in possible_login_paths:
print(f"[DEBUG] 检查路径: {path}")
@@ -854,49 +949,49 @@ def detect_current_logged_in_account(base_path: str = None) -> Dict[str, Any]:
login_dir = path
print(f"[DEBUG] 找到登录目录: {login_dir}")
break
if not login_dir:
return {
"current_account": None,
"latest_time": None,
"message": f"未找到登录信息目录,尝试的路径: {possible_login_paths}"
}
try:
# 遍历登录目录下的所有账号文件夹
items = os.listdir(login_dir)
print(f"[DEBUG] 登录目录内容: {items}")
for item in items:
item_path = os.path.join(login_dir, item)
print(f"[DEBUG] 检查项目: {item}, 路径: {item_path}, 是否为目录: {os.path.isdir(item_path)}")
if not os.path.isdir(item_path):
continue
# 检查key_info.db文件
key_info_path = os.path.join(item_path, "key_info.db")
print(f"[DEBUG] 检查key_info.db文件: {key_info_path}, 是否存在: {os.path.exists(key_info_path)}")
if not os.path.exists(key_info_path):
continue
# 获取文件修改时间
try:
file_time = os.path.getmtime(key_info_path)
file_datetime = datetime.fromtimestamp(file_time)
print(f"[DEBUG] 找到key_info.db文件: {key_info_path}, 修改时间: {file_datetime}")
# 更新最新登录的账号
if latest_time is None or file_time > latest_time:
latest_time = file_time
current_account = item
print(f"[DEBUG] 更新最新登录账号: {current_account}, 时间: {file_datetime}")
except OSError as e:
print(f"[DEBUG] 无法获取文件时间: {key_info_path}, 错误: {e}")
continue
except (PermissionError, OSError) as e:
print(f"[DEBUG] 无法访问登录目录: {login_dir}, 错误: {e}")
return {
@@ -904,7 +999,7 @@ def detect_current_logged_in_account(base_path: str = None) -> Dict[str, Any]:
"latest_time": None,
"message": f"无法访问登录目录: {e}"
}
if current_account:
print(f"[DEBUG] 最终结果: 当前登录账号 {current_account}, 时间 {latest_time}")
return {