import { parentPort } from 'worker_threads' import { wcdbService } from './wcdbService' export interface TopContact { username: string displayName: string avatarUrl?: string messageCount: number sentCount: number receivedCount: number } export interface MonthlyTopFriend { month: number displayName: string avatarUrl?: string messageCount: number } export interface ChatPeakDay { date: string messageCount: number topFriend?: string topFriendCount?: number } export interface ActivityHeatmap { data: number[][] } export interface AnnualReportData { year: number totalMessages: number totalFriends: number coreFriends: TopContact[] monthlyTopFriends: MonthlyTopFriend[] peakDay: ChatPeakDay | null longestStreak: { friendName: string days: number startDate: string endDate: string } | null activityHeatmap: ActivityHeatmap midnightKing: { displayName: string count: number percentage: number } | null selfAvatarUrl?: string mutualFriend: { displayName: string avatarUrl?: string sentCount: number receivedCount: number ratio: number } | null socialInitiative: { initiatedChats: number receivedChats: number initiativeRate: number } | null responseSpeed: { avgResponseTime: number fastestFriend: string fastestTime: number } | null topPhrases: { phrase: string count: number }[] snsStats?: { totalPosts: number typeCounts?: Record topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[] topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[] } lostFriend: { username: string displayName: string avatarUrl?: string earlyCount: number lateCount: number periodDesc: string } | null } export interface AvailableYearsLoadProgress { years: number[] strategy: 'cache' | 'native' | 'hybrid' phase: 'cache' | 'native' | 'scan' statusText: string nativeElapsedMs: number scanElapsedMs: number totalElapsedMs: number switched?: boolean nativeTimedOut?: boolean } interface AvailableYearsLoadMeta { strategy: 'cache' | 'native' | 'hybrid' nativeElapsedMs: number scanElapsedMs: number totalElapsedMs: number switched: boolean nativeTimedOut: boolean statusText: string } class AnnualReportService { private readonly availableYearsCacheTtlMs = 10 * 60 * 1000 private readonly availableYearsScanConcurrency = 4 private readonly availableYearsColumnCache = new Map() private readonly availableYearsCache = new Map() constructor() { } private broadcastProgress(status: string, progress: number) { if (parentPort) { parentPort.postMessage({ type: 'annualReport:progress', data: { status, progress } }) } } private reportProgress(status: string, progress: number, onProgress?: (status: string, progress: number) => void) { if (onProgress) { onProgress(status, progress) return } this.broadcastProgress(status, progress) } private cleanAccountDirName(dirName: string): string { const trimmed = dirName.trim() if (!trimmed) return trimmed if (trimmed.toLowerCase().startsWith('wxid_')) { const match = trimmed.match(/^(wxid_[^_]+)/i) if (match) return match[1] return trimmed } const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const cleaned = suffixMatch ? suffixMatch[1] : trimmed return cleaned } private async ensureConnectedWithConfig( dbPath: string, decryptKey: string, wxid: string ): Promise<{ success: boolean; cleanedWxid?: string; rawWxid?: string; error?: string }> { if (!wxid) return { success: false, error: '未配置微信ID' } if (!dbPath) return { success: false, error: '未配置数据库路径' } if (!decryptKey) return { success: false, error: '未配置解密密钥' } const cleanedWxid = this.cleanAccountDirName(wxid) const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid) if (!ok) return { success: false, error: 'WCDB 打开失败' } return { success: true, cleanedWxid, rawWxid: wxid } } private async getPrivateSessions(cleanedWxid: string): Promise { const sessionResult = await wcdbService.getSessions() if (!sessionResult.success || !sessionResult.sessions) return [] const rows = sessionResult.sessions as Record[] const excludeList = [ 'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle', 'newsapp', 'brandsessionholder', 'brandservicesessionholder', 'notifymessage', 'opencustomerservicemsg', 'notification_messages', 'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup', '@helper_folders', '@placeholder_foldgroup' ] return rows .map((row) => row.username || row.user_name || row.userName || '') .filter((username) => { if (!username) return false if (username.includes('@chatroom')) return false if (username === 'filehelper') return false if (username.startsWith('gh_')) return false if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false for (const prefix of excludeList) { if (username.startsWith(prefix) || username === prefix) return false } if (username.includes('@kefu.openim') || username.includes('@openim')) return false if (username.includes('service_')) return false return true }) } private async getEdgeMessageTime(sessionId: string, ascending: boolean): Promise { const cursor = await wcdbService.openMessageCursor(sessionId, 1, ascending, 0, 0) if (!cursor.success || !cursor.cursor) return null try { const batch = await wcdbService.fetchMessageBatch(cursor.cursor) if (!batch.success || !batch.rows || batch.rows.length === 0) return null const ts = parseInt(batch.rows[0].create_time || '0', 10) return ts > 0 ? ts : null } finally { await wcdbService.closeMessageCursor(cursor.cursor) } } private quoteSqlIdentifier(identifier: string): string { return `"${String(identifier || '').replace(/"/g, '""')}"` } private toUnixTimestamp(value: any): number { const n = Number(value) if (!Number.isFinite(n) || n <= 0) return 0 // 兼容毫秒级时间戳 const seconds = n > 1e12 ? Math.floor(n / 1000) : Math.floor(n) return seconds > 0 ? seconds : 0 } private addYearsFromRange(years: Set, firstTs: number, lastTs: number): boolean { let changed = false const currentYear = new Date().getFullYear() const minTs = firstTs > 0 ? firstTs : lastTs const maxTs = lastTs > 0 ? lastTs : firstTs if (minTs <= 0 || maxTs <= 0) return changed const minYear = new Date(minTs * 1000).getFullYear() const maxYear = new Date(maxTs * 1000).getFullYear() for (let y = minYear; y <= maxYear; y++) { if (y >= 2010 && y <= currentYear && !years.has(y)) { years.add(y) changed = true } } return changed } private normalizeAvailableYears(years: Iterable): number[] { return Array.from(new Set(Array.from(years))) .filter((y) => Number.isFinite(y)) .map((y) => Math.floor(y)) .sort((a, b) => b - a) } private async forEachWithConcurrency( items: T[], concurrency: number, handler: (item: T, index: number) => Promise, shouldStop?: () => boolean ): Promise { if (!items.length) return const workerCount = Math.max(1, Math.min(concurrency, items.length)) let nextIndex = 0 const workers: Promise[] = [] for (let i = 0; i < workerCount; i++) { workers.push((async () => { while (true) { if (shouldStop?.()) break const current = nextIndex nextIndex += 1 if (current >= items.length) break await handler(items[current], current) } })()) } await Promise.all(workers) } private async detectTimeColumn(dbPath: string, tableName: string): Promise { const cacheKey = `${dbPath}\u0001${tableName}` if (this.availableYearsColumnCache.has(cacheKey)) { const cached = this.availableYearsColumnCache.get(cacheKey) || '' return cached || null } const result = await wcdbService.getMessageTableColumns(dbPath, tableName) if (!result.success || !Array.isArray(result.columns) || result.columns.length === 0) { this.availableYearsColumnCache.set(cacheKey, '') return null } const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time'] const columns = new Set() for (const columnName of result.columns) { const name = String(columnName || '').trim().toLowerCase() if (name) columns.add(name) } for (const candidate of candidates) { if (columns.has(candidate)) { this.availableYearsColumnCache.set(cacheKey, candidate) return candidate } } this.availableYearsColumnCache.set(cacheKey, '') return null } private async getTableTimeRange(dbPath: string, tableName: string): Promise<{ first: number; last: number } | null> { const cacheKey = `${dbPath}\u0001${tableName}` const cachedColumn = this.availableYearsColumnCache.get(cacheKey) const initialColumn = cachedColumn && cachedColumn.length > 0 ? cachedColumn : 'create_time' const tried = new Set() const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => { const result = await wcdbService.getMessageTableTimeRange(dbPath, tableName) if (!result.success || !result.data) return null const row = result.data as Record const actualColumn = String(row.column || '').trim().toLowerCase() if (column && actualColumn && column.toLowerCase() !== actualColumn) return null const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs) const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs) return { first, last } } tried.add(initialColumn) const quick = await queryByColumn(initialColumn) if (quick) { if (!cachedColumn) this.availableYearsColumnCache.set(cacheKey, initialColumn) return quick } const detectedColumn = await this.detectTimeColumn(dbPath, tableName) if (!detectedColumn || tried.has(detectedColumn)) { return null } return queryByColumn(detectedColumn) } private async getAvailableYearsByTableScan( sessionIds: string[], options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean } ): Promise { const years = new Set() let lastEmittedSize = 0 const emitIfChanged = (force = false) => { if (!options?.onProgress) return const next = this.normalizeAvailableYears(years) if (!force && next.length === lastEmittedSize) return options.onProgress(next) lastEmittedSize = next.length } const shouldCancel = () => options?.shouldCancel?.() === true await this.forEachWithConcurrency(sessionIds, this.availableYearsScanConcurrency, async (sessionId) => { if (shouldCancel()) return const tableStats = await wcdbService.getMessageTableStats(sessionId) if (!tableStats.success || !Array.isArray(tableStats.tables) || tableStats.tables.length === 0) { return } for (const table of tableStats.tables as Record[]) { if (shouldCancel()) return const tableName = String(table.table_name || table.name || '').trim() const dbPath = String(table.db_path || table.dbPath || '').trim() if (!tableName || !dbPath) continue const range = await this.getTableTimeRange(dbPath, tableName) if (!range) continue const changed = this.addYearsFromRange(years, range.first, range.last) if (changed) emitIfChanged() } }, shouldCancel) emitIfChanged(true) return this.normalizeAvailableYears(years) } private async getAvailableYearsByEdgeScan( sessionIds: string[], options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean } ): Promise { const years = new Set() let lastEmittedSize = 0 const shouldCancel = () => options?.shouldCancel?.() === true const emitIfChanged = (force = false) => { if (!options?.onProgress) return const next = this.normalizeAvailableYears(years) if (!force && next.length === lastEmittedSize) return options.onProgress(next) lastEmittedSize = next.length } for (const sessionId of sessionIds) { if (shouldCancel()) break const first = await this.getEdgeMessageTime(sessionId, true) const last = await this.getEdgeMessageTime(sessionId, false) const changed = this.addYearsFromRange(years, first || 0, last || 0) if (changed) emitIfChanged() } emitIfChanged(true) return this.normalizeAvailableYears(years) } private buildAvailableYearsCacheKey(dbPath: string, cleanedWxid: string): string { return `${dbPath}\u0001${cleanedWxid}` } private getCachedAvailableYears(cacheKey: string): number[] | null { const cached = this.availableYearsCache.get(cacheKey) if (!cached) return null if (Date.now() - cached.updatedAt > this.availableYearsCacheTtlMs) { this.availableYearsCache.delete(cacheKey) return null } return [...cached.years] } private setCachedAvailableYears(cacheKey: string, years: number[]): void { const normalized = this.normalizeAvailableYears(years) this.availableYearsCache.set(cacheKey, { years: normalized, updatedAt: Date.now() }) if (this.availableYearsCache.size > 8) { let oldestKey = '' let oldestTime = Number.POSITIVE_INFINITY for (const [key, val] of this.availableYearsCache) { if (val.updatedAt < oldestTime) { oldestTime = val.updatedAt oldestKey = key } } if (oldestKey) this.availableYearsCache.delete(oldestKey) } } private decodeMessageContent(messageContent: any, compressContent: any): string { let content = this.decodeMaybeCompressed(compressContent) if (!content || content.length === 0) { content = this.decodeMaybeCompressed(messageContent) } return content } private decodeMaybeCompressed(raw: any): string { if (!raw) return '' if (typeof raw === 'string') { if (raw.length === 0) return '' // 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码 // 短字符串(如 "123456" 等纯数字)容易被误判为 hex if (raw.length > 16 && this.looksLikeHex(raw)) { const bytes = Buffer.from(raw, 'hex') if (bytes.length > 0) return this.decodeBinaryContent(bytes) } // 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码 // 短字符串(如 "test", "home" 等)容易被误判为 base64 if (raw.length > 16 && this.looksLikeBase64(raw)) { try { const bytes = Buffer.from(raw, 'base64') return this.decodeBinaryContent(bytes) } catch { return raw } } return raw } return '' } private decodeBinaryContent(data: Buffer): string { if (data.length === 0) return '' try { if (data.length >= 4) { const magic = data.readUInt32LE(0) if (magic === 0xFD2FB528) { const fzstd = require('fzstd') const decompressed = fzstd.decompress(data) return Buffer.from(decompressed).toString('utf-8') } } const decoded = data.toString('utf-8') const replacementCount = (decoded.match(/\uFFFD/g) || []).length if (replacementCount < decoded.length * 0.2) { return decoded.replace(/\uFFFD/g, '') } return data.toString('latin1') } catch { return '' } } private looksLikeHex(s: string): boolean { if (s.length % 2 !== 0) return false return /^[0-9a-fA-F]+$/.test(s) } private looksLikeBase64(s: string): boolean { if (s.length % 4 !== 0) return false return /^[A-Za-z0-9+/=]+$/.test(s) } private formatDateYmd(date: Date): string { const y = date.getFullYear() const m = String(date.getMonth() + 1).padStart(2, '0') const d = String(date.getDate()).padStart(2, '0') return `${y}-${m}-${d}` } private async computeLongestStreak( sessionIds: string[], beginTimestamp: number, endTimestamp: number, onProgress?: (status: string, progress: number) => void, progressStart: number = 0, progressEnd: number = 0 ): Promise<{ sessionId: string; days: number; start: Date | null; end: Date | null }> { let bestSessionId = '' let bestDays = 0 let bestStart: Date | null = null let bestEnd: Date | null = null let lastProgressAt = 0 let lastProgressSent = progressStart const shouldReportProgress = onProgress && progressEnd > progressStart && sessionIds.length > 0 let apiTimeMs = 0 let jsTimeMs = 0 for (let i = 0; i < sessionIds.length; i++) { const sessionId = sessionIds[i] const openStart = Date.now() const cursor = await wcdbService.openMessageCursorLite(sessionId, 2000, true, beginTimestamp, endTimestamp) apiTimeMs += Date.now() - openStart if (!cursor.success || !cursor.cursor) continue let lastDayIndex: number | null = null let currentStreak = 0 let currentStart: Date | null = null let maxStreak = 0 let maxStart: Date | null = null let maxEnd: Date | null = null try { let hasMore = true while (hasMore) { const fetchStart = Date.now() const batch = await wcdbService.fetchMessageBatch(cursor.cursor) apiTimeMs += Date.now() - fetchStart if (!batch.success || !batch.rows) break const processStart = Date.now() for (const row of batch.rows) { const createTime = parseInt(row.create_time || '0', 10) if (!createTime) continue const dt = new Date(createTime * 1000) const dayDate = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate()) const dayIndex = Math.floor(dayDate.getTime() / 86400000) if (lastDayIndex !== null && dayIndex === lastDayIndex) continue if (lastDayIndex !== null && dayIndex - lastDayIndex === 1) { currentStreak++ } else { currentStreak = 1 currentStart = dayDate } if (currentStreak > maxStreak) { maxStreak = currentStreak maxStart = currentStart maxEnd = dayDate } lastDayIndex = dayIndex } jsTimeMs += Date.now() - processStart hasMore = batch.hasMore === true await new Promise(resolve => setImmediate(resolve)) } } finally { const closeStart = Date.now() await wcdbService.closeMessageCursor(cursor.cursor) apiTimeMs += Date.now() - closeStart } if (maxStreak > bestDays) { bestDays = maxStreak bestSessionId = sessionId bestStart = maxStart bestEnd = maxEnd } if (shouldReportProgress) { const now = Date.now() if (now - lastProgressAt > 250) { const ratio = Math.min(1, (i + 1) / sessionIds.length) const progress = Math.floor(progressStart + ratio * (progressEnd - progressStart)) if (progress > lastProgressSent) { lastProgressSent = progress lastProgressAt = now const label = `${i + 1}/${sessionIds.length}` const timing = (apiTimeMs > 0 || jsTimeMs > 0) ? `, DB ${(apiTimeMs / 1000).toFixed(1)}s / JS ${(jsTimeMs / 1000).toFixed(1)}s` : '' onProgress?.(`计算连续聊天... (${label}${timing})`, progress) } } } } return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd } } async getAvailableYears(params: { dbPath: string decryptKey: string wxid: string onProgress?: (payload: AvailableYearsLoadProgress) => void shouldCancel?: () => boolean nativeTimeoutMs?: number }): Promise<{ success: boolean; data?: number[]; error?: string; meta?: AvailableYearsLoadMeta }> { try { const isCancelled = () => params.shouldCancel?.() === true const totalStartedAt = Date.now() let nativeElapsedMs = 0 let scanElapsedMs = 0 let switched = false let nativeTimedOut = false let latestYears: number[] = [] const emitProgress = (payload: { years?: number[] strategy: 'cache' | 'native' | 'hybrid' phase: 'cache' | 'native' | 'scan' statusText: string switched?: boolean nativeTimedOut?: boolean }) => { if (!params.onProgress) return if (Array.isArray(payload.years)) latestYears = payload.years params.onProgress({ years: latestYears, strategy: payload.strategy, phase: payload.phase, statusText: payload.statusText, nativeElapsedMs, scanElapsedMs, totalElapsedMs: Date.now() - totalStartedAt, switched: payload.switched ?? switched, nativeTimedOut: payload.nativeTimedOut ?? nativeTimedOut }) } const buildMeta = ( strategy: 'cache' | 'native' | 'hybrid', statusText: string ): AvailableYearsLoadMeta => ({ strategy, nativeElapsedMs, scanElapsedMs, totalElapsedMs: Date.now() - totalStartedAt, switched, nativeTimedOut, statusText }) const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid) if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error, meta: buildMeta('hybrid', '连接数据库失败') } if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid) const cached = this.getCachedAvailableYears(cacheKey) if (cached) { latestYears = cached emitProgress({ years: cached, strategy: 'cache', phase: 'cache', statusText: '命中缓存,已快速加载年份数据' }) return { success: true, data: cached, meta: buildMeta('cache', '命中缓存,已快速加载年份数据') } } const sessionIds = await this.getPrivateSessions(conn.cleanedWxid) if (sessionIds.length === 0) { return { success: false, error: '未找到消息会话', meta: buildMeta('hybrid', '未找到消息会话') } } if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } const nativeTimeoutMs = Math.max(1000, Math.floor(params.nativeTimeoutMs || 5000)) const nativeStartedAt = Date.now() let nativeTicker: ReturnType | null = null emitProgress({ strategy: 'native', phase: 'native', statusText: '正在使用原生快速模式加载年份...' }) nativeTicker = setInterval(() => { nativeElapsedMs = Date.now() - nativeStartedAt emitProgress({ strategy: 'native', phase: 'native', statusText: '正在使用原生快速模式加载年份...' }) }, 120) const nativeRace = await Promise.race([ wcdbService.getAvailableYears(sessionIds) .then((result) => ({ kind: 'result' as const, result })) .catch((error) => ({ kind: 'error' as const, error: String(error) })), new Promise<{ kind: 'timeout' }>((resolve) => setTimeout(() => resolve({ kind: 'timeout' }), nativeTimeoutMs)) ]) if (nativeTicker) { clearInterval(nativeTicker) nativeTicker = null } nativeElapsedMs = Math.max(nativeElapsedMs, Date.now() - nativeStartedAt) if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } if (nativeRace.kind === 'result' && nativeRace.result.success && Array.isArray(nativeRace.result.data) && nativeRace.result.data.length > 0) { const years = this.normalizeAvailableYears(nativeRace.result.data) latestYears = years this.setCachedAvailableYears(cacheKey, years) emitProgress({ years, strategy: 'native', phase: 'native', statusText: '原生快速模式加载完成' }) return { success: true, data: years, meta: buildMeta('native', '原生快速模式加载完成') } } switched = true nativeTimedOut = nativeRace.kind === 'timeout' emitProgress({ strategy: 'hybrid', phase: 'native', statusText: nativeTimedOut ? '原生快速模式超时,已自动切换到扫表兼容模式...' : '原生快速模式不可用,已自动切换到扫表兼容模式...', switched: true, nativeTimedOut }) const scanStartedAt = Date.now() let scanTicker: ReturnType | null = null scanTicker = setInterval(() => { scanElapsedMs = Date.now() - scanStartedAt emitProgress({ strategy: 'hybrid', phase: 'scan', statusText: nativeTimedOut ? '原生已超时,正在使用扫表兼容模式加载年份...' : '正在使用扫表兼容模式加载年份...', switched: true, nativeTimedOut }) }, 120) let years = await this.getAvailableYearsByTableScan(sessionIds, { onProgress: (items) => { latestYears = items scanElapsedMs = Date.now() - scanStartedAt emitProgress({ years: items, strategy: 'hybrid', phase: 'scan', statusText: nativeTimedOut ? '原生已超时,正在使用扫表兼容模式加载年份...' : '正在使用扫表兼容模式加载年份...', switched: true, nativeTimedOut }) }, shouldCancel: params.shouldCancel }) if (isCancelled()) { if (scanTicker) clearInterval(scanTicker) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } } if (years.length === 0) { years = await this.getAvailableYearsByEdgeScan(sessionIds, { onProgress: (items) => { latestYears = items scanElapsedMs = Date.now() - scanStartedAt emitProgress({ years: items, strategy: 'hybrid', phase: 'scan', statusText: '扫表结果为空,正在执行游标兜底扫描...', switched: true, nativeTimedOut }) }, shouldCancel: params.shouldCancel }) } if (scanTicker) { clearInterval(scanTicker) scanTicker = null } scanElapsedMs = Math.max(scanElapsedMs, Date.now() - scanStartedAt) if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } this.setCachedAvailableYears(cacheKey, years) latestYears = years emitProgress({ years, strategy: 'hybrid', phase: 'scan', statusText: '扫表兼容模式加载完成', switched: true, nativeTimedOut }) return { success: true, data: years, meta: buildMeta('hybrid', '扫表兼容模式加载完成') } } catch (e) { return { success: false, error: String(e), meta: { strategy: 'hybrid', nativeElapsedMs: 0, scanElapsedMs: 0, totalElapsedMs: 0, switched: false, nativeTimedOut: false, statusText: '加载年度数据失败' } } } } async generateReportWithConfig(params: { year: number wxid: string dbPath: string decryptKey: string onProgress?: (status: string, progress: number) => void }): Promise<{ success: boolean; data?: AnnualReportData; error?: string }> { try { const { year, wxid, dbPath, decryptKey, onProgress } = params this.reportProgress('正在连接数据库...', 5, onProgress) const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid) if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error } const cleanedWxid = conn.cleanedWxid const rawWxid = conn.rawWxid const sessionIds = await this.getPrivateSessions(cleanedWxid) if (sessionIds.length === 0) { return { success: false, error: '未找到消息会话' } } this.reportProgress('加载会话列表...', 15, onProgress) const isAllTime = year <= 0 const reportYear = isAllTime ? 0 : year const startTime = isAllTime ? 0 : Math.floor(new Date(year, 0, 1).getTime() / 1000) const endTime = isAllTime ? 0 : Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000) const now = new Date() // 全局统计始终使用自然年范围 (Jan 1st - Now/YearEnd) const actualStartTime = startTime const actualEndTime = endTime let totalMessages = 0 const contactStats = new Map() const monthlyStats = new Map>() const dailyStats = new Map() const dailyContactStats = new Map>() const heatmapData: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0)) const midnightStats = new Map() let longestStreakSessionId = '' let longestStreakDays = 0 let longestStreakStart: Date | null = null let longestStreakEnd: Date | null = null const conversationStarts = new Map() const responseTimeStats = new Map() const phraseCount = new Map() const lastMessageTime = new Map() const CONVERSATION_GAP = 3600 this.reportProgress('统计会话消息...', 20, onProgress) const result = await wcdbService.getAnnualReportStats(sessionIds, actualStartTime, actualEndTime) if (!result.success || !result.data) { return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' } } const d = result.data totalMessages = d.total this.reportProgress('汇总基础统计...', 25, onProgress) const totalMessagesForProgress = totalMessages > 0 ? totalMessages : sessionIds.length let processedMessages = 0 let lastProgressSent = 0 let lastProgressAt = 0 // 填充基础统计 for (const [sid, stat] of Object.entries(d.sessions)) { const s = stat as any contactStats.set(sid, { sent: s.sent, received: s.received }) const mMap = new Map() for (const [m, c] of Object.entries(s.monthly || {})) { mMap.set(parseInt(m, 10), c as number) } monthlyStats.set(sid, mMap) } // 填充全局分布,并锁定峰值日期以减少逐日消息统计 let peakDayKey = '' let peakDayCount = 0 for (const [day, count] of Object.entries(d.daily)) { const c = count as number dailyStats.set(day, c) if (c > peakDayCount) { peakDayCount = c peakDayKey = day } } let useSqlExtras = false let responseStatsFromSql: Record | null = null let topPhrasesFromSql: { phrase: string; count: number }[] | null = null let streakComputedInLoop = false let peakDayBegin = 0 let peakDayEnd = 0 if (peakDayKey) { const start = new Date(`${peakDayKey}T00:00:00`).getTime() if (!Number.isNaN(start)) { peakDayBegin = Math.floor(start / 1000) peakDayEnd = peakDayBegin + 24 * 3600 - 1 } } this.reportProgress('加载扩展统计...', 30, onProgress) const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd) if (extras.success && extras.data) { this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress) const extrasData = extras.data as any const heatmap = extrasData.heatmap as number[][] | undefined if (Array.isArray(heatmap) && heatmap.length === 7) { for (let w = 0; w < 7; w++) { if (Array.isArray(heatmap[w])) { for (let h = 0; h < 24; h++) { heatmapData[w][h] = heatmap[w][h] || 0 } } } } this.reportProgress('加载扩展统计... (解析夜聊统计)', 33, onProgress) const midnight = extrasData.midnight as Record | undefined if (midnight) { for (const [sid, count] of Object.entries(midnight)) { midnightStats.set(sid, count as number) } } this.reportProgress('加载扩展统计... (解析对话发起)', 34, onProgress) const conversation = extrasData.conversation as Record | undefined if (conversation) { for (const [sid, stats] of Object.entries(conversation)) { conversationStarts.set(sid, { initiated: stats.initiated || 0, received: stats.received || 0 }) } } this.reportProgress('加载扩展统计... (解析响应速度)', 35, onProgress) responseStatsFromSql = extrasData.response || null this.reportProgress('加载扩展统计... (解析峰值日)', 36, onProgress) const peakDayCounts = extrasData.peakDay as Record | undefined if (peakDayKey && peakDayCounts) { const dayMap = new Map() for (const [sid, count] of Object.entries(peakDayCounts)) { dayMap.set(sid, count as number) } if (dayMap.size > 0) { dailyContactStats.set(peakDayKey, dayMap) } } this.reportProgress('加载扩展统计... (解析常用语)', 37, onProgress) const sqlPhrases = extrasData.topPhrases as { phrase: string; count: number }[] | undefined if (Array.isArray(sqlPhrases) && sqlPhrases.length > 0) { topPhrasesFromSql = sqlPhrases } const streak = extrasData.streak as { sessionId?: string; days?: number; startDate?: string; endDate?: string } | undefined if (streak && streak.sessionId && streak.days && streak.days > 0) { longestStreakSessionId = streak.sessionId longestStreakDays = streak.days longestStreakStart = streak.startDate ? new Date(`${streak.startDate}T00:00:00`) : null longestStreakEnd = streak.endDate ? new Date(`${streak.endDate}T00:00:00`) : null if (longestStreakStart && !Number.isNaN(longestStreakStart.getTime()) && longestStreakEnd && !Number.isNaN(longestStreakEnd.getTime())) { streakComputedInLoop = true } } useSqlExtras = true this.reportProgress('加载扩展统计... (完成)', 40, onProgress) } else if (!extras.success) { const reason = extras.error ? ` (${extras.error})` : '' this.reportProgress(`扩展统计失败,转入完整分析...${reason}`, 30, onProgress) } if (!useSqlExtras) { // 注意:原生层目前未返回交叉维度 heatmapData[weekday][hour], // 这里的 heatmapData 仍然需要通过下面的遍历来精确填充。 // 考虑到 Annual Report 需要一些复杂的序列特征(响应速度、对话发起)和文本特征(常用语), // 我们仍然保留一次轻量级循环,但因为有了原生统计,我们可以分步进行,或者如果数据量极大则跳过某些步骤。 // 为保持功能完整,我们进行深度集成的轻量遍历: for (let i = 0; i < sessionIds.length; i++) { const sessionId = sessionIds[i] const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, actualStartTime, actualEndTime) if (!cursor.success || !cursor.cursor) continue let lastDayIndex: number | null = null let currentStreak = 0 let currentStart: Date | null = null let maxStreak = 0 let maxStart: Date | null = null let maxEnd: Date | null = null try { let hasMore = true while (hasMore) { const batch = await wcdbService.fetchMessageBatch(cursor.cursor) if (!batch.success || !batch.rows) break for (const row of batch.rows) { const createTime = parseInt(row.create_time || '0', 10) if (!createTime) continue const isSendRaw = row.computed_is_send ?? row.is_send ?? '0' let isSent = parseInt(isSendRaw, 10) === 1 const localType = parseInt(row.local_type || row.type || '1', 10) // 兼容逻辑 if (isSendRaw === undefined || isSendRaw === null || isSendRaw === '0') { const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase() if (sender) { const rawLower = rawWxid.toLowerCase() const cleanedLower = cleanedWxid.toLowerCase() if (sender === rawLower || sender === cleanedLower || rawLower.startsWith(sender + '_') || cleanedLower.startsWith(sender + '_')) { isSent = true } } } // 响应速度 & 对话发起 if (!conversationStarts.has(sessionId)) { conversationStarts.set(sessionId, { initiated: 0, received: 0 }) } const convStats = conversationStarts.get(sessionId)! const lastMsg = lastMessageTime.get(sessionId) if (!lastMsg || (createTime - lastMsg.time) > CONVERSATION_GAP) { if (isSent) convStats.initiated++ else convStats.received++ } else if (lastMsg.isSent !== isSent) { if (isSent && !lastMsg.isSent) { const responseTime = createTime - lastMsg.time if (responseTime > 0 && responseTime < 86400) { if (!responseTimeStats.has(sessionId)) responseTimeStats.set(sessionId, []) responseTimeStats.get(sessionId)!.push(responseTime) } } } lastMessageTime.set(sessionId, { time: createTime, isSent }) // 常用语 if ((localType === 1 || localType === 244813135921) && isSent) { const content = this.decodeMessageContent(row.message_content, row.compress_content) const text = String(content).trim() if (text.length >= 2 && text.length <= 20 && !text.includes('http') && !text.includes('<') && !text.startsWith('[') && !text.startsWith(' maxStreak) { maxStreak = currentStreak maxStart = currentStart maxEnd = dayDate } lastDayIndex = dayIndex } if (dt.getHours() >= 0 && dt.getHours() < 6) { midnightStats.set(sessionId, (midnightStats.get(sessionId) || 0) + 1) } if (peakDayKey) { const dayKey = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}` if (dayKey === peakDayKey) { if (!dailyContactStats.has(dayKey)) dailyContactStats.set(dayKey, new Map()) const dayContactMap = dailyContactStats.get(dayKey)! dayContactMap.set(sessionId, (dayContactMap.get(sessionId) || 0) + 1) } } if (totalMessagesForProgress > 0) { processedMessages++ } } hasMore = batch.hasMore === true const now = Date.now() if (now - lastProgressAt > 200) { let progress = 30 if (totalMessagesForProgress > 0) { const ratio = Math.min(1, processedMessages / totalMessagesForProgress) progress = 30 + Math.floor(ratio * 50) } else { const ratio = Math.min(1, (i + 1) / sessionIds.length) progress = 30 + Math.floor(ratio * 50) } if (progress > lastProgressSent) { lastProgressSent = progress lastProgressAt = now let label = `${i + 1}/${sessionIds.length}` if (totalMessagesForProgress > 0) { const done = Math.min(processedMessages, totalMessagesForProgress) label = `${done}/${totalMessagesForProgress}` } this.reportProgress(`分析聊天记录... (${label})`, progress, onProgress) } } await new Promise(resolve => setImmediate(resolve)) } } finally { await wcdbService.closeMessageCursor(cursor.cursor) } if (maxStreak > longestStreakDays) { longestStreakDays = maxStreak longestStreakSessionId = sessionId longestStreakStart = maxStart longestStreakEnd = maxEnd } } streakComputedInLoop = true } if (!streakComputedInLoop) { this.reportProgress('计算连续聊天...', 45, onProgress) const streakResult = await this.computeLongestStreak(sessionIds, actualStartTime, actualEndTime, onProgress, 45, 75) if (streakResult.days > longestStreakDays) { longestStreakDays = streakResult.days longestStreakSessionId = streakResult.sessionId longestStreakStart = streakResult.start longestStreakEnd = streakResult.end } } // 获取朋友圈统计 this.reportProgress('分析朋友圈数据...', 75, onProgress) let snsStatsResult: { totalPosts: number typeCounts?: Record topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[] topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[] } | undefined const snsStats = await wcdbService.getSnsAnnualStats(actualStartTime, actualEndTime) if (snsStats.success && snsStats.data) { const d = snsStats.data const usersToFetch = new Set() d.topLikers?.forEach((u: any) => usersToFetch.add(u.username)) d.topLiked?.forEach((u: any) => usersToFetch.add(u.username)) const snsUserIds = Array.from(usersToFetch) const [snsDisplayNames, snsAvatarUrls] = await Promise.all([ wcdbService.getDisplayNames(snsUserIds), wcdbService.getAvatarUrls(snsUserIds) ]) const getSnsUserInfo = (username: string) => ({ displayName: snsDisplayNames.success && snsDisplayNames.map ? (snsDisplayNames.map[username] || username) : username, avatarUrl: snsAvatarUrls.success && snsAvatarUrls.map ? snsAvatarUrls.map[username] : undefined }) snsStatsResult = { totalPosts: d.totalPosts || 0, typeCounts: d.typeCounts, topLikers: (d.topLikers || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) })), topLiked: (d.topLiked || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) })) } } this.reportProgress('整理联系人信息...', 85, onProgress) const contactIds = Array.from(contactStats.keys()) const [displayNames, avatarUrls] = await Promise.all([ wcdbService.getDisplayNames(contactIds), wcdbService.getAvatarUrls(contactIds) ]) const contactInfoMap = new Map() for (const sessionId of contactIds) { contactInfoMap.set(sessionId, { displayName: displayNames.success && displayNames.map ? (displayNames.map[sessionId] || sessionId) : sessionId, avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[sessionId] : undefined }) } const selfAvatarResult = await wcdbService.getAvatarUrls([rawWxid, cleanedWxid]) const selfAvatarUrl = selfAvatarResult.success && selfAvatarResult.map ? (selfAvatarResult.map[rawWxid] || selfAvatarResult.map[cleanedWxid]) : undefined const coreFriends: TopContact[] = Array.from(contactStats.entries()) .map(([sessionId, stats]) => { const info = contactInfoMap.get(sessionId) return { username: sessionId, displayName: info?.displayName || sessionId, avatarUrl: info?.avatarUrl, messageCount: stats.sent + stats.received, sentCount: stats.sent, receivedCount: stats.received } }) .sort((a, b) => b.messageCount - a.messageCount) .slice(0, 3) const monthlyTopFriends: MonthlyTopFriend[] = [] for (let month = 1; month <= 12; month++) { let maxCount = 0 let topSessionId = '' for (const [sessionId, monthMap] of monthlyStats.entries()) { const count = monthMap.get(month) || 0 if (count > maxCount) { maxCount = count topSessionId = sessionId } } const info = contactInfoMap.get(topSessionId) monthlyTopFriends.push({ month, displayName: info?.displayName || (topSessionId ? topSessionId : '暂无'), avatarUrl: info?.avatarUrl, messageCount: maxCount }) } let peakDay: ChatPeakDay | null = null let maxDayCount = 0 for (const [day, count] of dailyStats.entries()) { if (count > maxDayCount) { maxDayCount = count const dayContactMap = dailyContactStats.get(day) let topFriend = '' let topFriendCount = 0 if (dayContactMap) { for (const [sessionId, c] of dayContactMap.entries()) { if (c > topFriendCount) { topFriendCount = c topFriend = contactInfoMap.get(sessionId)?.displayName || sessionId } } } peakDay = { date: day, messageCount: count, topFriend, topFriendCount } } } let midnightKing: AnnualReportData['midnightKing'] = null const totalMidnight = Array.from(midnightStats.values()).reduce((a, b) => a + b, 0) if (totalMidnight > 0) { let maxMidnight = 0 let midnightSessionId = '' for (const [sessionId, count] of midnightStats.entries()) { if (count > maxMidnight) { maxMidnight = count midnightSessionId = sessionId } } const info = contactInfoMap.get(midnightSessionId) midnightKing = { displayName: info?.displayName || midnightSessionId, count: maxMidnight, percentage: Math.round((maxMidnight / totalMidnight) * 1000) / 10 } } let longestStreak: AnnualReportData['longestStreak'] = null if (longestStreakSessionId && longestStreakDays > 0 && longestStreakStart && longestStreakEnd) { const info = contactInfoMap.get(longestStreakSessionId) longestStreak = { friendName: info?.displayName || longestStreakSessionId, days: longestStreakDays, startDate: this.formatDateYmd(longestStreakStart), endDate: this.formatDateYmd(longestStreakEnd) } } let mutualFriend: AnnualReportData['mutualFriend'] = null let bestRatioDiff = Infinity for (const [sessionId, stats] of contactStats.entries()) { if (stats.sent >= 50 && stats.received >= 50) { const ratio = stats.sent / stats.received const ratioDiff = Math.abs(ratio - 1) if (ratioDiff < bestRatioDiff) { bestRatioDiff = ratioDiff const info = contactInfoMap.get(sessionId) mutualFriend = { displayName: info?.displayName || sessionId, avatarUrl: info?.avatarUrl, sentCount: stats.sent, receivedCount: stats.received, ratio: Math.round(ratio * 100) / 100 } } } } let socialInitiative: AnnualReportData['socialInitiative'] = null let totalInitiated = 0 let totalReceived = 0 for (const stats of conversationStarts.values()) { totalInitiated += stats.initiated totalReceived += stats.received } const totalConversations = totalInitiated + totalReceived if (totalConversations > 0) { socialInitiative = { initiatedChats: totalInitiated, receivedChats: totalReceived, initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10 } } this.reportProgress('生成报告...', 95, onProgress) let responseSpeed: AnnualReportData['responseSpeed'] = null if (responseStatsFromSql && Object.keys(responseStatsFromSql).length > 0) { let totalSum = 0 let totalCount = 0 let fastestFriendId = '' let fastestAvgTime = Infinity for (const [sessionId, stats] of Object.entries(responseStatsFromSql)) { const count = stats.count || 0 const avg = stats.avg || 0 if (count <= 0 || avg <= 0) continue totalSum += avg * count totalCount += count if (avg < fastestAvgTime) { fastestAvgTime = avg fastestFriendId = sessionId } } if (totalCount > 0) { const avgResponseTime = totalSum / totalCount const fastestInfo = contactInfoMap.get(fastestFriendId) responseSpeed = { avgResponseTime: Math.round(avgResponseTime), fastestFriend: fastestInfo?.displayName || fastestFriendId, fastestTime: Math.round(fastestAvgTime) } } } else { const allResponseTimes: number[] = [] let fastestFriendId = '' let fastestAvgTime = Infinity for (const [sessionId, times] of responseTimeStats.entries()) { if (times.length >= 10) { allResponseTimes.push(...times) const avgTime = times.reduce((a, b) => a + b, 0) / times.length if (avgTime < fastestAvgTime) { fastestAvgTime = avgTime fastestFriendId = sessionId } } } if (allResponseTimes.length > 0) { const avgResponseTime = allResponseTimes.reduce((a, b) => a + b, 0) / allResponseTimes.length const fastestInfo = contactInfoMap.get(fastestFriendId) responseSpeed = { avgResponseTime: Math.round(avgResponseTime), fastestFriend: fastestInfo?.displayName || fastestFriendId, fastestTime: Math.round(fastestAvgTime) } } } const topPhrases = topPhrasesFromSql && topPhrasesFromSql.length > 0 ? topPhrasesFromSql : Array.from(phraseCount.entries()) .filter(([_, count]) => count >= 2) .sort((a, b) => b[1] - a[1]) .slice(0, 32) .map(([phrase, count]) => ({ phrase, count })) // 曾经的好朋友 (Once Best Friend / Lost Friend) let lostFriend: AnnualReportData['lostFriend'] = null let maxEarlyCount = 80 // 最低门槛 let bestEarlyCount = 0 let bestLateCount = 0 let bestSid = '' let bestPeriodDesc = '' const currentMonthIndex = new Date().getMonth() + 1 // 1-12 const currentYearNum = now.getFullYear() if (isAllTime) { const days = Object.keys(d.daily).sort() if (days.length >= 2) { const firstDay = Math.floor(new Date(days[0]).getTime() / 1000) const lastDay = Math.floor(new Date(days[days.length - 1]).getTime() / 1000) const midPoint = Math.floor((firstDay + lastDay) / 2) this.reportProgress('分析历史趋势 (1/2)...', 86, onProgress) const earlyRes = await wcdbService.getAggregateStats(sessionIds, 0, midPoint) this.reportProgress('分析历史趋势 (2/2)...', 88, onProgress) const lateRes = await wcdbService.getAggregateStats(sessionIds, midPoint, 0) if (earlyRes.success && lateRes.success && earlyRes.data) { const earlyData = earlyRes.data.sessions || {} const lateData = (lateRes.data?.sessions) || {} for (const sid of sessionIds) { const e = earlyData[sid] || { sent: 0, received: 0 } const l = lateData[sid] || { sent: 0, received: 0 } const early = (e.sent || 0) + (e.received || 0) const late = (l.sent || 0) + (l.received || 0) if (early > 100 && early > late * 5) { // 选择前期消息量最多的 if (early > maxEarlyCount) { maxEarlyCount = early bestEarlyCount = early bestLateCount = late bestSid = sid bestPeriodDesc = '这段时间以来' } } } } } } else if (year === currentYearNum) { // 当前年份:独立获取过去12个月的滚动数据 this.reportProgress('分析近期好友趋势...', 86, onProgress) // 往前数12个月的起点、中点、终点 const rollingStart = Math.floor(new Date(now.getFullYear(), now.getMonth() - 11, 1).getTime() / 1000) const rollingMid = Math.floor(new Date(now.getFullYear(), now.getMonth() - 5, 1).getTime() / 1000) const rollingEnd = Math.floor(now.getTime() / 1000) const earlyRes = await wcdbService.getAggregateStats(sessionIds, rollingStart, rollingMid - 1) const lateRes = await wcdbService.getAggregateStats(sessionIds, rollingMid, rollingEnd) if (earlyRes.success && lateRes.success && earlyRes.data) { const earlyData = earlyRes.data.sessions || {} const lateData = lateRes.data?.sessions || {} for (const sid of sessionIds) { const e = earlyData[sid] || { sent: 0, received: 0 } const l = lateData[sid] || { sent: 0, received: 0 } const early = (e.sent || 0) + (e.received || 0) const late = (l.sent || 0) + (l.received || 0) if (early > 80 && early > late * 5) { // 选择前期消息量最多的 if (early > maxEarlyCount) { maxEarlyCount = early bestEarlyCount = early bestLateCount = late bestSid = sid bestPeriodDesc = '去年的这个时候' } } } } } else { // 指定完整年份 (1-6 vs 7-12) for (const [sid, stat] of Object.entries(d.sessions)) { const s = stat as any const mWeights = s.monthly || {} let early = 0 let late = 0 for (let m = 1; m <= 6; m++) early += mWeights[m] || 0 for (let m = 7; m <= 12; m++) late += mWeights[m] || 0 if (early > 80 && early > late * 5) { // 选择前期消息量最多的 if (early > maxEarlyCount) { maxEarlyCount = early bestEarlyCount = early bestLateCount = late bestSid = sid bestPeriodDesc = `${year}年上半年` } } } } if (bestSid) { let info = contactInfoMap.get(bestSid) // 如果 contactInfoMap 中没有该联系人,则单独获取 if (!info) { const [displayNameRes, avatarUrlRes] = await Promise.all([ wcdbService.getDisplayNames([bestSid]), wcdbService.getAvatarUrls([bestSid]) ]) info = { displayName: displayNameRes.success && displayNameRes.map ? (displayNameRes.map[bestSid] || bestSid) : bestSid, avatarUrl: avatarUrlRes.success && avatarUrlRes.map ? avatarUrlRes.map[bestSid] : undefined } } lostFriend = { username: bestSid, displayName: info?.displayName || bestSid, avatarUrl: info?.avatarUrl, earlyCount: bestEarlyCount, lateCount: bestLateCount, periodDesc: bestPeriodDesc } } const reportData: AnnualReportData = { year: reportYear, totalMessages, totalFriends: contactStats.size, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap: { data: heatmapData }, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases, snsStats: snsStatsResult, lostFriend } return { success: true, data: reportData } } catch (e) { return { success: false, error: String(e) } } } } export const annualReportService = new AnnualReportService()