- 修复 macOS/Windows/Linux 非沙盒下 FactoryReset 递归删除 ~/Documents 导致用户项目源代码丢失的严重 bug,改为仅删除已知应用专属文件/子目录并增加路径安全校验 - 数据库文件从 getApplicationDocumentsDirectory() 迁移到 getApplicationSupportDirectory()(应用专属),含自动迁移逻辑 - 启用 macOS Debug 模式沙盒,使开发环境与生产环境路径行为一致 - 统一迁移 13 处应用数据存储位置(Hive、聊天附件、字体、稍后读同步等)到 Application Support,应用启动时执行一次性迁移 - backup_service.dart 备份文件迁移至 Application Support,getBackupList() 兼容扫描新旧两个路径并去重 - clearCache() 同样修复危险递归清空逻辑 详见 CHANGELOG.md v6.136.0 ~ v6.138.0
393 lines
12 KiB
Dart
393 lines
12 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 自动备份服务
|
||
/// 创建时间: 2026-05-04
|
||
/// 更新时间: 2026-06-26
|
||
/// 作用: 定时自动备份用户数据到本地,支持备份管理和恢复
|
||
/// 上次更新: 备份存储路径从 getApplicationDocumentsDirectory() 迁移到
|
||
/// getApplicationSupportDirectory()(应用专属目录),避免桌面非沙盒
|
||
/// 下污染用户 ~/Documents 目录。getBackupList() 兼容旧路径残留文件。
|
||
/// ============================================================
|
||
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:archive/archive.dart';
|
||
import 'package:crypto/crypto.dart';
|
||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||
|
||
import '../../storage/database/app_database.dart';
|
||
import '../../storage/kv_storage.dart';
|
||
import '../../utils/logger.dart';
|
||
import '../../utils/platform/platform_utils.dart' as pu;
|
||
|
||
class BackupService {
|
||
BackupService._();
|
||
|
||
static const _keyAutoBackupEnabled = 'backup_auto_enabled';
|
||
static const _keyBackupIntervalHours = 'backup_interval_hours';
|
||
static const _keyLastBackupTime = 'backup_last_time';
|
||
static const _keyMaxBackupCount = 'backup_max_count';
|
||
static const _keyBackupRetentionDays = 'backup_retention_days';
|
||
static const _backupDirName = 'xianyan_backups';
|
||
static const _backupFilePrefix = 'auto_backup_';
|
||
static const _backupExtension = '.xypk';
|
||
|
||
static bool get autoBackupEnabled =>
|
||
KvStorage.getBool(_keyAutoBackupEnabled) ?? false;
|
||
|
||
static void setAutoBackupEnabled(bool v) {
|
||
KvStorage.setBool(_keyAutoBackupEnabled, v);
|
||
}
|
||
|
||
static int get backupIntervalHours =>
|
||
KvStorage.getInt(_keyBackupIntervalHours) ?? 24;
|
||
|
||
static void setBackupIntervalHours(int v) {
|
||
KvStorage.setInt(_keyBackupIntervalHours, v);
|
||
}
|
||
|
||
static int get maxBackupCount => KvStorage.getInt(_keyMaxBackupCount) ?? 5;
|
||
|
||
static void setMaxBackupCount(int v) {
|
||
KvStorage.setInt(_keyMaxBackupCount, v);
|
||
}
|
||
|
||
static int get backupRetentionDays =>
|
||
KvStorage.getInt(_keyBackupRetentionDays) ?? 30;
|
||
|
||
static void setBackupRetentionDays(int v) {
|
||
KvStorage.setInt(_keyBackupRetentionDays, v);
|
||
}
|
||
|
||
static DateTime? get lastBackupTime {
|
||
final str = KvStorage.getString(_keyLastBackupTime);
|
||
if (str == null) return null;
|
||
return DateTime.tryParse(str);
|
||
}
|
||
|
||
static void _setLastBackupTime(DateTime time) {
|
||
KvStorage.setString(_keyLastBackupTime, time.toIso8601String());
|
||
}
|
||
|
||
static bool get shouldBackupNow {
|
||
if (!autoBackupEnabled) return false;
|
||
final last = lastBackupTime;
|
||
if (last == null) return true;
|
||
final interval = Duration(hours: backupIntervalHours);
|
||
return DateTime.now().difference(last) >= interval;
|
||
}
|
||
|
||
/// 备份目录路径(应用专属:Application Support)
|
||
///
|
||
/// 历史路径为 getApplicationDocumentsDirectory()/xianyan_backups,
|
||
/// 在 macOS/Windows/Linux 非沙盒下会污染用户 ~/Documents 目录。
|
||
/// 现迁移到 getApplicationSupportDirectory()/xianyan_backups(应用专属)。
|
||
/// 应用启动时 migrateAppData() 会自动迁移旧路径下的备份文件。
|
||
static Future<String> get backupDirPath async {
|
||
if (pu.isWeb) throw UnsupportedError('Web端不支持文件系统操作');
|
||
var dirPath = await pu.safeAppDataPath;
|
||
// 无法获取应用支持目录时降级使用临时目录(如iOS模拟器某些环境)
|
||
if (dirPath == null) {
|
||
Log.w('BackupService: 无法获取应用支持目录,降级使用临时目录');
|
||
dirPath = await pu.safeTempDirPath;
|
||
if (dirPath == null) {
|
||
throw UnsupportedError('无法获取任何可用目录');
|
||
}
|
||
}
|
||
final backupDir = Directory('$dirPath/$_backupDirName');
|
||
if (!await backupDir.exists()) {
|
||
await backupDir.create(recursive: true);
|
||
}
|
||
return backupDir.path;
|
||
}
|
||
|
||
/// 获取备份列表(兼容新旧两个路径)
|
||
///
|
||
/// 同时扫描 Application Support(新路径,主要)和 Documents(旧路径,兼容),
|
||
/// 合并结果并按创建时间降序排序。旧路径扫描用于兼容迁移失败时的残留文件。
|
||
static Future<List<BackupFileInfo>> getBackupList() async {
|
||
if (pu.isWeb) return [];
|
||
|
||
final backups = <BackupFileInfo>[];
|
||
|
||
// 1. 扫描新路径:Application Support(主要)
|
||
try {
|
||
final newDirPath = await backupDirPath;
|
||
backups.addAll(await _scanBackupDir(newDirPath));
|
||
} catch (e) {
|
||
Log.w('BackupService: 扫描新路径备份失败', e);
|
||
}
|
||
|
||
// 2. 扫描旧路径:Documents(兼容迁移失败时的残留文件)
|
||
try {
|
||
final oldDirPath = await pu.safeAppDirPath;
|
||
if (oldDirPath != null) {
|
||
final legacyDir = Directory('$oldDirPath/$_backupDirName');
|
||
if (await legacyDir.exists()) {
|
||
backups.addAll(await _scanBackupDir(legacyDir.path));
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
|
||
// 去重(同一文件名只保留一个,优先新路径)
|
||
final seen = <String>{};
|
||
final deduped = <BackupFileInfo>[];
|
||
for (final backup in backups) {
|
||
if (seen.add(backup.fileName)) {
|
||
deduped.add(backup);
|
||
}
|
||
}
|
||
|
||
deduped.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||
return deduped;
|
||
}
|
||
|
||
/// 扫描指定目录下的备份文件
|
||
static Future<List<BackupFileInfo>> _scanBackupDir(String dirPath) async {
|
||
try {
|
||
final dir = Directory(dirPath);
|
||
if (!await dir.exists()) return [];
|
||
|
||
final files = await dir
|
||
.list()
|
||
.where(
|
||
(f) =>
|
||
f is File &&
|
||
f.path.endsWith(_backupExtension) &&
|
||
File(f.path).path.split('/').last.startsWith(_backupFilePrefix),
|
||
)
|
||
.cast<File>()
|
||
.toList();
|
||
|
||
final backups = <BackupFileInfo>[];
|
||
for (final file in files) {
|
||
final stat = await file.stat();
|
||
final fileName = file.path.split(Platform.pathSeparator).last;
|
||
final sizeBytes = await file.length();
|
||
backups.add(
|
||
BackupFileInfo(
|
||
path: file.path,
|
||
fileName: fileName,
|
||
createdAt: stat.modified,
|
||
sizeBytes: sizeBytes,
|
||
),
|
||
);
|
||
}
|
||
return backups;
|
||
} catch (e) {
|
||
Log.w('BackupService: 扫描备份目录失败: $dirPath', e);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
static Future<String> performBackup() async {
|
||
if (pu.isWeb) throw UnsupportedError('Web端不支持备份操作');
|
||
Log.i('开始自动备份…');
|
||
final db = AppDatabase.instance;
|
||
|
||
final favSentences = await db.getFavoriteSentences();
|
||
final historyWithTime = await db.getHistorySentencesWithTime(limit: 500);
|
||
final allSentences = await db.getAllSentences();
|
||
final shareHistory = await db.getShareHistories(limit: 200);
|
||
|
||
Map<dynamic, dynamic> notesData = {};
|
||
try {
|
||
final notesBox = await Hive.openBox<dynamic>('notes');
|
||
notesData = notesBox.toMap();
|
||
} catch (e) {
|
||
Log.w('读取笔记数据失败: $e');
|
||
}
|
||
|
||
final exportData = <String, dynamic>{
|
||
'version': '2.0',
|
||
'exportTime': DateTime.now().toIso8601String(),
|
||
'app': 'xianyan',
|
||
'type': 'auto_backup',
|
||
'favorites': favSentences
|
||
.map(
|
||
(s) => {
|
||
'id': s.id,
|
||
'content': s.content,
|
||
'author': s.author,
|
||
'source': s.source,
|
||
},
|
||
)
|
||
.toList(),
|
||
'history': historyWithTime
|
||
.map(
|
||
(h) => {
|
||
'sentenceId': h.sentence.id,
|
||
'content': h.sentence.content,
|
||
'author': h.sentence.author,
|
||
'readAt': h.readAt.toIso8601String(),
|
||
},
|
||
)
|
||
.toList(),
|
||
'sentences': allSentences
|
||
.map(
|
||
(s) => {
|
||
'id': s.id,
|
||
'content': s.content,
|
||
'author': s.author,
|
||
'source': s.source,
|
||
'tags': s.tags,
|
||
'feedType': s.feedType,
|
||
'isFavorite': s.isFavorite,
|
||
'isRead': s.isRead,
|
||
},
|
||
)
|
||
.toList(),
|
||
'shareHistory': shareHistory
|
||
.map(
|
||
(s) => {
|
||
'contentId': s.contentId,
|
||
'shareType': s.shareType,
|
||
'title': s.title,
|
||
'content': s.content,
|
||
'author': s.author,
|
||
'sharedAt': s.sharedAt.toIso8601String(),
|
||
},
|
||
)
|
||
.toList(),
|
||
'notes': notesData.map((k, v) => MapEntry(k.toString(), v)),
|
||
};
|
||
|
||
final jsonStr = const JsonEncoder.withIndent(' ').convert(exportData);
|
||
final jsonBytes = utf8.encode(jsonStr);
|
||
|
||
final signature = sha256.convert(jsonBytes).toString();
|
||
final archive = Archive();
|
||
archive.addFile(ArchiveFile('data.json', jsonBytes.length, jsonBytes));
|
||
archive.addFile(
|
||
ArchiveFile('signature.sha256', signature.length, signature.codeUnits),
|
||
);
|
||
|
||
final zipBytes = ZipEncoder().encode(archive);
|
||
if (zipBytes.isEmpty) {
|
||
throw Exception('ZIP编码失败');
|
||
}
|
||
|
||
final dirPath = await backupDirPath;
|
||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||
final filePath =
|
||
'$dirPath${Platform.pathSeparator}$_backupFilePrefix$timestamp$_backupExtension';
|
||
final file = File(filePath);
|
||
await file.writeAsBytes(zipBytes);
|
||
|
||
_setLastBackupTime(DateTime.now());
|
||
|
||
await _cleanupOldBackups();
|
||
|
||
Log.i('自动备份完成: $filePath');
|
||
return filePath;
|
||
}
|
||
|
||
static Future<void> _cleanupOldBackups() async {
|
||
final backups = await getBackupList();
|
||
|
||
final maxCount = maxBackupCount;
|
||
if (backups.length > maxCount) {
|
||
final toDelete = backups.sublist(maxCount);
|
||
for (final backup in toDelete) {
|
||
try {
|
||
await File(backup.path).delete();
|
||
Log.i('删除旧备份: ${backup.fileName}');
|
||
} catch (e) {
|
||
Log.w('删除旧备份失败: $e');
|
||
}
|
||
}
|
||
}
|
||
|
||
final retentionDays = backupRetentionDays;
|
||
final cutoff = DateTime.now().subtract(Duration(days: retentionDays));
|
||
for (final backup in backups) {
|
||
if (backup.createdAt.isBefore(cutoff)) {
|
||
try {
|
||
await File(backup.path).delete();
|
||
Log.i('删除过期备份: ${backup.fileName}');
|
||
} catch (e) {
|
||
Log.w('删除过期备份失败: $e');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
static Future<bool> deleteBackup(String path) async {
|
||
if (pu.isWeb) return false;
|
||
try {
|
||
final file = File(path);
|
||
if (await file.exists()) {
|
||
await file.delete();
|
||
Log.i('备份已删除: $path');
|
||
return true;
|
||
}
|
||
return false;
|
||
} catch (e) {
|
||
Log.e('删除备份失败', e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
static Future<void> deleteAllBackups() async {
|
||
if (pu.isWeb) return;
|
||
final backups = await getBackupList();
|
||
for (final backup in backups) {
|
||
try {
|
||
await File(backup.path).delete();
|
||
} catch (e) {
|
||
Log.w('删除备份失败: $e');
|
||
}
|
||
}
|
||
Log.i('所有自动备份已清除');
|
||
}
|
||
|
||
static Future<int> getTotalBackupSize() async {
|
||
if (pu.isWeb) return 0;
|
||
final backups = await getBackupList();
|
||
int total = 0;
|
||
for (final backup in backups) {
|
||
total += backup.sizeBytes;
|
||
}
|
||
return total;
|
||
}
|
||
|
||
static String formatSize(int bytes) {
|
||
if (bytes < 1024) return '$bytes B';
|
||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||
if (bytes < 1024 * 1024 * 1024) {
|
||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||
}
|
||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||
}
|
||
}
|
||
|
||
class BackupFileInfo {
|
||
const BackupFileInfo({
|
||
required this.path,
|
||
required this.fileName,
|
||
required this.createdAt,
|
||
required this.sizeBytes,
|
||
});
|
||
|
||
final String path;
|
||
final String fileName;
|
||
final DateTime createdAt;
|
||
final int sizeBytes;
|
||
|
||
String get formattedSize => BackupService.formatSize(sizeBytes);
|
||
|
||
String get formattedDate {
|
||
final now = DateTime.now();
|
||
final today = DateTime(now.year, now.month, now.day);
|
||
final date = DateTime(createdAt.year, createdAt.month, createdAt.day);
|
||
final yesterday = today.subtract(const Duration(days: 1));
|
||
|
||
if (date == today) return '今天 ${_formatTime(createdAt)}';
|
||
if (date == yesterday) return '昨天 ${_formatTime(createdAt)}';
|
||
return '${createdAt.year}-${_pad(createdAt.month)}-${_pad(createdAt.day)} ${_formatTime(createdAt)}';
|
||
}
|
||
|
||
String _formatTime(DateTime t) => '${_pad(t.hour)}:${_pad(t.minute)}';
|
||
String _pad(int n) => n.toString().padLeft(2, '0');
|
||
}
|