Compare commits

...

15 Commits

23 changed files with 1843 additions and 818 deletions
+1 -10
View File
@@ -6,6 +6,7 @@
<h1>WeChatDataAnalysis - 微信数据库解密与分析工具</h1>
<p>微信4.x数据解密并生成年度总结,高仿微信,支持实时更新,导出聊天记录,朋友圈等大量便捷功能</p>
<p><b>特别致谢</b><a href="https://github.com/H3CoF6">H3CoF6</a>(密钥与朋友圈等核心内容的技术支持)、<a href="https://github.com/ycccccccy/echotrace">echotrace</a>、<a href="https://github.com/hicccc77/WeFlow">WeFlow</a>(本项目大量功能参考其实现)</p>
<p>如需定制功能,请联系 QQ2977094657。</p>
<img src="https://img.shields.io/github/v/tag/LifeArchiveProject/WeChatDataAnalysis" alt="Version" />
<img src="https://img.shields.io/github/stars/LifeArchiveProject/WeChatDataAnalysis" alt="Stars" />
<img src="https://gh-down-badges.linkof.link/LifeArchiveProject/WeChatDataAnalysis" alt="Downloads" />
@@ -192,16 +193,6 @@ npm run dist
3. **数据隐私**: 解密后的数据包含个人隐私信息,请谨慎处理
4. **合法使用**: 请遵守相关法律法规,不得用于非法目的
## 修改消息
支持在聊天页对单条消息进行本地修改(如修改消息文本/字段、修复为我发送、反转本地气泡方向),并在“修改记录”页查看原始与当前对比,支持单条恢复或按会话一键恢复。
该功能只修改本机本地数据库(`db_storage` 与解密副本),不会调用远端回写接口。
<p align="center">
<img src="frontend/public/edit.gif" alt="本地消息修改" width="800" />
</p>
## 致谢
本项目的开发过程中参考了以下优秀的开源项目和资源:
+126
View File
@@ -1139,6 +1139,132 @@
flex-shrink: 0;
}
.wechat-link-card-finder {
width: 135px;
min-width: 135px;
max-width: 135px;
border: none;
box-shadow: none;
outline: none;
cursor: pointer;
text-decoration: none;
}
.wechat-link-card-finder.wechat-link-card--disabled {
cursor: default;
}
.wechat-link-finder-cover {
width: 135px;
height: 185px;
position: relative;
overflow: hidden;
border-radius: 4px;
background: var(--app-surface-muted);
}
.wechat-link-finder-cover--empty {
background: linear-gradient(180deg, #37cc6a 0%, #118f42 100%);
}
.wechat-link-finder-cover-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
.wechat-link-finder-cover-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.92);
}
.wechat-link-finder-cover-placeholder svg {
width: 34px;
height: 34px;
}
.wechat-link-finder-cover-shade {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.12) 42%, rgba(0, 0, 0, 0.68) 100%);
}
.wechat-link-finder-play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -66%);
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.42);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
}
.wechat-link-finder-play svg {
width: 20px;
height: 20px;
margin-left: 2px;
}
.wechat-link-finder-meta {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
display: flex;
flex-direction: column;
gap: 0;
}
.wechat-link-finder-author {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
padding: 5px 7px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.28);
backdrop-filter: blur(6px);
}
.wechat-link-finder-author-avatar {
width: 18px;
height: 18px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.wechat-link-finder-author-avatar-img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.wechat-link-finder-author-name {
min-width: 0;
flex: 1 1 auto;
font-size: 10px;
color: rgba(255, 255, 255, 0.96);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
}
/* 隐私模式模糊效果 */
.privacy-blur {
filter: blur(9px);
+6
View File
@@ -1437,14 +1437,20 @@
.session-list-item-name {
color: var(--session-list-name);
font-weight: 400;
font-synthesis: none;
}
.session-list-item-time {
color: var(--session-list-meta);
font-weight: 400;
font-synthesis: none;
}
.session-list-item-preview {
color: var(--session-list-preview);
font-weight: 400;
font-synthesis: none;
}
.contact-search-wrapper {
+394
View File
@@ -0,0 +1,394 @@
<template>
<div class="biz-page h-full min-h-0 flex overflow-hidden" style="background-color: var(--app-shell-bg)">
<div :class="['w-[300px] lg:w-[320px] border-r flex flex-col flex-shrink-0 z-10', isDark ? 'bg-[#1e1e1e] border-[#333]' : 'bg-white border-gray-200']">
<div class="p-3 border-b" :class="isDark ? 'border-[#333]' : 'border-gray-200'" style="background-color: var(--app-surface-muted)">
<div class="contact-search-wrapper flex-1">
<input
v-model="searchQuery"
type="text"
class="contact-search-input"
placeholder="搜索服务号"
/>
</div>
</div>
<div class="flex-1 overflow-y-auto min-h-0">
<div v-if="loadingAccounts" class="flex justify-center py-4">
<span class="text-sm" :class="isDark ? 'text-gray-500' : 'text-gray-400'">加载中...</span>
</div>
<div v-else class="pb-4">
<div
v-for="item in filteredAccounts"
:key="item.username"
@click="selectAccount(item)"
class="flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors border-b"
:class="[
isDark ? 'border-[#333]' : 'border-gray-50',
selectedBizAccount?.username === item.username
? (isDark ? 'bg-[#333]' : 'bg-[#E5E5E5]') // 选中状态
: item.username === 'gh_3dfda90e39d6'
? (isDark ? 'bg-[#2a2a2a] hover:bg-[#333]' : 'bg-[#F2F2F2] hover:bg-[#EAEAEA]') // 微信支付专门的底色
: (isDark ? 'hover:bg-[#252525]' : 'hover:bg-gray-50') // 普通悬浮色
]"
>
<img v-if="item.avatar" :src="api.getBizProxyImageUrl(item.avatar)" :class="['w-10 h-10 rounded-md object-cover flex-shrink-0', isDark ? 'bg-[#333]' : 'bg-gray-200']" alt=""/>
<div v-else class="w-10 h-10 rounded-md bg-[#03C160] text-white flex items-center justify-center text-lg font-medium flex-shrink-0 shadow-sm">
{{ (item.name || item.username).charAt(0).toUpperCase() }}
</div>
<div class="flex-1 min-w-0 flex flex-col justify-center gap-0.5">
<div class="flex justify-between items-center">
<h3 class="text-sm truncate" :class="isDark ? 'text-gray-100' : 'text-gray-900'">{{ item.name || item.username }}</h3>
<span v-if="item.formatted_last_time" class="text-[11px] flex-shrink-0 ml-2" :class="isDark ? 'text-gray-500' : 'text-gray-400'">
{{ item.formatted_last_time }}
</span>
</div>
<div
class="text-[10px] px-1.5 py-0.5 rounded w-max mt-0.5"
:class="[
item.type === 1 ? (isDark ? 'text-[#03C160] bg-[#03C160]/20' : 'text-[#03C160] bg-[#03C160]/10') : // 服务号
item.type === 0 ? (isDark ? 'text-blue-400 bg-blue-900/40' : 'text-blue-500 bg-blue-50') : // 公众号
item.type === 2 ? (isDark ? 'text-orange-400 bg-orange-900/40' : 'text-orange-500 bg-orange-50') : // 企业号
(isDark ? 'text-gray-400 bg-gray-700/50' : 'text-gray-400 bg-gray-100') // 未知
]"
>
{{ {1: '服务号', 0: '公众号', 2: '企业号', 3: '未知'}[item.type] || '未知' }}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex-1 flex flex-col min-h-0 min-w-0" :class="isDark ? 'bg-[#121212]' : 'bg-[#F5F5F5]'">
<div v-if="selectedBizAccount" class="flex-1 flex flex-col min-h-0 relative">
<div class="h-14 border-b flex items-center px-5 shrink-0 z-10" :class="isDark ? 'bg-[#121212] border-[#333]' : 'bg-[#F5F5F5] border-gray-200'">
<h2 class="text-base" :class="isDark ? 'text-gray-100' : 'text-gray-900'">{{ selectedBizAccount.name }}</h2>
</div>
<div class="flex-1 overflow-y-auto px-4 py-6 flex flex-col-reverse" @scroll="handleScroll" ref="messageListRef">
<div class="h-4 shrink-0" aria-hidden="true"></div>
<div v-if="!hasMore" class="text-center text-xs py-4 w-full" :class="isDark ? 'text-gray-500' : 'text-gray-400'">没有更多消息了</div>
<div v-if="loadingMessages" class="text-center text-xs py-4 w-full" :class="isDark ? 'text-gray-500' : 'text-gray-400'">正在加载...</div>
<div class="w-full max-w-[400px] mx-auto flex flex-col-reverse gap-6">
<div v-for="msg in messages" :key="msg.local_id" class="w-full">
<div v-if="selectedBizAccount.username === 'gh_3dfda90e39d6'" class="rounded-xl shadow-sm p-5 border" :class="isDark ? 'bg-[#1e1e1e] border-[#333]' : 'bg-white border-gray-100'">
<div class="flex items-center text-sm mb-5" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
<img v-if="msg.merchant_icon" :src="api.getBizProxyImageUrl(msg.merchant_icon)" class="w-6 h-6 rounded-full mr-2 object-cover" alt=""/>
<div v-else class="w-6 h-6 rounded-full mr-2 flex items-center justify-center" :class="isDark ? 'bg-green-900/40 text-green-400' : 'bg-green-100 text-green-600'">¥</div>
<span>{{ msg.merchant_name || '微信支付' }}</span>
</div>
<div class="text-center mb-6">
<h3 class="text-[22px] font-medium mb-1" :class="isDark ? 'text-gray-100' : 'text-gray-900'">{{ msg.title }}</h3>
</div>
<div class="text-[13px] whitespace-pre-wrap leading-relaxed" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
{{ msg.description }}
</div>
<div class="mt-4 pt-3 border-t text-[12px] text-right" :class="isDark ? 'border-[#333] text-gray-500' : 'border-gray-100 text-gray-400'">
{{ msg.formatted_time }}
</div>
</div>
<div v-else class="rounded-xl shadow-sm overflow-hidden border" :class="isDark ? 'bg-[#1e1e1e] border-[#333]' : 'bg-white border-gray-100'">
<a :href="msg.url" target="_blank" class="block relative group cursor-pointer">
<img :src="msg.cover ? api.getBizProxyImageUrl(msg.cover) : defaultImage" :class="['w-full h-[180px] object-cover', isDark ? 'bg-[#333]' : 'bg-gray-100']" alt=""/>
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3 pt-8">
<h3 class="text-white text-[15px] font-medium leading-snug line-clamp-2 group-hover:underline">
{{ msg.title }}
</h3>
</div>
</a>
<div v-if="msg.des" class="px-4 py-3 text-[13px] border-b" :class="isDark ? 'text-gray-400 border-[#333]' : 'text-gray-500 border-gray-50'">
{{ msg.des }}
</div>
<div v-if="msg.content_list && msg.content_list.length > 1" class="flex flex-col">
<a
v-for="(item, idx) in msg.content_list.slice(1)"
:key="idx"
:href="item.url"
target="_blank"
class="flex items-center justify-between p-3 border-t hover:bg-opacity-50 cursor-pointer group"
:class="isDark ? 'border-[#333] hover:bg-[#252525]' : 'border-gray-100 hover:bg-gray-50'"
>
<span class="text-[14px] leading-snug line-clamp-2 pr-3 group-hover:underline" :class="isDark ? 'text-gray-200' : 'text-gray-800'">
{{ item.title }}
</span>
<img :src="item.cover ? api.getBizProxyImageUrl(item.cover) : defaultImage" :class="['w-12 h-12 rounded object-cover flex-shrink-0 border', isDark ? 'bg-[#333] border-[#444]' : 'bg-gray-100 border-gray-100']" alt=""/>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center">
<div class="w-20 h-20 mx-auto mb-5 rounded-2xl flex items-center justify-center" :class="isDark ? 'bg-[#2a2a2a]' : 'bg-gray-200/50'">
<svg class="w-10 h-10" :class="isDark ? 'text-gray-600' : 'text-gray-400'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9.5L18.5 7H20" />
</svg>
</div>
<p class="text-sm" :class="isDark ? 'text-gray-500' : 'text-gray-400'">请选择一个服务号查看消息</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useApi } from '~/composables/useApi'
const api = useApi()
import { storeToRefs } from 'pinia'
import { useThemeStore } from '~/stores/theme'
import { useChatAccountsStore } from '~/stores/chatAccounts'
import { useChatRealtimeStore } from '~/stores/chatRealtime'
const accounts = ref([])
const loadingAccounts = ref(false)
const searchQuery = ref('')
const selectedBizAccount = ref(null)
const themeStore = useThemeStore()
const chatAccountsStore = useChatAccountsStore()
const realtimeStore = useChatRealtimeStore()
const { isDark } = storeToRefs(themeStore)
const { selectedAccount: selectedDbAccount } = storeToRefs(chatAccountsStore)
const { enabled: realtimeEnabled, changeSeq } = storeToRefs(realtimeStore)
const messages = ref([])
const loadingMessages = ref(false)
const offset = ref(0)
const limit = 20
const hasMore = ref(true)
const messageListRef = ref(null)
let realtimeRefreshFuture = null
let realtimeRefreshQueued = false
// 默认占位图
// const defaultAvatar = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDQwIDQwIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiNlNWU3ZWIiLz48L3N2Zz4='
const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg=='
const getCurrentAccountParam = () => {
const account = String(selectedDbAccount.value || '').trim()
return account || undefined
}
const resetMessagesState = () => {
messages.value = []
offset.value = 0
hasMore.value = true
}
const fetchAccounts = async ({ preserveSelection = true } = {}) => {
loadingAccounts.value = true
const previousUsername = preserveSelection ? String(selectedBizAccount.value?.username || '').trim() : ''
try {
const res = await api.listBizAccounts({ account: getCurrentAccountParam() })
const nextAccounts = Array.isArray(res?.data) ? res.data : []
accounts.value = nextAccounts
if (previousUsername) {
selectedBizAccount.value = nextAccounts.find(item => item.username === previousUsername) || null
} else if (!selectedBizAccount.value?.username) {
selectedBizAccount.value = null
}
} catch (err) {
accounts.value = []
selectedBizAccount.value = null
console.error('获取服务号失败:', err)
} finally {
loadingAccounts.value = false
}
}
// 搜索过滤
const filteredAccounts = computed(() => {
if (!searchQuery.value) return accounts.value
const q = searchQuery.value.toLowerCase()
return accounts.value.filter(a =>
(a.name && a.name.toLowerCase().includes(q)) ||
(a.username && a.username.toLowerCase().includes(q))
)
})
// 点击选择服务号
const selectAccount = async (account) => {
if (selectedBizAccount.value?.username === account.username) return
selectedBizAccount.value = account
// 重置消息状态
resetMessagesState()
await loadMessages()
}
// 加载消息
const loadMessages = async () => {
if (loadingMessages.value || !hasMore.value || !selectedBizAccount.value) return
loadingMessages.value = true
try {
const username = selectedBizAccount.value.username
const params = {
account: getCurrentAccountParam(),
username,
offset: offset.value,
limit,
}
let res
if (username === 'gh_3dfda90e39d6') {
res = await api.listBizPayRecords(params)
} else {
res = await api.listBizMessages(params)
}
if (res && res.data) {
if (res.data.length < limit) {
hasMore.value = false
}
// 追加数据
messages.value.push(...res.data)
offset.value += limit
}
} catch (err) {
console.error('加载消息失败:', err)
} finally {
loadingMessages.value = false
}
}
const reloadSelectedMessages = async () => {
if (!selectedBizAccount.value) return
resetMessagesState()
await loadMessages()
}
const syncAllBizRealtime = async ({ forceReload = false } = {}) => {
const priorityUsername = String(selectedBizAccount.value?.username || '').trim()
if (!realtimeEnabled.value) {
if (forceReload) {
await reloadSelectedMessages()
}
return
}
try {
const result = await api.syncChatRealtimeAll({
account: getCurrentAccountParam(),
max_scan: 200,
priority_username: priorityUsername,
priority_max_scan: 400,
include_hidden: true,
include_official: true,
only_official: true,
backfill_limit: 0,
})
const hasDelta = Number(result?.insertedTotal || 0) > 0 || Number(result?.sessionsUpdated || 0) > 0
await fetchAccounts({ preserveSelection: true })
if (selectedBizAccount.value?.username) {
if (hasDelta || forceReload) {
await reloadSelectedMessages()
}
} else if (forceReload) {
resetMessagesState()
}
} catch (err) {
console.error('实时同步服务号失败:', err)
if (forceReload) {
await fetchAccounts({ preserveSelection: true })
await reloadSelectedMessages()
}
}
}
const queueRealtimeBizRefresh = () => {
if (!realtimeEnabled.value) return
if (realtimeRefreshFuture) {
realtimeRefreshQueued = true
return
}
realtimeRefreshFuture = syncAllBizRealtime().finally(() => {
realtimeRefreshFuture = null
if (realtimeRefreshQueued) {
realtimeRefreshQueued = false
queueRealtimeBizRefresh()
}
})
}
// 向上滚动加载逻辑
// 因为容器设置了 flex-col-reverse,所以 scrollTop 越靠近负值(或0取决于浏览器)越是到了历史消息端
// 但比较通用兼容的做法是监听 scroll,距离顶部或底部小于阈值时触发
const handleScroll = (e) => {
const target = e.target
// 针对 flex-col-reverse: 滚动到底部实际上是视觉上的最上方(历史消息)
// 当 scrollHeight - Math.abs(scrollTop) - clientHeight < 50 时加载
if (target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight < 50) {
loadMessages()
}
}
watch(selectedDbAccount, async (next, prev) => {
if (String(next || '').trim() === String(prev || '').trim()) return
selectedBizAccount.value = null
resetMessagesState()
searchQuery.value = ''
if (!String(next || '').trim()) {
accounts.value = []
return
}
await fetchAccounts({ preserveSelection: false })
if (realtimeEnabled.value) {
await syncAllBizRealtime({ forceReload: true })
}
})
watch(changeSeq, (next, prev) => {
if (!realtimeEnabled.value) return
if (next === prev) return
queueRealtimeBizRefresh()
})
watch(realtimeEnabled, async (enabled, wasEnabled) => {
if (enabled && !wasEnabled) {
await syncAllBizRealtime({ forceReload: true })
}
})
onMounted(async () => {
await chatAccountsStore.ensureLoaded()
await fetchAccounts({ preserveSelection: false })
if (realtimeEnabled.value) {
await syncAllBizRealtime({ forceReload: true })
}
})
</script>
<style scoped>
/* 隐藏滚动条但允许滚动(可选) */
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: rgba(0,0,0,0.1);
border-radius: 10px;
}
</style>
+24 -26
View File
@@ -101,6 +101,21 @@
</div>
</div>
<div
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="服务号"
@click="goBiz"
>
<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-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isBizRoute }">
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5L6 9H2v6h4l5 4V5z"></path>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
</div>
</div>
</div>
<!-- Wrapped -->
<div
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@@ -479,34 +494,17 @@ const isChatRoute = computed(() => route.path?.startsWith('/chat'))
const isEditsRoute = computed(() => route.path?.startsWith('/edits'))
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
const isBizRoute = computed(() => route.path?.startsWith('/biz')) // 新增
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
const goChat = async () => {
await navigateTo('/chat')
}
const goEdits = async () => {
await navigateTo('/edits')
}
const goSns = async () => {
await navigateTo('/sns')
}
const goContacts = async () => {
await navigateTo('/contacts')
}
const goWrapped = async () => {
await navigateTo('/wrapped')
}
const goGuide = async () => {
await navigateTo('/')
}
const goSettings = () => {
openSettingsDialog()
}
const goChat = async () => { await navigateTo('/chat') }
const goEdits = async () => { await navigateTo('/edits') }
const goSns = async () => { await navigateTo('/sns') }
const goContacts = async () => { await navigateTo('/contacts') }
const goBiz = async () => { await navigateTo('/biz') }
const goWrapped = async () => { await navigateTo('/wrapped') }
const goGuide = async () => { await navigateTo('/') }
const goSettings = () => { openSettingsDialog() }
const onWindowKeydown = (event) => {
if (event?.key !== 'Escape') return
+68 -3
View File
@@ -2,6 +2,8 @@
import { defineComponent, h, ref, watch } from 'vue'
import miniProgramIconUrl from '~/assets/images/wechat/mini-program.svg'
const finderLogoUrl = '/assets/images/wechat/channels-logo.svg'
export default defineComponent({
name: 'LinkCard',
props: {
@@ -51,7 +53,11 @@ export default defineComponent({
return text ? (Array.from(text)[0] || '') : ''
})()
const fromAvatarUrl = String(props.fromAvatar || '').trim()
const headingText = String(props.heading || href || '').trim()
let abstractText = String(props.abstract || '').trim()
if (abstractText && headingText && abstractText === headingText) abstractText = ''
const isMiniProgram = String(props.linkType || '').trim() === 'mini_program'
const isFinder = String(props.linkType || '').trim() === 'finder'
const isCoverVariant = !isMiniProgram && String(props.variant || '').trim() === 'cover'
const Tag = canNavigate ? 'a' : 'div'
@@ -140,9 +146,68 @@ export default defineComponent({
)
}
const headingText = String(props.heading || href || '').trim()
let abstractText = String(props.abstract || '').trim()
if (abstractText && headingText && abstractText === headingText) abstractText = ''
if (isFinder) {
return h(
Tag,
{
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
class: [
'wechat-link-card-finder',
!canNavigate ? 'wechat-link-card--disabled' : '',
'wechat-special-card',
'msg-radius',
props.isSent ? 'wechat-special-sent-side' : ''
].filter(Boolean).join(' '),
style: {
width: '135px',
minWidth: '135px',
maxWidth: '135px',
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
flex: '0 0 auto',
border: 'none',
boxShadow: 'none',
textDecoration: 'none',
outline: 'none'
}
},
[
h('div', { class: ['wechat-link-finder-cover', !props.preview ? 'wechat-link-finder-cover--empty' : ''].filter(Boolean).join(' ') }, [
props.preview
? h('img', {
src: props.preview,
alt: props.heading || '视频号封面',
class: 'wechat-link-finder-cover-img',
referrerpolicy: 'no-referrer'
})
: h('div', { class: 'wechat-link-finder-cover-placeholder', 'aria-hidden': 'true' }, [
h('svg', { viewBox: '0 0 24 24', fill: 'currentColor' }, [
h('path', { d: 'M8 5v14l11-7z' })
])
]),
h('div', { class: 'wechat-link-finder-cover-shade', 'aria-hidden': 'true' }),
h('div', { class: 'wechat-link-finder-play', 'aria-hidden': 'true' }, [
h('svg', { viewBox: '0 0 24 24', fill: 'currentColor' }, [
h('path', { d: 'M8 5v14l11-7z' })
])
]),
h('div', { class: 'wechat-link-finder-meta' }, [
h('div', { class: 'wechat-link-finder-author' }, [
h('div', { class: 'wechat-link-finder-author-avatar', 'aria-hidden': 'true' }, [
h('img', {
src: finderLogoUrl,
alt: '',
class: 'wechat-link-finder-author-avatar-img'
})
]),
h('div', { class: 'wechat-link-finder-author-name' }, fromText || '视频号')
])
])
])
]
)
}
if (isMiniProgram) {
return h(
@@ -69,7 +69,7 @@
<div v-else-if="contacts.length === 0" class="session-list-status px-3 py-2 text-sm">
暂无会话
</div>
<template v-else>
<div v-else class="pb-4">
<div v-for="contact in filteredContacts" :key="contact.id"
class="session-list-item px-3 cursor-pointer transition-colors duration-150 h-[calc(80px/var(--dpr))] flex items-center"
:class="{
@@ -98,7 +98,7 @@
<!-- 联系人信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<h3 class="session-list-item-name text-sm font-medium truncate" :class="{ 'privacy-blur': privacyMode }">{{ contact.name }}</h3>
<h3 class="session-list-item-name text-sm truncate" :class="{ 'privacy-blur': privacyMode }">{{ contact.name }}</h3>
<div class="flex items-center flex-shrink-0 ml-2">
<span class="session-list-item-time text-xs">{{ contact.lastMessageTime }}</span>
</div>
@@ -118,7 +118,7 @@
</div>
</div>
</div>
</template>
</div>
</div>
</div>
+44
View File
@@ -205,6 +205,8 @@ export const useApi = () => {
if (params && params.priority_max_scan != null) query.set('priority_max_scan', String(params.priority_max_scan))
if (params && params.include_hidden != null) query.set('include_hidden', String(!!params.include_hidden))
if (params && params.include_official != null) query.set('include_official', String(!!params.include_official))
if (params && params.only_official != null) query.set('only_official', String(!!params.only_official))
if (params && params.backfill_limit != null) query.set('backfill_limit', String(params.backfill_limit))
const url = '/chat/realtime/sync_all' + (query.toString() ? `?${query.toString()}` : '')
return await request(url, { method: 'POST' })
}
@@ -561,6 +563,44 @@ export const useApi = () => {
return await request('/get_image_key')
}
// 枚举服务号信息
const listBizAccounts = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
const url = '/biz/list' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 获取普通服务号消息
const listBizMessages = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.username) query.set('username', params.username)
if (params && params.limit != null) query.set('limit', String(params.limit))
if (params && params.offset != null) query.set('offset', String(params.offset))
const url = '/biz/messages' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 获取微信支付记录
const listBizPayRecords = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.limit != null) query.set('limit', String(params.limit))
if (params && params.offset != null) query.set('offset', String(params.offset))
const url = '/biz/pay_records' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
const getBizProxyImageUrl = (url) => {
if (!url) return ''
if (url.startsWith('data:')) return url // 如果已经是 base64,不处理
const query = new URLSearchParams()
query.set('url', url)
const base = baseURL ? baseURL.replace(/\/$/, '') : ''
return `${base}/biz/proxy_image?${query.toString()}`
}
return {
detectWechat,
detectCurrentAccount,
@@ -616,5 +656,9 @@ export const useApi = () => {
getKeys,
getImageKey,
getWxStatus,
listBizAccounts,
listBizMessages,
listBizPayRecords,
getBizProxyImageUrl,
}
}
+16
View File
@@ -0,0 +1,16 @@
<template>
<div class="h-full min-h-0 flex overflow-hidden bg-white">
<div class="flex-1 min-w-0">
<BizMessages />
</div>
</div>
</template>
<script setup>
import BizMessages from "../components/BizMessages.vue";
useHead({
title: '服务号消息 - WeChatDataAnalysis'
})
</script>
-3
View File
@@ -73,9 +73,6 @@
</svg>
点击按钮将自动获取数据库图片双重密钥您也可以手动输入已知的64位密钥使用<a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a>等工具获取
</p>
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-900">
提示数据库密钥跟随账号 + 设备下发同一账号在另一台电脑生成的聊天记录复制到当前设备后通常无法在当前设备重新获取原设备对应的密钥因此也无法直接解密
</div>
</div>
<!-- 数据库路径输入 -->
@@ -0,0 +1,5 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg t="1774499781741" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7897" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
<path d="M118.81813333 106.3936c87.27893333-26.2144 192.03413333 75.1616 358.46826667 346.9312 18.70506667 30.5152 34.7136 55.46666667 35.60106667 55.5008 0.88746667 0 17.74933333-26.4192 37.41013333-58.70933333 165.51253333-271.53066667 270.336-371.3024 359.35573333-342.08426667 56.55893333 18.56853333 80.41813333 73.18186667 80.21333334 183.63733333-0.4096 214.86933333-103.69706667 551.59466667-188.0064 612.89813334-69.4272 50.44906667-173.1584-13.07306667-269.85813334-165.30773334-9.59146667-15.1552-18.36373333-27.57973333-19.42186666-27.61386666-1.05813333 0-9.59146667 12.32213333-18.944 27.4432-50.96106667 82.39786667-113.4592 146.26133333-167.04853334 170.7008-26.04373333 11.8784-71.33866667 13.5168-90.45333333 3.24266666-52.08746667-27.98933333-110.72853333-149.504-156.16-323.72053333C7.3728 310.95466667 21.504 135.68 118.81813333 106.3936zM848.31573333 217.088c-55.26186667 42.93973333-126.49813333 138.58133333-230.8096 309.93066667l-49.2544 80.82773333 16.86186667 30.17386667c42.35946667 75.94666667 91.30666667 139.81013333 130.79893333 170.66666666 26.76053333 20.95786667 35.60106667 16.55466667 58.9824-29.4912 73.5232-144.55466667 136.192-440.7296 115.712-547.19146666-6.144-32.0512-15.80373333-35.46453333-42.2912-14.91626667zM143.73546667 207.9744c-19.72906667 19.49013333-14.60906667 145.8176 10.99093333 271.90613333 30.89066667 152.23466667 95.91466667 329.3184 124.5184 339.2512 27.81866667 9.65973333 104.31146667-77.824 164.38613333-188.0064l13.14133334-24.13226666-42.15466667-69.18826667c-112.98133333-185.344-186.64106667-284.3648-240.8448-323.72053333-16.65706667-12.0832-22.7328-13.312-30.03733333-6.10986667z" fill="#FF9908" p-id="7898"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+2
View File
@@ -36,6 +36,7 @@ from .routers.wrapped import router as _wrapped_router
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
app = FastAPI(
title="微信数据库解密工具",
@@ -96,6 +97,7 @@ app.include_router(_chat_media_router)
app.include_router(_sns_router)
app.include_router(_sns_export_router)
app.include_router(_wrapped_router)
app.include_router(_biz_router)
class _SPAStaticFiles(StaticFiles):
+64
View File
@@ -1220,6 +1220,70 @@ def _parse_app_message(text: str) -> dict[str, Any]:
"linkStyle": link_style,
}
if app_type == 51:
# 视频号分享(Finder / Channels
# 常见特征:
# - title 是「当前版本不支持展示该内容,请升级至最新版本。」
# - 真正标题在 <finderFeed><desc> 或其它 finder 节点里
finder_feed = _extract_xml_tag_text(text, "finderFeed")
finder_desc = (
(_extract_xml_tag_text(finder_feed, "desc") if finder_feed else "")
or _extract_xml_tag_text(text, "finderdesc")
or des
)
finder_nickname = (
_extract_xml_tag_text(text, "findernickname")
or _extract_xml_tag_text(text, "finder_nickname")
or (_extract_xml_tag_text(finder_feed, "nickname") if finder_feed else "")
or (_extract_xml_tag_text(finder_feed, "findernickname") if finder_feed else "")
)
finder_username = (
_extract_xml_tag_text(text, "finderusername")
or _extract_xml_tag_text(text, "finder_username")
or (_extract_xml_tag_text(finder_feed, "username") if finder_feed else "")
or (_extract_xml_tag_text(finder_feed, "finderusername") if finder_feed else "")
)
thumb_url = _normalize_xml_url(
_extract_xml_tag_or_attr(text, "thumburl")
or _extract_xml_tag_or_attr(text, "cdnthumburl")
or _extract_xml_tag_or_attr(text, "coverurl")
or _extract_xml_tag_or_attr(text, "cover")
or (_extract_xml_tag_or_attr(finder_feed, "thumbUrl") if finder_feed else "")
or (_extract_xml_tag_or_attr(finder_feed, "thumburl") if finder_feed else "")
or (_extract_xml_tag_or_attr(finder_feed, "coverUrl") if finder_feed else "")
or (_extract_xml_tag_or_attr(finder_feed, "coverurl") if finder_feed else "")
)
finder_url = url or _normalize_xml_url(
(_extract_xml_tag_text(finder_feed, "url") if finder_feed else "")
or (_extract_xml_tag_text(text, "playurl"))
or (_extract_xml_tag_text(text, "dataurl"))
)
display_title = str(title or "").strip()
if (not display_title) or ("不支持" in display_title):
display_title = str(finder_desc or "").strip()
if not display_title:
display_title = str(des or "").strip()
display_title = display_title or "[视频号]"
summary_text = str(finder_desc or "").strip() or display_title
from_display = str(finder_nickname or source_display_name or "").strip() or "视频号"
from_u = str(finder_username or source_username or "").strip()
return {
"renderType": "link",
"content": summary_text,
"title": display_title,
"url": finder_url or "",
"thumbUrl": thumb_url or "",
"from": from_display,
"fromUsername": from_u,
"linkType": "finder",
"linkStyle": "finder",
}
if app_type in (33, 36):
# 小程序分享(WeChat v4 常见:local_type = 49 + (33<<32) / 49 + (36<<32)
# 注:部分 payload 的 <url> 为空;前端会按需渲染为不可点击卡片。
+64 -8
View File
@@ -41,6 +41,22 @@ class ColoredFormatter(logging.Formatter):
return formatted
def _can_use_logging_stream(stream) -> bool:
try:
if stream is None or getattr(stream, "closed", False):
return False
except Exception:
return False
try:
stream.write("")
stream.flush()
except Exception:
return False
return True
class WeChatLogger:
"""微信解密工具统一日志管理器"""
@@ -64,6 +80,12 @@ class WeChatLogger:
if env_level:
log_level = env_level
console_logging_env = str(os.environ.get("WECHAT_TOOL_ENABLE_CONSOLE_LOG", "") or "").strip().lower()
console_logging_forced = console_logging_env in {"1", "true", "yes", "on"}
console_logging_disabled = console_logging_env in {"0", "false", "no", "off"}
level = getattr(logging, str(log_level or "INFO").upper(), logging.INFO)
# 创建日志目录
now = datetime.now()
from .app_paths import get_output_dir
@@ -73,10 +95,41 @@ class WeChatLogger:
# 设置日志文件名
date_str = now.strftime("%d")
self.log_file = log_dir / f"{date_str}_wechat_tool.log"
desired_log_file = log_dir / f"{date_str}_wechat_tool.log"
root_logger = logging.getLogger()
wants_console_handler = _can_use_logging_stream(sys.stdout)
if getattr(sys, "frozen", False) and not console_logging_forced:
wants_console_handler = False
if console_logging_disabled:
wants_console_handler = False
if WeChatLogger._initialized:
current_log_file = Path(getattr(self, "log_file", desired_log_file))
has_expected_file_handler = False
has_stream_handler = False
for handler in root_logger.handlers:
if isinstance(handler, logging.FileHandler):
try:
if Path(handler.baseFilename).resolve() == desired_log_file.resolve():
has_expected_file_handler = True
except Exception:
if Path(handler.baseFilename) == desired_log_file:
has_expected_file_handler = True
elif isinstance(handler, logging.StreamHandler):
has_stream_handler = True
if (
current_log_file == desired_log_file
and root_logger.level == level
and has_expected_file_handler
and (has_stream_handler or not wants_console_handler)
):
self.log_file = desired_log_file
return self.log_file
self.log_file = desired_log_file
# 清除现有的处理器
root_logger = logging.getLogger()
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
try:
@@ -100,18 +153,20 @@ class WeChatLogger:
# 文件处理器
file_handler = logging.FileHandler(self.log_file, encoding='utf-8')
file_handler.setFormatter(file_formatter)
level = getattr(logging, str(log_level or "INFO").upper(), logging.INFO)
file_handler.setLevel(level)
# 控制台处理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(console_formatter)
console_handler.setLevel(level)
console_handler = None
if wants_console_handler:
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(console_formatter)
console_handler.setLevel(level)
# 配置根日志器
root_logger.setLevel(level)
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
if console_handler is not None:
root_logger.addHandler(console_handler)
# 只为uvicorn日志器添加文件处理器,保持其原有的控制台处理器(带颜色)
uvicorn_logger = logging.getLogger("uvicorn")
@@ -158,7 +213,8 @@ class WeChatLogger:
except Exception:
pass
fastapi_logger.addHandler(file_handler)
fastapi_logger.addHandler(console_handler)
if console_handler is not None:
fastapi_logger.addHandler(console_handler)
fastapi_logger.setLevel(level)
# 记录初始化信息
+370
View File
@@ -0,0 +1,370 @@
import hashlib
import sqlite3
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional, Any, Dict, List
import urllib
from fastapi import APIRouter, HTTPException, Response
from pydantic import BaseModel
from ..chat_helpers import _resolve_account_dir
from ..path_fix import PathFixRoute
from ..logging_config import get_logger
try:
import zstandard as zstd
except Exception:
zstd = None
logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
def decompress_zstd_content(data: bytes, source_id: str, local_id: int) -> Optional[bytes]:
"""Zstandard 解压逻辑"""
if not data or not data.startswith(b'\x28\xb5\x2f\xfd'):
return None
try:
if zstd:
dctx = zstd.ZstdDecompressor()
return dctx.decompress(data, max_output_size=10 * 1024 * 1024)
except Exception as e:
error_msg = f"❌ [解压失败] 服务号id: {source_id}, local_id: {local_id} -> {e}"
print(error_msg)
logger.error(error_msg)
return None
def extract_xml_from_db_content(content: Any, source_id: str, local_id: int) -> str:
"""提取并解压数据库内容"""
if not content:
return ""
if isinstance(content, memoryview):
content = content.tobytes()
elif isinstance(content, str):
content = content.encode('utf-8', errors='ignore')
if isinstance(content, bytes):
decompressed = decompress_zstd_content(content, source_id, local_id)
if decompressed:
return decompressed.decode('utf-8', errors='ignore')
# 若不是 zstd 压缩或解压失败,尝试直接 decode
try:
return content.decode('utf-8', errors='ignore')
except Exception:
return ""
return ""
def parse_wechat_xml_to_struct(xml_str: str, source_id: str, local_id: int) -> Optional[Dict[str, Any]]:
"""解析微信服务号 XML 到 Dict"""
if not xml_str.strip():
return None
try:
root = ET.fromstring(xml_str)
def get_tag_text(element, path, default=""):
node = element.find(path)
return node.text if node is not None and node.text else default
main_cover = get_tag_text(root, ".//appmsg/thumburl")
if not main_cover:
main_cover = get_tag_text(root, ".//topnew/cover")
result = {
"title": get_tag_text(root, ".//appmsg/title"),
"des": get_tag_text(root, ".//appmsg/des"),
"url": get_tag_text(root, ".//appmsg/url"),
"cover": main_cover,
"content_list": []
}
items = root.findall(".//mmreader/category/item")
for item in items:
item_struct = {
"title": get_tag_text(item, "title"),
"url": get_tag_text(item, "url"),
"cover": get_tag_text(item, "cover"),
"summary": get_tag_text(item, "summary")
}
if item_struct["title"]:
result["content_list"].append(item_struct)
return result
except Exception as e:
error_msg = f"❌ [解析XML失败] 服务号id: {source_id}, local_id: {local_id} -> {e}"
print(error_msg)
logger.error(error_msg)
return None
def parse_pay_xml(xml_str: str, local_id: int) -> Optional[Dict[str, Any]]:
"""解析微信支付 XML"""
if not xml_str.strip():
return None
try:
root = ET.fromstring(xml_str)
def get_text(path):
node = root.find(path)
return node.text if node is not None else ""
record = {
"title": get_text(".//appmsg/title"),
"description": get_text(".//appmsg/des"),
"merchant_name": get_text(".//template_header/display_name"),
"merchant_icon": get_text(".//template_header/icon_url"),
"timestamp": int(get_text(".//pub_time") or 0),
"formatted_time": ""
}
return record
except Exception as e:
error_msg = f"❌ [解析微信支付XML失败] 支付id: gh_3dfda90e39d6, local_id: {local_id} -> {e}"
print(error_msg)
logger.error(error_msg)
return None
@router.get("/api/biz/proxy_image", summary="代理请求微信服务号图片")
def proxy_biz_image(url: str):
if not url:
return Response(status_code=400)
try:
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
})
with urllib.request.urlopen(req, timeout=10) as response:
content = response.read()
content_type = response.headers.get('Content-Type', 'image/jpeg')
return Response(content=content, media_type=content_type)
except Exception as e:
logger.error(f"[biz] 代理图片失败: {url} -> {e}")
return Response(status_code=500)
# 接口 1:获取全部的服务号/公众号的信息
@router.get("/api/biz/list", summary="获取全部服务号/公众号列表")
def get_biz_account_list(account: Optional[str] = None):
account_dir = _resolve_account_dir(account)
biz_ids = set()
biz_latest_time = {}
# 1. 遍历 biz_message_*.db
for db_file in account_dir.glob("biz_message*.db"):
try:
conn = sqlite3.connect(str(db_file))
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(Name2Id)")
cols = [row[1].lower() for row in cursor.fetchall()]
user_col = "username" if "username" in cols else "user_name" if "user_name" in cols else ""
if user_col:
rows = cursor.execute(f"SELECT {user_col} FROM Name2Id").fetchall()
for r in rows:
if r[0]:
uname = r[0]
biz_ids.add(uname)
# 顺便查询该号的最后一条消息时间
md5_id = hashlib.md5(uname.encode('utf-8')).hexdigest().lower()
table_name = f"Msg_{md5_id}"
try:
time_res = conn.execute(f"SELECT MAX(create_time) FROM {table_name}").fetchone()
if time_res and time_res[0]:
current_max = biz_latest_time.get(uname, 0)
biz_latest_time[uname] = max(current_max, time_res[0])
except Exception:
pass
conn.close()
except Exception as e:
logger.warning(f"读取 Name2Id 失败 {db_file}: {e}")
contact_db_path = account_dir / "contact.db"
contact_info = {}
if contact_db_path.exists() and biz_ids:
try:
conn = sqlite3.connect(str(contact_db_path))
cursor = conn.cursor()
placeholders = ",".join(["?"] * len(biz_ids))
# 先查 contact 表
query_contact = f"SELECT username, remark, nick_name, alias, big_head_url FROM contact WHERE username IN ({placeholders})"
rows_contact = cursor.execute(query_contact, list(biz_ids)).fetchall()
for r in rows_contact:
uname = r[0]
name = r[1] or r[2] or r[3] or uname
contact_info[uname] = {
"username": uname,
"name": name,
"avatar": r[4],
"type": 3 # 默认给个 3(未知)
}
# 再查 biz_info 表获取类型
try:
query_biz = f"SELECT username, type FROM biz_info WHERE username IN ({placeholders})"
rows_biz = cursor.execute(query_biz, list(biz_ids)).fetchall()
for r in rows_biz:
uname = r[0]
biz_type = r[1]
# 如果查到了且是 0, 1, 2,就更新进去,否则保留 3
if uname in contact_info:
if biz_type in (0, 1, 2):
contact_info[uname]["type"] = biz_type
else:
contact_info[uname]["type"] = 3
except Exception as e:
logger.warning(f"读取 biz_info 失败: {e}")
conn.close()
except Exception as e:
logger.warning(f"读取 contact.db 失败: {e}")
# 3. 组装结果(不在 contact_info 里的直接丢弃)
result = []
for uid in biz_ids:
if uid in contact_info:
info = contact_info[uid]
info["last_time"] = biz_latest_time.get(uid, 0)
if info["last_time"]:
# 格式化日期给前端展示用
info["formatted_last_time"] = time.strftime("%Y-%m-%d", time.localtime(info["last_time"]))
else:
info["formatted_last_time"] = ""
result.append(info)
# 4. 按最后一条消息的时间降序排列
result.sort(key=lambda x: x.get("last_time", 0), reverse=True)
return {"status": "success", "total": len(result), "data": result}
# 接口 2:获取普通服务号/公众号的 json 消息 (已修复表名比对 bug)
@router.get("/api/biz/messages", summary="获取指定服务号的消息")
def get_biz_messages(username: str, account: Optional[str] = None, limit: int = 50, offset: int = 0):
if username == "gh_3dfda90e39d6":
raise HTTPException(status_code=400, detail="微信支付记录请请求 /api/biz/pay_records 接口")
account_dir = _resolve_account_dir(account)
md5_id = hashlib.md5(username.encode('utf-8')).hexdigest().lower()
table_name = f"Msg_{md5_id}"
target_db = None
for db_file in account_dir.glob("biz_message*.db"):
conn = sqlite3.connect(str(db_file))
try:
# 必须用 table_name.lower(),否则永远匹配不上
res = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND lower(name)=?",
(table_name.lower(),)).fetchone()
if res:
target_db = db_file
break
except Exception:
pass
finally:
conn.close()
if not target_db:
return {"status": "success", "data": [], "message": f"未找到 {username} 的消息历史"}
# ... (后续数据库查询逻辑保持不变) ...
messages = []
try:
conn = sqlite3.connect(str(target_db))
cursor = conn.cursor()
query = f"""
SELECT local_id, create_time, message_content
FROM [{table_name}]
WHERE local_type != 1
ORDER BY create_time DESC
LIMIT ? OFFSET ?
"""
rows = cursor.execute(query, (limit, offset)).fetchall()
for local_id, c_time, content in rows:
raw_xml = extract_xml_from_db_content(content, username, local_id)
if not raw_xml:
continue
struct_data = parse_wechat_xml_to_struct(raw_xml, username, local_id)
if struct_data:
struct_data["local_id"] = local_id
struct_data["create_time"] = c_time
messages.append(struct_data)
conn.close()
except Exception as e:
logger.error(f"[biz] 数据库查询出错: {e}")
return {"status": "error", "message": str(e)}
return {"status": "success", "data": messages}
# 接口 3:返回微信支付的 json 消息 (已修复表名比对 bug)
@router.get("/api/biz/pay_records", summary="获取微信支付记录")
def get_wechat_pay_records(account: Optional[str] = None, limit: int = 50, offset: int = 0):
username = "gh_3dfda90e39d6"
account_dir = _resolve_account_dir(account)
md5_id = hashlib.md5(username.encode('utf-8')).hexdigest().lower()
table_name = f"Msg_{md5_id}"
target_db = None
for db_file in account_dir.glob("biz_message*.db"):
conn = sqlite3.connect(str(db_file))
try:
# 必须用 table_name.lower()
res = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND lower(name)=?",
(table_name.lower(),)).fetchone()
if res:
target_db = db_file
break
except Exception:
pass
finally:
conn.close()
if not target_db:
return {"status": "success", "data": [], "message": "未找到微信支付的消息历史"}
messages = []
try:
conn = sqlite3.connect(str(target_db))
cursor = conn.cursor()
query = f"""
SELECT local_id, create_time, message_content
FROM [{table_name}]
WHERE local_type = 21474836529 OR local_type != 1
ORDER BY create_time DESC
LIMIT ? OFFSET ?
"""
rows = cursor.execute(query, (limit, offset)).fetchall()
for local_id, c_time, content in rows:
raw_xml = extract_xml_from_db_content(content, username, local_id)
if not raw_xml:
continue
parsed_data = parse_pay_xml(raw_xml, local_id)
if parsed_data:
parsed_data["local_id"] = local_id
parsed_data["create_time"] = c_time
if not parsed_data["timestamp"]:
parsed_data["timestamp"] = c_time
parsed_data["formatted_time"] = time.strftime(
"%Y-%m-%d %H:%M:%S", time.localtime(parsed_data["timestamp"])
)
messages.append(parsed_data)
conn.close()
except Exception as e:
logger.error(f"[biz] 查询微信支付数据库出错: {e}")
return {"status": "error", "message": str(e)}
return {"status": "success", "data": messages}
+340 -234
View File
@@ -1391,6 +1391,299 @@ def _load_contact_top_flags(contact_db_path: Path, usernames: list[str]) -> dict
conn.close()
def _coerce_realtime_blobish_value(value: Any) -> Any:
if value is None:
return None
if isinstance(value, memoryview):
value = value.tobytes()
if isinstance(value, bytearray):
return bytes(value)
if isinstance(value, bytes):
try:
s = value.decode("ascii").strip()
except Exception:
return value
if not s:
return value
b = _hex_to_bytes(s)
if b is not None:
return b
if (len(s) % 2 == 0) and (_HEX_RE.fullmatch(s) is not None):
try:
return bytes.fromhex(s)
except Exception:
return value
return value
if isinstance(value, str):
s = value.strip()
if not s:
return value
b = _hex_to_bytes(s)
if b is not None:
return b
if (len(s) % 2 == 0) and (_HEX_RE.fullmatch(s) is not None):
try:
return bytes.fromhex(s)
except Exception:
return value
return value
return value
def _normalize_realtime_message_item(item: dict[str, Any]) -> dict[str, Any]:
def _pick(*keys: str) -> Any:
return _pick_case_insensitive_value(item, *keys)
message_content = _coerce_realtime_blobish_value(
_pick("message_content", "messageContent", "MessageContent")
)
if message_content is None:
message_content = ""
return {
"local_id": int(_pick("local_id", "localId") or 0),
"server_id": int(_pick("server_id", "serverId", "MsgSvrID") or 0),
"local_type": int(_pick("local_type", "localType", "Type", "type") or 0),
"sort_seq": int(_pick("sort_seq", "sortSeq", "SortSeq") or 0),
"real_sender_id": int(_pick("real_sender_id", "realSenderId") or 0),
"create_time": int(_pick("create_time", "createTime", "CreateTime") or 0),
"message_content": message_content,
"compress_content": _coerce_realtime_blobish_value(
_pick("compress_content", "compressContent", "CompressContent")
),
"packed_info_data": _coerce_realtime_blobish_value(
_pick("packed_info_data", "packedInfoData", "PackedInfoData")
),
"sender_username": str(
_pick("sender_username", "senderUsername", "sender", "SenderUsername") or ""
).strip(),
}
def _collect_realtime_rows_for_session(
*,
trace_id: Optional[str],
account_name: str,
rt_conn: Any,
username: str,
msg_db_path_real: Path,
table_name: str,
max_local_id: int,
max_scan: int,
backfill_limit: int,
) -> dict[str, Any]:
label = f"[{trace_id}]" if trace_id else "[realtime]"
log_fn = logger.info if trace_id else logger.debug
uname = str(username or "").strip()
use_biz_exec_query = uname.startswith("gh_") and ("biz_message" in str(msg_db_path_real.name).lower())
if use_biz_exec_query:
try:
quoted_table = _quote_ident(table_name)
select_cols = (
"local_id",
"server_id",
"local_type",
"sort_seq",
"real_sender_id",
"create_time",
"message_content",
"compress_content",
"packed_info_data",
)
select_sql = ", ".join([_quote_ident(col) for col in select_cols])
if int(max_local_id) > 0:
sql_new = (
f"SELECT {select_sql} FROM {quoted_table} "
f"WHERE local_id > {int(max_local_id)} "
f"ORDER BY local_id ASC LIMIT {int(max_scan)}"
)
else:
sql_new = f"SELECT {select_sql} FROM {quoted_table} ORDER BY local_id DESC LIMIT {int(max_scan)}"
log_fn(
"%s wcdb_exec_query biz account=%s username=%s mode=new_rows max_local_id=%s limit=%s",
label,
account_name,
uname,
int(max_local_id),
int(max_scan),
)
wcdb_t0 = time.perf_counter()
with rt_conn.lock:
raw_new_rows = _wcdb_exec_query(rt_conn.handle, kind="message", path=str(msg_db_path_real), sql=sql_new)
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
logger.info(
"%s wcdb_exec_query biz done account=%s username=%s mode=new_rows rows=%s ms=%.1f",
label,
account_name,
uname,
len(raw_new_rows or []),
wcdb_ms,
)
if wcdb_ms > 2000:
logger.warning(
"%s wcdb_exec_query biz slow account=%s username=%s mode=new_rows ms=%.1f",
label,
account_name,
uname,
wcdb_ms,
)
normalized_new_rows: list[dict[str, Any]] = []
for item in raw_new_rows or []:
if not isinstance(item, dict):
continue
norm = _normalize_realtime_message_item(item)
if int(norm.get("local_id") or 0) <= 0:
continue
normalized_new_rows.append(norm)
if int(max_local_id) > 0:
new_rows = list(reversed(normalized_new_rows))
else:
new_rows = normalized_new_rows
backfill_rows: list[dict[str, Any]] = []
scanned = len(raw_new_rows or [])
if int(backfill_limit) > 0 and int(max_local_id) > 0:
sql_backfill = (
f"SELECT {select_sql} FROM {quoted_table} "
f"WHERE local_id <= {int(max_local_id)} "
f"ORDER BY local_id DESC LIMIT {int(backfill_limit)}"
)
log_fn(
"%s wcdb_exec_query biz account=%s username=%s mode=backfill limit=%s",
label,
account_name,
uname,
int(backfill_limit),
)
backfill_t0 = time.perf_counter()
with rt_conn.lock:
raw_backfill_rows = _wcdb_exec_query(
rt_conn.handle,
kind="message",
path=str(msg_db_path_real),
sql=sql_backfill,
)
backfill_ms = (time.perf_counter() - backfill_t0) * 1000.0
logger.info(
"%s wcdb_exec_query biz done account=%s username=%s mode=backfill rows=%s ms=%.1f",
label,
account_name,
uname,
len(raw_backfill_rows or []),
backfill_ms,
)
if backfill_ms > 2000:
logger.warning(
"%s wcdb_exec_query biz slow account=%s username=%s mode=backfill ms=%.1f",
label,
account_name,
uname,
backfill_ms,
)
scanned += len(raw_backfill_rows or [])
for item in raw_backfill_rows or []:
if not isinstance(item, dict):
continue
norm = _normalize_realtime_message_item(item)
if int(norm.get("local_id") or 0) <= 0:
continue
backfill_rows.append(norm)
return {
"fetchMode": "biz_exec_query",
"scanned": int(scanned),
"new_rows": new_rows,
"backfill_rows": backfill_rows,
}
except Exception as e:
logger.warning(
"%s wcdb_exec_query biz failed account=%s username=%s err=%s fallback=wcdb_get_messages",
label,
account_name,
uname,
str(e),
)
batch_size = 200
scanned = 0
offset = 0
new_rows: list[dict[str, Any]] = []
backfill_rows: list[dict[str, Any]] = []
reached_existing = False
stop = False
while scanned < int(max_scan):
take = min(batch_size, int(max_scan) - scanned)
log_fn(
"%s wcdb_get_messages account=%s username=%s take=%s offset=%s",
label,
account_name,
uname,
int(take),
int(offset),
)
wcdb_t0 = time.perf_counter()
with rt_conn.lock:
raw_rows = _wcdb_get_messages(rt_conn.handle, uname, limit=take, offset=offset)
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
log_fn(
"%s wcdb_get_messages done account=%s username=%s rows=%s ms=%.1f",
label,
account_name,
uname,
len(raw_rows or []),
wcdb_ms,
)
if wcdb_ms > 2000:
logger.warning(
"%s wcdb_get_messages slow account=%s username=%s ms=%.1f",
label,
account_name,
uname,
wcdb_ms,
)
if not raw_rows:
break
scanned += len(raw_rows)
offset += len(raw_rows)
for item in raw_rows:
if not isinstance(item, dict):
continue
norm = _normalize_realtime_message_item(item)
lid = int(norm.get("local_id") or 0)
if lid <= 0:
continue
if (not reached_existing) and lid > int(max_local_id):
new_rows.append(norm)
continue
reached_existing = True
if int(backfill_limit) <= 0:
stop = True
break
backfill_rows.append(norm)
if len(backfill_rows) >= int(backfill_limit):
stop = True
break
if stop or len(raw_rows) < take:
break
return {
"fetchMode": "wcdb_get_messages",
"scanned": int(scanned),
"new_rows": new_rows,
"backfill_rows": backfill_rows,
}
@router.post("/api/chat/realtime/sync", summary="实时消息同步到解密库(按会话增量)")
def sync_chat_realtime_messages(
request: Request,
@@ -1511,127 +1804,30 @@ def sync_chat_realtime_messages(
placeholders = ",".join(["?"] * len(insert_cols))
insert_sql = f"INSERT OR IGNORE INTO {quoted_table} ({','.join(insert_cols)}) VALUES ({placeholders})"
def pick(item: dict[str, Any], *keys: str) -> Any:
for k in keys:
if k in item and item[k] is not None:
return item[k]
lk = k.lower()
for kk in item.keys():
if str(kk).lower() == lk and item[kk] is not None:
return item[kk]
return None
def normalize_blob(value: Any) -> Optional[bytes]:
if value is None:
return None
if isinstance(value, memoryview):
return value.tobytes()
if isinstance(value, (bytes, bytearray)):
return bytes(value)
if isinstance(value, str):
s = value.strip()
if s.lower().startswith("0x"):
s = s[2:]
if s and re.fullmatch(r"[0-9a-fA-F]+", s) and (len(s) % 2 == 0):
try:
return bytes.fromhex(s)
except Exception:
return None
return s.encode("utf-8", errors="ignore")
return None
def normalize(item: dict[str, Any]) -> dict[str, Any]:
return {
"local_id": int(pick(item, "local_id", "localId") or 0),
"server_id": int(pick(item, "server_id", "serverId", "MsgSvrID") or 0),
"local_type": int(pick(item, "local_type", "localType", "Type", "type") or 0),
"sort_seq": int(pick(item, "sort_seq", "sortSeq", "SortSeq") or 0),
"real_sender_id": int(pick(item, "real_sender_id", "realSenderId") or 0),
"create_time": int(pick(item, "create_time", "createTime", "CreateTime") or 0),
"message_content": pick(item, "message_content", "messageContent", "MessageContent") or "",
"compress_content": pick(item, "compress_content", "compressContent", "CompressContent"),
"packed_info_data": normalize_blob(pick(item, "packed_info_data", "packedInfoData")),
"sender_username": str(
pick(item, "sender_username", "senderUsername", "sender", "SenderUsername") or ""
).strip(),
}
batch_size = 200
scanned = 0
offset = 0
new_rows: list[dict[str, Any]] = []
backfill_rows: list[dict[str, Any]] = []
reached_existing = False
stop = False
while scanned < int(max_scan):
take = min(batch_size, int(max_scan) - scanned)
logger.info(
"[%s] wcdb_get_messages account=%s username=%s take=%s offset=%s",
trace_id,
account_dir.name,
username,
int(take),
int(offset),
)
wcdb_t0 = time.perf_counter()
with rt_conn.lock:
raw_rows = _wcdb_get_messages(rt_conn.handle, username, limit=take, offset=offset)
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
logger.info(
"[%s] wcdb_get_messages done account=%s username=%s rows=%s ms=%.1f",
trace_id,
account_dir.name,
username,
len(raw_rows or []),
wcdb_ms,
)
if wcdb_ms > 2000:
logger.warning(
"[%s] wcdb_get_messages slow account=%s username=%s ms=%.1f",
trace_id,
account_dir.name,
username,
wcdb_ms,
)
if not raw_rows:
break
scanned += len(raw_rows)
offset += len(raw_rows)
for item in raw_rows:
if not isinstance(item, dict):
continue
norm = normalize(item)
lid = int(norm.get("local_id") or 0)
if lid <= 0:
continue
if (not reached_existing) and lid > max_local_id:
new_rows.append(norm)
continue
reached_existing = True
if int(backfill_limit) <= 0:
stop = True
break
backfill_rows.append(norm)
if len(backfill_rows) >= int(backfill_limit):
stop = True
break
if stop or len(raw_rows) < take:
break
fetch_result = _collect_realtime_rows_for_session(
trace_id=trace_id,
account_name=account_dir.name,
rt_conn=rt_conn,
username=username,
msg_db_path_real=msg_db_path_real,
table_name=table_name,
max_local_id=max_local_id,
max_scan=int(max_scan),
backfill_limit=int(backfill_limit),
)
scanned = int(fetch_result.get("scanned") or 0)
new_rows = list(fetch_result.get("new_rows") or [])
backfill_rows = list(fetch_result.get("backfill_rows") or [])
inserted = 0
backfilled = 0
if new_rows and (not name2id_synced):
_best_effort_upsert_output_name2id_rows(
msg_conn,
account_name=account_dir.name,
rows=new_rows,
)
if new_rows:
if not name2id_synced:
_best_effort_upsert_output_name2id_rows(
msg_conn,
account_name=account_dir.name,
rows=new_rows,
)
# Insert older -> newer to keep sqlite btree locality similar to existing data.
values = [tuple(r.get(c) for c in insert_cols) for r in reversed(new_rows)]
@@ -1879,124 +2075,30 @@ def _sync_chat_realtime_messages_for_table(
placeholders = ",".join(["?"] * len(insert_cols))
insert_sql = f"INSERT OR IGNORE INTO {quoted_table} ({','.join(insert_cols)}) VALUES ({placeholders})"
def pick(item: dict[str, Any], *keys: str) -> Any:
for k in keys:
if k in item and item[k] is not None:
return item[k]
lk = k.lower()
for kk in item.keys():
if str(kk).lower() == lk and item[kk] is not None:
return item[kk]
return None
def normalize_blob(value: Any) -> Optional[bytes]:
if value is None:
return None
if isinstance(value, memoryview):
return value.tobytes()
if isinstance(value, (bytes, bytearray)):
return bytes(value)
if isinstance(value, str):
s = value.strip()
if s.lower().startswith("0x"):
s = s[2:]
if s and re.fullmatch(r"[0-9a-fA-F]+", s) and (len(s) % 2 == 0):
try:
return bytes.fromhex(s)
except Exception:
return None
return s.encode("utf-8", errors="ignore")
return None
def normalize(item: dict[str, Any]) -> dict[str, Any]:
return {
"local_id": int(pick(item, "local_id", "localId") or 0),
"server_id": int(pick(item, "server_id", "serverId", "MsgSvrID") or 0),
"local_type": int(pick(item, "local_type", "localType", "Type", "type") or 0),
"sort_seq": int(pick(item, "sort_seq", "sortSeq", "SortSeq") or 0),
"real_sender_id": int(pick(item, "real_sender_id", "realSenderId") or 0),
"create_time": int(pick(item, "create_time", "createTime", "CreateTime") or 0),
"message_content": pick(item, "message_content", "messageContent", "MessageContent") or "",
"compress_content": pick(item, "compress_content", "compressContent", "CompressContent"),
"packed_info_data": normalize_blob(pick(item, "packed_info_data", "packedInfoData")),
"sender_username": str(
pick(item, "sender_username", "senderUsername", "sender", "SenderUsername") or ""
).strip(),
}
batch_size = 200
scanned = 0
offset = 0
new_rows: list[dict[str, Any]] = []
backfill_rows: list[dict[str, Any]] = []
reached_existing = False
stop = False
while scanned < int(max_scan):
take = min(batch_size, int(max_scan) - scanned)
logger.debug(
"[realtime] wcdb_get_messages account=%s username=%s take=%s offset=%s",
account_dir.name,
username,
int(take),
int(offset),
)
wcdb_t0 = time.perf_counter()
with rt_conn.lock:
raw_rows = _wcdb_get_messages(rt_conn.handle, username, limit=take, offset=offset)
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
logger.debug(
"[realtime] wcdb_get_messages done account=%s username=%s rows=%s ms=%.1f",
account_dir.name,
username,
len(raw_rows or []),
wcdb_ms,
)
if wcdb_ms > 2000:
logger.warning(
"[realtime] wcdb_get_messages slow account=%s username=%s ms=%.1f",
account_dir.name,
username,
wcdb_ms,
)
if not raw_rows:
break
scanned += len(raw_rows)
offset += len(raw_rows)
for item in raw_rows:
if not isinstance(item, dict):
continue
norm = normalize(item)
lid = int(norm.get("local_id") or 0)
if lid <= 0:
continue
if (not reached_existing) and lid > max_local_id:
new_rows.append(norm)
continue
reached_existing = True
if int(backfill_limit) <= 0:
stop = True
break
backfill_rows.append(norm)
if len(backfill_rows) >= int(backfill_limit):
stop = True
break
if stop or len(raw_rows) < take:
break
fetch_result = _collect_realtime_rows_for_session(
trace_id=None,
account_name=account_dir.name,
rt_conn=rt_conn,
username=username,
msg_db_path_real=msg_db_path_real,
table_name=table_name,
max_local_id=max_local_id,
max_scan=int(max_scan),
backfill_limit=int(backfill_limit),
)
scanned = int(fetch_result.get("scanned") or 0)
new_rows = list(fetch_result.get("new_rows") or [])
backfill_rows = list(fetch_result.get("backfill_rows") or [])
inserted = 0
backfilled = 0
if new_rows and (not name2id_synced):
_best_effort_upsert_output_name2id_rows(
msg_conn,
account_name=account_dir.name,
rows=new_rows,
)
if new_rows:
if not name2id_synced:
_best_effort_upsert_output_name2id_rows(
msg_conn,
account_name=account_dir.name,
rows=new_rows,
)
values = [tuple(r.get(c) for c in insert_cols) for r in reversed(new_rows)]
insert_t0 = time.perf_counter()
@@ -2161,6 +2263,7 @@ def sync_chat_realtime_messages_all(
priority_max_scan: int = 600,
include_hidden: bool = True,
include_official: bool = True,
only_official: bool = False,
backfill_limit: int = 200,
):
"""
@@ -2171,13 +2274,14 @@ def sync_chat_realtime_messages_all(
account_dir = _resolve_account_dir(account)
trace_id = f"rt-syncall-{int(time.time() * 1000)}-{threading.get_ident()}"
logger.info(
"[%s] realtime sync_all start account=%s max_scan=%s priority=%s include_hidden=%s include_official=%s",
"[%s] realtime sync_all start account=%s max_scan=%s priority=%s include_hidden=%s include_official=%s only_official=%s",
trace_id,
account_dir.name,
int(max_scan),
str(priority_username or "").strip(),
bool(include_hidden),
bool(include_official),
bool(only_official),
)
if max_scan < 20:
@@ -2239,6 +2343,8 @@ def sync_chat_realtime_messages_all(
hidden_val = 0
if not include_hidden and hidden_val == 1:
continue
if only_official and not uname.startswith("gh_"):
continue
if not _should_keep_session(uname, include_official=include_official):
continue
+3 -27
View File
@@ -14,12 +14,7 @@ from ..app_paths import get_output_databases_dir
from ..logging_config import get_logger
from ..path_fix import PathFixRoute
from ..key_store import upsert_account_keys_in_store
from ..wechat_decrypt import (
WeChatDatabaseDecryptor,
build_decrypt_result_message,
decrypt_wechat_databases,
scan_account_databases_from_path,
)
from ..wechat_decrypt import WeChatDatabaseDecryptor, decrypt_wechat_databases, scan_account_databases_from_path
logger = get_logger(__name__)
@@ -81,7 +76,6 @@ async def decrypt_databases(request: DecryptRequest):
"message": results["message"],
"processed_files": results["processed_files"],
"failed_files": results["failed_files"],
"failure_details": results.get("failure_details", []),
"account_results": results.get("account_results", {}),
}
@@ -165,7 +159,6 @@ async def decrypt_databases_stream(
fail_count = 0
processed_files: list[str] = []
failed_files: list[str] = []
failure_details: list[dict] = []
account_results: dict = {}
overall_current = 0
@@ -188,7 +181,6 @@ async def decrypt_databases_stream(
account_success = 0
account_processed: list[str] = []
account_failed: list[str] = []
account_failure_details: list[dict] = []
for db_info in dbs:
if await request.is_disconnected():
@@ -240,20 +232,11 @@ async def decrypt_databases_stream(
status = "success"
msg = "解密成功"
else:
failure_detail = {
"account": account,
"file": db_path,
"name": db_name,
"code": str(decryptor.last_error_code or "").strip(),
"reason": str(decryptor.last_error_message or "").strip() or "解密失败",
}
account_failed.append(db_path)
account_failure_details.append(failure_detail)
failed_files.append(db_path)
failure_details.append(failure_detail)
fail_count += 1
status = "fail"
msg = failure_detail["reason"]
msg = "解密失败"
yield _sse(
{
@@ -278,7 +261,6 @@ async def decrypt_databases_stream(
"output_dir": str(account_output_dir),
"processed_files": account_processed,
"failed_files": account_failed,
"failure_details": account_failure_details,
}
# Build cache table (keep behavior consistent with the POST endpoint).
@@ -325,15 +307,9 @@ async def decrypt_databases_stream(
"success_count": success_count,
"failure_count": total_databases - success_count,
"output_directory": str(base_output_dir.absolute()),
"message": build_decrypt_result_message(
total_databases=total_databases,
success_count=success_count,
failed_count=total_databases - success_count,
failure_details=failure_details,
),
"message": f"解密完成: 成功 {success_count}/{total_databases}",
"processed_files": processed_files,
"failed_files": failed_files,
"failure_details": failure_details,
"account_results": account_results,
}
+34
View File
@@ -26,6 +26,32 @@ _DEFAULT_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
_WCDB_API_DLL_SELECTED: Optional[Path] = None
def _iter_runtime_wcdb_api_dll_paths() -> tuple[Path, ...]:
candidates: list[Path] = []
seen: set[str] = set()
def add_anchor(anchor: str | Path | None) -> None:
if not anchor:
return
try:
base = Path(anchor).resolve()
except Exception:
base = Path(anchor)
candidate = base / "native" / "wcdb_api.dll"
key = str(candidate).replace("/", "\\").rstrip("\\").lower()
if key in seen:
return
seen.add(key)
candidates.append(candidate)
add_anchor(os.environ.get("WECHAT_TOOL_DATA_DIR", "").strip())
add_anchor(Path.cwd())
if getattr(sys, "frozen", False):
add_anchor(Path(sys.executable).resolve().parent)
return tuple(candidates)
def _is_project_wcdb_api_dll_path(path: Path) -> bool:
try:
resolved = path.resolve(strict=False)
@@ -40,6 +66,14 @@ def _is_project_wcdb_api_dll_path(path: Path) -> bool:
if resolved == default_resolved:
return True
for candidate in _iter_runtime_wcdb_api_dll_paths():
try:
if resolved == candidate.resolve(strict=False):
return True
except Exception:
if resolved == candidate:
return True
parts = tuple(str(part).lower() for part in resolved.parts)
allowed_suffixes = (
("backend", "native", "wcdb_api.dll"),
+130 -232
View File
@@ -13,12 +13,12 @@ import hashlib
import hmac
import os
import json
import shutil
import tempfile
from pathlib import Path
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from .app_paths import get_output_databases_dir
@@ -26,117 +26,6 @@ from .app_paths import get_output_databases_dir
# SQLite文件头
SQLITE_HEADER = b"SQLite format 3\x00"
PAGE_SIZE = 4096
KEY_SIZE = 32
SALT_SIZE = 16
IV_SIZE = 16
HMAC_SIZE = 64
RESERVE_SIZE = 80
KEY_MISMATCH_GUIDANCE = (
"请在当前设备登录该账号后重新获取密钥;"
"如果聊天记录是从另一台设备复制过来的,当前设备通常无法获取原设备对应的密钥。"
)
def _derive_mac_key(raw_key: bytes, salt: bytes) -> bytes:
mac_salt = bytes(b ^ 0x3A for b in salt)
return hashlib.pbkdf2_hmac("sha512", raw_key, mac_salt, 2, dklen=KEY_SIZE)
def _derive_sqlcipher_enc_key(key_material: bytes, salt: bytes) -> bytes:
return hashlib.pbkdf2_hmac("sha512", key_material, salt, 256000, dklen=KEY_SIZE)
def _resolve_page1_key_material(key_material: bytes, page1: bytes) -> tuple[bytes, bytes, str] | None:
salt = page1[:SALT_SIZE]
stored_page1_hmac = page1[PAGE_SIZE - HMAC_SIZE : PAGE_SIZE]
candidates = [
("raw_enc_key", key_material, _derive_mac_key(key_material, salt)),
]
derived_key = _derive_sqlcipher_enc_key(key_material, salt)
candidates.append(("sqlcipher_passphrase", derived_key, _derive_mac_key(derived_key, salt)))
for mode, enc_key, mac_key in candidates:
expected_page1_hmac = _compute_page_hmac(mac_key, page1, 1)
if stored_page1_hmac == expected_page1_hmac:
return enc_key, mac_key, mode
return None
def _compute_page_hmac(mac_key: bytes, page: bytes, page_num: int) -> bytes:
offset = SALT_SIZE if page_num == 1 else 0
data_end = PAGE_SIZE - RESERVE_SIZE + IV_SIZE
mac = hmac.new(mac_key, digestmod=hashlib.sha512)
mac.update(page[offset:data_end])
mac.update(page_num.to_bytes(4, "little"))
return mac.digest()
def _decrypt_page(raw_key: bytes, page: bytes, page_num: int) -> bytes:
iv = page[PAGE_SIZE - RESERVE_SIZE : PAGE_SIZE - RESERVE_SIZE + IV_SIZE]
offset = SALT_SIZE if page_num == 1 else 0
encrypted = page[offset : PAGE_SIZE - RESERVE_SIZE]
cipher = Cipher(
algorithms.AES(raw_key),
modes.CBC(iv),
backend=default_backend(),
)
decryptor = cipher.decryptor()
decrypted = decryptor.update(encrypted) + decryptor.finalize()
if page_num == 1:
return SQLITE_HEADER + decrypted + (b"\x00" * RESERVE_SIZE)
return decrypted + (b"\x00" * RESERVE_SIZE)
def _failure_matches_key_mismatch(detail: dict | None) -> bool:
if not isinstance(detail, dict):
return False
code = str(detail.get("code") or "").strip().lower()
reason = str(detail.get("reason") or "").strip()
if code == "key_mismatch":
return True
return ("密钥" in reason and "不匹配" in reason) or ("当前数据库密钥不正确" in reason)
def build_decrypt_result_message(
total_databases: int,
success_count: int,
failed_count: int,
failure_details: list[dict] | None = None,
) -> str:
total = max(int(total_databases or 0), 0)
success = max(int(success_count or 0), 0)
failed = max(int(failed_count or 0), 0)
details = list(failure_details or [])
if total == 0:
return "未找到可解密的数据库文件"
if failed == 0:
return f"解密完成: 成功 {success}/{total}"
key_mismatch_count = sum(1 for item in details if _failure_matches_key_mismatch(item))
if success == 0 and failed == total:
if key_mismatch_count == failed:
return (
f"解密失败:当前数据库密钥不正确,或该密钥不属于当前账号/当前设备(0/{total} 成功)。"
+ KEY_MISMATCH_GUIDANCE
)
return f"解密失败:0/{total} 个数据库解密成功,请检查密钥、账号与数据库路径是否匹配。"
if key_mismatch_count > 0:
return (
f"解密完成:成功 {success}/{total},失败 {failed}/{total}"
"失败文件中包含密钥不匹配的数据库,请确认使用的是当前账号在当前设备上的密钥。"
)
return f"解密完成:成功 {success}/{total},失败 {failed}/{total}"
def _normalize_account_name(name: str) -> str:
@@ -332,123 +221,153 @@ class WeChatDatabaseDecryptor:
self.key_bytes = bytes.fromhex(key_hex)
except ValueError:
raise ValueError("密钥必须是有效的十六进制字符串")
self.last_error_code = ""
self.last_error_message = ""
def _set_last_error(self, code: str, message: str) -> None:
self.last_error_code = str(code or "").strip()
self.last_error_message = str(message or "").strip()
def _clear_last_error(self) -> None:
self.last_error_code = ""
self.last_error_message = ""
def decrypt_database(self, db_path: str, output_path: str) -> bool:
"""解密微信4.x版本数据库
兼容两种输入形态
- raw enc_key部分内存扫描/工具直接返回
- SQLCipher 口令/基础 key需先用数据库 salt 做一轮 PBKDF2
使用SQLCipher 4.0参数:
- PBKDF2-SHA512, 256000轮迭代
- AES-256-CBC加密
- HMAC-SHA512验证
- 页面大小4096字节
"""
from .logging_config import get_logger
logger = get_logger(__name__)
logger.info(f"开始解密数据库: {db_path}")
tmp_output_path = ""
self._clear_last_error()
try:
file_size = os.path.getsize(db_path)
logger.info(f"读取文件大小: {file_size} bytes")
with open(db_path, 'rb') as f:
encrypted_data = f.read()
logger.info(f"读取文件大小: {len(encrypted_data)} bytes")
if file_size < PAGE_SIZE:
message = f"数据库文件小,无法解密: {db_path}"
self._set_last_error("file_too_small", message)
logger.warning(message)
return False
output_dir = Path(output_path).parent
output_dir.mkdir(parents=True, exist_ok=True)
with open(db_path, "rb") as source:
page1 = source.read(PAGE_SIZE)
if len(page1) < PAGE_SIZE:
message = f"数据库首页大小不足,无法解密: {db_path}"
self._set_last_error("page_too_small", message)
logger.warning(message)
if len(encrypted_data) < 4096:
logger.warning(f"文件小,跳过解密: {db_path}")
return False
# 检查是否已经是解密的数据库
if page1.startswith(SQLITE_HEADER):
if encrypted_data.startswith(SQLITE_HEADER):
logger.info(f"文件已是SQLite格式,直接复制: {db_path}")
fd, tmp_output_path = tempfile.mkstemp(
prefix=f".{Path(output_path).name}.",
suffix=".tmp",
dir=str(output_dir),
)
os.close(fd)
with open(db_path, "rb") as src, open(tmp_output_path, "wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
os.replace(tmp_output_path, output_path)
tmp_output_path = ""
with open(output_path, 'wb') as f:
f.write(encrypted_data)
return True
resolved_key_material = _resolve_page1_key_material(self.key_bytes, page1)
if resolved_key_material is None:
message = f"当前数据库密钥不正确,或该密钥不属于当前账号/当前设备: {db_path}"
self._set_last_error("key_mismatch", message)
logger.error(f"页面 1 HMAC验证失败,密钥与数据库不匹配: {db_path}")
return False
enc_key, mac_key, key_mode = resolved_key_material
logger.info(f"页面 1 HMAC验证通过: mode={key_mode} path={db_path}")
total_pages = (file_size + PAGE_SIZE - 1) // PAGE_SIZE
successful_pages = 0
fd, tmp_output_path = tempfile.mkstemp(
prefix=f".{Path(output_path).name}.",
suffix=".tmp",
dir=str(output_dir),
# 提取salt (前16字节)
salt = encrypted_data[:16]
# 计算mac_salt (salt XOR 0x3a)
mac_salt = bytes(b ^ 0x3a for b in salt)
# 使用PBKDF2-SHA512派生密钥
kdf = PBKDF2HMAC(
algorithm=hashes.SHA512(),
length=32,
salt=salt,
iterations=256000,
backend=default_backend()
)
os.close(fd)
with open(db_path, "rb") as source, open(tmp_output_path, "wb") as target:
for page_num in range(1, total_pages + 1):
page = source.read(PAGE_SIZE)
if not page:
break
if len(page) < PAGE_SIZE:
logger.warning(f"页面 {page_num} 大小不足: {len(page)} bytes,自动补齐到 {PAGE_SIZE} bytes")
page = page + (b"\x00" * (PAGE_SIZE - len(page)))
stored_hmac = page[PAGE_SIZE - HMAC_SIZE : PAGE_SIZE]
expected_hmac = _compute_page_hmac(mac_key, page, page_num)
if stored_hmac != expected_hmac:
message = f"数据库校验失败,文件可能损坏或密钥不匹配: {db_path}"
self._set_last_error("page_hmac_mismatch", message)
logger.error(f"页面 {page_num} HMAC验证失败,终止解密: {db_path}")
return False
target.write(_decrypt_page(enc_key, page, page_num))
derived_key = kdf.derive(self.key_bytes)
# 派生MAC密钥
mac_kdf = PBKDF2HMAC(
algorithm=hashes.SHA512(),
length=32,
salt=mac_salt,
iterations=2,
backend=default_backend()
)
mac_key = mac_kdf.derive(derived_key)
# 解密数据
decrypted_data = bytearray()
decrypted_data.extend(SQLITE_HEADER)
page_size = 4096
iv_size = 16
hmac_size = 64 # SHA512的HMAC是64字节
# 计算保留区域大小 (对齐到AES块大小)
reserve_size = iv_size + hmac_size
if reserve_size % 16 != 0:
reserve_size = ((reserve_size // 16) + 1) * 16
total_pages = len(encrypted_data) // page_size
successful_pages = 0
failed_pages = 0
# 逐页解密
for cur_page in range(total_pages):
start = cur_page * page_size
end = start + page_size
page = encrypted_data[start:end]
page_num = cur_page + 1 # 页面编号从1开始
if len(page) < page_size:
logger.warning(f"页面 {page_num} 大小不足: {len(page)} bytes")
break
# 确定偏移量:第一页(cur_page == 0)需要跳过salt
offset = 16 if cur_page == 0 else 0 # SALT_SIZE = 16
# 提取存储的HMAC
hmac_start = page_size - reserve_size + iv_size
hmac_end = hmac_start + hmac_size
stored_hmac = page[hmac_start:hmac_end]
# 按照wechat-dump-rs的方式验证HMAC
data_end = page_size - reserve_size + iv_size
hmac_data = page[offset:data_end]
# 分步计算HMAC:先更新数据,再更新页面编号
mac = hmac.new(mac_key, digestmod=hashlib.sha512)
mac.update(hmac_data) # 包含加密数据+IV
mac.update(page_num.to_bytes(4, 'little')) # 页面编号(小端序)
expected_hmac = mac.digest()
if stored_hmac != expected_hmac:
logger.warning(f"页面 {page_num} HMAC验证失败")
failed_pages += 1
continue
# 提取IV和加密数据用于AES解密
iv = page[page_size - reserve_size:page_size - reserve_size + iv_size]
encrypted_page = page[offset:page_size - reserve_size]
# AES-CBC解密
try:
cipher = Cipher(
algorithms.AES(derived_key),
modes.CBC(iv),
backend=default_backend()
)
decryptor = cipher.decryptor()
decrypted_page = decryptor.update(encrypted_page) + decryptor.finalize()
# 按照wechat-dump-rs的方式重组页面数据
decrypted_data.extend(decrypted_page)
decrypted_data.extend(page[page_size - reserve_size:]) # 保留区域
successful_pages += 1
except Exception as e:
logger.error(f"页面 {page_num} AES解密失败: {e}")
failed_pages += 1
continue
logger.info(f"解密完成: 成功 {successful_pages} 页, 失败 0")
os.replace(tmp_output_path, output_path)
tmp_output_path = ""
logger.info(f"解密文件大小: {os.path.getsize(output_path)} bytes")
self._clear_last_error()
logger.info(f"解密完成: 成功 {successful_pages} 页, 失败 {failed_pages}")
# 写入解密后的文件
with open(output_path, 'wb') as f:
f.write(decrypted_data)
logger.info(f"解密文件大小: {len(decrypted_data)} bytes")
return True
except Exception as e:
self._set_last_error("exception", f"解密过程中发生异常: {e}")
logger.error(f"解密失败: {db_path}, 错误: {e}")
return False
finally:
if tmp_output_path:
try:
os.remove(tmp_output_path)
except OSError:
pass
def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> dict:
"""
@@ -573,7 +492,6 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
success_count = 0
processed_files = []
failed_files = []
failure_details = []
account_results = {}
for account_name, databases in account_databases.items():
@@ -605,7 +523,6 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
account_success = 0
account_processed = []
account_failed = []
account_failure_details = []
for db_info in databases:
db_path = db_info['path']
@@ -625,16 +542,7 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
else:
account_failed.append(db_path)
failed_files.append(db_path)
failure_detail = {
"account": account_name,
"file": db_path,
"name": db_name,
"code": str(decryptor.last_error_code or "").strip(),
"reason": str(decryptor.last_error_message or "").strip() or "解密失败",
}
account_failure_details.append(failure_detail)
failure_details.append(failure_detail)
logger.error(f"解密失败: {account_name}/{db_name} reason={failure_detail['reason']}")
logger.error(f"解密失败: {account_name}/{db_name}")
# 记录账号解密结果
account_results[account_name] = {
@@ -643,8 +551,7 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
"failed": len(databases) - account_success,
"output_dir": str(account_output_dir),
"processed_files": account_processed,
"failed_files": account_failed,
"failure_details": account_failure_details,
"failed_files": account_failed
}
# 构建“会话最后一条消息”缓存表:把耗时挪到解密阶段,后续会话列表直接查表
@@ -668,23 +575,15 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
logger.info(f"账号 {account_name} 解密完成: 成功 {account_success}/{len(databases)}")
# 返回结果
failed_count = total_databases - success_count
message = build_decrypt_result_message(
total_databases=total_databases,
success_count=success_count,
failed_count=failed_count,
failure_details=failure_details,
)
result = {
"status": "success" if success_count > 0 else "error",
"message": message,
"message": f"解密完成: 成功 {success_count}/{total_databases}",
"total_databases": total_databases,
"successful_count": success_count,
"failed_count": failed_count,
"failed_count": total_databases - success_count,
"output_directory": str(base_output_dir.absolute()),
"processed_files": processed_files,
"failed_files": failed_files,
"failure_details": failure_details,
"account_results": account_results, # 新增:按账号的详细结果
"detected_accounts": detected_accounts,
}
@@ -692,9 +591,8 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
logger.info("=" * 60)
logger.info("解密任务完成!")
logger.info(f"成功: {success_count}/{total_databases}")
logger.info(f"失败: {failed_count}/{total_databases}")
logger.info(f"失败: {total_databases - success_count}/{total_databases}")
logger.info(f"输出目录: {base_output_dir.absolute()}")
logger.info(f"结果说明: {message}")
logger.info("=" * 60)
return result
+119
View File
@@ -112,6 +112,125 @@ class TestChatRealtimeName2IdSync(unittest.TestCase):
],
)
def test_sync_still_inserts_new_messages_when_name2id_is_up_to_date(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
username = "wxid_friend"
table_name = f"Msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
msg_db_path = account_dir / "message_0.db"
conn = sqlite3.connect(str(msg_db_path))
try:
conn.execute("CREATE TABLE Name2Id (user_name TEXT, is_session INTEGER DEFAULT 1)")
conn.execute(
"""
CREATE TABLE "{table_name}" (
local_id INTEGER PRIMARY KEY,
server_id INTEGER,
local_type INTEGER,
sort_seq INTEGER,
real_sender_id INTEGER,
create_time INTEGER,
message_content TEXT,
compress_content BLOB,
packed_info_data BLOB
)
""".format(table_name=table_name)
)
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (1, ?, 1)", ("acc",))
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (2, ?, 1)", (username,))
conn.execute(
f'INSERT INTO "{table_name}" '
"(local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content, packed_info_data) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(10, 10010, 1, 10, 2, 1710000010, "old", None, None),
)
conn.commit()
finally:
conn.close()
session_conn = sqlite3.connect(str(account_dir / "session.db"))
try:
session_conn.execute(
"""
CREATE TABLE SessionTable (
username TEXT PRIMARY KEY,
summary TEXT DEFAULT '',
last_timestamp INTEGER DEFAULT 0,
sort_timestamp INTEGER DEFAULT 0,
last_msg_locald_id INTEGER DEFAULT 0,
last_msg_type INTEGER DEFAULT 0,
last_msg_sub_type INTEGER DEFAULT 0,
last_msg_sender TEXT DEFAULT ''
)
"""
)
session_conn.commit()
finally:
session_conn.close()
def _fake_exec_query(_handle, *, kind, path, sql):
self.assertEqual(kind, "message")
self.assertTrue(str(path).endswith("message_0.db"))
if "COUNT(1)" in sql:
return [{"c": 2, "mx": 2}]
raise AssertionError(f"Unexpected SQL: {sql}")
live_messages = [
{
"local_id": 11,
"server_id": 10011,
"local_type": 1,
"sort_seq": 11,
"real_sender_id": 2,
"create_time": 1710000011,
"message_content": "new message",
"compress_content": None,
"sender_username": username,
}
]
with (
patch.object(
chat_router,
"_resolve_db_storage_message_paths",
return_value=(Path(td) / "live_message_0.db", Path(td) / "message_resource.db"),
),
patch.object(chat_router, "_wcdb_exec_query", side_effect=_fake_exec_query),
patch.object(chat_router, "_wcdb_get_messages", side_effect=[list(live_messages)]),
patch.object(chat_router, "_best_effort_upsert_output_name2id_rows") as mock_upsert_name2id,
):
result = chat_router._sync_chat_realtime_messages_for_table(
account_dir=account_dir,
rt_conn=_DummyConn(),
username=username,
msg_db_path=msg_db_path,
table_name=table_name,
max_scan=50,
backfill_limit=0,
)
self.assertEqual(result.get("inserted"), 1)
mock_upsert_name2id.assert_not_called()
conn = sqlite3.connect(str(msg_db_path))
try:
rows = conn.execute(
f'SELECT local_id, server_id, real_sender_id, create_time, message_content FROM "{table_name}" ORDER BY local_id ASC'
).fetchall()
finally:
conn.close()
self.assertEqual(
rows,
[
(10, 10010, 2, 1710000010, "old"),
(11, 10011, 2, 1710000011, "new message"),
],
)
if __name__ == "__main__":
unittest.main()
-100
View File
@@ -3,44 +3,14 @@ import os
import sys
import unittest
import importlib
import hashlib
import hmac
from pathlib import Path
from tempfile import TemporaryDirectory
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
def _encrypt_page(raw_key: bytes, plain_page: bytes, page_num: int, salt: bytes, iv: bytes) -> bytes:
from wechat_decrypt_tool.wechat_decrypt import PAGE_SIZE, RESERVE_SIZE, SALT_SIZE, _derive_mac_key
if page_num == 1:
encrypted_input = plain_page[SALT_SIZE : PAGE_SIZE - RESERVE_SIZE]
prefix = salt
else:
encrypted_input = plain_page[: PAGE_SIZE - RESERVE_SIZE]
prefix = b""
cipher = Cipher(
algorithms.AES(raw_key),
modes.CBC(iv),
backend=default_backend(),
)
encryptor = cipher.encryptor()
encrypted = encryptor.update(encrypted_input) + encryptor.finalize()
page_without_hmac = prefix + encrypted + iv
mac = hmac.new(_derive_mac_key(raw_key, salt), digestmod=hashlib.sha512)
mac.update(page_without_hmac[SALT_SIZE if page_num == 1 else 0 :])
mac.update(page_num.to_bytes(4, "little"))
return page_without_hmac + mac.digest()
class TestDecryptStreamSSE(unittest.TestCase):
def test_decrypt_stream_reports_progress(self):
from fastapi import FastAPI
@@ -115,76 +85,6 @@ class TestDecryptStreamSSE(unittest.TestCase):
else:
os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = prev_build_cache
def test_decrypt_stream_reports_key_scope_error_for_wrong_key(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from wechat_decrypt_tool.wechat_decrypt import PAGE_SIZE, RESERVE_SIZE, SQLITE_HEADER
good_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
bad_key = "ffeeddccbbaa998877665544332211000123456789abcdeffedcba9876543210"
salt = bytes.fromhex("11223344556677889900aabbccddeeff")
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
plain_page = SQLITE_HEADER + (b"A" * (PAGE_SIZE - RESERVE_SIZE - len(SQLITE_HEADER))) + (b"\x00" * RESERVE_SIZE)
encrypted_db = _encrypt_page(good_key, plain_page, 1, salt, iv1)
with TemporaryDirectory() as td:
root = Path(td)
prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
prev_build_cache = os.environ.get("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = "0"
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.routers.decrypt as decrypt_router
importlib.reload(app_paths)
importlib.reload(decrypt_router)
db_storage = root / "xwechat_files" / "wxid_wrong_key_user" / "db_storage"
db_storage.mkdir(parents=True, exist_ok=True)
(db_storage / "MSG0.db").write_bytes(encrypted_db)
app = FastAPI()
app.include_router(decrypt_router.router)
client = TestClient(app)
events: list[dict] = []
with client.stream(
"GET",
"/api/decrypt_stream",
params={"key": bad_key, "db_storage_path": str(db_storage)},
) as resp:
self.assertEqual(resp.status_code, 200)
for line in resp.iter_lines():
if not line:
continue
if isinstance(line, bytes):
line = line.decode("utf-8", errors="ignore")
line = str(line)
if line.startswith(":") or not line.startswith("data: "):
continue
payload = json.loads(line[len("data: ") :])
events.append(payload)
if payload.get("type") in {"complete", "error"}:
break
self.assertEqual(events[-1].get("type"), "complete")
self.assertEqual(events[-1].get("status"), "failed")
self.assertIn("当前数据库密钥不正确", events[-1].get("message", ""))
self.assertIn("另一台设备复制", events[-1].get("message", ""))
finally:
if prev_data_dir is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data_dir
if prev_build_cache is None:
os.environ.pop("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE", None)
else:
os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = prev_build_cache
if __name__ == "__main__":
unittest.main()
+30
View File
@@ -118,6 +118,36 @@ class TestParseAppMessage(unittest.TestCase):
self.assertEqual(parsed.get("linkType"), "official_article")
self.assertEqual(parsed.get("linkStyle"), "cover")
def test_finder_type_51_uses_nested_desc_and_cover(self):
raw_text = (
'<msg><appmsg appid="" sdkver="0">'
'<title>当前版本不支持展示该内容,请升级至最新版本。</title>'
'<des></des>'
'<type>51</type>'
'<url></url>'
'<finderFeed>'
'<nickname><![CDATA[央视新闻]]></nickname>'
'<username><![CDATA[finder_cctv_news]]></username>'
'<desc><![CDATA[微信视频号全金融行业今公布发布]]></desc>'
'<mediaList><media>'
'<coverUrl><![CDATA[https://finder.video.qq.com/cover.jpg]]></coverUrl>'
'<url><![CDATA[https://channels.weixin.qq.com/web/pages/feed?feedid=abc]]></url>'
'</media></mediaList>'
'</finderFeed>'
'</appmsg></msg>'
)
parsed = _parse_app_message(raw_text)
self.assertEqual(parsed.get("renderType"), "link")
self.assertEqual(parsed.get("linkType"), "finder")
self.assertEqual(parsed.get("title"), "微信视频号全金融行业今公布发布")
self.assertEqual(parsed.get("content"), "微信视频号全金融行业今公布发布")
self.assertEqual(parsed.get("from"), "央视新闻")
self.assertEqual(parsed.get("fromUsername"), "finder_cctv_news")
self.assertEqual(parsed.get("thumbUrl"), "https://finder.video.qq.com/cover.jpg")
self.assertEqual(parsed.get("url"), "https://channels.weixin.qq.com/web/pages/feed?feedid=abc")
def test_quote_type_5_nested_xml_refermsg_uses_inner_title(self):
raw_text = (
'<msg><appmsg appid="" sdkver="0">'
-172
View File
@@ -1,172 +0,0 @@
import hashlib
import hmac
import os
import sys
import tempfile
import unittest
from pathlib import Path
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.wechat_decrypt import (
PAGE_SIZE,
RESERVE_SIZE,
SALT_SIZE,
SQLITE_HEADER,
WeChatDatabaseDecryptor,
_derive_mac_key,
_derive_sqlcipher_enc_key,
decrypt_wechat_databases,
)
def _encrypt_page(
raw_key: bytes,
plain_page: bytes,
page_num: int,
salt: bytes,
iv: bytes,
*,
sqlcipher_passphrase: bool = False,
) -> bytes:
enc_key = _derive_sqlcipher_enc_key(raw_key, salt) if sqlcipher_passphrase else raw_key
if page_num == 1:
encrypted_input = plain_page[SALT_SIZE : PAGE_SIZE - RESERVE_SIZE]
prefix = salt
else:
encrypted_input = plain_page[: PAGE_SIZE - RESERVE_SIZE]
prefix = b""
cipher = Cipher(
algorithms.AES(enc_key),
modes.CBC(iv),
backend=default_backend(),
)
encryptor = cipher.encryptor()
encrypted = encryptor.update(encrypted_input) + encryptor.finalize()
page_without_hmac = prefix + encrypted + iv
mac = hmac.new(_derive_mac_key(enc_key, salt), digestmod=hashlib.sha512)
mac.update(page_without_hmac[SALT_SIZE if page_num == 1 else 0 :])
mac.update(page_num.to_bytes(4, "little"))
return page_without_hmac + mac.digest()
def _build_plain_page(body_byte: int, *, first_page: bool) -> bytes:
if first_page:
payload = SQLITE_HEADER + bytes([body_byte]) * (PAGE_SIZE - RESERVE_SIZE - len(SQLITE_HEADER))
else:
payload = bytes([body_byte]) * (PAGE_SIZE - RESERVE_SIZE)
return payload + (b"\x00" * RESERVE_SIZE)
class WeChatDecryptRawKeyTests(unittest.TestCase):
def test_decrypt_database_uses_raw_enc_key(self):
raw_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
salt = bytes.fromhex("11223344556677889900aabbccddeeff")
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
iv2 = bytes.fromhex("1112131415161718191a1b1c1d1e1f20")
page1 = _build_plain_page(0x41, first_page=True)
page2 = _build_plain_page(0x42, first_page=False)
encrypted_db = _encrypt_page(raw_key, page1, 1, salt, iv1) + _encrypt_page(raw_key, page2, 2, salt, iv2)
with tempfile.TemporaryDirectory() as tmpdir:
src = Path(tmpdir) / "source.db"
dst = Path(tmpdir) / "out.db"
src.write_bytes(encrypted_db)
decryptor = WeChatDatabaseDecryptor(raw_key.hex())
self.assertTrue(decryptor.decrypt_database(str(src), str(dst)))
self.assertEqual(dst.read_bytes(), page1 + page2)
def test_decrypt_database_falls_back_to_sqlcipher_passphrase_mode(self):
passphrase_key = bytes.fromhex("9f5dd0d3b6d0477ea5045c9e380ee272e53927993eb548dd98a022e842d5f7bd")
salt = bytes.fromhex("50f4090ef6897e146f94109f13743e34")
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
iv2 = bytes.fromhex("1112131415161718191a1b1c1d1e1f20")
page1 = _build_plain_page(0x41, first_page=True)
page2 = _build_plain_page(0x42, first_page=False)
encrypted_db = _encrypt_page(
passphrase_key,
page1,
1,
salt,
iv1,
sqlcipher_passphrase=True,
) + _encrypt_page(
passphrase_key,
page2,
2,
salt,
iv2,
sqlcipher_passphrase=True,
)
with tempfile.TemporaryDirectory() as tmpdir:
src = Path(tmpdir) / "source.db"
dst = Path(tmpdir) / "out.db"
src.write_bytes(encrypted_db)
decryptor = WeChatDatabaseDecryptor(passphrase_key.hex())
self.assertTrue(decryptor.decrypt_database(str(src), str(dst)))
self.assertEqual(dst.read_bytes(), page1 + page2)
def test_decrypt_database_keeps_existing_output_on_hmac_failure(self):
good_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
bad_key_hex = "ffeeddccbbaa998877665544332211000123456789abcdeffedcba9876543210"
salt = bytes.fromhex("11223344556677889900aabbccddeeff")
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
page1 = _build_plain_page(0x41, first_page=True)
encrypted_db = _encrypt_page(good_key, page1, 1, salt, iv1)
with tempfile.TemporaryDirectory() as tmpdir:
src = Path(tmpdir) / "source.db"
dst = Path(tmpdir) / "out.db"
src.write_bytes(encrypted_db)
dst.write_bytes(b"keep-existing-output")
decryptor = WeChatDatabaseDecryptor(bad_key_hex)
self.assertFalse(decryptor.decrypt_database(str(src), str(dst)))
self.assertEqual(dst.read_bytes(), b"keep-existing-output")
def test_decrypt_wechat_databases_reports_key_scope_message(self):
good_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
bad_key_hex = "ffeeddccbbaa998877665544332211000123456789abcdeffedcba9876543210"
salt = bytes.fromhex("11223344556677889900aabbccddeeff")
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
page1 = _build_plain_page(0x41, first_page=True)
encrypted_db = _encrypt_page(good_key, page1, 1, salt, iv1)
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
db_storage = root / "xwechat_files" / "wxid_scope_user" / "db_storage"
db_storage.mkdir(parents=True, exist_ok=True)
(db_storage / "MSG0.db").write_bytes(encrypted_db)
prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
result = decrypt_wechat_databases(str(db_storage), bad_key_hex)
finally:
if prev_data_dir is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data_dir
self.assertEqual(result["status"], "error")
self.assertIn("当前数据库密钥不正确", result["message"])
self.assertIn("账号/当前设备", result["message"])
self.assertIn("另一台设备复制", result["message"])
if __name__ == "__main__":
unittest.main()