diff --git a/README.md b/README.md index ec8a510..525ec32 100644 --- a/README.md +++ b/README.md @@ -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`。 diff --git a/miniprogram/app.ts b/miniprogram/app.ts index 70156b0..208d8f0 100644 --- a/miniprogram/app.ts +++ b/miniprogram/app.ts @@ -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({ 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({ wx.setStorageSync('printConfig', config); }, - async loadPageConfig() { + async loadPageConfig(forceRefresh = false) { + let context: ReturnType; + 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({ /* 新后端不可用时继续展示空状态,本地生成仍可使用。 */ } } + if (!current()) return false; // 三种数据入口都先转换预览图,旧缓存会随成功加载写回新地址。 config = normalizePreviewImages(config); @@ -126,7 +168,7 @@ App({ this.globalData.pageConfig = config; // 成功获取后写入 storage 缓存 try { - wx.setStorageSync(STORAGE_KEY_PAGE_CONFIG, config); + wx.setStorageSync(storageKey, config); } catch { // storage 写入失败不影响主流程 } @@ -140,16 +182,23 @@ App({ _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 { /* 缓存失败不影响当前数据。 */ } diff --git a/miniprogram/components3.0/nav-bar/nav-bar.less b/miniprogram/components3.0/nav-bar/nav-bar.less index 649becc..bec2f9f 100644 --- a/miniprogram/components3.0/nav-bar/nav-bar.less +++ b/miniprogram/components3.0/nav-bar/nav-bar.less @@ -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; -} \ No newline at end of file +} +.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; +} diff --git a/miniprogram/components3.0/nav-bar/nav-bar.ts b/miniprogram/components3.0/nav-bar/nav-bar.ts index c135c27..085f1cf 100644 --- a/miniprogram/components3.0/nav-bar/nav-bar.ts +++ b/miniprogram/components3.0/nav-bar/nav-bar.ts @@ -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); diff --git a/miniprogram/components3.0/nav-bar/nav-bar.wxml b/miniprogram/components3.0/nav-bar/nav-bar.wxml index cd296b2..58f2268 100644 --- a/miniprogram/components3.0/nav-bar/nav-bar.wxml +++ b/miniprogram/components3.0/nav-bar/nav-bar.wxml @@ -22,7 +22,12 @@ wx:elif="{{leftActionType === 'home'}}" class="toy-icon toy-icon-home-o nav-bar__back-icon"> - {{title}} + + {{title}} + {{apiEnvironment}} + diff --git a/miniprogram/config/api.ts b/miniprogram/config/api.ts index 9c84c67..409a6de 100644 --- a/miniprogram/config/api.ts +++ b/miniprogram/config/api.ts @@ -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) { + 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, + }; + } + }); } diff --git a/miniprogram/pages/profile/profile.less b/miniprogram/pages/profile/profile.less index 12ed229..ef83d7e 100644 --- a/miniprogram/pages/profile/profile.less +++ b/miniprogram/pages/profile/profile.less @@ -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; diff --git a/miniprogram/pages/profile/profile.ts b/miniprogram/pages/profile/profile.ts index 0fb2716..de94033 100644 --- a/miniprogram/pages/profile/profile.ts +++ b/miniprogram/pages/profile/profile.ts @@ -115,6 +115,11 @@ Page({ }, onOpenProfileEditor() { + console.log( + 'onOpenProfileEditor', + this.data.userName, + this.data.avatarUrl, + ); this.setData({ showProfileEditor: true, draftNickName: diff --git a/miniprogram/pages/profile/profile.wxml b/miniprogram/pages/profile/profile.wxml index d1ebace..c65f89f 100644 --- a/miniprogram/pages/profile/profile.wxml +++ b/miniprogram/pages/profile/profile.wxml @@ -225,16 +225,19 @@ - 取消 - - + diff --git a/miniprogram/services/api-activity.ts b/miniprogram/services/api-activity.ts new file mode 100644 index 0000000..0e19a85 --- /dev/null +++ b/miniprogram/services/api-activity.ts @@ -0,0 +1,13 @@ +let pendingWrites = 0; + +/** 覆盖登录等待、上传及重试全过程,防止写操作进行中切换服务端。 */ +export function beginApiWrite() { + pendingWrites++; + return () => { + pendingWrites--; + }; +} + +export function hasPendingApiWrites() { + return pendingWrites > 0; +} diff --git a/miniprogram/services/api-request.ts b/miniprogram/services/api-request.ts index 65619fc..92d8129 100644 --- a/miniprogram/services/api-request.ts +++ b/miniprogram/services/api-request.ts @@ -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( path: string, options: RequestOptions & { auth?: 'required' | 'none' } = {}, +): Promise { + const finish = + options.upload || (options.method && options.method !== 'GET') + ? beginApiWrite() + : undefined; + try { + return await performApiRequest(path, options); + } finally { + finish?.(); + } +} + +async function performApiRequest( + path: string, + options: RequestOptions & { auth?: 'required' | 'none' } = {}, ): Promise { // Authorization 由会话层统一提供;公开请求不附带调用方误传的凭证。 const header = Object.fromEntries( diff --git a/miniprogram/services/api-transport.ts b/miniprogram/services/api-transport.ts index 05b5f7b..2ad9608 100644 --- a/miniprogram/services/api-transport.ts +++ b/miniprogram/services/api-transport.ts @@ -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( 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( : '接口请求失败,请稍后重试', 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( ); } 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({ diff --git a/miniprogram/services/auth-session.ts b/miniprogram/services/auth-session.ts index 21d5af8..f52bb1a 100644 --- a/miniprogram/services/auth-session.ts +++ b/miniprogram/services/auth-session.ts @@ -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 { - 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); diff --git a/miniprogram/services/environment-switch.ts b/miniprogram/services/environment-switch.ts new file mode 100644 index 0000000..491475b --- /dev/null +++ b/miniprogram/services/environment-switch.ts @@ -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(); + app.resetApiState(); + // reLaunch 只重建页面,不重新执行 App.onLaunch;目标数据需要主动初始化。 + try { + await new Promise((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; + } +} diff --git a/miniprogram/supportPages/debug/debug.less b/miniprogram/supportPages/debug/debug.less index 804440b..0cf11e5 100644 --- a/miniprogram/supportPages/debug/debug.less +++ b/miniprogram/supportPages/debug/debug.less @@ -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; +} diff --git a/miniprogram/supportPages/debug/debug.ts b/miniprogram/supportPages/debug/debug.ts index 1cbfd40..29cdae5 100644 --- a/miniprogram/supportPages/debug/debug.ts +++ b/miniprogram/supportPages/debug/debug.ts @@ -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() { diff --git a/miniprogram/supportPages/debug/debug.wxml b/miniprogram/supportPages/debug/debug.wxml index cd707b7..a3ce676 100644 --- a/miniprogram/supportPages/debug/debug.wxml +++ b/miniprogram/supportPages/debug/debug.wxml @@ -1,9 +1,24 @@ - - Debug 工具页 - - 仅开发环境可见,用于发布前检查和同步调试数据。 - + + 接口环境 · {{currentEnvironment}} + {{currentBaseUrl}} + + + + 切换后自动返回首页;下次打开开发版继续使用所选环境。 diff --git a/tests/category-api.test.mjs b/tests/category-api.test.mjs index d1072ef..a7f12bb 100644 --- a/tests/category-api.test.mjs +++ b/tests/category-api.test.mjs @@ -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'), /配置/); }); diff --git a/tests/environment-switch.test.mjs b/tests/environment-switch.test.mjs new file mode 100644 index 0000000..af2a743 --- /dev/null +++ b/tests/environment-switch.test.mjs @@ -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); +}); diff --git a/tests/preview-assets.test.mjs b/tests/preview-assets.test.mjs index e167e0a..3b97ff3 100644 --- a/tests/preview-assets.test.mjs +++ b/tests/preview-assets.test.mjs @@ -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; }, }, diff --git a/typings/index.d.ts b/typings/index.d.ts index 615b16a..93d9d2d 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -31,5 +31,10 @@ interface IAppOption { }; userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback; getPageConfig(): Promise; + /** 切换接口环境时主动重置、初始化 App;页面重建不重新运行 onLaunch。 */ + resetApiState(): void; + warmAuthentication(): void; + loadPageConfig(forceRefresh?: boolean): Promise; + refreshPageConfig(): Promise; [key: string]: any; }