From bb602af7506c2cd350e46b48d47c60617a42477a Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Wed, 4 Mar 2026 17:28:03 +0800 Subject: [PATCH] fix(stats): ensure accurate transfer red-packet and call counts in detail panels --- electron/main.ts | 1 + electron/preload.ts | 2 +- electron/services/chatService.ts | 116 +++++++++++++----- electron/services/sessionStatsCacheService.ts | 18 ++- src/pages/ChatPage.tsx | 6 +- src/pages/ExportPage.tsx | 8 +- src/types/electron.d.ts | 2 +- 7 files changed, 111 insertions(+), 42 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 872b0ff..bde6a50 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1159,6 +1159,7 @@ function registerIpcHandlers() { includeRelations?: boolean forceRefresh?: boolean allowStaleCache?: boolean + preferAccurateSpecialTypes?: boolean }) => { return chatService.getExportSessionStats(sessionIds, options) }) diff --git a/electron/preload.ts b/electron/preload.ts index 6857811..800e2e7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -170,7 +170,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId), getExportSessionStats: ( sessionIds: string[], - options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean } + options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean } ) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options), getGroupMyMessageCountHint: (chatroomId: string) => ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index bea63a0..6fb0e7f 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -162,6 +162,7 @@ interface ExportSessionStatsOptions { includeRelations?: boolean forceRefresh?: boolean allowStaleCache?: boolean + preferAccurateSpecialTypes?: boolean } interface ExportSessionStatsCacheMeta { @@ -2365,9 +2366,59 @@ class ChatService { return this.extractXmlValue(content, 'type') } - private buildXmlTypeLikeExpr(columnName: string, xmlType: '2000' | '2001'): string { - const colExpr = `LOWER(CAST(COALESCE(${this.quoteSqlIdentifier(columnName)}, '') AS TEXT))` - return `${colExpr} LIKE '%${xmlType}%'` + private async collectSpecialMessageCountsByCursorScan(sessionId: string): Promise<{ + transferMessages: number + redPacketMessages: number + callMessages: number + }> { + const counters = { + transferMessages: 0, + redPacketMessages: 0, + callMessages: 0 + } + + const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 500, false, 0, 0) + if (!cursorResult.success || !cursorResult.cursor) { + return counters + } + + const cursor = cursorResult.cursor + try { + while (true) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success) break + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + for (const row of rows) { + const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) + if (localType === 50) { + counters.callMessages += 1 + continue + } + if (localType === 8589934592049) { + counters.transferMessages += 1 + continue + } + if (localType === 8594229559345) { + counters.redPacketMessages += 1 + continue + } + if (localType !== 49) continue + + const rawMessageContent = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content']) + const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content']) + const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) + const xmlType = this.extractType49XmlTypeForStats(content) + if (xmlType === '2000') counters.transferMessages += 1 + if (xmlType === '2001') counters.redPacketMessages += 1 + } + + if (!batch.hasMore || rows.length === 0) break + } + } finally { + await wcdbService.closeMessageCursor(cursor) + } + + return counters } private async collectSessionExportStatsByCursorScan( @@ -2474,7 +2525,8 @@ class ChatService { private async collectSessionExportStats( sessionId: string, - selfIdentitySet: Set + selfIdentitySet: Set, + preferAccurateSpecialTypes: boolean = false ): Promise { const stats: ExportSessionStats = { totalMessages: 0, @@ -2511,18 +2563,6 @@ class ChatService { const timeCol = this.pickFirstColumn(columnSet, ['create_time', 'createtime', 'msg_create_time', 'time']) const senderCol = this.pickFirstColumn(columnSet, ['sender_username', 'senderusername', 'sender']) const isSendCol = this.pickFirstColumn(columnSet, ['computed_is_send', 'computedissend', 'is_send', 'issend']) - const messageContentCol = this.pickFirstColumn(columnSet, ['message_content', 'messagecontent', 'msg_content', 'msgcontent', 'content']) - const compressContentCol = this.pickFirstColumn(columnSet, ['compress_content', 'compresscontent', 'compressed_content', 'compressedcontent']) - - const transferXmlConditions: string[] = [] - if (messageContentCol) transferXmlConditions.push(this.buildXmlTypeLikeExpr(messageContentCol, '2000')) - if (compressContentCol) transferXmlConditions.push(this.buildXmlTypeLikeExpr(compressContentCol, '2000')) - const transferXmlCond = transferXmlConditions.length > 0 ? `(${transferXmlConditions.join(' OR ')})` : '0' - - const redPacketXmlConditions: string[] = [] - if (messageContentCol) redPacketXmlConditions.push(this.buildXmlTypeLikeExpr(messageContentCol, '2001')) - if (compressContentCol) redPacketXmlConditions.push(this.buildXmlTypeLikeExpr(compressContentCol, '2001')) - const redPacketXmlCond = redPacketXmlConditions.length > 0 ? `(${redPacketXmlConditions.join(' OR ')})` : '0' const selectParts: string[] = [ 'COUNT(*) AS total_messages', @@ -2531,8 +2571,8 @@ class ChatService { typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 43 THEN 1 ELSE 0 END) AS video_messages` : '0 AS video_messages', typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 47 THEN 1 ELSE 0 END) AS emoji_messages` : '0 AS emoji_messages', typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 50 THEN 1 ELSE 0 END) AS call_messages` : '0 AS call_messages', - typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 8589934592049 THEN 1 WHEN ${this.quoteSqlIdentifier(typeCol)} = 49 AND ${transferXmlCond} THEN 1 ELSE 0 END) AS transfer_messages` : '0 AS transfer_messages', - typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 8594229559345 THEN 1 WHEN ${this.quoteSqlIdentifier(typeCol)} = 49 AND ${redPacketXmlCond} THEN 1 ELSE 0 END) AS red_packet_messages` : '0 AS red_packet_messages', + typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 8589934592049 THEN 1 ELSE 0 END) AS transfer_messages` : '0 AS transfer_messages', + typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 8594229559345 THEN 1 ELSE 0 END) AS red_packet_messages` : '0 AS red_packet_messages', timeCol ? `MIN(${this.quoteSqlIdentifier(timeCol)}) AS first_timestamp` : 'NULL AS first_timestamp', timeCol ? `MAX(${this.quoteSqlIdentifier(timeCol)}) AS last_timestamp` : 'NULL AS last_timestamp' ] @@ -2628,6 +2668,17 @@ class ChatService { return this.collectSessionExportStatsByCursorScan(sessionId, selfIdentitySet) } + if (preferAccurateSpecialTypes) { + try { + const preciseCounters = await this.collectSpecialMessageCountsByCursorScan(sessionId) + stats.transferMessages = preciseCounters.transferMessages + stats.redPacketMessages = preciseCounters.redPacketMessages + stats.callMessages = preciseCounters.callMessages + } catch { + // 保留聚合统计结果作为兜底 + } + } + if (isGroup) { stats.groupActiveSpeakers = senderIdentities.size if (Number.isFinite(stats.groupMyMessages)) { @@ -2728,9 +2779,10 @@ class ChatService { private async computeSessionExportStats( sessionId: string, selfIdentitySet: Set, - includeRelations: boolean + includeRelations: boolean, + preferAccurateSpecialTypes: boolean = false ): Promise { - const stats = await this.collectSessionExportStats(sessionId, selfIdentitySet) + const stats = await this.collectSessionExportStats(sessionId, selfIdentitySet, preferAccurateSpecialTypes) const isGroup = sessionId.endsWith('@chatroom') if (isGroup) { @@ -2768,7 +2820,8 @@ class ChatService { private async computeSessionExportStatsBatch( sessionIds: string[], includeRelations: boolean, - selfIdentitySet: Set + selfIdentitySet: Set, + preferAccurateSpecialTypes: boolean = false ): Promise> { const normalizedSessionIds = Array.from( new Set( @@ -2824,7 +2877,7 @@ class ChatService { await this.forEachWithConcurrency(normalizedSessionIds, 3, async (sessionId) => { try { - const stats = await this.collectSessionExportStats(sessionId, selfIdentitySet) + const stats = await this.collectSessionExportStats(sessionId, selfIdentitySet, preferAccurateSpecialTypes) if (sessionId.endsWith('@chatroom')) { stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number' ? Math.max(0, Math.floor(memberCountMap[sessionId])) @@ -2851,8 +2904,13 @@ class ChatService { private async getOrComputeSessionExportStats( sessionId: string, includeRelations: boolean, - selfIdentitySet: Set + selfIdentitySet: Set, + preferAccurateSpecialTypes: boolean = false ): Promise { + if (preferAccurateSpecialTypes) { + return this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations, true) + } + const scopedKey = this.buildScopedSessionStatsKey(sessionId) if (!includeRelations) { @@ -2866,7 +2924,7 @@ class ChatService { } const targetMap = includeRelations ? this.sessionStatsPendingFull : this.sessionStatsPendingBasic - const pending = this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations) + const pending = this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations, false) targetMap.set(scopedKey, pending) try { return await pending @@ -5275,6 +5333,7 @@ class ChatService { const includeRelations = options.includeRelations ?? true const forceRefresh = options.forceRefresh === true const allowStaleCache = options.allowStaleCache === true + const preferAccurateSpecialTypes = options.preferAccurateSpecialTypes === true const normalizedSessionIds = Array.from( new Set( @@ -5298,7 +5357,7 @@ class ChatService { ? this.getGroupMyMessageCountHintEntry(sessionId) : null const cachedResult = this.getSessionStatsCacheEntry(sessionId) - if (!forceRefresh) { + if (!forceRefresh && !preferAccurateSpecialTypes) { if (cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) { const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs if (!stale || allowStaleCache) { @@ -5334,7 +5393,7 @@ class ChatService { if (pendingSessionIds.length === 1) { const sessionId = pendingSessionIds[0] try { - const stats = await this.getOrComputeSessionExportStats(sessionId, includeRelations, selfIdentitySet) + const stats = await this.getOrComputeSessionExportStats(sessionId, includeRelations, selfIdentitySet, preferAccurateSpecialTypes) resultMap[sessionId] = stats const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) cacheMeta[sessionId] = { @@ -5352,7 +5411,8 @@ class ChatService { const batchedStatsMap = await this.computeSessionExportStatsBatch( pendingSessionIds, includeRelations, - selfIdentitySet + selfIdentitySet, + preferAccurateSpecialTypes ) for (const sessionId of pendingSessionIds) { const stats = batchedStatsMap[sessionId] @@ -5375,7 +5435,7 @@ class ChatService { if (!usedBatchedCompute) { await this.forEachWithConcurrency(pendingSessionIds, 3, async (sessionId) => { try { - const stats = await this.getOrComputeSessionExportStats(sessionId, includeRelations, selfIdentitySet) + const stats = await this.getOrComputeSessionExportStats(sessionId, includeRelations, selfIdentitySet, preferAccurateSpecialTypes) resultMap[sessionId] = stats const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) cacheMeta[sessionId] = { diff --git a/electron/services/sessionStatsCacheService.ts b/electron/services/sessionStatsCacheService.ts index 7115fd5..147930d 100644 --- a/electron/services/sessionStatsCacheService.ts +++ b/electron/services/sessionStatsCacheService.ts @@ -2,7 +2,7 @@ import { join, dirname } from 'path' import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs' import { ConfigService } from './config' -const CACHE_VERSION = 1 +const CACHE_VERSION = 2 const MAX_SESSION_ENTRIES_PER_SCOPE = 2000 const MAX_SCOPE_ENTRIES = 12 @@ -53,16 +53,19 @@ function normalizeStats(raw: unknown): SessionStatsCacheStats | null { const imageMessages = toNonNegativeInt(source.imageMessages) const videoMessages = toNonNegativeInt(source.videoMessages) const emojiMessages = toNonNegativeInt(source.emojiMessages) - const transferMessages = toNonNegativeInt(source.transferMessages) ?? 0 - const redPacketMessages = toNonNegativeInt(source.redPacketMessages) ?? 0 - const callMessages = toNonNegativeInt(source.callMessages) ?? 0 + const transferMessages = toNonNegativeInt(source.transferMessages) + const redPacketMessages = toNonNegativeInt(source.redPacketMessages) + const callMessages = toNonNegativeInt(source.callMessages) if ( totalMessages === undefined || voiceMessages === undefined || imageMessages === undefined || videoMessages === undefined || - emojiMessages === undefined + emojiMessages === undefined || + transferMessages === undefined || + redPacketMessages === undefined || + callMessages === undefined ) { return null } @@ -154,6 +157,11 @@ export class SessionStatsCacheService { } const payload = parsed as Record + const version = Number(payload.version) + if (!Number.isFinite(version) || version !== CACHE_VERSION) { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } const scopesRaw = payload.scopes if (!scopesRaw || typeof scopesRaw !== 'object') { this.store = { version: CACHE_VERSION, scopes: {} } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index b8b6def..b0dd9ca 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -925,7 +925,7 @@ function ChatPage(_props: ChatPageProps) { window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - { includeRelations: false, allowStaleCache: true } + { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } ) ]) @@ -983,7 +983,7 @@ function ChatPage(_props: ChatPageProps) { try { const relationResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - { includeRelations: true, allowStaleCache: true } + { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } ) if (requestSeq !== detailRequestSeqRef.current) return @@ -1007,7 +1007,7 @@ function ChatPage(_props: ChatPageProps) { try { const freshResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - { includeRelations: true, forceRefresh: true } + { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } ) if (requestSeq !== detailRequestSeqRef.current) return if (freshResult.success && freshResult.data) { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 47dda93..6dfea86 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -3499,7 +3499,7 @@ function ExportPage() { window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - { includeRelations: false, allowStaleCache: true } + { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } ) ]) @@ -3549,7 +3549,7 @@ function ExportPage() { try { const freshResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - { includeRelations: refreshIncludeRelations, forceRefresh: true } + { includeRelations: refreshIncludeRelations, forceRefresh: true, preferAccurateSpecialTypes: true } ) if (requestSeq !== detailRequestSeqRef.current) return if (freshResult.success && freshResult.data) { @@ -3591,7 +3591,7 @@ function ExportPage() { try { const relationResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - { includeRelations: true, allowStaleCache: true } + { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } ) if (requestSeq !== detailRequestSeqRef.current) return @@ -3615,7 +3615,7 @@ function ExportPage() { try { const freshResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - { includeRelations: true, forceRefresh: true } + { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } ) if (requestSeq !== detailRequestSeqRef.current) return if (freshResult.success && freshResult.data) { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 46d7c8a..ed4fc9b 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -242,7 +242,7 @@ export interface ElectronAPI { }> getExportSessionStats: ( sessionIds: string[], - options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean } + options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean } ) => Promise<{ success: boolean data?: Record