Files
biz_xfj_cloud/lib/common/utils/sync/terminal_heartbeat_tool.dart
T
2026-07-30 17:06:17 +08:00

169 lines
6.5 KiB
Dart

import 'dart:async';
import 'package:dio/dio.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 终端心跳场景编码,与后端 TerminalHeartbeatSceneEnum 名称保持一致。
enum TerminalHeartbeatScene {
/// 普通在线心跳,服务端收到后清除计划更新断开原因。
online('ONLINE'),
/// 自动更新安装前心跳,服务端在重启期间保留“自动更新重启”原因。
autoUpdate('AUTO_UPDATE');
/// 写入心跳接口的固定协议编码。
final String code;
/// 创建一项跨端固定心跳场景。
const TerminalHeartbeatScene(this.code);
}
/// 自动更新前心跳失败的可展示异常,隐藏 Dio 协议细节并保留明确恢复动作。
class TerminalHeartbeatException implements Exception {
/// 使用已经脱敏和限长的终端操作提示创建异常。
const TerminalHeartbeatException(this.message);
/// 可写入任务记录并直接展示给终端操作人员的短中文说明。
final String message;
/// 返回不含请求地址、堆栈和 Dio 实现细节的安全摘要。
@override
String toString() => message;
}
/// 食堂、超市 Android 终端心跳管理器。
abstract final class TerminalHeartbeatTool {
/// 心跳诊断日志使用的稳定模块标签。
static const String _tag = '终端心跳';
/// 服务端业务提示的最大展示长度,避免异常响应再次撑满 Toast 和任务列表。
static const int _maxServiceMessageLength = 120;
/// 终端在线阈值为 180 秒,客户端每 60 秒上报一次以容忍两次短暂失败。
static const Duration _interval = Duration(seconds: 60);
/// 当前进程唯一心跳计时器,应用控制器关闭时释放。
static Timer? _timer;
/// 启动独立心跳并立即上报一次;重复调用只会重建同一个计时器。
static void start() {
_timer?.cancel();
unawaited(report());
_timer = Timer.periodic(_interval, (_) => unawaited(report()));
}
/// 停止进程内心跳计时器。
static void stop() {
_timer?.cancel();
_timer = null;
}
///
/// 上报当前 APK、运行环境、Kiosk、Root 与外围硬件最新快照。
///
/// [throwOnError] 只供自动更新前的强一致场景使用;普通后台心跳失败仅写日志,
/// 不弹 Toast,也不影响收银流程。
static Future<bool> report({
TerminalHeartbeatScene scene = TerminalHeartbeatScene.online,
bool throwOnError = false,
}) async {
try {
if (!AppNetworkTool.isAutoNetwork()) return false;
final setting = await AppSettingTool.getSysSetting();
final hardId = setting.serviceId;
if (hardId == null || hardId.isEmpty) return false;
final package = await PackageInfo.fromPlatform();
final network = await AppRuntimeTool.getNetworkSnapshot();
final performance = await AppRuntimeTool.getPerformanceSnapshot();
final kiosk = await AppRuntimeTool.refreshKioskStatus();
final devices = AppDeviceStatusTool.status.value;
final versionCode = int.tryParse(package.buildNumber);
await HardApi.terminalHeartbeat(
hardId: hardId,
data: {
'apkVersionName': package.version,
'apkVersionCode': versionCode,
'scene': scene.code,
'systemUptimeSeconds': performance.systemUptimeSeconds,
'appUptimeSeconds': performance.appUptimeSeconds,
'networkType': network.type,
'availableStorageBytes': performance.storageAvailableBytes,
'rootStatus': setting.rootAccessStatus.name,
'deviceOwner': kiosk.deviceOwner,
'kioskActive': kiosk.active,
'deviceStatus': {
'printer': devices.printerAvailable,
'steelyard': devices.steelyardAvailable,
'cardReader': devices.cardReaderAvailable,
'subScreen': devices.subScreenAvailable,
},
},
);
return true;
} catch (error, stack) {
logger.w('|$_tag|上报失败scene:${scene.code}|error:$error|stack:$stack|');
if (throwOnError) {
throw TerminalHeartbeatException(_toUserMessage(error));
}
return false;
}
}
/// 将心跳异常转换为包含原因和恢复动作的短提示,原始异常仅保留在诊断日志中。
static String _toUserMessage(Object error) {
final serviceMessage = _extractServiceMessage(error);
if (serviceMessage != null &&
(serviceMessage.contains('设备不存在') ||
serviceMessage.contains('不属于当前租户'))) {
return '更新状态上报失败:设备不存在或不属于当前租户,请在“设置 > 基本信息”重新绑定设备';
}
if (serviceMessage != null) {
return '更新状态上报失败:$serviceMessage';
}
if (error is DioException) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return '更新状态上报超时,请检查网络后重试';
case DioExceptionType.connectionError:
case DioExceptionType.badCertificate:
return '更新状态上报失败:无法连接服务器,请检查网络后重试';
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
return statusCode == null
? '更新状态上报失败:服务端返回异常,请稍后重试'
: '更新状态上报失败:服务端返回 HTTP $statusCode,请稍后重试';
case DioExceptionType.cancel:
return '更新状态上报已取消,请重新执行任务';
case DioExceptionType.unknown:
break;
}
}
return '更新状态上报失败,请稍后重试';
}
/// 从统一响应或静默请求拒绝原因中提取服务端业务提示,并压缩空白与限制长度。
static String? _extractServiceMessage(Object error) {
if (error is! DioException) return null;
final data = error.response?.data;
Object? rawMessage;
if (data is Map) rawMessage = data['msg'];
if (rawMessage == null &&
error.type == DioExceptionType.badResponse &&
error.error is String) {
rawMessage = error.error;
}
final message = rawMessage?.toString().trim().replaceAll(
RegExp(r'\s+'),
' ',
);
if (message == null || message.isEmpty) return null;
return message.length <= _maxServiceMessageLength
? message
: '${message.substring(0, _maxServiceMessageLength)}...';
}
}