1. 替换hive_flutter为hive_ce_flutter依赖 2. 从各平台插件列表移除sqlite3_flutter_libs 3. 重构API请求体格式,优化历史记录去重逻辑 4. 新增CTC笔记相关功能:桌面小部件、模板模型、本地存储 5. 新增表单收集服务和后台管理接口 6. 优化缓存配置、多语言文案和UI细节 7. 重构首页状态监听组件
340 lines
10 KiB
Dart
340 lines
10 KiB
Dart
/// ============================================================
|
||
/// 闲言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');
|
||
}
|