mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-24 07:26:48 +00:00
fix(perf): prevent memory growth in chat and export flows
This commit is contained in:
@@ -347,6 +347,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'
|
||||
@@ -671,6 +672,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()) {
|
||||
@@ -1956,6 +1978,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. 没有游标状态
|
||||
@@ -1974,7 +2001,7 @@ class ChatService {
|
||||
// 关闭旧游标
|
||||
if (state) {
|
||||
try {
|
||||
await wcdbService.closeMessageCursor(state.cursor)
|
||||
await this.closeMessageCursorBySession(sessionId)
|
||||
} catch (e) {
|
||||
console.warn('[ChatService] 关闭旧游标失败:', e)
|
||||
}
|
||||
@@ -1992,6 +2019,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 时重建游标最安全;
|
||||
@@ -2062,6 +2090,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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user