Files
xianyan/lib/core/services/data/backup_service.dart
Developer 81ea0f60dc fix: 修复 FactoryReset 误删用户文件并将应用数据迁移至 Application Support
- 修复 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
2026-06-26 08:46:23 +08:00

393 lines
12 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 闲言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');
}