架构优化
This commit is contained in:
1 parent
147f3b7045
commit
ccbd154668
67 files changed
+1726
-421
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user