diff --git a/.gitignore b/.gitignore index e6877f1..66440f0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist dist-electron dist-ssr *.local +test/ # Editor directories and files .vscode/* @@ -42,6 +43,10 @@ release # OS Thumbs.db +# Electron dev cache +.electron/ +.cache/ + # 忽略 Visual Studio 临时文件夹 @@ -50,4 +55,4 @@ Thumbs.db *.ipch *.aps -wcdb/ \ No newline at end of file +wcdb/ diff --git a/electron/imageSearchWorker.ts b/electron/imageSearchWorker.ts index 2f99546..56826a2 100644 --- a/electron/imageSearchWorker.ts +++ b/electron/imageSearchWorker.ts @@ -62,6 +62,12 @@ function isThumbnailDat(fileName: string): boolean { return fileName.includes('.t.dat') || fileName.includes('_t.dat') } +function isHdDat(fileName: string): boolean { + const lower = fileName.toLowerCase() + const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower + return base.endsWith('_hd') || base.endsWith('_h') +} + function walkForDat( root: string, datName: string, @@ -101,6 +107,8 @@ function walkForDat( if (!isLikelyImageDatBase(baseLower)) continue if (!hasXVariant(baseLower)) continue if (!matchesDatName(lower, datName)) continue + // 排除高清图片格式 (_hd, _h) + if (isHdDat(lower)) continue matchedBases.add(baseLower) const isThumb = isThumbnailDat(lower) if (!allowThumbnail && isThumb) continue diff --git a/electron/main.ts b/electron/main.ts index 4c492a5..84332c9 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -16,6 +16,7 @@ import { annualReportService } from './services/annualReportService' import { exportService, ExportOptions } from './services/exportService' import { KeyService } from './services/keyService' + // 配置自动更新 autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true @@ -381,6 +382,8 @@ function registerIpcHandlers() { return true }) + + // 聊天相关 ipcMain.handle('chat:connect', async () => { return chatService.connect() @@ -410,6 +413,10 @@ function registerIpcHandlers() { return chatService.getContactAvatar(username) }) + ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => { + return chatService.getCachedSessionMessages(sessionId) + }) + ipcMain.handle('chat:getMyAvatarUrl', async () => { return chatService.getMyAvatarUrl() }) @@ -439,6 +446,9 @@ function registerIpcHandlers() { return chatService.getMessageById(sessionId, localId) }) + // 私聊克隆 + + ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => { return imageDecryptService.decryptImage(payload) }) @@ -460,8 +470,8 @@ function registerIpcHandlers() { }) // 数据分析相关 - ipcMain.handle('analytics:getOverallStatistics', async () => { - return analyticsService.getOverallStatistics() + ipcMain.handle('analytics:getOverallStatistics', async (_, force?: boolean) => { + return analyticsService.getOverallStatistics(force) }) ipcMain.handle('analytics:getContactRankings', async (_, limit?: number) => { @@ -675,9 +685,11 @@ function checkForUpdatesOnStartup() { app.whenReady().then(() => { configService = new ConfigService() - const resourcesPath = app.isPackaged + const candidateResources = app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources') + const fallbackResources = join(process.cwd(), 'resources') + const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources const userDataPath = app.getPath('userData') wcdbService.setPaths(resourcesPath, userDataPath) wcdbService.setLogEnabled(configService.get('logEnabled') === true) diff --git a/electron/preload.ts b/electron/preload.ts index 464c069..897d9b7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -69,7 +69,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid), open: (dbPath: string, hexKey: string, wxid: string) => ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid), - close: () => ipcRenderer.invoke('wcdb:close') + close: () => ipcRenderer.invoke('wcdb:close'), + }, // 密钥获取 @@ -101,12 +102,15 @@ contextBridge.exposeInMainWorld('electronAPI', { getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username), getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'), downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5), + getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId), close: () => ipcRenderer.invoke('chat:close'), getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), getVoiceData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId) }, + + // 图片解密 image: { decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts index a033fb6..9e83f64 100644 --- a/electron/services/analyticsService.ts +++ b/electron/services/analyticsService.ts @@ -1,5 +1,8 @@ import { ConfigService } from './config' import { wcdbService } from './wcdbService' +import { join } from 'path' +import { readFile, writeFile } from 'fs/promises' +import { app } from 'electron' export interface ChatStatistics { totalMessages: number @@ -253,15 +256,31 @@ class AnalyticsService { sessionIds: string[], beginTimestamp = 0, endTimestamp = 0, - window?: any + window?: any, + force = false ): Promise<{ success: boolean; data?: any; source?: string; error?: string }> { const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp) - if (this.aggregateCache && this.aggregateCache.key === cacheKey) { + + if (force) { + if (this.aggregateCache) this.aggregateCache = null + if (this.fallbackAggregateCache) this.fallbackAggregateCache = null + } + + if (!force && this.aggregateCache && this.aggregateCache.key === cacheKey) { if (Date.now() - this.aggregateCache.updatedAt < 5 * 60 * 1000) { return { success: true, data: this.aggregateCache.data, source: 'cache' } } } + // 尝试从文件加载缓存 + if (!force) { + const fileCache = await this.loadCacheFromFile() + if (fileCache && fileCache.key === cacheKey) { + this.aggregateCache = fileCache + return { success: true, data: fileCache.data, source: 'file-cache' } + } + } + if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) { return this.aggregatePromise.promise } @@ -291,7 +310,12 @@ class AnalyticsService { this.aggregatePromise = { key: cacheKey, promise } try { - return await promise + const result = await promise + // 如果计算成功,同时写入此文件缓存 + if (result.success && result.data && result.source !== 'cache') { + this.saveCacheToFile({ key: cacheKey, data: this.aggregateCache?.data, updatedAt: Date.now() }) + } + return result } finally { if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) { this.aggregatePromise = null @@ -299,6 +323,25 @@ class AnalyticsService { } } + private getCacheFilePath(): string { + return join(app.getPath('userData'), 'analytics_cache.json') + } + + private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> { + try { + const raw = await readFile(this.getCacheFilePath(), 'utf-8') + return JSON.parse(raw) + } catch { return null } + } + + private async saveCacheToFile(data: any) { + try { + await writeFile(this.getCacheFilePath(), JSON.stringify(data)) + } catch (e) { + console.error('保存统计缓存失败:', e) + } + } + private normalizeAggregateSessions( sessions: Record | undefined, idMap: Record | undefined @@ -326,7 +369,7 @@ class AnalyticsService { void results } - async getOverallStatistics(): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> { + async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> { try { const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } @@ -340,7 +383,7 @@ class AnalyticsService { const win = BrowserWindow.getAllWindows()[0] this.setProgress(win, '正在执行原生数据聚合...', 30) - const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win) + const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win, force) if (!result.success || !result.data) { return { success: false, error: result.error || '聚合统计失败' } @@ -458,8 +501,8 @@ class AnalyticsService { const d = result.data - // SQLite strftime('%w') 返回 0=Sun, 1=Mon...6=Sat - // 前端期望 1=Mon...7=Sun + // SQLite strftime('%w') 返回 0=周日, 1=周一...6=周六 + // 前端期望 1=周一...7=周日 const weekdayDistribution: Record = {} for (const [w, count] of Object.entries(d.weekday)) { const sqliteW = parseInt(w, 10) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index e711121..1e7457a 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -14,6 +14,8 @@ import { app } from 'electron' const execFileAsync = promisify(execFile) import { ConfigService } from './config' import { wcdbService } from './wcdbService' +import { MessageCacheService } from './messageCacheService' +import { ContactCacheService, ContactCacheEntry } from './contactCacheService' type HardlinkState = { db: Database.Database @@ -56,6 +58,7 @@ export interface Message { aesKey?: string encrypVer?: number cdnThumbUrl?: string + voiceDurationSeconds?: number } export interface Contact { @@ -74,13 +77,19 @@ class ChatService { private connected = false private messageCursors: Map = new Map() private readonly messageBatchDefault = 50 - private avatarCache: Map = new Map() + private avatarCache: Map private readonly avatarCacheTtlMs = 10 * 60 * 1000 private readonly defaultV1AesKey = 'cfcd208495d565ef' private hardlinkCache = new Map() + private readonly contactCacheService: ContactCacheService + private readonly messageCacheService: MessageCacheService constructor() { this.configService = new ConfigService() + this.contactCacheService = new ContactCacheService(this.configService.get('cachePath')) + const persisted = this.contactCacheService.getAllEntries() + this.avatarCache = new Map(Object.entries(persisted)) + this.messageCacheService = new MessageCacheService(this.configService.get('cachePath')) } /** @@ -231,7 +240,7 @@ class ChatService { let displayName = username let avatarUrl: string | undefined = undefined const cached = this.avatarCache.get(username) - if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) { + if (cached) { displayName = cached.displayName || username avatarUrl = cached.avatarUrl } @@ -279,6 +288,7 @@ class ChatService { const now = Date.now() const missing: string[] = [] const result: Record = {} + const updatedEntries: Record = {} // 检查缓存 for (const username of usernames) { @@ -304,17 +314,20 @@ class ChatService { const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined - result[username] = { displayName, avatarUrl } - - // 更新缓存 - this.avatarCache.set(username, { + const cacheEntry: ContactCacheEntry = { displayName: displayName || username, avatarUrl, updatedAt: now - }) + } + result[username] = { displayName, avatarUrl } + // 更新缓存并记录持久化 + this.avatarCache.set(username, cacheEntry) + updatedEntries[username] = cacheEntry + } + if (Object.keys(updatedEntries).length > 0) { + this.contactCacheService.setEntries(updatedEntries) } } - return { success: true, contacts: result } } catch (e) { console.error('ChatService: 补充联系人信息失败:', e) @@ -456,6 +469,7 @@ class ChatService { } state.fetched += rows.length + this.messageCacheService.set(sessionId, normalized) return { success: true, messages: normalized, hasMore } } catch (e) { console.error('ChatService: 获取消息失败:', e) @@ -463,6 +477,20 @@ class ChatService { } } + async getCachedSessionMessages(sessionId: string): Promise<{ success: boolean; messages?: Message[]; error?: string }> { + try { + if (!sessionId) return { success: true, messages: [] } + const entry = this.messageCacheService.get(sessionId) + if (!entry || !Array.isArray(entry.messages)) { + return { success: true, messages: [] } + } + return { success: true, messages: entry.messages.slice() } + } catch (error) { + console.error('ChatService: 获取缓存消息失败:', error) + return { success: false, error: String(error) } + } + } + /** * 尝试从 emoticon.db / emotion.db 恢复表情包 CDN URL */ @@ -639,24 +667,24 @@ class ChatService { const messages: Message[] = [] for (const row of rows) { - const content = this.decodeMessageContent( - this.getRowField(row, [ - 'message_content', - 'messageContent', - 'content', - 'msg_content', - 'msgContent', - 'WCDB_CT_message_content', - 'WCDB_CT_messageContent' - ]), - this.getRowField(row, [ - 'compress_content', - 'compressContent', - 'compressed_content', - 'WCDB_CT_compress_content', - 'WCDB_CT_compressContent' - ]) - ) + const rawMessageContent = this.getRowField(row, [ + 'message_content', + 'messageContent', + 'content', + 'msg_content', + 'msgContent', + 'WCDB_CT_message_content', + 'WCDB_CT_messageContent' + ]); + const rawCompressContent = this.getRowField(row, [ + 'compress_content', + 'compressContent', + 'compressed_content', + 'WCDB_CT_compress_content', + 'WCDB_CT_compressContent' + ]); + + const content = this.decodeMessageContent(rawMessageContent, rawCompressContent); const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']) let isSend = isSendRaw === null ? null : parseInt(isSendRaw, 10) @@ -668,6 +696,16 @@ class ChatService { const expectedIsSend = (senderLower === myWxidLower || senderLower === cleanedWxidLower) ? 1 : 0 if (isSend === null) { isSend = expectedIsSend + // [DEBUG] Issue #34: 记录 isSend 推断过程 + if (expectedIsSend === 0 && localType === 1) { + // 仅在被判为接收且是文本消息时记录,避免刷屏 + // console.log(`[ChatService] inferred isSend=0: sender=${senderUsername}, myWxid=${myWxid} (cleaned=${cleanedWxid})`) + } + } + } else if (senderUsername && !myWxid) { + // [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送 + if (messages.length < 5) { + console.warn(`[ChatService] Warning: myWxid not set. Cannot determine if message is sent by me. sender=${senderUsername}`) } } @@ -1453,9 +1491,9 @@ class ChatService { */ private decodeMessageContent(messageContent: any, compressContent: any): string { // 优先使用 compress_content - let content = this.decodeMaybeCompressed(compressContent) + let content = this.decodeMaybeCompressed(compressContent, 'compress_content') if (!content || content.length === 0) { - content = this.decodeMaybeCompressed(messageContent) + content = this.decodeMaybeCompressed(messageContent, 'message_content') } return content } @@ -1463,12 +1501,14 @@ class ChatService { /** * 尝试解码可能压缩的内容 */ - private decodeMaybeCompressed(raw: any): string { + private decodeMaybeCompressed(raw: any, fieldName: string = 'unknown'): string { if (!raw) return '' + // console.log(`[ChatService] Decoding ${fieldName}: type=${typeof raw}`, raw) + // 如果是 Buffer/Uint8Array if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) { - return this.decodeBinaryContent(Buffer.from(raw)) + return this.decodeBinaryContent(Buffer.from(raw), String(raw)) } // 如果是字符串 @@ -1479,7 +1519,9 @@ class ChatService { if (this.looksLikeHex(raw)) { const bytes = Buffer.from(raw, 'hex') if (bytes.length > 0) { - return this.decodeBinaryContent(bytes) + const result = this.decodeBinaryContent(bytes, raw) + // console.log(`[ChatService] HEX decoded result: ${result}`) + return result } } @@ -1487,7 +1529,7 @@ class ChatService { if (this.looksLikeBase64(raw)) { try { const bytes = Buffer.from(raw, 'base64') - return this.decodeBinaryContent(bytes) + return this.decodeBinaryContent(bytes, raw) } catch { } } @@ -1501,7 +1543,7 @@ class ChatService { /** * 解码二进制内容(处理 zstd 压缩) */ - private decodeBinaryContent(data: Buffer): string { + private decodeBinaryContent(data: Buffer, fallbackValue?: string): string { if (data.length === 0) return '' try { @@ -1528,10 +1570,16 @@ class ChatService { return decoded.replace(/\uFFFD/g, '') } + // 如果提供了 fallbackValue,且解码结果看起来像二进制垃圾,则返回 fallbackValue + if (fallbackValue && replacementCount > 0) { + // console.log(`[ChatService] Binary garbage detected, using fallback: ${fallbackValue}`) + return fallbackValue + } + // 尝试 latin1 解码 return data.toString('latin1') } catch { - return '' + return fallbackValue || '' } } @@ -1610,7 +1658,13 @@ class ChatService { const avatarResult = await wcdbService.getAvatarUrls([username]) const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined const displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username - this.avatarCache.set(username, { avatarUrl, displayName, updatedAt: Date.now() }) + const cacheEntry: ContactCacheEntry = { + avatarUrl, + displayName, + updatedAt: Date.now() + } + this.avatarCache.set(username, cacheEntry) + this.contactCacheService.setEntries({ [username]: cacheEntry }) return { avatarUrl, displayName } } catch { return null @@ -1633,11 +1687,24 @@ class ChatService { } const cleanedWxid = this.cleanAccountDirName(myWxid) - const result = await wcdbService.getAvatarUrls([myWxid, cleanedWxid]) + // 增加 'self' 作为兜底标识符,微信有时将个人信息存储在 'self' 记录中 + const fetchList = Array.from(new Set([myWxid, cleanedWxid, 'self'])) + + console.log(`[ChatService] 尝试获取个人头像, wxids: ${JSON.stringify(fetchList)}`) + const result = await wcdbService.getAvatarUrls(fetchList) + if (result.success && result.map) { - const avatarUrl = result.map[myWxid] || result.map[cleanedWxid] - return { success: true, avatarUrl } + // 按优先级尝试匹配 + const avatarUrl = result.map[myWxid] || result.map[cleanedWxid] || result.map['self'] + if (avatarUrl) { + console.log(`[ChatService] 成功获取个人头像: ${avatarUrl.substring(0, 50)}...`) + return { success: true, avatarUrl } + } + console.warn(`[ChatService] 未能在 contact.db 中找到个人头像, 请求列表: ${JSON.stringify(fetchList)}`) + return { success: true, avatarUrl: undefined } } + + console.error(`[ChatService] 查询个人头像失败: ${result.error || '未知错误'}`) return { success: true, avatarUrl: undefined } } catch (e) { console.error('ChatService: 获取当前用户头像失败:', e) @@ -2462,12 +2529,12 @@ class ChatService { } const aesData = payload.subarray(0, alignedAesSize) - let unpadded = Buffer.alloc(0) + let unpadded: Buffer = Buffer.alloc(0) if (aesData.length > 0) { const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, Buffer.alloc(0)) decipher.setAutoPadding(false) const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()]) - unpadded = this.strictRemovePadding(decrypted) + unpadded = this.strictRemovePadding(decrypted) as Buffer } const remaining = payload.subarray(alignedAesSize) @@ -2475,21 +2542,21 @@ class ChatService { throw new Error('文件格式异常:XOR 数据长度不合法') } - let rawData = Buffer.alloc(0) - let xoredData = Buffer.alloc(0) + let rawData: Buffer = Buffer.alloc(0) + let xoredData: Buffer = Buffer.alloc(0) if (xorSize > 0) { const rawLength = remaining.length - xorSize if (rawLength < 0) { throw new Error('文件格式异常:原始数据长度小于XOR长度') } - rawData = remaining.subarray(0, rawLength) + rawData = remaining.subarray(0, rawLength) as Buffer const xorData = remaining.subarray(rawLength) xoredData = Buffer.alloc(xorData.length) for (let i = 0; i < xorData.length; i++) { xoredData[i] = xorData[i] ^ xorKey } } else { - rawData = remaining + rawData = remaining as Buffer xoredData = Buffer.alloc(0) } diff --git a/electron/services/config.ts b/electron/services/config.ts index 648fdac..bbb7bb7 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -19,6 +19,7 @@ interface ConfigSchema { themeId: string language: string logEnabled: boolean + llmModelPath: string } export class ConfigService { @@ -40,7 +41,8 @@ export class ConfigService { theme: 'system', themeId: 'cloud-dancer', language: 'zh-CN', - logEnabled: false + logEnabled: false, + llmModelPath: '' } }) } diff --git a/electron/services/contactCacheService.ts b/electron/services/contactCacheService.ts new file mode 100644 index 0000000..e29e4a1 --- /dev/null +++ b/electron/services/contactCacheService.ts @@ -0,0 +1,75 @@ +import { join, dirname } from 'path' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { app } from 'electron' + +export interface ContactCacheEntry { + displayName?: string + avatarUrl?: string + updatedAt: number +} + +export class ContactCacheService { + private readonly cacheFilePath: string + private cache: Record = {} + + constructor(cacheBasePath?: string) { + const basePath = cacheBasePath && cacheBasePath.trim().length > 0 + ? cacheBasePath + : join(app.getPath('userData'), 'WeFlowCache') + this.cacheFilePath = join(basePath, 'contacts.json') + this.ensureCacheDir() + this.loadCache() + } + + private ensureCacheDir() { + const dir = dirname(this.cacheFilePath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + } + + private loadCache() { + if (!existsSync(this.cacheFilePath)) return + try { + const raw = readFileSync(this.cacheFilePath, 'utf8') + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object') { + this.cache = parsed + } + } catch (error) { + console.error('ContactCacheService: 载入缓存失败', error) + this.cache = {} + } + } + + get(username: string): ContactCacheEntry | undefined { + return this.cache[username] + } + + getAllEntries(): Record { + return { ...this.cache } + } + + setEntries(entries: Record): void { + if (Object.keys(entries).length === 0) return + let changed = false + for (const [username, entry] of Object.entries(entries)) { + const existing = this.cache[username] + if (!existing || entry.updatedAt >= existing.updatedAt) { + this.cache[username] = entry + changed = true + } + } + if (changed) { + this.persist() + } + } + + private persist() { + try { + writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8') + } catch (error) { + console.error('ContactCacheService: 保存缓存失败', error) + } + } +} diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index de87244..b022107 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -19,6 +19,7 @@ interface ChatLabMeta { platform: string type: 'group' | 'private' groupId?: string + groupAvatar?: string } interface ChatLabMember { @@ -425,6 +426,81 @@ class ExportService { return { rows, memberSet, firstTime, lastTime } } + // 补齐群成员,避免只导出发言者导致头像缺失 + private async mergeGroupMembers( + chatroomId: string, + memberSet: Map, + includeAvatars: boolean + ): Promise { + const result = await wcdbService.getGroupMembers(chatroomId) + if (!result.success || !result.members || result.members.length === 0) return + + const rawMembers = result.members as Array<{ + username?: string + avatarUrl?: string + nickname?: string + displayName?: string + remark?: string + originalName?: string + }> + const usernames = rawMembers + .map((member) => member.username) + .filter((username): username is string => Boolean(username)) + if (usernames.length === 0) return + + const lookupUsernames = new Set() + for (const username of usernames) { + lookupUsernames.add(username) + const cleaned = this.cleanAccountDirName(username) + if (cleaned && cleaned !== username) { + lookupUsernames.add(cleaned) + } + } + + const [displayNames, avatarUrls] = await Promise.all([ + wcdbService.getDisplayNames(Array.from(lookupUsernames)), + includeAvatars ? wcdbService.getAvatarUrls(Array.from(lookupUsernames)) : Promise.resolve({ success: true, map: {} as Record }) + ]) + + for (const member of rawMembers) { + const username = member.username + if (!username) continue + + const cleaned = this.cleanAccountDirName(username) + const displayName = displayNames.success && displayNames.map + ? (displayNames.map[username] || (cleaned ? displayNames.map[cleaned] : undefined) || username) + : username + const groupNickname = member.nickname || member.displayName || member.remark || member.originalName + const avatarUrl = includeAvatars && avatarUrls.success && avatarUrls.map + ? (avatarUrls.map[username] || (cleaned ? avatarUrls.map[cleaned] : undefined) || member.avatarUrl) + : member.avatarUrl + + const existing = memberSet.get(username) + if (existing) { + if (displayName && existing.member.accountName === existing.member.platformId && displayName !== existing.member.platformId) { + existing.member.accountName = displayName + } + if (groupNickname && !existing.member.groupNickname) { + existing.member.groupNickname = groupNickname + } + if (!existing.avatarUrl && avatarUrl) { + existing.avatarUrl = avatarUrl + } + memberSet.set(username, existing) + continue + } + + const chatlabMember: ChatLabMember = { + platformId: username, + accountName: displayName + } + if (groupNickname) { + chatlabMember.groupNickname = groupNickname + } + memberSet.set(username, { member: chatlabMember, avatarUrl }) + } + } + private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null { if (!avatarUrl) return null if (avatarUrl.startsWith('data:')) { @@ -514,7 +590,11 @@ class ExportService { } } if (!data) continue - const finalMime = mime || this.inferImageMime(fileInfo.ext) + + // 优先使用内容检测出的 MIME 类型 + const detectedMime = this.detectMimeType(data) + const finalMime = detectedMime || mime || this.inferImageMime(fileInfo.ext) + const base64 = data.toString('base64') result.set(member.username, `data:${finalMime};base64,${base64}`) } catch { @@ -525,6 +605,39 @@ class ExportService { return result } + private detectMimeType(buffer: Buffer): string | null { + if (buffer.length < 4) return null + + // PNG: 89 50 4E 47 + if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) { + return 'image/png' + } + + // JPEG: FF D8 FF + if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) { + return 'image/jpeg' + } + + // GIF: 47 49 46 38 + if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) { + return 'image/gif' + } + + // WEBP: RIFF ... WEBP + if (buffer.length >= 12 && + buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 && + buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) { + return 'image/webp' + } + + // BMP: 42 4D + if (buffer[0] === 0x42 && buffer[1] === 0x4D) { + return 'image/bmp' + } + + return null + } + private inferImageMime(ext: string): string { switch (ext.toLowerCase()) { case '.png': @@ -567,6 +680,9 @@ class ExportService { const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const allMessages = collected.rows + if (isGroup) { + await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) + } allMessages.sort((a, b) => a.createTime - b.createTime) @@ -580,11 +696,13 @@ class ExportService { const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => { const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || { platformId: msg.senderUsername, - accountName: msg.senderUsername + accountName: msg.senderUsername, + groupNickname: undefined } return { sender: msg.senderUsername, accountName: memberInfo.accountName, + groupNickname: memberInfo.groupNickname, timestamp: msg.createTime, type: this.convertMessageType(msg.localType, msg.content), content: this.parseMessageContent(msg.content, msg.localType) @@ -603,6 +721,7 @@ class ExportService { ) : new Map() + const sessionAvatar = avatarMap.get(sessionId) const members = Array.from(collected.memberSet.values()).map((info) => { const avatar = avatarMap.get(info.member.platformId) return avatar ? { ...info.member, avatar } : info.member @@ -618,7 +737,8 @@ class ExportService { name: sessionInfo.displayName, platform: 'wechat', type: isGroup ? 'group' : 'private', - ...(isGroup && { groupId: sessionId }) + ...(isGroup && { groupId: sessionId }), + ...(sessionAvatar && { groupAvatar: sessionAvatar }) }, members, messages: chatLabMessages @@ -729,7 +849,8 @@ class ExportService { displayName: sessionInfo.displayName, type: isGroup ? '群聊' : '私聊', lastTimestamp: collected.lastTime, - messageCount: allMessages.length + messageCount: allMessages.length, + avatar: undefined as string | undefined }, messages: allMessages } diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index 9758a7b..628e0bb 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -181,6 +181,8 @@ class GroupAnalyticsService { } } + + async getGroupActiveHours(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupActiveHours; error?: string }> { try { const conn = await this.ensureConnected() diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 6797270..585b44e 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -1,14 +1,14 @@ import { app, BrowserWindow } from 'electron' import { basename, dirname, extname, join } from 'path' import { pathToFileURL } from 'url' -import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs' +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs' import { writeFile } from 'fs/promises' import crypto from 'crypto' import { Worker } from 'worker_threads' import { ConfigService } from './config' import { wcdbService } from './wcdbService' -type DecryptResult = { +type DecryptResult = { success: boolean localPath?: string error?: string @@ -32,11 +32,45 @@ export class ImageDecryptService { private logInfo(message: string, meta?: Record): void { if (!this.configService.get('logEnabled')) return + const timestamp = new Date().toISOString() + const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' + const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n` + + // 同时输出到控制台 if (meta) { console.info(message, meta) } else { console.info(message) } + + // 写入日志文件 + this.writeLog(logLine) + } + + private logError(message: string, error?: unknown, meta?: Record): void { + if (!this.configService.get('logEnabled')) return + const timestamp = new Date().toISOString() + const errorStr = error ? ` Error: ${String(error)}` : '' + const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' + const logLine = `[${timestamp}] [ImageDecrypt] ERROR: ${message}${errorStr}${metaStr}\n` + + // 同时输出到控制台 + console.error(message, error, meta) + + // 写入日志文件 + this.writeLog(logLine) + } + + private writeLog(line: string): void { + try { + const logDir = join(app.getPath('userData'), 'logs') + if (!existsSync(logDir)) { + mkdirSync(logDir, { recursive: true }) + } + appendFileSync(join(logDir, 'wcdb.log'), line, { encoding: 'utf8' }) + } catch (err) { + console.error('写入日志失败:', err) + } } async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise { @@ -49,6 +83,7 @@ export class ImageDecryptService { for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) if (cached && existsSync(cached) && this.isImageFile(cached)) { + this.logInfo('缓存命中(从Map)', { key, path: cached, isThumb: this.isThumbnailPath(cached) }) const dataUrl = this.fileToDataUrl(cached) const isThumb = this.isThumbnailPath(cached) const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false @@ -68,6 +103,7 @@ export class ImageDecryptService { for (const key of cacheKeys) { const existing = this.findCachedOutput(key, false, payload.sessionId) if (existing) { + this.logInfo('缓存命中(文件系统)', { key, path: existing, isThumb: this.isThumbnailPath(existing) }) this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing) const dataUrl = this.fileToDataUrl(existing) const isThumb = this.isThumbnailPath(existing) @@ -81,6 +117,7 @@ export class ImageDecryptService { return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate } } } + this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName }) return { success: false, error: '未找到缓存图片' } } @@ -120,15 +157,18 @@ export class ImageDecryptService { payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }, cacheKey: string ): Promise { + this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force }) try { const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') if (!wxid || !dbPath) { + this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath }) return { success: false, error: '未配置账号或数据库路径' } } const accountDir = this.resolveAccountDir(dbPath, wxid) if (!accountDir) { + this.logError('未找到账号目录', undefined, { dbPath, wxid }) return { success: false, error: '未找到账号目录' } } @@ -139,15 +179,19 @@ export class ImageDecryptService { payload.sessionId, { allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) } ) - + // 如果要求高清图但没找到,直接返回提示 if (!datPath && payload.force) { + this.logError('未找到高清图', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' } } if (!datPath) { + this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) return { success: false, error: '未找到图片文件' } } + this.logInfo('找到DAT文件', { datPath }) + if (!extname(datPath).toLowerCase().includes('dat')) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath) const dataUrl = this.fileToDataUrl(datPath) @@ -160,6 +204,7 @@ export class ImageDecryptService { // 查找已缓存的解密文件 const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId) if (existing) { + this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) }) const isHd = this.isHdPath(existing) // 如果要求高清但找到的是缩略图,继续解密高清图 if (!(payload.force && !isHd)) { @@ -192,13 +237,15 @@ export class ImageDecryptService { const aesKeyRaw = this.configService.get('imageAesKey') const aesKey = this.resolveAesKey(aesKeyRaw) + this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey }) const decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey) - + const ext = this.detectImageExtension(decrypted) || '.jpg' const outputPath = this.getCacheOutputPathFromDat(datPath, ext, payload.sessionId) await writeFile(outputPath, decrypted) - + this.logInfo('解密成功', { outputPath, size: decrypted.length }) + const isThumb = this.isThumbnailPath(datPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) if (!isThumb) { @@ -209,6 +256,7 @@ export class ImageDecryptService { this.emitCacheResolved(payload, cacheKey, localPath) return { success: true, localPath, isThumb } } catch (e) { + this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName }) return { success: false, error: String(e) } } } @@ -233,7 +281,7 @@ export class ImageDecryptService { if (this.isAccountDir(entryPath)) return entryPath } } - } catch {} + } catch { } return null } @@ -244,10 +292,10 @@ export class ImageDecryptService { private getDecryptedCacheDir(wxid: string): string | null { const cachePath = this.configService.get('cachePath') if (!cachePath) return null - + const cleanedWxid = this.cleanAccountDirName(wxid) const cacheAccountDir = join(cachePath, cleanedWxid) - + // 检查缓存目录下是否有 hardlink.db if (existsSync(join(cacheAccountDir, 'hardlink.db'))) { return cacheAccountDir @@ -312,7 +360,7 @@ export class ImageDecryptService { allowThumbnail, skipResolvedCache }) - + // 优先通过 hardlink.db 查询 if (imageMd5) { this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId }) @@ -474,7 +522,7 @@ export class ImageDecryptService { if (!hasUpdate) return this.updateFlags.set(cacheKey, true) this.emitImageUpdate(payload, cacheKey) - }).catch(() => {}) + }).catch(() => { }) } private looksLikeMd5(value: string): boolean { @@ -528,7 +576,7 @@ export class ImageDecryptService { this.logInfo('[ImageDecrypt] hardlink row miss', { md5, table: state.imageTable }) return null } - + const dir1 = this.getRowValue(row, 'dir1') const dir2 = this.getRowValue(row, 'dir2') const fileName = this.getRowValue(row, 'file_name') ?? this.getRowValue(row, 'fileName') @@ -549,7 +597,7 @@ export class ImageDecryptService { // dir1 和 dir2 是 rowid,需要从 dir2id 表查询对应的目录名 let dir1Name: string | null = null let dir2Name: string | null = null - + if (state.dirTable) { try { // 通过 rowid 查询目录名 @@ -562,7 +610,7 @@ export class ImageDecryptService { const value = this.getRowValue(dir1Result.rows[0], 'username') if (value) dir1Name = String(value) } - + const dir2Result = await wcdbService.execQuery( 'media', hardlinkPath, @@ -588,14 +636,14 @@ export class ImageDecryptService { join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName), join(accountDir, 'msg', 'attach', dir1Name, dir2Name, fileName), ] - + for (const fullPath of possiblePaths) { if (existsSync(fullPath)) { this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath }) return fullPath } } - + this.logInfo('[ImageDecrypt] hardlink path miss', { possiblePaths }) return null } catch { @@ -829,14 +877,14 @@ export class ImageDecryptService { } } } - + // 新目录结构: Images/{normalizedKey}/{normalizedKey}_thumb.jpg 或 _hd.jpg const imageDir = join(root, normalizedKey) if (existsSync(imageDir)) { const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd) if (hit) return hit } - + // 兼容旧的平铺结构 for (const ext of extensions) { const candidate = join(root, `${cacheKey}${ext}`) @@ -846,7 +894,7 @@ export class ImageDecryptService { const candidate = join(root, `${cacheKey}_t${ext}`) if (existsSync(candidate)) return candidate } - + return null } @@ -863,6 +911,8 @@ export class ImageDecryptService { } const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`) if (existsSync(thumbPath)) return thumbPath + + // 允许返回 _hd 格式(因为它有 _hd 变体后缀) if (!preferHd) { const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`) if (existsSync(hdPath)) return hdPath @@ -875,14 +925,14 @@ export class ImageDecryptService { const name = basename(datPath) const lower = name.toLowerCase() const base = lower.endsWith('.dat') ? name.slice(0, -4) : name - + // 提取基础名称(去掉 _t, _h 等后缀) const normalizedBase = this.normalizeDatBase(base) - + // 判断是缩略图还是高清图 const isThumb = this.isThumbnailDat(lower) const suffix = isThumb ? '_thumb' : '_hd' - + const contactDir = this.sanitizeDirName(sessionId || 'unknown') const timeDir = this.resolveTimeDir(datPath) const outputDir = join(this.getCacheRoot(), contactDir, timeDir) @@ -960,8 +1010,9 @@ export class ImageDecryptService { const lower = entry.toLowerCase() if (!lower.endsWith('.dat')) continue if (this.isThumbnailDat(lower)) continue - if (!this.hasXVariant(lower.slice(0, -4))) continue const baseLower = lower.slice(0, -4) + // 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的) + if (!this.hasXVariant(baseLower)) continue if (this.normalizeDatBase(baseLower) !== target) continue return join(dirPath, entry) } @@ -973,6 +1024,7 @@ export class ImageDecryptService { if (!lower.endsWith('.dat')) return false if (this.isThumbnailDat(lower)) return false const baseLower = lower.slice(0, -4) + // 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的) return this.hasXVariant(baseLower) } @@ -1079,7 +1131,7 @@ export class ImageDecryptService { private async decryptDatAuto(datPath: string, xorKey: number, aesKey: Buffer | null): Promise { const version = this.getDatVersion(datPath) - + if (version === 0) { return this.decryptDatV3(datPath, xorKey) } @@ -1136,7 +1188,7 @@ export class ImageDecryptService { // 当 aesSize % 16 === 0 时,仍需要额外 16 字节的填充 const remainder = ((aesSize % 16) + 16) % 16 const alignedAesSize = aesSize + (16 - remainder) - + if (alignedAesSize > data.length) { throw new Error('文件格式异常:AES 数据长度超过文件实际长度') } @@ -1147,7 +1199,7 @@ export class ImageDecryptService { const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null) decipher.setAutoPadding(false) const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()]) - + // 使用 PKCS7 填充移除 unpadded = this.strictRemovePadding(decrypted) } @@ -1214,7 +1266,7 @@ export class ImageDecryptService { if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return '.png' if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg' if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 && - buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) { + buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) { return '.webp' } return null @@ -1332,10 +1384,10 @@ export class ImageDecryptService { keyCount.set(key, (keyCount.get(key) || 0) + 1) filesChecked++ } - } catch {} + } catch { } } } - } catch {} + } catch { } } scanDir(dirPath) diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index d78edbb..8b51c8d 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -1,9 +1,10 @@ import { app } from 'electron' import { join, dirname, basename } from 'path' -import { existsSync, readdirSync, readFileSync, statSync } from 'fs' +import { existsSync, readdirSync, readFileSync, statSync, copyFileSync, mkdirSync } from 'fs' import { execFile, spawn } from 'child_process' import { promisify } from 'util' import crypto from 'crypto' +import os from 'os' const execFileAsync = promisify(execFile) @@ -57,18 +58,94 @@ export class KeyService { private readonly ERROR_SUCCESS = 0 private getDllPath(): string { - const resourcesPath = app.isPackaged - ? join(process.resourcesPath, 'resources') - : join(app.getAppPath(), 'resources') - return join(resourcesPath, 'wx_key.dll') + const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production' + + // 候选路径列表 + const candidates: string[] = [] + + // 1. 显式环境变量 (最高优先级) + if (process.env.WX_KEY_DLL_PATH) { + candidates.push(process.env.WX_KEY_DLL_PATH) + } + + if (isPackaged) { + // 生产环境: 通常在 resources 目录下,但也可能直接在 resources 根目录 + candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll')) + candidates.push(join(process.resourcesPath, 'wx_key.dll')) + } else { + // 开发环境 + const cwd = process.cwd() + candidates.push(join(cwd, 'resources', 'wx_key.dll')) + candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll')) + } + + // 检查并返回第一个存在的路径 + for (const path of candidates) { + if (existsSync(path)) { + return path + } + } + + // 如果都没找到,返回最可能的路径以便报错信息有参考 + return candidates[0] + } + + // 检查路径是否为 UNC 路径或网络路径 + private isNetworkPath(path: string): boolean { + // UNC 路径以 \\ 开头 + if (path.startsWith('\\\\')) { + return true + } + // 检查是否为网络映射驱动器(简化检测:A: 表示驱动器) + // 注意:这是一个启发式检测,更准确的方式需要调用 GetDriveType Windows API + // 但对于大多数 VM 共享场景,UNC 路径检测已足够 + return false + } + + // 将 DLL 复制到本地临时目录 + private localizeNetworkDll(originalPath: string): string { + try { + const tempDir = join(os.tmpdir(), 'weflow_dll_cache') + if (!existsSync(tempDir)) { + mkdirSync(tempDir, { recursive: true }) + } + const localPath = join(tempDir, 'wx_key.dll') + + // 检查是否已经有本地副本,如果有就使用它 + if (existsSync(localPath)) { + console.log(`使用已存在的 DLL 本地副本: ${localPath}`) + return localPath + } + + console.log(`检测到网络路径 DLL,正在复制到本地: ${originalPath} -> ${localPath}`) + copyFileSync(originalPath, localPath) + console.log('DLL 本地化成功') + return localPath + } catch (e) { + console.error('DLL 本地化失败:', e) + // 如果本地化失败,返回原路径 + return originalPath + } } private ensureLoaded(): boolean { if (this.initialized) return true + + let dllPath = '' try { this.koffi = require('koffi') - const dllPath = this.getDllPath() - if (!existsSync(dllPath)) return false + dllPath = this.getDllPath() + + if (!existsSync(dllPath)) { + console.error(`wx_key.dll 不存在于路径: ${dllPath}`) + return false + } + + // 检查是否为网络路径,如果是则本地化 + if (this.isNetworkPath(dllPath)) { + console.log('检测到网络路径,将进行本地化处理') + dllPath = this.localizeNetworkDll(dllPath) + } this.lib = this.koffi.load(dllPath) this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)') @@ -80,7 +157,14 @@ export class KeyService { this.initialized = true return true } catch (e) { - console.error('加载 wx_key.dll 失败:', e) + const errorMsg = e instanceof Error ? e.message : String(e) + const errorStack = e instanceof Error ? e.stack : '' + console.error(`加载 wx_key.dll 失败`) + console.error(` 路径: ${dllPath}`) + console.error(` 错误: ${errorMsg}`) + if (errorStack) { + console.error(` 堆栈: ${errorStack}`) + } return false } } @@ -831,17 +915,40 @@ export class KeyService { return buffer.subarray(0, bytesRead[0]) } - private async getAesKeyFromMemory(pid: number, ciphertext: Buffer): Promise { + private async getAesKeyFromMemory( + pid: number, + ciphertext: Buffer, + onProgress?: (current: number, total: number, message: string) => void + ): Promise { if (!this.ensureKernel32()) return null const hProcess = this.OpenProcess(this.PROCESS_ALL_ACCESS, false, pid) if (!hProcess) return null try { - const regions = this.getMemoryRegions(hProcess) - const chunkSize = 4 * 1024 * 1024 + const allRegions = this.getMemoryRegions(hProcess) + + // 优化1: 只保留小内存区域(< 10MB)- 密钥通常在小区域,可大幅减少扫描时间 + const filteredRegions = allRegions.filter(([_, size]) => size <= 10 * 1024 * 1024) + + // 优化2: 优先级排序 - 按大小升序,先扫描小区域(密钥通常在较小区域) + const sortedRegions = filteredRegions.sort((a, b) => a[1] - b[1]) + + // 优化3: 计算总字节数用于精确进度报告 + const totalBytes = sortedRegions.reduce((sum, [_, size]) => sum + size, 0) + let processedBytes = 0 + + // 优化4: 减小分块大小到 1MB(参考 wx_key 项目) + const chunkSize = 1 * 1024 * 1024 const overlap = 65 - for (const [baseAddress, regionSize] of regions) { - if (regionSize > 100 * 1024 * 1024) continue + let currentRegion = 0 + + for (const [baseAddress, regionSize] of sortedRegions) { + currentRegion++ + const progress = totalBytes > 0 ? Math.floor((processedBytes / totalBytes) * 100) : 0 + onProgress?.(progress, 100, `扫描内存 ${progress}% (${currentRegion}/${sortedRegions.length})`) + + // 每个区域都让出主线程,确保UI流畅 + await new Promise(resolve => setImmediate(resolve)) let offset = 0 let trailing: Buffer | null = null while (offset < regionSize) { @@ -896,6 +1003,9 @@ export class KeyService { trailing = dataToScan.subarray(start < 0 ? 0 : start) offset += currentChunkSize } + + // 更新已处理字节数 + processedBytes += regionSize } return null } finally { @@ -933,7 +1043,9 @@ export class KeyService { if (!pid) return { success: false, error: '未检测到微信进程' } onProgress?.('正在扫描内存获取 AES 密钥...') - const aesKey = await this.getAesKeyFromMemory(pid, ciphertext) + const aesKey = await this.getAesKeyFromMemory(pid, ciphertext, (current, total, msg) => { + onProgress?.(`${msg} (${current}/${total})`) + }) if (!aesKey) { return { success: false, diff --git a/electron/services/messageCacheService.ts b/electron/services/messageCacheService.ts new file mode 100644 index 0000000..7fffa74 --- /dev/null +++ b/electron/services/messageCacheService.ts @@ -0,0 +1,68 @@ +import { join, dirname } from 'path' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { app } from 'electron' + +export interface SessionMessageCacheEntry { + updatedAt: number + messages: any[] +} + +export class MessageCacheService { + private readonly cacheFilePath: string + private cache: Record = {} + private readonly sessionLimit = 150 + + constructor(cacheBasePath?: string) { + const basePath = cacheBasePath && cacheBasePath.trim().length > 0 + ? cacheBasePath + : join(app.getPath('userData'), 'WeFlowCache') + this.cacheFilePath = join(basePath, 'session-messages.json') + this.ensureCacheDir() + this.loadCache() + } + + private ensureCacheDir() { + const dir = dirname(this.cacheFilePath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + } + + private loadCache() { + if (!existsSync(this.cacheFilePath)) return + try { + const raw = readFileSync(this.cacheFilePath, 'utf8') + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object') { + this.cache = parsed + } + } catch (error) { + console.error('MessageCacheService: 载入缓存失败', error) + this.cache = {} + } + } + + get(sessionId: string): SessionMessageCacheEntry | undefined { + return this.cache[sessionId] + } + + set(sessionId: string, messages: any[]): void { + if (!sessionId) return + const trimmed = messages.length > this.sessionLimit + ? messages.slice(-this.sessionLimit) + : messages.slice() + this.cache[sessionId] = { + updatedAt: Date.now(), + messages: trimmed + } + this.persist() + } + + private persist() { + try { + writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8') + } catch (error) { + console.error('MessageCacheService: 保存缓存失败', error) + } + } +} diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts new file mode 100644 index 0000000..763fd87 --- /dev/null +++ b/electron/services/wcdbCore.ts @@ -0,0 +1,1317 @@ +import { join, dirname, basename } from 'path' +import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' + +export class WcdbCore { + private resourcesPath: string | null = null + private userDataPath: string | null = null + private logEnabled = false + private lib: any = null + private koffi: any = null + private initialized = false + private handle: number | null = null + private currentPath: string | null = null + private currentKey: string | null = null + private currentWxid: string | null = null + + // 函数引用 + private wcdbInit: any = null + private wcdbShutdown: any = null + private wcdbOpenAccount: any = null + private wcdbCloseAccount: any = null + private wcdbSetMyWxid: any = null + private wcdbFreeString: any = null + private wcdbGetSessions: any = null + private wcdbGetMessages: any = null + private wcdbGetMessageCount: any = null + private wcdbGetDisplayNames: any = null + private wcdbGetAvatarUrls: any = null + private wcdbGetGroupMemberCount: any = null + private wcdbGetGroupMemberCounts: any = null + private wcdbGetGroupMembers: any = null + private wcdbGetMessageTables: any = null + private wcdbGetMessageMeta: any = null + private wcdbGetContact: any = null + private wcdbGetMessageTableStats: any = null + private wcdbGetAggregateStats: any = null + private wcdbGetAvailableYears: any = null + private wcdbGetAnnualReportStats: any = null + private wcdbGetAnnualReportExtras: any = null + private wcdbGetGroupStats: any = null + private wcdbOpenMessageCursor: any = null + private wcdbOpenMessageCursorLite: any = null + private wcdbFetchMessageBatch: any = null + private wcdbCloseMessageCursor: any = null + private wcdbGetLogs: any = null + private wcdbExecQuery: any = null + private wcdbListMessageDbs: any = null + private wcdbListMediaDbs: any = null + private wcdbGetMessageById: any = null + private wcdbGetEmoticonCdnUrl: any = null + private wcdbGetDbStatus: any = null + private avatarUrlCache: Map = new Map() + private readonly avatarCacheTtlMs = 10 * 60 * 1000 + private logTimer: NodeJS.Timeout | null = null + private lastLogTail: string | null = null + + setPaths(resourcesPath: string, userDataPath: string): void { + this.resourcesPath = resourcesPath + this.userDataPath = userDataPath + } + + setLogEnabled(enabled: boolean): void { + this.logEnabled = enabled + if (this.isLogEnabled() && this.initialized) { + this.startLogPolling() + } else { + this.stopLogPolling() + } + } + + /** + * 获取 DLL 路径 + */ + private getDllPath(): string { + const envDllPath = process.env.WCDB_DLL_PATH + if (envDllPath && envDllPath.length > 0) { + return envDllPath + } + + // 基础路径探测 + const isPackaged = typeof process['resourcesPath'] !== 'undefined' + const resourcesPath = isPackaged ? process.resourcesPath : join(process.cwd(), 'resources') + + const candidates = [ + // 环境变量指定 resource 目录 + process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, 'wcdb_api.dll') : null, + // 显式 setPaths 设置的路径 + this.resourcesPath ? join(this.resourcesPath, 'wcdb_api.dll') : null, + // text/resources/wcdb_api.dll (打包常见结构) + join(resourcesPath, 'resources', 'wcdb_api.dll'), + // items/resourcesPath/wcdb_api.dll (扁平结构) + join(resourcesPath, 'wcdb_api.dll'), + // CWD fallback + join(process.cwd(), 'resources', 'wcdb_api.dll') + ].filter(Boolean) as string[] + + for (const path of candidates) { + if (existsSync(path)) return path + } + + return candidates[0] || 'wcdb_api.dll' + } + + private isLogEnabled(): boolean { + if (process.env.WEFLOW_WORKER === '1') return false + if (process.env.WCDB_LOG_ENABLED === '1') return true + return this.logEnabled + } + + private writeLog(message: string, force = false): void { + if (!force && !this.isLogEnabled()) return + try { + const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd() + const dir = join(base, 'logs') + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + const line = `[${new Date().toISOString()}] ${message}\n` + appendFileSync(join(dir, 'wcdb.log'), line, { encoding: 'utf8' }) + } catch { } + } + + /** + * 递归查找 session.db 文件 + */ + private findSessionDb(dir: string, depth = 0): string | null { + if (depth > 5) return null + + try { + const entries = readdirSync(dir) + + for (const entry of entries) { + if (entry.toLowerCase() === 'session.db') { + const fullPath = join(dir, entry) + if (statSync(fullPath).isFile()) { + return fullPath + } + } + } + + for (const entry of entries) { + const fullPath = join(dir, entry) + try { + if (statSync(fullPath).isDirectory()) { + const found = this.findSessionDb(fullPath, depth + 1) + if (found) return found + } + } catch { } + } + } catch (e) { + console.error('查找 session.db 失败:', e) + } + + return null + } + + private resolveDbStoragePath(basePath: string, wxid: string): string | null { + if (!basePath) return null + const normalized = basePath.replace(/[\\\\/]+$/, '') + if (normalized.toLowerCase().endsWith('db_storage') && existsSync(normalized)) { + return normalized + } + const direct = join(normalized, 'db_storage') + if (existsSync(direct)) { + return direct + } + if (wxid) { + const viaWxid = join(normalized, wxid, 'db_storage') + if (existsSync(viaWxid)) { + return viaWxid + } + // 兼容目录名包含额外后缀(如 wxid_xxx_1234) + try { + const entries = readdirSync(normalized) + const lowerWxid = wxid.toLowerCase() + const candidates = entries.filter((entry) => { + const entryPath = join(normalized, entry) + try { + if (!statSync(entryPath).isDirectory()) return false + } catch { + return false + } + const lowerEntry = entry.toLowerCase() + return lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`) + }) + for (const entry of candidates) { + const candidate = join(normalized, entry, 'db_storage') + if (existsSync(candidate)) { + return candidate + } + } + } catch { } + } + return null + } + + /** + * 初始化 WCDB + */ + async initialize(): Promise { + if (this.initialized) return true + + try { + this.koffi = require('koffi') + const dllPath = this.getDllPath() + + if (!existsSync(dllPath)) { + console.error('WCDB DLL 不存在:', dllPath) + return false + } + + this.lib = this.koffi.load(dllPath) + + // 定义类型 + // wcdb_status wcdb_init() + this.wcdbInit = this.lib.func('int32 wcdb_init()') + + // wcdb_status wcdb_shutdown() + this.wcdbShutdown = this.lib.func('int32 wcdb_shutdown()') + + // wcdb_status wcdb_open_account(const char* session_db_path, const char* hex_key, wcdb_handle* out_handle) + // wcdb_handle 是 int64_t + this.wcdbOpenAccount = this.lib.func('int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)') + + // wcdb_status wcdb_close_account(wcdb_handle handle) + // C 接口是 int64, koffi 返回 handle 是 number 类型 + this.wcdbCloseAccount = this.lib.func('int32 wcdb_close_account(int64 handle)') + + // wcdb_status wcdb_set_my_wxid(wcdb_handle handle, const char* wxid) + try { + this.wcdbSetMyWxid = this.lib.func('int32 wcdb_set_my_wxid(int64 handle, const char* wxid)') + } catch { + this.wcdbSetMyWxid = null + } + + // void wcdb_free_string(char* ptr) + this.wcdbFreeString = this.lib.func('void wcdb_free_string(void* ptr)') + + // wcdb_status wcdb_get_sessions(wcdb_handle handle, char** out_json) + this.wcdbGetSessions = this.lib.func('int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)') + + // wcdb_status wcdb_get_messages(wcdb_handle handle, const char* username, int32_t limit, int32_t offset, char** out_json) + this.wcdbGetMessages = this.lib.func('int32 wcdb_get_messages(int64 handle, const char* username, int32 limit, int32 offset, _Out_ void** outJson)') + + // wcdb_status wcdb_get_message_count(wcdb_handle handle, const char* username, int32_t* out_count) + this.wcdbGetMessageCount = this.lib.func('int32 wcdb_get_message_count(int64 handle, const char* username, _Out_ int32* outCount)') + + // wcdb_status wcdb_get_display_names(wcdb_handle handle, const char* usernames_json, char** out_json) + this.wcdbGetDisplayNames = this.lib.func('int32 wcdb_get_display_names(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + + // wcdb_status wcdb_get_avatar_urls(wcdb_handle handle, const char* usernames_json, char** out_json) + this.wcdbGetAvatarUrls = this.lib.func('int32 wcdb_get_avatar_urls(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + + // wcdb_status wcdb_get_group_member_count(wcdb_handle handle, const char* chatroom_id, int32_t* out_count) + this.wcdbGetGroupMemberCount = this.lib.func('int32 wcdb_get_group_member_count(int64 handle, const char* chatroomId, _Out_ int32* outCount)') + + // wcdb_status wcdb_get_group_member_counts(wcdb_handle handle, const char* chatroom_ids_json, char** out_json) + try { + this.wcdbGetGroupMemberCounts = this.lib.func('int32 wcdb_get_group_member_counts(int64 handle, const char* chatroomIdsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetGroupMemberCounts = null + } + + // wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json) + this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)') + + // wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json) + this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)') + + // wcdb_status wcdb_get_message_meta(wcdb_handle handle, const char* db_path, const char* table_name, int32_t limit, int32_t offset, char** out_json) + this.wcdbGetMessageMeta = this.lib.func('int32 wcdb_get_message_meta(int64 handle, const char* dbPath, const char* tableName, int32 limit, int32 offset, _Out_ void** outJson)') + + // wcdb_status wcdb_get_contact(wcdb_handle handle, const char* username, char** out_json) + this.wcdbGetContact = this.lib.func('int32 wcdb_get_contact(int64 handle, const char* username, _Out_ void** outJson)') + + // wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json) + this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)') + + // wcdb_status wcdb_get_aggregate_stats(wcdb_handle handle, const char* session_ids_json, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) + this.wcdbGetAggregateStats = this.lib.func('int32 wcdb_get_aggregate_stats(int64 handle, const char* sessionIdsJson, int32 begin, int32 end, _Out_ void** outJson)') + + // wcdb_status wcdb_get_available_years(wcdb_handle handle, const char* session_ids_json, char** out_json) + try { + this.wcdbGetAvailableYears = this.lib.func('int32 wcdb_get_available_years(int64 handle, const char* sessionIdsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetAvailableYears = null + } + + // wcdb_status wcdb_get_annual_report_stats(wcdb_handle handle, const char* session_ids_json, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) + try { + this.wcdbGetAnnualReportStats = this.lib.func('int32 wcdb_get_annual_report_stats(int64 handle, const char* sessionIdsJson, int32 begin, int32 end, _Out_ void** outJson)') + } catch { + this.wcdbGetAnnualReportStats = null + } + + // wcdb_status wcdb_get_annual_report_extras(wcdb_handle handle, const char* session_ids_json, int32_t begin_timestamp, int32_t end_timestamp, int32_t peak_day_begin, int32_t peak_day_end, char** out_json) + try { + this.wcdbGetAnnualReportExtras = this.lib.func('int32 wcdb_get_annual_report_extras(int64 handle, const char* sessionIdsJson, int32 begin, int32 end, int32 peakBegin, int32 peakEnd, _Out_ void** outJson)') + } catch { + this.wcdbGetAnnualReportExtras = null + } + + // wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) + try { + this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)') + } catch { + this.wcdbGetGroupStats = null + } + + // wcdb_status wcdb_open_message_cursor(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor) + this.wcdbOpenMessageCursor = this.lib.func('int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)') + + // wcdb_status wcdb_open_message_cursor_lite(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor) + try { + this.wcdbOpenMessageCursorLite = this.lib.func('int32 wcdb_open_message_cursor_lite(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)') + } catch { + this.wcdbOpenMessageCursorLite = null + } + + // wcdb_status wcdb_fetch_message_batch(wcdb_handle handle, wcdb_cursor cursor, char** out_json, int32_t* out_has_more) + this.wcdbFetchMessageBatch = this.lib.func('int32 wcdb_fetch_message_batch(int64 handle, int64 cursor, _Out_ void** outJson, _Out_ int32* outHasMore)') + + // wcdb_status wcdb_close_message_cursor(wcdb_handle handle, wcdb_cursor cursor) + this.wcdbCloseMessageCursor = this.lib.func('int32 wcdb_close_message_cursor(int64 handle, int64 cursor)') + + // wcdb_status wcdb_get_logs(char** out_json) + this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)') + + // wcdb_status wcdb_exec_query(wcdb_handle handle, const char* db_kind, const char* db_path, const char* sql, char** out_json) + this.wcdbExecQuery = this.lib.func('int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)') + + // wcdb_status wcdb_get_emoticon_cdn_url(wcdb_handle handle, const char* db_path, const char* md5, char** out_url) + this.wcdbGetEmoticonCdnUrl = this.lib.func('int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)') + + // wcdb_status wcdb_list_message_dbs(wcdb_handle handle, char** out_json) + this.wcdbListMessageDbs = this.lib.func('int32 wcdb_list_message_dbs(int64 handle, _Out_ void** outJson)') + + // wcdb_status wcdb_list_media_dbs(wcdb_handle handle, char** out_json) + this.wcdbListMediaDbs = this.lib.func('int32 wcdb_list_media_dbs(int64 handle, _Out_ void** outJson)') + + // wcdb_status wcdb_get_message_by_id(wcdb_handle handle, const char* session_id, int32 local_id, char** out_json) + this.wcdbGetMessageById = this.lib.func('int32 wcdb_get_message_by_id(int64 handle, const char* sessionId, int32 localId, _Out_ void** outJson)') + + // wcdb_status wcdb_get_db_status(wcdb_handle handle, char** out_json) + try { + this.wcdbGetDbStatus = this.lib.func('int32 wcdb_get_db_status(int64 handle, _Out_ void** outJson)') + } catch { + this.wcdbGetDbStatus = null + } + + // 初始化 + const initResult = this.wcdbInit() + if (initResult !== 0) { + console.error('WCDB 初始化失败:', initResult) + return false + } + + this.initialized = true + return true + } catch (e) { + console.error('WCDB 初始化异常:', e) + return false + } + } + + /** + * 测试数据库连接 + */ + async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> { + try { + // 如果当前已经有相同参数的活动连接,直接返回成功 + if (this.handle !== null && + this.currentPath === dbPath && + this.currentKey === hexKey && + this.currentWxid === wxid) { + return { success: true, sessionCount: 0 } + } + + if (!this.initialized) { + const initOk = await this.initialize() + if (!initOk) { + return { success: false, error: 'WCDB 初始化失败' } + } + } + + // 构建 db_storage 目录路径 + const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid) + this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`) + + if (!dbStoragePath || !existsSync(dbStoragePath)) { + return { success: false, error: `数据库目录不存在: ${dbPath}` } + } + + // 递归查找 session.db + const sessionDbPath = this.findSessionDb(dbStoragePath) + this.writeLog(`testConnection sessionDb=${sessionDbPath || 'null'}`) + + if (!sessionDbPath) { + return { success: false, error: `未找到 session.db 文件` } + } + + // 分配输出参数内存 + const handleOut = [0] + const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut) + + if (result !== 0) { + await this.printLogs() + let errorMsg = '数据库打开失败' + if (result === -1) errorMsg = '参数错误' + else if (result === -2) errorMsg = '密钥错误' + else if (result === -3) errorMsg = '数据库打开失败' + this.writeLog(`testConnection openAccount failed code=${result}`) + return { success: false, error: `${errorMsg} (错误码: ${result})` } + } + + const tempHandle = handleOut[0] + if (tempHandle <= 0) { + return { success: false, error: '无效的数据库句柄' } + } + + // 测试成功,使用 shutdown 清理所有资源(包括测试句柄) + // 这会中断当前活动连接,但 testConnection 本应该是独立测试 + try { + this.wcdbShutdown() + this.handle = null + this.currentPath = null + this.currentKey = null + this.currentWxid = null + this.initialized = false + } catch (closeErr) { + console.error('关闭测试数据库时出错:', closeErr) + } + + return { success: true, sessionCount: 0 } + } catch (e) { + console.error('测试连接异常:', e) + this.writeLog(`testConnection exception: ${String(e)}`) + return { success: false, error: String(e) } + } + } + + /** + * 打印 DLL 内部日志(仅在出错时调用) + */ + private async printLogs(force = false): Promise { + try { + if (!this.wcdbGetLogs) return + const outPtr = [null as any] + const result = this.wcdbGetLogs(outPtr) + if (result === 0 && outPtr[0]) { + try { + const jsonStr = this.koffi.decode(outPtr[0], 'char', -1) + this.writeLog(`wcdb_logs: ${jsonStr}`, force) + this.wcdbFreeString(outPtr[0]) + } catch (e) { + // ignore + } + } + } catch (e) { + console.error('获取日志失败:', e) + this.writeLog(`wcdb_logs failed: ${String(e)}`, force) + } + } + + private startLogPolling(): void { + if (this.logTimer || !this.isLogEnabled()) return + this.logTimer = setInterval(() => { + void this.pollLogs() + }, 2000) + } + + private stopLogPolling(): void { + if (this.logTimer) { + clearInterval(this.logTimer) + this.logTimer = null + } + this.lastLogTail = null + } + + private async pollLogs(): Promise { + try { + if (!this.wcdbGetLogs || !this.isLogEnabled()) return + const outPtr = [null as any] + const result = this.wcdbGetLogs(outPtr) + if (result !== 0 || !outPtr[0]) return + let jsonStr = '' + try { + jsonStr = this.koffi.decode(outPtr[0], 'char', -1) + } finally { + try { this.wcdbFreeString(outPtr[0]) } catch { } + } + const logs = JSON.parse(jsonStr) as string[] + if (!Array.isArray(logs) || logs.length === 0) return + let startIdx = 0 + if (this.lastLogTail) { + const idx = logs.lastIndexOf(this.lastLogTail) + if (idx >= 0) startIdx = idx + 1 + } + for (let i = startIdx; i < logs.length; i += 1) { + this.writeLog(`wcdb: ${logs[i]}`) + } + this.lastLogTail = logs[logs.length - 1] + } catch (e) { + // ignore polling errors + } + } + + private decodeJsonPtr(outPtr: any): string | null { + if (!outPtr) return null + try { + const jsonStr = this.koffi.decode(outPtr, 'char', -1) + this.wcdbFreeString(outPtr) + return jsonStr + } catch (e) { + try { this.wcdbFreeString(outPtr) } catch { } + return null + } + } + + private ensureReady(): boolean { + return this.initialized && this.handle !== null + } + + private normalizeTimestamp(input: number): number { + if (!input || input <= 0) return 0 + const asNumber = Number(input) + if (!Number.isFinite(asNumber)) return 0 + // Treat >1e12 as milliseconds. + const seconds = asNumber > 1e12 ? Math.floor(asNumber / 1000) : Math.floor(asNumber) + const maxInt32 = 2147483647 + return Math.min(Math.max(seconds, 0), maxInt32) + } + + private normalizeRange(beginTimestamp: number, endTimestamp: number): { begin: number; end: number } { + const normalizedBegin = this.normalizeTimestamp(beginTimestamp) + let normalizedEnd = this.normalizeTimestamp(endTimestamp) + if (normalizedEnd <= 0) { + normalizedEnd = this.normalizeTimestamp(Date.now()) + } + if (normalizedBegin > 0 && normalizedEnd < normalizedBegin) { + normalizedEnd = normalizedBegin + } + return { begin: normalizedBegin, end: normalizedEnd } + } + + isReady(): boolean { + return this.ensureReady() + } + + /** + * 打开数据库 + */ + async open(dbPath: string, hexKey: string, wxid: string): Promise { + try { + if (!this.initialized) { + const initOk = await this.initialize() + if (!initOk) return false + } + + // 检查是否已经是当前连接的参数,如果是则直接返回成功,实现"始终保持链接" + if (this.handle !== null && + this.currentPath === dbPath && + this.currentKey === hexKey && + this.currentWxid === wxid) { + return true + } + + // 如果参数不同,则先关闭原来的连接 + if (this.handle !== null) { + this.close() + // 重新初始化,因为 close 呼叫了 shutdown + const initOk = await this.initialize() + if (!initOk) return false + } + + const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid) + this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`) + + if (!dbStoragePath || !existsSync(dbStoragePath)) { + console.error('数据库目录不存在:', dbPath) + this.writeLog(`open failed: dbStorage not found for ${dbPath}`) + return false + } + + const sessionDbPath = this.findSessionDb(dbStoragePath) + this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`) + if (!sessionDbPath) { + console.error('未找到 session.db 文件') + this.writeLog('open failed: session.db not found') + return false + } + + const handleOut = [0] + const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut) + + if (result !== 0) { + console.error('打开数据库失败:', result) + await this.printLogs() + this.writeLog(`open failed: openAccount code=${result}`) + return false + } + + const handle = handleOut[0] + if (handle <= 0) { + return false + } + + this.handle = handle + this.currentPath = dbPath + this.currentKey = hexKey + this.currentWxid = wxid + this.initialized = true + if (this.wcdbSetMyWxid && wxid) { + try { + this.wcdbSetMyWxid(this.handle, wxid) + } catch (e) { + console.warn('设置 wxid 失败:', e) + } + } + if (this.isLogEnabled()) { + this.startLogPolling() + } + this.writeLog(`open ok handle=${handle}`) + return true + } catch (e) { + console.error('打开数据库异常:', e) + this.writeLog(`open exception: ${String(e)}`) + return false + } + } + + /** + * 关闭数据库 + * 注意:wcdb_close_account 可能导致崩溃,使用 shutdown 代替 + */ + close(): void { + if (this.handle !== null || this.initialized) { + try { + // 不调用 closeAccount,直接 shutdown + this.wcdbShutdown() + } catch (e) { + console.error('WCDB shutdown 出错:', e) + } + this.handle = null + this.currentPath = null + this.currentKey = null + this.currentWxid = null + this.initialized = false + this.stopLogPolling() + } + } + + /** + * 关闭服务(与 close 相同) + */ + shutdown(): void { + this.close() + } + + /** + * 检查是否已连接 + */ + isConnected(): boolean { + return this.initialized && this.handle !== null + } + + async getSessions(): Promise<{ success: boolean; sessions?: any[]; error?: string }> { + if (!this.ensureReady()) { + this.writeLog('getSessions skipped: not connected') + return { success: false, error: 'WCDB 未连接' } + } + try { + // 使用 setImmediate 让事件循环有机会处理其他任务,避免长时间阻塞 + await new Promise(resolve => setImmediate(resolve)) + + const outPtr = [null as any] + const result = this.wcdbGetSessions(this.handle, outPtr) + + // DLL 调用后再次让出控制权 + await new Promise(resolve => setImmediate(resolve)) + + if (result !== 0 || !outPtr[0]) { + this.writeLog(`getSessions failed: code=${result}`) + return { success: false, error: `获取会话失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析会话失败' } + this.writeLog(`getSessions ok size=${jsonStr.length}`) + const sessions = JSON.parse(jsonStr) + return { success: true, sessions } + } catch (e) { + this.writeLog(`getSessions exception: ${String(e)}`) + return { success: false, error: String(e) } + } + } + + async getMessages(sessionId: string, limit: number, offset: number): Promise<{ success: boolean; messages?: any[]; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessages(this.handle, sessionId, limit, offset, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取消息失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析消息失败' } + const messages = JSON.parse(jsonStr) + return { success: true, messages } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMessageCount(sessionId: string): Promise<{ success: boolean; count?: number; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outCount = [0] + const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount) + if (result !== 0) { + return { success: false, error: `获取消息总数失败: ${result}` } + } + return { success: true, count: outCount[0] } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (usernames.length === 0) return { success: true, map: {} } + try { + // 让出控制权,避免阻塞事件循环 + await new Promise(resolve => setImmediate(resolve)) + + const outPtr = [null as any] + const result = this.wcdbGetDisplayNames(this.handle, JSON.stringify(usernames), outPtr) + + // DLL 调用后再次让出控制权 + await new Promise(resolve => setImmediate(resolve)) + + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取昵称失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析昵称失败' } + const map = JSON.parse(jsonStr) + return { success: true, map } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getAvatarUrls(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (usernames.length === 0) return { success: true, map: {} } + try { + const now = Date.now() + const resultMap: Record = {} + const toFetch: string[] = [] + const seen = new Set() + + for (const username of usernames) { + if (!username || seen.has(username)) continue + seen.add(username) + const cached = this.avatarUrlCache.get(username) + // 只使用有效的缓存(URL不为空) + if (cached && cached.url && cached.url.trim() && now - cached.updatedAt < this.avatarCacheTtlMs) { + resultMap[username] = cached.url + continue + } + toFetch.push(username) + } + + if (toFetch.length === 0) { + return { success: true, map: resultMap } + } + + // 让出控制权,避免阻塞事件循环 + await new Promise(resolve => setImmediate(resolve)) + + const outPtr = [null as any] + const result = this.wcdbGetAvatarUrls(this.handle, JSON.stringify(toFetch), outPtr) + + // DLL 调用后再次让出控制权 + await new Promise(resolve => setImmediate(resolve)) + + if (result !== 0 || !outPtr[0]) { + console.warn(`[wcdbCore] getAvatarUrls DLL调用失败: result=${result}, usernames=${toFetch.length}`) + if (Object.keys(resultMap).length > 0) { + return { success: true, map: resultMap, error: `获取头像失败: ${result}` } + } + return { success: false, error: `获取头像失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) { + console.error('[wcdbCore] getAvatarUrls 解析JSON失败') + return { success: false, error: '解析头像失败' } + } + const map = JSON.parse(jsonStr) as Record + let successCount = 0 + let emptyCount = 0 + for (const username of toFetch) { + const url = map[username] + if (url && url.trim()) { + resultMap[username] = url + // 只缓存有效的URL + this.avatarUrlCache.set(username, { url, updatedAt: now }) + successCount++ + } else { + emptyCount++ + // 不缓存空URL,下次可以重新尝试 + } + } + console.log(`[wcdbCore] getAvatarUrls 成功: ${successCount}个, 空结果: ${emptyCount}个, 总请求: ${toFetch.length}`) + return { success: true, map: resultMap } + } catch (e) { + console.error('[wcdbCore] getAvatarUrls 异常:', e) + return { success: false, error: String(e) } + } + } + + async getGroupMemberCount(chatroomId: string): Promise<{ success: boolean; count?: number; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outCount = [0] + const result = this.wcdbGetGroupMemberCount(this.handle, chatroomId, outCount) + if (result !== 0) { + return { success: false, error: `获取群成员数量失败: ${result}` } + } + return { success: true, count: outCount[0] } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getGroupMemberCounts(chatroomIds: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (chatroomIds.length === 0) return { success: true, map: {} } + if (!this.wcdbGetGroupMemberCounts) { + const map: Record = {} + for (const chatroomId of chatroomIds) { + const result = await this.getGroupMemberCount(chatroomId) + if (result.success && typeof result.count === 'number') { + map[chatroomId] = result.count + } + } + return { success: true, map } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetGroupMemberCounts(this.handle, JSON.stringify(chatroomIds), outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取群成员数量失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析群成员数量失败' } + const map = JSON.parse(jsonStr) + return { success: true, map } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getGroupMembers(chatroomId: string): Promise<{ success: boolean; members?: any[]; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetGroupMembers(this.handle, chatroomId, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取群成员失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析群成员失败' } + const members = JSON.parse(jsonStr) + return { success: true, members } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessageTables(this.handle, sessionId, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取消息表失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析消息表失败' } + const tables = JSON.parse(jsonStr) + return { success: true, tables } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMessageTableStats(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessageTableStats(this.handle, sessionId, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取表统计失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析表统计失败' } + const tables = JSON.parse(jsonStr) + return { success: true, tables } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMessageMeta(dbPath: string, tableName: string, limit: number, offset: number): Promise<{ success: boolean; rows?: any[]; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessageMeta(this.handle, dbPath, tableName, limit, offset, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取消息元数据失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析消息元数据失败' } + const rows = JSON.parse(jsonStr) + return { success: true, rows } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getContact(username: string): Promise<{ success: boolean; contact?: any; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetContact(this.handle, username, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取联系人失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析联系人失败' } + const contact = JSON.parse(jsonStr) + return { success: true, contact } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const normalizedBegin = this.normalizeTimestamp(beginTimestamp) + let normalizedEnd = this.normalizeTimestamp(endTimestamp) + if (normalizedEnd <= 0) { + normalizedEnd = this.normalizeTimestamp(Date.now()) + } + if (normalizedBegin > 0 && normalizedEnd < normalizedBegin) { + normalizedEnd = normalizedBegin + } + + const callAggregate = (ids: string[]) => { + const idsAreNumeric = ids.length > 0 && ids.every((id) => /^\d+$/.test(id)) + const payloadIds = idsAreNumeric ? ids.map((id) => Number(id)) : ids + + const outPtr = [null as any] + const result = this.wcdbGetAggregateStats(this.handle, JSON.stringify(payloadIds), normalizedBegin, normalizedEnd, outPtr) + + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取聚合统计失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) { + return { success: false, error: '解析聚合统计失败' } + } + + const data = JSON.parse(jsonStr) + return { success: true, data } + } + + let result = callAggregate(sessionIds) + if (result.success && result.data && result.data.total === 0 && result.data.idMap) { + const idMap = result.data.idMap as Record + const reverseMap: Record = {} + for (const [id, name] of Object.entries(idMap)) { + if (!name) continue + reverseMap[name] = id + } + const numericIds = sessionIds + .map((id) => reverseMap[id]) + .filter((id) => typeof id === 'string' && /^\d+$/.test(id)) + if (numericIds.length > 0) { + const retry = callAggregate(numericIds) + if (retry.success && retry.data) { + result = retry + } + } + } + + return result + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getAvailableYears(sessionIds: string[]): Promise<{ success: boolean; data?: number[]; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (!this.wcdbGetAvailableYears) { + return { success: false, error: '未支持获取年度列表' } + } + if (sessionIds.length === 0) return { success: true, data: [] } + try { + const outPtr = [null as any] + const result = this.wcdbGetAvailableYears(this.handle, JSON.stringify(sessionIds), outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取年度列表失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析年度列表失败' } + const data = JSON.parse(jsonStr) + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getAnnualReportStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (!this.wcdbGetAnnualReportStats) { + return this.getAggregateStats(sessionIds, beginTimestamp, endTimestamp) + } + try { + const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp) + const outPtr = [null as any] + const result = this.wcdbGetAnnualReportStats(this.handle, JSON.stringify(sessionIds), begin, end, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取年度统计失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析年度统计失败' } + const data = JSON.parse(jsonStr) + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getAnnualReportExtras( + sessionIds: string[], + beginTimestamp: number = 0, + endTimestamp: number = 0, + peakDayBegin: number = 0, + peakDayEnd: number = 0 + ): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (!this.wcdbGetAnnualReportExtras) { + return { success: false, error: '未支持年度扩展统计' } + } + if (sessionIds.length === 0) return { success: true, data: {} } + try { + const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp) + const outPtr = [null as any] + const result = this.wcdbGetAnnualReportExtras( + this.handle, + JSON.stringify(sessionIds), + begin, + end, + this.normalizeTimestamp(peakDayBegin), + this.normalizeTimestamp(peakDayEnd), + outPtr + ) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取年度扩展统计失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析年度扩展统计失败' } + const data = JSON.parse(jsonStr) + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getGroupStats(chatroomId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (!this.wcdbGetGroupStats) { + return this.getAggregateStats([chatroomId], beginTimestamp, endTimestamp) + } + try { + const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp) + const outPtr = [null as any] + const result = this.wcdbGetGroupStats(this.handle, chatroomId, begin, end, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取群聊统计失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析群聊统计失败' } + const data = JSON.parse(jsonStr) + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async openMessageCursor(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outCursor = [0] + const result = this.wcdbOpenMessageCursor( + this.handle, + sessionId, + batchSize, + ascending ? 1 : 0, + beginTimestamp, + endTimestamp, + outCursor + ) + if (result !== 0 || outCursor[0] <= 0) { + await this.printLogs(true) + this.writeLog( + `openMessageCursor failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`, + true + ) + return { success: false, error: `创建游标失败: ${result},请查看日志` } + } + return { success: true, cursor: outCursor[0] } + } catch (e) { + await this.printLogs(true) + this.writeLog(`openMessageCursor exception: ${String(e)}`, true) + return { success: false, error: '创建游标异常,请查看日志' } + } + } + + async openMessageCursorLite(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (!this.wcdbOpenMessageCursorLite) { + return this.openMessageCursor(sessionId, batchSize, ascending, beginTimestamp, endTimestamp) + } + try { + const outCursor = [0] + const result = this.wcdbOpenMessageCursorLite( + this.handle, + sessionId, + batchSize, + ascending ? 1 : 0, + beginTimestamp, + endTimestamp, + outCursor + ) + if (result !== 0 || outCursor[0] <= 0) { + await this.printLogs(true) + this.writeLog( + `openMessageCursorLite failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`, + true + ) + return { success: false, error: `创建游标失败: ${result},请查看日志` } + } + return { success: true, cursor: outCursor[0] } + } catch (e) { + await this.printLogs(true) + this.writeLog(`openMessageCursorLite exception: ${String(e)}`, true) + return { success: false, error: '创建游标异常,请查看日志' } + } + } + + async fetchMessageBatch(cursor: number): Promise<{ success: boolean; rows?: any[]; hasMore?: boolean; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const outHasMore = [0] + const result = this.wcdbFetchMessageBatch(this.handle, cursor, outPtr, outHasMore) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取批次失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析批次失败' } + const rows = JSON.parse(jsonStr) + return { success: true, rows, hasMore: outHasMore[0] === 1 } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async closeMessageCursor(cursor: number): Promise<{ success: boolean; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const result = this.wcdbCloseMessageCursor(this.handle, cursor) + if (result !== 0) { + return { success: false, error: `关闭游标失败: ${result}` } + } + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbExecQuery(this.handle, kind, path, sql, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `执行查询失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析查询结果失败' } + const rows = JSON.parse(jsonStr) + return { success: true, rows } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getEmoticonCdnUrl(dbPath: string, md5: string): Promise<{ success: boolean; url?: string; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetEmoticonCdnUrl(this.handle, dbPath, md5, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取表情 URL 失败: ${result}` } + } + const urlStr = this.decodeJsonPtr(outPtr[0]) + if (urlStr === null) return { success: false, error: '解析表情 URL 失败' } + return { success: true, url: urlStr || undefined } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async listMessageDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + try { + const outPtr = [null as any] + const result = this.wcdbListMessageDbs(this.handle, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取消息库列表失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析消息库列表失败' } + const data = JSON.parse(jsonStr) + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async listMediaDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + try { + const outPtr = [null as any] + const result = this.wcdbListMediaDbs(this.handle, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取媒体库列表失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析媒体库列表失败' } + const data = JSON.parse(jsonStr) + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessageById(this.handle, sessionId, localId, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `查询消息失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析消息失败' } + const message = JSON.parse(jsonStr) + // 处理 wcdb_get_message_by_id 返回空对象的情况 + if (Object.keys(message).length === 0) return { success: false, error: '未找到消息' } + return { success: true, message } + } catch (e) { + return { success: false, error: String(e) } + } + } +} + diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 7e9a5d5..eacd90b 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -1,1285 +1,346 @@ -import { join, dirname, basename } from 'path' -import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' +import { Worker } from 'worker_threads' +import { join } from 'path' +import { existsSync } from 'fs' +/** + * Worker 消息接口 + */ +interface WorkerMessage { + id: number + result?: any + error?: string +} + +/** + * WCDB 服务 (客户端代理) + * 负责与后台 Worker 线程通信,执行数据库操作 + * 避免主进程阻塞 + */ export class WcdbService { + private worker: Worker | null = null + private messageId = 0 + private pending = new Map void; reject: (err: any) => void }>() private resourcesPath: string | null = null private userDataPath: string | null = null private logEnabled = false - private lib: any = null - private koffi: any = null - private initialized = false - private handle: number | null = null - private currentPath: string | null = null - private currentKey: string | null = null - private currentWxid: string | null = null - // 函数引用 - private wcdbInit: any = null - private wcdbShutdown: any = null - private wcdbOpenAccount: any = null - private wcdbCloseAccount: any = null - private wcdbSetMyWxid: any = null - private wcdbFreeString: any = null - private wcdbGetSessions: any = null - private wcdbGetMessages: any = null - private wcdbGetMessageCount: any = null - private wcdbGetDisplayNames: any = null - private wcdbGetAvatarUrls: any = null - private wcdbGetGroupMemberCount: any = null - private wcdbGetGroupMemberCounts: any = null - private wcdbGetGroupMembers: any = null - private wcdbGetMessageTables: any = null - private wcdbGetMessageMeta: any = null - private wcdbGetContact: any = null - private wcdbGetMessageTableStats: any = null - private wcdbGetAggregateStats: any = null - private wcdbGetAvailableYears: any = null - private wcdbGetAnnualReportStats: any = null - private wcdbGetAnnualReportExtras: any = null - private wcdbGetGroupStats: any = null - private wcdbOpenMessageCursor: any = null - private wcdbOpenMessageCursorLite: any = null - private wcdbFetchMessageBatch: any = null - private wcdbCloseMessageCursor: any = null - private wcdbGetLogs: any = null - private wcdbExecQuery: any = null - private wcdbListMessageDbs: any = null - private wcdbListMediaDbs: any = null - private wcdbGetMessageById: any = null - private wcdbGetEmoticonCdnUrl: any = null - private avatarUrlCache: Map = new Map() - private readonly avatarCacheTtlMs = 10 * 60 * 1000 - private logTimer: NodeJS.Timeout | null = null - private lastLogTail: string | null = null + constructor() { + this.initWorker() + } + /** + * 初始化 Worker 线程 + */ + private initWorker() { + if (this.worker) return + + const isDev = process.env.NODE_ENV === 'development' + const workerPath = isDev + ? join(__dirname, '../dist-electron/wcdbWorker.js') + : join(__dirname, 'wcdbWorker.js') + + let finalPath = workerPath + if (isDev && !existsSync(finalPath)) { + finalPath = join(__dirname, 'wcdbWorker.js') + } + + try { + this.worker = new Worker(finalPath) + + this.worker.on('message', (msg: WorkerMessage) => { + const { id, result, error } = msg + const p = this.pending.get(id) + if (p) { + this.pending.delete(id) + if (error) p.reject(new Error(error)) + else p.resolve(result) + } + }) + + this.worker.on('error', (err) => { + console.error('WCDB Worker 错误:', err) + }) + + this.worker.on('exit', (code) => { + if (code !== 0) console.error(`WCDB Worker 异常退出,退出码: ${code}`) + this.worker = null + }) + + // 如果已有路径配置,重新发送给新的 worker + if (this.resourcesPath && this.userDataPath) { + this.setPaths(this.resourcesPath, this.userDataPath) + } + this.setLogEnabled(this.logEnabled) + + } catch (e) { + console.error('创建 WCDB Worker 失败:', e) + } + } + + /** + * 发送消息到 Worker 并等待响应 + */ + private callWorker(type: string, payload: any = {}): Promise { + if (!this.worker) this.initWorker() + if (!this.worker) return Promise.reject(new Error('WCDB Worker 不可用')) + + return new Promise((resolve, reject) => { + const id = ++this.messageId + this.pending.set(id, { resolve, reject }) + this.worker!.postMessage({ id, type, payload }) + }) + } + + /** + * 设置资源路径 + */ setPaths(resourcesPath: string, userDataPath: string): void { this.resourcesPath = resourcesPath this.userDataPath = userDataPath + this.callWorker('setPaths', { resourcesPath, userDataPath }).catch(console.error) } + /** + * 启用/禁用日志 + */ setLogEnabled(enabled: boolean): void { this.logEnabled = enabled - if (this.isLogEnabled() && this.initialized) { - this.startLogPolling() - } else { - this.stopLogPolling() - } + this.callWorker('setLogEnabled', { enabled }).catch(console.error) } /** - * 获取 DLL 路径 + * 检查服务是否就绪 */ - private getDllPath(): string { - const envDllPath = process.env.WCDB_DLL_PATH - if (envDllPath && envDllPath.length > 0) { - return envDllPath - } - - const envResourcesPath = process.env.WCDB_RESOURCES_PATH - if (envResourcesPath && envResourcesPath.length > 0) { - return join(envResourcesPath, 'wcdb_api.dll') - } - - if (this.resourcesPath && this.resourcesPath.length > 0) { - return join(this.resourcesPath, 'wcdb_api.dll') - } - - const fallbackBase = process.resourcesPath || join(process.cwd(), 'resources') - return join(fallbackBase, 'wcdb_api.dll') + isReady(): boolean { + return !!this.worker } - private isLogEnabled(): boolean { - if (process.env.WEFLOW_WORKER === '1') return false - if (process.env.WCDB_LOG_ENABLED === '1') return true - return this.logEnabled - } - - private writeLog(message: string, force = false): void { - if (!force && !this.isLogEnabled()) return - try { - const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd() - const dir = join(base, 'logs') - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - const line = `[${new Date().toISOString()}] ${message}\n` - appendFileSync(join(dir, 'wcdb.log'), line, { encoding: 'utf8' }) - } catch { } - } - - /** - * 递归查找 session.db 文件 - */ - private findSessionDb(dir: string, depth = 0): string | null { - if (depth > 5) return null - - try { - const entries = readdirSync(dir) - - for (const entry of entries) { - if (entry.toLowerCase() === 'session.db') { - const fullPath = join(dir, entry) - if (statSync(fullPath).isFile()) { - return fullPath - } - } - } - - for (const entry of entries) { - const fullPath = join(dir, entry) - try { - if (statSync(fullPath).isDirectory()) { - const found = this.findSessionDb(fullPath, depth + 1) - if (found) return found - } - } catch { } - } - } catch (e) { - console.error('查找 session.db 失败:', e) - } - - return null - } - - private resolveDbStoragePath(basePath: string, wxid: string): string | null { - if (!basePath) return null - const normalized = basePath.replace(/[\\\\/]+$/, '') - if (normalized.toLowerCase().endsWith('db_storage') && existsSync(normalized)) { - return normalized - } - const direct = join(normalized, 'db_storage') - if (existsSync(direct)) { - return direct - } - if (wxid) { - const viaWxid = join(normalized, wxid, 'db_storage') - if (existsSync(viaWxid)) { - return viaWxid - } - // 兼容目录名包含额外后缀(如 wxid_xxx_1234) - try { - const entries = readdirSync(normalized) - const lowerWxid = wxid.toLowerCase() - const candidates = entries.filter((entry) => { - const entryPath = join(normalized, entry) - try { - if (!statSync(entryPath).isDirectory()) return false - } catch { - return false - } - const lowerEntry = entry.toLowerCase() - return lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`) - }) - for (const entry of candidates) { - const candidate = join(normalized, entry, 'db_storage') - if (existsSync(candidate)) { - return candidate - } - } - } catch { } - } - return null - } - - /** - * 初始化 WCDB - */ - async initialize(): Promise { - if (this.initialized) return true - - try { - this.koffi = require('koffi') - const dllPath = this.getDllPath() - - if (!existsSync(dllPath)) { - console.error('WCDB DLL 不存在:', dllPath) - return false - } - - this.lib = this.koffi.load(dllPath) - - // 定义类型 - // wcdb_status wcdb_init() - this.wcdbInit = this.lib.func('int32 wcdb_init()') - - // wcdb_status wcdb_shutdown() - this.wcdbShutdown = this.lib.func('int32 wcdb_shutdown()') - - // wcdb_status wcdb_open_account(const char* session_db_path, const char* hex_key, wcdb_handle* out_handle) - // wcdb_handle 是 int64_t - this.wcdbOpenAccount = this.lib.func('int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)') - - // wcdb_status wcdb_close_account(wcdb_handle handle) - // C 接口是 int64, koffi 返回 handle 是 number 类型 - this.wcdbCloseAccount = this.lib.func('int32 wcdb_close_account(int64 handle)') - - // wcdb_status wcdb_set_my_wxid(wcdb_handle handle, const char* wxid) - try { - this.wcdbSetMyWxid = this.lib.func('int32 wcdb_set_my_wxid(int64 handle, const char* wxid)') - } catch { - this.wcdbSetMyWxid = null - } - - // void wcdb_free_string(char* ptr) - this.wcdbFreeString = this.lib.func('void wcdb_free_string(void* ptr)') - - // wcdb_status wcdb_get_sessions(wcdb_handle handle, char** out_json) - this.wcdbGetSessions = this.lib.func('int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)') - - // wcdb_status wcdb_get_messages(wcdb_handle handle, const char* username, int32_t limit, int32_t offset, char** out_json) - this.wcdbGetMessages = this.lib.func('int32 wcdb_get_messages(int64 handle, const char* username, int32 limit, int32 offset, _Out_ void** outJson)') - - // wcdb_status wcdb_get_message_count(wcdb_handle handle, const char* username, int32_t* out_count) - this.wcdbGetMessageCount = this.lib.func('int32 wcdb_get_message_count(int64 handle, const char* username, _Out_ int32* outCount)') - - // wcdb_status wcdb_get_display_names(wcdb_handle handle, const char* usernames_json, char** out_json) - this.wcdbGetDisplayNames = this.lib.func('int32 wcdb_get_display_names(int64 handle, const char* usernamesJson, _Out_ void** outJson)') - - // wcdb_status wcdb_get_avatar_urls(wcdb_handle handle, const char* usernames_json, char** out_json) - this.wcdbGetAvatarUrls = this.lib.func('int32 wcdb_get_avatar_urls(int64 handle, const char* usernamesJson, _Out_ void** outJson)') - - // wcdb_status wcdb_get_group_member_count(wcdb_handle handle, const char* chatroom_id, int32_t* out_count) - this.wcdbGetGroupMemberCount = this.lib.func('int32 wcdb_get_group_member_count(int64 handle, const char* chatroomId, _Out_ int32* outCount)') - - // wcdb_status wcdb_get_group_member_counts(wcdb_handle handle, const char* chatroom_ids_json, char** out_json) - try { - this.wcdbGetGroupMemberCounts = this.lib.func('int32 wcdb_get_group_member_counts(int64 handle, const char* chatroomIdsJson, _Out_ void** outJson)') - } catch { - this.wcdbGetGroupMemberCounts = null - } - - // wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json) - this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)') - - // wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json) - this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)') - - // wcdb_status wcdb_get_message_meta(wcdb_handle handle, const char* db_path, const char* table_name, int32_t limit, int32_t offset, char** out_json) - this.wcdbGetMessageMeta = this.lib.func('int32 wcdb_get_message_meta(int64 handle, const char* dbPath, const char* tableName, int32 limit, int32 offset, _Out_ void** outJson)') - - // wcdb_status wcdb_get_contact(wcdb_handle handle, const char* username, char** out_json) - this.wcdbGetContact = this.lib.func('int32 wcdb_get_contact(int64 handle, const char* username, _Out_ void** outJson)') - - // wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json) - this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)') - - // wcdb_status wcdb_get_aggregate_stats(wcdb_handle handle, const char* session_ids_json, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) - this.wcdbGetAggregateStats = this.lib.func('int32 wcdb_get_aggregate_stats(int64 handle, const char* sessionIdsJson, int32 begin, int32 end, _Out_ void** outJson)') - - // wcdb_status wcdb_get_available_years(wcdb_handle handle, const char* session_ids_json, char** out_json) - try { - this.wcdbGetAvailableYears = this.lib.func('int32 wcdb_get_available_years(int64 handle, const char* sessionIdsJson, _Out_ void** outJson)') - } catch { - this.wcdbGetAvailableYears = null - } - - // wcdb_status wcdb_get_annual_report_stats(wcdb_handle handle, const char* session_ids_json, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) - try { - this.wcdbGetAnnualReportStats = this.lib.func('int32 wcdb_get_annual_report_stats(int64 handle, const char* sessionIdsJson, int32 begin, int32 end, _Out_ void** outJson)') - } catch { - this.wcdbGetAnnualReportStats = null - } - - // wcdb_status wcdb_get_annual_report_extras(wcdb_handle handle, const char* session_ids_json, int32_t begin_timestamp, int32_t end_timestamp, int32_t peak_day_begin, int32_t peak_day_end, char** out_json) - try { - this.wcdbGetAnnualReportExtras = this.lib.func('int32 wcdb_get_annual_report_extras(int64 handle, const char* sessionIdsJson, int32 begin, int32 end, int32 peakBegin, int32 peakEnd, _Out_ void** outJson)') - } catch { - this.wcdbGetAnnualReportExtras = null - } - - // wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) - try { - this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)') - } catch { - this.wcdbGetGroupStats = null - } - - // wcdb_status wcdb_open_message_cursor(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor) - this.wcdbOpenMessageCursor = this.lib.func('int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)') - - // wcdb_status wcdb_open_message_cursor_lite(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor) - try { - this.wcdbOpenMessageCursorLite = this.lib.func('int32 wcdb_open_message_cursor_lite(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)') - } catch { - this.wcdbOpenMessageCursorLite = null - } - - // wcdb_status wcdb_fetch_message_batch(wcdb_handle handle, wcdb_cursor cursor, char** out_json, int32_t* out_has_more) - this.wcdbFetchMessageBatch = this.lib.func('int32 wcdb_fetch_message_batch(int64 handle, int64 cursor, _Out_ void** outJson, _Out_ int32* outHasMore)') - - // wcdb_status wcdb_close_message_cursor(wcdb_handle handle, wcdb_cursor cursor) - this.wcdbCloseMessageCursor = this.lib.func('int32 wcdb_close_message_cursor(int64 handle, int64 cursor)') - - // wcdb_status wcdb_get_logs(char** out_json) - this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)') - - // wcdb_status wcdb_exec_query(wcdb_handle handle, const char* db_kind, const char* db_path, const char* sql, char** out_json) - this.wcdbExecQuery = this.lib.func('int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)') - - // wcdb_status wcdb_get_emoticon_cdn_url(wcdb_handle handle, const char* db_path, const char* md5, char** out_url) - this.wcdbGetEmoticonCdnUrl = this.lib.func('int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)') - - // wcdb_status wcdb_list_message_dbs(wcdb_handle handle, char** out_json) - this.wcdbListMessageDbs = this.lib.func('int32 wcdb_list_message_dbs(int64 handle, _Out_ void** outJson)') - - // wcdb_status wcdb_list_media_dbs(wcdb_handle handle, char** out_json) - this.wcdbListMediaDbs = this.lib.func('int32 wcdb_list_media_dbs(int64 handle, _Out_ void** outJson)') - - // wcdb_status wcdb_get_message_by_id(wcdb_handle handle, const char* session_id, int32 local_id, char** out_json) - this.wcdbGetMessageById = this.lib.func('int32 wcdb_get_message_by_id(int64 handle, const char* sessionId, int32 localId, _Out_ void** outJson)') - - // 初始化 - const initResult = this.wcdbInit() - if (initResult !== 0) { - console.error('WCDB 初始化失败:', initResult) - return false - } - - this.initialized = true - return true - } catch (e) { - console.error('WCDB 初始化异常:', e) - return false - } - } + // ========================================== + // 代理方法 (Proxy Methods) + // ========================================== /** * 测试数据库连接 */ async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> { - try { - // 如果当前已经有相同参数的活动连接,直接返回成功 - if (this.handle !== null && - this.currentPath === dbPath && - this.currentKey === hexKey && - this.currentWxid === wxid) { - return { success: true, sessionCount: 0 } - } - - if (!this.initialized) { - const initOk = await this.initialize() - if (!initOk) { - return { success: false, error: 'WCDB 初始化失败' } - } - } - - // 构建 db_storage 目录路径 - const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid) - this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`) - - if (!dbStoragePath || !existsSync(dbStoragePath)) { - return { success: false, error: `数据库目录不存在: ${dbPath}` } - } - - // 递归查找 session.db - const sessionDbPath = this.findSessionDb(dbStoragePath) - this.writeLog(`testConnection sessionDb=${sessionDbPath || 'null'}`) - - if (!sessionDbPath) { - return { success: false, error: `未找到 session.db 文件` } - } - - // 分配输出参数内存 - const handleOut = [0] - const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut) - - if (result !== 0) { - await this.printLogs() - let errorMsg = '数据库打开失败' - if (result === -1) errorMsg = '参数错误' - else if (result === -2) errorMsg = '密钥错误' - else if (result === -3) errorMsg = '数据库打开失败' - this.writeLog(`testConnection openAccount failed code=${result}`) - return { success: false, error: `${errorMsg} (错误码: ${result})` } - } - - const tempHandle = handleOut[0] - if (tempHandle <= 0) { - return { success: false, error: '无效的数据库句柄' } - } - - // 测试成功,使用 shutdown 清理所有资源(包括测试句柄) - // 这会中断当前活动连接,但 testConnection 本应该是独立测试 - try { - this.wcdbShutdown() - this.handle = null - this.currentPath = null - this.currentKey = null - this.currentWxid = null - this.initialized = false - } catch (closeErr) { - console.error('关闭测试数据库时出错:', closeErr) - } - - return { success: true, sessionCount: 0 } - } catch (e) { - console.error('测试连接异常:', e) - this.writeLog(`testConnection exception: ${String(e)}`) - return { success: false, error: String(e) } - } - } - - /** - * 打印 DLL 内部日志(仅在出错时调用) - */ - private async printLogs(force = false): Promise { - try { - if (!this.wcdbGetLogs) return - const outPtr = [null as any] - const result = this.wcdbGetLogs(outPtr) - if (result === 0 && outPtr[0]) { - try { - const jsonStr = this.koffi.decode(outPtr[0], 'char', -1) - this.writeLog(`wcdb_logs: ${jsonStr}`, force) - this.wcdbFreeString(outPtr[0]) - } catch (e) { - // ignore - } - } - } catch (e) { - console.error('获取日志失败:', e) - this.writeLog(`wcdb_logs failed: ${String(e)}`, force) - } - } - - private startLogPolling(): void { - if (this.logTimer || !this.isLogEnabled()) return - this.logTimer = setInterval(() => { - void this.pollLogs() - }, 2000) - } - - private stopLogPolling(): void { - if (this.logTimer) { - clearInterval(this.logTimer) - this.logTimer = null - } - this.lastLogTail = null - } - - private async pollLogs(): Promise { - try { - if (!this.wcdbGetLogs || !this.isLogEnabled()) return - const outPtr = [null as any] - const result = this.wcdbGetLogs(outPtr) - if (result !== 0 || !outPtr[0]) return - let jsonStr = '' - try { - jsonStr = this.koffi.decode(outPtr[0], 'char', -1) - } finally { - try { this.wcdbFreeString(outPtr[0]) } catch { } - } - const logs = JSON.parse(jsonStr) as string[] - if (!Array.isArray(logs) || logs.length === 0) return - let startIdx = 0 - if (this.lastLogTail) { - const idx = logs.lastIndexOf(this.lastLogTail) - if (idx >= 0) startIdx = idx + 1 - } - for (let i = startIdx; i < logs.length; i += 1) { - this.writeLog(`wcdb: ${logs[i]}`) - } - this.lastLogTail = logs[logs.length - 1] - } catch (e) { - // ignore polling errors - } - } - - private decodeJsonPtr(outPtr: any): string | null { - if (!outPtr) return null - try { - const jsonStr = this.koffi.decode(outPtr, 'char', -1) - this.wcdbFreeString(outPtr) - return jsonStr - } catch (e) { - try { this.wcdbFreeString(outPtr) } catch { } - return null - } - } - - private ensureReady(): boolean { - return this.initialized && this.handle !== null - } - - private normalizeTimestamp(input: number): number { - if (!input || input <= 0) return 0 - const asNumber = Number(input) - if (!Number.isFinite(asNumber)) return 0 - // Treat >1e12 as milliseconds. - const seconds = asNumber > 1e12 ? Math.floor(asNumber / 1000) : Math.floor(asNumber) - const maxInt32 = 2147483647 - return Math.min(Math.max(seconds, 0), maxInt32) - } - - private normalizeRange(beginTimestamp: number, endTimestamp: number): { begin: number; end: number } { - const normalizedBegin = this.normalizeTimestamp(beginTimestamp) - let normalizedEnd = this.normalizeTimestamp(endTimestamp) - if (normalizedEnd <= 0) { - normalizedEnd = this.normalizeTimestamp(Date.now()) - } - if (normalizedBegin > 0 && normalizedEnd < normalizedBegin) { - normalizedEnd = normalizedBegin - } - return { begin: normalizedBegin, end: normalizedEnd } - } - - isReady(): boolean { - return this.ensureReady() + return this.callWorker('testConnection', { dbPath, hexKey, wxid }) } /** * 打开数据库 */ async open(dbPath: string, hexKey: string, wxid: string): Promise { - try { - if (!this.initialized) { - const initOk = await this.initialize() - if (!initOk) return false - } - - // 检查是否已经是当前连接的参数,如果是则直接返回成功,实现"始终保持链接" - if (this.handle !== null && - this.currentPath === dbPath && - this.currentKey === hexKey && - this.currentWxid === wxid) { - return true - } - - // 如果参数不同,则先关闭原来的连接 - if (this.handle !== null) { - this.close() - // 重新初始化,因为 close 呼叫了 shutdown - const initOk = await this.initialize() - if (!initOk) return false - } - - const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid) - this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`) - - if (!dbStoragePath || !existsSync(dbStoragePath)) { - console.error('数据库目录不存在:', dbPath) - this.writeLog(`open failed: dbStorage not found for ${dbPath}`) - return false - } - - const sessionDbPath = this.findSessionDb(dbStoragePath) - this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`) - if (!sessionDbPath) { - console.error('未找到 session.db 文件') - this.writeLog('open failed: session.db not found') - return false - } - - const handleOut = [0] - const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut) - - if (result !== 0) { - console.error('打开数据库失败:', result) - await this.printLogs() - this.writeLog(`open failed: openAccount code=${result}`) - return false - } - - const handle = handleOut[0] - if (handle <= 0) { - return false - } - - this.handle = handle - this.currentPath = dbPath - this.currentKey = hexKey - this.currentWxid = wxid - this.initialized = true - if (this.wcdbSetMyWxid && wxid) { - try { - this.wcdbSetMyWxid(this.handle, wxid) - } catch (e) { - console.warn('设置 wxid 失败:', e) - } - } - if (this.isLogEnabled()) { - this.startLogPolling() - } - this.writeLog(`open ok handle=${handle}`) - return true - } catch (e) { - console.error('打开数据库异常:', e) - this.writeLog(`open exception: ${String(e)}`) - return false - } + return this.callWorker('open', { dbPath, hexKey, wxid }) } /** - * 关闭数据库 - * 注意:wcdb_close_account 可能导致崩溃,使用 shutdown 代替 + * 关闭数据库连接 */ - close(): void { - if (this.handle !== null || this.initialized) { - try { - // 不调用 closeAccount,直接 shutdown - this.wcdbShutdown() - } catch (e) { - console.error('WCDB shutdown 出错:', e) - } - this.handle = null - this.currentPath = null - this.currentKey = null - this.currentWxid = null - this.initialized = false - this.stopLogPolling() - } + async close(): Promise { + return this.callWorker('close') } /** - * 关闭服务(与 close 相同) + * 关闭服务 */ shutdown(): void { this.close() + if (this.worker) { + this.worker.terminate() + this.worker = null + } } /** - * 检查是否已连接 + * 获取数据库连接状态 + * 注意:此方法现在是异步的 */ - isConnected(): boolean { - return this.initialized && this.handle !== null + async isConnected(): Promise { + return this.callWorker('isConnected') } + /** + * 获取会话列表 + */ async getSessions(): Promise<{ success: boolean; sessions?: any[]; error?: string }> { - if (!this.ensureReady()) { - this.writeLog('getSessions skipped: not connected') - return { success: false, error: 'WCDB 未连接' } - } - try { - // 使用 setImmediate 让事件循环有机会处理其他任务,避免长时间阻塞 - await new Promise(resolve => setImmediate(resolve)) - - const outPtr = [null as any] - const result = this.wcdbGetSessions(this.handle, outPtr) - - // DLL 调用后再次让出控制权 - await new Promise(resolve => setImmediate(resolve)) - - if (result !== 0 || !outPtr[0]) { - this.writeLog(`getSessions failed: code=${result}`) - return { success: false, error: `获取会话失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析会话失败' } - this.writeLog(`getSessions ok size=${jsonStr.length}`) - const sessions = JSON.parse(jsonStr) - return { success: true, sessions } - } catch (e) { - this.writeLog(`getSessions exception: ${String(e)}`) - return { success: false, error: String(e) } - } + return this.callWorker('getSessions') } + /** + * 获取消息列表 + */ async getMessages(sessionId: string, limit: number, offset: number): Promise<{ success: boolean; messages?: any[]; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - const outPtr = [null as any] - const result = this.wcdbGetMessages(this.handle, sessionId, limit, offset, outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取消息失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析消息失败' } - const messages = JSON.parse(jsonStr) - return { success: true, messages } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getMessages', { sessionId, limit, offset }) } + /** + * 获取消息总数 + */ async getMessageCount(sessionId: string): Promise<{ success: boolean; count?: number; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - const outCount = [0] - const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount) - if (result !== 0) { - return { success: false, error: `获取消息总数失败: ${result}` } - } - return { success: true, count: outCount[0] } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getMessageCount', { sessionId }) } + /** + * 获取联系人昵称 + */ async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - if (usernames.length === 0) return { success: true, map: {} } - try { - // 让出控制权,避免阻塞事件循环 - await new Promise(resolve => setImmediate(resolve)) - - const outPtr = [null as any] - const result = this.wcdbGetDisplayNames(this.handle, JSON.stringify(usernames), outPtr) - - // DLL 调用后再次让出控制权 - await new Promise(resolve => setImmediate(resolve)) - - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取昵称失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析昵称失败' } - const map = JSON.parse(jsonStr) - return { success: true, map } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getDisplayNames', { usernames }) } + /** + * 获取头像 URL + */ async getAvatarUrls(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - if (usernames.length === 0) return { success: true, map: {} } - try { - const now = Date.now() - const resultMap: Record = {} - const toFetch: string[] = [] - const seen = new Set() - - for (const username of usernames) { - if (!username || seen.has(username)) continue - seen.add(username) - const cached = this.avatarUrlCache.get(username) - if (cached && cached.url && now - cached.updatedAt < this.avatarCacheTtlMs) { - resultMap[username] = cached.url - continue - } - toFetch.push(username) - } - - if (toFetch.length === 0) { - return { success: true, map: resultMap } - } - - // 让出控制权,避免阻塞事件循环 - await new Promise(resolve => setImmediate(resolve)) - - const outPtr = [null as any] - const result = this.wcdbGetAvatarUrls(this.handle, JSON.stringify(toFetch), outPtr) - - // DLL 调用后再次让出控制权 - await new Promise(resolve => setImmediate(resolve)) - - if (result !== 0 || !outPtr[0]) { - if (Object.keys(resultMap).length > 0) { - return { success: true, map: resultMap, error: `获取头像失败: ${result}` } - } - return { success: false, error: `获取头像失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析头像失败' } - const map = JSON.parse(jsonStr) as Record - for (const username of toFetch) { - const url = map[username] - if (url) { - resultMap[username] = url - this.avatarUrlCache.set(username, { url, updatedAt: now }) - } - } - return { success: true, map: resultMap } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getAvatarUrls', { usernames }) } + /** + * 获取群成员数量 + */ async getGroupMemberCount(chatroomId: string): Promise<{ success: boolean; count?: number; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - const outCount = [0] - const result = this.wcdbGetGroupMemberCount(this.handle, chatroomId, outCount) - if (result !== 0) { - return { success: false, error: `获取群成员数量失败: ${result}` } - } - return { success: true, count: outCount[0] } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getGroupMemberCount', { chatroomId }) } + /** + * 批量获取群成员数量 + */ async getGroupMemberCounts(chatroomIds: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - if (chatroomIds.length === 0) return { success: true, map: {} } - if (!this.wcdbGetGroupMemberCounts) { - const map: Record = {} - for (const chatroomId of chatroomIds) { - const result = await this.getGroupMemberCount(chatroomId) - if (result.success && typeof result.count === 'number') { - map[chatroomId] = result.count - } - } - return { success: true, map } - } - try { - const outPtr = [null as any] - const result = this.wcdbGetGroupMemberCounts(this.handle, JSON.stringify(chatroomIds), outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取群成员数量失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析群成员数量失败' } - const map = JSON.parse(jsonStr) - return { success: true, map } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getGroupMemberCounts', { chatroomIds }) } + /** + * 获取群成员列表 + */ async getGroupMembers(chatroomId: string): Promise<{ success: boolean; members?: any[]; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - const outPtr = [null as any] - const result = this.wcdbGetGroupMembers(this.handle, chatroomId, outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取群成员失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析群成员失败' } - const members = JSON.parse(jsonStr) - return { success: true, members } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getGroupMembers', { chatroomId }) } + /** + * 获取消息表列表 + */ async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - const outPtr = [null as any] - const result = this.wcdbGetMessageTables(this.handle, sessionId, outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取消息表失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析消息表失败' } - const tables = JSON.parse(jsonStr) - return { success: true, tables } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getMessageTables', { sessionId }) } + /** + * 获取消息表统计 + */ async getMessageTableStats(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - const outPtr = [null as any] - const result = this.wcdbGetMessageTableStats(this.handle, sessionId, outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取表统计失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析表统计失败' } - const tables = JSON.parse(jsonStr) - return { success: true, tables } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getMessageTableStats', { sessionId }) } + /** + * 获取消息元数据 + */ async getMessageMeta(dbPath: string, tableName: string, limit: number, offset: number): Promise<{ success: boolean; rows?: any[]; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - const outPtr = [null as any] - const result = this.wcdbGetMessageMeta(this.handle, dbPath, tableName, limit, offset, outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取消息元数据失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析消息元数据失败' } - const rows = JSON.parse(jsonStr) - return { success: true, rows } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getMessageMeta', { dbPath, tableName, limit, offset }) } + /** + * 获取联系人详情 + */ async getContact(username: string): Promise<{ success: boolean; contact?: any; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - const outPtr = [null as any] - const result = this.wcdbGetContact(this.handle, username, outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取联系人失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析联系人失败' } - const contact = JSON.parse(jsonStr) - return { success: true, contact } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getContact', { username }) } + /** + * 获取聚合统计数据 + */ async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - const normalizedBegin = this.normalizeTimestamp(beginTimestamp) - let normalizedEnd = this.normalizeTimestamp(endTimestamp) - if (normalizedEnd <= 0) { - normalizedEnd = this.normalizeTimestamp(Date.now()) - } - if (normalizedBegin > 0 && normalizedEnd < normalizedBegin) { - normalizedEnd = normalizedBegin - } - - const callAggregate = (ids: string[]) => { - const idsAreNumeric = ids.length > 0 && ids.every((id) => /^\d+$/.test(id)) - const payloadIds = idsAreNumeric ? ids.map((id) => Number(id)) : ids - - const outPtr = [null as any] - const result = this.wcdbGetAggregateStats(this.handle, JSON.stringify(payloadIds), normalizedBegin, normalizedEnd, outPtr) - - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取聚合统计失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) { - return { success: false, error: '解析聚合统计失败' } - } - - const data = JSON.parse(jsonStr) - return { success: true, data } - } - - let result = callAggregate(sessionIds) - if (result.success && result.data && result.data.total === 0 && result.data.idMap) { - const idMap = result.data.idMap as Record - const reverseMap: Record = {} - for (const [id, name] of Object.entries(idMap)) { - if (!name) continue - reverseMap[name] = id - } - const numericIds = sessionIds - .map((id) => reverseMap[id]) - .filter((id) => typeof id === 'string' && /^\d+$/.test(id)) - if (numericIds.length > 0) { - const retry = callAggregate(numericIds) - if (retry.success && retry.data) { - result = retry - } - } - } - - return result - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getAggregateStats', { sessionIds, beginTimestamp, endTimestamp }) } + /** + * 获取可用年份 + */ async getAvailableYears(sessionIds: string[]): Promise<{ success: boolean; data?: number[]; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - if (!this.wcdbGetAvailableYears) { - return { success: false, error: '未支持获取年度列表' } - } - if (sessionIds.length === 0) return { success: true, data: [] } - try { - const outPtr = [null as any] - const result = this.wcdbGetAvailableYears(this.handle, JSON.stringify(sessionIds), outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取年度列表失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析年度列表失败' } - const data = JSON.parse(jsonStr) - return { success: true, data } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getAvailableYears', { sessionIds }) } + /** + * 获取年度报告统计 + */ async getAnnualReportStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - if (!this.wcdbGetAnnualReportStats) { - return this.getAggregateStats(sessionIds, beginTimestamp, endTimestamp) - } - try { - const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp) - const outPtr = [null as any] - const result = this.wcdbGetAnnualReportStats(this.handle, JSON.stringify(sessionIds), begin, end, outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取年度统计失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析年度统计失败' } - const data = JSON.parse(jsonStr) - return { success: true, data } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getAnnualReportStats', { sessionIds, beginTimestamp, endTimestamp }) } - async getAnnualReportExtras( - sessionIds: string[], - beginTimestamp: number = 0, - endTimestamp: number = 0, - peakDayBegin: number = 0, - peakDayEnd: number = 0 - ): Promise<{ success: boolean; data?: any; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - if (!this.wcdbGetAnnualReportExtras) { - return { success: false, error: '未支持年度扩展统计' } - } - if (sessionIds.length === 0) return { success: true, data: {} } - try { - const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp) - const outPtr = [null as any] - const result = this.wcdbGetAnnualReportExtras( - this.handle, - JSON.stringify(sessionIds), - begin, - end, - this.normalizeTimestamp(peakDayBegin), - this.normalizeTimestamp(peakDayEnd), - outPtr - ) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取年度扩展统计失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析年度扩展统计失败' } - const data = JSON.parse(jsonStr) - return { success: true, data } - } catch (e) { - return { success: false, error: String(e) } - } + /** + * 获取年度报告扩展数据 + */ + async getAnnualReportExtras(sessionIds: string[], beginTimestamp: number, endTimestamp: number, peakDayBegin: number, peakDayEnd: number): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('getAnnualReportExtras', { sessionIds, beginTimestamp, endTimestamp, peakDayBegin, peakDayEnd }) } + /** + * 获取群聊统计 + */ async getGroupStats(chatroomId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - if (!this.wcdbGetGroupStats) { - return this.getAggregateStats([chatroomId], beginTimestamp, endTimestamp) - } - try { - const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp) - const outPtr = [null as any] - const result = this.wcdbGetGroupStats(this.handle, chatroomId, begin, end, outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取群聊统计失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析群聊统计失败' } - const data = JSON.parse(jsonStr) - return { success: true, data } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getGroupStats', { chatroomId, beginTimestamp, endTimestamp }) } + /** + * 打开消息游标 + */ async openMessageCursor(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - const outCursor = [0] - const result = this.wcdbOpenMessageCursor( - this.handle, - sessionId, - batchSize, - ascending ? 1 : 0, - beginTimestamp, - endTimestamp, - outCursor - ) - if (result !== 0 || outCursor[0] <= 0) { - await this.printLogs(true) - this.writeLog( - `openMessageCursor failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`, - true - ) - return { success: false, error: `创建游标失败: ${result},请查看日志` } - } - return { success: true, cursor: outCursor[0] } - } catch (e) { - await this.printLogs(true) - this.writeLog(`openMessageCursor exception: ${String(e)}`, true) - return { success: false, error: '创建游标异常,请查看日志' } - } + return this.callWorker('openMessageCursor', { sessionId, batchSize, ascending, beginTimestamp, endTimestamp }) } + /** + * 打开轻量级消息游标 + */ async openMessageCursorLite(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - if (!this.wcdbOpenMessageCursorLite) { - return this.openMessageCursor(sessionId, batchSize, ascending, beginTimestamp, endTimestamp) - } - try { - const outCursor = [0] - const result = this.wcdbOpenMessageCursorLite( - this.handle, - sessionId, - batchSize, - ascending ? 1 : 0, - beginTimestamp, - endTimestamp, - outCursor - ) - if (result !== 0 || outCursor[0] <= 0) { - await this.printLogs(true) - this.writeLog( - `openMessageCursorLite failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`, - true - ) - return { success: false, error: `创建游标失败: ${result},请查看日志` } - } - return { success: true, cursor: outCursor[0] } - } catch (e) { - await this.printLogs(true) - this.writeLog(`openMessageCursorLite exception: ${String(e)}`, true) - return { success: false, error: '创建游标异常,请查看日志' } - } + return this.callWorker('openMessageCursorLite', { sessionId, batchSize, ascending, beginTimestamp, endTimestamp }) } + /** + * 获取下一批消息 + */ async fetchMessageBatch(cursor: number): Promise<{ success: boolean; rows?: any[]; hasMore?: boolean; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - const outPtr = [null as any] - const outHasMore = [0] - const result = this.wcdbFetchMessageBatch(this.handle, cursor, outPtr, outHasMore) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取批次失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析批次失败' } - const rows = JSON.parse(jsonStr) - return { success: true, rows, hasMore: outHasMore[0] === 1 } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('fetchMessageBatch', { cursor }) } + /** + * 关闭消息游标 + */ async closeMessageCursor(cursor: number): Promise<{ success: boolean; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - const result = this.wcdbCloseMessageCursor(this.handle, cursor) - if (result !== 0) { - return { success: false, error: `关闭游标失败: ${result}` } - } - return { success: true } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('closeMessageCursor', { cursor }) } + /** + * 执行 SQL 查询 + */ async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - const outPtr = [null as any] - const result = this.wcdbExecQuery(this.handle, kind, path, sql, outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `执行查询失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析查询结果失败' } - const rows = JSON.parse(jsonStr) - return { success: true, rows } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('execQuery', { kind, path, sql }) } + /** + * 获取表情包 CDN URL + */ async getEmoticonCdnUrl(dbPath: string, md5: string): Promise<{ success: boolean; url?: string; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - const outPtr = [null as any] - const result = this.wcdbGetEmoticonCdnUrl(this.handle, dbPath, md5, outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取表情 URL 失败: ${result}` } - } - const urlStr = this.decodeJsonPtr(outPtr[0]) - if (urlStr === null) return { success: false, error: '解析表情 URL 失败' } - return { success: true, url: urlStr || undefined } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getEmoticonCdnUrl', { dbPath, md5 }) } + /** + * 列出消息数据库 + */ async listMessageDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - try { - const outPtr = [null as any] - const result = this.wcdbListMessageDbs(this.handle, outPtr) - if (result !== 0 || !outPtr[0]) return { success: false, error: `获取消息库列表失败: ${result}` } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析消息库列表失败' } - const data = JSON.parse(jsonStr) - return { success: true, data } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('listMessageDbs') } + /** + * 列出媒体数据库 + */ async listMediaDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - try { - const outPtr = [null as any] - const result = this.wcdbListMediaDbs(this.handle, outPtr) - if (result !== 0 || !outPtr[0]) return { success: false, error: `获取媒体库列表失败: ${result}` } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析媒体库列表失败' } - const data = JSON.parse(jsonStr) - return { success: true, data } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('listMediaDbs') } + /** + * 根据 ID 获取消息 + */ async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - try { - const outPtr = [null as any] - const result = this.wcdbGetMessageById(this.handle, sessionId, localId, outPtr) - if (result !== 0 || !outPtr[0]) return { success: false, error: `查询消息失败: ${result}` } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析消息失败' } - const message = JSON.parse(jsonStr) - // 处理 wcdb_get_message_by_id 返回空对象的情况 - if (Object.keys(message).length === 0) return { success: false, error: '未找到消息' } - return { success: true, message } - } catch (e) { - return { success: false, error: String(e) } - } + return this.callWorker('getMessageById', { sessionId, localId }) } + } export const wcdbService = new WcdbService() diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts new file mode 100644 index 0000000..4958419 --- /dev/null +++ b/electron/wcdbWorker.ts @@ -0,0 +1,122 @@ +import { parentPort, workerData } from 'worker_threads' +import { WcdbCore } from './services/wcdbCore' + +const core = new WcdbCore() + +if (parentPort) { + parentPort.on('message', async (msg) => { + const { id, type, payload } = msg + + try { + let result: any + + switch (type) { + case 'setPaths': + core.setPaths(payload.resourcesPath, payload.userDataPath) + result = { success: true } + break + case 'setLogEnabled': + core.setLogEnabled(payload.enabled) + result = { success: true } + break + case 'testConnection': + result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid) + break + case 'open': + result = await core.open(payload.dbPath, payload.hexKey, payload.wxid) + break + case 'close': + core.close() + result = { success: true } + break + case 'isConnected': + result = core.isConnected() + break + case 'getSessions': + result = await core.getSessions() + break + case 'getMessages': + result = await core.getMessages(payload.sessionId, payload.limit, payload.offset) + break + case 'getMessageCount': + result = await core.getMessageCount(payload.sessionId) + break + case 'getDisplayNames': + result = await core.getDisplayNames(payload.usernames) + break + case 'getAvatarUrls': + result = await core.getAvatarUrls(payload.usernames) + break + case 'getGroupMemberCount': + result = await core.getGroupMemberCount(payload.chatroomId) + break + case 'getGroupMemberCounts': + result = await core.getGroupMemberCounts(payload.chatroomIds) + break + case 'getGroupMembers': + result = await core.getGroupMembers(payload.chatroomId) + break + case 'getMessageTables': + result = await core.getMessageTables(payload.sessionId) + break + case 'getMessageTableStats': + result = await core.getMessageTableStats(payload.sessionId) + break + case 'getMessageMeta': + result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset) + break + case 'getContact': + result = await core.getContact(payload.username) + break + case 'getAggregateStats': + result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp) + break + case 'getAvailableYears': + result = await core.getAvailableYears(payload.sessionIds) + break + case 'getAnnualReportStats': + result = await core.getAnnualReportStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp) + break + case 'getAnnualReportExtras': + result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd) + break + case 'getGroupStats': + result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp) + break + case 'openMessageCursor': + result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp) + break + case 'openMessageCursorLite': + result = await core.openMessageCursorLite(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp) + break + case 'fetchMessageBatch': + result = await core.fetchMessageBatch(payload.cursor) + break + case 'closeMessageCursor': + result = await core.closeMessageCursor(payload.cursor) + break + case 'execQuery': + result = await core.execQuery(payload.kind, payload.path, payload.sql) + break + case 'getEmoticonCdnUrl': + result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5) + break + case 'listMessageDbs': + result = await core.listMessageDbs() + break + case 'listMediaDbs': + result = await core.listMediaDbs() + break + case 'getMessageById': + result = await core.getMessageById(payload.sessionId, payload.localId) + break + default: + result = { success: false, error: `Unknown method: ${type}` } + } + + parentPort!.postMessage({ id, result }) + } catch (e) { + parentPort!.postMessage({ id, error: String(e) }) + } + }) +} diff --git a/package-lock.json b/package-lock.json index ba153dd..6c3e749 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "weflow", - "version": "1.0.0", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "weflow", - "version": "1.0.0", + "version": "1.0.4", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.5.0", diff --git a/package.json b/package.json index dff0f8e..3a6fce7 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,17 @@ { "name": "weflow", - "version": "1.0.4", - "description": "WeFlow - 微信聊天记录查看工具", + "version": "1.1.0", + "description": "WeFlow", "main": "dist-electron/main.js", "author": "cc", "scripts": { + "postinstall": "echo 'No native modules to rebuild'", + "rebuild": "echo 'No native modules to rebuild'", "dev": "vite", "build": "tsc && vite build && electron-builder", "preview": "vite preview", "electron:dev": "vite --mode electron", - "electron:build": "npm run build", - "postinstall": "electron-rebuild" + "electron:build": "npm run build" }, "dependencies": { "better-sqlite3": "^12.5.0", diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 70e4eb7..3428494 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/App.tsx b/src/App.tsx index 28691d2..ed93702 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import WelcomePage from './pages/WelcomePage' import HomePage from './pages/HomePage' import ChatPage from './pages/ChatPage' import AnalyticsPage from './pages/AnalyticsPage' +import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage' import AnnualReportPage from './pages/AnnualReportPage' import AnnualReportWindow from './pages/AnnualReportWindow' import AgreementPage from './pages/AgreementPage' @@ -14,6 +15,7 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage' import DataManagementPage from './pages/DataManagementPage' import SettingsPage from './pages/SettingsPage' import ExportPage from './pages/ExportPage' + import { useAppStore } from './stores/appStore' import { themes, useThemeStore, type ThemeId } from './stores/themeStore' import * as configService from './services/config' @@ -188,7 +190,7 @@ function App() { } console.log('检测到已保存的配置,正在自动连接...') const result = await window.electronAPI.chat.connect() - + if (result.success) { console.log('自动连接成功') setDbConnected(true, dbPath) @@ -307,7 +309,8 @@ function App() { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/Avatar.scss b/src/components/Avatar.scss new file mode 100644 index 0000000..34b11b2 --- /dev/null +++ b/src/components/Avatar.scss @@ -0,0 +1,79 @@ +.avatar-component { + position: relative; + display: inline-block; + overflow: hidden; + background-color: var(--bg-tertiary, #f5f5f5); + flex-shrink: 0; + border-radius: 4px; + /* Default radius */ + + &.circle { + border-radius: 50%; + } + + &.rounded { + border-radius: 6px; + } + + /* Image styling */ + img.avatar-image { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 0.3s ease-in-out; + border-radius: inherit; + + &.loaded { + opacity: 1; + } + + &.instant { + transition: none !important; + opacity: 1 !important; + } + } + + /* Placeholder/Letter styling */ + .avatar-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 500; + color: var(--text-secondary, #666); + background-color: var(--bg-tertiary, #e0e0e0); + font-size: 1.2em; + text-transform: uppercase; + user-select: none; + border-radius: inherit; + } + + /* Loading Skeleton */ + .avatar-skeleton { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(90deg, + var(--bg-tertiary, #f0f0f0) 25%, + var(--bg-secondary, #e0e0e0) 50%, + var(--bg-tertiary, #f0f0f0) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + z-index: 1; + border-radius: inherit; + } + + @keyframes shimmer { + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -200% 0; + } + } +} \ No newline at end of file diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx new file mode 100644 index 0000000..7406bd5 --- /dev/null +++ b/src/components/Avatar.tsx @@ -0,0 +1,129 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react' +import { User } from 'lucide-react' +import { avatarLoadQueue } from '../utils/AvatarLoadQueue' +import './Avatar.scss' + +// 全局缓存已成功加载过的头像 URL,用于控制后续是否显示动画 +const loadedAvatarCache = new Set() + +interface AvatarProps { + src?: string + name?: string + size?: number | string + shape?: 'circle' | 'square' | 'rounded' + className?: string + lazy?: boolean + onClick?: () => void +} + +export const Avatar = React.memo(function Avatar({ + src, + name, + size = 48, + shape = 'rounded', + className = '', + lazy = true, + onClick +}: AvatarProps) { + // 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画 + const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src]) + const [imageLoaded, setImageLoaded] = useState(isCached) + const [imageError, setImageError] = useState(false) + const [shouldLoad, setShouldLoad] = useState(!lazy || isCached) + const [isInQueue, setIsInQueue] = useState(false) + const imgRef = useRef(null) + const containerRef = useRef(null) + + const getAvatarLetter = (): string => { + if (!name) return '?' + const chars = [...name] + return chars[0] || '?' + } + + // Intersection Observer for lazy loading + useEffect(() => { + if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached) return + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !isInQueue) { + setIsInQueue(true) + avatarLoadQueue.enqueue(src).then(() => { + setShouldLoad(true) + }).catch(() => { + // 加载失败不要立刻显示错误,让浏览器渲染去报错 + setShouldLoad(true) + }).finally(() => { + setIsInQueue(false) + }) + observer.disconnect() + } + }) + }, + { rootMargin: '100px' } + ) + + observer.observe(containerRef.current) + + return () => observer.disconnect() + }, [src, lazy, shouldLoad, isInQueue, isCached]) + + // Reset state when src changes + useEffect(() => { + const cached = src ? loadedAvatarCache.has(src) : false + setImageLoaded(cached) + setImageError(false) + if (lazy && !cached) { + setShouldLoad(false) + setIsInQueue(false) + } else { + setShouldLoad(true) + } + }, [src, lazy]) + + // Check if image is already cached/loaded + useEffect(() => { + if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) { + setImageLoaded(true) + } + }, [src, shouldLoad]) + + const style = { + width: typeof size === 'number' ? `${size}px` : size, + height: typeof size === 'number' ? `${size}px` : size, + } + + const hasValidUrl = !!src && !imageError && shouldLoad + + return ( +
+ {hasValidUrl ? ( + <> + {!imageLoaded &&
} + {name { + if (src) loadedAvatarCache.add(src) + setImageLoaded(true) + }} + onError={() => setImageError(true)} + loading={lazy ? "lazy" : "eager"} + /> + + ) : ( +
+ {name ? {getAvatarLetter()} : } +
+ )} +
+ ) +}) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 55d7dce..b2fd84b 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' -import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download } from 'lucide-react' +import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot } from 'lucide-react' import './Sidebar.scss' function Sidebar() { @@ -34,6 +34,8 @@ function Sidebar() { 聊天 + + {/* 私聊分析 */} 数据管理 - +
- @@ -96,8 +98,8 @@ function Sidebar() { 设置 - - + + +
+
+ + ) +} + +export default AnalyticsWelcomePage diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index e53857c..2c7d462 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1426,10 +1426,12 @@ height: 6px; opacity: 0.5; } + 50% { height: 16px; opacity: 1; } + 100% { height: 6px; opacity: 0.5; @@ -1875,4 +1877,4 @@ opacity: 1; transform: translateX(0); } -} +} \ No newline at end of file diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 000e346..6376593 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon } from 'lucide-react' +import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' import type { ChatSession, Message } from '../types/models' import { getEmojiPath } from 'wechat-emojis' @@ -23,65 +24,10 @@ interface SessionDetail { messageTables: { dbName: string; tableName: string; count: number }[] } -// 全局头像加载队列管理器(限制并发,避免卡顿) -class AvatarLoadQueue { - private queue: Array<{ url: string; resolve: () => void; reject: () => void }> = [] - private loading = new Set() - private readonly maxConcurrent = 1 // 一次只加载1个头像,避免卡顿 - private readonly delayBetweenBatches = 100 // 批次间延迟100ms,给UI喘息时间 - - async enqueue(url: string): Promise { - // 如果已经在加载中,直接返回 - if (this.loading.has(url)) { - return Promise.resolve() - } - - return new Promise((resolve, reject) => { - this.queue.push({ url, resolve, reject }) - this.processQueue() - }) - } - - private async processQueue() { - // 如果已达到最大并发数,等待 - if (this.loading.size >= this.maxConcurrent) { - return - } - - // 如果队列为空,返回 - if (this.queue.length === 0) { - return - } - - // 取出一个任务 - const task = this.queue.shift() - if (!task) return - - this.loading.add(task.url) - - // 加载图片 - const img = new Image() - img.onload = () => { - this.loading.delete(task.url) - task.resolve() - // 延迟一下再处理下一个,避免一次性加载太多 - setTimeout(() => this.processQueue(), this.delayBetweenBatches) - } - img.onerror = () => { - this.loading.delete(task.url) - task.reject() - setTimeout(() => this.processQueue(), this.delayBetweenBatches) - } - img.src = task.url - } - - clear() { - this.queue = [] - this.loading.clear() - } -} - -const avatarLoadQueue = new AvatarLoadQueue() +// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts +// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts +import { avatarLoadQueue } from '../utils/AvatarLoadQueue' +import { Avatar } from '../components/Avatar' // 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染) // 会话项组件(使用 memo 优化,避免不必要的重渲染) @@ -97,7 +43,7 @@ const SessionItem = React.memo(function SessionItem({ formatTime: (timestamp: number) => string }) { // 缓存格式化的时间 - const timeText = useMemo(() => + const timeText = useMemo(() => formatTime(session.lastTimestamp || session.sortTimestamp), [formatTime, session.lastTimestamp, session.sortTimestamp] ) @@ -107,7 +53,12 @@ const SessionItem = React.memo(function SessionItem({ className={`session-item ${isActive ? 'active' : ''}`} onClick={() => onSelect(session)} > - +
{session.displayName || session.username} @@ -138,109 +89,7 @@ const SessionItem = React.memo(function SessionItem({ ) }) -const SessionAvatar = React.memo(function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: number }) { - const [imageLoaded, setImageLoaded] = useState(false) - const [imageError, setImageError] = useState(false) - const [shouldLoad, setShouldLoad] = useState(false) - const [isInQueue, setIsInQueue] = useState(false) - const imgRef = useRef(null) - const containerRef = useRef(null) - const isGroup = session.username.includes('@chatroom') - const getAvatarLetter = (): string => { - const name = session.displayName || session.username - if (!name) return '?' - const chars = [...name] - return chars[0] || '?' - } - - // 使用 Intersection Observer 实现懒加载(优化性能) - useEffect(() => { - if (!containerRef.current || shouldLoad || isInQueue) return - if (!session.avatarUrl) { - // 没有头像URL,不需要加载 - return - } - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting && !isInQueue) { - // 加入加载队列,而不是立即加载 - setIsInQueue(true) - avatarLoadQueue.enqueue(session.avatarUrl!).then(() => { - setShouldLoad(true) - }).catch(() => { - setImageError(true) - }).finally(() => { - setIsInQueue(false) - }) - observer.disconnect() - } - }) - }, - { - rootMargin: '50px' // 减少预加载距离,只提前50px - } - ) - - observer.observe(containerRef.current) - - return () => { - observer.disconnect() - } - }, [session.avatarUrl, shouldLoad, isInQueue]) - - // 当 avatarUrl 变化时重置状态 - useEffect(() => { - setImageLoaded(false) - setImageError(false) - setShouldLoad(false) - setIsInQueue(false) - }, [session.avatarUrl]) - - // 检查图片是否已经从缓存加载完成 - useEffect(() => { - if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) { - setImageLoaded(true) - } - }, [session.avatarUrl, shouldLoad]) - - const hasValidUrl = session.avatarUrl && !imageError && shouldLoad - - return ( -
- {hasValidUrl ? ( - <> - {!imageLoaded &&
} - setImageLoaded(true)} - onError={() => setImageError(true)} - loading="lazy" - /> - - ) : ( - {getAvatarLetter()} - )} -
- ) -}, (prevProps, nextProps) => { - // 自定义比较函数,只在关键属性变化时重渲染 - return ( - prevProps.session.username === nextProps.session.username && - prevProps.session.displayName === nextProps.session.displayName && - prevProps.session.avatarUrl === nextProps.session.avatarUrl && - prevProps.size === nextProps.size - ) -}) function ChatPage(_props: ChatPageProps) { const { @@ -278,6 +127,7 @@ function ChatPage(_props: ChatPageProps) { const sessionListRef = useRef(null) const [currentOffset, setCurrentOffset] = useState(0) const [myAvatarUrl, setMyAvatarUrl] = useState(undefined) + const [myWxid, setMyWxid] = useState(undefined) const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [sidebarWidth, setSidebarWidth] = useState(260) const [isResizing, setIsResizing] = useState(false) @@ -287,7 +137,7 @@ function ChatPage(_props: ChatPageProps) { const [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) const [hasInitialMessages, setHasInitialMessages] = useState(false) - + // 联系人信息加载控制 const isEnrichingRef = useRef(false) const enrichCancelledRef = useRef(false) @@ -354,6 +204,9 @@ function ChatPage(_props: ChatPageProps) { setConnected(true) await loadSessions() await loadMyAvatar() + // 获取 myWxid 用于匹配个人头像 + const wxid = await window.electronAPI.config.get('myWxid') + if (wxid) setMyWxid(wxid as string) } else { setConnectionError(result.error || '连接失败') } @@ -380,10 +233,8 @@ function ChatPage(_props: ChatPageProps) { // 确保 nextSessions 也是数组 if (Array.isArray(nextSessions)) { setSessions(nextSessions) - // 延迟启动联系人信息加载,确保UI先渲染完成 - setTimeout(() => { - void enrichSessionsContactInfo(nextSessions) - }, 500) + // 立即启动联系人信息加载,不再延迟 500ms + void enrichSessionsContactInfo(nextSessions) } else { console.error('mergeSessions returned non-array:', nextSessions) setSessions(sessionsArray) @@ -407,31 +258,30 @@ function ChatPage(_props: ChatPageProps) { // 分批异步加载联系人信息(优化性能:防止重复加载,滚动时暂停,只在空闲时加载) const enrichSessionsContactInfo = async (sessions: ChatSession[]) => { if (sessions.length === 0) return - + // 防止重复加载 if (isEnrichingRef.current) { console.log('[性能监控] 联系人信息正在加载中,跳过重复请求') return } - + isEnrichingRef.current = true enrichCancelledRef.current = false - + console.log(`[性能监控] 开始加载联系人信息,会话数: ${sessions.length}`) const totalStart = performance.now() - - // 延迟启动,等待UI渲染完成 - await new Promise(resolve => setTimeout(resolve, 500)) - + + // 移除初始 500ms 延迟,让后台加载与 UI 渲染并行 + // 检查是否被取消 if (enrichCancelledRef.current) { isEnrichingRef.current = false return } - + try { - // 找出需要加载联系人信息的会话(没有缓存的) - const needEnrich = sessions.filter(s => !s.avatarUrl && (!s.displayName || s.displayName === s.username)) + // 找出需要加载联系人信息的会话(没有头像或者没有显示名称的) + const needEnrich = sessions.filter(s => !s.avatarUrl || !s.displayName || s.displayName === s.username) if (needEnrich.length === 0) { console.log('[性能监控] 所有联系人信息已缓存,跳过加载') isEnrichingRef.current = false @@ -443,7 +293,7 @@ function ChatPage(_props: ChatPageProps) { // 进一步减少批次大小,每批3个,避免DLL调用阻塞 const batchSize = 3 let loadedCount = 0 - + for (let i = 0; i < needEnrich.length; i += batchSize) { // 如果正在滚动,暂停加载 if (isScrollingRef.current) { @@ -454,14 +304,14 @@ function ChatPage(_props: ChatPageProps) { } if (enrichCancelledRef.current) break } - + // 检查是否被取消 if (enrichCancelledRef.current) break - + const batchStart = performance.now() const batch = needEnrich.slice(i, i + batchSize) const usernames = batch.map(s => s.username) - + // 使用 requestIdleCallback 延迟执行,避免阻塞UI await new Promise((resolve) => { if ('requestIdleCallback' in window) { @@ -474,13 +324,13 @@ function ChatPage(_props: ChatPageProps) { }, 300) } }) - + loadedCount += batch.length const batchTime = performance.now() - batchStart if (batchTime > 200) { console.warn(`[性能监控] 批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(needEnrich.length / batchSize)} 耗时: ${batchTime.toFixed(2)}ms (已加载: ${loadedCount}/${needEnrich.length})`) } - + // 批次间延迟,给UI更多时间(DLL调用可能阻塞,需要更长的延迟) if (i + batchSize < needEnrich.length && !enrichCancelledRef.current) { // 如果不在滚动,可以延迟短一点 @@ -488,7 +338,7 @@ function ChatPage(_props: ChatPageProps) { await new Promise(resolve => setTimeout(resolve, delay)) } } - + const totalTime = performance.now() - totalStart if (!enrichCancelledRef.current) { console.log(`[性能监控] 联系人信息加载完成,总耗时: ${totalTime.toFixed(2)}ms, 已加载: ${loadedCount}/${needEnrich.length}`) @@ -570,23 +420,35 @@ function ChatPage(_props: ChatPageProps) { try { // 在 DLL 调用前让出控制权(使用 setTimeout 0 代替 setImmediate) await new Promise(resolve => setTimeout(resolve, 0)) - + const dllStart = performance.now() const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) const dllTime = performance.now() - dllStart - + // DLL 调用后再次让出控制权 await new Promise(resolve => setTimeout(resolve, 0)) - + const totalTime = performance.now() - startTime if (dllTime > 50 || totalTime > 100) { console.warn(`[性能监控] DLL调用耗时: ${dllTime.toFixed(2)}ms, 总耗时: ${totalTime.toFixed(2)}ms, usernames: ${usernames.length}`) } - + if (result.success && result.contacts) { - // 将更新加入队列,而不是立即更新 + // 将更新加入队列,用于侧边栏更新 for (const [username, contact] of Object.entries(result.contacts)) { contactUpdateQueueRef.current.set(username, contact) + + // 如果是自己的信息且当前个人头像为空,同步更新 + if (myWxid && username === myWxid && contact.avatarUrl && !myAvatarUrl) { + console.log('[ChatPage] 从联系人同步获取到个人头像') + setMyAvatarUrl(contact.avatarUrl) + } + + // 【核心优化】同步更新全局发送者头像缓存,供 MessageBubble 使用 + senderAvatarCache.set(username, { + avatarUrl: contact.avatarUrl, + displayName: contact.displayName + }) } // 触发批量更新 flushContactUpdates() @@ -644,7 +506,7 @@ function ChatPage(_props: ChatPageProps) { const session = sessionMapRef.current.get(sessionId) const unreadCount = session?.unreadCount ?? 0 const messageLimit = offset === 0 && unreadCount > 99 ? 30 : 50 - + if (offset === 0) { setLoadingMessages(true) setMessages([]) @@ -660,6 +522,31 @@ function ChatPage(_props: ChatPageProps) { if (result.success && result.messages) { if (offset === 0) { setMessages(result.messages) + + // 预取发送者信息:在关闭加载遮罩前处理 + const unreadCount = session?.unreadCount ?? 0 + const isGroup = sessionId.includes('@chatroom') + if (isGroup && result.messages.length > 0) { + const unknownSenders = [...new Set(result.messages + .filter(m => m.isSend !== 1 && m.senderUsername && !senderAvatarCache.has(m.senderUsername)) + .map(m => m.senderUsername as string) + )] + if (unknownSenders.length > 0) { + console.log(`[性能监控] 预取消息发送者信息: ${unknownSenders.length} 个`) + // 在批量请求前,先将这些发送者标记为加载中,防止 MessageBubble 触发重复请求 + const batchPromise = loadContactInfoBatch(unknownSenders) + unknownSenders.forEach(username => { + if (!senderAvatarLoading.has(username)) { + senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null)) + } + }) + // 确保在请求完成后清理 loading 状态 + batchPromise.finally(() => { + unknownSenders.forEach(username => senderAvatarLoading.delete(username)) + }) + } + } + // 首次加载滚动到底部 requestAnimationFrame(() => { if (messageListRef.current) { @@ -668,6 +555,27 @@ function ChatPage(_props: ChatPageProps) { }) } else { appendMessages(result.messages, true) + + // 加载更多也同样处理发送者信息预取 + const isGroup = sessionId.includes('@chatroom') + if (isGroup) { + const unknownSenders = [...new Set(result.messages + .filter(m => m.isSend !== 1 && m.senderUsername && !senderAvatarCache.has(m.senderUsername)) + .map(m => m.senderUsername as string) + )] + if (unknownSenders.length > 0) { + const batchPromise = loadContactInfoBatch(unknownSenders) + unknownSenders.forEach(username => { + if (!senderAvatarLoading.has(username)) { + senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null)) + } + }) + batchPromise.finally(() => { + unknownSenders.forEach(username => senderAvatarLoading.delete(username)) + }) + } + } + // 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置 if (firstMsgEl && listEl) { requestAnimationFrame(() => { @@ -742,7 +650,7 @@ function ChatPage(_props: ChatPageProps) { scrollTimeoutRef.current = requestAnimationFrame(() => { if (!messageListRef.current) return - + const { scrollTop, clientHeight, scrollHeight } = messageListRef.current // 显示回到底部按钮:距离底部超过 300px @@ -842,7 +750,7 @@ function ChatPage(_props: ChatPageProps) { if (!isConnected && !isConnecting) { connect() } - + // 组件卸载时清理 return () => { avatarLoadQueue.clear() @@ -906,7 +814,7 @@ function ChatPage(_props: ChatPageProps) { }) } if (payloads.length > 0) { - window.electronAPI.image.preload(payloads).catch(() => {}) + window.electronAPI.image.preload(payloads).catch(() => { }) } }, [currentSessionId, messages]) @@ -1101,8 +1009,10 @@ function ChatPage(_props: ChatPageProps) {
)} + {/* ... (previous content) ... */} {isLoadingSessions ? (
+ {/* ... (skeleton items) ... */} {[1, 2, 3, 4, 5].map(i => (
@@ -1114,16 +1024,14 @@ function ChatPage(_props: ChatPageProps) { ))}
) : Array.isArray(filteredSessions) && filteredSessions.length > 0 ? ( -
{ - // 标记正在滚动,暂停联系人信息加载 isScrollingRef.current = true if (sessionScrollTimeoutRef.current) { clearTimeout(sessionScrollTimeoutRef.current) } - // 滚动结束后200ms才认为滚动停止 sessionScrollTimeoutRef.current = window.setTimeout(() => { isScrollingRef.current = false sessionScrollTimeoutRef.current = null @@ -1147,6 +1055,8 @@ function ChatPage(_props: ChatPageProps) {

请先在数据管理页面解密数据库

)} + +
{/* 拖动调节条 */} @@ -1157,7 +1067,12 @@ function ChatPage(_props: ChatPageProps) { {currentSession ? ( <>
- +

{currentSession.displayName || currentSession.username}

{isGroupChat(currentSession.username) && ( @@ -1195,56 +1110,56 @@ function ChatPage(_props: ChatPageProps) { ref={messageListRef} onScroll={handleScroll} > - {hasMoreMessages && ( -
- {isLoadingMore ? ( - <> - - 加载更多... - - ) : ( - 向上滚动加载更多 - )} -
- )} - - {messages.map((msg, index) => { - const prevMsg = index > 0 ? messages[index - 1] : undefined - const showDateDivider = shouldShowDateDivider(msg, prevMsg) - - // 显示时间:第一条消息,或者与上一条消息间隔超过5分钟 - const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300) - const isSent = msg.isSend === 1 - const isSystem = msg.localType === 10000 - - // 系统消息居中显示 - const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') - - const messageKey = getMessageKey(msg) - return ( -
- {showDateDivider && ( -
- {formatDateDivider(msg.createTime)} -
- )} - -
- ) - })} - - {/* 回到底部按钮 */} -
- - 回到底部 + {hasMoreMessages && ( +
+ {isLoadingMore ? ( + <> + + 加载更多... + + ) : ( + 向上滚动加载更多 + )}
+ )} + + {messages.map((msg, index) => { + const prevMsg = index > 0 ? messages[index - 1] : undefined + const showDateDivider = shouldShowDateDivider(msg, prevMsg) + + // 显示时间:第一条消息,或者与上一条消息间隔超过5分钟 + const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300) + const isSent = msg.isSend === 1 + const isSystem = msg.localType === 10000 + + // 系统消息居中显示 + const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') + + const messageKey = getMessageKey(msg) + return ( +
+ {showDateDivider && ( +
+ {formatDateDivider(msg.createTime)} +
+ )} + +
+ ) + })} + + {/* 回到底部按钮 */} +
+ + 回到底部
+
{/* 会话详情面板 */} {showDetailPanel && ( @@ -1434,7 +1349,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) { return 'image/webp' } - } catch {} + } catch { } return 'image/jpeg' }, []) @@ -1473,9 +1388,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: }) } - // 群聊中获取发送者信息 + // 群聊中获取发送者信息 (如果自己发的没头像,也尝试拉取) useEffect(() => { - if (isGroupChat && !isSent && message.senderUsername) { + if (message.senderUsername && (isGroupChat || (isSent && !myAvatarUrl))) { const sender = message.senderUsername const cached = senderAvatarCache.get(sender) if (cached) { @@ -1501,11 +1416,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: setSenderAvatarUrl(result.avatarUrl) setSenderName(result.displayName) } - }).catch(() => {}).finally(() => { + }).catch(() => { }).finally(() => { senderAvatarLoading.delete(sender) }) } - }, [isGroupChat, isSent, message.senderUsername]) + }, [isGroupChat, isSent, message.senderUsername, myAvatarUrl]) // 自动下载表情包 useEffect(() => { @@ -1597,7 +1512,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: } setImageHasUpdate(Boolean(result.hasUpdate)) } - }).catch(() => {}) + }).catch(() => { }) return () => { cancelled = true } @@ -1672,11 +1587,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: const bubbleClass = isSent ? 'sent' : 'received' // 头像逻辑: - // - 自己发的:使用 myAvatarUrl + // - 自己发的:优先使用 myAvatarUrl,缺失则用 senderAvatarUrl (补救) // - 群聊中对方发的:使用发送者头像 // - 私聊中对方发的:使用会话头像 const avatarUrl = isSent - ? myAvatarUrl + ? (myAvatarUrl || senderAvatarUrl) : (isGroupChat ? senderAvatarUrl : session.avatarUrl) const avatarLetter = isSent ? '我' @@ -1685,6 +1600,12 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: // 是否有引用消息 const hasQuote = message.quotedContent && message.quotedContent.length > 0 + // 去除企业微信 ID 前缀 + const cleanMessageContent = (content: string) => { + if (!content) return '' + return content.replace(/^[a-zA-Z0-9]+@openim:\n?/, '') + } + // 解析混合文本和表情 const renderTextWithEmoji = (text: string) => { if (!text) return text @@ -1761,13 +1682,14 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: )}
- {showImagePreview && ( + {showImagePreview && createPortal(
setShowImagePreview(false)}> 图片预览 e.stopPropagation()} /> -
+
, + document.body )} ) @@ -1895,14 +1817,14 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
{message.quotedSender && {message.quotedSender}} - {renderTextWithEmoji(message.quotedContent || '')} + {renderTextWithEmoji(cleanMessageContent(message.quotedContent || ''))}
-
{renderTextWithEmoji(message.parsedContent)}
+
{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}
) } // 普通消息 - return
{renderTextWithEmoji(message.parsedContent)}
+ return
{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}
} return ( @@ -1914,11 +1836,15 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: )}
- {avatarUrl ? ( - - ) : ( - {avatarLetter} - )} +
{/* 群聊中显示发送者名称 */} diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index 846b6e6..cd4d973 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react' +import { Avatar } from '../components/Avatar' import ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' import './GroupAnalyticsPage.scss' @@ -177,7 +178,7 @@ function GroupAnalyticsPage() { const getMediaOption = () => { if (!mediaStats || mediaStats.typeCounts.length === 0) return {} - + // 定义颜色映射 const colorMap: Record = { 1: '#3b82f6', // 文本 - 蓝色 @@ -188,13 +189,13 @@ function GroupAnalyticsPage() { 49: '#14b8a6', // 链接/文件 - 青色 [-1]: '#6b7280', // 其他 - 灰色 } - + const data = mediaStats.typeCounts.map(item => ({ name: item.name, value: item.count, itemStyle: { color: colorMap[item.type] || '#6b7280' } })) - + return { tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, series: [{ @@ -202,8 +203,8 @@ function GroupAnalyticsPage() { radius: ['40%', '70%'], center: ['50%', '50%'], itemStyle: { borderRadius: 8, borderColor: 'rgba(255,255,255,0.1)', borderWidth: 2 }, - label: { - show: true, + label: { + show: true, formatter: (params: { name: string; percent: number }) => { // 只显示占比大于3%的标签 return params.percent > 3 ? `${params.name}\n${params.percent.toFixed(1)}%` : '' @@ -256,11 +257,7 @@ function GroupAnalyticsPage() {
- {selectedMember.avatarUrl ? ( - - ) : ( -
- )} +

{selectedMember.displayName}

@@ -334,7 +331,7 @@ function GroupAnalyticsPage() { onClick={() => handleGroupSelect(group)} >
- {group.avatarUrl ? :
} +
{group.displayName} @@ -352,7 +349,7 @@ function GroupAnalyticsPage() {
- {selectedGroup?.avatarUrl ? :
} +

{selectedGroup?.displayName}

{selectedGroup?.memberCount} 位成员

@@ -424,7 +421,7 @@ function GroupAnalyticsPage() { {members.map(member => (
handleMemberClick(member)}>
- {member.avatarUrl ? :
} +
{member.displayName}
@@ -437,7 +434,7 @@ function GroupAnalyticsPage() {
{index + 1}
- {item.member.avatarUrl ? :
} + {index < 3 &&
}
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 2f80c91..989522f 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -484,6 +484,7 @@ function SettingsPage() { {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'} {imageKeyStatus &&
{imageKeyStatus}
} + {isFetchingImageKey &&
正在扫描内存,请稍候...
}
diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index d7a08de..f728565 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -506,6 +506,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { {dbKeyStatus &&
{dbKeyStatus}
}
获取密钥会自动识别最近登录的账号
+
点击自动获取后微信将重新启动,当页面提示可以登录微信了再点击登录
)} @@ -532,6 +533,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { {imageKeyStatus &&
{imageKeyStatus}
}
如获取失败,请先打开朋友圈图片再重试
+ {isFetchingImageKey &&
正在扫描内存,请稍候...
}
)} diff --git a/src/services/config.ts b/src/services/config.ts index 19b6a1d..92bf69d 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -15,6 +15,7 @@ export const CONFIG_KEYS = { AGREEMENT_ACCEPTED: 'agreementAccepted', LOG_ENABLED: 'logEnabled', ONBOARDING_DONE: 'onboardingDone', + LLM_MODEL_PATH: 'llmModelPath', IMAGE_XOR_KEY: 'imageXorKey', IMAGE_AES_KEY: 'imageAesKey' } as const @@ -132,6 +133,17 @@ export async function setLogEnabled(enabled: boolean): Promise { await config.set(CONFIG_KEYS.LOG_ENABLED, enabled) } +// 获取 LLM 模型路径 +export async function getLlmModelPath(): Promise { + const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH) + return (value as string) || null +} + +// 设置 LLM 模型路径 +export async function setLlmModelPath(path: string): Promise { + await config.set(CONFIG_KEYS.LLM_MODEL_PATH, path) +} + // 清除所有配置 export async function clearConfig(): Promise { await config.clear() diff --git a/src/stores/analyticsStore.ts b/src/stores/analyticsStore.ts index 98ae4a1..7cc9559 100644 --- a/src/stores/analyticsStore.ts +++ b/src/stores/analyticsStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand' +import { persist } from 'zustand/middleware' interface ChatStatistics { totalMessages: number @@ -36,11 +37,11 @@ interface AnalyticsState { statistics: ChatStatistics | null rankings: ContactRanking[] timeDistribution: TimeDistribution | null - + // 状态 isLoaded: boolean lastLoadTime: number | null - + // Actions setStatistics: (data: ChatStatistics) => void setRankings: (data: ContactRanking[]) => void @@ -49,22 +50,29 @@ interface AnalyticsState { clearCache: () => void } -export const useAnalyticsStore = create((set) => ({ - statistics: null, - rankings: [], - timeDistribution: null, - isLoaded: false, - lastLoadTime: null, +export const useAnalyticsStore = create()( + persist( + (set) => ({ + statistics: null, + rankings: [], + timeDistribution: null, + isLoaded: false, + lastLoadTime: null, - setStatistics: (data) => set({ statistics: data }), - setRankings: (data) => set({ rankings: data }), - setTimeDistribution: (data) => set({ timeDistribution: data }), - markLoaded: () => set({ isLoaded: true, lastLoadTime: Date.now() }), - clearCache: () => set({ - statistics: null, - rankings: [], - timeDistribution: null, - isLoaded: false, - lastLoadTime: null - }), -})) + setStatistics: (data) => set({ statistics: data }), + setRankings: (data) => set({ rankings: data }), + setTimeDistribution: (data) => set({ timeDistribution: data }), + markLoaded: () => set({ isLoaded: true, lastLoadTime: Date.now() }), + clearCache: () => set({ + statistics: null, + rankings: [], + timeDistribution: null, + isLoaded: false, + lastLoadTime: null + }), + }), + { + name: 'analytics-storage', + } + ) +) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 35a0bdb..d50e4b5 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -45,6 +45,7 @@ export interface ElectronAPI { testConnection: (dbPath: string, hexKey: string, wxid: string) => Promise<{ success: boolean; error?: string; sessionCount?: number }> open: (dbPath: string, hexKey: string, wxid: string) => Promise close: () => Promise + } key: { autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }> @@ -95,6 +96,7 @@ export interface ElectronAPI { getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> getVoiceData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> } + image: { decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }> resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> @@ -103,7 +105,7 @@ export interface ElectronAPI { onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void } analytics: { - getOverallStatistics: () => Promise<{ + getOverallStatistics: (force?: boolean) => Promise<{ success: boolean data?: { totalMessages: number @@ -262,12 +264,12 @@ export interface ElectronAPI { fastestFriend: string fastestTime: number } | null - topPhrases: Array<{ - phrase: string - count: number - }> - } - error?: string + topPhrases: Array<{ + phrase: string + count: number + }> + } + error?: string }> exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => Promise<{ success: boolean diff --git a/src/utils/AvatarLoadQueue.ts b/src/utils/AvatarLoadQueue.ts new file mode 100644 index 0000000..85e297b --- /dev/null +++ b/src/utils/AvatarLoadQueue.ts @@ -0,0 +1,74 @@ + +// 全局头像加载队列管理器(限制并发,避免卡顿) +export class AvatarLoadQueue { + private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = [] + private loading = new Map>() + private activeCount = 0 + private readonly maxConcurrent = 3 + private readonly delayBetweenBatches = 10 + + private static instance: AvatarLoadQueue + + public static getInstance(): AvatarLoadQueue { + if (!AvatarLoadQueue.instance) { + AvatarLoadQueue.instance = new AvatarLoadQueue() + } + return AvatarLoadQueue.instance + } + + async enqueue(url: string): Promise { + if (!url) return Promise.resolve() + + // 核心修复:防止重复并发请求同一个 URL + const existingPromise = this.loading.get(url) + if (existingPromise) { + return existingPromise + } + + const loadPromise = new Promise((resolve, reject) => { + this.queue.push({ url, resolve, reject }) + this.processQueue() + }) + + this.loading.set(url, loadPromise) + loadPromise.finally(() => { + this.loading.delete(url) + }) + + return loadPromise + } + + private async processQueue() { + if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) { + return + } + + const task = this.queue.shift() + if (!task) return + + this.activeCount++ + + const img = new Image() + img.onload = () => { + this.activeCount-- + task.resolve() + setTimeout(() => this.processQueue(), this.delayBetweenBatches) + } + img.onerror = () => { + this.activeCount-- + task.reject(new Error(`Failed: ${task.url}`)) + setTimeout(() => this.processQueue(), this.delayBetweenBatches) + } + img.src = task.url + + this.processQueue() + } + + clear() { + this.queue = [] + this.loading.clear() + this.activeCount = 0 + } +} + +export const avatarLoadQueue = AvatarLoadQueue.getInstance() diff --git a/vite.config.ts b/vite.config.ts index 53df4ae..52a386c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,14 @@ export default defineConfig({ port: 3000, strictPort: false // 如果3000被占用,自动尝试下一个 }, + build: { + commonjsOptions: { + ignoreDynamicRequires: true + } + }, + optimizeDeps: { + exclude: [] + }, plugins: [ react(), electron([ @@ -19,7 +27,11 @@ export default defineConfig({ build: { outDir: 'dist-electron', rollupOptions: { - external: ['better-sqlite3', 'koffi'] + external: [ + 'better-sqlite3', + 'koffi', + 'fsevents' + ] } } } @@ -30,7 +42,10 @@ export default defineConfig({ build: { outDir: 'dist-electron', rollupOptions: { - external: ['koffi'], + external: [ + 'koffi', + 'fsevents' + ], output: { entryFileNames: 'annualReportWorker.js', inlineDynamicImports: true @@ -53,6 +68,25 @@ export default defineConfig({ } } }, + { + entry: 'electron/wcdbWorker.ts', + vite: { + build: { + outDir: 'dist-electron', + rollupOptions: { + external: [ + 'better-sqlite3', + 'koffi', + 'fsevents' + ], + output: { + entryFileNames: 'wcdbWorker.js', + inlineDynamicImports: true + } + } + } + } + }, { entry: 'electron/preload.ts', onstart(options) {