You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
358 lines
13 KiB
358 lines
13 KiB
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<MeetingUploadService>();
|
|
|
|
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<String, dynamic>.from(data)
|
|
: <String, dynamic>{};
|
|
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<String, dynamic>.from(data);
|
|
final v = map.remove(wavFileName);
|
|
if (v == null) return null;
|
|
_storage.write(_pendingLocalRecordingTitleStorageKey, map);
|
|
return v.toString();
|
|
}
|
|
|
|
void requestLocalImport() {
|
|
localImportState.value++;
|
|
}
|
|
|
|
/// 返回任务是否**已受理或已排队**。
|
|
///
|
|
/// 原来返回 void,调用方(详情页「生成」)不管结果一律把页面切成「等待转写」;
|
|
/// 而 `audiourl` 为空、上传队列里又找不到这条(上传项在离开详情页时会被摘掉)时,
|
|
/// 这里什么都没做——页面就永远停在占位图上等一个不存在的任务。
|
|
Future<bool> 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.isEmpty) {
|
|
Logger.w('MeetingTask', '记录 $id 没有音频 URL、上传队列里也没有它,无法提交任务');
|
|
return false;
|
|
}
|
|
if (uploadData['audiourl'].isEmpty) {
|
|
uploadData['isTask'] = true;
|
|
uploadData['formlanguage'] = formlanguage;
|
|
uploadData['tolanguage'] = tolanguage;
|
|
uploadData['isdistinguishspeaker'] = isdistinguishspeaker;
|
|
uploadData['templateid'] = templateid;
|
|
uploadData['tid'] = tid;
|
|
return true; // 上传完成时由 MeetingUploadService 提交
|
|
}
|
|
return submitTask(
|
|
id,
|
|
formlanguage,
|
|
tolanguage,
|
|
isdistinguishspeaker,
|
|
templateid,
|
|
tid,
|
|
);
|
|
}
|
|
return submitTask(
|
|
id,
|
|
formlanguage,
|
|
tolanguage,
|
|
isdistinguishspeaker,
|
|
templateid,
|
|
tid,
|
|
);
|
|
}
|
|
|
|
/// 智能总结模板的 tid(后台「Autopilot智能总结」,41 种语言各有一版,按 tid 反查)。
|
|
static const String smartTemplateTid = 'General-Autopilot';
|
|
|
|
/// 记录建的时候已经带了转写(翻译归档,`rtype=VOICETRANSLAT`):
|
|
/// 服务端跳过识别,直接用智能模板总结。源=目标语言,连翻译那一段也跳过。
|
|
///
|
|
/// 模板只有 App 的 41 个界面语言有版本,翻译语种(粤语、繁中…)超出这个集合时
|
|
/// 退到英文——否则服务端回 TemplateNotFound,记录停在「未处理」且没人知道为什么。
|
|
Future<bool> startSummaryForTranscript(int id, String language) async {
|
|
final lang = _templateLanguageFor(language);
|
|
return submitTask(id, lang, lang, false, 0, smartTemplateTid);
|
|
}
|
|
|
|
/// 模板语言集合 == App 界面语言集合(服务端按 41 语种各建一份公共模板)。
|
|
String _templateLanguageFor(String code) {
|
|
final key = code.trim().replaceAll('-', '_');
|
|
if (key.isEmpty) return 'en-US';
|
|
if (Get.translations.containsKey(key)) return code.trim();
|
|
// zh-HK / zh-TW 这种带地区的:先按语言前缀找同语系的界面语言
|
|
final prefix = key.split('_').first;
|
|
for (final k in Get.translations.keys) {
|
|
if (k.split('_').first == prefix) return k.replaceAll('_', '-');
|
|
}
|
|
return 'en-US';
|
|
}
|
|
|
|
/// 返回服务端是否受理。**没受理就不进轮询**:`AuthInterceptor` 在业务错误
|
|
/// (无会员 5302、模板不存在…)时把 data 置 null 后 resolve,原来这里不判、
|
|
/// 照样把任务塞进 _taskList 并把本地状态写成 1,列表就永远显示「等待转写中」,
|
|
/// 而服务端那条记录其实还停在 0。
|
|
Future<bool> submitTask(
|
|
int id,
|
|
String formlanguage,
|
|
String tolanguage,
|
|
bool isdistinguishspeaker,
|
|
int templateid,
|
|
String tid,
|
|
) async {
|
|
final resp = await Api.startTaskEchomeet({
|
|
'id': id,
|
|
'formlanguage': formlanguage,
|
|
'tolanguage': tolanguage,
|
|
'isdistinguishspeaker': isdistinguishspeaker,
|
|
'templateid': templateid,
|
|
'tid': tid,
|
|
});
|
|
if (resp == null) {
|
|
Logger.w('MeetingTask',
|
|
'任务未被服务端受理 id=$id lang=$formlanguage→$tolanguage tid=$tid,记录保持未处理');
|
|
return false;
|
|
}
|
|
final seconds = await _getMeetingSeconds(id);
|
|
_decrementMeetingIntegral(cost: seconds);
|
|
_taskList.add({
|
|
'id': id,
|
|
'tasktype': 1,
|
|
});
|
|
await SqfliteApi.setMeetingTaskType(id, 1);
|
|
updateListState.value++;
|
|
_executeTask();
|
|
return true;
|
|
}
|
|
|
|
Future<void> 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<void> 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<Map<String, Object?>>? _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<String, Object?>.from(e as Map)).toList();
|
|
} catch (e) {
|
|
Logger.w('MeetingTask', 'translate 解析失败,本轮跳过: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<void> removeTask(int id) async {
|
|
_taskList.removeWhere((element) => element['id'] == id);
|
|
await Api.delRecordEchomeet({
|
|
'ids': [id],
|
|
});
|
|
}
|
|
|
|
/// 只把任务从轮询队列摘掉,不打删除接口——记录在服务端已经没了(别的设备删的)。
|
|
void removeLocalTask(int id) {
|
|
_taskList.removeWhere((element) => element['id'] == id);
|
|
}
|
|
|
|
void startTask(List data) {
|
|
_taskList = data;
|
|
_executeTask();
|
|
}
|
|
|
|
void stopTask() {
|
|
_taskList.clear();
|
|
}
|
|
|
|
Future<void> _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<int> _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());
|
|
}
|
|
}
|
|
|