import PushApi from '@/sheep/api/member/push'; import * as wsApi from './wssocket'; import { checkPushNotificationPermission } from './jpush-permission'; import { JPUSH_APP_KEY, JPUSH_APNS_PRODUCTION } from './jpush-config'; // #ifdef APP-PLUS import * as nativePush from '@/uni_modules/jg-jpush-u'; // #endif /** 冷启动通知点击暂存键,只保存跳转标识,不保存聊天内容。 */ const PENDING_ROUTE_KEY = 'chat-jpush-pending-route'; /** 首次注册尚未完成时的补查间隔,单位毫秒。 */ const REGISTRATION_RETRY_MS = 3000; /** 单次初始化最多补查十次,之后由连接或前台恢复事件继续同步。 */ const REGISTRATION_RETRY_LIMIT = 10; /** 已处理点击的内存去重上限,避免原生冷启动回调重复打开页面。 */ const HANDLED_CLICK_LIMIT = 50; /** 与服务端 JPushGateway 固定渠道一致,Android 8+ 使用此渠道控制声音和通知展示。 */ const ANDROID_MESSAGE_CHANNEL_ID = 'htys_message'; /** 极光原生初始化状态,进程内只注册一次监听。 */ let initialized = false; /** 当前安装实例的极光标识,不随业务账号变化。 */ let registrationId = ''; /** 当前要求绑定的业务会员 ID,与 IM 用户 ID 分开维护。 */ let accountId = ''; /** 账户代际,用于忽略换账号或退出后的异步结果。 */ let accountVersion = 0; /** 已成功绑定的会员和安装实例组合。 */ let boundKey = ''; /** 串行执行绑定、解绑,防止旧账号迟到的请求覆盖新归属。 */ let bindingTask = Promise.resolve(); /** 当前绑定请求的组合,合并启动、登录和原生连接的重复同步。 */ let bindingKey = ''; /** 当前注册补查计时器,退出后立即停止。 */ let registrationTimer = null; /** 当前注册补查次数,前台恢复后重新计数。 */ let registrationRetries = 0; /** 跳转互斥锁,避免原生点击与聊天初始化同时消费。 */ let routing = false; /** 进程内已经完成的原生点击标识。 */ const handledClicks = new Set(); /** 判断当前是否为可调用极光原生模块的 App。 */ const isNativeApp = () => { // #ifdef APP-PLUS return typeof plus !== 'undefined'; // #endif return false; }; /** 安全解析 Android 字符串 extras 与 iOS 字典回调。 */ const asObject = (value) => { if (typeof value === 'string') { try { return asObject(JSON.parse(value)); } catch { return {}; } } return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }; /** 获取已经确认的当前业务会员 ID,供通知路由校验账号归属。 */ export const getPushAccountId = () => accountId; /** iOS 系统角标跟随现有聊天未读数,避免 APNs 累加后在阅读消息时残留。 */ export const syncPushBadge = (unreadCount) => { if (!initialized || !isNativeApp()) return; // #ifdef APP-PLUS try { if (uni.getSystemInfoSync().platform === 'ios') { nativePush.setBadgeNumber(Math.max(0, Math.floor(Number(unreadCount) || 0))); } } catch { console.warn('[JPush] 系统角标同步暂未完成'); } // #endif }; /** 将绑定任务异常限制在推送能力内部,保持主站登录和退出可继续。 */ const enqueueBinding = (operation) => { bindingTask = bindingTask.then(operation).catch(() => { console.warn('[JPush] 设备同步暂未成功,后续连接或回到前台时重试'); scheduleRegistrationRetry(); }); return bindingTask; }; /** 在当前登录令牌下绑定本机,结果仅对发起请求时的账户代际生效。 */ const syncBinding = () => { if (!accountId || !registrationId || !uni.getStorageSync('token')) return bindingTask; const key = `${accountId}:${registrationId}`; if (key === boundKey || key === bindingKey) return bindingTask; const version = accountVersion; const userId = accountId; const deviceId = registrationId; const token = uni.getStorageSync('token'); bindingKey = key; return enqueueBinding(async () => { if (version !== accountVersion) return; try { const platform = String(uni.getSystemInfoSync().platform).toLowerCase(); const response = await PushApi.bind({ registrationId: deviceId, appKey: JPUSH_APP_KEY, alias: `u${userId}`, platform, provider: 'jpush', }, token); if (version !== accountVersion) return; if (response?.code !== 0) throw new Error('推送设备绑定暂未成功'); // #ifdef APP-PLUS nativePush.setAlias(version + 1, `u${userId}`); // #endif boundKey = key; wsApi.setPushState({ registrationId: deviceId }); void consumePendingPushRoute(); } finally { if (bindingKey === key) bindingKey = ''; } }); }; /** 读取或更新极光注册标识,并让现有 WebSocket 心跳携带本机标识。 */ const refreshRegistration = () => { if (!initialized) return; // #ifdef APP-PLUS const currentId = String(nativePush.getRegistrationId() || '').trim(); if (currentId) { registrationId = currentId; wsApi.setPushState({ registrationId: accountId ? currentId : '' }); void syncBinding(); return; } // #endif scheduleRegistrationRetry(); }; /** 注册尚未生成或服务端短时不可用时有界补试,恢复前台后可开启新一轮重试。 */ const scheduleRegistrationRetry = () => { if (registrationTimer || registrationRetries >= REGISTRATION_RETRY_LIMIT || !accountId) return; registrationRetries += 1; registrationTimer = setTimeout(() => { registrationTimer = null; refreshRegistration(); }, REGISTRATION_RETRY_MS); }; /** 从原生通知取出统一业务扩展字段,兼容 iOS 顶层和 Android notificationExtras。 */ const notificationExtras = (raw) => { const message = asObject(raw); return { ...message, ...asObject(message.extra), ...asObject(message.extras), ...asObject(message.notificationExtras), ...asObject(asObject(message.android).extras), ...asObject(asObject(message.ios).extras), }; }; /** 暂存合法通知点击,聊天数据或登录尚未就绪时由后续生命周期消费。 */ export const handlePushNotificationClick = (raw) => { const extras = notificationExtras(raw); if (!['private_message', 'manual'].includes(extras.type)) return; const receiverId = String(extras.receiverId || ''); const targetId = String(extras.targetId || extras.senderId || ''); if (extras.type === 'private_message' && (!receiverId || !targetId)) return; if (accountId && receiverId && receiverId !== accountId) return; // 兼容升级前排队通知,Android 原生回调使用 msgId,iOS APNs 保留 _j_msgid。 const clickId = `${extras.type}:${extras.taskId || extras.messageId || extras.msgId || extras.messageID || extras._j_msgid || ''}`; if (handledClicks.has(clickId)) return; uni.setStorageSync(PENDING_ROUTE_KEY, { type: extras.type, targetId, ownerId: receiverId || accountId, clickId, }); getApp().$vm?.refreshUnreadBadge?.(); void consumePendingPushRoute(); }; /** 等待主站认证和 IM 离线同步后,使用稳定 IM ID 打开会话或消息列表。 */ export const consumePendingPushRoute = async () => { if (routing) return; const pending = asObject(uni.getStorageSync(PENDING_ROUTE_KEY)); if (!pending.type) return; const pages = getCurrentPages(); const current = pages[pages.length - 1]; if (!current) return; if (!uni.getStorageSync('token')) { if (current.route !== 'pages/login/login') { routing = true; try { await uni.navigateTo({ url: '/pages/login/login' }); } catch { console.warn('[JPush] 登录页尚未就绪,保留通知跳转'); } finally { routing = false; } } return; } if (!accountId) return; if (pending.ownerId && pending.ownerId !== accountId) { uni.removeStorageSync(PENDING_ROUTE_KEY); return; } const app = getApp().$vm; if (pending.type === 'private_message' && (!app?.configStore?.appInit || app?.chatStore?.loading)) return; const version = accountVersion; routing = true; try { if (pending.type === 'manual') { await uni.switchTab({ url: '/pages/tabbar/message' }); } else { const currentChat = current.$vm?.chat; const sameChat = current.route === 'pages/message/chat-box' && currentChat?.type === 'PRIVATE' && String(currentChat.targetId) === pending.targetId; if (!sameChat) { const url = `/pages/message/chat-box?chatType=PRIVATE&targetId=${encodeURIComponent(pending.targetId)}&receiverId=${encodeURIComponent(accountId)}`; if (current.route === 'pages/message/chat-box') await uni.redirectTo({ url }); else await uni.navigateTo({ url }); } } if (version !== accountVersion) return; handledClicks.add(pending.clickId); if (handledClicks.size > HANDLED_CLICK_LIMIT) handledClicks.delete(handledClicks.values().next().value); if (asObject(uni.getStorageSync(PENDING_ROUTE_KEY)).clickId === pending.clickId) { uni.removeStorageSync(PENDING_ROUTE_KEY); } } catch { console.warn('[JPush] 通知页面尚未就绪,保留跳转等待下一次恢复'); } finally { routing = false; const next = asObject(uni.getStorageSync(PENDING_ROUTE_KEY)); if (next.clickId && next.clickId !== pending.clickId) void consumePendingPushRoute(); } }; /** 项目最低 Android 10,启动时幂等创建服务端指定的高优先级消息渠道。 */ const createAndroidNotificationChannel = () => { // #ifdef APP-PLUS const NotificationChannel = plus.android.importClass('android.app.NotificationChannel'); const NotificationManager = plus.android.importClass('android.app.NotificationManager'); const manager = plus.android.runtimeMainActivity().getSystemService('notification'); plus.android.importClass(manager); const channel = new NotificationChannel(ANDROID_MESSAGE_CHANNEL_ID, '消息通知', NotificationManager.IMPORTANCE_HIGH); channel.setDescription('新私聊消息和平台通知'); channel.enableVibration(true); manager.createNotificationChannel(channel); // #endif }; /** 只在原生 App 初始化一次,先注册点击监听以接收原生缓存的冷启动事件。 */ export const initializePush = () => { if (!isNativeApp() || initialized) return; // #ifdef APP-PLUS try { nativePush.setEventCallBack({ callback: (event) => { if (event.eventName === 'onClickMessage') handlePushNotificationClick(event.eventData); if (event.eventName === 'onRegister' || event.eventName === 'onConnected') refreshRegistration(); } }); nativePush.setDebug(false); const platform = String(uni.getSystemInfoSync().platform).toLowerCase(); if (platform === 'ios') { nativePush.initPush({ appkey: JPUSH_APP_KEY, channel: 'App Store', isProduction: JPUSH_APNS_PRODUCTION, advertisingId: '', }); } else { createAndroidNotificationChannel(); nativePush.init(); nativePush.requestRequiredPermission(); } initialized = true; refreshRegistration(); } catch { console.warn('[JPush] 原生推送暂不可用,请确认使用包含极光插件的自定义基座'); } // #endif }; /** 登录或恢复主站认证后,绑定业务会员,账户发生变化时清理旧账号的待处理点击。 */ export const bindPushUser = (userId) => { if (!isNativeApp() || !userId) return Promise.resolve(); const nextId = String(userId); if (accountId && accountId !== nextId) { uni.removeStorageSync(PENDING_ROUTE_KEY); boundKey = ''; try { clearNotifications(); } catch { /* 继续新账号设备绑定。 */ } } if (accountId !== nextId) accountVersion += 1; accountId = nextId; initializePush(); // #ifdef APP-PLUS void checkPushNotificationPermission(nativePush); // #endif registrationRetries = 0; refreshRegistration(); void consumePendingPushRoute(); return syncBinding(); }; /** 清除本机通知,防止退出或换账号后旧消息继续保留在系统通知栏。 */ const clearNotifications = () => { // #ifdef APP-PLUS if (initialized) nativePush.clearNotificationAll(); // #endif }; /** 先使本地账户代际失效,再精确解绑,调用方可选择等待后再清除登录令牌。 */ export const unbindPushUser = ({ clearPendingRoute = false } = {}) => { const token = uni.getStorageSync('token'); const deviceId = registrationId; const hadBinding = Boolean(accountId || boundKey || bindingKey); accountVersion += 1; accountId = ''; boundKey = ''; bindingKey = ''; clearTimeout(registrationTimer); registrationTimer = null; // 匿名启动的认证失败会重复清理会话,通知待办应保留到登录后校验接收账号。 if (hadBinding || clearPendingRoute) uni.removeStorageSync(PENDING_ROUTE_KEY); handledClicks.clear(); wsApi.setPushState({ registrationId: '' }); try { // #ifdef APP-PLUS if (initialized) nativePush.deleteAlias(accountVersion + 1); // #endif clearNotifications(); } catch { console.warn('[JPush] 本机通知清理暂未完成'); } if (!hadBinding || !isNativeApp() || !deviceId || !token) return Promise.resolve(); return enqueueBinding(() => PushApi.unbind(deviceId, token)); }; /** 前后台变化立即通过现有 IM 心跳上报,前台恢复补查注册并消费冷启动路由。 */ export const setPushForeground = (foreground) => { if (!isNativeApp()) return; wsApi.setPushState({ foreground }); if (foreground) { // #ifdef APP-PLUS if (accountId) void checkPushNotificationPermission(nativePush); // #endif registrationRetries = 0; try { refreshRegistration(); } catch { /* 插件异常不阻止 App 回到前台。 */ } void consumePendingPushRoute(); } };