Files
multi-simadmin/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageService.swift
T
chick 3f6090081f feat(ios): 中继通道 P4/P5——端点抽象、信封、传输、配对与分层错误
- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct,
  写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>)
- RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器,
  因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写)
- NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter;
  DirectTransport 是薄适配,直通路径行为不变
- RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃,
  四类以上失败各有独立 UpstreamError 分支与用户文案
- DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet
- 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成
- scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫
- 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、
  传输往返与各类拒绝路径
2026-09-16 18:57:41 +08:00

125 lines
4.6 KiB
Swift

import Foundation
public enum MessageServiceError: Error, Equatable {
case invalidInput
case invalidResponse
case unauthorized
}
public struct MessageService: Sendable {
private let transport: any NodeTransport
public init(transport: any NodeTransport) {
self.transport = transport
}
/// Direct-mode construction, kept for the existing call sites and tests.
public init(
client: any SimAdminClientProtocol,
sessions: InstanceSessionStore,
credentials: (any CredentialStore)? = nil
) {
self.init(transport: DirectTransport(client: client, sessions: sessions, credentials: credentials))
}
public func messages(
_ instances: [Instance],
limit: Int = 50,
direction: SMSDirection? = nil
) async throws -> MessagePage {
let directionText: String?
switch direction {
case .incoming: directionText = "incoming"
case .outgoing: directionText = "outgoing"
default: directionText = nil
}
var collected: [SMSMessage] = []
var total = 0
var lastError: Error?
for instance in instances {
do {
let response = try await request(.smsList(limit: limit, offset: 0, direction: directionText), instance)
let page = try Self.parsePage(response, instance)
collected.append(contentsOf: page.messages)
total += page.total
} catch {
lastError = error
}
}
if collected.isEmpty && total == 0, let lastError {
throw lastError
}
collected.sort { $0.timestamp > $1.timestamp }
return MessagePage(messages: collected, total: total)
}
public func send(
_ instance: Instance,
phoneNumber rawPhoneNumber: String,
content rawContent: String
) async throws {
let phoneNumber = rawPhoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
let content = rawContent.trimmingCharacters(in: .whitespacesAndNewlines)
guard !phoneNumber.isEmpty, !content.isEmpty else {
throw MessageServiceError.invalidInput
}
_ = try await request(.smsSend(phoneNumber: phoneNumber, content: content), instance)
}
public func delete(_ ids: [String], from instance: Instance) async throws {
let numericIDs = ids.compactMap(Int.init)
guard !numericIDs.isEmpty else {
throw MessageServiceError.invalidInput
}
_ = try await request(.smsBatchDelete(ids: numericIDs), instance)
}
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint)
}
static func parsePage(_ response: UpstreamResponse, _ instance: Instance) throws -> MessagePage {
let root = try JSONSerialization.jsonObject(with: response.data)
guard let container = Self.dataContainer(root),
let data = container["data"] as? [String: Any] ?? container["response"] as? [String: Any]
else {
throw MessageServiceError.invalidResponse
}
let rawMessages = (data["messages"] as? [Any]) ?? []
let messages = rawMessages.compactMap { Self.message($0, instance) }
let total = (data["total"] as? Int) ?? messages.count
return MessagePage(messages: messages, total: total)
}
private static func dataContainer(_ root: Any) -> [String: Any]? {
root as? [String: Any]
}
private static func message(_ raw: Any, _ instance: Instance) -> SMSMessage? {
guard let value = raw as? [String: Any] else { return nil }
let id: String
if let number = value["id"] as? Int {
id = String(number)
} else if let text = value["id"] as? String {
id = text
} else {
return nil
}
let directionText = (value["direction"] as? String)?.lowercased() ?? ""
let direction: SMSDirection = directionText == "incoming" ? .incoming
: directionText == "outgoing" ? .outgoing
: .unknown
return SMSMessage(
upstreamID: id,
instanceID: instance.id,
instanceName: instance.name,
phoneNumber: value["phone_number"] as? String ?? "",
content: value["content"] as? String ?? "",
direction: direction,
status: value["status"] as? String ?? "",
timestamp: value["timestamp"] as? String ?? ""
)
}
}