|
|
@@ -0,0 +1,140 @@
|
|
|
+package com.dlc.exam.monitor
|
|
|
+
|
|
|
+import android.content.Context
|
|
|
+import android.os.Handler
|
|
|
+import android.os.Looper
|
|
|
+import android.os.SystemClock
|
|
|
+import com.dlc.exam.common.CommonUtils
|
|
|
+import com.dlc.exam.mqtt.MqttManager
|
|
|
+import com.rc.core.log.RcLog
|
|
|
+import com.rc.core.util.ProcessUtils
|
|
|
+import com.rc.httpcore.client.ApiRepository
|
|
|
+import com.rc.httpcore.exception.NetException
|
|
|
+
|
|
|
+/**
|
|
|
+ * 设备在线状态心跳上报
|
|
|
+ *
|
|
|
+ * 设计说明:
|
|
|
+ * 1. 挂在 Application 层(ExamApp 启动时开启),不挂在任何 Activity 上。
|
|
|
+ * 这样登录页 / 考试页 / 学习页来回切换都不会中断上报。
|
|
|
+ * 2. iot/aio/monitor 在服务端白名单中,不需要 token,
|
|
|
+ * 所以进程刚启动、用户还没登录时上报同样有效。
|
|
|
+ * 3. 只在主进程上报。App 还有第二个进程(:dexopt,X5 内核的 dex 优化进程),
|
|
|
+ * Android 会为每个进程各创建一份 Application 实例并调用 onCreate;
|
|
|
+ * 如果每个进程都上报,同一个 deviceNo 会去抢服务端同一个 30 秒限流 key,
|
|
|
+ * 请求互相被拒,设备状态就会在"在线/离线"之间来回跳。
|
|
|
+ * 4. 上报失败只写日志,不弹 toast、不打断用户操作,下一个周期自动重试。
|
|
|
+ */
|
|
|
+object MonitorManager {
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 心跳上报周期:5 秒。
|
|
|
+ *
|
|
|
+ * 与其他一体机保持完全一致。
|
|
|
+ * 服务端 /monitor 有 30 秒的每设备限流窗口(Redis SETNX,窗口内重复请求直接拒绝、
|
|
|
+ * 且被拒的那次不会转发到 MQTT),所以正常情况下真正打到 MQTT 的"有效刷新间隔"
|
|
|
+ * 恒为 30 秒;这里取 5 秒是为了:
|
|
|
+ * - 与其他正常的一体机完全对齐,不留下"我们跟别人不一样"的变量;
|
|
|
+ * - 万一服务端限流没按预期生效(例如 key 没过期),5 秒也能兜住刷新频率。
|
|
|
+ */
|
|
|
+ private const val INTERVAL_MS = 5_000L
|
|
|
+
|
|
|
+ /** 限流日志降噪间隔 */
|
|
|
+ private const val LIMIT_LOG_INTERVAL_MS = 60_000L
|
|
|
+
|
|
|
+ private var lastLimitLogAt = 0L
|
|
|
+
|
|
|
+ /** 上一次成功上报的时刻(elapsedRealtime),用于统计真实刷新间隔 */
|
|
|
+ @Volatile
|
|
|
+ private var lastSuccessAt = 0L
|
|
|
+
|
|
|
+ /** 上一次发起请求的时刻(elapsedRealtime) */
|
|
|
+ @Volatile
|
|
|
+ private var lastRequestAt = 0L
|
|
|
+
|
|
|
+ private val handler = Handler(Looper.getMainLooper())
|
|
|
+
|
|
|
+ private var appContext: Context? = null
|
|
|
+
|
|
|
+ @Volatile
|
|
|
+ private var started = false
|
|
|
+
|
|
|
+ private val heartbeatTask = object : Runnable {
|
|
|
+ override fun run() {
|
|
|
+ report()
|
|
|
+ handler.postDelayed(this, INTERVAL_MS)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 启动心跳上报(幂等,重复调用只会生效一次)
|
|
|
+ */
|
|
|
+ @JvmStatic
|
|
|
+ fun start(context: Context) {
|
|
|
+ if (started) return
|
|
|
+
|
|
|
+ // 非主进程不启动心跳,详见类注释第 3 点
|
|
|
+ if (!isMainProcess(context)) {
|
|
|
+ RcLog.info("[Monitor] 当前不是主进程,跳过心跳启动")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ started = true
|
|
|
+ appContext = context.applicationContext
|
|
|
+ // 立即上报一次,之后按 INTERVAL_MS 周期上报
|
|
|
+ handler.post(heartbeatTask)
|
|
|
+ RcLog.info("[Monitor] 心跳已启动,间隔=$INTERVAL_MS ms")
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 立即上报一次(例如登录成功后,立刻把设备置为在线)
|
|
|
+ */
|
|
|
+ @JvmStatic
|
|
|
+ fun reportNow() {
|
|
|
+ report()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 判断当前是否主进程。
|
|
|
+ *
|
|
|
+ * 取当前进程名与包名比较;取不到进程名时默认按主进程处理,
|
|
|
+ * 避免误伤导致心跳完全没启动。
|
|
|
+ */
|
|
|
+ private fun isMainProcess(context: Context): Boolean {
|
|
|
+ return ProcessUtils.isMainProcess(context)
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun report() {
|
|
|
+ val context = appContext ?: return
|
|
|
+ try {
|
|
|
+ val deviceNo = CommonUtils.getAndroidId(context)
|
|
|
+ val now = SystemClock.elapsedRealtime()
|
|
|
+ lastRequestAt = now
|
|
|
+ ApiRepository.monitor(deviceNo).subscribe({
|
|
|
+ val t = SystemClock.elapsedRealtime()
|
|
|
+ val gap = if (lastSuccessAt > 0) t - lastSuccessAt else -1L
|
|
|
+ lastSuccessAt = t
|
|
|
+ RcLog.info("[Monitor] 上报成功 deviceNo=$deviceNo 距上次成功=${gap}ms")
|
|
|
+ }, { throwable ->
|
|
|
+ val t = SystemClock.elapsedRealtime()
|
|
|
+ val gap = if (lastSuccessAt > 0) t - lastSuccessAt else -1L
|
|
|
+ if (throwable is NetException) {
|
|
|
+ // 服务端业务拒绝:命中 30 秒限流窗口,属于预期内的正常反馈,不是故障。
|
|
|
+ // 10 秒一次意味着大部分请求都会命中,这里做降噪,每分钟只记一条。
|
|
|
+ if (t - lastLimitLogAt > LIMIT_LOG_INTERVAL_MS) {
|
|
|
+ lastLimitLogAt = t
|
|
|
+ RcLog.info("[Monitor] 命中服务端 30 秒限流,本次未上报(属正常,下个窗口会成功)")
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 网络异常等真实故障
|
|
|
+ RcLog.warn("[Monitor] 心跳上报失败 deviceNo=$deviceNo, err=$throwable")
|
|
|
+ }
|
|
|
+ RcLog.info("[Monitor] 诊断 请求时刻=${now}ms 本次结果=${throwable.javaClass.simpleName}" +
|
|
|
+ " code=${(throwable as? NetException)?.code} 距上次成功=${gap}ms" +
|
|
|
+ " 距上次请求=${t - lastRequestAt}ms MQTT连接=${MqttManager.instance.isConnected()}")
|
|
|
+ })
|
|
|
+ } catch (e: Throwable) {
|
|
|
+ RcLog.warn("[Monitor] 心跳上报异常", e)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|