马良AI写作初始化仓库
This commit is contained in:
@@ -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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user