马良AI写作初始化仓库
This commit is contained in:
321
AINoval/lib/services/ai_preset_service.dart
Normal file
321
AINoval/lib/services/ai_preset_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2571
AINoval/lib/services/api_service/base/api_client.dart
Normal file
2571
AINoval/lib/services/api_service/base/api_client.dart
Normal file
File diff suppressed because it is too large
Load Diff
36
AINoval/lib/services/api_service/base/api_exception.dart
Normal file
36
AINoval/lib/services/api_service/base/api_exception.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
470
AINoval/lib/services/api_service/base/sse_client.dart
Normal file
470
AINoval/lib/services/api_service/base/sse_client.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import '../../../models/user_credit.dart';
|
||||
|
||||
/// 用户积分仓库接口
|
||||
abstract interface class CreditRepository {
|
||||
/// 获取当前用户的积分余额
|
||||
Future<UserCredit> getUserCredits();
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import '../../../models/public_model_config.dart';
|
||||
|
||||
/// 公共模型仓库接口
|
||||
abstract interface class PublicModelRepository {
|
||||
/// 获取公共模型列表
|
||||
/// 只包含向前端暴露的安全信息,不含API Keys等敏感数据
|
||||
/// 用户必须登录才能访问此接口
|
||||
Future<List<PublicModel>> getPublicModels();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
688
AINoval/lib/services/auth_service.dart
Normal file
688
AINoval/lib/services/auth_service.dart
Normal 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';
|
||||
}
|
||||
365
AINoval/lib/services/image_cache_service.dart
Normal file
365
AINoval/lib/services/image_cache_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
914
AINoval/lib/services/local_storage_service.dart
Normal file
914
AINoval/lib/services/local_storage_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
319
AINoval/lib/services/novel_cache_service.dart
Normal file
319
AINoval/lib/services/novel_cache_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
522
AINoval/lib/services/novel_file_service.dart
Normal file
522
AINoval/lib/services/novel_file_service.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
319
AINoval/lib/services/permission_service.dart
Normal file
319
AINoval/lib/services/permission_service.dart
Normal 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';
|
||||
}
|
||||
912
AINoval/lib/services/sync_service.dart
Normal file
912
AINoval/lib/services/sync_service.dart
Normal 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';
|
||||
}
|
||||
205
AINoval/lib/services/websocket_service.dart
Normal file
205
AINoval/lib/services/websocket_service.dart
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user