fix: 推送点击仅按 type 路由到收款页或消息中心。

对齐后端消息类型约定,去掉 route/uri 等兼容字段,并补充联调日志与单测。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-22 18:20:36 +08:00
parent caeeb9a1cf
commit 1b2637f0ee
6 changed files with 544 additions and 133 deletions

View File

@ -0,0 +1,426 @@
# 后端消息推送格式
## 1. 文档说明
- 本文整理后端经极光推送发送到 Android 和 iOS 的通知消息格式。
- 当前内容基于 2026-07-22 的 Android、iOS 实际收包日志、两端客户端现有解析逻辑,以及后端开发人员提供的 `PushMsg` 类型定义。
- 后端当前定义了 `1``12``100``999` 共 14 个业务消息类型;目前已取得 `type = 1`(付款成功)、`type = 2`(退款成功)和 `type = 6`(扫码成功)的完整收包样本。
- 本文描述的是推送协议,不是普通 HTTP 接口响应格式。
## 2. 统一业务结构
极光在 Android 和 iOS 上交付的原始结构不同,但当前消息都可以归一化为以下业务结构:
```json
{
"title": "通知标题",
"content": "通知正文",
"msg_id": 1858,
"type": 6,
"data": {
"order_type": 14
}
}
```
### 2.1 公共业务字段
| 字段 | 当前类型 | 说明 |
| --- | --- | --- |
| `title` | string | 通知标题。Android 为 `notificationTitle`iOS 为 `aps.alert.title`。 |
| `content` | string | 通知正文。Android 为 `notificationContent`iOS 为 `aps.alert.body`。 |
| `msg_id` | int | 后端业务消息 ID。它与极光消息 ID 不是同一个字段。 |
| `type` | int | 业务消息类型,是客户端业务分发的主要依据。 |
| `data` | object | 随消息类型变化的业务数据。 |
> “当前类型”表示现有样本中观察到的 JSON 类型。后端应保持字段类型稳定,避免同一个字段在不同推送中交替使用数字、字符串或 `null`。
## 3. Android 收包格式
Android 通过极光 `JPushMessageReceiver.onNotifyMessageArrived` 收到通知。标题、正文和附加字段分别由 SDK 提供,其中 `extras` 是 JSON 字符串:
```text
[onNotifyMessageArrived] msgId=<极光消息ID>, title=<标题>, content=<正文>, extras=<业务JSON字符串>
```
`extras` 的当前结构为:
```json
{
"data": {},
"msg_id": 0,
"type": 0
}
```
### 3.1 扫码成功原始日志
```text
[onNotifyMessageArrived] msgId=18103321643292031, title=扫码成功通知, content=扫码成功, extras={"data":{"order_type":14},"msg_id":1852,"type":6}
```
对应的 `extras`
```json
{
"data": {
"order_type": 14
},
"msg_id": 1852,
"type": 6
}
```
### 3.2 付款成功原始日志
```text
[onNotifyMessageArrived] msgId=18103321644290274, title=付款成功通知, content=您的订单 #260722128005 已支付成功,支付金额 ¥0.01, extras={"data":{"order_amount":"0.01","order_number":"260722128005","order_type":14,"pay_time":1784711036,"remark":""},"msg_id":1853,"type":1}
```
对应的 `extras`
```json
{
"data": {
"order_amount": "0.01",
"order_number": "260722128005",
"order_type": 14,
"pay_time": 1784711036,
"remark": ""
},
"msg_id": 1853,
"type": 1
}
```
### 3.3 退款成功原始日志
```text
[onNotifyMessageArrived] msgId=18103321800527159, title=退款成功通知, content=您的订单 #260722128002 已成功退款,退款金额 ¥0.01, extras={"data":{},"msg_id":1867,"type":2}
```
对应的 `extras`
```json
{
"data": {},
"msg_id": 1867,
"type": 2
}
```
## 4. iOS 收包格式
iOS 通过 APNs/极光收到 `userInfo` 字典。与 Android 相比:
- 标题和正文位于 `aps.alert`
- Android `extras` 中的 `msg_id``type``data` 在 iOS 中位于 payload 顶层。
- `_j_*` 为极光传输层内部字段,不应作为业务分发依据。
通用结构如下:
```json
{
"_j_business": 1,
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
"_j_msgid": 18103321726881995,
"_j_uid": 86399144805,
"aps": {
"alert": {
"body": "通知正文",
"title": "通知标题"
},
"sound": "default"
},
"data": {},
"msg_id": 0,
"type": 0
}
```
> 原始日志中该内部字段显示为 `*j\_data*`。本文按极光其他内部字段的命名方式记为 `_j_data`;后续联调时应以设备收到的真实字典 key 为准。该字段目前不参与业务解析。
### 4.1 扫码成功 payload
```json
{
"_j_business": 1,
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
"_j_msgid": 18103321726881995,
"_j_uid": 86399144805,
"aps": {
"alert": {
"body": "扫码成功",
"title": "扫码成功通知"
},
"sound": "default"
},
"data": {
"order_type": 14
},
"msg_id": 1858,
"type": 6
}
```
### 4.2 付款成功 payload
```json
{
"_j_business": 1,
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
"_j_msgid": 18103321720587147,
"_j_uid": 86399144805,
"aps": {
"alert": {
"body": "您的订单 #260722128006 已支付成功,支付金额 ¥0.01",
"title": "付款成功通知"
},
"sound": "default"
},
"data": {
"order_amount": "0.01",
"order_number": "260722128006",
"order_type": 14,
"pay_time": 1784712671,
"remark": ""
},
"msg_id": 1856,
"type": 1
}
```
### 4.3 退款成功 payload
```json
{
"_j_business": 1,
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
"_j_msgid": 18103321792603316,
"_j_uid": 86399144805,
"aps": {
"alert": {
"body": "您的订单 #260722128005 已成功退款,退款金额 ¥0.01",
"title": "退款成功通知"
},
"sound": "default"
},
"data": {},
"msg_id": 1862,
"type": 2
}
```
## 5. Android 与 iOS 字段对应关系
| 语义 | Android | iOS | 是否用于业务 |
| --- | --- | --- | --- |
| 极光消息 ID | SDK `message.msgId` | `_j_msgid` | 否,仅用于推送链路日志与排查 |
| 通知标题 | SDK `message.notificationTitle` | `aps.alert.title` | 是,用于展示 |
| 通知正文 | SDK `message.notificationContent` | `aps.alert.body` | 是,用于展示 |
| 后端业务消息 ID | `extras.msg_id` | 顶层 `msg_id` | 是 |
| 业务消息类型 | `extras.type` | 顶层 `type` | 是,作为业务分发主键 |
| 业务数据 | `extras.data` | 顶层 `data` | 是,结构由 `type` 决定 |
| 极光业务标记 | SDK 内部处理 | `_j_business` | 否 |
| 极光附加元数据 | SDK 内部处理 | `_j_data` | 否 |
| 极光用户 ID | SDK 内部处理 | `_j_uid` | 否 |
| APNs 展示配置 | 不适用 | `aps` | 仅 iOS 系统使用 |
### 5.1 两种消息 ID 不可混用
- `msgId` / `_j_msgid`:极光生成的传输层消息 ID当前样本为 17 位数字。
- `msg_id`:后端生成的业务消息 ID当前样本为 `1852``1853``1856``1858`
- 客户端日志、去重或上报时必须明确需要哪一种 ID不能仅用名称相近而互相替代。
- 极光消息 ID 已超过 JavaScript 可安全表示的整数范围;若需要经过 JavaScript、WebView 或其他只能安全处理 53 位整数的链路,应按字符串传递。
## 6. 消息类型清单
### 6.1 后端 `PushMsg` 完整类型定义
定义位置:后端 `PushMsg.php` 第 49 行附近。
| `type` | 消息类型 | 后端备注 | 当前收包样本 |
| ---: | --- | --- | --- |
| `1` | 付款成功通知 | 上文实际示例即为此类型 | Android、iOS 均已取得 |
| `2` | 退款成功通知 | — | Android、iOS 均已取得 |
| `3` | 下单成功通知 | App 无需推送,当前未使用 | 暂无 |
| `4` | 签到成功通知 | — | 暂无 |
| `5` | 付尾款成功通知 | — | 暂无 |
| `6` | 扫码成功通知 | — | Android、iOS 均已取得 |
| `7` | 付定金成功通知 | — | 暂无 |
| `8` | 订单核销成功通知 | — | 暂无 |
| `9` | 剪辑完成通知 | — | 暂无 |
| `10` | 修图完成通知 | — | 暂无 |
| `11` | 跟拍签到通知 | — | 暂无 |
| `12` | 剪辑师新任务通知 | 类型名称映射中漏配 | 暂无 |
| `100` | 用户登出通知 | 账号在其他设备登录 | 暂无 |
| `999` | 系统消息 | 当前未使用 | 暂无 |
后端特别说明:
- `type` 表示业务消息类型,不代表推送渠道。
- 推送渠道的取值另有定义:`1` 为极光推送,`2` 为微信小程序,`999` 为不推送。
- `type = 12` 虽然已经定义,但尚未加入后端 `MAP_TYPE_NAMES`。后端调用 `getTypeName(12)` 时会返回“未知消息类型”,需要后端补充映射。
- “暂无收包样本”只表示当前文档还没有该类型的完整 payload不能据此认为该类型未启用。收到后应继续补充其标题、正文、`data` 字段和点击行为。
### 6.2 `type = 1`:付款成功
| 项目 | 当前值 |
| --- | --- |
| 标题示例 | `付款成功通知` |
| 正文示例 | `您的订单 #260722128006 已支付成功,支付金额 ¥0.01` |
| 客户端点击行为 | Android、iOS 均进入收款记录页 |
`data` 字段:
| 字段 | 类型 | 当前样本 | 说明 |
| --- | --- | --- | --- |
| `order_amount` | string | `"0.01"` | 支付金额。使用十进制字符串,避免浮点精度问题。 |
| `order_number` | string | `"260722128006"` | 业务订单号。 |
| `order_type` | int | `14` | 订单类型;当前值表示“摄影师跟拍线下扫码”。 |
| `pay_time` | int | `1784712671` | 支付时间。当前样本为 Unix 秒级时间戳Android 现有代码同时兼容秒和毫秒。 |
| `remark` | string | `""` | 付款备注;没有备注时当前返回空字符串。 |
示例:
```json
{
"type": 1,
"msg_id": 1856,
"data": {
"order_amount": "0.01",
"order_number": "260722128006",
"order_type": 14,
"pay_time": 1784712671,
"remark": ""
}
}
```
### 6.3 `type = 2`:退款成功
| 项目 | 当前值 |
| --- | --- |
| 标题示例 | `退款成功通知` |
| 正文示例 | `您的订单 #260722128005 已成功退款,退款金额 ¥0.01` |
| 客户端点击行为 | Android、iOS 均进入消息中心 |
`data` 字段:
| 字段 | 类型 | 当前样本 | 说明 |
| --- | --- | --- | --- |
| — | object | `{}` | 当前 Android、iOS 样本均为空对象。 |
示例:
```json
{
"type": 2,
"msg_id": 1862,
"data": {}
}
```
> 当前订单号和退款金额只存在于展示正文中,没有以结构化字段放入 `data`。客户端如果需要可靠地刷新指定订单、展示退款详情或执行金额计算,应由后端在 `data` 中提供字段,不应解析自然语言正文。
### 6.4 `type = 6`:扫码成功
| 项目 | 当前值 |
| --- | --- |
| 标题示例 | `扫码成功通知` |
| 正文示例 | `扫码成功` |
| 客户端点击行为 | Android、iOS 均进入收款详情页 |
`data` 字段:
| 字段 | 类型 | 当前样本 | 说明 |
| --- | --- | --- | --- |
| `order_type` | int | `14` | 订单类型;`14` 在 Android 现有代码中表示“摄影师跟拍线下扫码”。 |
示例:
```json
{
"type": 6,
"msg_id": 1858,
"data": {
"order_type": 14
}
}
```
## 7. 客户端解析约定
1. Android、iOS 的通知点击路由只根据业务字段 `type` 判断,不读取标题、正文、`data``route``uri``action` 或极光内部字段决定页面。
2. `type = 1`:进入收款记录页。
3. `type = 6`:进入收款详情页。
4. 其他任意 `type`、缺少 `type`、类型无法解析或 payload 损坏:进入消息中心。
5. Android 从 `notificationExtras` 或厂商通道包装字段中提取 `type`iOS 优先读取 payload 顶层 `type`,并兼容常见推送包装层中的 `type`
6. `data` 仍可按消息类型用于页面内容刷新,但不参与通知点击路由。新增字段应保持向后兼容,客户端应忽略当前版本不认识的额外字段。
7. 金额继续使用字符串;时间戳建议统一为 Unix 秒级整数,并在协议中固定单位。
8. `remark` 等可空业务字段建议固定返回空字符串或明确约定 `null`,不要在多种表示之间切换。
## 8. 新增消息类型记录模板
后续收到新类型时,复制以下内容追加到“消息类型清单”:
````markdown
### 6.x `type = <类型值>`<消息名称>
| 项目 | 当前值 |
| --- | --- |
| 首次发现日期 | `YYYY-MM-DD` |
| 标题示例 | `<title>` |
| 正文示例 | `<content>` |
| 客户端业务行为 | `<展示刷新或跳转行为>` |
`data` 字段:
| 字段 | 类型 | 必有 | 说明 |
| --- | --- | --- | --- |
| `<field>` | `<type>` | `<//待确认>` | `<说明>` |
Android `extras` 示例:
```json
{
"data": {},
"msg_id": 0,
"type": 0
}
```
iOS 业务字段示例:
```json
{
"data": {},
"msg_id": 0,
"type": 0
}
```
````
记录新类型时至少保留以下信息:
- Android 和 iOS 各一份原始收包日志。
- `type`、`msg_id` 和完整 `data`。
- 标题、正文以及消息点击后的期望页面。
- 每个字段的 JSON 类型、是否必有、空值表示方式和时间/金额单位。
- 同一业务事件在两端的字段是否一致。
## 9. 待后端确认项
| 项目 | 当前观察 | 需要确认 |
| --- | --- | --- |
| `msg_id` | 与极光消息 ID 不同 | 是否对应消息中心记录 ID以及是否用于已读/送达状态上报 |
| `data` | `type = 1`、`2`、`6` 的样本中都存在;`type = 2` 为 `{}` | 所有业务推送是否保证存在;无业务字段时是否统一返回 `{}` |
| 退款成功的 `data` | 当前为空对象,订单号和退款金额只在正文中 | 是否补充 `order_number`、`refund_amount`、`refund_time`、`order_type` 等结构化字段 |
| 其他类型的 `data` | 后端已定义消息名称,但尚未提供 payload | 分别确认 `type = 3``5`、`7``12`、`100`、`999` 的字段结构、必填项及点击行为 |
| `pay_time` | 当前为秒级时间戳 | 后端是否统一保证使用 Unix 秒,且时区语义固定为 UTC 时间戳 |
| `remark` | 无备注时为空字符串 | 是否可能返回 `null` 或省略 |
| `order_type` | 当前为 `14` | 完整枚举和值含义由哪份接口文档维护 |
| 标题与正文 | 当前均存在 | 是否为所有通知消息的必有字段 |
| 推送渠道字段 | 后端已给出渠道取值 `1`、`2`、`999` | payload 或发送接口中对应字段的准确名称 |
| `type = 12` | `MAP_TYPE_NAMES` 中漏配 | 后端补充映射,避免 `getTypeName(12)` 返回“未知消息类型” |
| 新增 `type` | 当前定义维护在 `PushMsg.php` | 后端新增类型前是否可同步类型值、字段结构和客户端行为 |

View File

@ -2,129 +2,85 @@ import Foundation
///
enum PushDestination: Sendable, Equatable {
case payment
case orders
case verificationOrders
case task
case queue
case paymentRecord
case paymentDetails
case messageCenter
///
var requiresScenicContext: Bool {
switch self {
case .payment, .task, .queue:
true
case .orders, .verificationOrders, .messageCenter:
false
}
}
}
/// /APNs payload JSON
/// /APNs payload
struct PushPayload: Sendable, Equatable {
private let values: [String: String]
private let type: String
/// userInfo
/// userInfo
nonisolated init(userInfo: [AnyHashable: Any]) {
var flattened: [String: String] = [:]
Self.mergeDictionary(userInfo, into: &flattened)
values = flattened
type = Self.extractType(from: userInfo)
}
/// payload Actor 使
/// payload
nonisolated init(values: [String: String]) {
self.values = values
type = Self.normalizedType(values["type"])
}
///
nonisolated var normalizedValues: [String: String] {
values
type.isEmpty ? [:] : ["type": type]
}
///
///
nonisolated var destination: PushDestination {
let type = value(for: "type")
let route = value(for: "route")
let uri = value(for: "uri")
let action = value(for: "action")
let merged = [route, uri, action].joined(separator: " ").lowercased()
if type == "1" || type == "6"
|| merged.contains("payment") || merged.contains("payment_qr")
|| merged.contains("收款") {
return .payment
}
if merged.contains("queue") || merged.contains("scenic-queue")
|| merged.contains("排队") || merged.contains("叫号") {
return .queue
}
if merged.contains("verification") || merged.contains("writeoff")
|| merged.contains("write_off") || merged.contains("核销") {
return .verificationOrders
}
if merged.contains("order") || merged.contains("订单") {
return .orders
}
if merged.contains("task") || merged.contains("任务") {
return .task
}
switch type {
case "1":
return .paymentRecord
case "6":
return .paymentDetails
default:
return .messageCenter
}
private nonisolated func value(for key: String) -> String {
values[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
private nonisolated static func mergeDictionary(
_ dictionary: [AnyHashable: Any],
into result: inout [String: String]
) {
for (rawKey, value) in dictionary {
guard let key = rawKey as? String else { continue }
merge(value, key: key, into: &result)
}
private nonisolated static func extractType(
from dictionary: [AnyHashable: Any],
depth: Int = 0
) -> String {
guard depth <= 4 else { return "" }
let directType = normalizedType(dictionary["type"])
guard directType.isEmpty else { return directType }
for key in nestedTypeContainerKeys {
guard let value = dictionary[key] else { continue }
if let nested = value as? [AnyHashable: Any] {
let nestedType = extractType(from: nested, depth: depth + 1)
if !nestedType.isEmpty { return nestedType }
continue
}
private nonisolated static func merge(
_ value: Any,
key: String,
into result: inout [String: String]
) {
if let dictionary = value as? [String: Any] {
result[key] = stringValue(value)
mergeDictionary(dictionary, into: &result)
return
}
if let dictionary = value as? [AnyHashable: Any] {
result[key] = stringValue(value)
mergeDictionary(dictionary, into: &result)
return
}
let text = stringValue(value)
result[key] = text
guard Self.nestedJSONKeys.contains(key),
guard let text = value as? String,
let data = text.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data)
else { return }
let object = try? JSONSerialization.jsonObject(with: data),
let nested = object as? [String: Any]
else { continue }
if let dictionary = object as? [String: Any] {
mergeDictionary(dictionary, into: &result)
let bridged = Dictionary(uniqueKeysWithValues: nested.map {
(AnyHashable($0.key), $0.value)
})
let nestedType = extractType(from: bridged, depth: depth + 1)
if !nestedType.isEmpty { return nestedType }
}
return ""
}
private nonisolated static func stringValue(_ value: Any) -> String {
private nonisolated static func normalizedType(_ value: Any?) -> String {
switch value {
case let string as String:
string
return string.trimmingCharacters(in: .whitespacesAndNewlines)
case let number as NSNumber:
number.stringValue
return number.stringValue
default:
String(describing: value)
return ""
}
}
private nonisolated static let nestedJSONKeys: Set<String> = [
"extras", "extra", "data", "JMessageExtra", "n_extras",
private nonisolated static let nestedTypeContainerKeys = [
"extras", "extra", "JMessageExtra", "n_extras",
]
}

View File

@ -229,6 +229,7 @@ final class PushNotificationManager: NSObject {
/// Scene
func handleNotificationResponse(_ response: UNNotificationResponse) {
let userInfo = response.notification.request.content.userInfo
Self.logReceivedPush(userInfo, source: "tap(cold-start)")
JPUSHService.handleRemoteNotification(userInfo)
handleNotificationTap(
payload: PushPayload(userInfo: userInfo),
@ -238,10 +239,35 @@ final class PushNotificationManager: NSObject {
///
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) {
Self.logReceivedPush(userInfo, source: "background")
JPUSHService.handleRemoteNotification(userInfo)
NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil)
}
/// payload 便
private nonisolated static func logReceivedPush(
_ userInfo: [AnyHashable: Any],
source: String
) {
#if DEBUG
let payloadText: String
let normalized = Dictionary(uniqueKeysWithValues: userInfo.compactMap { key, value in
(key as? String).map { ($0, value) }
})
if JSONSerialization.isValidJSONObject(normalized),
let data = try? JSONSerialization.data(
withJSONObject: normalized,
options: [.prettyPrinted, .sortedKeys]
),
let text = String(data: data, encoding: .utf8) {
payloadText = text
} else {
payloadText = String(describing: userInfo)
}
print("[Push] received (\(source)):\n\(payloadText)")
#endif
}
private func observeJPushNetworkLogin() {
NotificationCenter.default.addObserver(
self,
@ -352,7 +378,9 @@ extension PushNotificationManager: JPUSHRegisterDelegate {
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (Int) -> Void
) {
JPUSHService.handleRemoteNotification(notification.request.content.userInfo)
let userInfo = notification.request.content.userInfo
Self.logReceivedPush(userInfo, source: "foreground")
JPUSHService.handleRemoteNotification(userInfo)
Task { @MainActor in
NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil)
}
@ -367,6 +395,7 @@ extension PushNotificationManager: JPUSHRegisterDelegate {
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
Self.logReceivedPush(userInfo, source: "tap")
JPUSHService.handleRemoteNotification(userInfo)
let payload = PushPayload(userInfo: userInfo)
let requestIdentifier = response.notification.request.identifier

View File

@ -84,16 +84,8 @@ final class PushRouteCoordinator: PushRouting {
return
}
let destination: PushDestination
if route.destination.requiresScenicContext,
appStore.session.currentScenicId <= 0 {
destination = .messageCenter
} else {
destination = route.destination
}
mainTabController.dismiss(animated: false)
mainTabController.openPushDestination(destination)
mainTabController.openPushDestination(route.destination)
}
private func markHandled(_ identifier: String) {

View File

@ -67,12 +67,7 @@ final class MainTabBarController: UITabBarController {
/// Tab
func openPushDestination(_ destination: PushDestination) {
let targetTab: AppTab = switch destination {
case .orders, .verificationOrders:
.orders
case .payment, .task, .queue, .messageCenter:
.home
}
let targetTab = AppTab.home
selectTab(targetTab)
guard let navigationController = tabNavigationControllers[targetTab] else { return }
@ -80,16 +75,12 @@ final class MainTabBarController: UITabBarController {
let controller: UIViewController?
switch destination {
case .payment:
case .paymentRecord:
controller = PaymentCollectionRecordViewController()
case .paymentDetails:
controller = PaymentCollectionDetailsViewController()
case .task:
controller = TaskAddViewController()
case .queue:
controller = ScenicQueueViewController()
case .messageCenter:
controller = MessageCenterViewController()
case .orders, .verificationOrders:
controller = nil
}
if let controller {

View File

@ -27,22 +27,40 @@ final class PushNotificationTests: XCTestCase {
super.tearDown()
}
func testPayloadRoutesTopLevelAndKnownDestinations() {
XCTAssertEqual(PushPayload(userInfo: ["type": "1"]).destination, .payment)
XCTAssertEqual(PushPayload(userInfo: ["route": "photographer_orders"]).destination, .orders)
XCTAssertEqual(PushPayload(userInfo: ["route": "verification_order"]).destination, .verificationOrders)
XCTAssertEqual(PushPayload(userInfo: ["action": "task_management"]).destination, .task)
XCTAssertEqual(PushPayload(userInfo: ["uri": "/scenic-queue"]).destination, .queue)
XCTAssertEqual(PushPayload(userInfo: ["route": "message_center"]).destination, .messageCenter)
func testPayloadRoutesPaymentTypesToTheirPages() {
XCTAssertEqual(
PushPayload(userInfo: ["type": 1, "route": "message_center"]).destination,
.paymentRecord
)
XCTAssertEqual(
PushPayload(userInfo: ["type": "6", "action": "task_management"]).destination,
.paymentDetails
)
}
func testPayloadRoutesNestedJSONStringAndUnknownFallsBackToMessageCenter() {
func testPayloadIgnoresNonTypeRoutingFieldsAndOtherTypesFallBackToMessageCenter() {
let conflictingFields: [AnyHashable: Any] = [
"type": 2,
"route": "payment",
"uri": "/payment_qr",
"action": "收款",
]
[0, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 100, 999].forEach { type in
XCTAssertEqual(PushPayload(userInfo: ["type": type]).destination, .messageCenter)
}
XCTAssertEqual(PushPayload(userInfo: conflictingFields).destination, .messageCenter)
XCTAssertEqual(PushPayload(userInfo: ["route": "payment"]).destination, .messageCenter)
XCTAssertEqual(PushPayload(userInfo: ["data": ["type": 6]]).destination, .messageCenter)
XCTAssertEqual(PushPayload(userInfo: ["type": 999]).destination, .messageCenter)
}
func testPayloadReadsTypeFromNestedJSONStringWithoutUsingOtherFields() {
let nested = PushPayload(userInfo: [
"extras": #"{"data":{"action":""}}"#,
"extras": #"{"type":6,"route":"message_center"}"#,
])
XCTAssertEqual(nested.destination, .queue)
XCTAssertEqual(PushPayload(userInfo: ["title": "未知消息"]).destination, .messageCenter)
XCTAssertEqual(nested.destination, .paymentDetails)
}
func testPushAPIUsesAndroidCompatibleEndpointAndQuery() async throws {
@ -152,10 +170,10 @@ final class PushNotificationTests: XCTestCase {
XCTAssertTrue(router.destinations.isEmpty)
manager.handleNotificationTap(
payload: PushPayload(userInfo: ["route": "task_management"]),
payload: PushPayload(userInfo: ["type": 1, "route": "task_management"]),
requestIdentifier: "request-1"
)
XCTAssertEqual(router.destinations, [.task])
XCTAssertEqual(router.destinations, [.paymentRecord])
}
func testRemoteNotificationNotifiesUnreadMessageStateChange() async {
@ -186,19 +204,18 @@ final class PushNotificationTests: XCTestCase {
window.rootViewController = UINavigationController(rootViewController: UIViewController())
coordinator.attach(window: window)
coordinator.handle(destination: .queue, requestIdentifier: "cold-request")
coordinator.handle(destination: .paymentDetails, requestIdentifier: "cold-request")
authenticate(userID: "100")
appStore.session.currentScenicId = 9
let mainTab = MainTabBarController()
mainTab.loadViewIfNeeded()
window.rootViewController = mainTab
coordinator.routePendingIfPossible()
let navigationController = mainTab.selectedViewController as? UINavigationController
XCTAssertTrue(navigationController?.topViewController is ScenicQueueViewController)
XCTAssertTrue(navigationController?.topViewController is PaymentCollectionDetailsViewController)
coordinator.handle(destination: .messageCenter, requestIdentifier: "cold-request")
XCTAssertTrue(navigationController?.topViewController is ScenicQueueViewController)
XCTAssertTrue(navigationController?.topViewController is PaymentCollectionDetailsViewController)
}
private func authenticate(userID: String) {