Files
Friendship-and-chat-uniapp/src/common/jpush.js
T
2026-11-11 19:38:29 +08:00

350 lines
14 KiB
JavaScript
Raw 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 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();
}
};