马良AI写作初始化仓库

This commit is contained in:
邓滨杰
2025-09-10 00:07:52 +08:00
parent 3c06bb1a03
commit 39c0f8840f
1309 changed files with 318528 additions and 0 deletions

View File

@@ -0,0 +1,321 @@
import 'package:ainoval/models/preset_models.dart';
import 'package:ainoval/services/api_service/repositories/ai_preset_repository.dart';
import 'package:ainoval/services/api_service/repositories/impl/ai_preset_repository_impl.dart';
import 'package:ainoval/services/api_service/base/api_client.dart';
import 'package:ainoval/utils/logger.dart';
/// AI预设服务
/// 提供预设管理的业务逻辑层
class AIPresetService {
final AIPresetRepository _repository;
final String _tag = 'AIPresetService';
AIPresetService({AIPresetRepository? repository})
: _repository = repository ?? AIPresetRepositoryImpl(apiClient: ApiClient());
/// 创建预设
/// [request] 创建预设请求
/// 返回创建的预设
Future<AIPromptPreset> createPreset(CreatePresetRequest request) async {
try {
AppLogger.i(_tag, '创建预设: ${request.presetName}');
final preset = await _repository.createPreset(request);
// 记录预设使用(创建后立即记录)
await _recordUsage(preset.presetId);
AppLogger.i(_tag, '预设创建成功: ${preset.presetId}');
return preset;
} catch (e) {
AppLogger.e(_tag, '创建预设失败', e);
rethrow;
}
}
/// 获取用户的所有预设
/// [userId] 用户ID如果为null则获取当前用户的预设
/// [featureType] 功能类型默认为AI_CHAT
/// 返回预设列表
Future<List<AIPromptPreset>> getUserPresets({String? userId, String featureType = 'AI_CHAT'}) async {
try {
AppLogger.d(_tag, '获取用户预设列表: userId=$userId, featureType=$featureType');
final presets = await _repository.getUserPresets(userId: userId, featureType: featureType);
AppLogger.i(_tag, '获取到 ${presets.length} 个用户预设 (featureType=$featureType)');
return presets;
} catch (e) {
AppLogger.e(_tag, '获取用户预设列表失败', e);
rethrow;
}
}
/// 搜索预设
/// [params] 搜索参数
/// 返回匹配的预设列表
Future<List<AIPromptPreset>> searchPresets(PresetSearchParams params) async {
try {
AppLogger.d(_tag, '搜索预设: ${params.keyword}');
final presets = await _repository.searchPresets(params);
AppLogger.i(_tag, '搜索到 ${presets.length} 个预设');
return presets;
} catch (e) {
AppLogger.e(_tag, '搜索预设失败', e);
rethrow;
}
}
/// 根据ID获取预设详情
/// [presetId] 预设ID
/// 返回预设详情
Future<AIPromptPreset> getPresetById(String presetId) async {
try {
AppLogger.d(_tag, '获取预设详情: $presetId');
final preset = await _repository.getPresetById(presetId);
AppLogger.i(_tag, '获取预设详情成功: ${preset.presetName}');
return preset;
} catch (e) {
AppLogger.e(_tag, '获取预设详情失败: $presetId', e);
rethrow;
}
}
/// 应用预设
/// [presetId] 预设ID
/// 返回预设详情并记录使用
Future<AIPromptPreset> applyPreset(String presetId) async {
try {
AppLogger.i(_tag, '应用预设: $presetId');
final preset = await _repository.getPresetById(presetId);
// 记录预设使用
await _recordUsage(presetId);
AppLogger.i(_tag, '预设应用成功: ${preset.presetName}');
return preset;
} catch (e) {
AppLogger.e(_tag, '应用预设失败: $presetId', e);
rethrow;
}
}
/// 更新预设信息
/// [presetId] 预设ID
/// [request] 更新请求
/// 返回更新后的预设
Future<AIPromptPreset> updatePresetInfo(String presetId, UpdatePresetInfoRequest request) async {
try {
AppLogger.i(_tag, '更新预设信息: $presetId');
final preset = await _repository.updatePresetInfo(presetId, request);
AppLogger.i(_tag, '预设信息更新成功: ${preset.presetName}');
return preset;
} catch (e) {
AppLogger.e(_tag, '更新预设信息失败: $presetId', e);
rethrow;
}
}
/// 更新预设提示词
/// [presetId] 预设ID
/// [request] 更新提示词请求
/// 返回更新后的预设
Future<AIPromptPreset> updatePresetPrompts(String presetId, UpdatePresetPromptsRequest request) async {
try {
AppLogger.i(_tag, '更新预设提示词: $presetId');
final preset = await _repository.updatePresetPrompts(presetId, request);
AppLogger.i(_tag, '预设提示词更新成功');
return preset;
} catch (e) {
AppLogger.e(_tag, '更新预设提示词失败: $presetId', e);
rethrow;
}
}
/// 删除预设
/// [presetId] 预设ID
Future<void> deletePreset(String presetId) async {
try {
AppLogger.i(_tag, '删除预设: $presetId');
await _repository.deletePreset(presetId);
AppLogger.i(_tag, '预设删除成功: $presetId');
} catch (e) {
AppLogger.e(_tag, '删除预设失败: $presetId', e);
rethrow;
}
}
/// 复制预设
/// [presetId] 源预设ID
/// [newName] 新预设名称
/// 返回新创建的预设
Future<AIPromptPreset> duplicatePreset(String presetId, String newName) async {
try {
AppLogger.i(_tag, '复制预设: $presetId -> $newName');
final request = DuplicatePresetRequest(newPresetName: newName);
final preset = await _repository.duplicatePreset(presetId, request);
AppLogger.i(_tag, '预设复制成功: ${preset.presetId}');
return preset;
} catch (e) {
AppLogger.e(_tag, '复制预设失败: $presetId', e);
rethrow;
}
}
/// 切换收藏状态
/// [presetId] 预设ID
/// 返回更新后的预设
Future<AIPromptPreset> toggleFavorite(String presetId) async {
try {
AppLogger.i(_tag, '切换预设收藏状态: $presetId');
final preset = await _repository.toggleFavorite(presetId);
AppLogger.i(_tag, '预设收藏状态切换成功: ${preset.isFavorite ? "已收藏" : "已取消收藏"}');
return preset;
} catch (e) {
AppLogger.e(_tag, '切换预设收藏状态失败: $presetId', e);
rethrow;
}
}
/// 获取预设统计信息
/// 返回统计信息
Future<PresetStatistics> getStatistics() async {
try {
AppLogger.d(_tag, '获取预设统计信息');
final statistics = await _repository.getPresetStatistics();
AppLogger.i(_tag, '获取预设统计信息成功: 总数 ${statistics.totalPresets}');
return statistics;
} catch (e) {
AppLogger.e(_tag, '获取预设统计信息失败', e);
rethrow;
}
}
/// 获取收藏的预设
/// [novelId] 小说ID如果为null则获取全局预设
/// [featureType] 功能类型,如果指定则只返回该类型的预设
/// 返回收藏预设列表
Future<List<AIPromptPreset>> getFavoritePresets({String? novelId, String? featureType}) async {
try {
AppLogger.d(_tag, '获取收藏预设列表: novelId=$novelId, featureType=$featureType');
final presets = await _repository.getFavoritePresets(novelId: novelId, featureType: featureType);
AppLogger.i(_tag, '获取到 ${presets.length} 个收藏预设');
return presets;
} catch (e) {
AppLogger.e(_tag, '获取收藏预设列表失败', e);
rethrow;
}
}
/// 获取最近使用的预设
/// [limit] 返回数量限制默认10个
/// [novelId] 小说ID如果为null则获取全局预设
/// [featureType] 功能类型,如果指定则只返回该类型的预设
/// 返回最近使用预设列表
Future<List<AIPromptPreset>> getRecentlyUsedPresets({int limit = 10, String? novelId, String? featureType}) async {
try {
AppLogger.d(_tag, '获取最近使用预设列表: novelId=$novelId, featureType=$featureType');
final presets = await _repository.getRecentlyUsedPresets(limit: limit, novelId: novelId, featureType: featureType);
AppLogger.i(_tag, '获取到 ${presets.length} 个最近使用预设');
return presets;
} catch (e) {
AppLogger.e(_tag, '获取最近使用预设列表失败', e);
rethrow;
}
}
/// 根据功能类型获取预设
/// [featureType] 功能类型
/// 返回指定功能类型的预设列表
Future<List<AIPromptPreset>> getPresetsByFeatureType(String featureType) async {
try {
AppLogger.d(_tag, '获取指定功能类型预设: $featureType');
final presets = await _repository.getPresetsByFeatureType(featureType);
AppLogger.i(_tag, '获取到 ${presets.length}$featureType 类型预设');
return presets;
} catch (e) {
AppLogger.e(_tag, '获取指定功能类型预设失败: $featureType', e);
rethrow;
}
}
/// 获取推荐预设
/// [featureType] 当前功能类型
/// [limit] 推荐数量默认5个
/// 返回推荐预设列表(基于收藏和使用频率)
Future<List<AIPromptPreset>> getRecommendedPresets(String featureType, {int limit = 5}) async {
try {
AppLogger.d(_tag, '获取推荐预设: $featureType');
// 优先获取同功能类型的收藏预设
final typedFavorites = await getFavoritePresets(featureType: featureType);
final limitedFavorites = typedFavorites.take(limit ~/ 2).toList();
// 补充最近使用的预设
final typedRecent = await getRecentlyUsedPresets(limit: limit, featureType: featureType);
final filteredRecent = typedRecent
.where((preset) => !limitedFavorites.any((fav) => fav.presetId == preset.presetId))
.take(limit - limitedFavorites.length)
.toList();
final recommended = [...limitedFavorites, ...filteredRecent];
AppLogger.i(_tag, '获取到 ${recommended.length} 个推荐预设');
return recommended;
} catch (e) {
AppLogger.e(_tag, '获取推荐预设失败: $featureType', e);
rethrow;
}
}
/// 记录预设使用(内部方法)
Future<void> _recordUsage(String presetId) async {
try {
await _repository.recordPresetUsage(presetId);
} catch (e) {
// 使用记录失败不影响主要流程
AppLogger.w(_tag, '记录预设使用失败: $presetId', e);
}
}
/// 获取功能预设列表(收藏、最近使用、推荐)
/// [featureType] 功能类型
/// [novelId] 小说ID可选
/// 返回分类的预设列表,包含标签信息
Future<PresetListResponse> getFeaturePresetList(String featureType, {String? novelId}) async {
try {
AppLogger.i(_tag, '获取功能预设列表: $featureType, novelId: $novelId');
final response = await _repository.getFeaturePresetList(featureType, novelId: novelId);
AppLogger.i(_tag, '功能预设列表获取成功: 总共${response.totalCount}个预设');
return response;
} catch (e) {
AppLogger.e(_tag, '获取功能预设列表失败: $featureType', e);
rethrow;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
/// API异常类
class ApiException implements Exception {
ApiException(this.statusCode, this.message);
final int statusCode;
final String message;
@override
String toString() => 'ApiException: $statusCode - $message';
}
/// 🚀 新增:积分不足异常
/// 当用户积分余额不足时抛出
class InsufficientCreditsException extends ApiException {
final int? requiredCredits;
InsufficientCreditsException(String message, [this.requiredCredits])
: super(402, message); // HTTP 402 Payment Required
/// 从错误消息中提取需要的积分数量
static int? extractRequiredCredits(String message) {
final regex = RegExp(r'需要 (\d+) 积分');
final match = regex.firstMatch(message);
if (match != null) {
return int.tryParse(match.group(1) ?? '');
}
return null;
}
/// 创建带有自动提取积分数量的实例
factory InsufficientCreditsException.fromMessage(String message) {
final requiredCredits = extractRequiredCredits(message);
return InsufficientCreditsException(message, requiredCredits);
}
}

View File

@@ -0,0 +1,470 @@
import 'dart:async';
import 'dart:convert';
import 'package:ainoval/config/app_config.dart';
import 'package:ainoval/services/api_service/base/api_exception.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:flutter_client_sse/constants/sse_request_type_enum.dart';
import 'package:flutter_client_sse/flutter_client_sse.dart';
import 'package:flutter_client_sse/flutter_client_sse.dart' as flutter_sse;
/// A client specifically designed for handling Server-Sent Events (SSE).
///
/// Encapsulates connection details, authentication, and event parsing logic,
/// using the 'flutter_client_sse' package.
class _RetryState {
int errorCount;
DateTime firstErrorAt;
_RetryState({required this.errorCount, required this.firstErrorAt});
}
class SseClient {
// --------------- Singleton Pattern (Optional but common) ---------------
// Private constructor
SseClient._internal() : _baseUrl = AppConfig.apiBaseUrl;
// Factory constructor to return the instance
factory SseClient() {
return _instance;
}
final String _tag = 'SseClient';
final String _baseUrl;
// 存储活跃连接,以便于管理
final Map<String, StreamSubscription> _activeConnections = {};
final Map<String, _RetryState> _retryStates = {};
// Static instance
static final SseClient _instance = SseClient._internal();
// --------------- End Singleton Pattern ---------------
// Or a simple public constructor if singleton is not desired:
// SseClient() : _baseUrl = AppConfig.apiBaseUrl;
/// Connects to an SSE endpoint and streams parsed events of type [T].
///
/// Handles base URL construction, authentication, and event parsing using flutter_client_sse.
///
/// - [path]: The relative path to the SSE endpoint (e.g., '/novels/import/jobId/status').
/// - [parser]: A function that takes a JSON map and returns an object of type [T].
/// - [eventName]: (Optional) The specific SSE event name to listen for. Defaults to 'message'.
/// - [queryParams]: (Optional) Query parameters to add to the URL.
/// - [method]: The HTTP method (defaults to GET).
/// - [body]: The request body for POST requests.
/// - [connectionId]: Optional. An identifier for this connection. If not provided, a random ID will be generated.
/// - [timeout]: Optional. Timeout duration for the stream. If not provided, no timeout is applied.
Stream<T> streamEvents<T>({
required String path,
required T Function(Map<String, dynamic>) parser,
String? eventName = 'message', // Default event name to filter
Map<String, String>? queryParams,
SSERequestType method = SSERequestType.GET, // Default to GET
Map<String, dynamic>? body, // For POST requests
String? connectionId,
Duration? timeout,
}) {
final controller = StreamController<T>();
final cid = connectionId ?? 'conn_${DateTime.now().millisecondsSinceEpoch}_${_activeConnections.length}';
try {
// 1. Prepare URL
final fullPath = path.startsWith('/') ? path : '/$path';
final uri = Uri.parse('$_baseUrl$fullPath');
final urlWithParams = queryParams != null ? uri.replace(queryParameters: queryParams) : uri;
final urlString = urlWithParams.toString(); // flutter_client_sse uses String URL
AppLogger.i(_tag, '[SSE] Connecting via ${method.name} to endpoint: $urlString');
// 针对设定生成等POST流若发生错误/完成,需全局取消以阻止插件自动重连
final bool shouldGlobalUnsubscribe = method == SSERequestType.POST && fullPath.contains('/setting-generation');
final String retryKey = '${method.name}:$fullPath';
// 冷却窗口1分钟内达到阈值则熔断
const int maxRetries = 3;
const Duration retryWindow = Duration(minutes: 1);
void _resetRetryIfWindowPassed() {
final existing = _retryStates[retryKey];
if (existing != null) {
if (DateTime.now().difference(existing.firstErrorAt) > retryWindow) {
_retryStates.remove(retryKey);
}
}
}
_resetRetryIfWindowPassed();
// 2. Prepare Headers & Authentication
final authToken = AppConfig.authToken;
final headers = {
// Accept and Cache-Control might be added automatically by the package,
// but explicitly adding them is safer.
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
// Add content-type if needed for POST
if (method == SSERequestType.POST && body != null)
'Content-Type': 'application/json',
};
// 🔧 修复在开发环境中允许无token连接生产环境中仍要求token
if (authToken != null) {
headers['Authorization'] = 'Bearer $authToken';
AppLogger.d(_tag, '[SSE] Added Authorization header');
} else if (AppConfig.environment == Environment.production) {
AppLogger.e(_tag, '[SSE] Auth token is null in production environment');
throw ApiException(401, 'Authentication token is missing');
} else {
AppLogger.w(_tag, '[SSE] Warning: No auth token in development environment, proceeding without Authorization header');
}
// 🔧 新增添加用户ID头部与API客户端保持一致
final userId = AppConfig.userId;
if (userId != null) {
headers['X-User-Id'] = userId;
AppLogger.d(_tag, '[SSE] Added X-User-Id header: $userId');
} else {
AppLogger.w(_tag, '[SSE] Warning: X-User-Id header not set (userId is null)');
}
AppLogger.d(_tag, '[SSE] Headers: $headers');
if (body != null) {
AppLogger.d(_tag, '[SSE] Body: $body');
}
// 3. Subscribe using flutter_client_sse
// This method directly returns the stream subscription management is handled internally.
// We listen to it and push data/errors into our controller.
late StreamSubscription sseSubscription; // 预声明变量
sseSubscription = SSEClient.subscribeToSSE(
method: method,
url: urlString,
header: headers,
body: body,
).listen(
(event) {
//TODO调试
//AppLogger.v(_tag, '[SSE] Raw Event: ID=${event.id}, Event=${event.event}, Data=${event.data}');
// 处理心跳消息
if (event.id != null && event.id!.startsWith('heartbeat-')) {
//AppLogger.v(_tag, '[SSE] 收到心跳消息: ${event.id}');
return; // 跳过心跳处理
}
// Determine event name (treat null/empty as 'message')
final currentEventName = (event.event == null || event.event!.isEmpty) ? 'message' : event.event;
// 处理complete事件 - 这是流式生成结束的标志
if (currentEventName == 'complete') {
AppLogger.i(_tag, '[SSE] 收到complete事件表示流式生成已完成');
// 🚀 修复:发送结束信号给下游,而不是直接关闭
try {
final json = jsonDecode(event.data ?? '{}');
if (json is Map<String, dynamic> && json.containsKey('data') && json['data'] == '[DONE]') {
AppLogger.i(_tag, '[SSE] 收到[DONE]标记,发送结束信号给下游');
// 🚀 发送一个带有finishReason的结束信号
final endSignal = {
'id': 'stream_end_${DateTime.now().millisecondsSinceEpoch}',
'content': '',
'finishReason': 'stop',
'isComplete': true,
};
final parsedEndSignal = parser(endSignal);
if (!controller.isClosed) {
controller.add(parsedEndSignal);
// 先主动取消底层连接,避免插件层自动重连
try { sseSubscription.cancel(); } catch (_) {}
_activeConnections.remove(cid);
if (shouldGlobalUnsubscribe) {
try { flutter_sse.SSEClient.unsubscribeFromSSE(); } catch (_) {}
}
// 延迟关闭,确保下游能收到结束信号
Future.delayed(const Duration(milliseconds: 100), () {
if (!controller.isClosed) {
controller.close();
}
});
}
return;
}
} catch (e) {
AppLogger.e(_tag, '[SSE] 解析complete事件数据失败', e);
}
// 🚀 如果解析失败,也要发送结束信号
try {
final endSignal = {
'id': 'stream_end_${DateTime.now().millisecondsSinceEpoch}',
'content': '',
'finishReason': 'stop',
'isComplete': true,
};
final parsedEndSignal = parser(endSignal);
if (!controller.isClosed) {
controller.add(parsedEndSignal);
try { sseSubscription.cancel(); } catch (_) {}
_activeConnections.remove(cid);
if (shouldGlobalUnsubscribe) {
try { flutter_sse.SSEClient.unsubscribeFromSSE(); } catch (_) {}
}
Future.delayed(const Duration(milliseconds: 100), () {
if (!controller.isClosed) {
controller.close();
}
});
}
} catch (parseError) {
AppLogger.e(_tag, '[SSE] 发送结束信号失败', parseError);
if (!controller.isClosed) {
controller.close();
}
}
return; // 无论如何都跳过complete事件的后续处理
}
// Filter by expected event name
if (eventName != null && currentEventName != eventName) {
//AppLogger.v(_tag, '[SSE] Skipping event name: $currentEventName (Expected: $eventName)');
return; // Skip this event
}
final data = event.data;
if (data == null || data.isEmpty || data == '[DONE]') {
//AppLogger.v(_tag, '[SSE] Skipping empty or [DONE] data.');
return; // Skip this event
}
// 检查特殊结束标记 "}"
if (data == '}' || data.trim() == '}') {
AppLogger.i(_tag, '[SSE] 检测到特殊结束标记 "}",关闭流');
try { sseSubscription.cancel(); } catch (_) {}
_activeConnections.remove(cid);
if (shouldGlobalUnsubscribe) {
try { flutter_sse.SSEClient.unsubscribeFromSSE(); } catch (_) {}
}
if (!controller.isClosed) {
controller.close();
}
return;
}
// Parse data
try {
final json = jsonDecode(data);
if (json is Map<String, dynamic>) {
// 检查JSON对象中是否包含特殊结束标记
if (json['content'] == '}' ||
(json['finishReason'] != null && json['finishReason'].toString().isNotEmpty)) {
AppLogger.i(_tag, '[SSE] 检测到JSON中的结束标记: content="${json['content']}", finishReason=${json['finishReason']}');
try { sseSubscription.cancel(); } catch (_) {}
_activeConnections.remove(cid);
if (shouldGlobalUnsubscribe) {
try { flutter_sse.SSEClient.unsubscribeFromSSE(); } catch (_) {}
}
if (!controller.isClosed) {
controller.close();
}
return;
}
final parsedData = parser(json);
//AppLogger.v(_tag, '[SSE] Parsed data for event \'$currentEventName\': $parsedData');
if (!controller.isClosed) {
controller.add(parsedData); // Add parsed data to our stream
}
} else {
AppLogger.w(_tag, '[SSE] Event data is not a JSON object: $data');
}
} catch (e, stack) {
AppLogger.e(_tag, '[SSE] Failed to parse JSON data: $data', e, stack);
if (!controller.isClosed) {
// 🚀 修复:保持原始异常类型,特别是 InsufficientCreditsException
if (e is InsufficientCreditsException) {
AppLogger.w(_tag, '[SSE] 保持积分不足异常类型不变');
controller.addError(e, stack);
} else {
// Report parsing errors through the stream
controller.addError(ApiException(-1, 'Failed to parse SSE data: $e'), stack);
}
}
}
},
onError: (error, stackTrace) {
AppLogger.e(_tag, '[SSE] Stream error received', error, stackTrace);
// 🔧 新增:检查是否为不可恢复的网络错误 & 对 POST 端点设置最多重试3次
final bool isPostMethod = method == SSERequestType.POST;
bool shouldStopRetry;
if (isPostMethod && shouldGlobalUnsubscribe) {
_resetRetryIfWindowPassed();
final current = _retryStates[retryKey] ?? _RetryState(errorCount: 0, firstErrorAt: DateTime.now());
current.errorCount += 1;
_retryStates[retryKey] = current;
AppLogger.w(_tag, '[SSE] ${retryKey} 错误次数: ${current.errorCount}');
shouldStopRetry = current.errorCount >= maxRetries || _shouldStopRetryOnError(error);
} else {
shouldStopRetry = _shouldStopRetryOnError(error);
}
if (shouldStopRetry) {
AppLogger.w(_tag, '[SSE] 检测到不可恢复的网络错误,停止重试: $error');
// 取消订阅以停止自动重试
sseSubscription.cancel();
if (shouldGlobalUnsubscribe) {
try { flutter_sse.SSEClient.unsubscribeFromSSE(); } catch (_) {}
}
}
if (!controller.isClosed) {
// Convert to ApiException for consistency
controller.addError(ApiException(-1, 'SSE stream error: $error'), stackTrace);
// 仅在停止重试时才关闭下游,允许在窗口内继续尝试
if (shouldStopRetry) {
controller.close();
}
}
// 移除连接
_activeConnections.remove(cid);
},
onDone: () {
AppLogger.i(_tag, '[SSE] Stream finished (onDone received).');
if (!controller.isClosed) {
controller.close(); // Close controller when the source stream is done
}
// 移除连接
_activeConnections.remove(cid);
},
);
// 保存此连接以便于后续管理
_activeConnections[cid] = sseSubscription;
AppLogger.i(_tag, '[SSE] Connection $cid has been registered. Active connections: ${_activeConnections.length}');
// Handle cancellation of the downstream listener
controller.onCancel = () {
AppLogger.i(_tag, '[SSE] Downstream listener cancelled. Cancelling SSE subscription for connection $cid.');
sseSubscription.cancel();
// 移除连接
_activeConnections.remove(cid);
if (shouldGlobalUnsubscribe) {
try { flutter_sse.SSEClient.unsubscribeFromSSE(); } catch (_) {}
}
// Ensure controller is closed if not already
if (!controller.isClosed) {
controller.close();
}
};
} catch (e, stack) {
// Catch synchronous errors during setup (e.g., URI parsing, initial auth check)
AppLogger.e(_tag, '[SSE] Setup Error', e, stack);
controller.addError(
e is ApiException ? e : ApiException(-1, 'SSE setup failed: $e'), stack);
controller.close();
}
// 应用超时(如果指定)
if (timeout != null) {
return controller.stream.timeout(
timeout,
onTimeout: (sink) {
AppLogger.w(_tag, '[SSE] Stream timeout after ${timeout.inSeconds} seconds for connection $cid');
// 主动取消SSE连接
cancelConnection(cid);
// 发送超时错误
sink.addError(
ApiException(-1, 'SSE stream timeout after ${timeout.inSeconds} seconds'),
StackTrace.current,
);
sink.close();
},
);
} else {
return controller.stream;
}
}
/// 取消特定连接
///
/// - [connectionId]: The ID of the connection to cancel
/// - 返回: True if connection was found and cancelled, false otherwise
Future<bool> cancelConnection(String connectionId) async {
final connection = _activeConnections[connectionId];
if (connection != null) {
AppLogger.i(_tag, '[SSE] Manually cancelling connection $connectionId');
await connection.cancel();
_activeConnections.remove(connectionId);
return true;
}
AppLogger.w(_tag, '[SSE] Connection $connectionId not found or already closed');
return false;
}
/// 取消所有活跃连接
Future<void> cancelAllConnections() async {
AppLogger.i(_tag, '[SSE] Cancelling all active connections (count: ${_activeConnections.length})');
// 创建一个连接ID列表以避免在迭代过程中修改集合
final connectionIds = _activeConnections.keys.toList();
for (final id in connectionIds) {
try {
final connection = _activeConnections[id];
if (connection != null) {
await connection.cancel();
_activeConnections.remove(id);
AppLogger.d(_tag, '[SSE] Cancelled connection $id');
}
} catch (e) {
AppLogger.e(_tag, '[SSE] Error cancelling connection $id', e);
}
}
AppLogger.i(_tag, '[SSE] All connections cancelled. Remaining: ${_activeConnections.length}');
}
/// 获取活跃连接数
int get activeConnectionCount => _activeConnections.length;
/// 检查是否应该因为特定错误而停止重试
///
/// 规则:
/// - POST 方法:一律不重试(避免 /start 在后端重启后被重复触发)
/// - ClientException: Failed to fetch - 服务器不可达,停止重试
/// - ClientException: network error - 也停止重试(后端重启期间常见,避免刷屏与重复日志)
/// - 连接拒绝/重置/关闭、502/503/404停止重试
/// - 其他错误类型继续重试
bool _shouldStopRetryOnError(dynamic error) {
final errorString = error.toString().toLowerCase();
// 检查特定的错误模式
if (errorString.contains('clientexception') && errorString.contains('failed to fetch')) {
AppLogger.i(_tag, '[SSE] 检测到 "Failed to fetch" 错误,判定为服务器不可达');
return true;
}
if (errorString.contains('clientexception') && errorString.contains('network error')) {
AppLogger.i(_tag, '[SSE] 检测到通用network error停止重试以避免后端重启期间重复请求');
return true;
}
// 检查连接被拒绝的错误
if (errorString.contains('connection refused') ||
errorString.contains('connection reset') ||
errorString.contains('connection closed')) {
AppLogger.i(_tag, '[SSE] 检测到连接被拒绝/重置/关闭,判定为服务器不可达');
return true;
}
// 检查 HTTP 404、503 等明确的服务错误
if (errorString.contains('404') || errorString.contains('503') || errorString.contains('502')) {
AppLogger.i(_tag, '[SSE] 检测到 HTTP 服务错误,判定为服务器不可达');
return true;
}
// 其他错误继续重试(如临时网络波动)
AppLogger.d(_tag, '[SSE] 错误类型允许重试: $error');
return false;
}
}

View File

@@ -0,0 +1,144 @@
/// LLM可观测性Repository接口
/// 用于管理后台LLM调用日志的查询和分析
import '../../../../models/admin/llm_observability_models.dart';
abstract class LLMObservabilityRepository {
// ==================== 日志查询 ====================
/// 获取所有LLM调用日志
Future<PagedResponse<LLMTrace>> getAllTraces({
int page = 0,
int size = 20,
String sortBy = 'timestamp',
String sortDir = 'desc',
});
/// 根据用户ID获取LLM调用日志
Future<PagedResponse<LLMTrace>> getTracesByUserId(
String userId, {
int page = 0,
int size = 20,
});
/// 根据提供商获取LLM调用日志
Future<PagedResponse<LLMTrace>> getTracesByProvider(
String provider, {
int page = 0,
int size = 20,
});
/// 根据模型名称获取LLM调用日志
Future<PagedResponse<LLMTrace>> getTracesByModel(
String modelName, {
int page = 0,
int size = 20,
});
/// 根据时间范围获取LLM调用日志
Future<PagedResponse<LLMTrace>> getTracesByTimeRange(
DateTime startTime,
DateTime endTime, {
int page = 0,
int size = 20,
});
/// 搜索LLM调用日志
Future<PagedResponse<LLMTrace>> searchTraces(
LLMTraceSearchCriteria criteria, {
String? businessType,
String? correlationId,
String? traceId,
String? type,
String? tag,
});
/// 游标分页获取LLM调用日志
Future<CursorPageResponse<LLMTrace>> getTracesByCursor({
String? cursor,
int limit = 50,
String? userId,
String? provider,
String? model,
String? sessionId,
bool? hasError,
String? businessType,
String? correlationId,
String? traceId,
String? type,
String? tag,
DateTime? startTime,
DateTime? endTime,
});
/// 获取单个LLM调用日志详情
Future<LLMTrace?> getTraceById(String traceId);
// ==================== 统计分析 ====================
/// 获取LLM调用统计概览
Future<Map<String, dynamic>> getOverviewStatistics({
DateTime? startTime,
DateTime? endTime,
});
/// 获取提供商统计信息
Future<List<ProviderStatistics>> getProviderStatistics({
DateTime? startTime,
DateTime? endTime,
});
/// 获取模型统计信息
Future<List<ModelStatistics>> getModelStatistics({
DateTime? startTime,
DateTime? endTime,
});
/// 获取用户统计信息
Future<List<UserStatistics>> getUserStatistics({
DateTime? startTime,
DateTime? endTime,
});
/// 获取错误统计信息
Future<List<ErrorStatistics>> getErrorStatistics({
DateTime? startTime,
DateTime? endTime,
});
/// 获取性能统计信息
Future<PerformanceStatistics> getPerformanceStatistics({
DateTime? startTime,
DateTime? endTime,
});
/// 获取趋势数据(按时间分桶)
Future<Map<String, dynamic>> getTrends({
String? metric,
String? groupBy,
String? businessType,
String? model,
String? provider,
String interval = 'hour',
DateTime? startTime,
DateTime? endTime,
});
// ==================== 导出功能 ====================
/// 导出LLM调用日志
Future<List<LLMTrace>> exportTraces({
Map<String, dynamic>? filterCriteria,
});
// ==================== 系统管理 ====================
/// 清理旧日志
Future<Map<String, dynamic>> cleanupOldTraces(DateTime beforeTime);
/// 获取系统健康状态
Future<SystemHealthStatus> getSystemHealth();
/// 获取数据库状态
Future<Map<String, dynamic>> getDatabaseStatus();
}

View File

@@ -0,0 +1,122 @@
import 'package:ainoval/models/preset_models.dart';
/// AI预设仓储接口
abstract class AIPresetRepository {
/// 创建预设
/// [request] 创建预设请求
/// 返回创建的预设
Future<AIPromptPreset> createPreset(CreatePresetRequest request);
/// 获取用户的所有预设
/// [userId] 用户ID如果为null则获取当前用户的预设
/// 返回预设列表
Future<List<AIPromptPreset>> getUserPresets({String? userId, String featureType = 'AI_CHAT'});
/// 搜索预设
/// [params] 搜索参数
/// 返回匹配的预设列表
Future<List<AIPromptPreset>> searchPresets(PresetSearchParams params);
/// 根据ID获取预设
/// [presetId] 预设ID
/// 返回预设详情
Future<AIPromptPreset> getPresetById(String presetId);
/// 覆盖更新预设(完整对象)
/// [preset] 完整的预设对象
/// 返回更新后的预设
Future<AIPromptPreset> overwritePreset(AIPromptPreset preset);
/// 更新预设信息
/// [presetId] 预设ID
/// [request] 更新请求
/// 返回更新后的预设
Future<AIPromptPreset> updatePresetInfo(String presetId, UpdatePresetInfoRequest request);
/// 更新预设提示词
/// [presetId] 预设ID
/// [request] 更新提示词请求
/// 返回更新后的预设
Future<AIPromptPreset> updatePresetPrompts(String presetId, UpdatePresetPromptsRequest request);
/// 删除预设
/// [presetId] 预设ID
Future<void> deletePreset(String presetId);
/// 复制预设
/// [presetId] 源预设ID
/// [request] 复制请求
/// 返回新创建的预设
Future<AIPromptPreset> duplicatePreset(String presetId, DuplicatePresetRequest request);
/// 切换收藏状态
/// [presetId] 预设ID
/// 返回更新后的预设
Future<AIPromptPreset> toggleFavorite(String presetId);
/// 记录预设使用
/// [presetId] 预设ID
Future<void> recordPresetUsage(String presetId);
/// 获取预设统计信息
/// 返回统计信息
Future<PresetStatistics> getPresetStatistics();
/// 获取收藏的预设
/// [novelId] 小说ID如果为null则获取全局预设
/// [featureType] 功能类型,如果指定则只返回该类型的预设
/// 返回收藏预设列表
Future<List<AIPromptPreset>> getFavoritePresets({String? novelId, String? featureType});
/// 获取最近使用的预设
/// [limit] 返回数量限制默认10个
/// [novelId] 小说ID如果为null则获取全局预设
/// [featureType] 功能类型,如果指定则只返回该类型的预设
/// 返回最近使用预设列表
Future<List<AIPromptPreset>> getRecentlyUsedPresets({int limit = 10, String? novelId, String? featureType});
/// 根据功能类型获取预设
/// [featureType] 功能类型
/// 返回指定功能类型的预设列表
Future<List<AIPromptPreset>> getPresetsByFeatureType(String featureType);
// ============ 新增:系统预设管理接口 ============
/// 获取系统预设列表
/// [featureType] 功能类型,如果指定则只返回该类型的系统预设
/// 返回系统预设列表
Future<List<AIPromptPreset>> getSystemPresets({String? featureType});
/// 获取快捷访问预设
/// [featureType] 功能类型,如果指定则只返回该类型的快捷访问预设
/// [novelId] 小说ID如果为null则获取全局快捷访问预设
/// 返回快捷访问预设列表
Future<List<AIPromptPreset>> getQuickAccessPresets({String? featureType, String? novelId});
/// 切换预设的快捷访问状态
/// [presetId] 预设ID
/// 返回更新后的预设
Future<AIPromptPreset> toggleQuickAccess(String presetId);
/// 批量获取预设
/// [presetIds] 预设ID列表
/// 返回预设列表
Future<List<AIPromptPreset>> getPresetsByIds(List<String> presetIds);
/// 获取用户预设按功能类型分组
/// [userId] 用户ID如果为null则获取当前用户的预设
/// 返回功能类型到预设列表的映射
Future<Map<String, List<AIPromptPreset>>> getUserPresetsByFeatureType({String? userId});
/// 获取用户在指定功能类型下的预设管理信息
/// [featureType] 功能类型
/// [novelId] 小说ID可选
/// 返回该功能类型下的完整预设管理信息
Future<Map<String, dynamic>> getFeatureTypePresetManagement(String featureType, {String? novelId});
/// 获取功能预设列表(收藏、最近使用、推荐)
/// [featureType] 功能类型
/// [novelId] 小说ID可选
/// 返回分类的预设列表
Future<PresetListResponse> getFeaturePresetList(String featureType, {String? novelId});
}

View File

@@ -0,0 +1,48 @@
import 'package:ainoval/models/analytics_data.dart';
abstract class AnalyticsRepository {
/// 获取用户分析概览数据
Future<AnalyticsData> getAnalyticsOverview();
/// 获取Token使用趋势数据
/// [viewMode] 查看模式daily, monthly, cumulative, range
/// [startDate] 开始日期range模式使用
/// [endDate] 结束日期range模式使用
Future<List<TokenUsageData>> getTokenUsageTrend({
required AnalyticsViewMode viewMode,
DateTime? startDate,
DateTime? endDate,
});
/// 获取功能使用统计数据
/// [viewMode] 查看模式daily, monthly, range
/// [startDate] 开始日期range模式使用
/// [endDate] 结束日期range模式使用
Future<List<FunctionUsageData>> getFunctionUsageStats({
required AnalyticsViewMode viewMode,
DateTime? startDate,
DateTime? endDate,
});
/// 获取大模型使用占比数据(按模型名聚合)
/// [viewMode] 查看模式daily, monthly, range
/// [startDate] 开始日期range模式使用
/// [endDate] 结束日期range模式使用
Future<List<ModelUsageData>> getModelUsageStats({
required AnalyticsViewMode viewMode,
DateTime? startDate,
DateTime? endDate,
});
/// 获取Token使用记录列表
/// [limit] 返回记录数量限制
/// [offset] 偏移量
Future<List<TokenUsageRecord>> getTokenUsageRecords({
int limit = 20,
int offset = 0,
});
/// 获取今日Token使用汇总
Future<Map<String, dynamic>> getTodayTokenSummary();
}

View File

@@ -0,0 +1,75 @@
import 'package:ainoval/models/chat_models.dart';
import 'package:ainoval/models/ai_request_models.dart';
/// 聊天仓库接口
///
/// 定义与聊天相关的所有API操作
abstract class ChatRepository {
/// 获取用户的所有会话
Stream<ChatSession> fetchUserSessions(String userId, {String? novelId});
/// 创建新的聊天会话
Future<ChatSession> createSession({
required String userId,
required String novelId,
String? modelName,
Map<String, dynamic>? metadata,
});
/// 获取特定会话详情包含AI配置
Future<ChatSession> getSession(String userId, String sessionId, {String? novelId});
/// 获取会话的AI配置
Future<UniversalAIRequest?> getSessionAIConfig(String userId, String sessionId, {String? novelId});
/// 更新会话信息
Future<ChatSession> updateSession({
required String userId,
required String sessionId,
required Map<String, dynamic> updates,
String? novelId,
});
/// 删除会话
Future<void> deleteSession(String userId, String sessionId, {String? novelId});
/// 发送消息并获取响应
/// 返回完整的 AI ChatMessage 对象
Future<ChatMessage> sendMessage({
required String userId,
required String sessionId,
required String content,
UniversalAIRequest? config,
Map<String, dynamic>? metadata,
String? configId,
String? novelId,
});
/// 流式发送消息并获取响应
/// 流式返回 AI ChatMessage 对象片段
Stream<ChatMessage> streamMessage({
required String userId,
required String sessionId,
required String content,
UniversalAIRequest? config,
Map<String, dynamic>? metadata,
String? configId,
String? novelId,
});
/// 获取会话消息历史
Stream<ChatMessage> getMessageHistory(String userId, String sessionId,
{int limit = 100, String? novelId});
/// 获取特定消息
Future<ChatMessage> getMessage(String userId, String messageId);
/// 删除消息
Future<void> deleteMessage(String userId, String messageId);
/// 获取会话消息数量
Future<int> countSessionMessages(String sessionId);
/// 获取用户会话数量
Future<int> countUserSessions(String userId, {String? novelId});
}

View File

@@ -0,0 +1,7 @@
import '../../../models/user_credit.dart';
/// 用户积分仓库接口
abstract interface class CreditRepository {
/// 获取当前用户的积分余额
Future<UserCredit> getUserCredits();
}

View File

@@ -0,0 +1,233 @@
import 'dart:async';
import 'package:ainoval/models/editor_content.dart';
import 'package:ainoval/models/editor_settings.dart';
import 'package:ainoval/models/novel_structure.dart';
import 'package:ainoval/models/chapters_for_preload_dto.dart';
import 'package:ainoval/services/local_storage_service.dart';
/// 编辑器仓库接口
///
/// 定义与编辑器相关的所有API操作
abstract class EditorRepository {
/// 获取本地存储服务
LocalStorageService getLocalStorageService();
/// 获取小说
Future<Novel?> getNovel(String novelId);
/// 获取小说详情(分页加载场景)
/// 基于上次编辑章节为中心,获取前后指定数量的章节及其场景内容
Future<Novel?> getNovelWithPaginatedScenes(String novelId, String lastEditedChapterId, {int chaptersLimit = 5});
/// 获取小说详情(一次性加载所有场景)
/// 一次性获取小说的所有章节及其场景内容
Future<Novel?> getNovelWithAllScenes(String novelId);
/// 加载更多章节场景
/// 根据方向(向上或向下)加载更多章节的场景内容
Future<Map<String, List<Scene>>> loadMoreScenes(String novelId, String? actId, String fromChapterId, String direction, {int chaptersLimit = 5});
/// 保存小说数据
Future<bool> saveNovel(Novel novel);
/// 获取场景内容
Future<Scene?> getSceneContent(
String novelId, String actId, String chapterId, String sceneId);
/// 保存场景内容
Future<Scene> saveSceneContent(
String novelId,
String actId,
String chapterId,
String sceneId,
String content,
String wordCount,
Summary summary,
{bool localOnly = false}
);
/// 保存摘要
Future<Summary> saveSummary(
String novelId,
String actId,
String chapterId,
String sceneId,
String content,
);
/// 获取编辑器内容
Future<EditorContent> getEditorContent(
String novelId, String chapterId, String sceneId);
/// 保存编辑器内容
Future<void> saveEditorContent(EditorContent content);
/// 获取编辑器设置
Future<Map<String, dynamic>> getEditorSettings();
/// 保存编辑器设置
Future<void> saveEditorSettings(Map<String, dynamic> settings);
/// 获取修订历史
Future<List<Revision>> getRevisionHistory(String novelId, String chapterId);
/// 创建修订版本
Future<Revision> createRevision(
String novelId, String chapterId, Revision revision);
/// 应用修订版本
Future<void> applyRevision(
String novelId, String chapterId, String revisionId);
/// 更新小说元数据
Future<void> updateNovelMetadata({
required String novelId,
required String title,
String? author,
String? series,
});
/// 获取封面上传凭证
Future<Map<String, dynamic>> getCoverUploadCredential({
required String novelId,
required String fileName,
});
/// 更新小说封面
Future<void> updateNovelCover({
required String novelId,
required String coverUrl,
});
/// 归档小说
Future<void> archiveNovel({
required String novelId,
});
/// 删除小说
Future<void> deleteNovel({
required String novelId,
});
/// 为指定场景生成摘要
Future<String> summarizeScene(String sceneId, {String? additionalInstructions});
/// 根据摘要生成场景内容(流式)
Stream<String> generateSceneFromSummaryStream(
String novelId,
String summary,
{String? chapterId, String? additionalInstructions}
);
/// 根据摘要生成场景内容(非流式)
Future<String> generateSceneFromSummary(
String novelId,
String summary,
{String? chapterId, String? additionalInstructions}
);
/// 获取小说详情包含场景摘要适用于Plan视图
Future<Novel?> getNovelWithSceneSummaries(String novelId, {bool readOnly = false});
/// 提交自动续写任务
///
/// [novelId] 小说ID
/// [numberOfChapters] 续写章节数
/// [aiConfigIdSummary] 摘要模型配置ID
/// [aiConfigIdContent] 内容模型配置ID
/// [startContextMode] 上下文模式,可选值: AUTO, LAST_N_CHAPTERS, CUSTOM
/// [contextChapterCount] 上下文章节数仅当startContextMode为LAST_N_CHAPTERS时有效
/// [customContext] 自定义上下文仅当startContextMode为CUSTOM时有效
/// [writingStyle] 写作风格提示,可选
///
/// 返回提交的任务ID
Future<String> submitContinueWritingTask({
required String novelId,
required int numberOfChapters,
required String aiConfigIdSummary,
required String aiConfigIdContent,
required String startContextMode,
int? contextChapterCount,
String? customContext,
String? writingStyle,
});
/// 删除场景
Future<bool> deleteScene(
String novelId,
String actId,
String chapterId,
String sceneId,
);
/// 添加场景
Future<Scene?> addScene(
String novelId,
String actId,
String chapterId,
Scene scene,
);
/// 删除章节
Future<Novel?> deleteChapter(
String novelId,
String actId,
String chapterId,
);
/// 将后端返回的带场景摘要的小说数据转换为前端模型
/// 更新小说最后编辑的章节ID细粒度更新
Future<bool> updateLastEditedChapterId(String novelId, String chapterId);
/// 批量更新小说字数统计(细粒度更新)
Future<bool> updateNovelWordCounts(String novelId, Map<String, int> sceneWordCounts);
/// 智能同步小说(根据变更类型选择最优同步策略)
Future<bool> smartSyncNovel(Novel novel, {Set<String>? changedComponents});
/// 仅更新小说结构(不包含场景内容)
Future<bool> updateNovelStructure(Novel novel);
/// 批量保存场景内容(优化网络请求数量)
Future<bool> batchSaveSceneContents(
String novelId,
List<Map<String, dynamic>> sceneUpdates
);
/// 细粒度添加卷 - 只提供必要信息
Future<Act> addActFine(String novelId, String title, {String? description});
/// 细粒度添加章节 - 只提供必要信息
Future<Chapter> addChapterFine(String novelId, String actId, String title, {String? description});
/// 细粒度添加场景 - 只提供必要信息
Future<Scene> addSceneFine(String novelId, String chapterId, String title, {String? summary, int? position});
/// 细粒度批量添加场景 - 一次添加多个场景到同一章节
Future<List<Scene>> addScenesBatchFine(String novelId, String chapterId, List<Map<String, dynamic>> scenes);
/// 细粒度删除卷 - 只提供ID
Future<bool> deleteActFine(String novelId, String actId);
/// 细粒度删除章节 - 只提供ID
Future<bool> deleteChapterFine(String novelId, String actId, String chapterId);
/// 细粒度删除场景 - 只提供ID
Future<bool> deleteSceneFine(String sceneId);
/// 获取指定章节后面的章节列表(用于预加载)
///
/// [novelId] 小说ID
/// [currentChapterId] 当前章节ID
/// [chaptersLimit] 要获取的章节数量限制
/// [includeCurrentChapter] 是否包含当前章节
///
/// 返回包含章节列表和场景数据的ChaptersForPreloadDto
Future<ChaptersForPreloadDto?> fetchChaptersForPreload(
String novelId,
String currentChapterId, {
int chaptersLimit = 3,
bool includeCurrentChapter = false,
});
}

View File

@@ -0,0 +1,53 @@
import 'package:ainoval/models/admin/billing_models.dart';
import 'package:ainoval/services/api_service/base/api_client.dart';
class BillingRepositoryImpl {
final ApiClient apiClient;
BillingRepositoryImpl({required this.apiClient});
Future<List<CreditTransactionModel>> listTransactions({int page = 0, int size = 20, String? status, String? userId}) async {
final params = <String, dynamic>{
'page': page,
'size': size,
if (status != null && status.isNotEmpty) 'status': status,
if (userId != null && userId.isNotEmpty) 'userId': userId,
};
final data = await apiClient.getWithParams('/admin/billing/transactions', queryParameters: params);
if (data is List) {
return data.map((e) => CreditTransactionModel.fromJson(Map<String, dynamic>.from(e))).toList();
}
return [];
}
Future<int> countTransactions({String? status, String? userId}) async {
final params = <String, dynamic>{
if (status != null && status.isNotEmpty) 'status': status,
if (userId != null && userId.isNotEmpty) 'userId': userId,
};
final data = await apiClient.getWithParams('/admin/billing/transactions/count', queryParameters: params);
if (data is int) return data;
if (data is String) return int.tryParse(data) ?? 0;
if (data is Map<String, dynamic> && data['count'] is int) return data['count'] as int;
return 0;
}
Future<CreditTransactionModel?> getTransaction(String traceId) async {
final data = await apiClient.get('/admin/billing/transactions/$traceId');
if (data is Map<String, dynamic>) {
return CreditTransactionModel.fromJson(data);
}
return null;
}
Future<CreditTransactionModel?> reverse(String traceId, {required String operatorUserId, required String reason}) async {
final payload = {'operatorUserId': operatorUserId, 'reason': reason};
final data = await apiClient.post('/admin/billing/transactions/$traceId/reverse', data: payload);
if (data is Map<String, dynamic>) {
return CreditTransactionModel.fromJson(data);
}
return null;
}
}

View File

@@ -0,0 +1,744 @@
import '../../../../../models/admin/llm_observability_models.dart';
import '../../admin/llm_observability_repository.dart';
import '../../../base/api_client.dart';
import '../../../base/api_exception.dart';
import '../../../../../utils/logger.dart';
/// LLM可观测性仓库实现
class LLMObservabilityRepositoryImpl implements LLMObservabilityRepository {
final ApiClient _apiClient;
final String _tag = 'LLMObservabilityRepository';
LLMObservabilityRepositoryImpl({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient();
// ==================== 日志查询 ====================
/// 获取所有LLM调用日志
Future<PagedResponse<LLMTrace>> getAllTraces({
int page = 0,
int size = 20,
String sortBy = 'timestamp',
String sortDir = 'desc',
}) async {
try {
AppLogger.d(_tag, '获取LLM调用日志: page=$page, size=$size, sortBy=$sortBy, sortDir=$sortDir');
final response = await _apiClient.getWithParams('/admin/llm-observability/traces', queryParameters: {
'page': page,
'size': size,
'sortBy': sortBy,
'sortDir': sortDir,
});
if (response is Map<String, dynamic> && response.containsKey('data')) {
return PagedResponse.fromJson(response['data'], (json) => LLMTrace.fromJson(json as Map<String, dynamic>));
} else if (response is Map<String, dynamic>) {
return PagedResponse.fromJson(response, (json) => LLMTrace.fromJson(json as Map<String, dynamic>));
} else {
throw ApiException(-1, 'LLM日志响应格式错误');
}
} catch (e) {
AppLogger.e(_tag, '获取LLM调用日志失败', e);
rethrow;
}
}
/// 游标分页获取LLM调用日志
@override
Future<CursorPageResponse<LLMTrace>> getTracesByCursor({
String? cursor,
int limit = 50,
String? userId,
String? provider,
String? model,
String? sessionId,
bool? hasError,
String? businessType,
String? correlationId,
String? traceId,
String? type,
String? tag,
DateTime? startTime,
DateTime? endTime,
}) async {
try {
final params = <String, dynamic>{
'limit': limit,
};
if (cursor != null && cursor.isNotEmpty) params['cursor'] = cursor;
if (userId != null) params['userId'] = userId;
if (provider != null) params['provider'] = provider;
if (model != null) params['model'] = model;
if (sessionId != null) params['sessionId'] = sessionId;
if (hasError != null) params['hasError'] = hasError;
if (businessType != null) params['businessType'] = businessType;
if (correlationId != null) params['correlationId'] = correlationId;
if (traceId != null) params['traceId'] = traceId;
if (type != null) params['type'] = type;
if (tag != null) params['tag'] = tag;
if (startTime != null) params['startTime'] = startTime.toIso8601String();
if (endTime != null) params['endTime'] = endTime.toIso8601String();
final response = await _apiClient.getWithParams('/admin/llm-observability/traces/cursor', queryParameters: params);
if (response is Map<String, dynamic> && response.containsKey('data')) {
return CursorPageResponse.fromJson(response['data'], (json) => LLMTrace.fromJson(json as Map<String, dynamic>));
} else if (response is Map<String, dynamic>) {
return CursorPageResponse.fromJson(response, (json) => LLMTrace.fromJson(json as Map<String, dynamic>));
} else {
throw ApiException(-1, '游标分页响应格式错误');
}
} catch (e) {
AppLogger.e(_tag, '游标分页获取LLM调用日志失败', e);
rethrow;
}
}
/// 根据用户ID获取LLM调用日志
Future<PagedResponse<LLMTrace>> getTracesByUserId(
String userId, {
int page = 0,
int size = 20,
}) async {
try {
AppLogger.d(_tag, '获取用户LLM调用日志: userId=$userId, page=$page, size=$size');
final response = await _apiClient.getWithParams('/admin/llm-observability/traces/user/$userId', queryParameters: {
'page': page,
'size': size,
});
if (response is Map<String, dynamic> && response.containsKey('data')) {
return PagedResponse.fromJson(response['data'], (json) => LLMTrace.fromJson(json as Map<String, dynamic>));
} else if (response is Map<String, dynamic>) {
return PagedResponse.fromJson(response, (json) => LLMTrace.fromJson(json as Map<String, dynamic>));
} else {
throw ApiException(-1, '用户LLM日志响应格式错误');
}
} catch (e) {
AppLogger.e(_tag, '获取用户LLM调用日志失败', e);
rethrow;
}
}
/// 根据提供商获取LLM调用日志
Future<PagedResponse<LLMTrace>> getTracesByProvider(
String provider, {
int page = 0,
int size = 20,
}) async {
try {
AppLogger.d(_tag, '获取提供商LLM调用日志: provider=$provider, page=$page, size=$size');
final response = await _apiClient.getWithParams('/admin/llm-observability/traces/provider/$provider', queryParameters: {
'page': page,
'size': size,
});
if (response is Map<String, dynamic> && response.containsKey('data')) {
return PagedResponse.fromJson(response['data'], (json) => LLMTrace.fromJson(json as Map<String, dynamic>));
} else if (response is Map<String, dynamic>) {
return PagedResponse.fromJson(response, (json) => LLMTrace.fromJson(json as Map<String, dynamic>));
} else {
throw ApiException(-1, '提供商LLM日志响应格式错误');
}
} catch (e) {
AppLogger.e(_tag, '获取提供商LLM调用日志失败', e);
rethrow;
}
}
/// 根据模型名称获取LLM调用日志
Future<PagedResponse<LLMTrace>> getTracesByModel(
String modelName, {
int page = 0,
int size = 20,
}) async {
try {
AppLogger.d(_tag, '获取模型LLM调用日志: modelName=$modelName, page=$page, size=$size');
final response = await _apiClient.getWithParams('/admin/llm-observability/traces/model/$modelName', queryParameters: {
'page': page,
'size': size,
});
if (response is Map<String, dynamic> && response.containsKey('data')) {
return PagedResponse.fromJson(response['data'], (json) => LLMTrace.fromJson(json as Map<String, dynamic>));
} else if (response is Map<String, dynamic>) {
return PagedResponse.fromJson(response, (json) => LLMTrace.fromJson(json as Map<String, dynamic>));
} else {
throw ApiException(-1, '模型LLM日志响应格式错误');
}
} catch (e) {
AppLogger.e(_tag, '获取模型LLM调用日志失败', e);
rethrow;
}
}
/// 根据时间范围获取LLM调用日志
Future<PagedResponse<LLMTrace>> getTracesByTimeRange(
DateTime startTime,
DateTime endTime, {
int page = 0,
int size = 20,
}) async {
try {
AppLogger.d(_tag, '按时间范围获取LLM调用日志: startTime=$startTime, endTime=$endTime, page=$page, size=$size');
final response = await _apiClient.getWithParams('/admin/llm-observability/traces/timerange', queryParameters: {
'startTime': startTime.toIso8601String(),
'endTime': endTime.toIso8601String(),
'page': page,
'size': size,
});
if (response is Map<String, dynamic> && response.containsKey('data')) {
return PagedResponse.fromJson(response['data'], (json) => LLMTrace.fromJson(json as Map<String, dynamic>));
} else if (response is Map<String, dynamic>) {
return PagedResponse.fromJson(response, (json) => LLMTrace.fromJson(json as Map<String, dynamic>));
} else {
throw ApiException(-1, '时间范围LLM日志响应格式错误');
}
} catch (e) {
AppLogger.e(_tag, '按时间范围获取LLM调用日志失败', e);
rethrow;
}
}
/// 搜索LLM调用日志
Future<PagedResponse<LLMTrace>> searchTraces(
LLMTraceSearchCriteria criteria, {
String? businessType,
String? correlationId,
String? traceId,
String? type,
String? tag,
}) async {
try {
AppLogger.d(_tag, '搜索LLM调用日志: criteria=$criteria');
final queryParams = <String, dynamic>{
'page': criteria.page,
'size': criteria.size,
};
if (criteria.userId != null) queryParams['userId'] = criteria.userId;
if (criteria.provider != null) queryParams['provider'] = criteria.provider;
if (criteria.model != null) queryParams['model'] = criteria.model;
if (criteria.sessionId != null) queryParams['sessionId'] = criteria.sessionId;
if (criteria.hasError != null) queryParams['hasError'] = criteria.hasError;
if (criteria.startTime != null) queryParams['startTime'] = criteria.startTime!.toIso8601String();
if (criteria.endTime != null) queryParams['endTime'] = criteria.endTime!.toIso8601String();
if (businessType != null && businessType.isNotEmpty) queryParams['businessType'] = businessType;
if (correlationId != null && correlationId.isNotEmpty) queryParams['correlationId'] = correlationId;
if (traceId != null && traceId.isNotEmpty) queryParams['traceId'] = traceId;
if (type != null && type.isNotEmpty) queryParams['type'] = type;
if (tag != null && tag.isNotEmpty) queryParams['tag'] = tag;
final response = await _apiClient.getWithParams('/admin/llm-observability/traces/search', queryParameters: queryParams);
if (response is Map<String, dynamic> && response.containsKey('data')) {
return PagedResponse.fromJson(response['data'], (json) => LLMTrace.fromJson(json as Map<String, dynamic>));
} else if (response is Map<String, dynamic>) {
return PagedResponse.fromJson(response, (json) => LLMTrace.fromJson(json as Map<String, dynamic>));
} else {
throw ApiException(-1, '搜索LLM日志响应格式错误');
}
} catch (e) {
AppLogger.e(_tag, '搜索LLM调用日志失败', e);
rethrow;
}
}
/// 获取单个LLM调用日志详情
Future<LLMTrace?> getTraceById(String traceId) async {
try {
AppLogger.d(_tag, '获取LLM调用日志详情: traceId=$traceId');
final response = await _apiClient.getWithParams('/admin/llm-observability/traces/$traceId');
if (response is Map<String, dynamic> && response.containsKey('data')) {
return LLMTrace.fromJson(response['data']);
} else if (response is Map<String, dynamic>) {
return LLMTrace.fromJson(response);
} else {
return null;
}
} catch (e) {
AppLogger.e(_tag, '获取LLM调用日志详情失败', e);
if (e is ApiException && e.statusCode == 404) {
return null;
}
rethrow;
}
}
// ==================== 统计分析 ====================
/// 获取LLM调用统计概览
Future<Map<String, dynamic>> getOverviewStatistics({
DateTime? startTime,
DateTime? endTime,
}) async {
try {
AppLogger.d(_tag, '获取LLM调用统计概览: startTime=$startTime, endTime=$endTime');
final queryParams = <String, dynamic>{};
if (startTime != null) queryParams['startTime'] = startTime.toIso8601String();
if (endTime != null) queryParams['endTime'] = endTime.toIso8601String();
final response = await _apiClient.getWithParams('/admin/llm-observability/statistics/overview', queryParameters: queryParams);
if (response is Map<String, dynamic> && response.containsKey('data')) {
return response['data'];
} else if (response is Map<String, dynamic>) {
return response;
} else {
throw ApiException(-1, '统计概览响应格式错误');
}
} catch (e) {
AppLogger.e(_tag, '获取LLM调用统计概览失败', e);
rethrow;
}
}
/// 获取提供商统计信息
@override
Future<List<ProviderStatistics>> getProviderStatistics({
DateTime? startTime,
DateTime? endTime,
}) async {
try {
AppLogger.d(_tag, '获取提供商统计信息: startTime=$startTime, endTime=$endTime');
final queryParams = <String, dynamic>{};
if (startTime != null) queryParams['startTime'] = startTime.toIso8601String();
if (endTime != null) queryParams['endTime'] = endTime.toIso8601String();
final response = await _apiClient.getWithParams('/admin/llm-observability/statistics/providers', queryParameters: queryParams);
Map<String, dynamic> dataMap;
if (response is Map<String, dynamic> && response.containsKey('data')) {
final d = response['data'];
if (d is Map<String, dynamic>) {
dataMap = d;
} else {
AppLogger.w(_tag, '提供商统计 data 非 Map实际为: ${d.runtimeType}');
return [];
}
} else if (response is Map<String, dynamic>) {
dataMap = response;
} else {
AppLogger.w(_tag, '提供商统计响应不是 Map实际为: ${response.runtimeType}');
return [];
}
final Map<String, num> callsByProvider = Map<String, num>.from(dataMap['callsByProvider'] ?? {});
final Map<String, num> errorsByProvider = Map<String, num>.from(dataMap['errorsByProvider'] ?? {});
final Map<String, num> avgDurationByProvider = Map<String, num>.from(dataMap['avgDurationByProvider'] ?? {});
final List<ProviderStatistics> result = [];
for (final entry in callsByProvider.entries) {
final String provider = entry.key;
final int totalCalls = entry.value.toInt();
final int failed = (errorsByProvider[provider] ?? 0).toInt();
final int successful = totalCalls - failed;
final double successRate = totalCalls == 0 ? 0.0 : successful / totalCalls * 100.0;
final double avgLatency = (avgDurationByProvider[provider] ?? 0).toDouble();
final stats = LLMStatistics(
totalCalls: totalCalls,
successfulCalls: successful,
failedCalls: failed,
successRate: successRate,
averageLatency: avgLatency,
totalTokens: 0,
);
result.add(ProviderStatistics(provider: provider, statistics: stats, models: const []));
}
// 排序:按调用次数降序
result.sort((a, b) => b.statistics.totalCalls.compareTo(a.statistics.totalCalls));
return result;
} catch (e) {
AppLogger.e(_tag, '获取提供商统计信息失败', e);
// 出错时返回空列表而不是抛出异常,避免崩溃
return [];
}
}
/// 获取模型统计信息
@override
Future<List<ModelStatistics>> getModelStatistics({
DateTime? startTime,
DateTime? endTime,
}) async {
try {
AppLogger.d(_tag, '获取模型统计信息: startTime=$startTime, endTime=$endTime');
final queryParams = <String, dynamic>{};
if (startTime != null) queryParams['startTime'] = startTime.toIso8601String();
if (endTime != null) queryParams['endTime'] = endTime.toIso8601String();
final response = await _apiClient.getWithParams('/admin/llm-observability/statistics/models', queryParameters: queryParams);
Map<String, dynamic> dataMap;
if (response is Map<String, dynamic> && response.containsKey('data')) {
final d = response['data'];
if (d is Map<String, dynamic>) {
dataMap = d;
} else {
AppLogger.w(_tag, '模型统计 data 非 Map实际为: ${d.runtimeType}');
return [];
}
} else if (response is Map<String, dynamic>) {
dataMap = response;
} else {
AppLogger.w(_tag, '模型统计响应不是 Map实际为: ${response.runtimeType}');
return [];
}
final Map<String, num> callsByModel = Map<String, num>.from(dataMap['callsByModel'] ?? {});
final Map<String, num> errorsByModel = Map<String, num>.from(dataMap['errorsByModel'] ?? {});
final Map<String, num> tokensByModel = Map<String, num>.from(dataMap['tokensByModel'] ?? {});
final List<ModelStatistics> result = [];
for (final entry in callsByModel.entries) {
final String modelName = entry.key;
final int totalCalls = entry.value.toInt();
final int failed = (errorsByModel[modelName] ?? 0).toInt();
final int successful = totalCalls - failed;
final double successRate = totalCalls == 0 ? 0.0 : successful / totalCalls * 100.0;
final int totalTokens = (tokensByModel[modelName] ?? 0).toInt();
final stats = LLMStatistics(
totalCalls: totalCalls,
successfulCalls: successful,
failedCalls: failed,
successRate: successRate,
averageLatency: 0.0,
totalTokens: totalTokens,
);
// 后端未提供 provider 归属,这里尝试从模型名前缀简单推断,否则留空
final provider = _inferProviderFromModel(modelName);
result.add(ModelStatistics(modelName: modelName, provider: provider, statistics: stats));
}
// 排序:按调用次数降序
result.sort((a, b) => b.statistics.totalCalls.compareTo(a.statistics.totalCalls));
return result;
} catch (e) {
AppLogger.e(_tag, '获取模型统计信息失败', e);
// 出错时返回空列表而不是抛出异常,避免崩溃
return [];
}
}
String _inferProviderFromModel(String modelName) {
final lower = modelName.toLowerCase();
if (lower.contains('gpt') || lower.contains('o1') || lower.contains('openai')) return 'OpenAI';
if (lower.contains('claude') || lower.contains('anthropic')) return 'Anthropic';
if (lower.contains('gemini') || lower.contains('google') || lower.contains('palm')) return 'Google';
if (lower.contains('glm') || lower.contains('zhipu')) return 'ZhipuAI';
if (lower.contains('qwen') || lower.contains('dashscope') || lower.contains('ali')) return 'AliCloud';
return '';
}
/// 获取用户统计信息
@override
Future<List<UserStatistics>> getUserStatistics({
DateTime? startTime,
DateTime? endTime,
}) async {
try {
AppLogger.d(_tag, '获取用户统计信息: startTime=$startTime, endTime=$endTime');
final queryParams = <String, dynamic>{};
if (startTime != null) queryParams['startTime'] = startTime.toIso8601String();
if (endTime != null) queryParams['endTime'] = endTime.toIso8601String();
final response = await _apiClient.getWithParams('/admin/llm-observability/statistics/users', queryParameters: queryParams);
Map<String, dynamic> dataMap;
if (response is Map<String, dynamic> && response.containsKey('data')) {
final d = response['data'];
if (d is Map<String, dynamic>) {
dataMap = d;
} else {
AppLogger.w(_tag, '用户统计 data 非 Map实际为: ${d.runtimeType}');
return [];
}
} else if (response is Map<String, dynamic>) {
dataMap = response;
} else {
AppLogger.w(_tag, '用户统计响应不是 Map实际为: ${response.runtimeType}');
return [];
}
final Map<String, num> callsByUser = Map<String, num>.from(dataMap['callsByUser'] ?? {});
final Map<String, num> tokensByUser = Map<String, num>.from(dataMap['tokensByUser'] ?? {});
final Map<String, num> errorsByUser = Map<String, num>.from(dataMap['errorsByUser'] ?? {});
final List<UserStatistics> result = [];
for (final entry in callsByUser.entries) {
final String userId = entry.key;
final int totalCalls = entry.value.toInt();
final int failed = (errorsByUser[userId] ?? 0).toInt();
final int successful = totalCalls - failed;
final double successRate = totalCalls == 0 ? 0.0 : successful / totalCalls * 100.0;
final int totalTokens = (tokensByUser[userId] ?? 0).toInt();
final stats = LLMStatistics(
totalCalls: totalCalls,
successfulCalls: successful,
failedCalls: failed,
successRate: successRate,
averageLatency: 0.0,
totalTokens: totalTokens,
);
result.add(UserStatistics(
userId: userId,
username: null,
statistics: stats,
topModels: const [],
topProviders: const [],
));
}
// 排序:按调用次数降序
result.sort((a, b) => b.statistics.totalCalls.compareTo(a.statistics.totalCalls));
return result;
} catch (e) {
AppLogger.e(_tag, '获取用户统计信息失败', e);
// 出错时返回空列表而不是抛出异常,避免崩溃
return [];
}
}
/// 获取错误统计信息
@override
Future<List<ErrorStatistics>> getErrorStatistics({
DateTime? startTime,
DateTime? endTime,
}) async {
try {
AppLogger.d(_tag, '获取错误统计信息: startTime=$startTime, endTime=$endTime');
final queryParams = <String, dynamic>{};
if (startTime != null) queryParams['startTime'] = startTime.toIso8601String();
if (endTime != null) queryParams['endTime'] = endTime.toIso8601String();
final response = await _apiClient.getWithParams('/admin/llm-observability/statistics/errors', queryParameters: queryParams);
List<dynamic> dataList;
if (response is Map<String, dynamic> && response.containsKey('data')) {
final data = response['data'];
if (data is List) {
dataList = data;
} else {
AppLogger.w(_tag, '错误统计数据不是List格式: ${data.runtimeType}');
return [];
}
} else if (response is List) {
dataList = response;
} else {
AppLogger.w(_tag, '错误统计响应格式错误,返回空列表: ${response.runtimeType}');
return [];
}
return dataList
.map((item) => ErrorStatistics.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
AppLogger.e(_tag, '获取错误统计信息失败', e);
return [];
}
}
/// 获取性能统计信息
@override
Future<PerformanceStatistics> getPerformanceStatistics({
DateTime? startTime,
DateTime? endTime,
}) async {
try {
AppLogger.d(_tag, '获取性能统计信息: startTime=$startTime, endTime=$endTime');
final queryParams = <String, dynamic>{};
if (startTime != null) queryParams['startTime'] = startTime.toIso8601String();
if (endTime != null) queryParams['endTime'] = endTime.toIso8601String();
final response = await _apiClient.getWithParams('/admin/llm-observability/statistics/performance', queryParameters: queryParams);
Map<String, dynamic> data;
if (response is Map<String, dynamic> && response.containsKey('data')) {
data = response['data'];
} else if (response is Map<String, dynamic>) {
data = response;
} else {
throw ApiException(-1, '性能统计响应格式错误');
}
return PerformanceStatistics.fromJson(data);
} catch (e) {
AppLogger.e(_tag, '获取性能统计信息失败', e);
// 返回空的性能统计对象
return const PerformanceStatistics(
averageLatency: 0.0,
medianLatency: 0.0,
p95Latency: 0.0,
p99Latency: 0.0,
averageThroughput: 0.0,
latencyTrends: [],
throughputTrends: [],
);
}
}
/// 获取趋势数据
@override
Future<Map<String, dynamic>> getTrends({
String? metric,
String? groupBy,
String? businessType,
String? model,
String? provider,
String interval = 'hour',
DateTime? startTime,
DateTime? endTime,
}) async {
try {
final queryParams = <String, dynamic>{
'interval': interval,
};
if (metric != null) queryParams['metric'] = metric;
if (groupBy != null) queryParams['groupBy'] = groupBy;
if (businessType != null) queryParams['businessType'] = businessType;
if (model != null) queryParams['model'] = model;
if (provider != null) queryParams['provider'] = provider;
if (startTime != null) queryParams['startTime'] = startTime.toIso8601String();
if (endTime != null) queryParams['endTime'] = endTime.toIso8601String();
final response = await _apiClient.getWithParams('/admin/llm-observability/statistics/trends', queryParameters: queryParams);
if (response is Map<String, dynamic> && response.containsKey('data')) {
return response['data'];
} else if (response is Map<String, dynamic>) {
return response;
} else {
throw ApiException(-1, '趋势数据响应格式错误');
}
} catch (e) {
AppLogger.e(_tag, '获取趋势数据失败', e);
rethrow;
}
}
// ==================== 系统管理 ====================
/// 导出LLM调用日志
@override
Future<List<LLMTrace>> exportTraces({
Map<String, dynamic>? filterCriteria,
}) async {
try {
AppLogger.d(_tag, '导出LLM调用日志: filterCriteria=$filterCriteria');
dynamic response;
try {
// 优先使用带过滤的高级导出端点
response = await _apiClient.post('/admin/llm-observability/export2', data: filterCriteria ?? {});
} catch (e) {
AppLogger.w(_tag, 'export2 不可用,回退到 export', e);
response = await _apiClient.post('/admin/llm-observability/export', data: filterCriteria ?? {});
}
if (response is Map<String, dynamic> && response.containsKey('data')) {
final List<dynamic> traces = response['data'];
return traces.map((trace) => LLMTrace.fromJson(trace)).toList();
} else if (response is List<dynamic>) {
return response.map((trace) => LLMTrace.fromJson(trace)).toList();
} else {
throw ApiException(-1, '导出日志响应格式错误');
}
} catch (e) {
AppLogger.e(_tag, '导出LLM调用日志失败', e);
rethrow;
}
}
/// 清理旧日志
@override
Future<Map<String, dynamic>> cleanupOldTraces(DateTime beforeTime) async {
try {
AppLogger.d(_tag, '清理旧日志: beforeTime=$beforeTime');
final response = await _apiClient.deleteWithParams('/admin/llm-observability/cleanup', queryParameters: {
'beforeTime': beforeTime.toIso8601String(),
});
if (response is Map<String, dynamic> && response.containsKey('data')) {
return response['data'];
} else if (response is Map<String, dynamic>) {
return response;
} else {
return {'deletedCount': 0};
}
} catch (e) {
AppLogger.e(_tag, '清理旧日志失败', e);
rethrow;
}
}
/// 获取系统健康状态
@override
Future<SystemHealthStatus> getSystemHealth() async {
try {
AppLogger.d(_tag, '获取系统健康状态');
final response = await _apiClient.getWithParams('/admin/llm-observability/health');
Map<String, dynamic> data;
if (response is Map<String, dynamic> && response.containsKey('data')) {
data = response['data'];
} else if (response is Map<String, dynamic>) {
data = response;
} else {
throw ApiException(-1, '系统健康状态响应格式错误');
}
return SystemHealthStatus.fromJson(data);
} catch (e) {
AppLogger.e(_tag, '获取系统健康状态失败', e);
// 返回默认的系统健康状态
return const SystemHealthStatus(
components: {},
status: HealthStatus.unknown,
);
}
}
/// 获取数据库状态
@override
Future<Map<String, dynamic>> getDatabaseStatus() async {
try {
AppLogger.d(_tag, '获取数据库状态');
final response = await _apiClient.getWithParams('/admin/llm-observability/database/status');
if (response is Map<String, dynamic> && response.containsKey('data')) {
return response['data'];
} else if (response is Map<String, dynamic>) {
return response;
} else {
throw ApiException(-1, '数据库状态响应格式错误');
}
} catch (e) {
AppLogger.e(_tag, '获取数据库状态失败', e);
rethrow;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
import 'admin_repository_impl.dart';
import '../../base/api_client.dart';
import '../../base/api_exception.dart';
import '../../../../models/prompt_models.dart';
import '../../../../utils/logger.dart';
extension PromptTemplateExtraApis on AdminRepositoryImpl {
static const String _tag = 'AdminRepository(Extra)';
/// 获取待审核模板列表
Future<List<PromptTemplate>> getPendingTemplates() async {
try {
AppLogger.d(_tag, '🔍 获取待审核模板列表');
final api = ApiClient();
final response = await api.get('/admin/prompt-templates/pending');
final data = (response is Map<String, dynamic>) ? (response['data'] ?? response) : response;
if (data is List) {
AppLogger.d(_tag, '✅ 获取待审核模板列表成功: count=${data.length}');
return data.map((json) => PromptTemplate.fromJson(json as Map<String, dynamic>)).toList();
}
throw ApiException(-1, '待审核模板列表响应格式错误');
} catch (e) {
AppLogger.e(_tag, '❌ 获取待审核模板列表失败', e);
rethrow;
}
}
/// 获取官方认证模板列表
Future<List<PromptTemplate>> getVerifiedTemplates() async {
try {
AppLogger.d(_tag, '🔍 获取官方认证模板列表');
final api = ApiClient();
final response = await api.get('/admin/prompt-templates/verified');
final data = (response is Map<String, dynamic>) ? (response['data'] ?? response) : response;
if (data is List) {
AppLogger.d(_tag, '✅ 获取官方认证模板列表成功: count=${data.length}');
return data.map((json) => PromptTemplate.fromJson(json as Map<String, dynamic>)).toList();
}
throw ApiException(-1, '官方认证模板列表响应格式错误');
} catch (e) {
AppLogger.e(_tag, '❌ 获取官方认证模板列表失败', e);
rethrow;
}
}
/// 获取所有用户模板列表(包括私有和公共)
Future<List<PromptTemplate>> getAllUserTemplates({
int page = 0,
int size = 20,
String? search,
}) async {
try {
AppLogger.d(_tag, '🔍 获取所有用户模板列表: page=$page, size=$size, search=$search');
String path = '/admin/prompt-templates/all-user?page=$page&size=$size';
if (search != null && search.isNotEmpty) {
path += '&search=${Uri.encodeComponent(search)}';
}
final api = ApiClient();
final response = await api.get(path);
final data = (response is Map<String, dynamic>) ? (response['data'] ?? response) : response;
if (data is List) {
AppLogger.d(_tag, '✅ 获取所有用户模板列表成功: count=${data.length}');
return data.map((json) => PromptTemplate.fromJson(json as Map<String, dynamic>)).toList();
} else if (data is Map<String, dynamic> && data.containsKey('content')) {
// 处理分页响应
final content = data['content'] as List;
AppLogger.d(_tag, '✅ 获取所有用户模板列表成功(分页): count=${content.length}');
return content.map((json) => PromptTemplate.fromJson(json as Map<String, dynamic>)).toList();
}
throw ApiException(-1, '所有用户模板列表响应格式错误');
} catch (e) {
AppLogger.e(_tag, '❌ 获取所有用户模板列表失败', e);
rethrow;
}
}
/// 更新模板
Future<PromptTemplate> updateTemplate(String templateId, PromptTemplate template) async {
try {
AppLogger.d(_tag, '🔄 更新模板: templateId=$templateId, name=${template.name}');
final api = ApiClient();
final response = await api.put('/admin/prompt-templates/$templateId', data: template.toJson());
final data = (response is Map<String, dynamic>) ? (response['data'] ?? response) : response;
if (data is Map<String, dynamic>) {
AppLogger.d(_tag, '✅ 更新模板成功: ${template.name}');
return PromptTemplate.fromJson(data);
}
throw ApiException(-1, '更新模板响应格式错误');
} catch (e) {
AppLogger.e(_tag, '❌ 更新模板失败', e);
rethrow;
}
}
}

View File

@@ -0,0 +1,567 @@
import 'package:ainoval/models/preset_models.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/api_service/repositories/ai_preset_repository.dart';
import 'package:ainoval/utils/logger.dart';
/// AI预设仓储实现类
class AIPresetRepositoryImpl implements AIPresetRepository {
final ApiClient apiClient;
final String _tag = 'AIPresetRepository';
AIPresetRepositoryImpl({required this.apiClient});
// 🚀 新增:统一解包 ApiResponse.data
dynamic _extractData(dynamic response) {
if (response is Map<String, dynamic> && response.containsKey('data')) {
return response['data'];
}
return response;
}
@override
Future<AIPromptPreset> createPreset(CreatePresetRequest request) async {
try {
AppLogger.d(_tag, '🔍 创建AI预设: ${request.presetName}');
// 🚀 调用新的AIPromptPresetController接口
final response = await apiClient.post(
'/ai/presets',
data: request.toJson(),
);
// 🚀 处理ApiResponse包装格式
final data = _extractData(response);
final preset = AIPromptPreset.fromJson(data);
AppLogger.i(_tag, '📘 预设创建成功: ${preset.presetId}');
return preset;
} catch (e) {
AppLogger.e(_tag, '❌ 创建预设失败', e);
rethrow;
}
}
@override
Future<List<AIPromptPreset>> getUserPresets({String? userId, String featureType = 'AI_CHAT'}) async {
try {
AppLogger.d(_tag, '获取用户预设列表: userId=$userId, featureType=$featureType');
String path = '/ai/presets';
final List<String> query = [];
// 必填参数 featureType
query.add('featureType=${Uri.encodeComponent(featureType)}');
// 可选 userId
if (userId != null) {
query.add('userId=$userId');
}
if (query.isNotEmpty) {
path = '$path?${query.join('&')}';
}
final response = await apiClient.get(path);
final data = _extractData(response);
if (data is! List) {
throw ApiException(-1, '响应格式不正确期望List类型');
}
final presets = data.map((json) => AIPromptPreset.fromJson(json)).toList();
AppLogger.i(_tag, '获取到 ${presets.length} 个用户预设');
return presets;
} catch (e) {
AppLogger.e(_tag, '获取用户预设列表失败', e);
rethrow;
}
}
@override
Future<List<AIPromptPreset>> searchPresets(PresetSearchParams params) async {
try {
AppLogger.d(_tag, '搜索预设: ${params.keyword}');
final queryParams = params.toQueryParams();
String path = '/ai/presets/search';
if (queryParams.isNotEmpty) {
final queryString = queryParams.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value.toString())}')
.join('&');
path = '$path?$queryString';
}
final response = await apiClient.get(path);
final data = _extractData(response);
if (data is! List) {
throw ApiException(-1, '响应格式不正确期望List类型');
}
final presets = data.map((json) => AIPromptPreset.fromJson(json)).toList();
AppLogger.i(_tag, '搜索到 ${presets.length} 个预设');
return presets;
} catch (e) {
AppLogger.e(_tag, '搜索预设失败', e);
rethrow;
}
}
@override
Future<AIPromptPreset> getPresetById(String presetId) async {
try {
AppLogger.d(_tag, '获取预设详情: $presetId');
final response = await apiClient.get('/ai/presets/detail/$presetId');
final data = _extractData(response);
final preset = AIPromptPreset.fromJson(data);
AppLogger.i(_tag, '获取预设详情成功: ${preset.presetName}');
return preset;
} catch (e) {
AppLogger.e(_tag, '获取预设详情失败: $presetId', e);
rethrow;
}
}
@override
Future<AIPromptPreset> overwritePreset(AIPromptPreset preset) async {
try {
AppLogger.d(_tag, '覆盖更新预设: ${preset.presetId}');
final response = await apiClient.put(
'/ai/presets/${preset.presetId}',
data: preset.toJson(),
);
final data = _extractData(response);
final updatedPreset = AIPromptPreset.fromJson(data);
AppLogger.i(_tag, '预设覆盖更新成功: ${updatedPreset.presetName}');
return updatedPreset;
} catch (e) {
AppLogger.e(_tag, '覆盖更新预设失败: ${preset.presetId}', e);
rethrow;
}
}
@override
Future<AIPromptPreset> updatePresetInfo(String presetId, UpdatePresetInfoRequest request) async {
try {
AppLogger.d(_tag, '更新预设信息: $presetId');
final response = await apiClient.put(
'/ai/presets/$presetId/info',
data: request.toJson(),
);
final data = _extractData(response);
final preset = AIPromptPreset.fromJson(data);
AppLogger.i(_tag, '预设信息更新成功: ${preset.presetName}');
return preset;
} catch (e) {
AppLogger.e(_tag, '更新预设信息失败: $presetId', e);
rethrow;
}
}
@override
Future<AIPromptPreset> updatePresetPrompts(String presetId, UpdatePresetPromptsRequest request) async {
try {
AppLogger.d(_tag, '更新预设提示词: $presetId');
final response = await apiClient.put(
'/ai/presets/$presetId/prompts',
data: request.toJson(),
);
final data = _extractData(response);
final preset = AIPromptPreset.fromJson(data);
AppLogger.i(_tag, '预设提示词更新成功');
return preset;
} catch (e) {
AppLogger.e(_tag, '更新预设提示词失败: $presetId', e);
rethrow;
}
}
@override
Future<void> deletePreset(String presetId) async {
try {
AppLogger.d(_tag, '删除预设: $presetId');
await apiClient.delete('/ai/presets/$presetId');
AppLogger.i(_tag, '预设删除成功: $presetId');
} catch (e) {
AppLogger.e(_tag, '删除预设失败: $presetId', e);
rethrow;
}
}
@override
Future<AIPromptPreset> duplicatePreset(String presetId, DuplicatePresetRequest request) async {
try {
AppLogger.d(_tag, '复制预设: $presetId -> ${request.newPresetName}');
final response = await apiClient.post(
'/ai/presets/$presetId/duplicate',
data: request.toJson(),
);
final data = _extractData(response);
final preset = AIPromptPreset.fromJson(data);
AppLogger.i(_tag, '预设复制成功: ${preset.presetId}');
return preset;
} catch (e) {
AppLogger.e(_tag, '复制预设失败: $presetId', e);
rethrow;
}
}
@override
Future<AIPromptPreset> toggleFavorite(String presetId) async {
try {
AppLogger.d(_tag, '切换预设收藏状态: $presetId');
final response = await apiClient.post('/ai/presets/$presetId/favorite');
final data = _extractData(response);
final preset = AIPromptPreset.fromJson(data);
AppLogger.i(_tag, '预设收藏状态切换成功: ${preset.isFavorite ? "已收藏" : "已取消收藏"}');
return preset;
} catch (e) {
AppLogger.e(_tag, '切换预设收藏状态失败: $presetId', e);
rethrow;
}
}
@override
Future<void> recordPresetUsage(String presetId) async {
try {
AppLogger.d(_tag, '记录预设使用: $presetId');
await apiClient.post('/ai/presets/$presetId/usage');
AppLogger.v(_tag, '预设使用记录成功: $presetId');
} catch (e) {
AppLogger.w(_tag, '记录预设使用失败: $presetId', e);
// 使用记录失败不抛出异常,不影响主要流程
}
}
@override
Future<PresetStatistics> getPresetStatistics() async {
try {
AppLogger.d(_tag, '获取预设统计信息');
final response = await apiClient.get('/ai/presets/statistics');
final data = _extractData(response);
final statistics = PresetStatistics.fromJson(data);
AppLogger.i(_tag, '获取预设统计信息成功: 总数 ${statistics.totalPresets}');
return statistics;
} catch (e) {
AppLogger.e(_tag, '获取预设统计信息失败', e);
rethrow;
}
}
@override
Future<List<AIPromptPreset>> getFavoritePresets({String? novelId, String? featureType}) async {
try {
AppLogger.d(_tag, '获取收藏预设列表: novelId=$novelId, featureType=$featureType');
String path = '/ai/presets/favorites';
List<String> queryParams = [];
if (novelId != null) {
queryParams.add('novelId=$novelId');
}
if (featureType != null) {
queryParams.add('featureType=$featureType');
}
if (queryParams.isNotEmpty) {
path = '$path?${queryParams.join('&')}';
}
final response = await apiClient.get(path);
final data = _extractData(response);
if (data is! List) {
throw ApiException(-1, '响应格式不正确期望List类型');
}
final presets = data.map((json) => AIPromptPreset.fromJson(json)).toList();
AppLogger.i(_tag, '获取到 ${presets.length} 个收藏预设');
return presets;
} catch (e) {
AppLogger.e(_tag, '获取收藏预设列表失败', e);
rethrow;
}
}
@override
Future<List<AIPromptPreset>> getRecentlyUsedPresets({int limit = 10, String? novelId, String? featureType}) async {
try {
AppLogger.d(_tag, '获取最近使用预设列表: 限制 $limit, novelId=$novelId, featureType=$featureType');
List<String> queryParams = ['limit=$limit'];
if (novelId != null) {
queryParams.add('novelId=$novelId');
}
if (featureType != null) {
queryParams.add('featureType=$featureType');
}
String path = '/ai/presets/recent?${queryParams.join('&')}';
final response = await apiClient.get(path);
final data = _extractData(response);
if (data is! List) {
throw ApiException(-1, '响应格式不正确期望List类型');
}
final presets = data.map((json) => AIPromptPreset.fromJson(json)).toList();
AppLogger.i(_tag, '获取到 ${presets.length} 个最近使用预设');
return presets;
} catch (e) {
AppLogger.e(_tag, '获取最近使用预设列表失败', e);
rethrow;
}
}
@override
Future<List<AIPromptPreset>> getPresetsByFeatureType(String featureType) async {
try {
AppLogger.d(_tag, '获取指定功能类型预设: $featureType');
final response = await apiClient.get(
'/ai/presets/feature/$featureType',
);
final data = _extractData(response);
if (data is! List) {
throw ApiException(-1, '响应格式不正确期望List类型');
}
final presets = data.map((json) => AIPromptPreset.fromJson(json)).toList();
AppLogger.i(_tag, '获取到 ${presets.length}$featureType 类型预设');
return presets;
} catch (e) {
AppLogger.e(_tag, '获取指定功能类型预设失败: $featureType', e);
rethrow;
}
}
// ============ 新增:系统预设管理接口实现 ============
@override
Future<List<AIPromptPreset>> getSystemPresets({String? featureType}) async {
try {
AppLogger.d(_tag, '获取系统预设列表: featureType=$featureType');
String path = '/ai/presets/system';
if (featureType != null) {
path = '$path?featureType=$featureType';
}
final response = await apiClient.get(path);
final data = _extractData(response);
if (data is! List) {
throw ApiException(-1, '响应格式不正确期望List类型');
}
final presets = data.map((json) => AIPromptPreset.fromJson(json)).toList();
AppLogger.i(_tag, '获取到 ${presets.length} 个系统预设');
return presets;
} catch (e) {
AppLogger.e(_tag, '获取系统预设列表失败', e);
rethrow;
}
}
@override
Future<List<AIPromptPreset>> getQuickAccessPresets({String? featureType, String? novelId}) async {
try {
AppLogger.d(_tag, '获取快捷访问预设: featureType=$featureType, novelId=$novelId');
String path = '/ai/presets/quick-access';
List<String> queryParams = [];
if (featureType != null) {
queryParams.add('featureType=$featureType');
}
if (novelId != null) {
queryParams.add('novelId=$novelId');
}
if (queryParams.isNotEmpty) {
path = '$path?${queryParams.join('&')}';
}
final response = await apiClient.get(path);
final data = _extractData(response);
if (data is! List) {
throw ApiException(-1, '响应格式不正确期望List类型');
}
final presets = data.map((json) => AIPromptPreset.fromJson(json)).toList();
AppLogger.i(_tag, '获取到 ${presets.length} 个快捷访问预设');
return presets;
} catch (e) {
AppLogger.e(_tag, '获取快捷访问预设失败', e);
rethrow;
}
}
@override
Future<AIPromptPreset> toggleQuickAccess(String presetId) async {
try {
AppLogger.d(_tag, '切换预设快捷访问状态: $presetId');
final response = await apiClient.post('/ai/presets/$presetId/quick-access');
final data = _extractData(response);
final preset = AIPromptPreset.fromJson(data);
AppLogger.i(_tag, '预设快捷访问状态切换成功: ${preset.showInQuickAccess ? "已加入快捷访问" : "已移出快捷访问"}');
return preset;
} catch (e) {
AppLogger.e(_tag, '切换预设快捷访问状态失败: $presetId', e);
rethrow;
}
}
@override
Future<List<AIPromptPreset>> getPresetsByIds(List<String> presetIds) async {
try {
AppLogger.d(_tag, '批量获取预设: ${presetIds.length}');
final response = await apiClient.post(
'/ai/presets/batch',
data: {'presetIds': presetIds},
);
final data = _extractData(response);
if (data is! List) {
throw ApiException(-1, '响应格式不正确期望List类型');
}
final presets = data.map((json) => AIPromptPreset.fromJson(json)).toList();
AppLogger.i(_tag, '批量获取到 ${presets.length} 个预设');
return presets;
} catch (e) {
AppLogger.e(_tag, '批量获取预设失败', e);
rethrow;
}
}
@override
Future<Map<String, List<AIPromptPreset>>> getUserPresetsByFeatureType({String? userId}) async {
try {
AppLogger.d(_tag, '获取用户预设按功能类型分组: userId=$userId');
String path = '/ai/presets/grouped';
if (userId != null) {
path = '$path?userId=$userId';
}
final response = await apiClient.get(path);
final data = _extractData(response);
if (data is! Map<String, dynamic>) {
throw ApiException(-1, '响应格式不正确期望Map类型');
}
final Map<String, List<AIPromptPreset>> groupedPresets = {};
data.forEach((featureType, presetsJson) {
try {
if (presetsJson is List) {
final presets = presetsJson.map((json) => AIPromptPreset.fromJson(json)).toList();
groupedPresets[featureType] = presets;
}
} catch (e) {
AppLogger.w(_tag, '解析功能类型预设失败: $featureType', e);
}
});
AppLogger.i(_tag, '获取到 ${groupedPresets.length} 个功能类型的分组预设');
return groupedPresets;
} catch (e) {
AppLogger.e(_tag, '获取用户预设按功能类型分组失败', e);
rethrow;
}
}
@override
Future<Map<String, dynamic>> getFeatureTypePresetManagement(String featureType, {String? novelId}) async {
try {
AppLogger.d(_tag, '获取功能类型预设管理信息: featureType=$featureType, novelId=$novelId');
String path = '/ai/presets/management/$featureType';
if (novelId != null) {
path = '$path?novelId=$novelId';
}
final response = await apiClient.get(path);
final data = _extractData(response);
if (data is! Map<String, dynamic>) {
throw ApiException(-1, '响应格式不正确期望Map类型');
}
AppLogger.i(_tag, '获取功能类型预设管理信息成功: $featureType');
return data;
} catch (e) {
AppLogger.e(_tag, '获取功能类型预设管理信息失败: $featureType', e);
rethrow;
}
}
@override
Future<PresetListResponse> getFeaturePresetList(String featureType, {String? novelId}) async {
try {
AppLogger.d(_tag, '获取功能预设列表: featureType=$featureType, novelId=$novelId');
Map<String, String> queryParams = {
'featureType': featureType,
};
if (novelId != null) {
queryParams['novelId'] = novelId;
}
final response = await apiClient.get(
'/ai/presets/feature-list?${queryParams.entries.map((e) => '${e.key}=${e.value}').join('&')}',
);
final data = _extractData(response);
if (data is! Map<String, dynamic>) {
throw ApiException(-1, '响应格式不正确期望Map类型');
}
final presetListResponse = PresetListResponse.fromJson(data);
AppLogger.i(_tag, '获取功能预设列表成功: 收藏${presetListResponse.favorites.length}个, '
'最近使用${presetListResponse.recentUsed.length}个, '
'推荐${presetListResponse.recommended.length}');
return presetListResponse;
} catch (e) {
AppLogger.e(_tag, '获取功能预设列表失败: $featureType', e);
rethrow;
}
}
}

View File

@@ -0,0 +1,437 @@
import 'dart:typed_data';
import 'package:ainoval/services/api_service/base/api_client.dart';
import 'package:ainoval/services/api_service/base/api_exception.dart';
import 'package:ainoval/services/api_service/repositories/storage_repository.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:flutter_oss_aliyun/flutter_oss_aliyun.dart';
import 'package:mime/mime.dart';
import 'package:dio/dio.dart';
import 'package:http_parser/http_parser.dart';
/// 阿里云OSS存储仓库实现使用 POST Policy 上传
class AliyunOssStorageRepository implements StorageRepository {
final ApiClient _apiClient;
Client? _ossClient;
final Dio _dio = Dio();
Map<String, dynamic>? _lastCredential;
AliyunOssStorageRepository(this._apiClient);
@override
Future<Map<String, dynamic>> getCoverUploadCredential({
required String novelId,
required String fileName,
String? contentType,
}) async {
try {
// 参数校验
if (novelId.isEmpty) {
throw ApiException(-1, '小说ID不能为空');
}
// 使用默认文件名
final String safeFileName = fileName.isEmpty ? 'cover.jpg' : fileName;
// 获取MIME类型如果未提供
final String mimeType = contentType ?? _getMimeType(safeFileName);
// 调用后端API获取 POST Policy 上传凭证
final credential = await _apiClient.getCoverUploadCredential(novelId);
// 校验返回的凭证是否包含 POST Policy 必要字段
final requiredFields = ['accessKeyId', 'policy', 'signature', 'key', 'host'];
final missingFields = requiredFields.where((field) =>
!credential.containsKey(field) ||
credential[field] == null ||
credential[field].toString().isEmpty).toList();
if (missingFields.isNotEmpty) {
throw ApiException(
-1, '获取上传凭证失败:缺少必要字段 ${missingFields.join(', ')}');
}
// 存储凭证,如果需要重新初始化客户端时使用
_lastCredential = Map<String, dynamic>.from(credential);
// 添加前端需要的额外信息
credential['fileName'] = safeFileName;
credential['contentType'] = mimeType;
AppLogger.d(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'获取 POST Policy 上传凭证成功:${credential.keys.join(', ')}',
);
return credential;
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'获取上传凭证失败',
e,
(e is DioException) ? e.stackTrace : StackTrace.current,
);
if (e is ApiException) {
rethrow;
}
throw ApiException(-1, '获取上传凭证失败: $e');
}
}
@override
Future<String> uploadCoverImage({
required String novelId,
required Uint8List fileBytes,
required String fileName,
String? contentType,
bool updateNovelCover = true,
}) async {
try {
// 参数校验
if (fileBytes.isEmpty) throw ApiException(-1, '上传内容为空');
final safeFileName = fileName.isEmpty ? 'cover.jpg' : fileName;
final mimeType = contentType ?? _getMimeType(safeFileName);
// 获取 POST Policy 上传凭证
final credential = await getCoverUploadCredential(
novelId: novelId,
fileName: safeFileName,
contentType: mimeType,
);
AppLogger.d(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'准备使用 POST Policy 上传,凭证字段: ${credential.keys.join(', ')}',
);
// 从凭证中提取必要字段
final String key = credential['key'].toString();
final String policy = credential['policy'].toString();
final String accessKeyId = credential['accessKeyId'].toString(); // Should be 'OSSAccessKeyId' in form
final String signature = credential['signature'].toString();
final String host = credential['host'].toString(); // Upload URL
// final String? callback = credential['callback']?.toString(); // Optional callback
// 准备 FormData
final formData = FormData.fromMap({
'key': key,
'policy': policy,
'OSSAccessKeyId': accessKeyId, // Field name expected by OSS
'signature': signature,
'success_action_status': '200', // Or '204' - request success status
'Content-Type': mimeType, // Explicitly set Content-Type for the request part
'file': MultipartFile.fromBytes(
fileBytes,
filename: safeFileName, // Use the safe file name
contentType: MediaType.parse(mimeType), // Use MediaType for content type
),
// if (callback != null) 'callback': callback, // Add callback if present
});
AppLogger.d(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'开始 POST Policy 上传文件: host=$host, key=$key, size=${fileBytes.length}, contentType=$mimeType',
);
try {
// 使用 Dio 发送 POST 请求
final response = await _dio.post(
host,
data: formData,
onSendProgress: (count, total) {
// Optional: Handle progress update
AppLogger.d(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'上传进度: $count/$total',
);
},
options: Options(
// OSS might return 200 or 204 on success depending on configuration
// Dio considers only 2xx as success by default.
// We check manually below.
followRedirects: false,
validateStatus: (status) {
return status != null; // Accept any status code, validate below
},
),
);
// 检查响应状态
if (response.statusCode != 200 && response.statusCode != 204) {
String errorBody = response.data?.toString() ?? 'No response body';
// OSS often returns XML errors for POST uploads
if (errorBody.contains('<Code>') && errorBody.contains('<Message>')) {
// Try to extract OSS error details
final codeMatch = RegExp(r'<Code>(.*?)<\/Code>').firstMatch(errorBody);
final messageMatch = RegExp(r'<Message>(.*?)<\/Message>').firstMatch(errorBody);
errorBody = 'OSS Error: Code=${codeMatch?.group(1) ?? 'Unknown'}, Message=${messageMatch?.group(1) ?? 'Unknown'}';
}
AppLogger.e(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'OSS POST Policy 上传失败,状态码: ${response.statusCode}, 消息: ${response.statusMessage ?? errorBody}',
Exception('OSS POST Policy Upload Failed'),
StackTrace.current,
);
throw ApiException(response.statusCode ?? -1, '上传失败: ${response.statusMessage ?? errorBody}');
}
AppLogger.i(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'POST Policy 上传成功',
);
// 构建文件URL (This might need adjustment depending on 'host' format)
// If host is like 'https://bucket.endpoint', URL is host + / + key
// If host is like 'https://endpoint' and bucket is separate, adjust accordingly.
// Assuming host is the base URL for the object.
final String fileUrl = '$host/$key'; // Simplistic assumption, adjust if needed
AppLogger.i(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'上传完成文件URL: $fileUrl',
);
if (updateNovelCover) {
await _apiClient.updateNovelCover(novelId, fileUrl);
}
return fileUrl;
} on DioException catch (e) {
String errorDetails = e.message ?? e.toString();
if (e.response != null) {
errorDetails += "\nResponse Status: ${e.response?.statusCode}";
errorDetails += "\nResponse Data: ${e.response?.data}";
}
AppLogger.e(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'上传过程中发生网络或服务器错误: $errorDetails', // FIX LINTER: Merge details into message
e, // 记录原始异常
e.stackTrace, // Pass the stack trace
);
throw ApiException(e.response?.statusCode ?? -1, '上传失败: $errorDetails');
} catch (e, s) {
// 处理其他类型的异常
AppLogger.e(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'上传过程中发生未知错误',
e,
s,
);
throw ApiException(-1, '上传失败: ${e.toString()}');
}
} catch (e, s) {
AppLogger.e(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'上传封面图片失败 (外部捕获)',
e,
s,
);
if (e is ApiException) {
rethrow;
}
throw ApiException(-1, '上传封面图片失败: $e');
}
}
@override
Future<String> getFileAccessUrl({
required String fileKey,
int? expirationSeconds,
}) async {
if (_ossClient == null && _lastCredential != null) {
try {
_initOssClient(_lastCredential!);
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'Failed to re-initialize OSS client for getFileAccessUrl',
e,
StackTrace.current
);
// Fallback: Return non-signed URL if client init fails
return _buildFileUrlFromKey(fileKey);
}
}
if (_ossClient == null) {
AppLogger.w(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'OSS Client not initialized for getFileAccessUrl, returning non-signed URL.',
);
// Fallback: Return non-signed URL if client is not initialized
return _buildFileUrlFromKey(fileKey);
}
try {
// This assumes _ossClient was initialized correctly with STS creds
final url = await _ossClient!.getSignedUrl(
fileKey
);
return url;
} catch (e, s) {
AppLogger.e(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'获取文件访问URL失败 (getSignedUrl)',
e,
s,
);
// Fallback: Return non-signed URL on error
return _buildFileUrlFromKey(fileKey);
}
}
@override
Future<bool> hasValidStorageConfig() async {
try {
// Test by attempting to get credentials for a dummy file
await getCoverUploadCredential(
novelId: 'test_config', // Use a distinct ID for testing
fileName: 'test.jpg',
);
// We assume if credentials are fetched, the config is likely valid enough
// A full test would involve a small test upload.
return true;
} catch (e) {
AppLogger.w(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'hasValidStorageConfig check failed',
e
);
return false;
}
}
/// 初始化或更新OSS客户端实例
void _initOssClient(Map<String, dynamic> credential) {
try {
// 从凭证中提取 STS 或 AK/SK 信息
final accessKeyId = credential['accessKeyId']?.toString();
final accessKeySecret = credential['accessKeySecret']?.toString();
final securityToken = credential['securityToken']?.toString(); // STS Token
final endpoint = credential['endpoint']?.toString();
final bucketName = credential['bucket']?.toString();
final expiration = credential['expiration']?.toString(); // STS凭证过期时间 (ISO 8601)
// 校验必要参数
if (accessKeyId == null || accessKeyId.isEmpty ||
accessKeySecret == null || accessKeySecret.isEmpty ||
// securityToken 对于 STS 是必需的
(securityToken == null || securityToken.isEmpty) ||
endpoint == null || endpoint.isEmpty ||
bucketName == null || bucketName.isEmpty) {
throw ApiException(-1, 'OSS客户端初始化失败凭证缺少必要参数 (Id, Secret, Token, Endpoint, Bucket)');
}
// 检查凭证是否已过期 (可选但推荐)
DateTime? expireTime;
if (expiration != null) {
try {
expireTime = DateTime.parse(expiration).toUtc();
// 留一些缓冲时间比如提前5分钟认为过期
if (DateTime.now().toUtc().isAfter(expireTime.subtract(const Duration(minutes: 5)))) {
AppLogger.w('Services/api_service/repositories/impl/aliyun_oss_storage_repository', 'STS凭证即将或已经过期建议重新获取');
// 这里可以决定是否强制重新获取凭证,或者让后续操作失败
}
} catch(e) {
AppLogger.w('Services/api_service/repositories/impl/aliyun_oss_storage_repository', '解析凭证过期时间失败: $expiration', e);
}
}
AppLogger.d(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'初始化OSS客户端: endpoint=$endpoint, bucket=$bucketName, 使用STS凭证',
);
// 使用 STS 凭证初始化 Client
// 确保 flutter_oss_aliyun 支持直接传入 STS token
_ossClient = Client.init(
// region: credential['region']?.toString(), // 如果需要指定 region
ossEndpoint: endpoint, // 使用后端提供的 endpoint
bucketName: bucketName, // 使用后端提供的 bucket
// signVersion: SignVersion.V4, // 显式指定V4 (如果SDK支持)
authGetter: () => Auth(
accessKey: accessKeyId,
accessSecret: accessKeySecret,
secureToken: securityToken, // 传递 STS Token
expire: expiration ?? DateTime.now().add(const Duration(hours: 1)).toIso8601String(),
),
);
AppLogger.i(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'OSS客户端初始化成功 (使用STS凭证)',
);
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'初始化OSS客户端失败',
e,
);
_ossClient = null; // 初始化失败,清空客户端
if (e is ApiException) rethrow;
throw ApiException(-1, '初始化OSS客户端失败: $e');
}
}
/// 根据凭证构建文件URL
String _buildFileUrl(Map<String, dynamic> credential) {
// 优先使用后端可能直接提供的 fileUrl 字段 (如果后端逻辑包含)
if (credential.containsKey('fileUrl') && credential['fileUrl'] != null && credential['fileUrl'].toString().isNotEmpty) {
return credential['fileUrl'].toString();
}
// 从 endpoint, bucket, key 构建标准 OSS URL
final endpoint = credential['endpoint']?.toString() ?? '';
final bucket = credential['bucket']?.toString() ?? '';
final key = credential['key']?.toString() ?? '';
if (endpoint.isEmpty || bucket.isEmpty || key.isEmpty) {
AppLogger.w('Services/api_service/repositories/impl/aliyun_oss_storage_repository', '无法构建文件URL缺少 endpoint, bucket 或 key');
return 'error_url_build_failed'; // 返回错误标识或抛出异常
}
// 确保 endpoint 不包含协议头,并移除末尾斜杠
String cleanEndpoint = endpoint.replaceAll(RegExp(r'^https?://'), '');
if (cleanEndpoint.endsWith('/')) {
cleanEndpoint = cleanEndpoint.substring(0, cleanEndpoint.length - 1);
}
// 确保 key 不以斜杠开头
String cleanKey = key;
if (cleanKey.startsWith('/')) {
cleanKey = cleanKey.substring(1);
}
// 构建 URL: https://bucket.endpoint/key
return 'https://$bucket.$cleanEndpoint/$cleanKey';
}
/// Builds a potentially non-signed URL just from the key
/// Requires _lastCredential to have endpoint/bucket info.
String _buildFileUrlFromKey(String key) {
final endpoint = _lastCredential?['endpoint']?.toString();
final bucket = _lastCredential?['bucket']?.toString(); // Bucket might not be in POST creds
if (endpoint == null || endpoint.isEmpty || bucket == null || bucket.isEmpty) {
AppLogger.w('Services/api_service/repositories/impl/aliyun_oss_storage_repository',
'Cannot build file URL from key, missing endpoint/bucket in last credential');
return key; // Return key as fallback
}
String cleanEndpoint = endpoint.replaceAll(RegExp(r'^https?://'), '');
if (cleanEndpoint.endsWith('/')) {
cleanEndpoint = cleanEndpoint.substring(0, cleanEndpoint.length - 1);
}
String cleanKey = key;
if (cleanKey.startsWith('/')) {
cleanKey = cleanKey.substring(1);
}
return 'https://$bucket.$cleanEndpoint/$cleanKey';
}
/// 根据文件名获取MIME类型
String _getMimeType(String fileName) {
final mimeType = lookupMimeType(fileName);
return mimeType ?? 'application/octet-stream';
}
}

View File

@@ -0,0 +1,454 @@
import 'package:ainoval/models/analytics_data.dart';
import 'package:ainoval/models/prompt_models.dart';
import 'package:ainoval/services/api_service/repositories/analytics_repository.dart';
import 'package:ainoval/services/api_service/base/api_client.dart';
import 'package:ainoval/utils/date_time_parser.dart';
class AnalyticsRepositoryImpl implements AnalyticsRepository {
final ApiClient _apiClient = ApiClient();
@override
Future<AnalyticsData> getAnalyticsOverview() async {
try {
final response = await _apiClient.get('/analytics/overview');
final data = response['data'] as Map<String, dynamic>;
final rawMostPopular = data['mostPopularFunction']?.toString() ?? '';
return AnalyticsData(
totalWords: data['totalWords'] ?? 0,
monthlyNewWords: data['monthlyNewWords'] ?? 0,
totalTokens: data['totalTokens'] ?? 0,
monthlyNewTokens: data['monthlyNewTokens'] ?? 0,
functionUsageCount: (data['functionUsageCount'] ?? 0).toInt(),
mostPopularFunction: _mapFunctionToDisplay(rawMostPopular).isEmpty
? '智能续写'
: _mapFunctionToDisplay(rawMostPopular),
writingDays: (data['writingDays'] ?? 0).toInt(),
consecutiveDays: (data['consecutiveDays'] ?? 0).toInt(),
);
} catch (e) {
throw Exception('Failed to load analytics overview: $e');
}
}
@override
Future<List<TokenUsageData>> getTokenUsageTrend({
AnalyticsViewMode viewMode = AnalyticsViewMode.monthly,
DateTime? startDate,
DateTime? endDate,
}) async {
try {
final Map<String, dynamic> params = {
'viewMode': _getViewModeString(viewMode),
};
if (startDate != null) {
params['startDate'] = startDate.toIso8601String();
}
if (endDate != null) {
params['endDate'] = endDate.toIso8601String();
}
final response = await _apiClient.getWithParams('/analytics/token-usage-trend', queryParameters: params);
final List<dynamic> data = response['data'] as List<dynamic>;
final List<TokenUsageData> rawSeries = data.map((item) => TokenUsageData(
date: item['date'] as String,
inputTokens: item['inputTokens'] ?? 0,
outputTokens: item['outputTokens'] ?? 0,
totalTokens: (item['inputTokens'] ?? 0) + (item['outputTokens'] ?? 0),
modelTokens: <String, int>{}, // 后端暂不返回按模型分组的数据
)).toList();
// 补齐缺失日期,避免仅单点数据显示突兀
return _postProcessTokenSeries(
rawSeries: rawSeries,
viewMode: viewMode,
startDate: startDate,
endDate: endDate,
);
} catch (e) {
throw Exception('Failed to load token usage trend: $e');
}
}
@override
Future<List<FunctionUsageData>> getFunctionUsageStats({
required AnalyticsViewMode viewMode,
DateTime? startDate,
DateTime? endDate,
}) async {
try {
final Map<String, dynamic> params = {
'viewMode': _getViewModeString(viewMode),
};
final response = await _apiClient.getWithParams('/analytics/function-usage-stats', queryParameters: params);
final List<dynamic> data = response['data'] as List<dynamic>;
return data.map((item) => FunctionUsageData(
name: _mapFunctionToDisplay(item['function']?.toString() ?? ''),
value: (item['count'] ?? 0).toInt(),
growth: 0.0, // 后端暂不返回增长率数据
)).toList();
} catch (e) {
throw Exception('Failed to load function usage stats: $e');
}
}
@override
Future<List<ModelUsageData>> getModelUsageStats({
required AnalyticsViewMode viewMode,
DateTime? startDate,
DateTime? endDate,
}) async {
try {
final Map<String, dynamic> params = {
'viewMode': _getViewModeString(viewMode),
};
final response = await _apiClient.getWithParams('/analytics/model-usage-stats', queryParameters: params);
final List<dynamic> data = response['data'] as List<dynamic>;
return data.map((item) => ModelUsageData(
modelName: item['model'] as String,
percentage: ((item['percentage'] ?? 0.0).toDouble()).round(),
totalTokens: item['count'] ?? 0, // 使用count作为总tokens的代表
color: _getModelColor(item['model'] as String),
)).toList();
} catch (e) {
throw Exception('Failed to load model usage stats: $e');
}
}
@override
Future<List<TokenUsageRecord>> getTokenUsageRecords({
int limit = 20,
int offset = 0,
}) async {
try {
final Map<String, dynamic> params = {
'limit': limit.toString(),
};
final response = await _apiClient.getWithParams('/analytics/token-usage-records', queryParameters: params);
final List<dynamic> data = response['data'] as List<dynamic>;
return data.map((item) => TokenUsageRecord(
id: item['id']?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(),
model: item['model'] as String,
taskType: _mapFunctionToDisplay(item['taskType']?.toString() ?? ''),
inputTokens: item['inputTokens'] ?? 0,
outputTokens: item['outputTokens'] ?? 0,
cost: (item['cost'] ?? 0.0).toDouble(),
timestamp: parseBackendDateTime(item['timestamp']),
)).toList();
} catch (e) {
throw Exception('Failed to load token usage records: $e');
}
}
@override
Future<Map<String, dynamic>> getTodayTokenSummary() async {
try {
final response = await _apiClient.get('/analytics/today-summary');
return response['data'] as Map<String, dynamic>;
} catch (e) {
throw Exception('Failed to load today token summary: $e');
}
}
String _getViewModeString(AnalyticsViewMode mode) {
switch (mode) {
case AnalyticsViewMode.daily:
return 'daily';
case AnalyticsViewMode.monthly:
return 'monthly';
case AnalyticsViewMode.cumulative:
return 'cumulative';
case AnalyticsViewMode.range:
return 'range';
}
}
String _getModelColor(String model) {
final String m = model.toLowerCase();
if (m.contains('gpt')) return '#3B82F6';
if (m.contains('claude')) return '#8B5CF6';
if (m.contains('gemini')) return '#10B981';
if (m.contains('deepseek')) return '#F59E0B';
// 对未知模型使用稳定的哈希颜色,确保“不同的显示不同颜色”
final List<String> palette = ['#06B6D4', '#EF4444', '#22C55E', '#F97316', '#A855F7', '#0EA5E9'];
final int idx = model.hashCode.abs() % palette.length;
return palette[idx];
}
String _mapFunctionToDisplay(String functionKey) {
if (functionKey.isEmpty) return '';
try {
final feature = AIFeatureTypeHelper.fromApiString(functionKey);
return feature.displayName;
} catch (_) {
return functionKey;
}
}
// ----------
// Token 使用趋势数据补齐逻辑
// ----------
List<TokenUsageData> _postProcessTokenSeries({
required List<TokenUsageData> rawSeries,
required AnalyticsViewMode viewMode,
DateTime? startDate,
DateTime? endDate,
}) {
if (rawSeries.isEmpty) {
// 空数据时保留为空,让前端显示“暂无数据”
return rawSeries;
}
switch (viewMode) {
case AnalyticsViewMode.daily:
case AnalyticsViewMode.range:
return _fillDailySeries(
rawSeries: rawSeries,
explicitStart: startDate,
explicitEnd: endDate,
);
case AnalyticsViewMode.monthly:
return _fillMonthlySeries(
rawSeries: rawSeries,
explicitStart: startDate,
explicitEnd: endDate,
);
case AnalyticsViewMode.cumulative:
// 累计模式一般由后端计算。为避免误解语义不进行数值重算仅在只有单点时做最小可视化填充填充前置0
if (rawSeries.length > 1) return _sortByDateString(rawSeries);
return _fillDailySeries(
rawSeries: rawSeries,
explicitStart: startDate,
explicitEnd: endDate,
defaultWindowDays: 7,
);
}
}
List<TokenUsageData> _fillDailySeries({
required List<TokenUsageData> rawSeries,
DateTime? explicitStart,
DateTime? explicitEnd,
int defaultWindowDays = 7,
}) {
final List<TokenUsageData> sorted = _sortByDateString(rawSeries);
// 解析现有最早与最晚日期
final DateTime? firstDate = _tryParseDate(sorted.first.date);
final DateTime? lastDate = _tryParseDate(sorted.last.date);
// 对于仅有 MM-dd 的场景_tryParseDate 会用当前年填充,可能导致“跨年跳跃”。为了可视化体验:
// 若显式日期范围为空,则直接使用数据内最早/最晚的字符串顺序来确定窗口,不再随当前年错配。
DateTime start = _normalizeToDateOnly(explicitStart ?? firstDate ?? DateTime.now());
DateTime end = _normalizeToDateOnly(explicitEnd ?? lastDate ?? DateTime.now());
if (sorted.length == 1 && explicitStart == null && explicitEnd == null) {
// 仅单点数据时,默认展示 [last - (N-1)天, last] 的连续窗口
start = _normalizeToDateOnly(end.subtract(Duration(days: defaultWindowDays - 1)));
}
if (end.isBefore(start)) {
// 防御:若区间反转,交换
final tmp = start;
start = end;
end = tmp;
}
// 建立日期到数据的索引
final Map<String, TokenUsageData> dateToData = {
for (final d in sorted) _formatDate(d.date, AnalyticsViewMode.daily): d,
};
final List<TokenUsageData> filled = [];
DateTime cursor = start;
while (!cursor.isAfter(end)) {
final String key = _formatDateFromDateTime(cursor, AnalyticsViewMode.daily);
final TokenUsageData? existing = dateToData[key];
if (existing != null) {
filled.add(existing);
} else {
filled.add(TokenUsageData(
date: key,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
modelTokens: const <String, int>{},
));
}
cursor = cursor.add(const Duration(days: 1));
}
return filled;
}
List<TokenUsageData> _fillMonthlySeries({
required List<TokenUsageData> rawSeries,
DateTime? explicitStart,
DateTime? explicitEnd,
int defaultWindowMonths = 6,
}) {
final List<TokenUsageData> sorted = _sortByDateString(rawSeries);
// 猜测月份:将字符串解析到当月第一天
final DateTime? firstMonth = _normalizeToMonthStart(_tryParseDate(sorted.first.date));
final DateTime? lastMonth = _normalizeToMonthStart(_tryParseDate(sorted.last.date));
DateTime start = explicitStart != null
? _normalizeToMonthStart(explicitStart)
: (firstMonth ?? _normalizeToMonthStart(DateTime.now()));
DateTime end = explicitEnd != null
? _normalizeToMonthStart(explicitEnd)
: (lastMonth ?? _normalizeToMonthStart(DateTime.now()));
if (sorted.length == 1 && explicitStart == null && explicitEnd == null) {
// 仅单点数据时,默认展示最近 N 个月
end = _normalizeToMonthStart(end);
start = _addMonths(end, -(defaultWindowMonths - 1));
}
if (end.isBefore(start)) {
final tmp = start;
start = end;
end = tmp;
}
// 索引yyyy-MM -> 数据
final Map<String, TokenUsageData> monthToData = {
for (final d in sorted) _formatDate(d.date, AnalyticsViewMode.monthly): d,
};
final List<TokenUsageData> filled = [];
DateTime cursor = start;
while (!cursor.isAfter(end)) {
final String key = _formatDateFromDateTime(cursor, AnalyticsViewMode.monthly);
final TokenUsageData? existing = monthToData[key];
if (existing != null) {
filled.add(existing);
} else {
filled.add(TokenUsageData(
date: key,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
modelTokens: const <String, int>{},
));
}
cursor = _addMonths(cursor, 1);
}
return filled;
}
List<TokenUsageData> _sortByDateString(List<TokenUsageData> series) {
final List<TokenUsageData> copy = List<TokenUsageData>.from(series);
copy.sort((a, b) {
final DateTime? da = _tryParseDate(a.date);
final DateTime? db = _tryParseDate(b.date);
if (da == null && db == null) return 0;
if (da == null) return -1;
if (db == null) return 1;
return da.compareTo(db);
});
return copy;
}
DateTime? _tryParseDate(String raw) {
// 1) 直接解析 ISO / yyyy-MM-dd / yyyy-MM
final DateTime? direct = DateTime.tryParse(raw);
if (direct != null) return direct;
final now = DateTime.now();
try {
// 2) 显式识别 'yyyy-MM-dd'
if (RegExp(r'^\d{4}-\d{1,2}-\d{1,2}$').hasMatch(raw)) {
final p = raw.split('-');
final y = int.parse(p[0]);
final m = int.parse(p[1]);
final d = int.parse(p[2]);
return DateTime(y, m, d);
}
// 3) 显式识别 'yyyy-MM'
if (RegExp(r'^\d{4}-\d{1,2}$').hasMatch(raw)) {
final p = raw.split('-');
final y = int.parse(p[0]);
final m = int.parse(p[1]);
return DateTime(y, m, 1);
}
// 4) 显式识别 'MM-dd':视为当前年份的 月-日
if (RegExp(r'^\d{1,2}-\d{1,2}$').hasMatch(raw)) {
final p = raw.split('-');
final m = int.parse(p[0]);
final d = int.parse(p[1]);
return DateTime(now.year, m, d);
}
// 5) 宽松兜底若为两段且第一段长度为2按 MM-dd否则按 yyyy-MM(-dd)
final parts = raw.split('-');
if (parts.length == 2 && parts[0].length <= 2) {
final m = int.tryParse(parts[0]) ?? 1;
final d = int.tryParse(parts[1]) ?? 1;
return DateTime(now.year, m, d);
}
if (parts.length >= 2) {
final int y = int.tryParse(parts[0]) ?? now.year;
final int m = int.tryParse(parts[1]) ?? 1;
final int d = parts.length >= 3 ? (int.tryParse(parts[2]) ?? 1) : 1;
return DateTime(y, m, d);
}
} catch (_) {}
return null;
}
DateTime _normalizeToDateOnly(DateTime? dt) {
final DateTime base = dt ?? DateTime.now();
return DateTime(base.year, base.month, base.day);
}
DateTime _normalizeToMonthStart(DateTime? dt) {
final DateTime base = dt ?? DateTime.now();
return DateTime(base.year, base.month, 1);
}
DateTime _addMonths(DateTime dt, int delta) {
final int yearDelta = (dt.month - 1 + delta) ~/ 12;
final int newMonthIndex = (dt.month - 1 + delta) % 12;
final int newYear = dt.year + yearDelta;
final int newMonth = newMonthIndex + 1;
final int day = dt.day;
// 处理不同月份天数
final int lastDay = DateTime(newYear, newMonth + 1, 0).day;
final int safeDay = day > lastDay ? lastDay : day;
return DateTime(newYear, newMonth, safeDay);
}
String _formatDate(String raw, AnalyticsViewMode mode) {
final DateTime? dt = _tryParseDate(raw);
if (dt == null) return raw;
return _formatDateFromDateTime(dt, mode);
}
String _formatDateFromDateTime(DateTime dt, AnalyticsViewMode mode) {
final int y = dt.year;
final String m = dt.month.toString().padLeft(2, '0');
final String d = dt.day.toString().padLeft(2, '0');
switch (mode) {
case AnalyticsViewMode.daily:
case AnalyticsViewMode.range:
case AnalyticsViewMode.cumulative:
return '$y-$m-$d';
case AnalyticsViewMode.monthly:
return '$y-$m';
}
}
}

View File

@@ -0,0 +1,521 @@
import 'dart:async';
import 'dart:convert';
import 'package:ainoval/models/ai_request_models.dart';
import 'package:ainoval/models/chat_models.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/api_service/base/sse_client.dart';
import 'package:ainoval/services/api_service/repositories/chat_repository.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:dio/dio.dart';
import 'package:flutter_client_sse/constants/sse_request_type_enum.dart';
/// 聊天仓库实现
class ChatRepositoryImpl implements ChatRepository {
ChatRepositoryImpl({
required this.apiClient,
});
final ApiClient apiClient;
// 🚀 修改为两层缓存映射用于存储会话的AI配置novelId -> sessionId -> config
static final Map<String, Map<String, UniversalAIRequest>> _cachedSessionConfigs = {};
/// 获取聊天会话列表 (流式) - 简化版
@override
Stream<ChatSession> fetchUserSessions(String userId, {String? novelId}) {
AppLogger.i('ChatRepositoryImpl', '获取用户会话流: userId=$userId, novelId=$novelId');
// 🚀 目前先使用原有API后续可以添加支持novelId的新API
try {
// TODO: 暂时使用原有API后续可以添加新的API方法
return apiClient.listAiChatUserSessionsStream(userId, novelId: novelId);
} catch (e, stackTrace) {
AppLogger.e('ChatRepositoryImpl', '发起获取用户会话流时出错 [同步]', e, stackTrace);
return Stream.error(
ApiExceptionHelper.fromException(e, '发起获取用户会话流失败'), stackTrace);
}
}
/// 创建新的聊天会话 (非流式)
@override
Future<ChatSession> createSession({
required String userId,
required String novelId,
String? modelName,
Map<String, dynamic>? metadata,
}) async {
try {
AppLogger.i('ChatRepositoryImpl',
'创建会话: userId=$userId, novelId=$novelId, modelName=$modelName');
final session = await apiClient.createAiChatSession(
userId: userId,
novelId: novelId,
modelName: modelName,
metadata: metadata,
);
AppLogger.i('ChatRepositoryImpl', '创建会话成功: sessionId=${session.id}');
return session;
} catch (e, stackTrace) {
AppLogger.e('ChatRepositoryImpl',
'创建会话失败: userId=$userId, novelId=$novelId', e, stackTrace);
throw ApiExceptionHelper.fromException(e, '创建会话失败');
}
}
/// 获取特定会话 (非流式) - 现在返回会话和AI配置的组合数据
@override
Future<ChatSession> getSession(String userId, String sessionId, {String? novelId}) async {
try {
AppLogger.i(
'ChatRepositoryImpl', '获取会话含AI配置: userId=$userId, sessionId=$sessionId, novelId=$novelId');
// 🚀 目前先使用原有API后续可以添加支持novelId的新API
final response = await apiClient.getAiChatSessionWithConfig(userId, sessionId, novelId: novelId);
AppLogger.i('ChatRepositoryImpl', '使用传统API获取会话');
final session = response['session'] as ChatSession;
AppLogger.i('ChatRepositoryImpl',
'获取会话成功: sessionId=$sessionId, title=${session.title}, hasAIConfig=${response["aiConfig"] != null}');
// 🚀 将AI配置信息缓存到两层映射中供后续使用
if (response['aiConfig'] != null && session.novelId != null) {
try {
final configData = response['aiConfig'];
Map<String, dynamic> configJson;
if (configData is String) {
final configString = configData as String;
if (configString.trim().isNotEmpty && configString != '{}') {
if (!configString.startsWith('{') || !configString.contains('"')) {
AppLogger.w('ChatRepositoryImpl', '检测到非标准JSON格式跳过解析');
} else {
try {
configJson = jsonDecode(configString);
final config = UniversalAIRequest.fromJson(configJson);
// 🚀 将配置缓存到两层映射中
_cachedSessionConfigs[session.novelId!] ??= {};
_cachedSessionConfigs[session.novelId!]![sessionId] = config;
AppLogger.i('ChatRepositoryImpl',
'成功缓存会话AI配置: novelId=${session.novelId}, sessionId=$sessionId, requestType=${config.requestType.value}');
} catch (e) {
AppLogger.e('ChatRepositoryImpl', '解析AI配置JSON失败: $e');
}
}
}
} else if (configData is Map<String, dynamic>) {
try {
final config = UniversalAIRequest.fromJson(configData);
// 🚀 将配置缓存到两层映射中
_cachedSessionConfigs[session.novelId!] ??= {};
_cachedSessionConfigs[session.novelId!]![sessionId] = config;
AppLogger.i('ChatRepositoryImpl',
'成功缓存会话AI配置: novelId=${session.novelId}, sessionId=$sessionId, requestType=${config.requestType.value}');
} catch (e) {
AppLogger.e('ChatRepositoryImpl', '解析AI配置Map失败: $e');
}
}
} catch (e) {
AppLogger.w('ChatRepositoryImpl', '缓存AI配置失败但不影响会话加载: $e');
}
}
return session;
} catch (e, stackTrace) {
AppLogger.e('ChatRepositoryImpl',
'获取会话失败: userId=$userId, sessionId=$sessionId, novelId=$novelId', e, stackTrace);
throw ApiExceptionHelper.fromException(e, '获取会话失败');
}
}
/// 获取会话的AI配置 (非流式) - 现在从两层缓存中获取
@override
Future<UniversalAIRequest?> getSessionAIConfig(String userId, String sessionId, {String? novelId}) async {
AppLogger.i('ChatRepositoryImpl',
'从缓存获取会话AI配置: userId=$userId, sessionId=$sessionId, novelId=$novelId');
// 🚀 从两层缓存中获取配置
if (novelId != null) {
final cachedConfig = _cachedSessionConfigs[novelId]?[sessionId];
if (cachedConfig != null) {
AppLogger.i('ChatRepositoryImpl',
'找到缓存的会话AI配置: novelId=$novelId, sessionId=$sessionId, requestType=${cachedConfig.requestType.value}');
return cachedConfig;
}
} else {
// 如果没有novelId尝试在所有novel中查找
for (final novelConfigs in _cachedSessionConfigs.values) {
final cachedConfig = novelConfigs[sessionId];
if (cachedConfig != null) {
AppLogger.i('ChatRepositoryImpl',
'在缓存中找到会话AI配置: sessionId=$sessionId, requestType=${cachedConfig.requestType.value}');
return cachedConfig;
}
}
}
AppLogger.i('ChatRepositoryImpl',
'缓存中没有找到会话AI配置: novelId=$novelId, sessionId=$sessionId');
return null;
}
/// 获取缓存的会话配置(静态方法,供其他类使用)
static UniversalAIRequest? getCachedSessionConfig(String sessionId, {String? novelId}) {
if (novelId != null) {
return _cachedSessionConfigs[novelId]?[sessionId];
} else {
// 如果没有novelId尝试在所有novel中查找
for (final novelConfigs in _cachedSessionConfigs.values) {
final config = novelConfigs[sessionId];
if (config != null) return config;
}
return null;
}
}
/// 缓存会话配置(静态方法,供其他类使用)
static void cacheSessionConfig(String sessionId, UniversalAIRequest config, {String? novelId}) {
final targetNovelId = novelId ?? config.novelId;
if (targetNovelId != null) {
_cachedSessionConfigs[targetNovelId] ??= {};
_cachedSessionConfigs[targetNovelId]![sessionId] = config;
AppLogger.i('ChatRepositoryImpl', '缓存会话AI配置: novelId=$targetNovelId, sessionId=$sessionId');
} else {
AppLogger.w('ChatRepositoryImpl', '无法缓存会话配置缺少novelId信息');
}
}
/// 清除会话配置缓存
static void clearSessionConfigCache(String sessionId, {String? novelId}) {
if (novelId != null) {
_cachedSessionConfigs[novelId]?.remove(sessionId);
AppLogger.i('ChatRepositoryImpl', '清除会话AI配置缓存: novelId=$novelId, sessionId=$sessionId');
} else {
// 如果没有novelId清除所有novel中的该sessionId
for (final novelConfigs in _cachedSessionConfigs.values) {
novelConfigs.remove(sessionId);
}
AppLogger.i('ChatRepositoryImpl', '清除所有小说中的会话AI配置缓存: sessionId=$sessionId');
}
}
/// 清除整个小说的配置缓存
static void clearNovelConfigCache(String novelId) {
_cachedSessionConfigs.remove(novelId);
AppLogger.i('ChatRepositoryImpl', '清除小说的所有AI配置缓存: novelId=$novelId');
}
/// 更新会话 (非流式)
@override
Future<ChatSession> updateSession({
required String userId,
required String sessionId,
required Map<String, dynamic> updates,
String? novelId,
}) async {
try {
AppLogger.i('ChatRepositoryImpl',
'更新会话: userId=$userId, sessionId=$sessionId, novelId=$novelId, updates=$updates');
// 🚀 目前先使用原有API后续可以添加支持novelId的新API
final updatedSession = await apiClient.updateAiChatSession(
userId: userId,
sessionId: sessionId,
updates: updates,
novelId: novelId,
);
AppLogger.i('ChatRepositoryImpl',
'更新会话成功: sessionId=$sessionId, title=${updatedSession.title}');
return updatedSession;
} catch (e, stackTrace) {
AppLogger.e('ChatRepositoryImpl',
'更新会话失败: userId=$userId, sessionId=$sessionId, novelId=$novelId', e, stackTrace);
throw ApiExceptionHelper.fromException(e, '更新会话失败');
}
}
/// 删除会话 (非流式)
@override
Future<void> deleteSession(String userId, String sessionId, {String? novelId}) async {
try {
AppLogger.i(
'ChatRepositoryImpl', '删除会话: userId=$userId, sessionId=$sessionId, novelId=$novelId');
// 🚀 目前先使用原有API后续可以添加支持novelId的新API
await apiClient.deleteAiChatSession(userId, sessionId, novelId: novelId);
// 清除该会话的配置缓存
clearSessionConfigCache(sessionId, novelId: novelId);
AppLogger.i('ChatRepositoryImpl', '删除会话成功: sessionId=$sessionId');
} catch (e, stackTrace) {
AppLogger.e('ChatRepositoryImpl',
'删除会话失败: userId=$userId, sessionId=$sessionId, novelId=$novelId', e, stackTrace);
throw ApiExceptionHelper.fromException(e, '删除会话失败');
}
}
/// 发送消息并获取响应 (非流式)
@override
Future<ChatMessage> sendMessage({
required String userId,
required String sessionId,
required String content,
UniversalAIRequest? config,
Map<String, dynamic>? metadata,
String? configId,
String? novelId,
}) async {
try {
AppLogger.i('ChatRepositoryImpl',
'发送消息: userId=$userId, sessionId=$sessionId, novelId=$novelId, configId=$configId, hasConfig=${config != null}, contentLength=${content.length}');
// 🚀 如果有配置将配置数据添加到metadata中
Map<String, dynamic>? finalMetadata = metadata ?? {};
if (config != null) {
// 将配置序列化到metadata中
finalMetadata['aiConfig'] = config.toApiJson();
AppLogger.d('ChatRepositoryImpl', '添加AI配置到metadata配置类型: ${config.requestType.value}');
}
// 🚀 目前先使用原有API后续可以添加支持novelId的新API
final messageResponse = await apiClient.sendAiChatMessage(
userId: userId,
sessionId: sessionId,
content: content,
metadata: finalMetadata,
novelId: novelId,
);
AppLogger.i('ChatRepositoryImpl',
'收到AI响应: sessionId=$sessionId, messageId=${messageResponse.id}, contentLength=${messageResponse.content.length}');
return messageResponse;
} catch (e, stackTrace) {
AppLogger.e('ChatRepositoryImpl',
'发送消息失败: userId=$userId, sessionId=$sessionId, novelId=$novelId', e, stackTrace);
throw ApiExceptionHelper.fromException(e, '发送消息失败');
}
}
/// 流式发送消息并获取响应 - 简化版
@override
Stream<ChatMessage> streamMessage({
required String userId,
required String sessionId,
required String content,
UniversalAIRequest? config,
Map<String, dynamic>? metadata,
String? configId,
String? novelId,
}) {
AppLogger.i('ChatRepositoryImpl',
'开始流式消息: userId=$userId, sessionId=$sessionId, novelId=$novelId, configId=$configId, hasConfig=${config != null}');
try {
// 🚀 准备配置数据
Map<String, dynamic>? configData;
Map<String, dynamic>? finalMetadata = metadata ?? {};
if (config != null) {
// 将配置序列化
configData = config.toApiJson();
// 同时添加到metadata中以保持兼容性
finalMetadata['aiConfig'] = configData;
AppLogger.d('ChatRepositoryImpl', '添加AI配置到请求配置类型: ${config.requestType.value}');
}
// 🚀 构建请求体根据是否有novelId选择不同的请求格式
Map<String, dynamic> requestBody = {
'userId': userId,
'sessionId': sessionId,
'content': content,
'metadata': finalMetadata,
};
if (novelId != null) {
requestBody['novelId'] = novelId;
}
// 🚀 使用SSE方式发送流式消息与后端的标准SSE格式匹配
return SseClient().streamEvents<ChatMessage>(
path: '/ai-chat/messages/stream',
method: SSERequestType.POST,
body: requestBody,
parser: (json) {
try {
return ChatMessage.fromJson(json);
} catch (e) {
AppLogger.e('ChatRepositoryImpl', '解析ChatMessage失败: $e, json: $json');
throw ApiException(-1, '解析聊天响应失败: $e');
}
},
eventName: 'chat-message', // 🚀 使用与后端一致的事件名称
).where((message) {
// 🚀 首先检查消息是否属于当前会话
if (message.sessionId != sessionId) {
AppLogger.v('ChatRepositoryImpl', '过滤掉其他会话的消息: sessionId=${message.sessionId}, 当前会话=$sessionId');
return false;
}
// 🚀 过滤掉心跳信号但保留STREAM_CHUNK消息用于打字机效果
final isHeartbeat = message.content == 'heartbeat';
if (isHeartbeat) {
AppLogger.v('ChatRepositoryImpl', '过滤掉心跳信号: sessionId=${message.sessionId}');
return false;
}
// 🚀 保留流式块消息用于打字机效果
if (message.status == MessageStatus.streaming) {
//AppLogger.v('ChatRepositoryImpl', '保留流式块消息用于打字机效果: ${message.content}');
return true;
}
// 只保留有实际ID和内容的完整消息
final isCompleteMessage = message.id.isNotEmpty && !message.id.startsWith('temp_chunk_') && message.content.isNotEmpty;
if (isCompleteMessage) {
AppLogger.i('ChatRepositoryImpl', '📘 接收到完整消息: messageId=${message.id}, contentLength=${message.content.length}');
}
return isCompleteMessage;
});
} catch (e, stackTrace) {
AppLogger.e('ChatRepositoryImpl', '发起流式消息请求时出错 [同步]', e, stackTrace);
return Stream.error(
ApiExceptionHelper.fromException(e, '发起流式消息请求失败'), stackTrace);
}
}
/// 获取会话消息历史 (流式) - 简化版
@override
Stream<ChatMessage> getMessageHistory(
String userId,
String sessionId, {
int limit = 100,
String? novelId,
}) {
AppLogger.i('ChatRepositoryImpl',
'获取消息历史流: userId=$userId, sessionId=$sessionId, novelId=$novelId, limit=$limit');
try {
// 🚀 目前先使用原有API后续可以添加支持novelId的新API
return apiClient.getAiChatMessageHistoryStream(userId, sessionId,
limit: limit, novelId: novelId);
} catch (e, stackTrace) {
AppLogger.e('ChatRepositoryImpl', '发起获取消息历史流请求时出错 [同步]', e, stackTrace);
return Stream.error(
ApiExceptionHelper.fromException(e, '发起获取消息历史流失败'), stackTrace);
}
}
/// 获取特定消息 (非流式)
@override
Future<ChatMessage> getMessage(String userId, String messageId) async {
try {
AppLogger.i(
'ChatRepositoryImpl', '获取消息: userId=$userId, messageId=$messageId');
final message = await apiClient.getAiChatMessage(userId, messageId);
AppLogger.i('ChatRepositoryImpl',
'获取消息成功: messageId=$messageId, role=${message.role}');
return message;
} catch (e, stackTrace) {
AppLogger.e('ChatRepositoryImpl',
'获取消息失败: userId=$userId, messageId=$messageId', e, stackTrace);
throw ApiExceptionHelper.fromException(e, '获取消息失败');
}
}
/// 删除消息 (非流式)
@override
Future<void> deleteMessage(String userId, String messageId) async {
try {
AppLogger.i(
'ChatRepositoryImpl', '删除消息: userId=$userId, messageId=$messageId');
await apiClient.deleteAiChatMessage(userId, messageId);
AppLogger.i('ChatRepositoryImpl', '删除消息成功: messageId=$messageId');
} catch (e, stackTrace) {
AppLogger.e('ChatRepositoryImpl',
'删除消息失败: userId=$userId, messageId=$messageId', e, stackTrace);
throw ApiExceptionHelper.fromException(e, '删除消息失败');
}
}
/// 获取会话消息数量 (非流式)
@override
Future<int> countSessionMessages(String sessionId) async {
try {
AppLogger.i('ChatRepositoryImpl', '统计会话消息数量: sessionId=$sessionId');
final count = await apiClient.countAiChatSessionMessages(sessionId);
AppLogger.i('ChatRepositoryImpl',
'统计会话消息数量成功: sessionId=$sessionId, count=$count');
return count;
} catch (e, stackTrace) {
AppLogger.e('ChatRepositoryImpl', '统计会话消息数量失败: sessionId=$sessionId', e,
stackTrace);
throw ApiExceptionHelper.fromException(e, '统计会话消息数量失败');
}
}
/// 获取用户会话数量 (非流式)
@override
Future<int> countUserSessions(String userId, {String? novelId}) async {
try {
AppLogger.i('ChatRepositoryImpl', '统计用户会话数量: userId=$userId, novelId=$novelId');
// 🚀 目前先使用原有API后续可以添加支持novelId的新API
final count = await apiClient.countAiChatUserSessions(userId);
AppLogger.i(
'ChatRepositoryImpl', '统计用户会话数量成功: userId=$userId, novelId=$novelId, count=$count');
return count;
} catch (e, stackTrace) {
AppLogger.e(
'ChatRepositoryImpl', '统计用户会话数量失败: userId=$userId, novelId=$novelId', e, stackTrace);
throw ApiExceptionHelper.fromException(e, '统计用户会话数量失败');
}
}
}
// 辅助扩展方法,如果 ApiException 没有 fromException
extension ApiExceptionHelper on ApiException {
static ApiException fromException(dynamic e, String defaultMessage) {
if (e is ApiException) {
return e;
} else if (e is DioException) {
// 现在可以识别 DioException 了
final statusCode = e.response?.statusCode ?? -1;
// 尝试获取后端返回的错误信息,如果失败则使用 DioException 的 message
final backendMessage = _tryGetBackendMessage(e.response);
final detailedMessage = backendMessage ?? e.message ?? defaultMessage;
return ApiException(statusCode, '$defaultMessage: $detailedMessage');
} else {
return ApiException(-1, '$defaultMessage: ${e.toString()}');
}
}
// 尝试从 Response 中提取后端错误信息
static String? _tryGetBackendMessage(Response? response) {
if (response?.data != null) {
try {
final data = response!.data;
if (data is Map<String, dynamic>) {
// 查找常见的错误消息字段
if (data.containsKey('message') && data['message'] is String) {
return data['message'];
}
if (data.containsKey('error') && data['error'] is String) {
return data['error'];
}
if (data.containsKey('detail') && data['detail'] is String) {
return data['detail'];
}
} else if (data is String && data.isNotEmpty) {
return data; // 如果响应体直接是错误字符串
}
} catch (_) {
// 忽略解析错误
}
}
return null;
}
}

View File

@@ -0,0 +1,29 @@
import '../../../../models/user_credit.dart';
import '../../../../utils/logger.dart';
import '../../base/api_client.dart';
import '../credit_repository.dart';
/// 用户积分仓库实现
class CreditRepositoryImpl implements CreditRepository {
final ApiClient _apiClient;
static const String _tag = 'CreditRepositoryImpl';
CreditRepositoryImpl({required ApiClient apiClient}) : _apiClient = apiClient;
@override
Future<UserCredit> getUserCredits() async {
try {
AppLogger.i(_tag, '获取用户积分余额');
final rawData = await _apiClient.getUserCredits();
// 转换为UserCredit对象
final userCredit = UserCredit.fromJson(rawData);
AppLogger.i(_tag, '获取用户积分余额成功: ${userCredit.credits}');
return userCredit;
} catch (e, stackTrace) {
AppLogger.e(_tag, '获取用户积分余额失败', e, stackTrace);
rethrow;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
import 'dart:convert';
import 'package:ainoval/models/next_outline/next_outline_dto.dart';
import 'package:ainoval/models/next_outline/outline_generation_chunk.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/api_service/base/sse_client.dart';
import 'package:ainoval/services/api_service/repositories/next_outline_repository.dart';
import 'package:flutter_client_sse/constants/sse_request_type_enum.dart';
import '../../../../utils/logger.dart';
/// 剧情推演仓库实现
class NextOutlineRepositoryImpl implements NextOutlineRepository {
NextOutlineRepositoryImpl({
required this.apiClient,
});
final ApiClient apiClient;
final String _tag = 'NextOutlineRepositoryImpl';
@override
Stream<OutlineGenerationChunk> generateNextOutlinesStream(
String novelId,
GenerateNextOutlinesRequest request
) {
AppLogger.i(_tag, '流式生成剧情大纲: novelId=$novelId, startChapter=${request.startChapterId}, endChapter=${request.endChapterId}, numOptions=${request.numOptions}');
return SseClient().streamEvents<OutlineGenerationChunk>(
path: '/novels/$novelId/next-outlines/generate-stream',
method: SSERequestType.POST,
body: request.toJson(),
parser: (json) {
// 增强解析器的错误处理: 首先检查是否是已知的错误格式
if (json is Map<String, dynamic> && json.containsKey('code') && json.containsKey('message')) {
final errorMessage = json['message'] as String? ?? 'Unknown server error';
final errorCodeString = json['code'] as String?;
final errorCode = int.tryParse(errorCodeString ?? '') ?? -1; // 尝试解析为int失败则为-1
AppLogger.e(_tag, '服务器返回已知错误格式: code=${json['code']}, message=$errorMessage');
throw ApiException(errorCode, errorMessage); // 使用int类型的errorCode
}
// 再检查是否包含 'error' 字段的值是否非空 (兼容旧的或不同的错误格式)
else if (json is Map<String, dynamic> && json['error'] != null) {
final errorMessage = json['error'] as String? ?? 'Unknown server error';
AppLogger.e(_tag, '服务器返回错误字段: $errorMessage');
throw ApiException(-1, errorMessage); // 默认错误码-1
}
// 如果不是错误格式,则尝试解析为正常数据块
try {
return OutlineGenerationChunk.fromJson(json);
} catch (e, stackTrace) {
AppLogger.e(_tag, '解析OutlineGenerationChunk失败: $e, json: $json'); // 移除 stackTrace
// 抛出更具体的解析异常
throw ApiException(-1, '解析响应失败: $e');
}
},
eventName: 'outline-chunk',
);
}
@override
Stream<OutlineGenerationChunk> regenerateOutlineOption(
String novelId,
RegenerateOptionRequest request
) {
AppLogger.i(_tag, '重新生成单个剧情大纲选项: novelId=$novelId, optionId=${request.optionId}, configId=${request.selectedConfigId}');
return SseClient().streamEvents<OutlineGenerationChunk>(
path: '/novels/$novelId/next-outlines/regenerate-option',
method: SSERequestType.POST,
body: request.toJson(),
parser: (json) {
// 增强解析器的错误处理: 首先检查是否是已知的错误格式
if (json is Map<String, dynamic> && json.containsKey('code') && json.containsKey('message')) {
final errorMessage = json['message'] as String? ?? 'Unknown server error';
final errorCodeString = json['code'] as String?;
final errorCode = int.tryParse(errorCodeString ?? '') ?? -1; // 尝试解析为int失败则为-1
AppLogger.e(_tag, '服务器返回已知错误格式: code=${json['code']}, message=$errorMessage');
throw ApiException(errorCode, errorMessage); // 使用int类型的errorCode
}
// 再检查是否包含 'error' 字段的值是否非空 (兼容旧的或不同的错误格式)
else if (json is Map<String, dynamic> && json['error'] != null) {
final errorMessage = json['error'] as String? ?? 'Unknown server error';
AppLogger.e(_tag, '服务器返回错误字段: $errorMessage');
throw ApiException(-1, errorMessage); // 默认错误码-1
}
// 如果不是错误格式,则尝试解析为正常数据块
try {
return OutlineGenerationChunk.fromJson(json);
} catch (e, stackTrace) {
AppLogger.e(_tag, '解析OutlineGenerationChunk失败: $e, json: $json'); // 移除 stackTrace
// 抛出更具体的解析异常
throw ApiException(-1, '解析响应失败: $e');
}
},
eventName: 'outline-chunk',
);
}
@override
Future<SaveNextOutlineResponse> saveNextOutline(
String novelId,
SaveNextOutlineRequest request
) async {
AppLogger.i(_tag, '保存剧情大纲: novelId=$novelId, outlineId=${request.outlineId}, insertType=${request.insertType}');
try {
final response = await apiClient.post(
'/novels/$novelId/next-outlines/save',
data: request.toJson(),
);
return SaveNextOutlineResponse.fromJson(response);
} catch (e) {
AppLogger.e(_tag, '保存剧情大纲失败', e);
rethrow;
}
}
}

View File

@@ -0,0 +1,54 @@
import 'package:ainoval/models/novel_setting_item.dart';
import 'package:ainoval/services/api_service/base/api_client.dart';
import 'package:ainoval/services/api_service/repositories/novel_ai_repository.dart';
import 'package:ainoval/utils/logger.dart';
class NovelAIRepositoryImpl implements NovelAIRepository {
final ApiClient apiClient;
NovelAIRepositoryImpl({required this.apiClient});
@override
Future<List<NovelSettingItem>> generateNovelSettings({
required String novelId,
required String startChapterId,
String? endChapterId,
required List<String> settingTypes,
required int maxSettingsPerType,
required String additionalInstructions,
}) async {
AppLogger.i('NovelAIRepoImpl', 'Generating settings for novel $novelId');
try {
final response = await apiClient.post(
// Make sure the path matches your backend routing exactly
'/novels/$novelId/ai/generate-settings',
data: {
'startChapterId': startChapterId,
if (endChapterId != null && endChapterId.isNotEmpty) 'endChapterId': endChapterId,
'settingTypes': settingTypes,
'maxSettingsPerType': maxSettingsPerType,
'additionalInstructions': additionalInstructions,
},
);
if (response is List) {
final items = response
.map((json) => NovelSettingItem.fromJson(json as Map<String, dynamic>))
.toList();
AppLogger.i('NovelAIRepoImpl', 'Successfully generated ${items.length} setting items.');
return items;
} else if (response is Map<String, dynamic> && response.containsKey('error')) {
// Handle structured error from backend if any
AppLogger.e('NovelAIRepoImpl', 'Error from backend: ${response['message']}');
throw Exception('Failed to generate settings: ${response['message']}');
} else {
AppLogger.e('NovelAIRepoImpl', 'Unexpected response format for generateNovelSettings: $response');
throw Exception('Failed to parse generated settings: Unexpected response format');
}
} catch (e, stackTrace) {
AppLogger.e('NovelAIRepoImpl', 'Failed to generate novel settings via API', e, stackTrace);
// Rethrow a more specific error or a generic one
throw Exception('API call failed for generating settings: ${e.toString()}');
}
}
}

View File

@@ -0,0 +1,920 @@
import 'package:ainoval/config/app_config.dart';
import 'package:ainoval/models/import_status.dart';
import 'package:ainoval/models/novel_structure.dart';
import 'package:ainoval/models/scene_version.dart';
import 'package:ainoval/models/chapters_for_preload_dto.dart';
import 'package:ainoval/models/editor_settings.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/api_service/base/sse_client.dart';
import 'package:ainoval/services/api_service/repositories/novel_repository.dart';
import 'package:ainoval/utils/date_time_parser.dart';
import 'package:ainoval/utils/logger.dart';
/// 小说仓库实现
class NovelRepositoryImpl implements NovelRepository {
/// 工厂构造函数
factory NovelRepositoryImpl() {
return _instance;
}
/// 内部构造函数
NovelRepositoryImpl._internal({
ApiClient? apiClient,
SseClient? sseClient,
}) : _apiClient = apiClient ?? ApiClient(),
_sseClient = sseClient ?? SseClient();
/// 创建NovelRepositoryImpl单例
static final NovelRepositoryImpl _instance = NovelRepositoryImpl._internal();
final ApiClient _apiClient;
final SseClient _sseClient;
/// 获取当前用户ID
String? get _currentUserId => AppConfig.userId;
/// 获取当前认证 Token
String? get _authToken => AppConfig.authToken;
/// 工厂方法获取单例
static NovelRepositoryImpl getInstance() {
return _instance;
}
/// 获取所有小说
@override
Future<List<Novel>> fetchNovels() async {
try {
final userId = _currentUserId;
if (userId == null) {
throw ApiException(401, '未登录或用户ID不可用');
}
final data = await _apiClient.getNovelsByAuthor(userId);
return _convertToNovelList(data);
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'获取小说列表失败',
e);
rethrow;
}
}
/// 获取单个小说
@override
Future<Novel> fetchNovel(String id) async {
try {
final data = await _apiClient.getNovelDetailById(id);
final novel = _convertToSingleNovel(data);
if (novel == null) {
throw ApiException(404, '小说不存在或数据格式不正确');
}
return novel;
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'获取小说详情失败',
e);
rethrow;
}
}
/// 创建小说
@override
Future<Novel> createNovel(String title,
{String? description, String? coverImage}) async {
try {
// 获取当前用户ID
final userId = _currentUserId;
if (userId == null) {
throw ApiException(401, '未登录或用户ID不可用');
}
// 准备请求体
final body = {
'title': title,
'description': description ?? '',
'coverImage': coverImage,
'author': {'id': userId, 'username': AppConfig.username ?? 'user'},
'status': 'draft',
'structure': {'acts': []},
'metadata': {'wordCount': 0, 'readTime': 0, 'version': 1}
};
// 发送创建请求
final data = await _apiClient.createNovel(body);
final novel = _convertToSingleNovel(data);
if (novel == null) {
throw ApiException(-1, '创建小说失败:服务器返回的数据格式不正确');
}
return novel;
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'创建小说失败',
e);
throw ApiException(-1, '创建小说失败: $e');
}
}
/// 根据作者ID获取小说列表
@override
Future<List<Novel>> fetchNovelsByAuthor(String authorId) async {
try {
final data = await _apiClient.getNovelsByAuthor(authorId);
return _convertToNovelList(data);
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'获取作者小说列表失败',
e);
rethrow;
}
}
/// 搜索小说
@override
Future<List<Novel>> searchNovelsByTitle(String title) async {
try {
final data = await _apiClient.searchNovelsByTitle(title);
return _convertToNovelList(data);
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'搜索小说失败',
e);
rethrow;
}
}
/// 删除小说
@override
Future<void> deleteNovel(String id) async {
try {
await _apiClient.deleteNovel(id);
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'删除小说失败',
e);
throw ApiException(-1, '删除小说失败: $e');
}
}
/// 获取场景内容
@override
Future<Scene> fetchSceneContent(
String novelId, String actId, String chapterId, String sceneId) async {
try {
final data = await _apiClient.getSceneById(novelId, chapterId, sceneId);
return _convertToSceneModel(data);
} catch (e) {
// 如果获取失败特别是404可能场景尚未创建返回一个空场景
if (e is ApiException && e.statusCode == 404) {
AppLogger.w(
'Services/api_service/repositories/impl/novel_repository_impl',
'场景 $sceneId 未找到,返回空场景');
return Scene.createDefault(sceneId);
}
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'获取场景内容失败',
e);
rethrow;
}
}
/// 更新场景内容并保存历史版本
@override
Future<Scene> updateSceneContentWithHistory(String novelId, String chapterId,
String sceneId, String content, String userId, String reason) async {
try {
// 发送API请求
final data = await _apiClient.updateSceneWithHistory(
novelId, chapterId, sceneId, content, userId, reason);
// 解析响应
return Scene.fromJson(data);
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'更新场景内容并保存历史版本失败',
e);
throw ApiException(500, '更新场景内容并保存历史版本失败: $e');
}
}
/// 获取场景的历史版本列表
@override
Future<List<SceneHistoryEntry>> getSceneHistory(
String novelId, String chapterId, String sceneId) async {
try {
// 发送API请求
final data =
await _apiClient.getSceneHistory(novelId, chapterId, sceneId);
// 解析响应
return (data as List).map((e) => SceneHistoryEntry.fromJson(e)).toList();
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'获取场景历史版本失败',
e);
throw ApiException(500, '获取场景历史版本失败: $e');
}
}
/// 恢复场景到指定的历史版本
@override
Future<Scene> restoreSceneVersion(String novelId, String chapterId,
String sceneId, int historyIndex, String userId, String reason) async {
try {
// 发送API请求
// 注意API 路径中的 historyIndex 可能需要调整为 versionId这取决于后端实现
// 假设后端接受 historyIndex
final data = await _apiClient.restoreSceneVersion(
novelId, chapterId, sceneId, historyIndex, userId, reason);
// 解析响应
return Scene.fromJson(data);
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'恢复历史版本失败',
e);
throw ApiException(500, '恢复历史版本失败: $e');
}
}
/// 对比两个场景版本
@override
Future<SceneVersionDiff> compareSceneVersions(
String novelId,
String chapterId,
String sceneId,
int versionIndex1,
int versionIndex2) async {
try {
// 发送API请求
// 注意API 路径中的 versionIndex 可能需要调整为 versionId这取决于后端实现
// 假设后端接受 versionIndex
final data = await _apiClient.compareSceneVersions(
novelId, chapterId, sceneId, versionIndex1, versionIndex2);
// 解析响应
return SceneVersionDiff.fromJson(data);
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'比较版本差异失败',
e);
throw ApiException(500, '比较版本差异失败: $e');
}
}
/// 导入小说文件
@override
Future<String> importNovel(List<int> fileBytes, String fileName) async {
try {
return await _apiClient.importNovel(fileBytes, fileName);
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'导入小说文件失败',
e);
rethrow;
}
}
/// 获取导入任务状态流
@override
Stream<ImportStatus> getImportStatus(String jobId) {
final String path = '/novels/import/$jobId/status';
final String connectionId = 'import_$jobId';
try {
AppLogger.i(
'Services/api_service/repositories/impl/novel_repository_impl',
'Subscribing to SSE stream for job: $jobId at path: $path using SseClient');
return _sseClient.streamEvents<ImportStatus>(
path: path,
parser: ImportStatus.fromJson,
eventName: 'import-status',
connectionId: connectionId,
);
} catch (e, stack) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'获取导入状态流失败 (同步)',
e,
stack);
return Stream.error(
e is ApiException ? e : ApiException(-1, '获取导入状态流失败: $e'), stack);
}
}
/// 取消导入任务
@override
Future<bool> cancelImport(String jobId) async {
final String connectionId = 'import_$jobId';
try {
AppLogger.i(
'Services/api_service/repositories/impl/novel_repository_impl',
'取消导入任务 $jobId: 发送请求到服务器');
// 首先通过API向服务器发送取消请求
final bool apiCanceled = await _apiClient.cancelImport(jobId);
// 然后尝试取消SSE连接
final bool sseCanceled = await _sseClient.cancelConnection(connectionId);
// 只要有一个成功就算成功
final bool success = apiCanceled || sseCanceled;
AppLogger.i(
'Services/api_service/repositories/impl/novel_repository_impl',
'取消导入任务 $jobId: ${success ? '成功' : '失败或已完成'} (API: $apiCanceled, SSE: $sseCanceled)');
return success;
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'取消导入任务失败',
e);
return false;
}
}
// === 新的三步导入流程方法实现 ===
/// 第一步上传文件获取预览会话ID
@override
Future<String> uploadFileForPreview(List<int> fileBytes, String fileName) async {
try {
AppLogger.i(
'Services/api_service/repositories/impl/novel_repository_impl',
'上传文件获取预览: fileName=$fileName, size=${fileBytes.length}');
final result = await _apiClient.uploadFileForPreview(fileBytes, fileName);
AppLogger.i(
'Services/api_service/repositories/impl/novel_repository_impl',
'文件上传成功预览会话ID: $result');
return result;
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'上传文件获取预览失败',
e);
throw e;
}
}
/// 第二步:获取导入预览
@override
Future<Map<String, dynamic>> getImportPreview({
required String fileSessionId,
String? customTitle,
int? chapterLimit,
bool enableSmartContext = true,
bool enableAISummary = false,
String? aiConfigId,
int previewChapterCount = 10,
}) async {
try {
AppLogger.i(
'Services/api_service/repositories/impl/novel_repository_impl',
'获取导入预览: sessionId=$fileSessionId, title=$customTitle, chapterLimit=$chapterLimit');
final responseData = await _apiClient.getImportPreview(
fileSessionId: fileSessionId,
customTitle: customTitle,
chapterLimit: chapterLimit,
enableSmartContext: enableSmartContext,
enableAISummary: enableAISummary,
aiConfigId: aiConfigId,
previewChapterCount: previewChapterCount,
);
AppLogger.i(
'Services/api_service/repositories/impl/novel_repository_impl',
'获取导入预览成功: 响应数据获取完成');
return responseData;
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'获取导入预览失败',
e);
rethrow;
}
}
/// 第三步:确认并开始导入
@override
Future<String> confirmAndStartImport({
required String previewSessionId,
required String finalTitle,
List<int>? selectedChapterIndexes,
bool enableSmartContext = true,
bool enableAISummary = false,
String? aiConfigId,
}) async {
try {
AppLogger.i(
'Services/api_service/repositories/impl/novel_repository_impl',
'确认并开始导入: sessionId=$previewSessionId, title=$finalTitle, chapters=${selectedChapterIndexes?.length ?? "全部"}');
final jobId = await _apiClient.confirmAndStartImport(
previewSessionId: previewSessionId,
finalTitle: finalTitle,
selectedChapterIndexes: selectedChapterIndexes,
enableSmartContext: enableSmartContext,
enableAISummary: enableAISummary,
aiConfigId: aiConfigId,
);
AppLogger.i(
'Services/api_service/repositories/impl/novel_repository_impl',
'确认导入成功任务ID: $jobId');
return jobId;
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'确认并开始导入失败',
e);
throw e;
}
}
/// 清理预览会话
@override
Future<void> cleanupPreviewSession(String previewSessionId) async {
try {
AppLogger.i(
'Services/api_service/repositories/impl/novel_repository_impl',
'清理预览会话: sessionId=$previewSessionId');
await _apiClient.cleanupPreviewSession(previewSessionId);
AppLogger.i(
'Services/api_service/repositories/impl/novel_repository_impl',
'预览会话清理成功');
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'清理预览会话失败',
e);
throw e;
}
}
/// 将WebFlux响应转换为Novel列表
List<Novel> _convertToNovelList(dynamic data) {
final processedData = _handleFluxResponse(data);
if (processedData == null) {
return [];
}
if (processedData is List) {
// 处理列表数据
return processedData.map((item) {
if (item is Map<String, dynamic>) {
return _convertToNovelModel(item);
} else {
AppLogger.w(
'Services/api_service/repositories/impl/novel_repository_impl',
'警告:列表中的项目不是有效的小说数据: $item');
// 返回一个错误占位小说对象
final now = DateTime.now();
return Novel(
id: 'error_${now.millisecondsSinceEpoch}',
title: '数据错误',
createdAt: now,
updatedAt: now,
acts: [],
);
}
}).toList();
} else if (processedData is Map<String, dynamic>) {
// 处理单个对象
return [_convertToNovelModel(processedData)];
}
return [];
}
/// 将WebFlux响应转换为单个Novel
Novel? _convertToSingleNovel(dynamic data) {
final processedData = _handleFluxResponse(data);
if (processedData == null) {
return null;
}
if (processedData is List && processedData.isNotEmpty) {
// 如果是列表,取第一个元素
final firstItem = processedData.first;
if (firstItem is Map<String, dynamic>) {
return _convertToNovelModel(firstItem);
}
} else if (processedData is Map<String, dynamic>) {
// 如果是单个对象,直接转换
return _convertToNovelModel(processedData);
}
return null;
}
/// 将后端Novel模型转换为前端Novel模型
Novel _convertToNovelModel(Map<String, dynamic> json) {
// 检查是否为NovelWithScenesDto格式
bool isNovelWithScenesDto =
json.containsKey('novel') && json.containsKey('scenesByChapter');
// 如果是NovelWithScenesDto格式提取novel部分
Map<String, dynamic> novelData =
isNovelWithScenesDto ? json['novel'] as Map<String, dynamic> : json;
Map<String, List<dynamic>>? scenesByChapter = isNovelWithScenesDto
? (json['scenesByChapter'] as Map<String, dynamic>)
.map((key, value) => MapEntry(key, value as List<dynamic>))
: null;
// 提取结构信息
final structure = novelData['structure'] as Map<String, dynamic>? ?? {};
final acts = (structure['acts'] as List?)?.map((actJson) {
final act = actJson as Map<String, dynamic>;
final chapters = (act['chapters'] as List?)?.map((chapterJson) {
final chapter = chapterJson as Map<String, dynamic>;
// 章节ID
final chapterId = chapter['id'];
// 获取场景ID列表
List<String> sceneIds = [];
if (chapter['sceneIds'] != null && chapter['sceneIds'] is List) {
//AppLogger.d('NovelRepositoryImpl', 'Found sceneIds in chapter ${chapter['id']}: ${chapter['sceneIds']}');
sceneIds = (chapter['sceneIds'] as List)
.map((id) => id.toString())
.toList();
//AppLogger.d('NovelRepositoryImpl', 'Parsed ${sceneIds.length} sceneIds');
} else {
//AppLogger.d('NovelRepositoryImpl', 'No sceneIds found in chapter ${chapter['id']}');
}
// 如果是NovelWithScenesDto格式且有该章节的场景数据添加场景
List<Scene> scenes = [];
if (isNovelWithScenesDto &&
scenesByChapter != null &&
scenesByChapter.containsKey(chapterId)) {
scenes = scenesByChapter[chapterId]!
.map((sceneJson) =>
Scene.fromJson(sceneJson as Map<String, dynamic>))
.toList();
}
return Chapter(
id: chapterId,
title: chapter['title'],
order: chapter['order'],
scenes: scenes,
sceneIds: sceneIds, // 添加场景ID列表
);
}).toList() ??
[];
return Act(
id: act['id'],
title: act['title'],
order: act['order'],
chapters: chapters,
);
}).toList() ??
[];
// 提取元数据
final metadata = novelData['metadata'] as Map<String, dynamic>? ?? {};
// 从元数据中获取字数和其他信息
final wordCount = metadata['wordCount'] as int? ?? 0;
final readTime = metadata['readTime'] as int? ?? 0;
final version = metadata['version'] as int? ?? 1;
final contributors = (metadata['contributors'] as List?)?.cast<String>() ?? <String>[];
// 解析创建时间和更新时间
DateTime createdAt;
DateTime updatedAt;
try {
// 使用新的工具函数解析 createdAt 和 updatedAt
createdAt = parseBackendDateTime(novelData['createdAt']);
updatedAt = parseBackendDateTime(novelData['updatedAt']);
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'解析小说时间戳失败',
e);
createdAt = DateTime.now();
updatedAt = DateTime.now();
}
// 创建Author对象
Author? author;
if (novelData['author'] != null) {
final authorData = novelData['author'] as Map<String, dynamic>;
author = Author(
id: authorData['id'] ?? '',
username: authorData['username'] ?? '未知作者',
);
}
return Novel(
id: novelData['id'],
title: novelData['title'] ?? '无标题',
coverUrl: novelData['coverImage'] ?? '',
createdAt: createdAt,
updatedAt: updatedAt,
acts: acts,
lastEditedChapterId: novelData['lastEditedChapterId'],
author: author,
wordCount: wordCount, // 使用从元数据提取的字数
readTime: readTime, // 使用从元数据提取的阅读时间
version: version, // 使用从元数据提取的版本号
contributors: contributors, // 使用从元数据提取的贡献者列表
);
}
/// 将后端Scene模型转换为前端Scene模型
Scene _convertToSceneModel(Map<String, dynamic> json) {
// 解析更新时间
DateTime lastEdited;
if (json.containsKey('updatedAt')) {
lastEdited = parseBackendDateTime(json['updatedAt']);
} else {
lastEdited = DateTime.now();
}
final sceneId =
json['id'] ?? 'scene_${DateTime.now().millisecondsSinceEpoch}';
return Scene(
id: sceneId,
content: json['content'] ?? '',
wordCount: json['wordCount'] ?? 0,
summary: Summary(
id: 'summary_$sceneId',
content: json['summary'] ?? '',
),
lastEdited: lastEdited,
);
}
/// 处理WebFlux流式响应数据统一处理数据类型
dynamic _handleFluxResponse(dynamic data) {
if (data == null) return null;
// 如果是列表类型确保列表中的每个元素都是Map类型
if (data is List) {
return data;
}
// 如果是Map类型直接返回
else if (data is Map<String, dynamic>) {
return data;
}
// 其他类型记录警告并返回null
else {
AppLogger.w(
'Services/api_service/repositories/impl/novel_repository_impl',
'警告API返回了意外的数据类型: ${data.runtimeType}');
return null;
}
}
/// 更新场景内容
@override
Future<Scene> updateSceneContent(String novelId, String actId,
String chapterId, String sceneId, Scene scene) async {
try {
// 将前端模型转换为后端模型
final sceneJson = {
'id': scene.id,
'novelId': novelId,
'chapterId': chapterId,
'content': scene.content,
'summary': scene.summary.content,
'wordCount': scene.wordCount,
'title': '场景 ${scene.id}', // 添加标题
};
// 使用新的API路径更新场景内容
final data = await _apiClient.updateScene(sceneJson);
return _convertToSceneModel(data);
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'更新场景内容失败',
e);
rethrow;
}
}
/// 更新摘要内容
@override
Future<Summary> updateSummary(String novelId, String actId, String chapterId,
String sceneId, Summary summary) async {
try {
// 获取当前场景
final scene = await fetchSceneContent(novelId, actId, chapterId, sceneId);
// 更新摘要
final updatedScene = await updateSceneContent(
novelId,
actId,
chapterId,
sceneId,
scene.copyWith(summary: summary),
);
return updatedScene.summary;
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'更新摘要失败',
e);
rethrow;
}
}
/// 获取当前章节后面指定数量的章节和场景内容
@override
Future<Novel?> fetchChaptersAfter(String novelId, String currentChapterId, {int chaptersLimit = 3, bool includeCurrentChapter = true}) async {
try {
AppLogger.i('NovelRepositoryImpl/fetchChaptersAfter',
'获取后续章节: novelId=$novelId, currentChapterId=$currentChapterId, limit=$chaptersLimit, includeCurrentChapter=$includeCurrentChapter');
// 调用新的API接口
final data = await _apiClient.getChaptersAfter(
novelId,
currentChapterId,
chaptersLimit: chaptersLimit,
includeCurrentChapter: includeCurrentChapter
);
if (data == null) {
AppLogger.w('NovelRepositoryImpl/fetchChaptersAfter', '后端返回空数据');
return null;
}
// 转换数据格式
final novel = _convertToNovelModel(data);
AppLogger.i('NovelRepositoryImpl/fetchChaptersAfter',
'获取后续章节成功: $novelId, 返回章节数: ${novel.acts.fold(0, (sum, act) => sum + act.chapters.length)}');
return novel;
} catch (e) {
AppLogger.e('NovelRepositoryImpl/fetchChaptersAfter',
'获取后续章节失败', e);
return null;
}
}
/// 获取指定章节后面的章节列表(用于预加载)
@override
Future<ChaptersForPreloadDto?> fetchChaptersForPreload(
String novelId,
String currentChapterId, {
int chaptersLimit = 3,
bool includeCurrentChapter = false,
}) async {
try {
AppLogger.i('NovelRepositoryImpl/fetchChaptersForPreload',
'获取章节列表用于预加载: novelId=$novelId, currentChapterId=$currentChapterId, chaptersLimit=$chaptersLimit, includeCurrentChapter=$includeCurrentChapter');
// 调用后端API
final requestData = {
'novelId': novelId,
'currentChapterId': currentChapterId,
'chaptersLimit': chaptersLimit,
'includeCurrentChapter': includeCurrentChapter,
};
final data = await _apiClient.post('/novels/get-chapters-for-preload', data: requestData);
if (data == null) {
AppLogger.w('NovelRepositoryImpl/fetchChaptersForPreload', '后端返回空数据');
return null;
}
// 将后端返回的数据转换为DTO
final dto = ChaptersForPreloadDto.fromJson(data);
AppLogger.i('NovelRepositoryImpl/fetchChaptersForPreload',
'成功获取章节列表用于预加载: novelId=$novelId, 章节数=${dto.chapterCount}, 场景章节数=${dto.scenesByChapter.keys.length}');
return dto;
} catch (e) {
AppLogger.e('NovelRepositoryImpl/fetchChaptersForPreload',
'获取章节列表用于预加载失败', e);
return null;
}
}
@override
Future<Novel> fetchNovelOnlyStructure(String id) {
// TODO: implement fetchNovelOnlyStructure
throw UnimplementedError();
}
@override
Future<Novel> fetchNovelText(String id) async {
try {
final data = await _apiClient.getNovelDetailByIdText(id);
final novel = _convertToSingleNovel(data);
if (novel == null) {
throw ApiException(404, '小说不存在或数据格式不正确');
}
return novel;
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/novel_repository_impl',
'获取小说详情失败',
e);
rethrow;
}
}
/// 获取用户编辑器设置
Future<EditorSettings> getUserEditorSettings(String userId) async {
try {
AppLogger.d('NovelRepositoryImpl', '获取用户编辑器设置: userId=$userId');
final data = await _apiClient.getUserEditorSettings(userId);
if (data != null && data is Map<String, dynamic>) {
final settings = EditorSettings.fromJson(data);
AppLogger.d('NovelRepositoryImpl', '成功获取用户编辑器设置');
return settings;
} else {
AppLogger.w('NovelRepositoryImpl', '获取到空的编辑器设置,使用默认设置');
return const EditorSettings();
}
} catch (e) {
AppLogger.e('NovelRepositoryImpl', '获取用户编辑器设置失败,使用默认设置', e);
return const EditorSettings();
}
}
/// 保存用户编辑器设置
Future<EditorSettings> saveUserEditorSettings(String userId, EditorSettings settings) async {
try {
AppLogger.d('NovelRepositoryImpl', '保存用户编辑器设置: userId=$userId');
final data = await _apiClient.saveUserEditorSettings(userId, settings.toJson());
if (data != null && data is Map<String, dynamic>) {
final savedSettings = EditorSettings.fromJson(data);
AppLogger.d('NovelRepositoryImpl', '成功保存用户编辑器设置');
return savedSettings;
} else {
AppLogger.w('NovelRepositoryImpl', '保存编辑器设置响应格式不正确,返回原设置');
return settings;
}
} catch (e) {
AppLogger.e('NovelRepositoryImpl', '保存用户编辑器设置失败', e);
throw ApiException(-1, '保存编辑器设置失败: $e');
}
}
/// 重置用户编辑器设置为默认值
Future<EditorSettings> resetUserEditorSettings(String userId) async {
try {
AppLogger.d('NovelRepositoryImpl', '重置用户编辑器设置: userId=$userId');
final data = await _apiClient.resetUserEditorSettings(userId);
if (data != null && data is Map<String, dynamic>) {
final resetSettings = EditorSettings.fromJson(data);
AppLogger.d('NovelRepositoryImpl', '成功重置用户编辑器设置');
return resetSettings;
} else {
AppLogger.w('NovelRepositoryImpl', '重置编辑器设置响应格式不正确,返回默认设置');
return const EditorSettings();
}
} catch (e) {
AppLogger.e('NovelRepositoryImpl', '重置用户编辑器设置失败', e);
throw ApiException(-1, '重置编辑器设置失败: $e');
}
}
}

View File

@@ -0,0 +1,519 @@
import 'dart:async';
import 'package:ainoval/models/novel_setting_item.dart';
import 'package:ainoval/models/setting_group.dart';
import 'package:ainoval/services/api_service/base/api_client.dart';
import 'package:ainoval/services/api_service/repositories/novel_setting_repository.dart';
import 'package:ainoval/utils/logger.dart';
/// 小说设定仓储实现
class NovelSettingRepositoryImpl implements NovelSettingRepository {
NovelSettingRepositoryImpl({required this.apiClient});
final ApiClient apiClient;
// API路径基础部分
String _getBasePath(String novelId) => '/novels/$novelId/settings';
// ==================== 设定条目管理 ====================
@override
Future<NovelSettingItem> createSettingItem({
required String novelId,
required NovelSettingItem settingItem,
}) async {
AppLogger.i('NovelSettingRepoImpl', '创建设定条目: novelId=$novelId, name=${settingItem.name}');
try {
final response = await apiClient.post(
'${_getBasePath(novelId)}/items/create',
data: settingItem.toJson(),
);
final result = NovelSettingItem.fromJson(response);
AppLogger.i('NovelSettingRepoImpl', '创建设定条目成功: id=${result.id}');
return result;
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '创建设定条目失败', e, stackTrace);
rethrow;
}
}
@override
Future<List<NovelSettingItem>> getNovelSettingItems({
required String novelId,
String? type,
String? name,
int? priority,
String? generatedBy,
String? status,
required int page,
required int size,
required String sortBy,
required String sortDirection,
}) async {
AppLogger.i('NovelSettingRepoImpl', '获取设定条目列表: novelId=$novelId');
try {
final response = await apiClient.post(
'${_getBasePath(novelId)}/items/list',
data: {
'type': type,
'name': name,
'priority': priority,
'generatedBy': generatedBy,
'status': status,
'page': page,
'size': size,
'sortBy': sortBy,
'sortDirection': sortDirection,
},
);
final List<dynamic> itemsJson = response;
final items = itemsJson
.map((json) => NovelSettingItem.fromJson(json))
.toList();
AppLogger.i('NovelSettingRepoImpl', '获取设定条目列表成功: count=${items.length}');
return items;
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '获取设定条目列表失败', e, stackTrace);
rethrow;
}
}
@override
Future<NovelSettingItem> getSettingItemDetail({
required String novelId,
required String itemId,
}) async {
AppLogger.i('NovelSettingRepoImpl', '获取设定条目详情: novelId=$novelId, itemId=$itemId');
try {
final response = await apiClient.post(
'${_getBasePath(novelId)}/items/detail',
data: {
'itemId': itemId,
},
);
final result = NovelSettingItem.fromJson(response);
AppLogger.i('NovelSettingRepoImpl', '获取设定条目详情成功: id=${result.id}');
return result;
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '获取设定条目详情失败', e, stackTrace);
rethrow;
}
}
@override
Future<NovelSettingItem> updateSettingItem({
required String novelId,
required String itemId,
required NovelSettingItem settingItem,
}) async {
AppLogger.i('NovelSettingRepoImpl', '更新设定条目: novelId=$novelId, itemId=$itemId');
try {
final response = await apiClient.post(
'${_getBasePath(novelId)}/items/update',
data: {
'itemId': itemId,
'settingItem': settingItem.toJson(),
},
);
final result = NovelSettingItem.fromJson(response);
AppLogger.i('NovelSettingRepoImpl', '更新设定条目成功: id=${result.id}');
return result;
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '更新设定条目失败', e, stackTrace);
rethrow;
}
}
@override
Future<void> deleteSettingItem({
required String novelId,
required String itemId,
}) async {
AppLogger.i('NovelSettingRepoImpl', '删除设定条目: novelId=$novelId, itemId=$itemId');
try {
await apiClient.post(
'${_getBasePath(novelId)}/items/delete',
data: {
'itemId': itemId,
},
);
AppLogger.i('NovelSettingRepoImpl', '删除设定条目成功');
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '删除设定条目失败', e, stackTrace);
rethrow;
}
}
@override
Future<NovelSettingItem> addSettingRelationship({
required String novelId,
required String itemId,
required String targetItemId,
required String relationshipType,
String? description,
}) async {
AppLogger.i('NovelSettingRepoImpl',
'添加设定关系: novelId=$novelId, itemId=$itemId, targetItemId=$targetItemId');
try {
final response = await apiClient.post(
'${_getBasePath(novelId)}/items/add-relationship',
data: {
'itemId': itemId,
'targetItemId': targetItemId,
'relationshipType': relationshipType,
'description': description,
},
);
final result = NovelSettingItem.fromJson(response);
AppLogger.i('NovelSettingRepoImpl', '添加设定关系成功');
return result;
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '添加设定关系失败', e, stackTrace);
rethrow;
}
}
@override
Future<void> removeSettingRelationship({
required String novelId,
required String itemId,
required String targetItemId,
required String relationshipType,
}) async {
AppLogger.i('NovelSettingRepoImpl',
'删除设定关系: novelId=$novelId, itemId=$itemId, targetItemId=$targetItemId');
try {
await apiClient.post(
'${_getBasePath(novelId)}/items/remove-relationship',
data: {
'itemId': itemId,
'targetItemId': targetItemId,
'relationshipType': relationshipType,
},
);
AppLogger.i('NovelSettingRepoImpl', '删除设定关系成功');
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '删除设定关系失败', e, stackTrace);
rethrow;
}
}
@override
Future<NovelSettingItem> setParentChildRelationship({
required String novelId,
required String childId,
required String parentId,
}) async {
AppLogger.i('NovelSettingRepoImpl', '设置父子关系: novelId=$novelId, childId=$childId, parentId=$parentId');
try {
final response = await apiClient.post(
'${_getBasePath(novelId)}/items/set-parent',
data: {
'childId': childId,
'parentId': parentId,
'description': null,
},
);
final result = NovelSettingItem.fromJson(response);
AppLogger.i('NovelSettingRepoImpl', '设置父子关系成功: id=${result.id}');
return result;
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '设置父子关系失败', e, stackTrace);
rethrow;
}
}
@override
Future<NovelSettingItem> removeParentChildRelationship({
required String novelId,
required String childId,
}) async {
AppLogger.i('NovelSettingRepoImpl', '移除父子关系: novelId=$novelId, childId=$childId');
try {
final response = await apiClient.post(
'${_getBasePath(novelId)}/items/remove-parent',
data: {
'childId': childId,
'parentId': null,
'description': null,
},
);
final result = NovelSettingItem.fromJson(response);
AppLogger.i('NovelSettingRepoImpl', '移除父子关系成功: id=${result.id}');
return result;
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '移除父子关系失败', e, stackTrace);
rethrow;
}
}
// ==================== 设定组管理 ====================
@override
Future<SettingGroup> createSettingGroup({
required String novelId,
required SettingGroup settingGroup,
}) async {
AppLogger.i('NovelSettingRepoImpl', '创建设定组: novelId=$novelId, name=${settingGroup.name}');
try {
final response = await apiClient.post(
'${_getBasePath(novelId)}/groups/create',
data: settingGroup.toJson(),
);
final result = SettingGroup.fromJson(response);
AppLogger.i('NovelSettingRepoImpl', '创建设定组成功: id=${result.id}');
return result;
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '创建设定组失败', e, stackTrace);
rethrow;
}
}
@override
Future<List<SettingGroup>> getNovelSettingGroups({
required String novelId,
String? name,
bool? isActiveContext,
}) async {
AppLogger.i('NovelSettingRepoImpl', '获取设定组列表: novelId=$novelId');
try {
final response = await apiClient.post(
'${_getBasePath(novelId)}/groups/list',
data: {
'name': name,
'isActiveContext': isActiveContext,
},
);
final List<dynamic> groupsJson = response;
final groups = groupsJson
.map((json) => SettingGroup.fromJson(json))
.toList();
AppLogger.i('NovelSettingRepoImpl', '获取设定组列表成功: count=${groups.length}');
return groups;
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '获取设定组列表失败', e, stackTrace);
rethrow;
}
}
@override
Future<SettingGroup> getSettingGroupDetail({
required String novelId,
required String groupId,
}) async {
AppLogger.i('NovelSettingRepoImpl', '获取设定组详情: novelId=$novelId, groupId=$groupId');
try {
final response = await apiClient.post(
'${_getBasePath(novelId)}/groups/detail',
data: {
'groupId': groupId,
},
);
final result = SettingGroup.fromJson(response);
AppLogger.i('NovelSettingRepoImpl', '获取设定组详情成功: id=${result.id}');
return result;
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '获取设定组详情失败', e, stackTrace);
rethrow;
}
}
@override
Future<SettingGroup> updateSettingGroup({
required String novelId,
required String groupId,
required SettingGroup settingGroup,
}) async {
AppLogger.i('NovelSettingRepoImpl', '更新设定组: novelId=$novelId, groupId=$groupId');
try {
final response = await apiClient.post(
'${_getBasePath(novelId)}/groups/update',
data: {
'groupId': groupId,
'settingGroup': settingGroup.toJson(),
},
);
final result = SettingGroup.fromJson(response);
AppLogger.i('NovelSettingRepoImpl', '更新设定组成功: id=${result.id}');
return result;
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '更新设定组失败', e, stackTrace);
rethrow;
}
}
@override
Future<void> deleteSettingGroup({
required String novelId,
required String groupId,
}) async {
AppLogger.i('NovelSettingRepoImpl', '删除设定组: novelId=$novelId, groupId=$groupId');
try {
await apiClient.post(
'${_getBasePath(novelId)}/groups/delete',
data: {
'groupId': groupId,
},
);
AppLogger.i('NovelSettingRepoImpl', '删除设定组成功');
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '删除设定组失败', e, stackTrace);
rethrow;
}
}
@override
Future<SettingGroup> addItemToGroup({
required String novelId,
required String groupId,
required String itemId,
}) async {
AppLogger.i('NovelSettingRepoImpl',
'添加条目到设定组: novelId=$novelId, groupId=$groupId, itemId=$itemId');
try {
final response = await apiClient.post(
'${_getBasePath(novelId)}/groups/add-item',
data: {
'groupId': groupId,
'itemId': itemId,
},
);
final result = SettingGroup.fromJson(response);
AppLogger.i('NovelSettingRepoImpl', '添加条目到设定组成功');
return result;
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '添加条目到设定组失败', e, stackTrace);
rethrow;
}
}
@override
Future<void> removeItemFromGroup({
required String novelId,
required String groupId,
required String itemId,
}) async {
AppLogger.i('NovelSettingRepoImpl',
'从设定组移除条目: novelId=$novelId, groupId=$groupId, itemId=$itemId');
try {
await apiClient.post(
'${_getBasePath(novelId)}/groups/remove-item',
data: {
'groupId': groupId,
'itemId': itemId,
},
);
AppLogger.i('NovelSettingRepoImpl', '从设定组移除条目成功');
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '从设定组移除条目失败', e, stackTrace);
rethrow;
}
}
@override
Future<SettingGroup> setGroupActiveContext({
required String novelId,
required String groupId,
required bool isActive,
}) async {
AppLogger.i('NovelSettingRepoImpl',
'设置设定组激活状态: novelId=$novelId, groupId=$groupId, isActive=$isActive');
try {
final response = await apiClient.post(
'${_getBasePath(novelId)}/groups/set-active',
data: {
'groupId': groupId,
'active': isActive,
},
);
final result = SettingGroup.fromJson(response);
AppLogger.i('NovelSettingRepoImpl', '设置设定组激活状态成功');
return result;
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '设置设定组激活状态失败', e, stackTrace);
rethrow;
}
}
// ==================== 高级功能 ====================
@override
Future<List<NovelSettingItem>> extractSettingsFromText({
required String novelId,
required String text,
required String type,
}) async {
AppLogger.i('NovelSettingRepoImpl', '从文本提取设定: novelId=$novelId, type=$type');
try {
final response = await apiClient.post(
'${_getBasePath(novelId)}/extract',
data: {
'text': text,
'type': type,
},
);
final List<dynamic> itemsJson = response;
final items = itemsJson
.map((json) => NovelSettingItem.fromJson(json))
.toList();
AppLogger.i('NovelSettingRepoImpl', '从文本提取设定成功: count=${items.length}');
return items;
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '从文本提取设定失败', e, stackTrace);
rethrow;
}
}
@override
Future<List<NovelSettingItem>> searchSettingItems({
required String novelId,
required String query,
List<String>? types,
List<String>? groupIds,
double? minScore,
int? maxResults,
}) async {
AppLogger.i('NovelSettingRepoImpl', '搜索设定条目: novelId=$novelId, query=$query');
try {
final response = await apiClient.post(
'${_getBasePath(novelId)}/search',
data: {
'query': query,
'types': types,
'groupIds': groupIds,
'minScore': minScore,
'maxResults': maxResults,
},
);
final List<dynamic> itemsJson = response;
final items = itemsJson
.map((json) => NovelSettingItem.fromJson(json))
.toList();
AppLogger.i('NovelSettingRepoImpl', '搜索设定条目成功: count=${items.length}');
return items;
} catch (e, stackTrace) {
AppLogger.e('NovelSettingRepoImpl', '搜索设定条目失败', e, stackTrace);
rethrow;
}
}
}

View File

@@ -0,0 +1,253 @@
import 'package:ainoval/models/novel_snippet.dart';
import 'package:ainoval/services/api_service/base/api_client.dart';
import 'package:ainoval/services/api_service/repositories/novel_snippet_repository.dart';
import 'package:ainoval/utils/logger.dart';
/// 小说片段仓储实现类
///
/// 使用ApiClient调用后端API实现片段相关操作
class NovelSnippetRepositoryImpl implements NovelSnippetRepository {
final ApiClient _apiClient;
NovelSnippetRepositoryImpl(this._apiClient);
@override
Future<NovelSnippet> createSnippet(CreateSnippetRequest request) async {
try {
AppLogger.i('NovelSnippetRepository', '创建片段: ${request.title}');
final response = await _apiClient.createSnippet(request.toJson());
if (response is Map<String, dynamic>) {
return NovelSnippet.fromJson(response);
} else {
throw Exception('创建片段响应格式错误: $response');
}
} catch (e) {
AppLogger.e('NovelSnippetRepository', '创建片段失败', e);
rethrow;
}
}
@override
Future<SnippetPageResult<NovelSnippet>> getSnippetsByNovelId(
String novelId, {
int page = 0,
int size = 20,
}) async {
try {
AppLogger.d('NovelSnippetRepository', '获取小说片段列表: novelId=$novelId, page=$page, size=$size');
final response = await _apiClient.getSnippetsByNovelId(novelId, page: page, size: size);
if (response is Map<String, dynamic>) {
return SnippetPageResult.fromJson(
response,
(json) => NovelSnippet.fromJson(json as Map<String, dynamic>),
);
} else {
throw Exception('获取片段列表响应格式错误: $response');
}
} catch (e) {
AppLogger.e('NovelSnippetRepository', '获取小说片段列表失败: novelId=$novelId', e);
rethrow;
}
}
@override
Future<NovelSnippet> getSnippetDetail(String snippetId) async {
try {
AppLogger.d('NovelSnippetRepository', '获取片段详情: snippetId=$snippetId');
final response = await _apiClient.getSnippetDetail(snippetId);
if (response is Map<String, dynamic>) {
return NovelSnippet.fromJson(response);
} else {
throw Exception('获取片段详情响应格式错误: $response');
}
} catch (e) {
AppLogger.e('NovelSnippetRepository', '获取片段详情失败: snippetId=$snippetId', e);
rethrow;
}
}
@override
Future<NovelSnippet> updateSnippetContent(UpdateSnippetContentRequest request) async {
try {
AppLogger.i('NovelSnippetRepository', '更新片段内容: snippetId=${request.snippetId}');
final response = await _apiClient.updateSnippetContent(request.toJson());
if (response is Map<String, dynamic>) {
return NovelSnippet.fromJson(response);
} else {
throw Exception('更新片段内容响应格式错误: $response');
}
} catch (e) {
AppLogger.e('NovelSnippetRepository', '更新片段内容失败: snippetId=${request.snippetId}', e);
rethrow;
}
}
@override
Future<NovelSnippet> updateSnippetTitle(UpdateSnippetTitleRequest request) async {
try {
AppLogger.i('NovelSnippetRepository', '更新片段标题: snippetId=${request.snippetId}');
final response = await _apiClient.updateSnippetTitle(request.toJson());
if (response is Map<String, dynamic>) {
return NovelSnippet.fromJson(response);
} else {
throw Exception('更新片段标题响应格式错误: $response');
}
} catch (e) {
AppLogger.e('NovelSnippetRepository', '更新片段标题失败: snippetId=${request.snippetId}', e);
rethrow;
}
}
@override
Future<NovelSnippet> updateSnippetFavorite(UpdateSnippetFavoriteRequest request) async {
try {
AppLogger.i('NovelSnippetRepository', '更新片段收藏状态: snippetId=${request.snippetId}, isFavorite=${request.isFavorite}');
final response = await _apiClient.updateSnippetFavorite(request.toJson());
if (response is Map<String, dynamic>) {
return NovelSnippet.fromJson(response);
} else {
throw Exception('更新片段收藏状态响应格式错误: $response');
}
} catch (e) {
AppLogger.e('NovelSnippetRepository', '更新片段收藏状态失败: snippetId=${request.snippetId}', e);
rethrow;
}
}
@override
Future<SnippetPageResult<NovelSnippetHistory>> getSnippetHistory(
String snippetId, {
int page = 0,
int size = 10,
}) async {
try {
AppLogger.d('NovelSnippetRepository', '获取片段历史记录: snippetId=$snippetId, page=$page, size=$size');
final response = await _apiClient.getSnippetHistory(snippetId, page: page, size: size);
if (response is Map<String, dynamic>) {
return SnippetPageResult.fromJson(
response,
(json) => NovelSnippetHistory.fromJson(json as Map<String, dynamic>),
);
} else {
throw Exception('获取片段历史记录响应格式错误: $response');
}
} catch (e) {
AppLogger.e('NovelSnippetRepository', '获取片段历史记录失败: snippetId=$snippetId', e);
rethrow;
}
}
@override
Future<NovelSnippetHistory> previewHistoryVersion(String snippetId, int version) async {
try {
AppLogger.d('NovelSnippetRepository', '预览历史版本: snippetId=$snippetId, version=$version');
final response = await _apiClient.previewSnippetHistoryVersion(snippetId, version);
if (response is Map<String, dynamic>) {
return NovelSnippetHistory.fromJson(response);
} else {
throw Exception('预览历史版本响应格式错误: $response');
}
} catch (e) {
AppLogger.e('NovelSnippetRepository', '预览历史版本失败: snippetId=$snippetId, version=$version', e);
rethrow;
}
}
@override
Future<NovelSnippet> revertToHistoryVersion(RevertSnippetVersionRequest request) async {
try {
AppLogger.i('NovelSnippetRepository', '回退到历史版本: snippetId=${request.snippetId}, version=${request.version}');
final response = await _apiClient.revertSnippetToVersion(request.toJson());
if (response is Map<String, dynamic>) {
return NovelSnippet.fromJson(response);
} else {
throw Exception('回退到历史版本响应格式错误: $response');
}
} catch (e) {
AppLogger.e('NovelSnippetRepository', '回退到历史版本失败: snippetId=${request.snippetId}, version=${request.version}', e);
rethrow;
}
}
@override
Future<void> deleteSnippet(String snippetId) async {
try {
AppLogger.i('NovelSnippetRepository', '删除片段: snippetId=$snippetId');
await _apiClient.deleteSnippet(snippetId);
AppLogger.i('NovelSnippetRepository', '片段删除成功: snippetId=$snippetId');
} catch (e) {
AppLogger.e('NovelSnippetRepository', '删除片段失败: snippetId=$snippetId', e);
rethrow;
}
}
@override
Future<SnippetPageResult<NovelSnippet>> getFavoriteSnippets({
int page = 0,
int size = 20,
}) async {
try {
AppLogger.d('NovelSnippetRepository', '获取收藏片段: page=$page, size=$size');
final response = await _apiClient.getFavoriteSnippets(page: page, size: size);
if (response is Map<String, dynamic>) {
return SnippetPageResult.fromJson(
response,
(json) => NovelSnippet.fromJson(json as Map<String, dynamic>),
);
} else {
throw Exception('获取收藏片段响应格式错误: $response');
}
} catch (e) {
AppLogger.e('NovelSnippetRepository', '获取收藏片段失败', e);
rethrow;
}
}
@override
Future<SnippetPageResult<NovelSnippet>> searchSnippets(
String novelId,
String searchText, {
int page = 0,
int size = 20,
}) async {
try {
AppLogger.d('NovelSnippetRepository', '搜索片段: novelId=$novelId, searchText=$searchText, page=$page, size=$size');
final response = await _apiClient.searchSnippets(novelId, searchText, page: page, size: size);
if (response is Map<String, dynamic>) {
return SnippetPageResult.fromJson(
response,
(json) => NovelSnippet.fromJson(json as Map<String, dynamic>),
);
} else {
throw Exception('搜索片段响应格式错误: $response');
}
} catch (e) {
AppLogger.e('NovelSnippetRepository', '搜索片段失败: novelId=$novelId, searchText=$searchText', e);
rethrow;
}
}
}

View File

@@ -0,0 +1,262 @@
import 'package:ainoval/models/preset_models.dart';
import 'package:ainoval/services/api_service/base/api_client.dart';
import 'package:ainoval/services/api_service/repositories/preset_aggregation_repository.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:dio/dio.dart';
/// 预设聚合仓储实现
class PresetAggregationRepositoryImpl implements PresetAggregationRepository {
final ApiClient _apiClient;
static const String _baseUrl = '/preset-aggregation';
static const String _tag = 'PresetAggregationRepositoryImpl';
/// 构造函数
PresetAggregationRepositoryImpl(this._apiClient);
@override
Future<PresetPackage> getCompletePresetPackage(
String featureType, {
String? novelId,
}) async {
try {
final Map<String, dynamic> queryParams = {
'featureType': featureType,
};
if (novelId != null) {
queryParams['novelId'] = novelId;
}
// 构建查询字符串
String url = '$_baseUrl/package';
if (queryParams.isNotEmpty) {
final queryString = queryParams.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value.toString())}')
.join('&');
url = '$url?$queryString';
}
final result = await _apiClient.get(url);
return PresetPackage.fromJson(result);
} catch (e) {
AppLogger.e(_tag, '获取完整预设包失败: featureType=$featureType, novelId=$novelId', e);
// 返回空的预设包作为降级处理
return PresetPackage(
featureType: featureType,
systemPresets: [],
userPresets: [],
favoritePresets: [],
quickAccessPresets: [],
recentlyUsedPresets: [],
totalCount: 0,
cachedAt: DateTime.now(),
);
}
}
@override
Future<UserPresetOverview> getUserPresetOverview() async {
try {
final result = await _apiClient.get('$_baseUrl/overview');
return UserPresetOverview.fromJson(result);
} catch (e) {
AppLogger.e(_tag, '获取用户预设概览失败', e);
// 返回空的概览作为降级处理
return UserPresetOverview(
totalPresets: 0,
systemPresets: 0,
userPresets: 0,
favoritePresets: 0,
presetsByFeatureType: {},
recentFeatureTypes: [],
popularTags: [],
generatedAt: DateTime.now(),
);
}
}
@override
Future<Map<String, PresetPackage>> getBatchPresetPackages({
List<String>? featureTypes,
String? novelId,
}) async {
try {
final Map<String, dynamic> queryParams = {};
if (featureTypes != null && featureTypes.isNotEmpty) {
queryParams['featureTypes'] = featureTypes.join(',');
}
if (novelId != null) {
queryParams['novelId'] = novelId;
}
// 构建查询字符串
String url = '$_baseUrl/batch';
if (queryParams.isNotEmpty) {
final queryString = queryParams.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value.toString())}')
.join('&');
url = '$url?$queryString';
}
final result = await _apiClient.get(url);
final Map<String, PresetPackage> packages = {};
if (result is Map<String, dynamic>) {
result.forEach((key, value) {
try {
packages[key] = PresetPackage.fromJson(value);
} catch (e) {
AppLogger.w(_tag, '解析预设包失败: $key', e);
}
});
}
return packages;
} catch (e) {
AppLogger.e(_tag, '批量获取预设包失败: featureTypes=$featureTypes, novelId=$novelId', e);
return {};
}
}
@override
Future<CacheWarmupResult> warmupCache() async {
try {
final result = await _apiClient.post('$_baseUrl/warmup', data: {});
return CacheWarmupResult.fromJson(result);
} catch (e) {
AppLogger.e(_tag, '预热缓存失败', e);
return CacheWarmupResult(
success: false,
warmedFeatureTypes: 0,
warmedPresets: 0,
durationMs: 0,
errorMessage: e.toString(),
);
}
}
@override
Future<AggregationCacheStats> getCacheStats() async {
try {
final result = await _apiClient.get('$_baseUrl/cache/stats');
return AggregationCacheStats.fromJson(result);
} catch (e) {
AppLogger.e(_tag, '获取缓存统计失败', e);
return AggregationCacheStats(
hitRate: 0.0,
cacheEntries: 0,
cacheSizeBytes: 0,
lastUpdated: DateTime.now(),
);
}
}
@override
Future<String> clearCache() async {
try {
final result = await _apiClient.delete('$_baseUrl/cache');
if (result is Map<String, dynamic> && result.containsKey('message')) {
return result['message'] as String;
}
return '缓存清除成功';
} catch (e) {
AppLogger.e(_tag, '清除缓存失败', e);
throw Exception('清除缓存失败: ${e.toString()}');
}
}
@override
Future<Map<String, dynamic>> healthCheck() async {
try {
final result = await _apiClient.get('$_baseUrl/health');
if (result is Map<String, dynamic>) {
return result;
}
return {'status': 'unknown'};
} catch (e) {
AppLogger.e(_tag, '聚合服务健康检查失败', e);
return {
'status': 'error',
'error': e.toString(),
'timestamp': DateTime.now().toIso8601String(),
};
}
}
@override
Future<AllUserPresetData> getAllUserPresetData({String? novelId}) async {
try {
final Map<String, dynamic> queryParams = {};
if (novelId != null) {
queryParams['novelId'] = novelId;
}
// 构建查询字符串
String url = '$_baseUrl/all-data';
if (queryParams.isNotEmpty) {
final queryString = queryParams.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value.toString())}')
.join('&');
url = '$url?$queryString';
}
AppLogger.i(_tag, '🚀 请求所有预设聚合数据: url=$url');
final result = await _apiClient.get(url);
// 检查响应格式 - API返回的是标准响应格式 {success, message, data}
if (result is! Map<String, dynamic>) {
throw Exception('响应格式错误: 不是JSON对象');
}
final response = result as Map<String, dynamic>;
AppLogger.i(_tag, '📋 响应字段: ${response.keys.toList()}');
if (response['success'] != true) {
throw Exception('请求失败: ${response['message'] ?? '未知错误'}');
}
final data = response['data'];
if (data == null) {
throw Exception('响应数据为空');
}
AppLogger.i(_tag, '✅ 开始解析聚合数据...');
final allData = AllUserPresetData.fromJson(data);
AppLogger.i(_tag, '✅ 所有预设聚合数据获取成功');
AppLogger.i(_tag, '📊 数据统计: 系统预设${allData.systemPresets.length}个, 用户预设分组${allData.userPresetsByFeatureType.length}个, 收藏${allData.favoritePresets.length}');
return allData;
} catch (e) {
AppLogger.e(_tag, '❌ 获取所有预设聚合数据失败: novelId=$novelId', e);
// 返回空的聚合数据作为降级处理
return AllUserPresetData(
userId: '',
overview: UserPresetOverview(
totalPresets: 0,
systemPresets: 0,
userPresets: 0,
favoritePresets: 0,
presetsByFeatureType: {},
recentFeatureTypes: [],
popularTags: [],
generatedAt: DateTime.now(),
),
packagesByFeatureType: {},
systemPresets: [],
userPresetsByFeatureType: {},
favoritePresets: [],
quickAccessPresets: [],
recentlyUsedPresets: [],
timestamp: DateTime.now(),
cacheDuration: 0,
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
import '../../../../models/public_model_config.dart';
import '../../../../utils/logger.dart';
import '../../base/api_client.dart';
import '../public_model_repository.dart';
/// 公共模型仓库实现
class PublicModelRepositoryImpl implements PublicModelRepository {
final ApiClient _apiClient;
static const String _tag = 'PublicModelRepositoryImpl';
PublicModelRepositoryImpl({required ApiClient apiClient}) : _apiClient = apiClient;
@override
Future<List<PublicModel>> getPublicModels() async {
try {
AppLogger.i(_tag, '获取公共模型列表');
final rawList = await _apiClient.getPublicModels();
final models = rawList.map((json) {
try {
return PublicModel.fromJson(json);
} catch (e) {
AppLogger.e(_tag, '解析公共模型数据失败', e);
AppLogger.d(_tag, '问题数据: $json');
// 跳过解析失败的模型,继续处理其他模型
return null;
}
}).whereType<PublicModel>().toList();
AppLogger.i(_tag, '获取公共模型列表成功: 共${models.length}个模型');
return models;
} catch (e, stackTrace) {
AppLogger.e(_tag, '获取公共模型列表失败', e, stackTrace);
rethrow;
}
}
}

View File

@@ -0,0 +1,237 @@
import 'dart:typed_data';
import 'package:ainoval/services/api_service/base/api_client.dart';
import 'package:ainoval/services/api_service/base/api_exception.dart';
import 'package:ainoval/services/api_service/repositories/storage_repository.dart';
import 'package:ainoval/services/api_service/repositories/impl/aliyun_oss_storage_repository.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:http/http.dart' as http;
import 'package:mime/mime.dart';
/// 默认存储库实现
class StorageRepositoryImpl implements StorageRepository {
final ApiClient _apiClient;
StorageRepositoryImpl(this._apiClient);
@override
Future<Map<String, dynamic>> getCoverUploadCredential({
required String novelId,
required String fileName,
String? contentType,
}) async {
try {
// 获取MIME类型如果未提供
final String mimeType = contentType ?? _getMimeType(fileName);
// 调用后端API获取上传凭证
// ApiClient现在已经在内部处理fileName和contentType参数
final credential = await _apiClient.getCoverUploadCredential(novelId);
if (credential is! Map<String, dynamic>) {
throw ApiException(-1, '获取上传凭证失败:返回类型错误');
}
// 添加额外信息到凭证中(如果不存在)
if (!credential.containsKey('contentType')) {
credential['contentType'] = mimeType;
}
if (!credential.containsKey('fileName')) {
credential['fileName'] = fileName;
}
// 记录结果,以便于调试
AppLogger.d(
'Services/api_service/repositories/impl/storage_repository_impl',
'获取上传凭证成功:包含字段 ${credential.keys.join(', ')}',
);
return credential;
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/storage_repository_impl',
'获取上传凭证失败',
e,
);
throw ApiException(-1, '获取上传凭证失败: $e');
}
}
@override
Future<String> uploadCoverImage({
required String novelId,
required Uint8List fileBytes,
required String fileName,
String? contentType,
bool updateNovelCover = true,
}) async {
try {
// 获取上传凭证
final credential = await getCoverUploadCredential(
novelId: novelId,
fileName: fileName,
contentType: contentType,
);
// === 识别阿里云 OSS 上传场景 ===
// 1) host 以 oss:// 开头
// 2) 或者 host 域名包含 aliyuncs.com典型形如 https://bucket.oss-cn-xx.aliyuncs.com
if (credential.containsKey('host')) {
final String hostStr = credential['host'].toString();
final bool isAliyunHost = hostStr.startsWith('oss://') || hostStr.contains('aliyuncs.com');
if (isAliyunHost) {
AppLogger.d(
'Services/api_service/repositories/impl/storage_repository_impl',
'检测到阿里云OSS URL切换到专用处理方式',
);
final ossSr = AliyunOssStorageRepository(_apiClient);
return await ossSr.uploadCoverImage(
novelId: novelId,
fileBytes: fileBytes,
fileName: fileName,
contentType: contentType,
updateNovelCover: updateNovelCover,
);
}
}
// 检查必要参数 - 处理阿里云OSS凭证
if (credential.containsKey('host') &&
credential.containsKey('key') &&
credential.containsKey('policy') &&
credential.containsKey('signature') &&
credential.containsKey('accessKeyId')) {
// 阿里云OSS上传
final uri = Uri.parse(credential['host']);
final request = http.MultipartRequest('POST', uri);
// 添加OSS表单字段
request.fields['key'] = credential['key'];
request.fields['policy'] = credential['policy'];
request.fields['signature'] = credential['signature'];
request.fields['OSSAccessKeyId'] = credential['accessKeyId'];
request.fields['success_action_status'] = '200';
// 如果有内容类型,添加到表单中
if (credential.containsKey('contentType')) {
request.fields['Content-Type'] = credential['contentType'];
}
// 添加文件
final mimeType = contentType ?? _getMimeType(fileName);
request.files.add(http.MultipartFile.fromBytes(
'file',
fileBytes,
filename: fileName,
contentType: mimeType.isNotEmpty ? null : null,
));
// 发送请求
final streamedResponse = await request.send();
final response = await http.Response.fromStream(streamedResponse);
// 检查响应
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(response.statusCode, '上传失败: ${response.body}');
}
// 构建文件URL并返回
final fileUrl = '${credential['host']}/${credential['key']}';
// 通知后端上传完成更新小说封面URL可禁用
if (updateNovelCover) {
await _apiClient.updateNovelCover(novelId, fileUrl);
}
return fileUrl;
}
// 原来的通用上传实现
else if (credential.containsKey('uploadUrl') && credential.containsKey('formFields')) {
// 原有的通用上传逻辑
final uri = Uri.parse(credential['uploadUrl']);
final request = http.MultipartRequest('POST', uri);
// 添加表单字段
final formFields = credential['formFields'] as Map<String, dynamic>;
formFields.forEach((key, value) {
request.fields[key] = value.toString();
});
// 添加文件
final mimeType = contentType ?? _getMimeType(fileName);
request.files.add(http.MultipartFile.fromBytes(
'file',
fileBytes,
filename: fileName,
contentType: mimeType.isNotEmpty ? null : null,
));
// 发送请求
final streamedResponse = await request.send();
final response = await http.Response.fromStream(streamedResponse);
// 检查响应
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(response.statusCode, '上传失败: ${response.body}');
}
// 获取文件URL
String fileUrl = '';
if (credential.containsKey('fileUrl')) {
fileUrl = credential['fileUrl'];
} else {
// 从响应中解析URL
try {
final responseData = Uri.parse(response.body);
fileUrl = responseData.toString();
} catch (e) {
// 如果无法解析响应使用预定的URL格式
fileUrl = '${credential['baseUrl']}/${credential['key']}';
}
}
// 通知后端上传完成更新小说封面URL可禁用
if (updateNovelCover) {
await _apiClient.updateNovelCover(novelId, fileUrl);
}
return fileUrl;
} else {
throw ApiException(-1, '上传凭证格式不支持: ${credential.keys.join(', ')}');
}
} catch (e) {
AppLogger.e(
'Services/api_service/repositories/impl/storage_repository_impl',
'上传封面图片失败',
e,
);
throw ApiException(-1, '上传封面图片失败: $e');
}
}
@override
Future<String> getFileAccessUrl({
required String fileKey,
int? expirationSeconds,
}) async {
// 对于公开读权限的文件直接返回URL
return fileKey;
}
@override
Future<bool> hasValidStorageConfig() async {
try {
// 尝试获取测试小说的上传凭证,如果成功则认为配置有效
await _apiClient.getCoverUploadCredential('test');
return true;
} catch (e) {
return false;
}
}
/// 根据文件名获取MIME类型
String _getMimeType(String fileName) {
final mimeType = lookupMimeType(fileName);
return mimeType ?? 'application/octet-stream';
}
}

View File

@@ -0,0 +1,238 @@
import '../../../../models/admin/subscription_models.dart';
import '../../../../utils/logger.dart';
import '../../base/api_client.dart';
import '../../base/api_exception.dart';
import '../subscription_repository.dart';
/// 订阅管理仓库实现
class SubscriptionRepositoryImpl implements SubscriptionRepository {
final ApiClient _apiClient;
static const String _tag = 'SubscriptionRepository';
SubscriptionRepositoryImpl({required ApiClient apiClient}) : _apiClient = apiClient;
@override
Future<List<SubscriptionPlan>> getAllPlans() async {
try {
AppLogger.d(_tag, '🔍 获取所有订阅计划');
final response = await _apiClient.get('/admin/subscription-plans');
// 添加详细的响应调试日志
AppLogger.d(_tag, '📡 订阅计划原始响应类型: ${response.runtimeType}');
AppLogger.d(_tag, '📡 订阅计划原始响应内容: $response');
// 解析响应数据
dynamic rawData;
if (response is Map<String, dynamic>) {
AppLogger.d(_tag, '📄 订阅计划响应是Map包含的键: ${response.keys.toList()}');
if (response.containsKey('data')) {
rawData = response['data'];
AppLogger.d(_tag, '📄 订阅计划data字段类型: ${rawData.runtimeType}');
AppLogger.d(_tag, '📄 订阅计划data字段内容: $rawData');
} else if (response.containsKey('success') && response['success'] == true) {
rawData = response['data'] ?? response;
AppLogger.d(_tag, '📄 订阅计划success结构提取的数据类型: ${rawData.runtimeType}');
} else {
rawData = response;
AppLogger.d(_tag, '📄 订阅计划直接使用整个response');
}
} else {
rawData = response;
AppLogger.d(_tag, '📄 订阅计划响应不是Map直接使用');
}
// 检查数据类型并转换为List兼容 List 与 {data: List} 两种结构)
List<dynamic> data;
if (rawData is List) {
data = rawData;
AppLogger.d(_tag, '✅ 订阅计划成功获得List长度: ${data.length}');
} else if (rawData is Map<String, dynamic>) {
AppLogger.d(_tag, '📄 订阅计划rawData是Map包含的键: ${rawData.keys.toList()}');
if (rawData.containsKey('content')) {
data = (rawData['content'] as List?) ?? [];
AppLogger.d(_tag, '✅ 订阅计划从content字段获得List长度: ${data.length}');
} else if (rawData.containsKey('data') && rawData['data'] is List) {
data = (rawData['data'] as List);
AppLogger.d(_tag, '✅ 订阅计划从data字段获得List长度: ${data.length}');
} else {
// 尝试将 Map 视为单个对象列表(极端兼容)
AppLogger.w(_tag, '⚠️ 订阅计划Map中未发现content/data列表字段返回空列表');
data = [];
}
} else {
AppLogger.e(_tag, '❌ 订阅计划无法识别的数据类型: ${rawData.runtimeType}');
throw ApiException(-1, '订阅计划数据格式错误: 未知的数据类型 ${rawData.runtimeType}');
}
AppLogger.d(_tag, '✅ 获取订阅计划成功: count=${data.length}');
return data.map((json) => SubscriptionPlan.fromJson(json as Map<String, dynamic>)).toList();
} catch (e) {
AppLogger.e(_tag, '❌ 获取订阅计划失败', e);
rethrow;
}
}
@override
Future<SubscriptionPlan> getPlanById(String id) async {
try {
AppLogger.d(_tag, '🔍 获取订阅计划详情: id=$id');
final response = await _apiClient.get('/admin/subscription-plans/$id');
dynamic planData;
if (response is Map<String, dynamic> && response.containsKey('data')) {
planData = response['data'];
} else if (response is Map<String, dynamic>) {
planData = response;
} else {
throw ApiException(-1, '订阅计划详情数据格式错误');
}
AppLogger.d(_tag, '✅ 获取订阅计划详情成功: id=$id');
return SubscriptionPlan.fromJson(planData as Map<String, dynamic>);
} catch (e) {
AppLogger.e(_tag, '❌ 获取订阅计划详情失败', e);
rethrow;
}
}
@override
Future<SubscriptionPlan> createPlan(SubscriptionPlan plan) async {
try {
AppLogger.d(_tag, '📝 创建订阅计划: ${plan.planName}');
final response = await _apiClient.post('/admin/subscription-plans', data: plan.toJson());
dynamic planData;
if (response is Map<String, dynamic> && response.containsKey('data')) {
planData = response['data'];
} else if (response is Map<String, dynamic>) {
planData = response;
} else {
throw ApiException(-1, '创建订阅计划响应格式错误');
}
AppLogger.d(_tag, '✅ 创建订阅计划成功: ${plan.planName}');
return SubscriptionPlan.fromJson(planData as Map<String, dynamic>);
} catch (e) {
AppLogger.e(_tag, '❌ 创建订阅计划失败', e);
rethrow;
}
}
@override
Future<SubscriptionPlan> updatePlan(String id, SubscriptionPlan plan) async {
try {
AppLogger.d(_tag, '📝 更新订阅计划: id=$id');
final response = await _apiClient.put('/admin/subscription-plans/$id', data: plan.toJson());
dynamic planData;
if (response is Map<String, dynamic> && response.containsKey('data')) {
planData = response['data'];
} else if (response is Map<String, dynamic>) {
planData = response;
} else {
throw ApiException(-1, '更新订阅计划响应格式错误');
}
AppLogger.d(_tag, '✅ 更新订阅计划成功: id=$id');
return SubscriptionPlan.fromJson(planData as Map<String, dynamic>);
} catch (e) {
AppLogger.e(_tag, '❌ 更新订阅计划失败', e);
rethrow;
}
}
@override
Future<void> deletePlan(String id) async {
try {
AppLogger.d(_tag, '🗑️ 删除订阅计划: id=$id');
await _apiClient.delete('/admin/subscription-plans/$id');
AppLogger.d(_tag, '✅ 删除订阅计划成功: id=$id');
} catch (e) {
AppLogger.e(_tag, '❌ 删除订阅计划失败', e);
rethrow;
}
}
@override
Future<SubscriptionPlan> togglePlanStatus(String id, bool active) async {
try {
AppLogger.d(_tag, '🔄 切换订阅计划状态: id=$id, active=$active');
final response = await _apiClient.patch('/admin/subscription-plans/$id/status', data: {
'active': active,
});
dynamic planData;
if (response is Map<String, dynamic> && response.containsKey('data')) {
planData = response['data'];
} else if (response is Map<String, dynamic>) {
planData = response;
} else {
throw ApiException(-1, '切换订阅计划状态响应格式错误');
}
AppLogger.d(_tag, '✅ 切换订阅计划状态成功: id=$id, active=$active');
return SubscriptionPlan.fromJson(planData as Map<String, dynamic>);
} catch (e) {
AppLogger.e(_tag, '❌ 切换订阅计划状态失败', e);
rethrow;
}
}
@override
Future<SubscriptionStatistics> getSubscriptionStatistics() async {
try {
AppLogger.d(_tag, '📊 获取订阅统计信息');
// TODO: 等后端提供订阅统计接口
// 临时返回模拟数据
await Future.delayed(const Duration(milliseconds: 500));
const statistics = SubscriptionStatistics(
totalPlans: 3,
activePlans: 2,
totalSubscriptions: 150,
activeSubscriptions: 120,
trialSubscriptions: 25,
monthlyRevenue: 5000.0,
yearlyRevenue: 60000.0,
);
AppLogger.d(_tag, '✅ 获取订阅统计信息成功');
return statistics;
} catch (e) {
AppLogger.e(_tag, '❌ 获取订阅统计信息失败', e);
rethrow;
}
}
@override
Future<List<UserSubscription>> getUserSubscriptions(String userId) async {
try {
AppLogger.d(_tag, '🔍 获取用户订阅历史: userId=$userId');
// TODO: 等后端提供用户订阅历史接口
// 临时返回空列表
await Future.delayed(const Duration(milliseconds: 300));
AppLogger.d(_tag, '✅ 获取用户订阅历史成功: userId=$userId');
return [];
} catch (e) {
AppLogger.e(_tag, '❌ 获取用户订阅历史失败', e);
rethrow;
}
}
@override
Future<UserSubscription?> getActiveUserSubscription(String userId) async {
try {
AppLogger.d(_tag, '🔍 获取用户当前订阅: userId=$userId');
// TODO: 等后端提供当前订阅接口
// 临时返回null
await Future.delayed(const Duration(milliseconds: 300));
AppLogger.d(_tag, '✅ 获取用户当前订阅成功: userId=$userId');
return null;
} catch (e) {
AppLogger.e(_tag, '❌ 获取用户当前订阅失败', e);
rethrow;
}
}
}

View File

@@ -0,0 +1,190 @@
import 'package:ainoval/models/ai_request_models.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/api_service/base/sse_client.dart';
import 'package:ainoval/services/api_service/repositories/universal_ai_repository.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/utils/date_time_parser.dart';
import 'package:flutter_client_sse/constants/sse_request_type_enum.dart';
/// 通用AI请求仓库实现
class UniversalAIRepositoryImpl implements UniversalAIRepository {
final ApiClient apiClient;
final String _tag = 'UniversalAIRepository';
UniversalAIRepositoryImpl({required this.apiClient});
@override
Future<UniversalAIResponse> sendRequest(UniversalAIRequest request) async {
try {
AppLogger.d(_tag, '发送AI请求: ${request.requestType.value}');
final response = await apiClient.post(
'/ai/universal/process',
data: request.toApiJson(),
);
return UniversalAIResponse.fromJson(response);
} catch (e) {
AppLogger.e(_tag, '发送AI请求失败', e);
rethrow;
}
}
@override
Stream<UniversalAIResponse> streamRequest(UniversalAIRequest request) {
try {
AppLogger.d(_tag, '发送流式AI请求: ${request.requestType.value}');
// 🚀 使用SseClient替代ApiClient复用剧情推演的流式处理逻辑
return SseClient().streamEvents<UniversalAIResponse>(
path: '/ai/universal/stream',
method: SSERequestType.POST,
body: request.toApiJson(),
parser: (json) {
// 🚀 修复:优先检查是否是结束标记
if (json is Map<String, dynamic>) {
final finishReason = json['finishReason'] as String?;
final isComplete = json['isComplete'] as bool? ?? false;
final content = json['content'] as String? ?? '';
// 🚀 如果有结束信号,立即返回结束响应
if (finishReason != null || isComplete || content == '}') {
AppLogger.i(_tag, '检测到流式生成结束信号: finishReason=$finishReason, isComplete=$isComplete, content="$content"');
return UniversalAIResponse(
id: json['id'] as String? ?? 'stream_end_${DateTime.now().millisecondsSinceEpoch}',
requestType: request.requestType,
content: '', // 结束信号内容为空
finishReason: finishReason ?? 'stop',
);
}
}
// 🚀 复用剧情推演的错误处理逻辑
// 首先检查是否是已知的错误格式
if (json is Map<String, dynamic> && json.containsKey('code') && json.containsKey('message')) {
final errorMessage = json['message'] as String? ?? 'Unknown server error';
final errorCodeString = json['code'] as String?;
final errorCode = int.tryParse(errorCodeString ?? '') ?? -1;
AppLogger.e(_tag, '服务器返回已知错误格式: code=${json['code']}, message=$errorMessage');
// 🚀 专门处理积分不足错误
if (errorCodeString == 'INSUFFICIENT_CREDITS') {
throw InsufficientCreditsException(errorMessage);
}
throw ApiException(errorCode, errorMessage);
}
// 检查是否包含 'error' 字段(兼容旧的或不同的错误格式)
else if (json is Map<String, dynamic> && json['error'] != null) {
final errorMessage = json['error'] as String? ?? 'Unknown server error';
AppLogger.e(_tag, '服务器返回错误字段: $errorMessage');
throw ApiException(-1, errorMessage);
}
//AppLogger.v(_tag, '收到流式响应数据: $json');
// 🚀 后端现在返回的是标准的ServerSentEvent<UniversalAIResponseDto>格式
// 直接解析UniversalAIResponseDto
try {
return UniversalAIResponse.fromJson(json);
} catch (e) {
AppLogger.e(_tag, '解析UniversalAIResponse失败: $e, json: $json');
// 🚀 fallback如果解析失败尝试从基本字段构建响应
if (json is Map<String, dynamic>) {
// 处理缺失字段的兼容性
final content = json['content'] as String? ?? '';
final id = json['id'] as String? ?? 'stream_${DateTime.now().millisecondsSinceEpoch}';
final requestType = json['requestType'] as String? ?? request.requestType.value;
final model = json['model'] as String?;
final finishReason = json['finishReason'] as String?;
final createdAtValue = json['createdAt'];
final metadata = json['metadata'] as Map<String, dynamic>? ?? <String, dynamic>{};
// 解析AI请求类型
final aiRequestType = AIRequestType.values.firstWhere(
(type) => type.value == requestType,
orElse: () => request.requestType,
);
// 🚀 使用parseBackendDateTime处理createdAt字段
DateTime? createdAt;
if (createdAtValue != null) {
try {
createdAt = parseBackendDateTime(createdAtValue);
} catch (e) {
AppLogger.w(_tag, '解析createdAt失败使用当前时间: $e');
createdAt = DateTime.now();
}
}
return UniversalAIResponse(
id: id,
requestType: aiRequestType,
content: content,
model: model,
finishReason: finishReason,
createdAt: createdAt,
metadata: metadata,
);
}
// 抛出更具体的解析异常
throw ApiException(-1, '解析响应失败: $e');
}
},
eventName: 'message', // 🚀 与后端保持一致的事件名
connectionId: 'universal_ai_${request.requestType.value}_${DateTime.now().millisecondsSinceEpoch}',
).where((response) {
// 🚀 修复不要过滤掉结束信号即使content为空但有finishReason的响应
if (response.finishReason != null) {
AppLogger.i(_tag, '保留结束信号: finishReason=${response.finishReason}');
return true;
}
// 🚀 只过滤掉既没有内容也没有结束信号的响应
return response.content.isNotEmpty;
});
} catch (e) {
AppLogger.e(_tag, '发送流式AI请求失败', e);
return Stream.error(Exception('流式AI请求失败: ${e.toString()}'));
}
}
@override
Future<UniversalAIPreviewResponse> previewRequest(UniversalAIRequest request) async {
try {
AppLogger.d(_tag, '预览AI请求: ${request.requestType.value}');
final response = await apiClient.post(
'/ai/universal/preview',
data: request.toApiJson(),
);
return UniversalAIPreviewResponse.fromJson(response);
} catch (e) {
AppLogger.e(_tag, '预览AI请求失败', e);
rethrow;
}
}
@override
Future<CostEstimationResponse> estimateCost(UniversalAIRequest request) async {
try {
AppLogger.d(_tag, '预估AI请求积分成本: ${request.requestType.value}');
final response = await apiClient.post(
'/ai/universal/estimate-cost',
data: request.toApiJson(),
);
final costResponse = CostEstimationResponse.fromJson(response);
AppLogger.d(_tag, '积分预估完成 - 预估成本: ${costResponse.estimatedCost}积分, 模型: ${costResponse.modelName}');
return costResponse;
} catch (e) {
AppLogger.e(_tag, '预估AI请求积分成本失败', e);
rethrow;
}
}
}

View File

@@ -0,0 +1,287 @@
import 'dart:async';
import 'package:ainoval/models/user_ai_model_config_model.dart';
import 'package:ainoval/models/model_info.dart';
import 'package:ainoval/services/api_service/base/api_client.dart';
// Api Exception 可能仍然需要,用于类型检查或如果 repository 层需要抛出特定类型的异常
// 但 ApiExceptionHelper 不需要了
import 'package:ainoval/services/api_service/repositories/user_ai_model_config_repository.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart'; // 添加枚举导入
/// 用户 AI 模型配置仓库实现
class UserAIModelConfigRepositoryImpl implements UserAIModelConfigRepository {
UserAIModelConfigRepositoryImpl({required this.apiClient});
final ApiClient apiClient;
@override
Future<List<String>> listAvailableProviders() async {
AppLogger.i('UserAIModelConfigRepoImpl', '获取可用提供商');
try {
final providers = await apiClient.listAIProviders();
AppLogger.i(
'UserAIModelConfigRepoImpl', '获取可用提供商成功: count=${providers.length}');
return providers;
} catch (e, stackTrace) {
AppLogger.e('UserAIModelConfigRepoImpl', '获取可用提供商失败', e, stackTrace);
// 直接重新抛出ApiClient 会处理 DioException 转换
rethrow;
}
}
@override
Future<List<ModelInfo>> listModelsForProvider(String provider) async {
AppLogger.i('UserAIModelConfigRepoImpl', '获取提供商 $provider 模型信息');
try {
final models = await apiClient.listAIModelsForProvider(provider: provider);
AppLogger.i('UserAIModelConfigRepoImpl',
'获取提供商 $provider 模型信息成功: count=${models.length}');
return models;
} catch (e, stackTrace) {
AppLogger.e(
'UserAIModelConfigRepoImpl', '获取提供商 $provider 模型信息失败', e, stackTrace);
rethrow;
}
}
@override
Future<UserAIModelConfigModel> addConfiguration({
required String userId,
required String provider,
required String modelName,
String? alias,
required String apiKey,
String? apiEndpoint,
}) async {
AppLogger.i('UserAIModelConfigRepoImpl',
'添加配置: userId=$userId'); // Mask apiKey in logs
try {
final config = await apiClient.addAIConfiguration(
userId: userId,
provider: provider,
modelName: modelName,
alias: alias,
apiKey: apiKey,
apiEndpoint: apiEndpoint,
);
AppLogger.i('UserAIModelConfigRepoImpl',
'添加配置成功: userId=$userId, configId=${config.id}');
return config;
} catch (e, stackTrace) {
AppLogger.e(
'UserAIModelConfigRepoImpl', '添加配置失败: userId=$userId', e, stackTrace);
// 直接重新抛出
rethrow;
}
}
@override
Future<List<UserAIModelConfigModel>> listConfigurations({
required String userId,
bool? validatedOnly,
}) async {
AppLogger.i('UserAIModelConfigRepoImpl',
'列出配置(包含API密钥): userId=$userId, validatedOnly=$validatedOnly');
try {
// 调用新的API端点获取包含解密后API密钥的配置列表
final configs = await apiClient.listAIConfigurationsWithDecryptedKeys(
userId: userId,
validatedOnly: validatedOnly,
);
AppLogger.i('UserAIModelConfigRepoImpl',
'列出配置(包含API密钥)成功: userId=$userId, count=${configs.length}');
return configs;
} catch (e, stackTrace) {
AppLogger.e(
'UserAIModelConfigRepoImpl', '列出配置(包含API密钥)失败: userId=$userId', e, stackTrace);
// 直接重新抛出
rethrow;
}
}
@override
Future<UserAIModelConfigModel> getConfigurationById({
required String userId,
required String configId,
}) async {
AppLogger.i('UserAIModelConfigRepoImpl',
'获取配置: userId=$userId, configId=$configId');
try {
final config = await apiClient.getAIConfigurationById(
userId: userId,
configId: configId,
);
AppLogger.i('UserAIModelConfigRepoImpl',
'获取配置成功: userId=$userId, configId=${config.id}');
return config;
} catch (e, stackTrace) {
AppLogger.e('UserAIModelConfigRepoImpl',
'获取配置失败: userId=$userId, configId=$configId', e, stackTrace);
// 直接重新抛出
rethrow;
}
}
@override
Future<UserAIModelConfigModel> updateConfiguration({
required String userId,
required String configId,
String? alias,
String? apiKey,
String? apiEndpoint,
}) async {
if (alias == null && apiKey == null && apiEndpoint == null) {
AppLogger.w('UserAIModelConfigRepoImpl',
'更新配置调用,但没有提供要更新的字段: userId=$userId, configId=$configId');
AppLogger.i('UserAIModelConfigRepoImpl', '无有效更新字段,尝试获取当前配置');
// 注意:这里的 getConfigurationById 本身也可能抛出异常
return getConfigurationById(userId: userId, configId: configId);
}
AppLogger.i('UserAIModelConfigRepoImpl',
'更新配置: userId=$userId, configId=$configId'); // Mask apiKey
try {
final config = await apiClient.updateAIConfiguration(
userId: userId,
configId: configId,
alias: alias,
apiKey: apiKey,
apiEndpoint: apiEndpoint,
);
AppLogger.i('UserAIModelConfigRepoImpl',
'更新配置成功: userId=$userId, configId=${config.id}');
return config;
} catch (e, stackTrace) {
AppLogger.e('UserAIModelConfigRepoImpl',
'更新配置失败: userId=$userId, configId=$configId', e, stackTrace);
// 直接重新抛出
rethrow;
}
}
@override
Future<void> deleteConfiguration({
required String userId,
required String configId,
}) async {
AppLogger.i('UserAIModelConfigRepoImpl',
'删除配置: userId=$userId, configId=$configId');
try {
await apiClient.deleteAIConfiguration(userId: userId, configId: configId);
AppLogger.i('UserAIModelConfigRepoImpl',
'删除配置成功: userId=$userId, configId=$configId');
} catch (e, stackTrace) {
AppLogger.e('UserAIModelConfigRepoImpl',
'删除配置失败: userId=$userId, configId=$configId', e, stackTrace);
// 直接重新抛出
rethrow;
}
}
@override
Future<UserAIModelConfigModel> validateConfiguration({
required String userId,
required String configId,
}) async {
AppLogger.i('UserAIModelConfigRepoImpl',
'验证配置: userId=$userId, configId=$configId');
try {
final config = await apiClient.validateAIConfiguration(
userId: userId,
configId: configId,
);
AppLogger.i('UserAIModelConfigRepoImpl',
'验证配置成功: userId=$userId, configId=${config.id}, isValidated=${config.isValidated}');
return config;
} catch (e, stackTrace) {
AppLogger.e('UserAIModelConfigRepoImpl',
'验证配置失败: userId=$userId, configId=$configId', e, stackTrace);
// 直接重新抛出
rethrow;
}
}
@override
Future<UserAIModelConfigModel> setDefaultConfiguration({
required String userId,
required String configId,
}) async {
AppLogger.i('UserAIModelConfigRepoImpl',
'设置默认配置: userId=$userId, configId=$configId');
try {
final config = await apiClient.setDefaultAIConfiguration(
userId: userId,
configId: configId,
);
AppLogger.i('UserAIModelConfigRepoImpl',
'设置默认配置成功: userId=$userId, configId=${config.id}, isDefault=${config.isDefault}');
return config;
} catch (e, stackTrace) {
AppLogger.e('UserAIModelConfigRepoImpl',
'设置默认配置失败: userId=$userId, configId=$configId', e, stackTrace);
// 直接重新抛出
rethrow;
}
}
@override
Future<ModelListingCapability> getProviderCapability(String providerName) async {
AppLogger.i('UserAIModelConfigRepoImpl', '获取提供商 $providerName 的模型列表能力');
try {
final capabilityString = await apiClient.getProviderCapability(providerName);
AppLogger.i('UserAIModelConfigRepoImpl', '获取提供商 $providerName 的模型列表能力成功: $capabilityString');
// 清理字符串,去除可能的前后引号
var cleanCapabilityString = capabilityString;
if (cleanCapabilityString.startsWith('"') && cleanCapabilityString.endsWith('"')) {
cleanCapabilityString = cleanCapabilityString.substring(1, cleanCapabilityString.length - 1);
}
ModelListingCapability capability;
// 使用清理后的字符串进行比较
switch (cleanCapabilityString) {
case 'NO_LISTING':
capability = ModelListingCapability.noListing;
break;
case 'LISTING_WITHOUT_KEY':
capability = ModelListingCapability.listingWithoutKey;
break;
case 'LISTING_WITH_KEY':
capability = ModelListingCapability.listingWithKey;
break;
default:
AppLogger.w('UserAIModelConfigRepoImpl', '未知的提供商能力字符串: $capabilityString, 使用默认 noListing');
capability = ModelListingCapability.noListing;
}
AppLogger.i('UserAIModelConfigRepoImpl', '获取提供商 $providerName 的模型列表能力成功: $capability');
return capability;
} catch (e, stackTrace) {
AppLogger.e('UserAIModelConfigRepoImpl', '获取提供商 $providerName 的模型列表能力失败', e, stackTrace);
// 如果出错,默认为最安全的能力类型
AppLogger.w('UserAIModelConfigRepoImpl', '使用默认能力类型 noListing');
return ModelListingCapability.noListing;
}
}
@override
Future<List<ModelInfo>> listModelsWithApiKey({
required String provider,
required String apiKey,
String? apiEndpoint,
}) async {
AppLogger.i('UserAIModelConfigRepoImpl', '使用API密钥获取提供商 $provider 的模型信息列表');
try {
final models = await apiClient.listAIModelsWithApiKey(
provider: provider,
apiKey: apiKey,
apiEndpoint: apiEndpoint,
);
AppLogger.i('UserAIModelConfigRepoImpl',
'使用API密钥获取提供商 $provider 的模型信息列表成功: count=${models.length}');
return models;
} catch (e, stackTrace) {
AppLogger.e('UserAIModelConfigRepoImpl', '使用API密钥获取提供商 $provider 的模型信息列表失败', e, stackTrace);
rethrow;
}
}
}

View File

@@ -0,0 +1,83 @@
import 'package:ainoval/services/api_service/base/api_client.dart';
import 'package:ainoval/utils/logger.dart';
class UserAnalyticsRepositoryImpl {
final ApiClient _apiClient;
final String _tag = 'UserAnalyticsRepository';
UserAnalyticsRepositoryImpl({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient();
Future<Map<String, int>> getMyDailyWords({DateTime? start, DateTime? end}) async {
try {
final qp = <String, dynamic>{};
if (start != null) qp['start'] = start.toIso8601String();
if (end != null) qp['end'] = end.toIso8601String();
final res = await _apiClient.getWithParams('/analytics/writing/daily', queryParameters: qp);
if (res is Map<String, dynamic> && res['data'] is Map<String, dynamic>) {
final data = res['data'] as Map<String, dynamic>;
final daily = (data['dailyWords'] as Map).map((k, v) => MapEntry(k.toString(), int.tryParse(v.toString()) ?? 0));
return daily;
}
return {};
} catch (e) {
AppLogger.e(_tag, '获取每日写作字数失败', e);
return {};
}
}
Future<Map<String, dynamic>> getMyWordsBySource({DateTime? start, DateTime? end}) async {
try {
final qp = <String, dynamic>{};
if (start != null) qp['start'] = start.toIso8601String();
if (end != null) qp['end'] = end.toIso8601String();
final res = await _apiClient.getWithParams('/analytics/writing/source', queryParameters: qp);
if (res is Map<String, dynamic> && res['data'] is Map<String, dynamic>) {
return res['data'] as Map<String, dynamic>;
}
return {};
} catch (e) {
AppLogger.e(_tag, '获取写作来源统计失败', e);
return {};
}
}
Future<Map<String, int>> getMyDailyTokens({DateTime? start, DateTime? end}) async {
try {
final qp = <String, dynamic>{};
if (start != null) qp['startTime'] = start.toIso8601String();
if (end != null) qp['endTime'] = end.toIso8601String();
final res = await _apiClient.getWithParams('/analytics/llm/daily-tokens', queryParameters: qp);
if (res is Map<String, dynamic> && res['data'] is Map<String, dynamic>) {
final map = <String, int>{};
(res['data'] as Map<String, dynamic>).forEach((k, v) {
map[k] = int.tryParse(v.toString()) ?? 0;
});
return map;
}
return {};
} catch (e) {
AppLogger.e(_tag, '获取每日Token失败', e);
return {};
}
}
Future<Map<String, dynamic>> getMyFeatureUsage({DateTime? start, DateTime? end}) async {
try {
final qp = <String, dynamic>{};
if (start != null) qp['startTime'] = start.toIso8601String();
if (end != null) qp['endTime'] = end.toIso8601String();
final res = await _apiClient.getWithParams('/analytics/llm/features', queryParameters: qp);
if (res is Map<String, dynamic> && res['data'] is Map<String, dynamic>) {
return res['data'] as Map<String, dynamic>;
}
return {};
} catch (e) {
AppLogger.e(_tag, '获取功能使用统计失败', e);
return {};
}
}
}

View File

@@ -0,0 +1,32 @@
import 'package:ainoval/models/next_outline/next_outline_dto.dart';
import 'package:ainoval/models/next_outline/outline_generation_chunk.dart';
/// 剧情推演仓库接口
abstract class NextOutlineRepository {
/// 流式生成剧情大纲
///
/// [novelId] 小说ID
/// [request] 生成请求
Stream<OutlineGenerationChunk> generateNextOutlinesStream(
String novelId,
GenerateNextOutlinesRequest request
);
/// 重新生成单个剧情大纲选项
///
/// [novelId] 小说ID
/// [request] 重新生成请求
Stream<OutlineGenerationChunk> regenerateOutlineOption(
String novelId,
RegenerateOptionRequest request
);
/// 保存选中的剧情大纲
///
/// [novelId] 小说ID
/// [request] 保存请求
Future<SaveNextOutlineResponse> saveNextOutline(
String novelId,
SaveNextOutlineRequest request
);
}

View File

@@ -0,0 +1,12 @@
import 'package:ainoval/models/novel_setting_item.dart';
abstract class NovelAIRepository {
Future<List<NovelSettingItem>> generateNovelSettings({
required String novelId,
required String startChapterId,
String? endChapterId,
required List<String> settingTypes,
required int maxSettingsPerType,
required String additionalInstructions,
});
}

View File

@@ -0,0 +1,154 @@
import 'package:ainoval/models/import_status.dart';
import 'package:ainoval/models/novel_structure.dart';
import 'package:ainoval/models/scene_version.dart';
import 'package:ainoval/models/chapters_for_preload_dto.dart';
/// 小说仓库接口
///
/// 定义与小说相关的所有API操作
abstract class NovelRepository {
/// 获取所有小说
Future<List<Novel>> fetchNovels();
/// 获取单个小说
Future<Novel> fetchNovel(String id);
/// 获取单个小说场景内容纯文本格式
Future<Novel> fetchNovelText(String id);
/// 获取单个小说
Future<Novel> fetchNovelOnlyStructure(String id);
/// 创建小说
Future<Novel> createNovel(String title,
{String? description, String? coverImage});
/// 根据作者ID获取小说列表
Future<List<Novel>> fetchNovelsByAuthor(String authorId);
/// 搜索小说
Future<List<Novel>> searchNovelsByTitle(String title);
/// 删除小说
Future<void> deleteNovel(String id);
/// 获取场景内容
Future<Scene> fetchSceneContent(
String novelId, String actId, String chapterId, String sceneId);
/// 更新场景内容
Future<Scene> updateSceneContent(String novelId, String actId,
String chapterId, String sceneId, Scene scene);
/// 更新摘要内容
Future<Summary> updateSummary(String novelId, String actId, String chapterId,
String sceneId, Summary summary);
/// 更新场景内容并保存历史版本
Future<Scene> updateSceneContentWithHistory(String novelId, String chapterId,
String sceneId, String content, String userId, String reason);
/// 获取场景的历史版本列表
Future<List<SceneHistoryEntry>> getSceneHistory(
String novelId, String chapterId, String sceneId);
/// 恢复场景到指定的历史版本
Future<Scene> restoreSceneVersion(String novelId, String chapterId,
String sceneId, int historyIndex, String userId, String reason);
/// 对比两个场景版本
Future<SceneVersionDiff> compareSceneVersions(String novelId,
String chapterId, String sceneId, int versionIndex1, int versionIndex2);
/// 导入小说文件(传统方式,向后兼容)
///
/// 返回导入任务的ID
Future<String> importNovel(List<int> fileBytes, String fileName);
// === 新的三步导入流程方法 ===
/// 第一步上传文件获取预览会话ID
///
/// - [fileBytes]: 文件字节数据
/// - [fileName]: 文件名
/// - 返回: 预览会话ID
Future<String> uploadFileForPreview(List<int> fileBytes, String fileName);
/// 第二步:获取导入预览
///
/// - [fileSessionId]: 预览会话ID
/// - [customTitle]: 自定义标题
/// - [chapterLimit]: 章节数量限制
/// - [enableSmartContext]: 是否启用智能上下文
/// - [enableAISummary]: 是否启用AI摘要
/// - [aiConfigId]: AI配置ID
/// - [previewChapterCount]: 预览章节数量
/// - 返回: 导入预览响应数据
Future<Map<String, dynamic>> getImportPreview({
required String fileSessionId,
String? customTitle,
int? chapterLimit,
bool enableSmartContext = true,
bool enableAISummary = false,
String? aiConfigId,
int previewChapterCount = 10,
});
/// 第三步:确认并开始导入
///
/// - [previewSessionId]: 预览会话ID
/// - [finalTitle]: 最终确认的标题
/// - [selectedChapterIndexes]: 选中的章节索引列表
/// - [enableSmartContext]: 是否启用智能上下文
/// - [enableAISummary]: 是否启用AI摘要
/// - [aiConfigId]: AI配置ID
/// - 返回: 导入任务ID
Future<String> confirmAndStartImport({
required String previewSessionId,
required String finalTitle,
List<int>? selectedChapterIndexes,
bool enableSmartContext = true,
bool enableAISummary = false,
String? aiConfigId,
});
/// 清理预览会话
///
/// - [previewSessionId]: 预览会话ID
Future<void> cleanupPreviewSession(String previewSessionId);
/// 获取导入任务状态流
///
/// 返回导入状态的实时更新
Stream<ImportStatus> getImportStatus(String jobId);
/// 取消导入任务
///
/// - [jobId]: 导入任务ID
/// - 返回: 是否成功取消
Future<bool> cancelImport(String jobId);
/// 获取当前章节后面指定数量的章节和场景内容
///
/// 允许跨卷加载,专门用于阅读器的分批加载功能
/// - [novelId]: 小说ID
/// - [currentChapterId]: 当前章节ID
/// - [chaptersLimit]: 要加载的章节数量默认为3
/// - 返回: 包含小说信息和后续章节场景数据的Novel对象
Future<Novel?> fetchChaptersAfter(String novelId, String currentChapterId, {int chaptersLimit = 3, bool includeCurrentChapter = true});
/// 获取指定章节后面的章节列表(用于预加载)
///
/// 专门为预加载功能设计,只返回章节列表和场景内容,不返回完整小说结构
/// - [novelId]: 小说ID
/// - [currentChapterId]: 当前章节ID
/// - [chaptersLimit]: 要获取的章节数量限制默认为3
/// - [includeCurrentChapter]: 是否包含当前章节默认为false
/// - 返回: 包含章节列表和场景数据的ChaptersForPreloadDto
Future<ChaptersForPreloadDto?> fetchChaptersForPreload(
String novelId,
String currentChapterId, {
int chaptersLimit = 3,
bool includeCurrentChapter = false,
});
}

View File

@@ -0,0 +1,147 @@
import 'package:ainoval/models/novel_setting_item.dart';
import 'package:ainoval/models/setting_group.dart';
/// 小说设定仓储接口
abstract class NovelSettingRepository {
// ==================== 设定条目管理 ====================
/// 创建小说设定条目
Future<NovelSettingItem> createSettingItem({
required String novelId,
required NovelSettingItem settingItem,
});
/// 获取小说设定条目列表
Future<List<NovelSettingItem>> getNovelSettingItems({
required String novelId,
String? type,
String? name,
int? priority,
String? generatedBy,
String? status,
required int page,
required int size,
required String sortBy,
required String sortDirection,
});
/// 获取小说设定条目详情
Future<NovelSettingItem> getSettingItemDetail({
required String novelId,
required String itemId,
});
/// 更新小说设定条目
Future<NovelSettingItem> updateSettingItem({
required String novelId,
required String itemId,
required NovelSettingItem settingItem,
});
/// 删除小说设定条目
Future<void> deleteSettingItem({
required String novelId,
required String itemId,
});
/// 添加设定条目之间的关系
Future<NovelSettingItem> addSettingRelationship({
required String novelId,
required String itemId,
required String targetItemId,
required String relationshipType,
String? description,
});
/// 删除设定条目之间的关系
Future<void> removeSettingRelationship({
required String novelId,
required String itemId,
required String targetItemId,
required String relationshipType,
});
/// 设置父子关系
Future<NovelSettingItem> setParentChildRelationship({
required String novelId,
required String childId,
required String parentId,
});
/// 移除父子关系
Future<NovelSettingItem> removeParentChildRelationship({
required String novelId,
required String childId,
});
// ==================== 设定组管理 ====================
/// 创建设定组
Future<SettingGroup> createSettingGroup({
required String novelId,
required SettingGroup settingGroup,
});
/// 获取小说的设定组列表
Future<List<SettingGroup>> getNovelSettingGroups({
required String novelId,
String? name,
bool? isActiveContext,
});
/// 获取设定组详情
Future<SettingGroup> getSettingGroupDetail({
required String novelId,
required String groupId,
});
/// 更新设定组
Future<SettingGroup> updateSettingGroup({
required String novelId,
required String groupId,
required SettingGroup settingGroup,
});
/// 删除设定组
Future<void> deleteSettingGroup({
required String novelId,
required String groupId,
});
/// 添加设定条目到设定组
Future<SettingGroup> addItemToGroup({
required String novelId,
required String groupId,
required String itemId,
});
/// 从设定组中移除设定条目
Future<void> removeItemFromGroup({
required String novelId,
required String groupId,
required String itemId,
});
/// 激活/停用设定组作为上下文
Future<SettingGroup> setGroupActiveContext({
required String novelId,
required String groupId,
required bool isActive,
});
// ==================== 高级功能 ====================
/// 从文本中自动提取设定条目
Future<List<NovelSettingItem>> extractSettingsFromText({
required String novelId,
required String text,
required String type,
});
/// 根据关键词搜索设定条目
Future<List<NovelSettingItem>> searchSettingItems({
required String novelId,
required String query,
List<String>? types,
List<String>? groupIds,
double? minScore,
int? maxResults,
});
}

View File

@@ -0,0 +1,103 @@
import 'package:ainoval/models/novel_snippet.dart';
/// 小说片段仓储接口
///
/// 定义与小说片段相关的所有API操作
abstract class NovelSnippetRepository {
/// 创建片段
///
/// [request] 创建片段请求数据
/// 返回创建的片段信息
Future<NovelSnippet> createSnippet(CreateSnippetRequest request);
/// 获取小说的所有片段(分页)
///
/// [novelId] 小说ID
/// [page] 页码默认为0
/// [size] 每页大小默认为20
/// 返回分页片段数据
Future<SnippetPageResult<NovelSnippet>> getSnippetsByNovelId(
String novelId, {
int page = 0,
int size = 20,
});
/// 获取片段详情
///
/// [snippetId] 片段ID
/// 返回片段详细信息(会增加浏览次数)
Future<NovelSnippet> getSnippetDetail(String snippetId);
/// 更新片段内容
///
/// [request] 更新内容请求数据
/// 返回更新后的片段信息
Future<NovelSnippet> updateSnippetContent(UpdateSnippetContentRequest request);
/// 更新片段标题
///
/// [request] 更新标题请求数据
/// 返回更新后的片段信息
Future<NovelSnippet> updateSnippetTitle(UpdateSnippetTitleRequest request);
/// 收藏/取消收藏片段
///
/// [request] 更新收藏状态请求数据
/// 返回更新后的片段信息
Future<NovelSnippet> updateSnippetFavorite(UpdateSnippetFavoriteRequest request);
/// 获取片段历史记录
///
/// [snippetId] 片段ID
/// [page] 页码默认为0
/// [size] 每页大小默认为10
/// 返回分页历史记录数据
Future<SnippetPageResult<NovelSnippetHistory>> getSnippetHistory(
String snippetId, {
int page = 0,
int size = 10,
});
/// 预览历史版本内容
///
/// [snippetId] 片段ID
/// [version] 版本号
/// 返回指定版本的历史记录
Future<NovelSnippetHistory> previewHistoryVersion(String snippetId, int version);
/// 回退到历史版本(创建新片段)
///
/// [request] 回退版本请求数据
/// 返回新创建的片段信息
Future<NovelSnippet> revertToHistoryVersion(RevertSnippetVersionRequest request);
/// 删除片段
///
/// [snippetId] 片段ID
/// 执行软删除操作
Future<void> deleteSnippet(String snippetId);
/// 获取用户收藏的片段
///
/// [page] 页码默认为0
/// [size] 每页大小默认为20
/// 返回分页收藏片段数据
Future<SnippetPageResult<NovelSnippet>> getFavoriteSnippets({
int page = 0,
int size = 20,
});
/// 搜索片段
///
/// [novelId] 小说ID
/// [searchText] 搜索文本
/// [page] 页码默认为0
/// [size] 每页大小默认为20
/// 返回搜索结果分页数据
Future<SnippetPageResult<NovelSnippet>> searchSnippets(
String novelId,
String searchText, {
int page = 0,
int size = 20,
});
}

View File

@@ -0,0 +1,81 @@
import 'package:ainoval/services/api_service/base/api_client.dart';
import 'package:ainoval/utils/logger.dart';
enum PayChannel { wechat, alipay }
class PaymentOrderDto {
final String id;
final String outTradeNo;
final String planId;
final String paymentUrl;
final String status;
PaymentOrderDto({
required this.id,
required this.outTradeNo,
required this.planId,
required this.paymentUrl,
required this.status,
});
factory PaymentOrderDto.fromJson(Map<String, dynamic> json) => PaymentOrderDto(
id: json['id'] ?? '',
outTradeNo: json['outTradeNo'] ?? '',
planId: json['planId'] ?? '',
paymentUrl: json['paymentUrl'] ?? '',
status: json['status']?.toString() ?? '',
);
}
class PaymentRepository {
final ApiClient _apiClient;
final String _tag = 'PaymentRepository';
PaymentRepository({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient();
Future<PaymentOrderDto> createPayment({
required String planId,
required PayChannel channel,
}) async {
try {
final res = await _apiClient.post('/payments/create/$planId?channel=${channel.name.toUpperCase()}');
if (res is Map<String, dynamic> && res['data'] is Map<String, dynamic>) {
return PaymentOrderDto.fromJson(res['data'] as Map<String, dynamic>);
}
throw Exception('创建支付订单失败');
} catch (e) {
AppLogger.e(_tag, '创建支付订单失败', e);
rethrow;
}
}
Future<PaymentOrderDto> createCreditPackPayment({
required String planId,
required PayChannel channel,
}) async {
try {
final res = await _apiClient.post('/payments/create-credit-pack/$planId?channel=${channel.name.toUpperCase()}');
if (res is Map<String, dynamic> && res['data'] is Map<String, dynamic>) {
return PaymentOrderDto.fromJson(res['data'] as Map<String, dynamic>);
}
throw Exception('创建积分包支付订单失败');
} catch (e) {
AppLogger.e(_tag, '创建积分包支付订单失败', e);
rethrow;
}
}
Future<List<PaymentOrderDto>> myOrders() async {
try {
final res = await _apiClient.get('/payments/my-orders');
if (res is List) {
return res.map((e) => PaymentOrderDto.fromJson(e as Map<String, dynamic>)).toList();
}
return [];
} catch (e) {
AppLogger.e(_tag, '获取我的订单失败', e);
return [];
}
}
}

View File

@@ -0,0 +1,53 @@
import 'package:ainoval/models/preset_models.dart';
/// 预设聚合仓储接口
/// 提供一站式的预设获取和缓存接口
abstract class PresetAggregationRepository {
/// 获取功能的完整预设包
/// [featureType] 功能类型
/// [novelId] 小说ID可选
/// 返回完整预设包,包含系统预设、用户预设、快捷访问预设等全部信息
Future<PresetPackage> getCompletePresetPackage(
String featureType, {
String? novelId,
});
/// 获取用户的预设概览
/// 返回跨功能统计信息用于用户Dashboard
Future<UserPresetOverview> getUserPresetOverview();
/// 批量获取多个功能的预设包
/// [featureTypes] 功能类型列表如果为null则获取所有类型
/// [novelId] 小说ID可选
/// 返回功能类型到预设包的映射,用于前端初始化时一次性获取所有需要的数据
Future<Map<String, PresetPackage>> getBatchPresetPackages({
List<String>? featureTypes,
String? novelId,
});
/// 预热用户缓存
/// 系统启动或用户登录时调用,提升后续响应速度
/// 返回缓存预热结果
Future<CacheWarmupResult> warmupCache();
/// 获取系统缓存统计
/// 用于系统监控和性能分析
/// 返回聚合服务的缓存统计信息
Future<AggregationCacheStats> getCacheStats();
/// 清除预设聚合缓存
/// 用于调试和强制刷新缓存
/// 返回清除结果消息
Future<String> clearCache();
/// 聚合服务健康检查
/// 检查预设聚合服务的健康状态
/// 返回健康状态信息
Future<Map<String, dynamic>> healthCheck();
/// 🚀 获取用户的所有预设聚合数据
/// 一次性返回用户的所有预设相关数据避免多次API调用
/// [novelId] 小说ID可选
/// 返回完整的用户预设聚合数据
Future<AllUserPresetData> getAllUserPresetData({String? novelId});
}

View File

@@ -0,0 +1,194 @@
import 'package:ainoval/models/prompt_models.dart';
/// 提示词管理接口
abstract class PromptRepository {
/// 获取所有提示词
Future<Map<AIFeatureType, PromptData>> getAllPrompts();
/// 获取指定功能类型的提示词
Future<PromptData> getPrompt(AIFeatureType featureType);
/// 保存提示词
Future<PromptData> savePrompt(AIFeatureType featureType, String promptText);
/// 删除提示词(恢复为默认)
Future<PromptData> deletePrompt(AIFeatureType featureType);
/// 获取提示词模板列表
Future<List<PromptTemplate>> getPromptTemplates();
/// 获取指定功能类型的提示词模板列表
Future<List<PromptTemplate>> getPromptTemplatesByFeatureType(AIFeatureType featureType);
/// 获取提示词模板详情
Future<PromptTemplate> getPromptTemplateById(String templateId);
/// 从公共模板复制创建私有模板
Future<PromptTemplate> copyPublicTemplate(PromptTemplate template);
/// 切换模板收藏状态
Future<PromptTemplate> toggleTemplateFavorite(PromptTemplate template);
/// 创建提示词模板
Future<PromptTemplate> createPromptTemplate({
required String name,
required String content,
required AIFeatureType featureType,
required String authorId,
String? description,
List<String>? tags,
});
/// 更新提示词模板
Future<PromptTemplate> updatePromptTemplate({
required String templateId,
String? name,
String? content,
});
/// 删除提示词模板
Future<void> deletePromptTemplate(String templateId);
/// 流式优化提示词
void optimizePromptStream(
String templateId,
OptimizePromptRequest request, {
Function(double)? onProgress,
Function(OptimizationResult)? onResult,
Function(String)? onError,
});
/// 取消优化
void cancelOptimization();
/// 优化提示词
Future<OptimizationResult> optimizePrompt({
required String templateId,
required OptimizePromptRequest request,
});
/// 生成场景摘要
Future<String> generateSceneSummary({
required String novelId,
required String sceneId,
});
/// 从摘要生成场景
Future<String> generateSceneFromSummary({
required String novelId,
required String summary,
});
// ====================== 统一提示词聚合接口 ======================
/// 获取功能的完整提示词包
/// 包含系统默认、用户自定义、公开模板、最近使用等全部信息
Future<PromptPackage> getCompletePromptPackage(
AIFeatureType featureType, {
bool includePublic = true,
});
/// 获取用户的提示词概览
/// 跨功能统计信息用于用户Dashboard
Future<UserPromptOverview> getUserPromptOverview();
/// 批量获取多个功能的提示词包
/// 用于前端初始化时一次性获取所有需要的数据
Future<Map<AIFeatureType, PromptPackage>> getBatchPromptPackages({
List<AIFeatureType>? featureTypes,
bool includePublic = true,
});
/// 预热用户缓存
/// 系统启动或用户登录时调用,提升后续响应速度
Future<CacheWarmupResult> warmupCache();
/// 获取系统缓存统计
/// 用于系统监控和性能分析
Future<AggregationCacheStats> getCacheStats();
/// 获取虚拟线程性能统计
/// 用于监控占位符解析性能
Future<PlaceholderPerformanceStats> getPlaceholderPerformanceStats();
/// 健康检查接口
/// 检查聚合服务是否正常工作
Future<SystemHealthStatus> healthCheck();
// ====================== 增强用户提示词模板管理接口 ======================
/// 创建增强用户提示词模板
Future<EnhancedUserPromptTemplate> createEnhancedPromptTemplate(
CreatePromptTemplateRequest request,
);
/// 更新增强用户提示词模板
Future<EnhancedUserPromptTemplate> updateEnhancedPromptTemplate(
String templateId,
UpdatePromptTemplateRequest request,
);
/// 删除增强用户提示词模板
Future<void> deleteEnhancedPromptTemplate(String templateId);
/// 获取增强用户提示词模板详情
Future<EnhancedUserPromptTemplate?> getEnhancedPromptTemplate(String templateId);
/// 获取用户所有增强提示词模板
Future<List<EnhancedUserPromptTemplate>> getUserEnhancedPromptTemplates({
AIFeatureType? featureType,
});
/// 获取用户收藏的增强模板
Future<List<EnhancedUserPromptTemplate>> getUserFavoriteEnhancedTemplates();
/// 获取最近使用的增强模板
Future<List<EnhancedUserPromptTemplate>> getRecentlyUsedEnhancedTemplates({
int limit = 10,
});
/// 发布模板为公开
Future<EnhancedUserPromptTemplate> publishEnhancedTemplate(
String templateId,
PublishTemplateRequest request,
);
/// 通过分享码获取模板
Future<EnhancedUserPromptTemplate?> getEnhancedTemplateByShareCode(String shareCode);
/// 复制公开增强模板
Future<EnhancedUserPromptTemplate> copyPublicEnhancedTemplate(String templateId);
/// 获取公开增强模板列表
Future<List<EnhancedUserPromptTemplate>> getPublicEnhancedTemplates(
AIFeatureType featureType, {
int page = 0,
int size = 20,
});
/// 收藏增强模板
Future<void> favoriteEnhancedTemplate(String templateId);
/// 取消收藏增强模板
Future<void> unfavoriteEnhancedTemplate(String templateId);
/// 评分增强模板
Future<EnhancedUserPromptTemplate> rateEnhancedTemplate(
String templateId,
int rating,
);
/// 记录增强模板使用
Future<void> recordEnhancedTemplateUsage(String templateId);
/// 获取用户所有标签
Future<List<String>> getUserPromptTags();
// ==================== 默认模板功能 ====================
/// 设置默认模板
Future<EnhancedUserPromptTemplate> setDefaultEnhancedTemplate(String templateId);
/// 获取默认模板
Future<EnhancedUserPromptTemplate?> getDefaultEnhancedTemplate(AIFeatureType featureType);
}

View File

@@ -0,0 +1,9 @@
import '../../../models/public_model_config.dart';
/// 公共模型仓库接口
abstract interface class PublicModelRepository {
/// 获取公共模型列表
/// 只包含向前端暴露的安全信息不含API Keys等敏感数据
/// 用户必须登录才能访问此接口
Future<List<PublicModel>> getPublicModels();
}

View File

@@ -0,0 +1,252 @@
import '../../../models/setting_generation_session.dart';
import '../../../models/setting_generation_event.dart';
import '../../../models/strategy_template_info.dart';
import '../../../models/save_result.dart';
import '../../../models/ai_request_models.dart';
/// 设定生成仓库接口
///
/// 核心功能说明:
/// 1. 设定生成流程管理支持AI生成和修改设定节点
/// 2. 用户维度历史记录管理:不再依赖特定小说,支持跨小说使用
/// 3. 编辑会话管理:支持从小说设定或历史记录创建编辑会话
/// 4. 历史记录操作:复制、删除、恢复等完整的历史记录管理功能
abstract class SettingGenerationRepository {
/// 获取可用的生成策略模板
Future<List<StrategyTemplateInfo>> getAvailableStrategies();
/// 启动设定生成
Stream<SettingGenerationEvent> startGeneration({
required String initialPrompt,
required String promptTemplateId,
String? novelId,
required String modelConfigId,
String? userId,
bool? usePublicTextModel,
String? textPhasePublicProvider,
String? textPhasePublicModelId,
});
/// 从小说设定创建编辑会话
///
/// 支持用户选择编辑模式:
/// - createNewSnapshot = true创建新的设定快照
/// - createNewSnapshot = false编辑上次的设定
Future<Map<String, dynamic>> startSessionFromNovel({
required String novelId,
required String editReason,
required String modelConfigId,
required bool createNewSnapshot,
});
/// 强制关闭所有与设定生成相关的SSE连接用于彻底停止自动重连
Future<void> forceCloseAllSSE();
/// 修改设定节点
Stream<SettingGenerationEvent> updateNode({
required String sessionId,
required String nodeId,
required String modificationPrompt,
required String modelConfigId,
String scope = 'self',
});
/// 基于会话整体调整生成
Stream<SettingGenerationEvent> adjustSession({
required String sessionId,
required String adjustmentPrompt,
required String modelConfigId,
String? promptTemplateId,
});
/// 直接更新节点内容
Future<String> updateNodeContent({
required String sessionId,
required String nodeId,
required String newContent,
});
/// 保存生成的设定
///
/// [novelId] 为 null 时表示保存为独立快照(不关联任何小说)
/// 返回包含根设定ID列表和历史记录ID的完整结果
Future<SaveResult> saveGeneratedSettings({
required String sessionId,
String? novelId,
bool updateExisting = false,
String? targetHistoryId,
});
/// 获取会话状态
Future<Map<String, dynamic>> getSessionStatus({
required String sessionId,
});
/// 加载历史记录详情(包含完整节点数据)
Future<Map<String, dynamic>> loadHistoryDetail({
required String historyId,
});
/// 取消生成会话
Future<void> cancelSession({
required String sessionId,
});
// ==================== NOVEL_COMPOSE 流式写作编排 ====================
/// 基于设定/提示词的写作编排(大纲/章节/组合)流式生成
/// 统一走通用AI通道/ai/universal/stream传入 AIRequestType.NOVEL_COMPOSE
Stream<UniversalAIResponse> composeStream({
required UniversalAIRequest request,
});
/// 建议:前端在开始黄金三章前,先创建一个草稿小说并将 novelId 放入 request
/// 以便后端在大纲/章节保存后直接绑定会话
/// 开始写作确保novelId并保存当前会话设定
Future<String?> startWriting({required String? sessionId, String? novelId, String? historyId});
// ==================== 历史记录管理 ====================
/// 获取用户的历史记录列表
///
/// 使用用户维度管理,支持按小说过滤
Future<List<Map<String, dynamic>>> getUserHistories({
String? novelId,
int page = 0,
int size = 20,
});
/// 获取历史记录详情
Future<Map<String, dynamic>?> getHistoryDetails({
required String historyId,
});
/// 从历史记录创建编辑会话(增强版)
Future<Map<String, dynamic>> createEditSessionFromHistory({
required String historyId,
required String editReason,
required String modelConfigId,
});
/// 复制历史记录
Future<Map<String, dynamic>> copyHistory({
required String historyId,
required String copyReason,
});
/// 恢复历史记录到小说中
Future<Map<String, dynamic>> restoreHistoryToNovel({
required String historyId,
required String novelId,
});
/// 删除历史记录
Future<void> deleteHistory({
required String historyId,
});
/// 批量删除历史记录
Future<Map<String, dynamic>> batchDeleteHistories({
required List<String> historyIds,
});
/// 统计历史记录数量
Future<int> countUserHistories({
String? novelId,
});
/// 获取节点历史记录
Future<List<Map<String, dynamic>>> getNodeHistories({
required String historyId,
required String nodeId,
int page = 0,
int size = 10,
});
// ==================== 策略管理接口 ====================
/// 创建用户自定义策略
Future<Map<String, dynamic>> createCustomStrategy({
required String name,
required String description,
required String systemPrompt,
required String userPrompt,
required List<Map<String, dynamic>> nodeTemplates,
required int expectedRootNodes,
required int maxDepth,
String? baseStrategyId,
});
/// 基于现有策略创建新策略
Future<Map<String, dynamic>> createStrategyFromBase({
required String baseTemplateId,
required String name,
required String description,
String? systemPrompt,
String? userPrompt,
required Map<String, dynamic> modifications,
});
/// 获取用户的策略列表
Future<List<Map<String, dynamic>>> getUserStrategies({
int page = 0,
int size = 20,
});
/// 获取公开策略列表
Future<List<Map<String, dynamic>>> getPublicStrategies({
String? category,
int page = 0,
int size = 20,
});
/// 获取策略详情
Future<Map<String, dynamic>?> getStrategyDetail({
required String strategyId,
});
/// 更新策略
Future<Map<String, dynamic>> updateStrategy({
required String strategyId,
required String name,
required String description,
String? systemPrompt,
String? userPrompt,
List<Map<String, dynamic>>? nodeTemplates,
int? expectedRootNodes,
int? maxDepth,
});
/// 删除策略
Future<void> deleteStrategy({
required String strategyId,
});
/// 提交策略审核
Future<void> submitStrategyForReview({
required String strategyId,
});
/// 获取待审核策略列表(管理员接口)
Future<List<Map<String, dynamic>>> getPendingStrategies({
int page = 0,
int size = 20,
});
/// 审核策略(管理员接口)
Future<void> reviewStrategy({
required String strategyId,
required String decision,
String? comment,
List<String>? rejectionReasons,
List<String>? improvementSuggestions,
});
// ==================== 工具方法 ====================
/// 检查会话是否已关联历史记录
bool isSessionLinkedToHistory(SettingGenerationSession session) {
return session.historyId != null && session.historyId!.isNotEmpty;
}
}

View File

@@ -0,0 +1,28 @@
import 'dart:typed_data';
abstract class StorageRepository {
/// 获取封面上传凭证
Future<Map<String, dynamic>> getCoverUploadCredential({
required String novelId,
required String fileName,
String? contentType,
});
/// 上传封面图片
Future<String> uploadCoverImage({
required String novelId,
required Uint8List fileBytes,
required String fileName,
String? contentType,
bool updateNovelCover = true,
});
/// 获取文件访问URL
Future<String> getFileAccessUrl({
required String fileKey,
int? expirationSeconds,
});
/// 检查用户是否有有效的上传配置
Future<bool> hasValidStorageConfig();
}

View File

@@ -0,0 +1,75 @@
import '../../../models/admin/subscription_models.dart';
import 'package:ainoval/services/api_service/base/api_client.dart';
import 'package:ainoval/utils/logger.dart';
/// 订阅管理仓库接口
abstract interface class SubscriptionRepository {
/// 获取所有订阅计划
Future<List<SubscriptionPlan>> getAllPlans();
/// 获取单个订阅计划
Future<SubscriptionPlan> getPlanById(String id);
/// 创建订阅计划
Future<SubscriptionPlan> createPlan(SubscriptionPlan plan);
/// 更新订阅计划
Future<SubscriptionPlan> updatePlan(String id, SubscriptionPlan plan);
/// 删除订阅计划
Future<void> deletePlan(String id);
/// 切换订阅计划状态
Future<SubscriptionPlan> togglePlanStatus(String id, bool active);
/// 获取订阅统计信息
Future<SubscriptionStatistics> getSubscriptionStatistics();
/// 获取用户订阅历史
Future<List<UserSubscription>> getUserSubscriptions(String userId);
/// 获取活跃的用户订阅
Future<UserSubscription?> getActiveUserSubscription(String userId);
}
/// 面向用户端的公开计划仓库
class PublicSubscriptionRepository {
final ApiClient _apiClient;
static const String _tag = 'PublicSubscriptionRepository';
PublicSubscriptionRepository({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient();
Future<List<SubscriptionPlan>> listActivePlans() async {
final res = await _apiClient.get('/subscription-plans');
AppLogger.d(_tag, '订阅计划原始响应类型: ${res.runtimeType}');
AppLogger.d(_tag, '订阅计划原始响应内容: $res');
// 兼容两种返回结构:
// 1) { success, data: [...] }
// 2) 直接返回数组 [...]
if (res is Map<String, dynamic>) {
final data = res['data'];
if (data is List) {
return data
.whereType<Map<String, dynamic>>()
.map(SubscriptionPlan.fromJson)
.toList();
}
} else if (res is List) {
return res
.whereType<Map<String, dynamic>>()
.map(SubscriptionPlan.fromJson)
.toList();
}
AppLogger.w(_tag, '订阅计划响应结构非预期,返回空数组');
// 非预期结构时返回空数组避免UI崩溃
return [];
}
Future<List<Map<String, dynamic>>> listActiveCreditPacks() async {
final res = await _apiClient.get('/credit-packs');
if (res is Map<String, dynamic> && res['data'] is List) {
return (res['data'] as List).cast<Map<String, dynamic>>();
}
return [];
}
}

View File

@@ -0,0 +1,17 @@
import 'package:ainoval/models/ai_request_models.dart';
/// 通用AI请求仓库接口
abstract class UniversalAIRepository {
/// 发送通用AI请求非流式
Future<UniversalAIResponse> sendRequest(UniversalAIRequest request);
/// 发送通用AI请求流式
Stream<UniversalAIResponse> streamRequest(UniversalAIRequest request);
/// 预览请求获取构建的提示内容不实际发送给AI
Future<UniversalAIPreviewResponse> previewRequest(UniversalAIRequest request);
/// 🚀 新增:预估积分成本
/// 快速预估AI请求的积分消耗不实际发送给AI
Future<CostEstimationResponse> estimateCost(UniversalAIRequest request);
}

View File

@@ -0,0 +1,74 @@
import 'dart:async';
import '../../../models/user_ai_model_config_model.dart';
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart'; // 导入以获取ModelListingCapability枚举
import 'package:ainoval/models/model_info.dart'; // Import ModelInfo
/// 用户 AI 模型配置仓库接口定义
abstract interface class UserAIModelConfigRepository {
/// 获取系统支持的所有AI提供商
Future<List<String>> listAvailableProviders();
/// 获取指定提供商支持的模型列表 (现在返回详细信息)
Future<List<ModelInfo>> listModelsForProvider(String provider);
/// 添加新的用户AI模型配置
Future<UserAIModelConfigModel> addConfiguration({
required String userId,
required String provider,
required String modelName,
String? alias,
required String apiKey,
String? apiEndpoint,
});
/// 列出用户所有的AI模型配置包含解密后的API密钥
/// [validatedOnly] 为 true 时,只返回已验证的配置
Future<List<UserAIModelConfigModel>> listConfigurations({
required String userId,
bool? validatedOnly,
});
/// 获取指定ID的用户AI模型配置
Future<UserAIModelConfigModel> getConfigurationById({
required String userId,
required String configId,
});
/// 更新指定ID的用户AI模型配置
/// [alias], [apiKey], [apiEndpoint] 可选,只传递需要更新的字段
Future<UserAIModelConfigModel> updateConfiguration({
required String userId,
required String configId,
String? alias,
String? apiKey,
String? apiEndpoint,
});
/// 删除指定ID的用户AI模型配置
Future<void> deleteConfiguration({
required String userId,
required String configId,
});
/// 手动触发指定配置的API Key验证
Future<UserAIModelConfigModel> validateConfiguration({
required String userId,
required String configId,
});
/// 设置指定配置为用户的默认模型
Future<UserAIModelConfigModel> setDefaultConfiguration({
required String userId,
required String configId,
});
/// 获取提供商的模型列表能力
Future<ModelListingCapability> getProviderCapability(String providerName);
/// 使用API密钥获取指定提供商的模型列表 (现在返回详细信息)
Future<List<ModelInfo>> listModelsWithApiKey({
required String provider,
required String apiKey,
String? apiEndpoint
});
}

View File

@@ -0,0 +1,688 @@
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/utils/logger.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:dio/dio.dart';
/// 用户认证服务
///
/// 负责用户登录、注册、令牌管理等认证相关功能
class AuthService {
AuthService({
ApiClient? apiClient,
}) : _apiClient = apiClient ?? ApiClient() {
// 设置ApiClient的AuthService实例避免循环依赖
_apiClient.setAuthService(this);
}
final ApiClient _apiClient;
// 存储令牌的键
static const String _tokenKey = 'auth_token';
static const String _refreshTokenKey = 'refresh_token';
static const String _userIdKey = 'user_id';
static const String _usernameKey = 'username';
// 认证状态流
final _authStateController = StreamController<AuthState>.broadcast();
Stream<AuthState> get authStateStream => _authStateController.stream;
// 当前认证状态
AuthState _currentState = AuthState.unauthenticated();
AuthState get currentState => _currentState;
/// 初始化认证服务
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(_tokenKey);
if (token != null) {
final userId = prefs.getString(_userIdKey);
final username = prefs.getString(_usernameKey);
// 设置认证状态
_currentState = AuthState.authenticated(
token: token,
userId: userId ?? '',
username: username ?? '',
);
// 设置全局认证令牌、用户ID和用户名
AppConfig.setAuthToken(token);
AppConfig.setUserId(userId);
AppConfig.setUsername(username);
// 发送认证状态更新
_authStateController.add(_currentState);
}
}
/// 用户登录
Future<AuthState> login(String username, String password) async {
try {
final data = await _apiClient.post('/auth/login', data: {
'username': username,
'password': password,
});
final token = data['token'];
final refreshToken = data['refreshToken'];
final userId = data['userId'];
// 保存令牌到本地存储
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
await prefs.setString(_refreshTokenKey, refreshToken);
await prefs.setString(_userIdKey, userId);
await prefs.setString(_usernameKey, username);
// 设置全局认证令牌、用户ID和用户名
AppConfig.setAuthToken(token);
AppConfig.setUserId(userId);
AppConfig.setUsername(username);
// 更新认证状态
_currentState = AuthState.authenticated(
token: token,
userId: userId,
username: username,
);
// 发送认证状态更新
_authStateController.add(_currentState);
return _currentState;
} on ApiException catch (e) {
throw AuthException(e.message);
} catch (e) {
throw AuthException('登录失败: $e');
}
}
/// 用户注册
Future<AuthState> register(String username, String password, String email, {String? displayName}) async {
try {
await _apiClient.post('/auth/register', data: {
'username': username,
'password': password,
'email': email,
'displayName': displayName ?? username,
});
// 注册成功后自动登录
return login(username, password);
} on ApiException catch (e) {
throw AuthException(e.message);
} catch (e) {
throw AuthException('注册失败: $e');
}
}
/// 用户注册(带验证)
Future<AuthState> registerWithVerification({
required String username,
required String password,
String? email,
String? phone,
String? displayName,
String? captchaId,
String? captchaCode,
String? emailVerificationCode,
String? phoneVerificationCode,
}) async {
try {
final data = await _apiClient.post('/auth/register', data: {
'username': username,
'password': password,
'email': email,
'phone': phone,
'displayName': displayName ?? username,
'captchaId': captchaId,
'captchaCode': captchaCode,
'emailVerificationCode': emailVerificationCode,
'phoneVerificationCode': phoneVerificationCode,
});
final token = data['token'];
final refreshToken = data['refreshToken'];
final userId = data['userId'];
// 保存令牌到本地存储
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
await prefs.setString(_refreshTokenKey, refreshToken);
await prefs.setString(_userIdKey, userId);
await prefs.setString(_usernameKey, username);
// 设置全局认证令牌、用户ID和用户名
AppConfig.setAuthToken(token);
AppConfig.setUserId(userId);
AppConfig.setUsername(username);
// 更新认证状态
_currentState = AuthState.authenticated(
token: token,
userId: userId,
username: username,
);
// 发送认证状态更新
_authStateController.add(_currentState);
return _currentState;
} on ApiException catch (e) {
throw AuthException(e.message);
} catch (e) {
throw AuthException('注册失败: $e');
}
}
/// 快捷注册(用户名 + 密码)
Future<AuthState> registerQuick({
required String username,
required String password,
String? displayName,
}) async {
try {
final data = await _apiClient.post('/auth/register/quick', data: {
'username': username,
'password': password,
'displayName': displayName ?? username,
});
final token = data['token'];
final refreshToken = data['refreshToken'];
final userId = data['userId'];
// 保存令牌到本地存储
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
await prefs.setString(_refreshTokenKey, refreshToken);
await prefs.setString(_userIdKey, userId);
await prefs.setString(_usernameKey, username);
// 设置全局认证令牌、用户ID和用户名
AppConfig.setAuthToken(token);
AppConfig.setUserId(userId);
AppConfig.setUsername(username);
// 更新认证状态
_currentState = AuthState.authenticated(
token: token,
userId: userId,
username: username,
);
// 发送认证状态更新
_authStateController.add(_currentState);
return _currentState;
} on ApiException catch (e) {
throw AuthException(e.message);
} catch (e) {
throw AuthException('注册失败: $e');
}
}
/// 手机号登录
Future<AuthState> loginWithPhone({
required String phone,
required String verificationCode,
}) async {
try {
final data = await _apiClient.post('/auth/login/phone', data: {
'phone': phone,
'verificationCode': verificationCode,
});
final token = data['token'];
final refreshToken = data['refreshToken'];
final userId = data['userId'];
final username = data['username'];
// 保存令牌到本地存储
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
await prefs.setString(_refreshTokenKey, refreshToken);
await prefs.setString(_userIdKey, userId);
await prefs.setString(_usernameKey, username);
// 设置全局认证令牌、用户ID和用户名
AppConfig.setAuthToken(token);
AppConfig.setUserId(userId);
AppConfig.setUsername(username);
// 更新认证状态
_currentState = AuthState.authenticated(
token: token,
userId: userId,
username: username,
);
// 发送认证状态更新
_authStateController.add(_currentState);
return _currentState;
} on ApiException catch (e) {
throw AuthException(e.message);
} catch (e) {
throw AuthException('登录失败: $e');
}
}
/// 邮箱登录
Future<AuthState> loginWithEmail({
required String email,
required String verificationCode,
}) async {
try {
final data = await _apiClient.post('/auth/login/email', data: {
'email': email,
'verificationCode': verificationCode,
});
final token = data['token'];
final refreshToken = data['refreshToken'];
final userId = data['userId'];
final username = data['username'];
// 保存令牌到本地存储
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
await prefs.setString(_refreshTokenKey, refreshToken);
await prefs.setString(_userIdKey, userId);
await prefs.setString(_usernameKey, username);
// 设置全局认证令牌、用户ID和用户名
AppConfig.setAuthToken(token);
AppConfig.setUserId(userId);
AppConfig.setUsername(username);
// 更新认证状态
_currentState = AuthState.authenticated(
token: token,
userId: userId,
username: username,
);
// 发送认证状态更新
_authStateController.add(_currentState);
return _currentState;
} on ApiException catch (e) {
throw AuthException(e.message);
} catch (e) {
throw AuthException('登录失败: $e');
}
}
/// 发送验证码(登录时使用,不需要图片验证码)
Future<bool> sendVerificationCode({
required String type,
required String target,
required String purpose,
}) async {
try {
await _apiClient.post('/auth/verification-code', data: {
'type': type,
'target': target,
'purpose': purpose,
});
return true;
} on ApiException catch (e) {
// 将后端的错误信息透传给上层
AppLogger.w('Services/auth_service', '发送验证码失败: ${e.message}');
throw AuthException(e.message);
} catch (e) {
AppLogger.e('Services/auth_service', '发送验证码异常', e);
throw AuthException('验证码发送失败: $e');
}
}
/// 发送验证码(注册时使用,需要先验证图片验证码)
Future<bool> sendVerificationCodeWithCaptcha({
required String type,
required String target,
required String purpose,
required String captchaId,
required String captchaCode,
}) async {
try {
final requestData = {
'type': type,
'target': target,
'purpose': purpose,
'captchaId': captchaId,
'captchaCode': captchaCode,
};
AppLogger.i('Services/auth_service', '🚀 发送验证码请求');
AppLogger.d('Services/auth_service', '📝 请求参数: $requestData');
final response = await _apiClient.post('/auth/verification-code', data: requestData);
AppLogger.i('Services/auth_service', '📬 API响应内容: $response');
AppLogger.i('Services/auth_service', '✅ 验证码发送成功HTTP 200');
return true;
} on ApiException catch (e) {
AppLogger.w('Services/auth_service', '❌ 验证码发送失败: ${e.message}');
throw Exception(e.message);
} catch (e) {
AppLogger.e('Services/auth_service', '💥 发送验证码异常', e);
rethrow;
}
}
/// 加载图片验证码
Future<Map<String, String>?> loadCaptcha() async {
try {
AppLogger.i('Services/auth_service', '🖼️ 请求图片验证码');
final response = await _apiClient.post('/auth/captcha');
AppLogger.i('Services/auth_service', '✅ 图片验证码加载成功');
return {
'captchaId': response['captchaId'],
'captchaImage': response['captchaImage'],
};
} on ApiException catch (e) {
AppLogger.w('Services/auth_service', '❌ 图片验证码加载失败: ${e.message}');
return null;
} catch (e) {
AppLogger.e('Services/auth_service', '💥 加载图片验证码异常', e);
return null;
}
}
/// 用户登出
Future<void> logout() async {
try {
// 立即清除本地数据不等待后端响应JWT无状态特性
final token = AppConfig.authToken;
// 异步调用后端logout接口不阻塞退出流程
if (token != null) {
// 使用fire-and-forget模式不等待响应
_callLogoutEndpoint(token).catchError((e) {
AppLogger.w('Services/auth_service', '后端登出请求失败', e);
});
}
// 清除本地存储的令牌
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_refreshTokenKey);
await prefs.remove(_userIdKey);
await prefs.remove(_usernameKey);
// 清除全局认证令牌、用户ID和用户名
AppConfig.setAuthToken(null);
AppConfig.setUserId(null);
AppConfig.setUsername(null);
// 更新认证状态
_currentState = AuthState.unauthenticated();
// 发送认证状态更新
_authStateController.add(_currentState);
AppLogger.i('Services/auth_service', '用户登出成功');
} catch (e) {
AppLogger.e('Services/auth_service', '登出失败', e);
// 即使出错也要清除本地状态
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_refreshTokenKey);
await prefs.remove(_userIdKey);
await prefs.remove(_usernameKey);
AppConfig.setAuthToken(null);
AppConfig.setUserId(null);
AppConfig.setUsername(null);
_currentState = AuthState.unauthenticated();
_authStateController.add(_currentState);
} catch (cleanupError) {
AppLogger.e('Services/auth_service', '清除本地认证状态失败', cleanupError);
}
}
}
/// 刷新令牌
Future<bool> refreshToken() async {
try {
final prefs = await SharedPreferences.getInstance();
final refreshToken = prefs.getString(_refreshTokenKey);
if (refreshToken == null) {
return false;
}
final data = await _apiClient.post('/auth/refresh', data: {
'refreshToken': refreshToken,
});
final newToken = data['token'];
final newRefreshToken = data['refreshToken'];
// 保存新令牌到本地存储
await prefs.setString(_tokenKey, newToken);
await prefs.setString(_refreshTokenKey, newRefreshToken);
// 设置全局认证令牌
AppConfig.setAuthToken(newToken);
// 更新认证状态
final userId = prefs.getString(_userIdKey) ?? '';
final username = prefs.getString(_usernameKey) ?? '';
// 设置用户ID和用户名
AppConfig.setUserId(userId);
AppConfig.setUsername(username);
_currentState = AuthState.authenticated(
token: newToken,
userId: userId,
username: username,
);
// 发送认证状态更新
_authStateController.add(_currentState);
return true;
} on ApiException {
// 刷新令牌失败,清除认证状态
await logout();
return false;
} catch (e) {
AppLogger.e('Services/auth_service', '刷新令牌失败', e);
// 刷新令牌失败,清除认证状态
await logout();
return false;
}
}
/// 获取当前用户信息
Future<Map<String, dynamic>> getCurrentUser() async {
if (!_currentState.isAuthenticated) {
throw AuthException('用户未登录');
}
try {
// 由于ApiClient会自动添加Authorization头我们直接调用即可
final data = await _apiClient.get('/users/${_currentState.userId}');
return data;
} on ApiException catch (e) {
if (e.statusCode == 401) {
// 令牌过期,尝试刷新
final refreshed = await refreshToken();
if (refreshed) {
// 刷新成功,重试
return getCurrentUser();
} else {
throw AuthException('认证已过期,请重新登录');
}
} else {
throw AuthException(e.message);
}
} catch (e) {
if (e is AuthException) rethrow;
throw AuthException('获取用户信息失败: $e');
}
}
/// 更新用户信息
Future<Map<String, dynamic>> updateUserProfile(Map<String, dynamic> profileData) async {
if (!_currentState.isAuthenticated) {
throw AuthException('用户未登录');
}
try {
final data = await _apiClient.put('/users/${_currentState.userId}', data: profileData);
return data;
} on ApiException catch (e) {
if (e.statusCode == 401) {
// 令牌过期,尝试刷新
final refreshed = await refreshToken();
if (refreshed) {
// 刷新成功,重试
return updateUserProfile(profileData);
} else {
throw AuthException('认证已过期,请重新登录');
}
} else {
throw AuthException(e.message);
}
} catch (e) {
if (e is AuthException) rethrow;
throw AuthException('更新用户信息失败: $e');
}
}
/// 修改密码
Future<void> changePassword(String currentPassword, String newPassword) async {
if (!_currentState.isAuthenticated) {
throw AuthException('用户未登录');
}
try {
await _apiClient.post('/auth/change-password', data: {
'currentPassword': currentPassword,
'newPassword': newPassword,
'username': AppConfig.username, // 确保后端能识别当前用户
});
// 密码修改成功
return;
} on ApiException catch (e) {
if (e.statusCode == 401) {
// 令牌过期,尝试刷新
final refreshed = await refreshToken();
if (refreshed) {
// 刷新成功,重试
return changePassword(currentPassword, newPassword);
} else {
throw AuthException('认证已过期,请重新登录');
}
} else {
throw AuthException(e.message);
}
} catch (e) {
if (e is AuthException) rethrow;
throw AuthException('修改密码失败: $e');
}
}
/// 异步调用后端登出接口fire-and-forget模式
Future<void> _callLogoutEndpoint(String token) async {
// 创建临时的Headers选项包含token
final options = Options(headers: {
'Authorization': 'Bearer $token',
});
final request = _apiClient.post('/auth/logout', options: options).timeout(
Duration(seconds: 3), // 设置3秒超时
onTimeout: () {
AppLogger.w('Services/auth_service', '后端登出请求超时');
throw TimeoutException('Logout request timeout', Duration(seconds: 3));
},
);
try {
await request;
AppLogger.i('Services/auth_service', '后端登出成功');
} on ApiException catch (e) {
AppLogger.w('Services/auth_service', '后端登出失败: ${e.message}');
} catch (e) {
AppLogger.w('Services/auth_service', '后端登出请求异常', e);
}
}
/// 关闭服务
void dispose() {
_authStateController.close();
}
}
/// 认证状态类
class AuthState {
AuthState({
required this.isAuthenticated,
this.token = '',
this.userId = '',
this.username = '',
this.error,
});
/// 已认证状态
factory AuthState.authenticated({
required String token,
required String userId,
required String username,
}) {
return AuthState(
isAuthenticated: true,
token: token,
userId: userId,
username: username,
);
}
/// 未认证状态
factory AuthState.unauthenticated() {
return AuthState(isAuthenticated: false);
}
/// 认证错误状态
factory AuthState.error(String errorMessage) {
return AuthState(
isAuthenticated: false,
error: errorMessage,
);
}
final bool isAuthenticated;
final String token;
final String userId;
final String username;
final String? error;
}
/// 认证异常类
class AuthException implements Exception {
AuthException(this.message);
final String message;
@override
String toString() => 'AuthException: $message';
}

View File

@@ -0,0 +1,365 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:ainoval/utils/logger.dart';
/// 图片缓存服务
/// 负责处理用户设置的图片缓存、自适应显示和内存管理
class ImageCacheService {
static final ImageCacheService _instance = ImageCacheService._internal();
factory ImageCacheService() => _instance;
ImageCacheService._internal();
// 内存缓存映射
final Map<String, ui.Image> _memoryCache = {};
final Map<String, ImageInfo> _imageInfoCache = {};
// 缓存限制
static const int _maxCacheSize = 50; // 最大缓存图片数量
static const int _maxMemoryUsage = 100 * 1024 * 1024; // 100MB内存限制
int _currentMemoryUsage = 0;
/// 获取自适应图片组件
Widget getAdaptiveImage({
required String imageUrl,
required double width,
required double height,
BoxFit fit = BoxFit.cover,
String? placeholder,
Color? backgroundColor,
BorderRadius? borderRadius,
double? aspectRatio,
}) {
return FutureBuilder<ui.Image?>(
future: _loadAndCacheImage(imageUrl),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return _buildAdaptiveImageWidget(
snapshot.data!,
width: width,
height: height,
fit: fit,
backgroundColor: backgroundColor,
borderRadius: borderRadius,
aspectRatio: aspectRatio,
);
}
// 显示占位符或加载指示器
return _buildPlaceholder(
width: width,
height: height,
backgroundColor: backgroundColor,
borderRadius: borderRadius,
isLoading: !snapshot.hasError,
placeholder: placeholder,
);
},
);
}
/// 构建自适应图片组件
Widget _buildAdaptiveImageWidget(
ui.Image image, {
required double width,
required double height,
BoxFit fit = BoxFit.cover,
Color? backgroundColor,
BorderRadius? borderRadius,
double? aspectRatio,
}) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: borderRadius,
),
clipBehavior: borderRadius != null ? Clip.antiAlias : Clip.none,
child: CustomPaint(
painter: _AdaptiveImagePainter(
image: image,
fit: fit,
aspectRatio: aspectRatio,
),
size: Size(width, height),
),
);
}
/// 构建占位符
Widget _buildPlaceholder({
required double width,
required double height,
Color? backgroundColor,
BorderRadius? borderRadius,
bool isLoading = false,
String? placeholder,
}) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: backgroundColor ?? Colors.grey[200],
borderRadius: borderRadius,
),
child: isLoading
? const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: Icon(
Icons.broken_image,
color: Colors.grey[400],
size: math.min(width, height) * 0.3,
),
);
}
/// 加载并缓存图片
Future<ui.Image?> _loadAndCacheImage(String imageUrl) async {
try {
// 检查内存缓存
if (_memoryCache.containsKey(imageUrl)) {
AppLogger.d('ImageCache', '从内存缓存加载图片: $imageUrl');
return _memoryCache[imageUrl];
}
// 加载图片
ui.Image? image;
if (imageUrl.startsWith('http')) {
// 网络图片
image = await _loadNetworkImage(imageUrl);
} else if (imageUrl.startsWith('assets/')) {
// 资源图片
image = await _loadAssetImage(imageUrl);
} else {
// 本地文件图片
image = await _loadFileImage(imageUrl);
}
if (image != null) {
await _cacheImage(imageUrl, image);
}
return image;
} catch (e) {
AppLogger.e('ImageCache', '加载图片失败: $imageUrl', e);
return null;
}
}
/// 加载网络图片
Future<ui.Image?> _loadNetworkImage(String url) async {
try {
final NetworkImage provider = NetworkImage(url);
final ImageStream stream = provider.resolve(ImageConfiguration.empty);
final Completer<ui.Image> completer = Completer<ui.Image>();
late ImageStreamListener listener;
listener = ImageStreamListener(
(ImageInfo info, bool synchronousCall) {
completer.complete(info.image);
stream.removeListener(listener);
},
onError: (dynamic exception, StackTrace? stackTrace) {
completer.completeError(exception, stackTrace);
stream.removeListener(listener);
},
);
stream.addListener(listener);
return await completer.future;
} catch (e) {
AppLogger.e('ImageCache', '加载网络图片失败: $url', e);
return null;
}
}
/// 加载资源图片
Future<ui.Image?> _loadAssetImage(String assetPath) async {
try {
final ByteData data = await rootBundle.load(assetPath);
final Uint8List bytes = data.buffer.asUint8List();
final ui.Codec codec = await ui.instantiateImageCodec(bytes);
final ui.FrameInfo frame = await codec.getNextFrame();
return frame.image;
} catch (e) {
AppLogger.e('ImageCache', '加载资源图片失败: $assetPath', e);
return null;
}
}
/// 加载本地文件图片
Future<ui.Image?> _loadFileImage(String filePath) async {
try {
final File file = File(filePath);
if (!await file.exists()) {
return null;
}
final Uint8List bytes = await file.readAsBytes();
final ui.Codec codec = await ui.instantiateImageCodec(bytes);
final ui.FrameInfo frame = await codec.getNextFrame();
return frame.image;
} catch (e) {
AppLogger.e('ImageCache', '加载本地图片失败: $filePath', e);
return null;
}
}
/// 缓存图片
Future<void> _cacheImage(String key, ui.Image image) async {
// 检查缓存大小限制
if (_memoryCache.length >= _maxCacheSize) {
_evictOldestCache();
}
// 估算图片内存使用
final int imageBytes = image.width * image.height * 4; // RGBA
// 检查内存限制
if (_currentMemoryUsage + imageBytes > _maxMemoryUsage) {
await _evictCacheToFitMemory(imageBytes);
}
_memoryCache[key] = image;
_currentMemoryUsage += imageBytes;
AppLogger.d('ImageCache',
'缓存图片: $key, 尺寸: ${image.width}x${image.height}, 内存使用: ${_currentMemoryUsage ~/ 1024}KB');
}
/// 移除最旧的缓存
void _evictOldestCache() {
if (_memoryCache.isNotEmpty) {
final String firstKey = _memoryCache.keys.first;
final ui.Image? image = _memoryCache.remove(firstKey);
if (image != null) {
final int imageBytes = image.width * image.height * 4;
_currentMemoryUsage -= imageBytes;
image.dispose();
}
_imageInfoCache.remove(firstKey);
}
}
/// 移除缓存以腾出内存空间
Future<void> _evictCacheToFitMemory(int requiredBytes) async {
while (_currentMemoryUsage + requiredBytes > _maxMemoryUsage &&
_memoryCache.isNotEmpty) {
_evictOldestCache();
}
}
/// 清理所有缓存
void clearCache() {
for (final ui.Image image in _memoryCache.values) {
image.dispose();
}
_memoryCache.clear();
_imageInfoCache.clear();
_currentMemoryUsage = 0;
AppLogger.i('ImageCache', '清理所有图片缓存');
}
/// 预加载图片
Future<void> preloadImage(String imageUrl) async {
if (!_memoryCache.containsKey(imageUrl)) {
await _loadAndCacheImage(imageUrl);
}
}
/// 获取缓存统计信息
Map<String, dynamic> getCacheStats() {
return {
'cacheSize': _memoryCache.length,
'memoryUsage': _currentMemoryUsage,
'memoryUsageKB': _currentMemoryUsage ~/ 1024,
'memoryUsageMB': _currentMemoryUsage ~/ (1024 * 1024),
};
}
}
/// 自适应图片绘制器
class _AdaptiveImagePainter extends CustomPainter {
final ui.Image image;
final BoxFit fit;
final double? aspectRatio;
_AdaptiveImagePainter({
required this.image,
required this.fit,
this.aspectRatio,
});
@override
void paint(Canvas canvas, Size size) {
final double imageAspectRatio = image.width / image.height;
final double containerAspectRatio = size.width / size.height;
Rect srcRect = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
Rect dstRect;
switch (fit) {
case BoxFit.cover:
if (imageAspectRatio > containerAspectRatio) {
// 图片更宽,裁剪左右
final double newWidth = image.height * containerAspectRatio;
final double offsetX = (image.width - newWidth) / 2;
srcRect = Rect.fromLTWH(offsetX, 0, newWidth, image.height.toDouble());
} else {
// 图片更高,裁剪上下
final double newHeight = image.width / containerAspectRatio;
final double offsetY = (image.height - newHeight) / 2;
srcRect = Rect.fromLTWH(0, offsetY, image.width.toDouble(), newHeight);
}
dstRect = Rect.fromLTWH(0, 0, size.width, size.height);
break;
case BoxFit.contain:
if (imageAspectRatio > containerAspectRatio) {
// 图片更宽,适应宽度
final double newHeight = size.width / imageAspectRatio;
final double offsetY = (size.height - newHeight) / 2;
dstRect = Rect.fromLTWH(0, offsetY, size.width, newHeight);
} else {
// 图片更高,适应高度
final double newWidth = size.height * imageAspectRatio;
final double offsetX = (size.width - newWidth) / 2;
dstRect = Rect.fromLTWH(offsetX, 0, newWidth, size.height);
}
break;
case BoxFit.fill:
default:
dstRect = Rect.fromLTWH(0, 0, size.width, size.height);
break;
}
// 使用高质量图片渲染
final Paint paint = Paint()
..filterQuality = FilterQuality.high
..isAntiAlias = true;
canvas.drawImageRect(image, srcRect, dstRect, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return oldDelegate is! _AdaptiveImagePainter ||
oldDelegate.image != image ||
oldDelegate.fit != fit ||
oldDelegate.aspectRatio != aspectRatio;
}
}

View File

@@ -0,0 +1,914 @@
import 'dart:convert';
import 'package:ainoval/models/editor_content.dart';
import 'package:ainoval/models/novel_structure.dart' as novel_models;
import 'package:ainoval/models/novel_summary.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart'; // Import uuid package
import 'package:hive/hive.dart';
import '../models/chat_models.dart';
/// 本地存储服务,用于缓存和获取小说数据
class LocalStorageService {
SharedPreferences? _prefs;
final Uuid _uuid = const Uuid(); // For generating unique local IDs
// 添加小说缓存
final Map<String, novel_models.Novel> _novelCache = {};
final Map<String, DateTime> _novelCacheTimestamp = {};
final Duration _cacheTTL = const Duration(minutes: 5); // 缓存有效期
final Map<String, String> _wordCountCache = {}; // 场景字数缓存
// 初始化
Future<void> init() async {
_prefs ??= await SharedPreferences.getInstance();
}
// 确保已初始化
Future<SharedPreferences> _ensureInitialized() async {
if (_prefs == null) {
await init();
}
return _prefs!;
}
// 基础存储方法
Future<String?> getString(String key) async {
final prefs = await _ensureInitialized();
return prefs.getString(key);
}
Future<bool> setString(String key, String value) async {
final prefs = await _ensureInitialized();
return await prefs.setString(key, value);
}
Future<bool> remove(String key) async {
final prefs = await _ensureInitialized();
return await prefs.remove(key);
}
// 存储键
static const String _novelsKey = 'novels';
static const String _currentNovelKey = 'current_novel';
static const String _editorContentPrefix = 'editor_content_';
static const String _editorSettingsKey = 'editor_settings';
// --- New Key for Pending Messages ---
static const String _pendingMessagesKey = 'pending_chat_messages';
// 获取所有小说
Future<List<novel_models.Novel>> getNovels() async {
final prefs = await _ensureInitialized();
final novelsJson = prefs.getStringList(_novelsKey) ?? [];
/* AppLogger.d('LocalStorageService',
'getNovels: Raw JSON list from prefs: $novelsJson');
*/
try {
final novels = novelsJson.map((json) {
// AppLogger.v('LocalStorageService', 'getNovels: Parsing JSON: $json');
final novel = novel_models.Novel.fromJson(jsonDecode(json));
AppLogger.v('LocalStorageService',
'getNovels: Parsed Novel: ID=${novel.id}, Title=${novel.title}, Acts=${novel.acts.length}');
return novel;
}).toList();
AppLogger.i('LocalStorageService',
'getNovels: Successfully parsed ${novels.length} novels.');
return novels;
} catch (e, stackTrace) {
AppLogger.e('LocalStorageService',
'getNovels: Failed to parse novels JSON.', e, stackTrace);
return [];
}
}
// 保存所有小说
Future<void> saveNovels(List<novel_models.Novel> novels) async {
final prefs = await _ensureInitialized();
try {
final novelsJson = novels.map((novel) {
final jsonMap = novel.toJson();
final jsonString = jsonEncode(jsonMap);
AppLogger.v('LocalStorageService',
'saveNovels: Serializing Novel ID=${novel.id}, Title=${novel.title}, Acts=${novel.acts.length}');
return jsonString;
}).toList();
/* AppLogger.d('LocalStorageService',
'saveNovels: Saving JSON list to prefs: $novelsJson'); */
await prefs.setStringList(_novelsKey, novelsJson);
AppLogger.i('LocalStorageService',
'saveNovels: Successfully saved ${novels.length} novels.');
} catch (e, stackTrace) {
AppLogger.e('LocalStorageService', 'saveNovels: Failed to save novels.',
e, stackTrace);
}
}
// 保存小说摘要列表
Future<void> saveNovelSummaries(List<NovelSummary> novels) async {
final prefs = await _ensureInitialized();
final novelsJson =
novels.map((novel) => jsonEncode(novel.toJson())).toList();
await prefs.setStringList('novel_summaries', novelsJson);
}
// 获取单个小说
Future<novel_models.Novel?> getNovel(String id) async {
AppLogger.d('LocalStorageService',
'getNovel: Attempting to get novel with ID: $id');
// 检查缓存是否存在且有效
if (_novelCache.containsKey(id)) {
final cacheTime = _novelCacheTimestamp[id];
if (cacheTime != null && DateTime.now().difference(cacheTime) < _cacheTTL) {
AppLogger.i('LocalStorageService',
'getNovel: Using cached novel: ID=$id, Title=${_novelCache[id]!.title}, Acts=${_novelCache[id]!.acts.length}');
return _novelCache[id];
}
}
// 缓存不存在或已过期,从存储获取
final novels = await getNovels();
try {
final novel = novels.firstWhere(
(novel) => novel.id == id,
);
// 更新缓存
_novelCache[id] = novel;
_novelCacheTimestamp[id] = DateTime.now();
AppLogger.i('LocalStorageService',
'getNovel: Found novel: ID=${novel.id}, Title=${novel.title}, Acts=${novel.acts.length}');
return novel;
} catch (e) {
AppLogger.w(
'LocalStorageService', 'getNovel: Novel with ID $id not found.', e);
return null;
}
}
// 保存单个小说
Future<void> saveNovel(novel_models.Novel novel) async {
//AppLogger.d('LocalStorageService',
// 'saveNovel: Attempting to save novel ID=${novel.id}, Title=${novel.title}, Acts=${novel.acts.length}');
// 检查上次保存时间,如果短时间内多次保存同一个小说,可以合并为一次操作
final cacheTime = _novelCacheTimestamp[novel.id];
final now = DateTime.now();
if (cacheTime != null && now.difference(cacheTime).inMilliseconds < 500) {
// 如果500毫秒内有多次保存只更新缓存延迟实际的存储操作
_novelCache[novel.id] = novel;
_novelCacheTimestamp[novel.id] = now;
//AppLogger.i('LocalStorageService',
// 'saveNovel: Multiple saves detected within 500ms, delaying actual storage operation for novel ID=${novel.id}');
return;
}
// 更新缓存
_novelCache[novel.id] = novel;
_novelCacheTimestamp[novel.id] = now;
try {
final novels = await getNovels();
final index = novels.indexWhere((n) => n.id == novel.id);
if (index >= 0) {
AppLogger.d('LocalStorageService',
'saveNovel: Updating existing novel at index $index.');
novels[index] = novel;
} else {
AppLogger.d('LocalStorageService', 'saveNovel: Adding new novel.');
novels.add(novel);
}
await saveNovels(novels);
AppLogger.i('LocalStorageService',
'saveNovel: Completed saving process for novel ID=${novel.id}.');
} catch (e) {
AppLogger.e('LocalStorageService', 'saveNovel: Failed to save novel', e);
// 从缓存中移除,以便下次重新加载
_novelCache.remove(novel.id);
_novelCacheTimestamp.remove(novel.id);
throw Exception('保存小说失败: $e');
}
}
// 删除小说
Future<void> deleteNovel(String id) async {
final novels = await getNovels();
novels.removeWhere((novel) => novel.id == id);
await saveNovels(novels);
}
// 获取当前正在编辑的小说ID
Future<String?> getCurrentNovelId() async {
final prefs = await _ensureInitialized();
return prefs.getString(_currentNovelKey);
}
// 设置当前正在编辑的小说ID
Future<void> setCurrentNovelId(String id) async {
final prefs = await _ensureInitialized();
final previousId = prefs.getString(_currentNovelKey);
// 如果前一个小说ID存在且与新ID不同清理前一个小说的同步标记
if (previousId != null && previousId.isNotEmpty && previousId != id) {
AppLogger.i('LocalStorageService', '小说ID切换: $previousId -> $id');
// 如果是设置为空ID特殊情况如app关闭不触发清理
if (id.isNotEmpty) {
await clearNovelSyncFlags(previousId);
}
}
await prefs.setString(_currentNovelKey, id);
AppLogger.i('LocalStorageService', '当前小说ID已设置为: $id');
}
// 获取章节内容
Future<EditorContent?> getChapterContent(
String novelId, String chapterId) async {
final prefs = await _ensureInitialized();
final key = _getContentKey(novelId, chapterId);
final jsonString = prefs.getString(key);
if (jsonString == null) {
return null;
}
try {
final json = jsonDecode(jsonString);
return EditorContent.fromJson(json);
} catch (e) {
AppLogger.e('LocalStorageService', '解析章节内容失败', e);
return null;
}
}
// 保存章节内容
Future<void> saveChapterContent(
String novelId, String chapterId, EditorContent content) async {
final prefs = await _ensureInitialized();
final key = _getContentKey(novelId, chapterId);
final jsonString = jsonEncode(content.toJson());
await prefs.setString(key, jsonString);
}
// 获取编辑器内容
Future<EditorContent?> getEditorContent(
String novelId, String chapterId, String sceneId) async {
return getChapterContent(novelId, chapterId);
}
// 保存编辑器内容
Future<void> saveEditorContent(EditorContent content) async {
final parts = content.id.split('-');
if (parts.length == 3) {
final novelId = parts[0];
final chapterId = parts[1];
final sceneId = parts[2];
await saveChapterContent(novelId, chapterId, content);
} else if (parts.length == 2) {
// 兼容旧格式
final novelId = parts[0];
final chapterId = parts[1];
await saveChapterContent(novelId, chapterId, content);
}
}
// 获取编辑器设置
Future<Map<String, dynamic>> getEditorSettings() async {
try {
final prefs = await _ensureInitialized();
final settingsJson = prefs.getString('editor_settings');
if (settingsJson != null) {
return jsonDecode(settingsJson) as Map<String, dynamic>;
}
// 返回默认设置
return {
'fontSize': 16.0,
'lineHeight': 1.5,
'fontFamily': 'Roboto',
'theme': 'light',
'autoSave': true,
};
} catch (e) {
AppLogger.e('LocalStorageService', '获取编辑器设置失败', e);
// 返回默认设置
return {
'fontSize': 16.0,
'lineHeight': 1.5,
'fontFamily': 'Roboto',
'theme': 'light',
'autoSave': true,
};
}
}
// 保存编辑器设置
Future<void> saveEditorSettings(Map<String, dynamic> settings) async {
try {
final prefs = await _ensureInitialized();
await prefs.setString('editor_settings', jsonEncode(settings));
} catch (e) {
AppLogger.e('LocalStorageService', '保存编辑器设置失败', e);
throw Exception('保存编辑器设置失败: $e');
}
}
// 生成内容存储键
String _getContentKey(String novelId, String chapterId) {
return '$_editorContentPrefix${novelId}_$chapterId';
}
/// 获取场景内容
Future<novel_models.Scene?> getSceneContent(
String novelId, String actId, String chapterId, String sceneId) async {
try {
final novel = await getNovel(novelId);
if (novel == null) return null;
final act = novel.acts.firstWhere((a) => a.id == actId);
final chapter = act.chapters.firstWhere((c) => c.id == chapterId);
if (chapter.scenes.isEmpty) return null;
// 查找特定场景
try {
return chapter.scenes.firstWhere((s) => s.id == sceneId);
} catch (e) {
// 如果找不到特定场景,返回第一个场景
return chapter.scenes.first;
}
} catch (e) {
return null;
}
}
/// 保存场景内容
Future<void> saveSceneContent(String novelId, String actId, String chapterId,
String sceneId, novel_models.Scene scene) async {
try {
// 生成场景缓存键
final sceneKey = '${novelId}_${actId}_${chapterId}_$sceneId';
// 如果缓存中有小说,则直接更新缓存中的场景内容
if (_novelCache.containsKey(novelId)) {
final novel = _novelCache[novelId]!;
bool sceneUpdated = false;
// 查找并更新缓存中的场景
final updatedActs = novel.acts.map((act) {
if (act.id == actId) {
final updatedChapters = act.chapters.map((chapter) {
if (chapter.id == chapterId) {
final sceneIndex = chapter.scenes.indexWhere((s) => s.id == sceneId);
if (sceneIndex >= 0) {
// 更新现有场景
final updatedScenes = List<novel_models.Scene>.from(chapter.scenes);
updatedScenes[sceneIndex] = scene;
sceneUpdated = true;
return chapter.copyWith(scenes: updatedScenes);
} else {
// 添加新场景
sceneUpdated = true;
return chapter.copyWith(
scenes: [...chapter.scenes, scene],
);
}
}
return chapter;
}).toList();
if (sceneUpdated) {
return act.copyWith(chapters: updatedChapters);
}
}
return act;
}).toList();
if (sceneUpdated) {
// 更新缓存中的小说
final updatedNovel = novel.copyWith(
acts: updatedActs,
updatedAt: DateTime.now(),
);
_novelCache[novelId] = updatedNovel;
_novelCacheTimestamp[novelId] = DateTime.now();
// 更新字数缓存
_updateWordCountCache(sceneKey, scene.content, scene.wordCount);
AppLogger.i('LocalStorageService',
'saveSceneContent: Updated scene in cached novel: $sceneKey');
}
}
// 正常保存场景到存储
final novel = await getNovel(novelId);
if (novel == null) return;
final acts = novel.acts.map((act) {
if (act.id == actId) {
final chapters = act.chapters.map((chapter) {
if (chapter.id == chapterId) {
// 查找特定场景
final sceneIndex =
chapter.scenes.indexWhere((s) => s.id == sceneId);
List<novel_models.Scene> updatedScenes;
if (sceneIndex >= 0) {
// 更新现有场景
updatedScenes = List.from(chapter.scenes);
updatedScenes[sceneIndex] = scene;
} else {
// 添加新场景
updatedScenes = List.from(chapter.scenes)..add(scene);
}
return chapter.copyWith(scenes: updatedScenes);
}
return chapter;
}).toList();
return act.copyWith(chapters: chapters);
}
return act;
}).toList();
final updatedNovel = novel.copyWith(
acts: acts,
updatedAt: DateTime.now(),
);
await saveNovel(updatedNovel);
// 更新字数缓存
_updateWordCountCache(sceneKey, scene.content, scene.wordCount);
} catch (e) {
AppLogger.e('LocalStorageService', '保存场景内容失败', e);
}
}
/// 保存摘要内容
Future<void> saveSummary(String novelId, String actId, String chapterId,
String sceneId, novel_models.Summary summary) async {
try {
final novel = await getNovel(novelId);
if (novel == null) return;
final acts = novel.acts.map((act) {
if (act.id == actId) {
final chapters = act.chapters.map((chapter) {
if (chapter.id == chapterId) {
// 查找特定场景
final sceneIndex =
chapter.scenes.indexWhere((s) => s.id == sceneId);
List<novel_models.Scene> updatedScenes;
if (sceneIndex >= 0) {
// 更新现有场景
updatedScenes = List.from(chapter.scenes);
updatedScenes[sceneIndex] =
updatedScenes[sceneIndex].copyWith(summary: summary);
} else {
// 如果场景不存在,不做任何操作
updatedScenes = chapter.scenes;
}
return chapter.copyWith(scenes: updatedScenes);
}
return chapter;
}).toList();
return act.copyWith(chapters: chapters);
}
return act;
}).toList();
final updatedNovel = novel.copyWith(
acts: acts,
updatedAt: DateTime.now(),
);
await saveNovel(updatedNovel);
} catch (e) {
AppLogger.e('LocalStorageService', '保存摘要内容失败', e);
}
}
// 标记需要同步的内容(按类型)
Future<void> markForSyncByType(String id, String type) async {
try {
final prefs = await _ensureInitialized();
final syncKey = 'syncList_$type';
final syncList = prefs.getStringList(syncKey) ?? [];
if (!syncList.contains(id)) {
syncList.add(id);
await prefs.setStringList(syncKey, syncList);
AppLogger.i('LocalStorageService', '已标记 $type: $id 需要同步');
}
} catch (e) {
AppLogger.e('LocalStorageService', '标记同步失败', e);
}
}
// 获取需要同步的内容列表(按类型)
Future<List<String>> getSyncList(String type) async {
try {
final prefs = await _ensureInitialized();
final syncKey = 'syncList_$type';
return prefs.getStringList(syncKey) ?? [];
} catch (e) {
AppLogger.e('LocalStorageService', '获取同步列表失败', e);
return [];
}
}
// 清除同步标记按类型和ID
Future<void> clearSyncFlagByType(String type, String id) async {
try {
final prefs = await _ensureInitialized();
final syncKey = 'syncList_$type';
final syncList = prefs.getStringList(syncKey) ?? [];
if (syncList.contains(id)) {
syncList.remove(id);
await prefs.setStringList(syncKey, syncList);
AppLogger.i('LocalStorageService', '已清除 $type: $id 的同步标记');
}
} catch (e) {
AppLogger.e('LocalStorageService', '清除同步标记失败', e);
}
}
// 保存聊天会话列表
Future<void> saveChatSessions(
String novelId, List<ChatSession> sessions) async {
final key = 'chat_sessions_$novelId';
final jsonList =
sessions.map((session) => jsonEncode(session.toJson())).toList();
final prefs = await _ensureInitialized();
await prefs.setStringList(key, jsonList);
}
// 获取聊天会话列表
Future<List<ChatSession>> getChatSessions(String novelId) async {
final key = 'chat_sessions_$novelId';
final prefs = await _ensureInitialized();
final jsonList = prefs.getStringList(key) ?? [];
return jsonList
.map((json) => ChatSession.fromJson(jsonDecode(json)))
.toList();
}
// 添加聊天会话
Future<void> addChatSession(String novelId, ChatSession session,
{bool needsSync = false}) async {
final sessions = await getChatSessions(novelId);
sessions.add(session);
await saveChatSessions(novelId, sessions);
await updateChatSession(session, needsSync: needsSync);
}
// 获取特定会话
Future<ChatSession?> getChatSession(String sessionId) async {
final key = 'chat_session_detail_$sessionId';
final prefs = await _ensureInitialized();
final json = prefs.getString(key);
if (json == null) {
return null;
}
return ChatSession.fromJson(jsonDecode(json));
}
// 更新会话 - 同时处理标记同步
Future<void> updateChatSession(ChatSession session,
{bool needsSync = false}) async {
final key = 'chat_session_detail_${session.id}';
final prefs = await _ensureInitialized();
await prefs.setString(key, jsonEncode(session.toJson()));
if (needsSync) {
await markForSyncByType(session.id, 'chat_session');
}
}
// 删除会话
Future<void> deleteChatSession(String sessionId) async {
final key = 'chat_session_detail_$sessionId';
final prefs = await _ensureInitialized();
await prefs.remove(key);
await clearSyncFlagByType('chat_session', sessionId);
}
// 获取需要同步的所有会话
Future<List<ChatSession>> getSessionsToSync() async {
final syncList = await getSyncList('chat_session');
final sessions = <ChatSession>[];
for (final sessionId in syncList) {
final session = await getChatSession(sessionId);
if (session != null) {
sessions.add(session);
} else {
AppLogger.w('LocalStorageService',
'getSessionsToSync: 未找到标记为同步的会话详情: $sessionId。考虑清除此标记。');
}
}
return sessions;
}
// 清除所有数据
Future<void> clearAll() async {
final prefs = await _ensureInitialized();
await prefs.clear();
}
/// 获取会话的消息列表(用于显示历史记录)
Future<List<ChatMessage>?> getMessagesForSession(String sessionId) async {
final prefs = await _ensureInitialized();
final key = 'chat_messages_$sessionId'; // Key for storing full history
final jsonList = prefs.getStringList(key);
if (jsonList == null) return null;
try {
// Sort messages by timestamp after parsing
final messages = jsonList
.map((json) => ChatMessage.fromJson(jsonDecode(json)))
.toList();
messages.sort((a, b) => a.timestamp.compareTo(b.timestamp));
return messages;
} catch (e, stackTrace) {
AppLogger.e(
'LocalStorageService', '解析会话 $sessionId 的消息失败', e, stackTrace);
return null;
}
}
/// 保存会话的消息列表(用于缓存历史记录)
Future<void> saveMessagesForSession(
String sessionId, List<ChatMessage> messages) async {
final prefs = await _ensureInitialized();
final key = 'chat_messages_$sessionId'; // Key for storing full history
// Sort messages by timestamp before saving
messages.sort((a, b) => a.timestamp.compareTo(b.timestamp));
final jsonList = messages.map((msg) => jsonEncode(msg.toJson())).toList();
await prefs.setStringList(key, jsonList);
}
/// 添加单条消息到会话历史(例如,收到新消息或发送成功后)
Future<void> addMessageToSessionHistory(
String sessionId, ChatMessage message) async {
final messages = await getMessagesForSession(sessionId) ?? [];
// Avoid duplicates if message already exists
if (!messages.any((m) => m.id == message.id)) {
messages.add(message);
await saveMessagesForSession(sessionId, messages);
}
}
/// 添加待发送消息到队列
Future<String> addPendingMessage({
required String userId,
required String sessionId,
required String content,
required Map<String, dynamic>? metadata,
}) async {
final prefs = await _ensureInitialized();
final pendingList = prefs.getStringList(_pendingMessagesKey) ?? [];
final localId = _uuid.v4(); // Generate unique local ID
final pendingMessageData = {
'localId': localId, // Unique ID for removal later
'userId': userId,
'sessionId': sessionId,
'content': content,
'metadata': metadata,
'timestamp':
DateTime.now().toIso8601String(), // Store time added to queue
};
pendingList.add(jsonEncode(pendingMessageData));
await prefs.setStringList(_pendingMessagesKey, pendingList);
AppLogger.i('LocalStorageService',
'添加待发送消息到队列: sessionId=$sessionId, localId=$localId');
return localId; // Return localId in case UI needs it
}
/// 获取所有待发送消息
Future<List<Map<String, dynamic>>> getPendingMessages() async {
final prefs = await _ensureInitialized();
final jsonList = prefs.getStringList(_pendingMessagesKey) ?? [];
try {
return jsonList
.map((json) => jsonDecode(json) as Map<String, dynamic>)
.toList();
} catch (e, stackTrace) {
AppLogger.e('LocalStorageService', '解析待发送消息队列失败', e, stackTrace);
// Optionally clear the corrupted queue
// await prefs.remove(_pendingMessagesKey);
return [];
}
}
/// 从队列中移除已发送的消息 (通过 localId)
Future<void> removePendingMessage(String localId) async {
final prefs = await _ensureInitialized();
final pendingList = prefs.getStringList(_pendingMessagesKey) ?? [];
final updatedList = <String>[];
bool removed = false;
for (final jsonString in pendingList) {
try {
final data = jsonDecode(jsonString) as Map<String, dynamic>;
if (data['localId'] != localId) {
updatedList.add(jsonString);
} else {
removed = true;
}
} catch (e) {
// Skip corrupted entry
AppLogger.w('LocalStorageService', '移除待发送消息时跳过损坏条目: $e');
}
}
if (removed) {
await prefs.setStringList(_pendingMessagesKey, updatedList);
AppLogger.i('LocalStorageService', '从队列移除待发送消息: localId=$localId');
} else {
AppLogger.w('LocalStorageService', '尝试移除待发送消息,但未找到: localId=$localId');
}
}
// 清理所有同步标记
Future<void> clearAllSyncFlags() async {
final prefs = await _ensureInitialized();
final syncTypes = ['novel', 'scene', 'editor', 'chat_session'];
for (final type in syncTypes) {
final syncKey = 'syncList_$type';
await prefs.remove(syncKey);
}
AppLogger.i('LocalStorageService', '已清理所有同步标记');
}
// 清理指定小说的同步标记
Future<void> clearNovelSyncFlags(String novelId) async {
if (novelId.isEmpty) return;
final prefs = await _ensureInitialized();
// 清理小说本身的同步标记
const novelSyncKey = 'syncList_novel';
final novelSyncList = prefs.getStringList(novelSyncKey) ?? [];
if (novelSyncList.contains(novelId)) {
novelSyncList.remove(novelId);
await prefs.setStringList(novelSyncKey, novelSyncList);
AppLogger.i('LocalStorageService', '已清理小说同步标记: $novelId');
}
// 清理场景同步标记
const sceneSyncKey = 'syncList_scene';
final sceneSyncList = prefs.getStringList(sceneSyncKey) ?? [];
final updatedSceneSyncList = sceneSyncList.where((sceneKey) {
final parts = sceneKey.split('_');
return parts.isEmpty || parts[0] != novelId;
}).toList();
if (updatedSceneSyncList.length != sceneSyncList.length) {
await prefs.setStringList(sceneSyncKey, updatedSceneSyncList);
AppLogger.i('LocalStorageService',
'已清理场景同步标记: ${sceneSyncList.length - updatedSceneSyncList.length} 个场景小说ID: $novelId');
}
// 清理编辑器内容同步标记
const editorSyncKey = 'syncList_editor';
final editorSyncList = prefs.getStringList(editorSyncKey) ?? [];
final updatedEditorSyncList = editorSyncList.where((contentKey) {
final parts = contentKey.split('_');
return parts.isEmpty || parts[0] != novelId;
}).toList();
if (updatedEditorSyncList.length != editorSyncList.length) {
await prefs.setStringList(editorSyncKey, updatedEditorSyncList);
AppLogger.i('LocalStorageService',
'已清理编辑器内容同步标记: ${editorSyncList.length - updatedEditorSyncList.length} 个内容小说ID: $novelId');
}
// 清理聊天会话同步标记
// 注意这需要先获取所有会话然后检查它们的metadata中的novelId
final sessions = await getSessionsToSync();
final sessionsToRemove = sessions.where((session) =>
session.metadata != null && session.metadata!['novelId'] == novelId).toList();
for (final session in sessionsToRemove) {
await clearSyncFlagByType('chat_session', session.id);
AppLogger.i('LocalStorageService', '已清理聊天会话同步标记: ${session.id}小说ID: $novelId');
}
}
/// 获取指定章节的所有场景键
Future<List<String>> getSceneKeysForChapter(
String novelId,
String actId,
String chapterId,
) async {
try {
final box = await Hive.openBox('scenes');
final prefix = '${novelId}_${actId}_${chapterId}_';
// 过滤出所有属于该章节的场景键
final List<String> sceneKeys = [];
for (final key in box.keys) {
if (key is String && key.startsWith(prefix)) {
// 从键中提取场景ID
final sceneId = key.substring(prefix.length);
sceneKeys.add(sceneId);
}
}
return sceneKeys;
} catch (e) {
AppLogger.e('LocalStorageService', '获取章节场景键失败', e);
return [];
}
}
/// 删除场景内容
Future<void> deleteSceneContent(
String novelId,
String actId,
String chapterId,
String sceneId,
) async {
final sceneKey = '${novelId}_${actId}_${chapterId}_$sceneId';
try {
// 使用SharedPreferences删除场景内容
final prefs = await _ensureInitialized();
await prefs.remove('scene_$sceneKey');
// 从场景索引中移除
final indexKey = 'scenes_index_${novelId}_${actId}_$chapterId';
final sceneIds = prefs.getStringList(indexKey) ?? [];
if (sceneIds.contains(sceneId)) {
sceneIds.remove(sceneId);
await prefs.setStringList(indexKey, sceneIds);
}
AppLogger.i('LocalStorageService', '本地场景内容已删除: $sceneKey');
} catch (e) {
AppLogger.e('LocalStorageService', '删除场景内容失败: $sceneKey', e);
throw Exception('删除场景内容失败: $e');
}
}
// 优化的字数统计缓存
void _updateWordCountCache(String sceneKey, String content, int wordCount) {
final contentHash = content.hashCode.toString();
final cacheKey = '${sceneKey}_$contentHash';
_wordCountCache[cacheKey] = wordCount.toString();
}
// 从缓存获取字数统计
int? getWordCountFromCache(String sceneKey, String content) {
final contentHash = content.hashCode.toString();
final cacheKey = '${sceneKey}_$contentHash';
final cachedCount = _wordCountCache[cacheKey];
if (cachedCount != null) {
return int.tryParse(cachedCount);
}
return null;
}
// 清除指定小说的缓存
Future<void> clearNovelCache(String novelId) async {
AppLogger.i('LocalStorageService', '清除小说缓存: $novelId');
_novelCache.remove(novelId);
_novelCacheTimestamp.remove(novelId);
}
// 清除所有小说缓存
Future<void> clearAllNovelCache() async {
AppLogger.i('LocalStorageService', '清除所有小说缓存');
_novelCache.clear();
_novelCacheTimestamp.clear();
}
}

View File

@@ -0,0 +1,319 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/novel_structure.dart';
import '../utils/logger.dart';
/// 小说缓存服务
/// 提供小说本地缓存、阅读进度管理等功能
class NovelCacheService {
static const String _cacheDirectoryName = 'novel_cache';
static const String _readingProgressKey = 'reading_progress';
static const String _novelMetadataPrefix = 'novel_metadata_';
// 单例模式
static final NovelCacheService _instance = NovelCacheService._internal();
factory NovelCacheService() => _instance;
NovelCacheService._internal();
// 缓存目录
Directory? _cacheDirectory;
/// 初始化缓存服务
Future<void> init() async {
try {
_cacheDirectory = await getNovelCacheDirectory();
await _cacheDirectory!.create(recursive: true);
AppLogger.i('NovelCacheService', '缓存服务初始化完成: ${_cacheDirectory!.path}');
} catch (e, stackTrace) {
AppLogger.e('NovelCacheService', '缓存服务初始化失败', e, stackTrace);
}
}
/// 获取小说缓存目录
Future<Directory> getNovelCacheDirectory() async {
final appDocDir = await getApplicationDocumentsDirectory();
return Directory('${appDocDir.path}/$_cacheDirectoryName');
}
/// 保存完整小说数据到本地缓存
Future<void> saveCompleteNovel(Novel novel) async {
try {
if (_cacheDirectory == null) {
await init();
}
final file = File('${_cacheDirectory!.path}/novel_${novel.id}.json');
final jsonData = json.encode(novel.toJson());
await file.writeAsString(jsonData);
// 保存小说元数据(包括服务器更新时间)
await _saveNovelMetadata(novel.id, {
'serverUpdatedAt': novel.updatedAt.toIso8601String(),
'isCached': true,
'cachedAt': DateTime.now().toIso8601String(),
});
AppLogger.i('NovelCacheService', '完整小说缓存保存成功: ${novel.id}');
} catch (e, stackTrace) {
AppLogger.e('NovelCacheService', '保存完整小说缓存失败: ${novel.id}', e, stackTrace);
rethrow;
}
}
/// 从本地缓存读取完整小说数据
Future<Novel?> getCompleteNovel(String novelId) async {
try {
if (_cacheDirectory == null) {
await init();
}
final file = File('${_cacheDirectory!.path}/novel_$novelId.json');
if (!await file.exists()) {
AppLogger.v('NovelCacheService', '小说缓存文件不存在: $novelId');
return null;
}
final jsonString = await file.readAsString();
final jsonData = json.decode(jsonString) as Map<String, dynamic>;
final novel = Novel.fromJson(jsonData);
AppLogger.v('NovelCacheService', '成功读取缓存小说: $novelId');
return novel;
} catch (e, stackTrace) {
AppLogger.e('NovelCacheService', '读取缓存小说失败: $novelId', e, stackTrace);
return null;
}
}
/// 获取缓存的小说服务器更新时间
Future<DateTime?> getCachedNovelServerUpdatedAt(String novelId) async {
try {
final metadata = await _getNovelMetadata(novelId);
if (metadata?['serverUpdatedAt'] != null) {
return DateTime.parse(metadata!['serverUpdatedAt']);
}
return null;
} catch (e, stackTrace) {
AppLogger.e('NovelCacheService', '获取缓存小说服务器更新时间失败: $novelId', e, stackTrace);
return null;
}
}
/// 标记小说是否已完整缓存
Future<void> markNovelAsFullyCached(String novelId, bool isFullyCached) async {
try {
final metadata = await _getNovelMetadata(novelId) ?? {};
metadata['isCached'] = isFullyCached;
metadata['lastUpdated'] = DateTime.now().toIso8601String();
await _saveNovelMetadata(novelId, metadata);
} catch (e, stackTrace) {
AppLogger.e('NovelCacheService', '标记小说缓存状态失败: $novelId', e, stackTrace);
}
}
/// 检查小说是否已完整缓存
Future<bool> isNovelFullyCached(String novelId) async {
try {
final metadata = await _getNovelMetadata(novelId);
return metadata?['isCached'] == true;
} catch (e, stackTrace) {
AppLogger.e('NovelCacheService', '检查小说缓存状态失败: $novelId', e, stackTrace);
return false;
}
}
/// 保存阅读进度
Future<void> saveReadingProgress(
String novelId,
String chapterId,
int pageIndex,
DateTime readTime
) async {
try {
final prefs = await SharedPreferences.getInstance();
final progressData = {
'lastReadChapterId': chapterId,
'lastReadPageIndex': pageIndex,
'lastReadTime': readTime.toIso8601String(),
};
await prefs.setString(
'${_readingProgressKey}_$novelId',
json.encode(progressData)
);
AppLogger.v('NovelCacheService',
'保存阅读进度: $novelId - 章节: $chapterId, 页面: $pageIndex');
} catch (e, stackTrace) {
AppLogger.e('NovelCacheService', '保存阅读进度失败: $novelId', e, stackTrace);
}
}
/// 获取阅读进度
Future<Map<String, dynamic>?> getReadingProgress(String novelId) async {
try {
final prefs = await SharedPreferences.getInstance();
final progressString = prefs.getString('${_readingProgressKey}_$novelId');
if (progressString != null) {
final progressData = json.decode(progressString) as Map<String, dynamic>;
AppLogger.v('NovelCacheService', '获取阅读进度: $novelId - $progressData');
return progressData;
}
return null;
} catch (e, stackTrace) {
AppLogger.e('NovelCacheService', '获取阅读进度失败: $novelId', e, stackTrace);
return null;
}
}
/// 获取所有小说的阅读进度
Future<Map<String, Map<String, dynamic>>> getAllReadingProgress() async {
try {
final prefs = await SharedPreferences.getInstance();
final allKeys = prefs.getKeys();
final progressMap = <String, Map<String, dynamic>>{};
for (final key in allKeys) {
if (key.startsWith(_readingProgressKey)) {
final novelId = key.substring('${_readingProgressKey}_'.length);
final progressString = prefs.getString(key);
if (progressString != null) {
final progressData = json.decode(progressString) as Map<String, dynamic>;
progressMap[novelId] = progressData;
}
}
}
return progressMap;
} catch (e, stackTrace) {
AppLogger.e('NovelCacheService', '获取所有阅读进度失败', e, stackTrace);
return {};
}
}
/// 清除单个小说的缓存
Future<void> clearNovelCache(String novelId) async {
try {
if (_cacheDirectory == null) {
await init();
}
final file = File('${_cacheDirectory!.path}/novel_$novelId.json');
if (await file.exists()) {
await file.delete();
}
// 清除元数据
final prefs = await SharedPreferences.getInstance();
await prefs.remove('$_novelMetadataPrefix$novelId');
AppLogger.i('NovelCacheService', '清除小说缓存: $novelId');
} catch (e, stackTrace) {
AppLogger.e('NovelCacheService', '清除小说缓存失败: $novelId', e, stackTrace);
}
}
/// 清除所有缓存
Future<void> clearAllCache() async {
try {
if (_cacheDirectory == null) {
await init();
}
// 删除所有缓存文件
if (await _cacheDirectory!.exists()) {
await _cacheDirectory!.delete(recursive: true);
await _cacheDirectory!.create(recursive: true);
}
// 清除所有元数据和阅读进度
final prefs = await SharedPreferences.getInstance();
final allKeys = prefs.getKeys().toList();
for (final key in allKeys) {
if (key.startsWith(_novelMetadataPrefix) ||
key.startsWith(_readingProgressKey)) {
await prefs.remove(key);
}
}
AppLogger.i('NovelCacheService', '清除所有缓存完成');
} catch (e, stackTrace) {
AppLogger.e('NovelCacheService', '清除所有缓存失败', e, stackTrace);
}
}
/// 获取缓存统计信息
Future<Map<String, dynamic>> getCacheStats() async {
try {
if (_cacheDirectory == null) {
await init();
}
final files = await _cacheDirectory!.list().toList();
final novelFiles = files.where((f) => f.path.contains('novel_')).toList();
int totalSize = 0;
for (final file in novelFiles) {
if (file is File) {
final stat = await file.stat();
totalSize += stat.size;
}
}
return {
'cachedNovelsCount': novelFiles.length,
'totalCacheSize': totalSize,
'cacheDirectory': _cacheDirectory!.path,
};
} catch (e, stackTrace) {
AppLogger.e('NovelCacheService', '获取缓存统计信息失败', e, stackTrace);
return {
'cachedNovelsCount': 0,
'totalCacheSize': 0,
'cacheDirectory': '',
};
}
}
/// 保存小说元数据
Future<void> _saveNovelMetadata(String novelId, Map<String, dynamic> metadata) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'$_novelMetadataPrefix$novelId',
json.encode(metadata)
);
} catch (e, stackTrace) {
AppLogger.e('NovelCacheService', '保存小说元数据失败: $novelId', e, stackTrace);
}
}
/// 获取小说元数据
Future<Map<String, dynamic>?> getCacheMetadata(String novelId) async {
return _getNovelMetadata(novelId);
}
/// 获取小说元数据(私有方法)
Future<Map<String, dynamic>?> _getNovelMetadata(String novelId) async {
try {
final prefs = await SharedPreferences.getInstance();
final metadataString = prefs.getString('$_novelMetadataPrefix$novelId');
if (metadataString != null) {
return json.decode(metadataString) as Map<String, dynamic>;
}
return null;
} catch (e, stackTrace) {
AppLogger.e('NovelCacheService', '获取小说元数据失败: $novelId', e, stackTrace);
return null;
}
}
}

View File

@@ -0,0 +1,522 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:intl/intl.dart';
import 'package:share_plus/share_plus.dart';
import 'package:ainoval/models/novel_structure.dart';
import 'package:ainoval/services/api_service/repositories/novel_repository.dart';
import 'package:ainoval/services/api_service/repositories/editor_repository.dart';
import 'package:ainoval/utils/logger.dart';
/// 小说文件导出格式
enum NovelExportFormat {
txt, // 纯文本
json, // JSON格式(包含结构信息)
markdown, // Markdown格式
}
/// 导出结果
class NovelExportResult {
final String filePath;
final String fileName;
final int fileSizeBytes;
final NovelExportFormat format;
final DateTime exportedAt;
const NovelExportResult({
required this.filePath,
required this.fileName,
required this.fileSizeBytes,
required this.format,
required this.exportedAt,
});
Map<String, dynamic> toJson() => {
'filePath': filePath,
'fileName': fileName,
'fileSizeBytes': fileSizeBytes,
'format': format.name,
'exportedAt': exportedAt.toIso8601String(),
};
factory NovelExportResult.fromJson(Map<String, dynamic> json) => NovelExportResult(
filePath: json['filePath'],
fileName: json['fileName'],
fileSizeBytes: json['fileSizeBytes'],
format: NovelExportFormat.values.firstWhere(
(e) => e.name == json['format'],
orElse: () => NovelExportFormat.txt,
),
exportedAt: DateTime.parse(json['exportedAt']),
);
}
/// 小说文件服务 - 处理小说内容的本地保存
class NovelFileService {
final NovelRepository _novelRepository;
final EditorRepository? _editorRepository;
NovelFileService({
required NovelRepository novelRepository,
EditorRepository? editorRepository,
}) : _novelRepository = novelRepository,
_editorRepository = editorRepository;
/// 获取小说存储目录
Future<Directory> _getNovelStorageDirectory() async {
Directory? directory;
if (Platform.isAndroid) {
// Android: 使用外部存储的Documents目录
directory = await getExternalStorageDirectory();
if (directory != null) {
directory = Directory('${directory.path}/Documents/AINoval/Novels');
} else {
// 如果外部存储不可用,使用应用文档目录
directory = await getApplicationDocumentsDirectory();
directory = Directory('${directory.path}/Novels');
}
} else if (Platform.isIOS) {
// iOS: 使用应用文档目录
directory = await getApplicationDocumentsDirectory();
directory = Directory('${directory.path}/Novels');
} else {
// 其他平台使用应用文档目录
directory = await getApplicationDocumentsDirectory();
directory = Directory('${directory.path}/Novels');
}
// 确保目录存在
if (!await directory.exists()) {
await directory.create(recursive: true);
}
return directory;
}
/// 从后端获取完整小说内容
Future<Novel> _fetchCompleteNovel(String novelId) async {
try {
AppLogger.i('NovelFileService', '开始获取完整小说内容: $novelId');
// 优先尝试使用EditorRepository获取全部场景
if (_editorRepository != null) {
final novelWithAllScenes = await _editorRepository!.getNovelWithAllScenes(novelId);
if (novelWithAllScenes != null) {
AppLogger.i('NovelFileService', '通过EditorRepository获取完整小说成功');
return novelWithAllScenes;
}
}
// 回退到NovelRepository
AppLogger.i('NovelFileService', '回退到NovelRepository获取小说基本信息');
final novel = await _novelRepository.fetchNovel(novelId);
// 逐个获取场景内容
for (final act in novel.acts) {
for (final chapter in act.chapters) {
final List<Scene> scenesWithContent = [];
for (final scene in chapter.scenes) {
try {
final sceneWithContent = await _novelRepository.fetchSceneContent(
novelId,
act.id,
chapter.id,
scene.id
);
scenesWithContent.add(sceneWithContent);
} catch (e) {
AppLogger.w('NovelFileService',
'获取场景内容失败,使用默认内容: novelId=$novelId, sceneId=${scene.id}', e);
scenesWithContent.add(scene);
}
}
// 更新章节的场景列表
chapter.scenes.clear();
chapter.scenes.addAll(scenesWithContent);
}
}
AppLogger.i('NovelFileService', '获取完整小说内容成功: ${novel.title}');
return novel;
} catch (e) {
AppLogger.e('NovelFileService', '获取完整小说内容失败: $novelId', e);
rethrow;
}
}
/// 将小说导出为TXT格式
String _exportToTxt(Novel novel) {
final buffer = StringBuffer();
// 标题和基本信息
buffer.writeln('${novel.title}');
buffer.writeln('${'=' * novel.title.length}');
buffer.writeln();
if (novel.author != null) {
buffer.writeln('作者:${novel.author!.username}');
}
buffer.writeln('创建时间:${DateFormat('yyyy-MM-dd HH:mm').format(novel.createdAt)}');
buffer.writeln('最后更新:${DateFormat('yyyy-MM-dd HH:mm').format(novel.updatedAt)}');
buffer.writeln();
buffer.writeln('-' * 50);
buffer.writeln();
// 内容
for (final act in novel.acts) {
// 幕标题
buffer.writeln('${act.title}');
buffer.writeln('${'*' * act.title.length}');
buffer.writeln();
for (final chapter in act.chapters) {
// 章节标题
buffer.writeln('${chapter.title}');
buffer.writeln('${'-' * chapter.title.length}');
buffer.writeln();
for (final scene in chapter.scenes) {
// 场景内容
if (scene.content.isNotEmpty) {
buffer.writeln(scene.content);
buffer.writeln();
}
// 如果场景有摘要,也添加进去
if (scene.summary != null && scene.summary!.content.isNotEmpty) {
buffer.writeln('【场景摘要:${scene.summary!.content}');
buffer.writeln();
}
}
buffer.writeln(); // 章节间空行
}
buffer.writeln(); // 幕间空行
}
return buffer.toString();
}
/// 将小说导出为Markdown格式
String _exportToMarkdown(Novel novel) {
final buffer = StringBuffer();
// 标题和基本信息
buffer.writeln('# ${novel.title}');
buffer.writeln();
if (novel.author != null) {
buffer.writeln('**作者:** ${novel.author!.username}');
}
buffer.writeln('**创建时间:** ${DateFormat('yyyy-MM-dd HH:mm').format(novel.createdAt)}');
buffer.writeln('**最后更新:** ${DateFormat('yyyy-MM-dd HH:mm').format(novel.updatedAt)}');
buffer.writeln();
buffer.writeln('---');
buffer.writeln();
// 内容
for (final act in novel.acts) {
// 幕标题 (二级标题)
buffer.writeln('## ${act.title}');
buffer.writeln();
for (final chapter in act.chapters) {
// 章节标题 (三级标题)
buffer.writeln('### ${chapter.title}');
buffer.writeln();
for (final scene in chapter.scenes) {
// 场景内容
if (scene.content.isNotEmpty) {
buffer.writeln(scene.content);
buffer.writeln();
}
// 如果场景有摘要,作为引用添加
if (scene.summary != null && scene.summary!.content.isNotEmpty) {
buffer.writeln('> **场景摘要:** ${scene.summary!.content}');
buffer.writeln();
}
}
}
}
return buffer.toString();
}
/// 将小说导出为JSON格式
String _exportToJson(Novel novel) {
final jsonData = {
'exportInfo': {
'exportedAt': DateTime.now().toIso8601String(),
'exportVersion': '1.0.0',
'appVersion': '0.1.0+1',
},
'novel': novel.toJson(),
};
return const JsonEncoder.withIndent(' ').convert(jsonData);
}
/// 生成文件名
String _generateFileName(Novel novel, NovelExportFormat format) {
final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
final safeTitle = novel.title.replaceAll(RegExp(r'[^\w\s-]'), '').replaceAll(' ', '_');
return '${safeTitle}_$timestamp.${format.name}';
}
/// 导出小说到本地文件
Future<NovelExportResult> exportNovelToFile(
String novelId, {
NovelExportFormat format = NovelExportFormat.txt,
String? customFileName,
}) async {
try {
AppLogger.i('NovelFileService', '开始导出小说: $novelId, 格式: ${format.name}');
// 1. 获取完整小说内容
final novel = await _fetchCompleteNovel(novelId);
// 2. 根据格式生成内容
String content;
switch (format) {
case NovelExportFormat.txt:
content = _exportToTxt(novel);
break;
case NovelExportFormat.markdown:
content = _exportToMarkdown(novel);
break;
case NovelExportFormat.json:
content = _exportToJson(novel);
break;
}
// 3. 生成文件名
final fileName = customFileName ?? _generateFileName(novel, format);
// 4. 获取存储目录
final directory = await _getNovelStorageDirectory();
// 5. 写入文件
final file = File('${directory.path}/$fileName');
await file.writeAsString(content, encoding: utf8);
// 6. 获取文件大小
final fileStat = await file.stat();
final result = NovelExportResult(
filePath: file.path,
fileName: fileName,
fileSizeBytes: fileStat.size,
format: format,
exportedAt: DateTime.now(),
);
AppLogger.i('NovelFileService', '小说导出成功: ${result.fileName}, 大小: ${result.fileSizeBytes} bytes');
return result;
} catch (e) {
AppLogger.e('NovelFileService', '导出小说失败: $novelId', e);
rethrow;
}
}
/// 批量导出小说(多种格式)
Future<List<NovelExportResult>> exportNovelMultipleFormats(
String novelId, {
List<NovelExportFormat> formats = const [
NovelExportFormat.txt,
NovelExportFormat.markdown,
NovelExportFormat.json,
],
}) async {
final results = <NovelExportResult>[];
for (final format in formats) {
try {
final result = await exportNovelToFile(novelId, format: format);
results.add(result);
} catch (e) {
AppLogger.e('NovelFileService', '导出格式 ${format.name} 失败', e);
// 继续导出其他格式
}
}
return results;
}
/// 分享导出的文件
Future<void> shareExportedFile(NovelExportResult exportResult) async {
try {
final file = File(exportResult.filePath);
if (await file.exists()) {
await Share.shareXFiles(
[XFile(exportResult.filePath)],
text: '分享小说文件:${exportResult.fileName}',
);
AppLogger.i('NovelFileService', '分享文件成功: ${exportResult.fileName}');
} else {
throw Exception('文件不存在:${exportResult.filePath}');
}
} catch (e) {
AppLogger.e('NovelFileService', '分享文件失败', e);
rethrow;
}
}
/// 获取已导出文件列表
Future<List<NovelExportResult>> getExportedFiles() async {
try {
final directory = await _getNovelStorageDirectory();
if (!await directory.exists()) {
return [];
}
final files = await directory.list().where((entity) => entity is File).cast<File>().toList();
final results = <NovelExportResult>[];
for (final file in files) {
try {
final fileName = file.path.split('/').last;
final fileStat = await file.stat();
// 尝试从文件名推断格式
NovelExportFormat format = NovelExportFormat.txt;
if (fileName.endsWith('.md')) {
format = NovelExportFormat.markdown;
} else if (fileName.endsWith('.json')) {
format = NovelExportFormat.json;
}
results.add(NovelExportResult(
filePath: file.path,
fileName: fileName,
fileSizeBytes: fileStat.size,
format: format,
exportedAt: fileStat.modified,
));
} catch (e) {
AppLogger.w('NovelFileService', '无法获取文件信息: ${file.path}', e);
}
}
// 按修改时间倒序排列
results.sort((a, b) => b.exportedAt.compareTo(a.exportedAt));
return results;
} catch (e) {
AppLogger.e('NovelFileService', '获取导出文件列表失败', e);
return [];
}
}
/// 删除导出的文件
Future<bool> deleteExportedFile(String filePath) async {
try {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
AppLogger.i('NovelFileService', '删除文件成功: $filePath');
return true;
}
return false;
} catch (e) {
AppLogger.e('NovelFileService', '删除文件失败: $filePath', e);
return false;
}
}
/// 清理过期的导出文件超过30天
Future<int> cleanupOldExports({Duration maxAge = const Duration(days: 30)}) async {
try {
final directory = await _getNovelStorageDirectory();
if (!await directory.exists()) {
return 0;
}
final files = await directory.list().where((entity) => entity is File).cast<File>().toList();
final now = DateTime.now();
int deletedCount = 0;
for (final file in files) {
try {
final fileStat = await file.stat();
if (now.difference(fileStat.modified) > maxAge) {
await file.delete();
deletedCount++;
AppLogger.i('NovelFileService', '清理过期文件: ${file.path}');
}
} catch (e) {
AppLogger.w('NovelFileService', '清理文件时出错: ${file.path}', e);
}
}
AppLogger.i('NovelFileService', '清理完成,删除了 $deletedCount 个过期文件');
return deletedCount;
} catch (e) {
AppLogger.e('NovelFileService', '清理过期文件失败', e);
return 0;
}
}
/// 获取存储目录路径(用于用户查看)
Future<String> getStorageDirectoryPath() async {
final directory = await _getNovelStorageDirectory();
return directory.path;
}
/// 检查存储空间使用情况
Future<Map<String, dynamic>> getStorageInfo() async {
try {
final directory = await _getNovelStorageDirectory();
if (!await directory.exists()) {
return {
'directoryPath': directory.path,
'fileCount': 0,
'totalSizeBytes': 0,
'totalSizeMB': 0.0,
};
}
final files = await directory.list().where((entity) => entity is File).cast<File>().toList();
int totalSize = 0;
for (final file in files) {
try {
final fileStat = await file.stat();
totalSize += fileStat.size;
} catch (e) {
AppLogger.w('NovelFileService', '无法获取文件大小: ${file.path}', e);
}
}
return {
'directoryPath': directory.path,
'fileCount': files.length,
'totalSizeBytes': totalSize,
'totalSizeMB': totalSize / (1024 * 1024),
};
} catch (e) {
AppLogger.e('NovelFileService', '获取存储信息失败', e);
return {
'directoryPath': 'unknown',
'fileCount': 0,
'totalSizeBytes': 0,
'totalSizeMB': 0.0,
};
}
}
}

View File

@@ -0,0 +1,319 @@
import 'dart:convert';
import '../models/admin/admin_auth_models.dart';
import '../models/admin/admin_models.dart';
import '../utils/logger.dart';
import 'local_storage_service.dart';
/// 权限管理服务
class PermissionService {
static const String _tag = 'PermissionService';
static const String _adminTokenKey = 'admin_token';
static const String _adminUserKey = 'admin_user';
final LocalStorageService _localStorage;
PermissionService({LocalStorageService? localStorage})
: _localStorage = localStorage ?? LocalStorageService();
/// 权限常量
static const String SYSTEM_ADMIN = 'SYSTEM_ADMIN';
static const String USER_MANAGEMENT = 'USER_MANAGEMENT';
static const String MODEL_MANAGEMENT = 'MODEL_MANAGEMENT';
static const String PRESET_MANAGEMENT = 'PRESET_MANAGEMENT';
static const String TEMPLATE_MANAGEMENT = 'TEMPLATE_MANAGEMENT';
static const String SYSTEM_CONFIG = 'SYSTEM_CONFIG';
static const String SUBSCRIPTION_MANAGEMENT = 'SUBSCRIPTION_MANAGEMENT';
static const String STATISTICS_VIEW = 'STATISTICS_VIEW';
/// 功能权限映射
static const Map<String, List<String>> _featurePermissions = {
'dashboard': [STATISTICS_VIEW],
'user_management': [USER_MANAGEMENT],
'role_management': [USER_MANAGEMENT],
'subscription_management': [SUBSCRIPTION_MANAGEMENT],
'model_management': [MODEL_MANAGEMENT],
'system_presets': [PRESET_MANAGEMENT],
'public_templates': [TEMPLATE_MANAGEMENT],
'system_config': [SYSTEM_CONFIG],
};
/// 获取当前管理员信息
Future<AdminUser?> getCurrentAdmin() async {
try {
final adminDataJson = await _localStorage.getString(_adminUserKey);
if (adminDataJson != null) {
final adminData = Map<String, dynamic>.from(json.decode(adminDataJson));
return AdminUser.fromJson(adminData);
}
return null;
} catch (e) {
AppLogger.e(_tag, '获取当前管理员信息失败', e);
return null;
}
}
/// 保存管理员信息
Future<void> saveAdminInfo(AdminUser admin, String token) async {
try {
await _localStorage.setString(_adminUserKey, json.encode(admin.toJson()));
await _localStorage.setString(_adminTokenKey, token);
AppLogger.info(_tag, '管理员信息保存成功: ${admin.username}');
} catch (e) {
AppLogger.e(_tag, '保存管理员信息失败', e);
rethrow;
}
}
/// 清除管理员信息
Future<void> clearAdminInfo() async {
try {
await _localStorage.remove(_adminUserKey);
await _localStorage.remove(_adminTokenKey);
AppLogger.info(_tag, '管理员信息清除成功');
} catch (e) {
AppLogger.e(_tag, '清除管理员信息失败', e);
}
}
/// 获取管理员token
Future<String?> getAdminToken() async {
try {
return await _localStorage.getString(_adminTokenKey);
} catch (e) {
AppLogger.e(_tag, '获取管理员token失败', e);
return null;
}
}
/// 检查是否是管理员
Future<bool> isAdmin() async {
final admin = await getCurrentAdmin();
return admin != null;
}
/// 检查是否是超级管理员
Future<bool> isSuperAdmin() async {
final admin = await getCurrentAdmin();
return admin?.roles?.any((role) =>
role.contains('SUPER_ADMIN') || role == 'SUPER_ADMIN'
) ?? false;
}
/// 检查特定权限
Future<bool> hasPermission(String permission) async {
final admin = await getCurrentAdmin();
if (admin == null) return false;
// 超级管理员拥有所有权限
if (await isSuperAdmin()) return true;
// 检查用户角色中是否包含指定权限
final userRoles = admin.roles ?? [];
// 基于角色的权限映射
if (userRoles.contains('ADMIN') || userRoles.contains('SUPER_ADMIN')) {
// 管理员和超级管理员拥有所有权限
return true;
}
// 具体权限映射可以在这里扩展
// 目前简化处理:所有登录的管理员都有基本权限
return userRoles.isNotEmpty;
}
/// 检查多个权限(需要全部拥有)
Future<bool> hasAllPermissions(List<String> permissions) async {
for (final permission in permissions) {
if (!await hasPermission(permission)) {
return false;
}
}
return true;
}
/// 检查多个权限(拥有其中任一即可)
Future<bool> hasAnyPermission(List<String> permissions) async {
for (final permission in permissions) {
if (await hasPermission(permission)) {
return true;
}
}
return false;
}
/// 检查功能访问权限
Future<bool> canAccessFeature(String feature) async {
final requiredPermissions = _featurePermissions[feature];
if (requiredPermissions == null || requiredPermissions.isEmpty) {
return await isAdmin(); // 默认需要管理员权限
}
return await hasAnyPermission(requiredPermissions);
}
/// 检查是否可以管理用户
Future<bool> canManageUsers() async {
return await hasPermission(USER_MANAGEMENT);
}
/// 检查是否可以管理模型
Future<bool> canManageModels() async {
return await hasPermission(MODEL_MANAGEMENT);
}
/// 检查是否可以管理预设
Future<bool> canManagePresets() async {
return await hasPermission(PRESET_MANAGEMENT);
}
/// 检查是否可以管理模板
Future<bool> canManageTemplates() async {
return await hasPermission(TEMPLATE_MANAGEMENT);
}
/// 检查是否可以管理系统配置
Future<bool> canManageSystemConfig() async {
return await hasPermission(SYSTEM_CONFIG);
}
/// 检查是否可以管理订阅
Future<bool> canManageSubscriptions() async {
return await hasPermission(SUBSCRIPTION_MANAGEMENT);
}
/// 检查是否可以查看统计数据
Future<bool> canViewStatistics() async {
return await hasPermission(STATISTICS_VIEW);
}
/// 验证操作权限(用于敏感操作)
Future<bool> validateOperation(String operation, {Map<String, dynamic>? context}) async {
if (!await isAdmin()) {
AppLogger.w(_tag, '非管理员尝试执行操作: $operation');
return false;
}
switch (operation) {
case 'delete_user':
return await canManageUsers();
case 'delete_model':
return await canManageModels();
case 'create_system_preset':
case 'update_system_preset':
case 'delete_system_preset':
return await canManagePresets();
case 'review_template':
case 'publish_template':
case 'verify_template':
case 'delete_template':
return await canManageTemplates();
case 'update_system_config':
return await canManageSystemConfig();
case 'create_subscription_plan':
case 'update_subscription_plan':
return await canManageSubscriptions();
default:
AppLogger.w(_tag, '未知操作权限检查: $operation');
return await isSuperAdmin(); // 未知操作需要超级管理员权限
}
}
/// 获取用户可访问的管理功能列表
Future<List<String>> getAccessibleFeatures() async {
final accessibleFeatures = <String>[];
for (final feature in _featurePermissions.keys) {
if (await canAccessFeature(feature)) {
accessibleFeatures.add(feature);
}
}
return accessibleFeatures;
}
/// 权限检查装饰器(用于业务方法)
Future<T?> withPermissionCheck<T>(
String permission,
Future<T> Function() operation, {
String? operationName,
}) async {
if (!await hasPermission(permission)) {
final admin = await getCurrentAdmin();
AppLogger.w(_tag, '权限不足: ${admin?.username ?? 'unknown'} 尝试执行 ${operationName ?? 'unknown operation'},需要权限: $permission');
throw PermissionDeniedException('权限不足,需要 $permission 权限');
}
try {
return await operation();
} catch (e) {
AppLogger.e(_tag, '执行操作失败: ${operationName ?? 'unknown'}', e);
rethrow;
}
}
/// 多权限检查装饰器
Future<T?> withMultiPermissionCheck<T>(
List<String> permissions,
Future<T> Function() operation, {
String? operationName,
bool requireAll = false,
}) async {
final hasAccess = requireAll
? await hasAllPermissions(permissions)
: await hasAnyPermission(permissions);
if (!hasAccess) {
final admin = await getCurrentAdmin();
final permissionStr = requireAll ? permissions.join(' AND ') : permissions.join(' OR ');
AppLogger.w(_tag, '权限不足: ${admin?.username ?? 'unknown'} 尝试执行 ${operationName ?? 'unknown operation'},需要权限: $permissionStr');
throw PermissionDeniedException('权限不足,需要 $permissionStr 权限');
}
try {
return await operation();
} catch (e) {
AppLogger.e(_tag, '执行操作失败: ${operationName ?? 'unknown'}', e);
rethrow;
}
}
/// 管理员会话验证
Future<bool> validateAdminSession() async {
try {
final token = await getAdminToken();
final admin = await getCurrentAdmin();
if (token == null || admin == null) {
return false;
}
// TODO: 可以添加token过期检查和服务器端验证
return true;
} catch (e) {
AppLogger.e(_tag, '管理员会话验证失败', e);
return false;
}
}
/// 刷新管理员信息
Future<void> refreshAdminInfo(AdminUser updatedAdmin) async {
try {
final token = await getAdminToken();
if (token != null) {
await saveAdminInfo(updatedAdmin, token);
}
} catch (e) {
AppLogger.error(_tag, '刷新管理员信息失败', e);
}
}
}
/// 权限拒绝异常
class PermissionDeniedException implements Exception {
final String message;
const PermissionDeniedException(this.message);
@override
String toString() => 'PermissionDeniedException: $message';
}

View File

@@ -0,0 +1,912 @@
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';
}

View File

@@ -0,0 +1,205 @@
import 'dart:async';
import 'dart:convert';
import 'package:stream_channel/stream_channel.dart';
import 'package:uuid/uuid.dart';
import 'package:web_socket_channel/status.dart' as status;
import 'package:web_socket_channel/web_socket_channel.dart';
import '../models/chat_models.dart';
class WebSocketService {
WebSocketService({
this.baseUrl = 'ws://localhost:8080/chat',
});
final String baseUrl;
final Map<String, WebSocketChannel> _connections = {};
// 创建聊天连接
Future<WebSocketChannel> createChatConnection(String sessionId) async {
// 在第二周迭代中我们不实际连接WebSocket而是模拟
// 返回一个模拟的WebSocketChannel
final channel = MockWebSocketChannel(sessionId: sessionId);
_connections[sessionId] = channel;
return channel;
}
// 关闭连接
void closeConnection(String sessionId) {
if (_connections.containsKey(sessionId)) {
_connections[sessionId]?.sink.close(status.goingAway);
_connections.remove(sessionId);
}
}
// 关闭所有连接
void closeAllConnections() {
for (final connection in _connections.values) {
connection.sink.close(status.goingAway);
}
_connections.clear();
}
}
// 简化的模拟WebSocketChannel实现
class MockWebSocketChannel extends StreamChannelMixin implements WebSocketChannel {
MockWebSocketChannel({required this.sessionId}) {
_sink = MockWebSocketSink(_sinkController);
// 监听发送的消息,模拟响应
_sinkController.stream.listen(_handleMessage);
}
final String sessionId;
final StreamController<dynamic> _controller = StreamController<dynamic>();
final StreamController<dynamic> _sinkController = StreamController<dynamic>();
late final MockWebSocketSink _sink;
@override
Stream get stream => _controller.stream;
@override
WebSocketSink get sink => _sink;
// 处理发送的消息,模拟响应
void _handleMessage(dynamic message) {
if (message is String) {
try {
final Map<String, dynamic> data = jsonDecode(message);
if (data.containsKey('action') && data['action'] == 'cancel') {
// 模拟取消请求
_controller.add(jsonEncode({
'done': true,
'message': '请求已取消',
}));
return;
}
if (data.containsKey('message')) {
// 模拟流式响应
_simulateStreamingResponse(data['message'] as String);
}
} catch (e) {
_controller.addError('解析消息失败: $e');
}
}
}
// 模拟流式响应
void _simulateStreamingResponse(String message) async {
// 根据消息内容生成不同的响应
String response;
List<MessageAction> actions = [];
if (message.contains('角色')) {
response = '角色设计是小说创作中的重要环节。好的角色应该有鲜明的性格特点、合理的动机和明确的目标。';
actions.add(MessageAction(
id: const Uuid().v4(),
label: '创建角色',
type: ActionType.createCharacter,
data: {'suggestion': '根据对话创建新角色'},
));
} else if (message.contains('情节')) {
response = '情节发展需要有起承转合,保持读者的兴趣。一个好的情节应该包含引人入胜的开端、不断升级的冲突、出人意料的转折和合理的结局。';
actions.add(MessageAction(
id: const Uuid().v4(),
label: '生成情节',
type: ActionType.generatePlot,
data: {'suggestion': '根据当前内容生成情节'},
));
} else {
response = '感谢您的提问。作为您的AI写作助手我很乐意帮助您解决创作中遇到的问题。请告诉我您需要什么样的帮助';
}
// 始终添加一个应用到编辑器的操作
actions.add(MessageAction(
id: const Uuid().v4(),
label: '应用到编辑器',
type: ActionType.applyToEditor,
data: {'suggestion': '将AI回复应用到编辑器'},
));
// 模拟流式响应,将响应分成多个块发送
final chunks = _splitIntoChunks(response, 10);
for (int i = 0; i < chunks.length; i++) {
// 添加随机延迟,模拟网络延迟
await Future.delayed(Duration(milliseconds: 100 + (50 * i)));
// 发送块
_controller.add(jsonEncode({
'chunk': chunks[i],
}));
}
// 发送完成信号和操作
await Future.delayed(const Duration(milliseconds: 500));
_controller.add(jsonEncode({
'done': true,
'actions': actions.map((a) => a.toJson()).toList(),
}));
}
// 将文本分成多个块
List<String> _splitIntoChunks(String text, int chunkSize) {
final chunks = <String>[];
for (int i = 0; i < text.length; i += chunkSize) {
final end = (i + chunkSize < text.length) ? i + chunkSize : text.length;
chunks.add(text.substring(i, end));
}
return chunks;
}
@override
Future<void> close([int? closeCode, String? closeReason]) {
_sinkController.close();
return _controller.close();
}
// WebSocketChannel 接口所需的属性
@override
int? get closeCode => null;
@override
String? get closeReason => null;
@override
String? get protocol => null;
@override
Future<void> get ready => Future.value();
}
// 模拟的WebSocketSink
class MockWebSocketSink implements WebSocketSink {
MockWebSocketSink(this._controller);
final StreamController<dynamic> _controller;
@override
void add(dynamic data) {
_controller.add(data);
}
@override
void addError(Object error, [StackTrace? stackTrace]) {
_controller.addError(error, stackTrace);
}
@override
Future<void> addStream(Stream<dynamic> stream) async {
await for (final data in stream) {
add(data);
}
}
@override
Future<void> close([int? closeCode, String? closeReason]) async {
// 不实际关闭,因为这是模拟的
return Future.value();
}
@override
Future<void> get done => Future.value();
}