import { ConfigService } from './config' import Database from 'better-sqlite3' import * as fs from 'fs' import * as path from 'path' import { app } from 'electron' export interface GroupChatInfo { username: string displayName: string memberCount: number avatarUrl?: string sortTimestamp?: number } export interface GroupMember { username: string displayName: string avatarUrl?: string } export interface GroupMessageRank { member: GroupMember messageCount: number } export interface GroupActiveHours { hourlyDistribution: Record } export interface MediaTypeCount { type: number name: string count: number } export interface GroupMediaStats { typeCounts: MediaTypeCount[] total: number } class GroupAnalyticsService { private configService: ConfigService private messageDbCache: Map = new Map() constructor() { this.configService = new ConfigService() } private getDecryptedDbDir(): string { const cachePath = this.configService.get('cachePath') if (cachePath) return cachePath // 开发环境使用文档目录 if (process.env.VITE_DEV_SERVER_URL) { const documentsPath = app.getPath('documents') return path.join(documentsPath, 'CipherTalkData') } // 生产环境 const exePath = app.getPath('exe') const installDir = path.dirname(exePath) // 检查是否安装在 C 盘 const isOnCDrive = /^[cC]:/i.test(installDir) || installDir.startsWith('\\') if (isOnCDrive) { const documentsPath = app.getPath('documents') return path.join(documentsPath, 'CipherTalkData') } return path.join(installDir, 'CipherTalkData') } private cleanAccountDirName(name: string): string { const trimmed = name.trim() if (!trimmed) return trimmed // wxid_ 开头的标准格式: wxid_xxx_yyyy -> wxid_xxx if (trimmed.toLowerCase().startsWith('wxid_')) { const match = trimmed.match(/^(wxid_[a-zA-Z0-9]+)/i) if (match) return match[1] return trimmed } // 自定义微信号格式: xxx_yyyy (4位后缀) -> xxx const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) if (suffixMatch) return suffixMatch[1] return trimmed } /** * 查找账号对应的实际目录名 * 支持多种匹配方式以兼容不同版本的目录命名 */ private findAccountDir(baseDir: string, wxid: string): string | null { if (!fs.existsSync(baseDir)) return null const cleanedWxid = this.cleanAccountDirName(wxid) // 1. 直接匹配原始 wxid const directPath = path.join(baseDir, wxid) if (fs.existsSync(directPath)) { return wxid } // 2. 直接匹配清理后的 wxid if (cleanedWxid !== wxid) { const cleanedPath = path.join(baseDir, cleanedWxid) if (fs.existsSync(cleanedPath)) { return cleanedWxid } } // 3. 扫描目录查找匹配 try { const entries = fs.readdirSync(baseDir, { withFileTypes: true }) for (const entry of entries) { if (!entry.isDirectory()) continue const dirName = entry.name const dirNameLower = dirName.toLowerCase() const wxidLower = wxid.toLowerCase() const cleanedWxidLower = cleanedWxid.toLowerCase() if (dirNameLower === wxidLower || dirNameLower === cleanedWxidLower) return dirName if (dirNameLower.startsWith(wxidLower + '_') || dirNameLower.startsWith(cleanedWxidLower + '_')) return dirName if (wxidLower.startsWith(dirNameLower + '_') || cleanedWxidLower.startsWith(dirNameLower + '_')) return dirName const cleanedDirName = this.cleanAccountDirName(dirName) if (cleanedDirName.toLowerCase() === wxidLower || cleanedDirName.toLowerCase() === cleanedWxidLower) return dirName } } catch (e) { console.error('查找账号目录失败:', e) } return null } private findMessageDbFiles(dbDir: string): string[] { try { const files = fs.readdirSync(dbDir) return files.filter(f => { const lower = f.toLowerCase() return (lower.startsWith('msg') || lower.startsWith('message')) && lower.endsWith('.db') }).map(f => path.join(dbDir, f)) } catch { return [] } } private getMessageDb(dbPath: string): Database.Database | null { if (this.messageDbCache.has(dbPath)) { return this.messageDbCache.get(dbPath)! } try { const db = new Database(dbPath, { readonly: true }) this.messageDbCache.set(dbPath, db) return db } catch { return null } } /** * 从 head_image.db 批量获取头像(转换为 base64 data URL) */ private async getAvatarsFromHeadImageDb(dbDir: string, usernames: string[]): Promise> { const result: Record = {} if (usernames.length === 0) return result try { const headImageDbPath = path.join(dbDir, 'head_image.db') if (!fs.existsSync(headImageDbPath)) return result const db = new Database(headImageDbPath, { readonly: true }) try { const stmt = db.prepare('SELECT username, image_buffer FROM head_image WHERE username = ?') for (const username of usernames) { try { const row = stmt.get(username) as any if (row && row.image_buffer) { const buffer = Buffer.from(row.image_buffer) const base64 = buffer.toString('base64') result[username] = `data:image/jpeg;base64,${base64}` } } catch (e) { console.error(`获取 ${username} 的头像失败:`, e) } } } finally { db.close() } } catch (e) { console.error('从 head_image.db 获取头像失败:', e) } return result } async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> { try { const wxid = this.configService.get('myWxid') if (!wxid) { return { success: false, error: '未配置微信ID' } } const baseDir = this.getDecryptedDbDir() const accountDir = this.findAccountDir(baseDir, wxid) if (!accountDir) { return { success: false, error: `未找到账号 ${wxid} 的数据库目录` } } const dbDir = path.join(baseDir, accountDir) const sessionDbPath = path.join(dbDir, 'session.db') if (!fs.existsSync(sessionDbPath)) { return { success: false, error: '未找到 session.db' } } const sessionDb = new Database(sessionDbPath, { readonly: true }) // 查询所有群聊会话,包含时间戳用于排序 const sessions = sessionDb.prepare(` SELECT username, sort_timestamp, last_timestamp FROM SessionTable WHERE username LIKE '%@chatroom' `).all() as { username: string; sort_timestamp?: number; last_timestamp?: number }[] sessionDb.close() const contactDbPath = path.join(dbDir, 'contact.db') const groupInfoMap: Map = new Map() const memberCountMap: Map = new Map() if (fs.existsSync(contactDbPath)) { const contactDb = new Database(contactDbPath, { readonly: true }) // 获取群名称和头像 const columns = contactDb.prepare("PRAGMA table_info(contact)").all() as { name: string }[] const columnNames = columns.map(c => c.name) const hasBigHeadUrl = columnNames.includes('big_head_url') const hasSmallHeadUrl = columnNames.includes('small_head_url') // 收集没有头像 URL 的用户名 const missingAvatars: string[] = [] for (const { username } of sessions) { try { const selectCols = ['nick_name', 'remark'] if (hasBigHeadUrl) selectCols.push('big_head_url') if (hasSmallHeadUrl) selectCols.push('small_head_url') const contact = contactDb.prepare(` SELECT ${selectCols.join(', ')} FROM contact WHERE username = ? `).get(username) as any if (contact) { const avatarUrl = (hasBigHeadUrl && contact.big_head_url) ? contact.big_head_url : (hasSmallHeadUrl && contact.small_head_url) ? contact.small_head_url : undefined groupInfoMap.set(username, { displayName: contact.remark || contact.nick_name || username, avatarUrl }) // 如果没有头像 URL,记录下来 if (!avatarUrl) { missingAvatars.push(username) } } } catch { /* skip */ } } contactDb.close() // 从 head_image.db 获取缺失的头像 if (missingAvatars.length > 0) { const headImageAvatars = await this.getAvatarsFromHeadImageDb(dbDir, missingAvatars) for (const username of missingAvatars) { const avatarUrl = headImageAvatars[username] if (avatarUrl) { const info = groupInfoMap.get(username) if (info) { info.avatarUrl = avatarUrl } } } } } else { return { success: false, error: '未找到 contact.db' } } // 获取群成员数量 if (fs.existsSync(contactDbPath)) { const contactDb = new Database(contactDbPath, { readonly: true }) // 获取群成员数量 try { const tables = contactDb.prepare(` SELECT name FROM sqlite_master WHERE type='table' AND name IN ('chatroom_member', 'name2id') `).all() as { name: string }[] const hasChatroomMember = tables.some(t => t.name === 'chatroom_member') const hasName2Id = tables.some(t => t.name === 'name2id') if (hasChatroomMember && hasName2Id) { for (const { username } of sessions) { try { const result = contactDb.prepare(` SELECT COUNT(*) as count FROM chatroom_member WHERE room_id = (SELECT rowid FROM name2id WHERE username = ?) `).get(username) as { count: number } memberCountMap.set(username, result?.count || 0) } catch { /* skip */ } } } } catch { /* skip */ } contactDb.close() } else { return { success: false, error: '未找到 contact.db' } } const groups: GroupChatInfo[] = sessions.map(({ username, sort_timestamp, last_timestamp }) => { const info = groupInfoMap.get(username) return { username, displayName: info?.displayName || username, memberCount: memberCountMap.get(username) || 0, avatarUrl: info?.avatarUrl, sortTimestamp: sort_timestamp || last_timestamp || 0 } }).sort((a, b) => { // 按最新消息时间降序排列(最新的在前) return (b.sortTimestamp || 0) - (a.sortTimestamp || 0) }) return { success: true, data: groups } } catch (e) { return { success: false, error: String(e) } } } async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> { try { const wxid = this.configService.get('myWxid') if (!wxid) { return { success: false, error: '未配置微信ID' } } const baseDir = this.getDecryptedDbDir() const accountDir = this.findAccountDir(baseDir, wxid) if (!accountDir) { return { success: false, error: `未找到账号 ${wxid} 的数据库目录` } } const dbDir = path.join(baseDir, accountDir) const contactDbPath = path.join(dbDir, 'contact.db') if (!fs.existsSync(contactDbPath)) { return { success: false, error: '未找到 contact.db' } } const contactDb = new Database(contactDbPath, { readonly: true }) const members: GroupMember[] = [] const missingAvatars: string[] = [] try { const memberRows = contactDb.prepare(` SELECT n.username, c.nick_name, c.remark, c.small_head_url FROM chatroom_member m JOIN name2id n ON m.member_id = n.rowid LEFT JOIN contact c ON n.username = c.username WHERE m.room_id = (SELECT rowid FROM name2id WHERE username = ?) `).all(chatroomId) as { username: string; nick_name?: string; remark?: string; small_head_url?: string }[] for (const row of memberRows) { const avatarUrl = row.small_head_url members.push({ username: row.username, displayName: row.remark || row.nick_name || row.username, avatarUrl }) // 如果没有头像 URL,记录下来 if (!avatarUrl) { missingAvatars.push(row.username) } } } catch { /* skip */ } contactDb.close() // 从 head_image.db 获取缺失的头像 if (missingAvatars.length > 0) { const headImageAvatars = await this.getAvatarsFromHeadImageDb(dbDir, missingAvatars) for (const member of members) { if (!member.avatarUrl) { const avatarUrl = headImageAvatars[member.username] if (avatarUrl) { member.avatarUrl = avatarUrl } } } } return { success: true, data: members } } catch (e) { return { success: false, error: String(e) } } } async getGroupMessageRanking(chatroomId: string, limit: number = 20, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupMessageRank[]; error?: string }> { try { const wxid = this.configService.get('myWxid') if (!wxid) { return { success: false, error: '未配置微信ID' } } const baseDir = this.getDecryptedDbDir() const accountDir = this.findAccountDir(baseDir, wxid) if (!accountDir) { return { success: false, error: `未找到账号 ${wxid} 的数据库目录` } } const dbDir = path.join(baseDir, accountDir) const dbFiles = this.findMessageDbFiles(dbDir) if (dbFiles.length === 0) { return { success: false, error: '未找到消息数据库' } } const crypto = require('crypto') const tableHash = crypto.createHash('md5').update(chatroomId).digest('hex') const messageCounts: Map = new Map() // 构建时间条件 let timeCondition = '' if (startTime && endTime) { timeCondition = `WHERE create_time >= ${startTime} AND create_time <= ${endTime}` } else if (startTime) { timeCondition = `WHERE create_time >= ${startTime}` } else if (endTime) { timeCondition = `WHERE create_time <= ${endTime}` } for (const dbPath of dbFiles) { const db = this.getMessageDb(dbPath) if (!db) continue const tables = db.prepare(` SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%' `).all() as { name: string }[] for (const { name: tableName } of tables) { if (!tableName.includes(tableHash)) continue try { // 群聊消息的 real_sender_id 对应发送者 const hasName2Id = db.prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name = 'Name2Id'" ).get() let senderCounts: { sender: string; count: number }[] if (hasName2Id) { const whereClause = timeCondition ? timeCondition.replace('WHERE', 'AND') : '' senderCounts = db.prepare(` SELECT n.user_name as sender, COUNT(*) as count FROM "${tableName}" m JOIN Name2Id n ON m.real_sender_id = n.rowid ${timeCondition ? `WHERE m.create_time >= ${startTime} AND m.create_time <= ${endTime}` : ''} GROUP BY m.real_sender_id `).all() as { sender: string; count: number }[] } else { // 备用方案:使用 sender 字段 const baseCondition = "sender IS NOT NULL AND sender != ''" const fullCondition = timeCondition ? `WHERE ${baseCondition} AND create_time >= ${startTime} AND create_time <= ${endTime}` : `WHERE ${baseCondition}` senderCounts = db.prepare(` SELECT sender, COUNT(*) as count FROM "${tableName}" ${fullCondition} GROUP BY sender `).all() as { sender: string; count: number }[] } for (const { sender, count } of senderCounts) { if (sender) { messageCounts.set(sender, (messageCounts.get(sender) || 0) + count) } } } catch { /* skip */ } } } // 获取成员信息 const membersResult = await this.getGroupMembers(chatroomId) const memberMap: Map = new Map() if (membersResult.success && membersResult.data) { for (const m of membersResult.data) { memberMap.set(m.username, m) } } const rankings: GroupMessageRank[] = Array.from(messageCounts.entries()) .map(([username, count]) => ({ member: memberMap.get(username) || { username, displayName: username }, messageCount: count })) .sort((a, b) => b.messageCount - a.messageCount) .slice(0, limit) return { success: true, data: rankings } } catch (e) { return { success: false, error: String(e) } } } async getGroupActiveHours(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupActiveHours; error?: string }> { try { const wxid = this.configService.get('myWxid') if (!wxid) { return { success: false, error: '未配置微信ID' } } const baseDir = this.getDecryptedDbDir() const accountDir = this.findAccountDir(baseDir, wxid) if (!accountDir) { return { success: false, error: `未找到账号 ${wxid} 的数据库目录` } } const dbDir = path.join(baseDir, accountDir) const dbFiles = this.findMessageDbFiles(dbDir) if (dbFiles.length === 0) { return { success: false, error: '未找到消息数据库' } } const crypto = require('crypto') const tableHash = crypto.createHash('md5').update(chatroomId).digest('hex') const hourlyDistribution: Record = {} for (let i = 0; i < 24; i++) hourlyDistribution[i] = 0 // 构建时间条件 let timeCondition = '' if (startTime && endTime) { timeCondition = `WHERE create_time >= ${startTime} AND create_time <= ${endTime}` } else if (startTime) { timeCondition = `WHERE create_time >= ${startTime}` } else if (endTime) { timeCondition = `WHERE create_time <= ${endTime}` } for (const dbPath of dbFiles) { const db = this.getMessageDb(dbPath) if (!db) continue const tables = db.prepare(` SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%' `).all() as { name: string }[] for (const { name: tableName } of tables) { if (!tableName.includes(tableHash)) continue try { const hourly = db.prepare(` SELECT CAST(strftime('%H', create_time, 'unixepoch', 'localtime') AS INTEGER) as hour, COUNT(*) as count FROM "${tableName}" ${timeCondition} GROUP BY hour `).all() as { hour: number; count: number }[] for (const { hour, count } of hourly) { hourlyDistribution[hour] = (hourlyDistribution[hour] || 0) + count } } catch { /* skip */ } } } return { success: true, data: { hourlyDistribution } } } catch (e) { return { success: false, error: String(e) } } } async getGroupMediaStats(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupMediaStats; error?: string }> { try { const wxid = this.configService.get('myWxid') if (!wxid) { return { success: false, error: '未配置微信ID' } } const baseDir = this.getDecryptedDbDir() const accountDir = this.findAccountDir(baseDir, wxid) if (!accountDir) { return { success: false, error: `未找到账号 ${wxid} 的数据库目录` } } const dbDir = path.join(baseDir, accountDir) const dbFiles = this.findMessageDbFiles(dbDir) if (dbFiles.length === 0) { return { success: false, error: '未找到消息数据库' } } const crypto = require('crypto') const tableHash = crypto.createHash('md5').update(chatroomId).digest('hex') // 主要类型(会单独显示) const mainTypes = new Set([1, 3, 34, 43, 47, 49]) // 类型名称映射 const typeNames: Record = { 1: '文本', 3: '图片', 34: '语音', 43: '视频', 47: '表情包', 49: '链接/文件', } const typeCounts: Map = new Map() // 构建时间条件 let timeCondition = '' if (startTime && endTime) { timeCondition = `WHERE create_time >= ${startTime} AND create_time <= ${endTime}` } else if (startTime) { timeCondition = `WHERE create_time >= ${startTime}` } else if (endTime) { timeCondition = `WHERE create_time <= ${endTime}` } for (const dbPath of dbFiles) { const db = this.getMessageDb(dbPath) if (!db) continue const tables = db.prepare(` SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%' `).all() as { name: string }[] for (const { name: tableName } of tables) { if (!tableName.includes(tableHash)) continue try { const stats = db.prepare(` SELECT local_type, COUNT(*) as count FROM "${tableName}" ${timeCondition} GROUP BY local_type `).all() as { local_type: number; count: number }[] for (const { local_type, count } of stats) { // 只统计主要类型,其他归为"其他" if (mainTypes.has(local_type)) { typeCounts.set(local_type, (typeCounts.get(local_type) || 0) + count) } else { // 其他类型合并到 -1 typeCounts.set(-1, (typeCounts.get(-1) || 0) + count) } } } catch { /* skip */ } } } // 转换为数组格式,过滤掉数量为0的 const result: MediaTypeCount[] = Array.from(typeCounts.entries()) .filter(([, count]) => count > 0) .map(([type, count]) => ({ type, name: type === -1 ? '其他' : (typeNames[type] || `其他`), count })) .sort((a, b) => b.count - a.count) const total = result.reduce((sum, item) => sum + item.count, 0) return { success: true, data: { typeCounts: result, total } } } catch (e) { return { success: false, error: String(e) } } } close() { this.messageDbCache.forEach(db => { try { db.close() } catch { /* ignore */ } }) this.messageDbCache.clear() } } export const groupAnalyticsService = new GroupAnalyticsService()