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