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