import { parallelLimit } from '../utils/parallelLimit'; import { ExportOptions, ExportProgress, ExportStatsResult, ExportStatsCacheEntry, ExportStatsSessionSnapshot, ExportAggregatedSessionMetric } from '../types'; import * as fs from 'fs' import * as path from 'path' import * as http from 'http' import * as https from 'https' import crypto from 'crypto' import { fileURLToPath } from 'url' import ExcelJS from 'exceljs' import { getEmojiPath } from 'wechat-emojis' import { ConfigService } from '../../config' import { wcdbService } from '../../wcdbService' import { imageDecryptService } from '../../imageDecryptService' import { chatService } from '../../chatService' import { videoService } from '../../videoService' import { voiceTranscribeService } from '../../voiceTranscribeService' import { exportRecordService } from '../../exportRecordService' import { EXPORT_HTML_STYLES } from '../../exportHtmlStyles' import { LRUCache } from '../../../utils/LRUCache.js' import { normalizeTimestampSeconds, formatTimestamp, formatIsoTimestamp, parseCompactDateTimeDigitsToSeconds, parseDateTimeTextToSeconds, normalizeExportDateRange, normalizeRowTimestampSeconds, getTimestampSecondsFromRow } from '../../export/utils/timestamp'; import { escapeHtml, escapeAttribute, renderMultilineText, decodeHtmlEntities } from '../../export/utils/htmlEscape'; import { sanitizeExportFileNamePart, resolveFileAttachmentExtensionDir, normalizeFileNamingMode, formatDateTokenBySeconds, buildDateRangeFileNamePart, buildSessionExportBaseName, reserveUniqueOutputPath } from '../../export/utils/fileNaming'; import { extractXmlValue, extractXmlAttribute, extractAppMessageType, normalizeAppMessageContent } from '../../export/parsers/xmlExtractor'; import { decodeMessageContent, decodeMaybeCompressed, decodeBinaryContent, looksLikeHex, looksLikeBase64 } from '../../export/parsers/contentDecoder'; import { parseVoipMessage } from '../../export/parsers/voipParser'; import { resolveTransferDesc, getTransferPrefix, isTransferExportContent, appendTransferDesc, extractAmountFromText, isSameWxid } from '../../export/parsers/transferParser'; import { looksLikeWxid, sanitizeQuotedContent, parseQuoteMessage } from '../../export/parsers/quoteParser'; import { parseChatHistory, formatForwardChatRecordContent } from '../../export/parsers/forwardRecordParser'; import { formatEmojiSemanticText, extractLooseHexMd5, normalizeEmojiCaption } from '../../export/parsers/fileAppParser'; import { stripSenderPrefix, cleanSystemMessage, extractReadableSystemMessageText, parseDurationSeconds } from '../../export/parsers/messageParser'; import { getPreferredDisplayName, resolveExportDisplayProfile } from '../../export/contacts/contactResolver'; import { resolveGroupNicknameByCandidates, buildGroupNicknameIdCandidates, normalizeGroupNicknameIdentity, normalizeGroupNickname } from '../../export/contacts/groupNickname'; import { getAvatarFallback } from '../../export/contacts/avatarHelper'; import { pathExists, ensureExportDir, copyFileOptimized, hardlinkOrCopyFile } from '../../export/media/fileCopy'; import { getMediaFileStat } from '../../export/media/attachmentResolver'; import { ExportContext } from "../core/ExportContext"; export class ExportStatsService { constructor(public context: ExportContext) { } /** * 获取导出前的预估统计信息 */ async getExportStats(sessionIds: string[], options: ExportOptions): Promise { const conn = await this.context.ensureConnected(); if (!conn.success || !conn.cleanedWxid) { return { totalMessages: 0, voiceMessages: 0, cachedVoiceCount: 0, needTranscribeCount: 0, mediaMessages: 0, estimatedSeconds: 0, sessions: [] } } const normalizedSessionIds = this.context.normalizeSessionIds(sessionIds); if (normalizedSessionIds.length === 0) { return { totalMessages: 0, voiceMessages: 0, cachedVoiceCount: 0, needTranscribeCount: 0, mediaMessages: 0, estimatedSeconds: 0, sessions: [] } } const cacheKey = this.buildExportStatsCacheKey(normalizedSessionIds, options, conn.cleanedWxid); const cachedStats = this.getExportStatsCacheEntry(cacheKey); if (cachedStats) { const cachedResult = this.cloneExportStatsResult(cachedStats.result) const orderedSessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> = [] const sessionMap = new Map(cachedResult.sessions.map((item) => [item.sessionId, item] as const)) for (const sessionId of normalizedSessionIds) { const cachedSession = sessionMap.get(sessionId) if (cachedSession) orderedSessions.push(cachedSession) } if (orderedSessions.length === cachedResult.sessions.length) { cachedResult.sessions = orderedSessions } return cachedResult } const cleanedMyWxid = conn.cleanedWxid; const sessionsStats: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> = []; const sessionSnapshotMap: Record = {}; let totalMessages = 0; let voiceMessages = 0; let cachedVoiceCount = 0; let mediaMessages = 0; const hasSenderFilter = Boolean(String(options.senderUsername || '').trim()); const canUseAggregatedStats = this.context.isUnboundedDateRange(options.dateRange) && !hasSenderFilter; if (canUseAggregatedStats) { try { let aggregatedData = this.getAggregatedSessionStatsCache(cacheKey) if (!aggregatedData) { const statsResult = await chatService.getExportSessionStats(normalizedSessionIds, { includeRelations: false, allowStaleCache: true }) if (statsResult.success && statsResult.data) { aggregatedData = statsResult.data as Record this.setAggregatedSessionStatsCache(cacheKey, aggregatedData) } } if (aggregatedData) { const cachedVoiceCountMap = chatService.getCachedVoiceTranscriptCountMap(normalizedSessionIds) const fastRows = await parallelLimit( normalizedSessionIds, 8, async (sessionId): Promise<{ sessionId: string displayName: string totalCount: number voiceCount: number cachedVoiceCount: number mediaCount: number }> => { let displayName = sessionId try { const sessionInfo = await this.context.getContactInfo(sessionId) displayName = sessionInfo.displayName || sessionId } catch { // 预估阶段显示名获取失败不阻塞统计 } const metric = aggregatedData?.[sessionId] const totalCount = Number.isFinite(metric?.totalMessages) ? Math.max(0, Math.floor(metric?.totalMessages ?? 0)) : 0 const voiceCount = Number.isFinite(metric?.voiceMessages) ? Math.max(0, Math.floor(metric?.voiceMessages ?? 0)) : 0 const imageCount = Number.isFinite(metric?.imageMessages) ? Math.max(0, Math.floor(metric?.imageMessages ?? 0)) : 0 const videoCount = Number.isFinite(metric?.videoMessages) ? Math.max(0, Math.floor(metric?.videoMessages ?? 0)) : 0 const emojiCount = Number.isFinite(metric?.emojiMessages) ? Math.max(0, Math.floor(metric?.emojiMessages ?? 0)) : 0 const lastTimestamp = Number.isFinite(metric?.lastTimestamp) ? Math.max(0, Math.floor(metric?.lastTimestamp ?? 0)) : undefined const cachedCountRaw = Number(cachedVoiceCountMap[sessionId] || 0) const sessionCachedVoiceCount = Math.min( voiceCount, Number.isFinite(cachedCountRaw) ? Math.max(0, Math.floor(cachedCountRaw)) : 0 ) sessionSnapshotMap[sessionId] = { totalCount, voiceCount, imageCount, videoCount, emojiCount, cachedVoiceCount: sessionCachedVoiceCount, lastTimestamp } return { sessionId, displayName, totalCount, voiceCount, cachedVoiceCount: sessionCachedVoiceCount, mediaCount: voiceCount + imageCount + videoCount + emojiCount } } ) for (const row of fastRows) { totalMessages += row.totalCount voiceMessages += row.voiceCount cachedVoiceCount += row.cachedVoiceCount mediaMessages += row.mediaCount sessionsStats.push({ sessionId: row.sessionId, displayName: row.displayName, totalCount: row.totalCount, voiceCount: row.voiceCount }) } const needTranscribeCount = Math.max(0, voiceMessages - cachedVoiceCount) const estimatedSeconds = needTranscribeCount * 2 const result: ExportStatsResult = { totalMessages, voiceMessages, cachedVoiceCount, needTranscribeCount, mediaMessages, estimatedSeconds, sessions: sessionsStats } this.setExportStatsCacheEntry(cacheKey, { createdAt: Date.now(), result: this.cloneExportStatsResult(result), sessions: { ...sessionSnapshotMap } }) return result } } catch (error) { // 聚合统计失败时自动回退到慢路径,保证功能正确。 } } for (const sessionId of normalizedSessionIds) { const sessionInfo = await this.context.getContactInfo(sessionId) const collected = await this.context.collectMessages( sessionId, cleanedMyWxid, options.dateRange, options.senderUsername, 'text-fast' ) const msgs = collected.rows let voiceCount = 0 let imageCount = 0 let videoCount = 0 let emojiCount = 0 let latestTimestamp = 0 let cached = 0 for (const msg of msgs) { if (msg.createTime > latestTimestamp) { latestTimestamp = msg.createTime } const localType = msg.localType if (localType === 34) { voiceCount++ if (chatService.hasTranscriptCache(sessionId, String(msg.localId), msg.createTime)) { cached++ } continue } if (localType === 3) imageCount++ if (localType === 43) videoCount++ if (localType === 47) emojiCount++ } const mediaCount = voiceCount + imageCount + videoCount + emojiCount totalMessages += msgs.length voiceMessages += voiceCount cachedVoiceCount += cached mediaMessages += mediaCount sessionSnapshotMap[sessionId] = { totalCount: msgs.length, voiceCount, imageCount, videoCount, emojiCount, cachedVoiceCount: cached, lastTimestamp: latestTimestamp > 0 ? latestTimestamp : undefined } sessionsStats.push({ sessionId, displayName: sessionInfo.displayName, totalCount: msgs.length, voiceCount }) } const needTranscribeCount = Math.max(0, voiceMessages - cachedVoiceCount); const estimatedSeconds = needTranscribeCount * 2; const result: ExportStatsResult = { totalMessages, voiceMessages, cachedVoiceCount, needTranscribeCount, mediaMessages, estimatedSeconds, sessions: sessionsStats }; this.setExportStatsCacheEntry(cacheKey, { createdAt: Date.now(), result: this.cloneExportStatsResult(result), sessions: { ...sessionSnapshotMap } }) return result } }