mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-02 23:15:59 +00:00
feat: 以chat的方式实现biz的解析
This commit is contained in:
@@ -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) {
|
|
||||||
result.content_list.push(itemStruct)
|
|
||||||
}
|
}
|
||||||
|
if (itemStruct.title) contentList.push(itemStruct)
|
||||||
}
|
}
|
||||||
|
} catch (e) {}
|
||||||
return result
|
return contentList
|
||||||
} catch (e) {
|
|
||||||
console.error('[BizService] XML parse failed:', e)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 自动定位
|
||||||
|
*/
|
||||||
const record = {
|
private async getBizRawMessages(username: string, account: string, limit: number, offset: number): Promise<Message[]> {
|
||||||
title: appMsg ? q(appMsg, 'title') : '',
|
console.log(`[BizService] getBizRawMessages: ${username}, offset=${offset}, limit=${limit}`)
|
||||||
description: appMsg ? q(appMsg, 'des') : '',
|
|
||||||
merchant_name: header ? q(header, 'display_name') : '微信支付',
|
// 1. 首先尝试直接用 chatService.getMessages (如果 Native 层支持路由)
|
||||||
merchant_icon: header ? q(header, 'icon_url') : '',
|
const chatRes = await chatService.getMessages(username, offset, limit)
|
||||||
timestamp: parseInt(q(doc, 'pub_time') || '0'),
|
if (chatRes.success && chatRes.messages && chatRes.messages.length > 0) {
|
||||||
formatted_time: ''
|
console.log(`[BizService] chatService found ${chatRes.messages.length} messages for ${username}`)
|
||||||
}
|
return chatRes.messages
|
||||||
return record
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[BizService] Pay XML parse failed:', e)
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 如果 chatService 没找到,手动扫描 biz_message*.db (类似 Python 逻辑)
|
||||||
|
console.log(`[BizService] chatService empty, manual scanning biz_message*.db...`)
|
||||||
|
const root = this.configService.get('dbPath')
|
||||||
|
const accountWxid = account || this.configService.get('myWxid')
|
||||||
|
if (!root || !accountWxid) return []
|
||||||
|
|
||||||
|
const dbDir = join(root, accountWxid, 'db_storage', 'message')
|
||||||
|
if (!existsSync(dbDir)) return []
|
||||||
|
|
||||||
|
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'))
|
||||||
|
|
||||||
|
for (const file of bizDbFiles) {
|
||||||
|
const dbPath = join(dbDir, file)
|
||||||
|
// 检查表是否存在
|
||||||
|
const checkRes = await wcdbService.execQuery('message', dbPath, `SELECT name FROM sqlite_master WHERE type='table' AND lower(name)='${tableName}'`)
|
||||||
|
if (checkRes.success && checkRes.rows && checkRes.rows.length > 0) {
|
||||||
|
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[]> {
|
async listAccounts(account?: string): Promise<BizAccount[]> {
|
||||||
const root = this.configService.get('dbPath')
|
try {
|
||||||
console.log(root)
|
const contactsResult = await chatService.getContacts({ lite: true })
|
||||||
let accountWxids: string[] = []
|
if (!contactsResult.success || !contactsResult.contacts) return []
|
||||||
|
|
||||||
if (account) {
|
|
||||||
accountWxids = [account]
|
|
||||||
} else {
|
|
||||||
const candidates = dbPathService.scanWxids(root)
|
|
||||||
accountWxids = candidates.map(c => c.wxid)
|
|
||||||
}
|
|
||||||
|
|
||||||
const allBizAccounts: Record<string, BizAccount> = {}
|
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 : {}
|
||||||
|
|
||||||
for (const wxid of accountWxids) {
|
const root = this.configService.get('dbPath')
|
||||||
const accountDir = join(root, wxid)
|
const myWxid = this.configService.get('myWxid')
|
||||||
const dbDir = join(accountDir, 'db_storage', 'message')
|
const accountWxid = account || myWxid
|
||||||
if (!existsSync(dbDir)) continue
|
if (!root || !accountWxid) return []
|
||||||
|
|
||||||
const bizDbFiles = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db'))
|
const dbDir = join(root, accountWxid, 'db_storage', 'message')
|
||||||
if (bizDbFiles.length === 0) continue
|
|
||||||
|
|
||||||
const bizIds = new Set<string>()
|
|
||||||
const bizLatestTime: Record<string, number> = {}
|
const bizLatestTime: Record<string, number> = {}
|
||||||
|
|
||||||
for (const file of bizDbFiles) {
|
if (existsSync(dbDir)) {
|
||||||
const dbPath = join(dbDir, file)
|
const bizDbFiles = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db'))
|
||||||
console.log(`path: ${dbPath}`)
|
for (const file of bizDbFiles) {
|
||||||
const name2idRes = await wcdbService.execQuery('biz', dbPath, 'SELECT username FROM Name2Id')
|
const dbPath = join(dbDir, file)
|
||||||
console.log(`name2idRes success: ${name2idRes.success}`)
|
const name2idRes = await wcdbService.execQuery('message', dbPath, 'SELECT username FROM Name2Id')
|
||||||
console.log(`name2idRes length: ${name2idRes.rows?.length}`)
|
if (name2idRes.success && name2idRes.rows) {
|
||||||
|
for (const row of name2idRes.rows) {
|
||||||
if (name2idRes.success && name2idRes.rows) {
|
const uname = row.username || row.user_name
|
||||||
for (const row of name2idRes.rows) {
|
if (uname) {
|
||||||
if (row.username) {
|
const md5 = createHash('md5').update(uname).digest('hex').toLowerCase()
|
||||||
const uname = row.username
|
const tName = `Msg_${md5}`
|
||||||
bizIds.add(uname)
|
const timeRes = await wcdbService.execQuery('message', dbPath, `SELECT MAX(create_time) as max_time FROM ${tName}`)
|
||||||
|
if (timeRes.success && timeRes.rows && timeRes.rows[0]?.max_time) {
|
||||||
const md5Id = createHash('md5').update(uname).digest('hex').toLowerCase()
|
const t = parseInt(timeRes.rows[0].max_time)
|
||||||
const tableName = `Msg_${md5Id}`
|
if (!isNaN(t)) bizLatestTime[uname] = Math.max(bizLatestTime[uname] || 0, t)
|
||||||
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) {
|
|
||||||
const t = timeRes.rows[0].max_time
|
|
||||||
bizLatestTime[uname] = Math.max(bizLatestTime[uname] || 0, t)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bizIds.size === 0) continue
|
const result: BizAccount[] = officialContacts.map(contact => {
|
||||||
|
const uname = contact.username
|
||||||
|
const info = contactInfoMap[uname]
|
||||||
|
const lastTime = bizLatestTime[uname] || 0
|
||||||
|
return {
|
||||||
|
username: uname,
|
||||||
|
name: info?.displayName || contact.displayName || uname,
|
||||||
|
avatar: info?.avatarUrl || '',
|
||||||
|
type: 0,
|
||||||
|
last_time: lastTime,
|
||||||
|
formatted_last_time: lastTime ? new Date(lastTime * 1000).toISOString().split('T')[0] : ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const contactDbPath = join(accountDir, 'contact.db')
|
const contactDbPath = join(root, accountWxid, 'contact.db')
|
||||||
if (existsSync(contactDbPath)) {
|
if (existsSync(contactDbPath)) {
|
||||||
const idsArray = Array.from(bizIds)
|
const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info')
|
||||||
const batchSize = 100
|
if (bizInfoRes.success && bizInfoRes.rows) {
|
||||||
for (let i = 0; i < idsArray.length; i += batchSize) {
|
const typeMap: Record<string, number> = {}
|
||||||
const batch = idsArray.slice(i, i + batchSize)
|
for (const r of bizInfoRes.rows) typeMap[r.username] = r.type
|
||||||
const placeholders = batch.map(() => '?').join(',')
|
for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
|
||||||
|
|
||||||
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,
|
|
||||||
name: name,
|
|
||||||
avatar: r.big_head_url,
|
|
||||||
type: 3,
|
|
||||||
last_time: Math.max(allBizAccounts[uname]?.last_time || 0, bizLatestTime[uname] || 0),
|
|
||||||
formatted_last_time: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bizInfoRes = await wcdbService.execQuery('biz', contactDbPath,
|
|
||||||
`SELECT username, type FROM biz_info WHERE username IN (${placeholders})`,
|
|
||||||
batch
|
|
||||||
)
|
|
||||||
if (bizInfoRes.success && bizInfoRes.rows) {
|
|
||||||
for (const r of bizInfoRes.rows) {
|
|
||||||
if (allBizAccounts[r.username]) {
|
|
||||||
allBizAccounts[r.username].type = r.type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const result = Object.values(allBizAccounts).map(acc => ({
|
return result.sort((a, b) => {
|
||||||
...acc,
|
if (a.username === 'gh_3dfda90e39d6') return -1
|
||||||
formatted_last_time: acc.last_time ? new Date(acc.last_time * 1000).toISOString().split('T')[0] : ''
|
if (b.username === 'gh_3dfda90e39d6') return 1
|
||||||
})).sort((a, b) => {
|
return b.last_time - a.last_time
|
||||||
// 微信支付强制置顶
|
})
|
||||||
if (a.username === 'gh_3dfda90e39d6') return -1
|
} catch (e) { return [] }
|
||||||
if (b.username === 'gh_3dfda90e39d6') return 1
|
|
||||||
return b.last_time - a.last_time
|
|
||||||
})
|
|
||||||
|
|
||||||
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')
|
|
||||||
|
const bizMessages: BizMessage[] = rawMessages.map(msg => {
|
||||||
if (!existsSync(dbDir)) return []
|
const bizMsg: BizMessage = {
|
||||||
const files = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db'))
|
local_id: msg.localId,
|
||||||
let targetDb: string | null = null
|
create_time: msg.createTime,
|
||||||
|
title: msg.linkTitle || msg.parsedContent || '',
|
||||||
for (const file of files) {
|
des: msg.appMsgDesc || '',
|
||||||
const dbPath = join(dbDir, file)
|
url: msg.linkUrl || '',
|
||||||
const checkRes = await wcdbService.execQuery('biz', dbPath, `SELECT name FROM sqlite_master WHERE type='table' AND lower(name)='${tableName}'`)
|
cover: msg.linkThumb || msg.appMsgThumbUrl || '',
|
||||||
if (checkRes.success && checkRes.rows && checkRes.rows.length > 0) {
|
content_list: []
|
||||||
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 != 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
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
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
|
||||||
|
})
|
||||||
|
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 records: BizPayRecord[] = []
|
||||||
const dbDir = join(accountDir, 'db_storage')
|
for (const msg of rawMessages) {
|
||||||
|
if (!msg.rawContent) continue
|
||||||
if (!existsSync(dbDir)) return []
|
const parsedData = this.parsePayXml(msg.rawContent)
|
||||||
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[] = []
|
|
||||||
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 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
|
||||||
|
} catch (e) { return [] }
|
||||||
return records
|
|
||||||
}
|
}
|
||||||
|
|
||||||
registerHandlers() {
|
registerHandlers() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -60,42 +59,42 @@ export const BizAccountList: React.FC<{
|
|||||||
if (!searchKeyword) return accounts;
|
if (!searchKeyword) return accounts;
|
||||||
const q = searchKeyword.toLowerCase();
|
const q = searchKeyword.toLowerCase();
|
||||||
return accounts.filter(a =>
|
return accounts.filter(a =>
|
||||||
(a.name && a.name.toLowerCase().includes(q)) ||
|
(a.name && a.name.toLowerCase().includes(q)) ||
|
||||||
(a.username && a.username.toLowerCase().includes(q))
|
(a.username && a.username.toLowerCase().includes(q))
|
||||||
);
|
);
|
||||||
}, [accounts, searchKeyword]);
|
}, [accounts, searchKeyword]);
|
||||||
|
|
||||||
if (loading) return <div className="biz-loading">加载中...</div>;
|
if (loading) return <div className="biz-loading">加载中...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="biz-account-list">
|
<div className="biz-account-list">
|
||||||
{filtered.map(item => (
|
{filtered.map(item => (
|
||||||
<div
|
<div
|
||||||
key={item.username}
|
key={item.username}
|
||||||
onClick={() => onSelect(item)}
|
onClick={() => onSelect(item)}
|
||||||
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=""
|
||||||
/>
|
/>
|
||||||
<div className="biz-info">
|
<div className="biz-info">
|
||||||
<div className="biz-info-top">
|
<div className="biz-info-top">
|
||||||
<span className="biz-name">{item.name || item.username}</span>
|
<span className="biz-name">{item.name || item.username}</span>
|
||||||
<span className="biz-time">{item.formatted_last_time}</span>
|
<span className="biz-time">{item.formatted_last_time}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`biz-badge ${
|
||||||
|
item.type === 1 ? 'type-service' :
|
||||||
|
item.type === 0 ? 'type-sub' :
|
||||||
|
item.type === 2 ? 'type-enterprise' : 'type-unknown'
|
||||||
|
}`}>
|
||||||
|
{item.type === 0 ? '服务号' : item.type === 1 ? '订阅号' : item.type === 2 ? '企业号' : '未知'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`biz-badge ${
|
))}
|
||||||
item.type === 1 ? 'type-service' :
|
</div>
|
||||||
item.type === 0 ? 'type-sub' :
|
|
||||||
item.type === 2 ? 'type-enterprise' : 'type-unknown'
|
|
||||||
}`}>
|
|
||||||
{item.type === 1 ? '服务号' : item.type === 0 ? '订阅号' : item.type === 2 ? '企业号' : '未知'}
|
|
||||||
</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
|
||||||
@@ -165,59 +184,59 @@ export const BizMessageArea: React.FC<{
|
|||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return (
|
return (
|
||||||
<div className="biz-empty-state">
|
<div className="biz-empty-state">
|
||||||
<div className="empty-icon"><Newspaper size={40} /></div>
|
<div className="empty-icon"><Newspaper size={40} /></div>
|
||||||
<p>请选择一个服务号查看消息</p>
|
<p>请选择一个服务号查看消息</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg==';
|
const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg==';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`biz-main ${isDark ? 'dark' : ''}`}>
|
<div className={`biz-main ${isDark ? 'dark' : ''}`}>
|
||||||
<div className="main-header">
|
<div className="main-header">
|
||||||
<h2>{account.name}</h2>
|
<h2>{account.name}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="message-container" onScroll={handleScroll} ref={messageListRef}>
|
<div className="message-container" onScroll={handleScroll} ref={messageListRef}>
|
||||||
<div className="messages-wrapper">
|
<div className="messages-wrapper">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<div key={msg.local_id}>
|
<div key={msg.local_id}>
|
||||||
{account.username === 'gh_3dfda90e39d6' ? (
|
{account.username === 'gh_3dfda90e39d6' ? (
|
||||||
<div className="pay-card">
|
<div className="pay-card">
|
||||||
<div className="pay-header">
|
<div className="pay-header">
|
||||||
{msg.merchant_icon ? <img src={msg.merchant_icon} className="pay-icon" alt=""/> : <div className="pay-icon placeholder">¥</div>}
|
{msg.merchant_icon ? <img src={msg.merchant_icon} className="pay-icon" alt=""/> : <div className="pay-icon placeholder">¥</div>}
|
||||||
<span>{msg.merchant_name || '微信支付'}</span>
|
<span>{msg.merchant_name || '微信支付'}</span>
|
||||||
</div>
|
|
||||||
<div className="pay-title">{msg.title}</div>
|
|
||||||
<div className="pay-desc">{msg.description}</div>
|
|
||||||
<div className="pay-footer">{msg.formatted_time}</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="article-card">
|
|
||||||
<div onClick={() => window.electronAPI.shell.openExternal(msg.url)} className="main-article">
|
|
||||||
<img src={msg.cover || defaultImage} className="article-cover" alt=""/>
|
|
||||||
<div className="article-overlay"><h3 className="article-title">{msg.title}</h3></div>
|
|
||||||
</div>
|
|
||||||
{msg.des && <div className="article-digest">{msg.des}</div>}
|
|
||||||
{msg.content_list && msg.content_list.length > 0 && (
|
|
||||||
<div className="sub-articles">
|
|
||||||
{msg.content_list.map((item: any, idx: number) => (
|
|
||||||
<div key={idx} onClick={() => window.electronAPI.shell.openExternal(item.url)} className="sub-item">
|
|
||||||
<span className="sub-title">{item.title}</span>
|
|
||||||
{item.cover && <img src={item.cover} className="sub-cover" alt=""/>}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="pay-title">{msg.title}</div>
|
||||||
</div>
|
<div className="pay-desc">{msg.description}</div>
|
||||||
|
<div className="pay-footer">{msg.formatted_time}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="article-card">
|
||||||
|
<div onClick={() => window.electronAPI.shell.openExternal(msg.url)} className="main-article">
|
||||||
|
<img src={msg.cover || defaultImage} className="article-cover" alt=""/>
|
||||||
|
<div className="article-overlay"><h3 className="article-title">{msg.title}</h3></div>
|
||||||
|
</div>
|
||||||
|
{msg.des && <div className="article-digest">{msg.des}</div>}
|
||||||
|
{msg.content_list && msg.content_list.length > 0 && (
|
||||||
|
<div className="sub-articles">
|
||||||
|
{msg.content_list.map((item: any, idx: number) => (
|
||||||
|
<div key={idx} onClick={() => window.electronAPI.shell.openExternal(item.url)} className="sub-item">
|
||||||
|
<span className="sub-title">{item.title}</span>
|
||||||
|
{item.cover && <img src={item.cover} className="sub-cover" alt=""/>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
{loading && <div className="biz-loading-more">加载中...</div>}
|
||||||
))}
|
</div>
|
||||||
{loading && <div className="biz-loading-more">加载中...</div>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -225,13 +244,13 @@ export const BizMessageArea: React.FC<{
|
|||||||
const BizPage: React.FC = () => {
|
const BizPage: React.FC = () => {
|
||||||
const [selectedAccount, setSelectedAccount] = useState<BizAccount | null>(null);
|
const [selectedAccount, setSelectedAccount] = useState<BizAccount | null>(null);
|
||||||
return (
|
return (
|
||||||
<div className="biz-page">
|
<div className="biz-page">
|
||||||
<div className="biz-sidebar">
|
<div className="biz-sidebar">
|
||||||
<BizAccountList onSelect={setSelectedAccount} selectedUsername={selectedAccount?.username} />
|
<BizAccountList onSelect={setSelectedAccount} selectedUsername={selectedAccount?.username} />
|
||||||
|
</div>
|
||||||
|
<BizMessageArea account={selectedAccount} />
|
||||||
</div>
|
</div>
|
||||||
<BizMessageArea account={selectedAccount} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BizPage;
|
export default BizPage;
|
||||||
Reference in New Issue
Block a user