Files
xianyan/lib/core/services/data/backup_service.dart
Developer ad00967c68 chore: 迁移依赖、移除sqlite3_flutter_libs并新增功能
1. 替换hive_flutter为hive_ce_flutter依赖
2. 从各平台插件列表移除sqlite3_flutter_libs
3. 重构API请求体格式,优化历史记录去重逻辑
4. 新增CTC笔记相关功能:桌面小部件、模板模型、本地存储
5. 新增表单收集服务和后台管理接口
6. 优化缓存配置、多语言文案和UI细节
7. 重构首页状态监听组件
2026-06-15 10:04:52 +08:00

340 lines
10 KiB
Dart
Raw 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-05
/// 作用: 定时自动备份用户数据到本地,支持备份管理和恢复
/// 上次更新: 完善Web端守卫所有公共方法添加isWeb早期返回
/// ============================================================
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;
}
static Future<String> get backupDirPath async {
if (pu.isWeb) throw UnsupportedError('Web端不支持文件系统操作');
var dirPath = await pu.safeAppDirPath;
// 无法获取应用文档目录时降级使用临时目录如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;
}
static Future<List<BackupFileInfo>> getBackupList() async {
if (pu.isWeb) return [];
final dirPath = await backupDirPath;
final dir = Directory(dirPath);
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,
),
);
}
backups.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return backups;
}
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');
}