feat: 接入起卦后端流程并完善积分扣减链路

This commit is contained in:
qzl
2026-04-03 19:04:46 +08:00
parent a136e42290
commit d87b2e1e3a
56 changed files with 3310 additions and 809 deletions
@@ -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]}';
}
}