Merge pull request #823 from Jasonzhu1207/main

fix(perf): prevent memory growth in chat and export flows
This commit is contained in:
cc
2026-04-23 18:41:04 +08:00
committed by GitHub
6 changed files with 243 additions and 70 deletions

View File

@@ -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 messageCursorMutex: boolean = false
private readonly messageBatchDefault = 50
private readonly messageCursorSessionLimit = 8
private avatarCache: Map<string, ContactCacheEntry>
private readonly avatarCacheTtlMs = 10 * 60 * 1000
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 {
try {
for (const state of this.messageCursors.values()) {
@@ -1958,6 +1980,11 @@ class ChatService {
}
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. 没有游标状态
@@ -1976,7 +2003,7 @@ class ChatService {
// 关闭旧游标
if (state) {
try {
await wcdbService.closeMessageCursor(state.cursor)
await this.closeMessageCursorBySession(sessionId)
} catch (e) {
console.warn('[ChatService] 关闭旧游标失败:', e)
}
@@ -1994,6 +2021,7 @@ class ChatService {
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
this.messageCursors.set(sessionId, state)
await this.trimMessageCursorStates(sessionId)
// 如果需要跳过消息(offset > 0),逐批获取但不返回
// 注意:仅在 offset === 0 时重建游标最安全;
@@ -2064,6 +2092,8 @@ class ChatService {
const filtered = collected.messages || []
const hasMore = collected.hasMore === true
state.fetched += rawRowsConsumed
this.messageCursors.delete(sessionId)
this.messageCursors.set(sessionId, state)
releaseMessageCursorMutex?.()
this.messageCacheService.set(sessionId, filtered)

View File

@@ -496,11 +496,20 @@ class HttpService {
const contentType = mimeTypes[ext] || 'application/octet-stream'
try {
const fileBuffer = fs.readFileSync(fullPath)
const stat = fs.statSync(fullPath)
res.setHeader('Content-Type', contentType)
res.setHeader('Content-Length', fileBuffer.length)
res.setHeader('Content-Length', stat.size)
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) {
this.sendError(res, 500, 'Failed to read media file')
}

View File

@@ -12,6 +12,7 @@ export class MessageCacheService {
private readonly cacheFilePath: string
private cache: Record<string, SessionMessageCacheEntry> = {}
private readonly sessionLimit = 150
private readonly maxSessionEntries = 48
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
@@ -36,6 +37,7 @@ export class MessageCacheService {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
this.cache = parsed
this.pruneSessionEntries()
}
} catch (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 {
return this.cache[sessionId]
}
@@ -56,6 +71,7 @@ export class MessageCacheService {
updatedAt: Date.now(),
messages: trimmed
}
this.pruneSessionEntries()
this.persist()
}

View File

@@ -324,6 +324,9 @@ class SnsService {
private configService: ConfigService
private contactCache: ContactCacheService
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 userPostCountsCache: { counts: Record<string, number>; updatedAt: number } | null = null
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
@@ -336,6 +339,38 @@ class SnsService {
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 {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
@@ -1239,20 +1274,27 @@ class SnsService {
if (!url) return { success: false, error: 'url 不能为空' }
const cacheKey = `${url}|${key ?? ''}`
if (this.imageCache.has(cacheKey)) {
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
const base64Part = cachedDataUrl.split(',')[1] || ''
if (base64Part) {
try {
const cachedBuf = Buffer.from(base64Part, 'base64')
if (detectImageMime(cachedBuf, '').startsWith('image/')) {
return { success: true, dataUrl: cachedDataUrl }
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
if (cachedDataUrl) {
const cachedAt = this.imageCacheMeta.get(cacheKey) || 0
if (cachedAt > 0 && Date.now() - cachedAt <= this.imageCacheTtlMs) {
const base64Part = cachedDataUrl.split(',')[1] || ''
if (base64Part) {
try {
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.imageCacheMeta.delete(cacheKey)
}
const result = await this.fetchAndDecryptImage(url, key)
@@ -1269,7 +1311,7 @@ class SnsService {
return { success: false, error: '无效图片数据(可能密钥不匹配或缓存损坏)' }
}
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
this.imageCache.set(cacheKey, dataUrl)
this.rememberImageCache(cacheKey, dataUrl)
return { success: true, dataUrl }
}
}