feat(export): add batch session stats api for export board

This commit is contained in:
tisonhuang
2026-03-01 14:55:19 +08:00
parent 06d6f15e38
commit e686bb6247
5 changed files with 392 additions and 71 deletions

View File

@@ -974,6 +974,10 @@ function registerIpcHandlers() {
return chatService.getSessionDetail(sessionId) return chatService.getSessionDetail(sessionId)
}) })
ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[]) => {
return chatService.getExportSessionStats(sessionIds)
})
ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => { ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => {
return chatService.getImageData(sessionId, msgId) return chatService.getImageData(sessionId, msgId)
}) })

View File

@@ -151,6 +151,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId), getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
close: () => ipcRenderer.invoke('chat:close'), close: () => ipcRenderer.invoke('chat:close'),
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
getExportSessionStats: (sessionIds: string[]) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds),
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),

View File

@@ -136,9 +136,25 @@ export interface ContactInfo {
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
} }
interface ExportSessionStats {
totalMessages: number
voiceMessages: number
imageMessages: number
videoMessages: number
emojiMessages: number
firstTimestamp?: number
lastTimestamp?: number
privateMutualGroups?: number
groupMemberCount?: number
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
}
// 表情包缓存 // 表情包缓存
const emojiCache: Map<string, string> = new Map() const emojiCache: Map<string, string> = new Map()
const emojiDownloading: Map<string, Promise<string | null>> = new Map() const emojiDownloading: Map<string, Promise<string | null>> = new Map()
const FRIEND_EXCLUDE_USERNAMES = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
class ChatService { class ChatService {
private configService: ConfigService private configService: ConfigService
@@ -1210,6 +1226,228 @@ class ChatService {
return Number.isFinite(parsed) ? parsed : NaN return Number.isFinite(parsed) ? parsed : NaN
} }
private buildIdentityKeys(raw: string): string[] {
const value = String(raw || '').trim()
if (!value) return []
const lowerRaw = value.toLowerCase()
const cleaned = this.cleanAccountDirName(value).toLowerCase()
if (cleaned && cleaned !== lowerRaw) {
return [cleaned, lowerRaw]
}
return [lowerRaw]
}
private extractGroupMemberUsername(member: any): string {
if (!member) return ''
if (typeof member === 'string') return member.trim()
return String(
member.username ||
member.userName ||
member.user_name ||
member.encryptUsername ||
member.encryptUserName ||
member.encrypt_username ||
member.originalName ||
''
).trim()
}
private async getFriendIdentitySet(): Promise<Set<string>> {
const identities = new Set<string>()
const contactResult = await wcdbService.execQuery(
'contact',
null,
'SELECT username, local_type, quan_pin FROM contact'
)
if (!contactResult.success || !contactResult.rows) {
return identities
}
for (const rowAny of contactResult.rows) {
const row = rowAny as Record<string, any>
const username = String(row.username || '').trim()
if (!username || username.includes('@chatroom') || username.startsWith('gh_')) continue
if (FRIEND_EXCLUDE_USERNAMES.has(username)) continue
const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
if (localType !== 1) continue
for (const key of this.buildIdentityKeys(username)) {
identities.add(key)
}
}
return identities
}
private async forEachWithConcurrency<T>(
items: T[],
limit: number,
worker: (item: T) => Promise<void>
): Promise<void> {
if (items.length === 0) return
const concurrency = Math.max(1, Math.min(limit, items.length))
let index = 0
const runners = Array.from({ length: concurrency }, async () => {
while (true) {
const current = index
index += 1
if (current >= items.length) return
await worker(items[current])
}
})
await Promise.all(runners)
}
private async collectSessionExportStats(
sessionId: string,
selfIdentitySet: Set<string>
): Promise<ExportSessionStats> {
const stats: ExportSessionStats = {
totalMessages: 0,
voiceMessages: 0,
imageMessages: 0,
videoMessages: 0,
emojiMessages: 0
}
if (sessionId.endsWith('@chatroom')) {
stats.groupMyMessages = 0
stats.groupActiveSpeakers = 0
}
const senderIdentities = new Set<string>()
const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 500, false, 0, 0)
if (!cursorResult.success || !cursorResult.cursor) {
return stats
}
const cursor = cursorResult.cursor
try {
while (true) {
const batch = await wcdbService.fetchMessageBatch(cursor)
if (!batch.success) {
break
}
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
for (const row of rows) {
stats.totalMessages += 1
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
if (localType === 34) stats.voiceMessages += 1
if (localType === 3) stats.imageMessages += 1
if (localType === 43) stats.videoMessages += 1
if (localType === 47) stats.emojiMessages += 1
const createTime = this.getRowInt(
row,
['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'],
0
)
if (createTime > 0) {
if (stats.firstTimestamp === undefined || createTime < stats.firstTimestamp) {
stats.firstTimestamp = createTime
}
if (stats.lastTimestamp === undefined || createTime > stats.lastTimestamp) {
stats.lastTimestamp = createTime
}
}
if (sessionId.endsWith('@chatroom')) {
const sender = String(this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || '').trim()
const senderKeys = this.buildIdentityKeys(sender)
if (senderKeys.length > 0) {
senderIdentities.add(senderKeys[0])
if (senderKeys.some((key) => selfIdentitySet.has(key))) {
stats.groupMyMessages = (stats.groupMyMessages || 0) + 1
}
} else {
const isSend = this.coerceRowNumber(this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']))
if (Number.isFinite(isSend) && isSend === 1) {
stats.groupMyMessages = (stats.groupMyMessages || 0) + 1
}
}
}
}
if (!batch.hasMore || rows.length === 0) {
break
}
}
} finally {
await wcdbService.closeMessageCursor(cursor)
}
if (sessionId.endsWith('@chatroom')) {
stats.groupActiveSpeakers = senderIdentities.size
}
return stats
}
private async buildGroupRelationStats(
groupSessionIds: string[],
privateSessionIds: string[],
selfIdentitySet: Set<string>
): Promise<{
privateMutualGroupMap: Record<string, number>
groupMutualFriendMap: Record<string, number>
}> {
const privateMutualGroupMap: Record<string, number> = {}
const groupMutualFriendMap: Record<string, number> = {}
if (groupSessionIds.length === 0) {
return { privateMutualGroupMap, groupMutualFriendMap }
}
const privateIndex = new Map<string, Set<string>>()
for (const sessionId of privateSessionIds) {
for (const key of this.buildIdentityKeys(sessionId)) {
const set = privateIndex.get(key) || new Set<string>()
set.add(sessionId)
privateIndex.set(key, set)
}
privateMutualGroupMap[sessionId] = 0
}
const friendIdentitySet = await this.getFriendIdentitySet()
await this.forEachWithConcurrency(groupSessionIds, 4, async (groupId) => {
const membersResult = await wcdbService.getGroupMembers(groupId)
if (!membersResult.success || !membersResult.members) {
groupMutualFriendMap[groupId] = 0
return
}
const touchedPrivateSessions = new Set<string>()
const friendMembers = new Set<string>()
for (const member of membersResult.members) {
const username = this.extractGroupMemberUsername(member)
const identityKeys = this.buildIdentityKeys(username)
if (identityKeys.length === 0) continue
const canonical = identityKeys[0]
if (!selfIdentitySet.has(canonical) && friendIdentitySet.has(canonical)) {
friendMembers.add(canonical)
}
for (const key of identityKeys) {
const linked = privateIndex.get(key)
if (!linked) continue
for (const sessionId of linked) {
touchedPrivateSessions.add(sessionId)
}
}
}
groupMutualFriendMap[groupId] = friendMembers.size
for (const sessionId of touchedPrivateSessions) {
privateMutualGroupMap[sessionId] = (privateMutualGroupMap[sessionId] || 0) + 1
}
})
return { privateMutualGroupMap, groupMutualFriendMap }
}
/** /**
* HTTP API 复用消息解析逻辑,确保和应用内展示一致。 * HTTP API 复用消息解析逻辑,确保和应用内展示一致。
*/ */
@@ -3407,6 +3645,108 @@ class ChatService {
return { success: false, error: String(e) } return { success: false, error: String(e) }
} }
} }
async getExportSessionStats(sessionIds: string[]): Promise<{
success: boolean
data?: Record<string, ExportSessionStats>
error?: string
}> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
const normalizedSessionIds = Array.from(
new Set(
(sessionIds || [])
.map((id) => String(id || '').trim())
.filter(Boolean)
)
)
if (normalizedSessionIds.length === 0) {
return { success: true, data: {} }
}
const myWxid = this.configService.get('myWxid') || ''
const selfIdentitySet = new Set<string>(this.buildIdentityKeys(myWxid))
const resultMap: Record<string, ExportSessionStats> = {}
await this.forEachWithConcurrency(normalizedSessionIds, 3, async (sessionId) => {
try {
resultMap[sessionId] = await this.collectSessionExportStats(sessionId, selfIdentitySet)
} catch {
resultMap[sessionId] = {
totalMessages: 0,
voiceMessages: 0,
imageMessages: 0,
videoMessages: 0,
emojiMessages: 0
}
}
})
const groupSessionIds = normalizedSessionIds.filter((id) => id.endsWith('@chatroom'))
const privateSessionIds = normalizedSessionIds.filter((id) => !id.endsWith('@chatroom'))
for (const privateId of privateSessionIds) {
resultMap[privateId] = {
...resultMap[privateId],
privateMutualGroups: resultMap[privateId]?.privateMutualGroups ?? 0
}
}
for (const groupId of groupSessionIds) {
resultMap[groupId] = {
...resultMap[groupId],
groupMyMessages: resultMap[groupId]?.groupMyMessages ?? 0,
groupActiveSpeakers: resultMap[groupId]?.groupActiveSpeakers ?? 0,
groupMemberCount: resultMap[groupId]?.groupMemberCount ?? 0,
groupMutualFriends: resultMap[groupId]?.groupMutualFriends ?? 0
}
}
if (groupSessionIds.length > 0) {
const memberCountsResult = await wcdbService.getGroupMemberCounts(groupSessionIds)
const memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {}
for (const groupId of groupSessionIds) {
resultMap[groupId] = {
...resultMap[groupId],
groupMemberCount: typeof memberCountMap[groupId] === 'number' ? memberCountMap[groupId] : 0
}
}
}
if (groupSessionIds.length > 0) {
try {
const { privateMutualGroupMap, groupMutualFriendMap } = await this.buildGroupRelationStats(
groupSessionIds,
privateSessionIds,
selfIdentitySet
)
for (const privateId of privateSessionIds) {
resultMap[privateId] = {
...resultMap[privateId],
privateMutualGroups: privateMutualGroupMap[privateId] || 0
}
}
for (const groupId of groupSessionIds) {
resultMap[groupId] = {
...resultMap[groupId],
groupMutualFriends: groupMutualFriendMap[groupId] || 0
}
}
} catch {
// 群成员关系统计失败时保留默认值,避免影响主列表展示
}
}
return { success: true, data: resultMap }
} catch (e) {
console.error('ChatService: 获取导出会话统计失败:', e)
return { success: false, error: String(e) }
}
}
/** /**
* 获取图片数据(解密后的) * 获取图片数据(解密后的)
*/ */

View File

@@ -237,8 +237,6 @@ function ExportPage() {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [sessions, setSessions] = useState<SessionRow[]>([]) const [sessions, setSessions] = useState<SessionRow[]>([])
const [contactMap, setContactMap] = useState<Record<string, ContactInfo>>({})
const [groupMemberCountMap, setGroupMemberCountMap] = useState<Record<string, number>>({})
const [sessionMetrics, setSessionMetrics] = useState<Record<string, SessionMetrics>>({}) const [sessionMetrics, setSessionMetrics] = useState<Record<string, SessionMetrics>>({})
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
const [activeTab, setActiveTab] = useState<ConversationTab>('private') const [activeTab, setActiveTab] = useState<ConversationTab>('private')
@@ -384,10 +382,9 @@ function ExportPage() {
return return
} }
const [sessionsResult, contactsResult, groupChatsResult] = await Promise.all([ const [sessionsResult, contactsResult] = await Promise.all([
window.electronAPI.chat.getSessions(), window.electronAPI.chat.getSessions(),
window.electronAPI.chat.getContacts(), window.electronAPI.chat.getContacts()
window.electronAPI.groupAnalytics.getGroupChats()
]) ])
const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : []
@@ -395,15 +392,6 @@ function ExportPage() {
map[contact.username] = contact map[contact.username] = contact
return map return map
}, {}) }, {})
setContactMap(nextContactMap)
const nextGroupMemberCountMap: Record<string, number> = {}
if (groupChatsResult.success && groupChatsResult.data) {
for (const group of groupChatsResult.data) {
nextGroupMemberCountMap[group.username] = group.memberCount
}
}
setGroupMemberCountMap(nextGroupMemberCountMap)
if (sessionsResult.success && sessionsResult.sessions) { if (sessionsResult.success && sessionsResult.sessions) {
const nextSessions = sessionsResult.sessions const nextSessions = sessionsResult.sessions
@@ -468,76 +456,46 @@ function ExportPage() {
const pending = targetSessions.filter(session => !sessionMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) const pending = targetSessions.filter(session => !sessionMetrics[session.username] && !loadingMetricsRef.current.has(session.username))
if (pending.length === 0) return if (pending.length === 0) return
const updates: Record<string, SessionMetrics> = {}
for (const session of pending) { for (const session of pending) {
loadingMetricsRef.current.add(session.username) loadingMetricsRef.current.add(session.username)
updates[session.username] = {}
} }
const updates: Record<string, SessionMetrics> = {} try {
const statsResult = await window.electronAPI.chat.getExportSessionStats(pending.map(session => session.username))
for (const session of pending) { if (statsResult.success && statsResult.data) {
const metrics: SessionMetrics = {} for (const session of pending) {
try { const raw = statsResult.data[session.username]
const detailResult = await window.electronAPI.chat.getSessionDetail(session.username) if (!raw) continue
if (detailResult.success && detailResult.detail) { updates[session.username] = {
metrics.totalMessages = detailResult.detail.messageCount totalMessages: raw.totalMessages,
metrics.firstTimestamp = detailResult.detail.firstMessageTime voiceMessages: raw.voiceMessages,
metrics.lastTimestamp = detailResult.detail.latestMessageTime imageMessages: raw.imageMessages,
} videoMessages: raw.videoMessages,
emojiMessages: raw.emojiMessages,
const exportStats = await window.electronAPI.export.getExportStats([session.username], { privateMutualGroups: raw.privateMutualGroups,
exportVoiceAsText: false, groupMemberCount: raw.groupMemberCount,
exportMedia: true, groupMyMessages: raw.groupMyMessages,
exportImages: true, groupActiveSpeakers: raw.groupActiveSpeakers,
exportVoices: true, groupMutualFriends: raw.groupMutualFriends,
exportVideos: true, firstTimestamp: raw.firstTimestamp,
exportEmojis: true, lastTimestamp: raw.lastTimestamp
dateRange: null
})
metrics.voiceMessages = exportStats.voiceMessages
if (metrics.totalMessages === undefined) {
metrics.totalMessages = exportStats.totalMessages
}
if (session.kind === 'group') {
metrics.groupMemberCount = groupMemberCountMap[session.username]
const [mediaStatsResult, rankingResult] = await Promise.all([
window.electronAPI.groupAnalytics.getGroupMediaStats(session.username),
window.electronAPI.groupAnalytics.getGroupMessageRanking(session.username)
])
if (mediaStatsResult.success && mediaStatsResult.data?.typeCounts) {
for (const item of mediaStatsResult.data.typeCounts) {
const n = item.name.toLowerCase()
if (n.includes('图片')) metrics.imageMessages = item.count
if (n.includes('视频')) metrics.videoMessages = item.count
if (n.includes('语音')) metrics.voiceMessages = item.count
if (n.includes('表情')) metrics.emojiMessages = item.count
}
}
if (rankingResult.success && rankingResult.data) {
metrics.groupActiveSpeakers = rankingResult.data.length
const selfWxid = session.selfWxid || currentUser.wxid
const me = rankingResult.data.find(item => item.member.username === selfWxid)
if (me) {
metrics.groupMyMessages = me.messageCount
}
} }
} }
} catch (error) { }
console.error('加载会话统计失败:', session.username, error) } catch (error) {
} finally { console.error('加载会话统计失败:', error)
} finally {
for (const session of pending) {
loadingMetricsRef.current.delete(session.username) loadingMetricsRef.current.delete(session.username)
} }
updates[session.username] = metrics
} }
if (Object.keys(updates).length > 0) { if (Object.keys(updates).length > 0) {
setSessionMetrics(prev => ({ ...prev, ...updates })) setSessionMetrics(prev => ({ ...prev, ...updates }))
} }
}, [sessionMetrics, groupMemberCountMap, currentUser.wxid]) }, [sessionMetrics])
useEffect(() => { useEffect(() => {
const targets = visibleSessions.slice(0, 40) const targets = visibleSessions.slice(0, 40)

View File

@@ -124,6 +124,24 @@ export interface ElectronAPI {
} }
error?: string error?: string
}> }>
getExportSessionStats: (sessionIds: string[]) => Promise<{
success: boolean
data?: Record<string, {
totalMessages: number
voiceMessages: number
imageMessages: number
videoMessages: number
emojiMessages: number
firstTimestamp?: number
lastTimestamp?: number
privateMutualGroups?: number
groupMemberCount?: number
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
}>
error?: string
}>
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }> getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }> getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }>