import 'dart:convert'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import '../../../core/utils/logger.dart'; import '../db/sqflite_api.dart'; import '../network/api.dart'; import '../../models/user_Info.dart'; import 'meeting_upload_service.dart'; class MeetingTaskService extends GetxService { MeetingUploadService get _uploadService => Get.find(); final GetStorage _storage = GetStorage(); static const String _pendingLocalRecordingTitleStorageKey = 'meeting_pending_local_recording_titles'; List _taskList = []; //待处理的会议任务列表 bool _isTask = false; //是否有正在处理的任务 RxInt updateListState = 0.obs; //是否更新首页列表 RxInt localImportState = 0.obs; //是否触发本地录音导入 int meetingId = 0; RxInt tasktype = 1.obs; //任务类型,0-初始,1-等待转写中,2-转写中,3-等待总结中,4-总结中,5-完成 List textList = []; String personnel = ''; String summary = ''; String overview = ''; void setPendingLocalRecordingTitle(String wavFileName, String displayTitle) { if (wavFileName.isEmpty) return; final data = _storage.read(_pendingLocalRecordingTitleStorageKey); final map = (data is Map) ? Map.from(data) : {}; map[wavFileName] = displayTitle; _storage.write(_pendingLocalRecordingTitleStorageKey, map); } String? takePendingLocalRecordingTitle(String wavFileName) { if (wavFileName.isEmpty) return null; final data = _storage.read(_pendingLocalRecordingTitleStorageKey); if (data is! Map) return null; final map = Map.from(data); final v = map.remove(wavFileName); if (v == null) return null; _storage.write(_pendingLocalRecordingTitleStorageKey, map); return v.toString(); } void requestLocalImport() { localImportState.value++; } Future addTask( int id, String audiourl, String formlanguage, String tolanguage, bool isdistinguishspeaker, int templateid, String tid, ) async { if (audiourl.isEmpty) { Map uploadData = _uploadService.uploadList.firstWhere( (element) => element['id'] == id, orElse: () => {}, ); if (uploadData.isNotEmpty) { if (uploadData['audiourl'].isEmpty) { uploadData['isTask'] = true; uploadData['formlanguage'] = formlanguage; uploadData['tolanguage'] = tolanguage; uploadData['isdistinguishspeaker'] = isdistinguishspeaker; uploadData['templateid'] = templateid; uploadData['tid'] = tid; } else { await submitTask( id, formlanguage, tolanguage, isdistinguishspeaker, templateid, tid, ); } } } else { await submitTask( id, formlanguage, tolanguage, isdistinguishspeaker, templateid, tid, ); } } Future submitTask( int id, String formlanguage, String tolanguage, bool isdistinguishspeaker, int templateid, String tid, ) async { await Api.startTaskEchomeet({ 'id': id, 'formlanguage': formlanguage, 'tolanguage': tolanguage, 'isdistinguishspeaker': isdistinguishspeaker, 'templateid': templateid, 'tid': tid, }); final seconds = await _getMeetingSeconds(id); _decrementMeetingIntegral(cost: seconds); _taskList.add({ 'id': id, 'tasktype': 1, }); await SqfliteApi.setMeetingTaskType(id, 1); updateListState.value++; _executeTask(); } Future refreshTask(int id, int templateid, String tid, String tolanguage) async { tasktype.value = 3; await Api.refreshTaskEchomeet({ 'id': id, 'templateid': templateid, 'tid': tid, 'tolanguage': tolanguage, }); _taskList.add({ 'id': id, 'tasktype': 3, }); await SqfliteApi.setMeetingTaskType(id, 3); updateListState.value++; _executeTask(); } Future readTask(int id) async { await Api.readRecordEchomeet({'id': id}); await SqfliteApi.setMeetingTaskType(id, 6); updateListState.value++; } void _executeTask() async { if (_isTask) return; _isTask = true; try { while (_taskList.isNotEmpty) { await Future.delayed(const Duration(seconds: 5)); try { await _queryTask(); } catch (e, st) { // ⚠️ 单轮失败绝不能终止整个轮询。 // // 原来这里没有 try/catch:_queryTask 里任何一次抛异常都会把 while 掀掉, // 而 `_isTask = false` 写在循环之后、根本执行不到 —— 于是 _isTask 永远 // 停在 true,开头那句 `if (_isTask) return;` 会挡住之后所有 startTask(), // **整个进程生命周期内轮询再也起不来,且一声不吭**。 // 2026-09-06 真机就是这么中的:服务端 15 秒就跑完了,App 一直显示「转写中」。 Logger.w('MeetingTask', '本轮任务查询失败,跳过继续: $e\n$st'); } } } finally { // 放 finally:无论正常退出还是异常穿出,标志位都必须复位 _isTask = false; } } /// 把服务端的 translate 解析成 speaker 列表;数据没齐或格式不对返回 null, /// 由调用方决定「本轮跳过、下轮再来」,而不是抛异常掀掉轮询。 List>? _decodeSpeakers(dynamic raw) { if (raw is! String || raw.trim().isEmpty) return null; try { final decoded = jsonDecode(raw); if (decoded is! List || decoded.isEmpty) return null; return decoded.map((e) => Map.from(e as Map)).toList(); } catch (e) { Logger.w('MeetingTask', 'translate 解析失败,本轮跳过: $e'); return null; } } Future removeTask(int id) async { _taskList.removeWhere((element) => element['id'] == id); await Api.delRecordEchomeet({ 'ids': [id], }); } void startTask(List data) { _taskList = data; _executeTask(); } void stopTask() { _taskList.clear(); } Future _queryTask() async { if (_taskList.isEmpty) return; List ids = _taskList.map((e) => e['id']).toList(); var response = await Api.getMultitermRecord({ 'ids': ids, }); // ⚠️ 业务错误时 AuthInterceptor 是把 data 置 null 后 **resolve**(不抛), // 所以这里必须判空。直接 response['records'] 会在 null 上抛 // NoSuchMethodError,配合上面那条就是轮询永久死亡。 if (response == null) { Logger.w('MeetingTask', '查询任务返回空(业务错误),本轮跳过'); return; } List queryList = response['records'] ?? []; for (var element in queryList) { int id = element['id']; int newTasktype = element['state']; Map taskData = _taskList.firstWhere( (i) => i['id'] == id, orElse: () => {}, ); if (taskData.isNotEmpty) { int oldTasktype = taskData['tasktype']; if (oldTasktype != newTasktype) { if (oldTasktype < 3 && newTasktype >= 3) { // 转写完成 // // ⚠️ translate 可能还没落库。服务端 PollTranscribe 是先把 state 推到 // AwaitSummarizing 并保存、再 `go finishTranscribe(...)` 在后台写 // translate 的,所以「状态说转写完了、数据还没到」这一拍是必然存在的 // (服务端已加 hideHalfDoneTranscribe 挡在响应侧,这里是第二道保险, // 老服务端 / 其它入口同样能兜住)。 // 直接 jsonDecode 空串会抛 FormatException,把整个轮询掀掉。 final speakerList = _decodeSpeakers(element['translate']); if (speakerList == null) { // 数据还没齐:**本轮不推进 tasktype**,下一轮拿到完整数据再处理。 // 推进了的话 oldTasktype 就跨过 3 了,这段转写文本永远补不回来。 continue; } await SqfliteApi.deleteMeetingSpeaker(id); await SqfliteApi.insertMeetingSpeaker(speakerList); if (id == meetingId) { List sqlSpeakerList = await SqfliteApi.getMeetingSpeaker(id); textList = List.from(sqlSpeakerList); } } // ⚠️ 判据用「正好是完成/已阅」而不是 >=5:失败态 SummarizFail=10001、 // TranscribeFail=10002 数值上也 >= 5,会被当成「总结完成」, // 然后拿响应里的空 summary/overview 覆盖本地已有内容 —— 与这次 // 丢总结是同一类数据损失。 if (oldTasktype < 5 && (newTasktype == 5 || newTasktype == 6)) { // 总结完成 await SqfliteApi.editMeeting(id, { 'personnel': element['personnel'] ?? '', 'summary': element['summary'] ?? '', 'overview': element['overview'] ?? '', }); if (id == meetingId) { personnel = element['personnel'] ?? ''; summary = element['summary'] ?? ''; overview = element['overview'] ?? ''; } } // 两张表一次落地。放在上面两个里程碑分支**之后**:分支里先把 // speaker / summary 写好,最后这一下才把状态推进并通知 UI, // 详情页的 ever(tasktype) 监听读到新状态时数据已经就位。 await SqfliteApi.setMeetingTaskType(id, newTasktype); // ⚠️ 这里原来也在两个分支里各写一次,于是 1→2(等待转写→转写中) // 这一跳不通知任何人。状态本身要一直推,等待态才不会在中途变回旧值。 if (id == meetingId) { tasktype.value = newTasktype; } taskData['tasktype'] = newTasktype; updateListState.value++; } } if (newTasktype >= 5) { _taskList.removeWhere((i) => i['id'] == id); } } } /// 获取会议音频秒数 Future _getMeetingSeconds(int id) async { try { final data = await SqfliteApi.getMeetingDetails(id); final v = data['seconds']; if (v is int) return v; if (v is num) return v.toInt(); if (v is String) return int.tryParse(v) ?? 0; return 0; } catch (_) { return 0; } } /// 减少用户会议积分 void _decrementMeetingIntegral({required int cost}) { if (!User.isLoggedIn()) return; if (cost <= 0) return; final next = User.instance.meetintegral - cost; User.instance.updateUserInfo({'meetintegral': next < 0 ? 0 : next}); //GetStorage().write('user_info', User.instance.toJson()); } }