mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
feat(biz): implement parse biz data
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div class="biz-page h-screen flex overflow-hidden" style="background-color: var(--app-shell-bg)">
|
||||
|
||||
<div class="w-[300px] lg:w-[320px] bg-white border-r border-gray-200 flex flex-col flex-shrink-0 z-10">
|
||||
<div class="p-3 border-b border-gray-200" style="background-color: var(--app-surface-muted)">
|
||||
<div class="contact-search-wrapper flex-1">
|
||||
<svg class="contact-search-icon" fill="none" stroke="currentColor" viewBox="0 0 16 16">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14 14L11.1 11.1" />
|
||||
</svg>
|
||||
<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 text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="item in filteredAccounts"
|
||||
:key="item.username"
|
||||
@click="selectAccount(item)"
|
||||
class="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
:class="{ 'bg-[#F2F2F2]': selectedAccount?.username === item.username }"
|
||||
>
|
||||
<img
|
||||
:src="item.avatar || defaultAvatar"
|
||||
class="w-10 h-10 rounded-md object-cover bg-gray-200 flex-shrink-0"
|
||||
@error="(e) => e.target.src = defaultAvatar"
|
||||
alt=""/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-sm text-gray-900 truncate">{{ item.name || item.username }}</h3>
|
||||
</div>
|
||||
<div v-if="item.username === 'gh_3dfda90e39d6'" class="text-xs text-[#03C160] bg-[#03C160]/10 px-1.5 py-0.5 rounded">
|
||||
官方
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col min-h-0 min-w-0 bg-[#F5F5F5]">
|
||||
<div v-if="selectedAccount" class="flex-1 flex flex-col min-h-0 relative">
|
||||
<div class="h-14 border-b border-gray-200 bg-[#F5F5F5] flex items-center px-5 shrink-0 z-10">
|
||||
<h2 class="text-base text-gray-900">{{ selectedAccount.name }}</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-y-auto px-4 py-6 flex flex-col-reverse"
|
||||
@scroll="handleScroll"
|
||||
ref="messageListRef"
|
||||
>
|
||||
<div v-if="!hasMore" class="text-center text-xs text-gray-400 py-4 w-full">没有更多消息了</div>
|
||||
<div v-if="loadingMessages" class="text-center text-xs text-gray-400 py-4 w-full">正在加载...</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="selectedAccount.username === 'gh_3dfda90e39d6'" class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
|
||||
<div class="flex items-center text-gray-500 text-sm mb-5">
|
||||
<img v-if="msg.merchant_icon" :src="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 bg-green-100 flex items-center justify-center text-green-600">¥</div>
|
||||
<span>{{ msg.merchant_name || '微信支付' }}</span>
|
||||
</div>
|
||||
<div class="text-center mb-6">
|
||||
<h3 class="text-[22px] font-medium text-gray-900 mb-1">{{ msg.title }}</h3>
|
||||
</div>
|
||||
<div class="text-[13px] text-gray-500 whitespace-pre-wrap leading-relaxed">
|
||||
{{ msg.description }}
|
||||
</div>
|
||||
<div class="mt-4 pt-3 border-t border-gray-100 text-[12px] text-gray-400 text-right">
|
||||
{{ msg.formatted_time }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="bg-white rounded-xl shadow-sm overflow-hidden border border-gray-100">
|
||||
<a :href="msg.url" target="_blank" class="block relative group cursor-pointer">
|
||||
<img :src="msg.cover || defaultImage" class="w-full h-[180px] object-cover 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] text-gray-500 border-b border-gray-50">
|
||||
{{ msg.des }}
|
||||
</div>
|
||||
|
||||
<div v-if="msg.content_list && msg.content_list.length > 0" class="flex flex-col">
|
||||
<a
|
||||
v-for="(item, idx) in msg.content_list"
|
||||
:key="idx"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
class="flex items-center justify-between p-3 border-t border-gray-100 hover:bg-gray-50 cursor-pointer group"
|
||||
>
|
||||
<span class="text-[14px] text-gray-800 leading-snug line-clamp-2 pr-3 group-hover:underline">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<img :src="item.cover" class="w-12 h-12 rounded object-cover flex-shrink-0 bg-gray-100 border 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 bg-gray-200/50 flex items-center justify-center">
|
||||
<svg class="w-10 h-10 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 text-gray-400">请选择一个服务号查看消息</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
// 状态
|
||||
const accounts = ref([])
|
||||
const loadingAccounts = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const selectedAccount = ref(null)
|
||||
|
||||
const messages = ref([])
|
||||
const loadingMessages = ref(false)
|
||||
const offset = ref(0)
|
||||
const limit = 20
|
||||
const hasMore = ref(true)
|
||||
|
||||
const messageListRef = ref(null)
|
||||
|
||||
// 默认占位图
|
||||
const defaultAvatar = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDQwIDQwIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiNlNWU3ZWIiLz48L3N2Zz4='
|
||||
const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg=='
|
||||
|
||||
// 获取账号列表,并将微信支付置顶
|
||||
const fetchAccounts = async () => {
|
||||
loadingAccounts.value = true
|
||||
try {
|
||||
const res = await api.listBizAccounts()
|
||||
if (res && res.data) {
|
||||
// 提取微信支付
|
||||
const payAccount = res.data.find(a => a.username === 'gh_3dfda90e39d6')
|
||||
const otherAccounts = res.data.filter(a => a.username !== 'gh_3dfda90e39d6')
|
||||
|
||||
const sortedList = []
|
||||
if (payAccount) {
|
||||
// 如果后端没有返回名字,可以手动补齐
|
||||
payAccount.name = '微信支付'
|
||||
sortedList.push(payAccount)
|
||||
} else {
|
||||
// 如果后端列表里没有,但你想强行显示,也可以造一个假的
|
||||
sortedList.push({ username: 'gh_3dfda90e39d6', name: '微信支付', avatar: '' })
|
||||
}
|
||||
|
||||
accounts.value = [...sortedList, ...otherAccounts]
|
||||
}
|
||||
} catch (err) {
|
||||
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 = (account) => {
|
||||
if (selectedAccount.value?.username === account.username) return
|
||||
selectedAccount.value = account
|
||||
|
||||
// 重置消息状态
|
||||
messages.value = []
|
||||
offset.value = 0
|
||||
hasMore.value = true
|
||||
|
||||
loadMessages()
|
||||
}
|
||||
|
||||
// 加载消息
|
||||
const loadMessages = async () => {
|
||||
if (loadingMessages.value || !hasMore.value || !selectedAccount.value) return
|
||||
|
||||
loadingMessages.value = true
|
||||
try {
|
||||
const username = selectedAccount.value.username
|
||||
const params = { 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
|
||||
}
|
||||
}
|
||||
|
||||
// 向上滚动加载逻辑
|
||||
// 因为容器设置了 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()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAccounts()
|
||||
})
|
||||
</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>
|
||||
@@ -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
|
||||
|
||||
@@ -561,6 +561,21 @@ export const useApi = () => {
|
||||
return await request('/get_image_key')
|
||||
}
|
||||
|
||||
// 枚举服务号信息
|
||||
const listBizAccounts = async (params) => {
|
||||
return await request('/biz/list', params)
|
||||
}
|
||||
|
||||
// 获取普通服务号消息
|
||||
const listBizMessages = async (params) => {
|
||||
return await request('/biz/messages', params)
|
||||
}
|
||||
|
||||
// 获取微信支付记录
|
||||
const listBizPayRecords = async (params) => {
|
||||
return await request('/biz/pay_records', params)
|
||||
}
|
||||
|
||||
return {
|
||||
detectWechat,
|
||||
detectCurrentAccount,
|
||||
@@ -616,5 +631,8 @@ export const useApi = () => {
|
||||
getKeys,
|
||||
getImageKey,
|
||||
getWxStatus,
|
||||
listBizAccounts,
|
||||
listBizMessages,
|
||||
listBizPayRecords,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="h-screen 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>
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Optional, Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
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
|
||||
|
||||
|
||||
# 接口 1:获取全部的服务号/公众号的信息
|
||||
@router.get("/api/biz/list", summary="获取全部服务号/公众号列表")
|
||||
def get_biz_account_list(account: Optional[str] = None):
|
||||
account_dir = _resolve_account_dir(account)
|
||||
|
||||
# 1. 遍历 biz_message_*.db,从 Name2Id 拿到所有 id
|
||||
biz_ids = set()
|
||||
for db_file in account_dir.glob("biz_message*.db"):
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_file))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 不同微信版本的主键可能是 user_name 或 username
|
||||
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]:
|
||||
biz_ids.add(r[0])
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"读取 Name2Id 失败 {db_file}: {e}")
|
||||
|
||||
# 2. 查询 contact.db 完善昵称和头像信息
|
||||
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))
|
||||
query = f"SELECT username, remark, nick_name, alias, big_head_url FROM contact WHERE username IN ({placeholders})"
|
||||
rows = cursor.execute(query, list(biz_ids)).fetchall()
|
||||
|
||||
for r in rows:
|
||||
uname = r[0]
|
||||
remark = r[1]
|
||||
nick_name = r[2]
|
||||
alias = r[3]
|
||||
head_url = r[4]
|
||||
|
||||
# 优先级: remark > nick_name > alias > id
|
||||
name = remark or nick_name or alias or uname
|
||||
contact_info[uname] = {
|
||||
"username": uname,
|
||||
"name": name,
|
||||
"avatar": head_url
|
||||
}
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"读取 contact.db 失败: {e}")
|
||||
|
||||
result = []
|
||||
for uid in biz_ids:
|
||||
if uid in contact_info:
|
||||
result.append(contact_info[uid])
|
||||
else:
|
||||
result.append({"username": uid, "name": uid, "avatar": ""})
|
||||
|
||||
return {"status": "success", "total": len(result), "data": result}
|
||||
|
||||
|
||||
# 接口 2:获取普通服务号/公众号的 json 消息
|
||||
@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:
|
||||
res = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND lower(name)=?",
|
||||
(table_name,)).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 消息
|
||||
@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:
|
||||
res = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND lower(name)=?",
|
||||
(table_name,)).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}
|
||||
Reference in New Issue
Block a user