feat(sdk): OTA 升级流程对齐 expo + 拆 extension 文件分层 + 秘钥本地化
主要改动: - 新增 fetchLatestFirmware/upgradeFirmware/tryReportPendingUpgrade 三段 OTA 接口,对齐 expo bindDeviceWithOrchestration 行为 - bind 仅做硬绑+getVersion,软绑/对账上报/拉最新固件搬到 fetchLatestFirmware 内部串;软绑失败自动硬解回滚 - UpgradeRecord 复合主键 (sn, firmwareId) 持久化 + sn 级 in-flight 互斥 - DuooomiBleSDKDelegate 新增 didUpdateUpgradeRecords,集成方 可观察对账上报结果(reported/outcome 状态) - DuooomiBleSDK 主类按职责拆 5 个 extension 文件,公开 API 不变 - TargetFirmware 简化为只取必要字段 + 新增 forceUpgrade - scanNamePrefix 改为必填 String - demo 秘钥移至本地 DemoSecrets.swift (gitignored) - README 重写:完整 OTA 流程示例 + 上报状态判定表 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,11 @@ import Foundation
|
||||
/// 设备命令服务:发送 JSON 命令(fire-and-forget,响应由协议层回调处理)
|
||||
final class DeviceInfoService {
|
||||
private let protocolService: BleProtocolService
|
||||
private let timeProvider: BeijingTimeProvider
|
||||
|
||||
init(protocolService: BleProtocolService) {
|
||||
init(protocolService: BleProtocolService, timeProvider: BeijingTimeProvider) {
|
||||
self.protocolService = protocolService
|
||||
self.timeProvider = timeProvider
|
||||
}
|
||||
|
||||
// MARK: - Commands
|
||||
@@ -24,30 +26,66 @@ final class DeviceInfoService {
|
||||
)
|
||||
}
|
||||
|
||||
func bindDevice(userId: String) throws {
|
||||
try protocolService.sendJSON(
|
||||
type: .bindDevice,
|
||||
payload: BindPayload(type: CommandType.bindDevice.rawValue, userId: userId, loop: nil)
|
||||
)
|
||||
/// 绑定:先取北京时间再发命令,与 RN 行为对齐。
|
||||
func bindDevice(userId: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
timeProvider.fetch { [weak self] time in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
try self.protocolService.sendJSON(
|
||||
type: .bindDevice,
|
||||
payload: BindPayload(
|
||||
type: CommandType.bindDevice.rawValue,
|
||||
userId: userId,
|
||||
loop: nil,
|
||||
time: time
|
||||
)
|
||||
)
|
||||
completion(.success(()))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func unbindDevice(userId: String) throws {
|
||||
try protocolService.sendJSON(
|
||||
type: .unbindDevice,
|
||||
payload: BindPayload(type: CommandType.unbindDevice.rawValue, userId: userId, loop: nil)
|
||||
)
|
||||
func unbindDevice(userId: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
timeProvider.fetch { [weak self] time in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
try self.protocolService.sendJSON(
|
||||
type: .unbindDevice,
|
||||
payload: BindPayload(
|
||||
type: CommandType.unbindDevice.rawValue,
|
||||
userId: userId,
|
||||
loop: nil,
|
||||
time: time
|
||||
)
|
||||
)
|
||||
completion(.success(()))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换播放模式(复用 BIND_DEVICE 命令字 0x0F,payload 携带 loop 字段)
|
||||
func setPlayMode(userId: String, loop: PlayMode) throws {
|
||||
try protocolService.sendJSON(
|
||||
type: .bindDevice,
|
||||
payload: BindPayload(
|
||||
type: CommandType.bindDevice.rawValue,
|
||||
userId: userId,
|
||||
loop: UInt8(loop.rawValue)
|
||||
)
|
||||
)
|
||||
func setPlayMode(userId: String, loop: PlayMode, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
timeProvider.fetch { [weak self] time in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
try self.protocolService.sendJSON(
|
||||
type: .bindDevice,
|
||||
payload: BindPayload(
|
||||
type: CommandType.bindDevice.rawValue,
|
||||
userId: userId,
|
||||
loop: UInt8(loop.rawValue),
|
||||
time: time
|
||||
)
|
||||
)
|
||||
completion(.success(()))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteFile(key: String) throws {
|
||||
@@ -77,19 +115,22 @@ private struct CommandPayload: Encodable {
|
||||
|
||||
/// 绑定 / 解绑 / 切模式 共用 payload。
|
||||
/// `loop == nil` 时严格省略该字段(不写 null),与 Expo 端 zod.optional 行为对齐。
|
||||
/// `time` 为北京时间 ISO 8601 字符串。
|
||||
struct BindPayload: Encodable {
|
||||
let type: UInt8
|
||||
let userId: String
|
||||
let loop: UInt8?
|
||||
let time: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type, userId, loop
|
||||
case type, userId, loop, time
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(type, forKey: .type)
|
||||
try container.encode(userId, forKey: .userId)
|
||||
try container.encode(time, forKey: .time)
|
||||
if let loop = loop {
|
||||
try container.encode(loop, forKey: .loop)
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// 固件信息
|
||||
public struct FirmwareInfo: Codable, Equatable {
|
||||
public let version: String
|
||||
public let fileUrl: String
|
||||
public let description: String?
|
||||
public let fileSize: String?
|
||||
public let fileMd5: String?
|
||||
public let identifier: String?
|
||||
public let status: String?
|
||||
}
|
||||
|
||||
struct FirmwareResponse: Codable {
|
||||
let success: Bool
|
||||
let data: FirmwareInfo?
|
||||
}
|
||||
|
||||
/// 固件服务内部实现
|
||||
final class FirmwareService {
|
||||
private let config: DuooomiBleConfig
|
||||
private let latestPath = "api/auth/loomart/firmware/latest-published"
|
||||
|
||||
init(config: DuooomiBleConfig) {
|
||||
self.config = config
|
||||
}
|
||||
|
||||
func fetchLatest(
|
||||
identifier: String? = nil,
|
||||
status: String? = nil,
|
||||
completion: @escaping (Result<FirmwareInfo?, Error>) -> Void
|
||||
) {
|
||||
let id = (identifier ?? config.firmwareIdentifier).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let st = status ?? config.firmwareStatus
|
||||
guard !id.isEmpty else {
|
||||
completion(.failure(DuooomiBleError.transferFailed("Invalid firmware identifier")))
|
||||
return
|
||||
}
|
||||
|
||||
let urlString = "\(config.apiHost)/\(latestPath)"
|
||||
guard var comps = URLComponents(string: urlString) else {
|
||||
completion(.failure(DuooomiBleError.transferFailed("Invalid firmware URL")))
|
||||
return
|
||||
}
|
||||
comps.queryItems = [
|
||||
URLQueryItem(name: "identifier", value: id),
|
||||
URLQueryItem(name: "status", value: st)
|
||||
]
|
||||
guard let url = comps.url else {
|
||||
completion(.failure(DuooomiBleError.transferFailed("Invalid firmware URL")))
|
||||
return
|
||||
}
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "GET"
|
||||
req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key")
|
||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||
|
||||
URLSession.shared.dataTask(with: req) { data, resp, error in
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
guard let http = resp as? HTTPURLResponse else {
|
||||
completion(.failure(DuooomiBleError.transferFailed("Invalid response")))
|
||||
return
|
||||
}
|
||||
guard (200...299).contains(http.statusCode) else {
|
||||
completion(.failure(DuooomiBleError.transferFailed("HTTP \(http.statusCode)")))
|
||||
return
|
||||
}
|
||||
guard let data = data else {
|
||||
completion(.failure(DuooomiBleError.transferFailed("Empty response")))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(FirmwareResponse.self, from: data)
|
||||
completion(.success(decoded.data))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
171
Sources/DuooomiBleSDK/Services/FirmwareUpgradeService.swift
Normal file
171
Sources/DuooomiBleSDK/Services/FirmwareUpgradeService.swift
Normal file
@@ -0,0 +1,171 @@
|
||||
import Foundation
|
||||
|
||||
/// 固件升级 RPC:检查升级 / 升级成功上报 / 升级失败上报。
|
||||
/// 与 RN `FirmwareController.{checkUpgrade, reportUpgradeSuccessBySn, reportUpgradeFailureBySn}` 1:1 对齐。
|
||||
final class FirmwareUpgradeService {
|
||||
private let config: DuooomiBleConfig
|
||||
private let session: URLSession
|
||||
|
||||
init(config: DuooomiBleConfig, session: URLSession = .shared) {
|
||||
self.config = config
|
||||
self.session = session
|
||||
}
|
||||
|
||||
/// 由服务端判断是否需要升级。
|
||||
func checkUpgrade(
|
||||
sn: String,
|
||||
currentVersion: String?,
|
||||
completion: @escaping (Result<FirmwareUpgradeCheckResult, Error>) -> Void
|
||||
) {
|
||||
var body: [String: String] = ["sn": sn]
|
||||
if let v = currentVersion, !v.isEmpty { body["currentVersion"] = v }
|
||||
post(
|
||||
path: "/api/auth/loomart/firmware/upgrade/check",
|
||||
body: body
|
||||
) { result in
|
||||
switch result {
|
||||
case .failure(let e): completion(.failure(e))
|
||||
case .success(let data):
|
||||
do {
|
||||
let parsed = try JSONDecoder().decode(FirmwareUpgradeCheckResult.self, from: data)
|
||||
completion(.success(parsed))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reportUpgradeSuccess(
|
||||
sn: String,
|
||||
firmwareId: String,
|
||||
fromVersion: String,
|
||||
bindUserId: String?,
|
||||
completion: @escaping (Result<Void, Error>) -> Void
|
||||
) {
|
||||
var body: [String: String] = [
|
||||
"sn": sn,
|
||||
"firmwareId": firmwareId,
|
||||
"fromVersion": fromVersion,
|
||||
]
|
||||
if let u = bindUserId, !u.isEmpty { body["bindUserId"] = u }
|
||||
post(path: "/api/auth/loomart/firmware/upgrade/report-success", body: body) { result in
|
||||
completion(result.map { _ in () })
|
||||
}
|
||||
}
|
||||
|
||||
func reportUpgradeFailure(
|
||||
sn: String,
|
||||
firmwareId: String,
|
||||
fromVersion: String,
|
||||
failureReason: String,
|
||||
completion: @escaping (Result<Void, Error>) -> Void
|
||||
) {
|
||||
let body = [
|
||||
"sn": sn,
|
||||
"firmwareId": firmwareId,
|
||||
"fromVersion": fromVersion,
|
||||
"failureReason": failureReason,
|
||||
]
|
||||
post(path: "/api/auth/loomart/firmware/upgrade/report-failure", body: body) { result in
|
||||
completion(result.map { _ in () })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HTTP
|
||||
|
||||
private func post(
|
||||
path: String,
|
||||
body: [String: String],
|
||||
completion: @escaping (Result<Data, Error>) -> Void
|
||||
) {
|
||||
guard let url = URL(string: config.apiHost.absoluteString + path) else {
|
||||
completion(.failure(DuooomiBleError.transferFailed("URL 拼装失败")))
|
||||
return
|
||||
}
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.timeoutInterval = 10
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key")
|
||||
req.setValue(config.owner, forHTTPHeaderField: "x-owner")
|
||||
do {
|
||||
req.httpBody = try JSONEncoder().encode(body)
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
session.dataTask(with: req) { data, resp, error in
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
guard let http = resp as? HTTPURLResponse else {
|
||||
completion(.failure(DuooomiBleError.transferFailed("无响应")))
|
||||
return
|
||||
}
|
||||
guard (200...299).contains(http.statusCode) else {
|
||||
let msg = Self.parseErrorMessage(data: data) ?? "HTTP \(http.statusCode)"
|
||||
completion(.failure(DuooomiBleError.transferFailed(msg)))
|
||||
return
|
||||
}
|
||||
// 后端响应 `{ success, data }` 包装:unwrap 后透传 data 字段
|
||||
if let data = data, let unwrapped = Self.unwrapData(from: data) {
|
||||
completion(.success(unwrapped))
|
||||
} else {
|
||||
completion(.success(data ?? Data()))
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private static func unwrapData(from raw: Data) -> Data? {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: raw) as? [String: Any],
|
||||
json["success"] as? Bool == true,
|
||||
let inner = json["data"] else {
|
||||
return nil
|
||||
}
|
||||
return try? JSONSerialization.data(withJSONObject: inner)
|
||||
}
|
||||
|
||||
private static func parseErrorMessage(data: Data?) -> String? {
|
||||
guard let data = data, let raw = String(data: data, encoding: .utf8) else { return nil }
|
||||
var stripped = raw
|
||||
if stripped.hasPrefix("API error:") {
|
||||
stripped = String(stripped.dropFirst("API error:".count)).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
if stripped.hasPrefix("{"),
|
||||
let json = try? JSONSerialization.jsonObject(with: Data(stripped.utf8)) as? [String: Any] {
|
||||
if let msg = json["message"] as? String { return msg }
|
||||
if let err = json["error"] as? [String: Any], let msg = err["message"] as? String { return msg }
|
||||
}
|
||||
return stripped.isEmpty ? nil : stripped
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Response Model
|
||||
|
||||
/// 服务端 `/firmware/upgrade/check` 响应。
|
||||
/// **判断能否升级看 `targetFirmware` 是否非空**——SDK 不再暴露 `upgradeAvailable` / `reason`
|
||||
/// 这种 client 端"判断"语义,回归"拉取最新固件信息"单一职责。
|
||||
public struct FirmwareUpgradeCheckResult: Codable, Equatable {
|
||||
public let currentSystemVersion: String?
|
||||
public let targetFirmware: TargetFirmware?
|
||||
|
||||
public struct TargetFirmware: Codable, Equatable {
|
||||
/// 固件 ID。`UpgradeRecord.firmwareId` 来源、对账上报必带。
|
||||
public let id: String
|
||||
/// 固件版本字符串(如 "1.2.3")。UI 展示「最新版本」+ 落 `UpgradeRecord.targetVersion`。
|
||||
public let version: String
|
||||
/// 固件包下载 URL。烧录入参 `upgradeFirmware(fileUrl:)`。
|
||||
public let fileUrl: String?
|
||||
/// 固件包字节数(字符串编码,避免 Int64 溢出)。可用于 UI 显示「需要下载 xx MB」。
|
||||
public let fileSize: String?
|
||||
/// 固件包 MD5。集成方有需要可校验下载内容;SDK 不做校验。
|
||||
public let fileMd5: String?
|
||||
/// 强制升级标记。`true` 表示该版本必须升级(典型场景:安全补丁 / 协议破坏性变更),
|
||||
/// 集成方应据此弹不可关闭的升级弹窗、阻断设备其他操作;`false` / `nil` 由用户自行决定。
|
||||
/// 服务端可能不返回此字段(旧固件版本),用 Optional 做 Codable 兜底。
|
||||
public let forceUpgrade: Bool?
|
||||
}
|
||||
}
|
||||
99
Sources/DuooomiBleSDK/Services/ShipmentSnDeviceService.swift
Normal file
99
Sources/DuooomiBleSDK/Services/ShipmentSnDeviceService.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
|
||||
/// 服务端 shipment-sn 软绑/软解绑:本地 BLE 硬绑成功后由 SDK 内部调用,集成方无需手动调起。
|
||||
final class ShipmentSnDeviceService {
|
||||
private let config: DuooomiBleConfig
|
||||
private let session: URLSession
|
||||
|
||||
init(config: DuooomiBleConfig, session: URLSession = .shared) {
|
||||
self.config = config
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func bind(
|
||||
sn: String,
|
||||
bindUserId: String,
|
||||
completion: @escaping (Result<Void, Error>) -> Void
|
||||
) {
|
||||
post(
|
||||
path: "/api/auth/loomart/shipment-sn/device/bind",
|
||||
body: ["sn": sn, "bindUserId": bindUserId],
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
func unbind(
|
||||
sn: String,
|
||||
bindUserId: String? = nil,
|
||||
completion: @escaping (Result<Void, Error>) -> Void
|
||||
) {
|
||||
var body: [String: String] = ["sn": sn]
|
||||
if let bindUserId = bindUserId, !bindUserId.isEmpty {
|
||||
body["bindUserId"] = bindUserId
|
||||
}
|
||||
post(
|
||||
path: "/api/auth/loomart/shipment-sn/device/unbind",
|
||||
body: body,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
private func post(
|
||||
path: String,
|
||||
body: [String: String],
|
||||
completion: @escaping (Result<Void, Error>) -> Void
|
||||
) {
|
||||
guard let url = URL(string: config.apiHost.absoluteString + path) else {
|
||||
completion(.failure(DuooomiBleError.softBindFailed("URL 拼装失败")))
|
||||
return
|
||||
}
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.timeoutInterval = 10
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key")
|
||||
req.setValue(config.owner, forHTTPHeaderField: "x-owner")
|
||||
|
||||
do {
|
||||
req.httpBody = try JSONEncoder().encode(body)
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
session.dataTask(with: req) { data, resp, error in
|
||||
if let error = error {
|
||||
completion(.failure(DuooomiBleError.softBindFailed(error.localizedDescription)))
|
||||
return
|
||||
}
|
||||
guard let http = resp as? HTTPURLResponse else {
|
||||
completion(.failure(DuooomiBleError.softBindFailed("无响应")))
|
||||
return
|
||||
}
|
||||
guard (200...299).contains(http.statusCode) else {
|
||||
let message = Self.parseErrorMessage(data: data) ?? "HTTP \(http.statusCode)"
|
||||
completion(.failure(DuooomiBleError.softBindFailed(message)))
|
||||
return
|
||||
}
|
||||
// 后端响应 `{ success, data }`;2xx 即视为成功,不强制解 data 字段。
|
||||
completion(.success(()))
|
||||
}.resume()
|
||||
}
|
||||
|
||||
/// 剥离 `API error: ` 前缀,必要时从 JSON 中抽 `message` 字段,与 RN 端 stripApiErrorPrefix 行为对齐。
|
||||
private static func parseErrorMessage(data: Data?) -> String? {
|
||||
guard let data = data, let raw = String(data: data, encoding: .utf8) else { return nil }
|
||||
var stripped = raw
|
||||
if stripped.hasPrefix("API error:") {
|
||||
stripped = String(stripped.dropFirst("API error:".count)).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
if stripped.hasPrefix("{"),
|
||||
let json = try? JSONSerialization.jsonObject(with: Data(stripped.utf8)) as? [String: Any] {
|
||||
if let msg = json["message"] as? String { return msg }
|
||||
if let err = json["error"] as? [String: Any], let msg = err["message"] as? String { return msg }
|
||||
}
|
||||
return stripped.isEmpty ? nil : stripped
|
||||
}
|
||||
}
|
||||
111
Sources/DuooomiBleSDK/Services/UpgradeRecordStore.swift
Normal file
111
Sources/DuooomiBleSDK/Services/UpgradeRecordStore.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
|
||||
/// OTA 升级记录持久化存储。与 RN `bleStore.upgradeRecords` 1:1 对齐:
|
||||
/// - 复合主键 `(sn, firmwareId)` 的 upsert
|
||||
/// - 30 天 TTL:过期但保留为审计轨迹(标记 `reported=true, outcome=nil`)
|
||||
/// - sn 级 in-flight 互斥锁,防止并发重复上报
|
||||
final class UpgradeRecordStore {
|
||||
private static let storageKey = "duooomi.firmware_upgrade_records"
|
||||
/// 30 天毫秒
|
||||
static let ttlMillis: Int64 = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let queue = DispatchQueue(label: "duooomi.upgradeRecord", attributes: .concurrent)
|
||||
private var records: [UpgradeRecord]
|
||||
private var inFlight: Set<String> = []
|
||||
|
||||
/// mutation 后主线程回调当前快照(SDK 用它通过 delegate 通知集成方)。
|
||||
var onChange: (([UpgradeRecord]) -> Void)?
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
if let data = defaults.data(forKey: Self.storageKey),
|
||||
let decoded = try? JSONDecoder().decode([UpgradeRecord].self, from: data) {
|
||||
self.records = decoded
|
||||
} else {
|
||||
self.records = []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Read
|
||||
|
||||
var all: [UpgradeRecord] {
|
||||
queue.sync { records }
|
||||
}
|
||||
|
||||
/// 取该 sn 下所有未上报的记录,按 triggeredAt 升序(旧→新)。
|
||||
func pending(sn: String) -> [UpgradeRecord] {
|
||||
queue.sync {
|
||||
records.filter { $0.sn == sn && !$0.reported }
|
||||
.sorted { $0.triggeredAt < $1.triggeredAt }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mutation
|
||||
|
||||
/// 复合主键 `(sn, firmwareId)`:已存在 → 覆盖并复位 reported/outcome;不存在 → 追加。
|
||||
func upsert(_ record: UpgradeRecord) {
|
||||
guard !record.sn.isEmpty, !record.firmwareId.isEmpty else { return }
|
||||
queue.async(flags: .barrier) {
|
||||
if let idx = self.records.firstIndex(where: { $0.sn == record.sn && $0.firmwareId == record.firmwareId }) {
|
||||
self.records[idx] = record
|
||||
} else {
|
||||
self.records.append(record)
|
||||
}
|
||||
self.persistLocked()
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记某条记录已上报(线程安全)。
|
||||
func markReported(sn: String, firmwareId: String, outcome: UpgradeOutcome?) {
|
||||
queue.async(flags: .barrier) {
|
||||
guard let idx = self.records.firstIndex(where: { $0.sn == sn && $0.firmwareId == firmwareId }) else { return }
|
||||
let original = self.records[idx]
|
||||
self.records[idx] = UpgradeRecord(
|
||||
sn: original.sn,
|
||||
firmwareId: original.firmwareId,
|
||||
fromVersion: original.fromVersion,
|
||||
targetVersion: original.targetVersion,
|
||||
triggeredAt: original.triggeredAt,
|
||||
reported: true,
|
||||
reportedAt: Self.nowMillis(),
|
||||
outcome: outcome
|
||||
)
|
||||
self.persistLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - In-flight Mutex (sn 级)
|
||||
|
||||
/// 尝试占用 sn 互斥锁。返回 false 表示已有进行中的上报,调用方应直接 return。
|
||||
func tryAcquire(sn: String) -> Bool {
|
||||
queue.sync(flags: .barrier) { () -> Bool in
|
||||
if inFlight.contains(sn) { return false }
|
||||
inFlight.insert(sn)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func release(sn: String) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.inFlight.remove(sn)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
static func nowMillis() -> Int64 {
|
||||
Int64(Date().timeIntervalSince1970 * 1000)
|
||||
}
|
||||
|
||||
/// 持久化 + 主线程回调 onChange。**必须在 barrier 内调用**。
|
||||
private func persistLocked() {
|
||||
if let data = try? JSONEncoder().encode(records) {
|
||||
defaults.set(data, forKey: Self.storageKey)
|
||||
}
|
||||
if let onChange = onChange {
|
||||
let snapshot = records
|
||||
DispatchQueue.main.async { onChange(snapshot) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user