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
+20 -2
View File
@@ -20,7 +20,7 @@ keyPrefix: 'doodle-assets/',
### 新后端静默登录与鉴权 ### 新后端静默登录与鉴权
启动及回到前台时通过 `utils/auth.ts` 非阻塞预热:`wx.login` 取一次性 code,调用 `POST /api/v1/auth/wechat/login`。首次使用 `start`,已有会话续期和 401 恢复使用 `resume`;明确无效 code 最多重新取一次。JWT 与用户摘要按 AppID、接口地址分开保存,凭证到期前 60 秒按需续登。不缓存角色或微信 OpenID,不记录凭证、code 或完整登录响应。 启动及回到前台时通过 `utils/auth.ts` 非阻塞预热:`wx.login` 取一次性 code,调用 `POST /api/v1/auth/wechat/login`。首次使用 `start`,已有会话续期和 401 恢复使用 `resume`;明确无效 code 最多重新取一次。JWT 与用户摘要按 AppID、包类型、环境名称和接口地址分开保存,凭证到期前 60 秒按需续登。不缓存角色或微信 OpenID,不记录凭证、code 或完整登录响应。
统一请求层 `services/api-request.ts` 默认要求登录并携带 Bearer;公开分类与题型接口显式指定 `auth: 'none'`,不携带凭证、不触发登录。受保护请求遇到可恢复 401 时最多重登并重发一次,并发请求合并登录,迟到的旧 401 不会清掉新凭证。恢复后身份变化时不补发原业务写入。403、限流、服务错误或网络失败不触发自动重登链。 统一请求层 `services/api-request.ts` 默认要求登录并携带 Bearer;公开分类与题型接口显式指定 `auth: 'none'`,不携带凭证、不触发登录。受保护请求遇到可恢复 401 时最多重登并重发一次,并发请求合并登录,迟到的旧 401 不会清掉新凭证。恢复后身份变化时不补发原业务写入。403、限流、服务错误或网络失败不触发自动重登链。
@@ -44,10 +44,28 @@ await apiRequest('/me');
### 业务接口切换 ### 业务接口切换
本地开发中的小程序运行代码已不再调用 `wx.cloud`;首页继续使用 `wx.getBackgroundFetchData` 获取预拉取首屏,在后台使用新 `/page-config` 刷新,管理改动也会刷新首页。旧云端的预拉取地址、生成与发布链路、定时任务保持原样,新管理开关仅控制新后端的快照刷新任务。 本地开发中的小程序运行代码已不再调用 `wx.cloud`;体验版与正式版首页继续使用 `wx.getBackgroundFetchData` 获取预拉取首屏,在后台使用新 `/page-config` 刷新。开发版跳过旧线上预拉取,使用所选接口环境自己的内容缓存及新后端;管理改动也会刷新首页。旧云端的预拉取地址、生成与发布链路、定时任务保持原样,新管理开关仅控制新后端的快照刷新任务。
当前已发布的线上版仍使用旧云开发接口。`cloudfunctions/` 中全部旧云函数源码、云开发配置及线上服务均须保留;本地开发版切换接口不代表线上旧版本已停止调用。未经另行明确安排,不删除、停用旧云函数,不改变其接口契约,不清理旧云数据库或云存储,也不迁移现有预拉取模式。 当前已发布的线上版仍使用旧云开发接口。`cloudfunctions/` 中全部旧云函数源码、云开发配置及线上服务均须保留;本地开发版切换接口不代表线上旧版本已停止调用。未经另行明确安排,不删除、停用旧云函数,不改变其接口契约,不清理旧云数据库或云存储,也不迁移现有预拉取模式。
用户接口由 `services/member-api.ts` 与 `utils/auth.ts` 管理,管理页由 `services/admin-api.ts` 适配具名 REST 路由。图片通过本站 multipart 上传接口进入七牛,不暴露 AK/SK;同一次上传恢复沿用 UUID uploadId。管理页只能由数据库中当前角色为 admin 的账户操作,开发版不会绕过权限。 用户接口由 `services/member-api.ts` 与 `utils/auth.ts` 管理,管理页由 `services/admin-api.ts` 适配具名 REST 路由。图片通过本站 multipart 上传接口进入七牛,不暴露 AK/SK;同一次上传恢复沿用 UUID uploadId。管理页只能由数据库中当前角色为 admin 的账户操作,开发版不会绕过权限。
服务端路由、权限与数据迁移说明见 doodle-store 的 `docs/v3.5/小程序云接口迁移清单.md`。旧云端用户、收藏与下载历史本次未自动导入,正式切换前需要另行核对迁移。 服务端路由、权限与数据迁移说明见 doodle-store 的 `docs/v3.5/小程序云接口迁移清单.md`。旧云端用户、收藏与下载历史本次未自动导入,正式切换前需要另行核对迁移。
### 开发版接口环境切换
入口:个人中心 → Debug 工具 → 接口环境。`develop` 可切换 `local / staging / production`,选择保存在本机,切换后自动重建页面返回首页,无需重新编译。开发版导航栏显示当前环境。`trial / release` 固定使用 `production`,忽略本机调试选择,配置层与切换入口均有校验。
地址在 `miniprogram/config/api.ts` 集中配置:
- `localBaseUrl`:本地开发接口。
- `stagingBaseUrl`:未来测试接口。
- `productionBaseUrl`:未来正式接口。
目前按要求,三个地址都暂填 `http://127.0.0.1:8800/api/v1`,客户端缓存和会话按环境隔离,但实际请求仍访问同一个本地后端和数据库。真机的 `127.0.0.1` 指向手机;调试时 local 需改为电脑局域网地址。部署时把 staging/production 占位地址改为对应 HTTPS 地址,不会因地址缺失自动回退 local。
切换会清空共享用户和内容状态,读取目标环境凭证,并强制从目标接口加载首页;原环境的缓存与会话保留。所有 API 响应按环境版本检查,即使快速 A→B→A,旧请求也不能覆盖当前数据。写请求(含登录等待及上传重试)未完成时禁止切换。目标接口不可用时提示连接失败并保留所选环境,不展示上一环境的内容。打印偏好继续共用。
本轮仅调整开发版数据来源;体验/正式包原预拉取模式、地址及全部旧云端服务保持原样。
环境切换回归:`node --import tsx --test tests/environment-switch.test.mjs`。
+73 -24
View File
@@ -2,11 +2,13 @@ import { fetchPageConfig } from './services/site-api';
import { normalizePreviewImages } from './config/assets'; import { normalizePreviewImages } from './config/assets';
import { defaultPrintConfig } from './config/config'; import { defaultPrintConfig } from './config/config';
import { getUser } from './utils/auth'; import { getUser } from './utils/auth';
import { apiContextKey, getApiConfig, isCurrentApiConfig } from './config/api';
import { generateUUID, getAppUUID, setAppUUID } from './utils/uuid'; import { generateUUID, getAppUUID, setAppUUID } from './utils/uuid';
let _refreshGeneration = 0; let _refreshGeneration = 0;
let _loadGeneration = 0;
const STORAGE_KEY_PAGE_CONFIG = 'pageConfig'; const pageConfigStorageKey = () => `doodle_page_config_v2:${apiContextKey()}`;
// Pending resolvers for getPageConfig() callers waiting on config // Pending resolvers for getPageConfig() callers waiting on config
let _configResolvers: Array<(config: PageConfigData | null) => void> = []; let _configResolvers: Array<(config: PageConfigData | null) => void> = [];
@@ -65,6 +67,23 @@ App<IAppOption>({
onHide() {}, onHide() {},
/** 切环境不重启 App:作废旧加载、释放等待者并重置共享业务状态。 */
resetApiState() {
_refreshGeneration++;
_loadGeneration++;
this.globalData.user = null;
this.globalData.pageConfig = undefined;
this.globalData.pageConfigReady = false;
this.globalData.pendingCategoryTabId = undefined;
for (const resolve of _configResolvers) resolve(null);
_configResolvers = [];
try {
wx.removeStorageSync('favorites_active_tab');
} catch {
/* 临时页签偏好清理失败不影响环境与用户隔离。 */
}
},
getPrintConfig(): PrintConfig { getPrintConfig(): PrintConfig {
return this.globalData.printConfig || defaultPrintConfig; return this.globalData.printConfig || defaultPrintConfig;
}, },
@@ -74,32 +93,54 @@ App<IAppOption>({
wx.setStorageSync('printConfig', config); wx.setStorageSync('printConfig', config);
}, },
async loadPageConfig() { async loadPageConfig(forceRefresh = false) {
let context: ReturnType<typeof getApiConfig>;
try {
context = getApiConfig();
} catch {
// 地址被移除时结束首页等待,开发者仍可进入 Debug 改选可用环境。
this.globalData.pageConfig = undefined;
this.globalData.pageConfigReady = true;
for (const resolve of _configResolvers) resolve(null);
_configResolvers = [];
return false;
}
const generation = ++_loadGeneration;
const current = () =>
generation === _loadGeneration && isCurrentApiConfig(context);
const storageKey = pageConfigStorageKey();
let config: PageConfigData | null = null; let config: PageConfigData | null = null;
// L1: 数据预拉取 // 仅开发版跳过旧线上预拉取,保证环境切换后的内容来自目标服务。
try { // 体验/正式版继续沿用原预拉取模式、地址与旧云端任务。
const res = await new Promise<{ fetchedData: string }>( if (context.version !== 'develop')
(resolve, reject) => { try {
wx.getBackgroundFetchData({ const res = await new Promise<{ fetchedData: string }>(
fetchType: 'pre', (resolve, reject) => {
success: resolve as any, wx.getBackgroundFetchData({
fail: reject, fetchType: 'pre',
}); success: resolve as any,
}, fail: reject,
); });
if (res.fetchedData) { },
const parsed = JSON.parse(res.fetchedData); );
config = parsed?.data || parsed; if (res.fetchedData) {
const parsed = JSON.parse(res.fetchedData);
config = parsed?.data || parsed;
}
} catch {
// 预拉取失败,继续降级
} }
} catch { if (!current()) return false;
// 预拉取失败,继续降级
}
// L2: storage 缓存 // L2: storage 缓存
if (!config) { if (!config && !forceRefresh) {
try { try {
const cached = wx.getStorageSync(STORAGE_KEY_PAGE_CONFIG); const cached =
wx.getStorageSync(storageKey) ||
(context.version !== 'develop'
? wx.getStorageSync('pageConfig')
: null);
if (cached && typeof cached === 'object') { if (cached && typeof cached === 'object') {
config = cached; config = cached;
} }
@@ -117,6 +158,7 @@ App<IAppOption>({
/* 新后端不可用时继续展示空状态,本地生成仍可使用。 */ /* 新后端不可用时继续展示空状态,本地生成仍可使用。 */
} }
} }
if (!current()) return false;
// 三种数据入口都先转换预览图,旧缓存会随成功加载写回新地址。 // 三种数据入口都先转换预览图,旧缓存会随成功加载写回新地址。
config = normalizePreviewImages(config); config = normalizePreviewImages(config);
@@ -126,7 +168,7 @@ App<IAppOption>({
this.globalData.pageConfig = config; this.globalData.pageConfig = config;
// 成功获取后写入 storage 缓存 // 成功获取后写入 storage 缓存
try { try {
wx.setStorageSync(STORAGE_KEY_PAGE_CONFIG, config); wx.setStorageSync(storageKey, config);
} catch { } catch {
// storage 写入失败不影响主流程 // storage 写入失败不影响主流程
} }
@@ -140,16 +182,23 @@ App<IAppOption>({
_configResolvers = []; _configResolvers = [];
// 预拉取/缓存先渲染,再用新后端刷新,避免旧静态配置长期覆盖管理修改。 // 预拉取/缓存先渲染,再用新后端刷新,避免旧静态配置长期覆盖管理修改。
if (hadCachedConfig) void this.refreshPageConfig(); if (hadCachedConfig) void this.refreshPageConfig();
return !!config;
}, },
async refreshPageConfig() { async refreshPageConfig() {
const context = getApiConfig();
const storageKey = pageConfigStorageKey();
const generation = ++_refreshGeneration; const generation = ++_refreshGeneration;
try { try {
const config = normalizePreviewImages(await fetchPageConfig()); const config = normalizePreviewImages(await fetchPageConfig());
if (generation !== _refreshGeneration) return; if (
generation !== _refreshGeneration ||
!isCurrentApiConfig(context)
)
return;
this.globalData.pageConfig = config; this.globalData.pageConfig = config;
try { try {
wx.setStorageSync(STORAGE_KEY_PAGE_CONFIG, config); wx.setStorageSync(storageKey, config);
} catch { } catch {
/* 缓存失败不影响当前数据。 */ /* 缓存失败不影响当前数据。 */
} }
+20 -2
View File
@@ -66,13 +66,21 @@
margin-top: 4rpx; margin-top: 4rpx;
} }
.nav-bar__title-group {
display: flex;
align-items: center;
gap: 12rpx;
flex: 1;
min-width: 0;
}
.nav-bar__title { .nav-bar__title {
font-size: @fs-nav-title; font-size: @fs-nav-title;
font-weight: @fw-nav-title; font-weight: @fw-nav-title;
color: @color-nav-title; color: @color-nav-title;
letter-spacing: @ls-nav-title; letter-spacing: @ls-nav-title;
line-height: 1; line-height: 1;
flex: 1; flex: 0 1 auto;
min-width: 0; min-width: 0;
} }
@@ -86,4 +94,14 @@
.nav-bar__placeholder { .nav-bar__placeholder {
width: 100%; width: 100%;
flex-shrink: 0; flex-shrink: 0;
} }
.nav-bar__environment {
margin-right: 16rpx;
padding: 6rpx 12rpx;
border-radius: 12rpx;
background: @brand;
color: @text-title;
font-size: 20rpx;
line-height: 1.2;
flex-shrink: 0;
}
@@ -1,4 +1,5 @@
import { getNavInnerPx } from '../../utils/navMetrics'; import { getNavInnerPx } from '../../utils/navMetrics';
import { getApiEnvironment } from '../../config/api';
const HOME_TAB_URL = '/pages/home/home'; const HOME_TAB_URL = '/pages/home/home';
const HOME_ROUTE = 'pages/home/home'; const HOME_ROUTE = 'pages/home/home';
@@ -24,6 +25,7 @@ Component({
leftActionType: 'none' as 'none' | 'back' | 'home' | 'brand', leftActionType: 'none' as 'none' | 'back' | 'home' | 'brand',
/** 仅首页(Tab 根页)在 logo 旁展示「涂鸦丫」;其余主 Tab 只展示 logo */ /** 仅首页(Tab 根页)在 logo 旁展示「涂鸦丫」;其余主 Tab 只展示 logo */
showBrandName: false, showBrandName: false,
apiEnvironment: '',
}, },
lifetimes: { lifetimes: {
attached() { attached() {
@@ -44,6 +46,12 @@ Component({
}, },
methods: { methods: {
syncLeftAction() { syncLeftAction() {
this.setData({
apiEnvironment:
wx.getAccountInfoSync().miniProgram.envVersion === 'develop'
? getApiEnvironment()
: '',
});
const pages = getCurrentPages(); const pages = getCurrentPages();
const currentRoute = pages[pages.length - 1]?.route || ''; const currentRoute = pages[pages.length - 1]?.route || '';
const isTabBarPage = TAB_BAR_ROUTES.has(currentRoute); const isTabBarPage = TAB_BAR_ROUTES.has(currentRoute);
@@ -22,7 +22,12 @@
wx:elif="{{leftActionType === 'home'}}" wx:elif="{{leftActionType === 'home'}}"
class="toy-icon toy-icon-home-o nav-bar__back-icon"></text> class="toy-icon toy-icon-home-o nav-bar__back-icon"></text>
</view> </view>
<text class="nav-bar__title">{{title}}</text> <view class="nav-bar__title-group">
<text class="nav-bar__title">{{title}}</text>
<text wx:if="{{apiEnvironment}}" class="nav-bar__environment"
>{{apiEnvironment}}</text
>
</view>
<view class="nav-bar__right"> <view class="nav-bar__right">
<slot name="right"></slot> <slot name="right"></slot>
</view> </view>
+112 -27
View File
@@ -1,37 +1,122 @@
export type ApiEnvironment = 'local' | 'online'; export type ApiEnvironment = 'local' | 'staging' | 'production';
type MiniProgramVersion = 'develop' | 'trial' | 'release'; type MiniProgramVersion = 'develop' | 'trial' | 'release';
/** 统一接口入口;本地真机调试时将 localBaseUrl 的 IP 改为电脑局域网 IP。 */ const LOCAL_BASE_URL = 'http://127.0.0.1:8800/api/v1';
const STORAGE_KEY = 'doodle_develop_api_environment_v1';
let selectedEnvironment: ApiEnvironment | undefined;
let revision = 0;
/** 暂时共用本地地址;部署后分别替换 staging/production,真机 local 使用电脑局域网 IP。 */
export const apiConfig = { export const apiConfig = {
developmentEnvironment: 'local' as ApiEnvironment, developmentEnvironment: 'local' as ApiEnvironment,
localBaseUrl: 'http://127.0.0.1:8800/api/v1', localBaseUrl: LOCAL_BASE_URL,
// 线上域名尚未确定:填写完整 HTTPS 地址,例如 https://你的域名/api/v1。 stagingBaseUrl: LOCAL_BASE_URL,
onlineBaseUrl: '', productionBaseUrl: LOCAL_BASE_URL,
timeout: 10000, timeout: 10000,
}; };
/** develop 可切换本地/线上;体验版、正式版和未知运行环境均只允许线上入口。 */ export const API_ENVIRONMENTS: ApiEnvironment[] = [
export function resolveApiConfig(version: MiniProgramVersion | string) { 'local',
const environment: ApiEnvironment = 'staging',
version === 'develop' ? apiConfig.developmentEnvironment : 'online'; 'production',
const baseUrl = ( ];
environment === 'local'
? apiConfig.localBaseUrl export function isApiEnvironment(value: unknown): value is ApiEnvironment {
: apiConfig.onlineBaseUrl return API_ENVIRONMENTS.includes(value as ApiEnvironment);
)
.trim()
.replace(/\/+$/, '');
if (!baseUrl) throw new Error('请先配置线上接口地址');
if (
!/^https?:\/\/[^/?#\s]+\/api\/v1$/.test(baseUrl) ||
(environment === 'online' && !baseUrl.startsWith('https://'))
) {
throw new Error('接口地址配置不正确');
}
return { environment, baseUrl, timeout: apiConfig.timeout };
} }
/** 请求时读取微信包版本,避免依赖 App 启动顺序,也不接受本地缓存覆盖正式版环境。 */ /** 仅开发包读取本机选择;体验、正式及未知包始终连接 production。 */
export function getApiConfig() { export function getApiEnvironment(
return resolveApiConfig(wx.getAccountInfoSync().miniProgram.envVersion); version: string = wx.getAccountInfoSync().miniProgram.envVersion,
): ApiEnvironment {
if (version === 'develop' && selectedEnvironment === undefined) {
try {
const stored: unknown = wx.getStorageSync(STORAGE_KEY);
if (isApiEnvironment(stored)) selectedEnvironment = stored;
} catch {
/* 无缓存时使用开发默认值。 */
}
}
return version === 'develop'
? (selectedEnvironment ?? apiConfig.developmentEnvironment)
: 'production';
}
export function resolveApiConfig(version: MiniProgramVersion | string) {
return resolveEnvironment(getApiEnvironment(version), version);
}
function resolveEnvironment(environment: ApiEnvironment, version: string) {
const baseUrl = apiConfig[`${environment}BaseUrl`]
.trim()
.replace(/\/+$/, '');
if (!baseUrl) throw new Error(`请先配置 ${environment} 接口地址`);
// 用户指定的本机占位地址暂允许 HTTP;实际云端入口仍须使用 HTTPS。
if (
!/^https?:\/\/[^/?#\s]+\/api\/v1$/.test(baseUrl) ||
(environment !== 'local' &&
!baseUrl.startsWith('https://') &&
baseUrl !== LOCAL_BASE_URL)
)
throw new Error('接口地址配置不正确');
return {
environment,
baseUrl,
timeout: apiConfig.timeout,
revision,
version,
};
}
export function getApiConfig() {
const { appId = '', envVersion } = wx.getAccountInfoSync().miniProgram;
return { ...resolveApiConfig(envVersion), appId };
}
/** 即使三个地址相同也按环境隔离;develop 不与体验/正式包共享业务缓存。 */
export function apiContextKey(config = getApiConfig()): string {
const scope = config.version === 'develop' ? 'develop' : 'public';
return [config.appId, scope, config.environment, config.baseUrl]
.map(encodeURIComponent)
.join(':');
}
/** 版本号阻止 A→B→A 后旧 A 请求覆盖当前页面。 */
export function isCurrentApiConfig(config: ReturnType<typeof getApiConfig>) {
const current = getApiConfig();
return (
config.revision === current.revision &&
apiContextKey(config) === apiContextKey(current)
);
}
/** 写入成功后才提交内存选择;配置失败保留原环境,不自动回退。 */
export function selectApiEnvironment(environment: ApiEnvironment): boolean {
if (wx.getAccountInfoSync().miniProgram.envVersion !== 'develop')
throw new Error('仅开发版可切换环境');
if (!isApiEnvironment(environment)) throw new Error('未知接口环境');
resolveEnvironment(environment, 'develop');
if (getApiEnvironment() === environment) return false;
wx.setStorageSync(STORAGE_KEY, environment);
selectedEnvironment = environment;
revision++;
return true;
}
export function getApiEnvironmentOptions() {
return API_ENVIRONMENTS.map((environment) => {
try {
return {
environment,
baseUrl: resolveEnvironment(environment, 'develop').baseUrl,
available: true,
};
} catch {
return {
environment,
baseUrl: '尚未配置有效地址',
available: false,
};
}
});
} }
+14 -3
View File
@@ -430,14 +430,17 @@ page {
} }
.profile-editor__actions { .profile-editor__actions {
display: grid; display: flex;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24rpx;
padding-top: 20rpx; padding-top: 20rpx;
} }
.profile-editor__action { .profile-editor__action {
flex: 1;
min-width: 0;
height: 92rpx; height: 92rpx;
margin: 0;
padding: 0;
border: 0;
border-radius: 24rpx; border-radius: 24rpx;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -447,6 +450,14 @@ page {
font-weight: 700; font-weight: 700;
} }
.profile-editor__action::after {
border: 0;
}
.profile-editor__action + .profile-editor__action {
margin-left: 24rpx;
}
.profile-editor__action--ghost { .profile-editor__action--ghost {
background: @bg-header; background: @bg-header;
color: @text-title; color: @text-title;
+5
View File
@@ -115,6 +115,11 @@ Page({
}, },
onOpenProfileEditor() { onOpenProfileEditor() {
console.log(
'onOpenProfileEditor',
this.data.userName,
this.data.avatarUrl,
);
this.setData({ this.setData({
showProfileEditor: true, showProfileEditor: true,
draftNickName: draftNickName:
+7 -4
View File
@@ -225,16 +225,19 @@
</view> </view>
<view class="profile-editor__actions"> <view class="profile-editor__actions">
<view <button
class="profile-editor__action profile-editor__action--ghost" class="profile-editor__action profile-editor__action--ghost"
disabled="{{isSavingProfile}}"
bindtap="onCloseProfileEditor"> bindtap="onCloseProfileEditor">
取消 取消
</view> </button>
<view <button
class="profile-editor__action profile-editor__action--primary {{isSavingProfile ? 'profile-editor__action--disabled' : ''}}" class="profile-editor__action profile-editor__action--primary {{isSavingProfile ? 'profile-editor__action--disabled' : ''}}"
loading="{{isSavingProfile}}"
disabled="{{isSavingProfile}}"
bindtap="onSubmitProfile"> bindtap="onSubmitProfile">
{{isSavingProfile ? '保存中...' : '保存'}} {{isSavingProfile ? '保存中...' : '保存'}}
</view> </button>
</view> </view>
</view> </view>
</van-popup> </van-popup>
+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 { getApiConfig } from '../config/api';
import { beginApiWrite } from './api-activity';
import { import {
ApiRequestError, ApiRequestError,
requestApi, requestApi,
@@ -30,6 +31,21 @@ const blocked = new Set([
export async function apiRequest<T>( export async function apiRequest<T>(
path: string, path: string,
options: RequestOptions & { auth?: 'required' | 'none' } = {}, 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> { ): Promise<T> {
// Authorization 由会话层统一提供;公开请求不附带调用方误传的凭证。 // Authorization 由会话层统一提供;公开请求不附带调用方误传的凭证。
const header = Object.fromEntries( 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 { export class ApiRequestError extends Error {
constructor( constructor(
@@ -28,7 +28,15 @@ export function requestApi<T>(
if (!path.startsWith('/') || path.startsWith('//')) if (!path.startsWith('/') || path.startsWith('//'))
throw new Error('接口路径不正确'); throw new Error('接口路径不正确');
return new Promise((resolve, reject) => { 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 }) => { const success = (res: { statusCode: number; data: unknown }) => {
if (stale()) return;
const body = res.data as { const body = res.data as {
code?: number; code?: number;
data?: T; data?: T;
@@ -42,7 +50,7 @@ export function requestApi<T>(
: '接口请求失败,请稍后重试', : '接口请求失败,请稍后重试',
res.statusCode, res.statusCode,
typeof body?.reason === 'string' && typeof body?.reason === 'string' &&
/^[A-Z_]{1,64}$/.test(body.reason) /^[A-Z_]{1,64}$/.test(body.reason)
? body.reason ? body.reason
: '', : '',
), ),
@@ -60,8 +68,12 @@ export function requestApi<T>(
); );
} else resolve(body.data as T); } else resolve(body.data as T);
}; };
const fail = () => const fail = () => {
reject(new ApiRequestError('连接失败,请确认后端已启动及网络配置')); if (!stale())
reject(
new ApiRequestError('连接失败,请确认后端已启动及网络配置'),
);
};
if (options.upload) { if (options.upload) {
// wx.uploadFile 由运行时生成 multipart boundary,不能自行设置 JSON Content-Type。 // wx.uploadFile 由运行时生成 multipart boundary,不能自行设置 JSON Content-Type。
wx.uploadFile({ 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'; import { ApiRequestError, requestApi } from './api-transport';
export interface ServerUser { export interface ServerUser {
@@ -32,7 +32,7 @@ export function getAuthContext() {
if (!appId) throw new ApiRequestError('无法识别小程序身份'); if (!appId) throw new ApiRequestError('无法识别小程序身份');
return { return {
...config, ...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, generation: 0,
}; };
try { 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) { if (stored) {
state.history = 'used'; state.history = 'used';
if ( if (
@@ -145,7 +156,7 @@ export async function ensureSession(
force = false, force = false,
explicitStart = false, explicitStart = false,
): Promise<AuthSession> { ): Promise<AuthSession> {
if (getAuthContext().key !== context.key) if (!isCurrentApiConfig(context))
throw new ApiRequestError('接口环境已变化,请重新操作'); throw new ApiRequestError('接口环境已变化,请重新操作');
const state = readState(context); const state = readState(context);
if (state.history === 'blocked' && !explicitStart) if (state.history === 'blocked' && !explicitStart)
@@ -184,11 +195,13 @@ export async function ensureSession(
); );
break; break;
} catch (error) { } catch (error) {
if (!( if (
error instanceof ApiRequestError && !(
error.reason === 'WECHAT_CODE_INVALID' && error instanceof ApiRequestError &&
attempt === 0 error.reason === 'WECHAT_CODE_INVALID' &&
)) attempt === 0
)
)
throw error; throw error;
} }
} }
@@ -210,10 +223,7 @@ export async function ensureSession(
user: parseServerUser(result.user), user: parseServerUser(result.user),
}; };
// 清理、切换环境期间迟到的登录响应不得恢复会话或写入当前界面。 // 清理、切换环境期间迟到的登录响应不得恢复会话或写入当前界面。
if ( if (state.generation !== generation || !isCurrentApiConfig(context))
state.generation !== generation ||
getAuthContext().key !== context.key
)
throw new ApiRequestError('登录状态已变化,请重新操作'); throw new ApiRequestError('登录状态已变化,请重新操作');
state.session = session; state.session = session;
state.history = 'used'; state.history = 'used';
@@ -222,6 +232,7 @@ export async function ensureSession(
} catch (error) { } catch (error) {
if ( if (
state.generation === generation && state.generation === generation &&
isCurrentApiConfig(context) &&
error instanceof ApiRequestError && error instanceof ApiRequestError &&
[ [
'ACCOUNT_NOT_FOUND', '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 直接复用较新凭证。 */ /** 同一原请求只恢复一次;迟到的旧 401 直接复用较新凭证。 */
export async function recoverSession(context: Context, failed: AuthSession) { export async function recoverSession(context: Context, failed: AuthSession) {
const state = readState(context); 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;
}
}
+62 -22
View File
@@ -12,28 +12,6 @@ page {
box-sizing: border-box; box-sizing: border-box;
} }
.debug-page__hero {
padding: 48rpx 40rpx;
background: @bg-white;
border-radius: @radius-xl;
box-shadow: @shadow;
}
.debug-page__hero-title {
display: block;
font-size: 40rpx;
font-weight: 800;
color: @text-title;
}
.debug-page__hero-desc {
display: block;
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.6;
color: @text-secondary;
}
.debug-page__section { .debug-page__section {
margin-top: 32rpx; margin-top: 32rpx;
} }
@@ -125,3 +103,65 @@ page {
font-size: 24rpx; font-size: 24rpx;
line-height: 1.6; line-height: 1.6;
} }
.debug-page__environment {
padding: 32rpx;
background: @bg-white;
border-radius: @radius-xl;
box-shadow: @shadow;
}
.debug-page__environment-url {
display: block;
word-break: break-all;
color: @text-secondary;
font-size: 24rpx;
}
.debug-page__environment-options {
display: flex;
flex-direction: column;
margin-top: 24rpx;
}
/* 明确按钮高度及整行点击区域,避免原生 button 被全局样式压缩。 */
.debug-page__environment-options .debug-environment-option {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 96rpx;
flex-shrink: 0;
box-sizing: border-box;
margin: 0;
padding: 0 24rpx;
border: 0;
font-size: 28rpx;
line-height: 1.4;
color: @text-title;
background: @bg-header;
border-radius: 20rpx;
}
.debug-page__environment-options .debug-environment-option::after {
border: 0;
}
.debug-page__environment-options
.debug-environment-option
+ .debug-environment-option {
margin-top: 16rpx;
}
.debug-page__environment-options .debug-environment-option--selected {
background: @brand;
font-weight: 700;
}
.debug-page__environment-note {
display: block;
margin-top: 20rpx;
color: @text-secondary;
font-size: 22rpx;
line-height: 1.6;
}
+46 -1
View File
@@ -1,3 +1,10 @@
import {
getApiEnvironment,
getApiEnvironmentOptions,
isApiEnvironment,
} from '../../config/api';
import { switchApiEnvironment } from '../../services/environment-switch';
type DebugEntry = { type DebugEntry = {
id: string; id: string;
title: string; title: string;
@@ -47,7 +54,45 @@ const DEBUG_ENTRIES: DebugEntry[] = [
Page({ Page({
data: { data: {
entries: DEBUG_ENTRIES, entries: DEBUG_ENTRIES,
isDevEnv: true, isDevEnv: false,
currentEnvironment: '',
currentBaseUrl: '',
environments: getApiEnvironmentOptions(),
isSwitchingEnvironment: false,
},
onShow() {
const environment = getApiEnvironment();
const environments = getApiEnvironmentOptions();
this.setData({
currentEnvironment: environment,
currentBaseUrl:
environments.find((item) => item.environment === environment)
?.baseUrl || '',
environments,
});
},
async onSelectEnvironment(e: WechatMiniprogram.TouchEvent) {
if (!this.data.isDevEnv || this.data.isSwitchingEnvironment) return;
const environment: unknown = e.currentTarget.dataset.environment;
if (!isApiEnvironment(environment)) return;
this.setData({ isSwitchingEnvironment: true });
wx.showLoading({ title: '切换环境中', mask: true });
let errorMessage = '';
try {
await switchApiEnvironment(environment);
} catch (error) {
errorMessage = error instanceof Error ? error.message : '切换失败';
} finally {
wx.hideLoading();
// 成功时本页已被 reLaunch 销毁,失败时才需要恢复按钮。
if (getCurrentPages().includes(this)) {
this.setData({ isSwitchingEnvironment: false });
this.onShow();
}
}
if (errorMessage) wx.showToast({ title: errorMessage, icon: 'none' });
}, },
onLoad() { onLoad() {
+20 -5
View File
@@ -1,9 +1,24 @@
<view class="debug-page"> <view class="debug-page">
<view class="debug-page__hero"> <view wx:if="{{isDevEnv}}" class="debug-page__environment">
<text class="debug-page__hero-title">Debug 工具页</text> <text class="debug-page__section-title"
<text class="debug-page__hero-desc"> >接口环境 · {{currentEnvironment}}</text
仅开发环境可见,用于发布前检查和同步调试数据。 >
</text> <text class="debug-page__environment-url">{{currentBaseUrl}}</text>
<view class="debug-page__environment-options">
<button
wx:for="{{environments}}"
wx:key="environment"
class="debug-environment-option {{currentEnvironment === item.environment ? 'debug-environment-option--selected' : ''}}"
data-environment="{{item.environment}}"
disabled="{{!item.available || isSwitchingEnvironment}}"
bindtap="onSelectEnvironment">
{{item.environment}}
<text wx:if="{{!item.available}}">(未配置)</text>
</button>
</view>
<text class="debug-page__environment-note"
>切换后自动返回首页;下次打开开发版继续使用所选环境。</text
>
</view> </view>
<view class="debug-page__section"> <view class="debug-page__section">
+15 -14
View File
@@ -52,7 +52,7 @@ beforeEach(() => {
requests = []; requests = [];
navigations = []; navigations = [];
apiConfig.developmentEnvironment = 'local'; apiConfig.developmentEnvironment = 'local';
apiConfig.onlineBaseUrl = ''; apiConfig.productionBaseUrl = 'http://127.0.0.1:8800/api/v1';
handler = () => ({ data: [] }); handler = () => ({ data: [] });
globalThis.wx = { globalThis.wx = {
getAccountInfoSync: () => ({ miniProgram: { envVersion: 'develop' } }), getAccountInfoSync: () => ({ miniProgram: { envVersion: 'develop' } }),
@@ -152,22 +152,23 @@ function useRows(rows, selectedCategories = categories) {
}; };
} }
test('环境配置:开发版默认本地,正式/体验/未知环境不回退本地,线上要求 HTTPS', () => { test('环境配置:体验/正式/未知包固定 production,暂允许指定的本机占位地址', () => {
assert.equal( assert.equal(resolveApiConfig('develop').environment, 'local');
resolveApiConfig('develop').baseUrl, for (const version of ['release', 'trial', 'unknown']) {
'http://127.0.0.1:8800/api/v1', assert.equal(resolveApiConfig(version).environment, 'production');
);
for (const version of ['release', 'trial', 'unknown'])
assert.throws(() => resolveApiConfig(version), /线上/);
apiConfig.onlineBaseUrl = 'https://api.example.test/api/v1/';
for (const version of ['release', 'trial'])
assert.equal( assert.equal(
resolveApiConfig(version).baseUrl, resolveApiConfig(version).baseUrl,
'https://api.example.test/api/v1', 'http://127.0.0.1:8800/api/v1',
); );
apiConfig.developmentEnvironment = 'online'; }
assert.equal(resolveApiConfig('develop').environment, 'online'); apiConfig.productionBaseUrl = '';
apiConfig.onlineBaseUrl = 'http://api.example.test/api/v1'; assert.throws(() => resolveApiConfig('release'), /production/);
apiConfig.productionBaseUrl = 'https://api.example.test/api/v1/';
assert.equal(
resolveApiConfig('release').baseUrl,
'https://api.example.test/api/v1',
);
apiConfig.productionBaseUrl = 'http://api.example.test/api/v1';
assert.throws(() => resolveApiConfig('release'), /配置/); assert.throws(() => resolveApiConfig('release'), /配置/);
}); });
+425
View File
@@ -0,0 +1,425 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import vm from 'node:vm';
import { test } from 'node:test';
import ts from 'typescript';
const deferred = () => {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
};
const tick = () => new Promise(setImmediate);
/** 加载真实配置、请求、认证、App 与切换流程,仅模拟微信运行时和服务端响应。 */
function fixture(storage = new Map()) {
let version = 'develop';
let app;
let pages = [];
let prefetchCalls = 0;
let navigationCount = 0;
let loginCount = 0;
let handler = ({ url, environment }) => {
if (url.endsWith('/auth/wechat/login')) {
return {
accessToken: `token-${environment}`,
tokenType: 'Bearer',
expiresIn: 86400,
expiresAt: '2099-01-01T00:00:00Z',
user: {
id: `user-${environment}`,
nickName: environment,
avatarUrl: null,
avatarUrlExpiresAt: null,
createdAt: '2026-10-19T00:00:00Z',
},
};
}
return { home: { marker: environment } };
};
const requests = [];
const wx = {
getAccountInfoSync: () => ({
miniProgram: { appId: 'wx-env-test', envVersion: version },
}),
getStorageSync: (key) => storage.get(key),
setStorageSync: (key, value) =>
storage.set(key, structuredClone(value)),
removeStorageSync: (key) => storage.delete(key),
login({ success }) {
loginCount++;
success({ code: `code-${loginCount}` });
},
request(options) {
send(options);
},
uploadFile(options) {
send(options, true);
},
getBackgroundFetchData({ success }) {
prefetchCalls++;
success({
fetchedData: JSON.stringify({
home: { marker: 'legacy-prefetch' },
}),
});
},
reLaunch({ url, success }) {
assert.equal(url, '/pages/home/home');
navigationCount++;
const page = {
route: 'pages/home/home',
data: {},
initData(config) {
this.data.config = config;
},
};
pages = [page];
void app.getPageConfig().then((config) => page.initData(config));
success();
},
};
function send(options, upload = false) {
const request = {
...options,
environment: load('config/api').getApiConfig().environment,
};
requests.push(request);
Promise.resolve()
.then(() => handler(request))
.then(
(data) => {
const body = { code: 0, data };
options.success({
statusCode: 200,
data: upload ? JSON.stringify(body) : body,
});
},
() => options.fail({}),
);
}
const modules = new Map();
const context = vm.createContext({
wx,
console,
Error,
getApp: () => app,
getCurrentPages: () => pages,
App(value) {
app = value;
},
});
function load(file) {
file = resolve('miniprogram', file);
if (!file.endsWith('.ts')) file += '.ts';
if (modules.has(file)) return modules.get(file);
const exports = {};
modules.set(file, exports);
const code = ts.transpileModule(readFileSync(file, 'utf8'), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
}).outputText;
vm.runInContext(`(function(require,exports){${code}\n})`, context)(
(name) => load(resolve(dirname(file), name)),
exports,
);
return exports;
}
load('app');
return {
config: load('config/api'),
auth: load('utils/auth'),
api: load('services/api-request'),
member: load('services/member-api'),
switcher: load('services/environment-switch'),
activity: load('services/api-activity'),
app,
wx,
storage,
requests,
handle(fn) {
handler = fn;
},
version(value) {
version = value;
},
pages(value) {
pages = value;
},
get currentPages() {
return pages;
},
get prefetchCalls() {
return prefetchCalls;
},
get navigationCount() {
return navigationCount;
},
get loginCount() {
return loginCount;
},
};
}
test('同址三环境仍隔离凭证与缓存;切换重建首页和个人状态,切回复用会话', async () => {
const f = fixture(
new Map([['pageConfig', { home: { marker: 'legacy-storage' } }]]),
);
await f.app.loadPageConfig();
await f.auth.getUser();
assert.equal(f.app.globalData.pageConfig.home.marker, 'local');
const localConfig = f.config.getApiConfig();
f.app.globalData.pendingCategoryTabId = 'math';
for (const target of ['staging', 'production', 'local']) {
await f.switcher.switchApiEnvironment(target);
await tick();
assert.equal(f.app.globalData.user.id, `user-${target}`);
assert.equal(f.app.globalData.pageConfig.home.marker, target);
assert.equal(f.currentPages[0].data.config.home.marker, target);
assert.equal(f.app.globalData.pendingCategoryTabId, undefined);
}
assert.equal(f.loginCount, 3);
assert.equal(f.navigationCount, 3);
assert.equal(f.prefetchCalls, 0);
assert.equal(f.config.isCurrentApiConfig(localConfig), false);
const cacheKeys = [...f.storage.keys()].filter((key) =>
key.startsWith('doodle_page_config_v2:'),
);
assert.equal(cacheKeys.length, 3);
const authKeys = [...f.storage.keys()].filter((key) =>
key.startsWith('doodle_auth_v2:'),
);
assert.equal(authKeys.length, 3);
const restored = fixture(f.storage);
assert.equal(restored.config.getApiConfig().environment, 'local');
f.config.selectApiEnvironment('staging');
assert.equal(
fixture(f.storage).config.getApiConfig().environment,
'staging',
);
});
test('trial/release/未知包固定 production,不能通过缓存或切换入口覆盖;保留原预拉取', async () => {
const f = fixture();
f.config.selectApiEnvironment('staging');
for (const version of ['trial', 'release', 'unknown']) {
f.version(version);
assert.equal(f.config.getApiConfig().environment, 'production');
await assert.rejects(
f.switcher.switchApiEnvironment('local'),
/仅开发版/,
);
assert.throws(() => f.config.selectApiEnvironment('local'), /仅开发版/);
}
f.version('trial');
await f.app.loadPageConfig();
assert.equal(f.prefetchCalls, 1);
assert.equal(f.app.globalData.pageConfig.home.marker, 'legacy-prefetch');
});
test('公开接口旧响应在 A→B→A 后仍作废;不会作为当前请求成功返回', async () => {
const f = fixture();
const gate = deferred();
f.handle(() => gate.promise);
const pending = f.api.apiRequest('/categories', { auth: 'none' });
const rejected = assert.rejects(pending, /环境已变化/);
await tick();
f.config.selectApiEnvironment('staging');
f.config.selectApiEnvironment('local');
gate.resolve([]);
await rejected;
});
test('写请求、上传及登录等待全过程禁止切换,完成后可切换', async () => {
const f = fixture();
await f.auth.getUser();
const gate = deferred();
f.handle(() => gate.promise);
const pending = f.member.uploadAsset('/tmp/test.jpg', 'preview');
await tick();
assert.equal(f.activity.hasPendingApiWrites(), true);
await assert.rejects(
f.switcher.switchApiEnvironment('staging'),
/等待当前保存或上传/,
);
assert.equal(f.config.getApiConfig().environment, 'local');
gate.resolve({
id: 'test-preview',
url: 'https://example.test/preview.jpg',
});
await pending;
assert.equal(f.activity.hasPendingApiWrites(), false);
f.pages([{ data: { debugPublishLoading: true } }]);
await assert.rejects(
f.switcher.switchApiEnvironment('staging'),
/等待当前保存或上传/,
);
});
test('未配置地址与缓存写入失败不改变当前环境;目标连接失败不回退、不展示旧内容', async () => {
const f = fixture();
f.config.apiConfig.stagingBaseUrl = '';
assert.equal(f.config.getApiEnvironmentOptions()[1].available, false);
await assert.rejects(
f.switcher.switchApiEnvironment('staging'),
/配置 staging/,
);
assert.equal(f.config.getApiConfig().environment, 'local');
f.config.apiConfig.stagingBaseUrl = f.config.apiConfig.localBaseUrl;
const original = f.wx.setStorageSync;
f.wx.setStorageSync = () => {
throw new Error('storage full');
};
await assert.rejects(
f.switcher.switchApiEnvironment('staging'),
/storage full/,
);
assert.equal(f.config.getApiConfig().environment, 'local');
f.wx.setStorageSync = original;
f.app.globalData.pageConfig = { home: { marker: 'local-old' } };
f.handle(() => {
throw new Error('offline');
});
await assert.rejects(
f.switcher.switchApiEnvironment('staging'),
/目标接口连接失败/,
);
assert.equal(f.config.getApiConfig().environment, 'staging');
assert.equal(f.app.globalData.pageConfig, undefined);
assert.equal(f.currentPages[0].data.config, null);
});
test('旧首页初始化与登录迟到响应不污染切换后的共享状态,旧等待者被释放', async () => {
const f = fixture();
const gate = deferred();
f.handle(({ environment, url }) => {
if (environment === 'local') return gate.promise;
if (url.endsWith('/page-config'))
return { home: { marker: 'staging' } };
throw new Error('skip login');
});
const waiter = f.app.getPageConfig();
const pendingConfig = f.app.loadPageConfig();
const pendingLogin = f.auth.getUser();
const rejected = assert.rejects(pendingLogin, /环境已变化|登录状态已变化/);
await tick();
await f.switcher.switchApiEnvironment('staging');
assert.equal(await waiter, null);
gate.resolve({ home: { marker: 'old-local' } });
await pendingConfig;
await rejected;
assert.equal(f.app.globalData.pageConfig.home.marker, 'staging');
assert.equal(f.app.globalData.user, null);
});
test('配置变更使当前地址失效时仍能切走;写请求等待首次登录期间也禁止切换', async () => {
const f = fixture();
f.config.selectApiEnvironment('staging');
f.config.apiConfig.stagingBaseUrl = '';
assert.throws(() => f.config.getApiConfig(), /配置 staging/);
const waiting = f.app.getPageConfig();
assert.equal(await f.app.loadPageConfig(), false);
assert.equal(await waiting, null);
await f.switcher.switchApiEnvironment('local');
assert.equal(f.config.getApiConfig().environment, 'local');
const g = fixture();
const gate = deferred();
g.handle(({ url }) =>
url.endsWith('/auth/wechat/login') ? gate.promise : {},
);
const write = g.api.apiRequest('/me/profile', {
method: 'PUT',
data: { nickName: 'test' },
});
await tick();
assert.equal(
g.requests.filter((row) => row.url.endsWith('/me/profile')).length,
0,
);
await assert.rejects(
g.switcher.switchApiEnvironment('staging'),
/等待当前保存或上传/,
);
gate.resolve({
accessToken: 'token-local',
tokenType: 'Bearer',
expiresIn: 86400,
expiresAt: '2099-01-01T00:00:00Z',
user: {
id: 'user-local',
nickName: null,
avatarUrl: null,
avatarUrlExpiresAt: null,
createdAt: '2026-10-19T00:00:00Z',
},
});
await write;
assert.equal(g.activity.hasPendingApiWrites(), false);
});
test('Debug 页面调用统一切换入口,失败时隐藏 loading 后显示错误;非开发包拒绝操作', async () => {
const f = fixture();
let definition;
let toasts = [];
let loading = false;
f.wx.showLoading = () => {
loading = true;
};
f.wx.hideLoading = () => {
loading = false;
};
f.wx.showToast = (options) => {
assert.equal(loading, false);
toasts.push(options.title);
};
const source = ts.transpileModule(
readFileSync('miniprogram/supportPages/debug/debug.ts', 'utf8'),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
},
).outputText;
vm.runInNewContext(source, {
exports: {},
wx: f.wx,
Error,
getCurrentPages: () => [definition],
require(name) {
return name === '../../config/api' ? f.config : f.switcher;
},
Page(value) {
definition = {
...value,
setData(value) {
Object.assign(this.data, value);
},
};
},
});
definition.onLoad();
definition.onShow();
assert.equal(definition.data.currentEnvironment, 'local');
f.config.apiConfig.stagingBaseUrl = '';
await definition.onSelectEnvironment({
currentTarget: { dataset: { environment: 'staging' } },
});
assert.match(toasts.at(-1), /配置 staging/);
assert.equal(definition.data.isSwitchingEnvironment, false);
f.version('trial');
definition.onLoad();
definition.onShow();
assert.equal(definition.data.isDevEnv, false);
assert.equal(definition.data.currentEnvironment, 'production');
toasts = [];
await definition.onSelectEnvironment({
currentTarget: { dataset: { environment: 'local' } },
});
assert.equal(toasts.length, 0);
});
+7 -1
View File
@@ -95,6 +95,12 @@ test('首页配置:预拉取、旧 storage 缓存及新后端兜底均写回
require(name) { require(name) {
if (name === './services/site-api') if (name === './services/site-api')
return { fetchPageConfig: async () => old }; return { fetchPageConfig: async () => old };
if (name === './config/api')
return {
getApiConfig: () => ({ version: 'release' }),
apiContextKey: () => 'test-production',
isCurrentApiConfig: () => true,
};
if (name === './config/assets') if (name === './config/assets')
return { normalizePreviewImages }; return { normalizePreviewImages };
return {}; return {};
@@ -112,7 +118,7 @@ test('首页配置:预拉取、旧 storage 缓存及新后端兜底均写回
return entry === 'storage' ? old : null; return entry === 'storage' ? old : null;
}, },
setStorageSync(key, value) { setStorageSync(key, value) {
assert.equal(key, 'pageConfig'); assert.equal(key, 'doodle_page_config_v2:test-production');
stored = value; stored = value;
}, },
}, },
+5
View File
@@ -31,5 +31,10 @@ interface IAppOption {
}; };
userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback; userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback;
getPageConfig(): Promise<PageConfigData | null>; getPageConfig(): Promise<PageConfigData | null>;
/** 切换接口环境时主动重置、初始化 App;页面重建不重新运行 onLaunch。 */
resetApiState(): void;
warmAuthentication(): void;
loadPageConfig(forceRefresh?: boolean): Promise<boolean>;
refreshPageConfig(): Promise<void>;
[key: string]: any; [key: string]: any;
} }