From caeeb9a1cfe3b4c1adab58451ec40f0ab0976b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 21 Jul 2026 18:02:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=9C=AA=E8=AF=BB=E6=95=B0=E3=80=81=E5=85=A8=E9=83=A8=E5=B7=B2?= =?UTF-8?q?=E8=AF=BB=E4=B8=8E=E9=A6=96=E9=A1=B5=E7=BA=A2=E7=82=B9=E8=A7=92?= =?UTF-8?q?=E6=A0=87=E5=90=8C=E6=AD=A5=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 固定消息中心为首页入口,并在推送到达、已读后刷新桌面角标。 Co-authored-by: Cursor --- docs/消息中心未读数量与全部已读接口需求.md | 204 ++++++++++++++++++ .../Core/Push/PushNotificationManager.swift | 41 +++- .../Home/Services/HomeCommonMenuStore.swift | 16 +- .../Home/Services/HomeMenuCatalog.swift | 8 + .../ViewModels/AllFunctionsViewModel.swift | 1 + .../Home/ViewModels/HomeViewModel.swift | 24 +++ .../MessageCenter/API/MessageCenterAPI.swift | 28 ++- .../Models/MessageCenterModels.swift | 40 ++++ .../ViewModels/MessageCenterViewModels.swift | 39 +++- suixinkan/UI/Home/HomeViewController.swift | 43 +++- .../UI/Home/Views/HomeScenicHeaderView.swift | 51 ++++- .../MessageCenterViewController.swift | 22 +- .../AllFunctionsViewModelTests.swift | 4 +- suixinkanTests/HomeCommonMenuStoreTests.swift | 24 ++- suixinkanTests/HomeViewModelTests.swift | 91 +++++++- suixinkanTests/MessageCenterAPITests.swift | 34 ++- .../MessageCenterViewModelTests.swift | 77 ++++++- suixinkanTests/PushNotificationTests.swift | 38 +++- 18 files changed, 748 insertions(+), 37 deletions(-) create mode 100644 docs/消息中心未读数量与全部已读接口需求.md diff --git a/docs/消息中心未读数量与全部已读接口需求.md b/docs/消息中心未读数量与全部已读接口需求.md new file mode 100644 index 0000000..cb9cef7 --- /dev/null +++ b/docs/消息中心未读数量与全部已读接口需求.md @@ -0,0 +1,204 @@ +# 消息中心未读数量与全部已读接口需求 + +## 1. 需求背景 + +iOS App 需要实现以下功能: + +1. 首页展示消息中心入口,有未读消息时显示红点。 +2. 获取最新未读消息数量后,同步更新 App 桌面图标右上角的角标数量。 +3. 用户点击某条未读消息并成功标记已读后,立即更新最新未读数量和桌面角标。 +4. 消息中心导航栏右上角提供“全部已读”按钮。 +5. 全部标记已读成功后,更新首页红点,并将桌面角标更新为服务端返回的最新未读数量。 + +## 2. 当前已有接口 + +### 2.1 获取消息列表 + +```http +GET /api/app/msg/list +``` + +当前参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `last_id` | int | 否 | 分页游标,首次请求传 `0` | +| `limit` | int | 否 | 每页数量 | +| `unread` | int | 否 | `0` 获取全部消息,`1` 只获取未读消息 | + +该接口仅返回分页消息,没有返回未读消息总数。客户端若通过分页遍历全部未读消息来统计数量,请求次数和数据流量都会随着未读消息数量增长,因此不适合作为桌面角标的常规数据来源。 + +### 2.2 标记单条消息已读 + +```http +POST /api/app/msg/read?id={message_id} +``` + +当前接口可以标记单条消息已读,但成功响应没有返回操作后的最新未读数量。 + +## 3. 需要新增或调整的接口 + +### 3.1 获取未读消息总数 + +建议新增: + +```http +GET /api/app/msg/unread-count +``` + +#### 请求说明 + +- 不需要业务请求参数。 +- 根据当前登录 Token 识别用户及业务账号。 +- 返回当前账号下尚未读取的消息总数。 + +#### 成功响应示例 + +```json +{ + "code": 100000, + "msg": "success", + "data": { + "unread_count": 12 + } +} +``` + +#### 字段说明 + +| 字段 | 类型 | 必有 | 说明 | +| --- | --- | --- | --- | +| `unread_count` | int | 是 | 当前账号的最新未读消息数量,最小值为 `0` | + +### 3.2 全部标记为已读 + +建议新增: + +```http +POST /api/app/msg/read-all +``` + +#### 请求说明 + +- 不需要业务请求参数和请求体。 +- 根据当前登录 Token 识别用户及业务账号。 +- 将当前账号下请求执行时已经存在的所有未读消息标记为已读。 +- 接口需要支持幂等调用;没有未读消息时仍返回成功。 + +#### 成功响应示例 + +```json +{ + "code": 100000, + "msg": "success", + "data": { + "updated_count": 12, + "unread_count": 0 + } +} +``` + +#### 字段说明 + +| 字段 | 类型 | 必有 | 说明 | +| --- | --- | --- | --- | +| `updated_count` | int | 是 | 本次实际被更新为已读的消息数量 | +| `unread_count` | int | 是 | 操作完成后的最新未读消息数量 | + +> 客户端应以响应中的 `unread_count` 为准,不应直接假定其一定为 `0`。例如“全部已读”执行期间如果有新消息到达,操作完成后仍可能存在新的未读消息。 + +### 3.3 调整单条已读接口响应 + +保留现有接口: + +```http +POST /api/app/msg/read?id={message_id} +``` + +建议成功响应增加最新未读数量: + +```json +{ + "code": 100000, + "msg": "success", + "data": { + "unread_count": 11 + } +} +``` + +#### 字段说明 + +| 字段 | 类型 | 必有 | 说明 | +| --- | --- | --- | --- | +| `unread_count` | int | 是 | 单条消息标记已读后的最新未读消息数量 | + +#### 幂等要求 + +- 对已经处于已读状态的消息重复调用时,接口仍返回成功和最新 `unread_count`。 +- 消息不存在或不属于当前账号时,返回明确的业务错误,不能修改其他账号的消息。 + +## 4. 统一约定 + +### 4.1 账号范围 + +- 所有接口必须根据登录 Token 限定当前用户及当前业务账号的数据范围。 +- 景区账号、门店账号等不同业务账号之间的未读数量不能互相污染。 +- 切换账号后,客户端会重新调用未读数量接口。 + +### 4.2 数值约束 + +- `unread_count` 和 `updated_count` 必须返回 JSON 整数。 +- 数值不得为负数。 +- 即使数量为 `0`,字段也必须存在,不能返回 `null`、空字符串或省略字段。 + +### 4.3 一致性与并发 + +- 单条已读或全部已读操作及其响应中的 `unread_count` 应基于同一账号范围。 +- `read-all` 应尽量在事务内完成批量更新和剩余未读数统计。 +- 如果操作期间有新消息写入,响应应返回操作完成时数据库中的真实 `unread_count`。 +- 客户端始终以最近一次成功接口响应中的 `unread_count` 更新首页红点和桌面角标。 + +### 4.4 错误响应 + +沿用现有统一业务响应结构,例如: + +```json +{ + "code": 100001, + "msg": "消息不存在或无权操作", + "data": null +} +``` + +建议至少区分以下情况: + +- Token 无效或登录已过期。 +- 单条消息不存在。 +- 单条消息不属于当前账号。 +- 数据库更新失败。 + +## 5. 客户端调用时机 + +| 场景 | 调用接口 | 客户端处理 | +| --- | --- | --- | +| 登录后首次进入首页 | `GET /msg/unread-count` | 更新首页红点和桌面角标 | +| App 回到前台 | `GET /msg/unread-count` | 校准最新未读数量 | +| 前台收到新消息推送 | `GET /msg/unread-count` | 更新首页红点和桌面角标 | +| 点击一条未读消息 | `POST /msg/read` | 使用返回的 `unread_count` 更新红点和桌面角标 | +| 点击“全部已读” | `POST /msg/read-all` | 刷新消息列表,使用返回的 `unread_count` 更新红点和桌面角标 | +| 切换业务账号 | `GET /msg/unread-count` | 使用新账号的数量覆盖旧账号角标 | +| 退出登录 | 无 | 客户端将桌面角标清零 | + +## 6. 验收标准 + +1. 未读数量为 `0` 时,接口稳定返回 `{"unread_count": 0}`。 +2. 存在多页未读消息时,未读数量接口返回完整总数,而不是当前页数量。 +3. 单条未读消息标记成功后,返回的 `unread_count` 与数据库实际剩余未读数一致。 +4. 对同一消息重复调用单条已读接口不会重复扣减未读数量。 +5. 全部已读接口可一次处理当前账号的所有未读消息,并返回实际更新数量。 +6. 重复调用全部已读接口仍成功,返回 `updated_count = 0`。 +7. 不同业务账号的未读数量相互隔离。 +8. 并发到达新消息时,响应中的 `unread_count` 能反映操作完成时的真实剩余数量。 +9. 所有接口继续使用项目现有统一鉴权、业务状态码和响应信封格式。 + diff --git a/suixinkan/Core/Push/PushNotificationManager.swift b/suixinkan/Core/Push/PushNotificationManager.swift index c8a7ba4..53f90d1 100644 --- a/suixinkan/Core/Push/PushNotificationManager.swift +++ b/suixinkan/Core/Push/PushNotificationManager.swift @@ -1,6 +1,32 @@ import UIKit import UserNotifications +/// App 桌面图标角标设置能力,便于业务同步未读消息数量并进行测试替换。 +@MainActor +protocol ApplicationIconBadgeSetting: AnyObject { + /// 将桌面图标角标更新为指定非负数量。 + func setBadgeCount(_ count: Int) async +} + +/// 使用 UserNotifications 更新 App 桌面图标角标的系统实现。 +@MainActor +final class SystemApplicationIconBadgeSetter: ApplicationIconBadgeSetting { + /// 全局角标设置器。 + static let shared = SystemApplicationIconBadgeSetter() + + private init() {} + + func setBadgeCount(_ count: Int) async { + do { + try await UNUserNotificationCenter.current().setBadgeCount(max(count, 0)) + } catch { + #if DEBUG + print("Update application icon badge failed: \(error.localizedDescription)") + #endif + } + } +} + /// 极光 SDK 的最小能力边界,便于推送生命周期逻辑使用测试替身。 @MainActor protocol PushSDKProviding: AnyObject { @@ -85,6 +111,7 @@ final class PushNotificationManager: NSObject { private let appStore: AppStore private let defaults: UserDefaults private let router: any PushRouting + private let applicationIconBadgeSetter: any ApplicationIconBadgeSetting private var isInitialized = false private var didRequestAuthorization = false @@ -99,13 +126,15 @@ final class PushNotificationManager: NSObject { api: (any PushRegistrationServing)? = nil, appStore: AppStore = .shared, defaults: UserDefaults = .standard, - router: (any PushRouting)? = nil + router: (any PushRouting)? = nil, + applicationIconBadgeSetter: (any ApplicationIconBadgeSetting)? = nil ) { self.sdk = sdk ?? JPushSDKAdapter() self.api = api ?? NetworkServices.shared.pushAPI self.appStore = appStore self.defaults = defaults self.router = router ?? PushRouteCoordinator.shared + self.applicationIconBadgeSetter = applicationIconBadgeSetter ?? SystemApplicationIconBadgeSetter.shared super.init() } @@ -152,6 +181,12 @@ final class PushNotificationManager: NSObject { uploadTask = nil queuedForcedUpload = false router.resetPendingRoute() + Task { await updateApplicationIconBadgeCount(0) } + } + + /// 将桌面 App Icon 角标更新为最新未读消息数量。 + func updateApplicationIconBadgeCount(_ count: Int) async { + await applicationIconBadgeSetter.setBadgeCount(max(count, 0)) } /// App 回到前台时补偿失败或尚未完成的 Registration ID 上报。 @@ -204,6 +239,7 @@ final class PushNotificationManager: NSObject { /// 处理后台静默通知并向极光上报到达,不触发页面跳转。 func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) { JPUSHService.handleRemoteNotification(userInfo) + NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil) } private func observeJPushNetworkLogin() { @@ -317,6 +353,9 @@ extension PushNotificationManager: JPUSHRegisterDelegate { withCompletionHandler completionHandler: @escaping (Int) -> Void ) { JPUSHService.handleRemoteNotification(notification.request.content.userInfo) + Task { @MainActor in + NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil) + } let options: UNNotificationPresentationOptions = [.banner, .list, .sound, .badge] completionHandler(Int(options.rawValue)) } diff --git a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift index 8134f36..40b267f 100644 --- a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift +++ b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift @@ -17,18 +17,23 @@ final class HomeCommonMenuStore { /// 读取已保存的常用 URI;无记录时返回空数组。 func savedCommonURIs(accountScope: String, roleCode: String) -> [String] { guard let key = storageKey(accountScope: accountScope, roleCode: roleCode) else { return [] } - return defaults.stringArray(forKey: key) ?? [] + let saved = defaults.stringArray(forKey: key) ?? [] + let sanitized = saved.filter(HomeMenuCatalog.isCustomizable(uri:)) + if sanitized != saved { + defaults.set(sanitized, forKey: key) + } + return sanitized } /// 保存常用 URI 列表。 func saveCommonURIs(_ uris: [String], accountScope: String, roleCode: String) { guard let key = storageKey(accountScope: accountScope, roleCode: roleCode) else { return } - defaults.set(uris, forKey: key) + defaults.set(uris.filter(HomeMenuCatalog.isCustomizable(uri:)), forKey: key) } /// 根据权限生成默认常用 URI(最多 4 个)。 static func defaultCommonURIs(from permissions: [HomePermissionItem]) -> [String] { - let uris = permissions.map(\.uri) + let uris = permissions.map(\.uri).filter(HomeMenuCatalog.isCustomizable(uri:)) return uris.count > 4 ? Array(uris.prefix(4)) : uris } @@ -51,8 +56,9 @@ final class HomeCommonMenuStore { commonURIs: [String] ) -> (common: [HomeMenuItem], more: [HomeMenuItem]) { let commonSet = Set(commonURIs) - let common = allMenus.filter { commonSet.contains($0.uri) } - let more = allMenus.filter { !commonSet.contains($0.uri) } + let customizableMenus = allMenus.filter { HomeMenuCatalog.isCustomizable(uri: $0.uri) } + let common = customizableMenus.filter { commonSet.contains($0.uri) } + let more = customizableMenus.filter { !commonSet.contains($0.uri) } return (common, more) } diff --git a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift index 7cc8b7f..f6d423b 100644 --- a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift +++ b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift @@ -8,6 +8,8 @@ import Foundation /// 首页菜单静态 catalog,与服务端 permission URI 取交集后展示。 enum HomeMenuCatalog { + private static let fixedHomeEntryURIs: Set = ["message_center"] + /// 全部已知菜单项,对齐 Android `Constants.menuList`。 static let allItems: [HomeMenuItem] = [ HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "home_menu_space"), @@ -45,6 +47,7 @@ enum HomeMenuCatalog { /// 将服务端权限映射为可展示菜单,保持服务端顺序。 static func visibleMenus(from permissions: [HomePermissionItem]) -> [HomeMenuItem] { permissions.compactMap { permission in + guard isCustomizable(uri: permission.uri) else { return nil } guard let catalog = allItems.first(where: { $0.uri == permission.uri }) else { return nil } return HomeMenuItem( uri: catalog.uri, @@ -59,4 +62,9 @@ enum HomeMenuCatalog { static func item(for uri: String) -> HomeMenuItem? { allItems.first { $0.uri == uri } } + + /// 判断入口是否允许出现在首页常用应用及其自定义页面中。 + static func isCustomizable(uri: String) -> Bool { + !fixedHomeEntryURIs.contains(uri) + } } diff --git a/suixinkan/Features/Home/ViewModels/AllFunctionsViewModel.swift b/suixinkan/Features/Home/ViewModels/AllFunctionsViewModel.swift index 7c13fe8..c64ded5 100644 --- a/suixinkan/Features/Home/ViewModels/AllFunctionsViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/AllFunctionsViewModel.swift @@ -48,6 +48,7 @@ final class AllFunctionsViewModel { /// 将菜单加入常用应用。 func addToCommon(_ menu: HomeMenuItem) { + guard HomeMenuCatalog.isCustomizable(uri: menu.uri) else { return } guard !commonMenus.contains(where: { $0.uri == menu.uri }) else { return } commonMenus.append(menu) moreMenus.removeAll { $0.uri == menu.uri } diff --git a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift index 3c4c529..a954d91 100644 --- a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift @@ -25,6 +25,7 @@ final class HomeViewModel { private(set) var reportTimeText = "" private(set) var isLocationReporting = false private(set) var needsPermissionReload = false + private(set) var unreadMessageCount = 0 var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? @@ -65,6 +66,7 @@ final class HomeViewModel { var isOnline: Bool { locationStateStore.isOnline } var countdownDisplayText: String { locationStateStore.countdownDisplayText } var reminderMinutes: Int { locationStateStore.reminderMinutes } + var hasUnreadMessages: Bool { unreadMessageCount > 0 } /// 首次进入首页时加载权限并恢复在线状态。 func initialize(api: HomeAPI) async { @@ -76,6 +78,28 @@ final class HomeViewModel { /// 账号切换后标记需要重新拉取权限。 func markNeedsPermissionReload() { needsPermissionReload = true + unreadMessageCount = 0 + } + + /// 查询未读消息总数,成功时更新首页状态并返回 `true`。 + func refreshUnreadMessageStatus(api: any MessageCenterServing) async -> Bool { + do { + let response = try await api.unreadCount() + unreadMessageCount = response.unreadCount + notifyStateChange() + return true + } catch is CancellationError { + return false + } catch { + // 查询失败时保留上一次状态,避免网络抖动导致红点错误消失。 + return false + } + } + + /// 使用已知的最新未读消息数量立即刷新首页状态。 + func updateUnreadMessageCount(_ count: Int) { + unreadMessageCount = max(count, 0) + notifyStateChange() } /// 查询待审核记录并决定进入申请页或审核状态页。 diff --git a/suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift b/suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift index 3910772..5adfd03 100644 --- a/suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift +++ b/suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift @@ -11,8 +11,14 @@ protocol MessageCenterServing { /// 拉取消息列表。 func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse - /// 标记消息已读。 - func markAsRead(messageId: Int) async throws + /// 获取当前账号未读消息总数。 + func unreadCount() async throws -> MessageUnreadCountResponse + + /// 标记消息已读并返回最新未读数量。 + func markAsRead(messageId: Int) async throws -> MessageUnreadCountResponse + + /// 将当前账号全部消息标记已读。 + func markAllAsRead() async throws -> MessageReadAllResponse /// 删除消息。 func delete(messageId: Int) async throws @@ -43,9 +49,16 @@ final class MessageCenterAPI: MessageCenterServing { ) } + /// GET /api/app/msg/unread-count + func unreadCount() async throws -> MessageUnreadCountResponse { + try await client.send( + APIRequest(method: .get, path: "/api/app/msg/unread-count") + ) + } + /// POST /api/app/msg/read - func markAsRead(messageId: Int) async throws { - let _: EmptyPayload = try await client.send( + func markAsRead(messageId: Int) async throws -> MessageUnreadCountResponse { + try await client.send( APIRequest( method: .post, path: "/api/app/msg/read", @@ -54,6 +67,13 @@ final class MessageCenterAPI: MessageCenterServing { ) } + /// POST /api/app/msg/read-all + func markAllAsRead() async throws -> MessageReadAllResponse { + try await client.send( + APIRequest(method: .post, path: "/api/app/msg/read-all") + ) + } + /// POST /api/app/msg/delete func delete(messageId: Int) async throws { let _: EmptyPayload = try await client.send( diff --git a/suixinkan/Features/MessageCenter/Models/MessageCenterModels.swift b/suixinkan/Features/MessageCenter/Models/MessageCenterModels.swift index cb81c0c..207f1af 100644 --- a/suixinkan/Features/MessageCenter/Models/MessageCenterModels.swift +++ b/suixinkan/Features/MessageCenter/Models/MessageCenterModels.swift @@ -32,6 +32,46 @@ enum MessageJSONValue: Decodable, Hashable, Sendable { } } +/// 消息未读数量响应,用于同步首页红点和桌面图标角标。 +struct MessageUnreadCountResponse: Decodable, Equatable, Sendable { + let unreadCount: Int + + enum CodingKeys: String, CodingKey { + case unreadCount = "unread_count" + } + + init(unreadCount: Int = 0) { + self.unreadCount = max(unreadCount, 0) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + unreadCount = max(try container.decodeLossyInt(forKey: .unreadCount) ?? 0, 0) + } +} + +/// 全部标记已读响应,包含本次更新数量和操作后的未读数量。 +struct MessageReadAllResponse: Decodable, Equatable, Sendable { + let updatedCount: Int + let unreadCount: Int + + enum CodingKeys: String, CodingKey { + case updatedCount = "updated_count" + case unreadCount = "unread_count" + } + + init(updatedCount: Int = 0, unreadCount: Int = 0) { + self.updatedCount = max(updatedCount, 0) + self.unreadCount = max(unreadCount, 0) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + updatedCount = max(try container.decodeLossyInt(forKey: .updatedCount) ?? 0, 0) + unreadCount = max(try container.decodeLossyInt(forKey: .unreadCount) ?? 0, 0) + } +} + /// 消息中心列表响应,对齐 Android `MsgResponse`。 struct MessageListResponse: Decodable, Equatable, Sendable { let hasMore: Bool diff --git a/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModels.swift b/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModels.swift index 1cffdb2..8fd798c 100644 --- a/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModels.swift +++ b/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModels.swift @@ -11,11 +11,13 @@ final class MessageCenterViewModel { private(set) var isLoading = false private(set) var isRefreshing = false private(set) var isLoadingMore = false + private(set) var isMarkingAllAsRead = false private(set) var canLoadMore = false var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onOpenDetail: ((MessageItem) -> Void)? + var onUnreadMessageCountChange: ((Int) -> Void)? private let pageSize = 20 private var lastId = 0 @@ -55,9 +57,10 @@ final class MessageCenterViewModel { } do { - try await api.markAsRead(messageId: id) + let response = try await api.markAsRead(messageId: id) let readItem = item.markedRead() items = items.map { $0.id == id ? readItem : $0 } + notifyUnreadMessageCountChanged(response.unreadCount) onOpenDetail?(readItem) } catch is CancellationError { return @@ -66,6 +69,30 @@ final class MessageCenterViewModel { } } + /// 将当前账号的全部消息标记已读,成功时返回服务端最新统计。 + func markAllAsRead(api: any MessageCenterServing) async -> MessageReadAllResponse? { + guard !isLoading, !isMarkingAllAsRead else { return nil } + isMarkingAllAsRead = true + notifyStateChange() + defer { + isMarkingAllAsRead = false + notifyStateChange() + } + + do { + let response = try await api.markAllAsRead() + items = items.map { $0.isRead ? $0 : $0.markedRead() } + notifyUnreadMessageCountChanged(response.unreadCount) + onShowMessage?(response.updatedCount > 0 ? "已全部标记为已读" : "暂无未读消息") + return response + } catch is CancellationError { + return nil + } catch { + onShowMessage?("全部标记已读失败,请稍后重试") + return nil + } + } + /// 删除后从本地列表移除消息。 func removeMessage(id: Int) { items.removeAll { $0.id == id } @@ -110,6 +137,16 @@ final class MessageCenterViewModel { private func notifyStateChange() { onStateChange?() } + + private func notifyUnreadMessageCountChanged(_ count: Int) { + let normalizedCount = max(count, 0) + onUnreadMessageCountChange?(normalizedCount) + NotificationCenter.default.post( + name: NotificationName.unreadMessageCountDidChange, + object: nil, + userInfo: [NotificationUserInfoKey.unreadCount: normalizedCount] + ) + } } /// 消息详情页 ViewModel,负责删除当前消息。 diff --git a/suixinkan/UI/Home/HomeViewController.swift b/suixinkan/UI/Home/HomeViewController.swift index 62a9e73..b2f6b42 100644 --- a/suixinkan/UI/Home/HomeViewController.swift +++ b/suixinkan/UI/Home/HomeViewController.swift @@ -13,6 +13,7 @@ final class HomeViewController: BaseViewController { private let viewModel = HomeViewModel() private let homeAPI = NetworkServices.shared.homeAPI + private let messageCenterAPI = NetworkServices.shared.messageCenterAPI private let scenicHeaderView = HomeScenicHeaderView() private var collectionView: UICollectionView! @@ -74,6 +75,7 @@ final class HomeViewController: BaseViewController { Task { @MainActor in self?.showToast(message) } } scenicHeaderView.onTap = { [weak self] in self?.presentScenicSelection() } + scenicHeaderView.onMessageTap = { [weak self] in self?.openMessageCenter() } collectionView.delegate = self NotificationCenter.default.addObserver( @@ -88,11 +90,26 @@ final class HomeViewController: BaseViewController { name: NotificationName.scenicDidChange, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleUnreadMessageCountDidChange(_:)), + name: NotificationName.unreadMessageCountDidChange, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleUnreadMessageCountDidChange(_:)), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) + if hasInitialized { + Task { await refreshUnreadMessageStatus() } + } } override func viewDidAppear(_ animated: Bool) { @@ -272,6 +289,7 @@ final class HomeViewController: BaseViewController { private func initializeHome() async { showLoading() await viewModel.initialize(api: homeAPI) + await refreshUnreadMessageStatus() hideLoading() applyViewModel() @@ -287,7 +305,10 @@ final class HomeViewController: BaseViewController { @MainActor private func applyViewModel() { - scenicHeaderView.apply(scenicName: viewModel.currentScenicName) + scenicHeaderView.apply( + scenicName: viewModel.currentScenicName, + hasUnreadMessages: viewModel.hasUnreadMessages + ) let showWorkBlock = !viewModel.isMinimalTopRole let showStore = viewModel.currentAppRole == .storeAdmin && viewModel.storeItem != nil @@ -499,6 +520,10 @@ final class HomeViewController: BaseViewController { pushScenicSelection() } + private func openMessageCenter() { + navigationController?.pushViewController(MessageCenterViewController(), animated: true) + } + private func pushScenicSelection() { navigationController?.setNavigationBarHidden(false, animated: true) let controller = ScenicSelectionViewController() @@ -529,6 +554,7 @@ final class HomeViewController: BaseViewController { viewModel.markNeedsPermissionReload() Task { await viewModel.reloadIfNeeded(api: homeAPI) + await refreshUnreadMessageStatus() applyViewModel() await evaluateDialogsWithDelay() } @@ -541,6 +567,21 @@ final class HomeViewController: BaseViewController { applyViewModel() } } + + @objc private func handleUnreadMessageCountDidChange(_ notification: Notification) { + guard hasInitialized else { return } + if let count = notification.userInfo?[NotificationUserInfoKey.unreadCount] as? Int { + viewModel.updateUnreadMessageCount(count) + Task { await PushNotificationManager.shared.updateApplicationIconBadgeCount(count) } + } else { + Task { await refreshUnreadMessageStatus() } + } + } + + private func refreshUnreadMessageStatus() async { + guard await viewModel.refreshUnreadMessageStatus(api: messageCenterAPI) else { return } + await PushNotificationManager.shared.updateApplicationIconBadgeCount(viewModel.unreadMessageCount) + } } extension HomeViewController: UICollectionViewDelegate { diff --git a/suixinkan/UI/Home/Views/HomeScenicHeaderView.swift b/suixinkan/UI/Home/Views/HomeScenicHeaderView.swift index bc53fc8..7a59e62 100644 --- a/suixinkan/UI/Home/Views/HomeScenicHeaderView.swift +++ b/suixinkan/UI/Home/Views/HomeScenicHeaderView.swift @@ -9,10 +9,16 @@ import UIKit /// 首页顶部景区选择条。 final class HomeScenicHeaderView: UIView { + /// 点击景区名称时触发。 var onTap: (() -> Void)? + /// 点击消息入口时触发。 + var onMessageTap: (() -> Void)? + private let titleLabel = UILabel() private let arrowView = UIImageView(image: UIImage(systemName: "chevron.down")) + private let messageButton = UIButton(type: .system) + private let unreadDotView = UIView() override init(frame: CGRect) { super.init(frame: frame) @@ -25,8 +31,24 @@ final class HomeScenicHeaderView: UIView { arrowView.contentMode = .scaleAspectFit arrowView.setContentCompressionResistancePriority(.required, for: .horizontal) + var messageConfiguration = UIButton.Configuration.plain() + messageConfiguration.image = UIImage(systemName: "bell") + messageConfiguration.baseForegroundColor = AppColor.textPrimary + messageConfiguration.contentInsets = .zero + messageButton.configuration = messageConfiguration + messageButton.accessibilityIdentifier = "home_message_button" + messageButton.addTarget(self, action: #selector(handleMessageTap), for: .touchUpInside) + + unreadDotView.backgroundColor = AppColor.danger + unreadDotView.layer.cornerRadius = 4 + unreadDotView.isUserInteractionEnabled = false + unreadDotView.isHidden = true + unreadDotView.accessibilityIdentifier = "home_message_unread_dot" + addSubview(titleLabel) addSubview(arrowView) + addSubview(messageButton) + messageButton.addSubview(unreadDotView) titleLabel.snp.makeConstraints { make in make.leading.equalTo(safeAreaLayoutGuide).offset(AppSpacing.screenHorizontalInset) @@ -34,12 +56,23 @@ final class HomeScenicHeaderView: UIView { } arrowView.snp.makeConstraints { make in make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xs) - make.trailing.lessThanOrEqualTo(safeAreaLayoutGuide).offset(-AppSpacing.screenHorizontalInset) + make.trailing.lessThanOrEqualTo(messageButton.snp.leading).offset(-AppSpacing.xs) make.centerY.equalTo(safeAreaLayoutGuide) make.width.height.equalTo(16) } + messageButton.snp.makeConstraints { make in + make.trailing.equalTo(safeAreaLayoutGuide).offset(-AppSpacing.screenHorizontalInset) + make.centerY.equalTo(safeAreaLayoutGuide) + make.width.height.equalTo(44) + } + unreadDotView.snp.makeConstraints { make in + make.centerX.equalTo(messageButton.snp.centerX).offset(9) + make.centerY.equalTo(messageButton.snp.centerY).offset(-9) + make.width.height.equalTo(8) + } let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + tap.delegate = self addGestureRecognizer(tap) } @@ -48,12 +81,26 @@ final class HomeScenicHeaderView: UIView { fatalError("init(coder:) has not been implemented") } - func apply(scenicName: String) { + /// 更新景区名称与未读消息状态。 + func apply(scenicName: String, hasUnreadMessages: Bool) { let trimmed = scenicName.trimmingCharacters(in: .whitespacesAndNewlines) titleLabel.text = trimmed.isEmpty ? "请选择景区" : trimmed + unreadDotView.isHidden = !hasUnreadMessages + messageButton.accessibilityLabel = hasUnreadMessages ? "消息中心,有未读消息" : "消息中心" } @objc private func handleTap() { onTap?() } + + @objc private func handleMessageTap() { + onMessageTap?() + } +} + +extension HomeScenicHeaderView: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard let touchedView = touch.view else { return true } + return touchedView !== messageButton && !touchedView.isDescendant(of: messageButton) + } } diff --git a/suixinkan/UI/MessageCenter/MessageCenterViewController.swift b/suixinkan/UI/MessageCenter/MessageCenterViewController.swift index 6d5695c..9142c47 100644 --- a/suixinkan/UI/MessageCenter/MessageCenterViewController.swift +++ b/suixinkan/UI/MessageCenter/MessageCenterViewController.swift @@ -19,6 +19,12 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate private let loadingIndicator = UIActivityIndicatorView(style: .medium) private let footerSpinner = UIActivityIndicatorView(style: .medium) private let emptyLabel = UILabel() + private lazy var markAllReadButton = UIBarButtonItem( + title: "全部已读", + style: .plain, + target: self, + action: #selector(markAllReadTapped) + ) private var dataSource: UITableViewDiffableDataSource! @@ -46,6 +52,7 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate override func setupNavigationBar() { title = "消息中心" + navigationItem.rightBarButtonItem = markAllReadButton } override func setupUI() { @@ -79,7 +86,8 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate override func setupConstraints() { tableView.snp.makeConstraints { make in - make.edges.equalTo(view.safeAreaLayoutGuide) + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.bottom.equalToSuperview() } loadingIndicator.snp.makeConstraints { make in make.center.equalToSuperview() @@ -103,6 +111,11 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate self?.openDetail(message) } } + viewModel.onUnreadMessageCountChange = { count in + Task { @MainActor in + await PushNotificationManager.shared.updateApplicationIconBadgeCount(count) + } + } } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { @@ -125,6 +138,12 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate } } + @objc private func markAllReadTapped() { + Task { @MainActor in + await viewModel.markAllAsRead(api: api) + } + } + private func configureDataSource() { dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in let cell = tableView.dequeueReusableCell( @@ -154,6 +173,7 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate } tableView.tableFooterView = footerView() + markAllReadButton.isEnabled = !viewModel.isLoading && !viewModel.isMarkingAllAsRead } private func applySnapshot(animated: Bool) { diff --git a/suixinkanTests/AllFunctionsViewModelTests.swift b/suixinkanTests/AllFunctionsViewModelTests.swift index 8214b17..9dfe9f7 100644 --- a/suixinkanTests/AllFunctionsViewModelTests.swift +++ b/suixinkanTests/AllFunctionsViewModelTests.swift @@ -29,7 +29,7 @@ final class AllFunctionsViewModelTests: XCTestCase { super.tearDown() } - func testLoadFunctionsUsesDefaultFirstFourWhenNoSavedURIs() { + func testLoadFunctionsUsesDefaultFirstFourAndExcludesMessageCenter() { appStore.permissions.savePermissionItems([ HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"), @@ -44,7 +44,7 @@ final class AllFunctionsViewModelTests: XCTestCase { XCTAssertEqual(viewModel.commonMenus.map(\.uri), [ "wallet", "cloud_management", "task_management", "system_settings", ]) - XCTAssertEqual(viewModel.moreMenus.map(\.uri), ["message_center"]) + XCTAssertTrue(viewModel.moreMenus.isEmpty) XCTAssertEqual( menuStore.savedCommonURIs( accountScope: appStore.session.accountCachePrefix, diff --git a/suixinkanTests/HomeCommonMenuStoreTests.swift b/suixinkanTests/HomeCommonMenuStoreTests.swift index c5fb90f..582e1cc 100644 --- a/suixinkanTests/HomeCommonMenuStoreTests.swift +++ b/suixinkanTests/HomeCommonMenuStoreTests.swift @@ -23,14 +23,26 @@ final class HomeCommonMenuStoreTests: XCTestCase { super.tearDown() } - func testDefaultCommonURIsTakeFirstFour() { - let permissions = (1...6).map { + func testDefaultCommonURIsTakeFirstFourAndExcludeMessageCenter() { + let permissions = [ + HomePermissionItem(id: "0", name: "消息", uri: "message_center"), + ] + (1...5).map { HomePermissionItem(id: "\($0)", name: "菜单\($0)", uri: "uri_\($0)") } let uris = HomeCommonMenuStore.defaultCommonURIs(from: permissions) XCTAssertEqual(uris, ["uri_1", "uri_2", "uri_3", "uri_4"]) } + func testSavedMessageCenterIsRemovedFromLegacyCommonURIs() { + let key = "scenic_user_u1_role_photographer_common_uris" + defaults.set(["message_center", "wallet"], forKey: key) + + let uris = store.savedCommonURIs(accountScope: "scenic_user_u1", roleCode: "photographer") + + XCTAssertEqual(uris, ["wallet"]) + XCTAssertEqual(defaults.stringArray(forKey: key), ["wallet"]) + } + func testSavedCommonURIsOverrideDefault() { let permissions = [ HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), @@ -65,8 +77,12 @@ final class HomeCommonMenuStoreTests: XCTestCase { HomeMenuItem(uri: "wallet", title: "钱包", iconName: "wallet.pass"), HomeMenuItem(uri: "cloud_management", title: "云盘", iconName: "icloud"), HomeMenuItem(uri: "task_management", title: "任务", iconName: "checklist"), + HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell"), ] - let split = HomeCommonMenuStore.splitMenus(from: menus, commonURIs: ["wallet", "task_management"]) + let split = HomeCommonMenuStore.splitMenus( + from: menus, + commonURIs: ["wallet", "task_management", "message_center"] + ) XCTAssertEqual(split.common.map(\.uri), ["wallet", "task_management"]) XCTAssertEqual(split.more.map(\.uri), ["cloud_management"]) } @@ -75,7 +91,7 @@ final class HomeCommonMenuStoreTests: XCTestCase { let item = HomeMenuCatalog.item(for: "report_photographer") XCTAssertEqual(item?.title, "举报摄影师") - XCTAssertEqual(item?.iconName, "shield.fill") + XCTAssertEqual(item?.iconName, "shield.lefthalf.filled") } func testHomeMenuCatalogUsesIOS16CompatibleIcons() { diff --git a/suixinkanTests/HomeViewModelTests.swift b/suixinkanTests/HomeViewModelTests.swift index b170caf..953750c 100644 --- a/suixinkanTests/HomeViewModelTests.swift +++ b/suixinkanTests/HomeViewModelTests.swift @@ -90,10 +90,10 @@ final class HomeViewModelTests: XCTestCase { let menuStore = HomeCommonMenuStore(defaults: defaults) appStore.permissions.savePermissionItems([ HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), - HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"), - HomePermissionItem(id: "3", name: "任务", uri: "task_management"), - HomePermissionItem(id: "4", name: "设置", uri: "system_settings"), - HomePermissionItem(id: "5", name: "消息", uri: "message_center"), + HomePermissionItem(id: "2", name: "消息", uri: "message_center"), + HomePermissionItem(id: "3", name: "云盘", uri: "cloud_management"), + HomePermissionItem(id: "4", name: "任务", uri: "task_management"), + HomePermissionItem(id: "5", name: "设置", uri: "system_settings"), ]) let viewModel = HomeViewModel(appStore: appStore, commonMenuStore: menuStore) @@ -231,6 +231,50 @@ final class HomeViewModelTests: XCTestCase { XCTAssertEqual(session.requests.count, 1) } + func testUnreadMessageStatusUsesCountEndpointAndUpdatesBadgeState() async { + let api = HomeMessageCenterAPIMock() + api.unreadCountResponses = [ + MessageUnreadCountResponse(unreadCount: 9), + MessageUnreadCountResponse(unreadCount: 0), + ] + let viewModel = HomeViewModel(appStore: appStore) + + await viewModel.refreshUnreadMessageStatus(api: api) + XCTAssertTrue(viewModel.hasUnreadMessages) + XCTAssertEqual(viewModel.unreadMessageCount, 9) + + await viewModel.refreshUnreadMessageStatus(api: api) + XCTAssertFalse(viewModel.hasUnreadMessages) + XCTAssertEqual(viewModel.unreadMessageCount, 0) + XCTAssertEqual(api.unreadCountCallCount, 2) + } + + func testScenicHeaderMessageButtonShowsUnreadStateAndHandlesTap() throws { + let headerView = HomeScenicHeaderView(frame: CGRect(x: 0, y: 0, width: 390, height: 100)) + var tapCount = 0 + headerView.onMessageTap = { tapCount += 1 } + + headerView.apply(scenicName: "测试景区", hasUnreadMessages: true) + headerView.layoutIfNeeded() + + let button = try XCTUnwrap( + findSubview(in: headerView) { $0.accessibilityIdentifier == "home_message_button" } as? UIButton + ) + let unreadDot = try XCTUnwrap( + findSubview(in: headerView) { $0.accessibilityIdentifier == "home_message_unread_dot" } + ) + XCTAssertEqual(button.bounds.size, CGSize(width: 44, height: 44)) + XCTAssertEqual(button.accessibilityLabel, "消息中心,有未读消息") + XCTAssertFalse(unreadDot.isHidden) + + button.sendActions(for: .touchUpInside) + XCTAssertEqual(tapCount, 1) + + headerView.apply(scenicName: "测试景区", hasUnreadMessages: false) + XCTAssertEqual(button.accessibilityLabel, "消息中心") + XCTAssertTrue(unreadDot.isHidden) + } + func testRefreshLocalDisplayStateClearsWhenNameMissing() { appStore.session.currentScenicId = 10 appStore.session.currentScenicName = "" @@ -283,6 +327,45 @@ final class HomeViewModelTests: XCTestCase { } return nil } + + private func findSubview(in view: UIView, matching predicate: (UIView) -> Bool) -> UIView? { + if predicate(view) { + return view + } + for subview in view.subviews { + if let match = findSubview(in: subview, matching: predicate) { + return match + } + } + return nil + } +} + +/// 首页未读消息查询替身。 +@MainActor +private final class HomeMessageCenterAPIMock: MessageCenterServing { + var unreadCountResponses: [MessageUnreadCountResponse] = [] + var unreadCountCallCount = 0 + + func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse { + MessageListResponse() + } + + func unreadCount() async throws -> MessageUnreadCountResponse { + unreadCountCallCount += 1 + guard !unreadCountResponses.isEmpty else { return MessageUnreadCountResponse() } + return unreadCountResponses.removeFirst() + } + + func markAsRead(messageId: Int) async throws -> MessageUnreadCountResponse { + MessageUnreadCountResponse() + } + + func markAllAsRead() async throws -> MessageReadAllResponse { + MessageReadAllResponse() + } + + func delete(messageId: Int) async throws {} } private extension UIColor { diff --git a/suixinkanTests/MessageCenterAPITests.swift b/suixinkanTests/MessageCenterAPITests.swift index 6cf69e6..9783949 100644 --- a/suixinkanTests/MessageCenterAPITests.swift +++ b/suixinkanTests/MessageCenterAPITests.swift @@ -53,21 +53,37 @@ final class MessageCenterAPITests: XCTestCase { XCTAssertEqual(response.items.first?.extraData?["order_no"], .string("NO123")) } - func testReadAndDeleteUseAndroidPathsAndBody() async throws { - let session = MockURLSession(responses: [envelopeJSON("{}"), envelopeJSON("{}")]) + func testUnreadCountReadAllReadAndDeleteUseExpectedContracts() async throws { + let session = MockURLSession(responses: [ + envelopeJSON(#"{"unread_count":"12"}"#), + envelopeJSON(#"{"unread_count":11}"#), + envelopeJSON(#"{"updated_count":11,"unread_count":0}"#), + envelopeJSON("{}"), + ]) let api = MessageCenterAPI(client: APIClient(environment: .testing, session: session)) - try await api.markAsRead(messageId: 21) + let unread = try await api.unreadCount() + let read = try await api.markAsRead(messageId: 21) + let readAll = try await api.markAllAsRead() try await api.delete(messageId: 21) - XCTAssertEqual(session.requests[0].httpMethod, "POST") - XCTAssertEqual(session.requests[0].url?.path, "/api/app/msg/read") - let readQuery = URLComponents(url: try XCTUnwrap(session.requests[0].url), resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(unread.unreadCount, 12) + XCTAssertEqual(session.requests[0].httpMethod, "GET") + XCTAssertEqual(session.requests[0].url?.path, "/api/app/msg/unread-count") + + XCTAssertEqual(read.unreadCount, 11) + XCTAssertEqual(session.requests[1].httpMethod, "POST") + XCTAssertEqual(session.requests[1].url?.path, "/api/app/msg/read") + let readQuery = URLComponents(url: try XCTUnwrap(session.requests[1].url), resolvingAgainstBaseURL: false)?.queryItems XCTAssertEqual(readQuery?.first { $0.name == "id" }?.value, "21") - XCTAssertEqual(session.requests[1].httpMethod, "POST") - XCTAssertEqual(session.requests[1].url?.path, "/api/app/msg/delete") - let deleteBody = try bodyJSON(session.requests[1]) + XCTAssertEqual(readAll, MessageReadAllResponse(updatedCount: 11, unreadCount: 0)) + XCTAssertEqual(session.requests[2].httpMethod, "POST") + XCTAssertEqual(session.requests[2].url?.path, "/api/app/msg/read-all") + + XCTAssertEqual(session.requests[3].httpMethod, "POST") + XCTAssertEqual(session.requests[3].url?.path, "/api/app/msg/delete") + let deleteBody = try bodyJSON(session.requests[3]) XCTAssertEqual(deleteBody["id"] as? Int, 21) } diff --git a/suixinkanTests/MessageCenterViewModelTests.swift b/suixinkanTests/MessageCenterViewModelTests.swift index 00825da..449ad43 100644 --- a/suixinkanTests/MessageCenterViewModelTests.swift +++ b/suixinkanTests/MessageCenterViewModelTests.swift @@ -32,17 +32,77 @@ final class MessageCenterViewModelTests: XCTestCase { func testUnreadSelectionMarksReadAndOpensDetail() async { let api = MockMessageCenterAPI() api.listResponses = [MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 3, isRead: false)])] + api.readResponse = MessageUnreadCountResponse(unreadCount: 4) let viewModel = MessageCenterViewModel() var opened: MessageItem? + var callbackCount: Int? viewModel.onOpenDetail = { opened = $0 } + viewModel.onUnreadMessageCountChange = { callbackCount = $0 } + let unreadChanged = expectation( + forNotification: NotificationName.unreadMessageCountDidChange, + object: nil, + handler: { notification in + notification.userInfo?[NotificationUserInfoKey.unreadCount] as? Int == 4 + } + ) await viewModel.loadInitial(api: api) await viewModel.selectMessage(id: 3, api: api) + await fulfillment(of: [unreadChanged], timeout: 1) XCTAssertEqual(api.readCalls, [3]) XCTAssertEqual(opened?.id, 3) XCTAssertEqual(opened?.isRead, true) XCTAssertEqual(viewModel.items.first?.isRead, true) + XCTAssertEqual(callbackCount, 4) + } + + func testMarkAllAsReadUpdatesItemsAndPublishesLatestCount() async { + let api = MockMessageCenterAPI() + api.listResponses = [MessageListResponse(items: [ + makeMessage(id: 1, isRead: false), + makeMessage(id: 2, isRead: true), + ])] + api.readAllResponse = MessageReadAllResponse(updatedCount: 1, unreadCount: 0) + let viewModel = MessageCenterViewModel() + var callbackCount: Int? + var message: String? + viewModel.onUnreadMessageCountChange = { callbackCount = $0 } + viewModel.onShowMessage = { message = $0 } + let unreadChanged = expectation( + forNotification: NotificationName.unreadMessageCountDidChange, + object: nil, + handler: { notification in + notification.userInfo?[NotificationUserInfoKey.unreadCount] as? Int == 0 + } + ) + + await viewModel.loadInitial(api: api) + let response = await viewModel.markAllAsRead(api: api) + await fulfillment(of: [unreadChanged], timeout: 1) + + XCTAssertEqual(api.readAllCallCount, 1) + XCTAssertEqual(response, MessageReadAllResponse(updatedCount: 1, unreadCount: 0)) + XCTAssertTrue(viewModel.items.allSatisfy(\.isRead)) + XCTAssertEqual(callbackCount, 0) + XCTAssertEqual(message, "已全部标记为已读") + } + + func testMarkAllAsReadFailureKeepsUnreadState() async { + let api = MockMessageCenterAPI() + api.listResponses = [MessageListResponse(items: [makeMessage(id: 6, isRead: false)])] + api.readAllError = TestError(message: "fail") + let viewModel = MessageCenterViewModel() + var message: String? + viewModel.onShowMessage = { message = $0 } + + await viewModel.loadInitial(api: api) + let response = await viewModel.markAllAsRead(api: api) + + XCTAssertNil(response) + XCTAssertEqual(api.readAllCallCount, 1) + XCTAssertEqual(viewModel.items.first?.isRead, false) + XCTAssertEqual(message, "全部标记已读失败,请稍后重试") } func testReadSelectionOpensDetailWithoutCallingReadAPI() async { @@ -125,8 +185,12 @@ private final class MockMessageCenterAPI: MessageCenterServing { var listResponses: [MessageListResponse] = [] var listCalls: [(lastId: Int, limit: Int, unread: Int)] = [] var readCalls: [Int] = [] + var readResponse = MessageUnreadCountResponse() + var readAllResponse = MessageReadAllResponse() + var readAllCallCount = 0 var deleteCalls: [Int] = [] var readError: Error? + var readAllError: Error? var deleteError: Error? func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse { @@ -135,9 +199,20 @@ private final class MockMessageCenterAPI: MessageCenterServing { return listResponses.removeFirst() } - func markAsRead(messageId: Int) async throws { + func unreadCount() async throws -> MessageUnreadCountResponse { + MessageUnreadCountResponse() + } + + func markAsRead(messageId: Int) async throws -> MessageUnreadCountResponse { readCalls.append(messageId) if let readError { throw readError } + return readResponse + } + + func markAllAsRead() async throws -> MessageReadAllResponse { + readAllCallCount += 1 + if let readAllError { throw readAllError } + return readAllResponse } func delete(messageId: Int) async throws { diff --git a/suixinkanTests/PushNotificationTests.swift b/suixinkanTests/PushNotificationTests.swift index 4eac9db..7e7d509 100644 --- a/suixinkanTests/PushNotificationTests.swift +++ b/suixinkanTests/PushNotificationTests.swift @@ -158,6 +158,28 @@ final class PushNotificationTests: XCTestCase { XCTAssertEqual(router.destinations, [.task]) } + func testRemoteNotificationNotifiesUnreadMessageStateChange() async { + let manager = makeManager() + let unreadChanged = expectation( + forNotification: NotificationName.unreadMessageCountDidChange, + object: nil + ) + + manager.handleRemoteNotification(["route": "message_center"]) + + await fulfillment(of: [unreadChanged], timeout: 1) + } + + func testApplicationIconBadgeUsesLatestNonnegativeCount() async { + let badgeSetter = ApplicationIconBadgeSetterMock() + let manager = makeManager(badgeSetter: badgeSetter) + + await manager.updateApplicationIconBadgeCount(12) + await manager.updateApplicationIconBadgeCount(-2) + + XCTAssertEqual(badgeSetter.counts, [12, 0]) + } + func testRouteCoordinatorWaitsForLoginAndDeduplicatesColdStartResponse() { let coordinator = PushRouteCoordinator(appStore: appStore) let window = UIWindow(frame: UIScreen.main.bounds) @@ -189,7 +211,8 @@ final class PushNotificationTests: XCTestCase { private func makeManager( sdk: PushSDKMock? = nil, api: PushRegistrationAPIMock? = nil, - router: PushRouterMock? = nil + router: PushRouterMock? = nil, + badgeSetter: ApplicationIconBadgeSetterMock? = nil ) -> PushNotificationManager { let resolvedSDK: PushSDKMock if let sdk { @@ -217,7 +240,8 @@ final class PushNotificationTests: XCTestCase { api: resolvedAPI, appStore: appStore, defaults: defaults, - router: resolvedRouter + router: resolvedRouter, + applicationIconBadgeSetter: badgeSetter ?? ApplicationIconBadgeSetterMock() ) } @@ -233,6 +257,16 @@ final class PushNotificationTests: XCTestCase { } } +/// 记录 App 桌面图标角标更新的测试替身。 +@MainActor +private final class ApplicationIconBadgeSetterMock: ApplicationIconBadgeSetting { + private(set) var counts: [Int] = [] + + func setBadgeCount(_ count: Int) async { + counts.append(count) + } +} + /// 可控的极光 SDK 测试替身,记录初始化与通知授权行为。 @MainActor private final class PushSDKMock: PushSDKProviding {