架构优化

This commit is contained in:
cc committed 2026-07-07 14:11:02 +08:00
1 parent 147f3b7045
commit ccbd154668
67 files changed
+1726 -421

No files matched your search

+3 -9
View File
@@ -145,15 +145,9 @@ if (config.userDataPath) {
}
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
const shouldUseWeliveEngine = () => {
if (config.mode === 'contacts') return false
const explicit = String(config.options?.exportEngine || config.options?.engine || '').trim().toLowerCase()
if (explicit === 'welive') return true
if (explicit === 'legacy') return false
const env = String(process.env.WEFLOW_EXPORT_ENGINE || '').trim().toLowerCase()
if (env === 'legacy') return false
return true
}
// 消息导出强制走 WeLive 引擎(不提供 legacy 回退开关);
// 仅联系人导出仍由 worker 内置流程处理。
const shouldUseWeliveEngine = () => config.mode !== 'contacts'
const normalizeImageXorKey = (value: unknown): string | number | undefined => {
if (typeof value === 'number' && Number.isFinite(value)) return value
+36
View File
@@ -0,0 +1,36 @@
import { parentPort, workerData } from 'worker_threads'
import { decryptDatViaNative, nativeAddonLocation } from './services/nativeImageDecrypt'
// worker_threads 中没有 Electron 注入的 resourcesPath,由主线程通过 workerData 传入,
// 供 nativeImageDecrypt 定位 .node 原生模块
if (workerData?.resourcesPath && !process.resourcesPath) {
;(process as NodeJS.Process & { resourcesPath?: string }).resourcesPath = workerData.resourcesPath
}
type DecryptRequest = {
id: number
datPath: string
xorKey: number
aesKey?: string
}
parentPort?.on('message', (req: DecryptRequest) => {
const { id, datPath, xorKey, aesKey } = req
try {
const result = decryptDatViaNative(datPath, xorKey, aesKey)
if (!result) {
// addonMissing 让主线程直接放弃 worker 路径,避免每次解密都白跑一次线程往返
parentPort?.postMessage({ id, ok: false, addonMissing: !nativeAddonLocation() })
return
}
// 拷贝出独立 ArrayBuffer 后转移所有权,避免结构化克隆再复制一次
const data = new ArrayBuffer(result.data.byteLength)
new Uint8Array(data).set(result.data)
parentPort?.postMessage(
{ id, ok: true, data, ext: result.ext, isWxgf: result.isWxgf, meta: result.meta },
[data]
)
} catch (error) {
parentPort?.postMessage({ id, ok: false, error: String(error) })
}
})
+5 -5
View File
@@ -1095,8 +1095,7 @@ function createWindow(options: { autoShow?: boolean } = {}) {
nodeIntegration: false,
webSecurity: false // Allow loading local files (video playback)
},
titleBarStyle: 'hidden',
titleBarOverlay: false,
frame: false,
show: false
})
setupCustomTitleBarWindow(win)
@@ -1574,8 +1573,7 @@ function createChatHistoryRouteWindow(route: string) {
contextIsolation: true,
nodeIntegration: false
},
titleBarStyle: 'hidden',
titleBarOverlay: false,
frame: false,
show: false,
backgroundColor: '#FFFFFF',
autoHideMenuBar: true
@@ -4690,7 +4688,9 @@ app.whenReady().then(async () => {
}
updateSplashProgress(75, '正在缓存联系人头像...')
const avatarWarmupUsernames = Array.from(preloadUsernames).slice(0, 2000)
// 只预热前 600 个会话的头像:覆盖首屏与常用会话,其余由 ChatPage
// 的渐进式补齐队列按需加载,降低启动耗时与常驻内存
const avatarWarmupUsernames = Array.from(preloadUsernames).slice(0, 600)
if (avatarWarmupUsernames.length > 0) {
await withTimeout(() => chatService.enrichSessionsContactInfo(avatarWarmupUsernames), 15000)
}
+117
View File
@@ -0,0 +1,117 @@
import { join } from 'path'
import { existsSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { writeFile } from 'fs/promises'
// 条件导入 electron(Worker 环境中不可用)
let app: any = null
try {
app = require('electron').app
} catch {
// Worker 环境
}
/**
* 大体积 UI 缓存(*CacheMap 键)的独立存储。
*
* 这些键此前存放在 electron-store 主配置中,而 conf 库每次 get/set
* 都会同步读写整个配置文件(曾膨胀到 3.2MB),主线程单次阻塞可达
* 数十毫秒且高频发生。此处改为:内存 Map 直接服务读写 + 防抖异步
* 落盘 + 退出时同步 flush,读写路径完全不再碰磁盘。
*/
export class CacheMapStore {
private readonly filePath: string
private data = new Map<string, unknown>()
private persistTimer: NodeJS.Timeout | null = null
private persistInFlight = false
private persistDirty = false
constructor(userDataPath: string) {
this.filePath = join(userDataPath, 'WeFlow-cache-maps.json')
this.load()
app?.once?.('will-quit', () => this.flushSync())
}
private load(): void {
if (!existsSync(this.filePath)) return
try {
const parsed = JSON.parse(readFileSync(this.filePath, 'utf8')) as unknown
if (parsed && typeof parsed === 'object') {
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
this.data.set(key, value)
}
}
} catch (error) {
console.error('CacheMapStore: 载入缓存失败', error)
}
}
get(key: string): unknown {
return this.data.get(key)
}
set(key: string, value: unknown): void {
if (value === undefined) {
this.data.delete(key)
} else {
this.data.set(key, value)
}
this.persist()
}
entries(): Record<string, unknown> {
return Object.fromEntries(this.data)
}
clear(): void {
this.data.clear()
if (this.persistTimer) {
clearTimeout(this.persistTimer)
this.persistTimer = null
}
try {
rmSync(this.filePath, { force: true })
} catch (error) {
console.error('CacheMapStore: 清理缓存失败', error)
}
}
private persist(): void {
if (this.persistTimer) return
this.persistTimer = setTimeout(() => {
this.persistTimer = null
void this.persistNow()
}, 500)
this.persistTimer.unref?.()
}
private async persistNow(): Promise<void> {
if (this.persistInFlight) {
this.persistDirty = true
return
}
this.persistInFlight = true
try {
await writeFile(this.filePath, JSON.stringify(Object.fromEntries(this.data)), 'utf8')
} catch (error) {
console.error('CacheMapStore: 保存缓存失败', error)
} finally {
this.persistInFlight = false
if (this.persistDirty) {
this.persistDirty = false
void this.persistNow()
}
}
}
/** 有待写改动时同步写入(应用退出前或迁移完成后调用) */
flushSync(): void {
if (!this.persistTimer) return
clearTimeout(this.persistTimer)
this.persistTimer = null
try {
writeFileSync(this.filePath, JSON.stringify(Object.fromEntries(this.data)), 'utf8')
} catch (error) {
console.error('CacheMapStore: 保存缓存失败', error)
}
}
}
+9 -4
View File
@@ -384,7 +384,8 @@ class ChatService {
private mediaDbsCache: string[] | null = null
private mediaDbsCacheTime = 0
private readonly mediaDbsCacheTtl = 300000 // 5分钟
private readonly voiceWavCacheMaxEntries = 50
// WAV 解码产物每条可达 ~2MB,50 条上限最坏 100MB;12 条足够覆盖连续回放场景
private readonly voiceWavCacheMaxEntries = 12
// 缓存 media.db 的表结构信息
private mediaDbSchemaCache = new Map<string, {
voiceTable: string
@@ -1085,8 +1086,9 @@ class ChatService {
for (const row of contactResult.contacts as Record<string, any>[]) {
const username = String(row.username || '').trim()
if (!username || !this.isAntiRevokeContactRow(username, row)) continue
const displayName = String(row.remark || row.nick_name || row.nickName || row.alias || username)
map.set(username, {
displayName: String(row.remark || row.nick_name || row.nickName || row.alias || username).trim()
displayName: displayName.trim() || displayName
})
}
} catch {
@@ -7904,7 +7906,8 @@ class ChatService {
const nicknameBuckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nickResult.nicknames)) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
const rawNickname = typeof nicknameRaw === 'string' ? nicknameRaw : String(nicknameRaw || '')
const nickname = rawNickname.trim() || rawNickname
if (!memberId || !nickname) continue
const slot = nicknameBuckets.get(memberId)
if (slot) {
@@ -11127,7 +11130,9 @@ class ChatService {
latest_ts: this.toSafeInt(item?.latest_ts, 0),
anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0),
anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0),
displayName: String(item?.displayName || '').trim() || undefined,
displayName: typeof item?.displayName === 'string' && item.displayName.length > 0
? (item.displayName.trim() || item.displayName)
: undefined,
avatarUrl: String(item?.avatarUrl || '').trim() || undefined
})).filter((item: MyFootprintPrivateSegment) => item.session_id && item.start_ts > 0)
+46 -1
View File
@@ -3,6 +3,7 @@ import { existsSync, readdirSync, statSync } from 'fs'
import crypto from 'crypto'
import Store from 'electron-store'
import { expandHomePath } from '../utils/pathUtils'
import { CacheMapStore } from './cacheMapStore'
// 条件导入 electron(Worker 环境中不可用)
let app: any = null
@@ -161,9 +162,18 @@ const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
const LOCKABLE_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey'])
const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
/**
* 大体积 UI 缓存键(以 CacheMap 结尾),存入独立的 CacheMapStore。
* conf 库对主配置文件的每次 get/set 都是全量同步读写,
* 这些缓存曾把配置文件撑到 3.2MB,导致高频主线程阻塞。
*/
const isCacheMapKey = (key: string): boolean => key.endsWith('CacheMap')
export class ConfigService {
private static instance: ConfigService
private store!: Store<ConfigSchema>
// Worker 环境不创建(CacheMap 键仅主进程访问,避免多进程并发写同一文件)
private cacheMapStore: CacheMapStore | null = null
// 锁定模式运行时状态
private unlockedKeys: Map<string, any> = new Map()
@@ -305,6 +315,29 @@ export class ConfigService {
}
this.migrateAuthFields()
this.migrateAiConfig()
if (!runningInWorker) {
this.cacheMapStore = new CacheMapStore(this.getUserDataPath())
this.migrateCacheMapKeys()
}
}
/** 一次性迁移:把主配置中的 *CacheMap 大键搬到旁路存储,缩小配置文件 */
private migrateCacheMapKeys(): void {
if (!this.cacheMapStore) return
try {
const all = this.store.store as unknown as Record<string, unknown>
const cacheKeys = Object.keys(all).filter(isCacheMapKey)
if (cacheKeys.length === 0) return
for (const key of cacheKeys) {
this.cacheMapStore.set(key, all[key])
delete all[key]
}
// 先落盘旁路存储,再整体重写主配置(各一次全量写)
this.cacheMapStore.flushSync()
;(this.store as any).store = all
} catch (error) {
console.error('ConfigService: 迁移 CacheMap 键失败', error)
}
}
// === 状态查询 ===
@@ -321,6 +354,9 @@ export class ConfigService {
// === get / set ===
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
if (this.cacheMapStore && isCacheMapKey(key as string)) {
return this.cacheMapStore.get(key as string) as ConfigSchema[K]
}
const raw = this.store.get(key)
if (ENCRYPTED_BOOL_KEYS.has(key)) {
@@ -362,6 +398,10 @@ export class ConfigService {
}
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
if (this.cacheMapStore && isCacheMapKey(key as string)) {
this.cacheMapStore.set(key as string, value)
return
}
let toStore = value
const inLockMode = this.isLockMode() && this.unlockPassword
@@ -1076,11 +1116,16 @@ export class ConfigService {
}
getAll(): Partial<ConfigSchema> {
return this.store.store
const all = { ...this.store.store } as Record<string, unknown>
if (this.cacheMapStore) {
Object.assign(all, this.cacheMapStore.entries())
}
return all as Partial<ConfigSchema>
}
clear(): void {
this.store.clear()
this.cacheMapStore?.clear()
this.unlockedKeys.clear()
this.unlockPassword = null
}
+42
View File
@@ -1,5 +1,6 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { writeFile } from 'fs/promises'
import { app } from 'electron'
import { ConfigService } from './config'
@@ -12,6 +13,9 @@ export interface ContactCacheEntry {
export class ContactCacheService {
private readonly cacheFilePath: string
private cache: Record<string, ContactCacheEntry> = {}
private persistTimer: NodeJS.Timeout | null = null
private persistInFlight = false
private persistDirty = false
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
@@ -20,6 +24,7 @@ export class ContactCacheService {
this.cacheFilePath = join(basePath, 'contacts.json')
this.ensureCacheDir()
this.loadCache()
app?.once('will-quit', () => this.flushSync())
}
private ensureCacheDir() {
@@ -74,7 +79,40 @@ export class ContactCacheService {
}
}
/** 防抖异步落盘:启动阶段批量补全联系人时避免连续同步写盘阻塞主线程 */
private persist() {
if (this.persistTimer) return
this.persistTimer = setTimeout(() => {
this.persistTimer = null
void this.persistNow()
}, 1000)
this.persistTimer.unref?.()
}
private async persistNow(): Promise<void> {
if (this.persistInFlight) {
this.persistDirty = true
return
}
this.persistInFlight = true
try {
await writeFile(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
} catch (error) {
console.error('ContactCacheService: 保存缓存失败', error)
} finally {
this.persistInFlight = false
if (this.persistDirty) {
this.persistDirty = false
void this.persistNow()
}
}
}
/** 退出前把尚未落盘的改动同步写入 */
private flushSync(): void {
if (!this.persistTimer) return
clearTimeout(this.persistTimer)
this.persistTimer = null
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
} catch (error) {
@@ -84,6 +122,10 @@ export class ContactCacheService {
clear(): void {
this.cache = {}
if (this.persistTimer) {
clearTimeout(this.persistTimer)
this.persistTimer = null
}
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
@@ -4419,7 +4419,15 @@ export class ExportContext {
control,
onCollectProgress
)
if (weliveCollected) return weliveCollected
if (weliveCollected) {
// WeLive 原始数据收集报错(文件缺失 / JSONL 解析失败)意味着数据不完整。
// 调用方(各 formatter)不会检查 error 字段,这里必须直接抛错让会话导出失败,
// 禁止把截断或空结果当作成功输出(issue #1129 的 WeFlow 侧防线)。
if (weliveCollected.error) {
throw new Error(`WeLive 原始导出数据不完整: ${weliveCollected.error}`)
}
return weliveCollected
}
const rows: any[] = [];
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>();
@@ -1,5 +1,7 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { writeFile } from 'fs/promises'
import { app } from 'electron'
import { ConfigService } from './config'
const CACHE_VERSION = 1
@@ -39,6 +41,9 @@ function normalizeEntry(raw: unknown): GroupMyMessageCountCacheEntry | null {
export class GroupMyMessageCountCacheService {
private readonly cacheFilePath: string
private persistTimer: NodeJS.Timeout | null = null
private persistInFlight = false
private persistDirty = false
private store: GroupMyMessageCountCacheStore = {
version: CACHE_VERSION,
scopes: {}
@@ -51,6 +56,7 @@ export class GroupMyMessageCountCacheService {
this.cacheFilePath = join(basePath, 'group-my-message-counts.json')
this.ensureCacheDir()
this.load()
app?.once('will-quit', () => this.flushSync())
}
private ensureCacheDir(): void {
@@ -158,6 +164,10 @@ export class GroupMyMessageCountCacheService {
clearAll(): void {
this.store = { version: CACHE_VERSION, scopes: {} }
if (this.persistTimer) {
clearTimeout(this.persistTimer)
this.persistTimer = null
}
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
@@ -194,7 +204,40 @@ export class GroupMyMessageCountCacheService {
this.store.scopes = trimmedScopes
}
/** 防抖异步落盘:批量刷新群统计时避免连续同步写盘阻塞主线程 */
private persist(): void {
if (this.persistTimer) return
this.persistTimer = setTimeout(() => {
this.persistTimer = null
void this.persistNow()
}, 1000)
this.persistTimer.unref?.()
}
private async persistNow(): Promise<void> {
if (this.persistInFlight) {
this.persistDirty = true
return
}
this.persistInFlight = true
try {
await writeFile(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
console.error('GroupMyMessageCountCacheService: 保存缓存失败', error)
} finally {
this.persistInFlight = false
if (this.persistDirty) {
this.persistDirty = false
void this.persistNow()
}
}
}
/** 退出前把尚未落盘的改动同步写入 */
private flushSync(): void {
if (!this.persistTimer) return
clearTimeout(this.persistTimer)
this.persistTimer = null
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
+4 -2
View File
@@ -328,7 +328,8 @@ class GroupSummaryService {
return { success: false, message: '手动总结时段不能超过 48 小时' }
}
const displayName = String(params?.displayName || sessionId).trim() || sessionId
const rawDisplayName = typeof params?.displayName === 'string' ? params.displayName : ''
const displayName = rawDisplayName.length > 0 ? (rawDisplayName.trim() || rawDisplayName) : sessionId
const avatarUrl = String(params?.avatarUrl || '').trim() || undefined
return this.generateSummaryForPeriod({
sessionId,
@@ -369,7 +370,8 @@ class GroupSummaryService {
return { success: true, message: '当前日期暂无已完成的总结时段', generated: 0, skipped: 0, records: [] }
}
const displayName = String(params?.displayName || sessionId).trim() || sessionId
const rawDisplayName = typeof params?.displayName === 'string' ? params.displayName : ''
const displayName = rawDisplayName.length > 0 ? (rawDisplayName.trim() || rawDisplayName) : sessionId
const avatarUrl = String(params?.avatarUrl || '').trim() || undefined
return this.generateSummariesForPeriods({
sessionId,
+2 -1
View File
@@ -2048,7 +2048,8 @@ class HttpService {
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
const rawNickname = typeof nicknameRaw === 'string' ? nicknameRaw : String(nicknameRaw || '')
const nickname = rawNickname.trim() || rawNickname
if (!memberId || !nickname) continue
const slot = buckets.get(memberId)
if (slot) {
+3 -2
View File
@@ -7,7 +7,7 @@ import { homedir, tmpdir } from 'os'
import crypto from 'crypto'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { decryptDatViaNative, nativeAddonLocation } from './nativeImageDecrypt'
import { decryptDatViaNativeAsync, nativeAddonLocation } from './nativeImageDecrypt'
// 获取 ffmpeg-static 的路径
function getStaticFfmpegPath(): string | null {
@@ -1949,7 +1949,8 @@ export class ImageDecryptService {
xorKey: number,
aesKey?: string
): Promise<{ data: Buffer; ext: string; isWxgf: boolean } | null> {
const result = decryptDatViaNative(datPath, xorKey, aesKey)
// worker 线程内执行原生解密,避免滚动预载时批量同步调用阻塞主线程
const result = await decryptDatViaNativeAsync(datPath, xorKey, aesKey)
if (!this.nativeLogged) {
this.nativeLogged = true
if (result) {
+26 -10
View File
@@ -681,7 +681,8 @@ class InsightService {
}
this.dbConnected = true
const displayName = String(params?.displayName || sessionId).trim() || sessionId
const rawDisplayName = typeof params?.displayName === 'string' ? params.displayName : ''
const displayName = rawDisplayName.length > 0 ? (rawDisplayName.trim() || rawDisplayName) : sessionId
insightLog('INFO', `手动触发当前会话见解:${displayName} (${sessionId})`)
return await this.generateInsightForSession({
sessionId,
@@ -739,7 +740,10 @@ class InsightService {
const topPrivateText = privateSegments.length > 0
? privateSegments
.map((item, idx) => {
const name = String(item.displayName || item.session_id || `联系人${idx + 1}`).trim()
const rawName = typeof item.displayName === 'string' && item.displayName.length > 0
? item.displayName
: String(item.session_id || `联系人${idx + 1}`)
const name = rawName.trim() || rawName
const inbound = Number(item.incoming_count) || 0
const outbound = Number(item.outgoing_count) || 0
const total = Math.max(Number(item.message_count) || 0, inbound + outbound)
@@ -751,7 +755,10 @@ class InsightService {
const topMentionText = mentionGroups.length > 0
? mentionGroups
.map((item, idx) => {
const name = String(item.displayName || item.session_id || `群聊${idx + 1}`).trim()
const rawName = typeof item.displayName === 'string' && item.displayName.length > 0
? item.displayName
: String(item.session_id || `群聊${idx + 1}`)
const name = rawName.trim() || rawName
const count = Number(item.count) || 0
return `${idx + 1}. ${name}(@我 ${count} 次)`
})
@@ -1080,7 +1087,8 @@ ${afterText}
}
private async resolveInsightSessionDisplayName(sessionId: string, fallbackDisplayName: string): Promise<string> {
const fallback = String(fallbackDisplayName || '').trim()
const rawFallback = typeof fallbackDisplayName === 'string' ? fallbackDisplayName : ''
const fallback = rawFallback.trim() || rawFallback
if (fallback && !this.looksLikeWxid(fallback)) {
return fallback
}
@@ -1088,7 +1096,8 @@ ${afterText}
try {
const sessions = await this.getSessionsCached()
const matched = sessions.find((session) => String(session.username || '').trim() === sessionId)
const cachedDisplayName = String(matched?.displayName || '').trim()
const rawCachedDisplayName = typeof matched?.displayName === 'string' ? matched.displayName : ''
const cachedDisplayName = rawCachedDisplayName.trim() || rawCachedDisplayName
if (cachedDisplayName && !this.looksLikeWxid(cachedDisplayName)) {
return cachedDisplayName
}
@@ -1098,7 +1107,8 @@ ${afterText}
try {
const contact = await chatService.getContactAvatar(sessionId)
const contactDisplayName = String(contact?.displayName || '').trim()
const rawContactDisplayName = typeof contact?.displayName === 'string' ? contact.displayName : ''
const contactDisplayName = rawContactDisplayName.trim() || rawContactDisplayName
if (contactDisplayName && !this.looksLikeWxid(contactDisplayName)) {
return contactDisplayName
}
@@ -1387,11 +1397,14 @@ ${afterText}
silentCount++
const silentDays = Math.floor(silentMs / (24 * 60 * 60 * 1000))
insightLog('INFO', `发现沉默联系人:${session.displayName || sessionId},已沉默 ${silentDays} 天`)
const displayName = typeof session.displayName === 'string' && session.displayName.length > 0
? (session.displayName.trim() || session.displayName)
: sessionId
insightLog('INFO', `发现沉默联系人:${displayName},已沉默 ${silentDays} 天`)
await this.generateInsightForSession({
sessionId,
displayName: session.displayName || session.username,
displayName,
triggerReason: 'silence',
silentDays
})
@@ -1504,12 +1517,15 @@ ${afterText}
if (cooldownMs - (now - lastAnalysis) > 0) continue
}
insightLog('INFO', `${session.displayName || sessionId} 有新消息,准备生成见解...`)
const displayName = typeof session.displayName === 'string' && session.displayName.length > 0
? (session.displayName.trim() || session.displayName)
: sessionId
insightLog('INFO', `${displayName} 有新消息,准备生成见解...`)
this.lastActivityAnalysis.set(sessionId, now)
await this.generateInsightForSession({
sessionId,
displayName: session.displayName || session.username,
displayName,
triggerReason: 'activity'
})
break
+4 -2
View File
@@ -13,8 +13,10 @@ export class MessageCacheService {
private static readonly CACHE_VERSION = 3
private readonly cacheFilePath: string
private cache: Record<string, SessionMessageCacheEntry> = {}
private readonly sessionLimit = 150
private readonly maxSessionEntries = 48
// 每会话 80 条已覆盖首屏渲染(DB 拉取会随后补全),48→24 个会话、150→80 条
// 可把该缓存的常驻内存压到原来的 1/4 左右
private readonly sessionLimit = 80
private readonly maxSessionEntries = 24
private persistTimer: ReturnType<typeof setTimeout> | null = null
private persistInFlight = false
private persistQueued = false
+2 -1
View File
@@ -1396,7 +1396,8 @@ class MessagePushService {
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
const rawNickname = typeof nicknameRaw === 'string' ? nicknameRaw : String(nicknameRaw || '')
const nickname = rawNickname.trim() || rawNickname
if (!memberId || !nickname) continue
const slot = buckets.get(memberId)
if (slot) {
+117
View File
@@ -1,5 +1,6 @@
import { existsSync } from 'fs'
import { join } from 'path'
import { Worker } from 'worker_threads'
type NativeDecryptResult = {
data: Buffer
@@ -152,3 +153,119 @@ export function encryptDatViaNative(
return null
}
}
// ─── Worker 化解密:原生解密是同步 CPU/IO 调用,滚动时批量触发会阻塞主线程 ───
type WorkerDecryptResult = { data: Buffer; ext: string; isWxgf: boolean; meta: NativeDatMeta } | null
type PendingJob = {
resolve: (value: WorkerDecryptResult) => void
}
let decryptWorker: Worker | null = null
let workerFailedPermanently = false
let workerJobSeq = 0
const pendingJobs = new Map<number, PendingJob>()
function resolveWorkerPath(): string | null {
const isDev = process.env.NODE_ENV === 'development'
const candidates = isDev
? [join(__dirname, '../dist-electron/imageDecryptWorker.js'), join(__dirname, 'imageDecryptWorker.js')]
: [join(__dirname, 'imageDecryptWorker.js')]
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
}
return null
}
function flushPendingJobs(): void {
for (const job of pendingJobs.values()) job.resolve(null)
pendingJobs.clear()
}
function ensureDecryptWorker(): Worker | null {
if (workerFailedPermanently) return null
if (decryptWorker) return decryptWorker
const workerPath = resolveWorkerPath()
if (!workerPath) {
workerFailedPermanently = true
return null
}
try {
const worker = new Worker(workerPath, {
workerData: { resourcesPath: process.resourcesPath || '' }
})
worker.on('message', (msg: { id: number; ok: boolean; addonMissing?: boolean; data?: ArrayBuffer; ext?: string; isWxgf?: boolean; meta?: NativeDatMeta }) => {
const job = pendingJobs.get(msg.id)
if (!job) return
pendingJobs.delete(msg.id)
if (msg.addonMissing) {
// worker 中找不到原生模块:停用 worker 路径,后续直接走主线程同步逻辑
workerFailedPermanently = true
terminateDecryptWorker()
job.resolve(null)
return
}
if (!msg.ok || !msg.data) {
job.resolve(null)
return
}
job.resolve({
data: Buffer.from(msg.data),
ext: msg.ext || '',
isWxgf: Boolean(msg.isWxgf),
meta: msg.meta || {}
})
})
const handleWorkerGone = () => {
flushPendingJobs()
decryptWorker = null
}
worker.on('error', handleWorkerGone)
worker.on('exit', handleWorkerGone)
worker.unref()
decryptWorker = worker
return worker
} catch {
workerFailedPermanently = true
return null
}
}
/**
* 在 worker 线程中执行原生 DAT 解密,不阻塞主线程。
* worker 不可用时回退为主线程同步解密(行为与旧版一致)。
*/
export async function decryptDatViaNativeAsync(
inputPath: string,
xorKey: number,
aesKey?: string
): Promise<WorkerDecryptResult> {
if (!shouldEnableNative()) return null
const worker = ensureDecryptWorker()
if (!worker) {
return decryptDatViaNative(inputPath, xorKey, aesKey)
}
const id = ++workerJobSeq
return new Promise<WorkerDecryptResult>((resolve) => {
pendingJobs.set(id, { resolve })
try {
worker.postMessage({ id, datPath: inputPath, xorKey, aesKey })
} catch {
pendingJobs.delete(id)
resolve(decryptDatViaNative(inputPath, xorKey, aesKey))
}
})
}
export function terminateDecryptWorker(): void {
if (decryptWorker) {
void decryptWorker.terminate()
decryptWorker = null
}
flushPendingJobs()
}
@@ -1,5 +1,7 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { writeFile } from 'fs/promises'
import { app } from 'electron'
import { ConfigService } from './config'
/** 缓存版本号。增加/修改 SessionStatsCacheStats 字段后必须提升,避免旧缓存被误用。 */
@@ -131,6 +133,9 @@ function normalizeEntry(raw: unknown): SessionStatsCacheEntry | null {
export class SessionStatsCacheService {
private readonly cacheFilePath: string
private persistTimer: NodeJS.Timeout | null = null
private persistInFlight = false
private persistDirty = false
private store: SessionStatsCacheStore = {
version: CACHE_VERSION,
scopes: {}
@@ -143,6 +148,7 @@ export class SessionStatsCacheService {
this.cacheFilePath = join(basePath, 'session-stats.json')
this.ensureCacheDir()
this.load()
app?.once('will-quit', () => this.flushSync())
}
private ensureCacheDir(): void {
@@ -251,6 +257,10 @@ export class SessionStatsCacheService {
clearAll(): void {
this.store = { version: CACHE_VERSION, scopes: {} }
if (this.persistTimer) {
clearTimeout(this.persistTimer)
this.persistTimer = null
}
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
@@ -289,7 +299,43 @@ export class SessionStatsCacheService {
this.store.scopes = trimmedScopes
}
/**
* 防抖异步落盘:批量刷新会话统计时 set() 会被连续调用,
* 同步写盘会反复阻塞主线程(文件可达数百 KB)
*/
private persist(): void {
if (this.persistTimer) return
this.persistTimer = setTimeout(() => {
this.persistTimer = null
void this.persistNow()
}, 1000)
this.persistTimer.unref?.()
}
private async persistNow(): Promise<void> {
if (this.persistInFlight) {
this.persistDirty = true
return
}
this.persistInFlight = true
try {
await writeFile(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
console.error('SessionStatsCacheService: 保存缓存失败', error)
} finally {
this.persistInFlight = false
if (this.persistDirty) {
this.persistDirty = false
void this.persistNow()
}
}
}
/** 退出前把尚未落盘的改动同步写入 */
private flushSync(): void {
if (!this.persistTimer) return
clearTimeout(this.persistTimer)
this.persistTimer = null
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
+50 -8
View File
@@ -2,7 +2,7 @@ import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { ContactCacheService } from './contactCacheService'
import { app } from 'electron'
import { existsSync, mkdirSync, unlinkSync } from 'fs'
import { existsSync, mkdirSync, unlinkSync, renameSync } from 'fs'
import { readFile, writeFile, mkdir } from 'fs/promises'
import { basename, join } from 'path'
import crypto from 'crypto'
@@ -908,12 +908,53 @@ class SnsService {
return emojiDir
}
/**
* 计算稳定的缓存标识:剥离 token、idx 等会随微信重新同步而变化的鉴权参数,
* 仅保留 host + path + 稳定查询参数。避免 token 变化后缓存无法命中,
* 导致对方删除朋友圈时回源 404 而误判为"已删除"。
*/
private normalizeCacheUrl(url: string): string {
if (!url) return url
try {
const u = new URL(url)
u.searchParams.delete('token')
u.searchParams.delete('idx')
const query = u.searchParams.toString()
return `${u.host}${u.pathname}${query ? `?${query}` : ''}`
} catch {
return url
.replace(/([?&])(?:token|idx)=[^&]*/gi, '$1')
.replace(/[?&]+$/g, '')
.replace(/&&+/g, '&')
.replace(/\?&/g, '?')
}
}
private getCacheFilePath(url: string): string {
const hash = crypto.createHash('md5').update(url).digest('hex')
const hash = crypto.createHash('md5').update(this.normalizeCacheUrl(url)).digest('hex')
const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
return join(this.getSnsCacheDir(), `${hash}${ext}`)
}
/**
* 历史缓存一次性迁移:旧版本用完整 URL(含 token)的 md5 命名缓存文件,
* 改为规范化 URL 命名后会全部失配。这里按需将当前 URL 对应的旧命名文件
* 重命名为新命名,仅针对单个 URL、不遍历目录,迁移一次后不再触发,避免反复扫描。
*/
private migrateLegacyCacheFile(url: string, newPath: string): void {
try {
if (existsSync(newPath)) return
const legacyHash = crypto.createHash('md5').update(url).digest('hex')
const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
const legacyPath = join(this.getSnsCacheDir(), `${legacyHash}${ext}`)
if (legacyPath !== newPath && existsSync(legacyPath)) {
renameSync(legacyPath, newPath)
}
} catch (e) {
console.warn('[SnsService] 迁移历史缓存失败:', e)
}
}
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
const result = await wcdbService.getSnsUsernames()
if (!result.success) {
@@ -1325,9 +1366,9 @@ class SnsService {
async proxyImage(url: string, key?: string | number): Promise<{ success: boolean; dataUrl?: string; videoPath?: string; error?: string }> {
async proxyImage(url: string, key?: string | number): Promise<{ success: boolean; dataUrl?: string; videoPath?: string; status?: number; error?: string }> {
if (!url) return { success: false, error: 'url 不能为空' }
const cacheKey = `${url}|${key ?? ''}`
const cacheKey = `${this.normalizeCacheUrl(url)}|${key ?? ''}`
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
if (cachedDataUrl) {
@@ -1370,7 +1411,7 @@ class SnsService {
return { success: true, dataUrl }
}
}
return { success: false, error: result.error }
return { success: false, status: result.status, error: result.error }
}
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
@@ -2052,11 +2093,12 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
</html>`
}
private async fetchAndDecryptImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
private async fetchAndDecryptImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; status?: number; error?: string }> {
if (!url) return { success: false, error: 'url 不能为空' }
const isVideo = isVideoUrl(url)
const cachePath = this.getCacheFilePath(url)
this.migrateLegacyCacheFile(url, cachePath)
// 1. 优先尝试从当前缓存目录读取
if (existsSync(cachePath)) {
@@ -2107,7 +2149,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
if (res.statusCode !== 200 && res.statusCode !== 206) {
fileStream.close()
fs.unlink(tmpPath, () => { }) // 删除临时文件
resolve({ success: false, error: `HTTP ${res.statusCode}` })
resolve({ success: false, status: res.statusCode, error: `HTTP ${res.statusCode}` })
return
}
@@ -2208,7 +2250,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
const req = https.request(options, (res: any) => {
if (res.statusCode !== 200 && res.statusCode !== 206) {
console.error(`[SnsService] CDN 请求失败: HTTP ${res.statusCode}`)
resolve({ success: false, error: `HTTP ${res.statusCode}` })
resolve({ success: false, status: res.statusCode, error: `HTTP ${res.statusCode}` })
return
}
+64 -5
View File
@@ -59,6 +59,8 @@ export class WcdbCore {
private wcdbInitProtection: any = null
private wcdbInit: any = null
private wcdbShutdown: any = null
private wcdbPurgeMemory: any = null
private purgeTimer: ReturnType<typeof setInterval> | null = null
private wcdbOpenAccount: any = null
private wcdbCloseAccount: any = null
private wcdbSetMyWxid: any = null
@@ -680,6 +682,21 @@ export class WcdbCore {
return ''
}
private pickFirstDisplayNameField(row: Record<string, any>, candidates: string[]): string {
for (const key of candidates) {
const value = row[key]
if (typeof value === 'string') {
if (value.length > 0) return value.trim() || value
continue
}
if (value !== null && value !== undefined) {
const converted = String(value)
if (converted.length > 0) return converted.trim() || converted
}
}
return ''
}
private escapeSqlString(value: string): string {
return String(value || '').replace(/'/g, "''")
}
@@ -854,6 +871,13 @@ export class WcdbCore {
// wcdb_status wcdb_shutdown()
this.wcdbShutdown = this.lib.func('int32 wcdb_shutdown()')
// wcdb_status wcdb_purge_memory()(旧版 DLL 无此导出,容错处理)
try {
this.wcdbPurgeMemory = this.lib.func('int32 wcdb_purge_memory()')
} catch {
this.wcdbPurgeMemory = null
}
// wcdb_status wcdb_open_account(const char* session_db_path, const char* hex_key, wcdb_handle* out_handle)
// wcdb_handle 是 int64_t
this.wcdbOpenAccount = this.lib.func('int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)')
@@ -1616,7 +1640,8 @@ export class WcdbCore {
private writeDisplayNameCache(username: string, displayName: string, now = Date.now()): void {
const normalizedUsername = String(username || '').trim()
const normalizedDisplayName = String(displayName || '').trim()
const rawDisplayName = typeof displayName === 'string' ? displayName : String(displayName || '')
const normalizedDisplayName = rawDisplayName.trim() || rawDisplayName
if (!normalizedUsername || !normalizedDisplayName) return
this.displayNameCache.set(normalizedUsername, {
displayName: normalizedDisplayName,
@@ -1865,6 +1890,7 @@ export class WcdbCore {
if (this.isLogEnabled()) {
this.startLogPolling()
}
this.startPeriodicPurge()
this.writeLog(`open ok handle=${handle}`, true)
await this.dumpDbStatus('open')
await this.runPostOpenDiagnostics(accountDir, dbStoragePath, sessionDbPath, wxid)
@@ -1881,11 +1907,36 @@ export class WcdbCore {
* 关闭数据库
* 注意:wcdb_close_account 可能导致崩溃,使用 shutdown 代替
*/
/**
* 周期性回收 WCDB 空闲连接与页缓存:批量查询(会话统计/分析)会让多库多连接
* 的页缓存驻留,purgeAll 只关闭空闲连接,热连接不受影响,代价可忽略
*/
private startPeriodicPurge(): void {
if (this.purgeTimer || !this.wcdbPurgeMemory) return
this.purgeTimer = setInterval(() => {
if (!this.initialized) return
try {
this.wcdbPurgeMemory()
} catch {
// 忽略:purge 失败不影响功能
}
}, 60 * 1000)
this.purgeTimer.unref?.()
}
private stopPeriodicPurge(): void {
if (this.purgeTimer) {
clearInterval(this.purgeTimer)
this.purgeTimer = null
}
}
close(): void {
if (this.handle !== null || this.initialized) {
// 先停止监控与云控回调,避免 shutdown 后仍有 native 回调访问已释放资源。
try { this.stopMonitor() } catch {}
try { this.cloudStop() } catch {}
this.stopPeriodicPurge()
try {
// 不调用 closeAccount,直接 shutdown
this.wcdbShutdown()
@@ -2696,7 +2747,7 @@ export class WcdbCore {
row.talker ||
''
).trim(),
displayName: String(row.displayName || row.display_name || row.remark || '').trim(),
displayName: this.pickFirstDisplayNameField(row, ['displayName', 'display_name', 'remark']),
sortTimestamp: toInt(
row.sort_timestamp ||
row.sortTimestamp ||
@@ -2876,7 +2927,10 @@ export class WcdbCore {
const displayNameRes = await this.getDisplayNames(unresolvedSessionIds)
if (displayNameRes.success && displayNameRes.map) {
unresolvedSessionIds.forEach((sessionId) => {
const display = String(displayNameRes.map?.[sessionId] || '').trim()
const rawDisplay = displayNameRes.map?.[sessionId]
const display = typeof rawDisplay === 'string'
? (rawDisplay.trim() || rawDisplay)
: ''
if (display) sessionNameMap.set(sessionId, display)
})
items = items.map((item) => ({
@@ -2949,7 +3003,7 @@ export class WcdbCore {
for (const row of (q.rows || []) as Array<Record<string, any>>) {
const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName'])
if (!username) continue
const display = this.pickFirstStringField(row, [
const display = this.pickFirstDisplayNameField(row, [
'remark', 'Remark',
'nick_name', 'nickName', 'nickname', 'NickName',
'alias', 'Alias'
@@ -2992,7 +3046,12 @@ export class WcdbCore {
const map = JSON.parse(jsonStr)
if (map && typeof map === 'object') {
for (const username of toFetch) {
const display = String((map as Record<string, unknown>)[username] || '').trim()
const rawDisplay = (map as Record<string, unknown>)[username]
const display = typeof rawDisplay === 'string'
? rawDisplay
: rawDisplay === null || rawDisplay === undefined
? ''
: String(rawDisplay).trim()
if (!display) continue
resultMap[username] = display
this.writeDisplayNameCache(username, display, now)
+31
View File
@@ -38,6 +38,32 @@ export function setNotificationNavigateHandler(
let notificationWindow: BrowserWindow | null = null;
let closeTimer: NodeJS.Timeout | null = null;
// 空闲销毁:隐藏的通知窗口(含渲染进程)常驻占用 ~120MB 工作集,
// 通知稀少时不值得养着。隐藏后闲置超时即销毁,下一条通知重新冷启动
const IDLE_DESTROY_DELAY_MS = 3 * 60 * 1000;
let idleDestroyTimer: NodeJS.Timeout | null = null;
function cancelIdleDestroy() {
if (idleDestroyTimer) {
clearTimeout(idleDestroyTimer);
idleDestroyTimer = null;
}
}
function scheduleIdleDestroy() {
cancelIdleDestroy();
idleDestroyTimer = setTimeout(() => {
idleDestroyTimer = null;
// 可见期间不销毁(cancel/schedule 时序兜底)
if (notificationWindow && !notificationWindow.isDestroyed() && notificationWindow.isVisible()) {
scheduleIdleDestroy();
return;
}
destroyNotificationWindow();
}, IDLE_DESTROY_DELAY_MS);
idleDestroyTimer.unref?.();
}
// 原生玻璃面板:与通知窗口一样常驻复用(创建后隐藏待命),
// 展示期跟随渲染层上报的卡片实测矩形(notification:glassRect)
let glassPanel: import("@hicccc77/electron-liquid-glass").GlassPanel | null = null;
@@ -138,6 +164,7 @@ function refreshDesktopSourceId(): Promise<void> {
}
export function destroyNotificationWindow() {
cancelIdleDestroy();
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
@@ -290,6 +317,7 @@ export async function showNotification(data: any) {
return;
}
cancelIdleDestroy();
let win = notificationWindow;
if (!win || win.isDestroyed()) {
win = createNotificationWindow();
@@ -469,6 +497,7 @@ export async function registerNotificationHandlers() {
notificationWindow.hide();
notificationWindow.setIgnoreMouseEvents(true, { forward: true });
}
scheduleIdleDestroy();
});
// —— 原生玻璃面板生命周期(仅 nativeGlass 可用时渲染层才会发这些消息)——
@@ -555,6 +584,8 @@ export async function registerNotificationHandlers() {
[],
);
}
// 预热窗口若迟迟没有通知到来,按空闲策略回收
scheduleIdleDestroy();
}, 3000);
if (!nativeGlass) {
// 显示器增删后源 ID 可能失效,重新预热