fix: 修复公众号消息读取与 Windows 中文路径兼容

- 支持从 biz_message 分片读取公众号聊天记录
- 在左侧栏增加独立的公众号折叠分组
- 为 Windows 中文数据目录建立 ASCII 路径桥接 (#12)
- 补充公众号分片、侧栏分类和路径桥接测试
This commit is contained in:
Wxw-Gu
2026-08-07 16:21:39 +08:00
parent 0b845db2e0
commit 96c67f5bf8
6 changed files with 306 additions and 36 deletions
+2
View File
@@ -34,6 +34,7 @@ export interface FormattedContact {
m_nsNickName: string m_nsNickName: string
md5: string md5: string
type: 'user' | 'group' type: 'user' | 'group'
isOfficialAccount?: boolean
avatar?: string avatar?: string
wechatNickname?: string wechatNickname?: string
remark?: string remark?: string
@@ -157,6 +158,7 @@ export function listContacts(filter?: string): FormattedContact[] {
m_nsNickName: user.nickname || '未知用户', m_nsNickName: user.nickname || '未知用户',
md5, md5,
type: isGroup ? 'group' : 'user', type: isGroup ? 'group' : 'user',
isOfficialAccount: !isGroup && user.m_nsUsrName.startsWith('gh_'),
avatar: typeof user.avatar === 'string' ? user.avatar : undefined, avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
wechatNickname: user.wechatNickname, wechatNickname: user.wechatNickname,
remark: user.remark, remark: user.remark,
+152 -32
View File
@@ -39,6 +39,11 @@ export interface Wcdb4SessionQueryOptions {
hydrateStatuses?: boolean hydrateStatuses?: boolean
} }
export interface WindowsNativePathBridgeOptions {
platform?: NodeJS.Platform
publicRoot?: string
}
type Wcdb4MessageStore = { type Wcdb4MessageStore = {
tableName: string tableName: string
dbPath: string dbPath: string
@@ -236,11 +241,58 @@ type WcdbHandleOut = [number]
const nodeRequire = createRequire(import.meta.url) const nodeRequire = createRequire(import.meta.url)
function isAsciiPath(value: string): boolean {
return /^[\x20-\x7e]+$/.test(value)
}
export function resolveWindowsNativeAccountRoot(
accountRoot: string,
options: WindowsNativePathBridgeOptions = {}
): string {
const platform = options.platform || process.platform
if (platform !== 'win32' || isAsciiPath(accountRoot)) return accountRoot
const publicRoot = [
options.publicRoot,
process.env.PUBLIC,
path.join(process.env.SystemDrive || 'C:', 'Users', 'Public')
]
.map((candidate) => String(candidate || '').trim())
.find((candidate) => candidate && isAsciiPath(candidate))
if (!publicRoot || !isAsciiPath(publicRoot)) return accountRoot
const bridgeRoot = path.join(publicRoot, 'WechatExplorer', 'path-bridges')
const bridgePath = path.join(
bridgeRoot,
crypto
.createHash('sha256')
.update(path.resolve(accountRoot).toLowerCase())
.digest('hex')
.slice(0, 24)
)
try {
fs.ensureDirSync(bridgeRoot)
if (fs.existsSync(bridgePath)) {
const existingTarget = fs.realpathSync.native(bridgePath)
if (path.resolve(existingTarget).toLowerCase() === path.resolve(accountRoot).toLowerCase()) {
return bridgePath
}
fs.removeSync(bridgePath)
}
fs.symlinkSync(path.resolve(accountRoot), bridgePath, 'junction')
return bridgePath
} catch (error) {
console.warn('[WCDB4] 无法为中文数据目录建立 native 路径别名:', error)
return accountRoot
}
}
export class Wcdb4Client { export class Wcdb4Client {
static readonly defaultRoot = Wcdb4Client.findExistingDefaultRoot() static readonly defaultRoot = Wcdb4Client.findExistingDefaultRoot()
private readonly key: string private readonly key: string
private readonly accountRoot: string private readonly accountRoot: string
private readonly nativeAccountRoot: string
private readonly wxid: string private readonly wxid: string
private readonly dbStoragePath: string private readonly dbStoragePath: string
private readonly sessionDbPath: string private readonly sessionDbPath: string
@@ -352,8 +404,9 @@ export class Wcdb4Client {
this.accountRoot = accountRoot this.accountRoot = accountRoot
? Wcdb4Client.resolveAccountRoot(accountRoot) ? Wcdb4Client.resolveAccountRoot(accountRoot)
: Wcdb4Client.findLatestAccountRoot() : Wcdb4Client.findLatestAccountRoot()
this.nativeAccountRoot = resolveWindowsNativeAccountRoot(this.accountRoot)
this.wxid = Wcdb4Client.cleanAccountDirName(path.basename(this.accountRoot)) this.wxid = Wcdb4Client.cleanAccountDirName(path.basename(this.accountRoot))
this.dbStoragePath = path.join(this.accountRoot, 'db_storage') this.dbStoragePath = path.join(this.nativeAccountRoot, 'db_storage')
this.sessionDbPath = this.findSessionDb() this.sessionDbPath = this.findSessionDb()
if (!this.sessionDbPath) { if (!this.sessionDbPath) {
@@ -931,7 +984,7 @@ export class Wcdb4Client {
) )
try { try {
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows) const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows)
if (cursorMessages) { if (cursorMessages && cursorMessages.length > 0) {
const recoveredMessages = this.readRecallJournal(username, startTime, endTime) const recoveredMessages = this.readRecallJournal(username, startTime, endTime)
const mergedMessages = this.mergeMessageRows(cursorMessages, recoveredMessages, maxRows) const mergedMessages = this.mergeMessageRows(cursorMessages, recoveredMessages, maxRows)
console.log( console.log(
@@ -1035,16 +1088,7 @@ export class Wcdb4Client {
let tables: Wcdb4MessageStore[] let tables: Wcdb4MessageStore[]
try { try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>( tables = await this.listMessageStoresAsync(username)
this.wcdbGetMessageTableStats as unknown as KoffiAsyncFunction,
username
)
tables = (Array.isArray(rows) ? rows : [])
.map((row) => ({
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
dbPath: this.pickString(row, ['db_path', 'dbPath', 'path'])
}))
.filter((row) => row.tableName && row.dbPath)
} catch (error) { } catch (error) {
console.warn(`[WCDB4] voice count table stats failed username=${username}:`, error) console.warn(`[WCDB4] voice count table stats failed username=${username}:`, error)
return null return null
@@ -1094,7 +1138,7 @@ export class Wcdb4Client {
endTime?: number, endTime?: number,
limit?: number limit?: number
): Wcdb4Message[] { ): Wcdb4Message[] {
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return [] if (!this.wcdbExecQuery) return []
let tables: Wcdb4MessageStore[] = [] let tables: Wcdb4MessageStore[] = []
try { try {
@@ -1139,20 +1183,11 @@ export class Wcdb4Client {
endTime?: number, endTime?: number,
limit?: number limit?: number
): Promise<Wcdb4Message[]> { ): Promise<Wcdb4Message[]> {
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return [] if (!this.wcdbExecQuery) return []
let tables: Wcdb4MessageStore[] = [] let tables: Wcdb4MessageStore[] = []
try { try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>( tables = await this.listMessageStoresAsync(username)
this.wcdbGetMessageTableStats as unknown as KoffiAsyncFunction,
username
)
tables = (Array.isArray(rows) ? rows : [])
.map((row) => ({
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
dbPath: this.pickString(row, ['db_path', 'dbPath', 'path'])
}))
.filter((row) => row.tableName && row.dbPath)
} catch (error) { } catch (error) {
console.warn(`[WCDB4] async message table stats failed username=${username}:`, error) console.warn(`[WCDB4] async message table stats failed username=${username}:`, error)
throw new Error( throw new Error(
@@ -1231,10 +1266,37 @@ export class Wcdb4Client {
} }
private listMessageStores(username: string): Wcdb4MessageStore[] { private listMessageStores(username: string): Wcdb4MessageStore[] {
if (!this.wcdbGetMessageTableStats) return [] let stores: Wcdb4MessageStore[] = []
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) => if (this.wcdbGetMessageTableStats) {
this.wcdbGetMessageTableStats!(handle, username, outJson) try {
) const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbGetMessageTableStats!(handle, username, outJson)
)
stores = this.parseMessageStores(rows)
} catch (error) {
if (!username.startsWith('gh_')) throw error
}
}
return stores.length > 0 ? stores : this.listBizMessageStores(username)
}
private async listMessageStoresAsync(username: string): Promise<Wcdb4MessageStore[]> {
let stores: Wcdb4MessageStore[] = []
if (this.wcdbGetMessageTableStats) {
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbGetMessageTableStats as unknown as KoffiAsyncFunction,
username
)
stores = this.parseMessageStores(rows)
} catch (error) {
if (!username.startsWith('gh_')) throw error
}
}
return stores.length > 0 ? stores : this.listBizMessageStoresAsync(username)
}
private parseMessageStores(rows: Record<string, unknown>[]): Wcdb4MessageStore[] {
return (Array.isArray(rows) ? rows : []) return (Array.isArray(rows) ? rows : [])
.map((row) => ({ .map((row) => ({
tableName: this.pickString(row, ['table_name', 'tableName', 'name']), tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
@@ -1243,6 +1305,64 @@ export class Wcdb4Client {
.filter((row) => row.tableName && row.dbPath) .filter((row) => row.tableName && row.dbPath)
} }
private getBizMessageDatabasePaths(): string[] {
const messageRoot = path.join(this.dbStoragePath, 'message')
try {
return fs
.readdirSync(messageRoot)
.filter((name) => /^biz_message(?:_\d+)?\.db$/i.test(name))
.sort()
.map((name) => path.join(messageRoot, name))
} catch {
return []
}
}
private listBizMessageStores(username: string): Wcdb4MessageStore[] {
if (!this.wcdbExecQuery || !username.startsWith('gh_')) return []
const tableName = `Msg_${this.md5(username)}`
const escapedTableName = tableName.replace(/'/g, "''")
const stores: Wcdb4MessageStore[] = []
for (const dbPath of this.getBizMessageDatabasePaths()) {
try {
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbExecQuery!(
handle,
'message',
dbPath,
`SELECT name FROM sqlite_master WHERE type='table' AND name='${escapedTableName}'`,
outJson
)
)
if (Array.isArray(rows) && rows.length > 0) stores.push({ tableName, dbPath })
} catch {
// Older biz shards may be absent or use a different key; continue scanning.
}
}
return stores
}
private async listBizMessageStoresAsync(username: string): Promise<Wcdb4MessageStore[]> {
if (!this.wcdbExecQuery || !username.startsWith('gh_')) return []
const tableName = `Msg_${this.md5(username)}`
const escapedTableName = tableName.replace(/'/g, "''")
const stores: Wcdb4MessageStore[] = []
for (const dbPath of this.getBizMessageDatabasePaths()) {
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'message',
dbPath,
`SELECT name FROM sqlite_master WHERE type='table' AND name='${escapedTableName}'`
)
if (Array.isArray(rows) && rows.length > 0) stores.push({ tableName, dbPath })
} catch {
// Older biz shards may be absent or use a different key; continue scanning.
}
}
return stores
}
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) =>
@@ -1889,7 +2009,7 @@ export class Wcdb4Client {
try { try {
return this.callJson<Wcdb4ImageHardlink>((handle, outJson) => return this.callJson<Wcdb4ImageHardlink>((handle, outJson) =>
this.wcdbResolveImageHardlink!(handle, normalizedMd5, this.accountRoot, outJson) this.wcdbResolveImageHardlink!(handle, normalizedMd5, this.nativeAccountRoot, outJson)
) )
} catch (error) { } catch (error) {
console.warn('[WCDB4] resolve image hardlink failed:', error) console.warn('[WCDB4] resolve image hardlink failed:', error)
@@ -1908,7 +2028,7 @@ export class Wcdb4Client {
return await this.callJsonAsync<Wcdb4ImageHardlink>( return await this.callJsonAsync<Wcdb4ImageHardlink>(
this.wcdbResolveImageHardlink as unknown as KoffiAsyncFunction, this.wcdbResolveImageHardlink as unknown as KoffiAsyncFunction,
normalizedMd5, normalizedMd5,
this.accountRoot this.nativeAccountRoot
) )
} catch (error) { } catch (error) {
console.warn('[WCDB4] async image hardlink resolve failed:', error) console.warn('[WCDB4] async image hardlink resolve failed:', error)
@@ -2287,8 +2407,8 @@ export class Wcdb4Client {
const candidates = [ const candidates = [
path.join(this.dbStoragePath, 'emoticon', 'emoticon.db'), path.join(this.dbStoragePath, 'emoticon', 'emoticon.db'),
path.join(this.dbStoragePath, 'emotion', 'emoticon.db'), path.join(this.dbStoragePath, 'emotion', 'emoticon.db'),
path.join(this.accountRoot, this.wxid, 'db_storage', 'emoticon', 'emoticon.db'), path.join(this.nativeAccountRoot, this.wxid, 'db_storage', 'emoticon', 'emoticon.db'),
path.join(this.accountRoot, this.wxid, 'db_storage', 'emotion', 'emoticon.db') path.join(this.nativeAccountRoot, this.wxid, 'db_storage', 'emotion', 'emoticon.db')
] ]
for (const candidate of candidates) { for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate if (fs.existsSync(candidate)) return candidate
@@ -26,7 +26,7 @@ export interface ConversationSidebarProps {
onRefresh: (filterKeyword: string) => Promise<void> onRefresh: (filterKeyword: string) => Promise<void>
} }
type SectionName = 'groups' | 'folded' | 'contacts' type SectionName = 'groups' | 'folded' | 'officialAccounts' | 'contacts'
type ConversationRow = type ConversationRow =
| { kind: 'header'; id: string; title: string; count: number; section: SectionName } | { kind: 'header'; id: string; title: string; count: number; section: SectionName }
| { kind: 'contact'; id: string; contact: Contact } | { kind: 'contact'; id: string; contact: Contact }
@@ -48,13 +48,24 @@ export function ConversationSidebar({
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({ const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
groups: true, groups: true,
folded: false, folded: false,
officialAccounts: false,
contacts: false contacts: false
}) })
const listRef = useRef<HTMLDivElement>(null) const listRef = useRef<HTMLDivElement>(null)
const groups = contacts.filter((contact) => contact.type === 'group' && !contact.isFolded) const groups = contacts.filter((contact) => contact.type === 'group' && !contact.isFolded)
const foldedGroups = contacts.filter((contact) => contact.type === 'group' && contact.isFolded) const foldedGroups = contacts.filter((contact) => contact.type === 'group' && contact.isFolded)
const users = contacts.filter((contact) => contact.type === 'user') const officialAccounts = contacts.filter(
(contact) =>
contact.type === 'user' &&
(contact.isOfficialAccount || contact.m_nsUsrName.startsWith('gh_'))
)
const users = contacts.filter(
(contact) =>
contact.type === 'user' &&
!contact.isOfficialAccount &&
!contact.m_nsUsrName.startsWith('gh_')
)
const rows = useMemo<ConversationRow[]>( const rows = useMemo<ConversationRow[]>(
() => [ () => [
{ {
@@ -89,6 +100,24 @@ export function ConversationSidebar({
: []) : [])
] ]
: []), : []),
...(officialAccounts.length
? [
{
kind: 'header' as const,
id: 'official-accounts-header',
title: '公众号',
count: officialAccounts.length,
section: 'officialAccounts' as const
},
...(expandedSections.officialAccounts
? officialAccounts.map((contact) => ({
kind: 'contact' as const,
id: `official-${contact.md5}`,
contact
}))
: [])
]
: []),
{ {
kind: 'header', kind: 'header',
id: 'contacts-header', id: 'contacts-header',
@@ -104,8 +133,10 @@ export function ConversationSidebar({
expandedSections.contacts, expandedSections.contacts,
expandedSections.folded, expandedSections.folded,
expandedSections.groups, expandedSections.groups,
expandedSections.officialAccounts,
foldedGroups, foldedGroups,
groups, groups,
officialAccounts,
users users
] ]
) )
+1
View File
@@ -3,6 +3,7 @@ export interface Contact {
m_nsNickName: string m_nsNickName: string
md5: string md5: string
type: 'user' | 'group' type: 'user' | 'group'
isOfficialAccount?: boolean
avatar?: string avatar?: string
wechatNickname?: string wechatNickname?: string
remark?: string remark?: string
@@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ConversationSidebar } from '../../src/renderer/src/components/conversation/ConversationSidebar'
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getTotalSize: () => count * 58,
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({
index,
key: index,
start: index * 58,
size: 58
}))
})
}))
describe('ConversationSidebar', () => {
it('keeps official accounts in their own collapsible section', async () => {
render(
<ConversationSidebar
contacts={[
{
m_nsUsrName: 'gh_fixture',
m_nsNickName: '测试公众号',
md5: 'official-md5',
type: 'user',
isOfficialAccount: true
},
{
m_nsUsrName: 'wxid_fixture',
m_nsNickName: '测试联系人',
md5: 'contact-md5',
type: 'user'
}
]}
selectedContact={null}
onSelectContact={vi.fn()}
onSearch={vi.fn()}
onContentFilter={vi.fn()}
width={320}
selfInfo={null}
dbReady
onOpenSettings={vi.fn()}
onRefresh={vi.fn(async () => undefined)}
/>
)
expect(screen.getByRole('button', { name: '公众号 (1)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '联系人 (1)' })).toBeInTheDocument()
expect(screen.queryByText('测试公众号')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: '公众号 (1)' }))
expect(screen.getByText('测试公众号')).toBeInTheDocument()
})
})
+61 -2
View File
@@ -1,5 +1,12 @@
import { describe, expect, it, vi } from 'vitest' import fs from 'fs-extra'
import { Wcdb4Client, type Wcdb4Message } from '../../src/main/wcdb4-client' import os from 'os'
import path from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
resolveWindowsNativeAccountRoot,
Wcdb4Client,
type Wcdb4Message
} from '../../src/main/wcdb4-client'
const message = (id: string, year: number, serverId = `server-${id}`): Wcdb4Message => ({ const message = (id: string, year: number, serverId = `server-${id}`): Wcdb4Message => ({
mesLocalID: id, mesLocalID: id,
@@ -12,6 +19,12 @@ const message = (id: string, year: number, serverId = `server-${id}`): Wcdb4Mess
}) })
describe('WCDB message shard pagination', () => { describe('WCDB message shard pagination', () => {
const temporaryDirectories: string[] = []
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) fs.removeSync(directory)
})
it('keeps messages whose local ids repeat across database shards', async () => { it('keeps messages whose local ids repeat across database shards', async () => {
const cursor = vi.fn(async () => [ const cursor = vi.fn(async () => [
message('1', 2024, 'server-2024'), message('1', 2024, 'server-2024'),
@@ -57,4 +70,50 @@ describe('WCDB message shard pagination', () => {
client.getMessagesAsync('fixture@chatroom', undefined, 1_767_225_600, { limit: 20 }) client.getMessagesAsync('fixture@chatroom', undefined, 1_767_225_600, { limit: 20 })
).rejects.toThrow('无法检查历史消息分片') ).rejects.toThrow('无法检查历史消息分片')
}) })
it('falls back to biz_message shards for official-account sessions', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-biz-shards-'))
temporaryDirectories.push(root)
const messageRoot = path.join(root, 'db_storage', 'message')
fs.ensureDirSync(messageRoot)
const bizDbPath = path.join(messageRoot, 'biz_message_0.db')
fs.writeFileSync(bizDbPath, '')
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
dbStoragePath: path.join(root, 'db_storage'),
wcdbGetMessageTableStats: null,
wcdbExecQuery: vi.fn(),
callJson: vi.fn(() => [{ name: 'Msg_19cde0e21f4f938ca1fcebd7146dbbd2' }])
}) as Wcdb4Client
const stores = Reflect.get(client, 'listMessageStores').call(client, 'gh_23069e016533')
expect(stores).toEqual([
{
tableName: 'Msg_19cde0e21f4f938ca1fcebd7146dbbd2',
dbPath: bizDbPath
}
])
})
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)
const publicRoot = path.join(root, 'Public')
const accountRoot = path.join(root, '微信聊天记录', 'wxid_fixture')
fs.ensureDirSync(path.join(accountRoot, 'db_storage'))
const first = resolveWindowsNativeAccountRoot(accountRoot, {
platform: 'win32',
publicRoot
})
const second = resolveWindowsNativeAccountRoot(accountRoot, {
platform: 'win32',
publicRoot
})
expect(first).toBe(second)
expect(first).not.toContain('微信聊天记录')
expect(fs.realpathSync(first)).toBe(fs.realpathSync(accountRoot))
})
}) })