diff --git a/electron/main.ts b/electron/main.ts index 3c574df..2b44252 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2208,11 +2208,21 @@ function registerIpcHandlers() { // WCDB 数据库相关 ipcMain.handle('wcdb:testConnection', async (_, dbPath: string, hexKey: string, wxid: string) => { - return wcdbService.testConnection(dbPath, hexKey, wxid) + const cfg = configService || new ConfigService() + const accountDir = cfg.getAccountDir(dbPath, wxid) + if (!accountDir) { + return { success: false, error: '未找到账号目录' } + } + return wcdbService.testConnection(accountDir, hexKey) }) ipcMain.handle('wcdb:open', async (_, dbPath: string, hexKey: string, wxid: string) => { - return wcdbService.open(dbPath, hexKey, wxid) + const cfg = configService || new ConfigService() + const accountDir = cfg.getAccountDir(dbPath, wxid) + if (!accountDir) { + return false + } + return wcdbService.open(accountDir, hexKey) }) ipcMain.handle('wcdb:close', async () => { diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts index 3bdee87..a340b36 100644 --- a/electron/services/analyticsService.ts +++ b/electron/services/analyticsService.ts @@ -131,9 +131,13 @@ class AnalyticsService { if (!dbPath) return { success: false, error: '未配置数据库路径' } if (!decryptKey) return { success: false, error: '未配置解密密钥' } - const cleanedWxid = this.cleanAccountDirName(wxid) - const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid) + const accountDir = this.configService.getAccountDir(dbPath, wxid) + if (!accountDir) return { success: false, error: '未找到账号目录' } + + const ok = await wcdbService.open(accountDir, decryptKey) if (!ok) return { success: false, error: 'WCDB 打开失败' } + + const cleanedWxid = this.cleanAccountDirName(wxid) return { success: true, cleanedWxid } } diff --git a/electron/services/annualReportService.ts b/electron/services/annualReportService.ts index fcd29c4..bf56492 100644 --- a/electron/services/annualReportService.ts +++ b/electron/services/annualReportService.ts @@ -1,5 +1,6 @@ import { parentPort } from 'worker_threads' import { wcdbService } from './wcdbService' +import { ConfigService } from './config' export interface TopContact { username: string @@ -158,9 +159,14 @@ class AnnualReportService { if (!dbPath) return { success: false, error: '未配置数据库路径' } if (!decryptKey) return { success: false, error: '未配置解密密钥' } - const cleanedWxid = this.cleanAccountDirName(wxid) - const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid) + const configService = ConfigService.getInstance() + const accountDir = configService.getAccountDir(dbPath, wxid) + if (!accountDir) return { success: false, error: '未找到账号目录' } + + const ok = await wcdbService.open(accountDir, decryptKey) if (!ok) return { success: false, error: 'WCDB 打开失败' } + + const cleanedWxid = this.cleanAccountDirName(wxid) return { success: true, cleanedWxid, rawWxid: wxid } } diff --git a/electron/services/backupService.ts b/electron/services/backupService.ts index 263afa0..abc1367 100644 --- a/electron/services/backupService.ts +++ b/electron/services/backupService.ts @@ -454,14 +454,14 @@ export class BackupService { if (!wxid || !dbPath) return { success: false, error: '请先配置数据库路径和微信账号' } if (!decryptKey) return { success: false, error: '请先配置数据库解密密钥' } - const accountDir = this.resolveAccountDir(dbPath, wxid) + // 使用 ConfigService 统一解析账号目录 + const accountDir = this.configService.getAccountDir(dbPath, wxid) if (!accountDir) return { success: false, error: `未在配置的 dbPath 下找到账号目录:${wxid}` } const dbStorage = join(accountDir, 'db_storage') if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' } - const accountDirName = basename(accountDir) const opened = await withTimeout( - wcdbService.open(dbPath, decryptKey, accountDirName), + wcdbService.open(accountDir, decryptKey), 15000, '连接目标账号数据库超时,请检查数据库路径、密钥是否正确' ) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 628ecf0..287c27e 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -553,8 +553,13 @@ class ChatService { return { success: false, error: '请先在设置页面配置解密密钥' } } - const cleanedWxid = this.cleanAccountDirName(wxid) - const openOk = await wcdbService.open(dbPath, decryptKey, cleanedWxid) + // 使用 ConfigService 统一解析账号目录 + const accountDir = this.configService.getAccountDir(dbPath, wxid) + if (!accountDir) { + return { success: false, error: '未找到账号目录,请检查数据库路径和微信ID配置' } + } + + const openOk = await wcdbService.open(accountDir, decryptKey) if (!openOk) { const detailedError = this.toCodeOnlyMessage(await wcdbService.getLastInitError()) await this.maybeShowInitFailureDialog(detailedError) diff --git a/electron/services/config.ts b/electron/services/config.ts index 2973e2d..d7ba1cc 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -1,4 +1,5 @@ import { join } from 'path' +import { existsSync, readdirSync, statSync } from 'fs' import { app, safeStorage } from 'electron' import crypto from 'crypto' import Store from 'electron-store' @@ -145,6 +146,9 @@ export class ConfigService { private unlockedKeys: Map = new Map() private unlockPassword: string | null = null + // 账号目录缓存 + private accountDirCache: Map = new Map() + static getInstance(): ConfigService { if (!ConfigService.instance) { ConfigService.instance = new ConfigService() @@ -839,6 +843,99 @@ export class ConfigService { } } + /** + * 清理账号目录名称(移除后缀) + */ + private cleanAccountDirName(dirName: string): string { + const trimmed = dirName.trim() + if (!trimmed) return trimmed + + // wxid_ 开头的特殊处理 + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + if (match) return match[1] + return trimmed + } + + // 移除4位后缀 + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + if (suffixMatch) return suffixMatch[1] + + return trimmed + } + + /** + * 检查是否是目录 + */ + private isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory() + } catch { + return false + } + } + + /** + * 获取账号目录路径 + * 统一的账号目录解析方法,所有服务应该使用此方法而不是自己实现 + * + * @param dbPath 数据库根目录(可选,默认从配置读取) + * @param wxid 微信ID(可选,默认从配置读取) + * @returns 账号目录的完整路径,如果找不到返回 null + */ + getAccountDir(dbPath?: string, wxid?: string): string | null { + const actualDbPath = dbPath || this.get('dbPath') + const actualWxid = wxid || this.get('myWxid') + + if (!actualDbPath || !actualWxid) return null + + const cleanedWxid = this.cleanAccountDirName(actualWxid) + const normalized = actualDbPath.replace(/[\\/]+$/, '') + const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}` + + // 检查缓存 + const cached = this.accountDirCache.get(cacheKey) + if (cached && existsSync(cached)) return cached + if (cached && !existsSync(cached)) { + this.accountDirCache.delete(cacheKey) + } + + // 尝试直接路径(非 wxid_ 开头的账号) + const lowerWxid = cleanedWxid.toLowerCase() + if (!lowerWxid.startsWith('wxid_')) { + const direct = join(normalized, cleanedWxid) + if (existsSync(direct) && this.isDirectory(direct)) { + this.accountDirCache.set(cacheKey, direct) + return direct + } + } + + // 扫描目录查找匹配的账号目录 + try { + const entries = readdirSync(normalized) + for (const entry of entries) { + const entryPath = join(normalized, entry) + if (!this.isDirectory(entryPath)) continue + + const lowerEntry = entry.toLowerCase() + const isExactMatch = lowerEntry === lowerWxid + const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`) + + // wxid_ 开头只接受带后缀的目录;其他账号精确匹配或带后缀都可以 + const shouldMatch = lowerWxid.startsWith('wxid_') + ? isSuffixMatch + : (isExactMatch || isSuffixMatch) + + if (shouldMatch) { + this.accountDirCache.set(cacheKey, entryPath) + return entryPath + } + } + } catch { } + + return null + } + private getUserDataPath(): string { const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim() if (workerUserDataPath) { diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts index 87fe017..ad92985 100644 --- a/electron/services/dbPathService.ts +++ b/electron/services/dbPathService.ts @@ -160,6 +160,16 @@ export class DbPathService { // 检查是否有有效账号目录结构 if (this.isAccountDir(entryPath)) { + // 过滤掉不带后缀的 wxid_ 目录 + const lowerEntry = entry.toLowerCase() + if (lowerEntry.startsWith('wxid_')) { + // wxid_ 开头的目录必须带后缀(wxid_xxx_yyyy 格式) + const parts = entry.split('_') + if (parts.length <= 2) { + // wxid_xxx 格式,跳过 + continue + } + } accounts.push(entry) } } @@ -232,6 +242,16 @@ export class DbPathService { const lower = entry.toLowerCase() if (lower === 'all_users') continue if (!entry.includes('_')) continue + + // 过滤掉不带后缀的 wxid_ 目录 + if (lower.startsWith('wxid_')) { + const parts = entry.split('_') + if (parts.length <= 2) { + // wxid_xxx 格式,跳过 + continue + } + } + wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs }) } } diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 66552b4..4fcc2fb 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -514,50 +514,11 @@ export class ImageDecryptService { } private resolveAccountDir(dbPath: string, wxid: string): string | null { - const cleanedWxid = this.cleanAccountDirName(wxid) - const normalized = dbPath.replace(/[\\/]+$/, '') - const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}` - const cached = this.accountDirCache.get(cacheKey) - if (cached && existsSync(cached)) return cached - if (cached && !existsSync(cached)) { - this.accountDirCache.delete(cacheKey) - } - - const direct = join(normalized, cleanedWxid) - if (existsSync(direct)) { - this.accountDirCache.set(cacheKey, direct) - return direct - } - - if (this.isAccountDir(normalized)) { - this.accountDirCache.set(cacheKey, normalized) - return normalized - } - - try { - const entries = readdirSync(normalized) - const lowerWxid = cleanedWxid.toLowerCase() - for (const entry of entries) { - const entryPath = join(normalized, entry) - if (!this.isDirectory(entryPath)) continue - const lowerEntry = entry.toLowerCase() - if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) { - if (this.isAccountDir(entryPath)) { - this.accountDirCache.set(cacheKey, entryPath) - return entryPath - } - } - } - } catch { } - - return null + return this.configService.getAccountDir(dbPath, wxid) } private resolveCurrentAccountDir(): string | null { - const wxid = this.getConfiguredMyWxid() - const dbPath = this.getConfiguredDbPath() - if (!wxid || !dbPath) return null - return this.resolveAccountDir(dbPath, wxid) + return this.configService.getAccountDir() } /** diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 4785621..33c6bb7 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -131,6 +131,14 @@ class VideoService { if (dbPathContainsWxid) { return join(dbPath, 'msg', 'video') } + + // 使用 ConfigService 的统一账号目录解析 + const accountDir = this.configService.getAccountDir(dbPath, wxid) + if (accountDir) { + return join(accountDir, 'msg', 'video') + } + + // 回退到原始逻辑 return join(dbPath, wxid, 'msg', 'video') } @@ -144,6 +152,13 @@ class VideoService { return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')] } + // 使用 ConfigService 的统一账号目录解析 + const accountDir = this.configService.getAccountDir(dbPath, wxid) + if (accountDir) { + return [join(accountDir, 'db_storage', 'hardlink', 'hardlink.db')] + } + + // 回退到原始逻辑 return [ join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'), join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db') diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 489991b..5055f1c 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -1260,13 +1260,12 @@ export class WcdbCore { /** * 测试数据库连接 */ - async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> { + async testConnection(accountDir: string, hexKey: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> { try { // 如果当前已经有相同参数的活动连接,直接返回成功 if (this.handle !== null && - this.currentPath === dbPath && - this.currentKey === hexKey && - this.currentWxid === wxid) { + this.currentPath === accountDir && + this.currentKey === hexKey) { return { success: true, sessionCount: 0 } } @@ -1284,9 +1283,9 @@ export class WcdbCore { } } - // 构建 db_storage 目录路径 - const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid) - this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`) + // 直接使用账号目录 + const dbStoragePath = join(accountDir, 'db_storage') + this.writeLog(`testConnection accountDir=${accountDir} dbStorage=${dbStoragePath}`) if (!dbStoragePath || !existsSync(dbStoragePath)) { return { success: false, error: this.formatInitProtectionError(-3001) } @@ -1329,9 +1328,9 @@ export class WcdbCore { } // 恢复测试前的连接(如果之前有活动连接) - if (hadActiveConnection && prevPath && prevKey && prevWxid) { + if (hadActiveConnection && prevPath && prevKey) { try { - await this.open(prevPath, prevKey, prevWxid) + await this.open(prevPath, prevKey) } catch { // 恢复失败则保持断开,由调用方处理 } @@ -1536,7 +1535,7 @@ export class WcdbCore { /** * 打开数据库 */ - async open(dbPath: string, hexKey: string, wxid: string): Promise { + async open(accountDir: string, hexKey: string): Promise { try { lastDllInitError = null if (!this.initialized) { @@ -1546,9 +1545,8 @@ export class WcdbCore { // 检查是否已经是当前连接的参数,如果是则直接返回成功,实现"始终保持链接" if (this.handle !== null && - this.currentPath === dbPath && - this.currentKey === hexKey && - this.currentWxid === wxid) { + this.currentPath === accountDir && + this.currentKey === hexKey) { return true } @@ -1560,12 +1558,12 @@ export class WcdbCore { if (!initOk) return false } - const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid) - this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`, true) + const dbStoragePath = join(accountDir, 'db_storage') + this.writeLog(`open accountDir=${accountDir} dbStorage=${dbStoragePath}`, true) if (!dbStoragePath || !existsSync(dbStoragePath)) { - console.error('数据库目录不存在:', dbPath) - this.writeLog(`open failed: dbStorage not found for ${dbPath}`) + console.error('数据库目录不存在:', accountDir) + this.writeLog(`open failed: dbStorage not found for ${accountDir}`) lastDllInitError = this.formatInitProtectionError(-3001) return false } @@ -1596,8 +1594,11 @@ export class WcdbCore { return false } + // 从账号目录路径中提取 wxid(目录名) + const wxid = basename(accountDir) + this.handle = handle - this.currentPath = dbPath + this.currentPath = accountDir this.currentKey = hexKey this.currentWxid = wxid this.currentDbStoragePath = dbStoragePath @@ -1615,7 +1616,7 @@ export class WcdbCore { } this.writeLog(`open ok handle=${handle}`, true) await this.dumpDbStatus('open') - await this.runPostOpenDiagnostics(dbPath, dbStoragePath, sessionDbPath, wxid) + await this.runPostOpenDiagnostics(accountDir, dbStoragePath, sessionDbPath, wxid) return true } catch (e) { console.error('打开数据库异常:', e) diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index e33dc64..9577170 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -154,15 +154,17 @@ export class WcdbService { /** * 测试数据库连接 */ - async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> { - return this.callWorker('testConnection', { dbPath, hexKey, wxid }) + async testConnection(accountDir: string, hexKey: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> { + return this.callWorker('testConnection', { accountDir, hexKey }) } /** * 打开数据库 + * @param accountDir 账号目录的完整路径 + * @param hexKey 解密密钥 */ - async open(dbPath: string, hexKey: string, wxid: string): Promise { - return this.callWorker('open', { dbPath, hexKey, wxid }) + async open(accountDir: string, hexKey: string): Promise { + return this.callWorker('open', { accountDir, hexKey }) } async getLastInitError(): Promise { diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index d8e3ed3..103d085 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -32,10 +32,10 @@ if (parentPort) { break } case 'testConnection': - result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid) + result = await core.testConnection(payload.accountDir, payload.hexKey) break case 'open': - result = await core.open(payload.dbPath, payload.hexKey, payload.wxid) + result = await core.open(payload.accountDir, payload.hexKey) break case 'getLastInitError': result = core.getLastInitError() diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index e998744..69882f8 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2245,11 +2245,28 @@ box-shadow: 0 0 0 2px var(--primary-light); } +.image-unavailable.error { + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.2); + + svg { + color: rgba(239, 68, 68, 0.8); + } +} + .image-unavailable:disabled { cursor: default; opacity: 0.7; } +.image-error-reason { + font-size: 11px; + color: rgba(239, 68, 68, 0.9); + max-width: 140px; + word-break: break-word; + line-height: 1.3; +} + .image-action { font-size: 11px; color: var(--text-quaternary); diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 6194a5f..0cf0fdd 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -8370,6 +8370,8 @@ function MessageBubble({ // State variables... const [imageError, setImageError] = useState(false) + const [imageErrorReason, setImageErrorReason] = useState(undefined) + const [imageFailureKind, setImageFailureKind] = useState<'not_found' | 'decrypt_failed' | undefined>(undefined) const [imageLoading, setImageLoading] = useState(false) const [imageLoaded, setImageLoaded] = useState(false) const [imageStageLockHeight, setImageStageLockHeight] = useState(null) @@ -8757,7 +8759,11 @@ function MessageBubble({ if (result.success && result.localPath) { const renderPath = toRenderableImageSrc(result.localPath) if (!renderPath) { - if (!silent) setImageError(true) + if (!silent) { + setImageError(true) + setImageErrorReason('路径无效') + setImageFailureKind('decrypt_failed') + } return { success: false } } imageDataUrlCache.set(imageCacheKey, renderPath) @@ -8769,6 +8775,10 @@ function MessageBubble({ setImageHasUpdate(false) if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath) return { ...result, localPath: renderPath } + } else if (!silent && result.error) { + setImageError(true) + setImageErrorReason(result.error) + setImageFailureKind(result.failureKind) } } @@ -8785,9 +8795,17 @@ function MessageBubble({ setImageHasUpdate(false) return { success: true, localPath: dataUrl } } - if (!silent) setImageError(true) - } catch { - if (!silent) setImageError(true) + if (!silent) { + setImageError(true) + setImageErrorReason('图片数据获取失败') + setImageFailureKind('not_found') + } + } catch (e) { + if (!silent) { + setImageError(true) + setImageErrorReason(e instanceof Error ? e.message : '解密异常') + setImageFailureKind('decrypt_failed') + } } finally { if (!silent) setImageLoading(false) imageDecryptPendingRef.current = false @@ -9636,14 +9654,15 @@ function MessageBubble({ ) : imageError || !imageLocalPath ? ( ) : ( <> @@ -9659,6 +9678,8 @@ function MessageBubble({ onLoad={() => { setImageLoaded(true) setImageError(false) + setImageErrorReason(undefined) + setImageFailureKind(undefined) stabilizeImageScrollAfterResize() releaseImageStageLock() }} diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index c422894..6f0ffc1 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -1038,7 +1038,13 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { className="field-input" placeholder="64 位十六进制密钥" value={decryptKey} - onChange={(e) => setDecryptKey(e.target.value.trim())} + onChange={(e) => { + const value = e.target.value.trim() + setDecryptKey(value) + if (value.length === 64) { + setHasReacquiredDbKey(true) + } + }} />