Merge pull request #39 from H3CoF6/feat/biz

粗略解析微信支付记录,独立展示公众号服务号消息
This commit is contained in:
2977094657
2026-03-28 10:58:16 +08:00
committed by GitHub
6 changed files with 724 additions and 26 deletions
+270
View File
@@ -0,0 +1,270 @@
<template>
<div class="biz-page h-screen 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>
<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',
selectedAccount?.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="selectedAccount" 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'">{{ 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 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="selectedAccount.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 } from 'vue'
import { useApi } from '~/composables/useApi'
const api = useApi()
import { storeToRefs } from 'pinia'
import { useThemeStore } from '~/stores/theme'
const accounts = ref([])
const loadingAccounts = ref(false)
const searchQuery = ref('')
const selectedAccount = ref(null)
const themeStore = useThemeStore()
const { isDark } = storeToRefs(themeStore)
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) {
accounts.value = res.data
}
} 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>
+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
+42
View File
@@ -561,6 +561,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 +654,9 @@ export const useApi = () => {
getKeys,
getImageKey,
getWxStatus,
listBizAccounts,
listBizMessages,
listBizPayRecords,
getBizProxyImageUrl,
}
}
+16
View File
@@ -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>
+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):
+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}