From 1b2637f0ee954b8424db6172a653e8ea5d74bc92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Wed, 22 Jul 2026 18:20:36 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8E=A8=E9=80=81=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E4=BB=85=E6=8C=89=20type=20=E8=B7=AF=E7=94=B1=E5=88=B0?= =?UTF-8?q?=E6=94=B6=E6=AC=BE=E9=A1=B5=E6=88=96=E6=B6=88=E6=81=AF=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐后端消息类型约定,去掉 route/uri 等兼容字段,并补充联调日志与单测。 Co-authored-by: Cursor --- docs/后端消息推送格式.md | 426 ++++++++++++++++++ suixinkan/Core/Push/PushModels.swift | 142 ++---- .../Core/Push/PushNotificationManager.swift | 31 +- .../Core/Push/PushRouteCoordinator.swift | 10 +- .../UI/MainTab/MainTabBarController.swift | 17 +- suixinkanTests/PushNotificationTests.swift | 51 ++- 6 files changed, 544 insertions(+), 133 deletions(-) create mode 100644 docs/后端消息推送格式.md diff --git a/docs/后端消息推送格式.md b/docs/后端消息推送格式.md new file mode 100644 index 0000000..b0a12dd --- /dev/null +++ b/docs/后端消息推送格式.md @@ -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` | +| 标题示例 | `` | +| 正文示例 | `<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` | 后端新增类型前是否可同步类型值、字段结构和客户端行为 | diff --git a/suixinkan/Core/Push/PushModels.swift b/suixinkan/Core/Push/PushModels.swift index bf84687..a8a2fb2 100644 --- a/suixinkan/Core/Push/PushModels.swift +++ b/suixinkan/Core/Push/PushModels.swift @@ -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 - } - 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) + switch type { + case "1": + return .paymentRecord + case "6": + return .paymentDetails + default: + return .messageCenter } } - 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 - } + 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 } - let text = stringValue(value) - result[key] = text - guard Self.nestedJSONKeys.contains(key), - let data = text.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data) - else { return } + 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 + } - if let dictionary = object as? [String: Any] { - mergeDictionary(dictionary, into: &result) + guard let text = value as? String, + let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let nested = object as? [String: Any] + else { continue } + + 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", ] } diff --git a/suixinkan/Core/Push/PushNotificationManager.swift b/suixinkan/Core/Push/PushNotificationManager.swift index 53f90d1..469fe63 100644 --- a/suixinkan/Core/Push/PushNotificationManager.swift +++ b/suixinkan/Core/Push/PushNotificationManager.swift @@ -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 diff --git a/suixinkan/Core/Push/PushRouteCoordinator.swift b/suixinkan/Core/Push/PushRouteCoordinator.swift index 09bbbb8..6b6e552 100644 --- a/suixinkan/Core/Push/PushRouteCoordinator.swift +++ b/suixinkan/Core/Push/PushRouteCoordinator.swift @@ -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) { diff --git a/suixinkan/UI/MainTab/MainTabBarController.swift b/suixinkan/UI/MainTab/MainTabBarController.swift index 620aa6e..8bfa7a4 100644 --- a/suixinkan/UI/MainTab/MainTabBarController.swift +++ b/suixinkan/UI/MainTab/MainTabBarController.swift @@ -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 { diff --git a/suixinkanTests/PushNotificationTests.swift b/suixinkanTests/PushNotificationTests.swift index 7e7d509..7f1317f 100644 --- a/suixinkanTests/PushNotificationTests.swift +++ b/suixinkanTests/PushNotificationTests.swift @@ -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) {