Files
xianyan/lib/core/services/data/backup_service.dart
Developer 355191aaf6 feat(leisure): 新增闲情逸致模块与多项功能优化
本次提交完成多项核心更新:
1. 新增闲情逸致功能模块,包含时间线、收藏标注、季节主题等基础框架
2. 替换hive为社区维护的hive_ce包,修复依赖兼容问题
3. 统一替换"开发中"提示为"当前设备不支持",优化用户提示文案
4. 新增多项功能开关与特性标志,统一管理不可用功能提示
5. 完善用户账户洞察系统,新增头像审核中状态检测
6. 优化TTS语音朗读服务,修复Android端引擎初始化问题
7. 重构知识图谱缩放手势逻辑,解决缩放不跟手问题
8. 新增精灵头像组件,替换默认聊天头像样式
9. 新增外部链接跳转确认弹窗,提升使用安全性
10. 升级后端API接口,新增签到配置获取与补签积分规则动态读取
11. 完善多语言翻译覆盖率限制,非中文语言仅显示最高50%进度
12. 新增HTTP缓存拦截器,优化网络请求性能
13. 新增恢复出厂设置选项,完善数据管理功能

同时修复了多处代码细节问题:简化字符串拼接、优化布局代码、移除多余代码等。
2026-05-27 08:06:54 +08:00

328 lines
9.8 KiB
Dart

/// ============================================================
/// 闲言APP — 自动备份服务
/// 创建时间: 2026-05-04
/// 更新时间: 2026-05-20
/// 作用: 定时自动备份用户数据到本地,支持备份管理和恢复
/// 上次更新: 修复Web端path_provider MissingPluginException(增加isWeb守卫)
/// ============================================================
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:crypto/crypto.dart';
import 'package:hive_ce/hive.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端不支持文件系统操作');
final dirPath = await pu.safeAppDirPath;
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 {
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 {
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 {
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 {
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 {
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');
}