From 8d83588e0b159c1ba20ff7c5c98af9e965e63b0c Mon Sep 17 00:00:00 2001 From: km2023 Date: Mon, 18 May 2026 16:38:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E7=BB=91=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=E8=AE=BE=E5=A4=87=20log=20=E5=88=B0=20update?= =?UTF-8?q?-metadata=20=E5=B9=B6=E5=86=85=E8=81=9A=E5=8F=8C=E5=B1=82?= =?UTF-8?q?=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1:1 复刻 iOS commit 4bc8fdb。 - bind(userId) 内聚为硬绑 + getVersion + 软绑 + updateMetadata 一次原子调用, 软绑失败自动硬解绑回滚 + 清状态;集成方无需感知双层绑定 - fetchLatestFirmware 简化为对账上报 + checkUpgrade 两步,不再重复软绑 - VersionInfo 增加 log: JSONObject?,fromJson 用 optJSONObject 解析; SDK 暴露只读 versionLog: StateFlow,断连/解绑/软绑回滚时清空 - ShipmentSnDeviceService 新增 updateMetadata(sn, data: JSONObject), HttpHelper.post 已支持 JSONObject 作为 body 子节点透传 - 老固件不返回 log → versionLog == null → 自动跳过 updateMetadata,前向兼容 - 同步更新 .wiki/tech-architecture.md(状态清理表 + OTA 编排图) + product-decisions.md(新增决策条目)+ README.md(versionLog 状态) Co-Authored-By: Claude Opus 4.7 (1M context) --- .wiki/product-decisions.md | 24 ++++- .wiki/tech-architecture.md | 33 ++++--- README.md | 5 +- .../kotlin/com/duooomi/ble/DuooomiBleSDK.kt | 96 +++++++++++++------ .../com/duooomi/ble/models/VersionInfo.kt | 10 +- .../ble/services/ShipmentSnDeviceService.kt | 23 ++++- 6 files changed, 143 insertions(+), 48 deletions(-) diff --git a/.wiki/product-decisions.md b/.wiki/product-decisions.md index 56ff71b..f0a77a4 100644 --- a/.wiki/product-decisions.md +++ b/.wiki/product-decisions.md @@ -13,7 +13,7 @@ **影响**:集成方升级时构造函数签名报错,必须显式传两个参数。可接受,因为 demo / iOS / Harmony 都同步改了。 -## bind 改瘦 + fetchLatestFirmware 串三步 +## bind 改瘦 + fetchLatestFirmware 串三步(已废弃,见下条) **决策时间**:2026-05-08(commit `2dde274`) @@ -27,6 +27,28 @@ **集成方迁移指南**:bind 之后必须显式调 `fetchLatestFirmware(sn, currentVersion, userId)` 才能拿到固件信息和触发对账。 +## bind 内聚双层绑定 + updateMetadata 上报 + +**决策时间**:2026-05-18(同步 iOS commit `4bc8fdb` 复刻) + +把软绑 + updateMetadata 从 `fetchLatestFirmware` 重新搬回 `bind` 内部,集成方一次调用完成"完整绑定"。 + +| 阶段 | 上一版(2026-05-08) | 本版(2026-05-18) | +|---|---|---| +| `bind(userId)` | BLE 硬绑 + getVersion | BLE 硬绑 + getVersion + **软绑** + **updateMetadata** + 失败回滚 | +| `fetchLatestFirmware(sn, currentVersion, userId)` | 对账 + 软绑 + checkUpgrade | 对账 + checkUpgrade(不再软绑) | + +**原因**: +- 软绑是"绑定"语义的一部分,集成方不该感知双层之分(与 iOS HEAD 对齐) +- updateMetadata 在 bind 时 versionLog 刚新鲜,语义上"绑定时一并上报状态"更连贯 +- 上一版"bind 改瘦"是过度拆分;事实证明拆开反而让集成方容易遗漏软绑 + +**集成方迁移指南**:bind 调用方式不变,但**不再需要**调 `fetchLatestFirmware` 才完成软绑。检查更新仍调 `fetchLatestFirmware`,但它只做对账+checkUpgrade。 + +**新增 SDK 状态**:`versionLog: StateFlow` —— 设备 BLE getVersion 响应中 `log` 字段(任意 JSON 对象,老固件不返回则 null)。bind 时 fire-and-forget 透传到 `POST /api/auth/loomart/shipment-sn/device/update-metadata`。**前向兼容**:老固件 versionLog == null → 自动跳过 updateMetadata。 + +**JSON object 选型**:直接用 `org.json.JSONObject?` 透传(不解析、不转 Map),HttpHelper.post 已支持 `JSONObject` 作为 body 子节点序列化。对应 iOS 的 `[String: Any]?` 方案。 + ## disconnect 时不清 sn / firmwareUpgrade **决策时间**:2026-05-08(commit `0dbfab9`) diff --git a/.wiki/tech-architecture.md b/.wiki/tech-architecture.md index e7d2559..e8736ef 100644 --- a/.wiki/tech-architecture.md +++ b/.wiki/tech-architecture.md @@ -32,9 +32,9 @@ sdk/src/main/kotlin/com/duooomi/ble/ └── BeijingTimeProvider.kt # 三级 fallback 时间获取 ``` -## OTA 三段式编排(核心架构) +## OTA 编排(核心架构 · 2026-05-18 重构) -`bind` 改瘦 + `unbind` 改胖 + `fetchLatestFirmware` 串三步,是与 expo / iOS HEAD 1:1 对齐的核心。**不要回退到"bind 一键搞定全套"的旧设计**。 +`bind` 内聚(硬绑+getVersion+软绑+updateMetadata)+ `fetchLatestFirmware` 瘦身(对账+checkUpgrade)+ `unbind` 改胖(硬解+软解),与 iOS HEAD 1:1 对齐。 ``` ┌──────────────────────────────────────────────────────────────┐ @@ -44,17 +44,21 @@ sdk/src/main/kotlin/com/duooomi/ble/ connect(deviceId) └─ BLE 连接 + 启动协议监听 -bind(userId) ← 只做 BLE 硬绑 + getVersion - ├─ sendAndWait(BIND_DEVICE) payload = {type, userId, time} - ├─ 写入 sdk.sn / sdk.isActivated - └─ runCatching { getVersion() } // 失败不阻断 +bind(userId) ← 完整绑定(4 步内部编排,对集成方原子) + ├─ Step 1: verifyBrand + ├─ Step 2: sendAndWait(BIND_DEVICE) BLE 硬绑 0x0F + │ └─ 写 sdk.sn / sdk.isActivated + ├─ Step 3: runCatching { getVersion() } // 失败不阻断 + │ └─ 写 sdk.version / sdk.versionLog + ├─ Step 4: shipmentService.bind(sn, userId) 软绑 + │ └─ 失败 → 自动硬解回滚 + 清 sn/isActivated/versionLog → 整体失败抛 SoftBindFailed + └─ Step 5: scope.launch { updateMetadata(sn, versionLog) } (fire-and-forget) + └─ 仅当 versionLog 非 null 且非空对象;失败仅 warn -fetchLatestFirmware(sn, currentVersion, userId) ← 三步编排 +fetchLatestFirmware(sn, currentVersion, userId) ← 两步编排 ├─ Step 1: tryReportPendingUpgrade(...) (fire-and-forget) │ └─ 把本地 pending UpgradeRecord 报给服务端 - ├─ Step 2: shipmentService.bind(sn, userId) 软绑 - │ └─ 失败 → 自动硬解回滚 + 清 sn / isActivated - └─ Step 3: firmwareUpgradeService.checkUpgrade(sn, currentVersion) + └─ Step 2: firmwareUpgradeService.checkUpgrade(sn, currentVersion) └─ 写 sdk.firmwareUpgrade upgradeFirmware(sn, fileUrl, firmwareId, fromVersion, targetVersion) @@ -64,7 +68,7 @@ upgradeFirmware(sn, fileUrl, firmwareId, fromVersion, targetVersion) unbind(userId) ← 硬解 + 软解(容错) ├─ 缓存 cachedSn = sdk.sn ├─ sendAndWait(UNBIND_DEVICE) - ├─ 写 sdk.isActivated = false / sdk.sn = "" + ├─ 写 sdk.isActivated = false / sdk.sn = "" / sdk.versionLog = null └─ if cachedSn 非空 → shipmentService.unbind(...)(失败仅 warn) ``` @@ -74,10 +78,11 @@ unbind(userId) ← 硬解 + 软解(容错) | 状态字段 | 写入时机 | 清理时机 | |---|---|---| -| `sn` | bind 成功(BindingResponse.sn) | unbind 成功;fetchLatestFirmware 软绑失败回滚 | +| `sn` | bind 硬绑成功(BindingResponse.sn) | unbind 成功;**bind 软绑失败回滚** | | `firmwareUpgrade` | fetchLatestFirmware 成功 | (**从不主动清**) | -| `isActivated` | bind 成功(true) / unbind 成功(false) / 软绑失败回滚(false) | disconnect handler / 主动 disconnect | +| `isActivated` | bind 硬绑成功(true) / unbind 成功(false) / **bind 软绑失败回滚(false)** | disconnect handler / 主动 disconnect | | `version` | getVersion / bind | disconnect handler / 主动 disconnect | +| `versionLog` | getVersion 成功(log 非 null) | unbind 成功 / **bind 软绑失败回滚** / disconnect handler / 主动 disconnect | | `deviceInfo` | getDeviceInfo / setPlayMode 后自动刷新 | disconnect handler / 主动 disconnect | | `connectedDevice` | connect 成功 | disconnect handler / 主动 disconnect | | `transferProgress` | transferFile 进度回调 | (持续写,无显式清) | @@ -89,6 +94,8 @@ unbind(userId) ← 硬解 + 软解(容错) 之前 Android 这两个字段在 disconnect 时被清,与 iOS 不一致;commit `0dbfab9` 修正。 +⚠️ **2026-05-18 重构**:把软绑从 `fetchLatestFirmware` 搬到 `bind` 内部。理由:软绑是"绑定"语义的一部分,不该让"检查更新"承担。同步新增 `versionLog` 状态 + `updateMetadata` 上报。详见 `product-decisions.md`。 + ## sn 级 in-flight 互斥锁(UpgradeRecordStore) `tryReportPendingUpgrade` 是 fire-and-forget。如果 demo 短时间内连续两次调 `fetchLatestFirmware`(用户狂点),会出现两个协程并行处理同一 sn 的 pending records 重复 markReported。 diff --git a/README.md b/README.md index ab6022d..069a002 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ sdk.discoveredDevices // List sdk.connectedDevice // DiscoveredDevice? sdk.deviceInfo // DeviceInfo? (含可选 loop: PlayMode?) sdk.version // String (设备固件版本) +sdk.versionLog // JSONObject? (设备 log 字段;bind 时透传到 update-metadata) sdk.isActivated // Boolean (是否已 bind) sdk.sn // String (绑定后写入设备序列号) sdk.firmwareUpgrade // FirmwareUpgradeCheckResult? (最新一次拉取结果) @@ -151,7 +152,9 @@ val info = sdk.getDeviceInfo() val versionInfo = sdk.getVersion() // versionInfo.version / versionInfo.type -// 绑定设备 —— 完成后写入 sdk.sn / sdk.version / sdk.isActivated +// 绑定设备 —— 一次调用完成 BLE 硬绑 + getVersion + 软绑 + metadata 上报 +// 完成后写入 sdk.sn / sdk.version / sdk.versionLog / sdk.isActivated +// 软绑失败 → 自动硬解绑回滚 + 抛 SoftBindFailed val bindResult = sdk.bind(userId) // bindResult.sn / bindResult.contents (设备文件列表) diff --git a/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt b/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt index 9349919..55f320e 100644 --- a/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt +++ b/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt @@ -33,6 +33,13 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) { private val _version = MutableStateFlow("") val version: StateFlow = _version.asStateFlow() + /** + * 设备返回的 `log` 字段(任意 JSON 对象)。`getVersion` 成功时写入,断连 / 解绑 / 软绑回滚时清空。 + * `bind` 软绑成功后透传给 shipment-sn `update-metadata`,集成方一般无需直接读。 + */ + private val _versionLog = MutableStateFlow(null) + val versionLog: StateFlow = _versionLog.asStateFlow() + private val _isActivated = MutableStateFlow(false) val isActivated: StateFlow = _isActivated.asStateFlow() @@ -107,12 +114,13 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) { _connectedDevice.value = null _deviceInfo.value = null _version.value = "" + _versionLog.value = null _isActivated.value = false _btState.value = ConnectionState.DISCONNECTED protocolService.stopListening() cancelAllPending(DuooomiBleError.NotConnected) // 与 iOS 一致:sn / firmwareUpgrade 不在断开时重置 - // —— sn 仅在 unbind 成功 / fetchLatestFirmware 软绑失败回滚时清空 + // —— sn 仅在 unbind 成功 / bind 软绑失败回滚时清空 // —— firmwareUpgrade 仅在 fetchLatestFirmware 成功时覆盖 } } @@ -206,6 +214,7 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) { _connectedDevice.value = null _deviceInfo.value = null _version.value = "" + _versionLog.value = null _isActivated.value = false _btState.value = ConnectionState.DISCONNECTED BleLog.i("Disconnected", "Connect") @@ -241,23 +250,32 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) { } val info = VersionInfo.fromJson(parseJsonResponse(data)) _version.value = info.version - BleLog.i("Version received: ${info.version} (type=${info.type})", "Command") + _versionLog.value = info.log + BleLog.i( + "Version received: ${info.version} (type=${info.type}, log=${if (info.log != null) "present" else "nil"})", + "Command" + ) return info } /** - * 硬绑:BLE 协议层把当前用户绑定到设备(命令 0x0F),完成后同步拉一次版本号。 + * 完整绑定:内部串四步业务编排(顺序固定),对集成方表现为一次原子调用。 * - * **作用域**:只做 BLE 通道的绑定 + [getVersion]。 - * 软绑 / 升级对账上报 / 拉最新固件 → 全部移到 [fetchLatestFirmware] 内部串。 + * 1. **BLE 硬绑**(命令 0x0F):写 [sn] / [isActivated] + * 2. **getVersion**:写 [version] / [versionLog](log 为任意 JSON 对象,缺失则 null) + * 3. **软绑**:POST `/api/auth/loomart/shipment-sn/device/bind`,登记云端绑定关系 + * - 软绑失败 → 自动硬解绑(BLE 0x12)回滚 + 重置 `isActivated`/`sn`/`versionLog`,bind 整体失败 + * 4. **updateMetadata**(fire-and-forget):把 `versionLog` 透传给 `shipment-sn/device/update-metadata` + * - 失败只 warn 不阻断;versionLog 为 null / 空对象则跳过 * * 完成后: * - [sn] 写入设备序列号 - * - [version] 写入当前固件版本(getVersion 失败时保持空,但 bind 不算失败) + * - [version] / [versionLog] 写入当前设备状态(getVersion 失败时保持空,但 bind 不算失败) * - [isActivated] = true * * @throws DuooomiBleError.NotConnected 未连接 * @throws DuooomiBleError.AlreadyBoundByOtherUser BLE 层 success != 1(设备已绑给他人) + * @throws DuooomiBleError.SoftBindFailed 软绑失败(含硬解绑回滚后) */ suspend fun bind(userId: String): BindingResponse { ensureConnected() @@ -274,8 +292,43 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) { } _sn.value = resp.sn BleLog.i("Hard bind success: sn=${resp.sn}", "Command") - // 同步拉版本,让后续 fetchLatestFirmware 的对账上报有 currentVersion 可用 + + // Step 2: 同步拉版本(写 version / versionLog 供软绑后的 updateMetadata 透传) runCatching { getVersion() } + + // Step 3: 软绑(失败 → 硬解绑回滚 + 清状态 + 整体失败) + val bindSn = resp.sn + BleLog.d("Soft bind start: sn=$bindSn", "Command") + try { + shipmentService.bind(sn = bindSn, bindUserId = userId) + } catch (e: Exception) { + BleLog.e("Soft bind failed: ${e.message}; rolling back hard unbind", "Command") + runCatching { + deviceInfoService.unbindDevice(userId) + }.onFailure { rb -> + BleLog.w("Hard unbind rollback failed (ignored): ${rb.message}", "Command") + } + _isActivated.value = false + _sn.value = "" + _versionLog.value = null + throw e + } + BleLog.i("Soft bind success: sn=$bindSn", "Command") + + // Step 4: updateMetadata(fire-and-forget;versionLog 缺失/空则跳过) + val log = _versionLog.value + if (log != null && log.length() > 0) { + scope.launch { + runCatching { + shipmentService.updateMetadata(sn = bindSn, data = log) + }.onSuccess { + BleLog.i("updateMetadata success: sn=$bindSn", "Command") + }.onFailure { e -> + BleLog.w("updateMetadata failed (ignored): ${e.message}", "Command") + } + } + } + return resp } @@ -328,6 +381,7 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) { } _isActivated.value = false _sn.value = "" + _versionLog.value = null BleLog.i("Hard unbind success", "Command") if (cachedSn.isNotEmpty()) { runCatching { @@ -469,12 +523,12 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) { } /** - * 拉取服务端最新固件信息。**内部串三步业务编排**(顺序固定): + * 拉取服务端最新固件信息。**内部串两步业务编排**(顺序固定): * * 1. **对账上报**:把本地 pending [UpgradeRecord] 逐条报给服务端(容错失败,不阻断) - * 2. **软绑**:POST `/api/auth/loomart/shipment-sn/device/bind`,登记云端绑定关系 - * - 软绑失败 → 自动硬解绑(BLE 0x12)回滚 + 重置 `isActivated`/`sn`,整个 fetch 失败 - * 3. **拉服务端最新固件**:写入 [firmwareUpgrade],通过返回值回传 + * 2. **拉服务端最新固件**:写入 [firmwareUpgrade],通过返回值回传 + * + * 软绑 / metadata 上报 / 硬绑失败回滚 → 已全部内聚到 [bind],本方法不再处理。 */ suspend fun fetchLatestFirmware( sn: String, @@ -487,25 +541,7 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) { // Step 1: 对账上报历史 OTA 记录(容错失败,sn 级 in-flight 互斥) tryReportPendingUpgrade(sn = sn, currentVersion = currentVersion, userId = userId) - // Step 2: 软绑(shipment-sn HTTP) - BleLog.d("Soft bind start: sn=$sn", "Firmware") - try { - shipmentService.bind(sn = sn, bindUserId = userId) - } catch (e: Exception) { - BleLog.e("Soft bind failed during fetchLatestFirmware: ${e.message}; rolling back hard unbind", "Firmware") - // 硬解绑回滚(fire-and-forget;BLE ack 不影响本次 fetch 错误回传) - runCatching { - deviceInfoService.unbindDevice(userId) - }.onFailure { rb -> - BleLog.w("Hard unbind rollback failed (ignored): ${rb.message}", "Firmware") - } - _isActivated.value = false - _sn.value = "" - throw e - } - BleLog.i("Soft bind success: sn=$sn", "Firmware") - - // Step 3: 服务端 checkUpgrade + // Step 2: 服务端 checkUpgrade val result = firmwareUpgradeService.checkUpgrade(sn = sn, currentVersion = currentVersion) _firmwareUpgrade.value = result return result diff --git a/sdk/src/main/kotlin/com/duooomi/ble/models/VersionInfo.kt b/sdk/src/main/kotlin/com/duooomi/ble/models/VersionInfo.kt index 5288812..b6975e5 100644 --- a/sdk/src/main/kotlin/com/duooomi/ble/models/VersionInfo.kt +++ b/sdk/src/main/kotlin/com/duooomi/ble/models/VersionInfo.kt @@ -2,9 +2,14 @@ package com.duooomi.ble.models import org.json.JSONObject +/** + * @param log 设备返回的 `log` 字段(任意 JSON 对象),缺失/非对象时为 null。 + * SDK 不解析其内部结构,原样透传给 `update-metadata` 接口。 + */ data class VersionInfo( val version: String, - val type: String + val type: String, + val log: JSONObject? = null ) { companion object { fun fromJson(json: JSONObject): VersionInfo { @@ -15,7 +20,8 @@ data class VersionInfo( } return VersionInfo( version = json.optString("version", ""), - type = typeValue + type = typeValue, + log = json.optJSONObject("log") ) } } diff --git a/sdk/src/main/kotlin/com/duooomi/ble/services/ShipmentSnDeviceService.kt b/sdk/src/main/kotlin/com/duooomi/ble/services/ShipmentSnDeviceService.kt index 04b01bc..fcddfed 100644 --- a/sdk/src/main/kotlin/com/duooomi/ble/services/ShipmentSnDeviceService.kt +++ b/sdk/src/main/kotlin/com/duooomi/ble/services/ShipmentSnDeviceService.kt @@ -4,9 +4,11 @@ import com.duooomi.ble.DuooomiBleConfig import com.duooomi.ble.core.DuooomiBleError import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.json.JSONObject /** - * 服务端 shipment-sn 软绑/软解绑:本地 BLE 硬绑成功后由 SDK 内部调用,集成方无需手动调起。 + * 服务端 shipment-sn 软绑/软解绑 / metadata 上报:本地 BLE 硬绑成功后由 SDK 内部调用, + * 集成方无需手动调起。 */ internal class ShipmentSnDeviceService(private val config: DuooomiBleConfig) { @@ -41,4 +43,23 @@ internal class ShipmentSnDeviceService(private val config: DuooomiBleConfig) { } Unit } + + /** + * 上报设备运行 metadata。`data` 是任意 JSON 对象(典型场景:BLE getVersion 返回的 log 字段), + * SDK 不做 schema 校验,原样透传。2xx 即视为成功。 + */ + suspend fun updateMetadata(sn: String, data: JSONObject) = withContext(Dispatchers.IO) { + try { + HttpHelper.post( + config = config, + path = "/api/auth/loomart/shipment-sn/device/update-metadata", + body = mapOf("sn" to sn, "data" to data) + ) + } catch (e: HttpHelper.HttpError) { + throw DuooomiBleError.SoftBindFailed(e.message ?: "HTTP ${e.code}") + } catch (e: Exception) { + throw DuooomiBleError.SoftBindFailed(e.message ?: "unknown") + } + Unit + } }