Files
suixinkan_uikit/suixinkan/Core/Push/PushNotificationManager.swift
lujiuyinandCursor 1b2637f0ee fix: 推送点击仅按 type 路由到收款页或消息中心。
对齐后端消息类型约定,去掉 route/uri 等兼容字段,并补充联调日志与单测。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 18:20:36 +08:00

423 lines
16 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
/// 初始化极光长连接与 APNs 代理服务。
func initialize(launchOptions: [UIApplication.LaunchOptionsKey: Any]?, isProduction: Bool)
/// 请求系统通知权限并注册极光通知代理。
func requestAuthorization(delegate: JPUSHRegisterDelegate)
/// 将 APNs Device Token 注册给极光。
func registerDeviceToken(_ deviceToken: Data)
/// 获取极光 Registration ID。
func fetchRegistrationID(completion: @escaping (Int32, String?) -> Void)
}
/// 跨越极光 `@Sendable` 回调边界的只读闭包容器。
private final class RegistrationIDCompletionBox: @unchecked Sendable {
let completion: (Int32, String?) -> Void
init(completion: @escaping (Int32, String?) -> Void) {
self.completion = completion
}
}
/// 生产环境极光 SDK 适配器,集中保存 AppKey 和渠道配置。
@MainActor
final class JPushSDKAdapter: PushSDKProviding {
private static let appKey = "194a633c2a6b79c2f1033c40"
private static let channel = "App Store"
/// 初始化极光 SDK;Debug 输出详细日志,Release 只保留告警和错误。
func initialize(launchOptions: [UIApplication.LaunchOptionsKey: Any]?, isProduction: Bool) {
JPUSHService.setup(
withOption: launchOptions,
appKey: Self.appKey,
channel: Self.channel,
apsForProduction: isProduction
)
#if DEBUG
JPUSHService.setDebugMode()
#else
JPUSHService.setLogOFF()
#endif
}
/// 请求 alert、badge、sound 三类通知权限。
func requestAuthorization(delegate: JPUSHRegisterDelegate) {
let entity = JPUSHRegisterEntity()
entity.types = 1 | 2 | 4
JPUSHService.register(forRemoteNotificationConfig: entity, delegate: delegate)
}
/// 将 APNs Device Token 交给极光。
func registerDeviceToken(_ deviceToken: Data) {
JPUSHService.registerDeviceToken(deviceToken)
}
/// 异步获取当前设备 Registration ID。
func fetchRegistrationID(completion: @escaping (Int32, String?) -> Void) {
let box = RegistrationIDCompletionBox(completion: completion)
JPUSHService.registrationIDCompletionHandler { resultCode, registrationID in
box.completion(resultCode, registrationID)
}
}
}
/// 推送生命周期管理器,负责隐私门禁、权限、Registration ID 绑定及通知点击分发。
@MainActor
final class PushNotificationManager: NSObject {
/// 全局推送管理器。
static let shared = PushNotificationManager()
/// 推送相关的本地持久化键。
private enum Key {
static let registrationID = "key_jpush_registration_id"
static let uploadedRegistrationIDSuffix = "jpush_uploaded_registration_id"
}
private let sdk: any PushSDKProviding
private let api: any PushRegistrationServing
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
private var uploadTask: Task<Void, Never>?
private var queuedForcedUpload = false
private var isFetchingRegistrationID = false
private var queuedForcedFetch = false
/// 创建推送管理器;生产环境使用共享依赖,测试可注入替身。
init(
sdk: (any PushSDKProviding)? = nil,
api: (any PushRegistrationServing)? = nil,
appStore: AppStore = .shared,
defaults: UserDefaults = .standard,
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()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
/// 绑定当前 Scene 的主窗口。
func attach(window: UIWindow) {
router.attach(window: window)
}
/// 仅在用户已同意隐私协议后初始化极光 SDK。
func initializeIfPrivacyAccepted(
launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) {
guard appStore.session.privacyAgreementAccepted, !isInitialized else { return }
isInitialized = true
sdk.initialize(launchOptions: launchOptions, isProduction: Self.isProductionBuild)
observeJPushNetworkLogin()
refreshRegistrationID(forceUpload: false)
}
/// 登录成功后请求通知权限,并强制绑定当前账号。
func handleLoginCompleted() {
initializeIfPrivacyAccepted()
guard isInitialized, appStore.session.isLoggedIn else { return }
if !didRequestAuthorization {
didRequestAuthorization = true
sdk.requestAuthorization(delegate: self)
}
bindCurrentAccount()
}
/// 账号切换后把同一设备重新绑定到新的业务账号。
func handleAccountSwitched() {
guard appStore.session.isLoggedIn else { return }
bindCurrentAccount()
}
/// 退出登录时停止账号上报并清除未完成的业务路由。
func handleLogout() {
uploadTask?.cancel()
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 上报。
func retryPendingRegistrationUpload() {
guard appStore.session.isLoggedIn else { return }
uploadCachedRegistrationID(force: false)
refreshRegistrationID(forceUpload: false)
}
/// 登录根页面建立后继续执行通知点击暂存的路由。
func routePendingNotificationIfPossible() {
router.routePendingIfPossible()
}
/// APNs 注册成功后把 Device Token 注册给极光。
func handleDeviceToken(_ deviceToken: Data) {
guard isInitialized else { return }
sdk.registerDeviceToken(deviceToken)
refreshRegistrationID(forceUpload: false)
}
/// APNs 注册失败只记录调试信息,不阻断登录和主业务。
func handleRegistrationError(_ error: Error) {
#if DEBUG
print("JPush APNs registration failed: \(error.localizedDescription)")
#endif
}
/// 处理冷启动或运行中通知点击。
func handleNotificationTap(
payload: PushPayload,
requestIdentifier: String
) {
router.handle(
destination: payload.destination,
requestIdentifier: requestIdentifier
)
}
/// 处理 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),
requestIdentifier: response.notification.request.identifier
)
}
/// 处理后台静默通知并向极光上报到达,不触发页面跳转。
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,
selector: #selector(handleJPushNetworkLogin),
name: .jpfNetworkDidLogin,
object: nil,
)
}
@objc private func handleJPushNetworkLogin() {
refreshRegistrationID(forceUpload: false)
}
private func refreshRegistrationID(forceUpload: Bool) {
guard isInitialized else { return }
queuedForcedFetch = queuedForcedFetch || forceUpload
guard !isFetchingRegistrationID else { return }
isFetchingRegistrationID = true
sdk.fetchRegistrationID { [weak self] resultCode, registrationID in
Task { @MainActor in
guard let self else { return }
let shouldForceUpload = self.queuedForcedFetch
self.queuedForcedFetch = false
self.isFetchingRegistrationID = false
guard resultCode == 0 else { return }
let normalized = registrationID?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !normalized.isEmpty else { return }
self.defaults.set(normalized, forKey: Key.registrationID)
self.upload(registrationID: normalized, force: shouldForceUpload)
}
}
}
private func uploadCachedRegistrationID(force: Bool) {
let registrationID = cachedRegistrationID
guard !registrationID.isEmpty else { return }
upload(registrationID: registrationID, force: force)
}
private func bindCurrentAccount() {
if cachedRegistrationID.isEmpty {
refreshRegistrationID(forceUpload: true)
} else {
uploadCachedRegistrationID(force: true)
refreshRegistrationID(forceUpload: false)
}
}
private var cachedRegistrationID: String {
defaults.string(forKey: Key.registrationID)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
private func upload(registrationID: String, force: Bool) {
guard appStore.session.isLoggedIn,
let uploadedKey = appStore.session.accountScopedKey(Key.uploadedRegistrationIDSuffix)
else { return }
if !force, defaults.string(forKey: uploadedKey) == registrationID {
return
}
if uploadTask != nil {
queuedForcedUpload = queuedForcedUpload || force
return
}
let accountScope = appStore.session.accountCachePrefix
uploadTask = Task { [weak self] in
guard let self else { return }
var succeeded = false
do {
try await self.api.registerJPushID(registrationID)
if self.appStore.session.accountCachePrefix == accountScope {
self.defaults.set(registrationID, forKey: uploadedKey)
}
succeeded = true
} catch is CancellationError {
// 退出登录时取消,不视为失败重试。
} catch {
#if DEBUG
print("JPush Registration ID upload failed: \(error.localizedDescription)")
#endif
}
self.uploadTask = nil
let shouldForceAgain = self.queuedForcedUpload
self.queuedForcedUpload = false
if succeeded, shouldForceAgain {
self.uploadCachedRegistrationID(force: true)
}
}
}
private nonisolated static var isProductionBuild: Bool {
#if DEBUG
false
#else
true
#endif
}
}
extension PushNotificationManager: JPUSHRegisterDelegate {
/// 前台通知只展示系统 UI,不自动执行业务路由。
nonisolated func jpushNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (Int) -> Void
) {
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)
}
let options: UNNotificationPresentationOptions = [.banner, .list, .sound, .badge]
completionHandler(Int(options.rawValue))
}
/// 用户点击通知后上报极光统计,并在主 Actor 执行业务路由。
nonisolated func jpushNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
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
Task { @MainActor [weak self] in
self?.handleNotificationTap(
payload: payload,
requestIdentifier: requestIdentifier
)
}
completionHandler()
}
/// 系统通知设置入口回调,首期无需额外应用内页面。
nonisolated func jpushNotificationCenter(
_ center: UNUserNotificationCenter,
openSettingsFor notification: UNNotification
) {}
/// 极光通知授权状态回调,权限变化由系统设置管理。
nonisolated func jpushNotificationAuthorization(
_ status: JPAuthorizationStatus,
withInfo info: [AnyHashable: Any]?
) {}
}