feat: 接入消息未读数、全部已读与首页红点角标同步。
固定消息中心为首页入口,并在推送到达、已读后刷新桌面角标。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
204
docs/消息中心未读数量与全部已读接口需求.md
Normal file
204
docs/消息中心未读数量与全部已读接口需求.md
Normal file
@ -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. 所有接口继续使用项目现有统一鉴权、业务状态码和响应信封格式。
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,8 @@ import Foundation
|
||||
/// 首页菜单静态 catalog,与服务端 permission URI 取交集后展示。
|
||||
enum HomeMenuCatalog {
|
||||
|
||||
private static let fixedHomeEntryURIs: Set<String> = ["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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
/// 查询待审核记录并决定进入申请页或审核状态页。
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,负责删除当前消息。
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Section, MessageItem>!
|
||||
|
||||
@ -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<Section, MessageItem>(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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user