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 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)}...'; } }