feat: 增加环境切换

This commit is contained in:
R524809
2026-10-19 17:49:19 +08:00
parent 997042789d
commit fe1074e3f0
21 changed files with 968 additions and 123 deletions
+13
View File
@@ -0,0 +1,13 @@
let pendingWrites = 0;
/** 覆盖登录等待、上传及重试全过程,防止写操作进行中切换服务端。 */
export function beginApiWrite() {
pendingWrites++;
return () => {
pendingWrites--;
};
}
export function hasPendingApiWrites() {
return pendingWrites > 0;
}
+16
View File
@@ -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(
+16 -4
View File
@@ -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({
+32 -13
View File
@@ -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;
}
}