diff --git a/electron/main.ts b/electron/main.ts index a035c37..c4eef38 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -30,7 +30,7 @@ import { cloudControlService } from './services/cloudControlService' import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow' import { httpService } from './services/httpService' import { messagePushService } from './services/messagePushService' - +import { bizService } from './services/bizService' // 配置自动更新 autoUpdater.autoDownload = false @@ -1206,6 +1206,7 @@ const removeMatchedEntriesInDir = async ( // 注册 IPC 处理器 function registerIpcHandlers() { registerNotificationHandlers() + bizService.registerHandlers() // 配置相关 ipcMain.handle('config:get', async (_, key: string) => { return configService?.get(key as any) diff --git a/electron/preload.ts b/electron/preload.ts index bfa151d..38e722f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -413,6 +413,14 @@ contextBridge.exposeInMainWorld('electronAPI', { downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params) }, + biz: { + listAccounts: (account?: string) => ipcRenderer.invoke('biz:listAccounts', account), + listMessages: (username: string, account?: string, limit?: number, offset?: number) => + ipcRenderer.invoke('biz:listMessages', username, account, limit, offset), + listPayRecords: (account?: string, limit?: number, offset?: number) => + ipcRenderer.invoke('biz:listPayRecords', account, limit, offset) + }, + // 数据收集 cloud: { diff --git a/electron/services/bizService.ts b/electron/services/bizService.ts new file mode 100644 index 0000000..110b22c --- /dev/null +++ b/electron/services/bizService.ts @@ -0,0 +1,221 @@ +import { join } from 'path' +import { readdirSync, existsSync } from 'fs' +import { wcdbService } from './wcdbService' +import { ConfigService } from './config' +import { chatService, Message } from './chatService' +import { ipcMain } from 'electron' +import { createHash } from 'crypto' + +export interface BizAccount { + username: string + name: string + avatar: string + type: number + last_time: number + formatted_last_time: string +} + +export interface BizMessage { + local_id: number + create_time: number + title: string + des: string + url: string + cover: string + content_list: any[] +} + +export interface BizPayRecord { + local_id: number + create_time: number + title: string + description: string + merchant_name: string + merchant_icon: string + timestamp: number + formatted_time: string +} + +export class BizService { + private configService: ConfigService + + constructor() { + this.configService = new ConfigService() + } + + private extractXmlValue(xml: string, tagName: string): string { + const regex = new RegExp(`<${tagName}>([\\s\\S]*?)`, 'i') + const match = regex.exec(xml) + if (match) { + return match[1].replace(//g, '').trim() + } + return '' + } + + private parseBizContentList(xmlStr: string): any[] { + if (!xmlStr) return [] + const contentList: any[] = [] + try { + const itemRegex = /([\s\S]*?)<\/item>/gi + let match: RegExpExecArray | null + while ((match = itemRegex.exec(xmlStr)) !== null) { + const itemXml = match[1] + const itemStruct = { + title: this.extractXmlValue(itemXml, 'title'), + url: this.extractXmlValue(itemXml, 'url'), + cover: this.extractXmlValue(itemXml, 'cover') || this.extractXmlValue(itemXml, 'thumburl'), + summary: this.extractXmlValue(itemXml, 'summary') || this.extractXmlValue(itemXml, 'digest') + } + if (itemStruct.title) contentList.push(itemStruct) + } + } catch (e) { } + return contentList + } + + private parsePayXml(xmlStr: string): any { + if (!xmlStr) return null + try { + const title = this.extractXmlValue(xmlStr, 'title') + const description = this.extractXmlValue(xmlStr, 'des') + const merchantName = this.extractXmlValue(xmlStr, 'display_name') || '微信支付' + 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 } + } + + async listAccounts(account?: string): Promise { + 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 = {} + + 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) { + for (const row of name2idRes.rows) { + const uname = row.username || row.user_name + if (uname) { + const md5 = createHash('md5').update(uname).digest('hex').toLowerCase() + const tName = `Msg_${md5}` + 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 t = parseInt(timeRes.rows[0].max_time) + if (!isNaN(t)) bizLatestTime[uname] = Math.max(bizLatestTime[uname] || 0, t) + } + } + } + } + } + } + + 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(root, accountWxid, 'contact.db') + if (existsSync(contactDbPath)) { + const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info') + if (bizInfoRes.success && bizInfoRes.rows) { + const typeMap: Record = {} + for (const r of bizInfoRes.rows) typeMap[r.username] = r.type + for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username] + } + } + + return result + .filter(acc => !acc.name.includes('朋友圈广告')) + .sort((a, b) => { + if (a.username === 'gh_3dfda90e39d6') return -1 + if (b.username === 'gh_3dfda90e39d6') return 1 + return b.last_time - a.last_time + }) + } catch (e) { return [] } + } + + async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise { + try { + // 仅保留核心路径:利用 chatService 的自动路由能力 + const res = await chatService.getMessages(username, offset, limit) + if (!res.success || !res.messages) return [] + + return res.messages.map(msg => { + const bizMsg: BizMessage = { + local_id: msg.localId, + create_time: msg.createTime, + title: msg.linkTitle || msg.parsedContent || '', + des: msg.appMsgDesc || '', + url: msg.linkUrl || '', + cover: msg.linkThumb || msg.appMsgThumbUrl || '', + content_list: [] + } + 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 + }) + } catch (e) { return [] } + } + + async listPayRecords(account?: string, limit: number = 20, offset: number = 0): Promise { + const username = 'gh_3dfda90e39d6' + try { + const res = await chatService.getMessages(username, offset, limit) + if (!res.success || !res.messages) return [] + + const records: BizPayRecord[] = [] + for (const msg of res.messages) { + if (!msg.rawContent) continue + const parsedData = this.parsePayXml(msg.rawContent) + if (parsedData) { + records.push({ + local_id: msg.localId, + create_time: msg.createTime, + ...parsedData, + timestamp: parsedData.timestamp || msg.createTime, + formatted_time: new Date((parsedData.timestamp || msg.createTime) * 1000).toLocaleString() + }) + } + } + return records + } catch (e) { return [] } + } + + registerHandlers() { + ipcMain.handle('biz:listAccounts', (_, account) => this.listAccounts(account)) + ipcMain.handle('biz:listMessages', (_, username, account, limit, offset) => this.listMessages(username, account, limit, offset)) + ipcMain.handle('biz:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset)) + } +} + +export const bizService = new BizService() diff --git a/src/App.tsx b/src/App.tsx index d5f2512..4ad162e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import ExportPage from './pages/ExportPage' import VideoWindow from './pages/VideoWindow' import ImageWindow from './pages/ImageWindow' import SnsPage from './pages/SnsPage' +import BizPage from './pages/BizPage' import ContactsPage from './pages/ContactsPage' import ChatHistoryPage from './pages/ChatHistoryPage' import NotificationWindow from './pages/NotificationWindow' @@ -736,6 +737,7 @@ function App() {