mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
feat(group-analytics): 新增并极致优化群成员详细分析与图表呈现功能
This commit is contained in:
@@ -5,6 +5,7 @@ import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { chatService } from './chatService'
|
||||
import type { Message } from './chatService'
|
||||
import type { ChatStatistics } from './analyticsService'
|
||||
|
||||
export interface GroupChatInfo {
|
||||
username: string
|
||||
@@ -49,6 +50,13 @@ export interface GroupMediaStats {
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface GroupMemberAnalytics {
|
||||
statistics: ChatStatistics
|
||||
timeDistribution: Record<number, number>
|
||||
commonPhrases?: Array<{ phrase: string; count: number }>
|
||||
commonEmojis?: Array<{ emoji: string; count: number }>
|
||||
}
|
||||
|
||||
export interface GroupMemberMessagesPage {
|
||||
messages: Message[]
|
||||
hasMore: boolean
|
||||
@@ -791,13 +799,33 @@ class GroupAnalyticsService {
|
||||
if (normalizedValue) return normalizedValue
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: fast extract from raw content to avoid full parse
|
||||
const rawContent = String(row.StrContent || row.message_content || row.content || row.msg_content || '').trim()
|
||||
if (rawContent) {
|
||||
const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i.exec(rawContent)
|
||||
if (match && match[1]) {
|
||||
return match[1].trim()
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
private parseSingleMessageRow(row: Record<string, any>): Message | null {
|
||||
try {
|
||||
const mapped = chatService.mapRowsToMessagesForApi([row])
|
||||
return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null
|
||||
if (Array.isArray(mapped) && mapped.length > 0) {
|
||||
const msg = mapped[0]
|
||||
if (!msg.localType) {
|
||||
msg.localType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
|
||||
}
|
||||
if (!msg.createTime) {
|
||||
msg.createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -1438,6 +1466,152 @@ class GroupAnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
async getGroupMemberAnalytics(
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Promise<{ success: boolean; data?: GroupMemberAnalytics; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success) return { success: false, error: conn.error }
|
||||
|
||||
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||
const normalizedMemberUsername = String(memberUsername || '').trim()
|
||||
|
||||
const batchSize = 10000
|
||||
const senderMatchCache = new Map<string, boolean>()
|
||||
const matchesTargetSender = (sender: string | null | undefined): boolean => {
|
||||
const key = String(sender || '').trim().toLowerCase()
|
||||
if (!key) return false
|
||||
const cached = senderMatchCache.get(key)
|
||||
if (typeof cached === 'boolean') return cached
|
||||
const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender)
|
||||
senderMatchCache.set(key, matched)
|
||||
return matched
|
||||
}
|
||||
|
||||
const cursorResult = await this.openMemberMessageCursor(normalizedChatroomId, batchSize, true, startTime || 0, endTime || 0)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '创建游标失败' }
|
||||
}
|
||||
|
||||
const cursor = cursorResult.cursor
|
||||
const stats: ChatStatistics = {
|
||||
totalMessages: 0,
|
||||
textMessages: 0,
|
||||
imageMessages: 0,
|
||||
voiceMessages: 0,
|
||||
videoMessages: 0,
|
||||
emojiMessages: 0,
|
||||
otherMessages: 0,
|
||||
sentMessages: 0, // In group, we only fetch messages of this member, so sentMessages = totalMessages
|
||||
receivedMessages: 0, // No meaning here
|
||||
firstMessageTime: null,
|
||||
lastMessageTime: null,
|
||||
activeDays: 0,
|
||||
messageTypeCounts: {}
|
||||
}
|
||||
|
||||
const hourlyDistribution: Record<number, number> = {}
|
||||
for (let i = 0; i < 24; i++) hourlyDistribution[i] = 0
|
||||
const dailySet = new Set<string>()
|
||||
const textTypes = [1, 244813135921]
|
||||
|
||||
const phraseCounts = new Map<string, number>()
|
||||
const emojiCounts = new Map<string, number>()
|
||||
|
||||
const myWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
|
||||
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>[] : []
|
||||
if (rows.length === 0) break
|
||||
|
||||
for (const row of rows) {
|
||||
let senderFromRow = this.extractRowSenderUsername(row)
|
||||
|
||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
|
||||
const isSend = isSendRaw != null ? parseInt(isSendRaw, 10) === 1 : false
|
||||
|
||||
if (isSend) {
|
||||
senderFromRow = myWxid
|
||||
}
|
||||
|
||||
if (!senderFromRow || !matchesTargetSender(senderFromRow)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const msgType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
|
||||
const createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
|
||||
|
||||
let content = String(row.StrContent || row.message_content || row.content || row.msg_content || '')
|
||||
if (content) {
|
||||
content = content.replace(/^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i, '')
|
||||
}
|
||||
|
||||
stats.totalMessages++
|
||||
if (textTypes.includes(msgType)) {
|
||||
stats.textMessages++
|
||||
if (content) {
|
||||
const text = content.trim()
|
||||
if (text && text.length <= 20) {
|
||||
phraseCounts.set(text, (phraseCounts.get(text) || 0) + 1)
|
||||
}
|
||||
const emojiMatches = text.match(/\[.*?\]/g)
|
||||
if (emojiMatches) {
|
||||
for (const em of emojiMatches) {
|
||||
emojiCounts.set(em, (emojiCounts.get(em) || 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (msgType === 3) stats.imageMessages++
|
||||
else if (msgType === 34) stats.voiceMessages++
|
||||
else if (msgType === 43) stats.videoMessages++
|
||||
else if (msgType === 47) stats.emojiMessages++
|
||||
else stats.otherMessages++
|
||||
|
||||
stats.sentMessages++
|
||||
|
||||
stats.messageTypeCounts[msgType] = (stats.messageTypeCounts[msgType] || 0) + 1
|
||||
|
||||
if (createTime > 0) {
|
||||
if (stats.firstMessageTime === null || createTime < stats.firstMessageTime) stats.firstMessageTime = createTime
|
||||
if (stats.lastMessageTime === null || createTime > stats.lastMessageTime) stats.lastMessageTime = createTime
|
||||
|
||||
const d = new Date(createTime * 1000)
|
||||
const hour = d.getHours()
|
||||
hourlyDistribution[hour]++
|
||||
dailySet.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
|
||||
}
|
||||
}
|
||||
if (!batch.hasMore) break
|
||||
}
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursor)
|
||||
}
|
||||
|
||||
stats.activeDays = dailySet.size
|
||||
|
||||
const commonPhrases = Array.from(phraseCounts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([phrase, count]) => ({ phrase, count }))
|
||||
|
||||
const commonEmojis = Array.from(emojiCounts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([emoji, count]) => ({ emoji, count }))
|
||||
|
||||
return { success: true, data: { statistics: stats, timeDistribution: hourlyDistribution, commonPhrases, commonEmojis } }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async exportGroupMemberMessages(
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
|
||||
Reference in New Issue
Block a user