From ec10f475675b49f47e62e762cf2349b3ce6449e0 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 30 May 2026 16:58:08 +0800 Subject: [PATCH] fix(export): stats would randomly reset to zero --- electron/services/chatService.ts | 47 ++++++++++- src/pages/ExportPage.tsx | 136 +++++++++++++++++++------------ src/types/electron.d.ts | 1 + 3 files changed, 129 insertions(+), 55 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 6c393a0..c928d19 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -208,6 +208,7 @@ interface ExportSessionStatsCacheMeta { stale: boolean includeRelations: boolean source: 'memory' | 'disk' | 'fresh' + rangeFiltered?: boolean } interface ExportTabCounts { @@ -7761,7 +7762,12 @@ class ChatService { success: true, count: Math.max(0, Math.floor(messageCount as number)) }) - : wcdbService.getMessageCount(normalizedSessionId) + : this.getSessionMessageCounts([normalizedSessionId], { preferHintCache: true }) + .then((result) => ({ + success: result.success, + count: result.counts?.[normalizedSessionId], + error: result.error + })) const [contactResult, avatarResult, messageCountResult] = await Promise.allSettled([ contactPromise, @@ -8066,7 +8072,15 @@ class ChatService { endTimestamp ) resultMap[sessionId] = stats - if (!useRangeFilter) { + if (useRangeFilter) { + cacheMeta[sessionId] = { + updatedAt: Date.now(), + stale: false, + includeRelations, + source: 'fresh', + rangeFiltered: true + } + } else { const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) cacheMeta[sessionId] = { updatedAt, @@ -8093,7 +8107,15 @@ class ChatService { const stats = batchedStatsMap[sessionId] if (!stats) continue resultMap[sessionId] = stats - if (!useRangeFilter) { + if (useRangeFilter) { + cacheMeta[sessionId] = { + updatedAt: Date.now(), + stale: false, + includeRelations, + source: 'fresh', + rangeFiltered: true + } + } else { const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) cacheMeta[sessionId] = { updatedAt, @@ -8121,7 +8143,15 @@ class ChatService { endTimestamp ) resultMap[sessionId] = stats - if (!useRangeFilter) { + if (useRangeFilter) { + cacheMeta[sessionId] = { + updatedAt: Date.now(), + stale: false, + includeRelations, + source: 'fresh', + rangeFiltered: true + } + } else { const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) cacheMeta[sessionId] = { updatedAt, @@ -8132,6 +8162,15 @@ class ChatService { } } catch { resultMap[sessionId] = this.buildEmptyExportSessionStats(sessionId, includeRelations) + if (useRangeFilter) { + cacheMeta[sessionId] = { + updatedAt: Date.now(), + stale: true, + includeRelations, + source: 'fresh', + rangeFiltered: true + } + } } }) } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 42730bc..d486530 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1384,6 +1384,7 @@ interface SessionExportCacheMeta { stale: boolean includeRelations: boolean source: 'memory' | 'disk' | 'fresh' + rangeFiltered?: boolean } type SessionLoadStageStatus = 'pending' | 'loading' | 'done' | 'failed' @@ -1602,6 +1603,12 @@ const normalizeTimestampSeconds = (value: unknown): number | undefined => { return Math.floor(parsed) } +const mergeStableCount = (incoming: number | undefined, previous: number | undefined): number | undefined => { + if (typeof incoming !== 'number') return previous + if (incoming === 0 && typeof previous === 'number' && previous > 0) return previous + return incoming +} + const clampExportSelectionToBounds = ( selection: ExportDateRangeSelection, bounds: TimeRangeBounds | null @@ -2369,27 +2376,6 @@ function ExportPage() { exportConcurrency: 2 }) - const exportStatsRangeOptions = useMemo(() => { - if (options.useAllTime || !options.dateRange) return null - const beginTimestamp = Math.floor(options.dateRange.start.getTime() / 1000) - const endTimestamp = Math.floor(options.dateRange.end.getTime() / 1000) - if (!Number.isFinite(beginTimestamp) || !Number.isFinite(endTimestamp)) return null - if (beginTimestamp <= 0 && endTimestamp <= 0) return null - return { - beginTimestamp: Math.max(0, beginTimestamp), - endTimestamp: Math.max(0, endTimestamp) - } - }, [options.useAllTime, options.dateRange]) - - const withExportStatsRange = useCallback((statsOptions: Record): Record => { - if (!exportStatsRangeOptions) return statsOptions - return { - ...statsOptions, - beginTimestamp: exportStatsRangeOptions.beginTimestamp, - endTimestamp: exportStatsRangeOptions.endTimestamp - } - }, [exportStatsRangeOptions]) - const [exportDialog, setExportDialog] = useState({ open: false, intent: 'manual', @@ -3673,9 +3659,16 @@ function ExportPage() { return [] }, [sessionSnsCommentRankings, sessionSnsLikeRankings, sessionSnsRankMode]) - const mergeSessionContentMetrics = useCallback((input: Record) => { + const mergeSessionContentMetrics = useCallback(( + input: Record, + options?: { + mergeTotalMessages?: boolean + preserveExistingTotalOnZero?: boolean + } + ) => { const entries = Object.entries(input) if (entries.length === 0) return + const mergeTotalMessages = options?.mergeTotalMessages !== false const nextMessageCounts: Record = {} const nextMetrics: Record = {} @@ -3683,7 +3676,13 @@ function ExportPage() { for (const [sessionIdRaw, metricRaw] of entries) { const sessionId = String(sessionIdRaw || '').trim() if (!sessionId || !metricRaw) continue - const totalMessages = normalizeMessageCount(metricRaw.totalMessages) + const previous = sessionContentMetricsRef.current[sessionId] || {} + const incomingTotalMessages = normalizeMessageCount(metricRaw.totalMessages) + const totalMessages = mergeTotalMessages + ? (options?.preserveExistingTotalOnZero + ? mergeStableCount(incomingTotalMessages, previous.totalMessages) + : incomingTotalMessages) + : undefined const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages) const imageMessages = normalizeMessageCount(metricRaw.imageMessages) const videoMessages = normalizeMessageCount(metricRaw.videoMessages) @@ -3729,8 +3728,12 @@ function ExportPage() { let changed = false const merged = { ...prev } for (const [sessionId, count] of Object.entries(nextMessageCounts)) { - if (merged[sessionId] === count) continue - merged[sessionId] = count + const previousCount = normalizeMessageCount(merged[sessionId]) + const nextCount = options?.preserveExistingTotalOnZero + ? mergeStableCount(count, previousCount) + : count + if (typeof nextCount !== 'number' || previousCount === nextCount) continue + merged[sessionId] = nextCount changed = true } return changed ? merged : prev @@ -3744,7 +3747,11 @@ function ExportPage() { for (const [sessionId, metric] of Object.entries(nextMetrics)) { const previous = merged[sessionId] || {} const nextMetric: SessionContentMetric = { - totalMessages: typeof metric.totalMessages === 'number' ? metric.totalMessages : previous.totalMessages, + totalMessages: mergeTotalMessages + ? (options?.preserveExistingTotalOnZero + ? mergeStableCount(metric.totalMessages, previous.totalMessages) + : (typeof metric.totalMessages === 'number' ? metric.totalMessages : previous.totalMessages)) + : previous.totalMessages, voiceMessages: typeof metric.voiceMessages === 'number' ? metric.voiceMessages : previous.voiceMessages, imageMessages: typeof metric.imageMessages === 'number' ? metric.imageMessages : previous.imageMessages, videoMessages: typeof metric.videoMessages === 'number' ? metric.videoMessages : previous.videoMessages, @@ -4084,7 +4091,10 @@ function ExportPage() { } }, [isSessionMediaMetricReady, patchSessionLoadTraceStage]) - const applySessionMediaMetricsFromStats = useCallback((data?: Record) => { + const applySessionMediaMetricsFromStats = useCallback(( + data?: Record, + cache?: Record + ) => { if (!data) return const nextMetrics: Record = {} let hasPatch = false @@ -4093,19 +4103,25 @@ function ExportPage() { if (!sessionId) continue const metric = pickSessionMediaMetric(metricRaw) if (!metric) continue - nextMetrics[sessionId] = metric + const metricForMerge = cache?.[sessionId]?.rangeFiltered + ? (() => { + const { totalMessages: _totalMessages, ...rest } = metric + return rest + })() + : metric + nextMetrics[sessionId] = metricForMerge hasPatch = true sessionMediaMetricPendingPersistRef.current[sessionId] = { ...sessionMediaMetricPendingPersistRef.current[sessionId], - ...metric + ...metricForMerge } - if (hasCompleteSessionMediaMetric(metric)) { + if (hasCompleteSessionMediaMetric(metricForMerge)) { sessionMediaMetricReadySetRef.current.add(sessionId) } } if (hasPatch) { - mergeSessionContentMetrics(nextMetrics) + mergeSessionContentMetrics(nextMetrics, { preserveExistingTotalOnZero: true }) scheduleFlushSessionMediaMetricCache() } }, [mergeSessionContentMetrics, scheduleFlushSessionMediaMetricCache]) @@ -4155,14 +4171,17 @@ function ExportPage() { const cacheResult = await withTimeout( window.electronAPI.chat.getExportSessionStats( batchSessionIds, - withExportStatsRange({ includeRelations: false, allowStaleCache: true, cacheOnly: true }) + { includeRelations: false, allowStaleCache: true, cacheOnly: true } ), 12000, 'cacheOnly' ) if (runId !== sessionMediaMetricRunIdRef.current) return if (cacheResult.success && cacheResult.data) { - applySessionMediaMetricsFromStats(cacheResult.data as Record) + applySessionMediaMetricsFromStats( + cacheResult.data as Record, + cacheResult.cache as Record | undefined + ) } const missingSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId)) @@ -4170,14 +4189,17 @@ function ExportPage() { const freshResult = await withTimeout( window.electronAPI.chat.getExportSessionStats( missingSessionIds, - withExportStatsRange({ includeRelations: false, allowStaleCache: true }) + { includeRelations: false, allowStaleCache: true } ), 45000, 'fresh' ) if (runId !== sessionMediaMetricRunIdRef.current) return if (freshResult.success && freshResult.data) { - applySessionMediaMetricsFromStats(freshResult.data as Record) + applySessionMediaMetricsFromStats( + freshResult.data as Record, + freshResult.cache as Record | undefined + ) } } @@ -4214,7 +4236,7 @@ function ExportPage() { void runSessionMediaMetricWorker(runId) } } - }, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage, withExportStatsRange]) + }, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage]) const scheduleSessionMediaMetricWorker = useCallback(() => { if (activeTaskCountRef.current > 0) return @@ -5051,9 +5073,10 @@ function ExportPage() { const applyStatsResult = (result?: { success: boolean data?: Record + cache?: Record } | null) => { if (!result?.success || !result.data) return - applySessionMediaMetricsFromStats(result.data) + applySessionMediaMetricsFromStats(result.data, result.cache) for (const sessionId of normalizedSessionIds) { absorbMetric(sessionId, result.data[sessionId]) } @@ -7326,13 +7349,21 @@ function ExportPage() { cacheMeta?: SessionExportCacheMeta, relationLoadedOverride?: boolean ) => { - mergeSessionContentMetrics({ [sessionId]: metric }) + const isRangeFilteredMetric = cacheMeta?.rangeFiltered === true + mergeSessionContentMetrics({ [sessionId]: metric }, { + mergeTotalMessages: !isRangeFilteredMetric, + preserveExistingTotalOnZero: true + }) setSessionDetail((prev) => { if (!prev || prev.wxid !== sessionId) return prev const relationLoaded = relationLoadedOverride ?? Boolean(prev.relationStatsLoaded) + const messageCount = mergeStableCount( + !isRangeFilteredMetric && Number.isFinite(metric.totalMessages) ? metric.totalMessages : undefined, + prev.messageCount + ) return { ...prev, - messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : prev.messageCount, + messageCount: Number.isFinite(messageCount) ? messageCount as number : prev.messageCount, voiceMessages: Number.isFinite(metric.voiceMessages) ? metric.voiceMessages : prev.voiceMessages, imageMessages: Number.isFinite(metric.imageMessages) ? metric.imageMessages : prev.imageMessages, videoMessages: Number.isFinite(metric.videoMessages) ? metric.videoMessages : prev.videoMessages, @@ -7364,8 +7395,6 @@ function ExportPage() { const preciseCacheKey = `${exportCacheScopeRef.current}::${normalizedSessionId}` detailStatsPriorityRef.current = true - sessionCountRequestIdRef.current += 1 - setIsLoadingSessionCounts(false) const requestSeq = ++detailRequestSeqRef.current const mappedSession = sessionRowByUsername.get(normalizedSessionId) @@ -7428,16 +7457,19 @@ function ExportPage() { const fastMessageCount = normalizeMessageCount(result.detail.messageCount) if (typeof fastMessageCount === 'number') { setSessionMessageCounts((prev) => { - if (prev[normalizedSessionId] === fastMessageCount) return prev + const nextCount = mergeStableCount(fastMessageCount, normalizeMessageCount(prev[normalizedSessionId])) + if (typeof nextCount !== 'number' || prev[normalizedSessionId] === nextCount) return prev return { ...prev, - [normalizedSessionId]: fastMessageCount + [normalizedSessionId]: nextCount } }) mergeSessionContentMetrics({ [normalizedSessionId]: { totalMessages: fastMessageCount } + }, { + preserveExistingTotalOnZero: true }) } setSessionDetail((prev) => ({ @@ -7447,7 +7479,9 @@ function ExportPage() { nickName: result.detail!.nickName ?? prev?.nickName, alias: result.detail!.alias ?? prev?.alias, avatarUrl: result.detail!.avatarUrl || prev?.avatarUrl, - messageCount: Number.isFinite(result.detail!.messageCount) ? result.detail!.messageCount : prev?.messageCount ?? Number.NaN, + messageCount: Number.isFinite(result.detail!.messageCount) + ? mergeStableCount(result.detail!.messageCount, prev?.messageCount) ?? Number.NaN + : prev?.messageCount ?? Number.NaN, voiceMessages: prev?.voiceMessages, imageMessages: prev?.imageMessages, videoMessages: prev?.videoMessages, @@ -7507,7 +7541,7 @@ function ExportPage() { try { const quickStatsResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - withExportStatsRange({ includeRelations: false, allowStaleCache: true, cacheOnly: true }) + { includeRelations: false, allowStaleCache: true, cacheOnly: true } ) if (requestSeq !== detailRequestSeqRef.current) return if (quickStatsResult.success) { @@ -7534,7 +7568,7 @@ function ExportPage() { try { const relationCacheResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - withExportStatsRange({ includeRelations: true, allowStaleCache: true, cacheOnly: true }) + { includeRelations: true, allowStaleCache: true, cacheOnly: true } ) if (requestSeq !== detailRequestSeqRef.current) return if (relationCacheResult.success && relationCacheResult.data) { @@ -7559,7 +7593,7 @@ function ExportPage() { // 后台补齐非关系统计,不走精确特型扫描,避免阻塞列表统计队列。 const freshResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - withExportStatsRange({ includeRelations: false, forceRefresh: true }) + { includeRelations: false, forceRefresh: true } ) if (requestSeq !== detailRequestSeqRef.current) return if (freshResult.success && freshResult.data) { @@ -7594,7 +7628,7 @@ function ExportPage() { setIsLoadingSessionDetailExtra(false) } } - }, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername, withExportStatsRange]) + }, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername]) const loadSessionRelationStats = useCallback(async (options?: { forceRefresh?: boolean }) => { const normalizedSessionId = String(sessionDetail?.wxid || '').trim() @@ -7607,7 +7641,7 @@ function ExportPage() { if (!forceRefresh) { const relationCacheResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - withExportStatsRange({ includeRelations: true, allowStaleCache: true, cacheOnly: true }) + { includeRelations: true, allowStaleCache: true, cacheOnly: true } ) if (requestSeq !== detailRequestSeqRef.current) return @@ -7625,7 +7659,7 @@ function ExportPage() { const relationResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - withExportStatsRange({ includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true }) + { includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true } ) if (requestSeq !== detailRequestSeqRef.current) return @@ -7645,7 +7679,7 @@ function ExportPage() { setIsLoadingSessionRelationStats(false) } } - }, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid, withExportStatsRange]) + }, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid]) const handleRefreshTableData = useCallback(async () => { const scopeKey = await ensureExportCacheScope() diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index b927e4f..b29454e 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -654,6 +654,7 @@ export interface ElectronAPI { stale: boolean includeRelations: boolean source: 'memory' | 'disk' | 'fresh' + rangeFiltered?: boolean }> needsRefresh?: string[] error?: string