feat: 新增微信图片文字索引与问问微信图片检索能力

This commit is contained in:
电摇小子
2026-09-16 10:42:52 +08:00
parent 24399f1d70
commit b8f08d54c8
46 changed files with 6938 additions and 75 deletions
+165
View File
@@ -0,0 +1,165 @@
/**
* 图片消息计数 / 增量的**标识符与列名**回归测试。
*
* 真机反馈「检测到图片消息为 0 → 无法统计(未找到该会话的消息表)」,根因有两个,
* 都在这里钉死:
*
* 1. **标识符混淆**:`contact.md5` 是 `md5(wxid)` 的哈希,而原生接口
* (`wcdbGetMessageTableStats` / `wcdbGetMessages`)要的是**原始 username**。
* 把 md5 直接当 username 传,原生侧匹配不到任何表 —— 既有读消息路径一直有做这层转换
* (`wechat-db.chatMd5ToUsername`、`chat-service` 的 `getUsernameByMd5`),统计路径漏了。
* 2. **类型列名硬编码**:不同微信版本的列名不统一(项目读行时用的是别名列表),
* 把 `local_type` 写进 WHERE 会在列名不同的库上抛错。
*/
import { createHash } from 'node:crypto'
import { describe, expect, it, vi } from 'vitest'
import { Wcdb4Client } from '../../src/main/wcdb4-client'
const USERNAME = 'wxid_real_user'
const ROOM = '12345678@chatroom'
const MD5_OF_USERNAME = createHash('md5').update(USERNAME).digest('hex')
const MD5_OF_ROOM = createHash('md5').update(ROOM).digest('hex')
type ClientOptions = {
sessions?: { username: string }[]
chatTables?: { name: string; db_number: string }[] | null
columns?: string[]
count?: number
maxLocalId?: number
identifierLog?: string[]
}
function makeClient(options: ClientOptions): Wcdb4Client {
const identifierLog = options.identifierLog ?? []
return Object.assign(Object.create(Wcdb4Client.prototype), {
cachedSessions: (options.sessions ?? []).map((session) => ({ ...session })),
cachedChatTables: options.chatTables ?? null,
messageTypeColumnCache: new Map<string, string | null>(),
messageTypeColumnCandidates: [
'local_type',
'localType',
'msg_type',
'msgType',
'message_type',
'messageType',
'type',
'WCDB_CT_local_type'
],
// 断言点:走到消息表查找时用的标识符必须是 username,不是 md5。
listMessageStoresAsync: vi.fn(async (identifier: string) => {
identifierLog.push(identifier)
return [{ tableName: 'Msg_fixture', dbPath: 'C:/fixture/message_0.db' }]
}),
// 聚合查询的返回(真实实现由 pickValue 解析)。
callJsonAsync: vi.fn(async () => [
{ image_count: options.count ?? 0, image_max_local_id: options.maxLocalId ?? 0 }
]),
readMessageColumns: vi.fn(() =>
(options.columns ?? []).map((name) => ({ name, declaration: 'INTEGER' }))
),
wcdbGetMessageTableStats: vi.fn(async () => 0),
wcdbExecQuery: vi.fn(async () => 0)
}) as Wcdb4Client
}
describe('图片消息统计:会话标识符必须是 username 而不是 md5', () => {
it('从 session 能把 md5 反解成 username,并把它交给消息表查找', async () => {
const identifierLog: string[] = []
const client = makeClient({
sessions: [{ username: USERNAME }],
columns: ['local_type'],
count: 7,
identifierLog
})
const result = await client.countImageMessagesAsync(MD5_OF_USERNAME)
expect(result).toEqual({ count: 7, typeColumn: 'local_type' })
// ★ 核心回归断言:绝不能把 md5 当 username 传下去。
expect(identifierLog).toEqual([USERNAME])
expect(identifierLog).not.toContain(MD5_OF_USERNAME)
})
it('群聊同样走 md5 → username', async () => {
const identifierLog: string[] = []
const client = makeClient({
sessions: [{ username: ROOM }],
columns: ['local_type'],
count: 2,
identifierLog
})
await client.countImageMessagesAsync(MD5_OF_ROOM)
expect(identifierLog).toEqual([ROOM])
})
it('不在 session 列表、只以 Chat_<md5> 表存在的会话也能反解', async () => {
const identifierLog: string[] = []
const client = makeClient({
sessions: [],
chatTables: [{ name: `Chat_${MD5_OF_ROOM}`, db_number: ROOM }],
columns: ['local_type'],
count: 3,
identifierLog
})
const result = await client.countImageMessagesAsync(MD5_OF_ROOM)
expect(result.count).toBe(3)
expect(identifierLog).toEqual([ROOM])
})
it('增量水位使用同一个标识符(否则水位永远拿不到、退化成每轮重扫)', async () => {
const identifierLog: string[] = []
const client = makeClient({
sessions: [{ username: USERNAME }],
columns: ['local_type'],
count: 7,
maxLocalId: 42,
identifierLog
})
const watermark = await client.imageConversationWatermarkAsync(MD5_OF_USERNAME)
expect(watermark).toEqual({ count: 7, maxLocalId: 42 })
expect(identifierLog).toEqual([USERNAME])
})
})
describe('图片消息统计:类型列名随版本变化', () => {
it('列名不是 local_type 时照样能统计,并把真实列名透出', async () => {
const client = makeClient({
sessions: [{ username: USERNAME }],
columns: ['msg_type'],
count: 5
})
const result = await client.countImageMessagesAsync(MD5_OF_USERNAME)
expect(result).toEqual({ count: 5, typeColumn: 'msg_type' })
})
it('探测不到类型列时明确失败,而不是静默返回 0', async () => {
const client = makeClient({ sessions: [{ username: USERNAME }], columns: [] })
const result = await client.countImageMessagesAsync(MD5_OF_USERNAME)
// "数不出来"必须是 null + 原因,不能是 0。
expect(result.count).toBeNull()
expect(result.error).toBe('消息表缺少可识别的消息类型列')
})
it('一张表都没匹配到时给出可诊断的原因', async () => {
const client = makeClient({ sessions: [{ username: USERNAME }], columns: ['local_type'] })
Object.assign(client, { listMessageStoresAsync: vi.fn(async () => []) })
const result = await client.countImageMessagesAsync(MD5_OF_USERNAME)
expect(result.count).toBeNull()
expect(result.error).toBe('未找到该会话的消息表')
})
it('原生统计通道不可用时说明是环境问题,与 0 张区分开', async () => {
const client = makeClient({ sessions: [{ username: USERNAME }], columns: ['local_type'] })
Object.assign(client, { wcdbExecQuery: null })
const result = await client.countImageMessagesAsync(MD5_OF_USERNAME)
expect(result.count).toBeNull()
expect(result.error).toBe('当前数据服务不支持消息表统计')
})
})