mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat: update chat service and simplify export contact rows
This commit is contained in:
@@ -2279,7 +2279,55 @@ class ChatService {
|
|||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
private async collectSessionExportStats(
|
private async getSessionMessageTables(sessionId: string): Promise<Array<{ tableName: string; dbPath: string }>> {
|
||||||
|
const cached = this.sessionTablesCache.get(sessionId)
|
||||||
|
if (cached && cached.length > 0) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||||
|
if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const tables = tableStats.tables
|
||||||
|
.map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path }))
|
||||||
|
.filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }>
|
||||||
|
|
||||||
|
if (tables.length > 0) {
|
||||||
|
this.sessionTablesCache.set(sessionId, tables)
|
||||||
|
setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl)
|
||||||
|
}
|
||||||
|
return tables
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMessageTableColumns(dbPath: string, tableName: string): Promise<Set<string>> {
|
||||||
|
const pragmaSql = `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`
|
||||||
|
const result = await wcdbService.execQuery('message', dbPath, pragmaSql)
|
||||||
|
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||||
|
return new Set<string>()
|
||||||
|
}
|
||||||
|
const columns = new Set<string>()
|
||||||
|
for (const row of result.rows as Record<string, any>[]) {
|
||||||
|
const name = String(this.getRowField(row, ['name', 'column_name', 'columnName']) || '').trim().toLowerCase()
|
||||||
|
if (name) columns.add(name)
|
||||||
|
}
|
||||||
|
return columns
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickFirstColumn(columns: Set<string>, candidates: string[]): string | undefined {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const normalized = candidate.toLowerCase()
|
||||||
|
if (columns.has(normalized)) return normalized
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeSqlLiteral(value: string): string {
|
||||||
|
return String(value || '').replace(/'/g, "''")
|
||||||
|
}
|
||||||
|
|
||||||
|
private async collectSessionExportStatsByCursorScan(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
selfIdentitySet: Set<string>
|
selfIdentitySet: Set<string>
|
||||||
): Promise<ExportSessionStats> {
|
): Promise<ExportSessionStats> {
|
||||||
@@ -2367,6 +2415,147 @@ class ChatService {
|
|||||||
return stats
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 tables = await this.getSessionMessageTables(sessionId)
|
||||||
|
if (tables.length === 0) {
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderIdentities = new Set<string>()
|
||||||
|
let aggregatedTableCount = 0
|
||||||
|
const isGroup = sessionId.endsWith('@chatroom')
|
||||||
|
const escapedSelfKeys = Array.from(selfIdentitySet)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((key) => `'${this.escapeSqlLiteral(key.toLowerCase())}'`)
|
||||||
|
|
||||||
|
for (const { tableName, dbPath } of tables) {
|
||||||
|
const columnSet = await this.getMessageTableColumns(dbPath, tableName)
|
||||||
|
if (columnSet.size === 0) continue
|
||||||
|
|
||||||
|
const typeCol = this.pickFirstColumn(columnSet, ['local_type', 'type', 'msg_type', 'msgtype'])
|
||||||
|
const timeCol = this.pickFirstColumn(columnSet, ['create_time', 'createtime', 'msg_create_time', 'time'])
|
||||||
|
const senderCol = this.pickFirstColumn(columnSet, ['sender_username', 'senderusername', 'sender'])
|
||||||
|
const isSendCol = this.pickFirstColumn(columnSet, ['computed_is_send', 'computedissend', 'is_send', 'issend'])
|
||||||
|
|
||||||
|
const selectParts: string[] = [
|
||||||
|
'COUNT(*) AS total_messages',
|
||||||
|
typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 34 THEN 1 ELSE 0 END) AS voice_messages` : '0 AS voice_messages',
|
||||||
|
typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 3 THEN 1 ELSE 0 END) AS image_messages` : '0 AS image_messages',
|
||||||
|
typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 43 THEN 1 ELSE 0 END) AS video_messages` : '0 AS video_messages',
|
||||||
|
typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 47 THEN 1 ELSE 0 END) AS emoji_messages` : '0 AS emoji_messages',
|
||||||
|
timeCol ? `MIN(${this.quoteSqlIdentifier(timeCol)}) AS first_timestamp` : 'NULL AS first_timestamp',
|
||||||
|
timeCol ? `MAX(${this.quoteSqlIdentifier(timeCol)}) AS last_timestamp` : 'NULL AS last_timestamp'
|
||||||
|
]
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
if (senderCol) {
|
||||||
|
const normalizedSender = `LOWER(TRIM(CAST(${this.quoteSqlIdentifier(senderCol)} AS TEXT)))`
|
||||||
|
if (escapedSelfKeys.length > 0 && isSendCol) {
|
||||||
|
selectParts.push(
|
||||||
|
`SUM(CASE WHEN ${normalizedSender} != '' THEN CASE WHEN ${normalizedSender} IN (${escapedSelfKeys.join(', ')}) THEN 1 ELSE 0 END ELSE CASE WHEN ${this.quoteSqlIdentifier(isSendCol)} = 1 THEN 1 ELSE 0 END END) AS group_my_messages`
|
||||||
|
)
|
||||||
|
} else if (escapedSelfKeys.length > 0) {
|
||||||
|
selectParts.push(`SUM(CASE WHEN ${normalizedSender} IN (${escapedSelfKeys.join(', ')}) THEN 1 ELSE 0 END) AS group_my_messages`)
|
||||||
|
} else if (isSendCol) {
|
||||||
|
selectParts.push(`SUM(CASE WHEN ${this.quoteSqlIdentifier(isSendCol)} = 1 THEN 1 ELSE 0 END) AS group_my_messages`)
|
||||||
|
} else {
|
||||||
|
selectParts.push('0 AS group_my_messages')
|
||||||
|
}
|
||||||
|
} else if (isSendCol) {
|
||||||
|
selectParts.push(`SUM(CASE WHEN ${this.quoteSqlIdentifier(isSendCol)} = 1 THEN 1 ELSE 0 END) AS group_my_messages`)
|
||||||
|
} else {
|
||||||
|
selectParts.push('0 AS group_my_messages')
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregateSql = `SELECT ${selectParts.join(', ')} FROM ${this.quoteSqlIdentifier(tableName)}`
|
||||||
|
const aggregateResult = await wcdbService.execQuery('message', dbPath, aggregateSql)
|
||||||
|
if (!aggregateResult.success || !aggregateResult.rows || aggregateResult.rows.length === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregateRow = aggregateResult.rows[0] as Record<string, any>
|
||||||
|
aggregatedTableCount += 1
|
||||||
|
stats.totalMessages += this.getRowInt(aggregateRow, ['total_messages', 'totalMessages'], 0)
|
||||||
|
stats.voiceMessages += this.getRowInt(aggregateRow, ['voice_messages', 'voiceMessages'], 0)
|
||||||
|
stats.imageMessages += this.getRowInt(aggregateRow, ['image_messages', 'imageMessages'], 0)
|
||||||
|
stats.videoMessages += this.getRowInt(aggregateRow, ['video_messages', 'videoMessages'], 0)
|
||||||
|
stats.emojiMessages += this.getRowInt(aggregateRow, ['emoji_messages', 'emojiMessages'], 0)
|
||||||
|
|
||||||
|
const firstTs = this.getRowInt(aggregateRow, ['first_timestamp', 'firstTimestamp'], 0)
|
||||||
|
if (firstTs > 0 && (stats.firstTimestamp === undefined || firstTs < stats.firstTimestamp)) {
|
||||||
|
stats.firstTimestamp = firstTs
|
||||||
|
}
|
||||||
|
const lastTs = this.getRowInt(aggregateRow, ['last_timestamp', 'lastTimestamp'], 0)
|
||||||
|
if (lastTs > 0 && (stats.lastTimestamp === undefined || lastTs > stats.lastTimestamp)) {
|
||||||
|
stats.lastTimestamp = lastTs
|
||||||
|
}
|
||||||
|
stats.groupMyMessages = (stats.groupMyMessages || 0) + this.getRowInt(aggregateRow, ['group_my_messages', 'groupMyMessages'], 0)
|
||||||
|
|
||||||
|
if (senderCol) {
|
||||||
|
const normalizedSender = `LOWER(TRIM(CAST(${this.quoteSqlIdentifier(senderCol)} AS TEXT)))`
|
||||||
|
const distinctSenderSql = `SELECT DISTINCT ${normalizedSender} AS sender_identity FROM ${this.quoteSqlIdentifier(tableName)} WHERE ${normalizedSender} != ''`
|
||||||
|
const senderResult = await wcdbService.execQuery('message', dbPath, distinctSenderSql)
|
||||||
|
if (senderResult.success && senderResult.rows) {
|
||||||
|
for (const row of senderResult.rows as Record<string, any>[]) {
|
||||||
|
const senderIdentity = String(this.getRowField(row, ['sender_identity', 'senderIdentity']) || '').trim()
|
||||||
|
if (!senderIdentity) continue
|
||||||
|
senderIdentities.add(senderIdentity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const aggregateSql = `SELECT ${selectParts.join(', ')} FROM ${this.quoteSqlIdentifier(tableName)}`
|
||||||
|
const aggregateResult = await wcdbService.execQuery('message', dbPath, aggregateSql)
|
||||||
|
if (!aggregateResult.success || !aggregateResult.rows || aggregateResult.rows.length === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const aggregateRow = aggregateResult.rows[0] as Record<string, any>
|
||||||
|
aggregatedTableCount += 1
|
||||||
|
stats.totalMessages += this.getRowInt(aggregateRow, ['total_messages', 'totalMessages'], 0)
|
||||||
|
stats.voiceMessages += this.getRowInt(aggregateRow, ['voice_messages', 'voiceMessages'], 0)
|
||||||
|
stats.imageMessages += this.getRowInt(aggregateRow, ['image_messages', 'imageMessages'], 0)
|
||||||
|
stats.videoMessages += this.getRowInt(aggregateRow, ['video_messages', 'videoMessages'], 0)
|
||||||
|
stats.emojiMessages += this.getRowInt(aggregateRow, ['emoji_messages', 'emojiMessages'], 0)
|
||||||
|
|
||||||
|
const firstTs = this.getRowInt(aggregateRow, ['first_timestamp', 'firstTimestamp'], 0)
|
||||||
|
if (firstTs > 0 && (stats.firstTimestamp === undefined || firstTs < stats.firstTimestamp)) {
|
||||||
|
stats.firstTimestamp = firstTs
|
||||||
|
}
|
||||||
|
const lastTs = this.getRowInt(aggregateRow, ['last_timestamp', 'lastTimestamp'], 0)
|
||||||
|
if (lastTs > 0 && (stats.lastTimestamp === undefined || lastTs > stats.lastTimestamp)) {
|
||||||
|
stats.lastTimestamp = lastTs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aggregatedTableCount === 0) {
|
||||||
|
return this.collectSessionExportStatsByCursorScan(sessionId, selfIdentitySet)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
stats.groupActiveSpeakers = senderIdentities.size
|
||||||
|
if (Number.isFinite(stats.groupMyMessages)) {
|
||||||
|
this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
private async buildGroupRelationStats(
|
private async buildGroupRelationStats(
|
||||||
groupSessionIds: string[],
|
groupSessionIds: string[],
|
||||||
privateSessionIds: string[],
|
privateSessionIds: string[],
|
||||||
@@ -2492,6 +2681,89 @@ class ChatService {
|
|||||||
return stats
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async computeSessionExportStatsBatch(
|
||||||
|
sessionIds: string[],
|
||||||
|
includeRelations: boolean,
|
||||||
|
selfIdentitySet: Set<string>
|
||||||
|
): Promise<Record<string, ExportSessionStats>> {
|
||||||
|
const normalizedSessionIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
(sessionIds || [])
|
||||||
|
.map((id) => String(id || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const result: Record<string, ExportSessionStats> = {}
|
||||||
|
if (normalizedSessionIds.length === 0) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupSessionIds = normalizedSessionIds.filter(sessionId => sessionId.endsWith('@chatroom'))
|
||||||
|
const privateSessionIds = normalizedSessionIds.filter(sessionId => !sessionId.endsWith('@chatroom'))
|
||||||
|
|
||||||
|
let memberCountMap: Record<string, number> = {}
|
||||||
|
if (groupSessionIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const memberCountsResult = await wcdbService.getGroupMemberCounts(groupSessionIds)
|
||||||
|
memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {}
|
||||||
|
} catch {
|
||||||
|
memberCountMap = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let privateMutualGroupMap: Record<string, number> = {}
|
||||||
|
let groupMutualFriendMap: Record<string, number> = {}
|
||||||
|
if (includeRelations) {
|
||||||
|
let relationGroupSessionIds: string[] = []
|
||||||
|
if (privateSessionIds.length > 0) {
|
||||||
|
const allGroups = await this.listAllGroupSessionIds()
|
||||||
|
relationGroupSessionIds = Array.from(new Set([...allGroups, ...groupSessionIds]))
|
||||||
|
} else if (groupSessionIds.length > 0) {
|
||||||
|
relationGroupSessionIds = groupSessionIds
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relationGroupSessionIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const relation = await this.buildGroupRelationStats(
|
||||||
|
relationGroupSessionIds,
|
||||||
|
privateSessionIds,
|
||||||
|
selfIdentitySet
|
||||||
|
)
|
||||||
|
privateMutualGroupMap = relation.privateMutualGroupMap || {}
|
||||||
|
groupMutualFriendMap = relation.groupMutualFriendMap || {}
|
||||||
|
} catch {
|
||||||
|
privateMutualGroupMap = {}
|
||||||
|
groupMutualFriendMap = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.forEachWithConcurrency(normalizedSessionIds, 3, async (sessionId) => {
|
||||||
|
try {
|
||||||
|
const stats = await this.collectSessionExportStats(sessionId, selfIdentitySet)
|
||||||
|
if (sessionId.endsWith('@chatroom')) {
|
||||||
|
stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number'
|
||||||
|
? Math.max(0, Math.floor(memberCountMap[sessionId]))
|
||||||
|
: 0
|
||||||
|
if (includeRelations) {
|
||||||
|
stats.groupMutualFriends = typeof groupMutualFriendMap[sessionId] === 'number'
|
||||||
|
? Math.max(0, Math.floor(groupMutualFriendMap[sessionId]))
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
} else if (includeRelations) {
|
||||||
|
stats.privateMutualGroups = typeof privateMutualGroupMap[sessionId] === 'number'
|
||||||
|
? Math.max(0, Math.floor(privateMutualGroupMap[sessionId]))
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
result[sessionId] = stats
|
||||||
|
} catch {
|
||||||
|
result[sessionId] = this.buildEmptyExportSessionStats(sessionId, includeRelations)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
private async getOrComputeSessionExportStats(
|
private async getOrComputeSessionExportStats(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
includeRelations: boolean,
|
includeRelations: boolean,
|
||||||
@@ -4966,6 +5238,31 @@ class ChatService {
|
|||||||
if (pendingSessionIds.length > 0) {
|
if (pendingSessionIds.length > 0) {
|
||||||
const myWxid = this.configService.get('myWxid') || ''
|
const myWxid = this.configService.get('myWxid') || ''
|
||||||
const selfIdentitySet = new Set<string>(this.buildIdentityKeys(myWxid))
|
const selfIdentitySet = new Set<string>(this.buildIdentityKeys(myWxid))
|
||||||
|
let usedBatchedCompute = false
|
||||||
|
try {
|
||||||
|
const batchedStatsMap = await this.computeSessionExportStatsBatch(
|
||||||
|
pendingSessionIds,
|
||||||
|
includeRelations,
|
||||||
|
selfIdentitySet
|
||||||
|
)
|
||||||
|
for (const sessionId of pendingSessionIds) {
|
||||||
|
const stats = batchedStatsMap[sessionId]
|
||||||
|
if (!stats) continue
|
||||||
|
resultMap[sessionId] = stats
|
||||||
|
const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations)
|
||||||
|
cacheMeta[sessionId] = {
|
||||||
|
updatedAt,
|
||||||
|
stale: false,
|
||||||
|
includeRelations,
|
||||||
|
source: 'fresh'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usedBatchedCompute = true
|
||||||
|
} catch {
|
||||||
|
usedBatchedCompute = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!usedBatchedCompute) {
|
||||||
await this.forEachWithConcurrency(pendingSessionIds, 3, async (sessionId) => {
|
await this.forEachWithConcurrency(pendingSessionIds, 3, async (sessionId) => {
|
||||||
try {
|
try {
|
||||||
const stats = await this.getOrComputeSessionExportStats(sessionId, includeRelations, selfIdentitySet)
|
const stats = await this.getOrComputeSessionExportStats(sessionId, includeRelations, selfIdentitySet)
|
||||||
@@ -4982,6 +5279,7 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const response: {
|
const response: {
|
||||||
success: boolean
|
success: boolean
|
||||||
|
|||||||
@@ -457,14 +457,6 @@ const matchesContactTab = (contact: ContactInfo, tab: ConversationTab): boolean
|
|||||||
return contact.type === 'former_friend'
|
return contact.type === 'former_friend'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getContactTypeName = (type: ContactInfo['type']): string => {
|
|
||||||
if (type === 'friend') return '好友'
|
|
||||||
if (type === 'group') return '群聊'
|
|
||||||
if (type === 'official') return '公众号'
|
|
||||||
if (type === 'former_friend') return '曾经的好友'
|
|
||||||
return '其他'
|
|
||||||
}
|
|
||||||
|
|
||||||
const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
const createExportDiagTraceId = (): string => `export-card-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
const createExportDiagTraceId = (): string => `export-card-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
const CONTACT_ENRICH_TIMEOUT_MS = 7000
|
const CONTACT_ENRICH_TIMEOUT_MS = 7000
|
||||||
@@ -3972,9 +3964,6 @@ function ExportPage() {
|
|||||||
<div className="contact-name">{contact.displayName}</div>
|
<div className="contact-name">{contact.displayName}</div>
|
||||||
<div className="contact-remark">{contact.username}</div>
|
<div className="contact-remark">{contact.username}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`contact-type ${contact.type}`}>
|
|
||||||
<span>{getContactTypeName(contact.type)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="row-message-count">
|
<div className="row-message-count">
|
||||||
<span className="row-message-count-label">总消息</span>
|
<span className="row-message-count-label">总消息</span>
|
||||||
<strong className={`row-message-count-value ${typeof displayedMessageCount === 'number' ? '' : 'muted'}`}>
|
<strong className={`row-message-count-value ${typeof displayedMessageCount === 'number' ? '' : 'muted'}`}>
|
||||||
|
|||||||
Reference in New Issue
Block a user