feat: 所有数据解析完全后台进行以解决页面未响应的问题;优化了头像渲染逻辑以提升渲染速度
fix: 修复了虚拟机上无法索引到wxkey的问题;修复图片密钥扫描的问题;修复年度报告错误;修复了年度报告和数据分析中的发送者错误问题;修复了部分页面偶发的未渲染名称问题;修复了头像偶发渲染失败的问题;修复了部分图片无法解密的问题
This commit is contained in:
1 parent
3151f79ee7
commit
2e41a03c96
32 files changed
+2772
-4130
No files matched your search
@@ -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)
|
||||
|
||||
@@ -667,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)
|
||||
@@ -696,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}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1481,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
|
||||
}
|
||||
@@ -1491,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))
|
||||
}
|
||||
|
||||
// 如果是字符串
|
||||
@@ -1507,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1515,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 { }
|
||||
}
|
||||
|
||||
@@ -1529,7 +1543,7 @@ class ChatService {
|
||||
/**
|
||||
* 解码二进制内容(处理 zstd 压缩)
|
||||
*/
|
||||
private decodeBinaryContent(data: Buffer): string {
|
||||
private decodeBinaryContent(data: Buffer, fallbackValue?: string): string {
|
||||
if (data.length === 0) return ''
|
||||
|
||||
try {
|
||||
@@ -1556,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 || ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -459,7 +459,7 @@ class ExportService {
|
||||
|
||||
const [displayNames, avatarUrls] = await Promise.all([
|
||||
wcdbService.getDisplayNames(Array.from(lookupUsernames)),
|
||||
includeAvatars ? wcdbService.getAvatarUrls(Array.from(lookupUsernames)) : Promise.resolve({ success: true, map: {} })
|
||||
includeAvatars ? wcdbService.getAvatarUrls(Array.from(lookupUsernames)) : Promise.resolve({ success: true, map: {} as Record<string, string> })
|
||||
])
|
||||
|
||||
for (const member of rawMembers) {
|
||||
@@ -590,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 {
|
||||
@@ -601,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':
|
||||
@@ -659,7 +696,8 @@ 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,
|
||||
@@ -811,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()
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
Reference in new issue
Block a user