From dd5861d5f587e0e5dd451b6184f76c1a32b327be Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:23:07 +0800 Subject: [PATCH 1/2] fix: align miniprogram web-view auth and share --- ...项目基线】当前产品与工程约束-2026-05-15.md | 1 + miniprogram/pages/web-view/index.js | 53 +++++++++++----- scripts/miniprogram-web-view-auth.test.ts | 62 ++++++++++++++++--- 3 files changed, 91 insertions(+), 25 deletions(-) diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 68c05a44..f5d50db7 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -46,6 +46,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。 4. 微信小程序 `web-view` 外壳默认不预登录,首次进入直接打开 H5,并保持与 Web 端一致的未登录状态;只有 H5 触发 `openLoginModal` / `requireAuth` 等受保护入口时,才跳转小程序原生授权态。 5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权,再调用 `/api/auth/wechat/miniprogram-login` 与 `/api/auth/wechat/bind-phone` 换取系统登录态。 +6. 小程序 `web-view` 页必须启用好友分享与朋友圈分享,分享目标固定回到 `pages/web-view/index`,不把 H5 当前 URL 作为不受控启动参数传回小程序页。 ## 账户与充值 diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index 1f33e2cb..0c2677f4 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -16,6 +16,33 @@ const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result'; const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result'; const AUTH_ACTION_LOGIN = 'login'; const PAY_RESULT_RECHECK_DELAY_MS = 120; +const WEB_VIEW_SHARE_TITLE = '陶泥儿'; +const WEB_VIEW_SHARE_PATH = '/pages/web-view/index'; + +function showWebViewShareMenu() { + if (typeof wx.showShareMenu !== 'function') { + return; + } + + wx.showShareMenu({ + withShareTicket: true, + menus: ['shareAppMessage', 'shareTimeline'], + }); +} + +function buildWebViewShareAppMessage() { + return { + title: WEB_VIEW_SHARE_TITLE, + path: WEB_VIEW_SHARE_PATH, + }; +} + +function buildWebViewShareTimeline() { + return { + title: WEB_VIEW_SHARE_TITLE, + query: '', + }; +} function isConfiguredEntryUrl(value) { const trimmed = String(value || '').trim(); @@ -271,18 +298,6 @@ async function resolveAuthResult() { }; } -async function refreshMiniProgramSessionSilently() { - if (!isConfiguredApiBaseUrl(API_BASE_URL)) { - return null; - } - try { - return await resolveAuthResult(); - } catch (error) { - console.warn('[web-view] silent mini program login refresh failed', error); - return null; - } -} - Page({ data: { authResult: null, @@ -296,6 +311,7 @@ Page({ async onLoad(query = {}) { this._lastLaunchQuery = query; + showWebViewShareMenu(); // 中文注释:web-view 只能打开已配置业务域名;未配置时展示本地提示,避免空白页误判。 if (!isConfiguredEntryUrl(WEB_VIEW_ENTRY_URL)) { this.setData({ @@ -309,14 +325,13 @@ Page({ const forcedPhoneBinding = parseBooleanQueryFlag(query.phoneBindingRequired); const returnToPreviousPage = shouldReturnToPreviousPage(query); if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) { - const authResult = await refreshMiniProgramSessionSilently(); this.setData({ - authResult, + authResult: null, errorMessage: '', loading: false, phoneBindingRequired: false, returnToPreviousPage: false, - webViewUrl: resolveWebViewUrl(authResult), + webViewUrl: resolveWebViewUrl(null), }); return; } @@ -490,4 +505,12 @@ Page({ // 中文注释:支付由独立 native 页面承接,web-view 消息只保留调试输出。 console.info('[web-view] message', event.detail); }, + + onShareAppMessage() { + return buildWebViewShareAppMessage(); + }, + + onShareTimeline() { + return buildWebViewShareTimeline(); + }, }); diff --git a/scripts/miniprogram-web-view-auth.test.ts b/scripts/miniprogram-web-view-auth.test.ts index 388677e0..fdac9e72 100644 --- a/scripts/miniprogram-web-view-auth.test.ts +++ b/scripts/miniprogram-web-view-auth.test.ts @@ -17,6 +17,8 @@ type MiniProgramPage = { data: Record; setData: (patch: Record) => void; onLoad: (query?: Record) => Promise; + onShareAppMessage: () => Record; + onShareTimeline: () => Record; onShow: () => void; consumePayResult: () => void; }; @@ -29,6 +31,7 @@ function createWxMock() { navigateBack: vi.fn(), removeStorageSync: vi.fn(), request: vi.fn(), + showShareMenu: vi.fn(), setStorageSync: vi.fn(), }; } @@ -91,7 +94,7 @@ describe('mini-program web-view auth page', () => { vi.clearAllMocks(); }); - test('默认进入时刷新微信小程序登录态后打开 web-view', async () => { + test('默认进入时不预登录,直接打开未登录 web-view', async () => { const wxMock = createWxMock(); wxMock.login.mockImplementation(({ success }) => { success({ code: 'wx-login-code' }); @@ -109,19 +112,58 @@ describe('mini-program web-view auth page', () => { await page.onLoad({}); - expect(wxMock.login).toHaveBeenCalledTimes(1); - expect(wxMock.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://www.genarrative.world/api/auth/wechat/miniprogram-login', - method: 'POST', - data: { code: 'wx-login-code' }, - }), - ); + expect(wxMock.login).not.toHaveBeenCalled(); + expect(wxMock.request).not.toHaveBeenCalled(); expect(page.data.loading).toBe(false); expect(page.data.phoneBindingRequired).toBe(false); expect(page.data.webViewUrl).toBe( - 'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program#auth_provider=wechat&auth_token=jwt-active-wechat&auth_binding_status=active', + 'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program', ); + expect(wxMock.showShareMenu).toHaveBeenCalledWith({ + withShareTicket: true, + menus: ['shareAppMessage', 'shareTimeline'], + }); + }); + + test('默认进入时即便微信新身份待绑手机号,也不弹出绑定手机号页', async () => { + const wxMock = createWxMock(); + wxMock.login.mockImplementation(({ success }) => { + success({ code: 'wx-login-code' }); + }); + wxMock.request.mockImplementation(({ success }) => { + success({ + statusCode: 200, + data: { + token: 'jwt-pending-wechat', + bindingStatus: 'pending_bind_phone', + }, + }); + }); + const page = loadWebViewPage(wxMock); + + await page.onLoad({}); + + expect(wxMock.login).not.toHaveBeenCalled(); + expect(wxMock.request).not.toHaveBeenCalled(); + expect(page.data.loading).toBe(false); + expect(page.data.phoneBindingRequired).toBe(false); + expect(page.data.webViewUrl).toBe( + 'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program', + ); + }); + + test('web-view 页面分享好友和朋友圈都回到小程序 web-view 入口', () => { + const wxMock = createWxMock(); + const page = loadWebViewPage(wxMock); + + expect(page.onShareAppMessage()).toEqual({ + title: '陶泥儿', + path: '/pages/web-view/index', + }); + expect(page.onShareTimeline()).toEqual({ + title: '陶泥儿', + query: '', + }); }); test('默认匿名进入 web-view 仍不依赖 API_BASE_URL 配置', async () => { From b19a3e42315be6081d93c855ee93e5d94c303ec7 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:52:59 +0800 Subject: [PATCH 2/2] fix: switch miniprogram channel by env version --- ...项目基线】当前产品与工程约束-2026-05-15.md | 1 + miniprogram/config.js | 6 +- miniprogram/pages/web-view/index.js | 84 +++++++++++++++++-- scripts/miniprogram-web-view-auth.test.ts | 50 +++++++++++ 4 files changed, 132 insertions(+), 9 deletions(-) diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index f5d50db7..254e5128 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -47,6 +47,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 4. 微信小程序 `web-view` 外壳默认不预登录,首次进入直接打开 H5,并保持与 Web 端一致的未登录状态;只有 H5 触发 `openLoginModal` / `requireAuth` 等受保护入口时,才跳转小程序原生授权态。 5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权,再调用 `/api/auth/wechat/miniprogram-login` 与 `/api/auth/wechat/bind-phone` 换取系统登录态。 6. 小程序 `web-view` 页必须启用好友分享与朋友圈分享,分享目标固定回到 `pages/web-view/index`,不把 H5 当前 URL 作为不受控启动参数传回小程序页。 +7. 小程序 `web-view` 外壳运行时通过 `wx.getAccountInfoSync().miniProgram.envVersion` 自动识别版本:线上版 `release` 使用 `www.genarrative.world`,体验版 `trial` 与开发版 `develop` 使用 `dev.genarrative.world`;传给后端的 `x-mini-program-env` 分别为 `release`、`trial`、`dev`。 ## 账户与充值 diff --git a/miniprogram/config.js b/miniprogram/config.js index f9488282..c521817f 100644 --- a/miniprogram/config.js +++ b/miniprogram/config.js @@ -2,15 +2,17 @@ // 示例:https://game.example.com/ // 注意:必须是 https 域名,不能是 localhost、IP 地址或未备案域名。 const WEB_VIEW_ENTRY_URL = 'https://www.genarrative.world'; +const DEV_WEB_VIEW_ENTRY_URL = 'https://dev.genarrative.world'; // 中文注释:这里填写 Rust api-server 的公网 HTTPS 域名,必须在“小程序后台-开发设置-request 合法域名”中配置。 // 如果 H5 和 API 同域,可保持和 WEB_VIEW_ENTRY_URL 同一个域名;请求路径会固定走 /api/auth/wechat/miniprogram-login。 const API_BASE_URL = 'https://www.genarrative.world'; +const DEV_API_BASE_URL = 'https://dev.genarrative.world'; // 中文注释:这里填写微信小程序 AppID,用于后端记录会话来源;project.config.json 里的 appid 也要保持一致。 const MINI_PROGRAM_APP_ID = 'wx3da23ea14ca66b65'; -// 中文注释:按当前上传版本填写 develop / trial / release,后端会写入会话来源快照。 +// 中文注释:仅作为运行时环境识别失败时的兜底;正常情况下由 wx.getAccountInfoSync 自动判断。 const MINI_PROGRAM_ENV = 'release'; // 中文注释:给 H5 加一个来源标记,便于后续前端或后端识别这是微信小程序 web-view 宿主。 @@ -21,6 +23,8 @@ const WEB_VIEW_SOURCE_QUERY = { module.exports = { API_BASE_URL, + DEV_API_BASE_URL, + DEV_WEB_VIEW_ENTRY_URL, MINI_PROGRAM_APP_ID, MINI_PROGRAM_ENV, WEB_VIEW_ENTRY_URL, diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index 0c2677f4..4b9e22c7 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -3,6 +3,8 @@ const { API_BASE_URL, + DEV_API_BASE_URL, + DEV_WEB_VIEW_ENTRY_URL, MINI_PROGRAM_APP_ID, MINI_PROGRAM_ENV, WEB_VIEW_ENTRY_URL, @@ -105,6 +107,68 @@ function parseBooleanQueryFlag(value) { return value === true || value === '1' || value === 'true' || value === 'yes'; } +function normalizeMiniProgramEnv(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'release') { + return 'release'; + } + if (normalized === 'trial') { + return 'trial'; + } + if ( + normalized === 'develop' || + normalized === 'development' || + normalized === 'dev' + ) { + return 'dev'; + } + return ''; +} + +function readMiniProgramEnvVersion() { + if (typeof wx.getAccountInfoSync !== 'function') { + return ''; + } + try { + const accountInfo = wx.getAccountInfoSync(); + return ( + accountInfo && + accountInfo.miniProgram && + accountInfo.miniProgram.envVersion + ); + } catch (error) { + console.warn('[web-view] read mini program env failed', error); + return ''; + } +} + +function resolveMiniProgramRuntimeConfig() { + const miniProgramEnv = + normalizeMiniProgramEnv(readMiniProgramEnvVersion()) || + normalizeMiniProgramEnv(MINI_PROGRAM_ENV) || + 'release'; + const useReleaseChannel = miniProgramEnv === 'release'; + const webViewEntryUrl = useReleaseChannel + ? WEB_VIEW_ENTRY_URL + : DEV_WEB_VIEW_ENTRY_URL || WEB_VIEW_ENTRY_URL; + const apiBaseUrl = useReleaseChannel + ? API_BASE_URL + : DEV_API_BASE_URL || API_BASE_URL; + const sourceQuery = { + ...WEB_VIEW_SOURCE_QUERY, + }; + if (!useReleaseChannel) { + sourceQuery.miniProgramEnv = miniProgramEnv; + } + + return { + apiBaseUrl, + miniProgramEnv, + sourceQuery, + webViewEntryUrl, + }; +} + function shouldStartAuthFromQuery(query) { return String((query && query.authAction) || '').trim() === AUTH_ACTION_LOGIN; } @@ -114,12 +178,13 @@ function shouldReturnToPreviousPage(query) { } function resolveWebViewUrl(authResult) { - const entryUrl = String(WEB_VIEW_ENTRY_URL || '').trim(); + const runtimeConfig = resolveMiniProgramRuntimeConfig(); + const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim(); if (!isConfiguredEntryUrl(entryUrl)) { return ''; } - const sourcedUrl = appendQuery(entryUrl, WEB_VIEW_SOURCE_QUERY); + const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery); if (!authResult || !authResult.token) { return sourcedUrl; } @@ -205,7 +270,8 @@ function wxLogin() { function requestMiniProgramLogin(code) { return new Promise((resolve, reject) => { - const apiBaseUrl = trimTrailingSlash(API_BASE_URL); + const runtimeConfig = resolveMiniProgramRuntimeConfig(); + const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl); if (!isConfiguredApiBaseUrl(apiBaseUrl)) { reject(new Error('请先配置 API_BASE_URL')); return; @@ -222,7 +288,7 @@ function requestMiniProgramLogin(code) { 'x-client-platform': resolveClientPlatform(), 'x-client-instance-id': getClientInstanceId(), 'x-mini-program-app-id': MINI_PROGRAM_APP_ID, - 'x-mini-program-env': MINI_PROGRAM_ENV, + 'x-mini-program-env': runtimeConfig.miniProgramEnv, }, success(response) { if (response.statusCode >= 200 && response.statusCode < 300) { @@ -246,7 +312,8 @@ function requestMiniProgramLogin(code) { function requestMiniProgramBindPhone(authToken, wechatPhoneCode) { return new Promise((resolve, reject) => { - const apiBaseUrl = trimTrailingSlash(API_BASE_URL); + const runtimeConfig = resolveMiniProgramRuntimeConfig(); + const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl); if (!isConfiguredApiBaseUrl(apiBaseUrl)) { reject(new Error('请先配置 API_BASE_URL')); return; @@ -264,7 +331,7 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode) { 'x-client-platform': resolveClientPlatform(), 'x-client-instance-id': getClientInstanceId(), 'x-mini-program-app-id': MINI_PROGRAM_APP_ID, - 'x-mini-program-env': MINI_PROGRAM_ENV, + 'x-mini-program-env': runtimeConfig.miniProgramEnv, }, success(response) { if (response.statusCode >= 200 && response.statusCode < 300) { @@ -312,8 +379,9 @@ Page({ async onLoad(query = {}) { this._lastLaunchQuery = query; showWebViewShareMenu(); + const runtimeConfig = resolveMiniProgramRuntimeConfig(); // 中文注释:web-view 只能打开已配置业务域名;未配置时展示本地提示,避免空白页误判。 - if (!isConfiguredEntryUrl(WEB_VIEW_ENTRY_URL)) { + if (!isConfiguredEntryUrl(runtimeConfig.webViewEntryUrl)) { this.setData({ errorMessage: '请先在 miniprogram/config.js 填写 WEB_VIEW_ENTRY_URL。', loading: false, @@ -336,7 +404,7 @@ Page({ return; } - if (!isConfiguredApiBaseUrl(API_BASE_URL)) { + if (!isConfiguredApiBaseUrl(runtimeConfig.apiBaseUrl)) { this.setData({ errorMessage: '请先在 miniprogram/config.js 填写 API_BASE_URL。', loading: false, diff --git a/scripts/miniprogram-web-view-auth.test.ts b/scripts/miniprogram-web-view-auth.test.ts index fdac9e72..ccf24a85 100644 --- a/scripts/miniprogram-web-view-auth.test.ts +++ b/scripts/miniprogram-web-view-auth.test.ts @@ -25,6 +25,9 @@ type MiniProgramPage = { function createWxMock() { return { + getAccountInfoSync: vi.fn(() => ({ + miniProgram: { envVersion: 'release' }, + })), getStorageSync: vi.fn(() => ''), getSystemInfoSync: vi.fn(() => ({ platform: 'ios' })), login: vi.fn(), @@ -57,6 +60,8 @@ function loadWebViewPage( if (requestPath === '../../config') { return { API_BASE_URL: 'https://www.genarrative.world/', + DEV_API_BASE_URL: 'https://dev.genarrative.world/', + DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world/', MINI_PROGRAM_APP_ID: 'wx-test-app', MINI_PROGRAM_ENV: 'release', WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world/', @@ -182,6 +187,51 @@ describe('mini-program web-view auth page', () => { ); }); + test('体验版自动切到 dev 子域名并透传 trial 环境', async () => { + const wxMock = createWxMock(); + wxMock.getAccountInfoSync.mockReturnValue({ + miniProgram: { envVersion: 'trial' }, + }); + const page = loadWebViewPage(wxMock); + + await page.onLoad({}); + + expect(page.data.webViewUrl).toBe( + 'https://dev.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program&miniProgramEnv=trial', + ); + }); + + test('开发版自动切到 dev 子域名并把 develop 规整为 dev', async () => { + const wxMock = createWxMock(); + wxMock.getAccountInfoSync.mockReturnValue({ + miniProgram: { envVersion: 'develop' }, + }); + wxMock.login.mockImplementation(({ success }) => { + success({ code: 'wx-login-code' }); + }); + wxMock.request.mockImplementation(({ success }) => { + success({ + statusCode: 200, + data: { + token: 'jwt-pending-wechat', + bindingStatus: 'pending_bind_phone', + }, + }); + }); + const page = loadWebViewPage(wxMock); + + await page.onLoad({ authAction: 'login', returnTo: 'previous' }); + + expect(wxMock.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://dev.genarrative.world/api/auth/wechat/miniprogram-login', + header: expect.objectContaining({ + 'x-mini-program-env': 'dev', + }), + }), + ); + }); + test('onShow 二次检查支付结果并写回 web-view hash', () => { const wxMock = createWxMock(); wxMock.getStorageSync.mockImplementation((key) =>