mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-24 07:26:48 +00:00
Merge pull request #823 from Jasonzhu1207/main
fix(perf): prevent memory growth in chat and export flows
This commit is contained in:
169
electron/main.ts
169
electron/main.ts
@@ -375,7 +375,34 @@ let isAppQuitting = false
|
|||||||
let shutdownPromise: Promise<void> | null = null
|
let shutdownPromise: Promise<void> | null = null
|
||||||
let tray: Tray | null = null
|
let tray: Tray | null = null
|
||||||
let isClosePromptVisible = false
|
let isClosePromptVisible = false
|
||||||
const chatHistoryPayloadStore = new Map<string, { sessionId: string; title?: string; recordList: any[] }>()
|
|
||||||
|
interface ChatHistoryPayloadEntry {
|
||||||
|
sessionId: string
|
||||||
|
title?: string
|
||||||
|
recordList: any[]
|
||||||
|
createdAt: number
|
||||||
|
lastAccessedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatHistoryPayloadStore = new Map<string, ChatHistoryPayloadEntry>()
|
||||||
|
const chatHistoryPayloadTtlMs = 10 * 60 * 1000
|
||||||
|
const chatHistoryPayloadMaxEntries = 20
|
||||||
|
|
||||||
|
const pruneChatHistoryPayloadStore = (): void => {
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
for (const [payloadId, payload] of chatHistoryPayloadStore.entries()) {
|
||||||
|
if (now - payload.createdAt > chatHistoryPayloadTtlMs) {
|
||||||
|
chatHistoryPayloadStore.delete(payloadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (chatHistoryPayloadStore.size > chatHistoryPayloadMaxEntries) {
|
||||||
|
const oldestPayloadId = chatHistoryPayloadStore.keys().next().value as string | undefined
|
||||||
|
if (!oldestPayloadId) break
|
||||||
|
chatHistoryPayloadStore.delete(oldestPayloadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||||
|
|
||||||
@@ -659,6 +686,62 @@ const setupCustomTitleBarWindow = (win: BrowserWindow): void => {
|
|||||||
win.webContents.on('did-finish-load', emitMaximizeState)
|
win.webContents.on('did-finish-load', emitMaximizeState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let notificationNavigateHandlerRegistered = false
|
||||||
|
const focusMainWindowAndNavigate = (sessionId: string): void => {
|
||||||
|
const targetWindow = mainWindow
|
||||||
|
if (!targetWindow || targetWindow.isDestroyed()) return
|
||||||
|
if (targetWindow.isMinimized()) targetWindow.restore()
|
||||||
|
targetWindow.show()
|
||||||
|
targetWindow.focus()
|
||||||
|
targetWindow.webContents.send('navigate-to-session', sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureNotificationNavigateHandlerRegistered = (): void => {
|
||||||
|
if (notificationNavigateHandlerRegistered) return
|
||||||
|
notificationNavigateHandlerRegistered = true
|
||||||
|
ipcMain.on('notification-clicked', (_event, sessionId) => {
|
||||||
|
focusMainWindowAndNavigate(String(sessionId || ''))
|
||||||
|
})
|
||||||
|
setNotificationNavigateHandler((sessionId: string) => {
|
||||||
|
focusMainWindowAndNavigate(String(sessionId || ''))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let wechatRequestHeaderInterceptorRegistered = false
|
||||||
|
const ensureWeChatRequestHeaderInterceptor = (): void => {
|
||||||
|
if (wechatRequestHeaderInterceptorRegistered) return
|
||||||
|
wechatRequestHeaderInterceptorRegistered = true
|
||||||
|
|
||||||
|
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||||
|
{
|
||||||
|
urls: [
|
||||||
|
'*://*.qpic.cn/*',
|
||||||
|
'*://*.qlogo.cn/*',
|
||||||
|
'*://*.wechat.com/*',
|
||||||
|
'*://*.weixin.qq.com/*',
|
||||||
|
'*://*.wx.qq.com/*'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
(details, callback) => {
|
||||||
|
details.requestHeaders['User-Agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351"
|
||||||
|
details.requestHeaders['Accept'] = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
|
||||||
|
details.requestHeaders['Accept-Encoding'] = "gzip, deflate, br"
|
||||||
|
details.requestHeaders['Accept-Language'] = "zh-CN,zh;q=0.9"
|
||||||
|
details.requestHeaders['Connection'] = "keep-alive"
|
||||||
|
details.requestHeaders['Range'] = "bytes=0-"
|
||||||
|
|
||||||
|
let host = ''
|
||||||
|
try {
|
||||||
|
host = new URL(details.url).hostname.toLowerCase()
|
||||||
|
} catch {}
|
||||||
|
const isWxQQ = host === 'wx.qq.com' || host.endsWith('.wx.qq.com')
|
||||||
|
details.requestHeaders['Referer'] = isWxQQ ? 'https://wx.qq.com/' : 'https://servicewechat.com/'
|
||||||
|
|
||||||
|
callback({ cancel: false, requestHeaders: details.requestHeaders })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const getWindowCloseBehavior = (): WindowCloseBehavior => {
|
const getWindowCloseBehavior = (): WindowCloseBehavior => {
|
||||||
const behavior = configService?.get('windowCloseBehavior')
|
const behavior = configService?.get('windowCloseBehavior')
|
||||||
return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask'
|
return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask'
|
||||||
@@ -734,44 +817,6 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
win.loadFile(join(__dirname, '../dist/index.html'))
|
win.loadFile(join(__dirname, '../dist/index.html'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle notification click navigation
|
|
||||||
ipcMain.on('notification-clicked', (_, sessionId) => {
|
|
||||||
if (win.isMinimized()) win.restore()
|
|
||||||
win.show()
|
|
||||||
win.focus()
|
|
||||||
win.webContents.send('navigate-to-session', sessionId)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 设置用于D-Bus通知的Linux通知导航处理程序
|
|
||||||
setNotificationNavigateHandler((sessionId: string) => {
|
|
||||||
if (win.isMinimized()) win.restore()
|
|
||||||
win.show()
|
|
||||||
win.focus()
|
|
||||||
win.webContents.send('navigate-to-session', sessionId)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 拦截请求,修改 Referer 和 User-Agent 以通过微信 CDN 鉴权
|
|
||||||
session.defaultSession.webRequest.onBeforeSendHeaders(
|
|
||||||
{
|
|
||||||
urls: [
|
|
||||||
'*://*.qpic.cn/*',
|
|
||||||
'*://*.qlogo.cn/*',
|
|
||||||
'*://*.wechat.com/*',
|
|
||||||
'*://*.weixin.qq.com/*'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
(details, callback) => {
|
|
||||||
details.requestHeaders['User-Agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351"
|
|
||||||
details.requestHeaders['Accept'] = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
|
|
||||||
details.requestHeaders['Accept-Encoding'] = "gzip, deflate, br"
|
|
||||||
details.requestHeaders['Accept-Language'] = "zh-CN,zh;q=0.9"
|
|
||||||
details.requestHeaders['Referer'] = "https://servicewechat.com/"
|
|
||||||
details.requestHeaders['Connection'] = "keep-alive"
|
|
||||||
details.requestHeaders['Range'] = "bytes=0-"
|
|
||||||
callback({ cancel: false, requestHeaders: details.requestHeaders })
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 忽略微信 CDN 域名的证书错误(部分节点证书配置不正确)
|
// 忽略微信 CDN 域名的证书错误(部分节点证书配置不正确)
|
||||||
win.webContents.on('certificate-error', (event, url, _error, _cert, callback) => {
|
win.webContents.on('certificate-error', (event, url, _error, _cert, callback) => {
|
||||||
const trusted = ['.qq.com', '.qpic.cn', '.weixin.qq.com', '.wechat.com']
|
const trusted = ['.qq.com', '.qpic.cn', '.weixin.qq.com', '.wechat.com']
|
||||||
@@ -1179,7 +1224,11 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createChatHistoryPayloadWindow(payloadId: string) {
|
function createChatHistoryPayloadWindow(payloadId: string) {
|
||||||
return createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`)
|
const win = createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`)
|
||||||
|
win.on('closed', () => {
|
||||||
|
chatHistoryPayloadStore.delete(payloadId)
|
||||||
|
})
|
||||||
|
return win
|
||||||
}
|
}
|
||||||
|
|
||||||
function createChatHistoryRouteWindow(route: string) {
|
function createChatHistoryRouteWindow(route: string) {
|
||||||
@@ -1612,6 +1661,7 @@ const runLegacySnsCacheMigration = async (
|
|||||||
// 注册 IPC 处理器
|
// 注册 IPC 处理器
|
||||||
function registerIpcHandlers() {
|
function registerIpcHandlers() {
|
||||||
registerNotificationHandlers()
|
registerNotificationHandlers()
|
||||||
|
ensureNotificationNavigateHandlerRegistered()
|
||||||
bizService.registerHandlers()
|
bizService.registerHandlers()
|
||||||
// 配置相关
|
// 配置相关
|
||||||
ipcMain.handle('config:get', async (_, key: string) => {
|
ipcMain.handle('config:get', async (_, key: string) => {
|
||||||
@@ -1989,19 +2039,38 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
ipcMain.handle('window:openChatHistoryPayloadWindow', (_, payload: { sessionId: string; title?: string; recordList: any[] }) => {
|
ipcMain.handle('window:openChatHistoryPayloadWindow', (_, payload: { sessionId: string; title?: string; recordList: any[] }) => {
|
||||||
const payloadId = randomUUID()
|
const payloadId = randomUUID()
|
||||||
|
pruneChatHistoryPayloadStore()
|
||||||
|
const now = Date.now()
|
||||||
chatHistoryPayloadStore.set(payloadId, {
|
chatHistoryPayloadStore.set(payloadId, {
|
||||||
sessionId: String(payload?.sessionId || '').trim(),
|
sessionId: String(payload?.sessionId || '').trim(),
|
||||||
title: String(payload?.title || '').trim() || '聊天记录',
|
title: String(payload?.title || '').trim() || '聊天记录',
|
||||||
recordList: Array.isArray(payload?.recordList) ? payload.recordList : []
|
recordList: Array.isArray(payload?.recordList) ? payload.recordList : [],
|
||||||
|
createdAt: now,
|
||||||
|
lastAccessedAt: now
|
||||||
})
|
})
|
||||||
|
pruneChatHistoryPayloadStore()
|
||||||
createChatHistoryPayloadWindow(payloadId)
|
createChatHistoryPayloadWindow(payloadId)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('window:getChatHistoryPayload', (_, payloadId: string) => {
|
ipcMain.handle('window:getChatHistoryPayload', (_, payloadId: string) => {
|
||||||
const payload = chatHistoryPayloadStore.get(String(payloadId || '').trim())
|
pruneChatHistoryPayloadStore()
|
||||||
|
const normalizedPayloadId = String(payloadId || '').trim()
|
||||||
|
const payload = chatHistoryPayloadStore.get(normalizedPayloadId)
|
||||||
if (!payload) return { success: false, error: '聊天记录载荷不存在或已失效' }
|
if (!payload) return { success: false, error: '聊天记录载荷不存在或已失效' }
|
||||||
return { success: true, payload }
|
const nextPayload: ChatHistoryPayloadEntry = {
|
||||||
|
...payload,
|
||||||
|
lastAccessedAt: Date.now()
|
||||||
|
}
|
||||||
|
chatHistoryPayloadStore.set(normalizedPayloadId, nextPayload)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
payload: {
|
||||||
|
sessionId: nextPayload.sessionId,
|
||||||
|
title: nextPayload.title,
|
||||||
|
recordList: nextPayload.recordList
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
|
// 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
|
||||||
@@ -3054,6 +3123,7 @@ function registerIpcHandlers() {
|
|||||||
ipcMain.handle('cache:clearImages', async () => {
|
ipcMain.handle('cache:clearImages', async () => {
|
||||||
const imageResult = await imageDecryptService.clearCache()
|
const imageResult = await imageDecryptService.clearCache()
|
||||||
const emojiResult = chatService.clearCaches({ includeMessages: false, includeContacts: false, includeEmojis: true })
|
const emojiResult = chatService.clearCaches({ includeMessages: false, includeContacts: false, includeEmojis: true })
|
||||||
|
snsService.clearMemoryCache()
|
||||||
const errors = [imageResult, emojiResult]
|
const errors = [imageResult, emojiResult]
|
||||||
.filter((result) => !result.success)
|
.filter((result) => !result.success)
|
||||||
.map((result) => result.error)
|
.map((result) => result.error)
|
||||||
@@ -3070,6 +3140,7 @@ function registerIpcHandlers() {
|
|||||||
imageDecryptService.clearCache()
|
imageDecryptService.clearCache()
|
||||||
])
|
])
|
||||||
const chatResult = chatService.clearCaches()
|
const chatResult = chatService.clearCaches()
|
||||||
|
snsService.clearMemoryCache()
|
||||||
const errors = [analyticsResult, imageResult, chatResult]
|
const errors = [analyticsResult, imageResult, chatResult]
|
||||||
.filter((result) => !result.success)
|
.filter((result) => !result.success)
|
||||||
.map((result) => result.error)
|
.map((result) => result.error)
|
||||||
@@ -3792,6 +3863,7 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
// 创建主窗口(不显示,由启动流程统一控制)
|
// 创建主窗口(不显示,由启动流程统一控制)
|
||||||
updateSplashProgress(70, '正在准备主窗口...')
|
updateSplashProgress(70, '正在准备主窗口...')
|
||||||
|
ensureWeChatRequestHeaderInterceptor()
|
||||||
mainWindow = createWindow({ autoShow: false })
|
mainWindow = createWindow({ autoShow: false })
|
||||||
|
|
||||||
let iconName = 'icon.ico';
|
let iconName = 'icon.ico';
|
||||||
@@ -3851,17 +3923,6 @@ app.whenReady().then(async () => {
|
|||||||
console.warn('[Tray] Failed to create tray icon:', e)
|
console.warn('[Tray] Failed to create tray icon:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置网络服务
|
|
||||||
session.defaultSession.webRequest.onBeforeSendHeaders(
|
|
||||||
{
|
|
||||||
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
|
|
||||||
},
|
|
||||||
(details, callback) => {
|
|
||||||
details.requestHeaders['Referer'] = 'https://wx.qq.com/'
|
|
||||||
callback({ requestHeaders: details.requestHeaders })
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
|
// 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
|
||||||
updateSplashProgress(70, '正在准备主窗口...', true)
|
updateSplashProgress(70, '正在准备主窗口...', true)
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
|
|||||||
@@ -349,6 +349,7 @@ class ChatService {
|
|||||||
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number; startTime?: number; endTime?: number; ascending?: boolean; bufferedMessages?: any[] }> = new Map()
|
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number; startTime?: number; endTime?: number; ascending?: boolean; bufferedMessages?: any[] }> = new Map()
|
||||||
private messageCursorMutex: boolean = false
|
private messageCursorMutex: boolean = false
|
||||||
private readonly messageBatchDefault = 50
|
private readonly messageBatchDefault = 50
|
||||||
|
private readonly messageCursorSessionLimit = 8
|
||||||
private avatarCache: Map<string, ContactCacheEntry>
|
private avatarCache: Map<string, ContactCacheEntry>
|
||||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
||||||
@@ -673,6 +674,27 @@ class ChatService {
|
|||||||
/**
|
/**
|
||||||
* 关闭数据库连接
|
* 关闭数据库连接
|
||||||
*/
|
*/
|
||||||
|
private async closeMessageCursorBySession(sessionId: string): Promise<void> {
|
||||||
|
const state = this.messageCursors.get(sessionId)
|
||||||
|
if (!state) return
|
||||||
|
try {
|
||||||
|
await wcdbService.closeMessageCursor(state.cursor)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[ChatService] 关闭消息游标失败: ${sessionId}`, error)
|
||||||
|
} finally {
|
||||||
|
this.messageCursors.delete(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async trimMessageCursorStates(activeSessionId: string): Promise<void> {
|
||||||
|
if (this.messageCursors.size <= this.messageCursorSessionLimit) return
|
||||||
|
for (const [sessionId] of this.messageCursors) {
|
||||||
|
if (this.messageCursors.size <= this.messageCursorSessionLimit) break
|
||||||
|
if (sessionId === activeSessionId) continue
|
||||||
|
await this.closeMessageCursorBySession(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
try {
|
try {
|
||||||
for (const state of this.messageCursors.values()) {
|
for (const state of this.messageCursors.values()) {
|
||||||
@@ -1958,6 +1980,11 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let state = this.messageCursors.get(sessionId)
|
let state = this.messageCursors.get(sessionId)
|
||||||
|
if (state) {
|
||||||
|
// refresh insertion order so Map iteration approximates LRU
|
||||||
|
this.messageCursors.delete(sessionId)
|
||||||
|
this.messageCursors.set(sessionId, state)
|
||||||
|
}
|
||||||
|
|
||||||
// 只在以下情况重新创建游标:
|
// 只在以下情况重新创建游标:
|
||||||
// 1. 没有游标状态
|
// 1. 没有游标状态
|
||||||
@@ -1976,7 +2003,7 @@ class ChatService {
|
|||||||
// 关闭旧游标
|
// 关闭旧游标
|
||||||
if (state) {
|
if (state) {
|
||||||
try {
|
try {
|
||||||
await wcdbService.closeMessageCursor(state.cursor)
|
await this.closeMessageCursorBySession(sessionId)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[ChatService] 关闭旧游标失败:', e)
|
console.warn('[ChatService] 关闭旧游标失败:', e)
|
||||||
}
|
}
|
||||||
@@ -1994,6 +2021,7 @@ class ChatService {
|
|||||||
|
|
||||||
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
|
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
|
||||||
this.messageCursors.set(sessionId, state)
|
this.messageCursors.set(sessionId, state)
|
||||||
|
await this.trimMessageCursorStates(sessionId)
|
||||||
|
|
||||||
// 如果需要跳过消息(offset > 0),逐批获取但不返回
|
// 如果需要跳过消息(offset > 0),逐批获取但不返回
|
||||||
// 注意:仅在 offset === 0 时重建游标最安全;
|
// 注意:仅在 offset === 0 时重建游标最安全;
|
||||||
@@ -2064,6 +2092,8 @@ class ChatService {
|
|||||||
const filtered = collected.messages || []
|
const filtered = collected.messages || []
|
||||||
const hasMore = collected.hasMore === true
|
const hasMore = collected.hasMore === true
|
||||||
state.fetched += rawRowsConsumed
|
state.fetched += rawRowsConsumed
|
||||||
|
this.messageCursors.delete(sessionId)
|
||||||
|
this.messageCursors.set(sessionId, state)
|
||||||
releaseMessageCursorMutex?.()
|
releaseMessageCursorMutex?.()
|
||||||
|
|
||||||
this.messageCacheService.set(sessionId, filtered)
|
this.messageCacheService.set(sessionId, filtered)
|
||||||
|
|||||||
@@ -496,11 +496,20 @@ class HttpService {
|
|||||||
const contentType = mimeTypes[ext] || 'application/octet-stream'
|
const contentType = mimeTypes[ext] || 'application/octet-stream'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileBuffer = fs.readFileSync(fullPath)
|
const stat = fs.statSync(fullPath)
|
||||||
res.setHeader('Content-Type', contentType)
|
res.setHeader('Content-Type', contentType)
|
||||||
res.setHeader('Content-Length', fileBuffer.length)
|
res.setHeader('Content-Length', stat.size)
|
||||||
res.writeHead(200)
|
res.writeHead(200)
|
||||||
res.end(fileBuffer)
|
|
||||||
|
const stream = fs.createReadStream(fullPath)
|
||||||
|
stream.on('error', () => {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
this.sendError(res, 500, 'Failed to read media file')
|
||||||
|
} else {
|
||||||
|
try { res.destroy() } catch {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
stream.pipe(res)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.sendError(res, 500, 'Failed to read media file')
|
this.sendError(res, 500, 'Failed to read media file')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export class MessageCacheService {
|
|||||||
private readonly cacheFilePath: string
|
private readonly cacheFilePath: string
|
||||||
private cache: Record<string, SessionMessageCacheEntry> = {}
|
private cache: Record<string, SessionMessageCacheEntry> = {}
|
||||||
private readonly sessionLimit = 150
|
private readonly sessionLimit = 150
|
||||||
|
private readonly maxSessionEntries = 48
|
||||||
|
|
||||||
constructor(cacheBasePath?: string) {
|
constructor(cacheBasePath?: string) {
|
||||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
@@ -36,6 +37,7 @@ export class MessageCacheService {
|
|||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
if (parsed && typeof parsed === 'object') {
|
if (parsed && typeof parsed === 'object') {
|
||||||
this.cache = parsed
|
this.cache = parsed
|
||||||
|
this.pruneSessionEntries()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('MessageCacheService: 载入缓存失败', error)
|
console.error('MessageCacheService: 载入缓存失败', error)
|
||||||
@@ -43,6 +45,19 @@ export class MessageCacheService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pruneSessionEntries(): void {
|
||||||
|
const entries = Object.entries(this.cache || {})
|
||||||
|
if (entries.length <= this.maxSessionEntries) return
|
||||||
|
|
||||||
|
entries.sort((left, right) => {
|
||||||
|
const leftAt = Number(left[1]?.updatedAt || 0)
|
||||||
|
const rightAt = Number(right[1]?.updatedAt || 0)
|
||||||
|
return rightAt - leftAt
|
||||||
|
})
|
||||||
|
|
||||||
|
this.cache = Object.fromEntries(entries.slice(0, this.maxSessionEntries))
|
||||||
|
}
|
||||||
|
|
||||||
get(sessionId: string): SessionMessageCacheEntry | undefined {
|
get(sessionId: string): SessionMessageCacheEntry | undefined {
|
||||||
return this.cache[sessionId]
|
return this.cache[sessionId]
|
||||||
}
|
}
|
||||||
@@ -56,6 +71,7 @@ export class MessageCacheService {
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
messages: trimmed
|
messages: trimmed
|
||||||
}
|
}
|
||||||
|
this.pruneSessionEntries()
|
||||||
this.persist()
|
this.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -324,6 +324,9 @@ class SnsService {
|
|||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private contactCache: ContactCacheService
|
private contactCache: ContactCacheService
|
||||||
private imageCache = new Map<string, string>()
|
private imageCache = new Map<string, string>()
|
||||||
|
private imageCacheMeta = new Map<string, number>()
|
||||||
|
private readonly imageCacheTtlMs = 15 * 60 * 1000
|
||||||
|
private readonly imageCacheMaxEntries = 120
|
||||||
private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null
|
private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null
|
||||||
private userPostCountsCache: { counts: Record<string, number>; updatedAt: number } | null = null
|
private userPostCountsCache: { counts: Record<string, number>; updatedAt: number } | null = null
|
||||||
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
|
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
|
||||||
@@ -336,6 +339,38 @@ class SnsService {
|
|||||||
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
|
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearMemoryCache(): void {
|
||||||
|
this.imageCache.clear()
|
||||||
|
this.imageCacheMeta.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneImageCache(now: number = Date.now()): void {
|
||||||
|
for (const [key, updatedAt] of this.imageCacheMeta.entries()) {
|
||||||
|
if (now - updatedAt > this.imageCacheTtlMs) {
|
||||||
|
this.imageCacheMeta.delete(key)
|
||||||
|
this.imageCache.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (this.imageCache.size > this.imageCacheMaxEntries) {
|
||||||
|
const oldestKey = this.imageCache.keys().next().value as string | undefined
|
||||||
|
if (!oldestKey) break
|
||||||
|
this.imageCache.delete(oldestKey)
|
||||||
|
this.imageCacheMeta.delete(oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberImageCache(cacheKey: string, dataUrl: string): void {
|
||||||
|
if (!cacheKey || !dataUrl) return
|
||||||
|
const now = Date.now()
|
||||||
|
if (this.imageCache.has(cacheKey)) {
|
||||||
|
this.imageCache.delete(cacheKey)
|
||||||
|
}
|
||||||
|
this.imageCache.set(cacheKey, dataUrl)
|
||||||
|
this.imageCacheMeta.set(cacheKey, now)
|
||||||
|
this.pruneImageCache(now)
|
||||||
|
}
|
||||||
|
|
||||||
private toOptionalString(value: unknown): string | undefined {
|
private toOptionalString(value: unknown): string | undefined {
|
||||||
if (typeof value !== 'string') return undefined
|
if (typeof value !== 'string') return undefined
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
@@ -1239,20 +1274,27 @@ class SnsService {
|
|||||||
if (!url) return { success: false, error: 'url 不能为空' }
|
if (!url) return { success: false, error: 'url 不能为空' }
|
||||||
const cacheKey = `${url}|${key ?? ''}`
|
const cacheKey = `${url}|${key ?? ''}`
|
||||||
|
|
||||||
if (this.imageCache.has(cacheKey)) {
|
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
|
||||||
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
|
if (cachedDataUrl) {
|
||||||
const base64Part = cachedDataUrl.split(',')[1] || ''
|
const cachedAt = this.imageCacheMeta.get(cacheKey) || 0
|
||||||
if (base64Part) {
|
if (cachedAt > 0 && Date.now() - cachedAt <= this.imageCacheTtlMs) {
|
||||||
try {
|
const base64Part = cachedDataUrl.split(',')[1] || ''
|
||||||
const cachedBuf = Buffer.from(base64Part, 'base64')
|
if (base64Part) {
|
||||||
if (detectImageMime(cachedBuf, '').startsWith('image/')) {
|
try {
|
||||||
return { success: true, dataUrl: cachedDataUrl }
|
const cachedBuf = Buffer.from(base64Part, 'base64')
|
||||||
|
if (detectImageMime(cachedBuf, '').startsWith('image/')) {
|
||||||
|
this.imageCache.delete(cacheKey)
|
||||||
|
this.imageCache.set(cacheKey, cachedDataUrl)
|
||||||
|
this.imageCacheMeta.set(cacheKey, Date.now())
|
||||||
|
return { success: true, dataUrl: cachedDataUrl }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and fall through to refetch
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// ignore and fall through to refetch
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.imageCache.delete(cacheKey)
|
this.imageCache.delete(cacheKey)
|
||||||
|
this.imageCacheMeta.delete(cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.fetchAndDecryptImage(url, key)
|
const result = await this.fetchAndDecryptImage(url, key)
|
||||||
@@ -1269,7 +1311,7 @@ class SnsService {
|
|||||||
return { success: false, error: '无效图片数据(可能密钥不匹配或缓存损坏)' }
|
return { success: false, error: '无效图片数据(可能密钥不匹配或缓存损坏)' }
|
||||||
}
|
}
|
||||||
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
|
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
|
||||||
this.imageCache.set(cacheKey, dataUrl)
|
this.rememberImageCache(cacheKey, dataUrl)
|
||||||
return { success: true, dataUrl }
|
return { success: true, dataUrl }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,21 @@ import './Avatar.scss'
|
|||||||
|
|
||||||
// 全局缓存已成功加载过的头像 URL,用于控制后续是否显示动画
|
// 全局缓存已成功加载过的头像 URL,用于控制后续是否显示动画
|
||||||
const loadedAvatarCache = new Set<string>()
|
const loadedAvatarCache = new Set<string>()
|
||||||
|
const MAX_LOADED_AVATAR_CACHE_SIZE = 3000
|
||||||
|
|
||||||
|
const rememberLoadedAvatar = (src: string): void => {
|
||||||
|
if (!src) return
|
||||||
|
if (loadedAvatarCache.has(src)) {
|
||||||
|
loadedAvatarCache.delete(src)
|
||||||
|
}
|
||||||
|
loadedAvatarCache.add(src)
|
||||||
|
|
||||||
|
while (loadedAvatarCache.size > MAX_LOADED_AVATAR_CACHE_SIZE) {
|
||||||
|
const oldest = loadedAvatarCache.values().next().value as string | undefined
|
||||||
|
if (!oldest) break
|
||||||
|
loadedAvatarCache.delete(oldest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface AvatarProps {
|
interface AvatarProps {
|
||||||
src?: string
|
src?: string
|
||||||
@@ -123,7 +138,7 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
if (src) {
|
if (src) {
|
||||||
avatarLoadQueue.clearFailed(src)
|
avatarLoadQueue.clearFailed(src)
|
||||||
loadedAvatarCache.add(src)
|
rememberLoadedAvatar(src)
|
||||||
}
|
}
|
||||||
setImageLoaded(true)
|
setImageLoaded(true)
|
||||||
setImageError(false)
|
setImageError(false)
|
||||||
|
|||||||
Reference in New Issue
Block a user