diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 2b0c5d4..89d2abb 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 @@ -1684,76 +1695,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 [] } @@ -1827,76 +1778,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 { @@ -2667,27 +2640,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)) @@ -2793,6 +2745,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: '' }) + ]) + }) +})