feat: 增加环境切换
This commit is contained in:
@@ -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、限流、服务错误或网络失败不触发自动重登链。
|
||||
|
||||
@@ -44,10 +44,28 @@ await apiRequest('/me');
|
||||
|
||||
### 业务接口切换
|
||||
|
||||
本地开发中的小程序运行代码已不再调用 `wx.cloud`;首页继续使用 `wx.getBackgroundFetchData` 获取预拉取首屏,在后台使用新 `/page-config` 刷新,管理改动也会刷新首页。旧云端的预拉取地址、生成与发布链路、定时任务保持原样,新管理开关仅控制新后端的快照刷新任务。
|
||||
本地开发中的小程序运行代码已不再调用 `wx.cloud`;体验版与正式版首页继续使用 `wx.getBackgroundFetchData` 获取预拉取首屏,在后台使用新 `/page-config` 刷新。开发版跳过旧线上预拉取,使用所选接口环境自己的内容缓存及新后端;管理改动也会刷新首页。旧云端的预拉取地址、生成与发布链路、定时任务保持原样,新管理开关仅控制新后端的快照刷新任务。
|
||||
|
||||
当前已发布的线上版仍使用旧云开发接口。`cloudfunctions/` 中全部旧云函数源码、云开发配置及线上服务均须保留;本地开发版切换接口不代表线上旧版本已停止调用。未经另行明确安排,不删除、停用旧云函数,不改变其接口契约,不清理旧云数据库或云存储,也不迁移现有预拉取模式。
|
||||
|
||||
用户接口由 `services/member-api.ts` 与 `utils/auth.ts` 管理,管理页由 `services/admin-api.ts` 适配具名 REST 路由。图片通过本站 multipart 上传接口进入七牛,不暴露 AK/SK;同一次上传恢复沿用 UUID uploadId。管理页只能由数据库中当前角色为 admin 的账户操作,开发版不会绕过权限。
|
||||
|
||||
服务端路由、权限与数据迁移说明见 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
@@ -2,11 +2,13 @@ import { fetchPageConfig } from './services/site-api';
|
||||
import { normalizePreviewImages } from './config/assets';
|
||||
import { defaultPrintConfig } from './config/config';
|
||||
import { getUser } from './utils/auth';
|
||||
import { apiContextKey, getApiConfig, isCurrentApiConfig } from './config/api';
|
||||
import { generateUUID, getAppUUID, setAppUUID } from './utils/uuid';
|
||||
|
||||
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
|
||||
let _configResolvers: Array<(config: PageConfigData | null) => void> = [];
|
||||
@@ -65,6 +67,23 @@ App<IAppOption>({
|
||||
|
||||
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 {
|
||||
return this.globalData.printConfig || defaultPrintConfig;
|
||||
},
|
||||
@@ -74,32 +93,54 @@ App<IAppOption>({
|
||||
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;
|
||||
|
||||
// L1: 数据预拉取
|
||||
try {
|
||||
const res = await new Promise<{ fetchedData: string }>(
|
||||
(resolve, reject) => {
|
||||
wx.getBackgroundFetchData({
|
||||
fetchType: 'pre',
|
||||
success: resolve as any,
|
||||
fail: reject,
|
||||
});
|
||||
},
|
||||
);
|
||||
if (res.fetchedData) {
|
||||
const parsed = JSON.parse(res.fetchedData);
|
||||
config = parsed?.data || parsed;
|
||||
// 仅开发版跳过旧线上预拉取,保证环境切换后的内容来自目标服务。
|
||||
// 体验/正式版继续沿用原预拉取模式、地址与旧云端任务。
|
||||
if (context.version !== 'develop')
|
||||
try {
|
||||
const res = await new Promise<{ fetchedData: string }>(
|
||||
(resolve, reject) => {
|
||||
wx.getBackgroundFetchData({
|
||||
fetchType: 'pre',
|
||||
success: resolve as any,
|
||||
fail: reject,
|
||||
});
|
||||
},
|
||||
);
|
||||
if (res.fetchedData) {
|
||||
const parsed = JSON.parse(res.fetchedData);
|
||||
config = parsed?.data || parsed;
|
||||
}
|
||||
} catch {
|
||||
// 预拉取失败,继续降级
|
||||
}
|
||||
} catch {
|
||||
// 预拉取失败,继续降级
|
||||
}
|
||||
if (!current()) return false;
|
||||
|
||||
// L2: storage 缓存
|
||||
if (!config) {
|
||||
if (!config && !forceRefresh) {
|
||||
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') {
|
||||
config = cached;
|
||||
}
|
||||
@@ -117,6 +158,7 @@ App<IAppOption>({
|
||||
/* 新后端不可用时继续展示空状态,本地生成仍可使用。 */
|
||||
}
|
||||
}
|
||||
if (!current()) return false;
|
||||
|
||||
// 三种数据入口都先转换预览图,旧缓存会随成功加载写回新地址。
|
||||
config = normalizePreviewImages(config);
|
||||
@@ -126,7 +168,7 @@ App<IAppOption>({
|
||||
this.globalData.pageConfig = config;
|
||||
// 成功获取后写入 storage 缓存
|
||||
try {
|
||||
wx.setStorageSync(STORAGE_KEY_PAGE_CONFIG, config);
|
||||
wx.setStorageSync(storageKey, config);
|
||||
} catch {
|
||||
// storage 写入失败不影响主流程
|
||||
}
|
||||
@@ -140,16 +182,23 @@ App<IAppOption>({
|
||||
_configResolvers = [];
|
||||
// 预拉取/缓存先渲染,再用新后端刷新,避免旧静态配置长期覆盖管理修改。
|
||||
if (hadCachedConfig) void this.refreshPageConfig();
|
||||
return !!config;
|
||||
},
|
||||
|
||||
async refreshPageConfig() {
|
||||
const context = getApiConfig();
|
||||
const storageKey = pageConfigStorageKey();
|
||||
const generation = ++_refreshGeneration;
|
||||
try {
|
||||
const config = normalizePreviewImages(await fetchPageConfig());
|
||||
if (generation !== _refreshGeneration) return;
|
||||
if (
|
||||
generation !== _refreshGeneration ||
|
||||
!isCurrentApiConfig(context)
|
||||
)
|
||||
return;
|
||||
this.globalData.pageConfig = config;
|
||||
try {
|
||||
wx.setStorageSync(STORAGE_KEY_PAGE_CONFIG, config);
|
||||
wx.setStorageSync(storageKey, config);
|
||||
} catch {
|
||||
/* 缓存失败不影响当前数据。 */
|
||||
}
|
||||
|
||||
@@ -66,13 +66,21 @@
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.nav-bar__title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-bar__title {
|
||||
font-size: @fs-nav-title;
|
||||
font-weight: @fw-nav-title;
|
||||
color: @color-nav-title;
|
||||
letter-spacing: @ls-nav-title;
|
||||
line-height: 1;
|
||||
flex: 1;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -86,4 +94,14 @@
|
||||
.nav-bar__placeholder {
|
||||
width: 100%;
|
||||
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 { getApiEnvironment } from '../../config/api';
|
||||
|
||||
const HOME_TAB_URL = '/pages/home/home';
|
||||
const HOME_ROUTE = 'pages/home/home';
|
||||
@@ -24,6 +25,7 @@ Component({
|
||||
leftActionType: 'none' as 'none' | 'back' | 'home' | 'brand',
|
||||
/** 仅首页(Tab 根页)在 logo 旁展示「涂鸦丫」;其余主 Tab 只展示 logo */
|
||||
showBrandName: false,
|
||||
apiEnvironment: '',
|
||||
},
|
||||
lifetimes: {
|
||||
attached() {
|
||||
@@ -44,6 +46,12 @@ Component({
|
||||
},
|
||||
methods: {
|
||||
syncLeftAction() {
|
||||
this.setData({
|
||||
apiEnvironment:
|
||||
wx.getAccountInfoSync().miniProgram.envVersion === 'develop'
|
||||
? getApiEnvironment()
|
||||
: '',
|
||||
});
|
||||
const pages = getCurrentPages();
|
||||
const currentRoute = pages[pages.length - 1]?.route || '';
|
||||
const isTabBarPage = TAB_BAR_ROUTES.has(currentRoute);
|
||||
|
||||
@@ -22,7 +22,12 @@
|
||||
wx:elif="{{leftActionType === 'home'}}"
|
||||
class="toy-icon toy-icon-home-o nav-bar__back-icon"></text>
|
||||
</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">
|
||||
<slot name="right"></slot>
|
||||
</view>
|
||||
|
||||
+112
-27
@@ -1,37 +1,122 @@
|
||||
export type ApiEnvironment = 'local' | 'online';
|
||||
export type ApiEnvironment = 'local' | 'staging' | 'production';
|
||||
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 = {
|
||||
developmentEnvironment: 'local' as ApiEnvironment,
|
||||
localBaseUrl: 'http://127.0.0.1:8800/api/v1',
|
||||
// 线上域名尚未确定:填写完整 HTTPS 地址,例如 https://你的域名/api/v1。
|
||||
onlineBaseUrl: '',
|
||||
localBaseUrl: LOCAL_BASE_URL,
|
||||
stagingBaseUrl: LOCAL_BASE_URL,
|
||||
productionBaseUrl: LOCAL_BASE_URL,
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
/** develop 可切换本地/线上;体验版、正式版和未知运行环境均只允许线上入口。 */
|
||||
export function resolveApiConfig(version: MiniProgramVersion | string) {
|
||||
const environment: ApiEnvironment =
|
||||
version === 'develop' ? apiConfig.developmentEnvironment : 'online';
|
||||
const baseUrl = (
|
||||
environment === 'local'
|
||||
? apiConfig.localBaseUrl
|
||||
: apiConfig.onlineBaseUrl
|
||||
)
|
||||
.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 };
|
||||
export const API_ENVIRONMENTS: ApiEnvironment[] = [
|
||||
'local',
|
||||
'staging',
|
||||
'production',
|
||||
];
|
||||
|
||||
export function isApiEnvironment(value: unknown): value is ApiEnvironment {
|
||||
return API_ENVIRONMENTS.includes(value as ApiEnvironment);
|
||||
}
|
||||
|
||||
/** 请求时读取微信包版本,避免依赖 App 启动顺序,也不接受本地缓存覆盖正式版环境。 */
|
||||
export function getApiConfig() {
|
||||
return resolveApiConfig(wx.getAccountInfoSync().miniProgram.envVersion);
|
||||
/** 仅开发包读取本机选择;体验、正式及未知包始终连接 production。 */
|
||||
export function getApiEnvironment(
|
||||
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,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -430,14 +430,17 @@ page {
|
||||
}
|
||||
|
||||
.profile-editor__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 24rpx;
|
||||
display: flex;
|
||||
padding-top: 20rpx;
|
||||
}
|
||||
|
||||
.profile-editor__action {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 92rpx;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -447,6 +450,14 @@ page {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-editor__action::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.profile-editor__action + .profile-editor__action {
|
||||
margin-left: 24rpx;
|
||||
}
|
||||
|
||||
.profile-editor__action--ghost {
|
||||
background: @bg-header;
|
||||
color: @text-title;
|
||||
|
||||
@@ -115,6 +115,11 @@ Page({
|
||||
},
|
||||
|
||||
onOpenProfileEditor() {
|
||||
console.log(
|
||||
'onOpenProfileEditor',
|
||||
this.data.userName,
|
||||
this.data.avatarUrl,
|
||||
);
|
||||
this.setData({
|
||||
showProfileEditor: true,
|
||||
draftNickName:
|
||||
|
||||
@@ -225,16 +225,19 @@
|
||||
</view>
|
||||
|
||||
<view class="profile-editor__actions">
|
||||
<view
|
||||
<button
|
||||
class="profile-editor__action profile-editor__action--ghost"
|
||||
disabled="{{isSavingProfile}}"
|
||||
bindtap="onCloseProfileEditor">
|
||||
取消
|
||||
</view>
|
||||
<view
|
||||
</button>
|
||||
<button
|
||||
class="profile-editor__action profile-editor__action--primary {{isSavingProfile ? 'profile-editor__action--disabled' : ''}}"
|
||||
loading="{{isSavingProfile}}"
|
||||
disabled="{{isSavingProfile}}"
|
||||
bindtap="onSubmitProfile">
|
||||
{{isSavingProfile ? '保存中...' : '保存'}}
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</van-popup>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -12,28 +12,6 @@ page {
|
||||
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 {
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
@@ -125,3 +103,65 @@ page {
|
||||
font-size: 24rpx;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
getApiEnvironment,
|
||||
getApiEnvironmentOptions,
|
||||
isApiEnvironment,
|
||||
} from '../../config/api';
|
||||
import { switchApiEnvironment } from '../../services/environment-switch';
|
||||
|
||||
type DebugEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -47,7 +54,45 @@ const DEBUG_ENTRIES: DebugEntry[] = [
|
||||
Page({
|
||||
data: {
|
||||
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() {
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
<view class="debug-page">
|
||||
<view class="debug-page__hero">
|
||||
<text class="debug-page__hero-title">Debug 工具页</text>
|
||||
<text class="debug-page__hero-desc">
|
||||
仅开发环境可见,用于发布前检查和同步调试数据。
|
||||
</text>
|
||||
<view wx:if="{{isDevEnv}}" class="debug-page__environment">
|
||||
<text class="debug-page__section-title"
|
||||
>接口环境 · {{currentEnvironment}}</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 class="debug-page__section">
|
||||
|
||||
+15
-14
@@ -52,7 +52,7 @@ beforeEach(() => {
|
||||
requests = [];
|
||||
navigations = [];
|
||||
apiConfig.developmentEnvironment = 'local';
|
||||
apiConfig.onlineBaseUrl = '';
|
||||
apiConfig.productionBaseUrl = 'http://127.0.0.1:8800/api/v1';
|
||||
handler = () => ({ data: [] });
|
||||
globalThis.wx = {
|
||||
getAccountInfoSync: () => ({ miniProgram: { envVersion: 'develop' } }),
|
||||
@@ -152,22 +152,23 @@ function useRows(rows, selectedCategories = categories) {
|
||||
};
|
||||
}
|
||||
|
||||
test('环境配置:开发版默认本地,正式/体验/未知环境不回退本地,线上要求 HTTPS', () => {
|
||||
assert.equal(
|
||||
resolveApiConfig('develop').baseUrl,
|
||||
'http://127.0.0.1:8800/api/v1',
|
||||
);
|
||||
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'])
|
||||
test('环境配置:体验/正式/未知包固定 production,暂允许指定的本机占位地址', () => {
|
||||
assert.equal(resolveApiConfig('develop').environment, 'local');
|
||||
for (const version of ['release', 'trial', 'unknown']) {
|
||||
assert.equal(resolveApiConfig(version).environment, 'production');
|
||||
assert.equal(
|
||||
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.onlineBaseUrl = 'http://api.example.test/api/v1';
|
||||
}
|
||||
apiConfig.productionBaseUrl = '';
|
||||
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'), /配置/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -95,6 +95,12 @@ test('首页配置:预拉取、旧 storage 缓存及新后端兜底均写回
|
||||
require(name) {
|
||||
if (name === './services/site-api')
|
||||
return { fetchPageConfig: async () => old };
|
||||
if (name === './config/api')
|
||||
return {
|
||||
getApiConfig: () => ({ version: 'release' }),
|
||||
apiContextKey: () => 'test-production',
|
||||
isCurrentApiConfig: () => true,
|
||||
};
|
||||
if (name === './config/assets')
|
||||
return { normalizePreviewImages };
|
||||
return {};
|
||||
@@ -112,7 +118,7 @@ test('首页配置:预拉取、旧 storage 缓存及新后端兜底均写回
|
||||
return entry === 'storage' ? old : null;
|
||||
},
|
||||
setStorageSync(key, value) {
|
||||
assert.equal(key, 'pageConfig');
|
||||
assert.equal(key, 'doodle_page_config_v2:test-production');
|
||||
stored = value;
|
||||
},
|
||||
},
|
||||
|
||||
Vendored
+5
@@ -31,5 +31,10 @@ interface IAppOption {
|
||||
};
|
||||
userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback;
|
||||
getPageConfig(): Promise<PageConfigData | null>;
|
||||
/** 切换接口环境时主动重置、初始化 App;页面重建不重新运行 onLaunch。 */
|
||||
resetApiState(): void;
|
||||
warmAuthentication(): void;
|
||||
loadPageConfig(forceRefresh?: boolean): Promise<boolean>;
|
||||
refreshPageConfig(): Promise<void>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user