fix: 修复日报生成时群成员名称显示错误

This commit is contained in:
wushili
2026-08-11 13:12:22 +08:00
parent 775b5aff18
commit 4d95fd7650
2 changed files with 292 additions and 148 deletions
+179 -148
View File
@@ -58,6 +58,17 @@ export interface Wcdb4GroupMember {
m_nsHeadImgUrl: string m_nsHeadImgUrl: string
} }
/**
* 联系人表中独立保存的成员名称字段。
*
* 群成员接口的 nickname 在部分微信数据版本中会被通讯录备注覆盖,因此不能用它
* 推断微信昵称或备注。
*/
type Wcdb4ContactMemberNames = {
wechatNickname: string
remark: string
}
export interface Wcdb4ImageHardlink { export interface Wcdb4ImageHardlink {
file_name?: string file_name?: string
full_path?: string full_path?: string
@@ -1684,76 +1695,16 @@ export class Wcdb4Client {
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) => const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbGetGroupMembers!(handle, chatroomId, outJson) this.wcdbGetGroupMembers!(handle, chatroomId, outJson)
) )
const memberRows = Array.isArray(rows) ? rows : []
const members = (Array.isArray(rows) ? rows : []).map((row) => { const contactNames = this.readContactMemberNames(this.groupMemberUsernames(memberRows))
const username = this.pickString(row, [ const members = this.normalizeGroupMembers(memberRows, groupNicknames, contactNames)
'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)
this.hydrateAvatarUrls( this.hydrateAvatarUrls(
members members
.filter((member) => !member.m_nsHeadImgUrl) .filter((member) => !member.m_nsHeadImgUrl)
.map((member) => member.m_nsUsrName) .map((member) => member.m_nsUsrName)
.filter(Boolean) .filter(Boolean)
) )
return members.map((member) => ({ return this.withCachedGroupMemberAvatars(members)
...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 { } catch {
return [] return []
} }
@@ -1827,76 +1778,98 @@ export class Wcdb4Client {
this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction, this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction,
chatroomId chatroomId
) )
const members = (Array.isArray(rows) ? rows : []).map((row) => { const memberRows = Array.isArray(rows) ? rows : []
const username = this.pickString(row, [ 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<string, unknown>[],
groupNicknames: Map<string, string>,
contactNames: Map<string, Wcdb4ContactMemberNames>
): 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, unknown>[]): string[] {
return rows
.map((row) =>
this.pickString(row, [
'username', 'username',
'userName', 'userName',
'user_name', 'user_name',
'member_username', 'member_username',
'm_nsUsrName' 'm_nsUsrName'
]) ])
const wechatNickname = this.pickString(row, [ )
'nickname', .filter(Boolean)
'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
}
})
const missingNames = members /**
.filter((member) => !member.nickname) * 为已标准化的成员补充头像缓存,不参与名称字段的回退。
.map((member) => member.m_nsUsrName) */
.filter(Boolean) private withCachedGroupMemberAvatars(members: Wcdb4GroupMember[]): Wcdb4GroupMember[] {
const missingAvatars = members return members.map((member) => ({
.filter((member) => !member.m_nsHeadImgUrl) ...member,
.map((member) => member.m_nsUsrName) m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(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 []
}
} }
getGroupNicknames(chatroomId: string): Map<string, string> { getGroupNicknames(chatroomId: string): Map<string, string> {
@@ -2667,27 +2640,6 @@ export class Wcdb4Client {
return false 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<string, string> | Record<string, unknown>[]>(
(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<void> { private async hydrateDisplayNamesAsync(usernames: string[]): Promise<void> {
if (!this.wcdbGetDisplayNames) return if (!this.wcdbGetDisplayNames) return
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username)) 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<string, Wcdb4ContactMemberNames> {
const result = new Map<string, Wcdb4ContactMemberNames>()
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<Record<string, unknown>[]>((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<Map<string, Wcdb4ContactMemberNames>> {
const result = new Map<string, Wcdb4ContactMemberNames>()
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<Record<string, unknown>[]>(
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<string, unknown>[]
): Map<string, Wcdb4ContactMemberNames> {
const result = new Map<string, Wcdb4ContactMemberNames>()
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<string, string> { private readContactAvatarUrls(usernames: string[]): Map<string, string> {
const result = new Map<string, string>() const result = new Map<string, string>()
if (!this.wcdbExecQuery || usernames.length === 0) return result if (!this.wcdbExecQuery || usernames.length === 0) return result
+113
View File
@@ -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<Record<string, string>> = {}) {
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<string, string>(),
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<string, string>(),
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<string, string>(),
wcdbGetAvatarUrls: null
}) as Wcdb4Client
expect(client.getGroupMembers('fixture@chatroom')).toEqual([
expectedMember({ nickname: '真实群昵称', wechatNickname: '', remark: '' })
])
})
})