From 108d04f9010026883a05067d1b00f94f4bf3e56a Mon Sep 17 00:00:00 2001 From: xxoommd Date: Thu, 8 Oct 2026 10:42:28 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Windows=20=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E8=81=8A=E5=A4=A9=E8=AE=B0=E5=BD=95=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E4=B8=BA=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/wcdb4-client.ts | 64 +++++++++++++++------- tests/unit/wcdb-message-shards.test.ts | 74 ++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 19 deletions(-) diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 4a1c848..2f33b89 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -54,6 +54,7 @@ export interface WindowsNativePathBridgeOptions { type Wcdb4MessageStore = { tableName: string dbPath: string + senderTableName?: string } export interface Wcdb4GroupMember { @@ -1623,7 +1624,7 @@ export class Wcdb4Client { try { const order = limit ? 'DESC' : 'ASC' 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[]>((handle, outJson) => this.wcdbExecQuery!(handle, 'message', table.dbPath, sql, outJson) ) @@ -1699,7 +1700,7 @@ export class Wcdb4Client { // `local_id` 参与排序:`create_time` 同秒的消息需要一个稳定次序, // 否则多次读取的行序可能不同,调用方无法做稳定游标。 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 rows = await this.callJsonAsync[]>( this.wcdbExecQuery as unknown as KoffiAsyncFunction, @@ -1771,7 +1772,7 @@ export class Wcdb4Client { let successfulTables = 0 for (const table of tables) { 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 rows = await this.callJsonAsync[]>( this.wcdbExecQuery as unknown as KoffiAsyncFunction, @@ -1847,7 +1848,7 @@ export class Wcdb4Client { 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 { @@ -1863,7 +1864,7 @@ export class Wcdb4Client { 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[]): Wcdb4MessageStore[] { @@ -1875,12 +1876,17 @@ export class Wcdb4Client { .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 pattern = username.startsWith('gh_') + ? /^biz_message(?:_\d+)?\.db$/i + : /^message(?:_\d+)?\.db$/i try { return fs .readdirSync(messageRoot) - .filter((name) => /^biz_message(?:_\d+)?\.db$/i.test(name)) + .filter((name) => pattern.test(name)) .sort() .map((name) => path.join(messageRoot, name)) } catch { @@ -1888,51 +1894,71 @@ export class Wcdb4Client { } } - private listBizMessageStores(username: string): Wcdb4MessageStore[] { - if (!this.wcdbExecQuery || !username.startsWith('gh_')) return [] + private listFallbackMessageStores(username: string): Wcdb4MessageStore[] { + if (!this.wcdbExecQuery) return [] const tableName = `Msg_${this.md5(username)}` const escapedTableName = tableName.replace(/'/g, "''") const stores: Wcdb4MessageStore[] = [] - for (const dbPath of this.getBizMessageDatabasePaths()) { + for (const dbPath of this.getMessageDatabasePaths(username)) { try { const rows = this.callJson[]>((handle, outJson) => this.wcdbExecQuery!( handle, 'message', 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 ) ) - 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 { - // 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 } - private async listBizMessageStoresAsync(username: string): Promise { - if (!this.wcdbExecQuery || !username.startsWith('gh_')) return [] + private async listFallbackMessageStoresAsync(username: string): Promise { + if (!this.wcdbExecQuery) return [] const tableName = `Msg_${this.md5(username)}` const escapedTableName = tableName.replace(/'/g, "''") const stores: Wcdb4MessageStore[] = [] - for (const dbPath of this.getBizMessageDatabasePaths()) { + for (const dbPath of this.getMessageDatabasePaths(username)) { try { const rows = await this.callJsonAsync[]>( this.wcdbExecQuery as unknown as KoffiAsyncFunction, 'message', 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 { - // 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 } + 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[] { if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道') const rows = this.callJson[]>((handle, outJson) => diff --git a/tests/unit/wcdb-message-shards.test.ts b/tests/unit/wcdb-message-shards.test.ts index 44734e8..16ca783 100644 --- a/tests/unit/wcdb-message-shards.test.ts +++ b/tests/unit/wcdb-message-shards.test.ts @@ -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', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-path-bridge-')) temporaryDirectories.push(root)