From fa5575592101125d8cc7c4cf3d7e7f51cb427bab Mon Sep 17 00:00:00 2001 From: hejk Date: Tue, 24 Mar 2026 14:58:42 +0800 Subject: [PATCH 01/39] feat(http): add sns HTTP API endpoints --- docs/HTTP-API.md | 117 ++++++++++- electron/services/httpService.ts | 324 ++++++++++++++++++++++++++++++- 2 files changed, 437 insertions(+), 4 deletions(-) diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index 052dd8a..bc25545 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -433,7 +433,118 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include --- -## 7. 访问导出媒体 +## 7. 朋友圈接口 + +### 7.1 获取朋友圈时间线 + +```http +GET /api/v1/sns/timeline +``` + +参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `limit` | number | 否 | 返回数量,默认 20,范围 `1~200` | +| `offset` | number | 否 | 偏移量,默认 0 | +| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` | +| `keyword` | string | 否 | 关键词过滤(正文) | +| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 | +| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 | +| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` | +| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` | +| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL,默认 `0` | + +示例: + +```bash +curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=5" +curl "http://127.0.0.1:5031/api/v1/sns/timeline?usernames=wxid_a,wxid_b&keyword=旅行" +curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&replace=1" +curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&inline=1" +``` + +媒体字段说明(`media=1`): + +- `media[].rawUrl/rawThumb`:原始朋友圈地址 +- `media[].proxyUrl/proxyThumbUrl`:可直接访问的代理地址 +- `media[].resolvedUrl/resolvedThumbUrl`:最终可用地址(`inline=1` 时可能是 `data:` URL) +- `replace=1` 时,`media[].url/thumb` 会被替换为可用地址 + +### 7.2 获取朋友圈发布者 + +```http +GET /api/v1/sns/usernames +``` + +### 7.3 获取朋友圈导出统计 + +```http +GET /api/v1/sns/export/stats +``` + +参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `fast` | number | 否 | `1` 使用快速统计(优先缓存) | + +### 7.4 朋友圈媒体代理 + +```http +GET /api/v1/sns/media/proxy +``` + +参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `url` | string | 是 | 媒体原始 URL | +| `key` | string/number | 否 | 解密 key(部分资源需要) | + +### 7.5 导出朋友圈 + +```http +POST /api/v1/sns/export +Content-Type: application/json +``` + +Body 示例: + +```json +{ + "outputDir": "C:\\Users\\Alice\\Desktop\\sns-export", + "format": "json", + "usernames": "wxid_a,wxid_b", + "keyword": "旅行", + "exportMedia": true, + "exportImages": true, + "exportLivePhotos": true, + "exportVideos": true, + "start": "20250101", + "end": "20251231" +} +``` + +`format` 支持:`json`、`html`、`arkmejson`(兼容写法:`arkme-json`)。 + +### 7.6 朋友圈防删开关 + +```http +GET /api/v1/sns/block-delete/status +POST /api/v1/sns/block-delete/install +POST /api/v1/sns/block-delete/uninstall +``` + +### 7.7 删除单条朋友圈 + +```http +DELETE /api/v1/sns/post/{postId} +``` + +--- + +## 8. 访问导出媒体 > 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json) @@ -476,7 +587,7 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif" --- -## 8. 使用示例 +## 9. 使用示例 ### PowerShell @@ -525,7 +636,7 @@ members = requests.get( --- -## 9. 注意事项 +## 10. 注意事项 1. API 仅监听本机 `127.0.0.1`,不对外网开放。 2. 使用前需要先在 WeFlow 中完成数据库连接。 diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 02fa030..c757db2 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -12,6 +12,7 @@ import { ConfigService } from './config' import { videoService } from './videoService' import { imageDecryptService } from './imageDecryptService' import { groupAnalyticsService } from './groupAnalyticsService' +import { snsService } from './snsService' // ChatLab 格式定义 interface ChatLabHeader { @@ -308,7 +309,7 @@ class HttpService { */ private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { res.setHeader('Access-Control-Allow-Origin', '*') - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') if (req.method === 'OPTIONS') { @@ -348,6 +349,33 @@ class HttpService { await this.handleContacts(url, res) } else if (pathname === '/api/v1/group-members') { await this.handleGroupMembers(url, res) + } else if (pathname === '/api/v1/sns/timeline') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsTimeline(url, res) + } else if (pathname === '/api/v1/sns/usernames') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsUsernames(res) + } else if (pathname === '/api/v1/sns/export/stats') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsExportStats(url, res) + } else if (pathname === '/api/v1/sns/media/proxy') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsMediaProxy(url, res) + } else if (pathname === '/api/v1/sns/export') { + if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST') + await this.handleSnsExport(url, res) + } else if (pathname === '/api/v1/sns/block-delete/status') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsBlockDeleteStatus(res) + } else if (pathname === '/api/v1/sns/block-delete/install') { + if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST') + await this.handleSnsBlockDeleteInstall(res) + } else if (pathname === '/api/v1/sns/block-delete/uninstall') { + if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST') + await this.handleSnsBlockDeleteUninstall(res) + } else if (pathname.startsWith('/api/v1/sns/post/')) { + if (req.method !== 'DELETE') return this.sendMethodNotAllowed(res, 'DELETE') + await this.handleSnsDeletePost(pathname, res) } else if (pathname.startsWith('/api/v1/media/')) { this.handleMediaRequest(pathname, res) } else { @@ -559,6 +587,15 @@ class HttpService { return defaultValue } + private parseStringListParam(value: string | null): string[] | undefined { + if (!value) return undefined + const values = value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + return values.length > 0 ? Array.from(new Set(values)) : undefined + } + private parseMediaOptions(url: URL): ApiMediaOptions { const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false) if (!mediaEnabled) { @@ -790,6 +827,286 @@ class HttpService { } } + private async handleSnsTimeline(url: URL, res: http.ServerResponse): Promise { + const limit = this.parseIntParam(url.searchParams.get('limit'), 20, 1, 200) + const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER) + const usernames = this.parseStringListParam(url.searchParams.get('usernames')) + const keyword = (url.searchParams.get('keyword') || '').trim() || undefined + const resolveMedia = this.parseBooleanParam(url, ['media', 'resolveMedia', 'meiti'], true) + const inlineMedia = resolveMedia && this.parseBooleanParam(url, ['inline'], false) + const replaceMedia = resolveMedia && this.parseBooleanParam(url, ['replace'], true) + const startTimeRaw = this.parseTimeParam(url.searchParams.get('start')) + const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true) + const startTime = startTimeRaw > 0 ? startTimeRaw : undefined + const endTime = endTimeRaw > 0 ? endTimeRaw : undefined + + const result = await snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to get sns timeline') + return + } + + let timeline = result.timeline || [] + if (resolveMedia && timeline.length > 0) { + timeline = await this.enrichSnsTimelineMedia(timeline, inlineMedia, replaceMedia) + } + + this.sendJson(res, { + success: true, + count: timeline.length, + timeline + }) + } + + private async handleSnsUsernames(res: http.ServerResponse): Promise { + const result = await snsService.getSnsUsernames() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to get sns usernames') + return + } + this.sendJson(res, { + success: true, + usernames: result.usernames || [] + }) + } + + private async handleSnsExportStats(url: URL, res: http.ServerResponse): Promise { + const fast = this.parseBooleanParam(url, ['fast'], false) + const result = fast + ? await snsService.getExportStatsFast() + : await snsService.getExportStats() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to get sns export stats') + return + } + this.sendJson(res, result) + } + + private async handleSnsMediaProxy(url: URL, res: http.ServerResponse): Promise { + const mediaUrl = (url.searchParams.get('url') || '').trim() + if (!mediaUrl) { + this.sendError(res, 400, 'Missing required parameter: url') + return + } + + const key = this.toSnsMediaKey(url.searchParams.get('key')) + const result = await snsService.downloadImage(mediaUrl, key) + if (!result.success || !result.data) { + this.sendError(res, 502, result.error || 'Failed to proxy sns media') + return + } + + res.setHeader('Content-Type', result.contentType || 'application/octet-stream') + res.setHeader('Content-Length', result.data.length) + res.writeHead(200) + res.end(result.data) + } + + private async handleSnsExport(url: URL, res: http.ServerResponse): Promise { + const outputDir = String(url.searchParams.get('outputDir') || '').trim() + if (!outputDir) { + this.sendError(res, 400, 'Missing required field: outputDir') + return + } + + const rawFormat = String(url.searchParams.get('format') || 'json').trim().toLowerCase() + const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat + if (!['json', 'html', 'arkmejson'].includes(format)) { + this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson') + return + } + + const usernames = this.parseStringListParam(url.searchParams.get('usernames')) + const keyword = String(url.searchParams.get('keyword') || '').trim() || undefined + const startTimeRaw = this.parseTimeParam(url.searchParams.get('start')) + const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true) + + const options: { + outputDir: string + format: 'json' | 'html' | 'arkmejson' + usernames?: string[] + keyword?: string + exportMedia?: boolean + exportImages?: boolean + exportLivePhotos?: boolean + exportVideos?: boolean + startTime?: number + endTime?: number + } = { + outputDir, + format: format as 'json' | 'html' | 'arkmejson', + usernames, + keyword, + exportMedia: this.parseBooleanParam(url, ['exportMedia'], false) + } + + if (url.searchParams.has('exportImages')) options.exportImages = this.parseBooleanParam(url, ['exportImages'], false) + if (url.searchParams.has('exportLivePhotos')) options.exportLivePhotos = this.parseBooleanParam(url, ['exportLivePhotos'], false) + if (url.searchParams.has('exportVideos')) options.exportVideos = this.parseBooleanParam(url, ['exportVideos'], false) + if (startTimeRaw > 0) options.startTime = startTimeRaw + if (endTimeRaw > 0) options.endTime = endTimeRaw + + const result = await snsService.exportTimeline(options) + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to export sns timeline') + return + } + this.sendJson(res, result) + } + + private async handleSnsBlockDeleteStatus(res: http.ServerResponse): Promise { + const result = await snsService.checkSnsBlockDeleteTrigger() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to check sns block-delete status') + return + } + this.sendJson(res, result) + } + + private async handleSnsBlockDeleteInstall(res: http.ServerResponse): Promise { + const result = await snsService.installSnsBlockDeleteTrigger() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to install sns block-delete trigger') + return + } + this.sendJson(res, result) + } + + private async handleSnsBlockDeleteUninstall(res: http.ServerResponse): Promise { + const result = await snsService.uninstallSnsBlockDeleteTrigger() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to uninstall sns block-delete trigger') + return + } + this.sendJson(res, result) + } + + private async handleSnsDeletePost(pathname: string, res: http.ServerResponse): Promise { + const postId = decodeURIComponent(pathname.replace('/api/v1/sns/post/', '')).trim() + if (!postId) { + this.sendError(res, 400, 'Missing required path parameter: postId') + return + } + + const result = await snsService.deleteSnsPost(postId) + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to delete sns post') + return + } + this.sendJson(res, result) + } + + private toSnsMediaKey(value: unknown): string | number | undefined { + if (value == null) return undefined + if (typeof value === 'number' && Number.isFinite(value)) return value + const text = String(value).trim() + if (!text) return undefined + if (/^-?\d+$/.test(text)) return Number(text) + return text + } + + private buildSnsMediaProxyUrl(rawUrl: string, key?: string | number): string | undefined { + const target = String(rawUrl || '').trim() + if (!target) return undefined + const params = new URLSearchParams({ url: target }) + if (key !== undefined) params.set('key', String(key)) + return `http://${this.host}:${this.port}/api/v1/sns/media/proxy?${params.toString()}` + } + + private async resolveSnsMediaUrl( + rawUrl: string, + key: string | number | undefined, + inline: boolean + ): Promise<{ resolvedUrl?: string; proxyUrl?: string }> { + const proxyUrl = this.buildSnsMediaProxyUrl(rawUrl, key) + if (!proxyUrl) return {} + if (!inline) return { resolvedUrl: proxyUrl, proxyUrl } + + try { + const resolved = await snsService.proxyImage(rawUrl, key) + if (resolved.success && resolved.dataUrl) { + return { resolvedUrl: resolved.dataUrl, proxyUrl } + } + } catch (error) { + console.warn('[HttpService] resolveSnsMediaUrl inline failed:', error) + } + + return { resolvedUrl: proxyUrl, proxyUrl } + } + + private async enrichSnsTimelineMedia(posts: any[], inline: boolean, replace: boolean): Promise { + return Promise.all( + (posts || []).map(async (post) => { + const mediaList = Array.isArray(post?.media) ? post.media : [] + if (mediaList.length === 0) return post + + const nextMedia = await Promise.all( + mediaList.map(async (media: any) => { + const rawUrl = typeof media?.url === 'string' ? media.url : '' + const rawThumb = typeof media?.thumb === 'string' ? media.thumb : '' + const mediaKey = this.toSnsMediaKey(media?.key) + + const [urlResolved, thumbResolved] = await Promise.all([ + this.resolveSnsMediaUrl(rawUrl, mediaKey, inline), + this.resolveSnsMediaUrl(rawThumb, mediaKey, inline) + ]) + + const nextItem: any = { + ...media, + rawUrl, + rawThumb, + resolvedUrl: urlResolved.resolvedUrl, + resolvedThumbUrl: thumbResolved.resolvedUrl, + proxyUrl: urlResolved.proxyUrl, + proxyThumbUrl: thumbResolved.proxyUrl + } + + if (replace) { + nextItem.url = urlResolved.resolvedUrl || rawUrl + nextItem.thumb = thumbResolved.resolvedUrl || rawThumb + } + + if (media?.livePhoto && typeof media.livePhoto === 'object') { + const livePhoto = media.livePhoto + const rawLiveUrl = typeof livePhoto.url === 'string' ? livePhoto.url : '' + const rawLiveThumb = typeof livePhoto.thumb === 'string' ? livePhoto.thumb : '' + const liveKey = this.toSnsMediaKey(livePhoto.key ?? mediaKey) + + const [liveUrlResolved, liveThumbResolved] = await Promise.all([ + this.resolveSnsMediaUrl(rawLiveUrl, liveKey, inline), + this.resolveSnsMediaUrl(rawLiveThumb, liveKey, inline) + ]) + + const nextLive: any = { + ...livePhoto, + rawUrl: rawLiveUrl, + rawThumb: rawLiveThumb, + resolvedUrl: liveUrlResolved.resolvedUrl, + resolvedThumbUrl: liveThumbResolved.resolvedUrl, + proxyUrl: liveUrlResolved.proxyUrl, + proxyThumbUrl: liveThumbResolved.proxyUrl + } + + if (replace) { + nextLive.url = liveUrlResolved.resolvedUrl || rawLiveUrl + nextLive.thumb = liveThumbResolved.resolvedUrl || rawLiveThumb + } + + nextItem.livePhoto = nextLive + } + + return nextItem + }) + ) + + return { + ...post, + media: nextMedia + } + }) + ) + } + private getApiMediaExportPath(): string { return path.join(this.configService.getCacheBasePath(), 'api-media') } @@ -1451,6 +1768,11 @@ class HttpService { res.end(JSON.stringify(data, null, 2)) } + private sendMethodNotAllowed(res: http.ServerResponse, allow: string): void { + res.setHeader('Allow', allow) + this.sendError(res, 405, `Method Not Allowed. Allowed: ${allow}`) + } + /** * 发送错误响应 */ From 0162769d2266f45515bed113b07bfad11eda3bef Mon Sep 17 00:00:00 2001 From: hejk Date: Tue, 24 Mar 2026 15:34:55 +0800 Subject: [PATCH 02/39] fix(sns): fallback usernames from timeline when SQL result is empty --- electron/services/snsService.ts | 43 ++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index e0d31f9..b23d26f 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -537,6 +537,32 @@ class SnsService { return raw.trim() } + private async collectSnsUsernamesFromTimeline(maxRounds: number = 2000): Promise { + const pageSize = 500 + const uniqueUsers = new Set() + let offset = 0 + + for (let round = 0; round < maxRounds; round++) { + const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0) + if (!result.success || !Array.isArray(result.timeline)) { + throw new Error(result.error || '获取朋友圈发布者失败') + } + + const rows = result.timeline + if (rows.length === 0) break + + for (const row of rows) { + const username = this.pickTimelineUsername(row) + if (username) uniqueUsers.add(username) + } + + if (rows.length < pageSize) break + offset += rows.length + } + + return Array.from(uniqueUsers) + } + private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> { const pageSize = 500 const uniqueUsers = new Set() @@ -794,7 +820,22 @@ class SnsService { if (!result.success) { return { success: false, error: result.error || '获取朋友圈联系人失败' } } - return { success: true, usernames: result.usernames || [] } + const directUsernames = Array.isArray(result.usernames) ? result.usernames : [] + if (directUsernames.length > 0) { + return { success: true, usernames: directUsernames } + } + + // 回退:通过 timeline 分页拉取收集用户名,兼容底层接口暂时返回空数组的场景。 + try { + const timelineUsers = await this.collectSnsUsernamesFromTimeline() + if (timelineUsers.length > 0) { + return { success: true, usernames: timelineUsers } + } + } catch { + // 忽略回退错误,保持与原行为一致返回空数组 + } + + return { success: true, usernames: directUsernames } } private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> { From 73a948c528bfcc327b762ec1c60fadec86b046e6 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Mon, 30 Mar 2026 20:36:20 +0800 Subject: [PATCH 03/39] =?UTF-8?q?feat:=20=E5=88=9D=E6=AD=A5=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E6=9C=8D=E5=8A=A1=E5=8F=B7/=E5=85=AC=E4=BC=97?= =?UTF-8?q?=E5=8F=B7=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 3 +- electron/preload.ts | 8 + electron/services/bizService.ts | 376 ++++++++++++++++++++++++++++++++ src/App.tsx | 2 + src/pages/BizPage.scss | 293 +++++++++++++++++++++++++ src/pages/BizPage.tsx | 237 ++++++++++++++++++++ src/pages/ChatPage.tsx | 108 ++++++--- src/types/electron.d.ts | 5 + 8 files changed, 1004 insertions(+), 28 deletions(-) create mode 100644 electron/services/bizService.ts create mode 100644 src/pages/BizPage.scss create mode 100644 src/pages/BizPage.tsx diff --git a/electron/main.ts b/electron/main.ts index bf22d19..e9a353a 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 @@ -1110,6 +1110,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..fb9fb1e --- /dev/null +++ b/electron/services/bizService.ts @@ -0,0 +1,376 @@ +import { join } from 'path' +import { readdirSync, existsSync } from 'fs' +import { wcdbService } from './wcdbService' +import { dbPathService } from './dbPathService' +import { ConfigService } from './config' +import * as fzstd from 'fzstd' +import { DOMParser } from '@xmldom/xmldom' +import { ipcMain } from 'electron' +import { createHash } from 'crypto' +import {ContactCacheService} from "./contactCacheService"; + +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 getAccountDir(account?: string): string { + const root = dbPathService.getDefaultPath() + if (account) { + return join(root, account) + } + // Default to the first scanned account if no account specified + const candidates = dbPathService.scanWxids(root) + if (candidates.length > 0) { + return join(root, candidates[0].wxid) + } + return root + } + + private decompressZstd(data: Buffer): string { + if (!data || data.length < 4) return data.toString('utf-8') + const magic = data.readUInt32LE(0) + if (magic !== 0xFD2FB528) { + return data.toString('utf-8') + } + try { + const decompressed = fzstd.decompress(data) + return Buffer.from(decompressed).toString('utf-8') + } catch (e) { + console.error('[BizService] Zstd decompression failed:', e) + 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 = { + title: q(item, 'title'), + url: q(item, 'url'), + cover: q(item, 'cover'), + summary: q(item, 'summary') + } + if (itemStruct.title) { + result.content_list.push(itemStruct) + } + } + + return result + } catch (e) { + console.error('[BizService] XML parse failed:', e) + return null + } + } + + private parsePayXml(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] + const header = doc.getElementsByTagName('template_header')[0] + + const record = { + title: appMsg ? q(appMsg, 'title') : '', + description: appMsg ? q(appMsg, 'des') : '', + merchant_name: header ? q(header, 'display_name') : '微信支付', + merchant_icon: header ? q(header, 'icon_url') : '', + 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 { + const root = this.configService.get('dbPath') + console.log(root) + let accountWxids: string[] = [] + + if (account) { + accountWxids = [account] + } else { + const candidates = dbPathService.scanWxids(root) + accountWxids = candidates.map(c => c.wxid) + } + + const allBizAccounts: Record = {} + + for (const wxid of accountWxids) { + const accountDir = join(root, wxid) + const dbDir = join(accountDir, 'db_storage', 'message') + if (!existsSync(dbDir)) continue + + const bizDbFiles = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db')) + if (bizDbFiles.length === 0) continue + + const bizIds = new Set() + const bizLatestTime: Record = {} + + for (const file of bizDbFiles) { + const dbPath = join(dbDir, file) + console.log(`path: ${dbPath}`) + const name2idRes = await wcdbService.execQuery('biz', dbPath, 'SELECT username FROM Name2Id') + console.log(`name2idRes success: ${name2idRes.success}`) + console.log(`name2idRes length: ${name2idRes.rows?.length}`) + + if (name2idRes.success && name2idRes.rows) { + for (const row of name2idRes.rows) { + if (row.username) { + const uname = row.username + bizIds.add(uname) + + const md5Id = createHash('md5').update(uname).digest('hex').toLowerCase() + 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) { + const t = timeRes.rows[0].max_time + bizLatestTime[uname] = Math.max(bizLatestTime[uname] || 0, t) + } + } + } + } + } + + if (bizIds.size === 0) continue + + const contactDbPath = join(accountDir, 'contact.db') + if (existsSync(contactDbPath)) { + const idsArray = Array.from(bizIds) + 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, + 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 => ({ + ...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 (b.username === 'gh_3dfda90e39d6') return 1 + return b.last_time - a.last_time + }) + + return result + } + + private async getMsgContentBuf(messageContent: any): Promise { + 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 { + const accountDir = this.getAccountDir(account) + const md5Id = createHash('md5').update(username).digest('hex').toLowerCase() + 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 != 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 messages + } + + async listPayRecords(account?: string, limit: number = 20, offset: number = 0): Promise { + const username = 'gh_3dfda90e39d6' // 硬编码的微信支付账号 + const accountDir = this.getAccountDir(account) + const md5Id = createHash('md5').update(username).digest('hex').toLowerCase() + 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[] = [] + 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) { + const timestamp = parsedData.timestamp || row.create_time + records.push({ + local_id: row.local_id, + create_time: row.create_time, + ...parsedData, + timestamp, + formatted_time: new Date(timestamp * 1000).toLocaleString() + }) + } + } + } + + return records + } + + 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 83af689..ed66bab 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' @@ -730,6 +731,7 @@ function App() {