Merge pull request #36 from hicccc77/dev

Dev
This commit is contained in:
Forrest authored and GitHub committed 2026-01-15 00:34:51 +08:00
commit b96a47fe29
36 files changed
+3119 -1587

No files matched your search

+6 -1
View File
@@ -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/
wcdb/
+8
View File
@@ -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
+15 -3
View File
@@ -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)
+5 -1
View File
@@ -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 }) =>
+50 -7
View File
@@ -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<string, any> | undefined,
idMap: Record<string, string> | 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<number, number> = {}
for (const [w, count] of Object.entries(d.weekday)) {
const sqliteW = parseInt(w, 10)
+111 -44
View File
@@ -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<string, { cursor: number; fetched: number; batchSize: number }> = new Map()
private readonly messageBatchDefault = 50
private avatarCache: Map<string, { avatarUrl?: string; displayName?: string; updatedAt: number }> = new Map()
private avatarCache: Map<string, ContactCacheEntry>
private readonly avatarCacheTtlMs = 10 * 60 * 1000
private readonly defaultV1AesKey = 'cfcd208495d565ef'
private hardlinkCache = new Map<string, HardlinkState>()
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<string, { displayName?: string; avatarUrl?: string }> = {}
const updatedEntries: Record<string, ContactCacheEntry> = {}
// 检查缓存
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)
}
+3 -1
View File
@@ -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: ''
}
})
}
+75
View File
@@ -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<string, ContactCacheEntry> = {}
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<string, ContactCacheEntry> {
return { ...this.cache }
}
setEntries(entries: Record<string, ContactCacheEntry>): 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)
}
}
}
+125 -4
View File
@@ -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<string, { member: ChatLabMember; avatarUrl?: string }>,
includeAvatars: boolean
): Promise<void> {
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<string>()
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<string, string> })
])
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<string, string>()
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
}
@@ -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()
+80 -28
View File
@@ -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<string, unknown>): 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<string, unknown>): 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<DecryptResult & { hasUpdate?: boolean }> {
@@ -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<DecryptResult> {
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<Buffer> {
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)
+126 -14
View File
@@ -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<string | null> {
private async getAesKeyFromMemory(
pid: number,
ciphertext: Buffer,
onProgress?: (current: number, total: number, message: string) => void
): Promise<string | null> {
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,
+68
View File
@@ -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<string, SessionMessageCacheEntry> = {}
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)
}
}
}
+1317
View File
@@ -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<string, { url?: string; updatedAt: number }> = 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<boolean> {
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<void> {
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<void> {
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<boolean> {
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<string, string>; 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<string, string>; 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<string, string> = {}
const toFetch: string[] = []
const seen = new Set<string>()
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<string, string>
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<string, number>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (chatroomIds.length === 0) return { success: true, map: {} }
if (!this.wcdbGetGroupMemberCounts) {
const map: Record<string, number> = {}
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<string, string>
const reverseMap: Record<string, string> = {}
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) }
}
}
}
+220 -1159
View File
@@ -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<number, { resolve: (val: any) => 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<string, { url?: string; updatedAt: number }> = 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<T>(type: string, payload: any = {}): Promise<T> {
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<boolean> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<boolean> {
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<string, string>; 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<string, string>; 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<string, string> = {}
const toFetch: string[] = []
const seen = new Set<string>()
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<string, string>
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<string, number>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (chatroomIds.length === 0) return { success: true, map: {} }
if (!this.wcdbGetGroupMemberCounts) {
const map: Record<string, number> = {}
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<string, string>
const reverseMap: Record<string, string> = {}
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()
+122
View File
@@ -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) })
}
})
}
+2 -2
View File
@@ -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",
+5 -4
View File
@@ -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",
Binary file not shown.
+5 -2
View File
@@ -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() {
<Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
<Route path="/analytics/view" element={<AnalyticsPage />} />
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
<Route path="/annual-report" element={<AnnualReportPage />} />
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
+79
View File
@@ -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;
}
}
}
+129
View File
@@ -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<string>()
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<HTMLImageElement>(null)
const containerRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
className={`avatar-component ${shape} ${className}`}
style={style}
onClick={onClick}
>
{hasValidUrl ? (
<>
{!imageLoaded && <div className="avatar-skeleton" />}
<img
ref={imgRef}
src={src}
alt={name || 'avatar'}
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
onLoad={() => {
if (src) loadedAvatarCache.add(src)
setImageLoaded(true)
}}
onError={() => setImageError(true)}
loading={lazy ? "lazy" : "eager"}
/>
</>
) : (
<div className="avatar-placeholder">
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
</div>
)}
</div>
)
})
+8 -6
View File
@@ -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() {
<span className="nav-label">聊天</span>
</NavLink>
{/* 私聊分析 */}
<NavLink
to="/analytics"
@@ -84,10 +86,10 @@ function Sidebar() {
<span className="nav-label">数据管理</span>
</NavLink>
</nav>
<div className="sidebar-footer">
<NavLink
to="/settings"
<NavLink
to="/settings"
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
title={collapsed ? '设置' : undefined}
>
@@ -96,8 +98,8 @@ function Sidebar() {
</span>
<span className="nav-label">设置</span>
</NavLink>
<button
<button
className="collapse-btn"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? '展开菜单' : '收起菜单'}
+10 -3
View File
@@ -1,10 +1,12 @@
import { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
import ReactECharts from 'echarts-for-react'
import { useAnalyticsStore } from '../stores/analyticsStore'
import { useThemeStore } from '../stores/themeStore'
import './AnalyticsPage.scss'
import './DataManagementPage.scss'
import { Avatar } from '../components/Avatar'
function AnalyticsPage() {
const [isLoading, setIsLoading] = useState(false)
@@ -28,7 +30,7 @@ function AnalyticsPage() {
try {
setLoadingStatus('正在统计消息数据...')
const statsResult = await window.electronAPI.analytics.getOverallStatistics()
const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
if (statsResult.success && statsResult.data) {
setStatistics(statsResult.data)
} else {
@@ -55,7 +57,12 @@ function AnalyticsPage() {
}
}
useEffect(() => { loadData() }, [])
const location = useLocation()
useEffect(() => {
const force = location.state?.forceRefresh === true
loadData(force)
}, [location.state])
const handleRefresh = () => loadData(true)
@@ -289,7 +296,7 @@ function AnalyticsPage() {
<div key={contact.username} className="ranking-item">
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
<div className="contact-avatar">
{contact.avatarUrl ? <img src={contact.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} />
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
</div>
<div className="contact-info">
+119
View File
@@ -0,0 +1,119 @@
.analytics-welcome-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 40px;
background: var(--bg-primary);
color: var(--text-primary);
animation: fadeIn 0.4s ease-out;
.welcome-content {
text-align: center;
max-width: 600px;
.icon-wrapper {
width: 80px;
height: 80px;
margin: 0 auto 24px;
background: rgba(7, 193, 96, 0.1);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
color: #07c160;
svg {
width: 40px;
height: 40px;
}
}
h1 {
font-size: 28px;
margin-bottom: 12px;
font-weight: 600;
}
p {
color: var(--text-secondary);
margin-bottom: 40px;
font-size: 16px;
line-height: 1.6;
}
.action-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 20px;
button {
display: flex;
flex-direction: column;
align-items: center;
padding: 30px 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
&:hover:not(:disabled) {
transform: translateY(-2px);
border-color: #07c160;
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.1);
.card-icon {
color: #07c160;
background: rgba(7, 193, 96, 0.1);
}
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
filter: grayscale(100%);
}
.card-icon {
width: 50px;
height: 50px;
border-radius: 12px;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
color: var(--text-secondary);
transition: all 0.2s ease;
}
h3 {
font-size: 18px;
margin-bottom: 8px;
color: var(--text-primary);
}
span {
font-size: 13px;
color: var(--text-tertiary);
}
}
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+63
View File
@@ -0,0 +1,63 @@
import { useNavigate } from 'react-router-dom'
import { BarChart2, History, RefreshCcw } from 'lucide-react'
import { useAnalyticsStore } from '../stores/analyticsStore'
import './AnalyticsWelcomePage.scss'
function AnalyticsWelcomePage() {
const navigate = useNavigate()
// 检查是否有任何缓存数据加载或基本的存储状态表明它已准备好。
// 实际上,如果 store 没有持久化,`isLoaded` 可能会在应用刷新时重置。
// 如果用户点击“加载缓存”但缓存为空,AnalyticsPage 的逻辑(loadData 不带 force)将尝试从后端缓存加载。
// 如果后端缓存也为空,则会重新计算。
// 我们也可以检查 `lastLoadTime` 来显示“上次更新:xxx”(如果已持久化)。
const { lastLoadTime } = useAnalyticsStore()
const handleLoadCache = () => {
navigate('/analytics/view')
}
const handleNewAnalysis = () => {
navigate('/analytics/view', { state: { forceRefresh: true } })
}
const formatLastTime = (ts: number | null) => {
if (!ts) return '无记录'
return new Date(ts).toLocaleString()
}
return (
<div className="analytics-welcome-container">
<div className="welcome-content">
<div className="icon-wrapper">
<BarChart2 size={40} />
</div>
<h1>私聊数据分析</h1>
<p>
WeFlow 可以分析您的聊天记录,生成详细的统计报表。<br />
您可以选择加载上次的分析结果(速度快),或者开始新的分析(数据最新)。
</p>
<div className="action-cards">
<button onClick={handleLoadCache}>
<div className="card-icon">
<History size={24} />
</div>
<h3>加载缓存</h3>
<span>查看上次分析结果<br />(上次更新: {formatLastTime(lastLoadTime)})</span>
</button>
<button onClick={handleNewAnalysis}>
<div className="card-icon">
<RefreshCcw size={24} />
</div>
<h3>新的分析</h3>
<span>重新扫描并计算数据<br />(可能需要几分钟)</span>
</button>
</div>
</div>
</div>
)
}
export default AnalyticsWelcomePage
+3 -1
View File
@@ -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);
}
}
}
+190 -264
View File
@@ -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<string>()
private readonly maxConcurrent = 1 // 一次只加载1个头像,避免卡顿
private readonly delayBetweenBatches = 100 // 批次间延迟100ms,给UI喘息时间
async enqueue(url: string): Promise<void> {
// 如果已经在加载中,直接返回
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)}
>
<SessionAvatar session={session} size={48} />
<Avatar
src={session.avatarUrl}
name={session.displayName || session.username}
size={48}
className={session.username.includes('@chatroom') ? 'group' : ''}
/>
<div className="session-info">
<div className="session-top">
<span className="session-name">{session.displayName || session.username}</span>
@@ -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<HTMLImageElement>(null)
const containerRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
className={`session-avatar ${isGroup ? 'group' : ''} ${hasValidUrl && !imageLoaded ? 'loading' : ''}`}
style={{ width: size, height: size }}
>
{hasValidUrl ? (
<>
{!imageLoaded && <div className="avatar-skeleton" />}
<img
ref={imgRef}
src={session.avatarUrl}
alt=""
className={imageLoaded ? 'loaded' : ''}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
loading="lazy"
/>
</>
) : (
<span className="avatar-letter">{getAvatarLetter()}</span>
)}
</div>
)
}, (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<HTMLDivElement>(null)
const [currentOffset, setCurrentOffset] = useState(0)
const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined)
const [myWxid, setMyWxid] = useState<string | undefined>(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<string[]>([])
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<void>((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) {
</div>
)}
{/* ... (previous content) ... */}
{isLoadingSessions ? (
<div className="loading-sessions">
{/* ... (skeleton items) ... */}
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="skeleton-item">
<div className="skeleton-avatar" />
@@ -1114,16 +1024,14 @@ function ChatPage(_props: ChatPageProps) {
))}
</div>
) : Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
<div
<div
className="session-list"
ref={sessionListRef}
onScroll={() => {
// 标记正在滚动,暂停联系人信息加载
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) {
<p className="hint">请先在数据管理页面解密数据库</p>
</div>
)}
</div>
{/* 拖动调节条 */}
@@ -1157,7 +1067,12 @@ function ChatPage(_props: ChatPageProps) {
{currentSession ? (
<>
<div className="message-header">
<SessionAvatar session={currentSession} size={40} />
<Avatar
src={currentSession.avatarUrl}
name={currentSession.displayName || currentSession.username}
size={40}
className={isGroupChat(currentSession.username) ? 'group session-avatar' : 'session-avatar'}
/>
<div className="header-info">
<h3>{currentSession.displayName || currentSession.username}</h3>
{isGroupChat(currentSession.username) && (
@@ -1195,56 +1110,56 @@ function ChatPage(_props: ChatPageProps) {
ref={messageListRef}
onScroll={handleScroll}
>
{hasMoreMessages && (
<div className={`load-more-trigger ${isLoadingMore ? 'loading' : ''}`}>
{isLoadingMore ? (
<>
<Loader2 size={14} />
<span>加载更多...</span>
</>
) : (
<span>向上滚动加载更多</span>
)}
</div>
)}
{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 (
<div key={messageKey} className={`message-wrapper ${wrapperClass} ${highlightedMessageSet.has(messageKey) ? 'new-message' : ''}`}>
{showDateDivider && (
<div className="date-divider">
<span>{formatDateDivider(msg.createTime)}</span>
</div>
)}
<MessageBubble
message={msg}
session={currentSession}
showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl}
isGroupChat={isGroupChat(currentSession.username)}
/>
</div>
)
})}
{/* 回到底部按钮 */}
<div className={`scroll-to-bottom ${showScrollToBottom ? 'show' : ''}`} onClick={scrollToBottom}>
<ChevronDown size={16} />
<span>回到底部</span>
{hasMoreMessages && (
<div className={`load-more-trigger ${isLoadingMore ? 'loading' : ''}`}>
{isLoadingMore ? (
<>
<Loader2 size={14} />
<span>加载更多...</span>
</>
) : (
<span>向上滚动加载更多</span>
)}
</div>
)}
{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 (
<div key={messageKey} className={`message-wrapper ${wrapperClass} ${highlightedMessageSet.has(messageKey) ? 'new-message' : ''}`}>
{showDateDivider && (
<div className="date-divider">
<span>{formatDateDivider(msg.createTime)}</span>
</div>
)}
<MessageBubble
message={msg}
session={currentSession}
showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl}
isGroupChat={isGroupChat(currentSession.username)}
/>
</div>
)
})}
{/* 回到底部按钮 */}
<div className={`scroll-to-bottom ${showScrollToBottom ? 'show' : ''}`} onClick={scrollToBottom}>
<ChevronDown size={16} />
<span>回到底部</span>
</div>
</div>
{/* 会话详情面板 */}
{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 }:
</button>
)}
</div>
{showImagePreview && (
{showImagePreview && createPortal(
<div className="image-preview-overlay" onClick={() => setShowImagePreview(false)}>
<img src={imageLocalPath} alt="图片预览" onClick={(e) => e.stopPropagation()} />
<button className="image-preview-close" onClick={() => setShowImagePreview(false)}>
<X size={16} />
</button>
</div>
</div>,
document.body
)}
</>
)
@@ -1895,14 +1817,14 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
<div className="bubble-content">
<div className="quoted-message">
{message.quotedSender && <span className="quoted-sender">{message.quotedSender}</span>}
<span className="quoted-text">{renderTextWithEmoji(message.quotedContent || '')}</span>
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(message.quotedContent || ''))}</span>
</div>
<div className="message-text">{renderTextWithEmoji(message.parsedContent)}</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}</div>
</div>
)
}
// 普通消息
return <div className="bubble-content">{renderTextWithEmoji(message.parsedContent)}</div>
return <div className="bubble-content">{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}</div>
}
return (
@@ -1914,11 +1836,15 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
)}
<div className={`message-bubble ${bubbleClass} ${isEmoji && message.emojiCdnUrl && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVoice ? 'voice' : ''}`}>
<div className="bubble-avatar">
{avatarUrl ? (
<img src={avatarUrl} alt="" />
) : (
<span className="avatar-letter">{avatarLetter}</span>
)}
<Avatar
src={avatarUrl}
name={!isSent ? (isGroupChat ? (senderName || message.senderUsername || '?') : (session.displayName || session.username)) : '我'}
size={36}
className="bubble-avatar"
// If it's sent by me (isSent), we might not want 'group' class even if it's a group chat.
// But 'group' class mainly handles default avatar icon.
// Let's rely on standard Avatar behavior.
/>
</div>
<div className="bubble-body">
{/* 群聊中显示发送者名称 */}
+11 -14
View File
@@ -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<number, string> = {
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() {
</button>
<div className="modal-content">
<div className="member-avatar large">
{selectedMember.avatarUrl ? (
<img src={selectedMember.avatarUrl} alt="" />
) : (
<div className="avatar-placeholder"><User size={48} /></div>
)}
<Avatar src={selectedMember.avatarUrl} name={selectedMember.displayName} size={96} />
</div>
<h3 className="member-display-name">{selectedMember.displayName}</h3>
<div className="member-details">
@@ -334,7 +331,7 @@ function GroupAnalyticsPage() {
onClick={() => handleGroupSelect(group)}
>
<div className="group-avatar">
{group.avatarUrl ? <img src={group.avatarUrl} alt="" /> : <div className="avatar-placeholder"><Users size={20} /></div>}
<Avatar src={group.avatarUrl} name={group.displayName} size={44} />
</div>
<div className="group-info">
<span className="group-name">{group.displayName}</span>
@@ -352,7 +349,7 @@ function GroupAnalyticsPage() {
<div className="function-menu">
<div className="selected-group-info">
<div className="group-avatar large">
{selectedGroup?.avatarUrl ? <img src={selectedGroup.avatarUrl} alt="" /> : <div className="avatar-placeholder"><Users size={40} /></div>}
<Avatar src={selectedGroup?.avatarUrl} name={selectedGroup?.displayName} size={80} />
</div>
<h2>{selectedGroup?.displayName}</h2>
<p>{selectedGroup?.memberCount} 位成员</p>
@@ -424,7 +421,7 @@ function GroupAnalyticsPage() {
{members.map(member => (
<div key={member.username} className="member-card" onClick={() => handleMemberClick(member)}>
<div className="member-avatar">
{member.avatarUrl ? <img src={member.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
<Avatar src={member.avatarUrl} name={member.displayName} size={48} />
</div>
<span className="member-name">{member.displayName}</span>
</div>
@@ -437,7 +434,7 @@ function GroupAnalyticsPage() {
<div key={item.member.username} className="ranking-item">
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
<div className="contact-avatar">
{item.member.avatarUrl ? <img src={item.member.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
<Avatar src={item.member.avatarUrl} name={item.member.displayName} size={40} />
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
</div>
<div className="contact-info">
+1
View File
@@ -484,6 +484,7 @@ function SettingsPage() {
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
</button>
{imageKeyStatus && <div className="form-hint status-text">{imageKeyStatus}</div>}
{isFetchingImageKey && <div className="form-hint status-text">正在扫描内存,请稍候...</div>}
</div>
<div className="form-group">
+2
View File
@@ -506,6 +506,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
<div className="field-hint">获取密钥会自动识别最近登录的账号</div>
<div className="field-hint">点击自动获取后微信将重新启动,当页面提示可以登录微信了再点击登录</div>
</div>
)}
@@ -532,6 +533,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
</button>
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
<div className="field-hint">如获取失败,请先打开朋友圈图片再重试</div>
{isFetchingImageKey && <div className="field-hint status-text">正在扫描内存,请稍候...</div>}
</div>
)}
+12
View File
@@ -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<void> {
await config.set(CONFIG_KEYS.LOG_ENABLED, enabled)
}
// 获取 LLM 模型路径
export async function getLlmModelPath(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH)
return (value as string) || null
}
// 设置 LLM 模型路径
export async function setLlmModelPath(path: string): Promise<void> {
await config.set(CONFIG_KEYS.LLM_MODEL_PATH, path)
}
// 清除所有配置
export async function clearConfig(): Promise<void> {
await config.clear()
+28 -20
View File
@@ -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<AnalyticsState>((set) => ({
statistics: null,
rankings: [],
timeDistribution: null,
isLoaded: false,
lastLoadTime: null,
export const useAnalyticsStore = create<AnalyticsState>()(
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',
}
)
)
+9 -7
View File
@@ -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<boolean>
close: () => Promise<boolean>
}
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
+74
View File
@@ -0,0 +1,74 @@
// 全局头像加载队列管理器(限制并发,避免卡顿)
export class AvatarLoadQueue {
private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = []
private loading = new Map<string, Promise<void>>()
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<void> {
if (!url) return Promise.resolve()
// 核心修复:防止重复并发请求同一个 URL
const existingPromise = this.loading.get(url)
if (existingPromise) {
return existingPromise
}
const loadPromise = new Promise<void>((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()
+36 -2
View File
@@ -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) {