feat: 优化数据存储目录迁移逻辑

This commit is contained in:
digua
2026-02-02 22:27:15 +08:00
committed by digua
parent a7fc8e45c0
commit 38738eb090
4 changed files with 337 additions and 218 deletions
+87 -168
View File
@@ -11,6 +11,16 @@
import { app } from 'electron'
import * as fs from 'fs'
import * as path from 'path'
import {
copyDirMerge,
copyDirRecursive,
ensureMarkerFile,
isDirectorySafeToUse,
isExistingChatLabDir,
isPathSafe,
isSubPath,
writeMigrationLog,
} from './utils/pathUtils'
// 缓存路径,避免重复计算
let _appDataDir: string | null = null
@@ -19,8 +29,10 @@ let _legacyDataDir: string | null = null
// 存储配置文件名(放在 userData 根目录,避免受自定义数据目录影响)
const STORAGE_CONFIG_FILE = 'storage.json'
// ChatLab 数据目录的标志性子目录列表(用于识别是否为 ChatLab 数据目录
const CHATLAB_DATA_DIRS = ['databases', 'ai', 'settings', 'logs', 'temp']
// ChatLab 数据目录标记文件(用于更严格的目录识别
const CHATLAB_MARKER_FILE = '.chatlab'
// ChatLab 数据目录关键子目录(用于识别已有数据)
const CHATLAB_REQUIRED_DIRS = ['databases', 'settings']
/**
* 获取应用数据根目录
@@ -123,113 +135,6 @@ export function getCustomDataDir(): string | null {
return dataDir
}
/**
* 检查路径是否安全(不在系统关键目录下)
*/
function isPathSafe(targetPath: string): boolean {
// 系统关键目录列表(Windows 和 Unix
const dangerousPaths = [
// Windows 系统目录
'C:\\Windows',
'C:\\Program Files',
'C:\\Program Files (x86)',
'C:\\ProgramData',
// Unix 系统目录
'/usr',
'/etc',
'/bin',
'/sbin',
'/lib',
'/var',
'/boot',
'/root',
'/System',
'/Library',
]
const normalizedTarget = targetPath.toLowerCase().replace(/\//g, '\\')
for (const dangerous of dangerousPaths) {
const normalizedDangerous = dangerous.toLowerCase().replace(/\//g, '\\')
if (normalizedTarget.startsWith(normalizedDangerous)) {
return false
}
}
return true
}
/**
* 检查目录是否为空或仅包含 ChatLab 数据
*/
function isDirectorySafeToUse(dirPath: string): boolean {
if (!fs.existsSync(dirPath)) {
return true // 目录不存在,可以安全使用
}
try {
const entries = fs.readdirSync(dirPath)
// 如果目录为空,可以安全使用
if (entries.length === 0) return true
// 如果包含 ChatLab 的标志性子目录,认为是之前的数据目录
const hasChatlabStructure = CHATLAB_DATA_DIRS.some((d) => entries.includes(d))
return hasChatlabStructure
} catch {
return false
}
}
/**
* 递归合并复制目录(仅复制目标不存在的文件)
* @returns 复制结果统计
*/
function copyDirMerge(
src: string,
dest: string,
stats: { copied: number; skipped: number; errors: string[] } = { copied: 0, skipped: 0, errors: [] }
): { copied: number; skipped: number; errors: string[] } {
if (!fs.existsSync(src)) return stats
try {
ensureDir(dest)
const entries = fs.readdirSync(src, { withFileTypes: true })
for (const entry of entries) {
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
if (entry.isDirectory()) {
if (!fs.existsSync(destPath)) {
copyDirRecursive(srcPath, destPath)
stats.copied++
} else {
copyDirMerge(srcPath, destPath, stats)
}
} else {
if (!fs.existsSync(destPath)) {
fs.copyFileSync(srcPath, destPath)
stats.copied++
} else {
stats.skipped++
}
}
} catch (error) {
const errorMsg = `复制失败: ${srcPath} -> ${error instanceof Error ? error.message : String(error)}`
console.error('[Paths]', errorMsg)
stats.errors.push(errorMsg)
}
}
} catch (error) {
const errorMsg = `读取目录失败: ${src} -> ${error instanceof Error ? error.message : String(error)}`
console.error('[Paths]', errorMsg)
stats.errors.push(errorMsg)
}
return stats
}
/**
* 设置自定义数据目录
* @param dataDir 目标目录(为空则恢复默认)
@@ -245,21 +150,48 @@ export function setCustomDataDir(
try {
if (!normalized) {
// 恢复默认路径
// 记录旧目录,下次启动时删除
writeStorageConfig({ pendingDeleteDir: oldDir })
_appDataDir = null
const newDir = getAppDataDir()
const newDir = getDefaultAppDataDir()
// 防止目标目录是当前目录的子目录,避免递归复制
if (migrate && oldDir !== newDir && isSubPath(oldDir, newDir)) {
return { success: false, error: '目标目录不能是当前数据目录的子目录' }
}
// 先清除自定义配置,切回默认目录
writeStorageConfig({})
_appDataDir = newDir
let canDeleteOldDir = false
if (migrate && oldDir !== newDir) {
const migrateResult = copyDirMerge(oldDir, newDir)
const migrateResult = copyDirMerge(oldDir, newDir, ensureDir)
console.log(
`[Paths] 数据迁移完成: 复制 ${migrateResult.copied} 项, 跳过 ${migrateResult.skipped} 项, 错误 ${migrateResult.errors.length}`
)
if (migrateResult.errors.length > 0) {
console.warn('[Paths] 迁移过程中出现错误:', migrateResult.errors)
console.warn('[Paths] 迁移失败,旧数据目录将不会自动删除')
// 迁移失败日志写入 app.log
writeMigrationLog(
getLogsDir(),
`切换为默认目录迁移失败: 从 ${oldDir}${newDir},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项,错误 ${migrateResult.errors.length}`,
ensureDir
)
} else {
canDeleteOldDir = true
// 迁移成功日志写入 app.log
writeMigrationLog(
getLogsDir(),
`切换为默认目录迁移成功: 从 ${oldDir}${newDir},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped}`,
ensureDir
)
}
}
// 迁移成功才标记删除旧目录,避免数据丢失
if (canDeleteOldDir) {
writeStorageConfig({ pendingDeleteDir: oldDir })
}
return { success: true, from: oldDir, to: newDir }
}
@@ -267,33 +199,59 @@ export function setCustomDataDir(
return { success: false, error: '数据目录必须是绝对路径' }
}
// 防止目标目录是当前目录的子目录,避免递归复制
if (migrate && oldDir !== normalized && isSubPath(oldDir, normalized)) {
return { success: false, error: '目标目录不能是当前数据目录的子目录' }
}
// 安全检查:不能使用系统关键目录
if (!isPathSafe(normalized)) {
return { success: false, error: '不能使用系统关键目录作为数据目录' }
}
// 安全检查:目标目录应为空或已有 ChatLab 数据
if (!isDirectorySafeToUse(normalized)) {
if (!isDirectorySafeToUse(normalized, CHATLAB_MARKER_FILE, CHATLAB_REQUIRED_DIRS)) {
return { success: false, error: '目标目录不为空且不包含 ChatLab 数据,请选择空目录或已有数据目录' }
}
// 确保目录存在
ensureDir(normalized)
// 记录旧目录,下次启动时删除
writeStorageConfig({ dataDir: normalized, pendingDeleteDir: oldDir })
_appDataDir = normalized
let canDeleteOldDir = false
if (migrate && oldDir !== normalized) {
const migrateResult = copyDirMerge(oldDir, normalized)
const migrateResult = copyDirMerge(oldDir, normalized, ensureDir)
console.log(
`[Paths] 数据迁移完成: 复制 ${migrateResult.copied} 项, 跳过 ${migrateResult.skipped} 项, 错误 ${migrateResult.errors.length}`
)
if (migrateResult.errors.length > 0) {
console.warn('[Paths] 迁移过程中出现错误:', migrateResult.errors)
console.warn('[Paths] 迁移失败,旧数据目录将不会自动删除')
// 迁移失败日志写入 app.log
writeMigrationLog(
getLogsDir(),
`切换目录迁移失败: 从 ${oldDir}${normalized},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项,错误 ${migrateResult.errors.length}`,
ensureDir
)
} else {
canDeleteOldDir = true
// 迁移成功日志写入 app.log
writeMigrationLog(
getLogsDir(),
`切换目录迁移成功: 从 ${oldDir}${normalized},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped}`,
ensureDir
)
}
}
// 迁移成功才标记删除旧目录,避免数据丢失
const config: StorageConfig = { dataDir: normalized }
if (canDeleteOldDir) {
config.pendingDeleteDir = oldDir
}
writeStorageConfig(config)
return { success: true, from: oldDir, to: normalized }
} catch (error) {
console.error('[Paths] Error setting custom data dir:', error)
@@ -330,16 +288,12 @@ export function cleanupPendingDeleteDir(): void {
return
}
// 安全检查:确保待删除目录确实是 ChatLab 数据目录(包含标志性子目录)
if (fs.existsSync(pendingDir)) {
const entries = fs.readdirSync(pendingDir)
const hasChatlabStructure = CHATLAB_DATA_DIRS.some((d) => entries.includes(d))
if (!hasChatlabStructure) {
console.log('[Paths] 跳过清理:待删除目录不是 ChatLab 数据目录:', pendingDir)
// 清除待删除标记
writeStorageConfig({ dataDir: config.dataDir })
return
}
// 安全检查:确保待删除目录确实是 ChatLab 数据目录(标记文件 + 关键子目录)
if (fs.existsSync(pendingDir) && !isExistingChatLabDir(pendingDir, CHATLAB_MARKER_FILE, CHATLAB_REQUIRED_DIRS)) {
console.log('[Paths] 跳过清理:待删除目录不是 ChatLab 数据目录:', pendingDir)
// 清除待删除标记
writeStorageConfig({ dataDir: config.dataDir })
return
}
// 检查目录是否存在
@@ -446,6 +400,8 @@ export function ensureAppDirs(): void {
ensureDir(getSettingsDir())
ensureDir(getTempDir())
ensureDir(getLogsDir())
// 写入数据目录标记文件
ensureMarkerFile(getAppDataDir(), CHATLAB_MARKER_FILE)
}
// ==================== 数据迁移 ====================
@@ -513,7 +469,7 @@ function migrateDirectory(
const stat = fs.statSync(srcPath)
if (stat.isDirectory()) {
copyDirRecursive(srcPath, destPath)
copyDirRecursive(srcPath, destPath, ensureDir)
} else {
fs.copyFileSync(srcPath, destPath)
}
@@ -532,24 +488,6 @@ function migrateDirectory(
return { migratedDirs, skippedDirs }
}
/**
* 写入迁移日志到 app.log
* 使用内联实现避免循环依赖
*/
function writeMigrationLog(message: string): void {
try {
const logDir = getLogsDir()
ensureDir(logDir)
const logPath = path.join(logDir, 'app.log')
const now = new Date()
const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
const logLine = `[${timestamp}] [MIGRATION] ${message}\n`
fs.appendFileSync(logPath, logLine, 'utf-8')
} catch {
// 日志写入失败时静默处理
}
}
/**
* 执行从 Documents/ChatLab 到新目录的数据迁移
* 迁移整个目录的所有内容,采用合并策略:只复制不存在的文件,不覆盖已存在的文件
@@ -597,13 +535,13 @@ export function migrateFromLegacyDir(): { success: boolean; migratedDirs: string
}
// 写入迁移日志
writeMigrationLog(summary.join(' | '))
writeMigrationLog(getLogsDir(), summary.join(' | '), ensureDir)
return { success: true, migratedDirs: result.migratedDirs }
} catch (error) {
console.error('[Paths] Migration failed:', error)
const errorMsg = error instanceof Error ? error.message : String(error)
writeMigrationLog(`Migration failed: ${errorMsg}`)
writeMigrationLog(getLogsDir(), `Migration failed: ${errorMsg}`, ensureDir)
return {
success: false,
migratedDirs: [],
@@ -612,25 +550,6 @@ export function migrateFromLegacyDir(): { success: boolean; migratedDirs: string
}
}
/**
* 递归复制目录
*/
function copyDirRecursive(src: string, dest: string): void {
ensureDir(dest)
const entries = fs.readdirSync(src, { withFileTypes: true })
for (const entry of entries) {
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
if (entry.isDirectory()) {
copyDirRecursive(srcPath, destPath)
} else {
fs.copyFileSync(srcPath, destPath)
}
}
}
/**
* 删除旧版数据目录(可选,供用户确认后调用)
*/
-1
View File
@@ -45,7 +45,6 @@ function switchToR2Mirror(): void {
provider: 'generic',
url: R2_MIRROR_URL,
})
logger.info(`[Update] 使用 R2 镜像源: ${R2_MIRROR_URL}`)
}
/**
+205
View File
@@ -0,0 +1,205 @@
import * as fs from 'fs'
import * as path from 'path'
// 系统关键目录列表(用于安全校验)
const DANGEROUS_PATHS = [
// Windows 系统目录
'C:\\Windows',
'C:\\Program Files',
'C:\\Program Files (x86)',
'C:\\ProgramData',
// Unix 系统目录
'/usr',
'/etc',
'/bin',
'/sbin',
'/lib',
'/var',
'/boot',
'/root',
'/System',
'/Library',
]
// 统一路径标准化(兼容 Windows 大小写差异)
function normalizePathForCompare(input: string): string {
const resolved = path.resolve(input)
const normalized = path.normalize(resolved)
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
}
/**
* 判断 child 是否为 parent 的子目录
*/
export function isSubPath(parent: string, child: string): boolean {
const parentPath = normalizePathForCompare(parent)
const childPath = normalizePathForCompare(child)
if (parentPath === childPath) return false
return childPath.startsWith(`${parentPath}${path.sep}`)
}
/**
* 检查路径是否安全(不在系统关键目录下)
*/
export function isPathSafe(targetPath: string): boolean {
const normalizedTarget = targetPath.toLowerCase().replace(/\//g, '\\')
for (const dangerous of DANGEROUS_PATHS) {
const normalizedDangerous = dangerous.toLowerCase().replace(/\//g, '\\')
if (normalizedTarget.startsWith(normalizedDangerous)) {
return false
}
}
return true
}
/**
* 检查目录是否为空或包含 ChatLab 标记与关键结构
*/
export function isDirectorySafeToUse(dirPath: string, markerFile: string, requiredDirs: string[]): boolean {
if (!fs.existsSync(dirPath)) {
return true // 目录不存在,可以安全使用
}
try {
const entries = fs.readdirSync(dirPath)
// 如果目录为空,可以安全使用
if (entries.length === 0) return true
return hasChatLabStructure(entries, markerFile, requiredDirs)
} catch {
return false
}
}
/**
* 检查目录是否为已存在的 ChatLab 数据目录
*/
export function isExistingChatLabDir(dirPath: string, markerFile: string, requiredDirs: string[]): boolean {
if (!fs.existsSync(dirPath)) return false
try {
const entries = fs.readdirSync(dirPath)
return hasChatLabStructure(entries, markerFile, requiredDirs)
} catch {
return false
}
}
/**
* 确保数据目录标记文件存在
*/
export function ensureMarkerFile(dirPath: string, markerFile: string): void {
try {
const markerPath = path.join(dirPath, markerFile)
if (!fs.existsSync(markerPath)) {
fs.writeFileSync(markerPath, 'ChatLab Data Directory', 'utf-8')
}
} catch {
// 标记文件写入失败时静默处理
}
}
/**
* 递归复制目录
*/
export function copyDirRecursive(src: string, dest: string, ensureDir: (dirPath: string) => void): void {
ensureDir(dest)
const entries = fs.readdirSync(src, { withFileTypes: true })
for (const entry of entries) {
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
if (entry.isDirectory()) {
copyDirRecursive(srcPath, destPath, ensureDir)
} else {
fs.copyFileSync(srcPath, destPath)
}
}
}
export interface CopyStats {
copied: number
skipped: number
errors: string[]
}
/**
* 递归合并复制目录(仅复制目标不存在的文件)
* @returns 复制结果统计
*/
export function copyDirMerge(
src: string,
dest: string,
ensureDir: (dirPath: string) => void,
stats: CopyStats = { copied: 0, skipped: 0, errors: [] }
): CopyStats {
if (!fs.existsSync(src)) return stats
try {
ensureDir(dest)
const entries = fs.readdirSync(src, { withFileTypes: true })
for (const entry of entries) {
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
if (entry.isDirectory()) {
if (!fs.existsSync(destPath)) {
copyDirRecursive(srcPath, destPath, ensureDir)
stats.copied++
} else {
copyDirMerge(srcPath, destPath, ensureDir, stats)
}
} else {
if (!fs.existsSync(destPath)) {
fs.copyFileSync(srcPath, destPath)
stats.copied++
} else {
stats.skipped++
}
}
} catch (error) {
const errorMsg = `复制失败: ${srcPath} -> ${error instanceof Error ? error.message : String(error)}`
console.error('[Paths]', errorMsg)
stats.errors.push(errorMsg)
}
}
} catch (error) {
const errorMsg = `读取目录失败: ${src} -> ${error instanceof Error ? error.message : String(error)}`
console.error('[Paths]', errorMsg)
stats.errors.push(errorMsg)
}
return stats
}
/**
* 写入迁移日志到 app.log
*/
export function writeMigrationLog(
logDir: string,
message: string,
ensureDir: (dirPath: string) => void
): void {
try {
ensureDir(logDir)
const logPath = path.join(logDir, 'app.log')
const now = new Date()
const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
const logLine = `[${timestamp}] [MIGRATION] ${message}\n`
fs.appendFileSync(logPath, logLine, 'utf-8')
} catch {
// 日志写入失败时静默处理
}
}
function hasChatLabStructure(entries: string[], markerFile: string, requiredDirs: string[]): boolean {
const hasMarker = entries.includes(markerFile)
const hasRequiredDirs = requiredDirs.every((dir) => entries.includes(dir))
return hasMarker && hasRequiredDirs
}