新的提交

This commit is contained in:
cc
2026-01-10 13:01:37 +08:00
commit 01641834de
188 changed files with 34865 additions and 0 deletions

View File

@@ -0,0 +1,490 @@
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
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
}
class AnalyticsService {
private configService: ConfigService
private fallbackAggregateCache: { key: string; data: any; updatedAt: number } | null = null
private aggregateCache: { key: string; data: any; updatedAt: number } | null = null
private aggregatePromise: { key: string; promise: Promise<{ success: boolean; data?: any; source?: string; error?: string }> } | null = null
constructor() {
this.configService = new ConfigService()
}
private cleanAccountDirName(name: string): string {
const trimmed = name.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
return trimmed
}
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
}
if (username.includes('@kefu.openim') || username.includes('@openim')) return false
if (username.includes('service_')) return false
return true
}
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
const decryptKey = this.configService.get('decryptKey')
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 }
}
private async getPrivateSessions(
cleanedWxid: string
): Promise<{ usernames: string[]; numericIds: string[] }> {
const sessionResult = await wcdbService.getSessions()
if (!sessionResult.success || !sessionResult.sessions) {
return { usernames: [], numericIds: [] }
}
const rows = sessionResult.sessions as Record<string, any>[]
const sample = rows[0]
void sample
const sessions = rows.map((row) => {
const username = row.username || row.user_name || row.userName || ''
const idValue =
row.id ??
row.session_id ??
row.sessionId ??
row.sid ??
row.local_id ??
row.user_id ??
row.userId ??
row.chatroom_id ??
row.chatroomId ??
null
return { username, idValue }
})
const usernames = sessions.map((s) => s.username)
const privateSessions = sessions.filter((s) => this.isPrivateSession(s.username, cleanedWxid))
const privateUsernames = privateSessions.map((s) => s.username)
const numericIds = privateSessions
.map((s) => s.idValue)
.filter((id) => typeof id === 'number' || (typeof id === 'string' && /^\d+$/.test(id)))
.map((id) => String(id))
return { usernames: privateUsernames, numericIds }
}
private async iterateSessionMessages(
sessionId: string,
onRow: (row: Record<string, any>) => void,
beginTimestamp = 0,
endTimestamp = 0
): Promise<void> {
const cursorResult = await wcdbService.openMessageCursor(
sessionId,
500,
true,
beginTimestamp,
endTimestamp
)
if (!cursorResult.success || !cursorResult.cursor) return
try {
let hasMore = true
let batchCount = 0
while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !batch.rows) break
for (const row of batch.rows) {
onRow(row)
}
hasMore = batch.hasMore === true
// 每处理完一个批次,如果已经处理了较多数据,暂时让出执行权
batchCount++
if (batchCount % 10 === 0) {
await new Promise(resolve => setImmediate(resolve))
}
}
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
}
private setProgress(window: any, status: string, progress: number) {
if (window && !window.isDestroyed()) {
window.webContents.send('analytics:progress', { status, progress })
}
}
private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
const sample = sessionIds.slice(0, 5).join(',')
return `${beginTimestamp}-${endTimestamp}-${sessionIds.length}-${sample}`
}
private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> {
const aggregate = {
total: 0,
sent: 0,
received: 0,
firstTime: 0,
lastTime: 0,
typeCounts: {} as Record<number, number>,
hourly: {} as Record<number, number>,
weekday: {} as Record<number, number>,
daily: {} as Record<string, number>,
monthly: {} as Record<string, number>,
sessions: {} as Record<string, { total: number; sent: number; received: number; lastTime: number }>,
idMap: {}
}
for (const sessionId of sessionIds) {
const sessionStat = { total: 0, sent: 0, received: 0, lastTime: 0 }
await this.iterateSessionMessages(sessionId, (row) => {
const createTime = parseInt(row.create_time || row.createTime || row.create_time_ms || '0', 10)
if (!createTime) return
if (beginTimestamp > 0 && createTime < beginTimestamp) return
if (endTimestamp > 0 && createTime > endTimestamp) return
const localType = parseInt(row.local_type || row.type || '1', 10)
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? 0
const isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true
aggregate.total += 1
sessionStat.total += 1
aggregate.typeCounts[localType] = (aggregate.typeCounts[localType] || 0) + 1
if (isSend) {
aggregate.sent += 1
sessionStat.sent += 1
} else {
aggregate.received += 1
sessionStat.received += 1
}
if (aggregate.firstTime === 0 || createTime < aggregate.firstTime) {
aggregate.firstTime = createTime
}
if (createTime > aggregate.lastTime) {
aggregate.lastTime = createTime
}
if (createTime > sessionStat.lastTime) {
sessionStat.lastTime = createTime
}
const date = new Date(createTime * 1000)
const hour = date.getHours()
const weekday = date.getDay()
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
const dayKey = `${monthKey}-${String(date.getDate()).padStart(2, '0')}`
aggregate.hourly[hour] = (aggregate.hourly[hour] || 0) + 1
aggregate.weekday[weekday] = (aggregate.weekday[weekday] || 0) + 1
aggregate.monthly[monthKey] = (aggregate.monthly[monthKey] || 0) + 1
aggregate.daily[dayKey] = (aggregate.daily[dayKey] || 0) + 1
}, beginTimestamp, endTimestamp)
if (sessionStat.total > 0) {
aggregate.sessions[sessionId] = sessionStat
}
}
return aggregate
}
private async getAggregateWithFallback(
sessionIds: string[],
beginTimestamp = 0,
endTimestamp = 0,
window?: any
): Promise<{ success: boolean; data?: any; source?: string; error?: string }> {
const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp)
if (this.aggregateCache && this.aggregateCache.key === cacheKey) {
if (Date.now() - this.aggregateCache.updatedAt < 5 * 60 * 1000) {
return { success: true, data: this.aggregateCache.data, source: 'cache' }
}
}
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
return this.aggregatePromise.promise
}
const promise = (async () => {
const result = await wcdbService.getAggregateStats(sessionIds, beginTimestamp, endTimestamp)
if (result.success && result.data && result.data.total > 0) {
this.aggregateCache = { key: cacheKey, data: result.data, updatedAt: Date.now() }
return { success: true, data: result.data, source: 'dll' }
}
if (this.fallbackAggregateCache && this.fallbackAggregateCache.key === cacheKey) {
if (Date.now() - this.fallbackAggregateCache.updatedAt < 5 * 60 * 1000) {
return { success: true, data: this.fallbackAggregateCache.data, source: 'cursor-cache' }
}
}
if (window) {
this.setProgress(window, '原生聚合为0使用游标统计...', 45)
}
const data = await this.computeAggregateByCursor(sessionIds, beginTimestamp, endTimestamp)
this.fallbackAggregateCache = { key: cacheKey, data, updatedAt: Date.now() }
this.aggregateCache = { key: cacheKey, data, updatedAt: Date.now() }
return { success: true, data, source: 'cursor' }
})()
this.aggregatePromise = { key: cacheKey, promise }
try {
return await promise
} finally {
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
this.aggregatePromise = null
}
}
}
private normalizeAggregateSessions(
sessions: Record<string, any> | undefined,
idMap: Record<string, string> | undefined
): Record<string, any> {
if (!sessions) return {}
if (!idMap) return sessions
const keys = Object.keys(sessions)
if (keys.length === 0) return sessions
const numericKeys = keys.every((k) => /^\d+$/.test(k))
if (!numericKeys) return sessions
const remapped: Record<string, any> = {}
for (const [id, stat] of Object.entries(sessions)) {
const username = idMap[id] || id
remapped[username] = stat
}
return remapped
}
private async logAggregateDiagnostics(sessionIds: string[]): Promise<void> {
const samples = sessionIds.slice(0, 5)
const results = await Promise.all(samples.map(async (sessionId) => {
const countResult = await wcdbService.getMessageCount(sessionId)
return { sessionId, success: countResult.success, count: countResult.count, error: countResult.error }
}))
void results
}
async getOverallStatistics(): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionInfo.usernames.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const { BrowserWindow } = require('electron')
const win = BrowserWindow.getAllWindows()[0]
this.setProgress(win, '正在执行原生数据聚合...', 30)
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win)
if (!result.success || !result.data) {
return { success: false, error: result.error || '聚合统计失败' }
}
this.setProgress(win, '同步分析结果...', 90)
const d = result.data
if (d.total === 0 && sessionInfo.usernames.length > 0) {
await this.logAggregateDiagnostics(sessionInfo.usernames)
}
const textTypes = [1, 244813135921]
let textMessages = 0
for (const t of textTypes) textMessages += (d.typeCounts[t] || 0)
const imageMessages = d.typeCounts[3] || 0
const voiceMessages = d.typeCounts[34] || 0
const videoMessages = d.typeCounts[43] || 0
const emojiMessages = d.typeCounts[47] || 0
const otherMessages = d.total - textMessages - imageMessages - voiceMessages - videoMessages - emojiMessages
// 估算活跃天数(按月分布估算或从日期列表中提取,由于 C++ 只返回了月份映射,
// 我们这里暂时返回月份数作为参考,或者如果需要精确天数,原生层需要返回 Set 大小)
// 为了性能,我们先用月份数,或者后续再优化 C++ 返回 activeDays 计数。
// 当前 C++ 逻辑中 gs.monthly.size() 就是活跃月份。
const activeMonths = Object.keys(d.monthly).length
return {
success: true,
data: {
totalMessages: d.total,
textMessages,
imageMessages,
voiceMessages,
videoMessages,
emojiMessages,
otherMessages: Math.max(0, otherMessages),
sentMessages: d.sent,
receivedMessages: d.received,
firstMessageTime: d.firstTime || null,
lastMessageTime: d.lastTime || null,
activeDays: activeMonths * 20, // 粗略估算,或改为返回活跃月份
messageTypeCounts: d.typeCounts
}
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async getContactRankings(limit: number = 20): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionInfo.usernames.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0)
if (!result.success || !result.data) {
return { success: false, error: result.error || '聚合统计失败' }
}
const d = result.data
const sessions = this.normalizeAggregateSessions(d.sessions, d.idMap)
const usernames = Object.keys(sessions)
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(usernames),
wcdbService.getAvatarUrls(usernames)
])
const rankings: ContactRanking[] = usernames
.map((username) => {
const stat = sessions[username]
const displayName = displayNames.success && displayNames.map
? (displayNames.map[username] || username)
: username
const avatarUrl = avatarUrls.success && avatarUrls.map
? avatarUrls.map[username]
: undefined
return {
username,
displayName,
avatarUrl,
messageCount: stat.total,
sentCount: stat.sent,
receivedCount: stat.received,
lastMessageTime: stat.lastTime || null
}
})
.sort((a, b) => b.messageCount - a.messageCount)
.slice(0, limit)
return { success: true, data: rankings }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getTimeDistribution(): Promise<{ success: boolean; data?: TimeDistribution; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionInfo.usernames.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0)
if (!result.success || !result.data) {
return { success: false, error: result.error || '聚合统计失败' }
}
const d = result.data
// SQLite strftime('%w') 返回 0=Sun, 1=Mon...6=Sat
// 前端期望 1=Mon...7=Sun
const weekdayDistribution: Record<number, number> = {}
for (const [w, count] of Object.entries(d.weekday)) {
const sqliteW = parseInt(w, 10)
const jsW = sqliteW === 0 ? 7 : sqliteW
weekdayDistribution[jsW] = count as number
}
// 补全 24 小时
const hourlyDistribution: Record<number, number> = {}
for (let i = 0; i < 24; i++) {
hourlyDistribution[i] = d.hourly[i] || 0
}
return {
success: true,
data: {
hourlyDistribution,
weekdayDistribution,
monthlyDistribution: d.monthly
}
}
} catch (e) {
return { success: false, error: String(e) }
}
}
}
export const analyticsService = new AnalyticsService()