重构与优化,旨在解决遗留的性能问题并优化用户体验,本次提交遗留了较多的待测功能
This commit is contained in:
1 parent
4c32bf5934
commit
48c4197b16
22 files changed
+2726
-1598
No files matched your search
@@ -62,6 +62,7 @@ server/
|
||||
chatlab-format.md
|
||||
*.bak
|
||||
AGENTS.md
|
||||
AGENT.md
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
.agents/
|
||||
|
||||
@@ -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/
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
+79
-8
@@ -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) => {
|
||||
|
||||
+16
-5
@@ -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 }
|
||||
|
||||
+394
-906
@@ -1,5 +1,5 @@
|
||||
import { join, dirname, basename, extname } from 'path'
|
||||
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch } from 'fs'
|
||||
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs'
|
||||
import * as https from 'https'
|
||||
@@ -233,12 +233,18 @@ class ChatService {
|
||||
name2IdTable?: string
|
||||
}>()
|
||||
// 缓存会话表信息,避免每次查询
|
||||
private sessionTablesCache = new Map<string, Array<{ tableName: string; dbPath: string }>>()
|
||||
private sessionTablesCache = new Map<string, { tables: Array<{ tableName: string; dbPath: string }>; updatedAt: number }>()
|
||||
private messageTableColumnsCache = new Map<string, { columns: Set<string>; updatedAt: number }>()
|
||||
private messageName2IdTableCache = new Map<string, string | null>()
|
||||
private messageSenderIdCache = new Map<string, string | null>()
|
||||
private readonly sessionTablesCacheTtl = 300000 // 5分钟
|
||||
private readonly messageTableColumnsCacheTtlMs = 30 * 60 * 1000
|
||||
private messageDbCountSnapshotCache: {
|
||||
dbPaths: string[]
|
||||
dbSignature: string
|
||||
updatedAt: number
|
||||
} | null = null
|
||||
private readonly messageDbCountSnapshotCacheTtlMs = 8000
|
||||
private sessionMessageCountCache = new Map<string, { count: number; updatedAt: number }>()
|
||||
private sessionMessageCountHintCache = new Map<string, number>()
|
||||
private sessionMessageCountBatchCache: {
|
||||
@@ -608,11 +614,10 @@ class ChatService {
|
||||
|
||||
const now = Date.now()
|
||||
for (const username of usernames) {
|
||||
const state = result.map[username]
|
||||
if (!state) continue
|
||||
const state = result.map[username] || { isFolded: false, isMuted: false }
|
||||
this.sessionStatusCache.set(username, {
|
||||
isFolded: state.isFolded,
|
||||
isMuted: state.isMuted,
|
||||
isFolded: Boolean(state.isFolded),
|
||||
isMuted: Boolean(state.isMuted),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
@@ -756,30 +761,6 @@ class ChatService {
|
||||
if (usernames.length === 0) return result
|
||||
|
||||
try {
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
const wxid = this.configService.get('myWxid')
|
||||
if (!dbPath || !wxid) return result
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) return result
|
||||
|
||||
// head_image.db 可能在不同位置
|
||||
const headImageDbPaths = [
|
||||
join(accountDir, 'db_storage', 'head_image', 'head_image.db'),
|
||||
join(accountDir, 'db_storage', 'head_image.db'),
|
||||
join(accountDir, 'head_image.db')
|
||||
]
|
||||
|
||||
let headImageDbPath: string | null = null
|
||||
for (const path of headImageDbPaths) {
|
||||
if (existsSync(path)) {
|
||||
headImageDbPath = path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!headImageDbPath) return result
|
||||
|
||||
const normalizedUsernames = Array.from(
|
||||
new Set(
|
||||
usernames
|
||||
@@ -793,38 +774,20 @@ class ChatService {
|
||||
for (let i = 0; i < normalizedUsernames.length; i += batchSize) {
|
||||
const batch = normalizedUsernames.slice(i, i + batchSize)
|
||||
if (batch.length === 0) continue
|
||||
const usernamesExpr = batch.map((name) => `'${this.escapeSqlString(name)}'`).join(',')
|
||||
const queryResult = await wcdbService.execQuery(
|
||||
'media',
|
||||
headImageDbPath,
|
||||
`SELECT username, image_buffer FROM head_image WHERE username IN (${usernamesExpr})`
|
||||
)
|
||||
|
||||
if (!queryResult.success || !queryResult.rows || queryResult.rows.length === 0) {
|
||||
continue
|
||||
}
|
||||
const queryResult = await wcdbService.getHeadImageBuffers(batch)
|
||||
if (!queryResult.success || !queryResult.map) continue
|
||||
|
||||
for (const row of queryResult.rows as any[]) {
|
||||
const username = String(row?.username || '').trim()
|
||||
if (!username || !row?.image_buffer) continue
|
||||
|
||||
let base64Data: string | null = null
|
||||
if (typeof row.image_buffer === 'string') {
|
||||
// WCDB 返回的 BLOB 可能是十六进制字符串,需要转换为 base64
|
||||
if (row.image_buffer.toLowerCase().startsWith('ffd8')) {
|
||||
const buffer = Buffer.from(row.image_buffer, 'hex')
|
||||
base64Data = buffer.toString('base64')
|
||||
} else {
|
||||
base64Data = row.image_buffer
|
||||
for (const [username, rawHex] of Object.entries(queryResult.map)) {
|
||||
const hex = String(rawHex || '').trim()
|
||||
if (!username || !hex) continue
|
||||
try {
|
||||
const base64Data = Buffer.from(hex, 'hex').toString('base64')
|
||||
if (base64Data) {
|
||||
result[username] = `data:image/jpeg;base64,${base64Data}`
|
||||
}
|
||||
} else if (Buffer.isBuffer(row.image_buffer)) {
|
||||
base64Data = row.image_buffer.toString('base64')
|
||||
} else if (Array.isArray(row.image_buffer)) {
|
||||
base64Data = Buffer.from(row.image_buffer).toString('base64')
|
||||
}
|
||||
|
||||
if (base64Data) {
|
||||
result[username] = `data:image/jpeg;base64,${base64Data}`
|
||||
} catch {
|
||||
// ignore invalid blob hex
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -867,48 +830,16 @@ class ChatService {
|
||||
return { success: false, error: connectResult.error }
|
||||
}
|
||||
|
||||
const excludeExpr = Array.from(FRIEND_EXCLUDE_USERNAMES)
|
||||
.map((username) => `'${this.escapeSqlString(username)}'`)
|
||||
.join(',')
|
||||
|
||||
const countsSql = `
|
||||
SELECT
|
||||
SUM(CASE WHEN username LIKE '%@chatroom' THEN 1 ELSE 0 END) AS group_count,
|
||||
SUM(CASE WHEN username LIKE 'gh_%' THEN 1 ELSE 0 END) AS official_count,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN username NOT LIKE '%@chatroom'
|
||||
AND username NOT LIKE 'gh_%'
|
||||
AND local_type = 1
|
||||
AND username NOT IN (${excludeExpr})
|
||||
THEN 1 ELSE 0
|
||||
END
|
||||
) AS private_count,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN username NOT LIKE '%@chatroom'
|
||||
AND username NOT LIKE 'gh_%'
|
||||
AND local_type = 0
|
||||
AND COALESCE(quan_pin, '') != ''
|
||||
THEN 1 ELSE 0
|
||||
END
|
||||
) AS former_friend_count
|
||||
FROM contact
|
||||
WHERE username IS NOT NULL
|
||||
AND username != ''
|
||||
`
|
||||
|
||||
const result = await wcdbService.execQuery('contact', null, countsSql)
|
||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||
const result = await wcdbService.getContactTypeCounts()
|
||||
if (!result.success || !result.counts) {
|
||||
return { success: false, error: result.error || '获取联系人类型数量失败' }
|
||||
}
|
||||
|
||||
const row = result.rows[0] as Record<string, any>
|
||||
const counts: ExportTabCounts = {
|
||||
private: this.getRowInt(row, ['private_count', 'privateCount'], 0),
|
||||
group: this.getRowInt(row, ['group_count', 'groupCount'], 0),
|
||||
official: this.getRowInt(row, ['official_count', 'officialCount'], 0),
|
||||
former_friend: this.getRowInt(row, ['former_friend_count', 'formerFriendCount'], 0)
|
||||
private: Number(result.counts.private || 0),
|
||||
group: Number(result.counts.group || 0),
|
||||
official: Number(result.counts.official || 0),
|
||||
former_friend: Number(result.counts.former_friend || 0)
|
||||
}
|
||||
|
||||
return { success: true, counts }
|
||||
@@ -1029,87 +960,20 @@ class ChatService {
|
||||
return { success: true, counts: {}, dbSignature: 'empty' }
|
||||
}
|
||||
|
||||
const dbPathsResult = await this.listMessageDbPathsForCount()
|
||||
if (!dbPathsResult.success) {
|
||||
return { success: false, error: dbPathsResult.error || '获取消息数据库列表失败' }
|
||||
const snapshotResult = await this.getMessageDbCountSnapshot()
|
||||
const dbPaths = snapshotResult.success ? (snapshotResult.dbPaths || []) : []
|
||||
const dbSignature = snapshotResult.success
|
||||
? (snapshotResult.dbSignature || this.buildMessageDbSignature(dbPaths))
|
||||
: this.buildMessageDbSignature(dbPaths)
|
||||
const nativeResult = await wcdbService.getSessionMessageCounts(normalizedSessionIds)
|
||||
if (!nativeResult.success || !nativeResult.counts) {
|
||||
return { success: false, error: nativeResult.error || '获取会话消息总数失败', dbSignature }
|
||||
}
|
||||
const dbPaths = dbPathsResult.dbPaths || []
|
||||
const dbSignature = this.buildMessageDbSignature(dbPaths)
|
||||
if (dbPaths.length === 0) {
|
||||
const emptyCounts = normalizedSessionIds.reduce<Record<string, number>>((acc, sessionId) => {
|
||||
acc[sessionId] = 0
|
||||
return acc
|
||||
}, {})
|
||||
return { success: true, counts: emptyCounts, dbSignature }
|
||||
}
|
||||
|
||||
const hashLookup = this.buildSessionHashLookup(normalizedSessionIds)
|
||||
const counts = normalizedSessionIds.reduce<Record<string, number>>((acc, sessionId) => {
|
||||
acc[sessionId] = 0
|
||||
const counts = normalizedSessionIds.reduce<Record<string, number>>((acc, sid) => {
|
||||
const raw = nativeResult.counts?.[sid]
|
||||
acc[sid] = Number.isFinite(raw) ? Math.max(0, Math.floor(Number(raw))) : 0
|
||||
return acc
|
||||
}, {})
|
||||
const unionChunkSize = 48
|
||||
const queryCountKeys = ['count', 'COUNT(*)', 'cnt', 'CNT', 'table_count', 'tableCount']
|
||||
|
||||
for (const dbPath of dbPaths) {
|
||||
const tablesResult = await wcdbService.execQuery(
|
||||
'message',
|
||||
dbPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
|
||||
)
|
||||
if (!tablesResult.success || !tablesResult.rows || tablesResult.rows.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const tableToSessionId = new Map<string, string>()
|
||||
for (const row of tablesResult.rows as Record<string, any>[]) {
|
||||
const tableName = String(this.getRowField(row, ['name', 'table_name', 'tableName']) || '').trim()
|
||||
if (!tableName) continue
|
||||
const sessionId = this.matchSessionIdByTableName(tableName, hashLookup)
|
||||
if (!sessionId) continue
|
||||
tableToSessionId.set(tableName, sessionId)
|
||||
}
|
||||
|
||||
if (tableToSessionId.size === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const matchedTables = Array.from(tableToSessionId.keys())
|
||||
for (let i = 0; i < matchedTables.length; i += unionChunkSize) {
|
||||
const chunk = matchedTables.slice(i, i + unionChunkSize)
|
||||
if (chunk.length === 0) continue
|
||||
|
||||
const unionSql = chunk.map((tableName) => {
|
||||
const tableAlias = tableName.replace(/'/g, "''")
|
||||
return `SELECT '${tableAlias}' AS table_name, COUNT(*) AS count FROM ${this.quoteSqlIdentifier(tableName)}`
|
||||
}).join(' UNION ALL ')
|
||||
|
||||
const unionResult = await wcdbService.execQuery('message', dbPath, unionSql)
|
||||
if (unionResult.success && unionResult.rows) {
|
||||
for (const row of unionResult.rows as Record<string, any>[]) {
|
||||
const tableName = String(this.getRowField(row, ['table_name', 'tableName', 'name']) || '').trim()
|
||||
const sessionId = tableToSessionId.get(tableName)
|
||||
if (!sessionId) continue
|
||||
const countValue = Math.max(0, Math.floor(this.getRowInt(row, queryCountKeys, 0)))
|
||||
counts[sessionId] = (counts[sessionId] || 0) + countValue
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 回退到逐表查询,避免单个 UNION 查询失败导致整批丢失。
|
||||
for (const tableName of chunk) {
|
||||
const sessionId = tableToSessionId.get(tableName)
|
||||
if (!sessionId) continue
|
||||
const countSql = `SELECT COUNT(*) AS count FROM ${this.quoteSqlIdentifier(tableName)}`
|
||||
const singleResult = await wcdbService.execQuery('message', dbPath, countSql)
|
||||
if (!singleResult.success || !singleResult.rows || singleResult.rows.length === 0) {
|
||||
continue
|
||||
}
|
||||
const countValue = Math.max(0, Math.floor(this.getRowInt(singleResult.rows[0], queryCountKeys, 0)))
|
||||
counts[sessionId] = (counts[sessionId] || 0) + countValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logExportDiag({
|
||||
traceId,
|
||||
@@ -1214,21 +1078,18 @@ class ChatService {
|
||||
now - cachedBatch.updatedAt <= this.sessionMessageCountBatchCacheTtlMs
|
||||
|
||||
if (cachedBatchFresh && cachedBatch.sessionIdsKey === sessionIdsKey) {
|
||||
const dbPathsResult = await this.listMessageDbPathsForCount()
|
||||
if (dbPathsResult.success) {
|
||||
const currentDbSignature = this.buildMessageDbSignature(dbPathsResult.dbPaths || [])
|
||||
if (currentDbSignature === cachedBatch.dbSignature) {
|
||||
for (const sessionId of pendingSessionIds) {
|
||||
const nextCountRaw = cachedBatch.counts[sessionId]
|
||||
const nextCount = Number.isFinite(nextCountRaw) ? Math.max(0, Math.floor(nextCountRaw)) : 0
|
||||
counts[sessionId] = nextCount
|
||||
this.sessionMessageCountCache.set(sessionId, {
|
||||
count: nextCount,
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
tableScanSucceeded = true
|
||||
const snapshot = await this.getMessageDbCountSnapshot()
|
||||
if (snapshot.success && snapshot.dbSignature === cachedBatch.dbSignature) {
|
||||
for (const sessionId of pendingSessionIds) {
|
||||
const nextCountRaw = cachedBatch.counts[sessionId]
|
||||
const nextCount = Number.isFinite(nextCountRaw) ? Math.max(0, Math.floor(nextCountRaw)) : 0
|
||||
counts[sessionId] = nextCount
|
||||
this.sessionMessageCountCache.set(sessionId, {
|
||||
count: nextCount,
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
tableScanSucceeded = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1340,29 +1201,15 @@ class ChatService {
|
||||
return { success: false, error: connectResult.error }
|
||||
}
|
||||
|
||||
// 使用execQuery直接查询加密的contact.db
|
||||
// kind='contact', path=null表示使用已打开的contact.db
|
||||
const contactQuery = `
|
||||
SELECT username, remark, nick_name, alias, local_type, quan_pin
|
||||
FROM contact
|
||||
WHERE username IS NOT NULL
|
||||
AND username != ''
|
||||
AND (
|
||||
username LIKE '%@chatroom'
|
||||
OR username LIKE 'gh_%'
|
||||
OR local_type = 1
|
||||
OR (local_type = 0 AND COALESCE(quan_pin, '') != '')
|
||||
)
|
||||
`
|
||||
const contactResult = await wcdbService.execQuery('contact', null, contactQuery)
|
||||
const contactResult = await wcdbService.getContactsCompact()
|
||||
|
||||
if (!contactResult.success || !contactResult.rows) {
|
||||
if (!contactResult.success || !contactResult.contacts) {
|
||||
console.error('查询联系人失败:', contactResult.error)
|
||||
return { success: false, error: contactResult.error || '查询联系人失败' }
|
||||
}
|
||||
|
||||
|
||||
const rows = contactResult.rows as Record<string, any>[]
|
||||
const rows = contactResult.contacts as Record<string, any>[]
|
||||
// 获取会话表的最后联系时间用于排序
|
||||
const lastContactTimeMap = new Map<string, number>()
|
||||
const sessionResult = await wcdbService.getSessions()
|
||||
@@ -2080,16 +1927,12 @@ class ChatService {
|
||||
|
||||
private async getFriendIdentitySet(): Promise<Set<string>> {
|
||||
const identities = new Set<string>()
|
||||
const contactResult = await wcdbService.execQuery(
|
||||
'contact',
|
||||
null,
|
||||
'SELECT username, local_type, quan_pin FROM contact'
|
||||
)
|
||||
if (!contactResult.success || !contactResult.rows) {
|
||||
const contactResult = await wcdbService.getContactsCompact()
|
||||
if (!contactResult.success || !contactResult.contacts) {
|
||||
return identities
|
||||
}
|
||||
|
||||
for (const rowAny of contactResult.rows) {
|
||||
for (const rowAny of contactResult.contacts) {
|
||||
const row = rowAny as Record<string, any>
|
||||
const username = String(row.username || '').trim()
|
||||
if (!username || username.includes('@chatroom') || username.startsWith('gh_')) continue
|
||||
@@ -2218,7 +2061,9 @@ class ChatService {
|
||||
this.sessionDetailFastCache.clear()
|
||||
this.sessionDetailExtraCache.clear()
|
||||
this.sessionStatusCache.clear()
|
||||
this.sessionTablesCache.clear()
|
||||
this.messageTableColumnsCache.clear()
|
||||
this.messageDbCountSnapshotCache = null
|
||||
this.refreshSessionStatsCacheScope(scope)
|
||||
this.refreshGroupMyMessageCountCacheScope(scope)
|
||||
}
|
||||
@@ -2410,6 +2255,13 @@ class ChatService {
|
||||
if (!this.sessionStatsCacheScope) return
|
||||
|
||||
const normalizedType = String(type || '').toLowerCase()
|
||||
if (
|
||||
normalizedType.includes('message') ||
|
||||
normalizedType.includes('session') ||
|
||||
normalizedType.includes('db')
|
||||
) {
|
||||
this.messageDbCountSnapshotCache = null
|
||||
}
|
||||
const maybeJson = String(json || '').trim()
|
||||
let ids = new Set<string>()
|
||||
if (maybeJson) {
|
||||
@@ -2472,9 +2324,13 @@ class ChatService {
|
||||
}
|
||||
|
||||
private async getSessionMessageTables(sessionId: string): Promise<Array<{ tableName: string; dbPath: string }>> {
|
||||
const now = Date.now()
|
||||
const cached = this.sessionTablesCache.get(sessionId)
|
||||
if (cached && cached.length > 0) {
|
||||
return cached
|
||||
if (cached && now - cached.updatedAt <= this.sessionTablesCacheTtl && cached.tables.length > 0) {
|
||||
return cached.tables
|
||||
}
|
||||
if (cached) {
|
||||
this.sessionTablesCache.delete(sessionId)
|
||||
}
|
||||
|
||||
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||
@@ -2487,8 +2343,10 @@ class ChatService {
|
||||
.filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }>
|
||||
|
||||
if (tables.length > 0) {
|
||||
this.sessionTablesCache.set(sessionId, tables)
|
||||
setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl)
|
||||
this.sessionTablesCache.set(sessionId, {
|
||||
tables,
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
return tables
|
||||
}
|
||||
@@ -2501,14 +2359,12 @@ class ChatService {
|
||||
return new Set<string>(cached.columns)
|
||||
}
|
||||
|
||||
const pragmaSql = `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`
|
||||
const result = await wcdbService.execQuery('message', dbPath, pragmaSql)
|
||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||
return new Set<string>()
|
||||
}
|
||||
const result = await wcdbService.getMessageTableColumns(dbPath, tableName)
|
||||
if (!result.success || !Array.isArray(result.columns) || result.columns.length === 0) return new Set<string>()
|
||||
|
||||
const columns = new Set<string>()
|
||||
for (const row of result.rows as Record<string, any>[]) {
|
||||
const name = String(this.getRowField(row, ['name', 'column_name', 'columnName']) || '').trim().toLowerCase()
|
||||
for (const columnName of result.columns) {
|
||||
const name = String(columnName || '').trim().toLowerCase()
|
||||
if (name) columns.add(name)
|
||||
}
|
||||
this.messageTableColumnsCache.set(cacheKey, {
|
||||
@@ -2717,136 +2573,32 @@ class ChatService {
|
||||
redPacketMessages: 0,
|
||||
callMessages: 0
|
||||
}
|
||||
if (sessionId.endsWith('@chatroom')) {
|
||||
const isGroup = sessionId.endsWith('@chatroom')
|
||||
if (isGroup) {
|
||||
stats.groupMyMessages = 0
|
||||
stats.groupActiveSpeakers = 0
|
||||
}
|
||||
|
||||
const tables = await this.getSessionMessageTables(sessionId)
|
||||
if (tables.length === 0) {
|
||||
return stats
|
||||
}
|
||||
|
||||
const senderIdentities = new Set<string>()
|
||||
let aggregatedTableCount = 0
|
||||
const isGroup = sessionId.endsWith('@chatroom')
|
||||
const escapedSelfKeys = Array.from(selfIdentitySet)
|
||||
.filter(Boolean)
|
||||
.map((key) => `'${this.escapeSqlLiteral(key.toLowerCase())}'`)
|
||||
|
||||
for (const { tableName, dbPath } of tables) {
|
||||
const columnSet = await this.getMessageTableColumns(dbPath, tableName)
|
||||
if (columnSet.size === 0) continue
|
||||
|
||||
const typeCol = this.pickFirstColumn(columnSet, ['local_type', 'type', 'msg_type', 'msgtype'])
|
||||
const timeCol = this.pickFirstColumn(columnSet, ['create_time', 'createtime', 'msg_create_time', 'time'])
|
||||
const senderCol = this.pickFirstColumn(columnSet, ['sender_username', 'senderusername', 'sender'])
|
||||
const isSendCol = this.pickFirstColumn(columnSet, ['computed_is_send', 'computedissend', 'is_send', 'issend'])
|
||||
|
||||
const selectParts: string[] = [
|
||||
'COUNT(*) AS total_messages',
|
||||
typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 34 THEN 1 ELSE 0 END) AS voice_messages` : '0 AS voice_messages',
|
||||
typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 3 THEN 1 ELSE 0 END) AS image_messages` : '0 AS image_messages',
|
||||
typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 43 THEN 1 ELSE 0 END) AS video_messages` : '0 AS video_messages',
|
||||
typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 47 THEN 1 ELSE 0 END) AS emoji_messages` : '0 AS emoji_messages',
|
||||
typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 50 THEN 1 ELSE 0 END) AS call_messages` : '0 AS call_messages',
|
||||
typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 8589934592049 THEN 1 ELSE 0 END) AS transfer_messages` : '0 AS transfer_messages',
|
||||
typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 8594229559345 THEN 1 ELSE 0 END) AS red_packet_messages` : '0 AS red_packet_messages',
|
||||
timeCol ? `MIN(${this.quoteSqlIdentifier(timeCol)}) AS first_timestamp` : 'NULL AS first_timestamp',
|
||||
timeCol ? `MAX(${this.quoteSqlIdentifier(timeCol)}) AS last_timestamp` : 'NULL AS last_timestamp'
|
||||
]
|
||||
|
||||
if (isGroup) {
|
||||
if (senderCol) {
|
||||
const normalizedSender = `LOWER(TRIM(CAST(${this.quoteSqlIdentifier(senderCol)} AS TEXT)))`
|
||||
if (escapedSelfKeys.length > 0 && isSendCol) {
|
||||
selectParts.push(
|
||||
`SUM(CASE WHEN ${normalizedSender} != '' THEN CASE WHEN ${normalizedSender} IN (${escapedSelfKeys.join(', ')}) THEN 1 ELSE 0 END ELSE CASE WHEN ${this.quoteSqlIdentifier(isSendCol)} = 1 THEN 1 ELSE 0 END END) AS group_my_messages`
|
||||
)
|
||||
} else if (escapedSelfKeys.length > 0) {
|
||||
selectParts.push(`SUM(CASE WHEN ${normalizedSender} IN (${escapedSelfKeys.join(', ')}) THEN 1 ELSE 0 END) AS group_my_messages`)
|
||||
} else if (isSendCol) {
|
||||
selectParts.push(`SUM(CASE WHEN ${this.quoteSqlIdentifier(isSendCol)} = 1 THEN 1 ELSE 0 END) AS group_my_messages`)
|
||||
} else {
|
||||
selectParts.push('0 AS group_my_messages')
|
||||
}
|
||||
} else if (isSendCol) {
|
||||
selectParts.push(`SUM(CASE WHEN ${this.quoteSqlIdentifier(isSendCol)} = 1 THEN 1 ELSE 0 END) AS group_my_messages`)
|
||||
} else {
|
||||
selectParts.push('0 AS group_my_messages')
|
||||
}
|
||||
|
||||
const aggregateSql = `SELECT ${selectParts.join(', ')} FROM ${this.quoteSqlIdentifier(tableName)}`
|
||||
const aggregateResult = await wcdbService.execQuery('message', dbPath, aggregateSql)
|
||||
if (!aggregateResult.success || !aggregateResult.rows || aggregateResult.rows.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const aggregateRow = aggregateResult.rows[0] as Record<string, any>
|
||||
aggregatedTableCount += 1
|
||||
stats.totalMessages += this.getRowInt(aggregateRow, ['total_messages', 'totalMessages'], 0)
|
||||
stats.voiceMessages += this.getRowInt(aggregateRow, ['voice_messages', 'voiceMessages'], 0)
|
||||
stats.imageMessages += this.getRowInt(aggregateRow, ['image_messages', 'imageMessages'], 0)
|
||||
stats.videoMessages += this.getRowInt(aggregateRow, ['video_messages', 'videoMessages'], 0)
|
||||
stats.emojiMessages += this.getRowInt(aggregateRow, ['emoji_messages', 'emojiMessages'], 0)
|
||||
stats.callMessages += this.getRowInt(aggregateRow, ['call_messages', 'callMessages'], 0)
|
||||
stats.transferMessages += this.getRowInt(aggregateRow, ['transfer_messages', 'transferMessages'], 0)
|
||||
stats.redPacketMessages += this.getRowInt(aggregateRow, ['red_packet_messages', 'redPacketMessages'], 0)
|
||||
|
||||
const firstTs = this.getRowInt(aggregateRow, ['first_timestamp', 'firstTimestamp'], 0)
|
||||
if (firstTs > 0 && (stats.firstTimestamp === undefined || firstTs < stats.firstTimestamp)) {
|
||||
stats.firstTimestamp = firstTs
|
||||
}
|
||||
const lastTs = this.getRowInt(aggregateRow, ['last_timestamp', 'lastTimestamp'], 0)
|
||||
if (lastTs > 0 && (stats.lastTimestamp === undefined || lastTs > stats.lastTimestamp)) {
|
||||
stats.lastTimestamp = lastTs
|
||||
}
|
||||
stats.groupMyMessages = (stats.groupMyMessages || 0) + this.getRowInt(aggregateRow, ['group_my_messages', 'groupMyMessages'], 0)
|
||||
|
||||
if (senderCol) {
|
||||
const normalizedSender = `LOWER(TRIM(CAST(${this.quoteSqlIdentifier(senderCol)} AS TEXT)))`
|
||||
const distinctSenderSql = `SELECT DISTINCT ${normalizedSender} AS sender_identity FROM ${this.quoteSqlIdentifier(tableName)} WHERE ${normalizedSender} != ''`
|
||||
const senderResult = await wcdbService.execQuery('message', dbPath, distinctSenderSql)
|
||||
if (senderResult.success && senderResult.rows) {
|
||||
for (const row of senderResult.rows as Record<string, any>[]) {
|
||||
const senderIdentity = String(this.getRowField(row, ['sender_identity', 'senderIdentity']) || '').trim()
|
||||
if (!senderIdentity) continue
|
||||
senderIdentities.add(senderIdentity)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const aggregateSql = `SELECT ${selectParts.join(', ')} FROM ${this.quoteSqlIdentifier(tableName)}`
|
||||
const aggregateResult = await wcdbService.execQuery('message', dbPath, aggregateSql)
|
||||
if (!aggregateResult.success || !aggregateResult.rows || aggregateResult.rows.length === 0) {
|
||||
continue
|
||||
}
|
||||
const aggregateRow = aggregateResult.rows[0] as Record<string, any>
|
||||
aggregatedTableCount += 1
|
||||
stats.totalMessages += this.getRowInt(aggregateRow, ['total_messages', 'totalMessages'], 0)
|
||||
stats.voiceMessages += this.getRowInt(aggregateRow, ['voice_messages', 'voiceMessages'], 0)
|
||||
stats.imageMessages += this.getRowInt(aggregateRow, ['image_messages', 'imageMessages'], 0)
|
||||
stats.videoMessages += this.getRowInt(aggregateRow, ['video_messages', 'videoMessages'], 0)
|
||||
stats.emojiMessages += this.getRowInt(aggregateRow, ['emoji_messages', 'emojiMessages'], 0)
|
||||
stats.callMessages += this.getRowInt(aggregateRow, ['call_messages', 'callMessages'], 0)
|
||||
stats.transferMessages += this.getRowInt(aggregateRow, ['transfer_messages', 'transferMessages'], 0)
|
||||
stats.redPacketMessages += this.getRowInt(aggregateRow, ['red_packet_messages', 'redPacketMessages'], 0)
|
||||
|
||||
const firstTs = this.getRowInt(aggregateRow, ['first_timestamp', 'firstTimestamp'], 0)
|
||||
if (firstTs > 0 && (stats.firstTimestamp === undefined || firstTs < stats.firstTimestamp)) {
|
||||
stats.firstTimestamp = firstTs
|
||||
}
|
||||
const lastTs = this.getRowInt(aggregateRow, ['last_timestamp', 'lastTimestamp'], 0)
|
||||
if (lastTs > 0 && (stats.lastTimestamp === undefined || lastTs > stats.lastTimestamp)) {
|
||||
stats.lastTimestamp = lastTs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (aggregatedTableCount === 0) {
|
||||
const nativeResult = await wcdbService.getSessionMessageTypeStats(sessionId, 0, 0)
|
||||
if (!nativeResult.success || !nativeResult.data) {
|
||||
return this.collectSessionExportStatsByCursorScan(sessionId, selfIdentitySet)
|
||||
}
|
||||
|
||||
const data = nativeResult.data as Record<string, any>
|
||||
stats.totalMessages = Math.max(0, Math.floor(Number(data.total_messages || 0)))
|
||||
stats.voiceMessages = Math.max(0, Math.floor(Number(data.voice_messages || 0)))
|
||||
stats.imageMessages = Math.max(0, Math.floor(Number(data.image_messages || 0)))
|
||||
stats.videoMessages = Math.max(0, Math.floor(Number(data.video_messages || 0)))
|
||||
stats.emojiMessages = Math.max(0, Math.floor(Number(data.emoji_messages || 0)))
|
||||
stats.callMessages = Math.max(0, Math.floor(Number(data.call_messages || 0)))
|
||||
stats.transferMessages = Math.max(0, Math.floor(Number(data.transfer_messages || 0)))
|
||||
stats.redPacketMessages = Math.max(0, Math.floor(Number(data.red_packet_messages || 0)))
|
||||
|
||||
const firstTs = Math.max(0, Math.floor(Number(data.first_timestamp || 0)))
|
||||
const lastTs = Math.max(0, Math.floor(Number(data.last_timestamp || 0)))
|
||||
if (firstTs > 0) stats.firstTimestamp = firstTs
|
||||
if (lastTs > 0) stats.lastTimestamp = lastTs
|
||||
|
||||
if (preferAccurateSpecialTypes) {
|
||||
try {
|
||||
const preciseCounters = await this.collectSpecialMessageCountsByCursorScan(sessionId)
|
||||
@@ -2854,12 +2606,13 @@ class ChatService {
|
||||
stats.redPacketMessages = preciseCounters.redPacketMessages
|
||||
stats.callMessages = preciseCounters.callMessages
|
||||
} catch {
|
||||
// 保留聚合统计结果作为兜底
|
||||
// 保留 native 聚合结果作为兜底
|
||||
}
|
||||
}
|
||||
|
||||
if (isGroup) {
|
||||
stats.groupActiveSpeakers = senderIdentities.size
|
||||
stats.groupMyMessages = Math.max(0, Math.floor(Number(data.group_my_messages || 0)))
|
||||
stats.groupActiveSpeakers = Math.max(0, Math.floor(Number(data.group_sender_count || 0)))
|
||||
if (Number.isFinite(stats.groupMyMessages)) {
|
||||
this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number)
|
||||
}
|
||||
@@ -2867,6 +2620,64 @@ class ChatService {
|
||||
return stats
|
||||
}
|
||||
|
||||
private toExportSessionStatsFromNativeTypeRow(sessionId: string, row: Record<string, any>): ExportSessionStats {
|
||||
const stats: ExportSessionStats = {
|
||||
totalMessages: Math.max(0, Math.floor(Number(row?.total_messages || 0))),
|
||||
voiceMessages: Math.max(0, Math.floor(Number(row?.voice_messages || 0))),
|
||||
imageMessages: Math.max(0, Math.floor(Number(row?.image_messages || 0))),
|
||||
videoMessages: Math.max(0, Math.floor(Number(row?.video_messages || 0))),
|
||||
emojiMessages: Math.max(0, Math.floor(Number(row?.emoji_messages || 0))),
|
||||
callMessages: Math.max(0, Math.floor(Number(row?.call_messages || 0))),
|
||||
transferMessages: Math.max(0, Math.floor(Number(row?.transfer_messages || 0))),
|
||||
redPacketMessages: Math.max(0, Math.floor(Number(row?.red_packet_messages || 0)))
|
||||
}
|
||||
|
||||
const firstTs = Math.max(0, Math.floor(Number(row?.first_timestamp || 0)))
|
||||
const lastTs = Math.max(0, Math.floor(Number(row?.last_timestamp || 0)))
|
||||
if (firstTs > 0) stats.firstTimestamp = firstTs
|
||||
if (lastTs > 0) stats.lastTimestamp = lastTs
|
||||
|
||||
if (sessionId.endsWith('@chatroom')) {
|
||||
stats.groupMyMessages = Math.max(0, Math.floor(Number(row?.group_my_messages || 0)))
|
||||
stats.groupActiveSpeakers = Math.max(0, Math.floor(Number(row?.group_sender_count || 0)))
|
||||
if (Number.isFinite(stats.groupMyMessages)) {
|
||||
this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number)
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
private async getMessageDbCountSnapshot(forceRefresh = false): Promise<{
|
||||
success: boolean
|
||||
dbPaths?: string[]
|
||||
dbSignature?: string
|
||||
error?: string
|
||||
}> {
|
||||
const now = Date.now()
|
||||
if (!forceRefresh && this.messageDbCountSnapshotCache) {
|
||||
if (now - this.messageDbCountSnapshotCache.updatedAt <= this.messageDbCountSnapshotCacheTtlMs) {
|
||||
return {
|
||||
success: true,
|
||||
dbPaths: [...this.messageDbCountSnapshotCache.dbPaths],
|
||||
dbSignature: this.messageDbCountSnapshotCache.dbSignature
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dbPathsResult = await this.listMessageDbPathsForCount()
|
||||
if (!dbPathsResult.success || !dbPathsResult.dbPaths) {
|
||||
return { success: false, error: dbPathsResult.error || '获取消息数据库列表失败' }
|
||||
}
|
||||
const dbPaths = dbPathsResult.dbPaths
|
||||
const dbSignature = this.buildMessageDbSignature(dbPaths)
|
||||
this.messageDbCountSnapshotCache = {
|
||||
dbPaths: [...dbPaths],
|
||||
dbSignature,
|
||||
updatedAt: now
|
||||
}
|
||||
return { success: true, dbPaths, dbSignature }
|
||||
}
|
||||
|
||||
private async buildGroupRelationStats(
|
||||
groupSessionIds: string[],
|
||||
privateSessionIds: string[],
|
||||
@@ -3018,7 +2829,8 @@ class ChatService {
|
||||
const privateSessionIds = normalizedSessionIds.filter(sessionId => !sessionId.endsWith('@chatroom'))
|
||||
|
||||
let memberCountMap: Record<string, number> = {}
|
||||
if (groupSessionIds.length > 0) {
|
||||
const shouldLoadGroupMemberCount = groupSessionIds.length > 0 && (includeRelations || normalizedSessionIds.length === 1)
|
||||
if (shouldLoadGroupMemberCount) {
|
||||
try {
|
||||
const memberCountsResult = await wcdbService.getGroupMemberCounts(groupSessionIds)
|
||||
memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {}
|
||||
@@ -3054,13 +2866,43 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
const nativeBatchStats: Record<string, ExportSessionStats> = {}
|
||||
let hasNativeBatchStats = false
|
||||
if (!preferAccurateSpecialTypes) {
|
||||
try {
|
||||
const quickMode = !includeRelations && normalizedSessionIds.length > 1
|
||||
const nativeBatch = await wcdbService.getSessionMessageTypeStatsBatch(normalizedSessionIds, {
|
||||
beginTimestamp: 0,
|
||||
endTimestamp: 0,
|
||||
quickMode,
|
||||
includeGroupSenderCount: true
|
||||
})
|
||||
if (nativeBatch.success && nativeBatch.data) {
|
||||
for (const sessionId of normalizedSessionIds) {
|
||||
const row = nativeBatch.data?.[sessionId] as Record<string, any> | undefined
|
||||
if (!row || typeof row !== 'object') continue
|
||||
nativeBatchStats[sessionId] = this.toExportSessionStatsFromNativeTypeRow(sessionId, row)
|
||||
}
|
||||
hasNativeBatchStats = Object.keys(nativeBatchStats).length > 0
|
||||
} else {
|
||||
console.warn('[fallback-exec] getSessionMessageTypeStatsBatch failed, fallback to per-session stats path')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[fallback-exec] getSessionMessageTypeStatsBatch exception, fallback to per-session stats path:', error)
|
||||
}
|
||||
}
|
||||
|
||||
await this.forEachWithConcurrency(normalizedSessionIds, 3, async (sessionId) => {
|
||||
try {
|
||||
const stats = await this.collectSessionExportStats(sessionId, selfIdentitySet, preferAccurateSpecialTypes)
|
||||
const stats = hasNativeBatchStats && nativeBatchStats[sessionId]
|
||||
? { ...nativeBatchStats[sessionId] }
|
||||
: await this.collectSessionExportStats(sessionId, selfIdentitySet, preferAccurateSpecialTypes)
|
||||
if (sessionId.endsWith('@chatroom')) {
|
||||
stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number'
|
||||
? Math.max(0, Math.floor(memberCountMap[sessionId]))
|
||||
: 0
|
||||
if (shouldLoadGroupMemberCount) {
|
||||
stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number'
|
||||
? Math.max(0, Math.floor(memberCountMap[sessionId]))
|
||||
: 0
|
||||
}
|
||||
if (includeRelations) {
|
||||
stats.groupMutualFriends = typeof groupMutualFriendMap[sessionId] === 'number'
|
||||
? Math.max(0, Math.floor(groupMutualFriendMap[sessionId]))
|
||||
@@ -4375,24 +4217,6 @@ class ChatService {
|
||||
return candidates
|
||||
}
|
||||
|
||||
private async resolveChatNameId(dbPath: string, senderWxid: string): Promise<number | null> {
|
||||
const escaped = this.escapeSqlString(senderWxid)
|
||||
const name2IdTable = await this.resolveName2IdTableName(dbPath)
|
||||
if (!name2IdTable) return null
|
||||
const info = await wcdbService.execQuery('media', dbPath, `PRAGMA table_info('${name2IdTable}')`)
|
||||
if (!info.success || !info.rows) return null
|
||||
const columns = info.rows.map((row) => String(row.name || row.Name || row.column || '')).filter(Boolean)
|
||||
const lower = new Map(columns.map((col) => [col.toLowerCase(), col]))
|
||||
const column = lower.get('name_id') || lower.get('id') || 'rowid'
|
||||
const sql = `SELECT ${column} AS id FROM ${name2IdTable} WHERE user_name = '${escaped}' LIMIT 1`
|
||||
const result = await wcdbService.execQuery('media', dbPath, sql)
|
||||
if (!result.success || !result.rows || result.rows.length === 0) return null
|
||||
const value = result.rows[0]?.id
|
||||
if (value === null || value === undefined) return null
|
||||
const parsed = typeof value === 'number' ? value : parseInt(String(value), 10)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
private decodeVoiceBlob(raw: any): Buffer | null {
|
||||
if (!raw) return null
|
||||
if (Buffer.isBuffer(raw)) return raw
|
||||
@@ -4415,66 +4239,10 @@ class ChatService {
|
||||
return null
|
||||
}
|
||||
|
||||
private async resolveVoiceInfoColumns(dbPath: string, tableName: string): Promise<{
|
||||
dataColumn: string;
|
||||
chatNameIdColumn?: string;
|
||||
createTimeColumn?: string;
|
||||
msgLocalIdColumn?: string;
|
||||
} | null> {
|
||||
const info = await wcdbService.execQuery('media', dbPath, `PRAGMA table_info('${tableName}')`)
|
||||
if (!info.success || !info.rows) return null
|
||||
const columns = info.rows.map((row) => String(row.name || row.Name || row.column || '')).filter(Boolean)
|
||||
if (columns.length === 0) return null
|
||||
const lower = new Map(columns.map((col) => [col.toLowerCase(), col]))
|
||||
const dataColumn =
|
||||
lower.get('voice_data') ||
|
||||
lower.get('buf') ||
|
||||
lower.get('voicebuf') ||
|
||||
lower.get('data')
|
||||
if (!dataColumn) return null
|
||||
return {
|
||||
dataColumn,
|
||||
chatNameIdColumn: lower.get('chat_name_id') || lower.get('chatnameid') || lower.get('chat_nameid'),
|
||||
createTimeColumn: lower.get('create_time') || lower.get('createtime') || lower.get('time'),
|
||||
msgLocalIdColumn: lower.get('msg_local_id') || lower.get('msglocalid') || lower.get('localid')
|
||||
}
|
||||
}
|
||||
|
||||
private escapeSqlString(value: string): string {
|
||||
return value.replace(/'/g, "''")
|
||||
}
|
||||
|
||||
private async resolveVoiceInfoTableName(dbPath: string): Promise<string | null> {
|
||||
// 1. 优先尝试标准表名 'VoiceInfo'
|
||||
const checkStandard = await wcdbService.execQuery(
|
||||
'media',
|
||||
dbPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='VoiceInfo'"
|
||||
)
|
||||
if (checkStandard.success && checkStandard.rows && checkStandard.rows.length > 0) {
|
||||
return 'VoiceInfo'
|
||||
}
|
||||
|
||||
// 2. 只有在找不到标准表时,才尝试模糊匹配 (兼容性)
|
||||
const result = await wcdbService.execQuery(
|
||||
'media',
|
||||
dbPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%' ORDER BY name DESC LIMIT 1"
|
||||
)
|
||||
if (!result.success || !result.rows || result.rows.length === 0) return null
|
||||
return result.rows[0]?.name || null
|
||||
}
|
||||
|
||||
private async resolveName2IdTableName(dbPath: string): Promise<string | null> {
|
||||
const result = await wcdbService.execQuery(
|
||||
'media',
|
||||
dbPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%' ORDER BY name DESC LIMIT 1"
|
||||
)
|
||||
if (!result.success || !result.rows || result.rows.length === 0) return null
|
||||
return result.rows[0]?.name || null
|
||||
}
|
||||
|
||||
private async resolveMessageName2IdTableName(dbPath: string): Promise<string | null> {
|
||||
const normalizedDbPath = String(dbPath || '').trim()
|
||||
if (!normalizedDbPath) return null
|
||||
@@ -4482,6 +4250,7 @@ class ChatService {
|
||||
return this.messageName2IdTableCache.get(normalizedDbPath) || null
|
||||
}
|
||||
|
||||
// fallback-exec: 当前缺少按 message.db 反查 Name2Id 表名的专属接口
|
||||
const result = await wcdbService.execQuery(
|
||||
'message',
|
||||
normalizedDbPath,
|
||||
@@ -4513,6 +4282,7 @@ class ChatService {
|
||||
}
|
||||
|
||||
const escapedTableName = String(name2IdTable).replace(/"/g, '""')
|
||||
// fallback-exec: 当前缺少按 rowid -> user_name 的 message.db 专属接口
|
||||
const result = await wcdbService.execQuery(
|
||||
'message',
|
||||
normalizedDbPath,
|
||||
@@ -4884,11 +4654,9 @@ class ChatService {
|
||||
// DLL 有时不返回 alias 字段,补一条直接 SQL 查询兜底
|
||||
if (!alias) {
|
||||
try {
|
||||
const safe = username.replace(/'/g, "''")
|
||||
const sqlResult = await wcdbService.execQuery('contact', null,
|
||||
`SELECT alias FROM contact WHERE username = '${safe}' LIMIT 1`)
|
||||
if (sqlResult.success && Array.isArray(sqlResult.rows) && sqlResult.rows.length > 0) {
|
||||
alias = String(sqlResult.rows[0]?.alias || sqlResult.rows[0]?.Alias || '')
|
||||
const aliasResult = await wcdbService.getContactAliasMap([username])
|
||||
if (aliasResult.success && aliasResult.map && aliasResult.map[username]) {
|
||||
alias = String(aliasResult.map[username] || '')
|
||||
}
|
||||
} catch {
|
||||
// 兜底失败不影响主流程
|
||||
@@ -5782,7 +5550,7 @@ class ChatService {
|
||||
}
|
||||
|
||||
/**
|
||||
* getVoiceData (绕过WCDB的buggy getVoiceData,直接用execQuery读取)
|
||||
* getVoiceData(主用批量专属接口读取语音数据)
|
||||
*/
|
||||
async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number, senderWxidOpt?: string): Promise<{ success: boolean; data?: string; error?: string }> {
|
||||
const startTime = Date.now()
|
||||
@@ -5813,13 +5581,12 @@ class ChatService {
|
||||
return { success: false, error: '未找到消息时间戳' }
|
||||
}
|
||||
|
||||
// 使用 sessionId + createTime 作为缓存key
|
||||
const cacheKey = `${sessionId}_${msgCreateTime}`
|
||||
// 使用 sessionId + createTime + msgId 作为缓存 key,避免同秒语音串音
|
||||
const cacheKey = this.getVoiceCacheKey(sessionId, String(localId), msgCreateTime)
|
||||
|
||||
// 检查 WAV 内存缓存
|
||||
const wavCache = this.voiceWavCache.get(cacheKey)
|
||||
if (wavCache) {
|
||||
|
||||
return { success: true, data: wavCache.toString('base64') }
|
||||
}
|
||||
|
||||
@@ -5829,9 +5596,7 @@ class ChatService {
|
||||
if (existsSync(wavFilePath)) {
|
||||
try {
|
||||
const wavData = readFileSync(wavFilePath)
|
||||
// 同时缓存到内存
|
||||
this.cacheVoiceWav(cacheKey, wavData)
|
||||
|
||||
return { success: true, data: wavData.toString('base64') }
|
||||
} catch (e) {
|
||||
console.error('[Voice] 读取缓存文件失败:', e)
|
||||
@@ -5859,7 +5624,7 @@ class ChatService {
|
||||
|
||||
const t3 = Date.now()
|
||||
// 从数据库读取 silk 数据
|
||||
const silkData = await this.getVoiceDataFromMediaDb(msgCreateTime, candidates)
|
||||
const silkData = await this.getVoiceDataFromMediaDb(sessionId, msgCreateTime, localId, serverId || 0, candidates)
|
||||
const t4 = Date.now()
|
||||
|
||||
|
||||
@@ -5903,216 +5668,179 @@ class ChatService {
|
||||
private async cacheVoiceWavToFile(cacheKey: string, wavData: Buffer): Promise<void> {
|
||||
try {
|
||||
const voiceCacheDir = this.getVoiceCacheDir()
|
||||
if (!existsSync(voiceCacheDir)) {
|
||||
mkdirSync(voiceCacheDir, { recursive: true })
|
||||
}
|
||||
|
||||
await fsPromises.mkdir(voiceCacheDir, { recursive: true })
|
||||
const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`)
|
||||
writeFileSync(wavFilePath, wavData)
|
||||
await fsPromises.writeFile(wavFilePath, wavData)
|
||||
} catch (e) {
|
||||
console.error('[Voice] 缓存文件失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 WCDB 的 execQuery 直接查询 media.db(绕过有bug的getVoiceData接口)
|
||||
* 策略:批量查询 + 多种兜底方案
|
||||
* 通过 WCDB 专属接口查询语音数据
|
||||
* 策略:批量查询 + 单条 native 兜底
|
||||
*/
|
||||
private async getVoiceDataFromMediaDb(createTime: number, candidates: string[]): Promise<Buffer | null> {
|
||||
const startTime = Date.now()
|
||||
private async getVoiceDataFromMediaDb(
|
||||
sessionId: string,
|
||||
createTime: number,
|
||||
localId: number,
|
||||
svrId: string | number,
|
||||
candidates: string[]
|
||||
): Promise<Buffer | null> {
|
||||
try {
|
||||
const t1 = Date.now()
|
||||
// 获取所有 media 数据库(永久缓存,直到应用重启)
|
||||
let mediaDbFiles: string[]
|
||||
if (this.mediaDbsCache) {
|
||||
mediaDbFiles = this.mediaDbsCache
|
||||
|
||||
} else {
|
||||
const mediaDbsResult = await wcdbService.listMediaDbs()
|
||||
const t2 = Date.now()
|
||||
|
||||
|
||||
let files = mediaDbsResult.success && mediaDbsResult.data ? (mediaDbsResult.data as string[]) : []
|
||||
|
||||
// Fallback: 如果 WCDB DLL 没找到,手动查找
|
||||
if (files.length === 0) {
|
||||
console.warn('[Voice] listMediaDbs returned empty, trying manual search')
|
||||
files = await this.findMediaDbsManually()
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error('[Voice] No media DBs found')
|
||||
return null
|
||||
}
|
||||
|
||||
mediaDbFiles = files
|
||||
this.mediaDbsCache = mediaDbFiles // 永久缓存
|
||||
}
|
||||
|
||||
// 在所有 media 数据库中查找
|
||||
for (const dbPath of mediaDbFiles) {
|
||||
try {
|
||||
// 检查缓存
|
||||
let schema = this.mediaDbSchemaCache.get(dbPath)
|
||||
|
||||
if (!schema) {
|
||||
const t3 = Date.now()
|
||||
// 第一次查询,获取表结构并缓存
|
||||
const tablesResult = await wcdbService.execQuery('media', dbPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%'"
|
||||
)
|
||||
const t4 = Date.now()
|
||||
|
||||
|
||||
if (!tablesResult.success || !tablesResult.rows || tablesResult.rows.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const voiceTable = tablesResult.rows[0].name
|
||||
|
||||
const t5 = Date.now()
|
||||
const columnsResult = await wcdbService.execQuery('media', dbPath,
|
||||
`PRAGMA table_info('${voiceTable}')`
|
||||
)
|
||||
const t6 = Date.now()
|
||||
|
||||
|
||||
if (!columnsResult.success || !columnsResult.rows) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建列名映射(原始名称 -> 小写名称)
|
||||
const columnMap = new Map<string, string>()
|
||||
for (const c of columnsResult.rows) {
|
||||
const name = String(c.name || '')
|
||||
if (name) {
|
||||
columnMap.set(name.toLowerCase(), name)
|
||||
}
|
||||
}
|
||||
|
||||
// 查找数据列(使用原始列名)
|
||||
const dataColumnLower = ['voice_data', 'buf', 'voicebuf', 'data'].find(n => columnMap.has(n))
|
||||
const dataColumn = dataColumnLower ? columnMap.get(dataColumnLower) : undefined
|
||||
|
||||
if (!dataColumn) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 查找 chat_name_id 列
|
||||
const chatNameIdColumnLower = ['chat_name_id', 'chatnameid', 'chat_nameid'].find(n => columnMap.has(n))
|
||||
const chatNameIdColumn = chatNameIdColumnLower ? columnMap.get(chatNameIdColumnLower) : undefined
|
||||
|
||||
// 查找时间列
|
||||
const timeColumnLower = ['create_time', 'createtime', 'time'].find(n => columnMap.has(n))
|
||||
const timeColumn = timeColumnLower ? columnMap.get(timeColumnLower) : undefined
|
||||
|
||||
const t7 = Date.now()
|
||||
// 查找 Name2Id 表
|
||||
const name2IdTablesResult = await wcdbService.execQuery('media', dbPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%'"
|
||||
)
|
||||
const t8 = Date.now()
|
||||
|
||||
|
||||
const name2IdTable = (name2IdTablesResult.success && name2IdTablesResult.rows && name2IdTablesResult.rows.length > 0)
|
||||
? name2IdTablesResult.rows[0].name
|
||||
: undefined
|
||||
|
||||
schema = {
|
||||
voiceTable,
|
||||
dataColumn,
|
||||
chatNameIdColumn,
|
||||
timeColumn,
|
||||
name2IdTable
|
||||
}
|
||||
|
||||
// 缓存表结构
|
||||
this.mediaDbSchemaCache.set(dbPath, schema)
|
||||
}
|
||||
|
||||
// 策略1: 通过 chat_name_id + create_time 查找(最准确)
|
||||
if (schema.chatNameIdColumn && schema.timeColumn && schema.name2IdTable) {
|
||||
const t9 = Date.now()
|
||||
// 批量获取所有 candidates 的 chat_name_id(减少查询次数)
|
||||
const candidatesStr = candidates.map(c => `'${c.replace(/'/g, "''")}'`).join(',')
|
||||
const name2IdResult = await wcdbService.execQuery('media', dbPath,
|
||||
`SELECT user_name, rowid FROM ${schema.name2IdTable} WHERE user_name IN (${candidatesStr})`
|
||||
)
|
||||
const t10 = Date.now()
|
||||
|
||||
|
||||
if (name2IdResult.success && name2IdResult.rows && name2IdResult.rows.length > 0) {
|
||||
// 构建 chat_name_id 列表
|
||||
const chatNameIds = name2IdResult.rows.map((r: any) => r.rowid)
|
||||
const chatNameIdsStr = chatNameIds.join(',')
|
||||
|
||||
const t11 = Date.now()
|
||||
// 一次查询所有可能的语音
|
||||
const voiceResult = await wcdbService.execQuery('media', dbPath,
|
||||
`SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.chatNameIdColumn} IN (${chatNameIdsStr}) AND ${schema.timeColumn} = ${createTime} LIMIT 1`
|
||||
)
|
||||
const t12 = Date.now()
|
||||
|
||||
|
||||
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
|
||||
const row = voiceResult.rows[0]
|
||||
const silkData = this.decodeVoiceBlob(row.data)
|
||||
if (silkData) {
|
||||
|
||||
return silkData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 策略2: 只通过 create_time 查找(兜底)
|
||||
if (schema.timeColumn) {
|
||||
const t13 = Date.now()
|
||||
const voiceResult = await wcdbService.execQuery('media', dbPath,
|
||||
`SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} = ${createTime} LIMIT 1`
|
||||
)
|
||||
const t14 = Date.now()
|
||||
|
||||
|
||||
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
|
||||
const row = voiceResult.rows[0]
|
||||
const silkData = this.decodeVoiceBlob(row.data)
|
||||
if (silkData) {
|
||||
|
||||
return silkData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 策略3: 时间范围查找(±5秒,处理时间戳不精确的情况)
|
||||
if (schema.timeColumn) {
|
||||
const t15 = Date.now()
|
||||
const voiceResult = await wcdbService.execQuery('media', dbPath,
|
||||
`SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} BETWEEN ${createTime - 5} AND ${createTime + 5} ORDER BY ABS(${schema.timeColumn} - ${createTime}) LIMIT 1`
|
||||
)
|
||||
const t16 = Date.now()
|
||||
|
||||
|
||||
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
|
||||
const row = voiceResult.rows[0]
|
||||
const silkData = this.decodeVoiceBlob(row.data)
|
||||
if (silkData) {
|
||||
|
||||
return silkData
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败,继续尝试下一个数据库
|
||||
const batchResult = await wcdbService.getVoiceDataBatch([{
|
||||
session_id: sessionId,
|
||||
create_time: Math.max(0, Math.floor(Number(createTime || 0))),
|
||||
local_id: Math.max(0, Math.floor(Number(localId || 0))),
|
||||
svr_id: svrId || 0,
|
||||
candidates: Array.isArray(candidates) ? candidates : []
|
||||
}])
|
||||
if (batchResult.success && Array.isArray(batchResult.rows) && batchResult.rows.length > 0) {
|
||||
const hex = String(batchResult.rows[0]?.hex || '').trim()
|
||||
if (hex) {
|
||||
const decoded = this.decodeVoiceBlob(hex)
|
||||
if (decoded && decoded.length > 0) return decoded
|
||||
}
|
||||
}
|
||||
|
||||
// fallback-native: 受控回退到旧单条 native 查询
|
||||
const single = await wcdbService.getVoiceData(
|
||||
sessionId,
|
||||
Math.max(0, Math.floor(Number(createTime || 0))),
|
||||
Array.isArray(candidates) ? candidates : [],
|
||||
Math.max(0, Math.floor(Number(localId || 0))),
|
||||
svrId || 0
|
||||
)
|
||||
if (single.success && single.hex) {
|
||||
const decoded = this.decodeVoiceBlob(single.hex)
|
||||
if (decoded && decoded.length > 0) return decoded
|
||||
}
|
||||
return null
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async preloadVoiceDataBatch(
|
||||
sessionId: string,
|
||||
messages: Array<{
|
||||
localId?: number | string
|
||||
createTime?: number | string
|
||||
serverId?: number | string
|
||||
senderWxid?: string | null
|
||||
}>,
|
||||
options?: { chunkSize?: number; decodeConcurrency?: number }
|
||||
): Promise<{ success: boolean; prepared?: number; error?: string }> {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
if (!normalizedSessionId) return { success: true, prepared: 0 }
|
||||
if (!Array.isArray(messages) || messages.length === 0) return { success: true, prepared: 0 }
|
||||
|
||||
const myWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const nowPrepared = new Set<string>()
|
||||
const pending: Array<{
|
||||
cacheKey: string
|
||||
request: { session_id: string; create_time: number; local_id: number; svr_id: string | number; candidates: string[] }
|
||||
}> = []
|
||||
|
||||
for (const item of messages) {
|
||||
const localId = Math.max(0, Math.floor(Number(item?.localId || 0)))
|
||||
const createTime = Math.max(0, Math.floor(Number(item?.createTime || 0)))
|
||||
if (!localId || !createTime) continue
|
||||
|
||||
const cacheKey = this.getVoiceCacheKey(normalizedSessionId, String(localId), createTime)
|
||||
if (nowPrepared.has(cacheKey)) continue
|
||||
nowPrepared.add(cacheKey)
|
||||
|
||||
const inMemory = this.voiceWavCache.get(cacheKey)
|
||||
if (inMemory && inMemory.length > 0) continue
|
||||
|
||||
const wavFilePath = join(this.getVoiceCacheDir(), `${cacheKey}.wav`)
|
||||
if (existsSync(wavFilePath)) {
|
||||
try {
|
||||
const wavData = readFileSync(wavFilePath)
|
||||
if (wavData.length > 0) {
|
||||
this.cacheVoiceWav(cacheKey, wavData)
|
||||
continue
|
||||
}
|
||||
} catch {
|
||||
// ignore corrupted cache file
|
||||
}
|
||||
}
|
||||
|
||||
const senderWxid = String(item?.senderWxid || '').trim()
|
||||
const candidates: string[] = []
|
||||
if (senderWxid) candidates.push(senderWxid)
|
||||
if (!candidates.includes(normalizedSessionId)) candidates.push(normalizedSessionId)
|
||||
if (myWxid && !candidates.includes(myWxid)) candidates.push(myWxid)
|
||||
|
||||
pending.push({
|
||||
cacheKey,
|
||||
request: {
|
||||
session_id: normalizedSessionId,
|
||||
create_time: createTime,
|
||||
local_id: localId,
|
||||
svr_id: item?.serverId || 0,
|
||||
candidates
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (pending.length === 0) {
|
||||
return { success: true, prepared: nowPrepared.size }
|
||||
}
|
||||
|
||||
const chunkSize = Math.max(8, Math.min(128, Math.floor(Number(options?.chunkSize || 48))))
|
||||
const decodeConcurrency = Math.max(1, Math.min(6, Math.floor(Number(options?.decodeConcurrency || 3))))
|
||||
let prepared = nowPrepared.size - pending.length
|
||||
|
||||
for (let i = 0; i < pending.length; i += chunkSize) {
|
||||
const chunk = pending.slice(i, i + chunkSize)
|
||||
const batchResult = await wcdbService.getVoiceDataBatch(chunk.map(item => item.request))
|
||||
if (!batchResult.success || !Array.isArray(batchResult.rows)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const byIndex = new Map<number, string>()
|
||||
for (const row of batchResult.rows as Array<Record<string, any>>) {
|
||||
const idx = Number.parseInt(String(row?.index ?? ''), 10)
|
||||
const hex = String(row?.hex || '').trim()
|
||||
if (!Number.isFinite(idx) || idx < 0 || !hex) continue
|
||||
byIndex.set(idx, hex)
|
||||
}
|
||||
|
||||
const readyItems: Array<{ cacheKey: string; hex: string }> = []
|
||||
for (let rowIdx = 0; rowIdx < chunk.length; rowIdx += 1) {
|
||||
const hex = byIndex.get(rowIdx)
|
||||
if (!hex) continue
|
||||
readyItems.push({ cacheKey: chunk[rowIdx].cacheKey, hex })
|
||||
}
|
||||
|
||||
await this.forEachWithConcurrency(readyItems, decodeConcurrency, async (item) => {
|
||||
const silkData = this.decodeVoiceBlob(item.hex)
|
||||
if (!silkData || silkData.length === 0) return
|
||||
|
||||
const pcmData = await this.decodeSilkToPcm(silkData, 24000)
|
||||
if (!pcmData || pcmData.length === 0) return
|
||||
|
||||
const wavData = this.createWavBuffer(pcmData, 24000)
|
||||
this.cacheVoiceWav(item.cacheKey, wavData)
|
||||
this.cacheVoiceWavToFile(item.cacheKey, wavData)
|
||||
prepared += 1
|
||||
})
|
||||
}
|
||||
|
||||
return { success: true, prepared }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查语音是否已有缓存(只检查内存,不查询数据库)
|
||||
*/
|
||||
@@ -6141,121 +5869,8 @@ class ChatService {
|
||||
const msgResult = await this.getMessageByLocalId(sessionId, localId)
|
||||
if (!msgResult.success || !msgResult.message) return { success: false, error: '未找到该消息' }
|
||||
const msg = msgResult.message
|
||||
if (msg.isSend === 1) {
|
||||
console.info('[ChatService][Voice] self-sent voice, continue decrypt flow')
|
||||
}
|
||||
|
||||
const candidates = this.getVoiceLookupCandidates(sessionId, msg)
|
||||
if (candidates.length === 0) {
|
||||
return { success: false, error: '未找到语音关联账号' }
|
||||
}
|
||||
console.info('[ChatService][Voice] request', {
|
||||
sessionId,
|
||||
localId: msg.localId,
|
||||
createTime: msg.createTime,
|
||||
candidates
|
||||
})
|
||||
|
||||
// 2. 查找所有的 media_*.db
|
||||
let mediaDbs = await wcdbService.listMediaDbs()
|
||||
// Fallback: 如果 WCDB DLL 不支持 listMediaDbs,手动查找
|
||||
if (!mediaDbs.success || !mediaDbs.data || mediaDbs.data.length === 0) {
|
||||
const manualMediaDbs = await this.findMediaDbsManually()
|
||||
if (manualMediaDbs.length > 0) {
|
||||
mediaDbs = { success: true, data: manualMediaDbs }
|
||||
} else {
|
||||
return { success: false, error: '未找到媒体库文件 (media_*.db)' }
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 在所有媒体库中查找该消息的语音数据
|
||||
let silkData: Buffer | null = null
|
||||
for (const dbPath of (mediaDbs.data || [])) {
|
||||
const voiceTable = await this.resolveVoiceInfoTableName(dbPath)
|
||||
if (!voiceTable) {
|
||||
continue
|
||||
}
|
||||
const columns = await this.resolveVoiceInfoColumns(dbPath, voiceTable)
|
||||
if (!columns) {
|
||||
continue
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const chatNameId = await this.resolveChatNameId(dbPath, candidate)
|
||||
// 策略 1: 使用 ChatNameId + CreateTime (最准确)
|
||||
if (chatNameId) {
|
||||
let whereClause = ''
|
||||
if (columns.chatNameIdColumn && columns.createTimeColumn) {
|
||||
whereClause = `${columns.chatNameIdColumn} = ${chatNameId} AND ${columns.createTimeColumn} = ${msg.createTime}`
|
||||
const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1`
|
||||
const result = await wcdbService.execQuery('media', dbPath, sql)
|
||||
if (result.success && result.rows && result.rows.length > 0) {
|
||||
const raw = result.rows[0]?.data
|
||||
const decoded = this.decodeVoiceBlob(raw)
|
||||
if (decoded && decoded.length > 0) {
|
||||
console.info('[ChatService][Voice] hit by createTime', { dbPath, voiceTable, whereClause, bytes: decoded.length })
|
||||
silkData = decoded
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 策略 2: 使用 MsgLocalId (兜底,如果表支持)
|
||||
if (columns.msgLocalIdColumn) {
|
||||
const whereClause = `${columns.msgLocalIdColumn} = ${msg.localId}`
|
||||
const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1`
|
||||
const result = await wcdbService.execQuery('media', dbPath, sql)
|
||||
if (result.success && result.rows && result.rows.length > 0) {
|
||||
const raw = result.rows[0]?.data
|
||||
const decoded = this.decodeVoiceBlob(raw)
|
||||
if (decoded && decoded.length > 0) {
|
||||
console.info('[ChatService][Voice] hit by localId', { dbPath, voiceTable, whereClause, bytes: decoded.length })
|
||||
silkData = decoded
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (silkData) break
|
||||
|
||||
// 策略 3: 只使用 CreateTime (兜底)
|
||||
if (!silkData && columns.createTimeColumn) {
|
||||
const whereClause = `${columns.createTimeColumn} = ${msg.createTime}`
|
||||
const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1`
|
||||
const result = await wcdbService.execQuery('media', dbPath, sql)
|
||||
if (result.success && result.rows && result.rows.length > 0) {
|
||||
const raw = result.rows[0]?.data
|
||||
const decoded = this.decodeVoiceBlob(raw)
|
||||
if (decoded && decoded.length > 0) {
|
||||
console.info('[ChatService][Voice] hit by createTime only', { dbPath, voiceTable, whereClause, bytes: decoded.length })
|
||||
silkData = decoded
|
||||
}
|
||||
}
|
||||
}
|
||||
if (silkData) break
|
||||
}
|
||||
|
||||
if (!silkData) return { success: false, error: '未找到语音数据' }
|
||||
|
||||
// 4. 使用 silk-wasm 解码
|
||||
try {
|
||||
const pcmData = await this.decodeSilkToPcm(silkData, 24000)
|
||||
if (!pcmData) {
|
||||
return { success: false, error: 'Silk 解码失败' }
|
||||
}
|
||||
|
||||
// PCM -> WAV
|
||||
const wavData = this.createWavBuffer(pcmData, 24000)
|
||||
|
||||
// 缓存 WAV 数据 (内存缓存)
|
||||
const cacheKey = this.getVoiceCacheKey(sessionId, msgId)
|
||||
this.cacheVoiceWav(cacheKey, wavData)
|
||||
|
||||
return { success: true, data: wavData.toString('base64') }
|
||||
} catch (e) {
|
||||
console.error('[ChatService][Voice] decoding error:', e)
|
||||
return { success: false, error: '语音解码失败: ' + String(e) }
|
||||
}
|
||||
const senderWxid = msg.senderUsername || undefined
|
||||
return this.getVoiceData(sessionId, msgId, msg.createTime, msg.serverId, senderWxid)
|
||||
} catch (e) {
|
||||
console.error('ChatService: getVoiceData 失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
@@ -6449,9 +6064,9 @@ class ChatService {
|
||||
|
||||
|
||||
private getVoiceCacheKey(sessionId: string, msgId: string, createTime?: number): string {
|
||||
// 优先使用 createTime 作为key,避免不同会话中localId相同导致的混乱
|
||||
// createTime + msgId 可避免同会话同秒多条语音互相覆盖
|
||||
if (createTime) {
|
||||
return `${sessionId}_${createTime}`
|
||||
return `${sessionId}_${createTime}_${msgId}`
|
||||
}
|
||||
return `${sessionId}_${msgId}`
|
||||
}
|
||||
@@ -6546,10 +6161,10 @@ class ChatService {
|
||||
for (const key of this.voiceTranscriptCache.keys()) {
|
||||
const rawKey = String(key || '')
|
||||
if (!rawKey) continue
|
||||
// cacheKey 形如 `${sessionId}_${createTime}`,createTime 为数字;兼容旧 key 时使用贪婪匹配。
|
||||
const match = /^(.*)_(\d+)$/.exec(rawKey)
|
||||
if (!match) continue
|
||||
const sessionId = String(match[1] || '').trim()
|
||||
// 新 key: `${sessionId}_${createTime}_${msgId}`;旧 key: `${sessionId}_${createTime}`
|
||||
const matchNew = /^(.*)_(\d+)_(\d+)$/.exec(rawKey)
|
||||
const matchOld = matchNew ? null : /^(.*)_(\d+)$/.exec(rawKey)
|
||||
const sessionId = String((matchNew ? matchNew[1] : (matchOld ? matchOld[1] : '')) || '').trim()
|
||||
if (!sessionId || !targetSet.has(sessionId)) continue
|
||||
countMap[sessionId] = (countMap[sessionId] || 0) + 1
|
||||
}
|
||||
@@ -6567,36 +6182,12 @@ class ChatService {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
|
||||
// 获取会话表信息
|
||||
let tables = this.sessionTablesCache.get(sessionId)
|
||||
if (!tables) {
|
||||
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||
if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) {
|
||||
return { success: false, error: '未找到会话消息表' }
|
||||
}
|
||||
tables = tableStats.tables
|
||||
.map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path }))
|
||||
.filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }>
|
||||
if (tables.length > 0) {
|
||||
this.sessionTablesCache.set(sessionId, tables)
|
||||
setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl)
|
||||
}
|
||||
const result = await wcdbService.getMessagesByType(sessionId, 34, false, 0, 0)
|
||||
if (!result.success || !Array.isArray(result.rows)) {
|
||||
return { success: false, error: result.error || '查询语音消息失败' }
|
||||
}
|
||||
|
||||
let allVoiceMessages: Message[] = []
|
||||
|
||||
for (const { tableName, dbPath } of tables) {
|
||||
try {
|
||||
const sql = `SELECT * FROM ${tableName} WHERE local_type = 34 ORDER BY create_time DESC`
|
||||
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||
if (result.success && result.rows && result.rows.length > 0) {
|
||||
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[])
|
||||
allVoiceMessages.push(...mapped)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[ChatService] 查询语音消息失败 (${dbPath}):`, e)
|
||||
}
|
||||
}
|
||||
let allVoiceMessages: Message[] = this.mapRowsToMessages(result.rows as Record<string, any>[])
|
||||
|
||||
// 按 createTime 降序排序
|
||||
allVoiceMessages.sort((a, b) => b.createTime - a.createTime)
|
||||
@@ -6634,43 +6225,20 @@ class ChatService {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
|
||||
let tables = this.sessionTablesCache.get(sessionId)
|
||||
if (!tables) {
|
||||
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||
if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) {
|
||||
return { success: false, error: '未找到会话消息表' }
|
||||
}
|
||||
tables = tableStats.tables
|
||||
.map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path }))
|
||||
.filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }>
|
||||
if (tables.length > 0) {
|
||||
this.sessionTablesCache.set(sessionId, tables)
|
||||
setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl)
|
||||
}
|
||||
const result = await wcdbService.getMessagesByType(sessionId, 3, false, 0, 0)
|
||||
if (!result.success || !Array.isArray(result.rows)) {
|
||||
return { success: false, error: result.error || '查询图片消息失败' }
|
||||
}
|
||||
|
||||
let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = []
|
||||
|
||||
for (const { tableName, dbPath } of tables) {
|
||||
try {
|
||||
const sql = `SELECT * FROM ${tableName} WHERE local_type = 3 ORDER BY create_time DESC`
|
||||
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||
if (result.success && result.rows && result.rows.length > 0) {
|
||||
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[])
|
||||
const images = mapped
|
||||
.filter(msg => msg.localType === 3)
|
||||
.map(msg => ({
|
||||
imageMd5: msg.imageMd5 || undefined,
|
||||
imageDatName: msg.imageDatName || undefined,
|
||||
createTime: msg.createTime || undefined
|
||||
}))
|
||||
.filter(img => Boolean(img.imageMd5 || img.imageDatName))
|
||||
allImages.push(...images)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[ChatService] 查询图片消息失败 (${dbPath}):`, e)
|
||||
}
|
||||
}
|
||||
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[])
|
||||
let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = mapped
|
||||
.filter(msg => msg.localType === 3)
|
||||
.map(msg => ({
|
||||
imageMd5: msg.imageMd5 || undefined,
|
||||
imageDatName: msg.imageDatName || undefined,
|
||||
createTime: msg.createTime || undefined
|
||||
}))
|
||||
.filter(img => Boolean(img.imageMd5 || img.imageDatName))
|
||||
|
||||
allImages.sort((a, b) => (b.createTime || 0) - (a.createTime || 0))
|
||||
|
||||
@@ -6719,50 +6287,11 @@ class ChatService {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
|
||||
let tables = this.sessionTablesCache.get(sessionId)
|
||||
if (!tables) {
|
||||
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||
if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) {
|
||||
return { success: false, error: '未找到会话消息表' }
|
||||
}
|
||||
tables = tableStats.tables
|
||||
.map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path }))
|
||||
.filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }>
|
||||
if (tables.length > 0) {
|
||||
this.sessionTablesCache.set(sessionId, tables)
|
||||
setTimeout(() => {
|
||||
this.sessionTablesCache.delete(sessionId)
|
||||
}, this.sessionTablesCacheTtl)
|
||||
}
|
||||
}
|
||||
|
||||
const counts: Record<string, number> = {}
|
||||
let hasAnySuccess = false
|
||||
|
||||
for (const { tableName, dbPath } of tables) {
|
||||
try {
|
||||
const escapedTableName = String(tableName).replace(/"/g, '""')
|
||||
const sql = `SELECT strftime('%Y-%m-%d', CASE WHEN create_time > 10000000000 THEN create_time / 1000 ELSE create_time END, 'unixepoch', 'localtime') AS date_key, COUNT(*) AS message_count FROM "${escapedTableName}" WHERE create_time IS NOT NULL GROUP BY date_key`
|
||||
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||
if (!result.success || !Array.isArray(result.rows)) {
|
||||
console.warn(`[ChatService] 查询每日消息数失败 (${dbPath}):`, result.error)
|
||||
continue
|
||||
}
|
||||
hasAnySuccess = true
|
||||
result.rows.forEach((row: Record<string, unknown>) => {
|
||||
const date = String(row.date_key || '').trim()
|
||||
const count = Number(row.message_count || 0)
|
||||
if (!date || !Number.isFinite(count) || count <= 0) return
|
||||
counts[date] = (counts[date] || 0) + count
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn(`[ChatService] 聚合每日消息数失败 (${dbPath}):`, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAnySuccess) {
|
||||
return { success: false, error: '查询每日消息数失败' }
|
||||
const result = await wcdbService.getSessionMessageDateCounts(sessionId)
|
||||
if (!result.success || !result.counts) {
|
||||
return { success: false, error: result.error || '查询每日消息数失败' }
|
||||
}
|
||||
const counts = result.counts
|
||||
|
||||
console.log(`[ChatService] 会话 ${sessionId} 获取到 ${Object.keys(counts).length} 个日期的消息计数`)
|
||||
return { success: true, counts }
|
||||
@@ -6774,54 +6303,12 @@ class ChatService {
|
||||
|
||||
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
|
||||
try {
|
||||
// 1. 尝试从缓存获取会话表信息
|
||||
let tables = this.sessionTablesCache.get(sessionId)
|
||||
|
||||
if (!tables) {
|
||||
// 缓存未命中,查询数据库
|
||||
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||
if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) {
|
||||
return { success: false, error: '未找到会话消息表' }
|
||||
}
|
||||
|
||||
// 提取表信息并缓存
|
||||
tables = tableStats.tables
|
||||
.map(t => ({
|
||||
tableName: t.table_name || t.name,
|
||||
dbPath: t.db_path
|
||||
}))
|
||||
.filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }>
|
||||
|
||||
if (tables.length > 0) {
|
||||
this.sessionTablesCache.set(sessionId, tables)
|
||||
// 设置过期清理
|
||||
setTimeout(() => {
|
||||
this.sessionTablesCache.delete(sessionId)
|
||||
}, this.sessionTablesCacheTtl)
|
||||
}
|
||||
const nativeResult = await wcdbService.getMessageById(sessionId, localId)
|
||||
if (nativeResult.success && nativeResult.message) {
|
||||
const message = await this.parseMessage(nativeResult.message as Record<string, any>, { source: 'detail', sessionId })
|
||||
if (message.localId !== 0) return { success: true, message }
|
||||
}
|
||||
|
||||
// 2. 遍历表查找消息 (通常只有一个主表,但可能有归档)
|
||||
for (const { tableName, dbPath } of tables) {
|
||||
// 构造查询
|
||||
const sql = `SELECT * FROM ${tableName} WHERE local_id = ${localId} LIMIT 1`
|
||||
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||
|
||||
if (result.success && result.rows && result.rows.length > 0) {
|
||||
const row = {
|
||||
...(result.rows[0] as Record<string, any>),
|
||||
db_path: dbPath,
|
||||
table_name: tableName
|
||||
}
|
||||
const message = await this.parseMessage(row, { source: 'detail', sessionId })
|
||||
|
||||
if (message.localId !== 0) {
|
||||
return { success: true, message }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: '未找到消息' }
|
||||
return { success: false, error: nativeResult.error || '未找到消息' }
|
||||
} catch (e) {
|
||||
console.error('ChatService: getMessageById 失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
@@ -7218,6 +6705,7 @@ class ChatService {
|
||||
if (!connectResult.success) {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
// fallback-exec: 仅用于诊断/低频兼容,不作为业务主路径
|
||||
return wcdbService.execQuery(kind, path, sql)
|
||||
} catch (e) {
|
||||
console.error('ChatService: 执行自定义查询失败:', e)
|
||||
|
||||
+837
-206
@@ -128,6 +128,10 @@ export interface ExportProgress {
|
||||
phaseProgress?: number
|
||||
phaseTotal?: number
|
||||
phaseLabel?: string
|
||||
collectedMessages?: number
|
||||
exportedMessages?: number
|
||||
estimatedTotalMessages?: number
|
||||
writtenFiles?: number
|
||||
}
|
||||
|
||||
interface ExportTaskControl {
|
||||
@@ -350,6 +354,73 @@ class ExportService {
|
||||
return Math.max(1, Math.min(raw, max))
|
||||
}
|
||||
|
||||
private createProgressEmitter(onProgress?: (progress: ExportProgress) => void): {
|
||||
emit: (progress: ExportProgress, options?: { force?: boolean }) => void
|
||||
flush: () => void
|
||||
} {
|
||||
if (!onProgress) {
|
||||
return {
|
||||
emit: () => { /* noop */ },
|
||||
flush: () => { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
let pending: ExportProgress | null = null
|
||||
let lastSentAt = 0
|
||||
let lastPhase = ''
|
||||
let lastSessionId = ''
|
||||
let lastCollected = 0
|
||||
let lastExported = 0
|
||||
|
||||
const commit = (progress: ExportProgress) => {
|
||||
onProgress(progress)
|
||||
pending = null
|
||||
lastSentAt = Date.now()
|
||||
lastPhase = String(progress.phase || '')
|
||||
lastSessionId = String(progress.currentSessionId || '')
|
||||
lastCollected = Number.isFinite(progress.collectedMessages) ? Math.max(0, Math.floor(progress.collectedMessages || 0)) : lastCollected
|
||||
lastExported = Number.isFinite(progress.exportedMessages) ? Math.max(0, Math.floor(progress.exportedMessages || 0)) : lastExported
|
||||
}
|
||||
|
||||
const emit = (progress: ExportProgress, options?: { force?: boolean }) => {
|
||||
pending = progress
|
||||
const force = options?.force === true
|
||||
const now = Date.now()
|
||||
const phase = String(progress.phase || '')
|
||||
const sessionId = String(progress.currentSessionId || '')
|
||||
const collected = Number.isFinite(progress.collectedMessages) ? Math.max(0, Math.floor(progress.collectedMessages || 0)) : lastCollected
|
||||
const exported = Number.isFinite(progress.exportedMessages) ? Math.max(0, Math.floor(progress.exportedMessages || 0)) : lastExported
|
||||
const collectedDelta = Math.abs(collected - lastCollected)
|
||||
const exportedDelta = Math.abs(exported - lastExported)
|
||||
const shouldEmit = force ||
|
||||
phase !== lastPhase ||
|
||||
sessionId !== lastSessionId ||
|
||||
collectedDelta >= 200 ||
|
||||
exportedDelta >= 200 ||
|
||||
(now - lastSentAt >= 120)
|
||||
|
||||
if (shouldEmit && pending) {
|
||||
commit(pending)
|
||||
}
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
if (!pending) return
|
||||
commit(pending)
|
||||
}
|
||||
|
||||
return { emit, flush }
|
||||
}
|
||||
|
||||
private async pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(filePath, fs.constants.F_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private isMediaExportEnabled(options: ExportOptions): boolean {
|
||||
return options.exportMedia === true &&
|
||||
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
|
||||
@@ -428,7 +499,8 @@ class ExportService {
|
||||
total: 100,
|
||||
currentSession: sessionName,
|
||||
phase: 'preparing',
|
||||
phaseLabel: `收集消息 ${fetched.toLocaleString()} 条`
|
||||
phaseLabel: `收集消息 ${fetched.toLocaleString()} 条`,
|
||||
collectedMessages: fetched
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -464,6 +536,39 @@ class ExportService {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
private getIntFromRow(row: Record<string, any>, keys: string[], fallback = 0): number {
|
||||
for (const key of keys) {
|
||||
const raw = row?.[key]
|
||||
if (raw === undefined || raw === null || raw === '') continue
|
||||
const parsed = Number.parseInt(String(raw), 10)
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private normalizeUnsignedIntToken(value: unknown): string {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return '0'
|
||||
if (/^\d+$/.test(raw)) {
|
||||
return raw.replace(/^0+(?=\d)/, '')
|
||||
}
|
||||
const num = Number(raw)
|
||||
if (!Number.isFinite(num) || num <= 0) return '0'
|
||||
return String(Math.floor(num))
|
||||
}
|
||||
|
||||
private getStableMessageKey(msg: { localId?: unknown; createTime?: unknown; serverId?: unknown }): string {
|
||||
const localId = this.normalizeUnsignedIntToken(msg?.localId)
|
||||
const createTime = this.normalizeUnsignedIntToken(msg?.createTime)
|
||||
const serverId = this.normalizeUnsignedIntToken(msg?.serverId)
|
||||
return `${localId}:${createTime}:${serverId}`
|
||||
}
|
||||
|
||||
private getMediaCacheKey(msg: { localType?: unknown; localId?: unknown; createTime?: unknown; serverId?: unknown }): string {
|
||||
const localType = this.normalizeUnsignedIntToken(msg?.localType)
|
||||
return `${localType}_${this.getStableMessageKey(msg)}`
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
@@ -577,13 +682,11 @@ class ExportService {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -2162,13 +2265,14 @@ class ExportService {
|
||||
exportEmojis?: boolean
|
||||
exportVoiceAsText?: boolean
|
||||
includeVoiceWithTranscript?: boolean
|
||||
dirCache?: Set<string>
|
||||
}
|
||||
): Promise<MediaExportItem | null> {
|
||||
const localType = msg.localType
|
||||
|
||||
// 图片消息
|
||||
if (localType === 3 && options.exportImages) {
|
||||
const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
||||
const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache)
|
||||
if (result) {
|
||||
}
|
||||
return result
|
||||
@@ -2177,7 +2281,7 @@ class ExportService {
|
||||
// 语音消息
|
||||
if (localType === 34) {
|
||||
if (options.exportVoices) {
|
||||
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
||||
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache)
|
||||
}
|
||||
if (options.exportVoiceAsText) {
|
||||
return null
|
||||
@@ -2186,14 +2290,14 @@ class ExportService {
|
||||
|
||||
// 动画表情
|
||||
if (localType === 47 && options.exportEmojis) {
|
||||
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
||||
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache)
|
||||
if (result) {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
if (localType === 43 && options.exportVideos) {
|
||||
return this.exportVideo(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
||||
return this.exportVideo(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache)
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -2206,12 +2310,14 @@ class ExportService {
|
||||
msg: any,
|
||||
sessionId: string,
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string
|
||||
mediaRelativePrefix: string,
|
||||
dirCache?: Set<string>
|
||||
): Promise<MediaExportItem | null> {
|
||||
try {
|
||||
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
|
||||
if (!fs.existsSync(imagesDir)) {
|
||||
fs.mkdirSync(imagesDir, { recursive: true })
|
||||
if (!dirCache?.has(imagesDir)) {
|
||||
await fs.promises.mkdir(imagesDir, { recursive: true })
|
||||
dirCache?.add(imagesDir)
|
||||
}
|
||||
|
||||
// 使用消息对象中已提取的字段
|
||||
@@ -2268,7 +2374,7 @@ class ExportService {
|
||||
const fileName = `${messageId}_${imageKey}${ext}`
|
||||
const destPath = path.join(imagesDir, fileName)
|
||||
|
||||
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
|
||||
await fs.promises.writeFile(destPath, Buffer.from(base64Data, 'base64'))
|
||||
|
||||
return {
|
||||
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
|
||||
@@ -2279,17 +2385,14 @@ class ExportService {
|
||||
}
|
||||
|
||||
// 复制文件
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
if (!(await this.pathExists(sourcePath))) {
|
||||
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
|
||||
return null
|
||||
}
|
||||
const ext = path.extname(sourcePath) || '.jpg'
|
||||
const fileName = `${messageId}_${imageKey}${ext}`
|
||||
const destPath = path.join(imagesDir, fileName)
|
||||
|
||||
if (!fs.existsSync(destPath)) {
|
||||
fs.copyFileSync(sourcePath, destPath)
|
||||
}
|
||||
await fs.promises.copyFile(sourcePath, destPath)
|
||||
|
||||
return {
|
||||
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
|
||||
@@ -2301,6 +2404,56 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出语音文件
|
||||
*/
|
||||
private async preloadVoiceWavCache(
|
||||
sessionId: string,
|
||||
messages: any[],
|
||||
control?: ExportTaskControl
|
||||
): Promise<void> {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return
|
||||
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
if (!normalizedSessionId) return
|
||||
|
||||
const normalized: Array<{
|
||||
localId: number
|
||||
createTime: number
|
||||
serverId?: string | number
|
||||
senderWxid?: string | null
|
||||
}> = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const msg of messages) {
|
||||
const localIdRaw = Number(msg?.localId)
|
||||
const createTimeRaw = Number(msg?.createTime)
|
||||
const localId = Number.isFinite(localIdRaw) ? Math.max(0, Math.floor(localIdRaw)) : 0
|
||||
const createTime = Number.isFinite(createTimeRaw) ? Math.max(0, Math.floor(createTimeRaw)) : 0
|
||||
if (!localId || !createTime) continue
|
||||
const dedupeKey = this.getStableMessageKey(msg)
|
||||
if (seen.has(dedupeKey)) continue
|
||||
seen.add(dedupeKey)
|
||||
normalized.push({
|
||||
localId,
|
||||
createTime,
|
||||
serverId: msg?.serverId,
|
||||
senderWxid: msg?.senderUsername || null
|
||||
})
|
||||
}
|
||||
if (normalized.length === 0) return
|
||||
|
||||
const chunkSize = 120
|
||||
for (let i = 0; i < normalized.length; i += chunkSize) {
|
||||
this.throwIfStopRequested(control)
|
||||
const chunk = normalized.slice(i, i + chunkSize)
|
||||
await chatService.preloadVoiceDataBatch(normalizedSessionId, chunk, {
|
||||
chunkSize: 48,
|
||||
decodeConcurrency: 3
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出语音文件
|
||||
*/
|
||||
@@ -2308,23 +2461,26 @@ class ExportService {
|
||||
msg: any,
|
||||
sessionId: string,
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string
|
||||
mediaRelativePrefix: string,
|
||||
dirCache?: Set<string>
|
||||
): Promise<MediaExportItem | null> {
|
||||
try {
|
||||
const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
|
||||
if (!fs.existsSync(voicesDir)) {
|
||||
fs.mkdirSync(voicesDir, { recursive: true })
|
||||
if (!dirCache?.has(voicesDir)) {
|
||||
await fs.promises.mkdir(voicesDir, { recursive: true })
|
||||
dirCache?.add(voicesDir)
|
||||
}
|
||||
|
||||
const msgId = String(msg.localId)
|
||||
const safeSession = this.cleanAccountDirName(sessionId)
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
.slice(0, 48) || 'session'
|
||||
const fileName = `voice_${safeSession}_${msgId}.wav`
|
||||
const stableKey = this.getStableMessageKey(msg).replace(/:/g, '_')
|
||||
const fileName = `voice_${safeSession}_${stableKey || msgId}.wav`
|
||||
const destPath = path.join(voicesDir, fileName)
|
||||
|
||||
// 如果已存在则跳过
|
||||
if (fs.existsSync(destPath)) {
|
||||
if (await this.pathExists(destPath)) {
|
||||
return {
|
||||
relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
|
||||
kind: 'voice'
|
||||
@@ -2332,14 +2488,20 @@ class ExportService {
|
||||
}
|
||||
|
||||
// 调用 chatService 获取语音数据
|
||||
const voiceResult = await chatService.getVoiceData(sessionId, msgId)
|
||||
const voiceResult = await chatService.getVoiceData(
|
||||
sessionId,
|
||||
msgId,
|
||||
Number.isFinite(Number(msg?.createTime)) ? Number(msg.createTime) : undefined,
|
||||
msg?.serverId,
|
||||
msg?.senderUsername || undefined
|
||||
)
|
||||
if (!voiceResult.success || !voiceResult.data) {
|
||||
return null
|
||||
}
|
||||
|
||||
// voiceResult.data 是 base64 编码的 wav 数据
|
||||
const wavBuffer = Buffer.from(voiceResult.data, 'base64')
|
||||
fs.writeFileSync(destPath, wavBuffer)
|
||||
await fs.promises.writeFile(destPath, wavBuffer)
|
||||
|
||||
return {
|
||||
relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
|
||||
@@ -2372,18 +2534,20 @@ class ExportService {
|
||||
msg: any,
|
||||
sessionId: string,
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string
|
||||
mediaRelativePrefix: string,
|
||||
dirCache?: Set<string>
|
||||
): Promise<MediaExportItem | null> {
|
||||
try {
|
||||
const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
|
||||
if (!fs.existsSync(emojisDir)) {
|
||||
fs.mkdirSync(emojisDir, { recursive: true })
|
||||
if (!dirCache?.has(emojisDir)) {
|
||||
await fs.promises.mkdir(emojisDir, { recursive: true })
|
||||
dirCache?.add(emojisDir)
|
||||
}
|
||||
|
||||
// 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑)
|
||||
const localPath = await chatService.downloadEmojiFile(msg)
|
||||
|
||||
if (!localPath || !fs.existsSync(localPath)) {
|
||||
if (!localPath || !(await this.pathExists(localPath))) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -2393,10 +2557,7 @@ class ExportService {
|
||||
const fileName = `${key}${ext}`
|
||||
const destPath = path.join(emojisDir, fileName)
|
||||
|
||||
// 复制文件到导出目录 (如果不存在)
|
||||
if (!fs.existsSync(destPath)) {
|
||||
fs.copyFileSync(localPath, destPath)
|
||||
}
|
||||
await fs.promises.copyFile(localPath, destPath)
|
||||
|
||||
return {
|
||||
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
|
||||
@@ -2415,15 +2576,17 @@ class ExportService {
|
||||
msg: any,
|
||||
sessionId: string,
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string
|
||||
mediaRelativePrefix: string,
|
||||
dirCache?: Set<string>
|
||||
): Promise<MediaExportItem | null> {
|
||||
try {
|
||||
const videoMd5 = msg.videoMd5
|
||||
if (!videoMd5) return null
|
||||
|
||||
const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos')
|
||||
if (!fs.existsSync(videosDir)) {
|
||||
fs.mkdirSync(videosDir, { recursive: true })
|
||||
if (!dirCache?.has(videosDir)) {
|
||||
await fs.promises.mkdir(videosDir, { recursive: true })
|
||||
dirCache?.add(videosDir)
|
||||
}
|
||||
|
||||
const videoInfo = await videoService.getVideoInfo(videoMd5)
|
||||
@@ -2435,9 +2598,7 @@ class ExportService {
|
||||
const fileName = path.basename(sourcePath)
|
||||
const destPath = path.join(videosDir, fileName)
|
||||
|
||||
if (!fs.existsSync(destPath)) {
|
||||
fs.copyFileSync(sourcePath, destPath)
|
||||
}
|
||||
await fs.promises.copyFile(sourcePath, destPath)
|
||||
|
||||
return {
|
||||
relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName),
|
||||
@@ -2707,12 +2868,19 @@ class ExportService {
|
||||
if ((rowIndex++ & 0x7f) === 0) {
|
||||
this.throwIfStopRequested(control)
|
||||
}
|
||||
const createTime = parseInt(row.create_time || '0', 10)
|
||||
const createTime = this.getIntFromRow(row, [
|
||||
'create_time', 'createTime', 'createtime',
|
||||
'msg_create_time', 'msgCreateTime',
|
||||
'msg_time', 'msgTime', 'time',
|
||||
'WCDB_CT_create_time'
|
||||
], 0)
|
||||
if (dateRange) {
|
||||
if (createTime < dateRange.start || createTime > dateRange.end) continue
|
||||
}
|
||||
|
||||
const localType = parseInt(row.local_type || row.type || '1', 10)
|
||||
const localType = this.getIntFromRow(row, [
|
||||
'local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'
|
||||
], 1)
|
||||
if (mediaTypeFilter && !mediaTypeFilter.has(localType)) {
|
||||
continue
|
||||
}
|
||||
@@ -2725,7 +2893,18 @@ class ExportService {
|
||||
const senderUsername = row.sender_username || ''
|
||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
|
||||
const isSend = parseInt(isSendRaw, 10) === 1
|
||||
const localId = parseInt(row.local_id || row.localId || '0', 10)
|
||||
const localId = this.getIntFromRow(row, [
|
||||
'local_id', 'localId', 'LocalId',
|
||||
'msg_local_id', 'msgLocalId', 'MsgLocalId',
|
||||
'msg_id', 'msgId', 'MsgId', 'id',
|
||||
'WCDB_CT_local_id'
|
||||
], 0)
|
||||
const serverId = this.getIntFromRow(row, [
|
||||
'server_id', 'serverId', 'ServerId',
|
||||
'msg_server_id', 'msgServerId', 'MsgServerId',
|
||||
'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId',
|
||||
'WCDB_CT_server_id'
|
||||
], 0)
|
||||
|
||||
// 确定实际发送者
|
||||
let actualSender: string
|
||||
@@ -2809,6 +2988,7 @@ class ExportService {
|
||||
|
||||
rows.push({
|
||||
localId,
|
||||
serverId,
|
||||
createTime,
|
||||
localType,
|
||||
content,
|
||||
@@ -3073,18 +3253,12 @@ class ExportService {
|
||||
)
|
||||
if (unique.length === 0) return result
|
||||
|
||||
const BATCH = 200
|
||||
for (let i = 0; i < unique.length; i += BATCH) {
|
||||
const batch = unique.slice(i, i + BATCH)
|
||||
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
|
||||
const sql = `SELECT username, local_type FROM contact WHERE username IN (${inList})`
|
||||
const query = await wcdbService.execQuery('contact', null, sql)
|
||||
if (!query.success || !query.rows) continue
|
||||
for (const row of query.rows) {
|
||||
const username = String((row as any).username || '').trim()
|
||||
if (!username) continue
|
||||
const localType = Number.parseInt(String((row as any).local_type ?? (row as any).localType ?? (row as any).WCDB_CT_local_type ?? ''), 10)
|
||||
result.set(username, Number.isFinite(localType) && localType === 1)
|
||||
const query = await wcdbService.getContactFriendFlags(unique)
|
||||
if (query.success && query.map) {
|
||||
for (const [username, isFriend] of Object.entries(query.map)) {
|
||||
const normalized = String(username || '').trim()
|
||||
if (!normalized) continue
|
||||
result.set(normalized, Boolean(isFriend))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3396,9 +3570,10 @@ class ExportService {
|
||||
collectProgressReporter
|
||||
)
|
||||
const allMessages = collected.rows
|
||||
const totalMessages = allMessages.length
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
if (allMessages.length === 0) {
|
||||
if (totalMessages === 0) {
|
||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||
}
|
||||
|
||||
@@ -3466,8 +3641,14 @@ class ExportService {
|
||||
: []
|
||||
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
const mediaDirCache = new Set<string>()
|
||||
|
||||
if (mediaMessages.length > 0) {
|
||||
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
||||
if (voiceMediaMessages.length > 0) {
|
||||
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
current: 20,
|
||||
total: 100,
|
||||
@@ -3475,7 +3656,8 @@ class ExportService {
|
||||
phase: 'exporting-media',
|
||||
phaseProgress: 0,
|
||||
phaseTotal: mediaMessages.length,
|
||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`
|
||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
||||
estimatedTotalMessages: totalMessages
|
||||
})
|
||||
|
||||
// 并行导出媒体,并发数跟随导出设置
|
||||
@@ -3483,14 +3665,15 @@ class ExportService {
|
||||
let mediaExported = 0
|
||||
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||
this.throwIfStopRequested(control)
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaKey = this.getMediaCacheKey(msg)
|
||||
if (!mediaCache.has(mediaKey)) {
|
||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportVideos: options.exportVideos,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
@@ -3510,9 +3693,11 @@ class ExportService {
|
||||
}
|
||||
|
||||
// ========== 阶段2:并行语音转文字 ==========
|
||||
const voiceTranscriptMap = new Map<number, string>()
|
||||
const voiceTranscriptMap = new Map<string, string>()
|
||||
|
||||
if (voiceMessages.length > 0) {
|
||||
await this.preloadVoiceWavCache(sessionId, voiceMessages, control)
|
||||
|
||||
onProgress?.({
|
||||
current: 40,
|
||||
total: 100,
|
||||
@@ -3520,7 +3705,8 @@ class ExportService {
|
||||
phase: 'exporting-voice',
|
||||
phaseProgress: 0,
|
||||
phaseTotal: voiceMessages.length,
|
||||
phaseLabel: `语音转文字 0/${voiceMessages.length}`
|
||||
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
|
||||
estimatedTotalMessages: totalMessages
|
||||
})
|
||||
|
||||
// 并行转写语音,限制 4 个并发(转写比较耗资源)
|
||||
@@ -3529,7 +3715,7 @@ class ExportService {
|
||||
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||
this.throwIfStopRequested(control)
|
||||
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
||||
voiceTranscriptMap.set(msg.localId, transcript)
|
||||
voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript)
|
||||
voiceTranscribed++
|
||||
onProgress?.({
|
||||
current: 40,
|
||||
@@ -3548,7 +3734,10 @@ class ExportService {
|
||||
current: 60,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting'
|
||||
phase: 'exporting',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: 0
|
||||
})
|
||||
|
||||
const chatLabMessages: ChatLabMessage[] = []
|
||||
@@ -3591,11 +3780,11 @@ class ExportService {
|
||||
|
||||
// 确定消息内容
|
||||
let content: string | null
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaKey = this.getMediaCacheKey(msg)
|
||||
const mediaItem = mediaCache.get(mediaKey)
|
||||
if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||
// 使用预先转写的文字
|
||||
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||
content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
|
||||
} else if (mediaItem && msg.localType === 3) {
|
||||
content = mediaItem.relativePath
|
||||
} else {
|
||||
@@ -3730,6 +3919,18 @@ class ExportService {
|
||||
}
|
||||
|
||||
chatLabMessages.push(message)
|
||||
if ((chatLabMessages.length % 200) === 0 || chatLabMessages.length === totalMessages) {
|
||||
const exportProgress = 60 + Math.floor((chatLabMessages.length / totalMessages) * 20)
|
||||
onProgress?.({
|
||||
current: exportProgress,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: chatLabMessages.length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const avatarMap = options.exportAvatars
|
||||
@@ -3780,7 +3981,10 @@ class ExportService {
|
||||
current: 80,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'writing'
|
||||
phase: 'writing',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: totalMessages
|
||||
})
|
||||
|
||||
if (options.format === 'chatlab-jsonl') {
|
||||
@@ -3799,17 +4003,21 @@ class ExportService {
|
||||
lines.push(JSON.stringify({ _type: 'message', ...message }))
|
||||
}
|
||||
this.throwIfStopRequested(control)
|
||||
fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8')
|
||||
await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8')
|
||||
} else {
|
||||
this.throwIfStopRequested(control)
|
||||
fs.writeFileSync(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8')
|
||||
await fs.promises.writeFile(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
current: 100,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'complete'
|
||||
phase: 'complete',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: totalMessages,
|
||||
writtenFiles: 1
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
@@ -3872,9 +4080,10 @@ class ExportService {
|
||||
control,
|
||||
collectProgressReporter
|
||||
)
|
||||
const totalMessages = collected.rows.length
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
if (collected.rows.length === 0) {
|
||||
if (totalMessages === 0) {
|
||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||
}
|
||||
|
||||
@@ -3915,8 +4124,14 @@ class ExportService {
|
||||
: []
|
||||
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
const mediaDirCache = new Set<string>()
|
||||
|
||||
if (mediaMessages.length > 0) {
|
||||
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
||||
if (voiceMediaMessages.length > 0) {
|
||||
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
current: 15,
|
||||
total: 100,
|
||||
@@ -3924,21 +4139,23 @@ class ExportService {
|
||||
phase: 'exporting-media',
|
||||
phaseProgress: 0,
|
||||
phaseTotal: mediaMessages.length,
|
||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`
|
||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
||||
estimatedTotalMessages: totalMessages
|
||||
})
|
||||
|
||||
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||
let mediaExported = 0
|
||||
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||
this.throwIfStopRequested(control)
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaKey = this.getMediaCacheKey(msg)
|
||||
if (!mediaCache.has(mediaKey)) {
|
||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportVideos: options.exportVideos,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
@@ -3958,9 +4175,11 @@ class ExportService {
|
||||
}
|
||||
|
||||
// ========== 阶段2:并行语音转文字 ==========
|
||||
const voiceTranscriptMap = new Map<number, string>()
|
||||
const voiceTranscriptMap = new Map<string, string>()
|
||||
|
||||
if (voiceMessages.length > 0) {
|
||||
await this.preloadVoiceWavCache(sessionId, voiceMessages, control)
|
||||
|
||||
onProgress?.({
|
||||
current: 35,
|
||||
total: 100,
|
||||
@@ -3968,7 +4187,8 @@ class ExportService {
|
||||
phase: 'exporting-voice',
|
||||
phaseProgress: 0,
|
||||
phaseTotal: voiceMessages.length,
|
||||
phaseLabel: `语音转文字 0/${voiceMessages.length}`
|
||||
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
|
||||
estimatedTotalMessages: totalMessages
|
||||
})
|
||||
|
||||
const VOICE_CONCURRENCY = 4
|
||||
@@ -3976,7 +4196,7 @@ class ExportService {
|
||||
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||
this.throwIfStopRequested(control)
|
||||
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
||||
voiceTranscriptMap.set(msg.localId, transcript)
|
||||
voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript)
|
||||
voiceTranscribed++
|
||||
onProgress?.({
|
||||
current: 35,
|
||||
@@ -4007,7 +4227,10 @@ class ExportService {
|
||||
current: 55,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting'
|
||||
phase: 'exporting',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: 0
|
||||
})
|
||||
|
||||
const allMessages: any[] = []
|
||||
@@ -4030,11 +4253,11 @@ class ExportService {
|
||||
const source = sourceMatch ? sourceMatch[0] : ''
|
||||
|
||||
let content: string | null
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaKey = this.getMediaCacheKey(msg)
|
||||
const mediaItem = mediaCache.get(mediaKey)
|
||||
|
||||
if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||
content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
|
||||
} else if (mediaItem) {
|
||||
content = mediaItem.relativePath
|
||||
} else {
|
||||
@@ -4124,6 +4347,18 @@ class ExportService {
|
||||
allMessages.push(msgObj)
|
||||
if (msg.createTime < lastCreateTime) needSort = true
|
||||
lastCreateTime = msg.createTime
|
||||
if ((allMessages.length % 200) === 0 || allMessages.length === totalMessages) {
|
||||
const exportProgress = 55 + Math.floor((allMessages.length / totalMessages) * 15)
|
||||
onProgress?.({
|
||||
current: exportProgress,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: allMessages.length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (transferCandidates.length > 0) {
|
||||
@@ -4172,7 +4407,10 @@ class ExportService {
|
||||
current: 70,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'writing'
|
||||
phase: 'writing',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: totalMessages
|
||||
})
|
||||
|
||||
// 获取会话的昵称和备注信息
|
||||
@@ -4421,7 +4659,7 @@ class ExportService {
|
||||
}
|
||||
|
||||
this.throwIfStopRequested(control)
|
||||
fs.writeFileSync(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8')
|
||||
await fs.promises.writeFile(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8')
|
||||
} else {
|
||||
const detailedExport: any = {
|
||||
weflow,
|
||||
@@ -4444,14 +4682,18 @@ class ExportService {
|
||||
}
|
||||
|
||||
this.throwIfStopRequested(control)
|
||||
fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
|
||||
await fs.promises.writeFile(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
current: 100,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'complete'
|
||||
phase: 'complete',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: totalMessages,
|
||||
writtenFiles: 1
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
@@ -4519,9 +4761,10 @@ class ExportService {
|
||||
control,
|
||||
collectProgressReporter
|
||||
)
|
||||
const totalMessages = collected.rows.length
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
if (collected.rows.length === 0) {
|
||||
if (totalMessages === 0) {
|
||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||
}
|
||||
|
||||
@@ -4548,7 +4791,10 @@ class ExportService {
|
||||
current: 30,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting'
|
||||
phase: 'exporting',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: 0
|
||||
})
|
||||
|
||||
// 创建 Excel 工作簿
|
||||
@@ -4685,8 +4931,14 @@ class ExportService {
|
||||
: []
|
||||
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
const mediaDirCache = new Set<string>()
|
||||
|
||||
if (mediaMessages.length > 0) {
|
||||
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
||||
if (voiceMediaMessages.length > 0) {
|
||||
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
current: 35,
|
||||
total: 100,
|
||||
@@ -4694,21 +4946,23 @@ class ExportService {
|
||||
phase: 'exporting-media',
|
||||
phaseProgress: 0,
|
||||
phaseTotal: mediaMessages.length,
|
||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`
|
||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
||||
estimatedTotalMessages: totalMessages
|
||||
})
|
||||
|
||||
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||
let mediaExported = 0
|
||||
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||
this.throwIfStopRequested(control)
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaKey = this.getMediaCacheKey(msg)
|
||||
if (!mediaCache.has(mediaKey)) {
|
||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportVideos: options.exportVideos,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
@@ -4728,9 +4982,11 @@ class ExportService {
|
||||
}
|
||||
|
||||
// ========== 并行预处理:语音转文字 ==========
|
||||
const voiceTranscriptMap = new Map<number, string>()
|
||||
const voiceTranscriptMap = new Map<string, string>()
|
||||
|
||||
if (voiceMessages.length > 0) {
|
||||
await this.preloadVoiceWavCache(sessionId, voiceMessages, control)
|
||||
|
||||
onProgress?.({
|
||||
current: 50,
|
||||
total: 100,
|
||||
@@ -4738,7 +4994,8 @@ class ExportService {
|
||||
phase: 'exporting-voice',
|
||||
phaseProgress: 0,
|
||||
phaseTotal: voiceMessages.length,
|
||||
phaseLabel: `语音转文字 0/${voiceMessages.length}`
|
||||
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
|
||||
estimatedTotalMessages: totalMessages
|
||||
})
|
||||
|
||||
const VOICE_CONCURRENCY = 4
|
||||
@@ -4746,7 +5003,7 @@ class ExportService {
|
||||
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||
this.throwIfStopRequested(control)
|
||||
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
||||
voiceTranscriptMap.set(msg.localId, transcript)
|
||||
voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript)
|
||||
voiceTranscribed++
|
||||
onProgress?.({
|
||||
current: 50,
|
||||
@@ -4760,15 +5017,41 @@ class ExportService {
|
||||
})
|
||||
}
|
||||
|
||||
const shouldUseStreamingWriter = totalMessages > 20000
|
||||
if (shouldUseStreamingWriter) {
|
||||
return this.exportSessionToExcelStreaming({
|
||||
outputPath,
|
||||
options,
|
||||
sessionId,
|
||||
sessionInfo,
|
||||
myInfo,
|
||||
cleanedMyWxid,
|
||||
rawMyWxid,
|
||||
isGroup,
|
||||
sortedMessages,
|
||||
mediaCache,
|
||||
voiceTranscriptMap,
|
||||
getContactCached,
|
||||
groupNicknamesMap,
|
||||
onProgress,
|
||||
control,
|
||||
totalMessages
|
||||
})
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
current: 65,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting'
|
||||
phase: 'exporting',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: 0
|
||||
})
|
||||
|
||||
// ========== 写入 Excel 行 ==========
|
||||
for (let i = 0; i < sortedMessages.length; i++) {
|
||||
const senderProfileCache = new Map<string, ExportDisplayProfile>()
|
||||
for (let i = 0; i < totalMessages; i++) {
|
||||
if ((i & 0x7f) === 0) {
|
||||
this.throwIfStopRequested(control)
|
||||
}
|
||||
@@ -4782,14 +5065,19 @@ class ExportService {
|
||||
let senderGroupNickname: string = '' // 群昵称
|
||||
|
||||
if (isGroup) {
|
||||
const senderProfile = await this.resolveExportDisplayProfile(
|
||||
msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
|
||||
options.displayNamePreference,
|
||||
getContactCached,
|
||||
groupNicknamesMap,
|
||||
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
|
||||
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
|
||||
)
|
||||
const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}`
|
||||
let senderProfile = senderProfileCache.get(senderProfileKey)
|
||||
if (!senderProfile) {
|
||||
senderProfile = await this.resolveExportDisplayProfile(
|
||||
msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
|
||||
options.displayNamePreference,
|
||||
getContactCached,
|
||||
groupNicknamesMap,
|
||||
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
|
||||
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
|
||||
)
|
||||
senderProfileCache.set(senderProfileKey, senderProfile)
|
||||
}
|
||||
senderWxid = senderProfile.wxid
|
||||
senderNickname = senderProfile.nickname
|
||||
senderRemark = senderProfile.remark
|
||||
@@ -4819,7 +5107,7 @@ class ExportService {
|
||||
const row = worksheet.getRow(currentRow)
|
||||
row.height = 24
|
||||
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaKey = this.getMediaCacheKey(msg)
|
||||
const mediaItem = mediaCache.get(mediaKey)
|
||||
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
|
||||
const contentValue = shouldUseTranscript
|
||||
@@ -4827,7 +5115,7 @@ class ExportService {
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(msg.localId),
|
||||
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
|
||||
cleanedMyWxid,
|
||||
msg.senderUsername,
|
||||
msg.isSend
|
||||
@@ -4837,7 +5125,7 @@ class ExportService {
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(msg.localId),
|
||||
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
|
||||
cleanedMyWxid,
|
||||
msg.senderUsername,
|
||||
msg.isSend
|
||||
@@ -4883,14 +5171,6 @@ class ExportService {
|
||||
worksheet.getCell(currentRow, 9).value = enrichedContentValue
|
||||
}
|
||||
|
||||
// 设置每个单元格的样式
|
||||
const maxColumns = useCompactColumns ? 5 : 9
|
||||
for (let col = 1; col <= maxColumns; col++) {
|
||||
const cell = worksheet.getCell(currentRow, col)
|
||||
cell.font = { name: 'Calibri', size: 11 }
|
||||
cell.alignment = { vertical: 'middle', wrapText: false }
|
||||
}
|
||||
|
||||
currentRow++
|
||||
|
||||
// 每处理 100 条消息报告一次进度
|
||||
@@ -4900,7 +5180,10 @@ class ExportService {
|
||||
current: progress,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting'
|
||||
phase: 'exporting',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: i + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4909,7 +5192,10 @@ class ExportService {
|
||||
current: 90,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'writing'
|
||||
phase: 'writing',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: totalMessages
|
||||
})
|
||||
|
||||
// 写入文件
|
||||
@@ -4920,7 +5206,11 @@ class ExportService {
|
||||
current: 100,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'complete'
|
||||
phase: 'complete',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: totalMessages,
|
||||
writtenFiles: 1
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
@@ -4939,6 +5229,236 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
private async exportSessionToExcelStreaming(params: {
|
||||
outputPath: string
|
||||
options: ExportOptions
|
||||
sessionId: string
|
||||
sessionInfo: { displayName: string }
|
||||
myInfo: { displayName: string }
|
||||
cleanedMyWxid: string
|
||||
rawMyWxid: string
|
||||
isGroup: boolean
|
||||
sortedMessages: any[]
|
||||
mediaCache: Map<string, MediaExportItem | null>
|
||||
voiceTranscriptMap: Map<string, string>
|
||||
getContactCached: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }>
|
||||
groupNicknamesMap: Map<string, string>
|
||||
onProgress?: (progress: ExportProgress) => void
|
||||
control?: ExportTaskControl
|
||||
totalMessages: number
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const {
|
||||
outputPath,
|
||||
options,
|
||||
sessionId,
|
||||
sessionInfo,
|
||||
myInfo,
|
||||
cleanedMyWxid,
|
||||
rawMyWxid,
|
||||
isGroup,
|
||||
sortedMessages,
|
||||
mediaCache,
|
||||
voiceTranscriptMap,
|
||||
getContactCached,
|
||||
groupNicknamesMap,
|
||||
onProgress,
|
||||
control,
|
||||
totalMessages
|
||||
} = params
|
||||
|
||||
try {
|
||||
const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({
|
||||
filename: outputPath,
|
||||
useStyles: true,
|
||||
useSharedStrings: false
|
||||
})
|
||||
const worksheet = workbook.addWorksheet('聊天记录')
|
||||
const useCompactColumns = options.excelCompactColumns === true
|
||||
const senderProfileCache = new Map<string, ExportDisplayProfile>()
|
||||
|
||||
worksheet.columns = useCompactColumns
|
||||
? [
|
||||
{ width: 8 },
|
||||
{ width: 20 },
|
||||
{ width: 18 },
|
||||
{ width: 12 },
|
||||
{ width: 50 }
|
||||
]
|
||||
: [
|
||||
{ width: 8 },
|
||||
{ width: 20 },
|
||||
{ width: 18 },
|
||||
{ width: 25 },
|
||||
{ width: 18 },
|
||||
{ width: 18 },
|
||||
{ width: 15 },
|
||||
{ width: 12 },
|
||||
{ width: 50 }
|
||||
]
|
||||
|
||||
const appendRow = (values: any[]) => {
|
||||
const row = worksheet.addRow(values)
|
||||
row.commit()
|
||||
}
|
||||
|
||||
appendRow(['会话信息'])
|
||||
appendRow(['微信ID', sessionId, '昵称', sessionInfo.displayName || sessionId])
|
||||
appendRow(['导出工具', 'WeFlow', '导出时间', this.formatTimestamp(Math.floor(Date.now() / 1000))])
|
||||
appendRow([])
|
||||
appendRow(useCompactColumns
|
||||
? ['序号', '时间', '发送者身份', '消息类型', '内容']
|
||||
: ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容'])
|
||||
|
||||
for (let i = 0; i < totalMessages; i++) {
|
||||
if ((i & 0x7f) === 0) this.throwIfStopRequested(control)
|
||||
const msg = sortedMessages[i]
|
||||
|
||||
let senderRole: string
|
||||
let senderWxid: string
|
||||
let senderNickname: string
|
||||
let senderRemark = ''
|
||||
let senderGroupNickname = ''
|
||||
|
||||
if (isGroup) {
|
||||
const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}`
|
||||
let senderProfile = senderProfileCache.get(senderProfileKey)
|
||||
if (!senderProfile) {
|
||||
senderProfile = await this.resolveExportDisplayProfile(
|
||||
msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
|
||||
options.displayNamePreference,
|
||||
getContactCached,
|
||||
groupNicknamesMap,
|
||||
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
|
||||
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
|
||||
)
|
||||
senderProfileCache.set(senderProfileKey, senderProfile)
|
||||
}
|
||||
senderWxid = senderProfile.wxid
|
||||
senderNickname = senderProfile.nickname
|
||||
senderRemark = senderProfile.remark
|
||||
senderGroupNickname = senderProfile.groupNickname
|
||||
senderRole = senderProfile.displayName
|
||||
} else if (msg.isSend) {
|
||||
senderRole = '我'
|
||||
senderWxid = cleanedMyWxid
|
||||
senderNickname = myInfo.displayName || cleanedMyWxid
|
||||
} else {
|
||||
senderWxid = sessionId
|
||||
const contactDetail = await getContactCached(sessionId)
|
||||
if (contactDetail.success && contactDetail.contact) {
|
||||
senderNickname = contactDetail.contact.nickName || sessionId
|
||||
senderRemark = contactDetail.contact.remark || ''
|
||||
senderRole = senderRemark || senderNickname
|
||||
} else {
|
||||
senderNickname = sessionInfo.displayName || sessionId
|
||||
senderRole = senderNickname
|
||||
}
|
||||
}
|
||||
|
||||
const mediaKey = this.getMediaCacheKey(msg)
|
||||
const mediaItem = mediaCache.get(mediaKey)
|
||||
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
|
||||
const contentValue = shouldUseTranscript
|
||||
? this.formatPlainExportContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
|
||||
cleanedMyWxid,
|
||||
msg.senderUsername,
|
||||
msg.isSend
|
||||
)
|
||||
: (mediaItem?.relativePath
|
||||
|| this.formatPlainExportContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
|
||||
cleanedMyWxid,
|
||||
msg.senderUsername,
|
||||
msg.isSend
|
||||
))
|
||||
|
||||
let enrichedContentValue = contentValue
|
||||
if (this.isTransferExportContent(contentValue) && msg.content) {
|
||||
const transferDesc = await this.resolveTransferDesc(
|
||||
msg.content,
|
||||
cleanedMyWxid,
|
||||
groupNicknamesMap,
|
||||
async (username) => {
|
||||
const c = await getContactCached(username)
|
||||
if (c.success && c.contact) {
|
||||
return c.contact.remark || c.contact.nickName || c.contact.alias || username
|
||||
}
|
||||
return username
|
||||
}
|
||||
)
|
||||
if (transferDesc) {
|
||||
enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc)
|
||||
}
|
||||
}
|
||||
|
||||
appendRow(useCompactColumns
|
||||
? [
|
||||
i + 1,
|
||||
this.formatTimestamp(msg.createTime),
|
||||
senderRole,
|
||||
this.getMessageTypeName(msg.localType),
|
||||
enrichedContentValue
|
||||
]
|
||||
: [
|
||||
i + 1,
|
||||
this.formatTimestamp(msg.createTime),
|
||||
senderNickname,
|
||||
senderWxid,
|
||||
senderRemark,
|
||||
senderGroupNickname,
|
||||
senderRole,
|
||||
this.getMessageTypeName(msg.localType),
|
||||
enrichedContentValue
|
||||
])
|
||||
|
||||
if ((i + 1) % 200 === 0) {
|
||||
onProgress?.({
|
||||
current: 65 + Math.floor((i + 1) / totalMessages * 25),
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'writing',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: i + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
worksheet.commit()
|
||||
await workbook.commit()
|
||||
|
||||
onProgress?.({
|
||||
current: 100,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'complete',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: totalMessages,
|
||||
writtenFiles: 1
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
if (this.isStopError(e)) {
|
||||
return { success: false, error: '导出任务已停止' }
|
||||
}
|
||||
if (e instanceof Error) {
|
||||
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
|
||||
return { success: false, error: '文件已经打开,请关闭后再导出' }
|
||||
}
|
||||
}
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保语音转写模型已下载
|
||||
*/
|
||||
@@ -5024,9 +5544,10 @@ class ExportService {
|
||||
control,
|
||||
collectProgressReporter
|
||||
)
|
||||
const totalMessages = collected.rows.length
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
if (collected.rows.length === 0) {
|
||||
if (totalMessages === 0) {
|
||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||
}
|
||||
|
||||
@@ -5076,8 +5597,14 @@ class ExportService {
|
||||
: []
|
||||
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
const mediaDirCache = new Set<string>()
|
||||
|
||||
if (mediaMessages.length > 0) {
|
||||
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
||||
if (voiceMediaMessages.length > 0) {
|
||||
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
current: 25,
|
||||
total: 100,
|
||||
@@ -5085,21 +5612,23 @@ class ExportService {
|
||||
phase: 'exporting-media',
|
||||
phaseProgress: 0,
|
||||
phaseTotal: mediaMessages.length,
|
||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`
|
||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
||||
estimatedTotalMessages: totalMessages
|
||||
})
|
||||
|
||||
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||
let mediaExported = 0
|
||||
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||
this.throwIfStopRequested(control)
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaKey = this.getMediaCacheKey(msg)
|
||||
if (!mediaCache.has(mediaKey)) {
|
||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportVideos: options.exportVideos,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
@@ -5118,9 +5647,11 @@ class ExportService {
|
||||
})
|
||||
}
|
||||
|
||||
const voiceTranscriptMap = new Map<number, string>()
|
||||
const voiceTranscriptMap = new Map<string, string>()
|
||||
|
||||
if (voiceMessages.length > 0) {
|
||||
await this.preloadVoiceWavCache(sessionId, voiceMessages, control)
|
||||
|
||||
onProgress?.({
|
||||
current: 45,
|
||||
total: 100,
|
||||
@@ -5128,7 +5659,8 @@ class ExportService {
|
||||
phase: 'exporting-voice',
|
||||
phaseProgress: 0,
|
||||
phaseTotal: voiceMessages.length,
|
||||
phaseLabel: `语音转文字 0/${voiceMessages.length}`
|
||||
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
|
||||
estimatedTotalMessages: totalMessages
|
||||
})
|
||||
|
||||
const VOICE_CONCURRENCY = 4
|
||||
@@ -5136,7 +5668,7 @@ class ExportService {
|
||||
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||
this.throwIfStopRequested(control)
|
||||
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
||||
voiceTranscriptMap.set(msg.localId, transcript)
|
||||
voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript)
|
||||
voiceTranscribed++
|
||||
onProgress?.({
|
||||
current: 45,
|
||||
@@ -5154,17 +5686,21 @@ class ExportService {
|
||||
current: 60,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting'
|
||||
phase: 'exporting',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: 0
|
||||
})
|
||||
|
||||
const lines: string[] = []
|
||||
const senderProfileCache = new Map<string, ExportDisplayProfile>()
|
||||
|
||||
for (let i = 0; i < sortedMessages.length; i++) {
|
||||
for (let i = 0; i < totalMessages; i++) {
|
||||
if ((i & 0x7f) === 0) {
|
||||
this.throwIfStopRequested(control)
|
||||
}
|
||||
const msg = sortedMessages[i]
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaKey = this.getMediaCacheKey(msg)
|
||||
const mediaItem = mediaCache.get(mediaKey)
|
||||
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
|
||||
const contentValue = shouldUseTranscript
|
||||
@@ -5172,7 +5708,7 @@ class ExportService {
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(msg.localId),
|
||||
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
|
||||
cleanedMyWxid,
|
||||
msg.senderUsername,
|
||||
msg.isSend
|
||||
@@ -5182,7 +5718,7 @@ class ExportService {
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(msg.localId),
|
||||
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
|
||||
cleanedMyWxid,
|
||||
msg.senderUsername,
|
||||
msg.isSend
|
||||
@@ -5214,14 +5750,19 @@ class ExportService {
|
||||
let senderRemark = ''
|
||||
|
||||
if (isGroup) {
|
||||
const senderProfile = await this.resolveExportDisplayProfile(
|
||||
msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
|
||||
options.displayNamePreference,
|
||||
getContactCached,
|
||||
groupNicknamesMap,
|
||||
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
|
||||
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
|
||||
)
|
||||
const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}`
|
||||
let senderProfile = senderProfileCache.get(senderProfileKey)
|
||||
if (!senderProfile) {
|
||||
senderProfile = await this.resolveExportDisplayProfile(
|
||||
msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
|
||||
options.displayNamePreference,
|
||||
getContactCached,
|
||||
groupNicknamesMap,
|
||||
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
|
||||
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
|
||||
)
|
||||
senderProfileCache.set(senderProfileKey, senderProfile)
|
||||
}
|
||||
senderWxid = senderProfile.wxid
|
||||
senderNickname = senderProfile.nickname
|
||||
senderRemark = senderProfile.remark
|
||||
@@ -5253,7 +5794,10 @@ class ExportService {
|
||||
current: progress,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting'
|
||||
phase: 'exporting',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: i + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5262,17 +5806,24 @@ class ExportService {
|
||||
current: 92,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'writing'
|
||||
phase: 'writing',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: totalMessages
|
||||
})
|
||||
|
||||
this.throwIfStopRequested(control)
|
||||
fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8')
|
||||
await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8')
|
||||
|
||||
onProgress?.({
|
||||
current: 100,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'complete'
|
||||
phase: 'complete',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: totalMessages,
|
||||
writtenFiles: 1
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
@@ -5334,7 +5885,8 @@ class ExportService {
|
||||
control,
|
||||
collectProgressReporter
|
||||
)
|
||||
if (collected.rows.length === 0) {
|
||||
const totalMessages = collected.rows.length
|
||||
if (totalMessages === 0) {
|
||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||
}
|
||||
|
||||
@@ -5383,8 +5935,14 @@ class ExportService {
|
||||
: []
|
||||
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
const mediaDirCache = new Set<string>()
|
||||
|
||||
if (mediaMessages.length > 0) {
|
||||
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
||||
if (voiceMediaMessages.length > 0) {
|
||||
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
current: 25,
|
||||
total: 100,
|
||||
@@ -5392,14 +5950,15 @@ class ExportService {
|
||||
phase: 'exporting-media',
|
||||
phaseProgress: 0,
|
||||
phaseTotal: mediaMessages.length,
|
||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`
|
||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
||||
estimatedTotalMessages: totalMessages
|
||||
})
|
||||
|
||||
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||
let mediaExported = 0
|
||||
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||
this.throwIfStopRequested(control)
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaKey = this.getMediaCacheKey(msg)
|
||||
if (!mediaCache.has(mediaKey)) {
|
||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
@@ -5425,9 +5984,11 @@ class ExportService {
|
||||
})
|
||||
}
|
||||
|
||||
const voiceTranscriptMap = new Map<number, string>()
|
||||
const voiceTranscriptMap = new Map<string, string>()
|
||||
|
||||
if (voiceMessages.length > 0) {
|
||||
await this.preloadVoiceWavCache(sessionId, voiceMessages, control)
|
||||
|
||||
onProgress?.({
|
||||
current: 45,
|
||||
total: 100,
|
||||
@@ -5435,7 +5996,8 @@ class ExportService {
|
||||
phase: 'exporting-voice',
|
||||
phaseProgress: 0,
|
||||
phaseTotal: voiceMessages.length,
|
||||
phaseLabel: `语音转文字 0/${voiceMessages.length}`
|
||||
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
|
||||
estimatedTotalMessages: totalMessages
|
||||
})
|
||||
|
||||
const VOICE_CONCURRENCY = 4
|
||||
@@ -5443,7 +6005,7 @@ class ExportService {
|
||||
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||
this.throwIfStopRequested(control)
|
||||
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
||||
voiceTranscriptMap.set(msg.localId, transcript)
|
||||
voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript)
|
||||
voiceTranscribed++
|
||||
onProgress?.({
|
||||
current: 45,
|
||||
@@ -5461,18 +6023,22 @@ class ExportService {
|
||||
current: 60,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting'
|
||||
phase: 'exporting',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: 0
|
||||
})
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push('id,MsgSvrID,type_name,is_sender,talker,msg,src,CreateTime')
|
||||
const senderProfileCache = new Map<string, ExportDisplayProfile>()
|
||||
|
||||
for (let i = 0; i < sortedMessages.length; i++) {
|
||||
for (let i = 0; i < totalMessages; i++) {
|
||||
if ((i & 0x7f) === 0) {
|
||||
this.throwIfStopRequested(control)
|
||||
}
|
||||
const msg = sortedMessages[i]
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaKey = this.getMediaCacheKey(msg)
|
||||
const mediaItem = mediaCache.get(mediaKey) || null
|
||||
|
||||
const typeName = this.getWeCloneTypeName(msg.localType, msg.content || '')
|
||||
@@ -5485,14 +6051,19 @@ class ExportService {
|
||||
|
||||
let talker = myInfo.displayName || '我'
|
||||
if (isGroup) {
|
||||
const senderProfile = await this.resolveExportDisplayProfile(
|
||||
msg.isSend ? cleanedMyWxid : senderWxid,
|
||||
options.displayNamePreference,
|
||||
getContactCached,
|
||||
groupNicknamesMap,
|
||||
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : senderWxid,
|
||||
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
|
||||
)
|
||||
const senderProfileKey = `${msg.isSend ? cleanedMyWxid : senderWxid}::${msg.isSend ? '1' : '0'}`
|
||||
let senderProfile = senderProfileCache.get(senderProfileKey)
|
||||
if (!senderProfile) {
|
||||
senderProfile = await this.resolveExportDisplayProfile(
|
||||
msg.isSend ? cleanedMyWxid : senderWxid,
|
||||
options.displayNamePreference,
|
||||
getContactCached,
|
||||
groupNicknamesMap,
|
||||
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : senderWxid,
|
||||
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
|
||||
)
|
||||
senderProfileCache.set(senderProfileKey, senderProfile)
|
||||
}
|
||||
talker = senderProfile.displayName
|
||||
} else if (!msg.isSend) {
|
||||
const contactDetail = await getContactCached(senderWxid)
|
||||
@@ -5515,7 +6086,7 @@ class ExportService {
|
||||
}
|
||||
|
||||
const msgText = msg.localType === 34 && options.exportVoiceAsText
|
||||
? (voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]')
|
||||
? (voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]')
|
||||
: (this.parseMessageContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
@@ -5546,7 +6117,10 @@ class ExportService {
|
||||
current: progress,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting'
|
||||
phase: 'exporting',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: i + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5555,17 +6129,24 @@ class ExportService {
|
||||
current: 92,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'writing'
|
||||
phase: 'writing',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: totalMessages
|
||||
})
|
||||
|
||||
this.throwIfStopRequested(control)
|
||||
fs.writeFileSync(outputPath, `\uFEFF${lines.join('\r\n')}`, 'utf-8')
|
||||
await fs.promises.writeFile(outputPath, `\uFEFF${lines.join('\r\n')}`, 'utf-8')
|
||||
|
||||
onProgress?.({
|
||||
current: 100,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'complete'
|
||||
phase: 'complete',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: totalMessages,
|
||||
writtenFiles: 1
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
@@ -5763,6 +6344,11 @@ class ExportService {
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
|
||||
if (mediaMessages.length > 0) {
|
||||
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
||||
if (voiceMediaMessages.length > 0) {
|
||||
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
current: 20,
|
||||
total: 100,
|
||||
@@ -5770,14 +6356,15 @@ class ExportService {
|
||||
phase: 'exporting-media',
|
||||
phaseProgress: 0,
|
||||
phaseTotal: mediaMessages.length,
|
||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`
|
||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
||||
estimatedTotalMessages: totalMessages
|
||||
})
|
||||
|
||||
const MEDIA_CONCURRENCY = 6
|
||||
let mediaExported = 0
|
||||
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
|
||||
this.throwIfStopRequested(control)
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaKey = this.getMediaCacheKey(msg)
|
||||
if (!mediaCache.has(mediaKey)) {
|
||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
@@ -5785,7 +6372,8 @@ class ExportService {
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVoiceWithTranscript: true,
|
||||
exportVideos: options.exportVideos
|
||||
exportVideos: options.exportVideos,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
@@ -5808,9 +6396,11 @@ class ExportService {
|
||||
const voiceMessages = useVoiceTranscript
|
||||
? sortedMessages.filter(msg => msg.localType === 34)
|
||||
: []
|
||||
const voiceTranscriptMap = new Map<number, string>()
|
||||
const voiceTranscriptMap = new Map<string, string>()
|
||||
|
||||
if (voiceMessages.length > 0) {
|
||||
await this.preloadVoiceWavCache(sessionId, voiceMessages, control)
|
||||
|
||||
onProgress?.({
|
||||
current: 40,
|
||||
total: 100,
|
||||
@@ -5818,7 +6408,8 @@ class ExportService {
|
||||
phase: 'exporting-voice',
|
||||
phaseProgress: 0,
|
||||
phaseTotal: voiceMessages.length,
|
||||
phaseLabel: `语音转文字 0/${voiceMessages.length}`
|
||||
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
|
||||
estimatedTotalMessages: totalMessages
|
||||
})
|
||||
|
||||
const VOICE_CONCURRENCY = 4
|
||||
@@ -5826,7 +6417,7 @@ class ExportService {
|
||||
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||
this.throwIfStopRequested(control)
|
||||
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
||||
voiceTranscriptMap.set(msg.localId, transcript)
|
||||
voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript)
|
||||
voiceTranscribed++
|
||||
onProgress?.({
|
||||
current: 40,
|
||||
@@ -5858,7 +6449,10 @@ class ExportService {
|
||||
current: 60,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'writing'
|
||||
phase: 'writing',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: 0
|
||||
})
|
||||
|
||||
// ================= BEGIN STREAM WRITING =================
|
||||
@@ -5919,6 +6513,7 @@ class ExportService {
|
||||
|
||||
// Pre-build avatar HTML lookup to avoid per-message rebuilds
|
||||
const avatarHtmlCache = new Map<string, string>()
|
||||
const senderProfileCache = new Map<string, ExportDisplayProfile>()
|
||||
const getAvatarHtml = (username: string, name: string): string => {
|
||||
const cached = avatarHtmlCache.get(username)
|
||||
if (cached !== undefined) return cached
|
||||
@@ -5934,28 +6529,41 @@ class ExportService {
|
||||
const WRITE_BATCH = 100
|
||||
let writeBuf: string[] = []
|
||||
|
||||
for (let i = 0; i < sortedMessages.length; i++) {
|
||||
for (let i = 0; i < totalMessages; i++) {
|
||||
if ((i & 0x7f) === 0) {
|
||||
this.throwIfStopRequested(control)
|
||||
}
|
||||
const msg = sortedMessages[i]
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaKey = this.getMediaCacheKey(msg)
|
||||
const mediaItem = mediaCache.get(mediaKey) || null
|
||||
|
||||
const isSenderMe = msg.isSend
|
||||
const senderInfo = collected.memberSet.get(msg.senderUsername)?.member
|
||||
const senderName = isGroup
|
||||
? (await this.resolveExportDisplayProfile(
|
||||
isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
|
||||
options.displayNamePreference,
|
||||
getContactCached,
|
||||
groupNicknamesMap,
|
||||
isSenderMe ? (myInfo.displayName || cleanedMyWxid) : (senderInfo?.accountName || msg.senderUsername || ''),
|
||||
isSenderMe ? [rawMyWxid, cleanedMyWxid] : []
|
||||
)).displayName
|
||||
? (() => {
|
||||
const senderKey = `${isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${isSenderMe ? '1' : '0'}`
|
||||
const cached = senderProfileCache.get(senderKey)
|
||||
if (cached) return cached.displayName
|
||||
return ''
|
||||
})()
|
||||
: (isSenderMe ? (myInfo.displayName || '我') : (sessionInfo.displayName || sessionId))
|
||||
const resolvedSenderName = isGroup && !senderName
|
||||
? (await (async () => {
|
||||
const senderKey = `${isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${isSenderMe ? '1' : '0'}`
|
||||
const profile = await this.resolveExportDisplayProfile(
|
||||
isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
|
||||
options.displayNamePreference,
|
||||
getContactCached,
|
||||
groupNicknamesMap,
|
||||
isSenderMe ? (myInfo.displayName || cleanedMyWxid) : (senderInfo?.accountName || msg.senderUsername || ''),
|
||||
isSenderMe ? [rawMyWxid, cleanedMyWxid] : []
|
||||
)
|
||||
senderProfileCache.set(senderKey, profile)
|
||||
return profile.displayName
|
||||
})())
|
||||
: senderName
|
||||
|
||||
const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, senderName)
|
||||
const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, resolvedSenderName)
|
||||
|
||||
const timeText = this.formatTimestamp(msg.createTime)
|
||||
const typeName = this.getMessageTypeName(msg.localType)
|
||||
@@ -5968,7 +6576,7 @@ class ExportService {
|
||||
msg.isSend
|
||||
)
|
||||
if (msg.localType === 34 && useVoiceTranscript) {
|
||||
textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||
textContent = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
|
||||
}
|
||||
if (mediaItem && (msg.localType === 3 || msg.localType === 47)) {
|
||||
textContent = ''
|
||||
@@ -6013,7 +6621,7 @@ class ExportService {
|
||||
? `<div class="message-text">${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
|
||||
: '')
|
||||
const senderNameHtml = isGroup
|
||||
? `<div class="sender-name">${this.escapeHtml(senderName)}</div>`
|
||||
? `<div class="sender-name">${this.escapeHtml(resolvedSenderName)}</div>`
|
||||
: ''
|
||||
const timeHtml = `<div class="message-time">${this.escapeHtml(timeText)}</div>`
|
||||
const messageBody = `${timeHtml}${senderNameHtml}<div class="message-content">${mediaHtml}${textHtml}</div>`
|
||||
@@ -6043,7 +6651,10 @@ class ExportService {
|
||||
current: 60 + Math.floor((i + 1) / sortedMessages.length * 30),
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'writing'
|
||||
phase: 'writing',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: i + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6168,7 +6779,11 @@ class ExportService {
|
||||
current: 100,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'complete'
|
||||
phase: 'complete',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: totalMessages,
|
||||
writtenFiles: 1
|
||||
})
|
||||
resolve({ success: true })
|
||||
})
|
||||
@@ -6443,6 +7058,10 @@ class ExportService {
|
||||
let failCount = 0
|
||||
const successSessionIds: string[] = []
|
||||
const failedSessionIds: string[] = []
|
||||
const progressEmitter = this.createProgressEmitter(onProgress)
|
||||
const emitProgress = (progress: ExportProgress, options?: { force?: boolean }) => {
|
||||
progressEmitter.emit(progress, options)
|
||||
}
|
||||
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
@@ -6463,9 +7082,13 @@ class ExportService {
|
||||
const exportBaseDir = writeLayout === 'A'
|
||||
? path.join(outputDir, 'texts')
|
||||
: outputDir
|
||||
if (!fs.existsSync(exportBaseDir)) {
|
||||
fs.mkdirSync(exportBaseDir, { recursive: true })
|
||||
const createdTaskDirs = new Set<string>()
|
||||
const ensureTaskDir = async (dirPath: string) => {
|
||||
if (createdTaskDirs.has(dirPath)) return
|
||||
await fs.promises.mkdir(dirPath, { recursive: true })
|
||||
createdTaskDirs.add(dirPath)
|
||||
}
|
||||
await ensureTaskDir(exportBaseDir)
|
||||
const sessionLayout = exportMediaEnabled
|
||||
? (effectiveOptions.sessionLayout ?? 'per-session')
|
||||
: 'shared'
|
||||
@@ -6521,7 +7144,7 @@ class ExportService {
|
||||
const EMPTY_SESSION_PRECHECK_LIMIT = 1200
|
||||
if (precheckSessionIds.length <= EMPTY_SESSION_PRECHECK_LIMIT) {
|
||||
let checkedCount = 0
|
||||
onProgress?.({
|
||||
emitProgress({
|
||||
current: computeAggregateCurrent(),
|
||||
total: sessionIds.length,
|
||||
currentSession: '',
|
||||
@@ -6558,7 +7181,7 @@ class ExportService {
|
||||
}
|
||||
|
||||
checkedCount = Math.min(precheckSessionIds.length, checkedCount + batchSessionIds.length)
|
||||
onProgress?.({
|
||||
emitProgress({
|
||||
current: computeAggregateCurrent(),
|
||||
total: sessionIds.length,
|
||||
currentSession: '',
|
||||
@@ -6570,7 +7193,7 @@ class ExportService {
|
||||
})
|
||||
}
|
||||
} else {
|
||||
onProgress?.({
|
||||
emitProgress({
|
||||
current: computeAggregateCurrent(),
|
||||
total: sessionIds.length,
|
||||
currentSession: '',
|
||||
@@ -6653,14 +7276,16 @@ class ExportService {
|
||||
successSessionIds.push(sessionId)
|
||||
activeSessionRatios.delete(sessionId)
|
||||
completedCount++
|
||||
onProgress?.({
|
||||
emitProgress({
|
||||
current: computeAggregateCurrent(),
|
||||
total: sessionIds.length,
|
||||
currentSession: sessionInfo.displayName,
|
||||
currentSessionId: sessionId,
|
||||
phase: 'complete',
|
||||
phaseLabel: '该会话没有消息,已跳过'
|
||||
})
|
||||
phaseLabel: '该会话没有消息,已跳过',
|
||||
estimatedTotalMessages: 0,
|
||||
exportedMessages: 0
|
||||
}, { force: true })
|
||||
return 'done'
|
||||
}
|
||||
|
||||
@@ -6669,14 +7294,16 @@ class ExportService {
|
||||
successSessionIds.push(sessionId)
|
||||
activeSessionRatios.delete(sessionId)
|
||||
completedCount++
|
||||
onProgress?.({
|
||||
emitProgress({
|
||||
current: computeAggregateCurrent(),
|
||||
total: sessionIds.length,
|
||||
currentSession: sessionInfo.displayName,
|
||||
currentSessionId: sessionId,
|
||||
phase: 'complete',
|
||||
phaseLabel: '该会话没有消息,已跳过'
|
||||
})
|
||||
phaseLabel: '该会话没有消息,已跳过',
|
||||
estimatedTotalMessages: 0,
|
||||
exportedMessages: 0
|
||||
}, { force: true })
|
||||
return 'done'
|
||||
}
|
||||
|
||||
@@ -6687,13 +7314,13 @@ class ExportService {
|
||||
? 1
|
||||
: Math.max(0, Math.min(1, phaseCurrent / phaseTotal))
|
||||
activeSessionRatios.set(sessionId, ratio)
|
||||
onProgress?.({
|
||||
emitProgress({
|
||||
...progress,
|
||||
current: computeAggregateCurrent(),
|
||||
total: sessionIds.length,
|
||||
currentSession: sessionInfo.displayName,
|
||||
currentSessionId: sessionId
|
||||
})
|
||||
}, { force: progress.phase === 'complete' })
|
||||
}
|
||||
|
||||
sessionProgress({
|
||||
@@ -6715,8 +7342,8 @@ class ExportService {
|
||||
const sessionDirName = sessionNameWithTypePrefix ? `${sessionTypePrefix}${safeName}` : safeName
|
||||
const sessionDir = useSessionFolder ? path.join(exportBaseDir, sessionDirName) : exportBaseDir
|
||||
|
||||
if (useSessionFolder && !fs.existsSync(sessionDir)) {
|
||||
fs.mkdirSync(sessionDir, { recursive: true })
|
||||
if (useSessionFolder) {
|
||||
await ensureTaskDir(sessionDir)
|
||||
}
|
||||
|
||||
let ext = '.json'
|
||||
@@ -6731,7 +7358,7 @@ class ExportService {
|
||||
messageCountHint >= 0 &&
|
||||
typeof latestTimestampHint === 'number' &&
|
||||
latestTimestampHint > 0 &&
|
||||
fs.existsSync(outputPath)
|
||||
await this.pathExists(outputPath)
|
||||
if (canTrySkipUnchanged) {
|
||||
const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format)
|
||||
const hasNoDataChange = Boolean(
|
||||
@@ -6744,14 +7371,16 @@ class ExportService {
|
||||
successSessionIds.push(sessionId)
|
||||
activeSessionRatios.delete(sessionId)
|
||||
completedCount++
|
||||
onProgress?.({
|
||||
emitProgress({
|
||||
current: computeAggregateCurrent(),
|
||||
total: sessionIds.length,
|
||||
currentSession: sessionInfo.displayName,
|
||||
currentSessionId: sessionId,
|
||||
phase: 'complete',
|
||||
phaseLabel: '无变化,已跳过'
|
||||
})
|
||||
phaseLabel: '无变化,已跳过',
|
||||
estimatedTotalMessages: Math.max(0, Math.floor(messageCountHint || 0)),
|
||||
exportedMessages: Math.max(0, Math.floor(messageCountHint || 0))
|
||||
}, { force: true })
|
||||
return 'done'
|
||||
}
|
||||
}
|
||||
@@ -6797,14 +7426,14 @@ class ExportService {
|
||||
|
||||
activeSessionRatios.delete(sessionId)
|
||||
completedCount++
|
||||
onProgress?.({
|
||||
emitProgress({
|
||||
current: computeAggregateCurrent(),
|
||||
total: sessionIds.length,
|
||||
currentSession: sessionInfo.displayName,
|
||||
currentSessionId: sessionId,
|
||||
phase: 'complete',
|
||||
phaseLabel: result.success ? '完成' : '导出失败'
|
||||
})
|
||||
}, { force: true })
|
||||
return 'done'
|
||||
} catch (error) {
|
||||
if (this.isStopError(error)) {
|
||||
@@ -6886,16 +7515,18 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
emitProgress({
|
||||
current: sessionIds.length,
|
||||
total: sessionIds.length,
|
||||
currentSession: '',
|
||||
currentSessionId: '',
|
||||
phase: 'complete'
|
||||
})
|
||||
}, { force: true })
|
||||
progressEmitter.flush()
|
||||
|
||||
return { success: true, successCount, failCount, successSessionIds, failedSessionIds }
|
||||
} catch (e) {
|
||||
progressEmitter.flush()
|
||||
return { success: false, successCount, failCount, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+582
-57
@@ -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.
+75
-15
@@ -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)
|
||||
|
||||
+283
-73
@@ -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>) => {
|
||||
|
||||
Vendored
+5
-2
@@ -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 {
|
||||
|
||||
+22
-1
@@ -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