fix: some
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 2m16s

This commit is contained in:
luo
2026-10-14 13:40:55 +08:00
parent 131823ca12
commit f7cb28febf
6 changed files with 262 additions and 20 deletions

View File

@@ -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',

View File

@@ -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();
}
}, {

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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');