mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-08 07:21:19 +08:00
fix: 修复联系人 API 因昵称备注未补全导致搜索结果为空 #51
Fixes https://github.com/Wxw-Gu/TraceMemo/issues/51
This commit is contained in:
+10
-4
@@ -3,6 +3,7 @@ import http, { IncomingMessage, ServerResponse, Server } from 'http'
|
|||||||
import {
|
import {
|
||||||
isReady,
|
isReady,
|
||||||
listContacts,
|
listContacts,
|
||||||
|
listContactsAsync,
|
||||||
listMessages,
|
listMessages,
|
||||||
getGroupSnapshot,
|
getGroupSnapshot,
|
||||||
listRecentChat,
|
listRecentChat,
|
||||||
@@ -230,21 +231,26 @@ const routes: Record<string, RouteHandler> = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
'/api/v1/contact': ({ res, url }) => {
|
'/api/v1/contact': async ({ res, url }) => {
|
||||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||||
const filter = url.searchParams.get('filter') || undefined
|
const filter = url.searchParams.get('filter') || undefined
|
||||||
const type = url.searchParams.get('type') || undefined
|
const type = url.searchParams.get('type') || undefined
|
||||||
let contacts = listContacts(filter)
|
// Use the hydrated data source: on macOS the session cache only carries raw
|
||||||
|
// ids until display names / contact identities are hydrated, so the sync
|
||||||
|
// `listContacts` would miss nickname and remark matches (Issue #51).
|
||||||
|
let contacts = await listContactsAsync(filter)
|
||||||
if (type === 'user' || type === 'group') {
|
if (type === 'user' || type === 'group') {
|
||||||
contacts = contacts.filter((c) => c.type === type)
|
contacts = contacts.filter((c) => c.type === type)
|
||||||
}
|
}
|
||||||
sendJson(res, 200, { count: contacts.length, contacts })
|
sendJson(res, 200, { count: contacts.length, contacts })
|
||||||
},
|
},
|
||||||
|
|
||||||
'/api/v1/chatroom': ({ res, url }) => {
|
'/api/v1/chatroom': async ({ res, url }) => {
|
||||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||||
const keyword = url.searchParams.get('keyword') || ''
|
const keyword = url.searchParams.get('keyword') || ''
|
||||||
let groups = listContacts().filter((c) => c.type === 'group')
|
// Same hydration requirement as /api/v1/contact: group display names are
|
||||||
|
// exactly the fields that stay un-hydrated on macOS.
|
||||||
|
let groups = (await listContactsAsync()).filter((c) => c.type === 'group')
|
||||||
if (keyword) {
|
if (keyword) {
|
||||||
const lower = keyword.toLowerCase()
|
const lower = keyword.toLowerCase()
|
||||||
groups = groups.filter(
|
groups = groups.filter(
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ vi.mock('electron', () => ({
|
|||||||
vi.mock('../../src/main/services/chat-service', () => ({
|
vi.mock('../../src/main/services/chat-service', () => ({
|
||||||
isReady: () => true,
|
isReady: () => true,
|
||||||
listContacts: () => fixture.contacts,
|
listContacts: () => fixture.contacts,
|
||||||
|
listContactsAsync: async () => fixture.contacts,
|
||||||
listMessages: () => fixture.chatlogMessages,
|
listMessages: () => fixture.chatlogMessages,
|
||||||
getGroupSnapshot: () => ({ members: [] }),
|
getGroupSnapshot: () => ({ members: [] }),
|
||||||
listRecentChat: () => [],
|
listRecentChat: () => [],
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
app: { getPath: () => '/tmp/wxe-local-api-contact-search' },
|
||||||
|
safeStorage: {
|
||||||
|
isEncryptionAvailable: () => true,
|
||||||
|
encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'),
|
||||||
|
decryptString: (buffer: Buffer) => buffer.toString('utf8').replace(/^encrypted:/, '')
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../src/main/group-report-service', () => ({
|
||||||
|
exportGroupReport: vi.fn(async () => ({ success: true }))
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../src/main/services/agent-group-report-service', () => ({
|
||||||
|
generateAgentGroupReport: vi.fn(async () => ({ success: true }))
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../src/main/services/agent-hub-service', () => ({
|
||||||
|
agentHubService: {
|
||||||
|
getStatus: () => ({
|
||||||
|
hub: 'online',
|
||||||
|
connector: 'online',
|
||||||
|
dataApi: 'online',
|
||||||
|
databaseReady: true
|
||||||
|
}),
|
||||||
|
testSend: vi.fn(async () => ({ success: true, status: 'sent' }))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// NOTE: chat-service is intentionally NOT mocked here — the HTTP layer must be
|
||||||
|
// exercised against the real contact search path so hydration is on the hook.
|
||||||
|
import type { WechatDb } from '../../src/main/wechat-db'
|
||||||
|
import { setChatDb } from '../../src/main/services/chat-service'
|
||||||
|
import { startHttpServer, type HttpServerHandle } from '../../src/main/http-server'
|
||||||
|
|
||||||
|
const TOKEN = 'B'.repeat(43)
|
||||||
|
const AUTH_HEADERS = { Authorization: `Bearer ${TOKEN}` }
|
||||||
|
const handles: HttpServerHandle[] = []
|
||||||
|
|
||||||
|
interface ContactSearchResponse {
|
||||||
|
count: number
|
||||||
|
contacts: Array<{ type: string; m_nsNickName: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServer(): Promise<string> {
|
||||||
|
const handle = await startHttpServer('127.0.0.1', 0, { tokenProvider: () => TOKEN })
|
||||||
|
handles.push(handle)
|
||||||
|
return `http://${handle.host}:${handle.port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A WechatDb stand-in that reproduces macOS behaviour: raw ids in the session
|
||||||
|
* cache until `getSessionsAsync({ hydrateDisplayNames: true })` runs.
|
||||||
|
*/
|
||||||
|
function installMacOsStyleFixtureDb(): void {
|
||||||
|
const userSession = { username: 'wxid_fixture_user', nickname: 'wxid_fixture_user' }
|
||||||
|
const groupSession = { username: 'fixture_group@chatroom', nickname: 'fixture_group@chatroom' }
|
||||||
|
|
||||||
|
const getSessionsAsync = vi.fn(async (options: { hydrateDisplayNames?: boolean }) => {
|
||||||
|
if (options.hydrateDisplayNames) {
|
||||||
|
userSession.nickname = '张三'
|
||||||
|
groupSession.nickname = '张三的测试群'
|
||||||
|
}
|
||||||
|
return [userSession, groupSession]
|
||||||
|
})
|
||||||
|
|
||||||
|
const fakeDb = {
|
||||||
|
close: vi.fn(),
|
||||||
|
md5: (value: string) => `md5-${value}`,
|
||||||
|
hydrateContactIdentitiesAsync: vi.fn(async () => undefined),
|
||||||
|
getAllGroupContacts: () => ({ [`md5-${groupSession.username}`]: groupSession.nickname }),
|
||||||
|
getUserList: () => [
|
||||||
|
{
|
||||||
|
m_nsUsrName: userSession.username,
|
||||||
|
nickname: userSession.nickname,
|
||||||
|
wxid: userSession.username
|
||||||
|
},
|
||||||
|
{
|
||||||
|
m_nsUsrName: groupSession.username,
|
||||||
|
nickname: groupSession.nickname
|
||||||
|
}
|
||||||
|
],
|
||||||
|
getWcdb4Client: () => ({ getSessionsAsync })
|
||||||
|
} as unknown as WechatDb
|
||||||
|
|
||||||
|
setChatDb(fakeDb)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Local API contact search (Issue #51)', () => {
|
||||||
|
beforeEach(() => installMacOsStyleFixtureDb())
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(handles.splice(0).map((handle) => handle.close()))
|
||||||
|
setChatDb(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the contact and the group when one keyword matches both', async () => {
|
||||||
|
const base = await startServer()
|
||||||
|
const response = await fetch(`${base}/api/v1/contact?filter=${encodeURIComponent('张三')}`, {
|
||||||
|
headers: AUTH_HEADERS
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const body = (await response.json()) as ContactSearchResponse
|
||||||
|
expect(body.count).toBe(2)
|
||||||
|
expect(body.contacts.map((contact) => contact.type).sort()).toEqual(['group', 'user'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the type=user and type=group filters intact', async () => {
|
||||||
|
const base = await startServer()
|
||||||
|
const query = `filter=${encodeURIComponent('张三')}`
|
||||||
|
|
||||||
|
const users = (await (
|
||||||
|
await fetch(`${base}/api/v1/contact?${query}&type=user`, { headers: AUTH_HEADERS })
|
||||||
|
).json()) as ContactSearchResponse
|
||||||
|
expect(users.count).toBe(1)
|
||||||
|
expect(users.contacts.every((contact) => contact.type === 'user')).toBe(true)
|
||||||
|
expect(users.contacts.some((contact) => contact.m_nsNickName === '张三')).toBe(true)
|
||||||
|
|
||||||
|
const groups = (await (
|
||||||
|
await fetch(`${base}/api/v1/contact?${query}&type=group`, { headers: AUTH_HEADERS })
|
||||||
|
).json()) as ContactSearchResponse
|
||||||
|
expect(groups.count).toBe(1)
|
||||||
|
expect(groups.contacts.every((contact) => contact.type === 'group')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hydrates group names before the chatroom keyword search runs', async () => {
|
||||||
|
const base = await startServer()
|
||||||
|
const response = await fetch(`${base}/api/v1/chatroom?keyword=${encodeURIComponent('张三')}`, {
|
||||||
|
headers: AUTH_HEADERS
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const body = (await response.json()) as { count: number }
|
||||||
|
expect(body.count).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { WechatDb } from '../../src/main/wechat-db'
|
||||||
|
import { listContacts, listContactsAsync, setChatDb } from '../../src/main/services/chat-service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #51 root-cause evidence.
|
||||||
|
*
|
||||||
|
* `listContactsAsync` hydrates display names / identities before filtering,
|
||||||
|
* while the synchronous `listContacts` reads whatever the session cache holds.
|
||||||
|
* On macOS the session cache frequently only carries raw ids (mirrors the
|
||||||
|
* production comment in `listContactsAsync`), so the sync path cannot match a
|
||||||
|
* Chinese nickname even though hydration makes both the contact and the group
|
||||||
|
* searchable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HYDRATED_USER_NAME = '张三'
|
||||||
|
const HYDRATED_GROUP_NAME = '张三的测试群'
|
||||||
|
|
||||||
|
function createMacOsStyleFixtureDb(): WechatDb {
|
||||||
|
// Before hydration the macOS session rows only expose raw identifiers.
|
||||||
|
const userSession = { username: 'wxid_fixture_user', nickname: 'wxid_fixture_user' }
|
||||||
|
const groupSession = { username: 'fixture_group@chatroom', nickname: 'fixture_group@chatroom' }
|
||||||
|
|
||||||
|
const getSessionsAsync = vi.fn(async (options: { hydrateDisplayNames?: boolean }) => {
|
||||||
|
if (options.hydrateDisplayNames) {
|
||||||
|
userSession.nickname = HYDRATED_USER_NAME
|
||||||
|
groupSession.nickname = HYDRATED_GROUP_NAME
|
||||||
|
}
|
||||||
|
return [userSession, groupSession]
|
||||||
|
})
|
||||||
|
|
||||||
|
const fakeDb = {
|
||||||
|
close: vi.fn(),
|
||||||
|
md5: (value: string) => `md5-${value}`,
|
||||||
|
hydrateContactIdentitiesAsync: vi.fn(async () => undefined),
|
||||||
|
getAllGroupContacts: () => ({ [`md5-${groupSession.username}`]: groupSession.nickname }),
|
||||||
|
getUserList: () => [
|
||||||
|
{
|
||||||
|
m_nsUsrName: userSession.username,
|
||||||
|
nickname: userSession.nickname,
|
||||||
|
wxid: userSession.username
|
||||||
|
},
|
||||||
|
{
|
||||||
|
m_nsUsrName: groupSession.username,
|
||||||
|
nickname: groupSession.nickname
|
||||||
|
}
|
||||||
|
],
|
||||||
|
getWcdb4Client: () => ({ getSessionsAsync })
|
||||||
|
} as unknown as WechatDb
|
||||||
|
|
||||||
|
return fakeDb
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('chat service contact search on un-hydrated macOS sessions', () => {
|
||||||
|
afterEach(() => setChatDb(null))
|
||||||
|
|
||||||
|
it('sync listContacts cannot see display names that only hydration provides', () => {
|
||||||
|
setChatDb(createMacOsStyleFixtureDb())
|
||||||
|
|
||||||
|
expect(listContacts('张三')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('async listContactsAsync hydrates first and returns both the contact and the group', async () => {
|
||||||
|
setChatDb(createMacOsStyleFixtureDb())
|
||||||
|
|
||||||
|
const matches = await listContactsAsync('张三')
|
||||||
|
|
||||||
|
expect(matches.map((contact) => contact.type).sort()).toEqual(['group', 'user'])
|
||||||
|
expect(matches.every((contact) => contact.m_nsNickName.includes('张三'))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { Contact } from '../../src/shared/types'
|
||||||
|
import { buildContactSearchIndex, filterContactSearchIndex } from '../../src/shared/contact-search'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #51 regression guard for the *shared* search primitives.
|
||||||
|
*
|
||||||
|
* The bug report claims that when one keyword matches a private contact AND a
|
||||||
|
* group chat, the API returns nothing. These tests pin down that multi-match is
|
||||||
|
* itself NOT the problem: the shared index/filter pair already returns every
|
||||||
|
* match. The real defect lived in the data the HTTP layer fed into it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const user: Contact = {
|
||||||
|
m_nsUsrName: 'wxid_fixture_user',
|
||||||
|
m_nsNickName: '张三',
|
||||||
|
md5: 'fixture-user-md5',
|
||||||
|
type: 'user',
|
||||||
|
remark: '张三'
|
||||||
|
}
|
||||||
|
|
||||||
|
const group: Contact = {
|
||||||
|
m_nsUsrName: 'fixture_group@chatroom',
|
||||||
|
m_nsNickName: '张三的测试群',
|
||||||
|
md5: 'fixture-group-md5',
|
||||||
|
type: 'group'
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('filterContactSearchIndex with a keyword hitting a contact and a group', () => {
|
||||||
|
it('returns both matches instead of collapsing to an empty list', () => {
|
||||||
|
const index = buildContactSearchIndex([user, group])
|
||||||
|
expect(filterContactSearchIndex(index, '张三')).toEqual([user, group])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still isolates the results with type=user', () => {
|
||||||
|
const index = buildContactSearchIndex([user, group])
|
||||||
|
expect(filterContactSearchIndex(index, '张三', 'user')).toEqual([user])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still isolates the results with type=group', () => {
|
||||||
|
const index = buildContactSearchIndex([user, group])
|
||||||
|
expect(filterContactSearchIndex(index, '张三', 'group')).toEqual([group])
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user