- bind(userId:) 内聚为硬绑 + getVersion + 软绑 + updateMetadata 一次原子调用, 软绑失败自动硬解绑回滚 + 清状态;集成方无需感知双层绑定 - fetchLatestFirmware 简化为对账上报 + checkUpgrade 两步,不再重复软绑 - VersionInfo 增加 log: [String: Any]?,走 JSONSerialization 解析(放弃 Codable); SDK 暴露只读 versionLog,断连/解绑/软绑回滚时清空 - ShipmentSnDeviceService 新增 updateMetadata(sn:data:),抽出 performPOST 共享 HTTP 编排,新增 postAnyJSON 支持 [String: Any] body - 老固件不返回 log → versionLog == nil → 自动跳过 updateMetadata,前向兼容 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
138 lines
4.9 KiB
Swift
138 lines
4.9 KiB
Swift
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
|
||
)
|
||
}
|
||
|
||
/// 上报设备运行 metadata。`data` 是任意 JSON 对象(典型场景:BLE getVersion 返回的 log 字段),
|
||
/// SDK 不做 schema 校验,原样透传。2xx 即视为成功。
|
||
func updateMetadata(
|
||
sn: String,
|
||
data: [String: Any],
|
||
completion: @escaping (Result<Void, Error>) -> Void
|
||
) {
|
||
postAnyJSON(
|
||
path: "/api/auth/loomart/shipment-sn/device/update-metadata",
|
||
body: ["sn": sn, "data": data],
|
||
completion: completion
|
||
)
|
||
}
|
||
|
||
private func post(
|
||
path: String,
|
||
body: [String: String],
|
||
completion: @escaping (Result<Void, Error>) -> Void
|
||
) {
|
||
let bodyData: Data
|
||
do {
|
||
bodyData = try JSONEncoder().encode(body)
|
||
} catch {
|
||
completion(.failure(error))
|
||
return
|
||
}
|
||
performPOST(path: path, bodyData: bodyData, completion: completion)
|
||
}
|
||
|
||
private func postAnyJSON(
|
||
path: String,
|
||
body: [String: Any],
|
||
completion: @escaping (Result<Void, Error>) -> Void
|
||
) {
|
||
let bodyData: Data
|
||
do {
|
||
bodyData = try JSONSerialization.data(withJSONObject: body, options: [])
|
||
} catch {
|
||
completion(.failure(error))
|
||
return
|
||
}
|
||
performPOST(path: path, bodyData: bodyData, completion: completion)
|
||
}
|
||
|
||
private func performPOST(
|
||
path: String,
|
||
bodyData: Data,
|
||
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.brand, forHTTPHeaderField: "x-owner")
|
||
req.httpBody = bodyData
|
||
|
||
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
|
||
}
|
||
}
|