mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-05-31 07:36:49 +00:00
## 问题现象
部分用户在新版 WeFlow 配置 / 启动时持续报错:
「数据库目录不存在: <dbPath>\<wxid>」(错误码 -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
1065 lines
39 KiB
TypeScript
1065 lines
39 KiB
TypeScript
import { join } from 'path'
|
||
import { existsSync, readdirSync, statSync } from 'fs'
|
||
import crypto from 'crypto'
|
||
import Store from 'electron-store'
|
||
import { expandHomePath } from '../utils/pathUtils'
|
||
|
||
// 条件导入 electron(Worker 环境中不可用)
|
||
let app: any = null
|
||
let safeStorage: any = null
|
||
const isWorkerThread = process.env.WEFLOW_WORKER === '1'
|
||
if (!isWorkerThread) {
|
||
try {
|
||
const electron = require('electron')
|
||
app = electron.app
|
||
safeStorage = electron.safeStorage
|
||
} catch {
|
||
// Worker 环境中 electron 不可用
|
||
}
|
||
}
|
||
|
||
// 加密前缀标记
|
||
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||
const isSafeStorageAvailable = (): boolean => {
|
||
try {
|
||
return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
|
||
|
||
interface ConfigSchema {
|
||
// 数据库相关
|
||
dbPath: string
|
||
decryptKey: string
|
||
myWxid: string
|
||
onboardingDone: boolean
|
||
imageXorKey: number
|
||
imageAesKey: string
|
||
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
|
||
exportPath?: string;
|
||
// 缓存相关
|
||
cachePath: string
|
||
lastOpenedDb: string
|
||
lastSession: string
|
||
|
||
// 界面相关
|
||
theme: 'light' | 'dark' | 'system'
|
||
themeId: string
|
||
language: string
|
||
logEnabled: boolean
|
||
launchAtStartup?: boolean
|
||
silentStartup?: boolean
|
||
llmModelPath: string
|
||
whisperModelName: string
|
||
whisperModelDir: string
|
||
whisperDownloadSource: string
|
||
autoTranscribeVoice: boolean
|
||
transcribeLanguages: string[]
|
||
exportDefaultConcurrency: number
|
||
analyticsExcludedUsernames: string[]
|
||
|
||
// 安全相关
|
||
authEnabled: boolean
|
||
authPassword: string // SHA-256 hash(safeStorage 加密)
|
||
authUseHello: boolean
|
||
authHelloSecret: string // 原始密码(safeStorage 加密,Hello 解锁时使用)
|
||
|
||
// 更新相关
|
||
ignoredUpdateVersion: string
|
||
updateChannel: 'auto' | 'stable' | 'preview' | 'dev'
|
||
|
||
// 通知
|
||
notificationEnabled: boolean
|
||
aiInsightNotificationEnabled: boolean
|
||
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||
notificationFilterList: string[]
|
||
messagePushEnabled: boolean
|
||
messagePushFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||
messagePushFilterList: string[]
|
||
httpApiEnabled: boolean
|
||
httpApiPort: number
|
||
httpApiHost: string
|
||
httpApiToken: string
|
||
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||
quoteLayout: 'quote-top' | 'quote-bottom'
|
||
wordCloudExcludeWords: string[]
|
||
exportWriteLayout: 'A' | 'B' | 'C'
|
||
exportAutomationTaskMap: Record<string, unknown>
|
||
|
||
// AI 见解
|
||
aiModelApiBaseUrl: string
|
||
aiModelApiKey: string
|
||
aiModelApiModel: string
|
||
aiModelApiMaxTokens: number
|
||
aiInsightEnabled: boolean
|
||
aiInsightApiBaseUrl: string
|
||
aiInsightApiKey: string
|
||
aiInsightApiModel: string
|
||
aiInsightSilenceDays: number
|
||
aiInsightAllowContext: boolean
|
||
aiInsightAllowMomentsContext: boolean
|
||
aiInsightMomentsContextCount: number
|
||
aiInsightMomentsBindings: Record<string, { enabled: boolean; updatedAt: number }>
|
||
aiInsightAllowSocialContext: boolean
|
||
aiInsightSocialContextCount: number
|
||
aiInsightWeiboCookie: string
|
||
aiInsightWeiboBindings: Record<string, { uid: string; screenName?: string; updatedAt: number }>
|
||
aiInsightFilterMode: 'whitelist' | 'blacklist'
|
||
aiInsightFilterList: string[]
|
||
aiInsightWhitelistEnabled: boolean
|
||
aiInsightWhitelist: string[]
|
||
/** 活跃分析冷却时间(分钟),0 表示无冷却 */
|
||
aiInsightCooldownMinutes: number
|
||
/** 沉默联系人扫描间隔(小时) */
|
||
aiInsightScanIntervalHours: number
|
||
/** 发送上下文时的最大消息条数 */
|
||
aiInsightContextCount: number
|
||
/** 自定义 system prompt,空字符串表示使用内置默认值 */
|
||
aiInsightSystemPrompt: string
|
||
/** 是否启用 Telegram 推送 */
|
||
aiInsightTelegramEnabled: boolean
|
||
/** Telegram Bot Token */
|
||
aiInsightTelegramToken: string
|
||
/** Telegram 接收 Chat ID,逗号分隔,支持多个 */
|
||
aiInsightTelegramChatIds: string
|
||
|
||
// AI 足迹
|
||
aiFootprintEnabled: boolean
|
||
aiFootprintSystemPrompt: string
|
||
/** 是否将 AI 见解调试日志输出到桌面 */
|
||
aiInsightDebugLogEnabled: boolean
|
||
autoDownloadHighRes: boolean
|
||
autoDownloadWhitelist: string[]
|
||
}
|
||
|
||
// 需要 safeStorage 加密的字段(普通模式)
|
||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set([
|
||
'decryptKey',
|
||
'imageAesKey',
|
||
'authPassword',
|
||
'httpApiToken',
|
||
'aiModelApiKey',
|
||
'aiInsightApiKey',
|
||
'aiInsightWeiboCookie'
|
||
])
|
||
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||
|
||
// 需要与密码绑定的敏感密钥字段(锁定模式时用 lock: 加密)
|
||
const LOCKABLE_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey'])
|
||
const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||
|
||
export class ConfigService {
|
||
private static instance: ConfigService
|
||
private store!: Store<ConfigSchema>
|
||
|
||
// 锁定模式运行时状态
|
||
private unlockedKeys: Map<string, any> = new Map()
|
||
private unlockPassword: string | null = null
|
||
|
||
// 账号目录缓存
|
||
private accountDirCache: Map<string, string> = new Map()
|
||
|
||
static getInstance(): ConfigService {
|
||
if (!ConfigService.instance) {
|
||
ConfigService.instance = new ConfigService()
|
||
}
|
||
return ConfigService.instance
|
||
}
|
||
|
||
constructor() {
|
||
if (ConfigService.instance) {
|
||
return ConfigService.instance
|
||
}
|
||
ConfigService.instance = this
|
||
const defaults: ConfigSchema = {
|
||
dbPath: '',
|
||
decryptKey: '',
|
||
myWxid: '',
|
||
onboardingDone: false,
|
||
imageXorKey: 0,
|
||
imageAesKey: '',
|
||
wxidConfigs: {},
|
||
cachePath: '',
|
||
lastOpenedDb: '',
|
||
lastSession: '',
|
||
theme: 'system',
|
||
themeId: 'cloud-dancer',
|
||
language: 'zh-CN',
|
||
logEnabled: false,
|
||
silentStartup: false,
|
||
llmModelPath: '',
|
||
whisperModelName: 'base',
|
||
whisperModelDir: '',
|
||
whisperDownloadSource: 'tsinghua',
|
||
autoTranscribeVoice: false,
|
||
transcribeLanguages: ['zh'],
|
||
exportDefaultConcurrency: 4,
|
||
analyticsExcludedUsernames: [],
|
||
authEnabled: false,
|
||
authPassword: '',
|
||
authUseHello: false,
|
||
authHelloSecret: '',
|
||
ignoredUpdateVersion: '',
|
||
updateChannel: 'auto',
|
||
notificationEnabled: true,
|
||
aiInsightNotificationEnabled: true,
|
||
notificationPosition: 'top-right',
|
||
notificationFilterMode: 'all',
|
||
notificationFilterList: [],
|
||
httpApiToken: '',
|
||
httpApiEnabled: false,
|
||
httpApiPort: 5031,
|
||
httpApiHost: '127.0.0.1',
|
||
messagePushEnabled: false,
|
||
messagePushFilterMode: 'all',
|
||
messagePushFilterList: [],
|
||
windowCloseBehavior: 'ask',
|
||
quoteLayout: 'quote-top',
|
||
wordCloudExcludeWords: [],
|
||
exportWriteLayout: 'A',
|
||
exportAutomationTaskMap: {},
|
||
aiModelApiBaseUrl: '',
|
||
aiModelApiKey: '',
|
||
aiModelApiModel: 'gpt-4o-mini',
|
||
aiModelApiMaxTokens: 1024,
|
||
aiInsightEnabled: false,
|
||
aiInsightApiBaseUrl: '',
|
||
aiInsightApiKey: '',
|
||
aiInsightApiModel: 'gpt-4o-mini',
|
||
aiInsightSilenceDays: 3,
|
||
aiInsightAllowContext: false,
|
||
aiInsightAllowMomentsContext: false,
|
||
aiInsightMomentsContextCount: 5,
|
||
aiInsightMomentsBindings: {},
|
||
aiInsightAllowSocialContext: false,
|
||
aiInsightFilterMode: 'whitelist',
|
||
aiInsightFilterList: [],
|
||
aiInsightWhitelistEnabled: false,
|
||
aiInsightWhitelist: [],
|
||
aiInsightCooldownMinutes: 120,
|
||
aiInsightScanIntervalHours: 4,
|
||
aiInsightContextCount: 40,
|
||
aiInsightSocialContextCount: 3,
|
||
aiInsightSystemPrompt: '',
|
||
aiInsightTelegramEnabled: false,
|
||
aiInsightTelegramToken: '',
|
||
aiInsightTelegramChatIds: '',
|
||
aiInsightWeiboCookie: '',
|
||
aiInsightWeiboBindings: {},
|
||
aiFootprintEnabled: false,
|
||
aiFootprintSystemPrompt: '',
|
||
aiInsightDebugLogEnabled: false,
|
||
autoDownloadHighRes: false,
|
||
autoDownloadWhitelist: []
|
||
}
|
||
|
||
const storeOptions: any = {
|
||
name: 'WeFlow-config',
|
||
defaults,
|
||
projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow'
|
||
}
|
||
const runningInWorker = process.env.WEFLOW_WORKER === '1'
|
||
if (runningInWorker) {
|
||
const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim()
|
||
if (cwd) {
|
||
storeOptions.cwd = cwd
|
||
}
|
||
}
|
||
|
||
try {
|
||
this.store = new Store<ConfigSchema>(storeOptions)
|
||
} catch (error) {
|
||
const message = String((error as Error)?.message || error || '')
|
||
if (message.includes('projectName')) {
|
||
const fallbackOptions = {
|
||
...storeOptions,
|
||
projectName: 'WeFlow',
|
||
cwd: storeOptions.cwd || process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || process.cwd()
|
||
}
|
||
this.store = new Store<ConfigSchema>(fallbackOptions)
|
||
} else {
|
||
throw error
|
||
}
|
||
}
|
||
this.migrateAuthFields()
|
||
this.migrateAiConfig()
|
||
}
|
||
|
||
// === 状态查询 ===
|
||
|
||
isLockMode(): boolean {
|
||
const raw: any = this.store.get('decryptKey')
|
||
return typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)
|
||
}
|
||
|
||
isUnlocked(): boolean {
|
||
return !this.isLockMode() || this.unlockedKeys.size > 0
|
||
}
|
||
|
||
// === get / set ===
|
||
|
||
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
|
||
const raw = this.store.get(key)
|
||
|
||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||
const str = typeof raw === 'string' ? raw : ''
|
||
if (!str || !str.startsWith(SAFE_PREFIX)) return raw
|
||
return (this.safeDecrypt(str) === 'true') as ConfigSchema[K]
|
||
}
|
||
|
||
if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||
const str = typeof raw === 'string' ? raw : ''
|
||
if (!str) return raw
|
||
if (str.startsWith(LOCK_PREFIX)) {
|
||
const cached = this.unlockedKeys.get(key as string)
|
||
return (cached !== undefined ? cached : 0) as ConfigSchema[K]
|
||
}
|
||
if (!str.startsWith(SAFE_PREFIX)) return raw
|
||
const num = Number(this.safeDecrypt(str))
|
||
return (Number.isFinite(num) ? num : 0) as ConfigSchema[K]
|
||
}
|
||
|
||
if (ENCRYPTED_STRING_KEYS.has(key) && typeof raw === 'string') {
|
||
if (key === 'authPassword') return this.safeDecrypt(raw) as ConfigSchema[K]
|
||
if (raw.startsWith(LOCK_PREFIX)) {
|
||
const cached = this.unlockedKeys.get(key as string)
|
||
return (cached !== undefined ? cached : '') as ConfigSchema[K]
|
||
}
|
||
return this.safeDecrypt(raw) as ConfigSchema[K]
|
||
}
|
||
|
||
if (key === 'wxidConfigs' && raw && typeof raw === 'object') {
|
||
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
|
||
}
|
||
|
||
if (key === 'dbPath' && typeof raw === 'string') {
|
||
return expandHomePath(raw) as ConfigSchema[K]
|
||
}
|
||
|
||
return raw
|
||
}
|
||
|
||
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
|
||
let toStore = value
|
||
const inLockMode = this.isLockMode() && this.unlockPassword
|
||
|
||
if (key === 'dbPath' && typeof value === 'string') {
|
||
toStore = expandHomePath(value) as ConfigSchema[K]
|
||
}
|
||
|
||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||
const boolValue = value === true || value === 'true'
|
||
// `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗
|
||
toStore = (boolValue ? this.safeEncrypt('true') : false) as ConfigSchema[K]
|
||
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
|
||
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
|
||
this.unlockedKeys.set(key as string, value)
|
||
} else {
|
||
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
||
}
|
||
} else if (ENCRYPTED_STRING_KEYS.has(key) && typeof value === 'string') {
|
||
if (key === 'authPassword') {
|
||
toStore = this.safeEncrypt(value) as ConfigSchema[K]
|
||
} else if (inLockMode && LOCKABLE_STRING_KEYS.has(key)) {
|
||
toStore = this.lockEncrypt(value, this.unlockPassword!) as ConfigSchema[K]
|
||
this.unlockedKeys.set(key as string, value)
|
||
} else {
|
||
toStore = this.safeEncrypt(value) as ConfigSchema[K]
|
||
}
|
||
} else if (key === 'wxidConfigs' && value && typeof value === 'object') {
|
||
if (inLockMode) {
|
||
toStore = this.lockEncryptWxidConfigs(value as any) as ConfigSchema[K]
|
||
} else {
|
||
toStore = this.encryptWxidConfigs(value as any) as ConfigSchema[K]
|
||
}
|
||
}
|
||
|
||
this.store.set(key, toStore)
|
||
}
|
||
|
||
// === 加密/解密工具 ===
|
||
|
||
private safeEncrypt(plaintext: string): string {
|
||
if (!plaintext) return ''
|
||
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
|
||
if (!isSafeStorageAvailable()) return plaintext
|
||
const encrypted = safeStorage.encryptString(plaintext)
|
||
return SAFE_PREFIX + encrypted.toString('base64')
|
||
}
|
||
|
||
private safeDecrypt(stored: string): string {
|
||
if (!stored) return ''
|
||
if (!stored.startsWith(SAFE_PREFIX)) return stored
|
||
if (!isSafeStorageAvailable()) return ''
|
||
try {
|
||
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
|
||
return safeStorage.decryptString(buf)
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
private lockEncrypt(plaintext: string, password: string): string {
|
||
if (!plaintext) return ''
|
||
const salt = crypto.randomBytes(16)
|
||
const iv = crypto.randomBytes(12)
|
||
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
|
||
const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv)
|
||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||
const authTag = cipher.getAuthTag()
|
||
const combined = Buffer.concat([salt, iv, authTag, encrypted])
|
||
return LOCK_PREFIX + combined.toString('base64')
|
||
}
|
||
|
||
private lockDecrypt(stored: string, password: string): string | null {
|
||
if (!stored || !stored.startsWith(LOCK_PREFIX)) return null
|
||
try {
|
||
const combined = Buffer.from(stored.slice(LOCK_PREFIX.length), 'base64')
|
||
const salt = combined.subarray(0, 16)
|
||
const iv = combined.subarray(16, 28)
|
||
const authTag = combined.subarray(28, 44)
|
||
const ciphertext = combined.subarray(44)
|
||
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
|
||
const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv)
|
||
decipher.setAuthTag(authTag)
|
||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||
return decrypted.toString('utf8')
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
// 通过尝试解密 lock: 字段来验证密码是否正确(当 authPassword 被删除时使用)
|
||
private verifyPasswordByDecrypt(password: string): boolean {
|
||
// 依次尝试解密任意一个 lock: 字段,GCM authTag 会验证密码正确性
|
||
const lockFields = ['decryptKey', 'imageAesKey', 'imageXorKey'] as const
|
||
for (const key of lockFields) {
|
||
const raw: any = this.store.get(key as any)
|
||
if (typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) {
|
||
const result = this.lockDecrypt(raw, password)
|
||
// lockDecrypt 返回 null 表示解密失败(密码错误),非 null 表示成功
|
||
return result !== null
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// === wxidConfigs 加密/解密 ===
|
||
|
||
private encryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
|
||
const result: ConfigSchema['wxidConfigs'] = {}
|
||
for (const [wxid, cfg] of Object.entries(configs)) {
|
||
result[wxid] = { ...cfg }
|
||
if (cfg.decryptKey) result[wxid].decryptKey = this.safeEncrypt(cfg.decryptKey)
|
||
if (cfg.imageAesKey) result[wxid].imageAesKey = this.safeEncrypt(cfg.imageAesKey)
|
||
if (cfg.imageXorKey !== undefined) {
|
||
(result[wxid] as any).imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
private decryptLockedWxidConfigs(password: string): void {
|
||
const wxidConfigs = this.store.get('wxidConfigs')
|
||
if (!wxidConfigs || typeof wxidConfigs !== 'object') return
|
||
for (const [wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
|
||
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && cfg.decryptKey.startsWith(LOCK_PREFIX)) {
|
||
const d = this.lockDecrypt(cfg.decryptKey, password)
|
||
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, d)
|
||
}
|
||
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
|
||
const d = this.lockDecrypt(cfg.imageAesKey, password)
|
||
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, d)
|
||
}
|
||
if (cfg.imageXorKey && typeof cfg.imageXorKey === 'string' && cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
|
||
const d = this.lockDecrypt(cfg.imageXorKey, password)
|
||
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, Number(d))
|
||
}
|
||
}
|
||
}
|
||
|
||
private decryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
|
||
const result: ConfigSchema['wxidConfigs'] = {}
|
||
for (const [wxid, cfg] of Object.entries(configs) as [string, any][]) {
|
||
result[wxid] = { ...cfg, updatedAt: cfg.updatedAt }
|
||
// decryptKey
|
||
if (typeof cfg.decryptKey === 'string') {
|
||
if (cfg.decryptKey.startsWith(LOCK_PREFIX)) {
|
||
result[wxid].decryptKey = this.unlockedKeys.get(`wxid:${wxid}:decryptKey`) ?? ''
|
||
} else {
|
||
result[wxid].decryptKey = this.safeDecrypt(cfg.decryptKey)
|
||
}
|
||
}
|
||
// imageAesKey
|
||
if (typeof cfg.imageAesKey === 'string') {
|
||
if (cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
|
||
result[wxid].imageAesKey = this.unlockedKeys.get(`wxid:${wxid}:imageAesKey`) ?? ''
|
||
} else {
|
||
result[wxid].imageAesKey = this.safeDecrypt(cfg.imageAesKey)
|
||
}
|
||
}
|
||
// imageXorKey
|
||
if (typeof cfg.imageXorKey === 'string') {
|
||
if (cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
|
||
result[wxid].imageXorKey = this.unlockedKeys.get(`wxid:${wxid}:imageXorKey`) ?? 0
|
||
} else if (cfg.imageXorKey.startsWith(SAFE_PREFIX)) {
|
||
const num = Number(this.safeDecrypt(cfg.imageXorKey))
|
||
result[wxid].imageXorKey = Number.isFinite(num) ? num : 0
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
private lockEncryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
|
||
const result: ConfigSchema['wxidConfigs'] = {}
|
||
for (const [wxid, cfg] of Object.entries(configs)) {
|
||
result[wxid] = { ...cfg }
|
||
if (cfg.decryptKey) result[wxid].decryptKey = this.lockEncrypt(cfg.decryptKey, this.unlockPassword!) as any
|
||
if (cfg.imageAesKey) result[wxid].imageAesKey = this.lockEncrypt(cfg.imageAesKey, this.unlockPassword!) as any
|
||
if (cfg.imageXorKey !== undefined) {
|
||
(result[wxid] as any).imageXorKey = this.lockEncrypt(String(cfg.imageXorKey), this.unlockPassword!)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// === 业务方法 ===
|
||
|
||
enableLock(password: string): { success: boolean; error?: string } {
|
||
try {
|
||
// 先读取当前所有明文密钥
|
||
const decryptKey = this.get('decryptKey')
|
||
const imageAesKey = this.get('imageAesKey')
|
||
const imageXorKey = this.get('imageXorKey')
|
||
const wxidConfigs = this.get('wxidConfigs')
|
||
|
||
// 存储密码 hash(safeStorage 加密)
|
||
const passwordHash = crypto.createHash('sha256').update(password).digest('hex')
|
||
this.store.set('authPassword', this.safeEncrypt(passwordHash) as any)
|
||
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||
|
||
// 设置运行时状态
|
||
this.unlockPassword = password
|
||
this.unlockedKeys.set('decryptKey', decryptKey)
|
||
this.unlockedKeys.set('imageAesKey', imageAesKey)
|
||
this.unlockedKeys.set('imageXorKey', imageXorKey)
|
||
|
||
// 用密码派生密钥重新加密所有敏感字段
|
||
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), password) as any)
|
||
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), password) as any)
|
||
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), password) as any)
|
||
|
||
// 处理 wxidConfigs 中的嵌套密钥
|
||
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
|
||
const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs)
|
||
this.store.set('wxidConfigs', lockedConfigs)
|
||
for (const [wxid, cfg] of Object.entries(wxidConfigs)) {
|
||
if (cfg.decryptKey) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, cfg.decryptKey)
|
||
if (cfg.imageAesKey) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, cfg.imageAesKey)
|
||
if (cfg.imageXorKey !== undefined) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, cfg.imageXorKey)
|
||
}
|
||
}
|
||
|
||
return { success: true }
|
||
} catch (e: any) {
|
||
return { success: false, error: e.message }
|
||
}
|
||
}
|
||
|
||
unlock(password: string): { success: boolean; error?: string } {
|
||
try {
|
||
// 验证密码
|
||
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
|
||
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
|
||
|
||
if (storedHash && storedHash !== inputHash) {
|
||
// authPassword 存在但密码不匹配
|
||
return { success: false, error: '密码错误' }
|
||
}
|
||
|
||
if (!storedHash) {
|
||
// authPassword 被删除/损坏,尝试用密码直接解密 lock: 字段来验证
|
||
const verified = this.verifyPasswordByDecrypt(password)
|
||
if (!verified) {
|
||
return { success: false, error: '密码错误' }
|
||
}
|
||
// 密码正确,自愈 authPassword
|
||
const newHash = crypto.createHash('sha256').update(password).digest('hex')
|
||
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
|
||
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||
}
|
||
|
||
// 解密所有 lock: 字段到内存缓存
|
||
const rawDecryptKey: any = this.store.get('decryptKey')
|
||
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
|
||
const d = this.lockDecrypt(rawDecryptKey, password)
|
||
if (d !== null) this.unlockedKeys.set('decryptKey', d)
|
||
}
|
||
|
||
const rawImageAesKey: any = this.store.get('imageAesKey')
|
||
if (typeof rawImageAesKey === 'string' && rawImageAesKey.startsWith(LOCK_PREFIX)) {
|
||
const d = this.lockDecrypt(rawImageAesKey, password)
|
||
if (d !== null) this.unlockedKeys.set('imageAesKey', d)
|
||
}
|
||
|
||
const rawImageXorKey: any = this.store.get('imageXorKey')
|
||
if (typeof rawImageXorKey === 'string' && rawImageXorKey.startsWith(LOCK_PREFIX)) {
|
||
const d = this.lockDecrypt(rawImageXorKey, password)
|
||
if (d !== null) this.unlockedKeys.set('imageXorKey', Number(d))
|
||
}
|
||
|
||
// 解密 wxidConfigs 嵌套密钥
|
||
this.decryptLockedWxidConfigs(password)
|
||
|
||
// 保留密码供 set() 使用
|
||
this.unlockPassword = password
|
||
return { success: true }
|
||
} catch (e: any) {
|
||
return { success: false, error: e.message }
|
||
}
|
||
}
|
||
|
||
disableLock(password: string): { success: boolean; error?: string } {
|
||
try {
|
||
// 验证密码
|
||
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
|
||
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
|
||
if (storedHash !== inputHash) {
|
||
return { success: false, error: '密码错误' }
|
||
}
|
||
|
||
// 先解密所有 lock: 字段
|
||
if (this.unlockedKeys.size === 0) {
|
||
this.unlock(password)
|
||
}
|
||
|
||
// 将所有密钥转回 safe: 格式
|
||
const decryptKey = this.unlockedKeys.get('decryptKey')
|
||
const imageAesKey = this.unlockedKeys.get('imageAesKey')
|
||
const imageXorKey = this.unlockedKeys.get('imageXorKey')
|
||
|
||
if (decryptKey) this.store.set('decryptKey', this.safeEncrypt(String(decryptKey)) as any)
|
||
if (imageAesKey) this.store.set('imageAesKey', this.safeEncrypt(String(imageAesKey)) as any)
|
||
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.safeEncrypt(String(imageXorKey)) as any)
|
||
|
||
// 转换 wxidConfigs
|
||
const wxidConfigs = this.get('wxidConfigs')
|
||
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
|
||
const safeConfigs = this.encryptWxidConfigs(wxidConfigs)
|
||
this.store.set('wxidConfigs', safeConfigs)
|
||
}
|
||
|
||
// 清除 auth 字段
|
||
this.store.set('authEnabled', false as any)
|
||
this.store.set('authPassword', '' as any)
|
||
this.store.set('authUseHello', false as any)
|
||
this.store.set('authHelloSecret', '' as any)
|
||
|
||
// 清除运行时状态
|
||
this.unlockedKeys.clear()
|
||
this.unlockPassword = null
|
||
|
||
return { success: true }
|
||
} catch (e: any) {
|
||
return { success: false, error: e.message }
|
||
}
|
||
}
|
||
|
||
changePassword(oldPassword: string, newPassword: string): { success: boolean; error?: string } {
|
||
try {
|
||
// 验证旧密码
|
||
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
|
||
const oldHash = crypto.createHash('sha256').update(oldPassword).digest('hex')
|
||
if (storedHash !== oldHash) {
|
||
return { success: false, error: '旧密码错误' }
|
||
}
|
||
|
||
// 确保已解锁
|
||
if (this.unlockedKeys.size === 0) {
|
||
this.unlock(oldPassword)
|
||
}
|
||
|
||
// 用新密码重新加密所有密钥
|
||
const decryptKey = this.unlockedKeys.get('decryptKey')
|
||
const imageAesKey = this.unlockedKeys.get('imageAesKey')
|
||
const imageXorKey = this.unlockedKeys.get('imageXorKey')
|
||
|
||
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), newPassword) as any)
|
||
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), newPassword) as any)
|
||
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), newPassword) as any)
|
||
|
||
// 重新加密 wxidConfigs
|
||
const wxidConfigs = this.get('wxidConfigs')
|
||
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
|
||
this.unlockPassword = newPassword
|
||
const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs)
|
||
this.store.set('wxidConfigs', lockedConfigs)
|
||
}
|
||
|
||
// 更新密码 hash
|
||
const newHash = crypto.createHash('sha256').update(newPassword).digest('hex')
|
||
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
|
||
|
||
// 更新 Hello secret(如果启用了 Hello)
|
||
const useHello = this.get('authUseHello')
|
||
if (useHello) {
|
||
this.store.set('authHelloSecret', this.safeEncrypt(newPassword) as any)
|
||
}
|
||
|
||
this.unlockPassword = newPassword
|
||
return { success: true }
|
||
} catch (e: any) {
|
||
return { success: false, error: e.message }
|
||
}
|
||
}
|
||
|
||
// === Hello 相关 ===
|
||
|
||
setHelloSecret(password: string): void {
|
||
this.store.set('authHelloSecret', this.safeEncrypt(password) as any)
|
||
this.store.set('authUseHello', this.safeEncrypt('true') as any)
|
||
}
|
||
|
||
getHelloSecret(): string {
|
||
const raw: any = this.store.get('authHelloSecret')
|
||
if (!raw || typeof raw !== 'string') return ''
|
||
return this.safeDecrypt(raw)
|
||
}
|
||
|
||
clearHelloSecret(): void {
|
||
this.store.set('authHelloSecret', '' as any)
|
||
this.store.set('authUseHello', false as any)
|
||
}
|
||
|
||
// === 迁移 ===
|
||
|
||
private migrateAuthFields(): void {
|
||
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
|
||
// 如果已经是 safe: 或 lock: 前缀则跳过
|
||
const rawEnabled: any = this.store.get('authEnabled')
|
||
if (rawEnabled === true || rawEnabled === 'true') {
|
||
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||
} else if (rawEnabled === false || rawEnabled === 'false') {
|
||
// 保持 false 为明文布尔,避免冷启动访问 keychain
|
||
this.store.set('authEnabled', false as any)
|
||
}
|
||
|
||
const rawUseHello: any = this.store.get('authUseHello')
|
||
if (rawUseHello === true || rawUseHello === 'true') {
|
||
this.store.set('authUseHello', this.safeEncrypt('true') as any)
|
||
} else if (rawUseHello === false || rawUseHello === 'false') {
|
||
this.store.set('authUseHello', false as any)
|
||
}
|
||
|
||
const rawPassword: any = this.store.get('authPassword')
|
||
if (typeof rawPassword === 'string' && rawPassword && !rawPassword.startsWith(SAFE_PREFIX)) {
|
||
this.store.set('authPassword', this.safeEncrypt(rawPassword) as any)
|
||
}
|
||
|
||
// 迁移敏感密钥字段(明文 → safe:)
|
||
for (const key of LOCKABLE_STRING_KEYS) {
|
||
const raw: any = this.store.get(key as any)
|
||
if (typeof raw === 'string' && raw && !raw.startsWith(SAFE_PREFIX) && !raw.startsWith(LOCK_PREFIX)) {
|
||
this.store.set(key as any, this.safeEncrypt(raw) as any)
|
||
}
|
||
}
|
||
|
||
// imageXorKey: 数字 → safe:
|
||
const rawXor: any = this.store.get('imageXorKey')
|
||
if (typeof rawXor === 'number' && rawXor !== 0) {
|
||
this.store.set('imageXorKey', this.safeEncrypt(String(rawXor)) as any)
|
||
}
|
||
|
||
// wxidConfigs 中的嵌套密钥
|
||
const wxidConfigs: any = this.store.get('wxidConfigs')
|
||
if (wxidConfigs && typeof wxidConfigs === 'object') {
|
||
let changed = false
|
||
for (const [_wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
|
||
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && !cfg.decryptKey.startsWith(SAFE_PREFIX) && !cfg.decryptKey.startsWith(LOCK_PREFIX)) {
|
||
cfg.decryptKey = this.safeEncrypt(cfg.decryptKey)
|
||
changed = true
|
||
}
|
||
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && !cfg.imageAesKey.startsWith(SAFE_PREFIX) && !cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
|
||
cfg.imageAesKey = this.safeEncrypt(cfg.imageAesKey)
|
||
changed = true
|
||
}
|
||
if (typeof cfg.imageXorKey === 'number' && cfg.imageXorKey !== 0) {
|
||
cfg.imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
|
||
changed = true
|
||
}
|
||
}
|
||
if (changed) {
|
||
this.store.set('wxidConfigs', wxidConfigs)
|
||
}
|
||
}
|
||
}
|
||
|
||
private migrateAiConfig(): void {
|
||
const sharedBaseUrl = String(this.get('aiModelApiBaseUrl') || '').trim()
|
||
const sharedApiKey = String(this.get('aiModelApiKey') || '').trim()
|
||
const sharedModel = String(this.get('aiModelApiModel') || '').trim()
|
||
|
||
const legacyBaseUrl = String(this.get('aiInsightApiBaseUrl') || '').trim()
|
||
const legacyApiKey = String(this.get('aiInsightApiKey') || '').trim()
|
||
const legacyModel = String(this.get('aiInsightApiModel') || '').trim()
|
||
|
||
if (!sharedBaseUrl && legacyBaseUrl) {
|
||
this.set('aiModelApiBaseUrl', legacyBaseUrl)
|
||
}
|
||
if (!sharedApiKey && legacyApiKey) {
|
||
this.set('aiModelApiKey', legacyApiKey)
|
||
}
|
||
if (!sharedModel && legacyModel) {
|
||
this.set('aiModelApiModel', legacyModel)
|
||
}
|
||
}
|
||
|
||
// === 验证 ===
|
||
|
||
verifyAuthEnabled(): boolean {
|
||
// 先检查 authEnabled 字段
|
||
const rawEnabled: any = this.store.get('authEnabled')
|
||
if (typeof rawEnabled === 'string' && rawEnabled.startsWith(SAFE_PREFIX)) {
|
||
if (this.safeDecrypt(rawEnabled) === 'true') return true
|
||
}
|
||
|
||
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
|
||
const rawDecryptKey: any = this.store.get('decryptKey')
|
||
return typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX);
|
||
|
||
|
||
}
|
||
|
||
// === 工具方法 ===
|
||
|
||
/**
|
||
* 获取当前用户 wxid(清洗后,不带后缀)
|
||
*/
|
||
getMyWxidCleaned(): string {
|
||
const wxid = this.get('myWxid')
|
||
return wxid ? this.cleanAccountDirName(wxid) : ''
|
||
}
|
||
|
||
/**
|
||
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局配置
|
||
*/
|
||
getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } {
|
||
const wxid = this.get('myWxid')
|
||
if (wxid) {
|
||
const wxidConfigs = this.get('wxidConfigs')
|
||
const cfg = wxidConfigs?.[wxid]
|
||
if (cfg && (cfg.imageXorKey !== undefined || cfg.imageAesKey)) {
|
||
return {
|
||
xorKey: cfg.imageXorKey ?? this.get('imageXorKey'),
|
||
aesKey: cfg.imageAesKey ?? this.get('imageAesKey')
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
xorKey: this.get('imageXorKey'),
|
||
aesKey: this.get('imageAesKey')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理账号目录名称(移除后缀)
|
||
*/
|
||
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
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 浅层判定一个目录"看起来像不像账号目录":
|
||
* 存在 db_storage 子目录,或存在 FileStorage/Image[2] 子目录之一即认为是。
|
||
*
|
||
* 用于在 {@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')
|
||
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)
|
||
}
|
||
|
||
const lowerWxid = cleanedWxid.toLowerCase()
|
||
|
||
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
|
||
|
||
const lowerEntry = entry.toLowerCase()
|
||
const isExactMatch = lowerEntry === lowerWxid
|
||
const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`)
|
||
// 既不是精确命中、也不是前缀命中 → 与本 wxid 无关,跳过
|
||
if (!isExactMatch && !isSuffixMatch) continue
|
||
|
||
// 看起来不像账号目录(连 db_storage 与 FileStorage/Image 都没有)→ 跳过
|
||
// 这一步是修复 #996 的关键:自定义微信号场景下旧的、无后缀空目录
|
||
// 会在这里被过滤掉,避免后续 wcdbCore.open 误判为真实账号目录。
|
||
if (!this.accountDirLooksValid(entryPath)) continue
|
||
|
||
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 { }
|
||
|
||
return null
|
||
}
|
||
|
||
private getUserDataPath(): string {
|
||
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||
if (workerUserDataPath) {
|
||
return workerUserDataPath
|
||
}
|
||
return app?.getPath?.('userData') || process.cwd()
|
||
}
|
||
|
||
getCacheBasePath(): string {
|
||
return join(this.getUserDataPath(), 'cache')
|
||
}
|
||
|
||
getAll(): Partial<ConfigSchema> {
|
||
return this.store.store
|
||
}
|
||
|
||
clear(): void {
|
||
this.store.clear()
|
||
this.unlockedKeys.clear()
|
||
this.unlockPassword = null
|
||
}
|
||
}
|
||
|