Files
multi-simadmin/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.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

267 lines
9.7 KiB
Swift

import Foundation
public struct LogCenterService: Sendable {
private struct ProbeOutcome: Sendable {
var status: ConnectionStatus
var outcome: ConnectionOutcome
var state: String
var errorCode: String?
var httpStatus: Int?
var durationMs: Int
var message: String
var version: String?
var platform: String?
}
private struct InstanceCollection: Sendable {
var runtime: [RuntimeLogEntry] = []
var connections: [ConnectionLogEntry] = []
var diagnostic: InstanceDiagnostic?
}
private let transport: any NodeTransport
private let store: any LogStore
public init(transport: any NodeTransport, store: any LogStore) {
self.transport = transport
self.store = store
}
/// Direct-mode construction, kept for the existing call sites and tests.
public init(
client: any SimAdminClientProtocol,
sessions: InstanceSessionStore,
credentials: (any CredentialStore)? = nil,
store: any LogStore
) {
self.init(
transport: DirectTransport(client: client, sessions: sessions, credentials: credentials),
store: store
)
}
public func refresh(_ instances: [Instance]) async throws -> LogSnapshot {
var collected: [InstanceCollection] = []
await withTaskGroup(of: InstanceCollection.self) { group in
for instance in instances {
group.addTask { await self.collect(instance) }
}
for await value in group {
collected.append(value)
}
}
collected.sort { ($0.diagnostic?.name ?? "") < ($1.diagnostic?.name ?? "") }
let runtime = collected.flatMap(\.runtime).sorted { $0.occurredAt > $1.occurredAt }
let connections = collected.flatMap(\.connections).sorted { $0.observedAt > $1.observedAt }
let diagnostics = collected.compactMap(\.diagnostic)
try await store.append(runtime: runtime, connections: connections)
var counts: [RuntimeLogSource: Int] = [:]
for source in RuntimeLogSource.allCases {
counts[source] = runtime.filter { $0.source == source }.count
}
return LogSnapshot(
observedAt: Date(),
runtime: runtime,
counts: counts,
connections: connections,
diagnostics: diagnostics
)
}
private func collect(_ instance: Instance) async -> InstanceCollection {
var result = InstanceCollection()
let probe = await probe(instance)
var modules: [String: Result<Bool, Error>] = [
"connection": .success(true),
"device": await moduleResult(instance, .device)
]
if probe.outcome == .failed {
modules["connection"] = .failure(UpstreamError.notSupported)
modules["device"] = .failure(UpstreamError.notSupported)
}
result.connections.append(ConnectionLogEntry(
id: "probe-\(instance.id.uuidString)-\(Int(Date().timeIntervalSince1970 * 1000))",
instanceID: instance.id,
instanceName: instance.name,
outcome: probe.outcome,
state: probe.state,
errorCode: probe.errorCode,
httpStatus: probe.httpStatus,
durationMs: probe.durationMs,
observedAt: Date()
))
if probe.outcome == .failed || probe.outcome == .stale {
result.runtime.append(RuntimeLogEntry(
id: "connection-\(instance.id.uuidString)-\(Int(Date().timeIntervalSince1970 * 1000))",
source: .delivery,
level: probe.outcome == .failed ? .error : .warning,
message: probe.message,
instanceID: instance.id,
instanceName: instance.name,
occurredAt: Date(),
code: probe.errorCode,
durationMs: probe.durationMs
))
}
modules["status"] = .success(true)
result.diagnostic = diagnostic(
instance,
probe: probe,
modules: modules,
connections: result.connections
)
return result
}
private func probe(_ instance: Instance) async -> ProbeOutcome {
let startedAt = Date()
do {
let response = try await transport.send(
.health,
instanceID: instance.id,
endpoint: instance.endpoint
)
let duration = elapsed(startedAt)
let root = (try? JSONSerialization.jsonObject(with: response.data)) as? [String: Any] ?? [:]
return ProbeOutcome(
status: .online,
outcome: .success,
state: "online",
errorCode: nil,
httpStatus: response.status,
durationMs: duration,
message: "节点连接成功",
version: Self.text(root["version"]),
platform: Self.text(root["platform"])
)
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
return ProbeOutcome(
status: .authRequired,
outcome: .stale,
state: "auth_required",
errorCode: "UNAUTHORIZED",
httpStatus: 401,
durationMs: elapsed(startedAt),
message: "节点需要登录",
version: nil,
platform: nil
)
} catch UpstreamError.timeout {
return ProbeOutcome(
status: .offline,
outcome: .failed,
state: "unreachable",
errorCode: "TIMEOUT",
httpStatus: nil,
durationMs: elapsed(startedAt),
message: "节点连接超时",
version: nil,
platform: nil
)
} catch {
return ProbeOutcome(
status: .degraded,
outcome: .failed,
state: "unreachable",
errorCode: "TRANSPORT",
httpStatus: nil,
durationMs: elapsed(startedAt),
message: "节点连接失败",
version: nil,
platform: nil
)
}
}
private func moduleResult(
_ instance: Instance,
_ route: UpstreamRoute
) async -> Result<Bool, Error> {
do {
_ = try await request(route, instance)
return .success(true)
} catch {
return .failure(error)
}
}
private func diagnostic(
_ instance: Instance,
probe: ProbeOutcome,
modules: [String: Result<Bool, Error>],
connections: [ConnectionLogEntry]
) -> InstanceDiagnostic {
let featureStatus = modules
.sorted { $0.key < $1.key }
.map { key, result -> FeatureStatus in
let name = key == "connection" ? "连接" : key == "status" ? "状态" : "设备"
switch result {
case .success:
return FeatureStatus(name: name, state: .ready)
case .failure(UpstreamError.unauthorized), .failure(UpstreamError.forbidden):
return FeatureStatus(name: name, state: .requiresLogin)
case .failure:
return FeatureStatus(name: name, state: .unavailable)
}
}
let success = connections.filter { $0.outcome == .success }.count
let failed = connections.filter { $0.outcome == .failed }.count
let average = connections.isEmpty
? 0
: connections.map(\.durationMs).reduce(0, +) / connections.count
return InstanceDiagnostic(
id: instance.id,
name: instance.name,
origin: instance.endpoint.displayAddress,
tags: instance.tags,
state: probe.status,
healthStatus: Self.healthLabel(probe),
version: probe.version,
platform: probe.platform,
observedAt: Date(),
featureStatus: featureStatus,
connection: ConnectionSummary(
total: connections.count,
success: success,
failed: failed,
averageDurationMs: average,
availabilityPercent: connections.isEmpty ? 0 : success * 100 / connections.count
)
)
}
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint)
}
private func elapsed(_ startedAt: Date) -> Int {
max(0, Int(Date().timeIntervalSince(startedAt) * 1000))
}
private static func healthLabel(_ probe: ProbeOutcome) -> String {
switch probe.state {
case "online", "UP", "ok": return "在线"
case "auth_required": return "需登录"
case "unreachable": return probe.errorCode == "TIMEOUT" ? "超时" : "不可达"
default: return probe.state
}
}
private static func text(_ value: Any?) -> String? {
guard let value = value as? String, !value.isEmpty, value.count <= 512 else { return nil }
return value
}
private static func date(_ value: Any?) -> Date {
guard let text = value as? String else { return Date() }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let parsed = formatter.date(from: text) { return parsed }
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: text) ?? Date()
}
}