From f7cb28febf7d4012397fe2346e80292bf1ee5e82 Mon Sep 17 00:00:00 2001 From: luo Date: Wed, 14 Oct 2026 13:40:55 +0800 Subject: [PATCH] fix: some --- api/modules/asset.js | 7 ++ components/TrafficCard.vue | 5 +- pages/index/index.vue | 18 ++++- pages/login/login.vue | 89 ++++++++++++++++++--- store/user.js | 6 ++ tests/october-h5.test.mjs | 157 ++++++++++++++++++++++++++++++++++++- 6 files changed, 262 insertions(+), 20 deletions(-) diff --git a/api/modules/asset.js b/api/modules/asset.js index 689c168..a9bff8f 100644 --- a/api/modules/asset.js +++ b/api/modules/asset.js @@ -59,8 +59,15 @@ export const hasActiveOrPendingPackage = async (identifier) => { export const assetApi = { getInfo(identifier) { + identifier = typeof identifier === 'string' ? identifier.trim() : ''; + if (!identifier) { + return Promise.reject({ msg: '缺少资产标识,请重新输入或扫码登录' }); + } const context = getSessionContext(); const userStore = useUserStore(); + if (userStore.state.loginPending || !context.token) { + return Promise.reject({ msg: '登录和微信配置尚未完成,请稍后重试' }); + } const phoneRevision = userStore.state.phoneRevision; return request({ url: '/api/c/v1/asset/info', diff --git a/components/TrafficCard.vue b/components/TrafficCard.vue index a6c2dc2..3f1297f 100644 --- a/components/TrafficCard.vue +++ b/components/TrafficCard.vue @@ -127,6 +127,7 @@ // 加载套餐流量数据 const loadPackageData = async () => { + if (userStore.state.loginPending || !userStore.state.token) return; const identifier = props.identifier || userStore.state.identifier; if (!identifier) return; @@ -151,8 +152,8 @@ }; // 监听 identifier 变化 - watch(() => props.identifier || userStore.state.identifier, (newVal) => { - if (newVal) { + watch(() => [props.identifier || userStore.state.identifier, userStore.state.token, userStore.state.loginPending], ([newVal, token, loginPending]) => { + if (newVal && token && !loginPending) { loadPackageData(); } }, { diff --git a/pages/index/index.vue b/pages/index/index.vue index c3827f1..bae5519 100644 --- a/pages/index/index.vue +++ b/pages/index/index.vue @@ -305,8 +305,9 @@ }; const loadAssetInfo = async () => { - const identifier = userStore.state.identifier; + const identifier = String(userStore.state.identifier || uni.getStorageSync('identifier') || '').trim(); if (!identifier) return; + if (userStore.state.identifier !== identifier) userStore.setIdentifier(identifier); const context = getSessionContext(); loading.value = true; @@ -401,11 +402,18 @@ }; const handleIndexEntry = async () => { + if (userStore.state.loginPending) return; const token = uni.getStorageSync('token'); - if (!token) { + const identifier = String(userStore.state.identifier || uni.getStorageSync('identifier') || '').trim(); + if (!token || !identifier) { + if (token && !identifier) { + userStore.clearUser(); + uni.showToast({ title: '缺少资产标识,请重新输入或扫码登录', icon: 'none' }); + } uni.reLaunch({ url: '/pages/login/login' }); return; } + if (userStore.state.identifier !== identifier) userStore.setIdentifier(identifier); if (indexEntryChecking.value) return; indexEntryChecking.value = true; @@ -1001,7 +1009,11 @@ awaitingHomeReminders.value = true; notificationFallbackPending = false; notificationQueued = false; - if (!getSessionContext().token) { + if (userStore.state.loginPending) { + awaitingHomeReminders.value = false; + return; + } + if (!getSessionContext().token || !String(userStore.state.identifier || uni.getStorageSync('identifier') || '').trim()) { awaitingHomeReminders.value = false; handleIndexEntry(); return; diff --git a/pages/login/login.vue b/pages/login/login.vue index 05b2e30..6c99d6a 100644 --- a/pages/login/login.vue +++ b/pages/login/login.vue @@ -71,7 +71,14 @@ import { useUserStore } from '@/store/index.js'; import { initWxConfig, wxScan, isInWechat } from '@/utils/wxsdk.js'; + const WECHAT_LOGIN_CONTEXT_KEY = 'wechatLoginContext'; + const userStore = useUserStore(); + // Close the asset-request gate synchronously, before cached homepage + // components can react to the callback's old token or saved identifier. + if (new URLSearchParams(window.location.search).get('code') && sessionStorage.getItem('assetToken')) { + userStore.setLoginPending(true); + } const identifier = ref(uni.getStorageSync('identifier') || ''); const loading = ref(false); const agreed = ref(true); @@ -82,6 +89,24 @@ const resetLoadingState = () => { loading.value = false; }; + const initializeLoginSdk = async () => { + if (isInWechat()) await initWxConfig(); + }; + const resumeStoredSession = async (assetIdentifier) => { + userStore.setLoginPending(true); + loading.value = true; + try { + userStore.setIdentifier(assetIdentifier); + await initializeLoginSdk(); + sessionStorage.removeItem('assetToken'); + sessionStorage.removeItem(WECHAT_LOGIN_CONTEXT_KEY); + userStore.setLoginPending(false); + uni.reLaunch({ url: '/pages/index/index' }); + } catch (e) { + uni.showToast({ title: e?.msg || e?.message || '微信初始化失败,请重新登录', icon: 'none' }); + loading.value = false; + } + }; onMounted(async () => { // Remove the obsolete post-bind re-login notice from older client builds. @@ -89,13 +114,19 @@ const urlIdentifier = getPathDeviceId(); const token = uni.getStorageSync('token'); const storedIdentifier = uni.getStorageSync('identifier') || ''; + // Complete OAuth first. An existing token or the callback's identifier + // must not bypass the callback or start another authorization round. + if (getCode() && sessionStorage.getItem('assetToken')) { + await handleWechatCallback(); + return; + } // 从外部链接进入时,链接中的资产标识优先于本地登录态。 // 只有相同资产才能复用已登录的会话,避免将上一台设备的数据带到新链接中。 if (urlIdentifier) { identifier.value = urlIdentifier; if (token && urlIdentifier === storedIdentifier) { - uni.reLaunch({ url: '/pages/index/index' }); + await resumeStoredSession(urlIdentifier); return; } @@ -103,16 +134,15 @@ userStore.clearUser(); } - doLogin(); + await doLogin(); return; } - if (token) { - uni.reLaunch({ url: '/pages/index/index' }); + if (token && storedIdentifier.trim()) { + await resumeStoredSession(storedIdentifier.trim()); return; } - - handleWechatCallback(); + if (token) userStore.clearUser(); // 初始化微信 SDK (仅在微信浏览器内执行) // #ifdef H5 @@ -163,10 +193,13 @@ return wechatAppId.value; }; - const redirectToWxAuth = async (assetToken) => { + const redirectToWxAuth = async (assetToken, assetIdentifier) => { sessionStorage.setItem('assetToken', assetToken); + sessionStorage.setItem(WECHAT_LOGIN_CONTEXT_KEY, JSON.stringify({ assetToken, identifier: assetIdentifier })); const appId = await getWechatAppId(); - const redirectUri = encodeURIComponent(window.location.origin + window.location.pathname); + const callbackUrl = new URL(window.location.pathname, window.location.origin); + callbackUrl.searchParams.set('identifier', assetIdentifier); + const redirectUri = encodeURIComponent(callbackUrl.toString()); const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_userinfo&state=wechat_auth#wechat_redirect`; window.location.href = url; }; @@ -290,19 +323,23 @@ const doLogin = async () => { loading.value = true; + userStore.setLoginPending(true); // 丢弃上一轮未完成授权留下的临时凭证,避免校验失败时继续复用。 if (typeof sessionStorage !== 'undefined') { sessionStorage.removeItem('assetToken'); + sessionStorage.removeItem(WECHAT_LOGIN_CONTEXT_KEY); } try { - const enteredIdentifier = identifier.value; + const enteredIdentifier = String(identifier.value || '').trim(); + if (!enteredIdentifier) throw { msg: '请输入有效的资产标识' }; const { verifyData, verifiedIdentifier } = await verifyAssetWithIccidRetry(enteredIdentifier); userStore.setAssetToken(verifyData.asset_token); userStore.setIdentifier(verifiedIdentifier); - await redirectToWxAuth(verifyData.asset_token); + await redirectToWxAuth(verifyData.asset_token, verifiedIdentifier); } catch (e) { + userStore.setLoginPending(false); console.error('登录失败', e); uni.showToast({ title: e?.msg || e?.message || '资产校验失败,请稍后重试', @@ -318,15 +355,38 @@ const assetToken = sessionStorage.getItem('assetToken'); if (!code || !assetToken) return; + userStore.setLoginPending(true); + let loginContext = null; + try { + loginContext = JSON.parse(sessionStorage.getItem(WECHAT_LOGIN_CONTEXT_KEY) || 'null'); + } catch (e) { + // Old clients may only have the legacy assetToken and saved identifier. + } + const contextIdentifier = loginContext?.assetToken === assetToken && typeof loginContext.identifier === 'string' + ? loginContext.identifier.trim() : ''; + const loginIdentifier = contextIdentifier || getPathDeviceId() || + String(uni.getStorageSync('identifier') || userStore.state.identifier || identifier.value || '').trim(); + if (!loginIdentifier) { + clearCode(); + sessionStorage.removeItem('assetToken'); + sessionStorage.removeItem(WECHAT_LOGIN_CONTEXT_KEY); + userStore.clearUser(); + uni.showToast({ title: '缺少资产标识,请重新输入或扫码登录', icon: 'none' }); + loading.value = false; + return; + } loading.value = true; clearCode(); try { const loginData = await authApi.wechatLogin(assetToken, code); + // Restore the verified identifier before publishing the login state or + // mounting homepage components that immediately request asset/info. + userStore.setIdentifier(loginIdentifier); userStore.setToken(loginData.token); userStore.setLoginPhoneState(loginData); - uni.setStorageSync('identifier', userStore.state.identifier); + identifier.value = loginIdentifier; // #ifdef H5 if (loginData.user_info) { @@ -337,6 +397,13 @@ } // #endif + // Keep the gate closed while JSSDK config/ready is still pending. + // Homepage and bind-page asset/info requests only start afterwards. + await initializeLoginSdk(); + sessionStorage.removeItem('assetToken'); + sessionStorage.removeItem(WECHAT_LOGIN_CONTEXT_KEY); + userStore.setLoginPending(false); + if (userStore.state.needBindPhone === true) { uni.redirectTo({ url: '/pages/bind/bind?fromLogin=true' }); } else { diff --git a/store/user.js b/store/user.js index 9869c5a..8e81645 100644 --- a/store/user.js +++ b/store/user.js @@ -12,6 +12,8 @@ const emptyPhoneState = () => ({ const savedPhoneState = uni.getStorageSync(PHONE_STATE_KEY); const token = uni.getStorageSync('token') || ''; const identifier = uni.getStorageSync('identifier') || ''; +const isOAuthCallback = typeof window !== 'undefined' && typeof sessionStorage !== 'undefined' && + !!new URLSearchParams(window.location.search).get('code') && !!sessionStorage.getItem('assetToken'); const phoneState = savedPhoneState?.token === token && savedPhoneState?.identifier === identifier && token ? { customerPhone: typeof savedPhoneState.customerPhone === 'string' ? savedPhoneState.customerPhone : null, @@ -25,6 +27,7 @@ const state = reactive({ token, assetToken: '', identifier, + loginPending: isOAuthCallback, ...phoneState, phoneRevision: 0, isDevice: false, @@ -36,6 +39,7 @@ const state = reactive({ }); export const useUserStore = () => { + const setLoginPending = (pending) => { state.loginPending = pending === true; }; const persistPhoneState = () => { uni.setStorageSync(PHONE_STATE_KEY, { token: state.token, identifier: state.identifier, @@ -111,6 +115,7 @@ export const useUserStore = () => { const clearUser = () => { resetContext(); state.token = ''; + state.loginPending = false; state.assetToken = ''; state.identifier = ''; state.isDevice = false; @@ -124,6 +129,7 @@ export const useUserStore = () => { return { state, setToken, + setLoginPending, setAssetToken, setIdentifier, setIsDevice, diff --git a/tests/october-h5.test.mjs b/tests/october-h5.test.mjs index 3b04fa2..a6982df 100644 --- a/tests/october-h5.test.mjs +++ b/tests/october-h5.test.mjs @@ -29,6 +29,8 @@ function runtime(initial = {}) { const timers = []; const sessionStorage = new Map(); const api = {}; + let inWechat = false; + let sdkInit = async () => {}; let options = {}; const uni = { getStorageSync: key => storage.get(key) ?? '', @@ -44,13 +46,13 @@ function runtime(initial = {}) { navigateBack: () => calls.push({ kind: 'navigateBack' }) }; const context = vm.createContext({ - uni, Intl, Date, URLSearchParams, + uni, Intl, Date, URLSearchParams, URL, console: { log() {}, error() {}, warn() {} }, setTimeout: callback => { timers.push(callback); return timers.length; }, setInterval: () => 1, clearInterval() {}, getCurrentPages: () => [{ options }], sessionStorage: { getItem: key => sessionStorage.get(key) || '', removeItem: key => sessionStorage.delete(key), setItem: (key, value) => sessionStorage.set(key, value) }, - window: { location: { search: '', pathname: '/', hash: '' }, history: { replaceState() {} } }, + window: { location: { search: '', pathname: '/', hash: '', origin: 'https://example.invalid' }, history: { replaceState() {} } }, defineProps: () => ({}), defineEmits: () => () => {} }); const cache = new Map(); @@ -88,7 +90,7 @@ function runtime(initial = {}) { consumePendingPaymentRefresh: () => false, PAYMENT_REFRESH_TARGETS: { HOME: 'home' } }); if (specifier === '@/utils/wxsdk.js') return synthetic('wxsdk', { - initWxConfig: async () => {}, wxScan: async () => {}, isInWechat: () => false + initWxConfig: () => sdkInit(), wxScan: async () => {}, isInWechat: () => inWechat }); if (specifier === './env.js') return synthetic('env', { BASE_URL: 'https://example.invalid' }); if (specifier.endsWith('.vue')) return synthetic(specifier, { default: {} }); @@ -105,7 +107,8 @@ function runtime(initial = {}) { request.success({ statusCode, data: { code, msg, data } }); }; return { storage, sessionStorage, context, calls, requests, mounted, shown, hidden, unmounted, timers, api, load, reply, - setOptions: value => { options = value; }, tick: () => vue.nextTick() }; + setOptions: value => { options = value; }, tick: () => vue.nextTick(), + setWechatSdk: init => { inWechat = true; sdkInit = init; } }; } test('phone binding modes and success updates distinguish all four cases', async () => { @@ -231,6 +234,152 @@ test('login routing honors server guidance even with a phone bound to someone el } }); +test('OAuth round trip carries the verified identifier without relying on localStorage', async () => { + const r = runtime({ token: '', identifier: '' }); + r.api.authApi = { verifyAsset: async id => { assert.equal(id, 'B'); return { asset_token: 'asset-token-B' }; } }; + r.api.wechatApi = { getAppId: async () => ({ app_id: 'mock-app-id' }) }; + const page = await r.load('pages/login/login.vue', ['identifier', 'doLogin']); + page.identifier.value = ' B '; + await page.doLogin(); + assert.equal(r.storage.get('identifier'), 'B'); + assert.deepEqual(JSON.parse(r.sessionStorage.get('wechatLoginContext')), { assetToken: 'asset-token-B', identifier: 'B' }); + const authUrl = new URL(r.context.window.location.href); + assert.equal(new URL(authUrl.searchParams.get('redirect_uri')).searchParams.get('identifier'), 'B'); +}); + +test('OAuth callback restores its own asset identifier before entering home', async () => { + const r = runtime({ token: 'old-token', identifier: 'old-asset' }); + r.sessionStorage.set('assetToken', 'asset-token-B'); + r.sessionStorage.set('wechatLoginContext', JSON.stringify({ assetToken: 'asset-token-B', identifier: 'B' })); + r.context.window.location.search = '?code=mock-code&identifier=B'; + r.api.authApi = { wechatLogin: async () => ({ token: 'new-token', need_bind_phone: false }) }; + r.api.wechatApi = {}; + await r.load('pages/login/login.vue'); + await r.mounted[0](); + const user = (await r.load('store/user.js')).useUserStore(); + assert.equal(user.state.identifier, 'B'); + assert.equal(r.storage.get('identifier'), 'B'); + assert.equal(user.state.token, 'new-token'); + assert.equal(r.calls.find(call => call.kind === 'redirectTo').url, '/pages/index/index'); + assert.equal(r.sessionStorage.has('assetToken'), false); + const { assetApi } = await r.load('api/modules/asset.js'); + const info = assetApi.getInfo(user.state.identifier); + assert.deepEqual(plain(r.requests[0].data), { identifier: 'B' }); + r.reply(r.requests[0], { identifier: 'B' }); await info; +}); + +test('callback can recover a verified identifier from session even if localStorage is empty', async () => { + const r = runtime({ token: '', identifier: '' }); + r.sessionStorage.set('assetToken', 'asset-token-B'); + r.sessionStorage.set('wechatLoginContext', JSON.stringify({ assetToken: 'asset-token-B', identifier: 'B' })); + r.context.window.location.search = '?code=mock-code'; + r.api.authApi = { wechatLogin: async () => ({ token: 'new-token', need_bind_phone: true }) }; + r.api.wechatApi = {}; + const page = await r.load('pages/login/login.vue', ['handleWechatCallback']); + await page.handleWechatCallback(); + assert.equal(r.storage.get('identifier'), 'B'); + assert.equal(r.calls.find(call => call.kind === 'redirectTo').url, '/pages/bind/bind?fromLogin=true'); +}); + +test('callback falls back to URL/saved identifiers, but never enters home without one', async () => { + for (const source of ['url', 'storage', 'missing']) { + const r = runtime({ token: '', identifier: '' }); + r.sessionStorage.set('assetToken', 'mock-asset-token'); + r.context.window.location.search = source === 'url' ? '?code=mock-code&identifier=B' : '?code=mock-code'; + if (source === 'storage') r.storage.set('identifier', 'B'); + let logins = 0; + r.api.wechatApi = {}; + r.api.authApi = { wechatLogin: async () => { logins++; return { token: 'new-token', need_bind_phone: false }; } }; + const page = await r.load('pages/login/login.vue', ['handleWechatCallback']); + await page.handleWechatCallback(); + if (source === 'missing') { + assert.equal(logins, 0); + assert.equal(r.calls.some(call => call.kind === 'redirectTo'), false); + assert.match(r.calls.find(call => call.kind === 'toast').title, /缺少资产标识/); + } else { + assert.equal(r.storage.get('identifier'), 'B'); + assert.equal(logins, 1); + } + } +}); + +test('asset-info API rejects empty identifiers without a network request', async () => { + const r = runtime(); + const { assetApi } = await r.load('api/modules/asset.js'); + for (const value of ['', ' ', undefined, null]) { + await assert.rejects(assetApi.getInfo(value)); + } + assert.equal(r.requests.length, 0); + const request = assetApi.getInfo(' A '); + assert.deepEqual(plain(r.requests[0].data), { identifier: 'A' }); + r.reply(r.requests[0], {}); await request; +}); + +test('asset info waits until both wechat-login and JSSDK readiness complete', async () => { + const r = runtime({ token: 'old-token', identifier: 'old-asset' }); + r.sessionStorage.set('assetToken', 'asset-token-B'); + r.sessionStorage.set('wechatLoginContext', JSON.stringify({ assetToken: 'asset-token-B', identifier: 'B' })); + r.context.window.location.search = '?code=mock-code'; + const login = deferred(); + const sdk = deferred(); + const events = []; + r.api.authApi = { wechatLogin: () => { events.push('login'); return login.promise; } }; + r.api.wechatApi = {}; + r.setWechatSdk(() => { events.push('jssdk'); return sdk.promise; }); + await r.load('pages/login/login.vue'); + const { assetApi } = await r.load('api/modules/asset.js'); + const pending = r.mounted[0](); + await assert.rejects(assetApi.getInfo('old-asset')); + assert.equal(r.requests.length, 0); + assert.deepEqual(events, ['login']); + login.resolve({ token: 'new-token', need_bind_phone: false }); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(events, ['login', 'jssdk']); + assert.equal(r.calls.some(call => call.kind === 'redirectTo'), false); + await assert.rejects(assetApi.getInfo('B')); + assert.equal(r.requests.length, 0); + sdk.resolve(); await pending; + const user = (await r.load('store/user.js')).useUserStore(); + assert.equal(user.state.loginPending, false); + assert.equal(r.calls.find(call => call.kind === 'redirectTo').url, '/pages/index/index'); + const info = assetApi.getInfo(user.state.identifier); + assert.deepEqual(plain(r.requests[0].data), { identifier: 'B' }); + assert.equal(r.requests[0].header.Authorization, 'Bearer new-token'); + r.reply(r.requests[0], {}); await info; +}); + +test('JSSDK failure does not enter the homepage or release asset requests', async () => { + const r = runtime({ token: '', identifier: 'A' }); + r.sessionStorage.set('assetToken', 'asset-token-A'); + r.context.window.location.search = '?code=mock-code'; + r.api.authApi = { wechatLogin: async () => ({ token: 'new-token', need_bind_phone: false }) }; + r.api.wechatApi = {}; + r.setWechatSdk(async () => { throw new Error('JSSDK failed'); }); + const page = await r.load('pages/login/login.vue', ['handleWechatCallback']); + await page.handleWechatCallback(); + assert.equal(r.calls.some(call => call.kind === 'redirectTo'), false); + const { assetApi } = await r.load('api/modules/asset.js'); + await assert.rejects(assetApi.getInfo('A')); + assert.equal(r.requests.length, 0); +}); + +test('returning login session initializes JSSDK before re-entering homepage', async () => { + const r = runtime(); + r.api.authApi = {}; r.api.wechatApi = {}; + const sdk = deferred(); + r.setWechatSdk(() => sdk.promise); + await r.load('pages/login/login.vue'); + const pending = r.mounted[0](); + const { assetApi } = await r.load('api/modules/asset.js'); + await assert.rejects(assetApi.getInfo('A')); + assert.equal(r.calls.some(call => call.kind === 'reLaunch'), false); + sdk.resolve(); await pending; + assert.equal(r.calls.find(call => call.kind === 'reLaunch').url, '/pages/index/index'); + const info = assetApi.getInfo('A'); + assert.equal(r.requests.length, 1); + r.reply(r.requests[0], {}); await info; +}); + test('expiry boundaries and Shanghai rendering preserve server values, including zero', async () => { const r = runtime(); const { normalizeExpiryReminder, getShanghaiDay, formatShanghaiExpiry } = await r.load('utils/expiry-reminder.js');