Files
CipherTalk/electron/services/analyticsService.ts
T
2026-04-01 22:36:47 +08:00

808 lines
27 KiB
TypeScript

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 ChatStatistics {
totalMessages: number
textMessages: number
imageMessages: number
voiceMessages: number
videoMessages: number
emojiMessages: number
otherMessages: number
sentMessages: number
receivedMessages: number
firstMessageTime: number | null
lastMessageTime: number | null
activeDays: number
messageTypeCounts: Record<number, number>
}
export interface TimeDistribution {
hourlyDistribution: Record<number, number>
weekdayDistribution: Record<number, number>
monthlyDistribution: Record<string, number>
}
export interface ContactRanking {
username: string
displayName: string
avatarUrl?: string
messageCount: number
sentCount: number
receivedCount: number
lastMessageTime: number | null
}
type TimeRangeFilter = {
startTimeSec?: number
endTimeSec?: number
}
class AnalyticsService {
private configService: ConfigService
private messageDbCache: Map<string, Database.Database> = new Map()
private myRowIdCache: Map<string, number | null> = 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
// 例如: xiangchao1985_b29d -> xiangchao1985
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
}
// 前缀匹配: 目录名以 wxid 或 cleanedWxid 开头
if (dirNameLower.startsWith(wxidLower + '_') || dirNameLower.startsWith(cleanedWxidLower + '_')) {
return dirName
}
// 反向前缀匹配: wxid 或 cleanedWxid 以目录名开头
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 (e) {
return null
}
}
private hasName2IdTable(db: Database.Database): boolean {
try {
const result = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name = 'Name2Id'"
).get()
return !!result
} catch {
return false
}
}
private getMyRowId(db: Database.Database, dbPath: string, myWxid: string): number | null {
const cacheKey = `${dbPath}:${myWxid}`
if (this.myRowIdCache.has(cacheKey)) {
return this.myRowIdCache.get(cacheKey)!
}
try {
// 先尝试原始 wxid
let row = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(myWxid) as any
// 如果没找到,尝试清理后的 wxid
if (!row) {
const cleanedWxid = this.cleanAccountDirName(myWxid)
if (cleanedWxid !== myWxid) {
row = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(cleanedWxid) as any
}
}
const rowId = row?.rowid ?? null
this.myRowIdCache.set(cacheKey, rowId)
return rowId
} catch {
this.myRowIdCache.set(cacheKey, null)
return null
}
}
private toTimestampSeconds(value?: number | null): number | undefined {
if (!value || !Number.isFinite(value) || value <= 0) return undefined
return value >= 1_000_000_000_000 ? Math.floor(value / 1000) : Math.floor(value)
}
private normalizeTimeRange(startTime?: number, endTime?: number): TimeRangeFilter {
const startTimeSec = this.toTimestampSeconds(startTime)
const endTimeSec = this.toTimestampSeconds(endTime)
if (startTimeSec && endTimeSec && startTimeSec > endTimeSec) {
return {
startTimeSec: endTimeSec,
endTimeSec: startTimeSec
}
}
return { startTimeSec, endTimeSec }
}
private buildTimeWhereClause(range: TimeRangeFilter, columnName: string = 'create_time'): string {
const clauses: string[] = []
if (range.startTimeSec) {
clauses.push(`${columnName} >= ${range.startTimeSec}`)
}
if (range.endTimeSec) {
clauses.push(`${columnName} <= ${range.endTimeSec}`)
}
return clauses.length > 0 ? ` WHERE ${clauses.join(' AND ')}` : ''
}
/**
* 判断是否为私聊会话(排除群聊、公众号、系统账号等)
*/
private isPrivateSession(username: string, cleanedWxid: string): boolean {
if (!username) return false
// 排除自己
if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false
// 排除群聊
if (username.includes('@chatroom')) return false
// 排除文件传输助手
if (username === 'filehelper') return false
// 排除公众号
if (username.startsWith('gh_')) return false
// 排除系统账号
const excludeList = [
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
'@helper_folders', '@placeholder_foldgroup'
]
for (const prefix of excludeList) {
if (username.startsWith(prefix) || username === prefix) return false
}
// 排除客服和 OpenIM
if (username.includes('@kefu.openim') || username.includes('@openim')) return false
if (username.includes('service_')) return false
return true
}
/**
* 获取私聊会话列表
*/
private getPrivateSessions(sessionDb: Database.Database, cleanedWxid: string): string[] {
const sessions = sessionDb.prepare(`
SELECT username FROM SessionTable
`).all() as { username: string }[]
return sessions
.map(s => s.username)
.filter(u => this.isPrivateSession(u, cleanedWxid))
}
async getOverallStatistics(startTime?: number, endTime?: number): Promise<{ success: boolean; data?: ChatStatistics; 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 cleanedWxid = this.cleanAccountDirName(wxid)
const dbDir = path.join(baseDir, accountDir)
const dbFiles = this.findMessageDbFiles(dbDir)
if (dbFiles.length === 0) {
return { success: false, error: '未找到消息数据库' }
}
// 获取私聊会话列表(排除群聊、公众号等)
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 privateUsernames = this.getPrivateSessions(sessionDb, cleanedWxid)
sessionDb.close()
const crypto = require('crypto')
const getTableHash = (username: string) => {
return crypto.createHash('md5').update(username).digest('hex')
}
const timeRange = this.normalizeTimeRange(startTime, endTime)
const timeWhere = this.buildTimeWhereClause(timeRange)
// 构建私聊表名的 hash 集合
const privateTableHashes = new Set(privateUsernames.map(u => getTableHash(u)))
let totalMessages = 0
let textMessages = 0
let imageMessages = 0
let voiceMessages = 0
let videoMessages = 0
let emojiMessages = 0
let otherMessages = 0
let sentMessages = 0
let receivedMessages = 0
let firstMessageTime: number | null = null
let lastMessageTime: number | null = null
const messageTypeCounts: Record<number, number> = {}
// 用 Set 收集所有活跃日期,避免重复计算
const activeDatesSet = new Set<string>()
for (const dbPath of dbFiles) {
const db = this.getMessageDb(dbPath)
if (!db) continue
const hasName2Id = this.hasName2IdTable(db)
const myRowId = hasName2Id ? this.getMyRowId(db, dbPath, cleanedWxid) : null
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) {
// 检查表名是否属于私聊会话
const tableHash = tableName.replace('Msg_', '')
if (!privateTableHashes.has(tableHash)) {
continue // 跳过群聊和其他非私聊表
}
try {
let statsQuery: string
if (hasName2Id && myRowId !== null) {
statsQuery = `
SELECT
COUNT(*) as total,
SUM(CASE WHEN local_type = 1 OR local_type = 244813135921 THEN 1 ELSE 0 END) as text_count,
SUM(CASE WHEN local_type = 3 THEN 1 ELSE 0 END) as image_count,
SUM(CASE WHEN local_type = 34 THEN 1 ELSE 0 END) as voice_count,
SUM(CASE WHEN local_type = 43 THEN 1 ELSE 0 END) as video_count,
SUM(CASE WHEN local_type = 47 THEN 1 ELSE 0 END) as emoji_count,
SUM(CASE WHEN real_sender_id = ${myRowId} THEN 1 ELSE 0 END) as sent_count,
SUM(CASE WHEN real_sender_id != ${myRowId} THEN 1 ELSE 0 END) as received_count,
MIN(create_time) as first_time,
MAX(create_time) as last_time
FROM "${tableName}"${timeWhere}
`
} else {
statsQuery = `
SELECT
COUNT(*) as total,
SUM(CASE WHEN local_type = 1 OR local_type = 244813135921 THEN 1 ELSE 0 END) as text_count,
SUM(CASE WHEN local_type = 3 THEN 1 ELSE 0 END) as image_count,
SUM(CASE WHEN local_type = 34 THEN 1 ELSE 0 END) as voice_count,
SUM(CASE WHEN local_type = 43 THEN 1 ELSE 0 END) as video_count,
SUM(CASE WHEN local_type = 47 THEN 1 ELSE 0 END) as emoji_count,
SUM(CASE WHEN is_send = 1 THEN 1 ELSE 0 END) as sent_count,
SUM(CASE WHEN is_send = 0 OR is_send IS NULL THEN 1 ELSE 0 END) as received_count,
MIN(create_time) as first_time,
MAX(create_time) as last_time
FROM "${tableName}"${timeWhere}
`
}
const stats = db.prepare(statsQuery).get() as any
if (stats && stats.total > 0) {
totalMessages += stats.total
textMessages += stats.text_count || 0
imageMessages += stats.image_count || 0
voiceMessages += stats.voice_count || 0
videoMessages += stats.video_count || 0
emojiMessages += stats.emoji_count || 0
sentMessages += stats.sent_count || 0
receivedMessages += stats.received_count || 0
if (stats.first_time) {
if (!firstMessageTime || stats.first_time < firstMessageTime) {
firstMessageTime = stats.first_time
}
}
if (stats.last_time) {
if (!lastMessageTime || stats.last_time > lastMessageTime) {
lastMessageTime = stats.last_time
}
}
// 收集该会话的所有活跃日期
const dates = db.prepare(`
SELECT DISTINCT date(create_time, 'unixepoch', 'localtime') as day
FROM "${tableName}"${timeWhere}
`).all() as { day: string }[]
for (const { day } of dates) {
if (day) activeDatesSet.add(day)
}
const typeCounts = db.prepare(`
SELECT local_type, COUNT(*) as count
FROM "${tableName}"
${timeWhere ? timeWhere : ''}
GROUP BY local_type
`).all() as { local_type: number; count: number }[]
for (const { local_type, count } of typeCounts) {
messageTypeCounts[local_type] = (messageTypeCounts[local_type] || 0) + count
}
}
} catch (e) {
// skip
}
}
}
otherMessages = totalMessages - textMessages - imageMessages - voiceMessages - videoMessages - emojiMessages
return {
success: true,
data: {
totalMessages,
textMessages,
imageMessages,
voiceMessages,
videoMessages,
emojiMessages,
otherMessages: Math.max(0, otherMessages),
sentMessages,
receivedMessages,
firstMessageTime,
lastMessageTime,
activeDays: activeDatesSet.size,
messageTypeCounts
}
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async getContactRankings(limit: number = 20, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: ContactRanking[]; 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 cleanedWxid = this.cleanAccountDirName(wxid)
const dbDir = path.join(baseDir, accountDir)
const dbFiles = this.findMessageDbFiles(dbDir)
if (dbFiles.length === 0) {
return { success: false, error: '未找到消息数据库' }
}
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 privateUsernames = this.getPrivateSessions(sessionDb, cleanedWxid)
sessionDb.close()
const contactStats: Map<string, {
messageCount: number
sentCount: number
receivedCount: number
lastMessageTime: number | null
}> = new Map()
const crypto = require('crypto')
const getTableHash = (username: string) => {
return crypto.createHash('md5').update(username).digest('hex')
}
const timeRange = this.normalizeTimeRange(startTime, endTime)
const timeWhere = this.buildTimeWhereClause(timeRange)
for (const username of privateUsernames) {
const tableHash = getTableHash(username)
// 遍历所有数据库,累加统计(同一会话可能分布在多个数据库中)
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 hasName2Id = this.hasName2IdTable(db)
const myRowId = hasName2Id ? this.getMyRowId(db, dbPath, cleanedWxid) : null
let statsQuery: string
if (hasName2Id && myRowId !== null) {
statsQuery = `
SELECT
COUNT(*) as total,
SUM(CASE WHEN real_sender_id = ${myRowId} THEN 1 ELSE 0 END) as sent_count,
SUM(CASE WHEN real_sender_id != ${myRowId} THEN 1 ELSE 0 END) as received_count,
MAX(create_time) as last_time
FROM "${tableName}"${timeWhere}
`
} else {
statsQuery = `
SELECT
COUNT(*) as total,
SUM(CASE WHEN is_send = 1 THEN 1 ELSE 0 END) as sent_count,
SUM(CASE WHEN is_send = 0 OR is_send IS NULL THEN 1 ELSE 0 END) as received_count,
MAX(create_time) as last_time
FROM "${tableName}"${timeWhere}
`
}
const stats = db.prepare(statsQuery).get() as any
if (stats && stats.total > 0) {
const existing = contactStats.get(username)
if (existing) {
existing.messageCount += stats.total
existing.sentCount += stats.sent_count || 0
existing.receivedCount += stats.received_count || 0
if (stats.last_time && (!existing.lastMessageTime || stats.last_time > existing.lastMessageTime)) {
existing.lastMessageTime = stats.last_time
}
} else {
contactStats.set(username, {
messageCount: stats.total,
sentCount: stats.sent_count || 0,
receivedCount: stats.received_count || 0,
lastMessageTime: stats.last_time || null
})
}
}
} catch (e) {
// skip
}
}
}
}
const contactDbPath = path.join(dbDir, 'contact.db')
const contactInfo: Map<string, { displayName: string; avatarUrl?: string }> = new Map()
if (fs.existsSync(contactDbPath)) {
const contactDb = new Database(contactDbPath, { readonly: true })
const usernames = Array.from(contactStats.keys())
// 检查表结构
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')
for (const username of usernames) {
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 { nick_name?: string; remark?: string; big_head_url?: string; small_head_url?: string } | undefined
if (contact) {
const avatarUrl = (hasBigHeadUrl && contact.big_head_url)
? contact.big_head_url
: (hasSmallHeadUrl && contact.small_head_url)
? contact.small_head_url
: undefined
contactInfo.set(username, {
displayName: contact.remark || contact.nick_name || username,
avatarUrl
})
}
} catch (e) {
// skip
}
}
contactDb.close()
}
const rankings: ContactRanking[] = Array.from(contactStats.entries())
.map(([username, stats]) => {
const info = contactInfo.get(username)
return {
username,
displayName: info?.displayName || username,
avatarUrl: info?.avatarUrl,
messageCount: stats.messageCount,
sentCount: stats.sentCount,
receivedCount: stats.receivedCount,
lastMessageTime: stats.lastMessageTime
}
})
.sort((a, b) => {
const messageCountDelta = b.messageCount - a.messageCount
if (messageCountDelta !== 0) return messageCountDelta
return (b.lastMessageTime || 0) - (a.lastMessageTime || 0)
})
.slice(0, limit)
return { success: true, data: rankings }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getTimeDistribution(startTime?: number, endTime?: number): Promise<{ success: boolean; data?: TimeDistribution; 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 cleanedWxid = this.cleanAccountDirName(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 privateUsernames = this.getPrivateSessions(sessionDb, cleanedWxid)
sessionDb.close()
const crypto = require('crypto')
const getTableHash = (username: string) => {
return crypto.createHash('md5').update(username).digest('hex')
}
const privateTableHashes = new Set(privateUsernames.map(u => getTableHash(u)))
const timeRange = this.normalizeTimeRange(startTime, endTime)
const timeWhere = this.buildTimeWhereClause(timeRange)
const dbFiles = this.findMessageDbFiles(dbDir)
const hourlyDistribution: Record<number, number> = {}
const weekdayDistribution: Record<number, number> = {}
const monthlyDistribution: Record<string, number> = {}
for (let i = 0; i < 24; i++) hourlyDistribution[i] = 0
for (let i = 1; i <= 7; i++) weekdayDistribution[i] = 0
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) {
// 只统计私聊表
const tableHash = tableName.replace('Msg_', '')
if (!privateTableHashes.has(tableHash)) {
continue
}
try {
const hourly = db.prepare(`
SELECT
CAST(strftime('%H', create_time, 'unixepoch', 'localtime') AS INTEGER) as hour,
COUNT(*) as count
FROM "${tableName}"${timeWhere}
GROUP BY hour
`).all() as { hour: number; count: number }[]
for (const { hour, count } of hourly) {
hourlyDistribution[hour] = (hourlyDistribution[hour] || 0) + count
}
const weekday = db.prepare(`
SELECT
CAST(strftime('%w', create_time, 'unixepoch', 'localtime') AS INTEGER) as dow,
COUNT(*) as count
FROM "${tableName}"${timeWhere}
GROUP BY dow
`).all() as { dow: number; count: number }[]
for (const { dow, count } of weekday) {
const weekdayNum = dow === 0 ? 7 : dow
weekdayDistribution[weekdayNum] = (weekdayDistribution[weekdayNum] || 0) + count
}
const monthly = db.prepare(`
SELECT
strftime('%Y-%m', create_time, 'unixepoch', 'localtime') as month,
COUNT(*) as count
FROM "${tableName}"${timeWhere}
GROUP BY month
`).all() as { month: string; count: number }[]
for (const { month, count } of monthly) {
if (month) {
monthlyDistribution[month] = (monthlyDistribution[month] || 0) + count
}
}
} catch (e) {
// skip
}
}
}
return {
success: true,
data: {
hourlyDistribution,
weekdayDistribution,
monthlyDistribution
}
}
} catch (e) {
return { success: false, error: String(e) }
}
}
close() {
this.messageDbCache.forEach(db => {
try {
db.close()
} catch (e) {
// ignore
}
})
this.messageDbCache.clear()
this.myRowIdCache.clear()
}
}
export const analyticsService = new AnalyticsService()