修复:足迹页面分段失效的问题;#972 #974 所提到的问题;数据库备份中目录错误的问题;

优化:足迹页面的索引扫描性能;导出页面的消息缓存逻辑
This commit is contained in:
cc
2026-05-17 11:54:42 +08:00
parent 6d419dbe9e
commit d008359d70
9 changed files with 116 additions and 38 deletions

View File

@@ -16,6 +16,7 @@ interface ExportWorkerConfig {
resourcesPath?: string
userDataPath?: string
logEnabled?: boolean
isPackaged?: boolean
}
const config = workerData as ExportWorkerConfig
@@ -150,7 +151,10 @@ async function run() {
decryptKey: config.decryptKey,
myWxid: config.myWxid,
imageXorKey: config.imageXorKey,
imageAesKey: config.imageAesKey
imageAesKey: config.imageAesKey,
resourcesPath: config.resourcesPath,
appPath: config.resourcesPath ? require('path').dirname(config.resourcesPath) : __dirname,
isPackaged: config.isPackaged
})
const onProgress = (progress: any) => queueProgress(progress)
@@ -173,7 +177,10 @@ async function run() {
chatService.setRuntimeConfig({
dbPath: config.dbPath,
decryptKey: config.decryptKey,
myWxid: config.myWxid
myWxid: config.myWxid,
resourcesPath: config.resourcesPath,
appPath: config.resourcesPath ? require('path').dirname(config.resourcesPath) : __dirname,
isPackaged: config.isPackaged
})
result = await contactExportService.exportContacts(
String(config.outputDir || ''),

View File

@@ -2349,8 +2349,8 @@ function registerIpcHandlers() {
return chatService.getContactTypeCounts()
})
ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => {
return chatService.getSessionMessageCounts(sessionIds)
ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => {
return chatService.getSessionMessageCounts(sessionIds, options)
})
ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[], options?: {
@@ -3213,7 +3213,8 @@ function registerIpcHandlers() {
imageAesKey: imageKeys.aesKey,
resourcesPath,
userDataPath,
logEnabled
logEnabled,
isPackaged: app.isPackaged
}
})
@@ -3344,7 +3345,8 @@ function registerIpcHandlers() {
imageAesKey: imageKeys.aesKey,
resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
userDataPath: app.getPath('userData'),
logEnabled: cfg.get('logEnabled')
logEnabled: cfg.get('logEnabled'),
isPackaged: app.isPackaged
}
})
@@ -3411,7 +3413,8 @@ function registerIpcHandlers() {
myWxid: String(cfg.getMyWxidCleaned() || '').trim(),
resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
userDataPath: app.getPath('userData'),
logEnabled: cfg.get('logEnabled')
logEnabled: cfg.get('logEnabled'),
isPackaged: app.isPackaged
}
})

View File

@@ -195,7 +195,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds),
getSessionMessageCounts: (sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds, options),
enrichSessionsContactInfo: (
usernames: string[],
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }

View File

@@ -460,6 +460,7 @@ export class BackupService {
const dbStorage = join(accountDir, 'db_storage')
if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' }
const accountDirName = basename(accountDir)
const opened = await withTimeout(
wcdbService.open(accountDir, decryptKey),
15000,

View File

@@ -1,5 +1,6 @@
import { join, dirname, basename, extname } from 'path'
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs'
import { createRequire } from 'module'
import * as path from 'path'
import * as fs from 'fs'
import * as https from 'https'
@@ -453,7 +454,7 @@ class ChatService {
this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录
}
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string }): void {
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; resourcesPath?: string; appPath?: string; isPackaged?: boolean }): void {
this.runtimeConfig = config
}
@@ -8613,13 +8614,17 @@ class ChatService {
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
try {
let wasmPath: string
if (app.isPackaged) {
wasmPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
const isPackaged = this.runtimeConfig?.isPackaged ?? app.isPackaged
const resourcesPath = this.runtimeConfig?.resourcesPath ?? process.resourcesPath
const appPath = this.runtimeConfig?.appPath ?? app.getAppPath()
if (isPackaged) {
wasmPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
if (!existsSync(wasmPath)) {
wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
wasmPath = join(resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
}
} else {
wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
wasmPath = join(appPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
}
if (!existsSync(wasmPath)) {
@@ -8627,7 +8632,9 @@ class ChatService {
return null
}
const silkWasm = require('silk-wasm')
// 在 worker 环境中使用 createRequire 来正确加载模块
const requireFromApp = createRequire(join(appPath, 'package.json'))
const silkWasm = requireFromApp('silk-wasm')
if (!silkWasm || !silkWasm.decode) {
console.error('[ChatService][Voice] silk-wasm module invalid')
return null
@@ -9456,12 +9463,13 @@ class ChatService {
data = this.filterMyFootprintMentionsBySource(nativeRaw, myWxid, mentionLimit)
if (privateSessionIds.length > 0 && data.private_segments.length === 0) {
if (data.private_sessions.length > 0) {
const sessionsWithMessages = data.private_sessions.map(s => s.session_id)
const privateSegments = await this.rebuildMyFootprintPrivateSegments({
begin,
end: normalizedEnd,
myWxid,
privateSessionIds
privateSessionIds: sessionsWithMessages
})
if (privateSegments.length > 0) {
data = {
@@ -9561,7 +9569,7 @@ class ChatService {
myWxid: string
privateSessionIds: string[]
}): Promise<MyFootprintPrivateSegment[]> {
const sessionGapSeconds = 10 * 60
const sessionGapSeconds = 5 * 60
const segments: MyFootprintPrivateSegment[] = []
type WorkingSegment = {
@@ -9579,14 +9587,17 @@ class ChatService {
}
for (const sessionId of params.privateSessionIds) {
const cursorResult = await wcdbService.openMessageCursorLite(
const cursorResult = await wcdbService.openMessageCursor(
sessionId,
360,
true,
params.begin,
params.end
0,
0
)
if (!cursorResult.success || !cursorResult.cursor) continue
if (!cursorResult.success || !cursorResult.cursor) {
console.log(`[足迹分段] 打开游标失败: ${sessionId}, 原因: ${cursorResult.error || '未知'}`)
continue
}
let segmentCursor = 0
let active: WorkingSegment | null = null
@@ -9620,19 +9631,30 @@ class ChatService {
}
let hasMore = true
let batchCount = 0
let totalMessages = 0
try {
while (hasMore) {
const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor)
batchCount++
if (!batchResult.success || !Array.isArray(batchResult.rows)) break
hasMore = Boolean(batchResult.hasMore)
totalMessages += batchResult.rows.length
for (const row of batchResult.rows as Array<Record<string, any>>) {
const createTime = this.toSafeInt(row.create_time, 0)
const localId = this.toSafeInt(row.local_id, 0)
const isSend = this.resolveFootprintRowIsSend(row, params.myWxid)
// 过滤时间范围外的消息
if (createTime > 0 && (createTime < params.begin || createTime > params.end)) {
continue
}
if (createTime > 0) {
const needNew = !active || (lastMessageTs > 0 && createTime - lastMessageTs > sessionGapSeconds)
const referenceTs = lastMessageTs > 0 ? lastMessageTs : (active ? active.end_ts : 0)
const timeDiff = referenceTs > 0 ? createTime - referenceTs : 0
const needNew = !active || (referenceTs > 0 && timeDiff > sessionGapSeconds)
if (needNew) {
commit()
segmentCursor += 1

View File

@@ -323,7 +323,7 @@ class ExportService {
return error
}
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void {
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string; resourcesPath?: string; appPath?: string; isPackaged?: boolean } | null): void {
this.runtimeConfig = config
imageDecryptService.setRuntimeConfig({
dbPath: config?.dbPath,
@@ -331,6 +331,14 @@ class ExportService {
imageXorKey: config?.imageXorKey,
imageAesKey: config?.imageAesKey
})
chatService.setRuntimeConfig({
dbPath: config?.dbPath,
decryptKey: config?.decryptKey,
myWxid: config?.myWxid,
resourcesPath: config?.resourcesPath,
appPath: config?.appPath,
isPackaged: config?.isPackaged
})
}
private getConfiguredDbPath(): string {
@@ -6651,7 +6659,7 @@ class ExportService {
if (msg.localType === 34 && options.exportVoiceAsText) {
// 使用预先转写的文字
content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
} else if (mediaItem && msg.localType === 3) {
} else if (mediaItem && msg.localType !== 47) {
content = mediaItem.relativePath
} else {
content = this.parseMessageContent(

View File

@@ -14,6 +14,7 @@ export interface SnsLivePhoto {
thumb: string
md5?: string
token?: string
thumbToken?: string
key?: string
encIdx?: string
}
@@ -23,6 +24,7 @@ export interface SnsMedia {
thumb: string
md5?: string
token?: string
thumbToken?: string
key?: string
encIdx?: string
livePhoto?: SnsLivePhoto
@@ -126,12 +128,22 @@ const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => {
let fixedUrl = url.replace('http://', 'https://')
// 只有非视频(即图片)才需要处理 /150 变 /0
// 只有非视频(即图片)才需要处理路径末尾的尺寸标识(/150、/200等变为 /0
if (!isVideo) {
fixedUrl = fixedUrl.replace(/\/150($|\?)/, '/0$1')
const [pathPart, queryPart] = fixedUrl.split('?')
const fixedPath = pathPart.replace(/\/\d+$/, '/0')
fixedUrl = queryPart ? `${fixedPath}?${queryPart}` : fixedPath
}
if (!token || fixedUrl.includes('token=')) return fixedUrl
// 如果没有提供新token直接返回
if (!token) return fixedUrl
// 移除已有的token和idx参数
const [pathPart, queryPart] = fixedUrl.split('?')
if (queryPart) {
const params = queryPart.split('&').filter(p => !p.startsWith('token=') && !p.startsWith('idx='))
fixedUrl = params.length > 0 ? `${pathPart}?${params.join('&')}` : pathPart
}
// 根据用户要求,视频链接组合方式为: BASE_URL + "?" + "token=" + token + "&idx=1" + 原有参数
if (isVideo) {
@@ -704,6 +716,7 @@ class SnsService {
url: urlMatch ? urlMatch[1].trim() : '',
thumb: thumbMatch ? thumbMatch[1].trim() : '',
token: urlToken || thumbToken,
thumbToken: thumbToken,
key: urlKey || thumbKey,
md5: urlMd5,
encIdx: urlEncIdx || thumbEncIdx
@@ -716,19 +729,24 @@ class SnsService {
const lpUrlTag = lx.match(/<url([^>]*)>/i)
const lpThumb = lx.match(/<thumb[^>]*>([^<]+)<\/thumb>/i)
const lpThumbTag = lx.match(/<thumb([^>]*)>/i)
let lpToken: string | undefined, lpKey: string | undefined, lpEncIdx: string | undefined
let lpUrlToken: string | undefined, lpThumbToken: string | undefined
let lpKey: string | undefined, lpEncIdx: string | undefined
if (lpUrlTag?.[1]) {
const a = lpUrlTag[1]
lpToken = a.match(/token="([^"]+)"/i)?.[1]
lpUrlToken = a.match(/token="([^"]+)"/i)?.[1]
lpKey = a.match(/key="([^"]+)"/i)?.[1]
lpEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1]
}
if (!lpToken && lpThumbTag?.[1]) lpToken = lpThumbTag[1].match(/token="([^"]+)"/i)?.[1]
if (!lpKey && lpThumbTag?.[1]) lpKey = lpThumbTag[1].match(/key="([^"]+)"/i)?.[1]
if (lpThumbTag?.[1]) {
const a = lpThumbTag[1]
lpThumbToken = a.match(/token="([^"]+)"/i)?.[1]
if (!lpKey) lpKey = a.match(/key="([^"]+)"/i)?.[1]
}
item.livePhoto = {
url: lpUrl ? lpUrl[1].trim() : '',
thumb: lpThumb ? lpThumb[1].trim() : '',
token: lpToken,
token: lpUrlToken || lpThumbToken,
thumbToken: lpThumbToken,
key: lpKey,
encIdx: lpEncIdx
}
@@ -1181,16 +1199,18 @@ class SnsService {
const fixedMedia = (post.media || []).map((m: any) => ({
url: fixSnsUrl(m.url, m.token, isVideoPost),
thumb: fixSnsUrl(m.thumb, m.token, false),
thumb: fixSnsUrl(m.thumb, m.thumbToken || m.token, false),
md5: m.md5,
token: m.token,
thumbToken: m.thumbToken,
key: isVideoPost ? (videoKey || m.key) : m.key,
encIdx: m.encIdx || m.enc_idx,
livePhoto: m.livePhoto ? {
...m.livePhoto,
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true),
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false),
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.thumbToken || m.livePhoto.token, false),
token: m.livePhoto.token,
thumbToken: m.livePhoto.thumbToken,
key: videoKey || m.livePhoto.key || m.key,
encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx
} : undefined

View File

@@ -4364,7 +4364,7 @@ function ExportPage() {
try {
if (prioritizedSessionIds.length > 0) {
patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading')
const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds)
const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds, { bypassSessionCache: true, preferHintCache: false })
if (isStale()) return { ...accumulatedCounts }
if (priorityResult.success) {
applyCounts(priorityResult.counts)
@@ -4381,7 +4381,7 @@ function ExportPage() {
if (remainingSessionIds.length > 0) {
patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading')
const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds)
const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds, { bypassSessionCache: true, preferHintCache: false })
if (isStale()) return { ...accumulatedCounts }
if (remainingResult.success) {
applyCounts(remainingResult.counts)
@@ -7613,12 +7613,29 @@ function ExportPage() {
scheduleSessionMutualFriendsWorker()
}
// 记录刷新前的会话时间戳
const oldTimestamps = new Map(
sessionsRef.current.map(s => [s.username, s.lastTimestamp || s.sortTimestamp || 0])
)
await Promise.all([
loadContactsList({ scopeKey }),
loadSnsStats({ full: true }),
loadSnsUserPostCounts({ force: true })
])
// 找出有变动的会话(最后消息时间变化)
const changedSessions = sessionsRef.current.filter(session => {
const oldTs = oldTimestamps.get(session.username) || 0
const newTs = session.lastTimestamp || session.sortTimestamp || 0
return newTs > oldTs
})
// 只对有变动的会话重新加载消息数量
if (changedSessions.length > 0) {
await loadSessionMessageCounts(changedSessions, activeTabRef.current, { scopeKey })
}
const currentDetailSessionId = showSessionDetailPanel
? String(sessionDetail?.wxid || '').trim()
: ''

View File

@@ -770,12 +770,12 @@ function MyFootprintPage() {
<>
<section className="kpi-grid">
<button type="button" className="kpi-card" onClick={() => setTimelineMode('private')}>
<span className="kpi-label"></span>
<span className="kpi-label"></span>
<strong>{data.summary.private_inbound_people}</strong>
<small> {data.summary.private_replied_people} </small>
</button>
<button type="button" className="kpi-card" onClick={() => setTimelineMode('private')}>
<span className="kpi-label"></span>
<span className="kpi-label"></span>
<strong>{data.summary.private_outbound_people}</strong>
<small> {formatPercent(data.summary.private_reply_rate)}</small>
</button>