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',
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import 'divination_params.dart';
|
||||
import 'divination_result.dart';
|
||||
|
||||
class PointsBalanceData {
|
||||
const PointsBalanceData({
|
||||
required this.balance,
|
||||
required this.frozenBalance,
|
||||
required this.availableBalance,
|
||||
required this.runCost,
|
||||
required this.canRun,
|
||||
});
|
||||
|
||||
factory PointsBalanceData.fromJson(Map<String, dynamic> json) {
|
||||
return PointsBalanceData(
|
||||
balance: _requiredInt(json, 'balance'),
|
||||
frozenBalance: _requiredInt(json, 'frozenBalance'),
|
||||
availableBalance: _requiredInt(json, 'availableBalance'),
|
||||
runCost: _requiredInt(json, 'runCost'),
|
||||
canRun: _requiredBool(json, 'canRun'),
|
||||
);
|
||||
}
|
||||
|
||||
final int balance;
|
||||
final int frozenBalance;
|
||||
final int availableBalance;
|
||||
final int runCost;
|
||||
final bool canRun;
|
||||
}
|
||||
|
||||
class RunAcceptedData {
|
||||
const RunAcceptedData({
|
||||
required this.taskId,
|
||||
required this.threadId,
|
||||
required this.runId,
|
||||
required this.created,
|
||||
});
|
||||
|
||||
factory RunAcceptedData.fromJson(Map<String, dynamic> json) {
|
||||
return RunAcceptedData(
|
||||
taskId: _requiredString(json, 'taskId'),
|
||||
threadId: _requiredString(json, 'threadId'),
|
||||
runId: _requiredString(json, 'runId'),
|
||||
created: _requiredBool(json, 'created'),
|
||||
);
|
||||
}
|
||||
|
||||
final String taskId;
|
||||
final String threadId;
|
||||
final String runId;
|
||||
final bool created;
|
||||
}
|
||||
|
||||
class DivinationRunAggregate {
|
||||
const DivinationRunAggregate({
|
||||
required this.derived,
|
||||
required this.signLevel,
|
||||
required this.summary,
|
||||
required this.conclusion,
|
||||
required this.focusPoints,
|
||||
required this.advice,
|
||||
required this.keywords,
|
||||
required this.answer,
|
||||
});
|
||||
|
||||
final DerivedDivinationData derived;
|
||||
final String signLevel;
|
||||
final String summary;
|
||||
final List<String> conclusion;
|
||||
final List<String> focusPoints;
|
||||
final List<String> advice;
|
||||
final List<String> keywords;
|
||||
final String answer;
|
||||
|
||||
DivinationResultData toViewData(DivinationParams params) {
|
||||
return DivinationResultData(
|
||||
params: params,
|
||||
binaryCode: derived.binaryCode,
|
||||
changedBinaryCode: derived.changedBinaryCode,
|
||||
guaName: derived.guaName,
|
||||
targetGuaName: derived.targetGuaName,
|
||||
upperName: derived.upperName,
|
||||
lowerName: derived.lowerName,
|
||||
signType: signLevel,
|
||||
keywords: keywords.join('、'),
|
||||
conclusion: _asBullet(conclusion),
|
||||
analysis: summary.isEmpty ? answer : '$summary\n\n$answer',
|
||||
suggestion: _asBullet(advice),
|
||||
ganzhi: GanzhiData(
|
||||
yearGanZhi: derived.ganzhi.yearGanZhi,
|
||||
monthGanZhi: derived.ganzhi.monthGanZhi,
|
||||
dayGanZhi: derived.ganzhi.dayGanZhi,
|
||||
timeGanZhi: derived.ganzhi.timeGanZhi,
|
||||
yearKongWang: derived.ganzhi.yearKongWang,
|
||||
monthKongWang: derived.ganzhi.monthKongWang,
|
||||
dayKongWang: derived.ganzhi.dayKongWang,
|
||||
timeKongWang: derived.ganzhi.timeKongWang,
|
||||
yueJian: derived.ganzhi.yueJian,
|
||||
riChen: derived.ganzhi.riChen,
|
||||
yuePo: derived.ganzhi.yuePo,
|
||||
riChong: derived.ganzhi.riChong,
|
||||
),
|
||||
wuXingStatus: derived.wuXingStatuses,
|
||||
yaoLines: derived.yaoInfoList
|
||||
.map((line) => line.toViewModel())
|
||||
.toList(growable: false),
|
||||
targetYaoLines: derived.targetYaoInfoList
|
||||
.map((line) => line.toViewModel())
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
String _asBullet(List<String> lines) {
|
||||
if (lines.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
return List<String>.generate(
|
||||
lines.length,
|
||||
(i) => '${i + 1}. ${lines[i]}',
|
||||
growable: false,
|
||||
).join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
class DerivedDivinationData {
|
||||
const DerivedDivinationData({
|
||||
required this.question,
|
||||
required this.questionType,
|
||||
required this.divinationMethod,
|
||||
required this.divinationTime,
|
||||
required this.binaryCode,
|
||||
required this.changedBinaryCode,
|
||||
required this.guaName,
|
||||
required this.upperName,
|
||||
required this.lowerName,
|
||||
required this.targetGuaName,
|
||||
required this.worldPosition,
|
||||
required this.responsePosition,
|
||||
required this.hasChangingYao,
|
||||
required this.ganzhi,
|
||||
required this.wuXingStatuses,
|
||||
required this.yaoInfoList,
|
||||
required this.targetYaoInfoList,
|
||||
});
|
||||
|
||||
factory DerivedDivinationData.fromJson(Map<String, dynamic> json) {
|
||||
final wuXingRaw = _requiredMap(json, 'wuXingStatuses');
|
||||
return DerivedDivinationData(
|
||||
question: _requiredString(json, 'question'),
|
||||
questionType: _requiredString(json, 'questionType'),
|
||||
divinationMethod: _requiredString(json, 'divinationMethod'),
|
||||
divinationTime: _requiredString(json, 'divinationTime'),
|
||||
binaryCode: _requiredString(json, 'binaryCode'),
|
||||
changedBinaryCode: _requiredString(json, 'changedBinaryCode'),
|
||||
guaName: _requiredString(json, 'guaName'),
|
||||
upperName: _requiredString(json, 'upperName'),
|
||||
lowerName: _requiredString(json, 'lowerName'),
|
||||
targetGuaName: _requiredString(json, 'targetGuaName'),
|
||||
worldPosition: _requiredInt(json, 'worldPosition'),
|
||||
responsePosition: _requiredInt(json, 'responsePosition'),
|
||||
hasChangingYao: _requiredBool(json, 'hasChangingYao'),
|
||||
ganzhi: GanzhiBackend.fromJson(_requiredMap(json, 'ganzhi')),
|
||||
wuXingStatuses: wuXingRaw.map(
|
||||
(key, value) => MapEntry(key, value.toString()),
|
||||
),
|
||||
yaoInfoList: _parseYaoList(json['yaoInfoList']),
|
||||
targetYaoInfoList: _parseYaoList(json['targetYaoInfoList']),
|
||||
);
|
||||
}
|
||||
|
||||
final String question;
|
||||
final String questionType;
|
||||
final String divinationMethod;
|
||||
final String divinationTime;
|
||||
final String binaryCode;
|
||||
final String changedBinaryCode;
|
||||
final String guaName;
|
||||
final String upperName;
|
||||
final String lowerName;
|
||||
final String targetGuaName;
|
||||
final int worldPosition;
|
||||
final int responsePosition;
|
||||
final bool hasChangingYao;
|
||||
final GanzhiBackend ganzhi;
|
||||
final Map<String, String> wuXingStatuses;
|
||||
final List<YaoBackendLine> yaoInfoList;
|
||||
final List<YaoBackendLine> targetYaoInfoList;
|
||||
|
||||
static List<YaoBackendLine> _parseYaoList(Object? raw) {
|
||||
final list = raw as List<dynamic>?;
|
||||
if (list == null) {
|
||||
throw const FormatException(
|
||||
'Missing required list: yaoInfoList/targetYaoInfoList',
|
||||
);
|
||||
}
|
||||
return list
|
||||
.map((item) {
|
||||
if (item is! Map<String, dynamic>) {
|
||||
throw const FormatException('Invalid yao line item');
|
||||
}
|
||||
return YaoBackendLine.fromJson(item);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
class GanzhiBackend {
|
||||
const GanzhiBackend({
|
||||
required this.yearGanZhi,
|
||||
required this.monthGanZhi,
|
||||
required this.dayGanZhi,
|
||||
required this.timeGanZhi,
|
||||
required this.yearKongWang,
|
||||
required this.monthKongWang,
|
||||
required this.dayKongWang,
|
||||
required this.timeKongWang,
|
||||
required this.yueJian,
|
||||
required this.riChen,
|
||||
required this.yuePo,
|
||||
required this.riChong,
|
||||
});
|
||||
|
||||
factory GanzhiBackend.fromJson(Map<String, dynamic> json) {
|
||||
return GanzhiBackend(
|
||||
yearGanZhi: _requiredString(json, 'yearGanZhi'),
|
||||
monthGanZhi: _requiredString(json, 'monthGanZhi'),
|
||||
dayGanZhi: _requiredString(json, 'dayGanZhi'),
|
||||
timeGanZhi: _requiredString(json, 'timeGanZhi'),
|
||||
yearKongWang: _requiredString(json, 'yearKongWang'),
|
||||
monthKongWang: _requiredString(json, 'monthKongWang'),
|
||||
dayKongWang: _requiredString(json, 'dayKongWang'),
|
||||
timeKongWang: _requiredString(json, 'timeKongWang'),
|
||||
yueJian: _requiredString(json, 'yueJian'),
|
||||
riChen: _requiredString(json, 'riChen'),
|
||||
yuePo: _requiredString(json, 'yuePo'),
|
||||
riChong: _requiredString(json, 'riChong'),
|
||||
);
|
||||
}
|
||||
|
||||
final String yearGanZhi;
|
||||
final String monthGanZhi;
|
||||
final String dayGanZhi;
|
||||
final String timeGanZhi;
|
||||
final String yearKongWang;
|
||||
final String monthKongWang;
|
||||
final String dayKongWang;
|
||||
final String timeKongWang;
|
||||
final String yueJian;
|
||||
final String riChen;
|
||||
final String yuePo;
|
||||
final String riChong;
|
||||
}
|
||||
|
||||
class YaoBackendLine {
|
||||
const YaoBackendLine({
|
||||
required this.position,
|
||||
required this.spiritName,
|
||||
required this.relationName,
|
||||
required this.tiganName,
|
||||
required this.elementName,
|
||||
required this.isYang,
|
||||
required this.isChanging,
|
||||
required this.specialMark,
|
||||
});
|
||||
|
||||
factory YaoBackendLine.fromJson(Map<String, dynamic> json) {
|
||||
return YaoBackendLine(
|
||||
position: _requiredInt(json, 'position'),
|
||||
spiritName: _requiredString(json, 'spiritName'),
|
||||
relationName: _requiredString(json, 'relationName'),
|
||||
tiganName: _requiredString(json, 'tiganName'),
|
||||
elementName: _requiredString(json, 'elementName'),
|
||||
isYang: _requiredBool(json, 'isYang'),
|
||||
isChanging: _requiredBool(json, 'isChanging'),
|
||||
specialMark: _requiredStringAllowEmpty(json, 'specialMark'),
|
||||
);
|
||||
}
|
||||
|
||||
final int position;
|
||||
final String spiritName;
|
||||
final String relationName;
|
||||
final String tiganName;
|
||||
final String elementName;
|
||||
final bool isYang;
|
||||
final bool isChanging;
|
||||
final String specialMark;
|
||||
|
||||
YaoLineData toViewModel() {
|
||||
final type = switch ((isYang, isChanging)) {
|
||||
(true, false) => YaoType.youngYang,
|
||||
(false, false) => YaoType.youngYin,
|
||||
(true, true) => YaoType.oldYang,
|
||||
(false, true) => YaoType.oldYin,
|
||||
};
|
||||
return YaoLineData(
|
||||
index: position - 1,
|
||||
spirit: spiritName,
|
||||
relation: relationName,
|
||||
branch: tiganName,
|
||||
element: elementName,
|
||||
type: type,
|
||||
mark: specialMark,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _requiredStringAllowEmpty(Map<String, dynamic> json, String key) {
|
||||
if (!json.containsKey(key)) {
|
||||
throw FormatException('Invalid or missing field: $key');
|
||||
}
|
||||
final value = json[key];
|
||||
if (value is! String) {
|
||||
throw FormatException('Invalid or missing field: $key');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
String _requiredString(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is! String || value.isEmpty) {
|
||||
throw FormatException('Invalid or missing field: $key');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
int _requiredInt(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is! num) {
|
||||
throw FormatException('Invalid or missing field: $key');
|
||||
}
|
||||
return value.toInt();
|
||||
}
|
||||
|
||||
bool _requiredBool(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is! bool) {
|
||||
throw FormatException('Invalid or missing field: $key');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _requiredMap(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is! Map<String, dynamic>) {
|
||||
throw FormatException('Invalid or missing field: $key');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -85,16 +85,3 @@ class DivinationParams {
|
||||
.join();
|
||||
}
|
||||
}
|
||||
|
||||
class DivinationMockData {
|
||||
static DivinationParams initial() {
|
||||
return DivinationParams(
|
||||
method: DivinationMethod.manual,
|
||||
questionType: QuestionType.career,
|
||||
question: '',
|
||||
divinationTime: DateTime.now(),
|
||||
coinBalance: 8,
|
||||
userId: 'mock_user_10001',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import '../models/divination_params.dart';
|
||||
import '../models/divination_result.dart';
|
||||
|
||||
class DivinationResultBuilder {
|
||||
DivinationResultData build({
|
||||
required DivinationParams params,
|
||||
required List<YaoType> yaoStates,
|
||||
}) {
|
||||
final binaryCode = params.toBinary(yaoStates);
|
||||
final changedBinaryCode = params.toChangedBinary(yaoStates);
|
||||
final baseHexagram = _hexagramMap[binaryCode];
|
||||
final changedHexagram = _hexagramMap[changedBinaryCode];
|
||||
if (baseHexagram == null || changedHexagram == null) {
|
||||
throw StateError(
|
||||
'Unknown hexagram mapping for binary=$binaryCode changed=$changedBinaryCode',
|
||||
);
|
||||
}
|
||||
|
||||
final signType = _signByStates(yaoStates);
|
||||
final content = _mockContent(
|
||||
params.questionType,
|
||||
params.question,
|
||||
signType,
|
||||
);
|
||||
|
||||
final lineData = _buildYaoLines(yaoStates, false);
|
||||
final targetStates = _toChangedStates(yaoStates);
|
||||
final targetLineData = _buildYaoLines(targetStates, true);
|
||||
|
||||
return DivinationResultData(
|
||||
params: params,
|
||||
binaryCode: binaryCode,
|
||||
changedBinaryCode: changedBinaryCode,
|
||||
guaName: baseHexagram.name,
|
||||
targetGuaName: changedHexagram.name,
|
||||
upperName: baseHexagram.upper,
|
||||
lowerName: baseHexagram.lower,
|
||||
signType: signType,
|
||||
keywords: content.keywords,
|
||||
conclusion: content.conclusion,
|
||||
analysis: content.analysis,
|
||||
suggestion: content.suggestion,
|
||||
ganzhi: const GanzhiData(
|
||||
yearGanZhi: '丙午',
|
||||
monthGanZhi: '甲辰',
|
||||
dayGanZhi: '辛亥',
|
||||
timeGanZhi: '乙巳',
|
||||
yearKongWang: '子丑',
|
||||
monthKongWang: '申酉',
|
||||
dayKongWang: '寅卯',
|
||||
timeKongWang: '午未',
|
||||
yueJian: '辰',
|
||||
riChen: '亥',
|
||||
yuePo: '戌',
|
||||
riChong: '巳',
|
||||
),
|
||||
wuXingStatus: const {'木': '旺', '火': '相', '土': '休', '金': '囚', '水': '死'},
|
||||
yaoLines: lineData,
|
||||
targetYaoLines: targetLineData,
|
||||
);
|
||||
}
|
||||
|
||||
List<YaoLineData> _buildYaoLines(List<YaoType> states, bool target) {
|
||||
const spirits = ['龙', '雀', '勾', '蛇', '虎', '玄'];
|
||||
const relations = ['父母', '兄弟', '官鬼', '妻财', '子孙', '父母'];
|
||||
const branches = ['子', '寅', '辰', '午', '申', '戌'];
|
||||
const elements = ['水', '木', '土', '火', '金', '土'];
|
||||
return List<YaoLineData>.generate(6, (idx) {
|
||||
final mark = switch (idx) {
|
||||
1 => '应',
|
||||
4 => '世',
|
||||
_ => '',
|
||||
};
|
||||
return YaoLineData(
|
||||
index: idx,
|
||||
spirit: spirits[idx],
|
||||
relation: relations[idx],
|
||||
branch: branches[idx],
|
||||
element: elements[idx],
|
||||
type: states[idx],
|
||||
mark: target ? '' : mark,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
List<YaoType> _toChangedStates(List<YaoType> source) {
|
||||
return source.map((state) {
|
||||
return switch (state) {
|
||||
YaoType.oldYang => YaoType.youngYin,
|
||||
YaoType.oldYin => YaoType.youngYang,
|
||||
_ => state,
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
String _signByStates(List<YaoType> states) {
|
||||
final dynamicCount = states
|
||||
.where((e) => e == YaoType.oldYang || e == YaoType.oldYin)
|
||||
.length;
|
||||
if (dynamicCount <= 1) {
|
||||
return '上上签';
|
||||
}
|
||||
if (dynamicCount <= 3) {
|
||||
return '中上签';
|
||||
}
|
||||
return '中下签';
|
||||
}
|
||||
|
||||
_MockContent _mockContent(
|
||||
QuestionType type,
|
||||
String question,
|
||||
String signType,
|
||||
) {
|
||||
final domain = switch (type) {
|
||||
QuestionType.career || QuestionType.study => '事业与成长',
|
||||
QuestionType.love => '关系与情感',
|
||||
QuestionType.wealth => '财富与资源',
|
||||
QuestionType.fortune => '阶段运势',
|
||||
QuestionType.dream => '潜意识信号',
|
||||
QuestionType.health => '身心节律',
|
||||
QuestionType.search => '寻物线索',
|
||||
QuestionType.other => '综合事项',
|
||||
};
|
||||
return _MockContent(
|
||||
keywords: '$signType · $domain',
|
||||
conclusion: '这个卦象的结果为$signType。你关注的“$question”处于可推进阶段,当前节奏重在稳步而行,不宜急进。',
|
||||
analysis:
|
||||
'本卦显示外在条件逐步成形,内在决心也在增强。若短期遇到反复,通常是资源重组与信息修正,并非方向错误。建议将目标拆分为可验证的小节点,持续复盘。',
|
||||
suggestion:
|
||||
'建议一:先定三周内可执行动作并按日推进。\n建议二:重要决定留有缓冲期,避免情绪化判断。\n建议三:遇到阻滞先调整节奏,再补关键资源。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MockContent {
|
||||
const _MockContent({
|
||||
required this.keywords,
|
||||
required this.conclusion,
|
||||
required this.analysis,
|
||||
required this.suggestion,
|
||||
});
|
||||
|
||||
final String keywords;
|
||||
final String conclusion;
|
||||
final String analysis;
|
||||
final String suggestion;
|
||||
}
|
||||
|
||||
class _HexagramShort {
|
||||
const _HexagramShort(this.name, this.upper, this.lower);
|
||||
|
||||
final String name;
|
||||
final String upper;
|
||||
final String lower;
|
||||
}
|
||||
|
||||
const Map<String, _HexagramShort> _hexagramMap = {
|
||||
'111111': _HexagramShort('乾为天', '乾', '乾'),
|
||||
'011111': _HexagramShort('天风姤', '乾', '巽'),
|
||||
'001111': _HexagramShort('天山遁', '乾', '艮'),
|
||||
'000111': _HexagramShort('天地否', '乾', '坤'),
|
||||
'000011': _HexagramShort('风地观', '巽', '坤'),
|
||||
'000001': _HexagramShort('山地剥', '艮', '坤'),
|
||||
'000101': _HexagramShort('火地晋', '离', '坤'),
|
||||
'111101': _HexagramShort('火天大有', '离', '乾'),
|
||||
'010010': _HexagramShort('坎为水', '坎', '坎'),
|
||||
'110010': _HexagramShort('水泽节', '坎', '兑'),
|
||||
'100010': _HexagramShort('水雷屯', '坎', '震'),
|
||||
'101010': _HexagramShort('水火既济', '坎', '离'),
|
||||
'101110': _HexagramShort('泽火革', '兑', '离'),
|
||||
'101100': _HexagramShort('雷火丰', '震', '离'),
|
||||
'101000': _HexagramShort('地火明夷', '坤', '离'),
|
||||
'010000': _HexagramShort('地水师', '坤', '坎'),
|
||||
'001001': _HexagramShort('艮为山', '艮', '艮'),
|
||||
'101001': _HexagramShort('山火贲', '艮', '离'),
|
||||
'111001': _HexagramShort('山天大畜', '艮', '乾'),
|
||||
'110001': _HexagramShort('山泽损', '艮', '兑'),
|
||||
'110101': _HexagramShort('火泽睽', '离', '兑'),
|
||||
'110111': _HexagramShort('天泽履', '乾', '兑'),
|
||||
'110011': _HexagramShort('风泽中孚', '巽', '兑'),
|
||||
'001011': _HexagramShort('风山渐', '巽', '艮'),
|
||||
'100100': _HexagramShort('震为雷', '震', '震'),
|
||||
'000100': _HexagramShort('雷地豫', '震', '坤'),
|
||||
'010100': _HexagramShort('雷水解', '震', '坎'),
|
||||
'011100': _HexagramShort('雷风恒', '震', '巽'),
|
||||
'011000': _HexagramShort('地风升', '坤', '巽'),
|
||||
'011010': _HexagramShort('水风井', '坎', '巽'),
|
||||
'011110': _HexagramShort('泽风大过', '兑', '巽'),
|
||||
'100110': _HexagramShort('泽雷随', '兑', '震'),
|
||||
'011011': _HexagramShort('巽为风', '巽', '巽'),
|
||||
'111011': _HexagramShort('风天小畜', '巽', '乾'),
|
||||
'101011': _HexagramShort('风火家人', '巽', '离'),
|
||||
'100011': _HexagramShort('风雷益', '巽', '震'),
|
||||
'100111': _HexagramShort('天雷无妄', '乾', '震'),
|
||||
'100101': _HexagramShort('火雷噬嗑', '离', '震'),
|
||||
'100001': _HexagramShort('山雷颐', '艮', '震'),
|
||||
'011001': _HexagramShort('山风蛊', '艮', '巽'),
|
||||
'101101': _HexagramShort('离为火', '离', '离'),
|
||||
'001101': _HexagramShort('火山旅', '离', '艮'),
|
||||
'011101': _HexagramShort('火风鼎', '离', '巽'),
|
||||
'010101': _HexagramShort('火水未济', '离', '坎'),
|
||||
'010001': _HexagramShort('山水蒙', '艮', '坎'),
|
||||
'010011': _HexagramShort('风水涣', '巽', '坎'),
|
||||
'010111': _HexagramShort('天水讼', '乾', '坎'),
|
||||
'101111': _HexagramShort('天火同人', '乾', '离'),
|
||||
'000000': _HexagramShort('坤为地', '坤', '坤'),
|
||||
'100000': _HexagramShort('地雷复', '坤', '震'),
|
||||
'110000': _HexagramShort('地泽临', '坤', '兑'),
|
||||
'111000': _HexagramShort('地天泰', '坤', '乾'),
|
||||
'111100': _HexagramShort('雷天大壮', '震', '乾'),
|
||||
'111110': _HexagramShort('泽天夬', '兑', '乾'),
|
||||
'111010': _HexagramShort('水天需', '坎', '乾'),
|
||||
'000010': _HexagramShort('水地比', '坎', '坤'),
|
||||
'110110': _HexagramShort('兑为泽', '兑', '兑'),
|
||||
'010110': _HexagramShort('泽水困', '兑', '坎'),
|
||||
'000110': _HexagramShort('泽地萃', '兑', '坤'),
|
||||
'001110': _HexagramShort('泽山咸', '兑', '艮'),
|
||||
'001010': _HexagramShort('水山蹇', '坎', '艮'),
|
||||
'001000': _HexagramShort('地山谦', '坤', '艮'),
|
||||
'001100': _HexagramShort('雷山小过', '震', '艮'),
|
||||
'110100': _HexagramShort('雷泽归妹', '震', '兑'),
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'dart:math';
|
||||
|
||||
import '../../../../core/logging/logger.dart';
|
||||
import '../../../../core/network/api_problem.dart';
|
||||
import '../apis/divination_api.dart';
|
||||
import '../models/divination_backend_models.dart';
|
||||
import '../models/divination_params.dart';
|
||||
|
||||
class DivinationRunService {
|
||||
const DivinationRunService({required DivinationApi api}) : _api = api;
|
||||
|
||||
final DivinationApi _api;
|
||||
static final Logger _logger = getLogger('features.divination.run_service');
|
||||
|
||||
Future<DivinationRunAggregate> run({
|
||||
required DivinationParams params,
|
||||
required List<YaoType> yaoStates,
|
||||
void Function()? onDerived,
|
||||
void Function()? onTextMessageEnd,
|
||||
}) async {
|
||||
final threadId = _uuidV4();
|
||||
final runId = 'run_${DateTime.now().millisecondsSinceEpoch}';
|
||||
await _api.enqueueRun(
|
||||
params: params,
|
||||
yaoStates: yaoStates,
|
||||
threadId: threadId,
|
||||
runId: runId,
|
||||
);
|
||||
|
||||
DerivedDivinationData? derived;
|
||||
String signLevel = '';
|
||||
String summary = '';
|
||||
List<String> conclusion = const <String>[];
|
||||
List<String> focusPoints = const <String>[];
|
||||
List<String> advice = const <String>[];
|
||||
List<String> keywords = const <String>[];
|
||||
String answer = '';
|
||||
|
||||
await for (final event in _api.streamEvents(
|
||||
threadId: threadId,
|
||||
runId: runId,
|
||||
)) {
|
||||
final type = event['type'] as String? ?? '';
|
||||
_logger.debug(
|
||||
message: 'Received run SSE event',
|
||||
extra: <String, dynamic>{
|
||||
'threadId': threadId,
|
||||
'runId': runId,
|
||||
'type': type,
|
||||
},
|
||||
);
|
||||
if (type == 'DIVINATION_DERIVED') {
|
||||
final payload = event['divination'];
|
||||
if (payload is! Map<String, dynamic>) {
|
||||
throw const FormatException('DIVINATION_DERIVED 缺少 divination 结构');
|
||||
}
|
||||
derived = DerivedDivinationData.fromJson(payload);
|
||||
onDerived?.call();
|
||||
continue;
|
||||
}
|
||||
if (type == 'TEXT_MESSAGE_END') {
|
||||
signLevel = _requiredString(event, 'sign_level');
|
||||
summary = _requiredString(event, 'summary');
|
||||
conclusion = _requiredStringList(event, 'conclusion');
|
||||
focusPoints = _requiredStringList(event, 'focus_points');
|
||||
advice = _requiredStringList(event, 'advice');
|
||||
keywords = _requiredStringList(event, 'keywords');
|
||||
answer = _requiredString(event, 'answer');
|
||||
onTextMessageEnd?.call();
|
||||
continue;
|
||||
}
|
||||
if (type == 'RUN_ERROR') {
|
||||
_logger.warning(
|
||||
message: 'Run ended with RUN_ERROR event',
|
||||
extra: <String, dynamic>{
|
||||
'threadId': threadId,
|
||||
'runId': runId,
|
||||
'code': event['code'],
|
||||
'detail': event['detail'],
|
||||
'message': event['message'],
|
||||
},
|
||||
);
|
||||
throw ApiProblem(
|
||||
status: 500,
|
||||
title: 'Run failed',
|
||||
detail: event['detail'] as String? ?? '解卦失败',
|
||||
code: event['code'] as String?,
|
||||
);
|
||||
}
|
||||
if (type == 'RUN_FINISHED') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (derived == null) {
|
||||
_logger.warning(
|
||||
message: 'Missing DIVINATION_DERIVED before RUN_FINISHED',
|
||||
extra: <String, dynamic>{'threadId': threadId, 'runId': runId},
|
||||
);
|
||||
throw const FormatException('未收到 DIVINATION_DERIVED 事件');
|
||||
}
|
||||
|
||||
return DivinationRunAggregate(
|
||||
derived: derived,
|
||||
signLevel: signLevel,
|
||||
summary: summary,
|
||||
conclusion: conclusion,
|
||||
focusPoints: focusPoints,
|
||||
advice: advice,
|
||||
keywords: keywords,
|
||||
answer: answer,
|
||||
);
|
||||
}
|
||||
|
||||
String _requiredString(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is! String || value.isEmpty) {
|
||||
throw FormatException('缺少字段: $key');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
List<String> _requiredStringList(Map<String, dynamic> json, String key) {
|
||||
final raw = json[key];
|
||||
if (raw is! List<dynamic>) {
|
||||
throw FormatException('缺少列表字段: $key');
|
||||
}
|
||||
return raw
|
||||
.map((item) {
|
||||
if (item is! String || item.isEmpty) {
|
||||
throw FormatException('字段 $key 存在非法项');
|
||||
}
|
||||
return item;
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _uuidV4() {
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
String toHex(int b) => b.toRadixString(16).padLeft(2, '0');
|
||||
final b = bytes.map(toHex).toList(growable: false);
|
||||
return '${b[0]}${b[1]}${b[2]}${b[3]}-${b[4]}${b[5]}-${b[6]}${b[7]}-${b[8]}${b[9]}-${b[10]}${b[11]}${b[12]}${b[13]}${b[14]}${b[15]}';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user