diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..bccc91c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,103 @@ +name: "报告 Bug" +description: "代码出现了非预期的问题、崩溃或报错" +title: "[Bug]: " +labels: ["type: bug", "status: needs info"] +body: + - type: markdown + attributes: + value: | + 请提供尽可能详细的信息,帮助我们快速定位和修复问题。 + - type: checkboxes + id: pre-check + attributes: + label: 提交前确认 + description: 请务必确认以下事项 + options: + - label: 我已搜索过现有的 Issues,确认这不是重复问题 + required: true + - label: 我使用的是最新版本 + required: true + - label: 我已阅读过相关文档 + required: true + - type: dropdown + id: severity + attributes: + label: 问题严重程度 + description: 这个问题对你的使用造成了多大影响? + options: + - 严重崩溃或数据丢失(无法使用) + - 核心功能受影响(在下一个常规发布中必须修复) + - 边缘场景或轻微问题(等待空闲时修复) + validations: + required: true + - type: textarea + id: description + attributes: + label: 问题描述 + description: 清晰描述你遇到的问题,包括实际发生了什么 + placeholder: 例如:当我点击发送按钮时,应用程序崩溃并显示白屏 + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: 复现步骤 + description: 提供详细的操作步骤,让我们能够重现这个问题 + placeholder: | + 1. 打开应用并登录账号 + 2. 进入聊天页面 + 3. 点击发送按钮 + 4. 观察到应用崩溃 + validations: + required: true + - type: textarea + id: expected-behavior + attributes: + label: 预期行为 + description: 描述你期望的正确行为应该是什么样的 + placeholder: 例如:点击发送按钮后,消息应该正常发送并显示在聊天窗口中 + validations: + required: true + - type: textarea + id: actual-behavior + attributes: + label: 实际行为 + description: 描述实际发生的错误行为 + placeholder: 例如:点击后应用直接崩溃,显示白屏 + validations: + required: true + - type: textarea + id: logs + attributes: + label: 错误日志或截图 + description: 粘贴控制台错误信息、崩溃日志,或拖入截图 + placeholder: 请粘贴完整的错误堆栈信息 + render: shell + - type: input + id: os + attributes: + label: 操作系统 + description: 例如:Windows 11、macOS 14.2、Ubuntu 22.04 + placeholder: Windows 11 + validations: + required: true + - type: input + id: app-version + attributes: + label: 应用版本 + description: 在关于页面或设置中查看版本号 + placeholder: v1.2.3 + validations: + required: true + - type: input + id: architecture + attributes: + label: 系统架构 + description: 例如:x64、arm64 + placeholder: x64 + - type: textarea + id: additional-context + attributes: + label: 补充信息 + description: 其他可能有助于定位问题的信息 + placeholder: 例如:这个问题是在某次更新后开始出现的,或者只在特定网络环境下出现 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3e9a940 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name:🤔 找不到合适的模板? + url: https://t.me/weflow_cc + about: 如果你的问题不属于上述任何分类,请前往我们的 Telegram 频道与我们交流。 diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml new file mode 100644 index 0000000..554c7f0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -0,0 +1,67 @@ +name: "文档反馈" +description: "文档存在错别字、描述不清晰或缺少必要的示例" +title: "[Docs]: " +labels: ["type: docs"] +body: + - type: markdown + attributes: + value: | + 优秀的文档和代码一样重要。感谢你帮助我们完善文档! + - type: dropdown + id: doc-type + attributes: + label: 文档类型 + description: 问题出现在哪类文档中? + options: + - README 或项目说明 + - 安装部署文档 + - 使用教程 + - API 文档 + - 开发者文档 + - 其他 + validations: + required: true + - type: input + id: doc-link + attributes: + label: 文档位置 + description: 提供文档的 URL 或文件路径 + placeholder: 例如:docs/installation.md 或 https://github.com/xxx/xxx/wiki/xxx + validations: + required: true + - type: dropdown + id: issue-type + attributes: + label: 问题类型 + description: 文档存在什么问题? + options: + - 错别字或语法错误 + - 内容过时或不准确 + - 描述不清晰或有歧义 + - 缺少必要的示例代码 + - 缺少重要的说明或警告 + - 链接失效或错误 + - 其他 + validations: + required: true + - type: textarea + id: issue-desc + attributes: + label: 问题描述 + description: 详细说明文档中存在的问题 + placeholder: 例如:第 3 步中的命令拼写错误,应该是 "npm install" 而不是 "npm instal" + validations: + required: true + - type: textarea + id: suggestion + attributes: + label: 修改建议 + description: 你认为应该如何修改? + placeholder: 例如:建议将"安装依赖"部分补充完整的命令示例,并说明不同操作系统的差异 + validations: + required: true + - type: textarea + id: additional + attributes: + label: 补充说明 + description: 其他需要补充的信息 diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml new file mode 100644 index 0000000..97ff477 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement.yml @@ -0,0 +1,78 @@ +name: "功能与体验优化" +description: "对现有的功能逻辑进行优化,或改进用户体验" +title: "[Enhancement]: " +labels: ["type: enhancement"] +body: + - type: markdown + attributes: + value: | + 持续优化是项目进步的动力!请告诉我们哪个现有功能可以做得更好。 + - type: checkboxes + id: pre-check + attributes: + label: 提交前确认 + options: + - label: 我已搜索过现有的 Issues,确认这个优化建议尚未被提出 + required: true + - label: 这是对现有功能的改进,而不是全新功能 + required: true + - type: dropdown + id: category + attributes: + label: 优化类别 + description: 这个优化主要属于哪个方面? + options: + - 性能优化(速度、内存、资源占用) + - 交互体验(操作流程、界面布局) + - 视觉设计(样式、动画、美观度) + - 易用性(降低使用门槛、减少操作步骤) + - 稳定性(减少崩溃、提高可靠性) + - 其他 + validations: + required: true + - type: textarea + id: target + attributes: + label: 目标功能或模块 + description: 你希望优化的具体功能或页面是哪个? + placeholder: 例如:聊天页面的消息加载、设置页面的布局、文件上传功能 + validations: + required: true + - type: textarea + id: current-behavior + attributes: + label: 当前表现 + description: 描述当前功能的不足之处或存在的问题 + placeholder: 例如:消息列表滚动时会出现明显卡顿,加载 100 条消息需要 3 秒 + validations: + required: true + - type: textarea + id: improvement + attributes: + label: 优化建议 + description: 详细说明你的优化方案和预期效果 + placeholder: 例如:建议使用虚拟滚动技术,只渲染可见区域的消息,预计可将加载时间缩短到 0.5 秒以内 + validations: + required: true + - type: textarea + id: benefits + attributes: + label: 优化收益 + description: 这个优化会带来什么具体好处? + placeholder: 例如:提升 80% 的加载速度、减少 50% 的内存占用、降低用户操作步骤从 5 步到 2 步 + validations: + required: true + - type: textarea + id: impact + attributes: + label: 影响范围 + description: 这个优化会影响哪些用户或场景? + placeholder: 例如:所有用户在查看历史消息时都会受益,尤其是群聊消息较多的场景 + - type: checkboxes + id: contribution + attributes: + label: 参与贡献 + options: + - label: 我愿意提交 Pull Request 来实现这个优化 + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..12352b4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,71 @@ +name: "全新功能请求" +description: "提议一个目前项目中完全没有的新特性" +title: "[Feature]: " +labels: ["type: feature"] +body: + - type: markdown + attributes: + value: | + 感谢你为项目提供新想法!详细的需求描述能极大提高该功能被采纳的几率。 + - type: checkboxes + id: pre-check + attributes: + label: 提交前确认 + options: + - label: 我已搜索过现有的 Issues 和 Pull Requests,确认这个功能尚未被提出或实现 + required: true + - label: 这是一个全新的功能,而不是对现有功能的改进 + required: true + - type: dropdown + id: priority + attributes: + label: 功能优先级 + description: 你认为这个功能有多重要? + options: + - 高优先级(核心功能缺失,严重影响使用体验) + - 中优先级(有助于提升使用体验) + - 低优先级(锦上添花的功能) + validations: + required: true + - type: textarea + id: problem + attributes: + label: 问题或痛点 + description: 【为什么需要】你现在做某件事遇到了什么困难?缺少什么能力? + placeholder: 例如:目前无法批量导出聊天记录,每次只能手动复制单条消息,处理 100 条消息需要半小时 + validations: + required: true + - type: textarea + id: solution + attributes: + label: 期望的解决方案 + description: 【怎么实现】详细描述功能的操作流程、界面位置、可选参数等 + placeholder: 例如:在聊天窗口右键菜单添加"导出记录",点击后弹窗可选时间范围、导出格式(TXT/JSON)、筛选用户,最后保存到本地 + validations: + required: true + - type: textarea + id: use-case + attributes: + label: 使用场景 + description: 【什么时候用】你会在哪些具体情况下使用这个功能? + placeholder: 例如:每周五整理工作讨论记录;保存客户沟通记录作为合同依据;备份重要群聊内容 + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: 替代方案 + description: 你目前使用什么临时方案?或者有没有考虑过其他实现方式? + placeholder: 例如:目前只能手动截图或逐条复制粘贴 + - type: textarea + id: reference + attributes: + label: 参考示例 + description: 其他应用中是否有类似功能可以参考? + placeholder: 例如:微信的聊天记录导出功能、Telegram 的导出数据功能 + - type: checkboxes + id: contribution + attributes: + label: 参与贡献 + options: + - label: 我愿意提交 Pull Request 来实现这个功能 diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..2540d6d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,71 @@ +name: "使用答疑" +description: "关于如何配置、如何使用项目的求助" +title: "[Question]: " +labels: ["type: question"] +body: + - type: markdown + attributes: + value: | + 在提问之前,请确保你已经仔细阅读过我们的官方文档。 + - type: checkboxes + id: pre-check + attributes: + label: 提交前确认 + options: + - label: 我已阅读过相关文档 + required: true + - label: 我已搜索过现有的 Issues,没有找到类似问题 + required: true + - type: dropdown + id: question-type + attributes: + label: 问题类型 + description: 你的问题属于哪个方面? + options: + - 安装部署问题 + - 配置相关问题 + - 功能使用问题 + - API 调用问题 + - 错误排查问题 + - 其他 + validations: + required: true + - type: textarea + id: question + attributes: + label: 问题描述 + description: 清晰描述你遇到的问题或疑问 + placeholder: 例如:我在 Windows 系统上安装后无法启动应用,双击图标没有任何反应 + validations: + required: true + - type: textarea + id: attempts + attributes: + label: 已尝试的方法 + description: 你已经尝试过哪些解决方法? + placeholder: 例如:我尝试过重新安装、以管理员身份运行、关闭防火墙,但问题依然存在 + validations: + required: true + - type: textarea + id: environment + attributes: + label: 运行环境 + description: 提供你的系统环境信息 + placeholder: | + 操作系统:Windows 11 + 应用版本:v1.2.3 + 系统架构:x64 + validations: + required: true + - type: textarea + id: code-snippet + attributes: + label: 相关配置或代码 + description: 如果涉及配置或代码问题,请粘贴相关内容 + placeholder: 粘贴你的配置文件或代码片段 + render: javascript + - type: textarea + id: screenshots + attributes: + label: 截图或日志 + description: 如有必要,请提供截图或错误日志 diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index ee499cd..f2f508d 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -234,6 +234,8 @@ class ChatService { // 缓存会话表信息,避免每次查询 private sessionTablesCache = new Map>() private messageTableColumnsCache = new Map; updatedAt: number }>() + private messageName2IdTableCache = new Map() + private messageSenderIdCache = new Map() private readonly sessionTablesCacheTtl = 300000 // 5分钟 private readonly messageTableColumnsCacheTtlMs = 30 * 60 * 1000 private sessionMessageCountCache = new Map() @@ -1990,6 +1992,62 @@ class ChatService { return [lowerRaw] } + private resolveMessageIsSend(rawIsSend: number | null, senderUsername?: string | null): { + isSend: number | null + selfMatched: boolean + correctedBySelfIdentity: boolean + } { + const normalizedRawIsSend = Number.isFinite(rawIsSend as number) ? rawIsSend : null + const senderKeys = this.buildIdentityKeys(String(senderUsername || '')) + if (senderKeys.length === 0) { + return { + isSend: normalizedRawIsSend, + selfMatched: false, + correctedBySelfIdentity: false + } + } + + const myWxid = String(this.configService.get('myWxid') || '').trim() + const selfKeys = this.buildIdentityKeys(myWxid) + if (selfKeys.length === 0) { + return { + isSend: normalizedRawIsSend, + selfMatched: false, + correctedBySelfIdentity: false + } + } + + const selfMatched = senderKeys.some(senderKey => + selfKeys.some(selfKey => + senderKey === selfKey || + senderKey.startsWith(selfKey + '_') || + selfKey.startsWith(senderKey + '_') + ) + ) + + if (selfMatched && normalizedRawIsSend !== 1) { + return { + isSend: 1, + selfMatched: true, + correctedBySelfIdentity: true + } + } + + if (normalizedRawIsSend === null) { + return { + isSend: selfMatched ? 1 : 0, + selfMatched, + correctedBySelfIdentity: false + } + } + + return { + isSend: normalizedRawIsSend, + selfMatched, + correctedBySelfIdentity: false + } + } + private extractGroupMemberUsername(member: any): string { if (!member) return '' if (typeof member === 'string') return member.trim() @@ -3048,9 +3106,6 @@ class ChatService { private mapRowsToMessages(rows: Record[]): Message[] { const myWxid = this.configService.get('myWxid') - const cleanedWxid = myWxid ? this.cleanAccountDirName(myWxid) : null - const myWxidLower = myWxid ? myWxid.toLowerCase() : null - const cleanedWxidLower = cleanedWxid ? cleanedWxid.toLowerCase() : null const messages: Message[] = [] for (const row of rows) { @@ -3075,30 +3130,14 @@ class ChatService { const content = this.decodeMessageContent(rawMessageContent, rawCompressContent); const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']) - let isSend = isSendRaw === null ? null : parseInt(isSendRaw, 10) + const parsedRawIsSend = isSendRaw === null ? null : parseInt(isSendRaw, 10) const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || this.extractSenderUsernameFromContent(content) || null + const { isSend } = this.resolveMessageIsSend(parsedRawIsSend, senderUsername) const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0) - if (senderUsername && (myWxidLower || cleanedWxidLower)) { - const senderLower = String(senderUsername).toLowerCase() - const expectedIsSend = ( - senderLower === myWxidLower || - senderLower === cleanedWxidLower || - // 兼容非 wxid 开头的账号(如果文件夹名带后缀,如 custom_backup,而 sender 是 custom) - (myWxidLower && myWxidLower.startsWith(senderLower + '_')) || - (cleanedWxidLower && cleanedWxidLower.startsWith(senderLower + '_')) - ) ? 1 : 0 - if (isSend === null) { - isSend = expectedIsSend - // [DEBUG] Issue #34: 记录 isSend 推断过程 - if (expectedIsSend === 0 && localType === 1) { - // 仅在被判为接收且是文本消息时记录,避免刷屏 - // - } - } - } else if (senderUsername && !myWxid) { + if (senderUsername && !myWxid) { // [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送 if (messages.length < 5) { console.warn(`[ChatService] Warning: myWxid not set. Cannot determine if message is sent by me. sender=${senderUsername}`) @@ -4421,6 +4460,75 @@ class ChatService { return result.rows[0]?.name || null } + private async resolveMessageName2IdTableName(dbPath: string): Promise { + const normalizedDbPath = String(dbPath || '').trim() + if (!normalizedDbPath) return null + if (this.messageName2IdTableCache.has(normalizedDbPath)) { + return this.messageName2IdTableCache.get(normalizedDbPath) || null + } + + const result = await wcdbService.execQuery( + 'message', + normalizedDbPath, + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%' ORDER BY name DESC LIMIT 1" + ) + const tableName = result.success && result.rows && result.rows.length > 0 + ? String(result.rows[0]?.name || '').trim() || null + : null + this.messageName2IdTableCache.set(normalizedDbPath, tableName) + return tableName + } + + private async resolveMessageSenderUsernameById(dbPath: string, senderId: unknown): Promise { + const normalizedDbPath = String(dbPath || '').trim() + const numericSenderId = Number.parseInt(String(senderId ?? '').trim(), 10) + if (!normalizedDbPath || !Number.isFinite(numericSenderId) || numericSenderId <= 0) { + return null + } + + const cacheKey = `${normalizedDbPath}::${numericSenderId}` + if (this.messageSenderIdCache.has(cacheKey)) { + return this.messageSenderIdCache.get(cacheKey) || null + } + + const name2IdTable = await this.resolveMessageName2IdTableName(normalizedDbPath) + if (!name2IdTable) { + this.messageSenderIdCache.set(cacheKey, null) + return null + } + + const escapedTableName = String(name2IdTable).replace(/"/g, '""') + const result = await wcdbService.execQuery( + 'message', + normalizedDbPath, + `SELECT user_name FROM "${escapedTableName}" WHERE rowid = ${numericSenderId} LIMIT 1` + ) + const username = result.success && result.rows && result.rows.length > 0 + ? String(result.rows[0]?.user_name || result.rows[0]?.userName || '').trim() || null + : null + this.messageSenderIdCache.set(cacheKey, username) + return username + } + + private async resolveSenderUsernameForMessageRow( + row: Record, + rawContent: string + ): Promise { + const directSender = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) + || this.extractSenderUsernameFromContent(rawContent) + if (directSender) { + return directSender + } + + const dbPath = this.getRowField(row, ['db_path', 'dbPath', '_db_path']) + const realSenderId = this.getRowField(row, ['real_sender_id', 'realSenderId']) + if (!dbPath || realSenderId === null || realSenderId === undefined || String(realSenderId).trim() === '') { + return null + } + + return this.resolveMessageSenderUsernameById(String(dbPath), realSenderId) + } + /** * 判断是否像 wxid */ @@ -6690,7 +6798,7 @@ class ChatService { db_path: dbPath, table_name: tableName } - const message = this.parseMessage(row) + const message = await this.parseMessage(row, { source: 'detail', sessionId }) if (message.localId !== 0) { return { success: true, message } @@ -6711,7 +6819,45 @@ class ChatService { if (!result.success || !result.messages) { return { success: false, error: result.error || '搜索失败' } } - const messages = result.messages.map((row: any) => this.parseMessage(row)).filter(Boolean) as Message[] + const messages: Message[] = [] + const isGroupSearch = Boolean(String(sessionId || '').trim().endsWith('@chatroom')) + + for (const row of result.messages) { + let message = await this.parseMessage(row, { source: 'search', sessionId }) + const needsDetailHydration = isGroupSearch && + Boolean(sessionId) && + message.localId > 0 && + (!message.senderUsername || message.isSend === null) + + if (needsDetailHydration && sessionId) { + const detail = await this.getMessageById(sessionId, message.localId) + if (detail.success && detail.message) { + message = { + ...message, + ...detail.message, + parsedContent: message.parsedContent || detail.message.parsedContent, + rawContent: message.rawContent || detail.message.rawContent, + content: message.content || detail.message.content + } + } + } + + if (isGroupSearch && (needsDetailHydration || message.isSend === 1)) { + console.info('[ChatService][GroupSearchHydratedHit]', { + sessionId, + localId: message.localId, + senderUsername: message.senderUsername, + isSend: message.isSend, + senderDisplayName: message.senderDisplayName, + senderAvatarUrl: message.senderAvatarUrl, + usedDetailHydration: needsDetailHydration, + parsedContent: message.parsedContent + }) + } + + messages.push(message) + } + return { success: true, messages } } catch (e) { console.error('ChatService: searchMessages 失败:', e) @@ -6719,7 +6865,7 @@ class ChatService { } } - private parseMessage(row: any): Message { + private async parseMessage(row: any, options?: { source?: 'search' | 'detail'; sessionId?: string }): Promise { const sourceInfo = this.getMessageSourceInfo(row) const rawContent = this.decodeMessageContent( this.getRowField(row, [ @@ -6746,9 +6892,9 @@ class ChatService { const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0) const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0) const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime) - const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) - || this.extractSenderUsernameFromContent(rawContent) - || null + const rawIsSend = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']) + const senderUsername = await this.resolveSenderUsernameForMessageRow(row, rawContent) + const sendState = this.resolveMessageIsSend(rawIsSend === null ? null : parseInt(rawIsSend, 10), senderUsername) const msg: Message = { messageKey: this.buildMessageKey({ localId, @@ -6764,7 +6910,7 @@ class ChatService { localType, createTime, sortSeq, - isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0), + isSend: sendState.isSend, senderUsername, rawContent: rawContent, content: rawContent, // 添加原始内容供视频MD5解析使用 @@ -6785,6 +6931,19 @@ class ChatService { }) } + if (options?.source === 'search' && String(options.sessionId || '').endsWith('@chatroom') && sendState.selfMatched) { + console.info('[ChatService][GroupSearchSelfHit]', { + sessionId: options.sessionId, + localId, + createTime, + senderUsername, + rawIsSend, + resolvedIsSend: sendState.isSend, + correctedBySelfIdentity: sendState.correctedBySelfIdentity, + rowKeys: Object.keys(row) + }) + } + // 图片/语音解析逻辑 (简化示例,实际应调用现有解析方法) if (msg.localType === 3) { // Image const imgInfo = this.parseImageInfo(rawContent) diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 6132a0a..36e784b 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -116,6 +116,40 @@ function resolveSearchSenderUsernameFallback(value?: string | null): string | un return normalized } +function buildSearchIdentityCandidates(value?: string | null): string[] { + const normalized = normalizeSearchIdentityText(value) + if (!normalized) return [] + const lower = normalized.toLowerCase() + const candidates = new Set([lower]) + if (lower.startsWith('wxid_')) { + const match = lower.match(/^(wxid_[^_]+)/i) + if (match?.[1]) { + candidates.add(match[1]) + } + } + return [...candidates] +} + +function isCurrentUserSearchIdentity( + senderUsername?: string | null, + myWxid?: string | null +): boolean { + const senderCandidates = buildSearchIdentityCandidates(senderUsername) + const selfCandidates = buildSearchIdentityCandidates(myWxid) + if (senderCandidates.length === 0 || selfCandidates.length === 0) { + return false + } + + for (const sender of senderCandidates) { + for (const self of selfCandidates) { + if (sender === self) return true + if (sender.startsWith(self + '_')) return true + if (self.startsWith(sender + '_')) return true + } + } + return false +} + interface XmlField { key: string; value: string; @@ -2764,6 +2798,7 @@ function ChatPage(props: ChatPageProps) { const { normalizedSessionId, isDirectSearchSession, + isGroupSearchSession, resolvedSessionDisplayName, resolvedSessionAvatarUrl } = resolveSearchSessionContext(sessionId) @@ -2771,6 +2806,7 @@ function ChatPage(props: ChatPageProps) { return sortedMessages.map((message) => { const senderUsername = normalizeSearchIdentityText(message.senderUsername) || message.senderUsername + const inferredSelfFromSender = isGroupSearchSession && isCurrentUserSearchIdentity(senderUsername, myWxid) const senderDisplayName = resolveSearchSenderDisplayName( message.senderDisplayName, senderUsername, @@ -2778,7 +2814,8 @@ function ChatPage(props: ChatPageProps) { ) const senderUsernameFallback = resolveSearchSenderUsernameFallback(senderUsername) const senderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl) - const nextSenderDisplayName = message.isSend === 1 + const nextIsSend = inferredSelfFromSender ? 1 : message.isSend + const nextSenderDisplayName = nextIsSend === 1 ? (senderDisplayName || '我') : ( senderDisplayName || @@ -2787,12 +2824,29 @@ function ChatPage(props: ChatPageProps) { (isDirectSearchSession ? resolvedSessionUsernameFallback : undefined) || '未知' ) - const nextSenderAvatarUrl = message.isSend === 1 + const nextSenderAvatarUrl = nextIsSend === 1 ? (senderAvatarUrl || myAvatarUrl) : (senderAvatarUrl || (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined)) + if (inferredSelfFromSender) { + console.info('[InSessionSearch][GroupSelfHit][hydrate]', { + sessionId: normalizedSessionId, + localId: message.localId, + senderUsername, + rawIsSend: message.isSend, + nextIsSend, + rawSenderDisplayName: message.senderDisplayName, + nextSenderDisplayName, + rawSenderAvatarUrl: message.senderAvatarUrl, + nextSenderAvatarUrl, + myWxid, + hasMyAvatarUrl: Boolean(myAvatarUrl) + }) + } + if ( senderUsername === message.senderUsername && + nextIsSend === message.isSend && nextSenderDisplayName === message.senderDisplayName && nextSenderAvatarUrl === message.senderAvatarUrl ) { @@ -2801,12 +2855,13 @@ function ChatPage(props: ChatPageProps) { return { ...message, + isSend: nextIsSend, senderUsername, senderDisplayName: nextSenderDisplayName, senderAvatarUrl: nextSenderAvatarUrl } }) - }, [currentSessionId, myAvatarUrl, resolveSearchSessionContext]) + }, [currentSessionId, myAvatarUrl, myWxid, resolveSearchSessionContext]) const enrichMessagesWithSenderProfiles = useCallback(async (rawMessages: Message[], sessionId?: string) => { let messages = hydrateInSessionSearchResults(rawMessages, sessionId) @@ -2962,6 +3017,7 @@ function ChatPage(props: ChatPageProps) { return messages.map((message) => { const sender = normalizeSearchIdentityText(message.senderUsername) const profile = sender ? profileMap.get(sender) : undefined + const inferredSelfFromSender = isGroupSearchSession && isCurrentUserSearchIdentity(sender, myWxid) const profileDisplayName = resolveSearchSenderDisplayName( profile?.displayName, sender, @@ -2975,7 +3031,8 @@ function ChatPage(props: ChatPageProps) { const senderUsernameFallback = resolveSearchSenderUsernameFallback(sender) const sessionUsernameFallback = resolveSearchSenderUsernameFallback(normalizedSessionId) const currentSenderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl) - const nextSenderDisplayName = message.isSend === 1 + const nextIsSend = inferredSelfFromSender ? 1 : message.isSend + const nextSenderDisplayName = nextIsSend === 1 ? (currentSenderDisplayName || profileDisplayName || '我') : ( profileDisplayName || @@ -2985,7 +3042,7 @@ function ChatPage(props: ChatPageProps) { (isDirectSearchSession ? sessionUsernameFallback : undefined) || '未知' ) - const nextSenderAvatarUrl = message.isSend === 1 + const nextSenderAvatarUrl = nextIsSend === 1 ? (currentSenderAvatarUrl || myAvatarUrl || normalizeSearchAvatarUrl(profile?.avatarUrl)) : ( currentSenderAvatarUrl || @@ -2993,8 +3050,27 @@ function ChatPage(props: ChatPageProps) { (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined) ) + if (inferredSelfFromSender) { + console.info('[InSessionSearch][GroupSelfHit][enrich]', { + sessionId: normalizedSessionId, + localId: message.localId, + senderUsername: sender, + rawIsSend: message.isSend, + nextIsSend, + profileDisplayName, + currentSenderDisplayName, + nextSenderDisplayName, + profileAvatarUrl: normalizeSearchAvatarUrl(profile?.avatarUrl), + currentSenderAvatarUrl, + nextSenderAvatarUrl, + myWxid, + hasMyAvatarUrl: Boolean(myAvatarUrl) + }) + } + if ( sender === message.senderUsername && + nextIsSend === message.isSend && nextSenderDisplayName === message.senderDisplayName && nextSenderAvatarUrl === message.senderAvatarUrl ) { @@ -3003,6 +3079,7 @@ function ChatPage(props: ChatPageProps) { return { ...message, + isSend: nextIsSend, senderUsername: sender || message.senderUsername, senderDisplayName: nextSenderDisplayName, senderAvatarUrl: nextSenderAvatarUrl @@ -3012,6 +3089,7 @@ function ChatPage(props: ChatPageProps) { currentSessionId, hydrateInSessionSearchResults, myAvatarUrl, + myWxid, resolveSearchSessionContext ]) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index cd5183f..0b228ea 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1596,6 +1596,7 @@ function ExportPage() { const sessionMutualFriendsRunIdRef = useRef(0) const sessionMutualFriendsWorkerRunningRef = useRef(false) const sessionMutualFriendsBackgroundFeedTimerRef = useRef(null) + const sessionMutualFriendsPersistTimerRef = useRef(null) const sessionMutualFriendsVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({ startIndex: 0, endIndex: -1 @@ -2748,8 +2749,32 @@ function ExportPage() { window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current) sessionMutualFriendsBackgroundFeedTimerRef.current = null } + if (sessionMutualFriendsPersistTimerRef.current) { + window.clearTimeout(sessionMutualFriendsPersistTimerRef.current) + sessionMutualFriendsPersistTimerRef.current = null + } }, []) + const flushSessionMutualFriendsCache = useCallback(async () => { + try { + const scopeKey = await ensureExportCacheScope() + await configService.setExportSessionMutualFriendsCache( + scopeKey, + sessionMutualFriendsDirectMetricsRef.current + ) + } catch (error) { + console.error('写入导出页共同好友缓存失败:', error) + } + }, [ensureExportCacheScope]) + + const scheduleFlushSessionMutualFriendsCache = useCallback(() => { + if (sessionMutualFriendsPersistTimerRef.current) return + sessionMutualFriendsPersistTimerRef.current = window.setTimeout(() => { + sessionMutualFriendsPersistTimerRef.current = null + void flushSessionMutualFriendsCache() + }, SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS) + }, [flushSessionMutualFriendsCache]) + const isSessionMutualFriendsReady = useCallback((sessionId: string): boolean => { if (!sessionId) return true if (sessionMutualFriendsReadySetRef.current.has(sessionId)) return true @@ -2879,10 +2904,35 @@ function ExportPage() { } }, [getSessionMutualFriendProfile]) + const rebuildSessionMutualFriendsStateFromDirectMetrics = useCallback((sessionIds?: string[]) => { + const targets = Array.isArray(sessionIds) && sessionIds.length > 0 + ? sessionIds + : Object.keys(sessionMutualFriendsDirectMetricsRef.current) + const nextMetrics: Record = {} + const readyIds: string[] = [] + for (const sessionIdRaw of targets) { + const sessionId = String(sessionIdRaw || '').trim() + if (!sessionId) continue + const rebuilt = rebuildSessionMutualFriendsMetric(sessionId) + if (!rebuilt) continue + nextMetrics[sessionId] = rebuilt + readyIds.push(sessionId) + } + sessionMutualFriendsMetricsRef.current = nextMetrics + setSessionMutualFriendsMetrics(nextMetrics) + if (readyIds.length > 0) { + for (const sessionId of readyIds) { + sessionMutualFriendsReadySetRef.current.add(sessionId) + } + patchSessionLoadTraceStage(readyIds, 'mutualFriends', 'done') + } + }, [patchSessionLoadTraceStage, rebuildSessionMutualFriendsMetric]) + const applySessionMutualFriendsMetric = useCallback((sessionId: string, directMetric: SessionMutualFriendsMetric) => { const normalizedSessionId = String(sessionId || '').trim() if (!normalizedSessionId) return sessionMutualFriendsDirectMetricsRef.current[normalizedSessionId] = directMetric + scheduleFlushSessionMutualFriendsCache() const impactedSessionIds = new Set([normalizedSessionId]) const allSessionIds = sessionsRef.current @@ -2912,7 +2962,7 @@ function ExportPage() { } return changed ? next : prev }) - }, [getSessionMutualFriendProfile, rebuildSessionMutualFriendsMetric]) + }, [getSessionMutualFriendProfile, rebuildSessionMutualFriendsMetric, scheduleFlushSessionMutualFriendsCache]) const isSessionMediaMetricReady = useCallback((sessionId: string): boolean => { if (!sessionId) return true @@ -3339,11 +3389,13 @@ function ExportPage() { const [ cachedContactsPayload, cachedMessageCountsPayload, - cachedContentMetricsPayload + cachedContentMetricsPayload, + cachedMutualFriendsPayload ] = await Promise.all([ loadContactsCaches(scopeKey), configService.getExportSessionMessageCountCache(scopeKey), - configService.getExportSessionContentMetricCache(scopeKey) + configService.getExportSessionContentMetricCache(scopeKey), + configService.getExportSessionMutualFriendsCache(scopeKey) ]) if (isStale()) return @@ -3411,6 +3463,15 @@ function ExportPage() { if (cachedContentMetricReadySessionIds.length > 0) { patchSessionLoadTraceStage(cachedContentMetricReadySessionIds, 'mediaMetrics', 'done') } + const cachedMutualFriendDirectMetrics = Object.entries(cachedMutualFriendsPayload?.metrics || {}).reduce>((acc, [sessionIdRaw, metricRaw]) => { + const sessionId = String(sessionIdRaw || '').trim() + if (!exportableSessionIdSet.has(sessionId) || !isSingleContactSession(sessionId)) return acc + const metric = metricRaw as SessionMutualFriendsMetric | undefined + if (!metric || !Array.isArray(metric.items) || !Number.isFinite(metric.count)) return acc + acc[sessionId] = metric + return acc + }, {}) + const cachedMutualFriendSessionIds = Object.keys(cachedMutualFriendDirectMetrics) if (isStale()) return if (Object.keys(cachedMessageCounts).length > 0) { @@ -3422,6 +3483,13 @@ function ExportPage() { if (Object.keys(cachedContentMetrics).length > 0) { mergeSessionContentMetrics(cachedContentMetrics) } + if (cachedMutualFriendSessionIds.length > 0) { + sessionMutualFriendsDirectMetricsRef.current = cachedMutualFriendDirectMetrics + rebuildSessionMutualFriendsStateFromDirectMetrics(cachedMutualFriendSessionIds) + } else { + sessionMutualFriendsMetricsRef.current = {} + setSessionMutualFriendsMetrics({}) + } setSessions(baseSessions) sessionsHydratedAtRef.current = Date.now() void (async () => { @@ -3622,7 +3690,7 @@ function ExportPage() { } finally { if (!isStale()) setIsLoading(false) } - }, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, resetSessionMediaMetricLoader, resetSessionMutualFriendsLoader, syncContactTypeCounts]) + }, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, rebuildSessionMutualFriendsStateFromDirectMetrics, resetSessionMediaMetricLoader, resetSessionMutualFriendsLoader, syncContactTypeCounts]) useEffect(() => { if (!isExportRoute) return @@ -3630,10 +3698,7 @@ function ExportPage() { const hasFreshSessionSnapshot = hasBaseConfigReadyRef.current && sessionsRef.current.length > 0 && now - sessionsHydratedAtRef.current <= EXPORT_REENTER_SESSION_SOFT_REFRESH_MS - const hasFreshSnsSnapshot = hasSeededSnsStatsRef.current && - now - snsStatsHydratedAtRef.current <= EXPORT_REENTER_SNS_SOFT_REFRESH_MS - - void loadBaseConfig() + const baseConfigPromise = loadBaseConfig() void ensureSharedTabCountsLoaded() if (!hasFreshSessionSnapshot) { void loadSessions() @@ -3641,9 +3706,14 @@ function ExportPage() { // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 const timer = window.setTimeout(() => { - if (!hasFreshSnsSnapshot) { - void loadSnsStats({ full: true }) - } + void (async () => { + await baseConfigPromise + const hasFreshSnsSnapshot = hasSeededSnsStatsRef.current && + Date.now() - snsStatsHydratedAtRef.current <= EXPORT_REENTER_SNS_SOFT_REFRESH_MS + if (!hasFreshSnsSnapshot) { + void loadSnsStats({ full: true }) + } + })() }, 120) return () => window.clearTimeout(timer) @@ -4988,9 +5058,14 @@ function ExportPage() { window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current) sessionMutualFriendsBackgroundFeedTimerRef.current = null } + if (sessionMutualFriendsPersistTimerRef.current) { + window.clearTimeout(sessionMutualFriendsPersistTimerRef.current) + sessionMutualFriendsPersistTimerRef.current = null + } void flushSessionMediaMetricCache() + void flushSessionMutualFriendsCache() } - }, [flushSessionMediaMetricCache]) + }, [flushSessionMediaMetricCache, flushSessionMutualFriendsCache]) const contactByUsername = useMemo(() => { const map = new Map() @@ -5254,6 +5329,23 @@ function ExportPage() { console.error('导出页读取会话统计缓存失败:', error) } + try { + const relationCacheResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: true, allowStaleCache: true, cacheOnly: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (relationCacheResult.success && relationCacheResult.data) { + const relationMetric = relationCacheResult.data[normalizedSessionId] as SessionExportMetric | undefined + const relationCacheMeta = relationCacheResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (relationMetric) { + applySessionDetailStats(normalizedSessionId, relationMetric, relationCacheMeta, true) + } + } + } catch (error) { + console.error('导出页读取会话关系缓存失败:', error) + } + const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0 const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale)) @@ -5302,16 +5394,36 @@ function ExportPage() { } }, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername]) - const loadSessionRelationStats = useCallback(async () => { + const loadSessionRelationStats = useCallback(async (options?: { forceRefresh?: boolean }) => { const normalizedSessionId = String(sessionDetail?.wxid || '').trim() if (!normalizedSessionId || isLoadingSessionRelationStats) return const requestSeq = detailRequestSeqRef.current + const forceRefresh = options?.forceRefresh === true setIsLoadingSessionRelationStats(true) try { + if (!forceRefresh) { + const relationCacheResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: true, allowStaleCache: true, cacheOnly: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + + const relationMetric = relationCacheResult.success && relationCacheResult.data + ? relationCacheResult.data[normalizedSessionId] as SessionExportMetric | undefined + : undefined + const relationCacheMeta = relationCacheResult.success + ? relationCacheResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + : undefined + if (relationMetric) { + applySessionDetailStats(normalizedSessionId, relationMetric, relationCacheMeta, true) + return + } + } + const relationResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } + { includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true } ) if (requestSeq !== detailRequestSeqRef.current) return @@ -5333,6 +5445,60 @@ function ExportPage() { } }, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid]) + const handleRefreshTableData = useCallback(async () => { + const scopeKey = await ensureExportCacheScope() + + resetSessionMutualFriendsLoader() + sessionMutualFriendsMetricsRef.current = {} + setSessionMutualFriendsMetrics({}) + closeSessionMutualFriendsDialog() + try { + await configService.clearExportSessionMutualFriendsCache(scopeKey) + } catch (error) { + console.error('清理导出页共同好友缓存失败:', error) + } + + if (isSessionCountStageReady) { + const visibleTargetIds = collectVisibleSessionMutualFriendsTargets(filteredContacts) + const visibleTargetSet = new Set(visibleTargetIds) + const remainingTargetIds = sessionsRef.current + .filter((session) => session.hasSession && isSingleContactSession(session.username) && !visibleTargetSet.has(session.username)) + .map((session) => session.username) + + if (visibleTargetIds.length > 0) { + enqueueSessionMutualFriendsRequests(visibleTargetIds, { front: true }) + } + if (remainingTargetIds.length > 0) { + enqueueSessionMutualFriendsRequests(remainingTargetIds) + } + scheduleSessionMutualFriendsWorker() + } + + await Promise.all([ + loadContactsList({ scopeKey }), + loadSnsStats({ full: true }), + loadSnsUserPostCounts({ force: true }) + ]) + + if (String(sessionDetail?.wxid || '').trim()) { + void loadSessionRelationStats({ forceRefresh: true }) + } + }, [ + closeSessionMutualFriendsDialog, + collectVisibleSessionMutualFriendsTargets, + enqueueSessionMutualFriendsRequests, + ensureExportCacheScope, + filteredContacts, + isSessionCountStageReady, + loadContactsList, + loadSessionRelationStats, + loadSnsStats, + loadSnsUserPostCounts, + resetSessionMutualFriendsLoader, + scheduleSessionMutualFriendsWorker, + sessionDetail?.wxid + ]) + useEffect(() => { if (!showSessionDetailPanel || !sessionDetailSupportsSnsTimeline) return if (snsUserPostCountsStatus === 'idle') { @@ -6371,7 +6537,7 @@ function ExportPage() { )} - @@ -6468,7 +6634,7 @@ function ExportPage() {
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • - diff --git a/src/services/config.ts b/src/services/config.ts index 5fce0f1..76d4b62 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -44,6 +44,7 @@ export const CONFIG_KEYS = { EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap', EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap', EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap', + EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP: 'exportSessionMutualFriendsCacheMap', SNS_PAGE_CACHE_MAP: 'snsPageCacheMap', CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs', CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap', @@ -596,6 +597,34 @@ export interface ExportSnsUserPostCountsCacheItem { counts: Record } +export type ExportSessionMutualFriendDirection = 'incoming' | 'outgoing' | 'bidirectional' +export type ExportSessionMutualFriendBehavior = 'likes' | 'comments' | 'both' + +export interface ExportSessionMutualFriendCacheItem { + name: string + incomingLikeCount: number + incomingCommentCount: number + outgoingLikeCount: number + outgoingCommentCount: number + totalCount: number + latestTime: number + direction: ExportSessionMutualFriendDirection + behavior: ExportSessionMutualFriendBehavior +} + +export interface ExportSessionMutualFriendsCacheEntry { + count: number + items: ExportSessionMutualFriendCacheItem[] + loadedPosts: number + totalPosts: number | null + computedAt: number +} + +export interface ExportSessionMutualFriendsCacheItem { + updatedAt: number + metrics: Record +} + export interface SnsPageOverviewCache { totalPosts: number totalFriends: number @@ -855,6 +884,148 @@ export async function setExportSnsUserPostCountsCache( await config.set(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP, map) } +const normalizeMutualFriendDirection = (value: unknown): ExportSessionMutualFriendDirection | null => { + if (value === 'incoming' || value === 'outgoing' || value === 'bidirectional') { + return value + } + return null +} + +const normalizeMutualFriendBehavior = (value: unknown): ExportSessionMutualFriendBehavior | null => { + if (value === 'likes' || value === 'comments' || value === 'both') { + return value + } + return null +} + +const normalizeExportSessionMutualFriendsCacheEntry = (raw: unknown): ExportSessionMutualFriendsCacheEntry | null => { + if (!raw || typeof raw !== 'object') return null + const source = raw as Record + const count = Number(source.count) + const loadedPosts = Number(source.loadedPosts) + const computedAt = Number(source.computedAt) + const itemsRaw = Array.isArray(source.items) ? source.items : [] + const totalPostsRaw = source.totalPosts + const totalPosts = totalPostsRaw === null || totalPostsRaw === undefined + ? null + : Number(totalPostsRaw) + + if (!Number.isFinite(count) || count < 0 || !Number.isFinite(loadedPosts) || loadedPosts < 0 || !Number.isFinite(computedAt) || computedAt < 0) { + return null + } + + const items: ExportSessionMutualFriendCacheItem[] = [] + for (const itemRaw of itemsRaw) { + if (!itemRaw || typeof itemRaw !== 'object') continue + const item = itemRaw as Record + const name = String(item.name || '').trim() + const direction = normalizeMutualFriendDirection(item.direction) + const behavior = normalizeMutualFriendBehavior(item.behavior) + const incomingLikeCount = Number(item.incomingLikeCount) + const incomingCommentCount = Number(item.incomingCommentCount) + const outgoingLikeCount = Number(item.outgoingLikeCount) + const outgoingCommentCount = Number(item.outgoingCommentCount) + const totalCount = Number(item.totalCount) + const latestTime = Number(item.latestTime) + if (!name || !direction || !behavior) continue + if ( + !Number.isFinite(incomingLikeCount) || incomingLikeCount < 0 || + !Number.isFinite(incomingCommentCount) || incomingCommentCount < 0 || + !Number.isFinite(outgoingLikeCount) || outgoingLikeCount < 0 || + !Number.isFinite(outgoingCommentCount) || outgoingCommentCount < 0 || + !Number.isFinite(totalCount) || totalCount < 0 || + !Number.isFinite(latestTime) || latestTime < 0 + ) { + continue + } + items.push({ + name, + incomingLikeCount: Math.floor(incomingLikeCount), + incomingCommentCount: Math.floor(incomingCommentCount), + outgoingLikeCount: Math.floor(outgoingLikeCount), + outgoingCommentCount: Math.floor(outgoingCommentCount), + totalCount: Math.floor(totalCount), + latestTime: Math.floor(latestTime), + direction, + behavior + }) + } + + return { + count: Math.floor(count), + items, + loadedPosts: Math.floor(loadedPosts), + totalPosts: totalPosts === null + ? null + : (Number.isFinite(totalPosts) && totalPosts >= 0 ? Math.floor(totalPosts) : null), + computedAt: Math.floor(computedAt) + } +} + +export async function getExportSessionMutualFriendsCache(scopeKey: string): Promise { + if (!scopeKey) return null + const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP) + if (!value || typeof value !== 'object') return null + const rawMap = value as Record + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const rawUpdatedAt = (rawItem as Record).updatedAt + const rawMetrics = (rawItem as Record).metrics + if (!rawMetrics || typeof rawMetrics !== 'object') return null + + const metrics: Record = {} + for (const [sessionIdRaw, metricRaw] of Object.entries(rawMetrics as Record)) { + const sessionId = String(sessionIdRaw || '').trim() + if (!sessionId) continue + const metric = normalizeExportSessionMutualFriendsCacheEntry(metricRaw) + if (!metric) continue + metrics[sessionId] = metric + } + + return { + updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0, + metrics + } +} + +export async function setExportSessionMutualFriendsCache( + scopeKey: string, + metrics: Record +): Promise { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP) + const map = current && typeof current === 'object' + ? { ...(current as Record) } + : {} + + const normalized: Record = {} + for (const [sessionIdRaw, metricRaw] of Object.entries(metrics || {})) { + const sessionId = String(sessionIdRaw || '').trim() + if (!sessionId) continue + const metric = normalizeExportSessionMutualFriendsCacheEntry(metricRaw) + if (!metric) continue + normalized[sessionId] = metric + } + + map[scopeKey] = { + updatedAt: Date.now(), + metrics: normalized + } + + await config.set(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP, map) +} + +export async function clearExportSessionMutualFriendsCache(scopeKey: string): Promise { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP) + if (!current || typeof current !== 'object') return + const map = { ...(current as Record) } + if (!(scopeKey in map)) return + delete map[scopeKey] + await config.set(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP, map) +} + export async function getSnsPageCache(scopeKey: string): Promise { if (!scopeKey) return null const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)