feat(export): optimize batch export flow and unify session detail typing

This commit is contained in:
tisonhuang
2026-03-02 18:14:11 +08:00
parent 750d6ad7eb
commit ac481c6b18
17 changed files with 2102 additions and 307 deletions

View File

@@ -1024,8 +1024,12 @@ function registerIpcHandlers() {
return chatService.getSessionDetailExtra(sessionId)
})
ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[]) => {
return chatService.getExportSessionStats(sessionIds)
ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[], options?: {
includeRelations?: boolean
forceRefresh?: boolean
allowStaleCache?: boolean
}) => {
return chatService.getExportSessionStats(sessionIds, options)
})
ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => {
@@ -1394,6 +1398,10 @@ function registerIpcHandlers() {
return groupAnalyticsService.getGroupMembers(chatroomId)
})
ipcMain.handle('groupAnalytics:getGroupMembersPanelData', async (_, chatroomId: string, forceRefresh?: boolean) => {
return groupAnalyticsService.getGroupMembersPanelData(chatroomId, forceRefresh)
})
ipcMain.handle('groupAnalytics:getGroupMessageRanking', async (_, chatroomId: string, limit?: number, startTime?: number, endTime?: number) => {
return groupAnalyticsService.getGroupMessageRanking(chatroomId, limit, startTime, endTime)
})

View File

@@ -157,7 +157,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId),
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
getExportSessionStats: (sessionIds: string[]) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds),
getExportSessionStats: (
sessionIds: string[],
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean }
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
@@ -234,6 +237,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
groupAnalytics: {
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
getGroupMembersPanelData: (chatroomId: string, forceRefresh?: boolean) =>
ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, forceRefresh),
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),

View File

@@ -12,6 +12,7 @@ import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { MessageCacheService } from './messageCacheService'
import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStats } from './sessionStatsCacheService'
import { voiceTranscribeService } from './voiceTranscribeService'
import { LRUCache } from '../utils/LRUCache.js'
@@ -152,6 +153,19 @@ interface ExportSessionStats {
groupMutualFriends?: number
}
interface ExportSessionStatsOptions {
includeRelations?: boolean
forceRefresh?: boolean
allowStaleCache?: boolean
}
interface ExportSessionStatsCacheMeta {
updatedAt: number
stale: boolean
includeRelations: boolean
source: 'memory' | 'disk' | 'fresh'
}
interface ExportTabCounts {
private: number
group: number
@@ -194,6 +208,7 @@ class ChatService {
private hardlinkCache = new Map<string, HardlinkState>()
private readonly contactCacheService: ContactCacheService
private readonly messageCacheService: MessageCacheService
private readonly sessionStatsCacheService: SessionStatsCacheService
private voiceWavCache: LRUCache<string, Buffer>
private voiceTranscriptCache: LRUCache<string, string>
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
@@ -225,6 +240,13 @@ class ChatService {
private readonly sessionDetailExtraCacheTtlMs = 5 * 60 * 1000
private sessionStatusCache = new Map<string, { isFolded?: boolean; isMuted?: boolean; updatedAt: number }>()
private readonly sessionStatusCacheTtlMs = 10 * 60 * 1000
private sessionStatsCacheScope = ''
private sessionStatsMemoryCache = new Map<string, SessionStatsCacheEntry>()
private sessionStatsPendingBasic = new Map<string, Promise<ExportSessionStats>>()
private sessionStatsPendingFull = new Map<string, Promise<ExportSessionStats>>()
private allGroupSessionIdsCache: { ids: string[]; updatedAt: number } | null = null
private readonly sessionStatsCacheTtlMs = 10 * 60 * 1000
private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000
constructor() {
this.configService = new ConfigService()
@@ -232,6 +254,7 @@ class ChatService {
const persisted = this.contactCacheService.getAllEntries()
this.avatarCache = new Map(Object.entries(persisted))
this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath())
this.sessionStatsCacheService = new SessionStatsCacheService(this.configService.getCacheBasePath())
// 初始化LRU缓存限制大小防止内存泄漏
this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries)
this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录
@@ -319,6 +342,7 @@ class ChatService {
// 使用 C++ DLL 内部的文件监控 (ReadDirectoryChangesW)
// 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更
wcdbService.setMonitor((type, json) => {
this.handleSessionStatsMonitorChange(type, json)
// 广播给所有渲染进程窗口
BrowserWindow.getAllWindows().forEach((win) => {
if (!win.isDestroyed()) {
@@ -1558,13 +1582,212 @@ class ChatService {
const dbPath = String(this.configService.get('dbPath') || '')
const myWxid = String(this.configService.get('myWxid') || '')
const scope = `${dbPath}::${myWxid}`
if (scope === this.sessionMessageCountCacheScope) return
if (scope === this.sessionMessageCountCacheScope) {
this.refreshSessionStatsCacheScope(scope)
return
}
this.sessionMessageCountCacheScope = scope
this.sessionMessageCountCache.clear()
this.sessionMessageCountHintCache.clear()
this.sessionDetailFastCache.clear()
this.sessionDetailExtraCache.clear()
this.sessionStatusCache.clear()
this.refreshSessionStatsCacheScope(scope)
}
private refreshSessionStatsCacheScope(scope: string): void {
if (scope === this.sessionStatsCacheScope) return
this.sessionStatsCacheScope = scope
this.sessionStatsMemoryCache.clear()
this.sessionStatsPendingBasic.clear()
this.sessionStatsPendingFull.clear()
this.allGroupSessionIdsCache = null
}
private buildScopedSessionStatsKey(sessionId: string): string {
return `${this.sessionStatsCacheScope}::${sessionId}`
}
private toSessionStatsCacheStats(stats: ExportSessionStats): SessionStatsCacheStats {
const normalized: SessionStatsCacheStats = {
totalMessages: Number.isFinite(stats.totalMessages) ? Math.max(0, Math.floor(stats.totalMessages)) : 0,
voiceMessages: Number.isFinite(stats.voiceMessages) ? Math.max(0, Math.floor(stats.voiceMessages)) : 0,
imageMessages: Number.isFinite(stats.imageMessages) ? Math.max(0, Math.floor(stats.imageMessages)) : 0,
videoMessages: Number.isFinite(stats.videoMessages) ? Math.max(0, Math.floor(stats.videoMessages)) : 0,
emojiMessages: Number.isFinite(stats.emojiMessages) ? Math.max(0, Math.floor(stats.emojiMessages)) : 0
}
if (Number.isFinite(stats.firstTimestamp)) normalized.firstTimestamp = Math.max(0, Math.floor(stats.firstTimestamp as number))
if (Number.isFinite(stats.lastTimestamp)) normalized.lastTimestamp = Math.max(0, Math.floor(stats.lastTimestamp as number))
if (Number.isFinite(stats.privateMutualGroups)) normalized.privateMutualGroups = Math.max(0, Math.floor(stats.privateMutualGroups as number))
if (Number.isFinite(stats.groupMemberCount)) normalized.groupMemberCount = Math.max(0, Math.floor(stats.groupMemberCount as number))
if (Number.isFinite(stats.groupMyMessages)) normalized.groupMyMessages = Math.max(0, Math.floor(stats.groupMyMessages as number))
if (Number.isFinite(stats.groupActiveSpeakers)) normalized.groupActiveSpeakers = Math.max(0, Math.floor(stats.groupActiveSpeakers as number))
if (Number.isFinite(stats.groupMutualFriends)) normalized.groupMutualFriends = Math.max(0, Math.floor(stats.groupMutualFriends as number))
return normalized
}
private fromSessionStatsCacheStats(stats: SessionStatsCacheStats): ExportSessionStats {
return {
totalMessages: stats.totalMessages,
voiceMessages: stats.voiceMessages,
imageMessages: stats.imageMessages,
videoMessages: stats.videoMessages,
emojiMessages: stats.emojiMessages,
firstTimestamp: stats.firstTimestamp,
lastTimestamp: stats.lastTimestamp,
privateMutualGroups: stats.privateMutualGroups,
groupMemberCount: stats.groupMemberCount,
groupMyMessages: stats.groupMyMessages,
groupActiveSpeakers: stats.groupActiveSpeakers,
groupMutualFriends: stats.groupMutualFriends
}
}
private supportsRequestedRelation(entry: SessionStatsCacheEntry, includeRelations: boolean): boolean {
if (!includeRelations) return true
return entry.includeRelations
}
private getSessionStatsCacheEntry(sessionId: string): { entry: SessionStatsCacheEntry; source: 'memory' | 'disk' } | null {
const scopedKey = this.buildScopedSessionStatsKey(sessionId)
const inMemory = this.sessionStatsMemoryCache.get(scopedKey)
if (inMemory) {
return { entry: inMemory, source: 'memory' }
}
const persisted = this.sessionStatsCacheService.get(this.sessionStatsCacheScope, sessionId)
if (!persisted) return null
this.sessionStatsMemoryCache.set(scopedKey, persisted)
return { entry: persisted, source: 'disk' }
}
private setSessionStatsCacheEntry(sessionId: string, stats: ExportSessionStats, includeRelations: boolean): number {
const updatedAt = Date.now()
const entry: SessionStatsCacheEntry = {
updatedAt,
includeRelations,
stats: this.toSessionStatsCacheStats(stats)
}
const scopedKey = this.buildScopedSessionStatsKey(sessionId)
this.sessionStatsMemoryCache.set(scopedKey, entry)
this.sessionStatsCacheService.set(this.sessionStatsCacheScope, sessionId, entry)
return updatedAt
}
private deleteSessionStatsCacheEntry(sessionId: string): void {
const scopedKey = this.buildScopedSessionStatsKey(sessionId)
this.sessionStatsMemoryCache.delete(scopedKey)
this.sessionStatsPendingBasic.delete(scopedKey)
this.sessionStatsPendingFull.delete(scopedKey)
this.sessionStatsCacheService.delete(this.sessionStatsCacheScope, sessionId)
}
private clearSessionStatsCacheForScope(): void {
this.sessionStatsMemoryCache.clear()
this.sessionStatsPendingBasic.clear()
this.sessionStatsPendingFull.clear()
this.allGroupSessionIdsCache = null
this.sessionStatsCacheService.clearScope(this.sessionStatsCacheScope)
}
private collectSessionIdsFromPayload(payload: unknown): Set<string> {
const ids = new Set<string>()
const walk = (value: unknown, keyHint?: string) => {
if (Array.isArray(value)) {
for (const item of value) walk(item, keyHint)
return
}
if (value && typeof value === 'object') {
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
walk(v, k)
}
return
}
if (typeof value !== 'string') return
const normalized = value.trim()
if (!normalized) return
const lowerKey = String(keyHint || '').toLowerCase()
const keyLooksLikeSession = (
lowerKey.includes('session') ||
lowerKey.includes('talker') ||
lowerKey.includes('username') ||
lowerKey.includes('chatroom')
)
if (!keyLooksLikeSession && !normalized.includes('@chatroom')) {
return
}
ids.add(normalized)
}
walk(payload)
return ids
}
private handleSessionStatsMonitorChange(type: string, json: string): void {
this.refreshSessionMessageCountCacheScope()
if (!this.sessionStatsCacheScope) return
const normalizedType = String(type || '').toLowerCase()
const maybeJson = String(json || '').trim()
let ids = new Set<string>()
if (maybeJson) {
try {
ids = this.collectSessionIdsFromPayload(JSON.parse(maybeJson))
} catch {
ids = this.collectSessionIdsFromPayload(maybeJson)
}
}
if (ids.size > 0) {
ids.forEach((sessionId) => this.deleteSessionStatsCacheEntry(sessionId))
if (Array.from(ids).some((id) => id.includes('@chatroom'))) {
this.allGroupSessionIdsCache = null
}
return
}
// 无法定位具体会话时,保守地仅在消息/群成员相关变更时清空当前 scope避免展示过旧统计。
if (
normalizedType.includes('message') ||
normalizedType.includes('session') ||
normalizedType.includes('group') ||
normalizedType.includes('member') ||
normalizedType.includes('contact')
) {
this.clearSessionStatsCacheForScope()
}
}
private async listAllGroupSessionIds(): Promise<string[]> {
const now = Date.now()
if (
this.allGroupSessionIdsCache &&
now - this.allGroupSessionIdsCache.updatedAt <= this.allGroupSessionIdsCacheTtlMs
) {
return this.allGroupSessionIdsCache.ids
}
const result = await wcdbService.getSessions()
if (!result.success || !Array.isArray(result.sessions)) {
return []
}
const ids = new Set<string>()
for (const rowAny of result.sessions) {
const row = rowAny as Record<string, unknown>
const usernameRaw = row.username ?? row.userName ?? row.talker ?? row.sessionId
const username = String(usernameRaw || '').trim()
if (!username || !username.endsWith('@chatroom')) continue
ids.add(username)
}
const list = Array.from(ids)
this.allGroupSessionIdsCache = {
ids: list,
updatedAt: now
}
return list
}
private async collectSessionExportStats(
@@ -1715,6 +1938,95 @@ class ChatService {
return { privateMutualGroupMap, groupMutualFriendMap }
}
private buildEmptyExportSessionStats(sessionId: string, includeRelations: boolean): ExportSessionStats {
const isGroup = sessionId.endsWith('@chatroom')
const stats: ExportSessionStats = {
totalMessages: 0,
voiceMessages: 0,
imageMessages: 0,
videoMessages: 0,
emojiMessages: 0
}
if (isGroup) {
stats.groupMyMessages = 0
stats.groupActiveSpeakers = 0
stats.groupMemberCount = 0
if (includeRelations) {
stats.groupMutualFriends = 0
}
} else if (includeRelations) {
stats.privateMutualGroups = 0
}
return stats
}
private async computeSessionExportStats(
sessionId: string,
selfIdentitySet: Set<string>,
includeRelations: boolean
): Promise<ExportSessionStats> {
const stats = await this.collectSessionExportStats(sessionId, selfIdentitySet)
const isGroup = sessionId.endsWith('@chatroom')
if (isGroup) {
const memberCountsResult = await wcdbService.getGroupMemberCounts([sessionId])
const memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {}
stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number' ? Math.max(0, Math.floor(memberCountMap[sessionId])) : 0
}
if (includeRelations) {
if (isGroup) {
try {
const { groupMutualFriendMap } = await this.buildGroupRelationStats([sessionId], [], selfIdentitySet)
stats.groupMutualFriends = groupMutualFriendMap[sessionId] || 0
} catch {
stats.groupMutualFriends = 0
}
} else {
const allGroups = await this.listAllGroupSessionIds()
if (allGroups.length === 0) {
stats.privateMutualGroups = 0
} else {
try {
const { privateMutualGroupMap } = await this.buildGroupRelationStats(allGroups, [sessionId], selfIdentitySet)
stats.privateMutualGroups = privateMutualGroupMap[sessionId] || 0
} catch {
stats.privateMutualGroups = 0
}
}
}
}
return stats
}
private async getOrComputeSessionExportStats(
sessionId: string,
includeRelations: boolean,
selfIdentitySet: Set<string>
): Promise<ExportSessionStats> {
const scopedKey = this.buildScopedSessionStatsKey(sessionId)
if (!includeRelations) {
const pendingFull = this.sessionStatsPendingFull.get(scopedKey)
if (pendingFull) return pendingFull
const pendingBasic = this.sessionStatsPendingBasic.get(scopedKey)
if (pendingBasic) return pendingBasic
} else {
const pendingFull = this.sessionStatsPendingFull.get(scopedKey)
if (pendingFull) return pendingFull
}
const targetMap = includeRelations ? this.sessionStatsPendingFull : this.sessionStatsPendingBasic
const pending = this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations)
targetMap.set(scopedKey, pending)
try {
return await pending
} finally {
targetMap.delete(scopedKey)
}
}
/**
* HTTP API 复用消息解析逻辑,确保和应用内展示一致。
*/
@@ -3600,6 +3912,14 @@ class ChatService {
this.voiceTranscriptPending.clear()
}
if (includeMessages || includeContacts) {
this.sessionStatsMemoryCache.clear()
this.sessionStatsPendingBasic.clear()
this.sessionStatsPendingFull.clear()
this.allGroupSessionIdsCache = null
this.sessionStatsCacheService.clearAll()
}
for (const state of this.hardlinkCache.values()) {
try {
state.db?.close()
@@ -4036,9 +4356,11 @@ class ChatService {
}
}
async getExportSessionStats(sessionIds: string[]): Promise<{
async getExportSessionStats(sessionIds: string[], options: ExportSessionStatsOptions = {}): Promise<{
success: boolean
data?: Record<string, ExportSessionStats>
cache?: Record<string, ExportSessionStatsCacheMeta>
needsRefresh?: string[]
error?: string
}> {
try {
@@ -4046,6 +4368,11 @@ class ChatService {
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
this.refreshSessionMessageCountCacheScope()
const includeRelations = options.includeRelations ?? true
const forceRefresh = options.forceRefresh === true
const allowStaleCache = options.allowStaleCache === true
const normalizedSessionIds = Array.from(
new Set(
@@ -4055,83 +4382,76 @@ class ChatService {
)
)
if (normalizedSessionIds.length === 0) {
return { success: true, data: {} }
return { success: true, data: {}, cache: {} }
}
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 cacheMeta: Record<string, ExportSessionStatsCacheMeta> = {}
const needsRefreshSet = new Set<string>()
const pendingSessionIds: string[] = []
const now = Date.now()
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 sessionId of normalizedSessionIds) {
if (!forceRefresh) {
const cachedResult = this.getSessionStatsCacheEntry(sessionId)
if (cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) {
const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs
if (!stale || allowStaleCache) {
resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats)
cacheMeta[sessionId] = {
updatedAt: cachedResult.entry.updatedAt,
stale,
includeRelations: cachedResult.entry.includeRelations,
source: cachedResult.source
}
if (stale) {
needsRefreshSet.add(sessionId)
}
continue
}
}
for (const groupId of groupSessionIds) {
resultMap[groupId] = {
...resultMap[groupId],
groupMutualFriends: groupMutualFriendMap[groupId] || 0
}
if (allowStaleCache) {
needsRefreshSet.add(sessionId)
continue
}
} catch {
// 群成员关系统计失败时保留默认值,避免影响主列表展示
}
pendingSessionIds.push(sessionId)
}
return { success: true, data: resultMap }
if (pendingSessionIds.length > 0) {
const myWxid = this.configService.get('myWxid') || ''
const selfIdentitySet = new Set<string>(this.buildIdentityKeys(myWxid))
await this.forEachWithConcurrency(pendingSessionIds, 3, async (sessionId) => {
try {
const stats = await this.getOrComputeSessionExportStats(sessionId, includeRelations, selfIdentitySet)
resultMap[sessionId] = stats
const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations)
cacheMeta[sessionId] = {
updatedAt,
stale: false,
includeRelations,
source: 'fresh'
}
} catch {
resultMap[sessionId] = this.buildEmptyExportSessionStats(sessionId, includeRelations)
}
})
}
const response: {
success: boolean
data?: Record<string, ExportSessionStats>
cache?: Record<string, ExportSessionStatsCacheMeta>
needsRefresh?: string[]
} = {
success: true,
data: resultMap,
cache: cacheMeta
}
if (needsRefreshSet.size > 0) {
response.needsRefresh = Array.from(needsRefreshSet)
}
return response
} catch (e) {
console.error('ChatService: 获取导出会话统计失败:', e)
return { success: false, error: String(e) }

View File

@@ -70,6 +70,7 @@ const MESSAGE_TYPE_MAP: Record<number, number> = {
export interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji'
dateRange?: { start: number; end: number } | null
senderUsername?: string
fileNameSuffix?: string
@@ -104,6 +105,9 @@ interface MediaExportItem {
posterDataUrl?: string
}
type MessageCollectMode = 'full' | 'text-fast' | 'media-fast'
type MediaContentType = 'voice' | 'image' | 'video' | 'emoji'
export interface ExportProgress {
current: number
total: number
@@ -159,6 +163,85 @@ class ExportService {
return Math.max(1, Math.min(raw, max))
}
private isMediaExportEnabled(options: ExportOptions): boolean {
return options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
}
private isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean {
if (!dateRange) return true
const start = Number.isFinite(dateRange.start) ? dateRange.start : 0
const end = Number.isFinite(dateRange.end) ? dateRange.end : 0
return start <= 0 && end <= 0
}
private shouldUseFastTextCollection(options: ExportOptions): boolean {
// 文本批量导出优先走轻量采集:不做媒体字段预提取,减少 CPU 与内存占用
return !this.isMediaExportEnabled(options)
}
private getMediaContentType(options: ExportOptions): MediaContentType | null {
const value = options.contentType
if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji') {
return value
}
return null
}
private isMediaContentBatchExport(options: ExportOptions): boolean {
return this.getMediaContentType(options) !== null
}
private getTargetMediaLocalTypes(options: ExportOptions): Set<number> {
const mediaContentType = this.getMediaContentType(options)
if (mediaContentType === 'voice') return new Set([34])
if (mediaContentType === 'image') return new Set([3])
if (mediaContentType === 'video') return new Set([43])
if (mediaContentType === 'emoji') return new Set([47])
const selected = new Set<number>()
if (options.exportImages) selected.add(3)
if (options.exportVoices) selected.add(34)
if (options.exportVideos) selected.add(43)
if (options.exportEmojis) selected.add(47)
return selected
}
private resolveCollectMode(options: ExportOptions): MessageCollectMode {
if (this.isMediaContentBatchExport(options)) {
return 'media-fast'
}
return this.shouldUseFastTextCollection(options) ? 'text-fast' : 'full'
}
private resolveCollectParams(options: ExportOptions): { mode: MessageCollectMode; targetMediaTypes?: Set<number> } {
const mode = this.resolveCollectMode(options)
if (mode === 'media-fast') {
const targetMediaTypes = this.getTargetMediaLocalTypes(options)
if (targetMediaTypes.size > 0) {
return { mode, targetMediaTypes }
}
}
return { mode }
}
private shouldDecodeMessageContentInFastMode(localType: number): boolean {
// 这些类型在文本导出里只需要占位符,无需解码完整 XML / 压缩内容
if (localType === 3 || localType === 34 || localType === 42 || localType === 43 || localType === 47) {
return false
}
return true
}
private shouldDecodeMessageContentInMediaMode(localType: number, targetMediaTypes: Set<number> | null): boolean {
if (!targetMediaTypes || !targetMediaTypes.has(localType)) return false
// 语音导出仅需要 localId 读取音频数据,不依赖 XML 内容
if (localType === 34) return false
// 图片/视频/表情可能需要从 XML 提取 md5/datName/cdnUrl
if (localType === 3 || localType === 43 || localType === 47) return true
return false
}
private cleanAccountDirName(dirName: string): string {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
@@ -1620,7 +1703,10 @@ class ExportService {
}
const msgId = String(msg.localId)
const fileName = `voice_${msgId}.wav`
const safeSession = this.cleanAccountDirName(sessionId)
.replace(/[^a-zA-Z0-9_-]/g, '_')
.slice(0, 48) || 'session'
const fileName = `voice_${safeSession}_${msgId}.wav`
const destPath = path.join(voicesDir, fileName)
// 如果已存在则跳过
@@ -1906,25 +1992,39 @@ class ExportService {
sessionId: string,
cleanedMyWxid: string,
dateRange?: { start: number; end: number } | null,
senderUsernameFilter?: string
senderUsernameFilter?: string,
collectMode: MessageCollectMode = 'full',
targetMediaTypes?: Set<number>
): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
const rows: any[] = []
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
const senderSet = new Set<string>()
let firstTime: number | null = null
let lastTime: number | null = null
const mediaTypeFilter = collectMode === 'media-fast' && targetMediaTypes && targetMediaTypes.size > 0
? targetMediaTypes
: null
// 修复时间范围0 表示不限制,而不是时间戳 0
const beginTime = dateRange?.start || 0
const endTime = dateRange?.end && dateRange.end > 0 ? dateRange.end : 0
const cursor = await wcdbService.openMessageCursor(
sessionId,
500,
true,
beginTime,
endTime
)
const batchSize = (collectMode === 'text-fast' || collectMode === 'media-fast') ? 2000 : 500
const cursor = collectMode === 'media-fast'
? await wcdbService.openMessageCursorLite(
sessionId,
batchSize,
true,
beginTime,
endTime
)
: await wcdbService.openMessageCursor(
sessionId,
batchSize,
true,
beginTime,
endTime
)
if (!cursor.success || !cursor.cursor) {
console.error(`[Export] 打开游标失败: ${cursor.error || '未知错误'}`)
return { rows, memberSet, firstTime, lastTime }
@@ -1950,8 +2050,16 @@ class ExportService {
if (createTime < dateRange.start || createTime > dateRange.end) continue
}
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = parseInt(row.local_type || row.type || '1', 10)
if (mediaTypeFilter && !mediaTypeFilter.has(localType)) {
continue
}
const shouldDecodeContent = collectMode === 'full'
|| (collectMode === 'text-fast' && this.shouldDecodeMessageContentInFastMode(localType))
|| (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter))
const content = shouldDecodeContent
? this.decodeMessageContent(row.message_content, row.compress_content)
: ''
const senderUsername = row.sender_username || ''
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
const isSend = parseInt(isSendRaw, 10) === 1
@@ -1987,7 +2095,7 @@ class ExportService {
}
senderSet.add(actualSender)
// 提取媒体相关字段
// 提取媒体相关字段(轻量模式下跳过)
let imageMd5: string | undefined
let imageDatName: string | undefined
let emojiCdnUrl: string | undefined
@@ -1995,22 +2103,31 @@ class ExportService {
let videoMd5: string | undefined
let chatRecordList: any[] | undefined
if (localType === 3 && content) {
// 图片消息
imageMd5 = this.extractImageMd5(content)
imageDatName = this.extractImageDatName(content)
} else if (localType === 47 && content) {
// 动画表情
emojiCdnUrl = this.extractEmojiUrl(content)
emojiMd5 = this.extractEmojiMd5(content)
} else if (localType === 43 && content) {
// 视频消息
videoMd5 = this.extractVideoMd5(content)
} else if (localType === 49 && content) {
// 检查是否是聊天记录消息type=19
const xmlType = this.extractXmlValue(content, 'type')
if (xmlType === '19') {
chatRecordList = this.parseChatHistory(content)
if (collectMode === 'full' || collectMode === 'media-fast') {
// 优先复用游标返回的字段,缺失时再回退到 XML 解析。
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || undefined
emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || undefined
videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || undefined
if (localType === 3 && content) {
// 图片消息
imageMd5 = imageMd5 || this.extractImageMd5(content)
imageDatName = imageDatName || this.extractImageDatName(content)
} else if (localType === 47 && content) {
// 动画表情
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(content)
emojiMd5 = emojiMd5 || this.extractEmojiMd5(content)
} else if (localType === 43 && content) {
// 视频消息
videoMd5 = videoMd5 || this.extractVideoMd5(content)
} else if (collectMode === 'full' && localType === 49 && content) {
// 检查是否是聊天记录消息type=19
const xmlType = this.extractXmlValue(content, 'type')
if (xmlType === '19') {
chatRecordList = this.parseChatHistory(content)
}
}
}
@@ -2045,6 +2162,10 @@ class ExportService {
}
}
if (collectMode === 'media-fast' && mediaTypeFilter && rows.length > 0) {
await this.backfillMediaFieldsFromMessageDetail(sessionId, rows, mediaTypeFilter)
}
if (senderSet.size > 0) {
const usernames = Array.from(senderSet)
const [nameResult, avatarResult] = await Promise.all([
@@ -2072,6 +2193,60 @@ class ExportService {
return { rows, memberSet, firstTime, lastTime }
}
private async backfillMediaFieldsFromMessageDetail(
sessionId: string,
rows: any[],
targetMediaTypes: Set<number>
): Promise<void> {
const needsBackfill = rows.filter((msg) => {
if (!targetMediaTypes.has(msg.localType)) return false
if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName
if (msg.localType === 47) return !msg.emojiMd5 && !msg.emojiCdnUrl
if (msg.localType === 43) return !msg.videoMd5
return false
})
if (needsBackfill.length === 0) return
const DETAIL_CONCURRENCY = 6
await parallelLimit(needsBackfill, DETAIL_CONCURRENCY, async (msg) => {
const localId = Number(msg.localId || 0)
if (!Number.isFinite(localId) || localId <= 0) return
try {
const detail = await wcdbService.getMessageById(sessionId, localId)
if (!detail.success || !detail.message) return
const row = detail.message as any
const rawMessageContent = row.message_content ?? row.messageContent ?? row.msg_content ?? row.msgContent ?? ''
const rawCompressContent = row.compress_content ?? row.compressContent ?? row.msg_compress_content ?? row.msgCompressContent ?? ''
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
if (msg.localType === 3) {
const imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content)
const imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || this.extractImageDatName(content)
if (imageMd5) msg.imageMd5 = imageMd5
if (imageDatName) msg.imageDatName = imageDatName
return
}
if (msg.localType === 47) {
const emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || this.extractEmojiMd5(content)
const emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || this.extractEmojiUrl(content)
if (emojiMd5) msg.emojiMd5 = emojiMd5
if (emojiCdnUrl) msg.emojiCdnUrl = emojiCdnUrl
return
}
if (msg.localType === 43) {
const videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || this.extractVideoMd5(content)
if (videoMd5) msg.videoMd5 = videoMd5
}
} catch (error) {
// 详情补取失败时保持降级导出(占位符),避免中断整批任务。
}
})
}
// 补齐群成员,避免只导出发言者导致头像缺失
private async mergeGroupMembers(
chatroomId: string,
@@ -2631,7 +2806,15 @@ class ExportService {
phase: 'preparing'
})
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
const collectParams = this.resolveCollectParams(options)
const collected = await this.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes
)
const allMessages = collected.rows
// 如果没有消息,不创建文件
@@ -3026,7 +3209,15 @@ class ExportService {
phase: 'preparing'
})
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
const collectParams = this.resolveCollectParams(options)
const collected = await this.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes
)
// 如果没有消息,不创建文件
if (collected.rows.length === 0) {
@@ -3600,7 +3791,15 @@ class ExportService {
phase: 'preparing'
})
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
const collectParams = this.resolveCollectParams(options)
const collected = await this.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes
)
// 如果没有消息,不创建文件
if (collected.rows.length === 0) {
@@ -4087,7 +4286,15 @@ class ExportService {
phase: 'preparing'
})
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
const collectParams = this.resolveCollectParams(options)
const collected = await this.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes
)
// 如果没有消息,不创建文件
if (collected.rows.length === 0) {
@@ -4367,7 +4574,15 @@ class ExportService {
phase: 'preparing'
})
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
const collectParams = this.resolveCollectParams(options)
const collected = await this.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes
)
if (collected.rows.length === 0) {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
@@ -4709,7 +4924,15 @@ class ExportService {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
const collectParams = this.resolveCollectParams(options)
const collected = await this.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes
)
// 如果没有消息,不创建文件
if (collected.rows.length === 0) {
@@ -5187,7 +5410,13 @@ class ExportService {
for (const sessionId of sessionIds) {
const sessionInfo = await this.getContactInfo(sessionId)
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
const collected = await this.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
'text-fast'
)
const msgs = collected.rows
const voiceMsgs = msgs.filter(m => m.localType === 34)
const mediaMsgs = msgs.filter(m => {
@@ -5264,8 +5493,12 @@ class ExportService {
return { success: false, successCount: 0, failCount: sessionIds.length, error: conn.error }
}
const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(options)
? { ...options, exportVoiceAsText: false }
: options
const exportMediaEnabled = effectiveOptions.exportMedia === true &&
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis)
const rawWriteLayout = this.configService.get('exportWriteLayout')
const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
? rawWriteLayout
@@ -5277,17 +5510,29 @@ class ExportService {
fs.mkdirSync(exportBaseDir, { recursive: true })
}
const sessionLayout = exportMediaEnabled
? (options.sessionLayout ?? 'per-session')
? (effectiveOptions.sessionLayout ?? 'per-session')
: 'shared'
let completedCount = 0
const defaultConcurrency = exportMediaEnabled ? 2 : 4
const rawConcurrency = typeof options.exportConcurrency === 'number'
? Math.floor(options.exportConcurrency)
const rawConcurrency = typeof effectiveOptions.exportConcurrency === 'number'
? Math.floor(effectiveOptions.exportConcurrency)
: defaultConcurrency
const clampedConcurrency = Math.max(1, Math.min(rawConcurrency, 6))
const sessionConcurrency = (exportMediaEnabled && sessionLayout === 'shared')
? 1
: clampedConcurrency
const sessionConcurrency = clampedConcurrency
const emptySessionIds = new Set<string>()
const canFastSkipEmptySessions = this.isUnboundedDateRange(effectiveOptions.dateRange) &&
!String(effectiveOptions.senderUsername || '').trim()
if (canFastSkipEmptySessions && sessionIds.length > 0) {
const countsResult = await wcdbService.getMessageCounts(sessionIds)
if (countsResult.success && countsResult.counts) {
for (const sessionId of sessionIds) {
const count = countsResult.counts[sessionId]
if (typeof count === 'number' && Number.isFinite(count) && count <= 0) {
emptySessionIds.add(sessionId)
}
}
}
}
const queue = [...sessionIds]
let pauseRequested = false
let stopRequested = false
@@ -5295,6 +5540,21 @@ class ExportService {
const runOne = async (sessionId: string) => {
const sessionInfo = await this.getContactInfo(sessionId)
if (emptySessionIds.has(sessionId)) {
failCount++
failedSessionIds.push(sessionId)
completedCount++
onProgress?.({
current: completedCount,
total: sessionIds.length,
currentSession: sessionInfo.displayName,
currentSessionId: sessionId,
phase: 'exporting',
phaseLabel: '该会话没有消息,已跳过'
})
return
}
const sessionProgress = (progress: ExportProgress) => {
onProgress?.({
...progress,
@@ -5314,7 +5574,7 @@ class ExportService {
const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim()
const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session'
const suffix = sanitizeName(options.fileNameSuffix || '')
const suffix = sanitizeName(effectiveOptions.fileNameSuffix || '')
const safeName = suffix ? `${baseName}_${suffix}` : baseName
const fileNameWithPrefix = `${await this.getSessionFilePrefix(sessionId)}${safeName}`
const useSessionFolder = sessionLayout === 'per-session'
@@ -5325,28 +5585,28 @@ class ExportService {
}
let ext = '.json'
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
else if (options.format === 'excel') ext = '.xlsx'
else if (options.format === 'txt') ext = '.txt'
else if (options.format === 'weclone') ext = '.csv'
else if (options.format === 'html') ext = '.html'
if (effectiveOptions.format === 'chatlab-jsonl') ext = '.jsonl'
else if (effectiveOptions.format === 'excel') ext = '.xlsx'
else if (effectiveOptions.format === 'txt') ext = '.txt'
else if (effectiveOptions.format === 'weclone') ext = '.csv'
else if (effectiveOptions.format === 'html') ext = '.html'
const outputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
let result: { success: boolean; error?: string }
if (options.format === 'json' || options.format === 'arkme-json') {
result = await this.exportSessionToDetailedJson(sessionId, outputPath, options, sessionProgress)
} else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') {
result = await this.exportSessionToChatLab(sessionId, outputPath, options, sessionProgress)
} else if (options.format === 'excel') {
result = await this.exportSessionToExcel(sessionId, outputPath, options, sessionProgress)
} else if (options.format === 'txt') {
result = await this.exportSessionToTxt(sessionId, outputPath, options, sessionProgress)
} else if (options.format === 'weclone') {
result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, options, sessionProgress)
} else if (options.format === 'html') {
result = await this.exportSessionToHtml(sessionId, outputPath, options, sessionProgress)
if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') {
result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress)
} else if (effectiveOptions.format === 'chatlab' || effectiveOptions.format === 'chatlab-jsonl') {
result = await this.exportSessionToChatLab(sessionId, outputPath, effectiveOptions, sessionProgress)
} else if (effectiveOptions.format === 'excel') {
result = await this.exportSessionToExcel(sessionId, outputPath, effectiveOptions, sessionProgress)
} else if (effectiveOptions.format === 'txt') {
result = await this.exportSessionToTxt(sessionId, outputPath, effectiveOptions, sessionProgress)
} else if (effectiveOptions.format === 'weclone') {
result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, effectiveOptions, sessionProgress)
} else if (effectiveOptions.format === 'html') {
result = await this.exportSessionToHtml(sessionId, outputPath, effectiveOptions, sessionProgress)
} else {
result = { success: false, error: `不支持的格式: ${options.format}` }
result = { success: false, error: `不支持的格式: ${effectiveOptions.format}` }
}
if (result.success) {

View File

@@ -24,6 +24,11 @@ export interface GroupMember {
isOwner?: boolean
}
export interface GroupMembersPanelEntry extends GroupMember {
isFriend: boolean
messageCount: number
}
export interface GroupMessageRank {
member: GroupMember
messageCount: number
@@ -44,8 +49,26 @@ export interface GroupMediaStats {
total: number
}
interface GroupMemberContactInfo {
remark: string
nickName: string
alias: string
username: string
userName: string
encryptUsername: string
encryptUserName: string
localType: number
}
class GroupAnalyticsService {
private configService: ConfigService
private readonly groupMembersPanelCacheTtlMs = 10 * 60 * 1000
private readonly groupMembersPanelCache = new Map<string, { updatedAt: number; data: GroupMembersPanelEntry[] }>()
private readonly groupMembersPanelInFlight = new Map<
string,
Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }>
>()
private readonly friendExcludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
constructor() {
this.configService = new ConfigService()
@@ -419,6 +442,166 @@ class GroupAnalyticsService {
return Array.from(set)
}
private toNonNegativeInteger(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 0
return Math.max(0, Math.floor(parsed))
}
private pickStringField(row: Record<string, unknown>, keys: string[]): string {
for (const key of keys) {
const value = row[key]
if (value == null) continue
const text = String(value).trim()
if (text) return text
}
return ''
}
private pickIntegerField(row: Record<string, unknown>, keys: string[], fallback: number = 0): number {
for (const key of keys) {
const value = row[key]
if (value == null || value === '') continue
const parsed = Number(value)
if (Number.isFinite(parsed)) return Math.floor(parsed)
}
return fallback
}
private buildGroupMembersPanelCacheKey(chatroomId: string): string {
const dbPath = String(this.configService.get('dbPath') || '').trim()
const wxid = this.cleanAccountDirName(String(this.configService.get('myWxid') || '').trim())
return `${dbPath}::${wxid}::${chatroomId}`
}
private pruneGroupMembersPanelCache(maxEntries: number = 80): void {
if (this.groupMembersPanelCache.size <= maxEntries) return
const entries = Array.from(this.groupMembersPanelCache.entries())
.sort((a, b) => a[1].updatedAt - b[1].updatedAt)
const removeCount = this.groupMembersPanelCache.size - maxEntries
for (let i = 0; i < removeCount; i += 1) {
this.groupMembersPanelCache.delete(entries[i][0])
}
}
private async buildGroupMemberContactLookup(usernames: string[]): Promise<Map<string, GroupMemberContactInfo>> {
const lookup = new Map<string, GroupMemberContactInfo>()
const candidates = this.buildIdCandidates(usernames)
if (candidates.length === 0) return lookup
const batchSize = 200
for (let i = 0; i < candidates.length; i += batchSize) {
const batch = candidates.slice(i, i + batchSize)
if (batch.length === 0) continue
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
const sql = `SELECT * FROM contact WHERE username IN (${inList})`
const result = await wcdbService.execQuery('contact', null, sql)
if (!result.success || !result.rows) continue
for (const row of result.rows as Record<string, unknown>[]) {
const contact: GroupMemberContactInfo = {
remark: this.pickStringField(row, ['remark', 'WCDB_CT_remark']),
nickName: this.pickStringField(row, ['nick_name', 'nickName', 'WCDB_CT_nick_name']),
alias: this.pickStringField(row, ['alias', 'WCDB_CT_alias']),
username: this.pickStringField(row, ['username', 'WCDB_CT_username']),
userName: this.pickStringField(row, ['user_name', 'userName', 'WCDB_CT_user_name']),
encryptUsername: this.pickStringField(row, ['encrypt_username', 'encryptUsername', 'WCDB_CT_encrypt_username']),
encryptUserName: this.pickStringField(row, ['encrypt_user_name', 'encryptUserName', 'WCDB_CT_encrypt_user_name']),
localType: this.pickIntegerField(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
}
const lookupKeys = this.buildIdCandidates([
contact.username,
contact.userName,
contact.encryptUsername,
contact.encryptUserName,
contact.alias
])
for (const key of lookupKeys) {
const normalized = key.toLowerCase()
if (!lookup.has(normalized)) {
lookup.set(normalized, contact)
}
}
}
}
return lookup
}
private resolveContactByCandidates(
lookup: Map<string, GroupMemberContactInfo>,
candidates: Array<string | undefined | null>
): GroupMemberContactInfo | undefined {
const ids = this.buildIdCandidates(candidates)
for (const id of ids) {
const hit = lookup.get(id.toLowerCase())
if (hit) return hit
}
return undefined
}
private async buildGroupMessageCountLookup(chatroomId: string): Promise<Map<string, number>> {
const lookup = new Map<string, number>()
const result = await wcdbService.getGroupStats(chatroomId, 0, 0)
if (!result.success || !result.data) return lookup
const sessionData = result.data?.sessions?.[chatroomId]
if (!sessionData || !sessionData.senders) return lookup
const idMap = result.data.idMap || {}
for (const [senderId, rawCount] of Object.entries(sessionData.senders as Record<string, number>)) {
const username = String(idMap[senderId] || senderId || '').trim()
if (!username) continue
const count = this.toNonNegativeInteger(rawCount)
const keys = this.buildIdCandidates([username])
for (const key of keys) {
const normalized = key.toLowerCase()
const prev = lookup.get(normalized) || 0
if (count > prev) {
lookup.set(normalized, count)
}
}
}
return lookup
}
private resolveMessageCountByCandidates(
lookup: Map<string, number>,
candidates: Array<string | undefined | null>
): number {
let maxCount = 0
const ids = this.buildIdCandidates(candidates)
for (const id of ids) {
const count = lookup.get(id.toLowerCase())
if (typeof count === 'number' && count > maxCount) {
maxCount = count
}
}
return maxCount
}
private isFriendMember(wxid: string, contact?: GroupMemberContactInfo): boolean {
const normalizedWxid = String(wxid || '').trim().toLowerCase()
if (!normalizedWxid) return false
if (normalizedWxid.includes('@chatroom') || normalizedWxid.startsWith('gh_')) return false
if (this.friendExcludeNames.has(normalizedWxid)) return false
if (!contact) return false
return contact.localType === 1
}
private sortGroupMembersPanelEntries(members: GroupMembersPanelEntry[]): GroupMembersPanelEntry[] {
return members.sort((a, b) => {
const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner))
if (ownerDiff !== 0) return ownerDiff
const friendDiff = Number(Boolean(b.isFriend)) - Number(Boolean(a.isFriend))
if (friendDiff !== 0) return friendDiff
if (a.messageCount !== b.messageCount) return b.messageCount - a.messageCount
return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN')
})
}
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
const idCandidates = this.buildIdCandidates(candidates)
if (idCandidates.length === 0) return ''
@@ -606,6 +789,138 @@ class GroupAnalyticsService {
}
}
private async loadGroupMembersPanelDataFresh(
chatroomId: string
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string }> {
const membersResult = await wcdbService.getGroupMembers(chatroomId)
if (!membersResult.success || !membersResult.members) {
return { success: false, error: membersResult.error || '获取群成员失败' }
}
const members = membersResult.members as Array<{
username: string
avatarUrl?: string
originalName?: string
[key: string]: unknown
}>
if (members.length === 0) return { success: true, data: [] }
const usernames = members
.map((member) => String(member.username || '').trim())
.filter(Boolean)
if (usernames.length === 0) return { success: true, data: [] }
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
const contactLookupPromise = this.buildGroupMemberContactLookup(usernames)
const ownerPromise = this.detectGroupOwnerUsername(chatroomId, members)
const messageCountLookupPromise = this.buildGroupMessageCountLookup(chatroomId)
const [displayNames, contactLookup, ownerUsername, messageCountLookup] = await Promise.all([
displayNamesPromise,
contactLookupPromise,
ownerPromise,
messageCountLookupPromise
])
const nicknameCandidates = this.buildIdCandidates([
...members.map((member) => member.username),
...members.map((member) => member.originalName),
...Array.from(contactLookup.values()).map((contact) => contact?.username),
...Array.from(contactLookup.values()).map((contact) => contact?.userName),
...Array.from(contactLookup.values()).map((contact) => contact?.encryptUsername),
...Array.from(contactLookup.values()).map((contact) => contact?.encryptUserName),
...Array.from(contactLookup.values()).map((contact) => contact?.alias)
])
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
const data: GroupMembersPanelEntry[] = members
.map((member) => {
const wxid = String(member.username || '').trim()
if (!wxid) return null
const contact = this.resolveContactByCandidates(contactLookup, [wxid, member.originalName])
const nickname = contact?.nickName || ''
const remark = contact?.remark || ''
const alias = contact?.alias || ''
const normalizedWxid = this.cleanAccountDirName(wxid)
const lookupCandidates = this.buildIdCandidates([
wxid,
member.originalName as string | undefined,
contact?.username,
contact?.userName,
contact?.encryptUsername,
contact?.encryptUserName,
alias
])
if (normalizedWxid === myWxid) {
lookupCandidates.push(myWxid)
}
const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates)
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
return {
username: wxid,
displayName,
nickname,
alias,
remark,
groupNickname,
avatarUrl: member.avatarUrl,
isOwner: Boolean(ownerUsername && ownerUsername === wxid),
isFriend: this.isFriendMember(wxid, contact),
messageCount: this.resolveMessageCountByCandidates(messageCountLookup, lookupCandidates)
}
})
.filter((member): member is GroupMembersPanelEntry => Boolean(member))
return { success: true, data: this.sortGroupMembersPanelEntries(data) }
}
async getGroupMembersPanelData(
chatroomId: string,
forceRefresh: boolean = false
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> {
try {
const normalizedChatroomId = String(chatroomId || '').trim()
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
const cacheKey = this.buildGroupMembersPanelCacheKey(normalizedChatroomId)
const now = Date.now()
const cached = this.groupMembersPanelCache.get(cacheKey)
if (!forceRefresh && cached && now - cached.updatedAt < this.groupMembersPanelCacheTtlMs) {
return { success: true, data: cached.data, fromCache: true, updatedAt: cached.updatedAt }
}
if (!forceRefresh) {
const pending = this.groupMembersPanelInFlight.get(cacheKey)
if (pending) return pending
}
const requestPromise = (async () => {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const fresh = await this.loadGroupMembersPanelDataFresh(normalizedChatroomId)
if (!fresh.success || !fresh.data) {
return { success: false, error: fresh.error || '获取群成员面板数据失败' }
}
const updatedAt = Date.now()
this.groupMembersPanelCache.set(cacheKey, { updatedAt, data: fresh.data })
this.pruneGroupMembersPanelCache()
return { success: true, data: fresh.data, fromCache: false, updatedAt }
})().finally(() => {
this.groupMembersPanelInFlight.delete(cacheKey)
})
this.groupMembersPanelInFlight.set(cacheKey, requestPromise)
return await requestPromise
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> {
try {
const conn = await this.ensureConnected()

View File

@@ -0,0 +1,276 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { ConfigService } from './config'
const CACHE_VERSION = 1
const MAX_SESSION_ENTRIES_PER_SCOPE = 2000
const MAX_SCOPE_ENTRIES = 12
export interface SessionStatsCacheStats {
totalMessages: number
voiceMessages: number
imageMessages: number
videoMessages: number
emojiMessages: number
firstTimestamp?: number
lastTimestamp?: number
privateMutualGroups?: number
groupMemberCount?: number
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
}
export interface SessionStatsCacheEntry {
updatedAt: number
includeRelations: boolean
stats: SessionStatsCacheStats
}
interface SessionStatsScopeMap {
[sessionId: string]: SessionStatsCacheEntry
}
interface SessionStatsCacheStore {
version: number
scopes: Record<string, SessionStatsScopeMap>
}
function toNonNegativeInt(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
return Math.max(0, Math.floor(value))
}
function normalizeStats(raw: unknown): SessionStatsCacheStats | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const totalMessages = toNonNegativeInt(source.totalMessages)
const voiceMessages = toNonNegativeInt(source.voiceMessages)
const imageMessages = toNonNegativeInt(source.imageMessages)
const videoMessages = toNonNegativeInt(source.videoMessages)
const emojiMessages = toNonNegativeInt(source.emojiMessages)
if (
totalMessages === undefined ||
voiceMessages === undefined ||
imageMessages === undefined ||
videoMessages === undefined ||
emojiMessages === undefined
) {
return null
}
const normalized: SessionStatsCacheStats = {
totalMessages,
voiceMessages,
imageMessages,
videoMessages,
emojiMessages
}
const firstTimestamp = toNonNegativeInt(source.firstTimestamp)
if (firstTimestamp !== undefined) normalized.firstTimestamp = firstTimestamp
const lastTimestamp = toNonNegativeInt(source.lastTimestamp)
if (lastTimestamp !== undefined) normalized.lastTimestamp = lastTimestamp
const privateMutualGroups = toNonNegativeInt(source.privateMutualGroups)
if (privateMutualGroups !== undefined) normalized.privateMutualGroups = privateMutualGroups
const groupMemberCount = toNonNegativeInt(source.groupMemberCount)
if (groupMemberCount !== undefined) normalized.groupMemberCount = groupMemberCount
const groupMyMessages = toNonNegativeInt(source.groupMyMessages)
if (groupMyMessages !== undefined) normalized.groupMyMessages = groupMyMessages
const groupActiveSpeakers = toNonNegativeInt(source.groupActiveSpeakers)
if (groupActiveSpeakers !== undefined) normalized.groupActiveSpeakers = groupActiveSpeakers
const groupMutualFriends = toNonNegativeInt(source.groupMutualFriends)
if (groupMutualFriends !== undefined) normalized.groupMutualFriends = groupMutualFriends
return normalized
}
function normalizeEntry(raw: unknown): SessionStatsCacheEntry | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const updatedAt = toNonNegativeInt(source.updatedAt)
const includeRelations = typeof source.includeRelations === 'boolean' ? source.includeRelations : false
const stats = normalizeStats(source.stats)
if (updatedAt === undefined || !stats) {
return null
}
return {
updatedAt,
includeRelations,
stats
}
}
export class SessionStatsCacheService {
private readonly cacheFilePath: string
private store: SessionStatsCacheStore = {
version: CACHE_VERSION,
scopes: {}
}
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: ConfigService.getInstance().getCacheBasePath()
this.cacheFilePath = join(basePath, 'session-stats.json')
this.ensureCacheDir()
this.load()
}
private ensureCacheDir(): void {
const dir = dirname(this.cacheFilePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
private load(): void {
if (!existsSync(this.cacheFilePath)) return
try {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const payload = parsed as Record<string, unknown>
const scopesRaw = payload.scopes
if (!scopesRaw || typeof scopesRaw !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const scopes: Record<string, SessionStatsScopeMap> = {}
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
if (!scopeValue || typeof scopeValue !== 'object') continue
const normalizedScope: SessionStatsScopeMap = {}
for (const [sessionId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
const entry = normalizeEntry(entryRaw)
if (!entry) continue
normalizedScope[sessionId] = entry
}
if (Object.keys(normalizedScope).length > 0) {
scopes[scopeKey] = normalizedScope
}
}
this.store = {
version: CACHE_VERSION,
scopes
}
} catch (error) {
console.error('SessionStatsCacheService: 载入缓存失败', error)
this.store = { version: CACHE_VERSION, scopes: {} }
}
}
get(scopeKey: string, sessionId: string): SessionStatsCacheEntry | undefined {
if (!scopeKey || !sessionId) return undefined
const scope = this.store.scopes[scopeKey]
if (!scope) return undefined
const entry = normalizeEntry(scope[sessionId])
if (!entry) {
delete scope[sessionId]
if (Object.keys(scope).length === 0) {
delete this.store.scopes[scopeKey]
}
this.persist()
return undefined
}
return entry
}
set(scopeKey: string, sessionId: string, entry: SessionStatsCacheEntry): void {
if (!scopeKey || !sessionId) return
const normalized = normalizeEntry(entry)
if (!normalized) return
if (!this.store.scopes[scopeKey]) {
this.store.scopes[scopeKey] = {}
}
this.store.scopes[scopeKey][sessionId] = normalized
this.trimScope(scopeKey)
this.trimScopes()
this.persist()
}
delete(scopeKey: string, sessionId: string): void {
if (!scopeKey || !sessionId) return
const scope = this.store.scopes[scopeKey]
if (!scope) return
if (!(sessionId in scope)) return
delete scope[sessionId]
if (Object.keys(scope).length === 0) {
delete this.store.scopes[scopeKey]
}
this.persist()
}
clearScope(scopeKey: string): void {
if (!scopeKey) return
if (!this.store.scopes[scopeKey]) return
delete this.store.scopes[scopeKey]
this.persist()
}
clearAll(): void {
this.store = { version: CACHE_VERSION, scopes: {} }
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('SessionStatsCacheService: 清理缓存失败', error)
}
}
private trimScope(scopeKey: string): void {
const scope = this.store.scopes[scopeKey]
if (!scope) return
const entries = Object.entries(scope)
if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
const trimmed: SessionStatsScopeMap = {}
for (const [sessionId, entry] of entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) {
trimmed[sessionId] = entry
}
this.store.scopes[scopeKey] = trimmed
}
private trimScopes(): void {
const scopeEntries = Object.entries(this.store.scopes)
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
scopeEntries.sort((a, b) => {
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
return bUpdatedAt - aUpdatedAt
})
const trimmedScopes: Record<string, SessionStatsScopeMap> = {}
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
trimmedScopes[scopeKey] = scopeMap
}
this.store.scopes = trimmedScopes
}
private persist(): void {
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
console.error('SessionStatsCacheService: 保存缓存失败', error)
}
}
}