Merge pull request #55 from xxoommd/fix/chat-display

fix: 修复 Windows 微信聊天记录读取为空
This commit is contained in:
qingmao
2026-10-09 21:33:12 +08:00
committed by GitHub
2 changed files with 119 additions and 19 deletions
+45 -19
View File
@@ -54,6 +54,7 @@ export interface WindowsNativePathBridgeOptions {
type Wcdb4MessageStore = { type Wcdb4MessageStore = {
tableName: string tableName: string
dbPath: string dbPath: string
senderTableName?: string
} }
export interface Wcdb4GroupMember { export interface Wcdb4GroupMember {
@@ -1623,7 +1624,7 @@ export class Wcdb4Client {
try { try {
const order = limit ? 'DESC' : 'ASC' const order = limit ? 'DESC' : 'ASC'
const rowLimit = limit || 5000 const rowLimit = limit || 5000
const sql = `SELECT * FROM ${this.quoteSqlIdentifier(table.tableName)}${whereSql} ORDER BY "create_time" ${order} LIMIT ${rowLimit}` const sql = `${this.messageTableSelect(table)}${whereSql} ORDER BY "create_time" ${order} LIMIT ${rowLimit}`
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) => const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbExecQuery!(handle, 'message', table.dbPath, sql, outJson) this.wcdbExecQuery!(handle, 'message', table.dbPath, sql, outJson)
) )
@@ -1699,7 +1700,7 @@ export class Wcdb4Client {
// `local_id` 参与排序:`create_time` 同秒的消息需要一个稳定次序, // `local_id` 参与排序:`create_time` 同秒的消息需要一个稳定次序,
// 否则多次读取的行序可能不同,调用方无法做稳定游标。 // 否则多次读取的行序可能不同,调用方无法做稳定游标。
const direction = options.order === 'desc' ? 'DESC' : 'ASC' const direction = options.order === 'desc' ? 'DESC' : 'ASC'
const sql = `SELECT * FROM ${this.quoteSqlIdentifier(table.tableName)} WHERE ${where} ORDER BY "create_time" ${direction}, "local_id" ${direction} LIMIT ${limit}` const sql = `${this.messageTableSelect(table)} WHERE ${where} ORDER BY "create_time" ${direction}, "local_id" ${direction} LIMIT ${limit}`
const queryStartedAt = Date.now() const queryStartedAt = Date.now()
const rows = await this.callJsonAsync<Record<string, unknown>[]>( const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction, this.wcdbExecQuery as unknown as KoffiAsyncFunction,
@@ -1771,7 +1772,7 @@ export class Wcdb4Client {
let successfulTables = 0 let successfulTables = 0
for (const table of tables) { for (const table of tables) {
try { try {
const sql = `SELECT * FROM ${this.quoteSqlIdentifier(table.tableName)}${whereSql} ORDER BY "create_time" ${order} LIMIT ${rowLimit}` const sql = `${this.messageTableSelect(table)}${whereSql} ORDER BY "create_time" ${order} LIMIT ${rowLimit}`
const queryStartedAt = Date.now() const queryStartedAt = Date.now()
const rows = await this.callJsonAsync<Record<string, unknown>[]>( const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction, this.wcdbExecQuery as unknown as KoffiAsyncFunction,
@@ -1847,7 +1848,7 @@ export class Wcdb4Client {
if (!username.startsWith('gh_')) throw error if (!username.startsWith('gh_')) throw error
} }
} }
return stores.length > 0 ? stores : this.listBizMessageStores(username) return stores.length > 0 ? stores : this.listFallbackMessageStores(username)
} }
private async listMessageStoresAsync(username: string): Promise<Wcdb4MessageStore[]> { private async listMessageStoresAsync(username: string): Promise<Wcdb4MessageStore[]> {
@@ -1863,7 +1864,7 @@ export class Wcdb4Client {
if (!username.startsWith('gh_')) throw error if (!username.startsWith('gh_')) throw error
} }
} }
return stores.length > 0 ? stores : this.listBizMessageStoresAsync(username) return stores.length > 0 ? stores : this.listFallbackMessageStoresAsync(username)
} }
private parseMessageStores(rows: Record<string, unknown>[]): Wcdb4MessageStore[] { private parseMessageStores(rows: Record<string, unknown>[]): Wcdb4MessageStore[] {
@@ -1875,12 +1876,17 @@ export class Wcdb4Client {
.filter((row) => row.tableName && row.dbPath) .filter((row) => row.tableName && row.dbPath)
} }
private getBizMessageDatabasePaths(): string[] { private getMessageDatabasePaths(username: string): string[] {
// Native shard discovery can return no stores on Windows even when SQL reads work.
// Enumerate only canonical databases; sync-conflict copies are not message shards.
const messageRoot = path.join(this.dbStoragePath, 'message') const messageRoot = path.join(this.dbStoragePath, 'message')
const pattern = username.startsWith('gh_')
? /^biz_message(?:_\d+)?\.db$/i
: /^message(?:_\d+)?\.db$/i
try { try {
return fs return fs
.readdirSync(messageRoot) .readdirSync(messageRoot)
.filter((name) => /^biz_message(?:_\d+)?\.db$/i.test(name)) .filter((name) => pattern.test(name))
.sort() .sort()
.map((name) => path.join(messageRoot, name)) .map((name) => path.join(messageRoot, name))
} catch { } catch {
@@ -1888,51 +1894,71 @@ export class Wcdb4Client {
} }
} }
private listBizMessageStores(username: string): Wcdb4MessageStore[] { private listFallbackMessageStores(username: string): Wcdb4MessageStore[] {
if (!this.wcdbExecQuery || !username.startsWith('gh_')) return [] if (!this.wcdbExecQuery) return []
const tableName = `Msg_${this.md5(username)}` const tableName = `Msg_${this.md5(username)}`
const escapedTableName = tableName.replace(/'/g, "''") const escapedTableName = tableName.replace(/'/g, "''")
const stores: Wcdb4MessageStore[] = [] const stores: Wcdb4MessageStore[] = []
for (const dbPath of this.getBizMessageDatabasePaths()) { for (const dbPath of this.getMessageDatabasePaths(username)) {
try { try {
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) => const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbExecQuery!( this.wcdbExecQuery!(
handle, handle,
'message', 'message',
dbPath, dbPath,
`SELECT name FROM sqlite_master WHERE type='table' AND name='${escapedTableName}'`, `SELECT name FROM sqlite_master WHERE type='table' AND (name='${escapedTableName}' OR name LIKE 'Name2Id%') ORDER BY name DESC`,
outJson outJson
) )
) )
if (Array.isArray(rows) && rows.length > 0) stores.push({ tableName, dbPath }) if (Array.isArray(rows) && rows.some((row) => row.name === tableName)) {
const senderTableName = rows.find((row) => /^Name2Id/i.test(String(row.name)))?.name
stores.push({
tableName,
dbPath,
...(senderTableName ? { senderTableName: String(senderTableName) } : {})
})
}
} catch { } catch {
// Older biz shards may be absent or use a different key; continue scanning. // A shard may use a different key; continue checking the other databases.
} }
} }
return stores return stores
} }
private async listBizMessageStoresAsync(username: string): Promise<Wcdb4MessageStore[]> { private async listFallbackMessageStoresAsync(username: string): Promise<Wcdb4MessageStore[]> {
if (!this.wcdbExecQuery || !username.startsWith('gh_')) return [] if (!this.wcdbExecQuery) return []
const tableName = `Msg_${this.md5(username)}` const tableName = `Msg_${this.md5(username)}`
const escapedTableName = tableName.replace(/'/g, "''") const escapedTableName = tableName.replace(/'/g, "''")
const stores: Wcdb4MessageStore[] = [] const stores: Wcdb4MessageStore[] = []
for (const dbPath of this.getBizMessageDatabasePaths()) { for (const dbPath of this.getMessageDatabasePaths(username)) {
try { try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>( const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction, this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'message', 'message',
dbPath, dbPath,
`SELECT name FROM sqlite_master WHERE type='table' AND name='${escapedTableName}'` `SELECT name FROM sqlite_master WHERE type='table' AND (name='${escapedTableName}' OR name LIKE 'Name2Id%') ORDER BY name DESC`
) )
if (Array.isArray(rows) && rows.length > 0) stores.push({ tableName, dbPath }) if (Array.isArray(rows) && rows.some((row) => row.name === tableName)) {
const senderTableName = rows.find((row) => /^Name2Id/i.test(String(row.name)))?.name
stores.push({
tableName,
dbPath,
...(senderTableName ? { senderTableName: String(senderTableName) } : {})
})
}
} catch { } catch {
// Older biz shards may be absent or use a different key; continue scanning. // A shard may use a different key; continue checking the other databases.
} }
} }
return stores return stores
} }
private messageTableSelect(store: Wcdb4MessageStore): string {
const table = this.quoteSqlIdentifier(store.tableName)
if (!store.senderTableName) return `SELECT * FROM ${table}`
return `SELECT m.*, n.user_name AS sender_username FROM ${table} AS m LEFT JOIN ${this.quoteSqlIdentifier(store.senderTableName)} AS n ON n.rowid = m.real_sender_id`
}
private executeMessageSql(store: Wcdb4MessageStore, sql: string): Record<string, unknown>[] { private executeMessageSql(store: Wcdb4MessageStore, sql: string): Record<string, unknown>[] {
if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道') if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道')
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) => const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
+74
View File
@@ -97,6 +97,80 @@ describe('WCDB message shard pagination', () => {
]) ])
}) })
it.each(['wxid_fixture_peer', 'fixture@chatroom'])(
'reads %s from all canonical shards when native discovery is empty',
async (username) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-message-fallback-'))
temporaryDirectories.push(root)
const dbStoragePath = path.join(root, 'db_storage')
const messageRoot = path.join(dbStoragePath, 'message')
fs.ensureDirSync(messageRoot)
for (const name of [
'message_0.db',
'message_7.db',
'message_6.db',
'message_0 (同步冲突).db',
'biz_message_0.db'
]) {
fs.writeFileSync(path.join(messageRoot, name), '')
}
const tableName = `Msg_${crypto.createHash('md5').update(username).digest('hex')}`
const query = vi.fn((_kind: string, dbPath: string, sql: string) => {
if (sql.includes('sqlite_master')) {
return dbPath.endsWith('message_6.db')
? [{ name: 'Name2Id' }]
: [{ name: tableName }, { name: 'Name2Id' }]
}
expect(sql).toContain('n.user_name AS sender_username')
expect(sql).toContain('n.rowid = m.real_sender_id')
expect(sql).toContain('"create_time" <= 1800000000')
expect(sql).toContain('ORDER BY "create_time" DESC LIMIT 20')
const isLatestShard = dbPath.endsWith('message_7.db')
return [
{
local_id: 1,
server_id: isLatestShard ? 'fixture-new' : 'fixture-old',
local_type: 1,
create_time: isLatestShard ? 1700000001 : 1700000000,
message_content: 'fixture text',
sender_username: isLatestShard ? 'wxid_self' : 'wxid_peer'
}
]
})
const stats = vi.fn(() => [])
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
dbStoragePath,
accountRoot: path.join(root, 'wxid_self'),
wxid: 'wxid_self',
displayNameCache: new Map(),
avatarCache: new Map(),
wcdbGetMessageTableStats: stats,
wcdbExecQuery: query,
getMessagesByCursor: vi.fn(() => []),
getMessagesByCursorAsync: vi.fn(async () => []),
getGroupNicknames: vi.fn(() => new Map()),
callJson: vi.fn((call) => call(1, [null])),
callJsonAsync: vi.fn(async (fn, ...args) => fn(...args))
}) as Wcdb4Client
// The synchronous FFI callback includes handle/output arguments.
const syncQuery = vi.fn((_handle, kind, dbPath, sql) => query(kind, dbPath, sql))
Reflect.set(client, 'wcdbExecQuery', syncQuery)
const synchronous = client.getMessages(username, undefined, 1800000000, { limit: 20 })
Reflect.set(client, 'wcdbExecQuery', query)
const asynchronous = await client.getMessagesAsync(username, undefined, 1800000000, {
limit: 20
})
expect(asynchronous).toEqual(synchronous)
expect(asynchronous.map((row) => [row.serverId, row.sender, row.mesDes])).toEqual([
['fixture-old', 'wxid_peer', 1],
['fixture-new', 'wxid_self', 0]
])
expect(query).toHaveBeenCalledTimes(10)
expect(query.mock.calls.every(([, dbPath]) => /message_(0|6|7)\.db$/.test(dbPath))).toBe(true)
}
)
it('creates a stable ASCII junction for a Windows account path containing Chinese', () => { it('creates a stable ASCII junction for a Windows account path containing Chinese', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-path-bridge-')) const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-path-bridge-'))
temporaryDirectories.push(root) temporaryDirectories.push(root)