diff --git a/main.js b/main.js index cd0ac36..32a2bce 100644 --- a/main.js +++ b/main.js @@ -58,9 +58,17 @@ app.$mount() // #ifdef VUE3 import { createSSRApp } from 'vue' +import { assetApi } from '@/api/modules/asset.js' +import { getSessionContext } from '@/utils/session-context.js' +import { createSilenceGuard, installSilenceGuard } from '@/utils/silence-guard.js' export function createApp() { patchSafeSetTabBarStyle() const app = createSSRApp(App) + // 沉默期已过期待延期:除绑定手机号、延期支付等页面外全部拦截到延期页 + installSilenceGuard(app, createSilenceGuard({ + getSession: getSessionContext, + loadPackageHistory: (identifier) => assetApi.getPackageHistory(identifier, 1, 20, { status: 0 }) + })) // 使用 uview-plus (版本 3.4.0+) app.use(uviewPlus, () => { return { diff --git a/pages/index/index.vue b/pages/index/index.vue index e681697..349af55 100644 --- a/pages/index/index.vue +++ b/pages/index/index.vue @@ -102,6 +102,8 @@ + + { + silenceChecking.value = true; + try { + await loadSilenceReminderInner(entryId); + } finally { + if (entryId === homeEntryId) silenceChecking.value = false; + } + }; + const loadSilenceReminderInner = async (entryId) => { const identifier = String(userStore.state.identifier || '').trim(); silenceReminder.value = null; if (!identifier) return; @@ -1115,6 +1126,7 @@ popupCandidateShow.value = false; silenceReminderShow.value = false; silenceReminder.value = null; + silenceChecking.value = false; notificationQueued = false; notificationFallbackPending = false; }); @@ -1149,4 +1161,11 @@ .bottom-spacer { height: 120rpx; } + + .silence-checking-mask { + position: fixed; + inset: 0; + z-index: 10199; + background: transparent; + } diff --git a/tests/silence-guard.test.mjs b/tests/silence-guard.test.mjs new file mode 100644 index 0000000..0d22082 --- /dev/null +++ b/tests/silence-guard.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createSilenceGuard, isSilenceGuardAllowed, pagePathOf, silenceExtensionUrl } from '../utils/silence-guard.js'; + +const expiredItems = [{ package_usage_id: 45016, package_name: '套餐', silence: { silence_status: 'expired', extendable: true } }]; +const okItems = [{ package_usage_id: 1, silence: { silence_status: 'ended' } }]; + +const setup = (items) => { + let clock = 0; + const calls = []; + const state = { items, session: { token: 't', identifier: 'A' } }; + const guard = createSilenceGuard({ + getSession: () => state.session, + loadPackageHistory: async (identifier) => { calls.push(identifier); return { items: state.items }; }, + now: () => clock + }); + return { guard, calls, state, advance: (ms) => { clock += ms; } }; +}; + +test('只有绑定手机号、首页、登录授权与延期支付相关页面不拦截', () => { + for (const url of ['/pages/bind/bind?fromLogin=true', 'pages/index/index', '/pages/login/login', '/pages/silence-extension/silence-extension?package_usage_id=1', + '/pages/silence-payment/silence-payment', '/pages/silence-orders/detail?id=3', '/pages/alipay-payment/alipay-payment']) { + assert.equal(isSilenceGuardAllowed(url), true, url); + } + for (const url of ['/pages/change-phone/change-phone', '/pages/package-order/package-order', '/pages/switch/switch', '/pages/order-list/order-list', + '/pages/my-wallet/my-wallet', '/pages/device-exchange/device-exchange', '/pages/asset-package-history/asset-package-history', '/pages/notifications/notifications']) { + assert.equal(isSilenceGuardAllowed(url), false, url); + } + assert.equal(pagePathOf('/pages/a/b?x=1'), 'pages/a/b'); + assert.equal(silenceExtensionUrl({ packageUsageId: 45016 }), '/pages/silence-extension/silence-extension?package_usage_id=45016'); +}); + +test('沉默期已过期待延期时拦截,且每次都重新查询,延期完成后立即放行', async () => { + const { guard, calls, state } = setup(expiredItems); + assert.equal(guard.isKnownPass(), false); + assert.equal((await guard.resolveBlock()).packageUsageId, 45016); + assert.equal((await guard.resolveBlock()).packageUsageId, 45016); + assert.equal(calls.length, 2); + state.items = okItems; + assert.equal(await guard.resolveBlock(), null); + assert.equal(guard.isKnownPass(), true); +}); + +test('不需要延期时缓存放行结果 5 分钟,换资产或换登录重新检查', async () => { + const { guard, calls, state, advance } = setup(okItems); + assert.equal(await guard.resolveBlock(), null); + assert.equal(guard.isKnownPass(), true); + await guard.resolveBlock(); + assert.equal(calls.length, 1); + advance(5 * 60 * 1000); + assert.equal(guard.isKnownPass(), false); + state.session = { token: 't', identifier: 'B' }; + assert.equal(guard.isKnownPass(), false); + state.items = expiredItems; + assert.equal((await guard.resolveBlock()).packageUsageId, 45016); +}); + +test('未登录不拦截;查询失败放行但不缓存', async () => { + const anon = createSilenceGuard({ getSession: () => ({ token: '', identifier: '' }), loadPackageHistory: async () => { throw new Error('x'); } }); + assert.equal(anon.isKnownPass(), true); + assert.equal(await anon.resolveBlock(), null); + const origError = console.error; console.error = () => {}; + const failing = createSilenceGuard({ getSession: () => ({ token: 't', identifier: 'A' }), loadPackageHistory: async () => { throw new Error('x'); } }); + assert.equal(await failing.resolveBlock(), null); + assert.equal(failing.isKnownPass(), false); + console.error = origError; +}); + +test('不可延期的过期套餐不拦截(无法办理延期)', async () => { + const { guard } = setup([{ package_usage_id: 2, silence: { silence_status: 'expired', extendable: false } }]); + assert.equal(await guard.resolveBlock(), null); +}); diff --git a/utils/silence-guard.js b/utils/silence-guard.js new file mode 100644 index 0000000..6a7292f --- /dev/null +++ b/utils/silence-guard.js @@ -0,0 +1,144 @@ +/** + * 沉默期强制延期拦截:当前资产有"沉默期已过期待延期"的套餐时, + * 除首页(强制延期弹窗)、登录/授权、绑定手机号、延期与支付相关页面外,其他页面一律不能进入, + * 统一引导到延期页。 + */ +import { isSilenceReminderBlocking, pickSilenceReminder } from './silence.js'; + +export const SILENCE_EXTENSION_PAGE = '/pages/silence-extension/silence-extension'; + +// 不拦截的页面:登录授权、绑定手机号、首页(自带强制弹窗)、延期办理与支付相关页面 +export const SILENCE_GUARD_ALLOWED_PAGES = Object.freeze([ + 'pages/index/index', + 'pages/login/login', + 'pages/auth/auth', + 'pages/error/error', + 'pages/bind/bind', + 'pages/silence-extension/silence-extension', + 'pages/silence-payment/silence-payment', + 'pages/silence-orders/silence-orders', + 'pages/silence-orders/detail', + 'pages/silence-records/silence-records', + 'pages/alipay-payment/alipay-payment' +]); + +// 未被拦截的结果缓存时长;被拦截时每次都重新查询,延期完成后可立即放行 +const PASS_CACHE_MS = 5 * 60 * 1000; +const NAVIGATION_METHODS = ['navigateTo', 'redirectTo', 'reLaunch', 'switchTab']; +const CHECKED_FLAG = '__silenceGuardChecked'; + +export const pagePathOf = (url) => String(url || '').split('?')[0].replace(/^\/+/, ''); + +export const isSilenceGuardAllowed = (url) => SILENCE_GUARD_ALLOWED_PAGES.includes(pagePathOf(url)); + +export const silenceExtensionUrl = (reminder) => + `${SILENCE_EXTENSION_PAGE}?package_usage_id=${reminder.packageUsageId}`; + +/** + * 创建拦截器核心(依赖可注入,便于测试)。 + * deps.getSession(): { token, identifier } + * deps.loadPackageHistory(identifier): Promise<{ items }> + * deps.now(): number + */ +export const createSilenceGuard = ({ getSession, loadPackageHistory, now = () => Date.now() }) => { + let passCache = null; + let pending = null; + + const sessionKey = () => { + const { token, identifier } = getSession(); + const id = String(identifier || '').trim(); + return token && id ? { key: `${token}|${id}`, identifier: id } : null; + }; + + /** 命中"未拦截"缓存时同步返回 true,可直接放行 */ + const isKnownPass = () => { + const session = sessionKey(); + if (!session) return true; + return !!passCache && passCache.key === session.key && now() - passCache.at < PASS_CACHE_MS; + }; + + /** 查询当前资产是否需要强制延期;返回需要延期的提醒,或 null(放行) */ + const resolveBlock = () => { + const session = sessionKey(); + if (!session) return Promise.resolve(null); + if (isKnownPass()) return Promise.resolve(null); + if (pending?.key === session.key) return pending.promise; + const promise = loadPackageHistory(session.identifier) + .then((data) => { + const reminder = pickSilenceReminder(data?.items, now()); + if (isSilenceReminderBlocking(reminder)) { + passCache = null; + return reminder; + } + passCache = { key: session.key, at: now() }; + return null; + }) + .catch((error) => { + // 查询失败不锁死用户,放行但不缓存,下次跳转再查 + console.error('沉默期拦截检查失败', error); + return null; + }) + .finally(() => { + if (pending?.promise === promise) pending = null; + }); + pending = { key: session.key, promise }; + return promise; + }; + + const reset = () => { + passCache = null; + pending = null; + }; + + return { isKnownPass, resolveBlock, reset }; +}; + +const notifyBlocked = () => { + uni.showToast({ title: '套餐沉默期已过期,请先办理延期', icon: 'none', duration: 2000 }); +}; + +/** + * 安装全局拦截: + * 1. 拦截 navigateTo/redirectTo/reLaunch/switchTab:目标页不在白名单时先检查,需要延期则改去延期页; + * 2. 页面显示时(直接打开链接、刷新、浏览器后退)再检查一次,需要延期则替换为延期页。 + */ +export const installSilenceGuard = (app, guard) => { + NAVIGATION_METHODS.forEach((method) => { + uni.addInterceptor(method, { + invoke(args) { + if (args && args[CHECKED_FLAG]) { + delete args[CHECKED_FLAG]; + return; + } + if (isSilenceGuardAllowed(args?.url) || guard.isKnownPass()) return; + guard.resolveBlock().then((reminder) => { + if (reminder) { + notifyBlocked(); + uni.navigateTo({ url: silenceExtensionUrl(reminder) }); + return; + } + uni[method]({ ...args, [CHECKED_FLAG]: true }); + }); + return false; + } + }); + }); + + app.mixin({ + onShow() { + const route = this.$page?.route || this.route; + if (!route || isSilenceGuardAllowed(route)) return; + const known = guard.isKnownPass(); + if (known) return; + uni.showLoading({ title: '加载中', mask: true }); + guard.resolveBlock().then((reminder) => { + uni.hideLoading(); + const pages = getCurrentPages(); + const current = pages[pages.length - 1]; + if (!reminder || (current?.route && current.route !== route)) return; + notifyBlocked(); + uni.redirectTo({ url: silenceExtensionUrl(reminder), [CHECKED_FLAG]: true }); + }); + } + }); +};