mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
fix: 修复日报生成时群成员名称显示错误
This commit is contained in:
+179
-148
@@ -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<Record<string, unknown>[]>((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<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',
|
||||
'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<string, string> {
|
||||
@@ -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<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> {
|
||||
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<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> {
|
||||
const result = new Map<string, string>()
|
||||
if (!this.wcdbExecQuery || usernames.length === 0) return result
|
||||
|
||||
@@ -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: '' })
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user