From ff33242887c565da8adcd9ea82d61da49a0afc1e Mon Sep 17 00:00:00 2001
From: cc <98377878+hicccc77@users.noreply.github.com>
Date: Sat, 11 Apr 2026 17:14:34 +0800
Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20#706?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 16 -
electron/main.ts | 15 +
electron/preload.ts | 18 +
electron/services/chatService.ts | 2293 ++++++++++++++++-
electron/services/wcdbCore.ts | 106 +-
electron/services/wcdbService.ts | 13 +
electron/wcdbWorker.ts | 3 +
resources/wcdb/linux/x64/libwcdb_api.so | Bin 11602448 -> 11700184 bytes
.../wcdb/macos/universal/libwcdb_api.dylib | Bin 1289664 -> 1363232 bytes
resources/wcdb/win32/arm64/wcdb_api.dll | Bin 1120256 -> 1184768 bytes
resources/wcdb/win32/x64/wcdb_api.dll | Bin 1063936 -> 1120256 bytes
src/App.tsx | 2 +
src/components/DateRangePicker.scss | 9 +-
src/components/DateRangePicker.tsx | 52 +-
src/components/Sidebar.tsx | 12 +-
src/pages/ChatPage.tsx | 88 +-
src/pages/MyFootprintPage.scss | 789 ++++++
src/pages/MyFootprintPage.tsx | 931 +++++++
src/pages/WelcomePage.tsx | 3 +-
src/types/electron.d.ts | 89 +
20 files changed, 4377 insertions(+), 62 deletions(-)
create mode 100644 src/pages/MyFootprintPage.scss
create mode 100644 src/pages/MyFootprintPage.tsx
diff --git a/README.md b/README.md
index 6a97826..bec1328 100644
--- a/README.md
+++ b/README.md
@@ -96,22 +96,6 @@ npm install
npm run dev
```
-## 构建状态
-
-用于开发者排查发布链路,普通用户可忽略:
-
-
-
-
-
-
-
-
-
-
-
-
-
## 致谢
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
diff --git a/electron/main.ts b/electron/main.ts
index 48d18de..dca37dc 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -2363,6 +2363,21 @@ function registerIpcHandlers() {
return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp)
})
+ ipcMain.handle('chat:getMyFootprintStats', async (_, beginTimestamp: number, endTimestamp: number, options?: {
+ myWxid?: string
+ privateSessionIds?: string[]
+ groupSessionIds?: string[]
+ mentionLimit?: number
+ privateLimit?: number
+ mentionMode?: 'text_at_me' | string
+ }) => {
+ return chatService.getMyFootprintStats(beginTimestamp, endTimestamp, options)
+ })
+
+ ipcMain.handle('chat:exportMyFootprint', async (_, beginTimestamp: number, endTimestamp: number, format: 'csv' | 'json', filePath: string) => {
+ return chatService.exportMyFootprint(beginTimestamp, endTimestamp, format, filePath)
+ })
+
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
})
diff --git a/electron/preload.ts b/electron/preload.ts
index 48564f1..9e45516 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -258,6 +258,24 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
ipcRenderer.invoke('chat:searchMessages', keyword, sessionId, limit, offset, beginTimestamp, endTimestamp),
+ getMyFootprintStats: (
+ beginTimestamp: number,
+ endTimestamp: number,
+ options?: {
+ myWxid?: string
+ privateSessionIds?: string[]
+ groupSessionIds?: string[]
+ mentionLimit?: number
+ privateLimit?: number
+ mentionMode?: 'text_at_me' | string
+ }
+ ) => ipcRenderer.invoke('chat:getMyFootprintStats', beginTimestamp, endTimestamp, options),
+ exportMyFootprint: (
+ beginTimestamp: number,
+ endTimestamp: number,
+ format: 'csv' | 'json',
+ filePath: string
+ ) => ipcRenderer.invoke('chat:exportMyFootprint', beginTimestamp, endTimestamp, format, filePath),
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
ipcRenderer.on('wcdb-change', callback)
return () => ipcRenderer.removeListener('wcdb-change', callback)
diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts
index 24da2ca..90f2555 100644
--- a/electron/services/chatService.ts
+++ b/electron/services/chatService.ts
@@ -232,6 +232,99 @@ interface SessionDetailExtra {
type SessionDetail = SessionDetailFast & SessionDetailExtra
+interface MyFootprintSummary {
+ private_inbound_people: number
+ private_replied_people: number
+ private_outbound_people: number
+ private_reply_rate: number
+ mention_count: number
+ mention_group_count: number
+}
+
+interface MyFootprintPrivateSession {
+ session_id: string
+ incoming_count: number
+ outgoing_count: number
+ replied: boolean
+ first_incoming_ts: number
+ first_reply_ts: number
+ latest_ts: number
+ anchor_local_id: number
+ anchor_create_time: number
+ displayName?: string
+ avatarUrl?: string
+}
+
+interface MyFootprintPrivateSegment {
+ session_id: string
+ segment_index: number
+ start_ts: number
+ end_ts: number
+ duration_sec: number
+ incoming_count: number
+ outgoing_count: number
+ message_count: number
+ replied: boolean
+ first_incoming_ts: number
+ first_reply_ts: number
+ latest_ts: number
+ anchor_local_id: number
+ anchor_create_time: number
+ displayName?: string
+ avatarUrl?: string
+}
+
+interface MyFootprintMentionItem {
+ session_id: string
+ local_id: number
+ create_time: number
+ sender_username: string
+ message_content: string
+ source: string
+ sessionDisplayName?: string
+ senderDisplayName?: string
+ senderAvatarUrl?: string
+}
+
+interface MyFootprintMentionGroup {
+ session_id: string
+ count: number
+ latest_ts: number
+ displayName?: string
+ avatarUrl?: string
+}
+
+interface MyFootprintDiagnostics {
+ truncated: boolean
+ scanned_dbs: number
+ elapsed_ms: number
+ mention_truncated?: boolean
+ private_truncated?: boolean
+ native_ms?: number
+ source_filter_ms?: number
+ fallback_ms?: number
+ enrich_ms?: number
+ pipeline_ms?: number
+ fallback_used?: boolean
+ private_limit_effective?: number
+ mention_candidate_limit?: number
+ native_mention_candidates?: number
+ source_filtered_mentions?: number
+ private_session_count?: number
+ group_session_count?: number
+ native_passes?: number
+ native_group_chunks?: number
+}
+
+interface MyFootprintData {
+ summary: MyFootprintSummary
+ private_sessions: MyFootprintPrivateSession[]
+ private_segments: MyFootprintPrivateSegment[]
+ mentions: MyFootprintMentionItem[]
+ mention_groups: MyFootprintMentionGroup[]
+ diagnostics: MyFootprintDiagnostics
+}
+
// 表情包缓存
const emojiCache: Map = new Map()
const emojiDownloading: Map> = new Map()
@@ -5719,12 +5812,13 @@ class ChatService {
// 如果是字符串
if (typeof raw === 'string') {
if (raw.length === 0) return ''
+ const compactRaw = this.compactEncodedPayload(raw)
// 检查是否是 hex 编码
// 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码
// 短字符串(如 "123456" 等纯数字)容易被误判为 hex
- if (raw.length > 16 && this.looksLikeHex(raw)) {
- const bytes = Buffer.from(raw, 'hex')
+ if (compactRaw.length > 16 && this.looksLikeHex(compactRaw)) {
+ const bytes = Buffer.from(compactRaw, 'hex')
if (bytes.length > 0) {
const result = this.decodeBinaryContent(bytes, raw)
//
@@ -5735,9 +5829,9 @@ class ChatService {
// 检查是否是 base64 编码
// 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码
// 短字符串(如 "test", "home" 等)容易被误判为 base64
- if (raw.length > 16 && this.looksLikeBase64(raw)) {
+ if (compactRaw.length > 16 && this.looksLikeBase64(compactRaw)) {
try {
- const bytes = Buffer.from(raw, 'base64')
+ const bytes = Buffer.from(compactRaw, 'base64')
return this.decodeBinaryContent(bytes, raw)
} catch { }
}
@@ -5796,16 +5890,22 @@ class ChatService {
* 检查是否像 hex 编码
*/
private looksLikeHex(s: string): boolean {
- if (s.length % 2 !== 0) return false
- return /^[0-9a-fA-F]+$/.test(s)
+ const compact = this.compactEncodedPayload(s)
+ if (compact.length % 2 !== 0) return false
+ return /^[0-9a-fA-F]+$/.test(compact)
}
/**
* 检查是否像 base64 编码
*/
private looksLikeBase64(s: string): boolean {
- if (s.length % 4 !== 0) return false
- return /^[A-Za-z0-9+/=]+$/.test(s)
+ const compact = this.compactEncodedPayload(s)
+ if (compact.length % 4 !== 0) return false
+ return /^[A-Za-z0-9+/=]+$/.test(compact)
+ }
+
+ private compactEncodedPayload(raw: string): string {
+ return String(raw || '').replace(/\s+/g, '').trim()
}
private shouldKeepSession(username: string): boolean {
@@ -7828,6 +7928,552 @@ class ChatService {
}
}
+ async getMyFootprintStats(
+ beginTimestamp: number,
+ endTimestamp: number,
+ options?: {
+ myWxid?: string
+ privateSessionIds?: string[]
+ groupSessionIds?: string[]
+ mentionLimit?: number
+ privateLimit?: number
+ mentionMode?: 'text_at_me' | string
+ }
+ ): Promise<{ success: boolean; data?: MyFootprintData; error?: string }> {
+ try {
+ const connectResult = await this.ensureConnected()
+ if (!connectResult.success) {
+ return { success: false, error: connectResult.error || '数据库未连接' }
+ }
+
+ const begin = this.normalizeTimestampSeconds(beginTimestamp)
+ const end = this.normalizeTimestampSeconds(endTimestamp)
+ const normalizedEnd = begin > 0 && end > 0 && end < begin ? begin : end
+ const mentionLimitRaw = Number(options?.mentionLimit ?? 0)
+ const privateLimitRaw = Number(options?.privateLimit ?? 0)
+ const mentionLimit = Number.isFinite(mentionLimitRaw) && mentionLimitRaw >= 0
+ ? Math.floor(mentionLimitRaw)
+ : 0
+ const privateLimit = Number.isFinite(privateLimitRaw) && privateLimitRaw >= 0
+ ? Math.floor(privateLimitRaw)
+ : 0
+
+ let myWxid = String(options?.myWxid || '').trim()
+ if (!myWxid) {
+ myWxid = String(this.configService.get('myWxid') || '').trim()
+ }
+ if (!myWxid) {
+ return { success: false, error: '未识别当前账号 wxid' }
+ }
+
+ let privateSessionIds = Array.isArray(options?.privateSessionIds)
+ ? options!.privateSessionIds!.map((value) => String(value || '').trim()).filter(Boolean)
+ : []
+ let groupSessionIds = Array.isArray(options?.groupSessionIds)
+ ? options!.groupSessionIds!.map((value) => String(value || '').trim()).filter(Boolean)
+ : []
+ const hasExplicitGroupScope = Array.isArray(options?.groupSessionIds)
+ && options!.groupSessionIds!.some((value) => String(value || '').trim().length > 0)
+
+ if (privateSessionIds.length === 0 && groupSessionIds.length === 0) {
+ const sessionsResult = await wcdbService.getSessions()
+ if (!sessionsResult.success || !Array.isArray(sessionsResult.sessions)) {
+ return { success: false, error: sessionsResult.error || '读取会话列表失败' }
+ }
+ for (const session of sessionsResult.sessions as Array>) {
+ const sessionId = String(session.username || session.user_name || '').trim()
+ if (!sessionId) continue
+ const sessionLastTs = this.normalizeTimestampSeconds(
+ Number(session.lastTimestamp || session.sortTimestamp || 0)
+ )
+ if (sessionId.endsWith('@chatroom')) {
+ groupSessionIds.push(sessionId)
+ } else {
+ if (!this.shouldKeepSession(sessionId)) continue
+ if (begin > 0 && sessionLastTs > 0 && sessionLastTs < begin) continue
+ privateSessionIds.push(sessionId)
+ }
+ }
+ }
+
+ privateSessionIds = Array.from(new Set(
+ privateSessionIds
+ .map((value) => String(value || '').trim())
+ .filter((value) => value && !value.endsWith('@chatroom') && this.shouldKeepSession(value))
+ ))
+ groupSessionIds = Array.from(new Set(
+ groupSessionIds
+ .map((value) => String(value || '').trim())
+ .filter((value) => value && value.endsWith('@chatroom'))
+ ))
+ if (!hasExplicitGroupScope) {
+ groupSessionIds = await this.resolveMyFootprintGroupSessionIds(groupSessionIds, begin, normalizedEnd)
+ }
+
+ privateSessionIds = await this.filterMyFootprintPrivateSessions(privateSessionIds)
+
+ let data: MyFootprintData | null = null
+ const effectivePrivateLimit = privateLimit
+ // native 候选上限:0 表示不截断候选,确保前端 source 二次过滤有完整输入
+ const nativeMentionCandidateLimit = 0
+ let nativePasses = 0
+ const candidateLimitUsed = nativeMentionCandidateLimit
+ let nativeGroupChunks = 0
+
+ const runNativePass = async (passOptions: {
+ label: string
+ passPrivateSessionIds: string[]
+ passGroupSessionIds: string[]
+ candidateLimit: number
+ passPrivateLimit: number
+ }): Promise => {
+ nativePasses += 1
+ const nativeResult = await wcdbService.getMyFootprintStats({
+ beginTimestamp: begin,
+ endTimestamp: normalizedEnd,
+ myWxid,
+ privateSessionIds: passOptions.passPrivateSessionIds,
+ groupSessionIds: passOptions.passGroupSessionIds,
+ mentionLimit: passOptions.candidateLimit,
+ privateLimit: passOptions.passPrivateLimit,
+ mentionMode: options?.mentionMode || 'text_at_me'
+ })
+ if (!nativeResult.success || !nativeResult.data) {
+ throw new Error(nativeResult.error || '获取我的足迹统计失败')
+ }
+ const normalized = this.normalizeMyFootprintData(nativeResult.data)
+ return normalized
+ }
+
+ const runGroupPasses = async (targetGroupSessionIds: string[]): Promise<{ raw: MyFootprintData | null; chunks: number }> => {
+ if (!Array.isArray(targetGroupSessionIds) || targetGroupSessionIds.length === 0) {
+ return { raw: null, chunks: 0 }
+ }
+ const singleGroupThresholdRaw = Number(process.env.WEFLOW_MY_FOOTPRINT_SINGLE_GROUP_THRESHOLD || 40)
+ const singleGroupThreshold = Number.isFinite(singleGroupThresholdRaw) && singleGroupThresholdRaw >= 1
+ ? Math.floor(singleGroupThresholdRaw)
+ : 40
+
+ let aggregated: MyFootprintData | null = null
+ let chunks = 0
+ if (targetGroupSessionIds.length <= singleGroupThreshold) {
+ chunks = targetGroupSessionIds.length
+ for (const sessionId of targetGroupSessionIds) {
+ const chunkRaw = await runNativePass({
+ label: `group-single:${sessionId}`,
+ passPrivateSessionIds: [],
+ passGroupSessionIds: [sessionId],
+ candidateLimit: candidateLimitUsed,
+ passPrivateLimit: 0
+ })
+ aggregated = aggregated
+ ? this.mergeMyFootprintMentionResult(aggregated, chunkRaw)
+ : chunkRaw
+ }
+ } else {
+ const groupChunks = splitGroupSessionsForNative(targetGroupSessionIds)
+ chunks = groupChunks.length
+ for (const chunk of groupChunks) {
+ const chunkRaw = await runNativePass({
+ label: `group-chunk:${chunk[0] || ''}..(${chunk.length})`,
+ passPrivateSessionIds: [],
+ passGroupSessionIds: chunk,
+ candidateLimit: candidateLimitUsed,
+ passPrivateLimit: 0
+ })
+ aggregated = aggregated
+ ? this.mergeMyFootprintMentionResult(aggregated, chunkRaw)
+ : chunkRaw
+ }
+ }
+ return { raw: aggregated, chunks }
+ }
+
+ const splitGroupSessionsForNative = (sessionIds: string[]): string[][] => {
+ const normalized = Array.from(new Set(
+ (sessionIds || [])
+ .map((value) => String(value || '').trim())
+ .filter((value) => value.endsWith('@chatroom'))
+ ))
+ if (normalized.length === 0) return []
+
+ // 规避 native options_json 可能存在的固定缓冲上限:按 payload 字节安全分块。
+ // 这不是降级或裁剪范围,而是完整遍历所有群并做结果合并。
+ const maxBytesRaw = Number(process.env.WEFLOW_MY_FOOTPRINT_GROUP_OPTIONS_MAX_BYTES || 900)
+ const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw >= 512
+ ? Math.floor(maxBytesRaw)
+ : 900
+ const estimateBytes = (groups: string[]): number => Buffer.byteLength(JSON.stringify({
+ begin,
+ end: normalizedEnd,
+ my_wxid: myWxid,
+ private_session_ids: [],
+ group_session_ids: groups,
+ mention_limit: candidateLimitUsed,
+ private_limit: 0,
+ mention_mode: options?.mentionMode || 'text_at_me'
+ }), 'utf8')
+
+ const chunks: string[][] = []
+ let current: string[] = []
+ for (const sessionId of normalized) {
+ if (current.length === 0) {
+ current.push(sessionId)
+ continue
+ }
+ const next = [...current, sessionId]
+ if (estimateBytes(next) > maxBytes) {
+ chunks.push(current)
+ current = [sessionId]
+ } else {
+ current = next
+ }
+ }
+ if (current.length > 0) chunks.push(current)
+ return chunks
+ }
+
+ let privateNativeRaw: MyFootprintData | null = null
+ let mentionNativeRaw: MyFootprintData | null = null
+
+ if (privateSessionIds.length > 0) {
+ privateNativeRaw = await runNativePass({
+ label: 'private',
+ passPrivateSessionIds: privateSessionIds,
+ passGroupSessionIds: [],
+ candidateLimit: 0,
+ passPrivateLimit: effectivePrivateLimit
+ })
+ }
+
+ if (groupSessionIds.length > 0) {
+ const firstPass = await runGroupPasses(groupSessionIds)
+ mentionNativeRaw = firstPass.raw
+ nativeGroupChunks = firstPass.chunks
+
+ if ((mentionNativeRaw?.mentions.length || 0) === 0) {
+ const probeIndexes = Array.from(new Set([
+ 0,
+ Math.floor(groupSessionIds.length / 2),
+ groupSessionIds.length - 1
+ ])).filter((index) => index >= 0 && index < groupSessionIds.length)
+ let probeHit = false
+ for (const index of probeIndexes) {
+ const sessionId = groupSessionIds[index]
+ const probeRaw = await runNativePass({
+ label: `group-probe:${sessionId}`,
+ passPrivateSessionIds: [],
+ passGroupSessionIds: [sessionId],
+ candidateLimit: candidateLimitUsed,
+ passPrivateLimit: 0
+ })
+ if (probeRaw.mentions.length > 0 || probeRaw.summary.mention_count > 0) {
+ probeHit = true
+ break
+ }
+ }
+
+ if (probeHit) {
+ await wcdbService.getSessions().catch(() => ({ success: false }))
+ const retryPass = await runGroupPasses(groupSessionIds)
+ mentionNativeRaw = retryPass.raw
+ nativeGroupChunks = retryPass.chunks
+ }
+ }
+ }
+
+ let nativeRaw = privateNativeRaw || mentionNativeRaw || this.normalizeMyFootprintData({})
+ if (privateNativeRaw && mentionNativeRaw) {
+ nativeRaw = this.mergeMyFootprintMentionResult(privateNativeRaw, mentionNativeRaw)
+ }
+
+ data = this.filterMyFootprintMentionsBySource(nativeRaw, myWxid, mentionLimit)
+
+ if (privateSessionIds.length > 0 && data.private_segments.length === 0) {
+ const privateSegments = await this.rebuildMyFootprintPrivateSegments({
+ begin,
+ end: normalizedEnd,
+ myWxid,
+ privateSessionIds
+ })
+ if (privateSegments.length > 0) {
+ data = {
+ ...data,
+ private_segments: privateSegments
+ }
+ }
+ }
+
+ if (data.mentions.length === 0) {
+ if (this.shouldRunMyFootprintHeavyDebug()) {
+ const privatePassRawMentions = privateNativeRaw?.mentions.length || 0
+ const mentionPassRawMentions = mentionNativeRaw?.mentions.length || 0
+ console.warn(
+ `[MyFootprint][diag] zero filtered mentions begin=${begin} end=${normalizedEnd} groups=${groupSessionIds.length} raw=${nativeRaw.mentions.length} splitRaw(private=${privatePassRawMentions},group=${mentionPassRawMentions}) passes=${nativePasses} groupChunks=${nativeGroupChunks}`
+ )
+ await this.printMyFootprintNativeLogs('zero_filtered_mentions')
+ await this.logMyFootprintNativeQuickProbe({
+ begin,
+ end: normalizedEnd,
+ myWxid,
+ groupSessionIds,
+ mentionMode: options?.mentionMode || 'text_at_me'
+ })
+ await this.logMyFootprintZeroMentionDebug({
+ begin,
+ end: normalizedEnd,
+ myWxid,
+ groupSessionIds,
+ nativeData: nativeRaw
+ })
+ }
+ }
+
+ const enriched = await this.enrichMyFootprintData(data)
+ return { success: true, data: enriched }
+ } catch (error) {
+ console.error('[ChatService] 获取我的足迹统计失败:', error)
+ return { success: false, error: String(error) }
+ }
+ }
+
+ private async logMyFootprintNativeQuickProbe(params: {
+ begin: number
+ end: number
+ myWxid: string
+ groupSessionIds: string[]
+ mentionMode: string
+ }): Promise {
+ try {
+ const groups = Array.from(new Set(
+ (params.groupSessionIds || [])
+ .map((value) => String(value || '').trim())
+ .filter((value) => value.endsWith('@chatroom'))
+ ))
+ if (groups.length === 0) {
+ console.warn('[MyFootprint][native-quick] skipped: no groups')
+ return
+ }
+ const indices = Array.from(new Set([
+ 0,
+ Math.floor(groups.length / 2),
+ groups.length - 1
+ ])).filter((index) => index >= 0 && index < groups.length)
+
+ for (const index of indices) {
+ const sessionId = groups[index]
+ const result = await wcdbService.getMyFootprintStats({
+ beginTimestamp: params.begin,
+ endTimestamp: params.end,
+ myWxid: params.myWxid,
+ privateSessionIds: [],
+ groupSessionIds: [sessionId],
+ mentionLimit: 0,
+ privateLimit: 0,
+ mentionMode: params.mentionMode
+ })
+ if (!result.success || !result.data) {
+ console.warn(
+ `[MyFootprint][native-quick][${index + 1}/${groups.length}][${sessionId}] fail err=${result.error || 'unknown'}`
+ )
+ continue
+ }
+ const raw = this.normalizeMyFootprintData(result.data)
+ console.warn(
+ `[MyFootprint][native-quick][${index + 1}/${groups.length}][${sessionId}] mentions=${raw.mentions.length} mentionGroups=${raw.mention_groups.length} summaryMention=${raw.summary.mention_count} diagScanned=${raw.diagnostics.scanned_dbs} diagElapsed=${raw.diagnostics.elapsed_ms}`
+ )
+ }
+ } catch (error) {
+ console.warn('[MyFootprint][native-quick] exception:', error)
+ }
+ }
+
+ private async rebuildMyFootprintPrivateSegments(params: {
+ begin: number
+ end: number
+ myWxid: string
+ privateSessionIds: string[]
+ }): Promise {
+ const sessionGapSeconds = 10 * 60
+ const segments: MyFootprintPrivateSegment[] = []
+
+ type WorkingSegment = {
+ segment_index: number
+ start_ts: number
+ end_ts: number
+ incoming_count: number
+ outgoing_count: number
+ first_incoming_ts: number
+ first_reply_ts: number
+ anchor_local_id: number
+ anchor_create_time: number
+ latest_local_id: number
+ latest_create_time: number
+ }
+
+ for (const sessionId of params.privateSessionIds) {
+ const cursorResult = await wcdbService.openMessageCursorLite(
+ sessionId,
+ 360,
+ true,
+ params.begin,
+ params.end
+ )
+ if (!cursorResult.success || !cursorResult.cursor) continue
+
+ let segmentCursor = 0
+ let active: WorkingSegment | null = null
+ let lastMessageTs = 0
+ const commit = () => {
+ if (!active) return
+ const startTs = active.start_ts > 0 ? active.start_ts : active.anchor_create_time
+ const endTs = active.end_ts > 0 ? active.end_ts : startTs
+ const incoming = Math.max(0, active.incoming_count)
+ const outgoing = Math.max(0, active.outgoing_count)
+ const messageCount = incoming + outgoing
+ if (startTs > 0 && messageCount > 0) {
+ segments.push({
+ session_id: sessionId,
+ segment_index: active.segment_index,
+ start_ts: startTs,
+ end_ts: endTs,
+ duration_sec: Math.max(0, endTs - startTs),
+ incoming_count: incoming,
+ outgoing_count: outgoing,
+ message_count: messageCount,
+ replied: incoming > 0 && outgoing > 0,
+ first_incoming_ts: active.first_incoming_ts,
+ first_reply_ts: active.first_reply_ts,
+ latest_ts: endTs,
+ anchor_local_id: active.anchor_local_id,
+ anchor_create_time: startTs
+ })
+ }
+ active = null
+ }
+
+ let hasMore = true
+ try {
+ while (hasMore) {
+ const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor)
+ if (!batchResult.success || !Array.isArray(batchResult.rows)) break
+ hasMore = Boolean(batchResult.hasMore)
+
+ for (const row of batchResult.rows as Array>) {
+ const createTime = this.toSafeInt(row.create_time, 0)
+ const localId = this.toSafeInt(row.local_id, 0)
+ const isSend = this.resolveFootprintRowIsSend(row, params.myWxid)
+
+ if (createTime > 0) {
+ const needNew = !active || (lastMessageTs > 0 && createTime - lastMessageTs > sessionGapSeconds)
+ if (needNew) {
+ commit()
+ segmentCursor += 1
+ active = {
+ segment_index: segmentCursor,
+ start_ts: createTime,
+ end_ts: createTime,
+ incoming_count: 0,
+ outgoing_count: 0,
+ first_incoming_ts: 0,
+ first_reply_ts: 0,
+ anchor_local_id: localId,
+ anchor_create_time: createTime,
+ latest_local_id: localId,
+ latest_create_time: createTime
+ }
+ }
+ } else if (!active) {
+ segmentCursor += 1
+ active = {
+ segment_index: segmentCursor,
+ start_ts: 0,
+ end_ts: 0,
+ incoming_count: 0,
+ outgoing_count: 0,
+ first_incoming_ts: 0,
+ first_reply_ts: 0,
+ anchor_local_id: localId,
+ anchor_create_time: 0,
+ latest_local_id: localId,
+ latest_create_time: 0
+ }
+ }
+
+ if (isSend) {
+ if (active) {
+ active.outgoing_count += 1
+ if (
+ createTime > 0
+ && active.first_incoming_ts > 0
+ && createTime >= active.first_incoming_ts
+ && active.first_reply_ts <= 0
+ ) {
+ active.first_reply_ts = createTime
+ }
+ }
+ } else if (active) {
+ active.incoming_count += 1
+ if (active.first_incoming_ts <= 0 || (createTime > 0 && createTime < active.first_incoming_ts)) {
+ active.first_incoming_ts = createTime
+ }
+ }
+
+ if (active && createTime > 0) {
+ active.end_ts = createTime
+ active.latest_create_time = createTime
+ active.latest_local_id = localId
+ lastMessageTs = createTime
+ }
+ }
+ }
+ } finally {
+ await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {})
+ }
+
+ commit()
+ }
+
+ return segments.sort((a, b) => {
+ if (a.start_ts !== b.start_ts) return a.start_ts - b.start_ts
+ if (a.session_id !== b.session_id) return a.session_id.localeCompare(b.session_id)
+ return a.segment_index - b.segment_index
+ })
+ }
+
+ async exportMyFootprint(
+ beginTimestamp: number,
+ endTimestamp: number,
+ format: 'csv' | 'json',
+ filePath: string
+ ): Promise<{ success: boolean; filePath?: string; error?: string }> {
+ try {
+ const normalizedFormat = String(format || '').toLowerCase() === 'csv' ? 'csv' : 'json'
+ const targetPath = String(filePath || '').trim()
+ if (!targetPath) {
+ return { success: false, error: '导出路径不能为空' }
+ }
+
+ const statsResult = await this.getMyFootprintStats(beginTimestamp, endTimestamp)
+ if (!statsResult.success || !statsResult.data) {
+ return { success: false, error: statsResult.error || '导出前获取统计失败' }
+ }
+
+ mkdirSync(dirname(targetPath), { recursive: true })
+ if (normalizedFormat === 'json') {
+ writeFileSync(targetPath, JSON.stringify(statsResult.data, null, 2), 'utf-8')
+ } else {
+ const csv = this.buildMyFootprintCsv(statsResult.data)
+ writeFileSync(targetPath, `\uFEFF${csv}`, 'utf-8')
+ }
+
+ return { success: true, filePath: targetPath }
+ } catch (error) {
+ console.error('[ChatService] 导出我的足迹失败:', error)
+ return { success: false, error: String(error) }
+ }
+ }
+
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
try {
const nativeResult = await wcdbService.getMessageById(sessionId, localId)
@@ -7885,6 +8531,1637 @@ class ChatService {
}
}
+ private normalizeTimestampSeconds(value: number): number {
+ const numeric = Number(value || 0)
+ if (!Number.isFinite(numeric) || numeric <= 0) return 0
+ return numeric > 1e12 ? Math.floor(numeric / 1000) : Math.floor(numeric)
+ }
+
+ private toSafeInt(value: unknown, fallback = 0): number {
+ const parsed = Number.parseInt(String(value ?? '').trim(), 10)
+ return Number.isFinite(parsed) ? parsed : fallback
+ }
+
+ private toSafeNumber(value: unknown, fallback = 0): number {
+ const parsed = Number(value)
+ return Number.isFinite(parsed) ? parsed : fallback
+ }
+
+ private resolveFootprintRowIsSend(row: Record, myWxid: string): boolean {
+ const raw = row.computed_is_send ?? row.is_send
+ if (raw === 1 || raw === '1' || raw === true || raw === 'true') return true
+ if (raw === 0 || raw === '0' || raw === false || raw === 'false') return false
+ const senderUsername = String(row.sender_username || row.senderUsername || '').trim()
+ return Boolean(senderUsername && myWxid && senderUsername === myWxid)
+ }
+
+ private splitAtUserList(raw: string): string[] {
+ const tokens = String(raw || '')
+ .split(/[,\s;|]+/g)
+ .map((token) => token.trim().replace(/^@+/, '').replace(/^["']+|["']+$/g, ''))
+ .filter(Boolean)
+ return Array.from(new Set(tokens))
+ }
+
+ private containsAtSign(text: string): boolean {
+ if (!text) return false
+ return text.includes('@') || text.includes('@')
+ }
+
+ private footprintMessageLikelyContainsAt(rawContent: unknown): boolean {
+ if (rawContent === null || rawContent === undefined) return false
+ const text = typeof rawContent === 'string' ? rawContent : String(rawContent || '')
+ return this.containsAtSign(text)
+ }
+
+ private matchesMyFootprintIdentity(rawToken: string, identitySet: Set): boolean {
+ const token = String(rawToken || '').trim().replace(/^@+/, '')
+ if (!token) return false
+
+ const normalizedCandidates = new Set()
+ const addCandidate = (value: string) => {
+ const normalized = String(value || '').trim().toLowerCase()
+ if (!normalized) return
+ normalizedCandidates.add(normalized)
+ }
+
+ addCandidate(token)
+ addCandidate(token.replace(/@chatroom$/i, ''))
+ addCandidate(token.replace(/@openim$/i, ''))
+
+ for (const candidate of normalizedCandidates) {
+ if (!candidate) continue
+ for (const selfId of identitySet) {
+ if (!selfId) continue
+ if (candidate === selfId) return true
+ if (candidate.startsWith(`${selfId}_`) || selfId.startsWith(`${candidate}_`)) return true
+ }
+ }
+ return false
+ }
+
+ private buildMyFootprintIdentitySet(myWxid: string): Set {
+ const set = new Set()
+ const add = (value: string) => {
+ const normalized = String(value || '').trim().toLowerCase()
+ if (!normalized) return
+ set.add(normalized)
+ }
+
+ const raw = String(myWxid || '').trim()
+ add(raw)
+ add(this.cleanAccountDirName(raw))
+ for (const key of this.buildIdentityKeys(raw)) {
+ add(key)
+ }
+ return set
+ }
+
+ private buildFootprintSourceCandidates(source: unknown): string[] {
+ const sourceCandidates: string[] = []
+ const seen = new Set()
+ const pushCandidate = (value: unknown) => {
+ const normalized = this.cleanUtf16(String(value || '').trim())
+ if (!normalized) return
+ if (seen.has(normalized)) return
+ seen.add(normalized)
+ sourceCandidates.push(normalized)
+ }
+
+ const rawSource = typeof source === 'string'
+ ? source
+ : Buffer.isBuffer(source) || source instanceof Uint8Array
+ ? Buffer.from(source).toString('utf-8')
+ : typeof source === 'object' && source !== null && Array.isArray((source as { data?: unknown }).data)
+ ? Buffer.from((source as { data: number[] }).data).toString('utf-8')
+ : String(source || '')
+ const normalizedSource = String(rawSource || '').trim()
+ pushCandidate(normalizedSource)
+ if (normalizedSource.includes('&')) {
+ pushCandidate(this.decodeHtmlEntities(normalizedSource))
+ }
+
+ const sourceLooksEncoded = normalizedSource.length > 16
+ && (this.looksLikeHex(normalizedSource) || this.looksLikeBase64(normalizedSource))
+ if (sourceLooksEncoded) {
+ const decodedFromText = this.decodeMaybeCompressed(normalizedSource, 'footprint_source')
+ pushCandidate(decodedFromText)
+ if (decodedFromText.includes('&')) {
+ pushCandidate(this.decodeHtmlEntities(decodedFromText))
+ }
+ } else if (typeof source !== 'string') {
+ const decodedFromBinary = this.decodeMaybeCompressed(source, 'footprint_source')
+ pushCandidate(decodedFromBinary)
+ if (decodedFromBinary.includes('&')) {
+ pushCandidate(this.decodeHtmlEntities(decodedFromBinary))
+ }
+ }
+
+ return sourceCandidates
+ }
+
+ private normalizeFootprintSourceForOutput(source: unknown): string {
+ if (source === null || source === undefined) return ''
+ if (typeof source === 'string') return source.trim()
+ if (Buffer.isBuffer(source) || source instanceof Uint8Array) {
+ return this.decodeBinaryContent(Buffer.from(source), '').trim()
+ }
+ if (typeof source === 'object' && source !== null && Array.isArray((source as { data?: unknown }).data)) {
+ return this.decodeBinaryContent(Buffer.from((source as { data: number[] }).data), '').trim()
+ }
+ return String(source || '').trim()
+ }
+
+ private extractAtUserListTokensFromSource(source: unknown, prebuiltCandidates?: string[]): string[] {
+ const tokens = new Set()
+ const sourceCandidates = Array.isArray(prebuiltCandidates) && prebuiltCandidates.length > 0
+ ? prebuiltCandidates
+ : this.buildFootprintSourceCandidates(source)
+ const addTokens = (values: string[]) => {
+ for (const value of values) {
+ const normalized = String(value || '').trim()
+ if (!normalized) continue
+ tokens.add(normalized)
+ }
+ }
+
+ const xmlPattern = /]*>([\s\S]*?)<\/atuserlist>/gi
+ const cdataPattern = //i
+ for (const candidateSource of sourceCandidates) {
+ if (!candidateSource.toLowerCase().includes('atuserlist')) continue
+
+ const trimmedCandidateSource = candidateSource.trim()
+ const maybeJson = trimmedCandidateSource.startsWith('{')
+ || trimmedCandidateSource.startsWith('[')
+ || trimmedCandidateSource.includes('"atuserlist"')
+ if (maybeJson) {
+ try {
+ const parsed = JSON.parse(candidateSource)
+ const atUserList = parsed?.atuserlist
+ if (Array.isArray(atUserList)) {
+ const values = atUserList
+ .map((item: unknown) => this.splitAtUserList(String(item || '')))
+ .flat()
+ addTokens(values)
+ }
+ if (typeof atUserList === 'string') {
+ addTokens(this.splitAtUserList(atUserList))
+ }
+ } catch {
+ // ignore JSON parse error and continue fallback parsing
+ }
+ }
+
+ const jsonMatch = candidateSource.match(/"atuserlist"\s*:\s*(\[[^\]]*\]|"[^"]*"|'[^']*'|[^,}\s]+)/i)
+ if (jsonMatch) {
+ const jsonCandidate = String(jsonMatch[1] || '').trim()
+ if (jsonCandidate.startsWith('[')) {
+ try {
+ const arr = JSON.parse(jsonCandidate)
+ if (Array.isArray(arr)) {
+ const values = arr
+ .map((item) => this.splitAtUserList(String(item || '')))
+ .flat()
+ addTokens(values)
+ }
+ } catch {
+ // ignore array parse error
+ }
+ }
+ const unquoted = jsonCandidate.replace(/^["']+|["']+$/g, '')
+ addTokens(this.splitAtUserList(unquoted))
+ }
+
+ xmlPattern.lastIndex = 0
+ let xmlMatch: RegExpExecArray | null
+ while ((xmlMatch = xmlPattern.exec(candidateSource)) !== null) {
+ let xmlValue = String(xmlMatch[1] || '')
+ const cdataMatch = xmlValue.match(cdataPattern)
+ if (cdataMatch?.[1]) {
+ xmlValue = cdataMatch[1]
+ }
+ addTokens(this.splitAtUserList(xmlValue))
+ }
+ }
+
+ return Array.from(tokens)
+ }
+
+ private sourceAtUserListContains(source: unknown, myWxid: string): boolean {
+ const selfIdentitySet = this.buildMyFootprintIdentitySet(myWxid)
+ return this.sourceAtUserListContainsWithIdentitySet(source, selfIdentitySet)
+ }
+
+ private sourceAtUserListContainsWithIdentitySet(source: unknown, selfIdentitySet: Set): boolean {
+ if (selfIdentitySet.size === 0) return false
+ if (typeof source === 'string') {
+ const raw = source.trim()
+ if (!raw) return false
+ const loweredRaw = raw.toLowerCase()
+ if (loweredRaw.includes('atuserlist')) {
+ for (const identity of selfIdentitySet) {
+ if (identity && loweredRaw.includes(identity)) {
+ return true
+ }
+ }
+ const quickXmlMatch = raw.match(/]*>([\s\S]*?)<\/atuserlist>/i)
+ if (quickXmlMatch?.[1]) {
+ const inner = quickXmlMatch[1]
+ const cdata = inner.match(//i)?.[1] || inner
+ const quickTokens = this.splitAtUserList(cdata)
+ if (quickTokens.some((token) => this.matchesMyFootprintIdentity(token, selfIdentitySet))) {
+ return true
+ }
+ }
+ } else if (raw.length <= 16 || (!this.looksLikeHex(raw) && !this.looksLikeBase64(raw))) {
+ return false
+ }
+ }
+ const sourceCandidates = this.buildFootprintSourceCandidates(source)
+ for (const candidate of sourceCandidates) {
+ const normalized = String(candidate || '').toLowerCase()
+ if (!normalized || !normalized.includes('atuserlist')) continue
+ for (const identity of selfIdentitySet) {
+ if (identity && normalized.includes(identity)) {
+ return true
+ }
+ }
+ }
+ const tokens = this.extractAtUserListTokensFromSource(source, sourceCandidates)
+ if (tokens.length === 0) return false
+ return tokens.some((token) => this.matchesMyFootprintIdentity(token, selfIdentitySet))
+ }
+
+ private async resolveMyFootprintGroupSessionIds(
+ groupSessionIds: string[],
+ beginTimestamp = 0,
+ endTimestamp = 0
+ ): Promise {
+ const normalized = Array.from(new Set(
+ (groupSessionIds || [])
+ .map((value) => String(value || '').trim())
+ .filter((value) => value.endsWith('@chatroom'))
+ ))
+ const begin = this.normalizeTimestampSeconds(beginTimestamp)
+ const end = this.normalizeTimestampSeconds(endTimestamp)
+ void begin
+ void end
+
+ const merged: string[] = []
+ const seen = new Set()
+ const sessionLastTsMap = new Map()
+ const hasSessionRank = new Set()
+ const shouldKeepByLastTs = (sessionId: string, preferKeepUnknown: boolean): boolean => {
+ const normalizedSessionId = String(sessionId || '').trim()
+ if (!normalizedSessionId) return false
+ const lastTs = this.normalizeTimestampSeconds(sessionLastTsMap.get(normalizedSessionId) || 0)
+ const known = hasSessionRank.has(normalizedSessionId)
+ if (!known) return preferKeepUnknown || begin <= 0
+ if (begin > 0 && lastTs > 0 && lastTs < begin) return false
+ return true
+ }
+ const push = (value: string) => {
+ const normalizedValue = String(value || '').trim()
+ if (!normalizedValue || !normalizedValue.endsWith('@chatroom')) return
+ if (seen.has(normalizedValue)) return
+ seen.add(normalizedValue)
+ merged.push(normalizedValue)
+ }
+
+ try {
+ const sessionsResult = await this.getSessions()
+ if (sessionsResult.success && Array.isArray(sessionsResult.sessions)) {
+ const rankedGroups = sessionsResult.sessions
+ .map((session) => {
+ const sessionId = String(session?.username || '').trim()
+ const lastTs = this.normalizeTimestampSeconds(
+ Number(session?.lastTimestamp || session?.sortTimestamp || 0)
+ )
+ if (sessionId.endsWith('@chatroom')) {
+ hasSessionRank.add(sessionId)
+ sessionLastTsMap.set(sessionId, lastTs)
+ }
+ return { sessionId, lastTs }
+ })
+ .filter((item) => item.sessionId.endsWith('@chatroom'))
+ .filter((item) => shouldKeepByLastTs(item.sessionId, false))
+ .sort((a, b) => {
+ if (a.lastTs !== b.lastTs) return b.lastTs - a.lastTs
+ return a.sessionId.localeCompare(b.sessionId)
+ })
+ for (const item of rankedGroups) {
+ push(item.sessionId)
+ }
+ }
+ } catch {
+ // ignore session-based scope resolution failure
+ }
+
+ try {
+ const contactGroups = await this.listMyFootprintGroupSessionIdsFromContact()
+ for (const sessionId of contactGroups) {
+ if (!shouldKeepByLastTs(sessionId, false)) continue
+ push(sessionId)
+ }
+ } catch {
+ // ignore contact-based scope resolution failure
+ }
+
+ for (const sessionId of normalized) {
+ if (!shouldKeepByLastTs(sessionId, true)) continue
+ push(sessionId)
+ }
+
+ return merged.length > 0 ? merged : normalized
+ }
+
+ private async listMyFootprintGroupSessionIdsFromContact(): Promise {
+ try {
+ const result = await wcdbService.execQuery(
+ 'contact',
+ null,
+ "SELECT username FROM contact WHERE username IS NOT NULL AND username != '' AND username LIKE '%@chatroom'"
+ )
+ if (!result.success || !Array.isArray(result.rows)) {
+ return []
+ }
+
+ return Array.from(new Set(
+ (result.rows as Array>)
+ .map((row) => String(this.getRowField(row, ['username', 'user_name', 'userName']) || '').trim())
+ .filter((value) => value.endsWith('@chatroom'))
+ ))
+ } catch {
+ return []
+ }
+ }
+
+ private async filterMyFootprintPrivateSessions(privateSessionIds: string[]): Promise {
+ const normalized = Array.from(new Set(
+ (privateSessionIds || [])
+ .map((value) => String(value || '').trim())
+ .filter((value) => value && !value.endsWith('@chatroom'))
+ ))
+ if (normalized.length === 0) return normalized
+
+ try {
+ const officialSessionIds = await this.getMyFootprintOfficialSessionIdSet(normalized)
+ if (officialSessionIds.size === 0) return normalized
+ return normalized.filter((sessionId) => !officialSessionIds.has(sessionId))
+ } catch {
+ return normalized
+ }
+ }
+
+ private async getMyFootprintOfficialSessionIdSet(privateSessionIds: string[]): Promise> {
+ const officialSessionIds = new Set()
+ const normalized = Array.from(new Set(
+ (privateSessionIds || [])
+ .map((value) => String(value || '').trim())
+ .filter((value) => value && !value.endsWith('@chatroom'))
+ ))
+ if (normalized.length === 0) return officialSessionIds
+
+ for (const sessionId of normalized) {
+ if (sessionId.startsWith('gh_')) {
+ officialSessionIds.add(sessionId)
+ }
+ }
+
+ const chunkSize = 320
+ const buildInListSql = (values: string[]) => values
+ .map((value) => `'${this.escapeSqlString(value)}'`)
+ .join(',')
+
+ try {
+ const bizInfoTableResult = await wcdbService.execQuery(
+ 'contact',
+ null,
+ "SELECT name FROM sqlite_master WHERE type='table' AND lower(name)='biz_info' LIMIT 1"
+ )
+ const bizInfoTableName = bizInfoTableResult.success && Array.isArray(bizInfoTableResult.rows)
+ ? String((bizInfoTableResult.rows[0] as Record | undefined)?.name || '').trim()
+ : ''
+ if (bizInfoTableName) {
+ const tableSqlName = this.quoteSqlIdentifier(bizInfoTableName)
+ for (let index = 0; index < normalized.length; index += chunkSize) {
+ const batch = normalized.slice(index, index + chunkSize)
+ if (batch.length === 0) continue
+ const inListSql = buildInListSql(batch)
+ const sql = `SELECT username FROM ${tableSqlName} WHERE username IN (${inListSql})`
+ const result = await wcdbService.execQuery('contact', null, sql)
+ if (!result.success || !Array.isArray(result.rows)) continue
+ for (const row of result.rows as Array>) {
+ const username = String(this.getRowField(row, ['username', 'user_name', 'userName']) || '').trim()
+ if (username) officialSessionIds.add(username)
+ }
+ }
+ }
+ } catch {
+ // ignore biz_info lookup failure
+ }
+
+ try {
+ const tableInfo = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact)')
+ if (tableInfo.success && Array.isArray(tableInfo.rows)) {
+ const availableColumns = new Map()
+ for (const row of tableInfo.rows as Array>) {
+ const rawName = row.name ?? row.column_name ?? row.columnName
+ const name = String(rawName || '').trim()
+ if (!name) continue
+ availableColumns.set(name.toLowerCase(), name)
+ }
+
+ const pickColumn = (candidates: string[]): string | null => {
+ for (const candidate of candidates) {
+ const actual = availableColumns.get(candidate.toLowerCase())
+ if (actual) return actual
+ }
+ return null
+ }
+
+ const usernameColumn = pickColumn(['username', 'user_name', 'userName'])
+ const officialFlagColumns = [
+ pickColumn(['verify_flag', 'verifyFlag', 'verifyflag']),
+ pickColumn(['verify_status', 'verifyStatus']),
+ pickColumn(['verify_type', 'verifyType']),
+ pickColumn(['biz_type', 'bizType']),
+ pickColumn(['brand_flag', 'brandFlag']),
+ pickColumn(['service_type', 'serviceType'])
+ ].filter((column): column is string => Boolean(column))
+
+ if (usernameColumn && officialFlagColumns.length > 0) {
+ const selectColumns = Array.from(new Set([usernameColumn, ...officialFlagColumns]))
+ const selectSql = selectColumns.map((column) => this.quoteSqlIdentifier(column)).join(', ')
+ for (let index = 0; index < normalized.length; index += chunkSize) {
+ const batch = normalized.slice(index, index + chunkSize)
+ if (batch.length === 0) continue
+ const inListSql = buildInListSql(batch)
+ const sql = `SELECT ${selectSql} FROM contact WHERE ${this.quoteSqlIdentifier(usernameColumn)} IN (${inListSql})`
+ const result = await wcdbService.execQuery('contact', null, sql)
+ if (!result.success || !Array.isArray(result.rows)) continue
+ for (const row of result.rows as Array>) {
+ const username = String(this.getRowField(row, [usernameColumn, 'username', 'user_name', 'userName']) || '').trim()
+ if (!username) continue
+ const hasOfficialFlag = officialFlagColumns.some((column) => (
+ this.isTruthyMyFootprintOfficialFlag(this.getRowField(row, [column]))
+ ))
+ if (hasOfficialFlag) {
+ officialSessionIds.add(username)
+ }
+ }
+ }
+ }
+ }
+ } catch {
+ // ignore contact-flag lookup failure
+ }
+
+ return officialSessionIds
+ }
+
+ private isTruthyMyFootprintOfficialFlag(value: unknown): boolean {
+ if (value === null || value === undefined) return false
+ if (typeof value === 'boolean') return value
+ if (typeof value === 'number') return Number.isFinite(value) && value > 0
+
+ const normalized = String(value || '').trim().toLowerCase()
+ if (!normalized) return false
+ if (normalized === '0' || normalized === 'false' || normalized === 'null' || normalized === 'undefined') {
+ return false
+ }
+
+ const numeric = Number(normalized)
+ if (Number.isFinite(numeric)) {
+ return numeric > 0
+ }
+ return true
+ }
+
+ private normalizeMyFootprintData(raw: any): MyFootprintData {
+ const summaryRaw = raw?.summary || {}
+ const privateSessionsRaw = Array.isArray(raw?.private_sessions) ? raw.private_sessions : []
+ const privateSegmentsRaw = Array.isArray(raw?.private_segments) ? raw.private_segments : []
+ const mentionsRaw = Array.isArray(raw?.mentions) ? raw.mentions : []
+ const mentionGroupsRaw = Array.isArray(raw?.mention_groups) ? raw.mention_groups : []
+ const diagnosticsRaw = raw?.diagnostics || {}
+
+ const summary: MyFootprintSummary = {
+ private_inbound_people: this.toSafeInt(summaryRaw.private_inbound_people, 0),
+ private_replied_people: this.toSafeInt(summaryRaw.private_replied_people, 0),
+ private_outbound_people: this.toSafeInt(summaryRaw.private_outbound_people, 0),
+ private_reply_rate: this.toSafeNumber(summaryRaw.private_reply_rate, 0),
+ mention_count: this.toSafeInt(summaryRaw.mention_count, 0),
+ mention_group_count: this.toSafeInt(summaryRaw.mention_group_count, 0)
+ }
+
+ const private_sessions: MyFootprintPrivateSession[] = privateSessionsRaw.map((item: any) => ({
+ session_id: String(item?.session_id || '').trim(),
+ incoming_count: this.toSafeInt(item?.incoming_count, 0),
+ outgoing_count: this.toSafeInt(item?.outgoing_count, 0),
+ replied: Boolean(item?.replied),
+ first_incoming_ts: this.toSafeInt(item?.first_incoming_ts, 0),
+ first_reply_ts: this.toSafeInt(item?.first_reply_ts, 0),
+ latest_ts: this.toSafeInt(item?.latest_ts, 0),
+ anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0),
+ anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0)
+ })).filter((item) => item.session_id)
+
+ const private_segments: MyFootprintPrivateSegment[] = privateSegmentsRaw.map((item: any) => ({
+ session_id: String(item?.session_id || '').trim(),
+ segment_index: this.toSafeInt(item?.segment_index, 0),
+ start_ts: this.toSafeInt(item?.start_ts, 0),
+ end_ts: this.toSafeInt(item?.end_ts, 0),
+ duration_sec: this.toSafeInt(item?.duration_sec, 0),
+ incoming_count: this.toSafeInt(item?.incoming_count, 0),
+ outgoing_count: this.toSafeInt(item?.outgoing_count, 0),
+ message_count: this.toSafeInt(item?.message_count, 0),
+ replied: Boolean(item?.replied),
+ first_incoming_ts: this.toSafeInt(item?.first_incoming_ts, 0),
+ first_reply_ts: this.toSafeInt(item?.first_reply_ts, 0),
+ latest_ts: this.toSafeInt(item?.latest_ts, 0),
+ anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0),
+ anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0),
+ displayName: String(item?.displayName || '').trim() || undefined,
+ avatarUrl: String(item?.avatarUrl || '').trim() || undefined
+ })).filter((item) => item.session_id && item.start_ts > 0)
+
+ const mentions: MyFootprintMentionItem[] = mentionsRaw.map((item: any) => ({
+ session_id: String(item?.session_id || '').trim(),
+ local_id: this.toSafeInt(item?.local_id, 0),
+ create_time: this.toSafeInt(item?.create_time, 0),
+ sender_username: String(item?.sender_username || '').trim(),
+ message_content: String(item?.message_content || ''),
+ source: String(item?.source || '')
+ })).filter((item) => item.session_id)
+
+ const mention_groups: MyFootprintMentionGroup[] = mentionGroupsRaw.map((item: any) => ({
+ session_id: String(item?.session_id || '').trim(),
+ count: this.toSafeInt(item?.count, 0),
+ latest_ts: this.toSafeInt(item?.latest_ts, 0)
+ })).filter((item) => item.session_id)
+
+ const diagnostics: MyFootprintDiagnostics = {
+ truncated: Boolean(diagnosticsRaw.truncated),
+ scanned_dbs: this.toSafeInt(diagnosticsRaw.scanned_dbs, 0),
+ elapsed_ms: this.toSafeInt(diagnosticsRaw.elapsed_ms, 0),
+ mention_truncated: Boolean(diagnosticsRaw.mention_truncated),
+ private_truncated: Boolean(diagnosticsRaw.private_truncated)
+ }
+
+ return {
+ summary,
+ private_sessions,
+ private_segments,
+ mentions,
+ mention_groups,
+ diagnostics
+ }
+ }
+
+ private filterMyFootprintMentionsBySource(data: MyFootprintData, myWxid: string, mentionLimit: number): MyFootprintData {
+ const identitySet = this.buildMyFootprintIdentitySet(myWxid)
+ if (identitySet.size === 0) {
+ return {
+ ...data,
+ summary: {
+ ...data.summary,
+ mention_count: 0,
+ mention_group_count: 0
+ },
+ mentions: [],
+ mention_groups: []
+ }
+ }
+
+ const sourceMatchCache = new Map()
+ const filteredMentions = data.mentions.filter((item) => {
+ const sourceKey = String(item.source || '')
+ const cachedMatched = sourceMatchCache.get(sourceKey)
+ if (cachedMatched !== undefined) return cachedMatched
+ const matched = this.sourceAtUserListContainsWithIdentitySet(item.source, identitySet)
+ if (sourceMatchCache.size < 4096) {
+ sourceMatchCache.set(sourceKey, matched)
+ }
+ return matched
+ })
+ .sort((a, b) => {
+ if (b.create_time !== a.create_time) return b.create_time - a.create_time
+ return b.local_id - a.local_id
+ })
+
+ let truncatedByFrontendLimit = false
+ if (mentionLimit > 0 && filteredMentions.length > mentionLimit) {
+ filteredMentions.length = mentionLimit
+ truncatedByFrontendLimit = true
+ }
+
+ const mentionGroupMap = new Map()
+ for (const mention of filteredMentions) {
+ const group = mentionGroupMap.get(mention.session_id) || {
+ session_id: mention.session_id,
+ count: 0,
+ latest_ts: 0
+ }
+ group.count += 1
+ if (mention.create_time > group.latest_ts) group.latest_ts = mention.create_time
+ mentionGroupMap.set(mention.session_id, group)
+ }
+
+ const filteredMentionGroups = Array.from(mentionGroupMap.values())
+ .sort((a, b) => {
+ if (b.count !== a.count) return b.count - a.count
+ if (b.latest_ts !== a.latest_ts) return b.latest_ts - a.latest_ts
+ return a.session_id.localeCompare(b.session_id)
+ })
+
+ const nextSummary: MyFootprintSummary = {
+ ...data.summary,
+ mention_count: filteredMentions.length,
+ mention_group_count: filteredMentionGroups.length
+ }
+
+ return {
+ ...data,
+ summary: nextSummary,
+ mentions: filteredMentions,
+ mention_groups: filteredMentionGroups,
+ diagnostics: {
+ ...data.diagnostics,
+ truncated: Boolean(data.diagnostics.truncated || truncatedByFrontendLimit)
+ }
+ }
+ }
+
+ private mergeMyFootprintMentionResult(base: MyFootprintData, mentionResult: MyFootprintData): MyFootprintData {
+ const mentionMap = new Map()
+ const pushMention = (item: MyFootprintMentionItem) => {
+ const key = `${item.session_id}#${item.local_id}#${item.create_time}`
+ mentionMap.set(key, item)
+ }
+ for (const item of base.mentions) pushMention(item)
+ for (const item of mentionResult.mentions) pushMention(item)
+
+ const mergedMentions = Array.from(mentionMap.values())
+ .sort((a, b) => {
+ if (b.create_time !== a.create_time) return b.create_time - a.create_time
+ return b.local_id - a.local_id
+ })
+
+ const mentionGroupMetaMap = new Map>()
+ const pushGroupMeta = (group: MyFootprintMentionGroup) => {
+ const prev = mentionGroupMetaMap.get(group.session_id) || {}
+ mentionGroupMetaMap.set(group.session_id, {
+ displayName: group.displayName || prev.displayName,
+ avatarUrl: group.avatarUrl || prev.avatarUrl
+ })
+ }
+ for (const group of base.mention_groups) pushGroupMeta(group)
+ for (const group of mentionResult.mention_groups) pushGroupMeta(group)
+
+ const mentionGroupMap = new Map()
+ for (const mention of mergedMentions) {
+ const current = mentionGroupMap.get(mention.session_id) || {
+ session_id: mention.session_id,
+ count: 0,
+ latest_ts: 0
+ }
+ current.count += 1
+ if (mention.create_time > current.latest_ts) {
+ current.latest_ts = mention.create_time
+ }
+ mentionGroupMap.set(mention.session_id, current)
+ }
+
+ const mergedMentionGroups = Array.from(mentionGroupMap.values())
+ .map((group) => {
+ const meta = mentionGroupMetaMap.get(group.session_id)
+ return {
+ ...group,
+ displayName: meta?.displayName,
+ avatarUrl: meta?.avatarUrl
+ }
+ })
+ .sort((a, b) => {
+ if (b.count !== a.count) return b.count - a.count
+ if (b.latest_ts !== a.latest_ts) return b.latest_ts - a.latest_ts
+ return a.session_id.localeCompare(b.session_id)
+ })
+
+ return {
+ ...base,
+ summary: {
+ ...base.summary,
+ mention_count: mergedMentions.length,
+ mention_group_count: mergedMentionGroups.length
+ },
+ private_segments: mentionResult.private_segments.length > 0
+ ? mentionResult.private_segments
+ : base.private_segments,
+ mentions: mergedMentions,
+ mention_groups: mergedMentionGroups,
+ diagnostics: {
+ ...base.diagnostics,
+ truncated: Boolean(base.diagnostics.truncated || mentionResult.diagnostics.truncated),
+ scanned_dbs: Math.max(base.diagnostics.scanned_dbs || 0, mentionResult.diagnostics.scanned_dbs || 0),
+ elapsed_ms: Math.max(base.diagnostics.elapsed_ms || 0, mentionResult.diagnostics.elapsed_ms || 0)
+ }
+ }
+ }
+
+ private shouldRunMyFootprintHeavyDebug(): boolean {
+ const flag = String(process.env.WEFLOW_MY_FOOTPRINT_DEBUG || '').trim().toLowerCase()
+ return flag === '1' || flag === 'true' || flag === 'yes' || flag === 'on'
+ }
+
+ private async logMyFootprintZeroMentionDebug(params: {
+ begin: number
+ end: number
+ myWxid: string
+ groupSessionIds: string[]
+ nativeData: MyFootprintData
+ }): Promise {
+ try {
+ const identityKeySet = this.buildMyFootprintIdentitySet(params.myWxid)
+ const identitySet = Array.from(identityKeySet)
+ console.warn(
+ `[MyFootprint][debug] zero mentions: myWxid=${params.myWxid} identityKeys=${identitySet.join('|')} groups=${params.groupSessionIds.length} nativeMentions=${params.nativeData.mentions.length} nativeMentionGroups=${params.nativeData.mention_groups.length} scannedDbs=${params.nativeData.diagnostics.scanned_dbs}`
+ )
+
+ if (params.nativeData.mentions.length > 0) {
+ const samples = params.nativeData.mentions.slice(0, 5).map((item) => {
+ const tokens = this.extractAtUserListTokensFromSource(item.source)
+ const matched = tokens.some((token) => this.matchesMyFootprintIdentity(token, identityKeySet))
+ return {
+ sessionId: item.session_id,
+ localId: item.local_id,
+ createTime: item.create_time,
+ tokens,
+ matched
+ }
+ })
+ console.warn(`[MyFootprint][debug] native mention samples=${JSON.stringify(samples)}`)
+ }
+
+ const allGroups = params.groupSessionIds
+ console.warn(`[MyFootprint][debug] start group scan: totalGroups=${allGroups.length}`)
+ let skippedNoTableGroups = 0
+ let sqlProbeCount = 0
+ let nativeSingleProbeCount = 0
+ for (let index = 0; index < allGroups.length; index += 1) {
+ const sessionId = allGroups[index]
+ const cursorResult = await wcdbService.openMessageCursorLite(
+ sessionId,
+ 120,
+ false,
+ params.begin,
+ params.end
+ )
+ if (!cursorResult.success || !cursorResult.cursor) {
+ const openCursorError = String(cursorResult.error || 'unknown')
+ if (openCursorError.includes('-3')) {
+ skippedNoTableGroups += 1
+ console.warn(`[MyFootprint][debug][${index + 1}/${allGroups.length}][${sessionId}] skipped(no message table): ${openCursorError}`)
+ } else {
+ console.warn(`[MyFootprint][debug][${index + 1}/${allGroups.length}][${sessionId}] open cursor failed: ${openCursorError}`)
+ }
+ continue
+ }
+
+ let rows = 0
+ let atContentRows = 0
+ let sourcePresentRows = 0
+ let atUserListRows = 0
+ let matchedRows = 0
+ const unmatchedSamples: Array<{
+ localId: number
+ createTime: number
+ tokens: string[]
+ sourcePreview: string
+ }> = []
+
+ let hasMore = true
+ try {
+ while (hasMore && rows < 200) {
+ const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor)
+ if (!batchResult.success || !Array.isArray(batchResult.rows)) {
+ break
+ }
+ hasMore = Boolean(batchResult.hasMore)
+ for (const row of batchResult.rows as Array>) {
+ rows += 1
+ if (rows > 200) break
+
+ const messageContentRaw = row.message_content ?? row.messageContent ?? row.content
+ const hasAtInContent = this.footprintMessageLikelyContainsAt(messageContentRaw)
+ if (hasAtInContent) atContentRows += 1
+
+ const sourceRaw = row.source ?? row.msg_source ?? row.message_source
+ if (sourceRaw !== null && sourceRaw !== undefined && String(sourceRaw).trim().length > 0) {
+ sourcePresentRows += 1
+ }
+ if (!hasAtInContent) continue
+
+ const tokens = this.extractAtUserListTokensFromSource(sourceRaw)
+ if (tokens.length > 0) atUserListRows += 1
+ const matched = tokens.some((token) => this.matchesMyFootprintIdentity(token, identityKeySet))
+ if (matched) {
+ matchedRows += 1
+ } else if (tokens.length > 0 && unmatchedSamples.length < 3) {
+ const sourceDecoded = this.decodeMaybeCompressed(sourceRaw, 'footprint_source') || String(sourceRaw || '')
+ unmatchedSamples.push({
+ localId: this.toSafeInt(row.local_id, 0),
+ createTime: this.toSafeInt(row.create_time, 0),
+ tokens,
+ sourcePreview: sourceDecoded.replace(/\s+/g, ' ').slice(0, 260)
+ })
+ }
+ }
+ }
+ } finally {
+ await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {})
+ }
+
+ console.warn(
+ `[MyFootprint][debug][${index + 1}/${allGroups.length}][${sessionId}] rows=${rows} atContentRows=${atContentRows} sourcePresentRows=${sourcePresentRows} atUserListRows=${atUserListRows} matchedRows=${matchedRows}`
+ )
+ if (unmatchedSamples.length > 0) {
+ console.warn(`[MyFootprint][debug][${sessionId}] unmatchedSamples=${JSON.stringify(unmatchedSamples)}`)
+ }
+
+ if ((matchedRows > 0 || atContentRows > 0 || atUserListRows > 0) && sqlProbeCount < 6) {
+ sqlProbeCount += 1
+ await this.logMyFootprintNativeSqlProbe(sessionId, params.begin, params.end)
+ }
+ if (matchedRows > 0 && nativeSingleProbeCount < 4) {
+ nativeSingleProbeCount += 1
+ await this.logMyFootprintNativeSingleGroupProbe(sessionId, params.begin, params.end, params.myWxid)
+ }
+ }
+ if (skippedNoTableGroups > 0) {
+ console.warn(`[MyFootprint][debug] skippedNoTableGroups=${skippedNoTableGroups}/${allGroups.length}`)
+ }
+ } catch (error) {
+ console.warn('[MyFootprint][debug] zero mention diagnostics failed:', error)
+ }
+ }
+
+ private async printMyFootprintNativeLogs(tag: string): Promise {
+ try {
+ const logsResult = await wcdbService.getLogs()
+ if (!logsResult.success || !Array.isArray(logsResult.logs)) {
+ console.warn(`[MyFootprint][native-log][${tag}] getLogs failed: ${logsResult.error || 'unknown'}`)
+ return
+ }
+
+ const logs = logsResult.logs
+ .map((line) => String(line || '').trim())
+ .filter(Boolean)
+ const keywords = [
+ 'wcdb_get_my_footprint_stats',
+ 'message_db_cache_refresh',
+ 'open_message_cursor',
+ 'open_message_cursor_lite',
+ 'cursor_init',
+ 'schema mismatch',
+ 'no message db',
+ 'get_sessions'
+ ]
+ const related = logs.filter((line) => {
+ const lowered = line.toLowerCase()
+ return keywords.some((keyword) => lowered.includes(keyword.toLowerCase()))
+ })
+
+ console.warn(
+ `[MyFootprint][native-log][${tag}] total=${logs.length} related=${related.length}`
+ )
+ const tail = related.slice(-240)
+ for (const line of tail) {
+ console.warn(`[MyFootprint][native-log] ${line}`)
+ }
+ } catch (error) {
+ console.warn(`[MyFootprint][native-log][${tag}] exception:`, error)
+ }
+ }
+
+ private async logMyFootprintNativeSqlProbe(sessionId: string, begin: number, end: number): Promise {
+ try {
+ const tables = await this.getSessionMessageTables(sessionId)
+ if (!Array.isArray(tables) || tables.length === 0) {
+ console.warn(`[MyFootprint][sql-probe][${sessionId}] no tables`)
+ return
+ }
+
+ const beginTs = this.normalizeTimestampSeconds(begin)
+ const endTs = this.normalizeTimestampSeconds(end)
+ const clauseTime = [
+ beginTs > 0 ? `"create_time" >= ${beginTs}` : '',
+ endTs > 0 ? `"create_time" <= ${endTs}` : ''
+ ].filter(Boolean).join(' AND ')
+ const whereParts: string[] = []
+ if (clauseTime) whereParts.push(clauseTime)
+ whereParts.push(`"source" IS NOT NULL`)
+ whereParts.push(`"source" != ''`)
+ whereParts.push(`(("message_content" IS NOT NULL AND "message_content" != '' AND (instr("message_content", '@') > 0 OR instr("message_content", '@') > 0)) OR instr(lower("source"), 'atuserlist') > 0)`)
+ const whereSql = whereParts.length > 0 ? ` WHERE ${whereParts.join(' AND ')}` : ''
+
+ let total = 0
+ for (const table of tables) {
+ const tableName = String(table.tableName || '').trim()
+ const dbPath = String(table.dbPath || '').trim()
+ if (!tableName || !dbPath) continue
+ const sql = `SELECT COUNT(1) AS cnt FROM ${this.quoteSqlIdentifier(tableName)}${whereSql}`
+ const result = await wcdbService.execQuery('message', dbPath, sql)
+ if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) {
+ console.warn(`[MyFootprint][sql-probe][${sessionId}] query failed db=${dbPath} table=${tableName} err=${result.error || 'unknown'}`)
+ continue
+ }
+ const cnt = this.toSafeInt((result.rows[0] as Record).cnt, 0)
+ total += cnt
+ if (cnt > 0) {
+ console.warn(`[MyFootprint][sql-probe][${sessionId}] db=${dbPath} table=${tableName} cnt=${cnt}`)
+ }
+ }
+ console.warn(`[MyFootprint][sql-probe][${sessionId}] total=${total}`)
+ } catch (error) {
+ console.warn(`[MyFootprint][sql-probe][${sessionId}] exception:`, error)
+ }
+ }
+
+ private async logMyFootprintNativeSingleGroupProbe(sessionId: string, begin: number, end: number, myWxid: string): Promise {
+ try {
+ const probeResult = await wcdbService.getMyFootprintStats({
+ beginTimestamp: begin,
+ endTimestamp: end,
+ myWxid,
+ privateSessionIds: [],
+ groupSessionIds: [sessionId],
+ mentionLimit: 0,
+ privateLimit: 0,
+ mentionMode: 'text_at_me'
+ })
+ if (!probeResult.success || !probeResult.data) {
+ console.warn(`[MyFootprint][single-native][${sessionId}] failed err=${probeResult.error || 'unknown'}`)
+ return
+ }
+
+ const raw = this.normalizeMyFootprintData(probeResult.data)
+ const first = raw.mentions[0]
+ console.warn(
+ `[MyFootprint][single-native][${sessionId}] mentions=${raw.mentions.length} groups=${raw.mention_groups.length} truncated=${raw.diagnostics.truncated} firstLocalId=${first?.local_id || 0} firstTs=${first?.create_time || 0}`
+ )
+ } catch (error) {
+ console.warn(`[MyFootprint][single-native][${sessionId}] exception:`, error)
+ }
+ }
+
+ private async getMyFootprintStatsByCursorFallback(params: {
+ begin: number
+ end: number
+ myWxid: string
+ privateSessionIds: string[]
+ groupSessionIds: string[]
+ mentionLimit: number
+ privateLimit: number
+ skipPrivateScan?: boolean
+ mentionScanLimitPerGroup?: number
+ }): Promise<{ success: boolean; data?: MyFootprintData; error?: string }> {
+ const startedAt = Date.now()
+ let truncated = false
+
+ try {
+ const privateSessionMap = new Map()
+ type PrivateSegmentWorking = {
+ segment_index: number
+ start_ts: number
+ end_ts: number
+ incoming_count: number
+ outgoing_count: number
+ first_incoming_ts: number
+ first_reply_ts: number
+ anchor_local_id: number
+ anchor_create_time: number
+ latest_local_id: number
+ latest_create_time: number
+ }
+ const privateSegments: MyFootprintPrivateSegment[] = []
+ const mentionGroupsMap = new Map()
+ const mentions: MyFootprintMentionItem[] = []
+ const mentionIdentitySet = this.buildMyFootprintIdentitySet(params.myWxid)
+ const mentionSourceMatchCache = new Map()
+ const mentionScanLimit = Number.isFinite(params.mentionScanLimitPerGroup as number)
+ ? Math.max(60, Math.floor(Number(params.mentionScanLimitPerGroup)))
+ : Math.max(params.mentionLimit * 12, 4000)
+ const privateScanLimitPerSession = Math.max(
+ 120,
+ Math.min(
+ 600,
+ Math.floor((params.privateLimit * 2) / Math.max(params.privateSessionIds.length || 1, 1))
+ )
+ )
+ const privateBatchSize = Math.min(200, privateScanLimitPerSession)
+ const privateSessionGapSeconds = 10 * 60
+ const mentionBatchSize = 360
+ const skipPrivateScan = params.skipPrivateScan === true
+
+ if (!skipPrivateScan) for (const sessionId of params.privateSessionIds) {
+ const cursorResult = await wcdbService.openMessageCursorLite(
+ sessionId,
+ privateBatchSize,
+ true,
+ params.begin,
+ params.end
+ )
+ if (!cursorResult.success || !cursorResult.cursor) continue
+
+ const stat: MyFootprintPrivateSession = {
+ session_id: sessionId,
+ incoming_count: 0,
+ outgoing_count: 0,
+ replied: false,
+ first_incoming_ts: 0,
+ first_reply_ts: 0,
+ latest_ts: 0,
+ anchor_local_id: 0,
+ anchor_create_time: 0
+ }
+ let segmentCursor = 0
+ let activeSegment: PrivateSegmentWorking | null = null
+ let lastSegmentMessageTs = 0
+ const commitActiveSegment = () => {
+ if (!activeSegment) return
+
+ const normalizedStart = activeSegment.start_ts > 0 ? activeSegment.start_ts : activeSegment.anchor_create_time
+ const normalizedEnd = activeSegment.end_ts > 0 ? activeSegment.end_ts : normalizedStart
+ const incomingCount = Math.max(0, activeSegment.incoming_count)
+ const outgoingCount = Math.max(0, activeSegment.outgoing_count)
+ const messageCount = incomingCount + outgoingCount
+ if (normalizedStart > 0 && messageCount > 0) {
+ privateSegments.push({
+ session_id: sessionId,
+ segment_index: activeSegment.segment_index,
+ start_ts: normalizedStart,
+ end_ts: normalizedEnd,
+ duration_sec: Math.max(0, normalizedEnd - normalizedStart),
+ incoming_count: incomingCount,
+ outgoing_count: outgoingCount,
+ message_count: messageCount,
+ replied: incomingCount > 0 && outgoingCount > 0,
+ first_incoming_ts: activeSegment.first_incoming_ts,
+ first_reply_ts: activeSegment.first_reply_ts,
+ latest_ts: normalizedEnd,
+ anchor_local_id: activeSegment.anchor_local_id,
+ anchor_create_time: normalizedStart
+ })
+ }
+ activeSegment = null
+ }
+
+ let processed = 0
+ let hasMore = true
+ try {
+ while (hasMore) {
+ const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor)
+ if (!batchResult.success || !Array.isArray(batchResult.rows)) {
+ break
+ }
+ hasMore = Boolean(batchResult.hasMore)
+ for (const row of batchResult.rows as Array>) {
+ if (processed >= privateScanLimitPerSession) {
+ if (hasMore || batchResult.rows.length > 0) truncated = true
+ hasMore = false
+ break
+ }
+ processed += 1
+
+ const createTime = this.toSafeInt(row.create_time, 0)
+ const localId = this.toSafeInt(row.local_id, 0)
+ const isSend = this.resolveFootprintRowIsSend(row, params.myWxid)
+
+ if (createTime > 0) {
+ const startNewSegment = !activeSegment
+ || (lastSegmentMessageTs > 0 && createTime - lastSegmentMessageTs > privateSessionGapSeconds)
+ if (startNewSegment) {
+ commitActiveSegment()
+ segmentCursor += 1
+ activeSegment = {
+ segment_index: segmentCursor,
+ start_ts: createTime,
+ end_ts: createTime,
+ incoming_count: 0,
+ outgoing_count: 0,
+ first_incoming_ts: 0,
+ first_reply_ts: 0,
+ anchor_local_id: localId,
+ anchor_create_time: createTime,
+ latest_local_id: localId,
+ latest_create_time: createTime
+ }
+ }
+ } else if (!activeSegment) {
+ segmentCursor += 1
+ activeSegment = {
+ segment_index: segmentCursor,
+ start_ts: 0,
+ end_ts: 0,
+ incoming_count: 0,
+ outgoing_count: 0,
+ first_incoming_ts: 0,
+ first_reply_ts: 0,
+ anchor_local_id: localId,
+ anchor_create_time: 0,
+ latest_local_id: localId,
+ latest_create_time: 0
+ }
+ }
+
+ if (isSend) {
+ stat.outgoing_count += 1
+ if (
+ createTime > 0
+ && stat.first_incoming_ts > 0
+ && createTime >= stat.first_incoming_ts
+ && stat.first_reply_ts <= 0
+ ) {
+ stat.first_reply_ts = createTime
+ }
+ if (activeSegment) {
+ activeSegment.outgoing_count += 1
+ if (
+ createTime > 0
+ && activeSegment.first_incoming_ts > 0
+ && createTime >= activeSegment.first_incoming_ts
+ && activeSegment.first_reply_ts <= 0
+ ) {
+ activeSegment.first_reply_ts = createTime
+ }
+ }
+ } else {
+ stat.incoming_count += 1
+ if (stat.first_incoming_ts <= 0 || (createTime > 0 && createTime < stat.first_incoming_ts)) {
+ stat.first_incoming_ts = createTime
+ }
+ if (activeSegment) {
+ activeSegment.incoming_count += 1
+ if (activeSegment.first_incoming_ts <= 0 || (createTime > 0 && createTime < activeSegment.first_incoming_ts)) {
+ activeSegment.first_incoming_ts = createTime
+ }
+ }
+ }
+
+ if (stat.latest_ts <= 0 || createTime > stat.latest_ts || (createTime === stat.latest_ts && localId > stat.anchor_local_id)) {
+ stat.latest_ts = createTime
+ stat.anchor_local_id = localId
+ stat.anchor_create_time = createTime
+ }
+
+ if (activeSegment && createTime > 0) {
+ activeSegment.end_ts = createTime
+ activeSegment.latest_create_time = createTime
+ activeSegment.latest_local_id = localId
+ lastSegmentMessageTs = createTime
+ }
+ }
+ }
+ if (hasMore) truncated = true
+ } finally {
+ await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {})
+ }
+ commitActiveSegment()
+ stat.replied = stat.incoming_count > 0 && stat.outgoing_count > 0
+
+ if (stat.incoming_count > 0 || stat.outgoing_count > 0 || stat.latest_ts > 0) {
+ privateSessionMap.set(sessionId, stat)
+ }
+ }
+
+ for (const sessionId of params.groupSessionIds) {
+ if (mentions.length >= params.mentionLimit) {
+ truncated = true
+ break
+ }
+ const cursorResult = await wcdbService.openMessageCursorLite(
+ sessionId,
+ mentionBatchSize,
+ false,
+ params.begin,
+ params.end
+ )
+ if (!cursorResult.success || !cursorResult.cursor) continue
+
+ let scanned = 0
+ let hasMore = true
+ try {
+ while (hasMore && scanned < mentionScanLimit) {
+ const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor)
+ if (!batchResult.success || !Array.isArray(batchResult.rows)) {
+ break
+ }
+ hasMore = Boolean(batchResult.hasMore)
+ for (const row of batchResult.rows as Array>) {
+ if (mentions.length >= params.mentionLimit) {
+ truncated = true
+ hasMore = false
+ break
+ }
+ scanned += 1
+ const messageContentRaw = row.message_content ?? row.messageContent ?? row.content
+ if (!this.footprintMessageLikelyContainsAt(messageContentRaw)) continue
+ const sourceRaw = row.source ?? row.msg_source ?? row.message_source
+ let sourceMatched = false
+ if (typeof sourceRaw === 'string') {
+ const sourceKey = sourceRaw
+ const cachedMatched = mentionSourceMatchCache.get(sourceKey)
+ if (cachedMatched !== undefined) {
+ sourceMatched = cachedMatched
+ } else {
+ sourceMatched = this.sourceAtUserListContainsWithIdentitySet(sourceRaw, mentionIdentitySet)
+ if (mentionSourceMatchCache.size < 8192) {
+ mentionSourceMatchCache.set(sourceKey, sourceMatched)
+ }
+ }
+ } else {
+ sourceMatched = this.sourceAtUserListContainsWithIdentitySet(sourceRaw, mentionIdentitySet)
+ }
+ if (!sourceMatched) continue
+ const normalizedSource = this.normalizeFootprintSourceForOutput(sourceRaw)
+
+ let senderUsername = String(row.sender_username || row.senderUsername || '').trim()
+ if (!senderUsername && row._db_path && row.real_sender_id) {
+ senderUsername = await this.resolveMessageSenderUsernameById(
+ String(row._db_path),
+ row.real_sender_id
+ ) || ''
+ }
+
+ const mention: MyFootprintMentionItem = {
+ session_id: sessionId,
+ local_id: this.toSafeInt(row.local_id, 0),
+ create_time: this.toSafeInt(row.create_time, 0),
+ sender_username: senderUsername,
+ message_content: String(row.message_content || row.messageContent || ''),
+ source: normalizedSource
+ }
+ mentions.push(mention)
+
+ const group = mentionGroupsMap.get(sessionId) || {
+ session_id: sessionId,
+ count: 0,
+ latest_ts: 0
+ }
+ group.count += 1
+ if (mention.create_time > group.latest_ts) group.latest_ts = mention.create_time
+ mentionGroupsMap.set(sessionId, group)
+ }
+ }
+ if (hasMore || scanned >= mentionScanLimit) {
+ truncated = true
+ }
+ } finally {
+ await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {})
+ }
+ }
+
+ mentions.sort((a, b) => {
+ if (b.create_time !== a.create_time) return b.create_time - a.create_time
+ return b.local_id - a.local_id
+ })
+ if (mentions.length > params.mentionLimit) {
+ mentions.length = params.mentionLimit
+ truncated = true
+ }
+
+ const private_sessions = Array.from(privateSessionMap.values())
+ .sort((a, b) => {
+ if (b.latest_ts !== a.latest_ts) return b.latest_ts - a.latest_ts
+ return a.session_id.localeCompare(b.session_id)
+ })
+ const private_segments = [...privateSegments]
+ .sort((a, b) => {
+ if (a.start_ts !== b.start_ts) return a.start_ts - b.start_ts
+ if (a.session_id !== b.session_id) return a.session_id.localeCompare(b.session_id)
+ return a.segment_index - b.segment_index
+ })
+ const mention_groups = Array.from(mentionGroupsMap.values())
+ .sort((a, b) => {
+ if (b.count !== a.count) return b.count - a.count
+ if (b.latest_ts !== a.latest_ts) return b.latest_ts - a.latest_ts
+ return a.session_id.localeCompare(b.session_id)
+ })
+
+ const private_inbound_people = private_sessions.filter((item) => item.incoming_count > 0).length
+ const private_replied_people = private_sessions.filter((item) => item.replied).length
+ const private_outbound_people = private_sessions.filter((item) => item.outgoing_count > 0).length
+ const mention_count = mention_groups.reduce((sum, item) => sum + item.count, 0)
+ const mention_group_count = mention_groups.length
+
+ const summary: MyFootprintSummary = {
+ private_inbound_people,
+ private_replied_people,
+ private_outbound_people,
+ private_reply_rate: private_inbound_people > 0 ? private_replied_people / private_inbound_people : 0,
+ mention_count,
+ mention_group_count
+ }
+
+ const diagnostics: MyFootprintDiagnostics = {
+ truncated,
+ scanned_dbs: 0,
+ elapsed_ms: Math.max(0, Date.now() - startedAt)
+ }
+
+ return {
+ success: true,
+ data: {
+ summary,
+ private_sessions,
+ private_segments,
+ mentions,
+ mention_groups,
+ diagnostics
+ }
+ }
+ } catch (error) {
+ return { success: false, error: String(error) }
+ }
+ }
+
+ private async enrichMyFootprintData(data: MyFootprintData): Promise {
+ try {
+ const sessionIds = Array.from(new Set([
+ ...data.private_sessions.map((item) => item.session_id),
+ ...data.private_segments.map((item) => item.session_id),
+ ...data.mention_groups.map((item) => item.session_id),
+ ...data.mentions.map((item) => item.session_id)
+ ].filter(Boolean)))
+ const senderUsernames = Array.from(new Set(
+ data.mentions
+ .map((item) => item.sender_username)
+ .filter((value) => String(value || '').trim())
+ ))
+
+ const usernames = Array.from(new Set([...sessionIds, ...senderUsernames]))
+ if (usernames.length === 0) return data
+
+ const enrichResult = await this.enrichSessionsContactInfo(usernames)
+ if (!enrichResult.success || !enrichResult.contacts) return data
+ const contacts = enrichResult.contacts
+
+ const nextPrivateSessions = data.private_sessions.map((item) => {
+ const contact = contacts[item.session_id]
+ return {
+ ...item,
+ displayName: contact?.displayName || item.displayName,
+ avatarUrl: contact?.avatarUrl || item.avatarUrl
+ }
+ })
+ const nextPrivateSegments = data.private_segments.map((item) => {
+ const contact = contacts[item.session_id]
+ return {
+ ...item,
+ displayName: contact?.displayName || item.displayName,
+ avatarUrl: contact?.avatarUrl || item.avatarUrl
+ }
+ })
+
+ const nextMentionGroups = data.mention_groups.map((item) => {
+ const contact = contacts[item.session_id]
+ return {
+ ...item,
+ displayName: contact?.displayName || item.displayName,
+ avatarUrl: contact?.avatarUrl || item.avatarUrl
+ }
+ })
+
+ const nextMentions = await Promise.all(data.mentions.map(async (item) => {
+ const sessionContact = contacts[item.session_id]
+ const senderContact = item.sender_username ? contacts[item.sender_username] : undefined
+
+ let normalizedContent = this.normalizeMyFootprintMentionContent(item.message_content)
+ if (this.isLikelyUnreadableFootprintContent(normalizedContent) && item.session_id && item.local_id > 0) {
+ const detailResult = await this.getMessageById(item.session_id, item.local_id)
+ if (detailResult.success && detailResult.message) {
+ const detailMessage = detailResult.message
+ const detailRaw = String(
+ detailMessage.rawContent
+ || detailMessage.content
+ || detailMessage.parsedContent
+ || ''
+ )
+ const resolvedFromDetail = this.normalizeMyFootprintMentionContent(detailRaw)
+ if (resolvedFromDetail && !this.isLikelyUnreadableFootprintContent(resolvedFromDetail)) {
+ normalizedContent = resolvedFromDetail
+ } else {
+ const parsedFallback = String(detailMessage.parsedContent || '').trim()
+ if (parsedFallback && !this.isLikelyUnreadableFootprintContent(parsedFallback)) {
+ normalizedContent = parsedFallback
+ }
+ }
+ }
+ }
+
+ return {
+ ...item,
+ message_content: normalizedContent,
+ sessionDisplayName: sessionContact?.displayName || item.sessionDisplayName,
+ senderDisplayName: senderContact?.displayName || item.senderDisplayName || item.sender_username,
+ senderAvatarUrl: senderContact?.avatarUrl || item.senderAvatarUrl
+ }
+ }))
+
+ return {
+ ...data,
+ private_sessions: nextPrivateSessions,
+ private_segments: nextPrivateSegments,
+ mention_groups: nextMentionGroups,
+ mentions: nextMentions
+ }
+ } catch (error) {
+ console.error('[ChatService] 补充我的足迹展示信息失败:', error)
+ return data
+ }
+ }
+
+ private normalizeMyFootprintMentionContent(rawContent: unknown): string {
+ const decodedRaw = this.decodeMaybeCompressed(rawContent, 'footprint_message_content')
+ let content = String(decodedRaw || rawContent || '')
+ if (!content) return ''
+
+ content = this.cleanUtf16(this.decodeHtmlEntities(content)).trim()
+ if (!content) return ''
+
+ const looksLikeXml = content.includes('')) return true
+ return false
+ }
+
+ private formatFootprintTime(timestamp: number): string {
+ if (!Number.isFinite(timestamp) || timestamp <= 0) return ''
+ const date = new Date(timestamp * 1000)
+ const y = date.getFullYear()
+ const m = `${date.getMonth() + 1}`.padStart(2, '0')
+ const d = `${date.getDate()}`.padStart(2, '0')
+ const hh = `${date.getHours()}`.padStart(2, '0')
+ const mm = `${date.getMinutes()}`.padStart(2, '0')
+ const ss = `${date.getSeconds()}`.padStart(2, '0')
+ return `${y}-${m}-${d} ${hh}:${mm}:${ss}`
+ }
+
+ private escapeCsvCell(value: unknown): string {
+ const text = String(value ?? '')
+ if (!text) return ''
+ if (!/[",\n\r]/.test(text)) return text
+ return `"${text.replace(/"/g, '""')}"`
+ }
+
+ private buildMyFootprintCsv(data: MyFootprintData): string {
+ const lines: string[] = []
+ const pushRow = (...columns: unknown[]) => {
+ lines.push(columns.map((value) => this.escapeCsvCell(value)).join(','))
+ }
+
+ pushRow('模块', '指标', '数值')
+ pushRow('summary', '私聊找我人数', data.summary.private_inbound_people)
+ pushRow('summary', '我回复人数', data.summary.private_replied_people)
+ pushRow('summary', '我主动联系人数', data.summary.private_outbound_people)
+ pushRow('summary', '私聊回复率', data.summary.private_reply_rate)
+ pushRow('summary', '@我次数', data.summary.mention_count)
+ pushRow('summary', '@我群聊数', data.summary.mention_group_count)
+ pushRow('summary', '诊断:是否截断', data.diagnostics.truncated ? 'true' : 'false')
+ pushRow('summary', '诊断:扫描分库数', data.diagnostics.scanned_dbs)
+ pushRow('summary', '诊断:耗时ms', data.diagnostics.elapsed_ms)
+
+ lines.push('')
+ pushRow('private_sessions', 'session_id', 'display_name', 'incoming_count', 'outgoing_count', 'replied', 'first_incoming_ts', 'first_reply_ts', 'latest_ts', 'anchor_local_id', 'anchor_create_time')
+ for (const row of data.private_sessions) {
+ pushRow(
+ 'private_sessions',
+ row.session_id,
+ row.displayName || '',
+ row.incoming_count,
+ row.outgoing_count,
+ row.replied ? 'true' : 'false',
+ this.formatFootprintTime(row.first_incoming_ts),
+ this.formatFootprintTime(row.first_reply_ts),
+ this.formatFootprintTime(row.latest_ts),
+ row.anchor_local_id,
+ row.anchor_create_time
+ )
+ }
+
+ lines.push('')
+ pushRow(
+ 'private_segments',
+ 'session_id',
+ 'display_name',
+ 'segment_index',
+ 'start_ts',
+ 'end_ts',
+ 'duration_sec',
+ 'incoming_count',
+ 'outgoing_count',
+ 'message_count',
+ 'replied',
+ 'first_incoming_ts',
+ 'first_reply_ts',
+ 'latest_ts',
+ 'anchor_local_id',
+ 'anchor_create_time'
+ )
+ for (const row of data.private_segments) {
+ pushRow(
+ 'private_segments',
+ row.session_id,
+ row.displayName || '',
+ row.segment_index,
+ this.formatFootprintTime(row.start_ts),
+ this.formatFootprintTime(row.end_ts),
+ row.duration_sec,
+ row.incoming_count,
+ row.outgoing_count,
+ row.message_count,
+ row.replied ? 'true' : 'false',
+ this.formatFootprintTime(row.first_incoming_ts),
+ this.formatFootprintTime(row.first_reply_ts),
+ this.formatFootprintTime(row.latest_ts),
+ row.anchor_local_id,
+ row.anchor_create_time
+ )
+ }
+
+ lines.push('')
+ pushRow('mentions', 'session_id', 'session_display_name', 'local_id', 'create_time', 'sender_username', 'sender_display_name', 'message_content', 'source')
+ for (const row of data.mentions) {
+ pushRow(
+ 'mentions',
+ row.session_id,
+ row.sessionDisplayName || '',
+ row.local_id,
+ this.formatFootprintTime(row.create_time),
+ row.sender_username,
+ row.senderDisplayName || '',
+ row.message_content,
+ row.source
+ )
+ }
+
+ lines.push('')
+ pushRow('mention_groups', 'session_id', 'display_name', 'count', 'latest_ts')
+ for (const row of data.mention_groups) {
+ pushRow(
+ 'mention_groups',
+ row.session_id,
+ row.displayName || '',
+ row.count,
+ this.formatFootprintTime(row.latest_ts)
+ )
+ }
+
+ return lines.join('\n')
+ }
+
private async parseMessage(row: any, options?: { source?: 'search' | 'detail'; sessionId?: string }): Promise {
const sourceInfo = this.getMessageSourceInfo(row)
const rawContent = this.decodeMessageContent(
diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts
index fde2ca7..116ba45 100644
--- a/electron/services/wcdbCore.ts
+++ b/electron/services/wcdbCore.ts
@@ -58,6 +58,7 @@ export class WcdbCore {
private wcdbGetAnnualReportExtras: any = null
private wcdbGetDualReportStats: any = null
private wcdbGetGroupStats: any = null
+ private wcdbGetMyFootprintStats: any = null
private wcdbGetMessageDates: any = null
private wcdbOpenMessageCursor: any = null
private wcdbOpenMessageCursorLite: any = null
@@ -127,6 +128,8 @@ export class WcdbCore {
private logTimer: NodeJS.Timeout | null = null
private lastLogTail: string | null = null
private lastResolvedLogPath: string | null = null
+ private lastCursorForceReopenAt = 0
+ private readonly cursorForceReopenCooldownMs = 15000
setPaths(resourcesPath: string, userDataPath: string): void {
this.resourcesPath = resourcesPath
@@ -923,6 +926,13 @@ export class WcdbCore {
this.wcdbGetGroupStats = null
}
+ // wcdb_status wcdb_get_my_footprint_stats(wcdb_handle handle, const char* options_json, char** out_json)
+ try {
+ this.wcdbGetMyFootprintStats = this.lib.func('int32 wcdb_get_my_footprint_stats(int64 handle, const char* optionsJson, _Out_ void** outJson)')
+ } catch {
+ this.wcdbGetMyFootprintStats = null
+ }
+
// wcdb_status wcdb_get_message_dates(wcdb_handle handle, const char* session_id, char** out_json)
try {
this.wcdbGetMessageDates = this.lib.func('int32 wcdb_get_message_dates(int64 handle, const char* sessionId, _Out_ void** outJson)')
@@ -3098,6 +3108,65 @@ export class WcdbCore {
}
}
+ async getMyFootprintStats(options: {
+ beginTimestamp?: number
+ endTimestamp?: number
+ myWxid?: string
+ privateSessionIds?: string[]
+ groupSessionIds?: string[]
+ mentionLimit?: number
+ privateLimit?: number
+ mentionMode?: 'text_at_me' | string
+ }): Promise<{ success: boolean; data?: any; error?: string }> {
+ if (!this.ensureReady()) {
+ return { success: false, error: 'WCDB 未连接' }
+ }
+ if (!this.wcdbGetMyFootprintStats) {
+ return { success: false, error: '接口未就绪' }
+ }
+
+ try {
+ const normalizedPrivateSessions = Array.from(new Set(
+ (options?.privateSessionIds || [])
+ .map((value) => String(value || '').trim())
+ .filter(Boolean)
+ ))
+ const normalizedGroupSessions = Array.from(new Set(
+ (options?.groupSessionIds || [])
+ .map((value) => String(value || '').trim())
+ .filter(Boolean)
+ ))
+ const mentionLimitRaw = Number(options?.mentionLimit ?? 0)
+ const privateLimitRaw = Number(options?.privateLimit ?? 0)
+ const mentionLimit = Number.isFinite(mentionLimitRaw) && mentionLimitRaw >= 0 ? Math.floor(mentionLimitRaw) : 0
+ const privateLimit = Number.isFinite(privateLimitRaw) && privateLimitRaw >= 0 ? Math.floor(privateLimitRaw) : 0
+
+ const payload = JSON.stringify({
+ begin: this.normalizeTimestamp(options?.beginTimestamp || 0),
+ end: this.normalizeTimestamp(options?.endTimestamp || 0),
+ my_wxid: String(options?.myWxid || '').trim(),
+ private_session_ids: normalizedPrivateSessions,
+ group_session_ids: normalizedGroupSessions,
+ mention_limit: mentionLimit,
+ private_limit: privateLimit,
+ mention_mode: options?.mentionMode || 'text_at_me'
+ })
+
+ const outPtr = [null as any]
+ const result = this.wcdbGetMyFootprintStats(this.handle, payload, outPtr)
+ if (result !== 0 || !outPtr[0]) {
+ return { success: false, error: `获取我的足迹统计失败: ${result}` }
+ }
+ const jsonStr = this.decodeJsonPtr(outPtr[0])
+ if (!jsonStr) {
+ return { success: false, error: '解析我的足迹统计失败' }
+ }
+ return { success: true, data: JSON.parse(jsonStr) || {} }
+ } catch (e) {
+ return { success: false, error: String(e) }
+ }
+ }
+
/**
* 强制重新打开账号连接(绕过路径缓存),用于微信重装后消息数据库刷新失败时的自动恢复。
* 返回重新打开是否成功。
@@ -3119,6 +3188,15 @@ export class WcdbCore {
return this.open(path, key, wxid)
}
+ private shouldRetryCursorAfterNoDb(): boolean {
+ const now = Date.now()
+ if (now - this.lastCursorForceReopenAt < this.cursorForceReopenCooldownMs) {
+ return false
+ }
+ this.lastCursorForceReopenAt = now
+ return true
+ }
+
async openMessageCursor(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
@@ -3136,7 +3214,7 @@ export class WcdbCore {
)
// result=-3 表示 WCDB_STATUS_NO_MESSAGE_DB:消息数据库缓存为空(常见于微信重装后)
// 自动强制重连并重试一次
- if (result === -3 && outCursor[0] <= 0) {
+ if (result === -3 && outCursor[0] <= 0 && this.shouldRetryCursorAfterNoDb()) {
this.writeLog('openMessageCursor: result=-3 (no message db), attempting forceReopen...', true)
const reopened = await this.forceReopen()
if (reopened && this.handle !== null) {
@@ -3156,11 +3234,13 @@ export class WcdbCore {
}
}
if (result !== 0 || outCursor[0] <= 0) {
- await this.printLogs(true)
- this.writeLog(
- `openMessageCursor failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
- true
- )
+ if (result !== -3) {
+ await this.printLogs(true)
+ this.writeLog(
+ `openMessageCursor failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
+ true
+ )
+ }
const hint = result === -3
? `创建游标失败: ${result}(消息数据库未找到)。如果你最近重装过微信,请尝试重新指定数据目录后重试`
: result === -7
@@ -3197,7 +3277,7 @@ export class WcdbCore {
// result=-3 表示 WCDB_STATUS_NO_MESSAGE_DB:消息数据库缓存为空
// 自动强制重连并重试一次
- if (result === -3 && outCursor[0] <= 0) {
+ if (result === -3 && outCursor[0] <= 0 && this.shouldRetryCursorAfterNoDb()) {
this.writeLog('openMessageCursorLite: result=-3 (no message db), attempting forceReopen...', true)
const reopened = await this.forceReopen()
if (reopened && this.handle !== null) {
@@ -3218,11 +3298,13 @@ export class WcdbCore {
}
if (result !== 0 || outCursor[0] <= 0) {
- await this.printLogs(true)
- this.writeLog(
- `openMessageCursorLite failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
- true
- )
+ if (result !== -3) {
+ await this.printLogs(true)
+ this.writeLog(
+ `openMessageCursorLite failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
+ true
+ )
+ }
if (result === -7) {
return { success: false, error: 'message schema mismatch:当前账号消息表结构与程序要求不一致' }
}
diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts
index 5e7478c..d4c77ef 100644
--- a/electron/services/wcdbService.ts
+++ b/electron/services/wcdbService.ts
@@ -448,6 +448,19 @@ export class WcdbService {
return this.callWorker('getGroupStats', { chatroomId, beginTimestamp, endTimestamp })
}
+ async getMyFootprintStats(options: {
+ beginTimestamp?: number
+ endTimestamp?: number
+ myWxid?: string
+ privateSessionIds?: string[]
+ groupSessionIds?: string[]
+ mentionLimit?: number
+ privateLimit?: number
+ mentionMode?: 'text_at_me' | string
+ }): Promise<{ success: boolean; data?: any; error?: string }> {
+ return this.callWorker('getMyFootprintStats', { options })
+ }
+
/**
* 打开消息游标
*/
diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts
index a666732..2992d01 100644
--- a/electron/wcdbWorker.ts
+++ b/electron/wcdbWorker.ts
@@ -158,6 +158,9 @@ if (parentPort) {
case 'getGroupStats':
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
break
+ case 'getMyFootprintStats':
+ result = await core.getMyFootprintStats(payload.options || {})
+ break
case 'openMessageCursor':
result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
break
diff --git a/resources/wcdb/linux/x64/libwcdb_api.so b/resources/wcdb/linux/x64/libwcdb_api.so
index 8f698f3039b986a74de1dcebbed45bd01dc41bc7..63149bcba84bdd3e6b9e4fed0133590f9d8e082a 100644
GIT binary patch
delta 3318142
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