diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt index b5b27c4bf..b76a16a58 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt @@ -162,6 +162,9 @@ class StsAgent( var credentialReady = false private set + // ── 心跳冷却 ────────────────────────────────────────────────────────────── + private val HEARTBEAT_COOLDOWN_MS = 3_600_000L // 1 小时 + // ═════════════════════════════════════════════════════════════════════════ // 凭证获取 // ═════════════════════════════════════════════════════════════════════════ @@ -225,6 +228,16 @@ class StsAgent( Log.w(TAG, "[YIDONG] reportHeartbeat: 凭证未就绪,跳过上报") return } + // 1 小时冷却:StsAgent 会被反复重建,init/fetchCredentials 都会调到这里, + // 必须在原生层拦,否则每次重建都会打一发心跳 + val prefs = context.getSharedPreferences("sts_agent", Context.MODE_PRIVATE) + val last = prefs.getLong("last_heartbeat_ts", 0L) + val now = System.currentTimeMillis() + if (last > 0L && now - last < HEARTBEAT_COOLDOWN_MS) { + Log.i(TAG, "[YIDONG] reportHeartbeat: 距上次 ${(now - last) / 1000}s,冷却中跳过") + return + } + prefs.edit().putLong("last_heartbeat_ts", now).apply() Log.i(TAG, "[YIDONG] reportHeartbeat: 开始上报 deviceId=${config.deviceId} deviceNo=${config.deviceNo} cmei=${config.cmei}") launch { try { @@ -271,10 +284,14 @@ class StsAgent( emit("heartbeat_reported", mapOf("time" to System.currentTimeMillis())) } else { Log.e(TAG, "[YIDONG] reportHeartbeat: 上报失败 code=$code msg=$msg") + // 上报失败回滚时间戳,避免卡 1 小时无法重试 + prefs.edit().putLong("last_heartbeat_ts", last).apply() } } } catch (e: Exception) { Log.e(TAG, "[YIDONG] reportHeartbeat: 请求异常 ${e.message}") + // 异常同样回滚时间戳 + prefs.edit().putLong("last_heartbeat_ts", last).apply() } } } diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift index 034f6b72d..13dc6b0af 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift @@ -211,7 +211,8 @@ class AgentServiceImpl: NSObject { deviceNo: config["stsDeviceNo"] as? String ?? "", deviceId: config["stsDeviceId"] as? String ?? "", deviceSecret: config["stsDeviceSecret"] as? String ?? "", - agentId: agentId + agentId: agentId, + cmei: config["stsCmei"] as? String ?? "" ) stsAgent = StsAgent( config: stsConfig, @@ -726,6 +727,16 @@ class AgentServiceImpl: NSObject { return success } + /// STS 模式下主动触发中国移动平台的凭证获取(Dart AgentService.fetchCredentials 会调到这里) + func fetchCredentials() { + stsAgent?.fetchCredentials() + } + + /// STS 模式下主动上报一次心跳(1 小时冷却在原生层拦截) + func reportHeartbeat() { + stsAgent?.reportHeartbeat() + } + func interruptCurrentResponse() -> Bool { if agentMode == "sts" { stsAgent?.interruptSpeaking() diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift index 8963009e1..fe0e3a60e 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift @@ -203,6 +203,17 @@ public class AgentServicePlugin: NSObject, FlutterPlugin { } impl.setCanWakeUp(canWakeUp) result(true) + + case "reportHeartbeat": + // 中国移动精灵心跳上报,原生层持久化 1 小时冷却 + impl.reportHeartbeat() + result(nil) + + case "fetchCredentials": + // 中国移动精灵凭证拉取,成功后会自动触发心跳(受冷却保护) + impl.fetchCredentials() + result(nil) + default: result(FlutterMethodNotImplemented) } diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/StsAgent.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/StsAgent.swift index a5e50eb78..477eb78cb 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/StsAgent.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/StsAgent.swift @@ -1,4 +1,5 @@ import Foundation +import UIKit import AVFoundation import CryptoKit import os.log @@ -13,6 +14,7 @@ struct StsConfig { var deviceId: String var deviceSecret: String let agentId: String + var cmei: String = "" } // MARK: - StsAgent @@ -71,6 +73,11 @@ class StsAgent: NSObject { // MARK: 凭证请求 private var credentialsFetched = false + // MARK: 心跳冷却 + /// 1 小时冷却(毫秒),避免 StsAgent 反复重建 / fetchCredentials 反复触发时频繁上报 + private let HEARTBEAT_COOLDOWN_MS: Int64 = 3_600_000 + private let HEARTBEAT_PREFS_KEY = "sts_agent_last_heartbeat_ts" + init(config: StsConfig, onEvent: @escaping (String, [String: Any]) -> Void, onCredentialsFetched: ((String, String, String) -> Void)? = nil) { @@ -80,6 +87,22 @@ class StsAgent: NSObject { super.init() let sessionConfig = URLSessionConfiguration.default urlSession = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: OperationQueue()) + os_log("StsAgent 初始化: productId=%{public}@ deviceNo=%{public}@ deviceId=%{public}@ cmei=%{public}@ hasSecret=%{public}@", + log: logger, type: .info, + config.productId, config.deviceNo, config.deviceId, config.cmei, + config.deviceSecret.isEmpty ? "false" : "true") + // 与 Android 行为对齐:凭证已有时立即尝试上报一次心跳(受 1 小时冷却保护) + if !config.deviceSecret.isEmpty && !config.deviceId.isEmpty { + credentialsFetched = true + reportHeartbeat() + } + } + + /// 动态更新 CMEI(首次初始化时可能尚未获取到) + func updateCmei(_ cmei: String) { + guard !cmei.isEmpty, cmei != config.cmei else { return } + config.cmei = cmei + os_log("updateCmei: cmei 已更新为 %{public}@", log: logger, type: .info, cmei) } // MARK: - 公共接口 @@ -139,8 +162,18 @@ class StsAgent: NSObject { // MARK: - 凭证获取 + /// 公开入口:主动触发认证检查(Dart 层 AgentService.fetchCredentials 会调用) + func fetchCredentials() { + fetchCredentialsIfNeeded { [weak self] in + // 成功与否都不做额外处理;fetchCredentialsIfNeeded 内部会在命中凭证时触发心跳 + _ = self + } + } + private func fetchCredentialsIfNeeded(completion: @escaping () -> Void) { if credentialsFetched && !config.deviceId.isEmpty && !config.deviceSecret.isEmpty { + // 凭证已有,直接上报心跳(受冷却保护) + reportHeartbeat() completion() return } @@ -175,10 +208,95 @@ class StsAgent: NSObject { if let deviceNo = body["deviceNo"] as? String { self.config.deviceNo = deviceNo } self.credentialsFetched = true self.onCredentialsFetched?(self.config.deviceId, self.config.deviceSecret, self.config.deviceNo) + // 凭证获取成功后立即上报心跳(受冷却保护) + self.reportHeartbeat() completion() }.resume() } + // MARK: - 心跳上报 + + /// 向中国移动平台上报心跳;1 小时冷却通过 UserDefaults 持久化,所有调用路径共享同一把闸门 + func reportHeartbeat() { + guard credentialsFetched, + !config.deviceId.isEmpty, + !config.deviceSecret.isEmpty else { + os_log("reportHeartbeat: 凭证未就绪,跳过上报", log: logger, type: .info) + return + } + let defaults = UserDefaults.standard + // 用 Double 存毫秒时间戳,避开 Int64 的 UserDefaults 桥接问题 + let last = defaults.double(forKey: HEARTBEAT_PREFS_KEY) + let now = Date().timeIntervalSince1970 * 1000 + if last > 0, now - last < Double(HEARTBEAT_COOLDOWN_MS) { + os_log("reportHeartbeat: 距上次 %{public}ds,冷却中跳过", log: logger, type: .info, Int((now - last) / 1000)) + return + } + defaults.set(now, forKey: HEARTBEAT_PREFS_KEY) + os_log("reportHeartbeat: 开始上报 deviceId=%{public}@ deviceNo=%{public}@ cmei=%{public}@", + log: logger, type: .info, config.deviceId, config.deviceNo, config.cmei) + + let urlStr = "https://ivs.chinamobiledevice.com:11443/v2/customer/device/report" + guard let url = URL(string: urlStr) else { return } + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.timeoutInterval = 10 + + let systemVersion = UIDevice.current.systemVersion + let params: [String: Any] = [ + "innerIp": ["127.0.0.1"], + "netSpeed": "0Mbps", + "netType": "WiFi", + "platform": "iOS\(systemVersion)", + "sdkVersion": "ai_http_1.0", + "firmwareVersion": "1.0.0", + "imei": "", + "cmei": config.cmei, + "mac": "", + ] + let bodyDict: [String: Any] = [ + "deviceId": config.deviceId, + "deviceSecret": config.deviceSecret, + "productId": config.productId, + "productKey": config.productKey, + "params": params, + ] + guard let bodyData = try? JSONSerialization.data(withJSONObject: bodyDict, options: []) else { return } + req.httpBody = bodyData + + URLSession.shared.dataTask(with: req) { [weak self] data, _, error in + guard let self = self else { return } + if let error = error { + os_log("reportHeartbeat: 请求异常 %{public}@", log: self.logger, type: .error, error.localizedDescription) + // 异常回滚时间戳,避免卡 1 小时无法重试 + UserDefaults.standard.set(last, forKey: self.HEARTBEAT_PREFS_KEY) + return + } + guard let data = data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + os_log("reportHeartbeat: 响应解析失败", log: self.logger, type: .error) + UserDefaults.standard.set(last, forKey: self.HEARTBEAT_PREFS_KEY) + return + } + // code 可能是 String 或 NSNumber,统一转成字符串 + let code: String = { + if let s = json["code"] as? String { return s } + if let n = json["code"] as? NSNumber { return n.stringValue } + return "" + }() + let msg = (json["message"] as? String) ?? "" + if code == "200" { + os_log("reportHeartbeat: 上报成功 code=%{public}@ msg=%{public}@", log: self.logger, type: .info, code, msg) + self.emit("heartbeat_reported", data: ["time": Int64(now)]) + } else { + os_log("reportHeartbeat: 上报失败 code=%{public}@ msg=%{public}@", log: self.logger, type: .error, code, msg) + // 上报失败回滚时间戳 + UserDefaults.standard.set(last, forKey: self.HEARTBEAT_PREFS_KEY) + } + }.resume() + } + // MARK: - WebSocket 连接 private func connect() {