/// ============================================================ /// 闲言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 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> getBackupList() async { if (pu.isWeb) return []; final backups = []; // 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 = {}; final deduped = []; 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> _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() .toList(); final backups = []; 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 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 notesData = {}; try { final notesBox = await Hive.openBox('notes'); notesData = notesBox.toMap(); } catch (e) { Log.w('读取笔记数据失败: $e'); } final exportData = { '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 _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 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 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 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'); }