feat: 以chat的方式实现biz的解析

This commit is contained in:
H3CoF6
2026-04-03 04:40:34 +08:00
parent 5b56b2e0be
commit 617b400884
2 changed files with 267 additions and 358 deletions

View File

@@ -1,13 +1,10 @@
import { join } from 'path' import { join } from 'path'
import { readdirSync, existsSync } from 'fs' import { readdirSync, existsSync } from 'fs'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { dbPathService } from './dbPathService'
import { ConfigService } from './config' import { ConfigService } from './config'
import * as fzstd from 'fzstd' import { chatService, Message } from './chatService'
import { DOMParser } from '@xmldom/xmldom'
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import { createHash } from 'crypto' import { createHash } from 'crypto'
import {ContactCacheService} from "./contactCacheService";
export interface BizAccount { export interface BizAccount {
username: string username: string
@@ -26,6 +23,7 @@ export interface BizMessage {
url: string url: string
cover: string cover: string
content_list: any[] content_list: any[]
raw?: any // 调试用
} }
export interface BizPayRecord { export interface BizPayRecord {
@@ -41,329 +39,221 @@ export interface BizPayRecord {
export class BizService { export class BizService {
private configService: ConfigService private configService: ConfigService
constructor() { constructor() {
this.configService = new ConfigService() this.configService = new ConfigService()
} }
private getAccountDir(account?: string): string { private extractXmlValue(xml: string, tagName: string): string {
const root = dbPathService.getDefaultPath() const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i')
if (account) { const match = regex.exec(xml)
return join(root, account) if (match) {
return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
} }
// Default to the first scanned account if no account specified return ''
const candidates = dbPathService.scanWxids(root)
if (candidates.length > 0) {
return join(root, candidates[0].wxid)
}
return root
} }
private decompressZstd(data: Buffer): string { private parseBizContentList(xmlStr: string): any[] {
if (!data || data.length < 4) return data.toString('utf-8') if (!xmlStr) return []
const magic = data.readUInt32LE(0) const contentList: any[] = []
if (magic !== 0xFD2FB528) {
return data.toString('utf-8')
}
try { try {
const decompressed = fzstd.decompress(data) const itemRegex = /<item>([\s\S]*?)<\/item>/gi
return Buffer.from(decompressed).toString('utf-8') let match: RegExpExecArray | null
} catch (e) { while ((match = itemRegex.exec(xmlStr)) !== null) {
console.error('[BizService] Zstd decompression failed:', e) const itemXml = match[1]
return data.toString('utf-8')
}
}
private parseBizXml(xmlStr: string): any {
if (!xmlStr) return null
try {
const doc = new DOMParser().parseFromString(xmlStr, 'text/xml')
const q = (parent: any, selector: string) => {
const nodes = parent.getElementsByTagName(selector)
return nodes.length > 0 ? nodes[0].textContent || '' : ''
}
const appMsg = doc.getElementsByTagName('appmsg')[0]
if (!appMsg) return null
// 提取主封面
let mainCover = q(appMsg, 'thumburl')
if (!mainCover) {
const coverNode = doc.getElementsByTagName('cover')[0]
if (coverNode) mainCover = coverNode.textContent || ''
}
const result = {
title: q(appMsg, 'title'),
des: q(appMsg, 'des'),
url: q(appMsg, 'url'),
cover: mainCover,
content_list: [] as any[]
}
const items = doc.getElementsByTagName('item')
for (let i = 0; i < items.length; i++) {
const item = items[i]
const itemStruct = { const itemStruct = {
title: q(item, 'title'), title: this.extractXmlValue(itemXml, 'title'),
url: q(item, 'url'), url: this.extractXmlValue(itemXml, 'url'),
cover: q(item, 'cover'), cover: this.extractXmlValue(itemXml, 'cover') || this.extractXmlValue(itemXml, 'thumburl'),
summary: q(item, 'summary') summary: this.extractXmlValue(itemXml, 'summary') || this.extractXmlValue(itemXml, 'digest')
} }
if (itemStruct.title) { if (itemStruct.title) contentList.push(itemStruct)
result.content_list.push(itemStruct)
}
}
return result
} catch (e) {
console.error('[BizService] XML parse failed:', e)
return null
} }
} catch (e) {}
return contentList
} }
private parsePayXml(xmlStr: string): any { private parsePayXml(xmlStr: string): any {
if (!xmlStr) return null if (!xmlStr) return null
try { try {
const doc = new DOMParser().parseFromString(xmlStr, 'text/xml') const title = this.extractXmlValue(xmlStr, 'title')
const q = (parent: any, selector: string) => { const description = this.extractXmlValue(xmlStr, 'des')
const nodes = parent.getElementsByTagName(selector) const merchantName = this.extractXmlValue(xmlStr, 'display_name') || '微信支付'
return nodes.length > 0 ? nodes[0].textContent || '' : '' const merchantIcon = this.extractXmlValue(xmlStr, 'icon_url')
const pubTime = parseInt(this.extractXmlValue(xmlStr, 'pub_time') || '0')
if (!title && !description) return null
return { title, description, merchant_name: merchantName, merchant_icon: merchantIcon, timestamp: pubTime }
} catch (e) { return null }
} }
const appMsg = doc.getElementsByTagName('appmsg')[0] /**
const header = doc.getElementsByTagName('template_header')[0] * 核心:获取公众号消息,支持从 biz_message*.db 自动定位
*/
private async getBizRawMessages(username: string, account: string, limit: number, offset: number): Promise<Message[]> {
console.log(`[BizService] getBizRawMessages: ${username}, offset=${offset}, limit=${limit}`)
const record = { // 1. 首先尝试直接用 chatService.getMessages (如果 Native 层支持路由)
title: appMsg ? q(appMsg, 'title') : '', const chatRes = await chatService.getMessages(username, offset, limit)
description: appMsg ? q(appMsg, 'des') : '', if (chatRes.success && chatRes.messages && chatRes.messages.length > 0) {
merchant_name: header ? q(header, 'display_name') : '微信支付', console.log(`[BizService] chatService found ${chatRes.messages.length} messages for ${username}`)
merchant_icon: header ? q(header, 'icon_url') : '', return chatRes.messages
timestamp: parseInt(q(doc, 'pub_time') || '0'),
formatted_time: ''
}
return record
} catch (e) {
console.error('[BizService] Pay XML parse failed:', e)
return null
}
} }
async listAccounts(account?: string): Promise<BizAccount[]> { // 2. 如果 chatService 没找到,手动扫描 biz_message*.db (类似 Python 逻辑)
console.log(`[BizService] chatService empty, manual scanning biz_message*.db...`)
const root = this.configService.get('dbPath') const root = this.configService.get('dbPath')
console.log(root) const accountWxid = account || this.configService.get('myWxid')
let accountWxids: string[] = [] if (!root || !accountWxid) return []
if (account) { const dbDir = join(root, accountWxid, 'db_storage', 'message')
accountWxids = [account] if (!existsSync(dbDir)) return []
} else {
const candidates = dbPathService.scanWxids(root)
accountWxids = candidates.map(c => c.wxid)
}
const allBizAccounts: Record<string, BizAccount> = {}
for (const wxid of accountWxids) {
const accountDir = join(root, wxid)
const dbDir = join(accountDir, 'db_storage', 'message')
if (!existsSync(dbDir)) continue
const md5Id = createHash('md5').update(username).digest('hex').toLowerCase()
const tableName = `Msg_${md5Id}`
const bizDbFiles = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db')) const bizDbFiles = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db'))
if (bizDbFiles.length === 0) continue
const bizIds = new Set<string>()
const bizLatestTime: Record<string, number> = {}
for (const file of bizDbFiles) { for (const file of bizDbFiles) {
const dbPath = join(dbDir, file) const dbPath = join(dbDir, file)
console.log(`path: ${dbPath}`) // 检查表是否存在
const name2idRes = await wcdbService.execQuery('biz', dbPath, 'SELECT username FROM Name2Id') const checkRes = await wcdbService.execQuery('message', dbPath, `SELECT name FROM sqlite_master WHERE type='table' AND lower(name)='${tableName}'`)
console.log(`name2idRes success: ${name2idRes.success}`) if (checkRes.success && checkRes.rows && checkRes.rows.length > 0) {
console.log(`name2idRes length: ${name2idRes.rows?.length}`) console.log(`[BizService] Found table ${tableName} in ${file}`)
// 分页查询原始行
const sql = `SELECT * FROM ${tableName} ORDER BY create_time DESC LIMIT ${limit} OFFSET ${offset}`
const queryRes = await wcdbService.execQuery('message', dbPath, sql)
if (queryRes.success && queryRes.rows) {
// *** 复用 chatService 的解析逻辑 ***
return chatService.mapRowsToMessagesForApi(queryRes.rows)
}
}
}
return []
}
async listAccounts(account?: string): Promise<BizAccount[]> {
try {
const contactsResult = await chatService.getContacts({ lite: true })
if (!contactsResult.success || !contactsResult.contacts) return []
const officialContacts = contactsResult.contacts.filter(c => c.type === 'official')
const usernames = officialContacts.map(c => c.username)
const enrichment = await chatService.enrichSessionsContactInfo(usernames)
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
const root = this.configService.get('dbPath')
const myWxid = this.configService.get('myWxid')
const accountWxid = account || myWxid
if (!root || !accountWxid) return []
const dbDir = join(root, accountWxid, 'db_storage', 'message')
const bizLatestTime: Record<string, number> = {}
if (existsSync(dbDir)) {
const bizDbFiles = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db'))
for (const file of bizDbFiles) {
const dbPath = join(dbDir, file)
const name2idRes = await wcdbService.execQuery('message', dbPath, 'SELECT username FROM Name2Id')
if (name2idRes.success && name2idRes.rows) { if (name2idRes.success && name2idRes.rows) {
for (const row of name2idRes.rows) { for (const row of name2idRes.rows) {
if (row.username) { const uname = row.username || row.user_name
const uname = row.username if (uname) {
bizIds.add(uname) const md5 = createHash('md5').update(uname).digest('hex').toLowerCase()
const tName = `Msg_${md5}`
const md5Id = createHash('md5').update(uname).digest('hex').toLowerCase() const timeRes = await wcdbService.execQuery('message', dbPath, `SELECT MAX(create_time) as max_time FROM ${tName}`)
const tableName = `Msg_${md5Id}`
const timeRes = await wcdbService.execQuery('biz', dbPath, `SELECT MAX(create_time) as max_time FROM ${tableName}`)
if (timeRes.success && timeRes.rows && timeRes.rows[0]?.max_time) { if (timeRes.success && timeRes.rows && timeRes.rows[0]?.max_time) {
const t = timeRes.rows[0].max_time const t = parseInt(timeRes.rows[0].max_time)
bizLatestTime[uname] = Math.max(bizLatestTime[uname] || 0, t) if (!isNaN(t)) bizLatestTime[uname] = Math.max(bizLatestTime[uname] || 0, t)
}
} }
} }
} }
} }
} }
if (bizIds.size === 0) continue const result: BizAccount[] = officialContacts.map(contact => {
const uname = contact.username
const contactDbPath = join(accountDir, 'contact.db') const info = contactInfoMap[uname]
if (existsSync(contactDbPath)) { const lastTime = bizLatestTime[uname] || 0
const idsArray = Array.from(bizIds) return {
const batchSize = 100
for (let i = 0; i < idsArray.length; i += batchSize) {
const batch = idsArray.slice(i, i + batchSize)
const placeholders = batch.map(() => '?').join(',')
const contactRes = await wcdbService.execQuery('contact', contactDbPath,
`SELECT username, remark, nick_name, alias, big_head_url FROM contact WHERE username IN (${placeholders})`,
batch
)
if (contactRes.success && contactRes.rows) {
for (const r of contactRes.rows) {
const uname = r.username
const name = r.remark || r.nick_name || r.alias || uname
allBizAccounts[uname] = {
username: uname, username: uname,
name: name, name: info?.displayName || contact.displayName || uname,
avatar: r.big_head_url, avatar: info?.avatarUrl || '',
type: 3, type: 0,
last_time: Math.max(allBizAccounts[uname]?.last_time || 0, bizLatestTime[uname] || 0), last_time: lastTime,
formatted_last_time: '' formatted_last_time: lastTime ? new Date(lastTime * 1000).toISOString().split('T')[0] : ''
}
}
} }
})
const bizInfoRes = await wcdbService.execQuery('biz', contactDbPath, const contactDbPath = join(root, accountWxid, 'contact.db')
`SELECT username, type FROM biz_info WHERE username IN (${placeholders})`, if (existsSync(contactDbPath)) {
batch const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info')
)
if (bizInfoRes.success && bizInfoRes.rows) { if (bizInfoRes.success && bizInfoRes.rows) {
for (const r of bizInfoRes.rows) { const typeMap: Record<string, number> = {}
if (allBizAccounts[r.username]) { for (const r of bizInfoRes.rows) typeMap[r.username] = r.type
allBizAccounts[r.username].type = r.type for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
}
}
}
}
} }
} }
const result = Object.values(allBizAccounts).map(acc => ({ return result.sort((a, b) => {
...acc,
formatted_last_time: acc.last_time ? new Date(acc.last_time * 1000).toISOString().split('T')[0] : ''
})).sort((a, b) => {
// 微信支付强制置顶
if (a.username === 'gh_3dfda90e39d6') return -1 if (a.username === 'gh_3dfda90e39d6') return -1
if (b.username === 'gh_3dfda90e39d6') return 1 if (b.username === 'gh_3dfda90e39d6') return 1
return b.last_time - a.last_time return b.last_time - a.last_time
}) })
} catch (e) { return [] }
return result
}
private async getMsgContentBuf(messageContent: any): Promise<Buffer | null> {
if (typeof messageContent === 'string') {
if (messageContent.length > 0 && /^[0-9a-fA-F]+$/.test(messageContent)) {
return Buffer.from(messageContent, 'hex')
}
return Buffer.from(messageContent, 'utf-8')
} else if (messageContent && messageContent.data) {
return Buffer.from(messageContent.data)
} else if (Buffer.isBuffer(messageContent) || messageContent instanceof Uint8Array) {
return Buffer.from(messageContent)
}
return null
} }
async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> { async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> {
const accountDir = this.getAccountDir(account) console.log(`[BizService] listMessages: ${username}, limit=${limit}, offset=${offset}`)
const md5Id = createHash('md5').update(username).digest('hex').toLowerCase() try {
const tableName = `Msg_${md5Id}` const rawMessages = await this.getBizRawMessages(username, account || '', limit, offset)
const dbDir = join(accountDir, 'db_storage')
if (!existsSync(dbDir)) return [] const bizMessages: BizMessage[] = rawMessages.map(msg => {
const files = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db')) const bizMsg: BizMessage = {
let targetDb: string | null = null local_id: msg.localId,
create_time: msg.createTime,
for (const file of files) { title: msg.linkTitle || msg.parsedContent || '',
const dbPath = join(dbDir, file) des: msg.appMsgDesc || '',
const checkRes = await wcdbService.execQuery('biz', dbPath, `SELECT name FROM sqlite_master WHERE type='table' AND lower(name)='${tableName}'`) url: msg.linkUrl || '',
if (checkRes.success && checkRes.rows && checkRes.rows.length > 0) { cover: msg.linkThumb || msg.appMsgThumbUrl || '',
targetDb = dbPath content_list: []
break }
if (msg.rawContent) {
bizMsg.content_list = this.parseBizContentList(msg.rawContent)
if (bizMsg.content_list.length > 0 && !bizMsg.title) {
bizMsg.title = bizMsg.content_list[0].title
bizMsg.cover = bizMsg.cover || bizMsg.content_list[0].cover
} }
} }
return bizMsg
if (!targetDb) return []
const msgRes = await wcdbService.execQuery('biz', targetDb,
`SELECT local_id, create_time, message_content FROM ${tableName} WHERE local_type != 1 ORDER BY create_time DESC LIMIT ${limit} OFFSET ${offset}`
)
const messages: BizMessage[] = []
if (msgRes.success && msgRes.rows) {
for (const row of msgRes.rows) {
const contentBuf = await this.getMsgContentBuf(row.message_content)
if (!contentBuf) continue
const xmlStr = this.decompressZstd(contentBuf)
const structData = this.parseBizXml(xmlStr)
if (structData) {
messages.push({
local_id: row.local_id,
create_time: row.create_time,
...structData
}) })
return bizMessages
} catch (e) {
console.error(`[BizService] listMessages error:`, e)
return []
} }
} }
}
return messages
}
async listPayRecords(account?: string, limit: number = 20, offset: number = 0): Promise<BizPayRecord[]> { async listPayRecords(account?: string, limit: number = 20, offset: number = 0): Promise<BizPayRecord[]> {
const username = 'gh_3dfda90e39d6' // 硬编码的微信支付账号 const username = 'gh_3dfda90e39d6'
const accountDir = this.getAccountDir(account) try {
const md5Id = createHash('md5').update(username).digest('hex').toLowerCase() const rawMessages = await this.getBizRawMessages(username, account || '', limit, offset)
const tableName = `Msg_${md5Id}`
const dbDir = join(accountDir, 'db_storage')
if (!existsSync(dbDir)) return []
const files = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db'))
let targetDb: string | null = null
for (const file of files) {
const dbPath = join(dbDir, file)
const checkRes = await wcdbService.execQuery('biz', dbPath, `SELECT name FROM sqlite_master WHERE type='table' AND lower(name)='${tableName}'`)
if (checkRes.success && checkRes.rows && checkRes.rows.length > 0) {
targetDb = dbPath
break
}
}
if (!targetDb) return []
const msgRes = await wcdbService.execQuery('biz', targetDb,
`SELECT local_id, create_time, message_content FROM ${tableName} WHERE local_type = 21474836529 OR local_type != 1 ORDER BY create_time DESC LIMIT ${limit} OFFSET ${offset}`
)
const records: BizPayRecord[] = [] const records: BizPayRecord[] = []
if (msgRes.success && msgRes.rows) { for (const msg of rawMessages) {
for (const row of msgRes.rows) { if (!msg.rawContent) continue
const contentBuf = await this.getMsgContentBuf(row.message_content) const parsedData = this.parsePayXml(msg.rawContent)
if (!contentBuf) continue
const xmlStr = this.decompressZstd(contentBuf)
const parsedData = this.parsePayXml(xmlStr)
if (parsedData) { if (parsedData) {
const timestamp = parsedData.timestamp || row.create_time
records.push({ records.push({
local_id: row.local_id, local_id: msg.localId,
create_time: row.create_time, create_time: msg.createTime,
...parsedData, ...parsedData,
timestamp, timestamp: parsedData.timestamp || msg.createTime,
formatted_time: new Date(timestamp * 1000).toLocaleString() formatted_time: new Date((parsedData.timestamp || msg.createTime) * 1000).toLocaleString()
}) })
} }
} }
}
return records return records
} catch (e) { return [] }
} }
registerHandlers() { registerHandlers() {

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'; import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useThemeStore } from '../stores/themeStore'; import { useThemeStore } from '../stores/themeStore';
import { useAppStore } from '../stores/appStore'; import { Newspaper } from 'lucide-react';
import { ChevronLeft, Newspaper } from 'lucide-react';
import './BizPage.scss'; import './BizPage.scss';
export interface BizAccount { export interface BizAccount {
@@ -76,7 +75,7 @@ export const BizAccountList: React.FC<{
className={`biz-account-item ${selectedUsername === item.username ? 'active' : ''} ${item.username === 'gh_3dfda90e39d6' ? 'pay-account' : ''}`} className={`biz-account-item ${selectedUsername === item.username ? 'active' : ''} ${item.username === 'gh_3dfda90e39d6' ? 'pay-account' : ''}`}
> >
<img <img
src={item.avatar || 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.png'} src={item.avatar}
className="biz-avatar" className="biz-avatar"
alt="" alt=""
/> />
@@ -90,7 +89,7 @@ export const BizAccountList: React.FC<{
item.type === 0 ? 'type-sub' : item.type === 0 ? 'type-sub' :
item.type === 2 ? 'type-enterprise' : 'type-unknown' item.type === 2 ? 'type-enterprise' : 'type-unknown'
}`}> }`}>
{item.type === 1 ? '服务号' : item.type === 0 ? '订阅号' : item.type === 2 ? '企业号' : '未知'} {item.type === 0 ? '服务号' : item.type === 1 ? '订阅号' : item.type === 2 ? '企业号' : '未知'}
</div> </div>
</div> </div>
</div> </div>
@@ -110,7 +109,24 @@ export const BizMessageArea: React.FC<{
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const limit = 20; const limit = 20;
const messageListRef = useRef<HTMLDivElement>(null); const messageListRef = useRef<HTMLDivElement>(null);
const myWxid = useAppStore((state) => state.myWxid);
// ======== 修改开始:独立从底层获取 myWxid ========
const [myWxid, setMyWxid] = useState<string>('');
useEffect(() => {
const initWxid = async () => {
try {
const wxid = await window.electronAPI.config.get('myWxid');
if (wxid) {
setMyWxid(wxid as string);
}
} catch (e) {
console.error("获取 myWxid 失败:", e);
}
};
initWxid();
}, []);
// ======== 修改结束 ========
const isDark = useMemo(() => { const isDark = useMemo(() => {
if (themeMode === 'dark') return true; if (themeMode === 'dark') return true;
@@ -120,14 +136,17 @@ export const BizMessageArea: React.FC<{
return false; return false;
}, [themeMode]); }, [themeMode]);
// ======== 补充修改:添加 myWxid 依赖 ========
// 必须加上 myWxid 作为依赖项,否则第一次点击左侧账号时,如果 wxid 还没异步拿回来,就不会触发加载
useEffect(() => { useEffect(() => {
if (account) { if (account && myWxid) {
setMessages([]); setMessages([]);
setOffset(0); setOffset(0);
setHasMore(true); setHasMore(true);
loadMessages(account.username, 0); loadMessages(account.username, 0);
} }
}, [account]); }, [account, myWxid]);
// ======== 补充修改结束 ========
const loadMessages = async (username: string, currentOffset: number) => { const loadMessages = async (username: string, currentOffset: number) => {
if (loading || !myWxid) return; // 没账号直接 return if (loading || !myWxid) return; // 没账号直接 return