From 48b6b2216f269d3ae9284c67f01bc04e9996d7a7 Mon Sep 17 00:00:00 2001 From: aliyun8639545015 Date: Thu, 28 May 2026 00:50:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(account-dir):=20=E4=BF=AE=E5=A4=8D=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=9B=AE=E5=BD=95=E8=A7=A3=E6=9E=90=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=20-3001=20=E9=94=99=E8=AF=AF=E7=9A=84=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=E7=BC=BA=E9=99=B7=20(#996)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题现象 部分用户在新版 WeFlow 配置 / 启动时持续报错: 「数据库目录不存在: \」(错误码 -3001) 复现条件之一:用户曾在微信里"自定义过微信号",dbPath 下会同时遗留两个 形似的子目录: - `<自定义号>` :旧的、无后缀的目录,里面没有 db_storage - `<自定义号>_<4 位后缀>` :微信 4.x 实际写入数据的目录 ## 根因分析 账号目录解析有两处独立缺陷,分别命中不同人群: 1. **dbPathService.findAccountDirs / scanWxidCandidates** 对名字以 `wxid_` 开头的目录额外要求"段数(按 `_` 切分)≥ 3"才放行, 会让"未自定义过微信号"的普通用户(真实目录就叫 `wxid_X`)的账号 完全消失在欢迎页扫描结果里。 2. **config.getAccountDir / accountDirResolver.resolveAccountDir** 对非 `wxid_` 开头的输入存在错误的"短路返回"分支: if (!lowerWxid.startsWith('wxid_')) { const direct = join(root, cleanedWxid) if (existsSync(direct)) return direct // ← 没校验里面有没有 db_storage } 叠加 cleanAccountDirName 会把 `<自定义号>_<4 位后缀>` 清洗成 `<自定义号>`, 于是无论用户保存的 myWxid 是哪个,都会命中旧的、无后缀的空目录, 最终在 wcdbCore.open 阶段触发 -3001。 ## 修复策略 把两个文件中"快速短路返回"的代码路径全部去掉,统一走基于"候选 + 评分" 的扫描流程: 1) 同时接受**精确匹配**(entry == cleanedWxid) 与 **后缀匹配**(entry.startsWith(cleanedWxid + '_')) 两种命中; 2) 用 accountDirLooksValid 过滤掉"看起来根本不像账号目录"的项 (没有 db_storage 也没有 FileStorage/Image[2]),从而过滤掉残留空目录; 3) 在剩余候选中按以下优先级排序,取最优: - 有 session.db > 没有:区分"真正写入数据" vs "残留空目录"; - 后缀匹配 > 精确匹配:与微信 4.x 实际写入目录命名一致; - 修改时间更新 > 更旧:兜底。 dbPathService 侧不再以"段数"过滤目录,改由新增的 dedupeAccountDirs 处理 "无后缀目录"与"带后缀目录"同时存在时的去重,保留"微信实际在用"那个。 ## 兼容性 - 旧版本残留的 myWxid(无论用户存的是无后缀还是带后缀形式)都会被 正确解析到带 session.db 的目录,用户无需手动修改配置; - 未自定义微信号的普通用户(目录就叫 `wxid_X`)现在能正常被识别; - 多账号、自定义微信号目录、绝对路径形式的 dbPath 等其它场景行为不变。 ## 改动范围 - electron/services/dbPathService.ts findAccountDirs / scanWxidCandidates 不再按段数过滤; 新增 dedupeAccountDirs / shouldPreferSuffixedDir / hasSessionDb 三个辅助方法。 - electron/services/config.ts 重写 getAccountDir 扫描分支;新增 accountDirLooksValid / accountDirHasSessionDb 两个辅助方法。 - electron/services/accountDirResolver.ts 与 config.ts 同步重写 resolveAccountDir,去掉错误的短路分支。 Closes #996 --- electron/services/accountDirResolver.ts | 138 ++++++++++++++++++++--- electron/services/config.ts | 126 +++++++++++++++++---- electron/services/dbPathService.ts | 144 ++++++++++++++++++++---- 3 files changed, 347 insertions(+), 61 deletions(-) diff --git a/electron/services/accountDirResolver.ts b/electron/services/accountDirResolver.ts index 1ad8c7a..7c440fb 100644 --- a/electron/services/accountDirResolver.ts +++ b/electron/services/accountDirResolver.ts @@ -1,8 +1,30 @@ +/** + * 账号目录解析器(Worker 线程 / 主进程通用) + * + * 职责:在 dbPath 根目录下,根据传入的 wxid,找出微信"实际写入数据" + * 的那个账号子目录,例如: + * dbPath = <微信数据根目录> + * wxid = customwxid_abcd 或 customwxid + * 期望返回 <微信数据根目录>/customwxid_abcd(带后缀、有 session.db 的那个) + * + * 与 ConfigService.getAccountDir 行为保持一致;二者实现独立是因为本文件 + * 也会在 Worker 线程中被加载,无法依赖 electron-store。 + */ import { existsSync, readdirSync, statSync } from 'fs' import { join } from 'path' +// 解析结果缓存(进程内,避免重复 IO)。key = `${dbPath}|${cleanedWxid}` const accountDirCache = new Map() +/** + * 把 wxid 字符串"标准化"为目录前缀。 + * - wxid_xxx_yyyy → wxid_xxx (wxid_ 后只取第一段) + * - 自定义微信号_后缀(4 位) → 自定义微信号 (例如 customwxid_abcd → customwxid) + * - 其他形式 → 原样返回 + * + * 注意:清洗只是为了得到"前缀"用于扫描匹配,并不代表清洗结果就是真实目录名。 + * 真实目录名仍需在 dbPath 下按"前缀 + 任意后缀"扫描得出。 + */ const cleanAccountDirName = (dirName: string): string => { const trimmed = dirName.trim() if (!trimmed) return trimmed @@ -27,6 +49,39 @@ const isDirectory = (path: string): boolean => { } } +/** + * 解析账号目录的真实绝对路径。 + * + * ## 修复 #996(错误码 -3001:未找到数据库目录) + * + * ### 旧实现存在的两处严重缺陷 + * 1. **对 wxid_ 开头的目录强制要求"带后缀"**: + * 未自定义微信号的普通用户,目录就叫 `wxid_X`(无任何后缀), + * 旧逻辑会因为段数不足而把它过滤掉,导致这类用户根本匹配不到。 + * + * 2. **对非 wxid_ 开头(自定义微信号)走短路返回,且不校验目录有效性**: + * 旧实现写法是 + * ``` + * if (!lowerWxid.startsWith('wxid_')) { + * const direct = join(root, cleanedWxid) + * if (existsSync(direct)) return direct // ← 直接返回,没校验里面有没有 db_storage + * } + * ``` + * 叠加 `cleanAccountDirName` 会把 `<自定义号>_<4位后缀>` 清洗成 + * `<自定义号>`,于是无论用户存的是哪个 wxid,都会命中旧的、无后缀的 + * 空目录(它真实存在但里面没有 db_storage),最终触发 -3001。 + * + * ### 修复后的统一匹配流程 + * 1. 扫描 dbPath 下所有子目录; + * 2. 同时接受**精确匹配**(`entry == cleanedWxid`) 与 + * **后缀匹配**(`entry.startsWith(cleanedWxid + '_')`) 两种命中方式; + * 3. 用 {@link accountDirLooksValid} 过滤掉"看起来根本不像账号目录"的项 + * (没有 db_storage 也没有 FileStorage/Image[2]); + * 4. 在剩余候选中按以下优先级排序,取最优: + * - **有 session.db** > 没有:区分"真正写入数据"与"残留空目录"; + * - **后缀匹配** > 精确匹配:与微信 4.x 实际写入目录的命名习惯一致; + * - **修改时间更新** > 更旧:兜底。 + */ export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null => { if (!dbPath || !wxid) return null @@ -34,6 +89,7 @@ export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null const normalized = dbPath.replace(/[\\/]+$/, '') const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}` + // 命中缓存且目标仍存在则直接返回;目标已被删除的过期缓存项会被剔除 const cached = accountDirCache.get(cacheKey) if (cached && existsSync(cached)) return cached if (cached && !existsSync(cached)) { @@ -41,16 +97,12 @@ export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null } const lowerWxid = cleanedWxid.toLowerCase() - if (!lowerWxid.startsWith('wxid_')) { - const direct = join(normalized, cleanedWxid) - if (existsSync(direct) && isDirectory(direct)) { - accountDirCache.set(cacheKey, direct) - return direct - } - } try { const entries = readdirSync(normalized) + type Candidate = { entryPath: string; isExact: boolean; hasSession: boolean; mtime: number } + const candidates: Candidate[] = [] + for (const entry of entries) { const entryPath = join(normalized, entry) if (!isDirectory(entryPath)) continue @@ -58,16 +110,72 @@ export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null const lowerEntry = entry.toLowerCase() const isExactMatch = lowerEntry === lowerWxid const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`) - const shouldMatch = lowerWxid.startsWith('wxid_') - ? isSuffixMatch - : (isExactMatch || isSuffixMatch) + // 既不是精确命中、也不是前缀命中 → 与本 wxid 无关,跳过 + if (!isExactMatch && !isSuffixMatch) continue - if (shouldMatch) { - accountDirCache.set(cacheKey, entryPath) - return entryPath - } + // 看起来不像账号目录(连 db_storage 与 FileStorage/Image 都没有)→ 跳过 + // 这一步是修复 #996 的关键:自定义微信号场景下旧的、无后缀空目录 + // 会在这里被过滤掉,避免后续 wcdbCore.open 误判为真实账号目录。 + if (!accountDirLooksValid(entryPath)) continue + + let mtime = 0 + try { mtime = statSync(entryPath).mtimeMs } catch { /* 忽略 stat 异常 */ } + candidates.push({ + entryPath, + isExact: isExactMatch, + hasSession: accountDirHasSessionDb(entryPath), + mtime, + }) } - } catch { } + + if (candidates.length > 0) { + candidates.sort((a, b) => { + // 1) 优先选有 session.db 的(真实写入数据的目录) + if (a.hasSession !== b.hasSession) return a.hasSession ? -1 : 1 + // 2) 其次优先选"带后缀"的(更接近微信 4.x 实际写入目录) + if (a.isExact !== b.isExact) return a.isExact ? 1 : -1 + // 3) 最后按修改时间倒序(最新的优先) + return b.mtime - a.mtime + }) + const best = candidates[0].entryPath + accountDirCache.set(cacheKey, best) + return best + } + } catch { /* 扫描目录失败时直接 fallthrough 返回 null */ } return null } + +/** + * 浅层判定一个目录"看起来像不像账号目录": + * 存在 db_storage 子目录,或存在 FileStorage/Image[2] 子目录之一即认为是。 + * + * 用于在候选阶段剔除"同名但实际无数据"的残留空目录 + *(例如自定义微信号后遗留下来的旧 wxid 主目录)。 + */ +const accountDirLooksValid = (entryPath: string): boolean => { + return ( + existsSync(join(entryPath, 'db_storage')) || + existsSync(join(entryPath, 'FileStorage', 'Image')) || + existsSync(join(entryPath, 'FileStorage', 'Image2')) + ) +} + +/** + * 检测账号目录下是否存在 session.db。 + * + * 是排序优先级里"区分真实写入数据 vs 仅有空 db_storage 骨架"的关键判据, + * 同时兼容微信 4.x 两种已知布局: + * - db_storage/session/session.db (新版本嵌套布局) + * - db_storage/session.db (部分版本扁平布局) + */ +const accountDirHasSessionDb = (entryPath: string): boolean => { + const candidates = [ + join(entryPath, 'db_storage', 'session', 'session.db'), + join(entryPath, 'db_storage', 'session.db'), + ] + for (const candidate of candidates) { + if (existsSync(candidate)) return true + } + return false +} diff --git a/electron/services/config.ts b/electron/services/config.ts index 618d908..65f20e6 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -899,12 +899,78 @@ export class ConfigService { } /** - * 获取账号目录路径 - * 统一的账号目录解析方法,所有服务应该使用此方法而不是自己实现 + * 浅层判定一个目录"看起来像不像账号目录": + * 存在 db_storage 子目录,或存在 FileStorage/Image[2] 子目录之一即认为是。 * - * @param dbPath 数据库根目录(可选,默认从配置读取) - * @param wxid 微信ID(可选,默认从配置读取) - * @returns 账号目录的完整路径,如果找不到返回 null + * 用于在 {@link getAccountDir} 候选阶段剔除"同名但实际无数据"的残留空目录 + * (例如自定义微信号后微信遗留下来的旧 wxid 主目录)。 + */ + private accountDirLooksValid(entryPath: string): boolean { + return ( + existsSync(join(entryPath, 'db_storage')) || + existsSync(join(entryPath, 'FileStorage', 'Image')) || + existsSync(join(entryPath, 'FileStorage', 'Image2')) + ) + } + + /** + * 检测账号目录下是否存在 session.db。 + * + * 是排序优先级里"区分真实写入数据 vs 仅有空 db_storage 骨架"的关键判据, + * 同时兼容微信 4.x 两种已知布局: + * - db_storage/session/session.db (新版本嵌套布局) + * - db_storage/session.db (部分版本扁平布局) + */ + private accountDirHasSessionDb(entryPath: string): boolean { + const candidates = [ + join(entryPath, 'db_storage', 'session', 'session.db'), + join(entryPath, 'db_storage', 'session.db'), + ] + for (const candidate of candidates) { + if (existsSync(candidate)) return true + } + return false + } + + /** + * 获取账号目录的真实绝对路径。 + * + * 这是 WeFlow 统一的账号目录解析入口,所有服务都应通过本方法获取 + * 账号目录,而不要自行拼接 `join(dbPath, wxid)`。 + * + * ## 修复 #996(错误码 -3001:未找到数据库目录) + * + * ### 旧实现存在的两处严重缺陷 + * 1. **对 wxid_ 开头强制要求"带后缀"**: + * 未自定义微信号的普通用户,目录就叫 `wxid_X`(无任何后缀), + * 旧逻辑把它过滤掉,导致这类用户根本匹配不到自己的账号目录。 + * + * 2. **对非 wxid_ 开头(自定义微信号)走短路返回,不校验目录有效性**: + * 旧实现写法是 + * ```ts + * if (!lowerWxid.startsWith('wxid_')) { + * const direct = join(root, cleanedWxid) + * if (existsSync(direct)) return direct // ← 直接返回,没校验里面有没有 db_storage + * } + * ``` + * 叠加 {@link cleanAccountDirName} 会把 `<自定义号>_<4位后缀>` 清洗成 + * `<自定义号>`,于是无论用户保存的是哪个 wxid,都会命中旧的、 + * 无后缀的空目录(它真实存在但里面没有 db_storage),最终在 + * wcdbCore.open 阶段触发 -3001。 + * + * ### 修复后的统一匹配流程 + * 1. 扫描 dbPath 下所有子目录; + * 2. 同时接受**精确匹配**(`entry == cleanedWxid`) 与 + * **后缀匹配**(`entry.startsWith(cleanedWxid + '_')`) 两种命中方式; + * 3. 用 {@link accountDirLooksValid} 过滤掉"看起来根本不像账号目录"的项; + * 4. 在剩余候选中按以下优先级排序,取最优: + * - **有 session.db** > 没有:区分"真正写入数据"与"残留空目录"; + * - **后缀匹配** > 精确匹配:与微信 4.x 实际写入目录的命名习惯一致; + * - **修改时间更新** > 更旧:兜底。 + * + * @param dbPath 数据库根目录(可选,默认从配置读取 `dbPath`) + * @param wxid 微信 ID(可选,默认从配置读取 `myWxid`) + * @returns 账号目录的完整绝对路径;找不到返回 null */ getAccountDir(dbPath?: string, wxid?: string): string | null { const actualDbPath = dbPath || this.get('dbPath') @@ -916,26 +982,20 @@ export class ConfigService { 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) + type Candidate = { entryPath: string; isExact: boolean; hasSession: boolean; mtime: number } + const candidates: Candidate[] = [] + for (const entry of entries) { const entryPath = join(normalized, entry) if (!this.isDirectory(entryPath)) continue @@ -943,16 +1003,36 @@ export class ConfigService { const lowerEntry = entry.toLowerCase() const isExactMatch = lowerEntry === lowerWxid const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`) + // 既不是精确命中、也不是前缀命中 → 与本 wxid 无关,跳过 + if (!isExactMatch && !isSuffixMatch) continue - // wxid_ 开头只接受带后缀的目录;其他账号精确匹配或带后缀都可以 - const shouldMatch = lowerWxid.startsWith('wxid_') - ? isSuffixMatch - : (isExactMatch || isSuffixMatch) + // 看起来不像账号目录(连 db_storage 与 FileStorage/Image 都没有)→ 跳过 + // 这一步是修复 #996 的关键:自定义微信号场景下旧的、无后缀空目录 + // 会在这里被过滤掉,避免后续 wcdbCore.open 误判为真实账号目录。 + if (!this.accountDirLooksValid(entryPath)) continue - if (shouldMatch) { - this.accountDirCache.set(cacheKey, entryPath) - return entryPath - } + let mtime = 0 + try { mtime = statSync(entryPath).mtimeMs } catch { /* 忽略 stat 异常 */ } + candidates.push({ + entryPath, + isExact: isExactMatch, + hasSession: this.accountDirHasSessionDb(entryPath), + mtime, + }) + } + + if (candidates.length > 0) { + candidates.sort((a, b) => { + // 1) 优先选有 session.db 的(真实写入数据的目录) + if (a.hasSession !== b.hasSession) return a.hasSession ? -1 : 1 + // 2) 其次优先选"带后缀"的(更接近微信 4.x 实际写入目录) + if (a.isExact !== b.isExact) return a.isExact ? 1 : -1 + // 3) 最后按修改时间倒序(最新的优先) + return b.mtime - a.mtime + }) + const best = candidates[0].entryPath + this.accountDirCache.set(cacheKey, best) + return best } } catch { } diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts index ad92985..9e8de33 100644 --- a/electron/services/dbPathService.ts +++ b/electron/services/dbPathService.ts @@ -137,7 +137,25 @@ export class DbPathService { } /** - * 查找账号目录(包含 db_storage 或图片目录) + * 查找 dbPath 根目录下所有"看起来像账号目录"的子目录名。 + * + * ## 修复 #996(错误码 -3001:未找到数据库目录) + * + * ### 旧实现的过滤逻辑及缺陷 + * 旧实现对名字以 `wxid_` 开头的目录额外加了一道判断: + * "段数(按下划线切分)必须 ≥ 3,否则跳过" + * 也就是 `wxid_X_` 才算合法、`wxid_X` 一律忽略。 + * + * 这种粗暴过滤会**误伤未自定义微信号的普通用户**——他们的真实账号目录 + * 就叫 `wxid_X`(没有任何数字后缀),结果在欢迎页扫描时压根看不到自己。 + * + * ### 修复策略 + * 1. **不再依据"段数"过滤**:先按是否真的是账号目录(含 db_storage 或 + * FileStorage/Image[2])一视同仁地收集所有候选; + * 2. **用 {@link dedupeAccountDirs} 做更精准的去重**:仅当 `wxid_X` 和 + * `wxid_X_` 同时存在时(这是自定义微信号后微信遗留旧空目录 + * 的典型场景),才二选一保留"更像微信实际在用"的那个,避免下拉框里 + * 出现两个看起来一样但只有一个能用的混乱选项。 */ findAccountDirs(rootPath: string): string[] { const resolvedRootPath = expandHomePath(rootPath) @@ -160,23 +178,93 @@ 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) } } } } catch { } - return accounts + return this.dedupeAccountDirs(resolvedRootPath, accounts) + } + + /** + * 账号目录去重:仅当存在"前缀-后缀变体对"时(即同时出现 `wxid_X` 与 + * `wxid_X_`),才二选一保留"微信实际在用"的那个目录。 + * + * - 仅有一个候选目录时,原样返回,不做任何处理; + * - 没有匹配到变体对的目录也都保留(互不相关的多账号场景); + * - 真正二选一时由 {@link shouldPreferSuffixedDir} 决定胜负。 + */ + private dedupeAccountDirs(rootPath: string, names: string[]): string[] { + if (names.length <= 1) return names.slice() + + const lowered = names.map(n => n.toLowerCase()) + const toSkip = new Set() + + // O(n^2) 双层循环找出所有"前缀-后缀变体对"。账号数极少,性能可忽略。 + for (let i = 0; i < names.length; i++) { + for (let j = 0; j < names.length; j++) { + if (i === j) continue + // 判定 names[j] 是 names[i] 的"带后缀变体":以 `_` 开头 + if (lowered[j].startsWith(lowered[i] + '_')) { + const baseName = names[i] + const suffixedName = names[j] + if (this.shouldPreferSuffixedDir(rootPath, baseName, suffixedName)) { + toSkip.add(baseName) // 留 suffixedName,去掉无后缀的旧目录 + } else { + toSkip.add(suffixedName) // 反之亦然 + } + } + } + } + + return names.filter(n => !toSkip.has(n)) + } + + /** + * 在"无后缀目录"与"带后缀目录"之间二选一时,判定后者是否应该胜出。 + * + * 优先级(从高到低): + * 1) 谁含有 session.db 谁优先 —— 这是"数据真实写入"最强的信号; + * 2) 都含或都不含 session.db 时,比较修改时间,更新的优先; + * 3) 兜底返回 true,即默认保留带后缀的目录(与微信 4.x 自定义微信号 + * 后真实目录命名一致)。 + */ + private shouldPreferSuffixedDir(rootPath: string, baseName: string, suffixedName: string): boolean { + const basePath = join(rootPath, baseName) + const suffixedPath = join(rootPath, suffixedName) + + const baseHasSession = this.hasSessionDb(basePath) + const suffixedHasSession = this.hasSessionDb(suffixedPath) + if (baseHasSession !== suffixedHasSession) { + return suffixedHasSession + } + + const baseTime = this.getAccountModifiedTime(basePath) + const suffixedTime = this.getAccountModifiedTime(suffixedPath) + if (baseTime !== suffixedTime) { + return suffixedTime >= baseTime + } + + return true + } + + /** + * 浅层检测账号目录下是否存在 session.db("数据是否真实写入"的判据)。 + * + * 仅检测两条已知路径,不做深度递归,避免在大目录上拖慢扫描: + * - db_storage/session/session.db (新版本嵌套布局) + * - db_storage/session.db (部分版本扁平布局) + */ + private hasSessionDb(accountDir: string): boolean { + const candidates = [ + join(accountDir, 'db_storage', 'session', 'session.db'), + join(accountDir, 'db_storage', 'session.db'), + ] + for (const candidate of candidates) { + if (existsSync(candidate)) return true + } + return false } private isAccountDir(entryPath: string): boolean { @@ -225,7 +313,20 @@ export class DbPathService { } /** - * 扫描目录名候选(仅包含下划线的文件夹,排除 all_users) + * 扫描 dbPath 下"目录名包含下划线"的文件夹作为 wxid 候选。 + * 与 {@link findAccountDirs} 的区别:本方法不要求目录里真的有 db_storage/ + * FileStorage,仅按命名特征判断,结果会暴露给"手动选择 wxid"的弹窗使用。 + * + * ## 修复 #996(错误码 -3001:未找到数据库目录) + * + * 旧实现对 `wxid_` 开头的目录额外要求"段数 ≥ 3"才放行,会误伤未自定义 + * 微信号的普通用户(他们的真实目录就叫 `wxid_X`)。现在改为不再依据段数 + * 过滤,并在末尾通过 {@link dedupeAccountDirs} 处理 `wxid_X` 与 + * `wxid_X_` 同时存在的去重场景。 + * + * 排除规则保留: + * - 微信本身的非账号目录(如 `all_users`); + * - 不含下划线的文件夹(不可能是 wxid)。 */ scanWxidCandidates(rootPath: string): WxidInfo[] { const resolvedRootPath = expandHomePath(rootPath) @@ -243,15 +344,6 @@ export class DbPathService { 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 }) } } @@ -266,7 +358,13 @@ export class DbPathService { } } catch { } - const sorted = wxids.sort((a, b) => { + // 修复 #996:对扫描到的 wxid 候选做去重,避免同时显示 wxid_X 与 wxid_X_。 + const dedupedNames = new Set( + this.dedupeAccountDirs(resolvedRootPath, wxids.map(w => w.wxid)) + ) + const deduped = wxids.filter(w => dedupedNames.has(w.wxid)) + + const sorted = deduped.sort((a, b) => { if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime return a.wxid.localeCompare(b.wxid) });