913 lines
33 KiB
Dart
913 lines
33 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:ainoval/config/app_config.dart';
|
||
import 'package:ainoval/services/api_service/base/api_client.dart';
|
||
import 'package:ainoval/services/api_service/base/api_exception.dart';
|
||
|
||
import 'package:ainoval/services/local_storage_service.dart';
|
||
import 'package:ainoval/utils/logger.dart';
|
||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||
|
||
/// 数据同步服务
|
||
///
|
||
/// 负责在本地数据和远程API之间同步数据,支持离线模式和冲突解决
|
||
class SyncService {
|
||
SyncService({
|
||
required this.apiService,
|
||
required this.localStorageService,
|
||
});
|
||
|
||
final ApiClient apiService;
|
||
final LocalStorageService localStorageService;
|
||
|
||
// 同步状态流
|
||
final _syncStateController = StreamController<SyncState>.broadcast();
|
||
Stream<SyncState> get syncStateStream => _syncStateController.stream;
|
||
|
||
// 当前同步状态
|
||
SyncState _currentState = SyncState.idle();
|
||
SyncState get currentState => _currentState;
|
||
|
||
// 网络连接监听器
|
||
StreamSubscription? _connectivitySubscription;
|
||
|
||
// 自动同步定时器
|
||
Timer? _autoSyncTimer;
|
||
|
||
// 服务是否已关闭
|
||
bool _isDisposed = false;
|
||
bool get isDisposed => _isDisposed;
|
||
|
||
/// 初始化同步服务
|
||
Future<void> init() async {
|
||
AppLogger.i('SyncService', '初始化同步服务');
|
||
|
||
// 监听网络连接状态
|
||
_connectivitySubscription =
|
||
Connectivity().onConnectivityChanged.listen((result) {
|
||
final isOnline = result != ConnectivityResult.none;
|
||
AppLogger.d('SyncService', '网络连接状态变化: ${isOnline ? "在线" : "离线"}');
|
||
_handleConnectivityChange(isOnline);
|
||
});
|
||
|
||
// 检查当前网络状态
|
||
final connectivityResult = await Connectivity().checkConnectivity();
|
||
final isOnline = connectivityResult != ConnectivityResult.none;
|
||
AppLogger.d('SyncService', '当前网络状态: ${isOnline ? "在线" : "离线"}');
|
||
_updateSyncState(isOnline: isOnline);
|
||
|
||
// 设置自动同步定时器
|
||
_setupAutoSync();
|
||
}
|
||
|
||
/// 设置自动同步
|
||
void _setupAutoSync() {
|
||
AppLogger.i('SyncService', '设置自动同步定时器,每5分钟同步一次');
|
||
_autoSyncTimer?.cancel();
|
||
_autoSyncTimer = Timer.periodic(const Duration(minutes: 5), (_) async {
|
||
if (_currentState.isOnline) {
|
||
// 检查当前小说ID是否设置
|
||
final currentNovelId = await localStorageService.getCurrentNovelId();
|
||
if (currentNovelId == null) {
|
||
AppLogger.w('SyncService', '自动同步触发,但无当前小说ID,跳过');
|
||
return;
|
||
}
|
||
|
||
AppLogger.d('SyncService', '自动同步触发,当前小说ID: $currentNovelId');
|
||
syncAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 处理网络连接变化
|
||
void _handleConnectivityChange(bool isOnline) {
|
||
// Store the previous online state before updating
|
||
final wasOffline = !_currentState.isOnline;
|
||
_updateSyncState(isOnline: isOnline);
|
||
|
||
// Trigger sync only when coming back online
|
||
if (isOnline && wasOffline) {
|
||
// 检查当前小说ID是否设置后再同步
|
||
localStorageService.getCurrentNovelId().then((currentNovelId) {
|
||
if (currentNovelId != null) {
|
||
AppLogger.i('SyncService', '网络恢复,开始同步数据,当前小说ID: $currentNovelId');
|
||
syncAll(); // syncAll will now also handle pending messages
|
||
} else {
|
||
AppLogger.w('SyncService', '网络恢复,但无当前小说ID,不执行自动同步');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/// 更新同步状态
|
||
void _updateSyncState({
|
||
bool? isOnline,
|
||
bool? isSyncing,
|
||
String? error,
|
||
double? progress,
|
||
}) {
|
||
// 如果服务已关闭,则不更新状态
|
||
if (_isDisposed) {
|
||
AppLogger.w('SyncService', '服务已关闭,忽略状态更新');
|
||
return;
|
||
}
|
||
|
||
_currentState = SyncState(
|
||
isOnline: isOnline ?? _currentState.isOnline,
|
||
isSyncing: isSyncing ?? _currentState.isSyncing,
|
||
error: error,
|
||
progress: progress ?? _currentState.progress,
|
||
);
|
||
|
||
_syncStateController.add(_currentState);
|
||
AppLogger.v('SyncService', '同步状态更新: $_currentState');
|
||
}
|
||
|
||
/// 同步所有数据
|
||
Future<bool> syncAll() async {
|
||
// 如果服务已关闭,直接返回
|
||
if (_isDisposed) {
|
||
AppLogger.w('SyncService', '服务已关闭,无法执行同步');
|
||
return false;
|
||
}
|
||
|
||
if (_currentState.isSyncing) {
|
||
AppLogger.w('SyncService', '同步已在进行中,跳过本次同步');
|
||
return false;
|
||
}
|
||
|
||
if (!_currentState.isOnline) {
|
||
AppLogger.w('SyncService', '无网络连接,无法同步');
|
||
_updateSyncState(error: '无网络连接,无法同步');
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
AppLogger.i('SyncService', '开始全量数据同步');
|
||
_updateSyncState(
|
||
isSyncing: true, progress: 0.0, error: null); // Clear previous error
|
||
|
||
// --- Sync Pending Messages First ---
|
||
AppLogger.d('SyncService', '开始同步待发送消息');
|
||
await _syncPendingMessages();
|
||
_updateSyncState(progress: 0.1); // Adjust progress steps
|
||
|
||
// --- Sync other data types ---
|
||
AppLogger.d('SyncService', '开始同步小说数据');
|
||
await _syncNovels();
|
||
_updateSyncState(progress: 0.3);
|
||
|
||
AppLogger.d('SyncService', '开始同步场景内容');
|
||
await _syncScenes();
|
||
_updateSyncState(progress: 0.5);
|
||
|
||
AppLogger.d('SyncService', '开始同步编辑器内容');
|
||
await _syncEditorContents();
|
||
_updateSyncState(progress: 0.7);
|
||
|
||
// Sync Chat Session METADATA (title, etc.)
|
||
AppLogger.d('SyncService', '开始同步聊天会话元数据');
|
||
await _syncChatSessions(); // This now only syncs metadata
|
||
_updateSyncState(progress: 0.9); // Example progress
|
||
|
||
// --- Final step, maybe sync user profile or other settings ---
|
||
_updateSyncState(progress: 1.0); // Example finish
|
||
|
||
AppLogger.i('SyncService', '全量数据同步完成');
|
||
_updateSyncState(isSyncing: false, error: null); // Clear error on success
|
||
return true;
|
||
} catch (e, stackTrace) {
|
||
AppLogger.e('SyncService', '同步失败', e, stackTrace);
|
||
// Preserve isOnline state, set error
|
||
_updateSyncState(isSyncing: false, error: '同步失败: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 同步小说数据
|
||
Future<void> _syncNovels() async {
|
||
try {
|
||
// 获取当前正在编辑的小说ID
|
||
final currentNovelId = await localStorageService.getCurrentNovelId();
|
||
if (currentNovelId == null) {
|
||
AppLogger.w('SyncService', '无当前小说ID,跳过小说同步');
|
||
return;
|
||
}
|
||
|
||
final syncList = await localStorageService.getSyncList('novel');
|
||
AppLogger.d('SyncService', '需要同步的小说数量: ${syncList.length}');
|
||
|
||
// 筛选出当前小说
|
||
final novelIdsToSync = syncList.where((novelId) => novelId == currentNovelId).toList();
|
||
AppLogger.d('SyncService', '当前小说需要同步: ${novelIdsToSync.length} (当前小说ID: $currentNovelId)');
|
||
|
||
if (novelIdsToSync.isEmpty) {
|
||
AppLogger.i('SyncService', '当前小说不需要同步,跳过');
|
||
return;
|
||
}
|
||
|
||
for (final novelId in novelIdsToSync) {
|
||
final localNovel = await localStorageService.getNovel(novelId);
|
||
if (localNovel == null) {
|
||
AppLogger.w('SyncService', '本地小说不存在: $novelId');
|
||
continue;
|
||
}
|
||
|
||
AppLogger.i('SyncService', '同步小说: ${localNovel.title}($novelId)');
|
||
|
||
// 构建后端所需的小说数据结构
|
||
final backendNovelJson = {
|
||
'id': localNovel.id,
|
||
'title': localNovel.title,
|
||
'coverImage': localNovel.coverUrl,
|
||
// 确保包含作者信息
|
||
'author': localNovel.author?.toJson() ??
|
||
{
|
||
'id': AppConfig.userId ?? '',
|
||
'username': AppConfig.username ?? 'user'
|
||
},
|
||
'lastEditedChapterId': localNovel.lastEditedChapterId,
|
||
'createdAt': localNovel.createdAt.toIso8601String(),
|
||
'updatedAt': DateTime.now().toIso8601String(),
|
||
'structure': {
|
||
'acts': localNovel.acts
|
||
.map((act) => {
|
||
'id': act.id,
|
||
'title': act.title,
|
||
'order': act.order,
|
||
'chapters': act.chapters
|
||
.map((chapter) => {
|
||
'id': chapter.id,
|
||
'title': chapter.title,
|
||
'order': chapter.order,
|
||
// 注意:章节中只需包含ID,场景内容通过scenesByChapter单独提供
|
||
'sceneIds': chapter.scenes.map((scene) => scene.id).toList(),
|
||
})
|
||
.toList(),
|
||
})
|
||
.toList(),
|
||
},
|
||
};
|
||
|
||
// 组织场景数据,按章节分组
|
||
Map<String, List<Map<String, dynamic>>> scenesByChapter = {};
|
||
for (final act in localNovel.acts) {
|
||
for (final chapter in act.chapters) {
|
||
if (chapter.scenes.isNotEmpty) {
|
||
scenesByChapter[chapter.id] = chapter.scenes
|
||
.map((scene) => {
|
||
'id': scene.id,
|
||
'novelId': localNovel.id,
|
||
'chapterId': chapter.id,
|
||
'content': scene.content,
|
||
'summary': scene.summary.content,
|
||
'updatedAt': scene.lastEdited.toIso8601String(),
|
||
'version': scene.version,
|
||
'title': '',
|
||
'sequence': 0,
|
||
'sceneType': 'NORMAL',
|
||
})
|
||
.toList();
|
||
}
|
||
}
|
||
}
|
||
|
||
// 组装完整的请求数据
|
||
final novelWithScenesJson = {
|
||
'novel': backendNovelJson,
|
||
'scenesByChapter': scenesByChapter,
|
||
};
|
||
|
||
// 调用updateNovelWithScenes接口
|
||
await apiService.updateNovelWithScenes(novelWithScenesJson);
|
||
|
||
await localStorageService.clearSyncFlagByType('novel', novelId);
|
||
AppLogger.d('SyncService', '小说同步完成: $novelId');
|
||
}
|
||
} catch (e, stackTrace) {
|
||
AppLogger.e('SyncService', '同步小说数据失败', e, stackTrace);
|
||
throw SyncException('同步小说数据失败: $e');
|
||
}
|
||
}
|
||
|
||
/// 同步场景内容
|
||
Future<void> _syncScenes() async {
|
||
try {
|
||
// 获取当前正在编辑的小说ID
|
||
final currentNovelId = await localStorageService.getCurrentNovelId();
|
||
if (currentNovelId == null) {
|
||
AppLogger.w('SyncService', '无当前小说ID,跳过场景同步');
|
||
return;
|
||
}
|
||
|
||
final syncList = await localStorageService.getSyncList('scene');
|
||
AppLogger.d('SyncService', '需要同步的场景数量: ${syncList.length}');
|
||
|
||
// 筛选出当前小说的场景
|
||
final scenesToSync = syncList.where((sceneKey) {
|
||
final parts = sceneKey.split('_');
|
||
return parts.length == 4 && parts[0] == currentNovelId;
|
||
}).toList();
|
||
|
||
AppLogger.d('SyncService', '当前小说的场景需要同步: ${scenesToSync.length} (当前小说ID: $currentNovelId)');
|
||
|
||
if (scenesToSync.isEmpty) {
|
||
AppLogger.i('SyncService', '当前小说没有场景需要同步,跳过');
|
||
return;
|
||
}
|
||
|
||
for (final sceneKey in scenesToSync) {
|
||
final parts = sceneKey.split('_');
|
||
if (parts.length != 4) {
|
||
AppLogger.w('SyncService', '无效的场景键格式: $sceneKey');
|
||
continue;
|
||
}
|
||
|
||
final novelId = parts[0];
|
||
final actId = parts[1];
|
||
final chapterId = parts[2];
|
||
final sceneId = parts[3];
|
||
|
||
final localScene = await localStorageService.getSceneContent(
|
||
novelId, actId, chapterId, sceneId);
|
||
if (localScene == null) {
|
||
AppLogger.w('SyncService', '本地场景不存在: $sceneKey');
|
||
continue;
|
||
}
|
||
|
||
AppLogger.i('SyncService', '同步场景: $sceneKey');
|
||
final sceneData = localScene.toJson();
|
||
await apiService.updateScene(sceneData);
|
||
|
||
await localStorageService.clearSyncFlagByType('scene', sceneKey);
|
||
AppLogger.d('SyncService', '场景同步完成: $sceneKey');
|
||
}
|
||
} catch (e, stackTrace) {
|
||
AppLogger.e('SyncService', '同步场景内容失败', e, stackTrace);
|
||
throw SyncException('同步场景内容失败: $e');
|
||
}
|
||
}
|
||
|
||
/// 同步编辑器内容
|
||
Future<void> _syncEditorContents() async {
|
||
try {
|
||
// 获取当前正在编辑的小说ID
|
||
final currentNovelId = await localStorageService.getCurrentNovelId();
|
||
if (currentNovelId == null) {
|
||
AppLogger.w('SyncService', '无当前小说ID,跳过编辑器内容同步');
|
||
return;
|
||
}
|
||
|
||
final syncList = await localStorageService.getSyncList('editor');
|
||
AppLogger.d('SyncService', '需要同步的编辑器内容数量: ${syncList.length}');
|
||
|
||
// 筛选出当前小说的编辑器内容
|
||
final contentsToSync = syncList.where((contentKey) {
|
||
final parts = contentKey.split('_');
|
||
return parts.length >= 2 && parts[0] == currentNovelId;
|
||
}).toList();
|
||
|
||
AppLogger.d('SyncService', '当前小说的编辑器内容需要同步: ${contentsToSync.length} (当前小说ID: $currentNovelId)');
|
||
|
||
if (contentsToSync.isEmpty) {
|
||
AppLogger.i('SyncService', '当前小说没有编辑器内容需要同步,跳过');
|
||
return;
|
||
}
|
||
|
||
for (final contentKey in contentsToSync) {
|
||
final parts = contentKey.split('_');
|
||
if (parts.length < 2) {
|
||
AppLogger.w('SyncService', '无效的编辑器内容键格式: $contentKey');
|
||
continue;
|
||
}
|
||
|
||
final novelId = parts[0];
|
||
final chapterId = parts[1];
|
||
|
||
final localContent =
|
||
await localStorageService.getEditorContent(novelId, chapterId, '');
|
||
if (localContent == null) {
|
||
AppLogger.w('SyncService', '本地编辑器内容不存在: $contentKey');
|
||
continue;
|
||
}
|
||
|
||
AppLogger.i('SyncService', '同步编辑器内容: $contentKey');
|
||
await apiService.saveEditorContent(
|
||
novelId, chapterId, localContent.toJson());
|
||
|
||
await localStorageService.clearSyncFlagByType('editor', contentKey);
|
||
AppLogger.d('SyncService', '编辑器内容同步完成: $contentKey');
|
||
}
|
||
} catch (e, stackTrace) {
|
||
AppLogger.e('SyncService', '同步编辑器内容失败', e, stackTrace);
|
||
throw SyncException('同步编辑器内容失败: $e');
|
||
}
|
||
}
|
||
|
||
/// 同步聊天会话元数据 (No longer sends full message history)
|
||
Future<void> _syncChatSessions() async {
|
||
// This method now only syncs session metadata like title, updatedAt
|
||
try {
|
||
// 获取当前正在编辑的小说ID
|
||
final currentNovelId = await localStorageService.getCurrentNovelId();
|
||
if (currentNovelId == null) {
|
||
AppLogger.w('SyncService', '无当前小说ID,跳过聊天会话同步');
|
||
return;
|
||
}
|
||
|
||
final sessions = await localStorageService.getSessionsToSync();
|
||
AppLogger.d('SyncService', '需要同步的聊天会话元数据数量: ${sessions.length}');
|
||
|
||
// 筛选出当前小说的聊天会话
|
||
// 注意:这里假设 ChatSession 模型有 novelId 属性,如果没有,需要调整过滤逻辑
|
||
final sessionsToSync = sessions.where((session) =>
|
||
session.metadata != null &&
|
||
session.metadata!['novelId'] == currentNovelId).toList();
|
||
|
||
AppLogger.d('SyncService', '当前小说的聊天会话需要同步: ${sessionsToSync.length} (当前小说ID: $currentNovelId)');
|
||
|
||
if (sessionsToSync.isEmpty) {
|
||
AppLogger.i('SyncService', '当前小说没有聊天会话需要同步,跳过');
|
||
return;
|
||
}
|
||
|
||
// No need for userId here if updateSession API only updates metadata
|
||
// If updateSession *requires* userId, get it once:
|
||
// final String currentUserId = await _getCurrentUserId();
|
||
|
||
for (final session in sessionsToSync) {
|
||
AppLogger.i('SyncService', '同步聊天会话元数据: ${session.id}');
|
||
|
||
// Construct updates payload - only include fields managed locally
|
||
// that need syncing (like title if user can rename offline)
|
||
final Map<String, dynamic> updates = {
|
||
'title': session.title,
|
||
// Include lastUpdatedAt from local session to inform server?
|
||
// 'updatedAt': session.lastUpdatedAt.toIso8601String(),
|
||
// Or maybe server handles updatedAt automatically on update?
|
||
};
|
||
|
||
// Only call update if there are actual updates to send
|
||
if (updates.isNotEmpty) {
|
||
// If updateSession requires userId, pass it: userId: currentUserId,
|
||
await apiService.updateAiChatSession(
|
||
userId: await _getCurrentUserId(), // Get userId if needed by API
|
||
sessionId: session.id,
|
||
updates: updates,
|
||
);
|
||
} // Else: Session might be marked for sync without local changes, skip API call?
|
||
|
||
// REMOVED: Loop sending messages using getMessagesForSession
|
||
|
||
await localStorageService.clearSyncFlagByType(
|
||
'chat_session', session.id);
|
||
AppLogger.d('SyncService', '聊天会话元数据同步完成: ${session.id}');
|
||
}
|
||
} catch (e, stackTrace) {
|
||
// Don't throw - allow other sync tasks to proceed if possible
|
||
AppLogger.e('SyncService', '同步聊天会话元数据失败', e, stackTrace);
|
||
// Optionally update state with a non-fatal error?
|
||
// _updateSyncState(error: '部分同步失败: 聊天会话元数据'); // Be careful not to overwrite fatal errors
|
||
}
|
||
}
|
||
|
||
/// --- New Method: Sync Pending Chat Messages ---
|
||
Future<void> _syncPendingMessages() async {
|
||
try {
|
||
// 如果服务已关闭,直接返回
|
||
if (_isDisposed) {
|
||
AppLogger.w('SyncService', '服务已关闭,无法同步待发送消息');
|
||
return;
|
||
}
|
||
|
||
// 获取当前正在编辑的小说ID
|
||
final currentNovelId = await localStorageService.getCurrentNovelId();
|
||
if (currentNovelId == null) {
|
||
AppLogger.w('SyncService', '无当前小说ID,跳过待发送消息同步');
|
||
return;
|
||
}
|
||
|
||
final pendingMessages = await localStorageService.getPendingMessages();
|
||
if (pendingMessages.isEmpty) {
|
||
AppLogger.d('SyncService', '没有待发送的消息。');
|
||
return;
|
||
}
|
||
|
||
// 筛选出当前小说的待发送消息
|
||
final messagesToSync = pendingMessages.where((message) {
|
||
// 检查消息元数据中是否包含小说ID
|
||
final metadata = message['metadata'] as Map<String, dynamic>?;
|
||
return metadata != null && metadata['novelId'] == currentNovelId;
|
||
}).toList();
|
||
|
||
if (messagesToSync.isEmpty) {
|
||
AppLogger.i('SyncService', '当前小说没有待发送消息需要同步,跳过');
|
||
return;
|
||
}
|
||
|
||
AppLogger.i('SyncService', '开始处理 ${messagesToSync.length} 条当前小说的待发送消息。 (当前小说ID: $currentNovelId)');
|
||
final String currentUserId =
|
||
await _getCurrentUserId(); // Get User ID once
|
||
|
||
for (final messageData in messagesToSync) {
|
||
final localId = messageData['localId'] as String?;
|
||
final sessionId = messageData['sessionId'] as String?;
|
||
final content = messageData['content'] as String?;
|
||
final metadata = messageData['metadata'] as Map<String, dynamic>?;
|
||
// Important: Use the userId stored with the message if available,
|
||
// otherwise use currentUserId. This handles cases where sync might
|
||
// happen after user logout/login, though ideally pending messages
|
||
// should be cleared on logout.
|
||
final userIdToSend = messageData['userId'] as String? ?? currentUserId;
|
||
|
||
if (localId == null || sessionId == null || content == null) {
|
||
AppLogger.e('SyncService', '待发送消息数据不完整,跳过: $messageData');
|
||
// Optionally remove corrupted data
|
||
// if (localId != null) await localStorageService.removePendingMessage(localId);
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
AppLogger.d(
|
||
'SyncService', '尝试发送消息: localId=$localId, sessionId=$sessionId');
|
||
// Call the actual API to send the message
|
||
await apiService.sendAiChatMessage(
|
||
userId: userIdToSend,
|
||
sessionId: sessionId,
|
||
content: content,
|
||
metadata: metadata,
|
||
);
|
||
|
||
// If sendMessage succeeds, remove from local pending queue
|
||
await localStorageService.removePendingMessage(localId);
|
||
AppLogger.i('SyncService', '成功发送并移除待发送消息: localId=$localId');
|
||
|
||
// OPTIONAL: Add the successfully sent message to the local history cache
|
||
// This requires constructing a proper ChatMessage object from the response
|
||
// or assuming success and creating one locally. This is complex.
|
||
// It might be simpler to rely on fetching history later.
|
||
} on ApiException catch (apiError, stack) {
|
||
// Catch specific API errors
|
||
AppLogger.e(
|
||
'SyncService',
|
||
'发送待处理消息失败 (API Error $localId): ${apiError.message}',
|
||
apiError,
|
||
stack);
|
||
// Decide if error is temporary or permanent.
|
||
// For now, leave in queue and retry later.
|
||
// If 4xx error, maybe remove from queue?
|
||
if (apiError.statusCode >= 400 &&
|
||
apiError.statusCode < 500 &&
|
||
apiError.statusCode != 401 &&
|
||
apiError.statusCode != 429) {
|
||
AppLogger.w('SyncService',
|
||
'接收到客户端错误 (${apiError.statusCode}),可能移除待发送消息 $localId');
|
||
// Consider removing permanently failed message
|
||
// await localStorageService.removePendingMessage(localId);
|
||
}
|
||
} catch (e, stackTrace) {
|
||
// Catch other errors
|
||
AppLogger.e(
|
||
'SyncService', '发送待处理消息时发生未知错误 ($localId)', e, stackTrace);
|
||
// Leave in queue for retry
|
||
}
|
||
}
|
||
AppLogger.i('SyncService', '处理待发送消息完成。');
|
||
} catch (e, stackTrace) {
|
||
// Error fetching or processing the queue itself
|
||
AppLogger.e('SyncService', '处理待发送消息队列时出错', e, stackTrace);
|
||
// Don't throw, allow other sync tasks. Update state?
|
||
// _updateSyncState(error: '部分同步失败: 待发送消息');
|
||
}
|
||
}
|
||
|
||
/// 同步单个小说
|
||
Future<bool> syncNovel(String novelId) async {
|
||
// 如果服务已关闭,直接返回
|
||
if (_isDisposed) {
|
||
AppLogger.w('SyncService', '服务已关闭,无法同步小说');
|
||
return false;
|
||
}
|
||
|
||
if (!_currentState.isOnline) {
|
||
_updateSyncState(error: '无网络连接,无法同步');
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
// 获取本地小说
|
||
final localNovel = await localStorageService.getNovel(novelId);
|
||
if (localNovel == null) return false;
|
||
|
||
// 构建后端所需的小说数据结构
|
||
final backendNovelJson = {
|
||
'id': localNovel.id,
|
||
'title': localNovel.title,
|
||
'coverImage': localNovel.coverUrl,
|
||
// 确保包含作者信息
|
||
'author': localNovel.author?.toJson() ??
|
||
{
|
||
'id': AppConfig.userId ?? '',
|
||
'username': AppConfig.username ?? 'user'
|
||
},
|
||
'lastEditedChapterId': localNovel.lastEditedChapterId,
|
||
'createdAt': localNovel.createdAt.toIso8601String(),
|
||
'updatedAt': DateTime.now().toIso8601String(),
|
||
'structure': {
|
||
'acts': localNovel.acts
|
||
.map((act) => {
|
||
'id': act.id,
|
||
'title': act.title,
|
||
'order': act.order,
|
||
'chapters': act.chapters
|
||
.map((chapter) => {
|
||
'id': chapter.id,
|
||
'title': chapter.title,
|
||
'order': chapter.order,
|
||
// 注意:章节中只需包含ID,场景内容通过scenesByChapter单独提供
|
||
'sceneIds': chapter.scenes.map((scene) => scene.id).toList(),
|
||
})
|
||
.toList(),
|
||
})
|
||
.toList(),
|
||
},
|
||
};
|
||
|
||
// 组织场景数据,按章节分组
|
||
Map<String, List<Map<String, dynamic>>> scenesByChapter = {};
|
||
for (final act in localNovel.acts) {
|
||
for (final chapter in act.chapters) {
|
||
if (chapter.scenes.isNotEmpty) {
|
||
scenesByChapter[chapter.id] = chapter.scenes
|
||
.map((scene) => {
|
||
'id': scene.id,
|
||
'novelId': localNovel.id,
|
||
'chapterId': chapter.id,
|
||
'content': scene.content,
|
||
'summary': scene.summary.content,
|
||
'updatedAt': scene.lastEdited.toIso8601String(),
|
||
'version': scene.version,
|
||
'title': '',
|
||
'sequence': 0,
|
||
'sceneType': 'NORMAL',
|
||
})
|
||
.toList();
|
||
}
|
||
}
|
||
}
|
||
|
||
// 组装完整的请求数据
|
||
final novelWithScenesJson = {
|
||
'novel': backendNovelJson,
|
||
'scenesByChapter': scenesByChapter,
|
||
};
|
||
|
||
// 调用updateNovelWithScenes接口
|
||
await apiService.updateNovelWithScenes(novelWithScenesJson);
|
||
|
||
// 标记为已同步
|
||
await localStorageService.clearSyncFlagByType('novel', novelId);
|
||
|
||
return true;
|
||
} catch (e) {
|
||
AppLogger.e('Services/sync_service', '同步小说失败', e);
|
||
_updateSyncState(error: '同步小说失败: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 同步单个场景
|
||
Future<bool> syncScene(
|
||
String novelId, String actId, String chapterId, String sceneId) async {
|
||
// 如果服务已关闭,直接返回
|
||
if (_isDisposed) {
|
||
AppLogger.w('SyncService', '服务已关闭,无法同步场景');
|
||
return false;
|
||
}
|
||
|
||
if (!_currentState.isOnline) {
|
||
_updateSyncState(error: '无网络连接,无法同步');
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
// 获取本地场景
|
||
final localScene = await localStorageService.getSceneContent(
|
||
novelId, actId, chapterId, sceneId);
|
||
if (localScene == null) return false;
|
||
|
||
// 上传到服务器
|
||
final sceneData = localScene.toJson();
|
||
await apiService.updateScene(sceneData);
|
||
|
||
// 标记为已同步
|
||
final sceneKey = '${novelId}_${actId}_${chapterId}_$sceneId';
|
||
await localStorageService.clearSyncFlagByType('scene', sceneKey);
|
||
|
||
return true;
|
||
} catch (e) {
|
||
AppLogger.e('Services/sync_service', '同步场景失败', e);
|
||
_updateSyncState(error: '同步场景失败: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 同步单个编辑器内容
|
||
Future<bool> syncEditorContent(String novelId, String chapterId,
|
||
String sceneId) async {
|
||
// 如果服务已关闭,直接返回
|
||
if (_isDisposed) {
|
||
AppLogger.w('SyncService', '服务已关闭,无法同步编辑器内容');
|
||
return false;
|
||
}
|
||
|
||
if (!_currentState.isOnline) {
|
||
_updateSyncState(error: '无网络连接,无法同步');
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
// 获取本地编辑器内容
|
||
final localContent = await localStorageService.getEditorContent(
|
||
novelId, chapterId, ''); // 传递空的 sceneId 或适配
|
||
if (localContent == null) return false;
|
||
|
||
// 上传到服务器
|
||
await apiService.saveEditorContent(
|
||
novelId, chapterId, localContent.toJson());
|
||
|
||
// 标记为已同步
|
||
final contentKey = '${novelId}_$chapterId'; // 调整 key
|
||
await localStorageService.clearSyncFlagByType('editor', contentKey);
|
||
|
||
return true;
|
||
} catch (e) {
|
||
AppLogger.e('Services/sync_service', '同步编辑器内容失败', e);
|
||
_updateSyncState(error: '同步编辑器内容失败: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 同步单个聊天会话元数据 (不再发送消息历史)
|
||
Future<bool> syncChatSession(String sessionId) async {
|
||
// 如果服务已关闭,直接返回
|
||
if (_isDisposed) {
|
||
AppLogger.w('SyncService', '服务已关闭,无法同步聊天会话');
|
||
return false;
|
||
}
|
||
|
||
if (!_currentState.isOnline) {
|
||
_updateSyncState(error: '无网络连接,无法同步');
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
final session = await localStorageService.getChatSession(sessionId);
|
||
if (session == null) {
|
||
AppLogger.w('SyncService', '尝试同步单个会话元数据,但本地未找到: $sessionId');
|
||
await localStorageService.clearSyncFlagByType(
|
||
'chat_session', sessionId); // 清除无效标记
|
||
return false; // 无法同步不存在的会话
|
||
}
|
||
|
||
// 同步元数据 (例如: title)
|
||
final Map<String, dynamic> updates = {'title': session.title};
|
||
|
||
if (updates.isNotEmpty) {
|
||
await apiService.updateAiChatSession(
|
||
userId: await _getCurrentUserId(), // 如果 API 需要,获取 userId
|
||
sessionId: session.id,
|
||
updates: updates,
|
||
);
|
||
AppLogger.d('SyncService', '单个聊天会话元数据 API 更新调用完成: $sessionId');
|
||
} else {
|
||
AppLogger.d('SyncService', '单个聊天会话 ${session.id} 没有需要同步的元数据更新');
|
||
}
|
||
|
||
// ======== 移除: 不再通过 getMessagesForSession 循环发送消息 ========
|
||
|
||
// 清除此会话的元数据同步标记
|
||
await localStorageService.clearSyncFlagByType('chat_session', sessionId);
|
||
AppLogger.i('SyncService', '单个聊天会话元数据同步处理完成: $sessionId');
|
||
return true;
|
||
} catch (e, stackTrace) {
|
||
// 捕获所有可能的错误
|
||
AppLogger.e('SyncService', '同步单个聊天会话元数据失败 ($sessionId)', e, stackTrace);
|
||
_updateSyncState(error: '同步单个聊天会话元数据失败: $e');
|
||
// 同步失败,暂时不清除标记,留待下次重试
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 解决冲突 (需要根据聊天数据的具体冲突场景来完善)
|
||
|
||
/// 关闭服务,释放资源
|
||
void dispose() {
|
||
// 设置已关闭标志
|
||
_isDisposed = true;
|
||
|
||
// 取消网络监听和定时器
|
||
_connectivitySubscription?.cancel();
|
||
_autoSyncTimer?.cancel();
|
||
|
||
// 关闭状态流
|
||
if (!_syncStateController.isClosed) {
|
||
_syncStateController.close();
|
||
}
|
||
|
||
// 清除当前小说ID,避免后续同步错误
|
||
localStorageService.setCurrentNovelId('').then((_) {
|
||
AppLogger.i('SyncService', '同步服务已关闭,清除当前小说ID');
|
||
});
|
||
|
||
AppLogger.i('SyncService', '同步服务已关闭');
|
||
}
|
||
|
||
/// 获取当前用户ID (使用 AppConfig 实现)
|
||
Future<String> _getCurrentUserId() async {
|
||
final userId = AppConfig.userId; // 从 AppConfig 获取用户ID
|
||
if (userId == null || userId.isEmpty) {
|
||
AppLogger.e('SyncService', '无法获取当前用户ID,同步操作可能失败或无法执行。');
|
||
// 根据需求,可以抛出异常或返回占位符/空字符串
|
||
// 如果 userId 是必需的,抛出异常更安全
|
||
throw SyncException('无法获取当前用户ID,无法执行需要用户ID的同步操作。');
|
||
}
|
||
return userId;
|
||
}
|
||
|
||
/// 直接设置当前小说ID
|
||
Future<void> setCurrentNovelId(String novelId) async {
|
||
// 即使服务已关闭也允许设置,但记录警告
|
||
if (_isDisposed) {
|
||
AppLogger.w('SyncService', '尝试在服务已关闭状态下设置当前小说ID: $novelId');
|
||
// 考虑到可能在关闭过程中调用此方法,仍然允许操作继续
|
||
}
|
||
|
||
await localStorageService.setCurrentNovelId(novelId);
|
||
AppLogger.i('SyncService', '同步服务已设置当前小说ID: $novelId');
|
||
}
|
||
}
|
||
|
||
/// 同步状态类
|
||
class SyncState {
|
||
SyncState({
|
||
required this.isOnline, // 网络是否连接
|
||
required this.isSyncing, // 是否正在同步中
|
||
this.error, // 同步错误信息,null表示无错误
|
||
this.progress = 0.0, // 同步进度 (0.0 到 1.0)
|
||
});
|
||
|
||
/// 空闲状态 (默认在线)
|
||
factory SyncState.idle({bool online = true}) {
|
||
return SyncState(
|
||
isOnline: online,
|
||
isSyncing: false,
|
||
);
|
||
}
|
||
|
||
/// 同步中状态
|
||
factory SyncState.syncing({double progress = 0.0}) {
|
||
return SyncState(
|
||
isOnline: true, // 同步时必须在线
|
||
isSyncing: true,
|
||
progress: progress,
|
||
);
|
||
}
|
||
|
||
/// 离线状态
|
||
factory SyncState.offline() {
|
||
return SyncState(
|
||
isOnline: false,
|
||
isSyncing: false, // 离线时不能同步
|
||
);
|
||
}
|
||
|
||
/// 错误状态 (允许指定当时的网络状态)
|
||
factory SyncState.error(String errorMessage, {bool online = true}) {
|
||
return SyncState(
|
||
isOnline: online, // 错误可能在线或离线时发生
|
||
isSyncing: false, // 出错时停止同步
|
||
error: errorMessage,
|
||
);
|
||
}
|
||
final bool isOnline;
|
||
final bool isSyncing;
|
||
final String? error;
|
||
final double progress;
|
||
|
||
@override
|
||
String toString() {
|
||
// 提供更清晰的状态描述
|
||
return 'SyncState(在线: $isOnline, 同步中: $isSyncing, 进度: ${progress.toStringAsFixed(2)}, 错误: ${error ?? "无"})';
|
||
}
|
||
}
|
||
|
||
/// 同步异常类
|
||
class SyncException implements Exception {
|
||
SyncException(this.message);
|
||
final String message; // 异常信息
|
||
|
||
@override
|
||
String toString() => 'SyncException: $message';
|
||
}
|