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 });
+ });
+ }
+ });
+};