Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
30
suixinkan_ios/Core/Push/PushAPI.swift
Normal file
30
suixinkan_ios/Core/Push/PushAPI.swift
Normal file
@ -0,0 +1,30 @@
|
||||
//
|
||||
// PushAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 推送 API,负责把 iOS APNs token 上报到当前后端兼容接口。
|
||||
final class PushAPI {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化推送 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 上报 APNs device token。后端当前沿用 Android/JPush 字段名。
|
||||
func registerJPushId(_ registrationId: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/user/register-jpush-id",
|
||||
queryItems: [URLQueryItem(name: "jpush_reg_id", value: registrationId)]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
151
suixinkan_ios/Core/Push/PushNotificationManager.swift
Normal file
151
suixinkan_ios/Core/Push/PushNotificationManager.swift
Normal file
@ -0,0 +1,151 @@
|
||||
//
|
||||
// PushNotificationManager.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
/// 推送生命周期管理器,负责权限、APNs 注册、token 上报和通知点击路由。
|
||||
final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate {
|
||||
static let shared = PushNotificationManager()
|
||||
|
||||
private var api: PushAPI?
|
||||
private weak var session: AppSession?
|
||||
private weak var router: AppRouter?
|
||||
private var latestDeviceToken: String?
|
||||
private var latestAuthorizationStatus: UNAuthorizationStatus = .notDetermined
|
||||
private let tokenDefaultsKey = "apns_device_token"
|
||||
private let uploadedTokenDefaultsKey = "apns_uploaded_token"
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// 绑定推送所需的网络、登录和导航依赖。
|
||||
func configure(api: PushAPI, session: AppSession, router: AppRouter) {
|
||||
self.api = api
|
||||
self.session = session
|
||||
self.router = router
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
latestDeviceToken = UserDefaults.standard.string(forKey: tokenDefaultsKey)
|
||||
}
|
||||
|
||||
/// 请求通知权限并注册 APNs;已授权时会直接注册并尝试补传缓存 token。
|
||||
func requestAuthorizationAndRegister() {
|
||||
Task { @MainActor in
|
||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
latestAuthorizationStatus = settings.authorizationStatus
|
||||
switch settings.authorizationStatus {
|
||||
case .notDetermined:
|
||||
let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])) ?? false
|
||||
guard granted else { return }
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
uploadPendingTokenIfPossible()
|
||||
case .denied:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理系统返回的 APNs token。
|
||||
func handleDeviceToken(_ deviceToken: Data) {
|
||||
let token = APNsDeviceToken.hexString(from: deviceToken)
|
||||
latestDeviceToken = token
|
||||
UserDefaults.standard.set(token, forKey: tokenDefaultsKey)
|
||||
uploadPendingTokenIfPossible()
|
||||
}
|
||||
|
||||
/// 处理 APNs 注册失败。失败不阻断登录和主流程。
|
||||
func handleRegistrationError(_ error: Error) {
|
||||
#if DEBUG
|
||||
print("APNs registration failed: \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 登录后补传尚未成功上报的 token。
|
||||
func uploadPendingTokenIfPossible() {
|
||||
guard let api, session?.isLoggedIn == true else { return }
|
||||
guard let token = latestDeviceToken, !token.isEmpty else { return }
|
||||
let uploaded = UserDefaults.standard.string(forKey: uploadedTokenDefaultsKey)
|
||||
guard uploaded != token else { return }
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await api.registerJPushId(token)
|
||||
UserDefaults.standard.set(token, forKey: uploadedTokenDefaultsKey)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Push token upload failed: \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理静默或远程通知 userInfo。
|
||||
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) {
|
||||
route(from: PushPayload(userInfo: userInfo))
|
||||
}
|
||||
|
||||
/// 处理已解析的推送 payload。供系统回调和单测复用。
|
||||
func handleRemoteNotification(_ payload: PushPayload) {
|
||||
route(from: payload)
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
let payload = PushPayload(userInfo: notification.request.content.userInfo)
|
||||
Task { @MainActor in
|
||||
handleRemoteNotification(payload)
|
||||
}
|
||||
completionHandler([.banner, .list, .sound, .badge])
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let payload = PushPayload(userInfo: response.notification.request.content.userInfo)
|
||||
Task { @MainActor in
|
||||
handleRemoteNotification(payload)
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
private func route(from payload: PushPayload) {
|
||||
guard let router else { return }
|
||||
|
||||
switch payload.route {
|
||||
case .payment:
|
||||
navigateHomeRoute(.paymentCollection)
|
||||
case .order:
|
||||
router.selectOrders(entry: .storeOrders)
|
||||
case .verificationOrder:
|
||||
router.selectOrders(entry: .verificationOrders)
|
||||
case .task:
|
||||
navigateHomeRoute(.taskManagement)
|
||||
case .queue:
|
||||
navigateHomeRoute(.queueManagement)
|
||||
case .messageCenter:
|
||||
navigateHomeRoute(.messageCenter)
|
||||
}
|
||||
}
|
||||
|
||||
private func navigateHomeRoute(_ route: HomeRoute) {
|
||||
guard let router else { return }
|
||||
router.select(.home)
|
||||
router.router(for: .home).navigate(to: .home(route))
|
||||
UIKitAppNavigation.pushHomeRoute(route, animated: true)
|
||||
}
|
||||
}
|
||||
99
suixinkan_ios/Core/Push/PushPayload.swift
Normal file
99
suixinkan_ios/Core/Push/PushPayload.swift
Normal file
@ -0,0 +1,99 @@
|
||||
//
|
||||
// PushPayload.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// APNs token 工具,把系统返回的 Data 转成后端可接收的小写十六进制字符串。
|
||||
enum APNsDeviceToken {
|
||||
static func hexString(from data: Data) -> String {
|
||||
data.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
/// 推送 payload 解析器,兼容顶层字段和第三方通道常见 extras/data 嵌套结构。
|
||||
struct PushPayload: Sendable {
|
||||
enum Route: Sendable, Equatable {
|
||||
case payment
|
||||
case order
|
||||
case verificationOrder
|
||||
case task
|
||||
case queue
|
||||
case messageCenter
|
||||
}
|
||||
|
||||
private let values: [String: String]
|
||||
|
||||
nonisolated init(userInfo: [AnyHashable: Any]) {
|
||||
var result: [String: String] = [:]
|
||||
for (key, value) in userInfo {
|
||||
guard let key = key as? String else { continue }
|
||||
result[key] = Self.stringValue(value)
|
||||
if key == "extras" || key == "extra" || key == "data" || key == "JMessageExtra" {
|
||||
Self.mergeJSON(value, into: &result)
|
||||
}
|
||||
}
|
||||
values = result
|
||||
}
|
||||
|
||||
nonisolated var route: Route {
|
||||
let typeText = values["type"] ?? ""
|
||||
let routeText = values["route"] ?? ""
|
||||
let uriText = values["uri"] ?? ""
|
||||
let actionText = values["action"] ?? ""
|
||||
let merged = [typeText, routeText, uriText, actionText].joined(separator: " ").lowercased()
|
||||
|
||||
if typeText == "1" || merged.contains("payment") || merged.contains("pay") || merged.contains("收款") {
|
||||
return .payment
|
||||
}
|
||||
if merged.contains("queue") || merged.contains("排队") || merged.contains("叫号") || uriText == "/scenic-queue" {
|
||||
return .queue
|
||||
}
|
||||
if merged.contains("verification") || merged.contains("writeoff") || merged.contains("核销") {
|
||||
return .verificationOrder
|
||||
}
|
||||
if merged.contains("order") || merged.contains("订单") {
|
||||
return .order
|
||||
}
|
||||
if merged.contains("task") || merged.contains("任务") {
|
||||
return .task
|
||||
}
|
||||
return .messageCenter
|
||||
}
|
||||
|
||||
nonisolated private static func mergeJSON(_ value: Any, into result: inout [String: String]) {
|
||||
if let dict = value as? [String: Any] {
|
||||
for (key, value) in dict {
|
||||
result[key] = stringValue(value)
|
||||
mergeJSON(value, into: &result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let text = value as? String,
|
||||
let data = text.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else { return }
|
||||
|
||||
for (key, value) in object {
|
||||
result[key] = stringValue(value)
|
||||
mergeJSON(value, into: &result)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func stringValue(_ value: Any?) -> String {
|
||||
switch value {
|
||||
case let string as String:
|
||||
return string
|
||||
case let number as NSNumber:
|
||||
return number.stringValue
|
||||
case .some(let value):
|
||||
return "\(value)"
|
||||
case nil:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user