feat: 接入起卦后端流程并完善积分扣减链路
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/logging/logger.dart';
|
||||
import '../../../../core/network/api_problem.dart';
|
||||
import '../../../../data/network/api_client.dart';
|
||||
import '../models/divination_backend_models.dart';
|
||||
import '../models/divination_params.dart';
|
||||
|
||||
class DivinationApi {
|
||||
const DivinationApi({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
final ApiClient _apiClient;
|
||||
static final Logger _logger = getLogger('features.divination.api');
|
||||
|
||||
Future<PointsBalanceData> getPointsBalance() async {
|
||||
final json = await _apiClient.getJson('/api/v1/points/balance');
|
||||
return PointsBalanceData.fromJson(json);
|
||||
}
|
||||
|
||||
Future<RunAcceptedData> enqueueRun({
|
||||
required DivinationParams params,
|
||||
required List<YaoType> yaoStates,
|
||||
required String threadId,
|
||||
required String runId,
|
||||
}) async {
|
||||
final payload = buildDivinationRunPayload(
|
||||
params: params,
|
||||
yaoStates: yaoStates,
|
||||
threadId: threadId,
|
||||
runId: runId,
|
||||
clientNow: DateTime.now(),
|
||||
);
|
||||
final json = await _apiClient.postJson('/api/v1/agent/runs', data: payload);
|
||||
return RunAcceptedData.fromJson(json);
|
||||
}
|
||||
|
||||
Stream<Map<String, dynamic>> streamEvents({
|
||||
required String threadId,
|
||||
required String runId,
|
||||
}) async* {
|
||||
Response<ResponseBody> response;
|
||||
try {
|
||||
response = await _apiClient.rawDio.get<ResponseBody>(
|
||||
'/api/v1/agent/runs/$threadId/events',
|
||||
queryParameters: <String, dynamic>{'runId': runId},
|
||||
options: Options(responseType: ResponseType.stream),
|
||||
);
|
||||
} on DioException catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Failed to open SSE stream for divination run',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
extra: <String, dynamic>{'threadId': threadId, 'runId': runId},
|
||||
);
|
||||
throw _mapProblem(error);
|
||||
}
|
||||
|
||||
final body = response.data;
|
||||
if (body == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String buffer = '';
|
||||
try {
|
||||
await for (final textChunk in utf8.decoder.bind(body.stream)) {
|
||||
buffer += textChunk.replaceAll('\r\n', '\n');
|
||||
while (true) {
|
||||
final splitAt = buffer.indexOf('\n\n');
|
||||
if (splitAt < 0) {
|
||||
break;
|
||||
}
|
||||
final frame = buffer.substring(0, splitAt);
|
||||
buffer = buffer.substring(splitAt + 2);
|
||||
final event = _parseSseFrame(frame);
|
||||
if (event != null) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
}
|
||||
} on FormatException catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Failed to decode SSE stream chunk',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
extra: <String, dynamic>{
|
||||
'threadId': threadId,
|
||||
'runId': runId,
|
||||
'bufferLength': buffer.length,
|
||||
},
|
||||
);
|
||||
throw ApiProblem(
|
||||
status: 502,
|
||||
title: 'SSE parse error',
|
||||
detail: error.message,
|
||||
);
|
||||
}
|
||||
|
||||
if (buffer.trim().isNotEmpty) {
|
||||
final event = _parseSseFrame(buffer);
|
||||
if (event != null) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ApiProblem _mapProblem(DioException error) {
|
||||
final status = error.response?.statusCode ?? 500;
|
||||
final data = error.response?.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
return ApiProblem(
|
||||
status: status,
|
||||
title: (data['title'] as String?) ?? 'Request failed',
|
||||
detail: (data['detail'] as String?) ?? '',
|
||||
code: data['code'] as String?,
|
||||
);
|
||||
}
|
||||
return ApiProblem(
|
||||
status: status,
|
||||
title: 'Network error',
|
||||
detail: error.message ?? 'Request failed',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _parseSseFrame(String frame) {
|
||||
if (frame.startsWith(':')) {
|
||||
return null;
|
||||
}
|
||||
final lines = frame.split('\n');
|
||||
String eventType = '';
|
||||
final dataLines = <String>[];
|
||||
for (final raw in lines) {
|
||||
final line = raw.trimRight();
|
||||
if (line.startsWith('event:')) {
|
||||
eventType = line.substring(6).trim();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.add(line.substring(5).trimLeft());
|
||||
}
|
||||
}
|
||||
if (dataLines.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final dataText = dataLines.join('\n');
|
||||
if (dataText.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final decoded = jsonDecode(dataText);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
if (!decoded.containsKey('type') && eventType.isNotEmpty) {
|
||||
decoded['type'] = eventType;
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> buildDivinationRunPayload({
|
||||
required DivinationParams params,
|
||||
required List<YaoType> yaoStates,
|
||||
required String threadId,
|
||||
required String runId,
|
||||
required DateTime clientNow,
|
||||
}) {
|
||||
if (yaoStates.length != 6) {
|
||||
throw ArgumentError('yaoStates must contain exactly 6 items');
|
||||
}
|
||||
|
||||
return <String, dynamic>{
|
||||
'threadId': threadId,
|
||||
'runId': runId,
|
||||
'state': <String, dynamic>{},
|
||||
'messages': [
|
||||
{'id': 'msg_${runId}_user_0', 'role': 'user', 'content': params.question},
|
||||
],
|
||||
'tools': const <Map<String, dynamic>>[],
|
||||
'context': const <Map<String, dynamic>>[],
|
||||
'forwardedProps': {
|
||||
'runtime_mode': 'chat',
|
||||
'client_time': {
|
||||
'device_timezone': 'Asia/Shanghai',
|
||||
'client_now_iso': _toRfc3339Utc(clientNow),
|
||||
'client_epoch_ms': clientNow.millisecondsSinceEpoch,
|
||||
},
|
||||
'divinationPayload': {
|
||||
'divinationMethod': params.method == DivinationMethod.manual
|
||||
? '手动起卦'
|
||||
: '自动起卦',
|
||||
'questionType': _questionTypeToText(params.questionType),
|
||||
'question': params.question,
|
||||
'divinationTimeIso': _toRfc3339Utc(params.divinationTime),
|
||||
'yaoLines': yaoStates.map(_yaoTypeToText).toList(growable: false),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
String _toRfc3339Utc(DateTime value) {
|
||||
return value.toUtc().toIso8601String();
|
||||
}
|
||||
|
||||
String _questionTypeToText(QuestionType type) {
|
||||
return switch (type) {
|
||||
QuestionType.career => '事业',
|
||||
QuestionType.love => '情感',
|
||||
QuestionType.wealth => '财富',
|
||||
QuestionType.fortune => '运势',
|
||||
QuestionType.dream => '解梦',
|
||||
QuestionType.health => '健康',
|
||||
QuestionType.study => '学业',
|
||||
QuestionType.search => '寻物',
|
||||
QuestionType.other => '其他',
|
||||
};
|
||||
}
|
||||
|
||||
String _yaoTypeToText(YaoType type) {
|
||||
return switch (type) {
|
||||
YaoType.youngYang => '少阳',
|
||||
YaoType.youngYin => '少阴',
|
||||
YaoType.oldYang => '老阳',
|
||||
YaoType.oldYin => '老阴',
|
||||
YaoType.undetermined => throw ArgumentError(
|
||||
'yaoStates contains undetermined line',
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user