diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationModels.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationModels.swift new file mode 100644 index 0000000..e3615e9 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationModels.swift @@ -0,0 +1,78 @@ +import Foundation + +public enum NotificationDeviceState: String, Equatable, Sendable, CaseIterable { + case ready + case unauthorized + case unavailable + case failed +} + +public struct NotificationDevice: Equatable, Identifiable, Sendable { + public let id: UUID + public let name: String + public let state: NotificationDeviceState +} + +public struct NotificationChannelTypeSummary: Equatable, Identifiable, Sendable { + public var id: String { type } + public let type: String + public let total: Int + public let enabled: Int +} + +public struct NotificationConfigSummary: Equatable, Sendable { + public let channelCount: Int + public let channelEnabled: Int + public let ruleCount: Int + public let ruleEnabled: Int + public let channelTypes: [NotificationChannelTypeSummary] +} + +public struct NotificationLogEntry: Equatable, Identifiable, Sendable { + public let id: String + public let eventType: String + public let status: String + public let ruleName: String? + public let channelName: String? + public let createdAt: String +} + +public struct NotificationLogSummary: Equatable, Sendable { + public let total: Int + public let success: Int + public let failed: Int + public let quietHours: Int + public let unmatched: Int + public let noAvailableChannel: Int + public let other: Int + public let entries: [NotificationLogEntry] +} + +public struct NotificationQueueEntry: Equatable, Identifiable, Sendable { + public let id: String + public let instanceID: UUID + public let instanceName: String + public let eventType: String + public let status: String + public let ruleName: String? + public let channelName: String? + public let createdAt: String +} + +public struct NotificationQueueSummary: Equatable, Sendable { + public let total: Int + public let pending: Int + public let scheduled: Int + public let retrying: Int + public let sending: Int + public let failed: Int + public let items: [NotificationQueueEntry] +} + +public struct NotificationOverview: Equatable, Sendable { + public let observedAt: Date + public let devices: [NotificationDevice] + public let config: NotificationConfigSummary + public let logs: NotificationLogSummary + public let queue: NotificationQueueSummary +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationService.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationService.swift new file mode 100644 index 0000000..8ee7baf --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationService.swift @@ -0,0 +1,356 @@ +import Foundation + +public enum NotificationServiceError: Error, Equatable { + case invalidInput + case invalidResponse + case unauthorized +} + +public struct NotificationService: Sendable { + private let client: any SimAdminClientProtocol + private let sessions: InstanceSessionStore + private let credentials: (any CredentialStore)? + + public init( + client: any SimAdminClientProtocol, + sessions: InstanceSessionStore, + credentials: (any CredentialStore)? = nil + ) { + self.client = client + self.sessions = sessions + self.credentials = credentials + } + + public func overview(_ instances: [Instance]) async throws -> NotificationOverview { + var devices: [NotificationDevice] = [] + var configs: [NotificationConfigSummary] = [] + var logs: [NotificationLogSummary] = [] + var queues: [NotificationQueueSummary] = [] + + for instance in instances { + do { + let config = try Self.parseConfig(Self.data(of: try await request(.notificationConfig, instance))) + let logPage = try Self.parseLogs(Self.data(of: try await request( + .notificationLogs(limit: 50, offset: 0), + instance + ))) + let queue = try Self.parseQueue( + Self.data(of: try await request(.notificationQueue(limit: 100), instance)), + instance + ) + configs.append(config) + logs.append(logPage) + queues.append(queue) + devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .ready)) + } catch UpstreamError.notSupported { + devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .unavailable)) + } catch UpstreamError.unauthorized, UpstreamError.forbidden { + devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .unauthorized)) + } catch NotificationServiceError.unauthorized { + devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .unauthorized)) + } catch { + devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .failed)) + } + } + + return NotificationOverview( + observedAt: Date(), + devices: devices, + config: Self.aggregateConfigs(configs), + logs: Self.aggregateLogs(logs), + queue: Self.aggregateQueues(queues) + ) + } + + public func retryAll(_ instances: [Instance]) async -> NotificationActionResult { + var result = NotificationActionResult() + for instance in instances { + do { + _ = try await request(.notificationRetryAllQueue, instance) + result.succeeded += 1 + } catch UpstreamError.notSupported { + result.skipped += 1 + } catch { + result.failed += 1 + } + } + return result + } + + public func retryQueue(_ instance: Instance, id: String) async throws { + try Self.validateQueueID(id) + _ = try await request(.notificationRetryQueue(id: id), instance) + } + + public func deleteQueueItem(_ instance: Instance, id: String) async throws { + try Self.validateQueueID(id) + _ = try await request(.notificationDeleteQueue(id: id), instance) + } + + static func validateQueueID(_ id: String) throws { + guard !id.isEmpty, id.count <= 128, + id.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" || $0 == "." }) + else { + throw NotificationServiceError.invalidInput + } + } + + private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse { + var cookie = await sessions.cookie(for: instance.id, origin: instance.origin) + do { + return try await client.send(route, baseURL: instance.origin, cookie: cookie) + } catch UpstreamError.unauthorized, UpstreamError.forbidden { + cookie = try await login(instance) + return try await client.send(route, baseURL: instance.origin, cookie: cookie) + } + } + + private func login(_ instance: Instance) async throws -> String { + guard let credentials, + let password = try credentials.get(instanceID: instance.id), + !password.isEmpty + else { + throw NotificationServiceError.unauthorized + } + let sessionService = InstanceSessionService(store: sessions, client: client) + let result = try await sessionService.login(instance, password: password) + guard result.authenticated, + let cookie = await sessions.cookie(for: instance.id, origin: instance.origin) + else { + throw NotificationServiceError.unauthorized + } + return cookie + } + + static func data(of response: UpstreamResponse) throws -> [String: Any] { + guard let root = try JSONSerialization.jsonObject(with: response.data) as? [String: Any], + let status = root["status"] as? String, + ["success", "ok", "OK"].contains(status), + let data = root["data"] as? [String: Any] + else { + throw NotificationServiceError.invalidResponse + } + return data + } + + static func parseConfig(_ data: [String: Any]) throws -> NotificationConfigSummary { + var channelCount = 0 + var channelEnabled = 0 + var typeOrder: [String] = [] + var typeTotals: [String: (total: Int, enabled: Int)] = [:] + + func record(type: String, enabled: Bool) { + if !typeOrder.contains(type) { + typeOrder.append(type) + } + let current = typeTotals[type] ?? (0, 0) + typeTotals[type] = (current.total + 1, current.enabled + (enabled ? 1 : 0)) + } + + if let channels = data["channels"] as? [[String: Any]] { + for raw in channels { + guard let type = raw["type"] as? String, let enabled = raw["enabled"] as? Bool else { continue } + channelCount += 1 + channelEnabled += enabled ? 1 : 0 + record(type: type, enabled: enabled) + } + } else { + for type in ["webhook", "bark", "pushplus", "wecom_app", "wecom_robot", "dingtalk_robot", "dingtalk_app", "feishu_robot", "telegram", "email", "serverchan"] { + guard let value = data[type] as? [String: Any], let enabled = value["enabled"] as? Bool else { continue } + channelCount += 1 + channelEnabled += enabled ? 1 : 0 + record(type: type, enabled: enabled) + } + } + + var ruleCount = 0 + var ruleEnabled = 0 + if let rules = data["rules"] as? [[String: Any]] { + for raw in rules { + guard let enabled = raw["enabled"] as? Bool else { continue } + ruleCount += 1 + ruleEnabled += enabled ? 1 : 0 + } + } + let types = typeOrder.map { + NotificationChannelTypeSummary( + type: $0, + total: typeTotals[$0]?.total ?? 0, + enabled: typeTotals[$0]?.enabled ?? 0 + ) + } + return NotificationConfigSummary( + channelCount: channelCount, + channelEnabled: channelEnabled, + ruleCount: ruleCount, + ruleEnabled: ruleEnabled, + channelTypes: types + ) + } + + static func parseLogs(_ data: [String: Any]) throws -> NotificationLogSummary { + guard let total = positive(data["total"]), let raw = data["logs"] as? [[String: Any]] else { + throw NotificationServiceError.invalidResponse + } + var entries: [NotificationLogEntry] = [] + var counts = ( + success: 0, failed: 0, quietHours: 0, unmatched: 0, noAvailableChannel: 0, other: 0 + ) + for item in raw { + guard let id = text(item["id"], 128), + let eventType = text(item["event_type"], 80), + let status = text(item["status"], 40), + let createdAt = text(item["created_at"], 64) + else { + throw NotificationServiceError.invalidResponse + } + entries.append(NotificationLogEntry( + id: id, + eventType: eventType, + status: status, + ruleName: text(item["rule_name"], 160), + channelName: text(item["channel_name"], 160), + createdAt: createdAt + )) + switch status { + case "success": counts.success += 1 + case "failed": counts.failed += 1 + case "quiet_hours": counts.quietHours += 1 + case "unmatched": counts.unmatched += 1 + case "no_available_channel": counts.noAvailableChannel += 1 + default: counts.other += 1 + } + } + return NotificationLogSummary( + total: total, + success: counts.success, + failed: counts.failed, + quietHours: counts.quietHours, + unmatched: counts.unmatched, + noAvailableChannel: counts.noAvailableChannel, + other: counts.other, + entries: entries + ) + } + + static func parseQueue(_ data: [String: Any], _ instance: Instance) throws -> NotificationQueueSummary { + guard let total = positive(data["total"]), let raw = data["items"] as? [[String: Any]] else { + throw NotificationServiceError.invalidResponse + } + var items: [NotificationQueueEntry] = [] + var counts = (pending: 0, scheduled: 0, retrying: 0, sending: 0, failed: 0) + for value in raw { + guard let id = text(value["id"], 128), + let eventType = text(value["event_type"], 80), + let status = text(value["status"], 32), + let createdAt = text(value["created_at"], 64) + else { + throw NotificationServiceError.invalidResponse + } + items.append(NotificationQueueEntry( + id: id, + instanceID: instance.id, + instanceName: instance.name, + eventType: eventType, + status: status, + ruleName: text(value["rule_name"], 160), + channelName: text(value["channel_name"], 160), + createdAt: createdAt + )) + switch status { + case "pending": counts.pending += 1 + case "scheduled": counts.scheduled += 1 + case "retrying": counts.retrying += 1 + case "sending": counts.sending += 1 + case "failed": counts.failed += 1 + default: break + } + } + return NotificationQueueSummary( + total: total, + pending: counts.pending, + scheduled: counts.scheduled, + retrying: counts.retrying, + sending: counts.sending, + failed: counts.failed, + items: items + ) + } + + static func aggregateConfigs(_ values: [NotificationConfigSummary]) -> NotificationConfigSummary { + NotificationConfigSummary( + channelCount: values.reduce(0) { $0 + $1.channelCount }, + channelEnabled: values.reduce(0) { $0 + $1.channelEnabled }, + ruleCount: values.reduce(0) { $0 + $1.ruleCount }, + ruleEnabled: values.reduce(0) { $0 + $1.ruleEnabled }, + channelTypes: mergeChannelTypes(values.map(\.channelTypes)) + ) + } + + static func aggregateLogs(_ values: [NotificationLogSummary]) -> NotificationLogSummary { + NotificationLogSummary( + total: values.reduce(0) { $0 + $1.total }, + success: values.reduce(0) { $0 + $1.success }, + failed: values.reduce(0) { $0 + $1.failed }, + quietHours: values.reduce(0) { $0 + $1.quietHours }, + unmatched: values.reduce(0) { $0 + $1.unmatched }, + noAvailableChannel: values.reduce(0) { $0 + $1.noAvailableChannel }, + other: values.reduce(0) { $0 + $1.other }, + entries: Array(values.flatMap(\.entries).prefix(20)) + ) + } + + static func aggregateQueues(_ values: [NotificationQueueSummary]) -> NotificationQueueSummary { + NotificationQueueSummary( + total: values.reduce(0) { $0 + $1.total }, + pending: values.reduce(0) { $0 + $1.pending }, + scheduled: values.reduce(0) { $0 + $1.scheduled }, + retrying: values.reduce(0) { $0 + $1.retrying }, + sending: values.reduce(0) { $0 + $1.sending }, + failed: values.reduce(0) { $0 + $1.failed }, + items: Array(values.flatMap(\.items).prefix(20)) + ) + } + + private static func text(_ value: Any?, _ maximum: Int) -> String? { + guard let value, let text = value as? String, + !text.isEmpty, text.count <= maximum, + !text.unicodeScalars.contains(where: { $0.value < 9 || ($0.value > 13 && $0.value < 32) || $0.value == 127 }) + else { return nil } + return text + } + + private static func positive(_ value: Any?) -> Int? { + guard let number = value as? Int, number >= 0, number <= 10_000_000 else { return nil } + return number + } + + private static func mergeChannelTypes( + _ groups: [[NotificationChannelTypeSummary]] + ) -> [NotificationChannelTypeSummary] { + var merged: [NotificationChannelTypeSummary] = [] + for group in groups { + for summary in group { + if let index = merged.firstIndex(where: { $0.type == summary.type }) { + let current = merged[index] + merged[index] = NotificationChannelTypeSummary( + type: current.type, + total: current.total + summary.total, + enabled: current.enabled + summary.enabled + ) + } else { + merged.append(summary) + } + } + } + return merged + } +} + +public struct NotificationActionResult: Equatable, Sendable { + public var succeeded = 0 + public var failed = 0 + public var skipped = 0 + + public init() {} +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift new file mode 100644 index 0000000..3e560d3 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift @@ -0,0 +1,316 @@ +import SwiftUI + +struct NotificationsView: View { + @StateObject private var model: NotificationsViewModel + + init(model: NotificationsViewModel? = nil) { + _model = StateObject(wrappedValue: model ?? DefaultContainer.makeNotificationsModel()) + } + + var body: some View { + ZStack { + AITheme.canvas.ignoresSafeArea() + ScrollView { + LazyVStack(spacing: 12) { + if let actionMessage = model.actionMessage { + AICard { + Text(actionMessage) + .font(.subheadline) + .foregroundStyle(AITheme.success) + } + } + if let errorMessage = model.errorMessage { + AICard { + Text(errorMessage) + .font(.subheadline) + .foregroundStyle(AITheme.warning) + } + } + + if let overview = model.overview { + stats(overview) + devices(overview) + channels(overview) + queue(overview) + logs(overview) + } else if model.isLoading { + AICard { + ProgressView("正在加载通知中心...") + .frame(maxWidth: .infinity) + .padding(.vertical, 22) + } + } else { + empty + } + } + .padding(16) + } + } + .navigationTitle("通知中心") + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button { + Task { await model.retryAll() } + } label: { + Label("立即投递", systemImage: "paperplane") + } + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular)) + .disabled(model.isLoading) + } + } + .refreshable { + await model.refresh() + } + .task { + await model.refresh() + } + } + + private var empty: some View { + AICard { + VStack(spacing: 10) { + Image(systemName: "bell") + .font(.system(size: 28)) + .foregroundStyle(AITheme.muted) + Text("暂无通知数据") + .font(.headline) + Text("下拉刷新后从已配置实例同步。") + .font(.subheadline) + .foregroundStyle(AITheme.muted) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + } + } + + private func stats(_ overview: NotificationOverview) -> some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + statCard("通道", "\(overview.config.channelEnabled)/\(overview.config.channelCount)", "已启用/总数", AITheme.primary) + statCard("规则", "\(overview.config.ruleEnabled)/\(overview.config.ruleCount)", "已启用/总数", AITheme.primary) + statCard("队列", "\(overview.queue.pending + overview.queue.retrying)", "等待与重试", AITheme.warning) + statCard("失败", "\(overview.queue.failed + overview.logs.failed)", "队列与投递", AITheme.danger) + } + } + + private func statCard(_ title: String, _ value: String, _ detail: String, _ tint: Color) -> some View { + AICard { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.caption) + .foregroundStyle(AITheme.muted) + Text(value) + .font(.title2.weight(.bold)) + .foregroundStyle(tint) + Text(detail) + .font(.caption) + .foregroundStyle(AITheme.muted) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func devices(_ overview: NotificationOverview) -> some View { + AICard { + VStack(alignment: .leading, spacing: 10) { + title("实例状态") + if overview.devices.isEmpty { + Text("尚未添加实例。") + .font(.subheadline) + .foregroundStyle(AITheme.muted) + } else { + ForEach(overview.devices) { device in + HStack(spacing: 8) { + Text(device.name) + .font(.subheadline) + Spacer() + AITag(deviceState(device.state), tint: deviceTint(device.state)) + } + } + } + } + } + } + + private func channels(_ overview: NotificationOverview) -> some View { + AICard { + VStack(alignment: .leading, spacing: 10) { + title("通道类型") + if overview.config.channelTypes.isEmpty { + Text("实例未配置通知通道。") + .font(.subheadline) + .foregroundStyle(AITheme.muted) + } else { + ForEach(overview.config.channelTypes) { channel in + HStack(spacing: 8) { + Text(channelTypeLabel(channel.type)) + .font(.subheadline) + Spacer() + AITag("\(channel.enabled)/\(channel.total)", tint: channel.enabled > 0 ? AITheme.success : AITheme.muted) + } + } + } + } + } + } + + private func queue(_ overview: NotificationOverview) -> some View { + AICard { + VStack(alignment: .leading, spacing: 10) { + HStack { + title("最近队列") + Spacer() + Text("\(overview.queue.total) 项") + .font(.caption) + .foregroundStyle(AITheme.muted) + } + if overview.queue.items.isEmpty { + Text("队列为空。") + .font(.subheadline) + .foregroundStyle(AITheme.muted) + } else { + ForEach(overview.queue.items) { item in + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text(eventTypeLabel(item.eventType)) + .font(.subheadline.weight(.semibold)) + Spacer() + AITag(statusLabel(item.status), tint: statusTint(item.status)) + } + Text("\(item.instanceName) · \(item.createdAt)") + .font(.caption) + .foregroundStyle(AITheme.muted) + HStack(spacing: 8) { + Button { + Task { await model.retryQueue(item) } + } label: { + Label("重试", systemImage: "arrow.clockwise") + } + .buttonStyle(AIButtonStyle(kind: .secondary, size: .compact)) + .disabled(model.busyQueueID == item.id) + + Button { + Task { await model.deleteQueue(item) } + } label: { + Label("删除", systemImage: "trash") + } + .buttonStyle(AIButtonStyle(kind: .danger, size: .compact)) + } + } + .padding(.bottom, 4) + } + } + } + } + } + + private func logs(_ overview: NotificationOverview) -> some View { + AICard { + VStack(alignment: .leading, spacing: 10) { + HStack { + title("投递日志") + Spacer() + Text("\(overview.logs.total) 条") + .font(.caption) + .foregroundStyle(AITheme.muted) + } + if overview.logs.entries.isEmpty { + Text("暂无投递日志。") + .font(.subheadline) + .foregroundStyle(AITheme.muted) + } else { + ForEach(overview.logs.entries) { entry in + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(eventTypeLabel(entry.eventType)) + .font(.subheadline.weight(.semibold)) + Spacer() + AITag(statusLabel(entry.status), tint: statusTint(entry.status)) + } + Text("\(entry.channelName ?? "未指定通道") · \(entry.createdAt)") + .font(.caption) + .foregroundStyle(AITheme.muted) + } + .padding(.bottom, 4) + } + } + } + } + } + + private func title(_ text: String) -> some View { + Text(text) + .font(.headline) + .foregroundStyle(AITheme.ink) + } + + private func deviceState(_ state: NotificationDeviceState) -> String { + switch state { + case .ready: return "在线" + case .unauthorized: return "需登录" + case .unavailable: return "离线" + case .failed: return "异常" + } + } + + private func deviceTint(_ state: NotificationDeviceState) -> Color { + switch state { + case .ready: return AITheme.success + case .unauthorized: return AITheme.warning + case .unavailable: return AITheme.muted + case .failed: return AITheme.danger + } + } + + private func channelTypeLabel(_ type: String) -> String { + switch type { + case "webhook": return "Webhook" + case "bark": return "Bark" + case "pushplus": return "PushPlus" + case "wecom_app": return "企业微信应用" + case "wecom_robot": return "企业微信机器人" + case "dingtalk_robot": return "钉钉机器人" + case "dingtalk_app": return "钉钉应用" + case "feishu_robot": return "飞书机器人" + case "telegram": return "Telegram" + case "email": return "邮件" + case "serverchan": return "Server 酱" + default: return type + } + } + + private func eventTypeLabel(_ value: String) -> String { + switch value { + case "sms": return "短信" + case "ddns": return "DDNS" + case "version": return "版本" + case "system": return "系统" + case "device": return "设备" + case "automation": return "自动化" + default: return value + } + } + + private func statusLabel(_ value: String) -> String { + switch value { + case "success", "succeeded": return "成功" + case "failed": return "失败" + case "pending": return "等待" + case "sending": return "发送中" + case "retrying": return "重试中" + case "cancelled": return "已取消" + case "unmatched": return "未匹配" + case "no_available_channel": return "无可用通道" + case "quiet_hours": return "免打扰" + default: return value + } + } + + private func statusTint(_ value: String) -> Color { + switch value { + case "success", "succeeded": return AITheme.success + case "pending", "sending", "retrying": return AITheme.warning + case "failed": return AITheme.danger + default: return AITheme.muted + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift new file mode 100644 index 0000000..306c513 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift @@ -0,0 +1,76 @@ +import Foundation + +@MainActor +final class NotificationsViewModel: ObservableObject { + @Published private(set) var overview: NotificationOverview? + @Published private(set) var instances: [Instance] = [] + @Published private(set) var isLoading = false + @Published var errorMessage: String? + @Published private(set) var actionMessage: String? + @Published private(set) var busyQueueID: String? + + private let repository: any InstanceRepository + private let service: NotificationService + + init(repository: any InstanceRepository, service: NotificationService) { + self.repository = repository + self.service = service + } + + func refresh() async { + isLoading = true + errorMessage = nil + do { + let loaded = try await repository.list(InstanceQuery()) + instances = loaded + overview = try await service.overview(loaded) + } catch { + errorMessage = "无法加载通知中心。" + } + isLoading = false + } + + func retryAll() async { + let result = await service.retryAll(instances) + if result.succeeded > 0 { + actionMessage = "已提交全部队列重试,成功 \(result.succeeded) 个实例。" + } else if result.skipped > 0 { + actionMessage = "没有可重试的队列接口。" + } else { + actionMessage = "队列重试失败。" + } + await refresh() + } + + func retryQueue(_ item: NotificationQueueEntry) async { + await queueAction(item) { instance, id in + try await self.service.retryQueue(instance, id: id) + } + } + + func deleteQueue(_ item: NotificationQueueEntry) async { + await queueAction(item) { instance, id in + try await self.service.deleteQueueItem(instance, id: id) + } + await refresh() + } + + private func queueAction( + _ item: NotificationQueueEntry, + action: (Instance, String) async throws -> Void + ) async { + guard let instance = instances.first(where: { $0.id == item.instanceID }) else { + errorMessage = "找不到队列所属实例。" + return + } + busyQueueID = item.id + do { + try await action(instance, item.id) + actionMessage = "队列操作已完成。" + errorMessage = nil + } catch { + errorMessage = "队列操作失败。" + } + busyQueueID = nil + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift index fdea6f7..d8e0834 100644 --- a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift +++ b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift @@ -13,7 +13,7 @@ struct MultiSimAdminApp: App { .tabItem { Label("消息", systemImage: "message") } - Text("通知") + NotificationsView() .tabItem { Label("通知", systemImage: "bell") } diff --git a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift index b5b965e..ec4a907 100644 --- a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift +++ b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift @@ -27,6 +27,18 @@ enum DefaultContainer { ) } + @MainActor + static func makeNotificationsModel() -> NotificationsViewModel { + NotificationsViewModel( + repository: FileInstanceRepository(location: documentFileURL("instances.json")), + service: NotificationService( + client: SimAdminClient(), + sessions: InstanceSessionStore(), + credentials: KeychainCredentialStore() + ) + ) + } + @MainActor static func makeDeviceModel(instance: Instance) -> DeviceViewModel { DeviceViewModel( diff --git a/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift b/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift index 41919ed..349ff68 100644 --- a/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift +++ b/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift @@ -12,16 +12,24 @@ public enum UpstreamRoute: Hashable, Sendable { case smsList(limit: Int, offset: Int, direction: String?) case smsSend(phoneNumber: String, content: String) case smsBatchDelete(ids: [Int]) + case notificationConfig + case notificationLogs(limit: Int, offset: Int) + case notificationQueue(limit: Int) + case notificationRetryAllQueue + case notificationRetryQueue(id: String) + case notificationDeleteQueue(id: String) case authLogin(password: String) case authLogout public var method: String { switch self { case .health, .device, .sim, .network, .signalStrength, .stats, .statsCpu, - .connectivity, .smsList: + .connectivity, .smsList, .notificationConfig, .notificationLogs, .notificationQueue: return "GET" - case .smsSend, .smsBatchDelete, .authLogin, .authLogout: + case .smsSend, .smsBatchDelete, .notificationRetryAllQueue, .notificationRetryQueue, .authLogin, .authLogout: return "POST" + case .notificationDeleteQueue: + return "DELETE" } } @@ -43,6 +51,17 @@ public enum UpstreamRoute: Hashable, Sendable { return "/api/sms/list?\(items.joined(separator: "&"))" case .smsSend: return "/api/sms/send" case .smsBatchDelete: return "/api/sms/batch-delete" + case .notificationConfig: return "/api/notifications/config" + case .notificationLogs(let limit, let offset): + return "/api/notifications/logs?limit=\(limit)&offset=\(offset)" + case .notificationQueue(let limit): + return "/api/notifications/queue?limit=\(limit)" + case .notificationRetryAllQueue: + return "/api/notifications/queue/retry-all" + case .notificationRetryQueue(let id): + return "/api/notifications/queue/\(id)/retry" + case .notificationDeleteQueue(let id): + return "/api/notifications/queue/\(id)" case .authLogin: return "/api/auth/login" case .authLogout: return "/api/auth/logout" } diff --git a/apps/ios/Tests/MultiSimAdminTests/NotificationServiceTests.swift b/apps/ios/Tests/MultiSimAdminTests/NotificationServiceTests.swift new file mode 100644 index 0000000..1b49dda --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/NotificationServiceTests.swift @@ -0,0 +1,163 @@ +import XCTest + +@testable import MultiSimAdmin + +final class NotificationServiceTests: XCTestCase { + func testAggregatesInstanceConfigLogsAndQueue() async throws { + let client = NotificationTestClient() + let service = NotificationService(client: client, sessions: InstanceSessionStore()) + let instance = Self.instance() + + let overview = try await service.overview([instance]) + + XCTAssertEqual(client.routes, [ + .notificationConfig, + .notificationLogs(limit: 50, offset: 0), + .notificationQueue(limit: 100) + ]) + XCTAssertEqual(overview.config.channelCount, 2) + XCTAssertEqual(overview.config.channelEnabled, 1) + XCTAssertEqual(overview.config.ruleCount, 1) + XCTAssertEqual(overview.config.ruleEnabled, 1) + XCTAssertEqual(overview.config.channelTypes.first?.type, "webhook") + XCTAssertEqual(overview.logs.total, 3) + XCTAssertEqual(overview.logs.success, 2) + XCTAssertEqual(overview.logs.failed, 1) + XCTAssertEqual(overview.queue.total, 2) + XCTAssertEqual(overview.queue.pending, 1) + XCTAssertEqual(overview.queue.failed, 1) + XCTAssertEqual(overview.queue.items.first?.id, "q-1") + XCTAssertEqual(overview.queue.items.first?.instanceID, instance.id) + XCTAssertEqual(overview.devices.first?.state, .ready) + } + + func testQueueActionsValidateIDsAndUseUpstreamRoutes() async throws { + let client = NotificationTestClient() + let service = NotificationService(client: client, sessions: InstanceSessionStore()) + let instance = Self.instance() + + try await service.retryQueue(instance, id: "q_1-a.2") + try await service.deleteQueueItem(instance, id: "q_1-a.2") + + XCTAssertEqual(client.routes.suffix(2), [ + .notificationRetryQueue(id: "q_1-a.2"), + .notificationDeleteQueue(id: "q_1-a.2") + ]) + + do { + try await service.retryQueue(instance, id: "bad id") + XCTFail("Invalid queue ID should fail.") + } catch { + XCTAssertEqual(error as? NotificationServiceError, .invalidInput) + } + } + + func testReturnsStatefulOverviewWhenEveryInstanceIsUnauthorized() async throws { + let service = NotificationService( + client: UnauthorizedNotificationClient(), + sessions: InstanceSessionStore() + ) + let instance = Self.instance() + + let overview = try await service.overview([instance]) + + XCTAssertEqual(overview.devices, [NotificationDevice( + id: instance.id, + name: instance.name, + state: .unauthorized + )]) + XCTAssertEqual(overview.config.channelCount, 0) + XCTAssertEqual(overview.logs.total, 0) + XCTAssertEqual(overview.queue.total, 0) + } + + func testAggregatesChannelTypeSummariesInFirstSeenOrder() { + let first = NotificationConfigSummary( + channelCount: 2, + channelEnabled: 1, + ruleCount: 0, + ruleEnabled: 0, + channelTypes: [ + NotificationChannelTypeSummary(type: "webhook", total: 1, enabled: 1), + NotificationChannelTypeSummary(type: "bark", total: 1, enabled: 0) + ] + ) + let second = NotificationConfigSummary( + channelCount: 2, + channelEnabled: 2, + ruleCount: 0, + ruleEnabled: 0, + channelTypes: [ + NotificationChannelTypeSummary(type: "telegram", total: 1, enabled: 1), + NotificationChannelTypeSummary(type: "webhook", total: 1, enabled: 1) + ] + ) + + let result = NotificationService.aggregateConfigs([first, second]) + + XCTAssertEqual(result.channelTypes.map(\.type), ["webhook", "bark", "telegram"]) + let webhook = result.channelTypes.first { $0.type == "webhook" } + XCTAssertEqual(webhook, NotificationChannelTypeSummary(type: "webhook", total: 2, enabled: 2)) + } + + private static func instance() -> Instance { + Instance( + id: UUID(), + name: "Notification Node", + origin: URL(string: "http://192.168.1.20:3000")!, + configRevision: 1, + createdAt: Date(), + updatedAt: Date() + ) + } +} + +private struct UnauthorizedNotificationClient: SimAdminClientProtocol { + func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse { + throw UpstreamError.unauthorized + } +} + +private final class NotificationTestClient: SimAdminClientProtocol, @unchecked Sendable { + var routes: [UpstreamRoute] = [] + + func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse { + routes.append(route) + switch route { + case .notificationConfig: + let json = """ + {"status":"ok","data":{"channels":[{"type":"webhook","enabled":true}, + {"type":"bark","enabled":false}],"rules":[{"enabled":true}]}} + """ + return Self.response(json) + case .notificationLogs(let limit, let offset): + XCTAssertEqual(limit, 50) + XCTAssertEqual(offset, 0) + let json = """ + {"status":"ok","data":{"total":3,"logs":[ + {"id":"l-1","event_type":"device_offline","status":"success","created_at":"2026-09-08T10:00:00Z"}, + {"id":"l-2","event_type":"low_signal","status":"success","created_at":"2026-09-08T10:01:00Z"}, + {"id":"l-3","event_type":"device_offline","status":"failed","created_at":"2026-09-08T10:02:00Z"} + ]}} + """ + return Self.response(json) + case .notificationQueue(let limit): + XCTAssertEqual(limit, 100) + let json = """ + {"status":"ok","data":{"total":2,"items":[ + {"id":"q-1","event_type":"device_offline","status":"pending","created_at":"2026-09-08T10:00:00Z"}, + {"id":"q-2","event_type":"low_signal","status":"failed","created_at":"2026-09-08T10:01:00Z"} + ]}} + """ + return Self.response(json) + case .notificationRetryQueue, .notificationDeleteQueue: + return Self.response(#"{"status":"ok"}"#) + default: + return UpstreamResponse(status: 404, data: Data("{}".utf8)) + } + } + + private static func response(_ json: String) -> UpstreamResponse { + UpstreamResponse(status: 200, data: Data(json.utf8)) + } +} diff --git a/apps/ios/Tests/MultiSimAdminTests/NotificationsViewModelTests.swift b/apps/ios/Tests/MultiSimAdminTests/NotificationsViewModelTests.swift new file mode 100644 index 0000000..8352eb2 --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/NotificationsViewModelTests.swift @@ -0,0 +1,91 @@ +import XCTest + +@testable import MultiSimAdmin + +@MainActor +final class NotificationsViewModelTests: XCTestCase { + func testRefreshLoadsInstancesAndAggregatedOverview() async throws { + let repository = MemoryInstanceRepository() + let client = NotificationViewModelClient() + let service = NotificationService(client: client, sessions: InstanceSessionStore()) + let model = NotificationsViewModel(repository: repository, service: service) + _ = try await repository.create(InstanceInput( + name: "Notification Node", + origin: URL(string: "http://192.168.1.20:3000")!, + tags: [], + password: nil + )) + + await model.refresh() + + XCTAssertEqual(model.instances.count, 1) + XCTAssertEqual(model.overview?.devices.first?.state, .ready) + XCTAssertEqual(model.overview?.queue.items.first?.id, "queue-1") + XCTAssertNil(model.errorMessage) + } + + func testRetryQueueTargetsOwningInstance() async throws { + let repository = MemoryInstanceRepository() + let client = NotificationViewModelClient() + let service = NotificationService(client: client, sessions: InstanceSessionStore()) + let model = NotificationsViewModel(repository: repository, service: service) + _ = try await repository.create(InstanceInput( + name: "Notification Node", + origin: URL(string: "http://192.168.1.20:3000")!, + tags: [], + password: nil + )) + await model.refresh() + let item = try XCTUnwrap(model.overview?.queue.items.first) + + await model.retryQueue(item) + + XCTAssertEqual(client.routes.last, .notificationRetryQueue(id: "queue-1")) + } + + func testRetryAllTargetsEveryInstance() async throws { + let repository = MemoryInstanceRepository() + let client = NotificationViewModelClient() + let service = NotificationService(client: client, sessions: InstanceSessionStore()) + let model = NotificationsViewModel(repository: repository, service: service) + _ = try await repository.create(InstanceInput( + name: "First", + origin: URL(string: "http://192.168.1.20:3000")!, + tags: [], + password: nil + )) + _ = try await repository.create(InstanceInput( + name: "Second", + origin: URL(string: "http://192.168.1.21:3000")!, + tags: [], + password: nil + )) + await model.refresh() + + await model.retryAll() + + XCTAssertEqual(client.routes.filter { $0 == .notificationRetryAllQueue }.count, 2) + } +} + +private final class NotificationViewModelClient: SimAdminClientProtocol, @unchecked Sendable { + var routes: [UpstreamRoute] = [] + + func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse { + routes.append(route) + switch route { + case .notificationConfig: + return Self.response(#"{"status":"ok","data":{"channels":[{"type":"webhook","enabled":true}]}}"#) + case .notificationLogs: + return Self.response(#"{"status":"ok","data":{"total":1,"logs":[{"id":"log-1","event_type":"system","status":"success","created_at":"2026-09-08T10:00:00Z"}]}}"#) + case .notificationQueue: + return Self.response(#"{"status":"ok","data":{"total":1,"items":[{"id":"queue-1","event_type":"system","status":"failed","created_at":"2026-09-08T10:00:00Z"}]}}"#) + default: + return Self.response(#"{"status":"ok"}"#) + } + } + + private static func response(_ json: String) -> UpstreamResponse { + UpstreamResponse(status: 200, data: Data(json.utf8)) + } +}