mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 15:03:07 +00:00
重构与优化,旨在解决遗留的性能问题并优化用户体验,本次提交遗留了较多的待测功能
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -62,6 +62,7 @@ server/
|
||||
chatlab-format.md
|
||||
*.bak
|
||||
AGENTS.md
|
||||
AGENT.md
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
.agents/
|
||||
|
||||
4
.npmrc
4
.npmrc
@@ -1,3 +1,3 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
electron-mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron-builder-binaries-mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
|
||||
47
electron/exportWorker.ts
Normal file
47
electron/exportWorker.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
import { wcdbService } from './services/wcdbService'
|
||||
import { exportService, ExportOptions } from './services/exportService'
|
||||
|
||||
interface ExportWorkerConfig {
|
||||
sessionIds: string[]
|
||||
outputDir: string
|
||||
options: ExportOptions
|
||||
resourcesPath?: string
|
||||
userDataPath?: string
|
||||
logEnabled?: boolean
|
||||
}
|
||||
|
||||
const config = workerData as ExportWorkerConfig
|
||||
process.env.WEFLOW_WORKER = '1'
|
||||
if (config.resourcesPath) {
|
||||
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
||||
}
|
||||
|
||||
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
||||
wcdbService.setLogEnabled(config.logEnabled === true)
|
||||
|
||||
async function run() {
|
||||
const result = await exportService.exportSessions(
|
||||
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||
String(config.outputDir || ''),
|
||||
config.options || { format: 'json' },
|
||||
(progress) => {
|
||||
parentPort?.postMessage({
|
||||
type: 'export:progress',
|
||||
data: progress
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
parentPort?.postMessage({
|
||||
type: 'export:result',
|
||||
data: result
|
||||
})
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
parentPort?.postMessage({
|
||||
type: 'export:error',
|
||||
error: String(error)
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,6 @@ import { annualReportService } from './services/annualReportService'
|
||||
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
||||
import { KeyService } from './services/keyService'
|
||||
import { KeyServiceMac } from './services/keyServiceMac'
|
||||
import { KeyServiceLinux} from "./services/keyServiceLinux"
|
||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||
import { videoService } from './services/videoService'
|
||||
import { snsService, isVideoUrl } from './services/snsService'
|
||||
@@ -96,7 +95,7 @@ let keyService: any
|
||||
if (process.platform === 'darwin') {
|
||||
keyService = new KeyServiceMac()
|
||||
} else if (process.platform === 'linux') {
|
||||
// const { KeyServiceLinux } = require('./services/keyServiceLinux')
|
||||
const { KeyServiceLinux } = require('./services/keyServiceLinux')
|
||||
keyService = new KeyServiceLinux()
|
||||
} else {
|
||||
keyService = new KeyService()
|
||||
@@ -1629,7 +1628,7 @@ function registerIpcHandlers() {
|
||||
|
||||
ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => {
|
||||
return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => {
|
||||
event.sender.send('chat:voiceTranscriptPartial', { msgId, text })
|
||||
event.sender.send('chat:voiceTranscriptPartial', { sessionId, msgId, createTime, text })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1641,10 +1640,6 @@ function registerIpcHandlers() {
|
||||
return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => {
|
||||
return chatService.execQuery(kind, path, sql)
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
|
||||
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||
})
|
||||
@@ -1856,7 +1851,83 @@ function registerIpcHandlers() {
|
||||
}
|
||||
}
|
||||
|
||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
|
||||
const runMainFallback = async (reason: string) => {
|
||||
console.warn(`[fallback-export-main] ${reason}`)
|
||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
|
||||
}
|
||||
|
||||
const cfg = configService || new ConfigService()
|
||||
configService = cfg
|
||||
const logEnabled = cfg.get('logEnabled')
|
||||
const resourcesPath = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
const userDataPath = app.getPath('userData')
|
||||
const workerPath = join(__dirname, 'exportWorker.js')
|
||||
|
||||
const runWorker = async () => {
|
||||
return await new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: {
|
||||
sessionIds,
|
||||
outputDir,
|
||||
options,
|
||||
resourcesPath,
|
||||
userDataPath,
|
||||
logEnabled
|
||||
}
|
||||
})
|
||||
|
||||
let settled = false
|
||||
const finalizeResolve = (value: any) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate()
|
||||
resolve(value)
|
||||
}
|
||||
const finalizeReject = (error: Error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'export:progress') {
|
||||
onProgress(msg.data as ExportProgress)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:result') {
|
||||
finalizeResolve(msg.data)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:error') {
|
||||
finalizeReject(new Error(String(msg.error || '导出 Worker 执行失败')))
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (error) => {
|
||||
finalizeReject(error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
|
||||
worker.on('exit', (code) => {
|
||||
if (settled) return
|
||||
if (code === 0) {
|
||||
finalizeResolve({ success: false, successCount: 0, failCount: 0, error: '导出 Worker 未返回结果' })
|
||||
} else {
|
||||
finalizeReject(new Error(`导出 Worker 异常退出: ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
return await runWorker()
|
||||
} catch (error) {
|
||||
return runMainFallback(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||
|
||||
@@ -215,13 +215,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
|
||||
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
||||
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
|
||||
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
|
||||
const listener = (_: any, payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => callback(payload)
|
||||
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
||||
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
||||
},
|
||||
execQuery: (kind: string, path: string | null, sql: string) =>
|
||||
ipcRenderer.invoke('chat:execQuery', kind, path, sql),
|
||||
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
|
||||
getMessage: (sessionId: string, localId: number) =>
|
||||
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
||||
@@ -352,7 +350,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||
exportContacts: (outputDir: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportContacts', outputDir, options),
|
||||
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; currentSessionId?: string; phase: string }) => void) => {
|
||||
onProgress: (callback: (payload: {
|
||||
current: number
|
||||
total: number
|
||||
currentSession: string
|
||||
currentSessionId?: string
|
||||
phase: string
|
||||
phaseProgress?: number
|
||||
phaseTotal?: number
|
||||
phaseLabel?: string
|
||||
collectedMessages?: number
|
||||
exportedMessages?: number
|
||||
estimatedTotalMessages?: number
|
||||
writtenFiles?: number
|
||||
}) => void) => {
|
||||
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('export:progress')
|
||||
}
|
||||
|
||||
@@ -68,29 +68,14 @@ class AnalyticsService {
|
||||
return new Set(this.getExcludedUsernamesList())
|
||||
}
|
||||
|
||||
private escapeSqlValue(value: string): string {
|
||||
return value.replace(/'/g, "''")
|
||||
}
|
||||
|
||||
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
|
||||
const map: Record<string, string> = {}
|
||||
if (usernames.length === 0) return map
|
||||
|
||||
// C++ 层不支持参数绑定,直接内联转义后的字符串值
|
||||
const chunkSize = 200
|
||||
for (let i = 0; i < usernames.length; i += chunkSize) {
|
||||
const chunk = usernames.slice(i, i + chunkSize)
|
||||
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
|
||||
const sql = `SELECT username, alias FROM contact WHERE username IN (${inList})`
|
||||
const result = await wcdbService.execQuery('contact', null, sql)
|
||||
if (!result.success || !result.rows) continue
|
||||
for (const row of result.rows as Record<string, any>[]) {
|
||||
const username = row.username || ''
|
||||
const alias = row.alias || ''
|
||||
if (username && alias) {
|
||||
map[username] = alias
|
||||
}
|
||||
}
|
||||
const result = await wcdbService.getContactAliasMap(usernames)
|
||||
if (!result.success || !result.map) return map
|
||||
for (const [username, alias] of Object.entries(result.map)) {
|
||||
if (username && alias) map[username] = alias
|
||||
}
|
||||
|
||||
return map
|
||||
|
||||
@@ -278,16 +278,16 @@ class AnnualReportService {
|
||||
return cached || null
|
||||
}
|
||||
|
||||
const result = await wcdbService.execQuery('message', dbPath, `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`)
|
||||
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) {
|
||||
const result = await wcdbService.getMessageTableColumns(dbPath, tableName)
|
||||
if (!result.success || !Array.isArray(result.columns) || result.columns.length === 0) {
|
||||
this.availableYearsColumnCache.set(cacheKey, '')
|
||||
return null
|
||||
}
|
||||
|
||||
const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time']
|
||||
const columns = new Set<string>()
|
||||
for (const row of result.rows as Record<string, any>[]) {
|
||||
const name = String(row.name || row.column_name || row.columnName || '').trim().toLowerCase()
|
||||
for (const columnName of result.columns) {
|
||||
const name = String(columnName || '').trim().toLowerCase()
|
||||
if (name) columns.add(name)
|
||||
}
|
||||
|
||||
@@ -309,10 +309,11 @@ class AnnualReportService {
|
||||
const tried = new Set<string>()
|
||||
|
||||
const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => {
|
||||
const sql = `SELECT MIN(${this.quoteSqlIdentifier(column)}) AS first_ts, MAX(${this.quoteSqlIdentifier(column)}) AS last_ts FROM ${this.quoteSqlIdentifier(tableName)}`
|
||||
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) return null
|
||||
const row = result.rows[0] as Record<string, any>
|
||||
const result = await wcdbService.getMessageTableTimeRange(dbPath, tableName)
|
||||
if (!result.success || !result.data) return null
|
||||
const row = result.data as Record<string, any>
|
||||
const actualColumn = String(row.column || '').trim().toLowerCase()
|
||||
if (column && actualColumn && column.toLowerCase() !== actualColumn) return null
|
||||
const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs)
|
||||
const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs)
|
||||
return { first, last }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -230,10 +230,9 @@ class GroupAnalyticsService {
|
||||
}
|
||||
|
||||
try {
|
||||
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
||||
const roomResult = await wcdbService.execQuery('contact', null, `SELECT * FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`)
|
||||
if (roomResult.success && roomResult.rows && roomResult.rows.length > 0) {
|
||||
const owner = tryResolve(roomResult.rows[0])
|
||||
const roomExt = await wcdbService.getChatRoomExtBuffer(chatroomId)
|
||||
if (roomExt.success && roomExt.extBuffer) {
|
||||
const owner = tryResolve({ ext_buffer: roomExt.extBuffer })
|
||||
if (owner) return owner
|
||||
}
|
||||
} catch {
|
||||
@@ -273,13 +272,12 @@ class GroupAnalyticsService {
|
||||
}
|
||||
|
||||
try {
|
||||
const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1'
|
||||
const result = await wcdbService.execQuery('contact', null, sql, [chatroomId])
|
||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||
const result = await wcdbService.getChatRoomExtBuffer(chatroomId)
|
||||
if (!result.success || !result.extBuffer) {
|
||||
return nicknameMap
|
||||
}
|
||||
|
||||
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
|
||||
const extBuffer = this.decodeExtBuffer(result.extBuffer)
|
||||
if (!extBuffer) return nicknameMap
|
||||
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
|
||||
return nicknameMap
|
||||
@@ -583,19 +581,9 @@ class GroupAnalyticsService {
|
||||
const batch = candidates.slice(i, i + batchSize)
|
||||
if (batch.length === 0) continue
|
||||
|
||||
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
|
||||
const lightweightSql = `
|
||||
SELECT username, user_name, encrypt_username, encrypt_user_name, remark, nick_name, alias, local_type
|
||||
FROM contact
|
||||
WHERE username IN (${inList})
|
||||
`
|
||||
let result = await wcdbService.execQuery('contact', null, lightweightSql)
|
||||
if (!result.success || !result.rows) {
|
||||
// 兼容历史/变体列名,轻查询失败时回退全字段查询,避免好友标识丢失
|
||||
result = await wcdbService.execQuery('contact', null, `SELECT * FROM contact WHERE username IN (${inList})`)
|
||||
}
|
||||
if (!result.success || !result.rows) continue
|
||||
appendContactsToLookup(result.rows as Record<string, unknown>[])
|
||||
const result = await wcdbService.getContactsCompact(batch)
|
||||
if (!result.success || !result.contacts) continue
|
||||
appendContactsToLookup(result.contacts as Record<string, unknown>[])
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
@@ -774,31 +762,111 @@ class GroupAnalyticsService {
|
||||
return ''
|
||||
}
|
||||
|
||||
private normalizeCursorTimestamp(value: number): number {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0
|
||||
const normalized = Math.floor(value)
|
||||
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
|
||||
}
|
||||
|
||||
private extractRowSenderUsername(row: Record<string, any>): string {
|
||||
const candidates = [
|
||||
row.sender_username,
|
||||
row.senderUsername,
|
||||
row.sender,
|
||||
row.WCDB_CT_sender_username
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
const value = String(candidate || '').trim()
|
||||
if (value) return value
|
||||
}
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
const normalizedKey = key.toLowerCase()
|
||||
if (
|
||||
normalizedKey === 'sender_username' ||
|
||||
normalizedKey === 'senderusername' ||
|
||||
normalizedKey === 'sender' ||
|
||||
normalizedKey === 'wcdb_ct_sender_username'
|
||||
) {
|
||||
const normalizedValue = String(value || '').trim()
|
||||
if (normalizedValue) return normalizedValue
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private parseSingleMessageRow(row: Record<string, any>): Message | null {
|
||||
try {
|
||||
const mapped = chatService.mapRowsToMessagesForApi([row])
|
||||
return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async openMemberMessageCursor(
|
||||
chatroomId: string,
|
||||
batchSize: number,
|
||||
ascending: boolean,
|
||||
startTime: number,
|
||||
endTime: number
|
||||
): Promise<{ success: boolean; cursor?: number; error?: string }> {
|
||||
const beginTimestamp = this.normalizeCursorTimestamp(startTime)
|
||||
const endTimestamp = this.normalizeCursorTimestamp(endTime)
|
||||
const liteResult = await wcdbService.openMessageCursorLite(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||
if (liteResult.success && liteResult.cursor) return liteResult
|
||||
return wcdbService.openMessageCursor(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||
}
|
||||
|
||||
private async collectMessagesByMember(
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
startTime: number,
|
||||
endTime: number
|
||||
): Promise<{ success: boolean; data?: Message[]; error?: string }> {
|
||||
const batchSize = 500
|
||||
const batchSize = 800
|
||||
const matchedMessages: Message[] = []
|
||||
let offset = 0
|
||||
const senderMatchCache = new Map<string, boolean>()
|
||||
const matchesTargetSender = (sender: string | null | undefined): boolean => {
|
||||
const key = String(sender || '').trim().toLowerCase()
|
||||
if (!key) return false
|
||||
const cached = senderMatchCache.get(key)
|
||||
if (typeof cached === 'boolean') return cached
|
||||
const matched = this.isSameAccountIdentity(memberUsername, sender)
|
||||
senderMatchCache.set(key, matched)
|
||||
return matched
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const batch = await chatService.getMessages(chatroomId, offset, batchSize, startTime, endTime, true)
|
||||
if (!batch.success || !batch.messages) {
|
||||
return { success: false, error: batch.error || '获取群消息失败' }
|
||||
}
|
||||
const cursorResult = await this.openMemberMessageCursor(chatroomId, batchSize, true, startTime, endTime)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '创建群消息游标失败' }
|
||||
}
|
||||
|
||||
for (const message of batch.messages) {
|
||||
if (this.isSameAccountIdentity(memberUsername, message.senderUsername)) {
|
||||
matchedMessages.push(message)
|
||||
const cursor = cursorResult.cursor
|
||||
try {
|
||||
while (true) {
|
||||
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||
if (!batch.success) {
|
||||
return { success: false, error: batch.error || '获取群消息失败' }
|
||||
}
|
||||
}
|
||||
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||
if (rows.length === 0) break
|
||||
|
||||
const fetchedCount = batch.messages.length
|
||||
if (fetchedCount <= 0 || !batch.hasMore) break
|
||||
offset += fetchedCount
|
||||
for (const row of rows) {
|
||||
const senderFromRow = this.extractRowSenderUsername(row)
|
||||
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||
continue
|
||||
}
|
||||
const message = this.parseSingleMessageRow(row)
|
||||
if (!message) continue
|
||||
if (matchesTargetSender(message.senderUsername)) {
|
||||
matchedMessages.push(message)
|
||||
}
|
||||
}
|
||||
|
||||
if (!batch.hasMore) break
|
||||
}
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursor)
|
||||
}
|
||||
|
||||
return { success: true, data: matchedMessages }
|
||||
@@ -832,57 +900,93 @@ class GroupAnalyticsService {
|
||||
: 0
|
||||
|
||||
const matchedMessages: Message[] = []
|
||||
const batchSize = Math.max(limit * 2, 100)
|
||||
const senderMatchCache = new Map<string, boolean>()
|
||||
const matchesTargetSender = (sender: string | null | undefined): boolean => {
|
||||
const key = String(sender || '').trim().toLowerCase()
|
||||
if (!key) return false
|
||||
const cached = senderMatchCache.get(key)
|
||||
if (typeof cached === 'boolean') return cached
|
||||
const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender)
|
||||
senderMatchCache.set(key, matched)
|
||||
return matched
|
||||
}
|
||||
const batchSize = Math.max(limit * 4, 240)
|
||||
let hasMore = false
|
||||
|
||||
while (matchedMessages.length < limit) {
|
||||
const batch = await chatService.getMessages(
|
||||
normalizedChatroomId,
|
||||
cursor,
|
||||
batchSize,
|
||||
startTimeValue,
|
||||
endTimeValue,
|
||||
false
|
||||
)
|
||||
if (!batch.success || !batch.messages) {
|
||||
return { success: false, error: batch.error || '获取群成员消息失败' }
|
||||
}
|
||||
const cursorResult = await this.openMemberMessageCursor(
|
||||
normalizedChatroomId,
|
||||
batchSize,
|
||||
false,
|
||||
startTimeValue,
|
||||
endTimeValue
|
||||
)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '创建群成员消息游标失败' }
|
||||
}
|
||||
|
||||
const currentMessages = batch.messages
|
||||
const nextCursor = typeof batch.nextOffset === 'number'
|
||||
? Math.max(cursor, Math.floor(batch.nextOffset))
|
||||
: cursor + currentMessages.length
|
||||
let consumedRows = 0
|
||||
const dbCursor = cursorResult.cursor
|
||||
|
||||
let overflowMatchFound = false
|
||||
for (const message of currentMessages) {
|
||||
if (!this.isSameAccountIdentity(normalizedMemberUsername, message.senderUsername)) {
|
||||
continue
|
||||
try {
|
||||
while (matchedMessages.length < limit) {
|
||||
const batch = await wcdbService.fetchMessageBatch(dbCursor)
|
||||
if (!batch.success) {
|
||||
return { success: false, error: batch.error || '获取群成员消息失败' }
|
||||
}
|
||||
|
||||
if (matchedMessages.length < limit) {
|
||||
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||
if (rows.length === 0) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
|
||||
let startIndex = 0
|
||||
if (cursor > consumedRows) {
|
||||
const skipCount = Math.min(cursor - consumedRows, rows.length)
|
||||
consumedRows += skipCount
|
||||
startIndex = skipCount
|
||||
if (startIndex >= rows.length) {
|
||||
if (!batch.hasMore) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = startIndex; index < rows.length; index += 1) {
|
||||
const row = rows[index]
|
||||
consumedRows += 1
|
||||
|
||||
const senderFromRow = this.extractRowSenderUsername(row)
|
||||
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const message = this.parseSingleMessageRow(row)
|
||||
if (!message) continue
|
||||
if (!matchesTargetSender(message.senderUsername)) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchedMessages.push(message)
|
||||
} else {
|
||||
overflowMatchFound = true
|
||||
if (matchedMessages.length >= limit) {
|
||||
cursor = consumedRows
|
||||
hasMore = index < rows.length - 1 || batch.hasMore === true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedMessages.length >= limit) break
|
||||
|
||||
cursor = consumedRows
|
||||
if (!batch.hasMore) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cursor = nextCursor
|
||||
|
||||
if (overflowMatchFound) {
|
||||
hasMore = true
|
||||
break
|
||||
}
|
||||
|
||||
if (currentMessages.length === 0 || !batch.hasMore) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
|
||||
if (matchedMessages.length >= limit) {
|
||||
hasMore = true
|
||||
break
|
||||
}
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(dbCursor)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -55,14 +55,8 @@ type DecryptResult = {
|
||||
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
||||
}
|
||||
|
||||
type HardlinkState = {
|
||||
imageTable?: string
|
||||
dirTable?: string
|
||||
}
|
||||
|
||||
export class ImageDecryptService {
|
||||
private configService = new ConfigService()
|
||||
private hardlinkCache = new Map<string, HardlinkState>()
|
||||
private resolvedCache = new Map<string, string>()
|
||||
private pending = new Map<string, Promise<DecryptResult>>()
|
||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
||||
@@ -683,45 +677,19 @@ export class ImageDecryptService {
|
||||
|
||||
private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise<string | null> {
|
||||
try {
|
||||
const hardlinkPath = this.resolveHardlinkDbPath(accountDir)
|
||||
if (!hardlinkPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ready = await this.ensureWcdbReady()
|
||||
if (!ready) {
|
||||
this.logInfo('[ImageDecrypt] hardlink db not ready')
|
||||
return null
|
||||
}
|
||||
|
||||
const state = await this.getHardlinkState(accountDir, hardlinkPath)
|
||||
if (!state.imageTable) {
|
||||
this.logInfo('[ImageDecrypt] hardlink table missing', { hardlinkPath })
|
||||
return null
|
||||
}
|
||||
const resolveResult = await wcdbService.resolveImageHardlink(md5, accountDir)
|
||||
if (!resolveResult.success || !resolveResult.data) return null
|
||||
const fileName = String(resolveResult.data.file_name || '').trim()
|
||||
const fullPath = String(resolveResult.data.full_path || '').trim()
|
||||
if (!fileName) return null
|
||||
|
||||
const escapedMd5 = this.escapeSqlString(md5)
|
||||
const rowResult = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
`SELECT dir1, dir2, file_name FROM ${state.imageTable} WHERE lower(md5) = lower('${escapedMd5}') LIMIT 1`
|
||||
)
|
||||
const row = rowResult.success && rowResult.rows ? rowResult.rows[0] : null
|
||||
|
||||
if (!row) {
|
||||
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')
|
||||
if (dir1 === undefined || dir2 === undefined || !fileName) {
|
||||
this.logInfo('[ImageDecrypt] hardlink row incomplete', { row })
|
||||
return null
|
||||
}
|
||||
|
||||
const lowerFileName = fileName.toLowerCase()
|
||||
const lowerFileName = String(fileName).toLowerCase()
|
||||
if (lowerFileName.endsWith('.dat')) {
|
||||
const baseLower = lowerFileName.slice(0, -4)
|
||||
if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) {
|
||||
@@ -730,57 +698,11 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
// dir1 和 dir2 是 rowid,需要从 dir2id 表查询对应的目录名
|
||||
let dir1Name: string | null = null
|
||||
let dir2Name: string | null = null
|
||||
|
||||
if (state.dirTable) {
|
||||
try {
|
||||
// 通过 rowid 查询目录名
|
||||
const dir1Result = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
`SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir1)} LIMIT 1`
|
||||
)
|
||||
if (dir1Result.success && dir1Result.rows && dir1Result.rows.length > 0) {
|
||||
const value = this.getRowValue(dir1Result.rows[0], 'username')
|
||||
if (value) dir1Name = String(value)
|
||||
}
|
||||
|
||||
const dir2Result = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
`SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir2)} LIMIT 1`
|
||||
)
|
||||
if (dir2Result.success && dir2Result.rows && dir2Result.rows.length > 0) {
|
||||
const value = this.getRowValue(dir2Result.rows[0], 'username')
|
||||
if (value) dir2Name = String(value)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (fullPath && existsSync(fullPath)) {
|
||||
this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath })
|
||||
return fullPath
|
||||
}
|
||||
|
||||
if (!dir1Name || !dir2Name) {
|
||||
this.logInfo('[ImageDecrypt] hardlink dir resolve miss', { dir1, dir2, dir1Name, dir2Name })
|
||||
return null
|
||||
}
|
||||
|
||||
// 构建路径: msg/attach/{dir1Name}/{dir2Name}/Img/{fileName}
|
||||
const possiblePaths = [
|
||||
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Img', fileName),
|
||||
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 })
|
||||
this.logInfo('[ImageDecrypt] hardlink path miss', { fullPath, md5 })
|
||||
return null
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -788,35 +710,6 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
|
||||
private async getHardlinkState(accountDir: string, hardlinkPath: string): Promise<HardlinkState> {
|
||||
const cached = this.hardlinkCache.get(hardlinkPath)
|
||||
if (cached) return cached
|
||||
|
||||
const imageResult = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1"
|
||||
)
|
||||
const dirResult = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1"
|
||||
)
|
||||
const imageTable = imageResult.success && imageResult.rows && imageResult.rows.length > 0
|
||||
? this.getRowValue(imageResult.rows[0], 'name')
|
||||
: undefined
|
||||
const dirTable = dirResult.success && dirResult.rows && dirResult.rows.length > 0
|
||||
? this.getRowValue(dirResult.rows[0], 'name')
|
||||
: undefined
|
||||
const state: HardlinkState = {
|
||||
imageTable: imageTable ? String(imageTable) : undefined,
|
||||
dirTable: dirTable ? String(dirTable) : undefined
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink state', { hardlinkPath, imageTable: state.imageTable, dirTable: state.dirTable })
|
||||
this.hardlinkCache.set(hardlinkPath, state)
|
||||
return state
|
||||
}
|
||||
|
||||
private async ensureWcdbReady(): Promise<boolean> {
|
||||
if (wcdbService.isReady()) return true
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
@@ -1992,7 +1885,6 @@ export class ImageDecryptService {
|
||||
|
||||
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
||||
this.resolvedCache.clear()
|
||||
this.hardlinkCache.clear()
|
||||
this.pending.clear()
|
||||
this.updateFlags.clear()
|
||||
this.cacheIndexed = false
|
||||
|
||||
@@ -136,7 +136,7 @@ export class KeyServiceMac {
|
||||
if (sipStatus.enabled) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. 重启 Mac 并按住 Command + R 进入恢复模式\n2. 打开终端,输入: csrutil disable\n3. 重启电脑'
|
||||
error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. Intel 芯片:重启 Mac 并按住 Command + R 进入恢复模式\n2. Apple 芯片(M 系列):关机后长按开机(指纹)键,选择“设置(选项)”进入恢复模式\n3. 打开终端,输入: csrutil disable\n4. 重启电脑'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -663,100 +663,24 @@ class SnsService {
|
||||
}
|
||||
|
||||
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||||
const collect = (rows?: any[]): string[] => {
|
||||
if (!Array.isArray(rows)) return []
|
||||
const usernames: string[] = []
|
||||
for (const row of rows) {
|
||||
const raw = row?.user_name ?? row?.userName ?? row?.username ?? Object.values(row || {})[0]
|
||||
const username = typeof raw === 'string' ? raw.trim() : String(raw || '').trim()
|
||||
if (username) usernames.push(username)
|
||||
}
|
||||
return usernames
|
||||
const result = await wcdbService.getSnsUsernames()
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || '获取朋友圈联系人失败' }
|
||||
}
|
||||
|
||||
const primary = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT DISTINCT user_name FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
|
||||
)
|
||||
const fallback = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT DISTINCT userName FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
|
||||
)
|
||||
|
||||
const merged = Array.from(new Set([
|
||||
...collect(primary.rows),
|
||||
...collect(fallback.rows)
|
||||
]))
|
||||
|
||||
// 任一查询成功且拿到用户名即视为成功,避免因为列名差异导致误判为空。
|
||||
if (merged.length > 0) {
|
||||
return { success: true, usernames: merged }
|
||||
}
|
||||
|
||||
// 两条查询都成功但无数据,说明确实没有朋友圈发布者。
|
||||
if (primary.success || fallback.success) {
|
||||
return { success: true, usernames: [] }
|
||||
}
|
||||
|
||||
return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人失败' }
|
||||
return { success: true, usernames: result.usernames || [] }
|
||||
}
|
||||
|
||||
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||
let totalPosts = 0
|
||||
let totalFriends = 0
|
||||
let myPosts: number | null = null
|
||||
|
||||
const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine')
|
||||
if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) {
|
||||
totalPosts = this.parseCountValue(postCountResult.rows[0])
|
||||
}
|
||||
|
||||
if (totalPosts > 0) {
|
||||
const friendCountPrimary = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
|
||||
)
|
||||
if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) {
|
||||
totalFriends = this.parseCountValue(friendCountPrimary.rows[0])
|
||||
} else {
|
||||
const friendCountFallback = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
|
||||
)
|
||||
if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) {
|
||||
totalFriends = this.parseCountValue(friendCountFallback.rows[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedMyWxid = this.toOptionalString(myWxid)
|
||||
if (normalizedMyWxid) {
|
||||
const myPostPrimary = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = ?",
|
||||
[normalizedMyWxid]
|
||||
)
|
||||
if (myPostPrimary.success && myPostPrimary.rows && myPostPrimary.rows.length > 0) {
|
||||
myPosts = this.parseCountValue(myPostPrimary.rows[0])
|
||||
} else {
|
||||
const myPostFallback = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = ?",
|
||||
[normalizedMyWxid]
|
||||
)
|
||||
if (myPostFallback.success && myPostFallback.rows && myPostFallback.rows.length > 0) {
|
||||
myPosts = this.parseCountValue(myPostFallback.rows[0])
|
||||
}
|
||||
}
|
||||
const result = await wcdbService.getSnsExportStats(normalizedMyWxid || undefined)
|
||||
if (!result.success || !result.data) {
|
||||
return { totalPosts: 0, totalFriends: 0, myPosts: normalizedMyWxid ? 0 : null }
|
||||
}
|
||||
return {
|
||||
totalPosts: Number(result.data.totalPosts || 0),
|
||||
totalFriends: Number(result.data.totalFriends || 0),
|
||||
myPosts: result.data.myPosts === null || result.data.myPosts === undefined ? null : Number(result.data.myPosts || 0)
|
||||
}
|
||||
|
||||
return { totalPosts, totalFriends, myPosts }
|
||||
}
|
||||
|
||||
async getExportStats(options?: {
|
||||
|
||||
@@ -70,7 +70,7 @@ class VideoService {
|
||||
|
||||
/**
|
||||
* 从 video_hardlink_info_v4 表查询视频文件名
|
||||
* 使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
* 使用 wcdb 专属接口查询加密的 hardlink.db
|
||||
*/
|
||||
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
||||
const dbPath = this.getDbPath()
|
||||
@@ -103,17 +103,11 @@ class VideoService {
|
||||
if (existsSync(p)) {
|
||||
try {
|
||||
this.log('尝试加密 hardlink.db', { path: p })
|
||||
const escapedMd5 = md5.replace(/'/g, "''")
|
||||
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
||||
const result = await wcdbService.execQuery('media', p, sql)
|
||||
|
||||
if (result.success && result.rows && result.rows.length > 0) {
|
||||
const row = result.rows[0]
|
||||
if (row?.file_name) {
|
||||
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
|
||||
this.log('加密 hardlink.db 命中', { file_name: row.file_name, realMd5 })
|
||||
return realMd5
|
||||
}
|
||||
const result = await wcdbService.resolveVideoHardlinkMd5(md5, p)
|
||||
if (result.success && result.data?.resolved_md5) {
|
||||
const realMd5 = String(result.data.resolved_md5)
|
||||
this.log('加密 hardlink.db 命中', { file_name: result.data.file_name, realMd5 })
|
||||
return realMd5
|
||||
}
|
||||
this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) })
|
||||
} catch (e) {
|
||||
|
||||
@@ -5,47 +5,6 @@ import { tmpdir } from 'os'
|
||||
// DLL 初始化错误信息,用于帮助用户诊断问题
|
||||
let lastDllInitError: string | null = null
|
||||
|
||||
/**
|
||||
* 解析 extra_buffer(protobuf)中的免打扰状态
|
||||
* - field 12 (tag 0x60): 值非0 = 免打扰
|
||||
* 折叠状态通过 contact.flag & 0x10000000 判断
|
||||
*/
|
||||
function parseExtraBuffer(raw: Buffer | string | null | undefined): { isMuted: boolean } {
|
||||
if (!raw) return { isMuted: false }
|
||||
// execQuery 返回的 BLOB 列是十六进制字符串,需要先解码
|
||||
const buf: Buffer = typeof raw === 'string' ? Buffer.from(raw, 'hex') : raw
|
||||
if (buf.length === 0) return { isMuted: false }
|
||||
let isMuted = false
|
||||
let i = 0
|
||||
const len = buf.length
|
||||
|
||||
const readVarint = (): number => {
|
||||
let result = 0, shift = 0
|
||||
while (i < len) {
|
||||
const b = buf[i++]
|
||||
result |= (b & 0x7f) << shift
|
||||
shift += 7
|
||||
if (!(b & 0x80)) break
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
while (i < len) {
|
||||
const tag = readVarint()
|
||||
const fieldNum = tag >>> 3
|
||||
const wireType = tag & 0x07
|
||||
if (wireType === 0) {
|
||||
const val = readVarint()
|
||||
if (fieldNum === 12 && val !== 0) isMuted = true
|
||||
} else if (wireType === 2) {
|
||||
const sz = readVarint()
|
||||
i += sz
|
||||
} else if (wireType === 5) { i += 4
|
||||
} else if (wireType === 1) { i += 8
|
||||
} else { break }
|
||||
}
|
||||
return { isMuted }
|
||||
}
|
||||
export function getLastDllInitError(): string | null {
|
||||
return lastDllInitError
|
||||
}
|
||||
@@ -86,6 +45,11 @@ export class WcdbCore {
|
||||
private wcdbGetMessageMeta: any = null
|
||||
private wcdbGetContact: any = null
|
||||
private wcdbGetContactStatus: any = null
|
||||
private wcdbGetContactTypeCounts: any = null
|
||||
private wcdbGetContactsCompact: any = null
|
||||
private wcdbGetContactAliasMap: any = null
|
||||
private wcdbGetContactFriendFlags: any = null
|
||||
private wcdbGetChatRoomExtBuffer: any = null
|
||||
private wcdbGetMessageTableStats: any = null
|
||||
private wcdbGetAggregateStats: any = null
|
||||
private wcdbGetAvailableYears: any = null
|
||||
@@ -106,9 +70,24 @@ export class WcdbCore {
|
||||
private wcdbGetEmoticonCdnUrl: any = null
|
||||
private wcdbGetDbStatus: any = null
|
||||
private wcdbGetVoiceData: any = null
|
||||
private wcdbGetVoiceDataBatch: any = null
|
||||
private wcdbGetMediaSchemaSummary: any = null
|
||||
private wcdbGetSessionMessageCounts: any = null
|
||||
private wcdbGetSessionMessageTypeStats: any = null
|
||||
private wcdbGetSessionMessageTypeStatsBatch: any = null
|
||||
private wcdbGetSessionMessageDateCounts: any = null
|
||||
private wcdbGetSessionMessageDateCountsBatch: any = null
|
||||
private wcdbGetMessagesByType: any = null
|
||||
private wcdbGetHeadImageBuffers: any = null
|
||||
private wcdbSearchMessages: any = null
|
||||
private wcdbGetSnsTimeline: any = null
|
||||
private wcdbGetSnsAnnualStats: any = null
|
||||
private wcdbGetSnsUsernames: any = null
|
||||
private wcdbGetSnsExportStats: any = null
|
||||
private wcdbGetMessageTableColumns: any = null
|
||||
private wcdbGetMessageTableTimeRange: any = null
|
||||
private wcdbResolveImageHardlink: any = null
|
||||
private wcdbResolveVideoHardlinkMd5: any = null
|
||||
private wcdbInstallSnsBlockDeleteTrigger: any = null
|
||||
private wcdbUninstallSnsBlockDeleteTrigger: any = null
|
||||
private wcdbCheckSnsBlockDeleteTrigger: any = null
|
||||
@@ -719,6 +698,32 @@ export class WcdbCore {
|
||||
this.wcdbGetContactStatus = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbGetContactTypeCounts = this.lib.func('int32 wcdb_get_contact_type_counts(int64 handle, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetContactTypeCounts = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetContactsCompact = this.lib.func('int32 wcdb_get_contacts_compact(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetContactsCompact = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetContactAliasMap = this.lib.func('int32 wcdb_get_contact_alias_map(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetContactAliasMap = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetContactFriendFlags = this.lib.func('int32 wcdb_get_contact_friend_flags(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetContactFriendFlags = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetChatRoomExtBuffer = this.lib.func('int32 wcdb_get_chat_room_ext_buffer(int64 handle, const char* chatroomId, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetChatRoomExtBuffer = null
|
||||
}
|
||||
|
||||
// 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)')
|
||||
|
||||
@@ -821,6 +826,51 @@ export class WcdbCore {
|
||||
} catch {
|
||||
this.wcdbGetVoiceData = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetVoiceDataBatch = this.lib.func('int32 wcdb_get_voice_data_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetVoiceDataBatch = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetMediaSchemaSummary = this.lib.func('int32 wcdb_get_media_schema_summary(int64 handle, const char* dbPath, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetMediaSchemaSummary = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetSessionMessageCounts = this.lib.func('int32 wcdb_get_session_message_counts(int64 handle, const char* sessionIdsJson, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetSessionMessageCounts = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetSessionMessageTypeStats = this.lib.func('int32 wcdb_get_session_message_type_stats(int64 handle, const char* sessionId, int32 beginTimestamp, int32 endTimestamp, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetSessionMessageTypeStats = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetSessionMessageTypeStatsBatch = this.lib.func('int32 wcdb_get_session_message_type_stats_batch(int64 handle, const char* sessionIdsJson, const char* optionsJson, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetSessionMessageTypeStatsBatch = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetSessionMessageDateCounts = this.lib.func('int32 wcdb_get_session_message_date_counts(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetSessionMessageDateCounts = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetSessionMessageDateCountsBatch = this.lib.func('int32 wcdb_get_session_message_date_counts_batch(int64 handle, const char* sessionIdsJson, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetSessionMessageDateCountsBatch = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetMessagesByType = this.lib.func('int32 wcdb_get_messages_by_type(int64 handle, const char* sessionId, int64 localType, int32 ascending, int32 limit, int32 offset, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetMessagesByType = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetHeadImageBuffers = this.lib.func('int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetHeadImageBuffers = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_search_messages(wcdb_handle handle, const char* session_id, const char* keyword, int32_t limit, int32_t offset, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
|
||||
try {
|
||||
@@ -842,6 +892,36 @@ export class WcdbCore {
|
||||
} catch {
|
||||
this.wcdbGetSnsAnnualStats = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetSnsUsernames = this.lib.func('int32 wcdb_get_sns_usernames(int64 handle, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetSnsUsernames = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetSnsExportStats = this.lib.func('int32 wcdb_get_sns_export_stats(int64 handle, const char* myWxid, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetSnsExportStats = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetMessageTableColumns = this.lib.func('int32 wcdb_get_message_table_columns(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetMessageTableColumns = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetMessageTableTimeRange = this.lib.func('int32 wcdb_get_message_table_time_range(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetMessageTableTimeRange = null
|
||||
}
|
||||
try {
|
||||
this.wcdbResolveImageHardlink = this.lib.func('int32 wcdb_resolve_image_hardlink(int64 handle, const char* md5, const char* accountDir, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbResolveImageHardlink = null
|
||||
}
|
||||
try {
|
||||
this.wcdbResolveVideoHardlinkMd5 = this.lib.func('int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbResolveVideoHardlinkMd5 = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
|
||||
try {
|
||||
@@ -1392,6 +1472,197 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetSessionMessageCounts) return this.getMessageCounts(sessionIds)
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetSessionMessageCounts(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 raw = JSON.parse(jsonStr) || {}
|
||||
const counts: Record<string, number> = {}
|
||||
for (const sid of sessionIds || []) {
|
||||
const value = Number(raw?.[sid] ?? 0)
|
||||
counts[sid] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
|
||||
}
|
||||
return { success: true, counts }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getSessionMessageTypeStats(
|
||||
sessionId: string,
|
||||
beginTimestamp: number = 0,
|
||||
endTimestamp: number = 0
|
||||
): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetSessionMessageTypeStats) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetSessionMessageTypeStats(
|
||||
this.handle,
|
||||
sessionId,
|
||||
this.normalizeTimestamp(beginTimestamp),
|
||||
this.normalizeTimestamp(endTimestamp),
|
||||
outPtr
|
||||
)
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
return { success: false, error: `获取会话类型统计失败: ${result}` }
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析会话类型统计失败' }
|
||||
return { success: true, data: JSON.parse(jsonStr) || {} }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getSessionMessageTypeStatsBatch(
|
||||
sessionIds: string[],
|
||||
options?: {
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
quickMode?: boolean
|
||||
includeGroupSenderCount?: boolean
|
||||
}
|
||||
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
const normalizedSessionIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||
if (normalizedSessionIds.length === 0) return { success: true, data: {} }
|
||||
|
||||
if (!this.wcdbGetSessionMessageTypeStatsBatch) {
|
||||
const data: Record<string, any> = {}
|
||||
for (const sessionId of normalizedSessionIds) {
|
||||
const single = await this.getSessionMessageTypeStats(
|
||||
sessionId,
|
||||
options?.beginTimestamp || 0,
|
||||
options?.endTimestamp || 0
|
||||
)
|
||||
if (single.success) {
|
||||
data[sessionId] = single.data || {}
|
||||
}
|
||||
}
|
||||
return { success: true, data }
|
||||
}
|
||||
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const optionsJson = JSON.stringify({
|
||||
begin: this.normalizeTimestamp(options?.beginTimestamp || 0),
|
||||
end: this.normalizeTimestamp(options?.endTimestamp || 0),
|
||||
quick_mode: options?.quickMode === true,
|
||||
include_group_sender_count: options?.includeGroupSenderCount !== false
|
||||
})
|
||||
const result = this.wcdbGetSessionMessageTypeStatsBatch(
|
||||
this.handle,
|
||||
JSON.stringify(normalizedSessionIds),
|
||||
optionsJson,
|
||||
outPtr
|
||||
)
|
||||
if (result !== 0 || !outPtr[0]) return { success: false, error: `批量获取会话类型统计失败: ${result}` }
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析批量会话类型统计失败' }
|
||||
return { success: true, data: JSON.parse(jsonStr) || {} }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetSessionMessageDateCounts) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetSessionMessageDateCounts(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 raw = JSON.parse(jsonStr) || {}
|
||||
const counts: Record<string, number> = {}
|
||||
for (const [dateKey, value] of Object.entries(raw)) {
|
||||
const count = Number(value)
|
||||
if (!dateKey || !Number.isFinite(count) || count <= 0) continue
|
||||
counts[String(dateKey)] = Math.floor(count)
|
||||
}
|
||||
return { success: true, counts }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record<string, Record<string, number>>; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
const normalizedSessionIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||
if (normalizedSessionIds.length === 0) return { success: true, data: {} }
|
||||
|
||||
if (!this.wcdbGetSessionMessageDateCountsBatch) {
|
||||
const data: Record<string, Record<string, number>> = {}
|
||||
for (const sessionId of normalizedSessionIds) {
|
||||
const single = await this.getSessionMessageDateCounts(sessionId)
|
||||
data[sessionId] = single.success && single.counts ? single.counts : {}
|
||||
}
|
||||
return { success: true, data }
|
||||
}
|
||||
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetSessionMessageDateCountsBatch(this.handle, JSON.stringify(normalizedSessionIds), outPtr)
|
||||
if (result !== 0 || !outPtr[0]) return { success: false, error: `批量获取会话日消息统计失败: ${result}` }
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析批量会话日消息统计失败' }
|
||||
const raw = JSON.parse(jsonStr) || {}
|
||||
const data: Record<string, Record<string, number>> = {}
|
||||
for (const sessionId of normalizedSessionIds) {
|
||||
const source = raw?.[sessionId] || {}
|
||||
const next: Record<string, number> = {}
|
||||
for (const [dateKey, value] of Object.entries(source)) {
|
||||
const count = Number(value)
|
||||
if (!dateKey || !Number.isFinite(count) || count <= 0) continue
|
||||
next[String(dateKey)] = Math.floor(count)
|
||||
}
|
||||
data[sessionId] = next
|
||||
}
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getMessagesByType(
|
||||
sessionId: string,
|
||||
localType: number,
|
||||
ascending = false,
|
||||
limit = 0,
|
||||
offset = 0
|
||||
): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetMessagesByType) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetMessagesByType(
|
||||
this.handle,
|
||||
sessionId,
|
||||
BigInt(localType),
|
||||
ascending ? 1 : 0,
|
||||
Math.max(0, Math.floor(limit || 0)),
|
||||
Math.max(0, Math.floor(offset || 0)),
|
||||
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: Array.isArray(rows) ? rows : [] }
|
||||
} 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 未连接' }
|
||||
@@ -1766,24 +2037,25 @@ export class WcdbCore {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
}
|
||||
if (!this.wcdbGetContactStatus) {
|
||||
return { success: false, error: '接口未就绪' }
|
||||
}
|
||||
try {
|
||||
// 分批查询,避免 SQL 过长(execQuery 不支持参数绑定,直接拼 SQL)
|
||||
const BATCH = 200
|
||||
const outPtr = [null as any]
|
||||
const code = this.wcdbGetContactStatus(this.handle, JSON.stringify(usernames || []), outPtr)
|
||||
if (code !== 0 || !outPtr[0]) {
|
||||
return { success: false, error: `获取会话状态失败: ${code}` }
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析会话状态失败' }
|
||||
|
||||
const rawMap = JSON.parse(jsonStr) || {}
|
||||
const map: Record<string, { isFolded: boolean; isMuted: boolean }> = {}
|
||||
for (let i = 0; i < usernames.length; i += BATCH) {
|
||||
const batch = usernames.slice(i, i + BATCH)
|
||||
const inList = batch.map(u => `'${u.replace(/'/g, "''")}'`).join(',')
|
||||
const sql = `SELECT username, flag, extra_buffer FROM contact WHERE username IN (${inList})`
|
||||
const result = await this.execQuery('contact', null, sql)
|
||||
if (!result.success || !result.rows) continue
|
||||
for (const row of result.rows) {
|
||||
const uname: string = row.username
|
||||
// 折叠:flag bit 28 (0x10000000)
|
||||
const flag = parseInt(row.flag ?? '0', 10)
|
||||
const isFolded = (flag & 0x10000000) !== 0
|
||||
// 免打扰:extra_buffer field 12 非0
|
||||
const { isMuted } = parseExtraBuffer(row.extra_buffer)
|
||||
map[uname] = { isFolded, isMuted }
|
||||
for (const username of usernames || []) {
|
||||
const state = rawMap[username] || {}
|
||||
map[username] = {
|
||||
isFolded: Boolean(state.isFolded),
|
||||
isMuted: Boolean(state.isMuted)
|
||||
}
|
||||
}
|
||||
return { success: true, map }
|
||||
@@ -1792,6 +2064,128 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetMessageTableColumns) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetMessageTableColumns(this.handle, dbPath, tableName, outPtr)
|
||||
if (result !== 0 || !outPtr[0]) return { success: false, error: `获取消息表列失败: ${result}` }
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析消息表列失败' }
|
||||
const columns = JSON.parse(jsonStr)
|
||||
return { success: true, columns: Array.isArray(columns) ? columns.map((c: any) => String(c || '')) : [] }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetMessageTableTimeRange) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetMessageTableTimeRange(this.handle, dbPath, tableName, 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 getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetContactTypeCounts) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const code = this.wcdbGetContactTypeCounts(this.handle, outPtr)
|
||||
if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人分类统计失败: ${code}` }
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析联系人分类统计失败' }
|
||||
const raw = JSON.parse(jsonStr) || {}
|
||||
return {
|
||||
success: true,
|
||||
counts: {
|
||||
private: Number(raw.private || 0),
|
||||
group: Number(raw.group || 0),
|
||||
official: Number(raw.official || 0),
|
||||
former_friend: Number(raw.former_friend || 0)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetContactsCompact) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const payload = Array.isArray(usernames) && usernames.length > 0 ? JSON.stringify(usernames) : null
|
||||
const code = this.wcdbGetContactsCompact(this.handle, payload, outPtr)
|
||||
if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人列表失败: ${code}` }
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析联系人列表失败' }
|
||||
const contacts = JSON.parse(jsonStr)
|
||||
return { success: true, contacts: Array.isArray(contacts) ? contacts : [] }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetContactAliasMap) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const code = this.wcdbGetContactAliasMap(this.handle, JSON.stringify(usernames || []), outPtr)
|
||||
if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人 alias 失败: ${code}` }
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析联系人 alias 失败' }
|
||||
const map = JSON.parse(jsonStr)
|
||||
return { success: true, map }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record<string, boolean>; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetContactFriendFlags) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const code = this.wcdbGetContactFriendFlags(this.handle, JSON.stringify(usernames || []), outPtr)
|
||||
if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人好友标记失败: ${code}` }
|
||||
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 getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetChatRoomExtBuffer) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const code = this.wcdbGetChatRoomExtBuffer(this.handle, chatroomId, outPtr)
|
||||
if (code !== 0 || !outPtr[0]) return { success: false, error: `获取群聊 ext_buffer 失败: ${code}` }
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析群聊 ext_buffer 失败' }
|
||||
const data = JSON.parse(jsonStr) || {}
|
||||
const extBuffer = String(data.ext_buffer || '').trim()
|
||||
return { success: true, extBuffer: extBuffer || undefined }
|
||||
} 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 未连接' }
|
||||
@@ -2078,8 +2472,11 @@ export class WcdbCore {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
}
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
|
||||
const fallbackFlag = /fallback|diag|diagnostic/i.test(String(sql || ''))
|
||||
this.writeLog(`[audit:execQuery] kind=${kind} path=${path || ''} sql_len=${String(sql || '').length} fallback=${fallbackFlag ? 1 : 0}`)
|
||||
|
||||
// 如果提供了参数,使用参数化查询(需要 C++ 层支持)
|
||||
// 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现
|
||||
@@ -2114,12 +2511,14 @@ export class WcdbCore {
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析查询结果失败' }
|
||||
const rows = JSON.parse(jsonStr)
|
||||
this.writeLog(`[audit:execQuery] done kind=${kind} cost_ms=${Date.now() - startedAt} rows=${Array.isArray(rows) ? rows.length : -1}`)
|
||||
if (isContactQuery) {
|
||||
const count = Array.isArray(rows) ? rows.length : -1
|
||||
this.writeLog(`[diag:execQuery] contact query ok rows=${count} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true)
|
||||
}
|
||||
return { success: true, rows }
|
||||
} catch (e) {
|
||||
this.writeLog(`[audit:execQuery] fail kind=${kind} cost_ms=${Date.now() - startedAt} err=${String(e)}`)
|
||||
const isContactQuery = String(kind).toLowerCase() === 'contact' || /\bfrom\s+contact\b/i.test(String(sql))
|
||||
if (isContactQuery) {
|
||||
this.writeLog(`[diag:execQuery] contact query exception kind=${kind} path=${path || ''} sql="${this.formatSqlForLog(sql)}" err=${String(e)}`, true)
|
||||
@@ -2209,6 +2608,93 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
async getVoiceDataBatch(
|
||||
requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }>
|
||||
): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetVoiceDataBatch) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const payload = JSON.stringify(Array.isArray(requests) ? requests : [])
|
||||
const result = this.wcdbGetVoiceDataBatch(this.handle, payload, 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)
|
||||
const normalized = Array.isArray(rows) ? rows.map((row: any) => ({
|
||||
index: Number(row?.index ?? 0),
|
||||
hex: row?.hex ? String(row.hex) : undefined
|
||||
})) : []
|
||||
return { success: true, rows: normalized }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetMediaSchemaSummary) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetMediaSchemaSummary(this.handle, dbPath, 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 getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetHeadImageBuffers) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetHeadImageBuffers(this.handle, JSON.stringify(usernames || []), 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 resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbResolveImageHardlink) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbResolveImageHardlink(this.handle, md5, accountDir || null, outPtr)
|
||||
if (result !== 0 || !outPtr[0]) return { success: false, error: `解析图片 hardlink 失败: ${result}` }
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析图片 hardlink 响应失败' }
|
||||
const data = JSON.parse(jsonStr) || {}
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbResolveVideoHardlinkMd5) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbResolveVideoHardlinkMd5(this.handle, md5, dbPath || null, outPtr)
|
||||
if (result !== 0 || !outPtr[0]) return { success: false, error: `解析视频 hardlink 失败: ${result}` }
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析视频 hardlink 响应失败' }
|
||||
const data = JSON.parse(jsonStr) || {}
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据收集初始化
|
||||
*/
|
||||
@@ -2373,6 +2859,45 @@ export class WcdbCore {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetSnsUsernames) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetSnsUsernames(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 usernames = JSON.parse(jsonStr)
|
||||
return { success: true, usernames: Array.isArray(usernames) ? usernames.map((u: any) => String(u || '').trim()).filter(Boolean) : [] }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetSnsExportStats) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetSnsExportStats(this.handle, myWxid || null, outPtr)
|
||||
if (result !== 0 || !outPtr[0]) return { success: false, error: `获取朋友圈导出统计失败: ${result}` }
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析朋友圈导出统计失败' }
|
||||
const raw = JSON.parse(jsonStr) || {}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalPosts: Number(raw.total_posts || 0),
|
||||
totalFriends: Number(raw.total_friends || 0),
|
||||
myPosts: raw.my_posts === null || raw.my_posts === undefined ? null : Number(raw.my_posts || 0)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 为朋友圈安装删除
|
||||
*/
|
||||
|
||||
@@ -222,6 +222,48 @@ export class WcdbService {
|
||||
return this.callWorker('getMessageCounts', { sessionIds })
|
||||
}
|
||||
|
||||
async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||
return this.callWorker('getSessionMessageCounts', { sessionIds })
|
||||
}
|
||||
|
||||
async getSessionMessageTypeStats(
|
||||
sessionId: string,
|
||||
beginTimestamp: number = 0,
|
||||
endTimestamp: number = 0
|
||||
): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('getSessionMessageTypeStats', { sessionId, beginTimestamp, endTimestamp })
|
||||
}
|
||||
|
||||
async getSessionMessageTypeStatsBatch(
|
||||
sessionIds: string[],
|
||||
options?: {
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
quickMode?: boolean
|
||||
includeGroupSenderCount?: boolean
|
||||
}
|
||||
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
|
||||
return this.callWorker('getSessionMessageTypeStatsBatch', { sessionIds, options })
|
||||
}
|
||||
|
||||
async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||
return this.callWorker('getSessionMessageDateCounts', { sessionId })
|
||||
}
|
||||
|
||||
async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record<string, Record<string, number>>; error?: string }> {
|
||||
return this.callWorker('getSessionMessageDateCountsBatch', { sessionIds })
|
||||
}
|
||||
|
||||
async getMessagesByType(
|
||||
sessionId: string,
|
||||
localType: number,
|
||||
ascending = false,
|
||||
limit = 0,
|
||||
offset = 0
|
||||
): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取联系人昵称
|
||||
*/
|
||||
@@ -287,6 +329,14 @@ export class WcdbService {
|
||||
return this.callWorker('getMessageMeta', { dbPath, tableName, limit, offset })
|
||||
}
|
||||
|
||||
async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> {
|
||||
return this.callWorker('getMessageTableColumns', { dbPath, tableName })
|
||||
}
|
||||
|
||||
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('getMessageTableTimeRange', { dbPath, tableName })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取联系人详情
|
||||
*/
|
||||
@@ -301,6 +351,26 @@ export class WcdbService {
|
||||
return this.callWorker('getContactStatus', { usernames })
|
||||
}
|
||||
|
||||
async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> {
|
||||
return this.callWorker('getContactTypeCounts')
|
||||
}
|
||||
|
||||
async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> {
|
||||
return this.callWorker('getContactsCompact', { usernames })
|
||||
}
|
||||
|
||||
async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
||||
return this.callWorker('getContactAliasMap', { usernames })
|
||||
}
|
||||
|
||||
async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record<string, boolean>; error?: string }> {
|
||||
return this.callWorker('getContactFriendFlags', { usernames })
|
||||
}
|
||||
|
||||
async getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> {
|
||||
return this.callWorker('getChatRoomExtBuffer', { chatroomId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聚合统计数据
|
||||
*/
|
||||
@@ -372,7 +442,7 @@ export class WcdbService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 SQL 查询(支持参数化查询)
|
||||
* 执行 SQL 查询(仅主进程内部使用:fallback/diagnostic/低频兼容)
|
||||
*/
|
||||
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
return this.callWorker('execQuery', { kind, path, sql, params })
|
||||
@@ -417,6 +487,28 @@ export class WcdbService {
|
||||
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
|
||||
}
|
||||
|
||||
async getVoiceDataBatch(
|
||||
requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }>
|
||||
): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> {
|
||||
return this.callWorker('getVoiceDataBatch', { requests })
|
||||
}
|
||||
|
||||
async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('getMediaSchemaSummary', { dbPath })
|
||||
}
|
||||
|
||||
async getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
||||
return this.callWorker('getHeadImageBuffers', { usernames })
|
||||
}
|
||||
|
||||
async resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('resolveImageHardlink', { md5, accountDir })
|
||||
}
|
||||
|
||||
async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('resolveVideoHardlinkMd5', { md5, dbPath })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取朋友圈
|
||||
*/
|
||||
@@ -431,6 +523,14 @@ export class WcdbService {
|
||||
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
|
||||
}
|
||||
|
||||
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||||
return this.callWorker('getSnsUsernames')
|
||||
}
|
||||
|
||||
async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
|
||||
return this.callWorker('getSnsExportStats', { myWxid })
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装朋友圈删除拦截
|
||||
*/
|
||||
|
||||
@@ -59,6 +59,24 @@ if (parentPort) {
|
||||
case 'getMessageCounts':
|
||||
result = await core.getMessageCounts(payload.sessionIds)
|
||||
break
|
||||
case 'getSessionMessageCounts':
|
||||
result = await core.getSessionMessageCounts(payload.sessionIds)
|
||||
break
|
||||
case 'getSessionMessageTypeStats':
|
||||
result = await core.getSessionMessageTypeStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'getSessionMessageTypeStatsBatch':
|
||||
result = await core.getSessionMessageTypeStatsBatch(payload.sessionIds, payload.options)
|
||||
break
|
||||
case 'getSessionMessageDateCounts':
|
||||
result = await core.getSessionMessageDateCounts(payload.sessionId)
|
||||
break
|
||||
case 'getSessionMessageDateCountsBatch':
|
||||
result = await core.getSessionMessageDateCountsBatch(payload.sessionIds)
|
||||
break
|
||||
case 'getMessagesByType':
|
||||
result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset)
|
||||
break
|
||||
case 'getDisplayNames':
|
||||
result = await core.getDisplayNames(payload.usernames)
|
||||
break
|
||||
@@ -89,12 +107,33 @@ if (parentPort) {
|
||||
case 'getMessageMeta':
|
||||
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
|
||||
break
|
||||
case 'getMessageTableColumns':
|
||||
result = await core.getMessageTableColumns(payload.dbPath, payload.tableName)
|
||||
break
|
||||
case 'getMessageTableTimeRange':
|
||||
result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName)
|
||||
break
|
||||
case 'getContact':
|
||||
result = await core.getContact(payload.username)
|
||||
break
|
||||
case 'getContactStatus':
|
||||
result = await core.getContactStatus(payload.usernames)
|
||||
break
|
||||
case 'getContactTypeCounts':
|
||||
result = await core.getContactTypeCounts()
|
||||
break
|
||||
case 'getContactsCompact':
|
||||
result = await core.getContactsCompact(payload.usernames)
|
||||
break
|
||||
case 'getContactAliasMap':
|
||||
result = await core.getContactAliasMap(payload.usernames)
|
||||
break
|
||||
case 'getContactFriendFlags':
|
||||
result = await core.getContactFriendFlags(payload.usernames)
|
||||
break
|
||||
case 'getChatRoomExtBuffer':
|
||||
result = await core.getChatRoomExtBuffer(payload.chatroomId)
|
||||
break
|
||||
case 'getAggregateStats':
|
||||
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
@@ -149,12 +188,33 @@ if (parentPort) {
|
||||
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
||||
}
|
||||
break
|
||||
case 'getVoiceDataBatch':
|
||||
result = await core.getVoiceDataBatch(payload.requests)
|
||||
break
|
||||
case 'getMediaSchemaSummary':
|
||||
result = await core.getMediaSchemaSummary(payload.dbPath)
|
||||
break
|
||||
case 'getHeadImageBuffers':
|
||||
result = await core.getHeadImageBuffers(payload.usernames)
|
||||
break
|
||||
case 'resolveImageHardlink':
|
||||
result = await core.resolveImageHardlink(payload.md5, payload.accountDir)
|
||||
break
|
||||
case 'resolveVideoHardlinkMd5':
|
||||
result = await core.resolveVideoHardlinkMd5(payload.md5, payload.dbPath)
|
||||
break
|
||||
case 'getSnsTimeline':
|
||||
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
||||
break
|
||||
case 'getSnsAnnualStats':
|
||||
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'getSnsUsernames':
|
||||
result = await core.getSnsUsernames()
|
||||
break
|
||||
case 'getSnsExportStats':
|
||||
result = await core.getSnsExportStats(payload.myWxid)
|
||||
break
|
||||
case 'installSnsBlockDeleteTrigger':
|
||||
result = await core.installSnsBlockDeleteTrigger()
|
||||
break
|
||||
|
||||
Binary file not shown.
@@ -1443,7 +1443,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
|
||||
window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
{ includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true }
|
||||
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
||||
)
|
||||
])
|
||||
|
||||
@@ -1476,6 +1476,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
}
|
||||
|
||||
let refreshIncludeRelations = false
|
||||
let shouldRefreshStatsInBackground = false
|
||||
if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) {
|
||||
const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined
|
||||
const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
||||
@@ -1493,11 +1494,49 @@ function ChatPage(props: ChatPageProps) {
|
||||
}
|
||||
})
|
||||
}
|
||||
shouldRefreshStatsInBackground = !metric || Boolean(cacheMeta?.stale)
|
||||
} else {
|
||||
shouldRefreshStatsInBackground = true
|
||||
}
|
||||
finishBackgroundTask(taskId, 'completed', {
|
||||
detail: '聊天页会话详情统计完成',
|
||||
progressText: '已完成'
|
||||
})
|
||||
|
||||
if (shouldRefreshStatsInBackground) {
|
||||
setIsRefreshingDetailStats(true)
|
||||
void (async () => {
|
||||
try {
|
||||
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
{ includeRelations: false, forceRefresh: true }
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
if (freshResult.success && freshResult.data) {
|
||||
const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
||||
const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
||||
if (freshMetric) {
|
||||
applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, false)
|
||||
} else if (freshMeta) {
|
||||
setSessionDetail((prev) => {
|
||||
if (!prev || prev.wxid !== normalizedSessionId) return prev
|
||||
return {
|
||||
...prev,
|
||||
statsUpdatedAt: freshMeta.updatedAt,
|
||||
statsStale: freshMeta.stale
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('聊天页后台刷新会话统计失败:', error)
|
||||
} finally {
|
||||
if (requestSeq === detailRequestSeqRef.current) {
|
||||
setIsRefreshingDetailStats(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载会话详情补充统计失败:', e)
|
||||
finishBackgroundTask(taskId, 'failed', {
|
||||
@@ -5778,12 +5817,13 @@ function ChatPage(props: ChatPageProps) {
|
||||
// 下载完成后,触发页面刷新让组件重新尝试转写
|
||||
// 通过更新缓存触发组件重新检查
|
||||
if (pendingVoiceTranscriptRequest) {
|
||||
// 清除缓存中的请求标记,让组件可以重新尝试
|
||||
const cacheKey = `voice-transcript:${pendingVoiceTranscriptRequest.messageId}`
|
||||
// 不直接调用转写,而是让组件自己重试
|
||||
// 通过触发一个自定义事件来通知所有 MessageBubble 组件
|
||||
window.dispatchEvent(new CustomEvent('model-downloaded', {
|
||||
detail: { messageId: pendingVoiceTranscriptRequest.messageId }
|
||||
detail: {
|
||||
sessionId: pendingVoiceTranscriptRequest.sessionId,
|
||||
messageId: pendingVoiceTranscriptRequest.messageId
|
||||
}
|
||||
}))
|
||||
}
|
||||
setPendingVoiceTranscriptRequest(null)
|
||||
@@ -6298,6 +6338,20 @@ const voiceTranscriptCache = new Map<string, string>()
|
||||
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
|
||||
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
||||
|
||||
const buildVoiceCacheIdentity = (
|
||||
sessionId: string,
|
||||
message: Pick<Message, 'localId' | 'createTime' | 'serverId'>
|
||||
): string => {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
const localId = Math.max(0, Math.floor(Number(message?.localId || 0)))
|
||||
const createTime = Math.max(0, Math.floor(Number(message?.createTime || 0)))
|
||||
const serverIdRaw = String(message?.serverId ?? '').trim()
|
||||
const serverId = /^\d+$/.test(serverIdRaw)
|
||||
? serverIdRaw.replace(/^0+(?=\d)/, '')
|
||||
: String(Math.max(0, Math.floor(Number(serverIdRaw || 0))))
|
||||
return `${normalizedSessionId}:${localId}:${createTime}:${serverId || '0'}`
|
||||
}
|
||||
|
||||
// 引用消息中的动画表情组件
|
||||
function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
|
||||
const cacheKey = md5 || cdnUrl
|
||||
@@ -6372,11 +6426,12 @@ function MessageBubble({
|
||||
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
|
||||
() => imageDataUrlCache.get(imageCacheKey)
|
||||
)
|
||||
const voiceCacheKey = `voice:${message.localId}`
|
||||
const voiceIdentityKey = buildVoiceCacheIdentity(session.username, message)
|
||||
const voiceCacheKey = `voice:${voiceIdentityKey}`
|
||||
const [voiceDataUrl, setVoiceDataUrl] = useState<string | undefined>(
|
||||
() => voiceDataUrlCache.get(voiceCacheKey)
|
||||
)
|
||||
const voiceTranscriptCacheKey = `voice-transcript:${message.localId}`
|
||||
const voiceTranscriptCacheKey = `voice-transcript:${voiceIdentityKey}`
|
||||
const [voiceTranscript, setVoiceTranscript] = useState<string | undefined>(
|
||||
() => voiceTranscriptCache.get(voiceTranscriptCacheKey)
|
||||
)
|
||||
@@ -6938,14 +6993,16 @@ function MessageBubble({
|
||||
// 监听流式转写结果
|
||||
useEffect(() => {
|
||||
if (!isVoice) return
|
||||
const removeListener = window.electronAPI.chat.onVoiceTranscriptPartial?.((payload: { msgId: string; text: string }) => {
|
||||
if (payload.msgId === String(message.localId)) {
|
||||
setVoiceTranscript(payload.text)
|
||||
voiceTranscriptCache.set(voiceTranscriptCacheKey, payload.text)
|
||||
}
|
||||
const removeListener = window.electronAPI.chat.onVoiceTranscriptPartial?.((payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => {
|
||||
const sameSession = !payload.sessionId || payload.sessionId === session.username
|
||||
const sameMsgId = payload.msgId === String(message.localId)
|
||||
const sameCreateTime = payload.createTime == null || Number(payload.createTime) === Number(message.createTime || 0)
|
||||
if (!sameSession || !sameMsgId || !sameCreateTime) return
|
||||
setVoiceTranscript(payload.text)
|
||||
voiceTranscriptCache.set(voiceTranscriptCacheKey, payload.text)
|
||||
})
|
||||
return () => removeListener?.()
|
||||
}, [isVoice, message.localId, voiceTranscriptCacheKey])
|
||||
}, [isVoice, message.createTime, message.localId, session.username, voiceTranscriptCacheKey])
|
||||
|
||||
const requestVoiceTranscript = useCallback(async () => {
|
||||
if (voiceTranscriptLoading || voiceTranscriptRequestedRef.current) return
|
||||
@@ -6999,14 +7056,17 @@ function MessageBubble({
|
||||
} finally {
|
||||
setVoiceTranscriptLoading(false)
|
||||
}
|
||||
}, [message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading, onRequireModelDownload])
|
||||
}, [message.createTime, message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading, onRequireModelDownload])
|
||||
|
||||
// 监听模型下载完成事件
|
||||
useEffect(() => {
|
||||
if (!isVoice) return
|
||||
|
||||
const handleModelDownloaded = (event: CustomEvent) => {
|
||||
if (event.detail?.messageId === String(message.localId)) {
|
||||
if (
|
||||
event.detail?.messageId === String(message.localId) &&
|
||||
(!event.detail?.sessionId || event.detail?.sessionId === session.username)
|
||||
) {
|
||||
// 重置状态,允许重新尝试转写
|
||||
voiceTranscriptRequestedRef.current = false
|
||||
setVoiceTranscriptError(false)
|
||||
@@ -7019,7 +7079,7 @@ function MessageBubble({
|
||||
return () => {
|
||||
window.removeEventListener('model-downloaded', handleModelDownloaded as EventListener)
|
||||
}
|
||||
}, [isVoice, message.localId, requestVoiceTranscript])
|
||||
}, [isVoice, message.localId, requestVoiceTranscript, session.username])
|
||||
|
||||
// 视频懒加载
|
||||
const videoAutoLoadTriggered = useRef(false)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type UIEvent, type WheelEvent } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
subscribeBackgroundTasks
|
||||
} from '../services/backgroundTaskMonitor'
|
||||
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
||||
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
||||
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
|
||||
@@ -104,6 +105,10 @@ interface TaskProgress {
|
||||
phaseLabel: string
|
||||
phaseProgress: number
|
||||
phaseTotal: number
|
||||
exportedMessages: number
|
||||
estimatedTotalMessages: number
|
||||
collectedMessages: number
|
||||
writtenFiles: number
|
||||
}
|
||||
|
||||
type TaskPerfStage = 'collect' | 'build' | 'write' | 'other'
|
||||
@@ -166,7 +171,7 @@ interface ExportDialogState {
|
||||
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
|
||||
const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10
|
||||
const SESSION_MEDIA_METRIC_BATCH_SIZE = 12
|
||||
const SESSION_MEDIA_METRIC_BATCH_SIZE = 8
|
||||
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48
|
||||
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS = 120
|
||||
const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200
|
||||
@@ -254,7 +259,11 @@ const createEmptyProgress = (): TaskProgress => ({
|
||||
phase: '',
|
||||
phaseLabel: '',
|
||||
phaseProgress: 0,
|
||||
phaseTotal: 0
|
||||
phaseTotal: 0,
|
||||
exportedMessages: 0,
|
||||
estimatedTotalMessages: 0,
|
||||
collectedMessages: 0,
|
||||
writtenFiles: 0
|
||||
})
|
||||
|
||||
const createEmptyTaskPerformance = (): TaskPerformance => ({
|
||||
@@ -1280,6 +1289,14 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
completedSessionTotal,
|
||||
(task.settledSessionIds || []).length
|
||||
)
|
||||
const exportedMessages = Math.max(0, Math.floor(task.progress.exportedMessages || 0))
|
||||
const estimatedTotalMessages = Math.max(0, Math.floor(task.progress.estimatedTotalMessages || 0))
|
||||
const messageProgressLabel = estimatedTotalMessages > 0
|
||||
? `已导出 ${Math.min(exportedMessages, estimatedTotalMessages)}/${estimatedTotalMessages} 条`
|
||||
: `已导出 ${exportedMessages} 条`
|
||||
const sessionProgressLabel = completedSessionTotal > 0
|
||||
? `会话 ${completedSessionCount}/${completedSessionTotal}`
|
||||
: '会话处理中'
|
||||
const currentSessionRatio = task.progress.phaseTotal > 0
|
||||
? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal))
|
||||
: null
|
||||
@@ -1300,9 +1317,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
/>
|
||||
</div>
|
||||
<div className="task-progress-text">
|
||||
{completedSessionTotal > 0
|
||||
? `已完成 ${completedSessionCount} / ${completedSessionTotal}`
|
||||
: '处理中'}
|
||||
{`${sessionProgressLabel} · ${messageProgressLabel}`}
|
||||
{task.status === 'running' && currentSessionRatio !== null
|
||||
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)`
|
||||
: ''}
|
||||
@@ -1387,6 +1402,8 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
})
|
||||
|
||||
function ExportPage() {
|
||||
const navigate = useNavigate()
|
||||
const { setCurrentSession } = useChatStore()
|
||||
const location = useLocation()
|
||||
const isExportRoute = location.pathname === '/export'
|
||||
|
||||
@@ -2787,6 +2804,7 @@ function ExportPage() {
|
||||
}, [])
|
||||
|
||||
const enqueueSessionMutualFriendsRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => {
|
||||
if (activeTaskCountRef.current > 0) return
|
||||
const front = options?.front === true
|
||||
const incoming: string[] = []
|
||||
for (const sessionIdRaw of sessionIds) {
|
||||
@@ -2976,6 +2994,7 @@ function ExportPage() {
|
||||
}, [])
|
||||
|
||||
const enqueueSessionMediaMetricRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => {
|
||||
if (activeTaskCountRef.current > 0) return
|
||||
const front = options?.front === true
|
||||
const incoming: string[] = []
|
||||
for (const sessionIdRaw of sessionIds) {
|
||||
@@ -3025,13 +3044,27 @@ function ExportPage() {
|
||||
const runSessionMediaMetricWorker = useCallback(async (runId: number) => {
|
||||
if (sessionMediaMetricWorkerRunningRef.current) return
|
||||
sessionMediaMetricWorkerRunningRef.current = true
|
||||
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number, stage: string): Promise<T> => {
|
||||
let timer: number | null = null
|
||||
try {
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = window.setTimeout(() => {
|
||||
reject(new Error(`会话多媒体统计超时(${stage}, ${timeoutMs}ms)`))
|
||||
}, timeoutMs)
|
||||
})
|
||||
return await Promise.race([promise, timeoutPromise])
|
||||
} finally {
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
while (runId === sessionMediaMetricRunIdRef.current) {
|
||||
if (isLoadingSessionCountsRef.current || detailStatsPriorityRef.current) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 80))
|
||||
if (activeTaskCountRef.current > 0) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 150))
|
||||
continue
|
||||
}
|
||||
|
||||
if (sessionMediaMetricQueueRef.current.length === 0) break
|
||||
|
||||
const batchSessionIds: string[] = []
|
||||
@@ -3050,9 +3083,13 @@ function ExportPage() {
|
||||
patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'loading')
|
||||
|
||||
try {
|
||||
const cacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
batchSessionIds,
|
||||
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
||||
const cacheResult = await withTimeout(
|
||||
window.electronAPI.chat.getExportSessionStats(
|
||||
batchSessionIds,
|
||||
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
||||
),
|
||||
12000,
|
||||
'cacheOnly'
|
||||
)
|
||||
if (runId !== sessionMediaMetricRunIdRef.current) return
|
||||
if (cacheResult.success && cacheResult.data) {
|
||||
@@ -3061,15 +3098,26 @@ function ExportPage() {
|
||||
|
||||
const missingSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId))
|
||||
if (missingSessionIds.length > 0) {
|
||||
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
missingSessionIds,
|
||||
{ includeRelations: false, allowStaleCache: true }
|
||||
const freshResult = await withTimeout(
|
||||
window.electronAPI.chat.getExportSessionStats(
|
||||
missingSessionIds,
|
||||
{ includeRelations: false, allowStaleCache: true }
|
||||
),
|
||||
45000,
|
||||
'fresh'
|
||||
)
|
||||
if (runId !== sessionMediaMetricRunIdRef.current) return
|
||||
if (freshResult.success && freshResult.data) {
|
||||
applySessionMediaMetricsFromStats(freshResult.data as Record<string, SessionExportMetric>)
|
||||
}
|
||||
}
|
||||
|
||||
const unresolvedSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId))
|
||||
if (unresolvedSessionIds.length > 0) {
|
||||
patchSessionLoadTraceStage(unresolvedSessionIds, 'mediaMetrics', 'failed', {
|
||||
error: '统计结果缺失,已跳过当前批次'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导出页加载会话媒体统计失败:', error)
|
||||
patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'failed', {
|
||||
@@ -3100,12 +3148,11 @@ function ExportPage() {
|
||||
}, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage])
|
||||
|
||||
const scheduleSessionMediaMetricWorker = useCallback(() => {
|
||||
if (!isSessionCountStageReady) return
|
||||
if (isLoadingSessionCountsRef.current) return
|
||||
if (activeTaskCountRef.current > 0) return
|
||||
if (sessionMediaMetricWorkerRunningRef.current) return
|
||||
const runId = sessionMediaMetricRunIdRef.current
|
||||
void runSessionMediaMetricWorker(runId)
|
||||
}, [isSessionCountStageReady, runSessionMediaMetricWorker])
|
||||
}, [runSessionMediaMetricWorker])
|
||||
|
||||
const loadSessionMutualFriendsMetric = useCallback(async (sessionId: string): Promise<SessionMutualFriendsMetric> => {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
@@ -3150,6 +3197,10 @@ function ExportPage() {
|
||||
sessionMutualFriendsWorkerRunningRef.current = true
|
||||
try {
|
||||
while (runId === sessionMutualFriendsRunIdRef.current) {
|
||||
if (activeTaskCountRef.current > 0) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 150))
|
||||
continue
|
||||
}
|
||||
if (hasPendingMetricLoads()) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 120))
|
||||
continue
|
||||
@@ -3196,6 +3247,7 @@ function ExportPage() {
|
||||
])
|
||||
|
||||
const scheduleSessionMutualFriendsWorker = useCallback(() => {
|
||||
if (activeTaskCountRef.current > 0) return
|
||||
if (!isSessionCountStageReady) return
|
||||
if (hasPendingMetricLoads()) return
|
||||
if (sessionMutualFriendsWorkerRunningRef.current) return
|
||||
@@ -3291,9 +3343,6 @@ function ExportPage() {
|
||||
|
||||
setIsLoadingSessionCounts(true)
|
||||
try {
|
||||
if (detailStatsPriorityRef.current) {
|
||||
return { ...accumulatedCounts }
|
||||
}
|
||||
if (prioritizedSessionIds.length > 0) {
|
||||
patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading')
|
||||
const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds)
|
||||
@@ -3311,9 +3360,6 @@ function ExportPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (detailStatsPriorityRef.current) {
|
||||
return { ...accumulatedCounts }
|
||||
}
|
||||
if (remainingSessionIds.length > 0) {
|
||||
patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading')
|
||||
const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds)
|
||||
@@ -4135,6 +4181,126 @@ function ExportPage() {
|
||||
|
||||
progressUnsubscribeRef.current?.()
|
||||
const settledSessionIdsFromProgress = new Set<string>()
|
||||
const sessionMessageProgress = new Map<string, { exported: number; total: number; knownTotal: boolean }>()
|
||||
let queuedProgressPayload: ExportProgress | null = null
|
||||
let queuedProgressRaf: number | null = null
|
||||
let queuedProgressTimer: number | null = null
|
||||
|
||||
const clearQueuedProgress = () => {
|
||||
if (queuedProgressRaf !== null) {
|
||||
window.cancelAnimationFrame(queuedProgressRaf)
|
||||
queuedProgressRaf = null
|
||||
}
|
||||
if (queuedProgressTimer !== null) {
|
||||
window.clearTimeout(queuedProgressTimer)
|
||||
queuedProgressTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const updateSessionMessageProgress = (payload: ExportProgress) => {
|
||||
const sessionId = String(payload.currentSessionId || '').trim()
|
||||
if (!sessionId) return
|
||||
const prev = sessionMessageProgress.get(sessionId) || { exported: 0, total: 0, knownTotal: false }
|
||||
const nextExported = Number.isFinite(payload.exportedMessages)
|
||||
? Math.max(prev.exported, Math.max(0, Math.floor(Number(payload.exportedMessages || 0))))
|
||||
: prev.exported
|
||||
const hasEstimatedTotal = Number.isFinite(payload.estimatedTotalMessages)
|
||||
const nextTotal = hasEstimatedTotal
|
||||
? Math.max(prev.total, Math.max(0, Math.floor(Number(payload.estimatedTotalMessages || 0))))
|
||||
: prev.total
|
||||
const knownTotal = prev.knownTotal || hasEstimatedTotal
|
||||
sessionMessageProgress.set(sessionId, {
|
||||
exported: nextExported,
|
||||
total: nextTotal,
|
||||
knownTotal
|
||||
})
|
||||
}
|
||||
|
||||
const resolveAggregatedMessageProgress = () => {
|
||||
let exported = 0
|
||||
let estimated = 0
|
||||
let allKnown = true
|
||||
for (const sessionId of next.payload.sessionIds) {
|
||||
const entry = sessionMessageProgress.get(sessionId)
|
||||
if (!entry) {
|
||||
allKnown = false
|
||||
continue
|
||||
}
|
||||
exported += entry.exported
|
||||
estimated += entry.total
|
||||
if (!entry.knownTotal) {
|
||||
allKnown = false
|
||||
}
|
||||
}
|
||||
return {
|
||||
exported: Math.max(0, Math.floor(exported)),
|
||||
estimated: allKnown ? Math.max(0, Math.floor(estimated)) : 0
|
||||
}
|
||||
}
|
||||
|
||||
const flushQueuedProgress = () => {
|
||||
if (!queuedProgressPayload) return
|
||||
const payload = queuedProgressPayload
|
||||
queuedProgressPayload = null
|
||||
const now = Date.now()
|
||||
const currentSessionId = String(payload.currentSessionId || '').trim()
|
||||
updateTask(next.id, task => {
|
||||
if (task.status !== 'running') return task
|
||||
const performance = applyProgressToTaskPerformance(task, payload, now)
|
||||
const settledSessionIds = task.settledSessionIds || []
|
||||
const nextSettledSessionIds = (
|
||||
payload.phase === 'complete' &&
|
||||
currentSessionId &&
|
||||
!settledSessionIds.includes(currentSessionId)
|
||||
)
|
||||
? [...settledSessionIds, currentSessionId]
|
||||
: settledSessionIds
|
||||
const aggregatedMessageProgress = resolveAggregatedMessageProgress()
|
||||
const collectedMessages = Number.isFinite(payload.collectedMessages)
|
||||
? Math.max(0, Math.floor(Number(payload.collectedMessages || 0)))
|
||||
: task.progress.collectedMessages
|
||||
const writtenFiles = Number.isFinite(payload.writtenFiles)
|
||||
? Math.max(task.progress.writtenFiles, Math.max(0, Math.floor(Number(payload.writtenFiles || 0))))
|
||||
: task.progress.writtenFiles
|
||||
return {
|
||||
...task,
|
||||
progress: {
|
||||
current: payload.current,
|
||||
total: payload.total,
|
||||
currentName: payload.currentSession,
|
||||
phase: payload.phase,
|
||||
phaseLabel: payload.phaseLabel || '',
|
||||
phaseProgress: payload.phaseProgress || 0,
|
||||
phaseTotal: payload.phaseTotal || 0,
|
||||
exportedMessages: Math.max(task.progress.exportedMessages, aggregatedMessageProgress.exported),
|
||||
estimatedTotalMessages: aggregatedMessageProgress.estimated > 0
|
||||
? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated)
|
||||
: (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0),
|
||||
collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages),
|
||||
writtenFiles
|
||||
},
|
||||
settledSessionIds: nextSettledSessionIds,
|
||||
performance
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const queueProgressUpdate = (payload: ExportProgress) => {
|
||||
queuedProgressPayload = payload
|
||||
if (payload.phase === 'complete') {
|
||||
clearQueuedProgress()
|
||||
flushQueuedProgress()
|
||||
return
|
||||
}
|
||||
if (queuedProgressRaf !== null || queuedProgressTimer !== null) return
|
||||
queuedProgressRaf = window.requestAnimationFrame(() => {
|
||||
queuedProgressRaf = null
|
||||
queuedProgressTimer = window.setTimeout(() => {
|
||||
queuedProgressTimer = null
|
||||
flushQueuedProgress()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
if (next.payload.scope === 'sns') {
|
||||
progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => {
|
||||
updateTask(next.id, task => {
|
||||
@@ -4148,7 +4314,11 @@ function ExportPage() {
|
||||
phase: 'exporting',
|
||||
phaseLabel: payload.status || '',
|
||||
phaseProgress: payload.total > 0 ? payload.current : 0,
|
||||
phaseTotal: payload.total || 0
|
||||
phaseTotal: payload.total || 0,
|
||||
exportedMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.current || 0)) : task.progress.exportedMessages,
|
||||
estimatedTotalMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.total || 0)) : task.progress.estimatedTotalMessages,
|
||||
collectedMessages: task.progress.collectedMessages,
|
||||
writtenFiles: task.progress.writtenFiles
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -4157,6 +4327,7 @@ function ExportPage() {
|
||||
progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => {
|
||||
const now = Date.now()
|
||||
const currentSessionId = String(payload.currentSessionId || '').trim()
|
||||
updateSessionMessageProgress(payload)
|
||||
if (payload.phase === 'complete' && currentSessionId && !settledSessionIdsFromProgress.has(currentSessionId)) {
|
||||
settledSessionIdsFromProgress.add(currentSessionId)
|
||||
const phaseLabel = String(payload.phaseLabel || '')
|
||||
@@ -4172,33 +4343,7 @@ function ExportPage() {
|
||||
markSessionExportRecords([currentSessionId], taskExportContentLabel, next.payload.outputDir, now)
|
||||
}
|
||||
}
|
||||
|
||||
updateTask(next.id, task => {
|
||||
if (task.status !== 'running') return task
|
||||
const performance = applyProgressToTaskPerformance(task, payload, now)
|
||||
const settledSessionIds = task.settledSessionIds || []
|
||||
const nextSettledSessionIds = (
|
||||
payload.phase === 'complete' &&
|
||||
currentSessionId &&
|
||||
!settledSessionIds.includes(currentSessionId)
|
||||
)
|
||||
? [...settledSessionIds, currentSessionId]
|
||||
: settledSessionIds
|
||||
return {
|
||||
...task,
|
||||
progress: {
|
||||
current: payload.current,
|
||||
total: payload.total,
|
||||
currentName: payload.currentSession,
|
||||
phase: payload.phase,
|
||||
phaseLabel: payload.phaseLabel || '',
|
||||
phaseProgress: payload.phaseProgress || 0,
|
||||
phaseTotal: payload.phaseTotal || 0
|
||||
},
|
||||
settledSessionIds: nextSettledSessionIds,
|
||||
performance
|
||||
}
|
||||
})
|
||||
queueProgressUpdate(payload)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4310,6 +4455,8 @@ function ExportPage() {
|
||||
performance: finalizeTaskPerformance(task, doneAt)
|
||||
}))
|
||||
} finally {
|
||||
clearQueuedProgress()
|
||||
flushQueuedProgress()
|
||||
progressUnsubscribeRef.current?.()
|
||||
progressUnsubscribeRef.current = null
|
||||
runningTaskIdRef.current = null
|
||||
@@ -4715,10 +4862,22 @@ function ExportPage() {
|
||||
return new Date(value).toLocaleTimeString('zh-CN', { hour12: false })
|
||||
}, [])
|
||||
|
||||
const getLoadDetailStatusLabel = useCallback((loaded: number, total: number, hasStarted: boolean): string => {
|
||||
const getLoadDetailStatusLabel = useCallback((
|
||||
loaded: number,
|
||||
total: number,
|
||||
hasStarted: boolean,
|
||||
hasLoading: boolean,
|
||||
failedCount: number
|
||||
): string => {
|
||||
if (total <= 0) return '待加载'
|
||||
if (loaded >= total) return `已完成 ${total}`
|
||||
if (hasStarted) return `加载中 ${loaded}/${total}`
|
||||
const terminalCount = loaded + failedCount
|
||||
if (terminalCount >= total) {
|
||||
if (failedCount > 0) return `已完成 ${loaded}/${total}(失败 ${failedCount})`
|
||||
return `已完成 ${total}`
|
||||
}
|
||||
if (hasLoading) return `加载中 ${loaded}/${total}`
|
||||
if (hasStarted && failedCount > 0) return `已完成 ${loaded}/${total}(失败 ${failedCount})`
|
||||
if (hasStarted) return `已完成 ${loaded}/${total}`
|
||||
return '待加载'
|
||||
}, [])
|
||||
|
||||
@@ -4728,7 +4887,9 @@ function ExportPage() {
|
||||
): SessionLoadStageSummary => {
|
||||
const total = sessionIds.length
|
||||
let loaded = 0
|
||||
let failedCount = 0
|
||||
let hasStarted = false
|
||||
let hasLoading = false
|
||||
let earliestStart: number | undefined
|
||||
let latestFinish: number | undefined
|
||||
let latestProgressAt: number | undefined
|
||||
@@ -4742,6 +4903,12 @@ function ExportPage() {
|
||||
: Math.max(latestProgressAt, stage.finishedAt)
|
||||
}
|
||||
}
|
||||
if (stage?.status === 'failed') {
|
||||
failedCount += 1
|
||||
}
|
||||
if (stage?.status === 'loading') {
|
||||
hasLoading = true
|
||||
}
|
||||
if (stage?.status === 'loading' || stage?.status === 'failed' || typeof stage?.startedAt === 'number') {
|
||||
hasStarted = true
|
||||
}
|
||||
@@ -4759,9 +4926,9 @@ function ExportPage() {
|
||||
return {
|
||||
total,
|
||||
loaded,
|
||||
statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted),
|
||||
statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted, hasLoading, failedCount),
|
||||
startedAt: earliestStart,
|
||||
finishedAt: loaded >= total ? latestFinish : undefined,
|
||||
finishedAt: (loaded + failedCount) >= total ? latestFinish : undefined,
|
||||
latestProgressAt
|
||||
}
|
||||
}, [getLoadDetailStatusLabel, sessionLoadTraceMap])
|
||||
@@ -4907,7 +5074,6 @@ function ExportPage() {
|
||||
const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex
|
||||
sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex }
|
||||
sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex }
|
||||
if (isLoadingSessionCountsRef.current || !isSessionCountStageReady) return
|
||||
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
|
||||
if (visibleTargets.length === 0) return
|
||||
enqueueSessionMediaMetricRequests(visibleTargets, { front: true })
|
||||
@@ -4923,13 +5089,13 @@ function ExportPage() {
|
||||
enqueueSessionMediaMetricRequests,
|
||||
enqueueSessionMutualFriendsRequests,
|
||||
filteredContacts,
|
||||
isSessionCountStageReady,
|
||||
scheduleSessionMediaMetricWorker,
|
||||
scheduleSessionMutualFriendsWorker
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSessionCountStageReady || filteredContacts.length === 0) return
|
||||
if (activeTaskCount > 0) return
|
||||
if (filteredContacts.length === 0) return
|
||||
const runId = sessionMediaMetricRunIdRef.current
|
||||
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
|
||||
if (visibleTargets.length > 0) {
|
||||
@@ -4946,7 +5112,6 @@ function ExportPage() {
|
||||
let cursor = 0
|
||||
const feedNext = () => {
|
||||
if (runId !== sessionMediaMetricRunIdRef.current) return
|
||||
if (isLoadingSessionCountsRef.current) return
|
||||
const batchIds: string[] = []
|
||||
while (cursor < filteredContacts.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) {
|
||||
const contact = filteredContacts[cursor]
|
||||
@@ -4976,15 +5141,61 @@ function ExportPage() {
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activeTaskCount,
|
||||
collectVisibleSessionMetricTargets,
|
||||
enqueueSessionMediaMetricRequests,
|
||||
filteredContacts,
|
||||
isSessionCountStageReady,
|
||||
scheduleSessionMediaMetricWorker,
|
||||
sessionRowByUsername
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTaskCount > 0) return
|
||||
const runId = sessionMediaMetricRunIdRef.current
|
||||
const allTargets = [
|
||||
...(loadDetailTargetsByTab.private || []),
|
||||
...(loadDetailTargetsByTab.group || []),
|
||||
...(loadDetailTargetsByTab.former_friend || [])
|
||||
]
|
||||
if (allTargets.length === 0) return
|
||||
|
||||
let timer: number | null = null
|
||||
let cursor = 0
|
||||
const feedNext = () => {
|
||||
if (runId !== sessionMediaMetricRunIdRef.current) return
|
||||
const batchIds: string[] = []
|
||||
while (cursor < allTargets.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) {
|
||||
const sessionId = allTargets[cursor]
|
||||
cursor += 1
|
||||
if (!sessionId) continue
|
||||
batchIds.push(sessionId)
|
||||
}
|
||||
if (batchIds.length > 0) {
|
||||
enqueueSessionMediaMetricRequests(batchIds)
|
||||
scheduleSessionMediaMetricWorker()
|
||||
}
|
||||
if (cursor < allTargets.length) {
|
||||
timer = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
feedNext()
|
||||
return () => {
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activeTaskCount,
|
||||
enqueueSessionMediaMetricRequests,
|
||||
loadDetailTargetsByTab.former_friend,
|
||||
loadDetailTargetsByTab.group,
|
||||
loadDetailTargetsByTab.private,
|
||||
scheduleSessionMediaMetricWorker
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTaskCount > 0) return
|
||||
if (!isSessionCountStageReady || filteredContacts.length === 0) return
|
||||
const runId = sessionMutualFriendsRunIdRef.current
|
||||
const visibleTargets = collectVisibleSessionMutualFriendsTargets(filteredContacts)
|
||||
@@ -5031,6 +5242,7 @@ function ExportPage() {
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activeTaskCount,
|
||||
collectVisibleSessionMutualFriendsTargets,
|
||||
enqueueSessionMutualFriendsRequests,
|
||||
filteredContacts,
|
||||
@@ -5348,16 +5560,16 @@ function ExportPage() {
|
||||
|
||||
const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0
|
||||
const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS
|
||||
const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale))
|
||||
const shouldRunBackgroundRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale))
|
||||
|
||||
if (shouldRunPreciseRefresh) {
|
||||
if (shouldRunBackgroundRefresh) {
|
||||
setIsRefreshingSessionDetailStats(true)
|
||||
void (async () => {
|
||||
try {
|
||||
// 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示。
|
||||
// 后台补齐非关系统计,不走精确特型扫描,避免阻塞列表统计队列。
|
||||
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
{ includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true }
|
||||
{ includeRelations: false, forceRefresh: true }
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
if (freshResult.success && freshResult.data) {
|
||||
@@ -6083,14 +6295,10 @@ function ExportPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="row-open-chat-link"
|
||||
title="在新窗口打开该会话"
|
||||
title="切换到聊天页查看该会话"
|
||||
onClick={() => {
|
||||
void window.electronAPI.window.openSessionChatWindow(contact.username, {
|
||||
source: 'export',
|
||||
initialDisplayName: contact.displayName || contact.username,
|
||||
initialAvatarUrl: contact.avatarUrl,
|
||||
initialContactType: contact.type
|
||||
})
|
||||
setCurrentSession(contact.username)
|
||||
navigate('/chat')
|
||||
}}
|
||||
>
|
||||
{openChatLabel}
|
||||
@@ -6198,6 +6406,7 @@ function ExportPage() {
|
||||
)
|
||||
}, [
|
||||
lastExportBySession,
|
||||
navigate,
|
||||
nowTick,
|
||||
openContactSnsTimeline,
|
||||
openSessionDetail,
|
||||
@@ -6219,6 +6428,7 @@ function ExportPage() {
|
||||
shouldShowSnsColumn,
|
||||
snsUserPostCounts,
|
||||
snsUserPostCountsStatus,
|
||||
setCurrentSession,
|
||||
toggleSelectSession
|
||||
])
|
||||
const handleContactsListWheelCapture = useCallback((event: WheelEvent<HTMLDivElement>) => {
|
||||
|
||||
7
src/types/electron.d.ts
vendored
7
src/types/electron.d.ts
vendored
@@ -319,8 +319,7 @@ export interface ElectronAPI {
|
||||
getMessageDateCounts: (sessionId: string) => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }>
|
||||
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
|
||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
||||
execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }>
|
||||
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => () => void
|
||||
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
|
||||
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
|
||||
}
|
||||
@@ -862,6 +861,10 @@ export interface ExportProgress {
|
||||
phaseProgress?: number
|
||||
phaseTotal?: number
|
||||
phaseLabel?: string
|
||||
collectedMessages?: number
|
||||
exportedMessages?: number
|
||||
estimatedTotalMessages?: number
|
||||
writtenFiles?: number
|
||||
}
|
||||
|
||||
export interface WxidInfo {
|
||||
|
||||
@@ -34,7 +34,8 @@ export default defineConfig({
|
||||
'whisper-node',
|
||||
'shelljs',
|
||||
'exceljs',
|
||||
'node-llama-cpp'
|
||||
'node-llama-cpp',
|
||||
'sudo-prompt'
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -126,6 +127,26 @@ export default defineConfig({
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
entry: 'electron/exportWorker.ts',
|
||||
vite: {
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'better-sqlite3',
|
||||
'koffi',
|
||||
'fsevents',
|
||||
'exceljs'
|
||||
],
|
||||
output: {
|
||||
entryFileNames: 'exportWorker.js',
|
||||
inlineDynamicImports: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
entry: 'electron/preload.ts',
|
||||
onstart(options) {
|
||||
|
||||
Reference in New Issue
Block a user