feat: 增加环境切换
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
let pendingWrites = 0;
|
||||
|
||||
/** 覆盖登录等待、上传及重试全过程,防止写操作进行中切换服务端。 */
|
||||
export function beginApiWrite() {
|
||||
pendingWrites++;
|
||||
return () => {
|
||||
pendingWrites--;
|
||||
};
|
||||
}
|
||||
|
||||
export function hasPendingApiWrites() {
|
||||
return pendingWrites > 0;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getApiConfig } from '../config/api';
|
||||
import { beginApiWrite } from './api-activity';
|
||||
import {
|
||||
ApiRequestError,
|
||||
requestApi,
|
||||
@@ -30,6 +31,21 @@ const blocked = new Set([
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
options: RequestOptions & { auth?: 'required' | 'none' } = {},
|
||||
): Promise<T> {
|
||||
const finish =
|
||||
options.upload || (options.method && options.method !== 'GET')
|
||||
? beginApiWrite()
|
||||
: undefined;
|
||||
try {
|
||||
return await performApiRequest<T>(path, options);
|
||||
} finally {
|
||||
finish?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function performApiRequest<T>(
|
||||
path: string,
|
||||
options: RequestOptions & { auth?: 'required' | 'none' } = {},
|
||||
): Promise<T> {
|
||||
// Authorization 由会话层统一提供;公开请求不附带调用方误传的凭证。
|
||||
const header = Object.fromEntries(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getApiConfig } from '../config/api';
|
||||
import { getApiConfig, isCurrentApiConfig } from '../config/api';
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
@@ -28,7 +28,15 @@ export function requestApi<T>(
|
||||
if (!path.startsWith('/') || path.startsWith('//'))
|
||||
throw new Error('接口路径不正确');
|
||||
return new Promise((resolve, reject) => {
|
||||
// 公开请求、上传和登录共用检查,旧环境的响应不进入上层业务回调。
|
||||
const stale = () => {
|
||||
if (isCurrentApiConfig(config)) return false;
|
||||
reject(new ApiRequestError('接口环境已变化,请重新操作'));
|
||||
return true;
|
||||
};
|
||||
if (stale()) return;
|
||||
const success = (res: { statusCode: number; data: unknown }) => {
|
||||
if (stale()) return;
|
||||
const body = res.data as {
|
||||
code?: number;
|
||||
data?: T;
|
||||
@@ -42,7 +50,7 @@ export function requestApi<T>(
|
||||
: '接口请求失败,请稍后重试',
|
||||
res.statusCode,
|
||||
typeof body?.reason === 'string' &&
|
||||
/^[A-Z_]{1,64}$/.test(body.reason)
|
||||
/^[A-Z_]{1,64}$/.test(body.reason)
|
||||
? body.reason
|
||||
: '',
|
||||
),
|
||||
@@ -60,8 +68,12 @@ export function requestApi<T>(
|
||||
);
|
||||
} else resolve(body.data as T);
|
||||
};
|
||||
const fail = () =>
|
||||
reject(new ApiRequestError('连接失败,请确认后端已启动及网络配置'));
|
||||
const fail = () => {
|
||||
if (!stale())
|
||||
reject(
|
||||
new ApiRequestError('连接失败,请确认后端已启动及网络配置'),
|
||||
);
|
||||
};
|
||||
if (options.upload) {
|
||||
// wx.uploadFile 由运行时生成 multipart boundary,不能自行设置 JSON Content-Type。
|
||||
wx.uploadFile({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getApiConfig } from '../config/api';
|
||||
import { getApiConfig, apiContextKey, isCurrentApiConfig } from '../config/api';
|
||||
import { ApiRequestError, requestApi } from './api-transport';
|
||||
|
||||
export interface ServerUser {
|
||||
@@ -32,7 +32,7 @@ export function getAuthContext() {
|
||||
if (!appId) throw new ApiRequestError('无法识别小程序身份');
|
||||
return {
|
||||
...config,
|
||||
key: `doodle_auth_v1:${encodeURIComponent(appId)}:${encodeURIComponent(config.baseUrl)}`,
|
||||
key: `doodle_auth_v2:${apiContextKey(config)}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,7 +70,18 @@ function readState(context: Context): SessionState {
|
||||
generation: 0,
|
||||
};
|
||||
try {
|
||||
const stored = wx.getStorageSync(context.key);
|
||||
// 旧版会话只迁入原默认环境,不能复制到同址的 staging/production 开发配置。
|
||||
const legacyDefault =
|
||||
context.version === 'develop'
|
||||
? context.environment === 'local'
|
||||
: context.environment === 'production';
|
||||
const stored =
|
||||
wx.getStorageSync(context.key) ||
|
||||
(legacyDefault
|
||||
? wx.getStorageSync(
|
||||
`doodle_auth_v1:${encodeURIComponent(wx.getAccountInfoSync().miniProgram.appId)}:${encodeURIComponent(context.baseUrl)}`,
|
||||
)
|
||||
: null);
|
||||
if (stored) {
|
||||
state.history = 'used';
|
||||
if (
|
||||
@@ -145,7 +156,7 @@ export async function ensureSession(
|
||||
force = false,
|
||||
explicitStart = false,
|
||||
): Promise<AuthSession> {
|
||||
if (getAuthContext().key !== context.key)
|
||||
if (!isCurrentApiConfig(context))
|
||||
throw new ApiRequestError('接口环境已变化,请重新操作');
|
||||
const state = readState(context);
|
||||
if (state.history === 'blocked' && !explicitStart)
|
||||
@@ -184,11 +195,13 @@ export async function ensureSession(
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!(
|
||||
error instanceof ApiRequestError &&
|
||||
error.reason === 'WECHAT_CODE_INVALID' &&
|
||||
attempt === 0
|
||||
))
|
||||
if (
|
||||
!(
|
||||
error instanceof ApiRequestError &&
|
||||
error.reason === 'WECHAT_CODE_INVALID' &&
|
||||
attempt === 0
|
||||
)
|
||||
)
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -210,10 +223,7 @@ export async function ensureSession(
|
||||
user: parseServerUser(result.user),
|
||||
};
|
||||
// 清理、切换环境期间迟到的登录响应不得恢复会话或写入当前界面。
|
||||
if (
|
||||
state.generation !== generation ||
|
||||
getAuthContext().key !== context.key
|
||||
)
|
||||
if (state.generation !== generation || !isCurrentApiConfig(context))
|
||||
throw new ApiRequestError('登录状态已变化,请重新操作');
|
||||
state.session = session;
|
||||
state.history = 'used';
|
||||
@@ -222,6 +232,7 @@ export async function ensureSession(
|
||||
} catch (error) {
|
||||
if (
|
||||
state.generation === generation &&
|
||||
isCurrentApiConfig(context) &&
|
||||
error instanceof ApiRequestError &&
|
||||
[
|
||||
'ACCOUNT_NOT_FOUND',
|
||||
@@ -244,6 +255,14 @@ export async function ensureSession(
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留各环境已保存会话和账户历史,只作废尚未完成的登录任务。 */
|
||||
export function invalidatePendingAuthentication() {
|
||||
for (const state of states.values()) {
|
||||
state.generation++;
|
||||
state.flight = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一原请求只恢复一次;迟到的旧 401 直接复用较新凭证。 */
|
||||
export async function recoverSession(context: Context, failed: AuthSession) {
|
||||
const state = readState(context);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
getApiEnvironment,
|
||||
selectApiEnvironment,
|
||||
type ApiEnvironment,
|
||||
} from '../config/api';
|
||||
import { hasPendingApiWrites } from './api-activity';
|
||||
import { invalidatePendingAuthentication } from './auth-session';
|
||||
|
||||
let switching = false;
|
||||
|
||||
/** 统一切换入口:写入中不切换,保留分环境会话,重建全部页面以丢弃旧业务状态。 */
|
||||
export async function switchApiEnvironment(environment: ApiEnvironment) {
|
||||
if (wx.getAccountInfoSync().miniProgram.envVersion !== 'develop')
|
||||
throw new Error('仅开发版可切换环境');
|
||||
if (switching) throw new Error('正在切换环境,请稍候');
|
||||
if (getApiEnvironment() === environment) return false;
|
||||
const busyPage = getCurrentPages().some(
|
||||
(page) => page.data.debugPublishLoading || page.data.isSavingProfile,
|
||||
);
|
||||
if (hasPendingApiWrites() || busyPage)
|
||||
throw new Error('请等待当前保存或上传完成后再切换');
|
||||
switching = true;
|
||||
try {
|
||||
selectApiEnvironment(environment);
|
||||
invalidatePendingAuthentication();
|
||||
const app = getApp<IAppOption>();
|
||||
app.resetApiState();
|
||||
// reLaunch 只重建页面,不重新执行 App.onLaunch;目标数据需要主动初始化。
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
wx.reLaunch({
|
||||
url: '/pages/home/home',
|
||||
success: () => resolve(),
|
||||
fail: () => reject(new Error('环境已切换,请重新进入首页')),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
app.warmAuthentication();
|
||||
if (!(await app.loadPageConfig(true)))
|
||||
throw new Error('已切换,目标接口连接失败');
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
switching = false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user