mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-05-19 02:40:13 +08:00
887 lines
31 KiB
TypeScript
887 lines
31 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'
|
||
import * as crypto from 'crypto'
|
||
|
||
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[][] // 7x24 矩阵,weekday x hour
|
||
}
|
||
|
||
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 // 接近1表示双向奔赴
|
||
} | null
|
||
socialInitiative: {
|
||
initiatedChats: number // 主动发起的对话数
|
||
receivedChats: number // 被动回复的对话数
|
||
initiativeRate: number // 主动率百分比
|
||
} | null
|
||
responseSpeed: {
|
||
avgResponseTime: number // 平均回复时间(秒)
|
||
fastestFriend: string // 回复最快的好友
|
||
fastestTime: number // 最快回复时间
|
||
} | null
|
||
topPhrases: {
|
||
phrase: string
|
||
count: number
|
||
}[]
|
||
}
|
||
|
||
class AnnualReportService {
|
||
private configService: ConfigService
|
||
private messageDbCache: Map<string, Database.Database> = new Map()
|
||
private readonly systemAccounts = new Set([
|
||
'medianote',
|
||
'floatbottle',
|
||
'qmessage',
|
||
'qqmail',
|
||
'fmessage',
|
||
'weixin',
|
||
'newsapp',
|
||
'notification_messages',
|
||
'weixinreminder',
|
||
'masssendapp',
|
||
'qqsync',
|
||
'facebookapp',
|
||
'feedsapp',
|
||
'voip',
|
||
'blogapp',
|
||
'gmailapp',
|
||
'linkedinplugin',
|
||
'appbrand_notify_message',
|
||
'appbrandcustomerservicemsg'
|
||
])
|
||
|
||
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(dirName: string): string {
|
||
const trimmed = dirName.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 {
|
||
return null
|
||
}
|
||
}
|
||
|
||
private getTableHash(username: string): string {
|
||
return crypto.createHash('md5').update(username).digest('hex')
|
||
}
|
||
|
||
private shouldExcludeAnnualSession(username: string): boolean {
|
||
if (!username) return true
|
||
const u = username.toLowerCase().trim()
|
||
if (!u) return true
|
||
|
||
// 群聊、公众号、文件传输助手
|
||
if (u.includes('@chatroom')) return true
|
||
if (u.startsWith('gh_')) return true
|
||
if (u === 'filehelper') return true
|
||
|
||
// 已知系统账号
|
||
if (this.systemAccounts.has(u)) return true
|
||
|
||
// 邮箱/提醒类
|
||
if (u.includes('qqmail') || u.includes('mail')) return true
|
||
if (u.includes('reminder') || u.includes('notify')) return true
|
||
|
||
return false
|
||
}
|
||
|
||
private extractTableHash(tableName: string): string | null {
|
||
const match = tableName.match(/msg_([0-9a-f]{32})/i)
|
||
if (match?.[1]) return match[1].toLowerCase()
|
||
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 myRowIdCache: Map<string, number | null> = new Map()
|
||
|
||
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 {
|
||
const row = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(myWxid) as { rowid: number } | undefined
|
||
const rowId = row?.rowid ?? null
|
||
this.myRowIdCache.set(cacheKey, rowId)
|
||
return rowId
|
||
} catch {
|
||
this.myRowIdCache.set(cacheKey, null)
|
||
return null
|
||
}
|
||
}
|
||
|
||
async getAvailableYears(): Promise<{ success: boolean; data?: number[]; 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 years = new Set<number>()
|
||
|
||
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 lower(name) LIKE 'msg_%'
|
||
`).all() as { name: string }[]
|
||
|
||
for (const { name: tableName } of tables) {
|
||
try {
|
||
const result = db.prepare(`
|
||
SELECT MIN(create_time) as min_time, MAX(create_time) as max_time
|
||
FROM "${tableName}" WHERE create_time > 0
|
||
`).get() as { min_time: number; max_time: number } | undefined
|
||
|
||
if (result?.min_time && result?.max_time) {
|
||
const minYear = new Date(result.min_time * 1000).getFullYear()
|
||
const maxYear = new Date(result.max_time * 1000).getFullYear()
|
||
for (let y = minYear; y <= maxYear; y++) {
|
||
if (y >= 2010 && y <= new Date().getFullYear()) {
|
||
years.add(y)
|
||
}
|
||
}
|
||
}
|
||
} catch { /* skip */ }
|
||
}
|
||
}
|
||
|
||
const sortedYears = Array.from(years).sort((a, b) => b - a)
|
||
return { success: true, data: sortedYears }
|
||
} catch (e) {
|
||
return { success: false, error: String(e) }
|
||
}
|
||
}
|
||
|
||
async generateReport(year: number): Promise<{ success: boolean; data?: AnnualReportData; 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 sessions = sessionDb.prepare(`
|
||
SELECT username FROM SessionTable
|
||
WHERE username NOT LIKE '%@chatroom'
|
||
AND username != 'filehelper'
|
||
AND username NOT LIKE 'gh_%'
|
||
`).all() as { username: string }[]
|
||
sessionDb.close()
|
||
|
||
// 过滤掉自己(同时匹配原始wxid和清理后的wxid)
|
||
const wxidLower = wxid.toLowerCase()
|
||
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||
const privateUsernames = sessions
|
||
.map(s => s.username)
|
||
.filter(u => {
|
||
const uLower = u.toLowerCase()
|
||
return uLower !== wxidLower && uLower !== cleanedWxidLower && !this.shouldExcludeAnnualSession(u)
|
||
})
|
||
|
||
// 构建 hash -> username 映射
|
||
const hashToUsername = new Map<string, string>()
|
||
for (const username of privateUsernames) {
|
||
hashToUsername.set(this.getTableHash(username).toLowerCase(), username)
|
||
}
|
||
|
||
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 ? Math.floor(Date.now() / 1000) : Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
|
||
|
||
// 统计数据
|
||
let totalMessages = 0
|
||
const contactStats = new Map<string, { sent: number; received: number }>()
|
||
const monthlyStats = new Map<string, Map<number, number>>()
|
||
const dailyStats = new Map<string, number>()
|
||
const dailyContactStats = new Map<string, Map<string, number>>()
|
||
const heatmapData: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0))
|
||
const midnightStats = new Map<string, number>()
|
||
|
||
// 新增统计数据
|
||
const conversationStarts = new Map<string, { initiated: number; received: number }>() // 对话发起统计
|
||
const responseTimeStats = new Map<string, number[]>() // 回复时间统计
|
||
const phraseCount = new Map<string, number>() // 常用语统计
|
||
let lastMessageTime = new Map<string, { time: number; isSent: boolean }>() // 用于计算回复时间
|
||
|
||
// 火花统计:每个会话的活跃日期集合
|
||
const sessionActiveDays = new Map<string, Set<string>>()
|
||
|
||
let tablesProcessed = 0
|
||
|
||
// 遍历所有消息数据库
|
||
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 lower(name) LIKE 'msg_%'
|
||
`).all() as { name: string }[]
|
||
|
||
for (const { name: tableName } of tables) {
|
||
// 从表名提取 hash,查找对应的 sessionId
|
||
const tableHash = this.extractTableHash(tableName)
|
||
if (!tableHash) continue
|
||
const sessionId = hashToUsername.get(tableHash)
|
||
if (!sessionId) continue // 不是私聊表
|
||
|
||
tablesProcessed++
|
||
|
||
try {
|
||
// 必须有 Name2Id 表和 myRowId 才能正确判断发送者
|
||
if (!hasName2Id || myRowId === null) {
|
||
// 没有 Name2Id 表,无法判断发送者,跳过此表
|
||
continue
|
||
}
|
||
|
||
// 检查表是否有内容字段(按优先级尝试多个字段名)
|
||
const columns = db.prepare(`PRAGMA table_info("${tableName}")`).all() as { name: string }[]
|
||
const columnNames = columns.map(c => c.name.toLowerCase())
|
||
|
||
// 内容字段候选列表(按优先级排序)
|
||
const contentCandidates = ['display_content', 'message_content', 'content', 'msg_content', 'WCDB_CT_message_content']
|
||
let contentColumn: string | null = null
|
||
for (const candidate of contentCandidates) {
|
||
if (columnNames.includes(candidate.toLowerCase())) {
|
||
contentColumn = candidate
|
||
break
|
||
}
|
||
}
|
||
|
||
const selectFields = `
|
||
create_time,
|
||
CASE WHEN real_sender_id = ${myRowId} THEN 1 ELSE 0 END as is_sent,
|
||
strftime('%Y-%m-%d', create_time, 'unixepoch', 'localtime') as day,
|
||
CAST(strftime('%m', create_time, 'unixepoch', 'localtime') AS INTEGER) as month,
|
||
CAST(strftime('%H', create_time, 'unixepoch', 'localtime') AS INTEGER) as hour,
|
||
CAST(strftime('%w', create_time, 'unixepoch', 'localtime') AS INTEGER) as weekday,
|
||
local_type
|
||
${contentColumn ? `, "${contentColumn}" as msg_content` : ''}
|
||
`
|
||
|
||
const query = `
|
||
SELECT ${selectFields}
|
||
FROM "${tableName}"
|
||
WHERE create_time >= ? AND create_time <= ?
|
||
ORDER BY create_time ASC
|
||
`
|
||
|
||
const messages = db.prepare(query).all(startTime, endTime) as {
|
||
create_time: number; is_sent: number; day: string; month: number; hour: number; weekday: number;
|
||
local_type: number; msg_content?: string | null
|
||
}[]
|
||
|
||
// 用于计算对话发起和回复时间
|
||
let lastMsg = lastMessageTime.get(sessionId)
|
||
const CONVERSATION_GAP = 3600 // 1小时内算同一对话
|
||
|
||
for (const msg of messages) {
|
||
totalMessages++
|
||
|
||
// 联系人统计
|
||
if (!contactStats.has(sessionId)) {
|
||
contactStats.set(sessionId, { sent: 0, received: 0 })
|
||
}
|
||
const stats = contactStats.get(sessionId)!
|
||
if (msg.is_sent === 1) {
|
||
stats.sent++
|
||
} else {
|
||
stats.received++
|
||
}
|
||
|
||
// 对话发起统计
|
||
if (!conversationStarts.has(sessionId)) {
|
||
conversationStarts.set(sessionId, { initiated: 0, received: 0 })
|
||
}
|
||
const convStats = conversationStarts.get(sessionId)!
|
||
|
||
if (!lastMsg || (msg.create_time - lastMsg.time) > CONVERSATION_GAP) {
|
||
// 新对话开始
|
||
if (msg.is_sent === 1) {
|
||
convStats.initiated++
|
||
} else {
|
||
convStats.received++
|
||
}
|
||
} else if (lastMsg.isSent !== (msg.is_sent === 1)) {
|
||
// 回复时间统计(对方发消息后我回复)
|
||
if (msg.is_sent === 1 && !lastMsg.isSent) {
|
||
const responseTime = msg.create_time - lastMsg.time
|
||
if (responseTime > 0 && responseTime < 86400) { // 24小时内的回复
|
||
if (!responseTimeStats.has(sessionId)) {
|
||
responseTimeStats.set(sessionId, [])
|
||
}
|
||
responseTimeStats.get(sessionId)!.push(responseTime)
|
||
}
|
||
}
|
||
}
|
||
|
||
lastMsg = { time: msg.create_time, isSent: msg.is_sent === 1 }
|
||
lastMessageTime.set(sessionId, lastMsg)
|
||
|
||
// 常用语统计(只统计文本消息,local_type 1 和 244813135921 都是文本消息)
|
||
if ((msg.local_type === 1 || msg.local_type === 244813135921) && msg.msg_content && msg.is_sent === 1) {
|
||
const content = String(msg.msg_content)
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[\p{P}\p{S}]+$/gu, '');
|
||
// 过滤掉系统消息、链接等
|
||
if (content.length >= 2 && content.length <= 20 &&
|
||
!content.includes('http') &&
|
||
!content.includes('<') &&
|
||
!content.startsWith('[') &&
|
||
!content.startsWith('<?xml')) {
|
||
phraseCount.set(content, (phraseCount.get(content) || 0) + 1)
|
||
}
|
||
}
|
||
|
||
// 月度统计
|
||
if (!monthlyStats.has(sessionId)) {
|
||
monthlyStats.set(sessionId, new Map())
|
||
}
|
||
const monthMap = monthlyStats.get(sessionId)!
|
||
monthMap.set(msg.month, (monthMap.get(msg.month) || 0) + 1)
|
||
|
||
// 日统计
|
||
dailyStats.set(msg.day, (dailyStats.get(msg.day) || 0) + 1)
|
||
if (!dailyContactStats.has(msg.day)) {
|
||
dailyContactStats.set(msg.day, new Map())
|
||
}
|
||
const dayContactMap = dailyContactStats.get(msg.day)!
|
||
dayContactMap.set(sessionId, (dayContactMap.get(sessionId) || 0) + 1)
|
||
|
||
// 火花统计:收集每个会话的活跃日期
|
||
if (!sessionActiveDays.has(sessionId)) {
|
||
sessionActiveDays.set(sessionId, new Set())
|
||
}
|
||
sessionActiveDays.get(sessionId)!.add(msg.day)
|
||
|
||
// 热力图 (weekday: 0=周日, 1-6=周一到周六)
|
||
const weekdayIndex = msg.weekday === 0 ? 6 : msg.weekday - 1
|
||
heatmapData[weekdayIndex][msg.hour]++
|
||
|
||
// 深夜统计 (0-5点)
|
||
if (msg.hour >= 0 && msg.hour < 6) {
|
||
midnightStats.set(sessionId, (midnightStats.get(sessionId) || 0) + 1)
|
||
}
|
||
}
|
||
} catch {
|
||
// skip error
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取联系人信息
|
||
const contactDbPath = path.join(dbDir, 'contact.db')
|
||
const contactInfoMap = new Map<string, { displayName: string; avatarUrl?: string }>()
|
||
let selfAvatarUrl: string | undefined
|
||
|
||
if (fs.existsSync(contactDbPath)) {
|
||
try {
|
||
const contactDb = new Database(contactDbPath, { readonly: true })
|
||
|
||
// 获取自己的头像
|
||
try {
|
||
const self = contactDb.prepare(`
|
||
SELECT small_head_url FROM contact WHERE username = ?
|
||
`).get(cleanedWxid) as { small_head_url?: string } | undefined
|
||
selfAvatarUrl = self?.small_head_url
|
||
} catch { /* skip */ }
|
||
|
||
for (const sessionId of Array.from(contactStats.keys())) {
|
||
try {
|
||
const contact = contactDb.prepare(`
|
||
SELECT nick_name, remark, small_head_url FROM contact WHERE username = ?
|
||
`).get(sessionId) as { nick_name?: string; remark?: string; small_head_url?: string } | undefined
|
||
if (contact) {
|
||
contactInfoMap.set(sessionId, {
|
||
displayName: contact.remark || contact.nick_name || sessionId,
|
||
avatarUrl: contact.small_head_url
|
||
})
|
||
}
|
||
} catch { /* skip */ }
|
||
}
|
||
contactDb.close()
|
||
} catch { /* skip */ }
|
||
}
|
||
|
||
// 构建年度挚友 (Top 10)
|
||
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 = ''
|
||
const sessionIds = Array.from(monthlyStats.keys())
|
||
for (const sessionId of sessionIds) {
|
||
const monthMap = monthlyStats.get(sessionId)!
|
||
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
|
||
const days = Array.from(dailyStats.keys())
|
||
for (const day of days) {
|
||
const count = dailyStats.get(day)!
|
||
if (count > maxDayCount) {
|
||
maxDayCount = count
|
||
const dayContactMap = dailyContactStats.get(day)
|
||
let topFriend = ''
|
||
let topFriendCount = 0
|
||
if (dayContactMap) {
|
||
const contactIds = Array.from(dayContactMap.keys())
|
||
for (const sessionId of contactIds) {
|
||
const c = dayContactMap.get(sessionId)!
|
||
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: number, b: number) => a + b, 0)
|
||
if (totalMidnight > 0) {
|
||
let maxMidnight = 0
|
||
let midnightSessionId = ''
|
||
const midnightIds = Array.from(midnightStats.keys())
|
||
for (const sessionId of midnightIds) {
|
||
const count = midnightStats.get(sessionId)!
|
||
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
|
||
}
|
||
}
|
||
|
||
// 计算最长连续聊天(基于 sessionActiveDays 中收集的活跃日期)
|
||
let longestStreak: AnnualReportData['longestStreak'] = null
|
||
let bestStreakDays = 0
|
||
let bestStreakSessionId = ''
|
||
let bestStreakStart: Date | null = null
|
||
let bestStreakEnd: Date | null = null
|
||
|
||
for (const [sessionId, activeDaysSet] of Array.from(sessionActiveDays.entries())) {
|
||
if (activeDaysSet.size < 2) continue // 至少需要2天才能形成连续
|
||
|
||
// 将日期字符串转换为 Date 对象并排序
|
||
const sortedDates = Array.from(activeDaysSet)
|
||
.map(dateStr => new Date(dateStr + 'T00:00:00'))
|
||
.sort((a, b) => a.getTime() - b.getTime())
|
||
|
||
// 计算最长连续天数
|
||
let currentStreak = 1
|
||
let currentStart = sortedDates[0]
|
||
let maxStreak = 1
|
||
let maxStart = sortedDates[0]
|
||
let maxEnd = sortedDates[0]
|
||
|
||
for (let i = 1; i < sortedDates.length; i++) {
|
||
const prevDate = sortedDates[i - 1]
|
||
const currDate = sortedDates[i]
|
||
const diffDays = Math.round((currDate.getTime() - prevDate.getTime()) / (24 * 60 * 60 * 1000))
|
||
|
||
if (diffDays === 1) {
|
||
// 连续的一天
|
||
currentStreak++
|
||
} else {
|
||
// 不连续,重新开始计数
|
||
currentStreak = 1
|
||
currentStart = currDate
|
||
}
|
||
|
||
if (currentStreak > maxStreak) {
|
||
maxStreak = currentStreak
|
||
maxStart = currentStart
|
||
maxEnd = currDate
|
||
}
|
||
}
|
||
|
||
if (maxStreak > bestStreakDays) {
|
||
bestStreakDays = maxStreak
|
||
bestStreakSessionId = sessionId
|
||
bestStreakStart = maxStart
|
||
bestStreakEnd = maxEnd
|
||
}
|
||
}
|
||
|
||
// 构建最终结果
|
||
if (bestStreakSessionId && bestStreakDays > 0 && bestStreakStart && bestStreakEnd) {
|
||
const info = contactInfoMap.get(bestStreakSessionId)
|
||
const formatDate = (d: Date) => {
|
||
const y = d.getFullYear()
|
||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||
const day = String(d.getDate()).padStart(2, '0')
|
||
return `${y}-${m}-${day}`
|
||
}
|
||
longestStreak = {
|
||
friendName: info?.displayName || bestStreakSessionId,
|
||
days: bestStreakDays,
|
||
startDate: formatDate(bestStreakStart),
|
||
endDate: formatDate(bestStreakEnd)
|
||
}
|
||
}
|
||
|
||
// 计算双向奔赴(发送/接收比例最接近1:1的好友)
|
||
let mutualFriend: AnnualReportData['mutualFriend'] = null
|
||
let bestRatioDiff = Infinity
|
||
const contactEntries = Array.from(contactStats.entries())
|
||
for (const [sessionId, stats] of contactEntries) {
|
||
if (stats.sent >= 50 && stats.received >= 50) { // 至少各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
|
||
const convValues = Array.from(conversationStarts.values())
|
||
for (const stats of convValues) {
|
||
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
|
||
}
|
||
}
|
||
|
||
// 计算回复速度
|
||
let responseSpeed: AnnualReportData['responseSpeed'] = null
|
||
let allResponseTimes: number[] = []
|
||
let fastestFriendId = ''
|
||
let fastestAvgTime = Infinity
|
||
const responseEntries = Array.from(responseTimeStats.entries())
|
||
for (const [sessionId, times] of responseEntries) {
|
||
if (times.length >= 10) { // 至少10次回复
|
||
allResponseTimes.push(...times)
|
||
const avgTime = times.reduce((a: number, b: number) => a + b, 0) / times.length
|
||
if (avgTime < fastestAvgTime) {
|
||
fastestAvgTime = avgTime
|
||
fastestFriendId = sessionId
|
||
}
|
||
}
|
||
}
|
||
if (allResponseTimes.length > 0) {
|
||
const avgResponseTime = allResponseTimes.reduce((a: number, b: number) => a + b, 0) / allResponseTimes.length
|
||
const fastestInfo = contactInfoMap.get(fastestFriendId)
|
||
responseSpeed = {
|
||
avgResponseTime: Math.round(avgResponseTime),
|
||
fastestFriend: fastestInfo?.displayName || fastestFriendId,
|
||
fastestTime: Math.round(fastestAvgTime)
|
||
}
|
||
}
|
||
|
||
// 计算年度常用语(Top 32 for word cloud)
|
||
const topPhrases = Array.from(phraseCount.entries())
|
||
.filter(([_, count]) => count >= 2) // 至少使用2次
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 32)
|
||
.map(([phrase, count]) => ({ phrase, count }))
|
||
|
||
const reportData: AnnualReportData = {
|
||
year: reportYear,
|
||
totalMessages,
|
||
totalFriends: contactStats.size,
|
||
coreFriends,
|
||
monthlyTopFriends,
|
||
peakDay,
|
||
longestStreak,
|
||
activityHeatmap: { data: heatmapData },
|
||
midnightKing,
|
||
selfAvatarUrl,
|
||
mutualFriend,
|
||
socialInitiative,
|
||
responseSpeed,
|
||
topPhrases
|
||
}
|
||
|
||
return { success: true, data: reportData }
|
||
} catch (e) {
|
||
return { success: false, error: String(e) }
|
||
}
|
||
}
|
||
|
||
close() {
|
||
this.messageDbCache.forEach(db => {
|
||
try { db.close() } catch { /* ignore */ }
|
||
})
|
||
this.messageDbCache.clear()
|
||
}
|
||
}
|
||
|
||
export const annualReportService = new AnnualReportService()
|