diff --git a/.npmrc b/.npmrc index bf2e764..4b5e155 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ shamefully-hoist=true +electron_mirror=https://npmmirror.com/mirrors/electron/ diff --git a/docs/README.md b/docs/README.md index 2e69ff1..9ae512e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -55,6 +55,7 @@ TraceMemo 有两种不同的接入方式。微信机器人是普通用户可以 - [macOS 数据访问说明](./platform/macos.md) - [开发、测试与构建](./development/overview.md) +- [本地启动排障](./development/local-startup-troubleshooting.md) - [v2.1.9 API 鉴权迁移说明](./agent/release-notes-v2.1.9.md) 当前工作区版本:**2.2.0**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。 diff --git a/docs/development/local-startup-troubleshooting.md b/docs/development/local-startup-troubleshooting.md new file mode 100644 index 0000000..db1cd70 --- /dev/null +++ b/docs/development/local-startup-troubleshooting.md @@ -0,0 +1,62 @@ +# 本地启动排障 + +本文面向运行源码开发环境的贡献者。常规启动顺序和测试入口请先阅读[开发、测试与构建](./overview.md)。 + +## 启动成功的判断标准 + +执行 `pnpm dev` 后,以下状态同时满足,说明本地开发环境已经可用: + +- 控制台显示连接器已生成,例如 `resources/connectors/wechat/win32-x64/wechat-connector.exe`; +- Electron 窗口已打开,或 `http://localhost:5173/` 返回 HTTP `200`; +- 控制台显示 Local HTTP API 正在监听 `http://127.0.0.1:6131`。 + +`6131` 是应用提供给本机集成使用的 API 端口,不是 Vite 的页面端口。 + +## Go 命令找不到 + +如果 `pnpm dev` 在构建微信连接器时出现 `spawnSync go ENOENT`,先执行: + +```bash +go version +``` + +命令不可用表示当前终端的 `PATH` 没有找到 Go。Windows 默认安装位置是 `C:\Program Files\Go\bin`。确认 Go 已安装并把该目录加入系统 `PATH` 后,关闭并重新打开终端或 IDE,再重新执行 `go version` 和 `pnpm dev`。 + +如果 Go 刚完成安装,已经打开的终端不会自动继承新的环境变量;重开终端是必要步骤。不要绕过连接器构建直接启动 `electron-vite dev`,否则 Agent Hub 的微信连接器不会生成。 + +## Electron 二进制缺失或下载失败 + +`electron-vite dev` 报 `Electron uninstall`,或 Electron 安装器报 `fetch failed`,通常表示 `node_modules/electron/dist` 中的 Electron 二进制缺失或下载未完成。这不是应用业务代码的启动错误。 + +项目的 [`.npmrc`](../../.npmrc) 已设置: + +```ini +electron_mirror=https://npmmirror.com/mirrors/electron/ +``` + +pnpm 会把该值传给 Electron 安装器,令其从镜像下载与 `package.json` 锁定版本匹配的二进制文件,避免默认 GitHub 下载源在受限网络中不可访问。 + +依赖安装被中断或 Electron 目录不完整时,删除不完整的 `node_modules` 后重新安装: + +```bash +pnpm install --frozen-lockfile +``` + +单次安装需要使用其他镜像时,可以临时覆盖项目默认值。PowerShell 示例: + +```powershell +$env:ELECTRON_MIRROR = 'https://your-electron-mirror.example/' +pnpm install --frozen-lockfile +``` + +该环境变量只影响当前终端,不会改写仓库中的 `.npmrc`。镜像地址必须保留末尾的 `/`,并提供与 Electron 版本对应的目录结构。 + +## 页面地址无法通过 IPv4 访问 + +Vite 在某些 Windows 环境中只监听 IPv6 本机回环地址 `::1`。这时直接访问 `http://127.0.0.1:5173/` 可能失败,但 `http://localhost:5173/` 仍然正常,Electron 也会使用后者加载页面。 + +排查时优先访问 `http://localhost:5173/`;需要显式验证 IPv6 时,使用 `http://[::1]:5173/`。不要因为 IPv4 回环地址不可用就判断 Electron 或 Vite 启动失败。 + +## 仍无法启动时 + +保留首次错误的完整输出,并同时记录操作系统、Node.js、pnpm 和 Go 版本,以及 `pnpm install --frozen-lockfile` 与 `pnpm dev` 的执行结果。不要提交数据库密钥、AI API Key、微信数据路径或聊天内容。 diff --git a/docs/development/overview.md b/docs/development/overview.md index 201b4f9..a986cf7 100644 --- a/docs/development/overview.md +++ b/docs/development/overview.md @@ -18,6 +18,8 @@ pnpm install pnpm dev ``` +本地依赖安装、Go 环境和 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。 + 常用检查: ```bash diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index d3f63d2..57212e7 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -58,6 +58,17 @@ export interface Wcdb4GroupMember { m_nsHeadImgUrl: string } +/** + * 联系人表中独立保存的成员名称字段。 + * + * 群成员接口的 nickname 在部分微信数据版本中会被通讯录备注覆盖,因此不能用它 + * 推断微信昵称或备注。 + */ +type Wcdb4ContactMemberNames = { + wechatNickname: string + remark: string +} + export interface Wcdb4ImageHardlink { file_name?: string full_path?: string @@ -1685,76 +1696,16 @@ export class Wcdb4Client { const rows = this.callJson[]>((handle, outJson) => this.wcdbGetGroupMembers!(handle, chatroomId, outJson) ) - - const members = (Array.isArray(rows) ? rows : []).map((row) => { - const username = this.pickString(row, [ - 'username', - 'userName', - 'user_name', - 'member_username', - 'm_nsUsrName' - ]) - const wechatNickname = this.pickString(row, [ - 'nickname', - 'nickName', - 'wechatNickname', - 'wechat_nickname', - 'm_nsNickName' - ]) - const remark = this.pickString(row, [ - 'remark', - 'remarkName', - 'remark_name', - 'contactRemark', - 'contact_remark' - ]) - const rowGroupNickname = this.pickString(row, [ - 'groupNickname', - 'group_nickname', - 'displayName', - 'display_name' - ]) - const memberNickname = this.pickString(row, ['name']) - const avatar = this.pickString(row, [ - 'avatarUrl', - 'avatar_url', - 'headImgUrl', - 'm_nsHeadImgUrl' - ]) - - if (username) { - if (avatar) this.avatarCache.set(username, avatar) - } - - return { - m_nsUsrName: username, - nickname: wechatNickname || memberNickname || remark || rowGroupNickname, - groupNickname: groupNicknames.get(username) || rowGroupNickname, - wechatNickname: wechatNickname || memberNickname, - remark, - m_nsHeadImgUrl: avatar - } - }) - - const missingDisplayNames = members - .filter((member) => !member.nickname) - .map((member) => member.m_nsUsrName) - .filter(Boolean) - this.hydrateDisplayNames(missingDisplayNames) + const memberRows = Array.isArray(rows) ? rows : [] + const contactNames = this.readContactMemberNames(this.groupMemberUsernames(memberRows)) + const members = this.normalizeGroupMembers(memberRows, groupNicknames, contactNames) this.hydrateAvatarUrls( members .filter((member) => !member.m_nsHeadImgUrl) .map((member) => member.m_nsUsrName) .filter(Boolean) ) - return members.map((member) => ({ - ...member, - nickname: - member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName, - wechatNickname: - member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '', - m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || '' - })) + return this.withCachedGroupMemberAvatars(members) } catch { return [] } @@ -1828,76 +1779,98 @@ export class Wcdb4Client { this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction, chatroomId ) - const members = (Array.isArray(rows) ? rows : []).map((row) => { - const username = this.pickString(row, [ + const memberRows = Array.isArray(rows) ? rows : [] + const contactNames = await this.readContactMemberNamesAsync( + this.groupMemberUsernames(memberRows) + ) + const members = this.normalizeGroupMembers(memberRows, groupNicknames, contactNames) + const missingAvatars = members + .filter((member) => !member.m_nsHeadImgUrl) + .map((member) => member.m_nsUsrName) + .filter(Boolean) + await this.hydrateAvatarUrlsAsync(missingAvatars) + return this.withCachedGroupMemberAvatars(members) + } catch (error) { + console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error) + return [] + } + } + + /** + * 将群成员接口与联系人表组装为语义明确的成员快照。 + * + * 群昵称只接受群聊数据;微信昵称与通讯录备注只接受 contact 表。即使群成员 + * 接口返回了 nickname,也不能作为名称字段的回退,以免把通讯录备注误展示为微信昵称。 + */ + private normalizeGroupMembers( + rows: Record[], + groupNicknames: Map, + contactNames: Map + ): Wcdb4GroupMember[] { + return rows.map((row) => { + const username = this.pickString(row, [ + 'username', + 'userName', + 'user_name', + 'member_username', + 'm_nsUsrName' + ]) + const rowGroupNickname = this.pickString(row, [ + 'groupNickname', + 'group_nickname', + 'displayName', + 'display_name' + ]) + const groupNickname = groupNicknames.get(username) || rowGroupNickname + const contact = contactNames.get(username) + const wechatNickname = contact?.wechatNickname || '' + const remark = contact?.remark || '' + const avatar = this.pickString(row, [ + 'avatarUrl', + 'avatar_url', + 'headImgUrl', + 'm_nsHeadImgUrl' + ]) + + if (username && avatar) this.avatarCache.set(username, avatar) + + return { + m_nsUsrName: username, + // nickname 是旧调用方使用的通用显示字段,保持安全的名称优先级。 + nickname: wechatNickname || groupNickname || username, + groupNickname, + wechatNickname, + remark, + m_nsHeadImgUrl: avatar + } + }) + } + + /** + * 从原始群成员行提取联系人查询键,避免把空值传进 SQL IN 条件。 + */ + private groupMemberUsernames(rows: Record[]): string[] { + return rows + .map((row) => + this.pickString(row, [ 'username', 'userName', 'user_name', 'member_username', 'm_nsUsrName' ]) - const wechatNickname = this.pickString(row, [ - 'nickname', - 'nickName', - 'wechatNickname', - 'wechat_nickname', - 'm_nsNickName' - ]) - const remark = this.pickString(row, [ - 'remark', - 'remarkName', - 'remark_name', - 'contactRemark', - 'contact_remark' - ]) - const rowGroupNickname = this.pickString(row, [ - 'groupNickname', - 'group_nickname', - 'displayName', - 'display_name' - ]) - const memberNickname = this.pickString(row, ['name']) - const avatar = this.pickString(row, [ - 'avatarUrl', - 'avatar_url', - 'headImgUrl', - 'm_nsHeadImgUrl' - ]) - if (username && avatar) this.avatarCache.set(username, avatar) - return { - m_nsUsrName: username, - nickname: wechatNickname || memberNickname || remark || rowGroupNickname, - groupNickname: groupNicknames.get(username) || rowGroupNickname, - wechatNickname: wechatNickname || memberNickname, - remark, - m_nsHeadImgUrl: avatar - } - }) + ) + .filter(Boolean) + } - const missingNames = members - .filter((member) => !member.nickname) - .map((member) => member.m_nsUsrName) - .filter(Boolean) - const missingAvatars = members - .filter((member) => !member.m_nsHeadImgUrl) - .map((member) => member.m_nsUsrName) - .filter(Boolean) - await Promise.all([ - this.hydrateDisplayNamesAsync(missingNames), - this.hydrateAvatarUrlsAsync(missingAvatars) - ]) - return members.map((member) => ({ - ...member, - nickname: - member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName, - wechatNickname: - member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '', - m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || '' - })) - } catch (error) { - console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error) - return [] - } + /** + * 为已标准化的成员补充头像缓存,不参与名称字段的回退。 + */ + private withCachedGroupMemberAvatars(members: Wcdb4GroupMember[]): Wcdb4GroupMember[] { + return members.map((member) => ({ + ...member, + m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || '' + })) } getGroupNicknames(chatroomId: string): Map { @@ -2668,27 +2641,6 @@ export class Wcdb4Client { return false } - private hydrateDisplayNames(usernames: string[]): void { - if (!this.wcdbGetDisplayNames) return - const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username)) - if (missing.length === 0) return - - try { - const rows = this.callJson | Record[]>( - (handle, outJson) => this.wcdbGetDisplayNames!(handle, JSON.stringify(missing), outJson) - ) - this.readStringMap(rows, [ - 'nickname', - 'displayName', - 'display_name', - 'remark', - 'name' - ]).forEach((name, username) => this.displayNameCache.set(username, name)) - } catch { - // Names are optional; usernames are still enough to load chats. - } - } - private async hydrateDisplayNamesAsync(usernames: string[]): Promise { if (!this.wcdbGetDisplayNames) return const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username)) @@ -2794,6 +2746,85 @@ export class Wcdb4Client { } } + /** + * 从 contact 表读取群成员的微信昵称与通讯录备注。 + * + * 查询失败时返回空映射:群成员快照仍可用,并由调用方回退到群昵称或 wxid; + * 不得使用群成员接口的 nickname,因为该字段可能实际保存的是通讯录备注。 + */ + private readContactMemberNames(usernames: string[]): Map { + const result = new Map() + if (!this.wcdbExecQuery || usernames.length === 0) return result + + const inList = this.uniq(usernames) + .map((username) => `'${username.replace(/'/g, "''")}'`) + .join(',') + if (!inList) return result + + try { + const rows = this.callJson[]>((handle, outJson) => + this.wcdbExecQuery!( + handle, + 'contact', + '', + `SELECT username, nick_name, remark FROM contact WHERE username IN (${inList})`, + outJson + ) + ) + return this.contactMemberNamesFromRows(rows) + } catch { + return result + } + } + + /** + * 异步读取 contact 表的成员名称字段,语义与同步路径保持一致。 + */ + private async readContactMemberNamesAsync( + usernames: string[] + ): Promise> { + const result = new Map() + if (!this.wcdbExecQuery || usernames.length === 0) return result + + const inList = this.uniq(usernames) + .map((username) => `'${username.replace(/'/g, "''")}'`) + .join(',') + if (!inList) return result + + try { + const rows = await this.callJsonAsync[]>( + this.wcdbExecQuery as unknown as KoffiAsyncFunction, + 'contact', + '', + `SELECT username, nick_name, remark FROM contact WHERE username IN (${inList})` + ) + return this.contactMemberNamesFromRows(rows) + } catch { + return result + } + } + + /** + * 将 contact 查询行转换为按 wxid 索引的名称映射。 + */ + private contactMemberNamesFromRows( + rows: Record[] + ): Map { + const result = new Map() + if (!Array.isArray(rows)) return result + + for (const row of rows) { + const username = this.pickString(row, ['username', 'user_name', 'userName', 'm_nsUsrName']) + if (!username) continue + result.set(username, { + wechatNickname: this.pickString(row, ['nick_name', 'nickName', 'm_nsNickName']), + remark: this.pickString(row, ['remark', 'remark_name', 'remarkName']) + }) + } + + return result + } + private readContactAvatarUrls(usernames: string[]): Map { const result = new Map() if (!this.wcdbExecQuery || usernames.length === 0) return result diff --git a/tests/unit/wcdb-group-members.test.ts b/tests/unit/wcdb-group-members.test.ts new file mode 100644 index 0000000..57f55b9 --- /dev/null +++ b/tests/unit/wcdb-group-members.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest' +import { Wcdb4Client } from '../../src/main/wcdb4-client' + +const groupMemberRows = [ + { + username: 'wxid-member', + // 某些微信数据版本会在这个字段返回通讯录备注,不能作为微信昵称使用。 + nickname: '被污染的接口备注', + groupNickname: '行内群昵称', + avatarUrl: 'https://example.com/member.jpg' + } +] + +const contactRows = [ + { + username: 'wxid-member', + nick_name: '真实微信昵称', + remark: '真实通讯录备注' + } +] + +function expectedMember(overrides: Partial> = {}) { + return { + m_nsUsrName: 'wxid-member', + nickname: '真实微信昵称', + groupNickname: '真实群昵称', + wechatNickname: '真实微信昵称', + remark: '真实通讯录备注', + m_nsHeadImgUrl: 'https://example.com/member.jpg', + ...overrides + } +} + +describe('WCDB group member names', () => { + it('uses contact nick_name and remark instead of the ambiguous synchronous member nickname', () => { + const getGroupMembers = vi.fn(() => 0) + const executeQuery = vi.fn(() => 0) + const callJson = vi.fn((call: (handle: number, output: [null]) => number) => { + const queryCount = executeQuery.mock.calls.length + call(1, [null]) + return executeQuery.mock.calls.length > queryCount ? contactRows : groupMemberRows + }) + const client = Object.assign(Object.create(Wcdb4Client.prototype), { + wcdbGetGroupMembers: getGroupMembers, + wcdbExecQuery: executeQuery, + callJson, + getGroupNicknames: vi.fn(() => new Map([['wxid-member', '真实群昵称']])), + avatarCache: new Map(), + wcdbGetAvatarUrls: null + }) as Wcdb4Client + + expect(client.getGroupMembers('fixture@chatroom')).toEqual([expectedMember()]) + expect(executeQuery).toHaveBeenCalledWith( + 1, + 'contact', + '', + expect.stringContaining('SELECT username, nick_name, remark FROM contact'), + expect.any(Array) + ) + }) + + it('uses the same independent contact fields in the asynchronous snapshot path', async () => { + const getGroupMembers = vi.fn() + const executeQuery = vi.fn() + const callJsonAsync = vi.fn(async (fn: unknown) => { + if (fn === getGroupMembers) return groupMemberRows + if (fn === executeQuery) return contactRows + throw new Error('unexpected native query') + }) + const client = Object.assign(Object.create(Wcdb4Client.prototype), { + wcdbGetGroupMembers: getGroupMembers, + wcdbExecQuery: executeQuery, + callJsonAsync, + getGroupNicknamesAsync: vi.fn(async () => new Map([['wxid-member', '真实群昵称']])), + avatarCache: new Map(), + wcdbGetAvatarUrls: null + }) as Wcdb4Client + + await expect(client.getGroupMembersAsync('fixture@chatroom')).resolves.toEqual([ + expectedMember() + ]) + expect(callJsonAsync).toHaveBeenLastCalledWith( + executeQuery, + 'contact', + '', + expect.stringContaining('SELECT username, nick_name, remark FROM contact') + ) + }) + + it('falls back to the group nickname without leaking an ambiguous member nickname', () => { + const getGroupMembers = vi.fn(() => 0) + const executeQuery = vi.fn(() => 0) + const callJson = vi.fn((call: (handle: number, output: [null]) => number) => { + const queryCount = executeQuery.mock.calls.length + call(1, [null]) + if (executeQuery.mock.calls.length > queryCount) + throw new Error('contact database unavailable') + return groupMemberRows + }) + const client = Object.assign(Object.create(Wcdb4Client.prototype), { + wcdbGetGroupMembers: getGroupMembers, + wcdbExecQuery: executeQuery, + callJson, + getGroupNicknames: vi.fn(() => new Map([['wxid-member', '真实群昵称']])), + avatarCache: new Map(), + wcdbGetAvatarUrls: null + }) as Wcdb4Client + + expect(client.getGroupMembers('fixture@chatroom')).toEqual([ + expectedMember({ nickname: '真实群昵称', wechatNickname: '', remark: '' }) + ]) + }) +})