diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index ac4d1258..694eb853 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -15,6 +15,14 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 小程序 H5 导航不能清掉宿主 query + +- 现象:微信小程序首次进入 H5 后,点击需要登录的入口没有返回小程序原生授权页,而是弹出 Web 端登录窗口;充值渠道也可能被误判为普通网页环境。 +- 原因:小程序 `web-view` 入口通过 `clientType=mini_program`、`clientRuntime=wechat_mini_program`、`miniProgramEnv` 标记宿主环境,但 H5 内部 `pushAppHistoryPath(...)` 阶段导航会默认清空 query;首点时微信 JS bridge 也可能尚未就绪,导致 `isWechatMiniProgramWebViewRuntime()` 和充值平台判断读不到小程序上下文。 +- 处理:路由层统一把 `clientType`、`clientRuntime`、`miniProgramEnv` 当作 app runtime context,在普通路径归一、显式 query 路由和同一创作流跳转时都跨导航保留;小程序环境识别同时用 `MicroMessenger + miniProgram` User-Agent 兜底首点 bridge 未就绪场景;创作恢复参数仍只在同玩法创作流内保留,离开创作流时继续清理。 +- 验证:`npm exec vitest run src/routing/appPageRoutes.test.ts src/components/auth/AuthGate.test.tsx src/services/authService.test.ts src/services/payment/paymentPlatform.test.ts`。 +- 关联:`src/routing/appPageRoutes.ts`、`src/services/authService.ts`、`src/services/payment/paymentPlatform.ts`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 + ## 平台异步错误必须带来源弹窗,不要只显示裸错误 - 现象:用户先后触发多个拼图或草稿生成时,旧请求失败后会在当前页面显示“图片生成失败”等裸错误,容易误判为当前正在看的拼图失败;错误文本也不便复制给开发排查。 diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md index 27d98f26..296fd2ed 100644 --- a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md +++ b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md @@ -9,7 +9,7 @@ - H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。 - `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。 - 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端虚拟支付消息推送写入订单为准,普通微信支付订单则继续走微信支付 V3 notify / query。虚拟支付订单的确认接口只读取本地订单真相,不再用普通微信支付 V3 查单。 -- 小程序 WebView 默认进入时会静默调用 `wx.login` 刷新后端微信登录态,避免历史登录用户只有前端 JWT、后端缺少 `session_key` 时无法生成虚拟支付签名。 +- 小程序 WebView 普通进入不预登录;H5 触发受保护入口或支付前必须保留 `clientRuntime=wechat_mini_program` 等宿主上下文,并用 `MicroMessenger + miniProgram` User-Agent 兜底识别首点 bridge 未就绪场景,再跳转小程序原生授权态,确保后端拿到带 `session_key` 的微信登录态。 ## 关键文件 @@ -59,7 +59,7 @@ npm run check:encoding ## 注意事项 -- 旧微信登录快照可能没有 `session_key`;小程序 WebView 会在普通进入时静默刷新一次微信登录态,刷新失败时仍允许匿名打开 WebView,但虚拟支付会继续由后端拦截并提示重新登录。 +- 旧微信登录快照可能没有 `session_key`;普通进入小程序 WebView 仍允许匿名打开,虚拟支付会由后端拦截并提示用户在小程序内重新登录。H5 内部导航不得清理 `clientType`、`clientRuntime`、`miniProgramEnv`,且首点登录要用小程序 User-Agent 兜底识别,否则登录和支付会误判为普通网页环境。 - 小程序充值商品全部映射到虚拟支付;泥点使用 `short_series_coin`,会员使用 `short_series_goods`。 - `short_series_coin` 只用于代币购买,后端从本次下单返回的充值中心商品快照读取 `points_amount` 并写入 `buyQuantity`;不要把 coin 商品当成道具,也不要把 `buyQuantity` 固定为 1。 - 后台新增的会员类充值商品会直接把商品 `productId` 作为微信 `short_series_goods` 的道具 ID;例如微信后台道具 ID 为 `item01` 时,后台会员商品 `productId` 也应配置为 `item01`,且商品价格需要与微信后台道具价格一致。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 254e5128..49bdf7b3 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -1,6 +1,6 @@ # 当前产品与工程约束 -更新时间:`2026-05-15` +更新时间:`2026-06-05` ## 项目定位 @@ -46,8 +46,9 @@ 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 作为不受控启动参数传回小程序页。 -7. 小程序 `web-view` 外壳运行时通过 `wx.getAccountInfoSync().miniProgram.envVersion` 自动识别版本:线上版 `release` 使用 `www.genarrative.world`,体验版 `trial` 与开发版 `develop` 使用 `dev.genarrative.world`;传给后端的 `x-mini-program-env` 分别为 `release`、`trial`、`dev`。 +6. 小程序外壳注入到 H5 URL 的 `clientType`、`clientRuntime`、`miniProgramEnv` 是宿主上下文,H5 内部 `pushState` / 阶段导航必须跨页面保留,避免登录和充值误判为普通浏览器;首点时微信 JS bridge 可能尚未就绪,前端还需用 `MicroMessenger + miniProgram` User-Agent 作为小程序识别兜底。 +7. 小程序 `web-view` 页必须启用好友分享与朋友圈分享,分享目标固定回到 `pages/web-view/index`,不把 H5 当前 URL 作为不受控启动参数传回小程序页。 +8. 小程序 `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/src/routing/appPageRoutes.test.ts b/src/routing/appPageRoutes.test.ts index f80c8028..f9ecddd1 100644 --- a/src/routing/appPageRoutes.test.ts +++ b/src/routing/appPageRoutes.test.ts @@ -132,7 +132,37 @@ describe('appPageRoutes', () => { expect(window.location.pathname).toBe('/creation/rpg/result'); expect(window.location.search).toBe( - '?sessionId=session-1&profileId=profile-1&draftId=draft-1&workId=work-1', + '?sessionId=session-1&profileId=profile-1&draftId=draft-1&workId=work-1&clientRuntime=wechat_mini_program', + ); + }); + + it('preserves mini program runtime context while normalizing app paths', () => { + window.history.replaceState( + null, + '', + '/?clientType=mini_program&clientRuntime=wechat_mini_program&miniProgramEnv=trial', + ); + + pushAppHistoryPath('/'); + + expect(window.location.pathname).toBe('/'); + expect(window.location.search).toBe( + '?clientType=mini_program&clientRuntime=wechat_mini_program&miniProgramEnv=trial', + ); + }); + + it('keeps mini program runtime context when navigating to explicit query routes', () => { + window.history.replaceState( + null, + '', + '/?clientRuntime=wechat_mini_program', + ); + + pushAppHistoryPath('/works/detail?work=PZ-7A7B18D9'); + + expect(window.location.pathname).toBe('/works/detail'); + expect(window.location.search).toBe( + '?work=PZ-7A7B18D9&clientRuntime=wechat_mini_program', ); }); diff --git a/src/routing/appPageRoutes.ts b/src/routing/appPageRoutes.ts index 19750566..b7872473 100644 --- a/src/routing/appPageRoutes.ts +++ b/src/routing/appPageRoutes.ts @@ -72,6 +72,12 @@ const ROUTE_STAGE_BY_PATH = new Map( STAGE_ROUTE_ENTRIES.map(([stage, path]) => [path, stage] as const), ) as Map; +const APP_RUNTIME_CONTEXT_QUERY_KEYS = [ + 'clientType', + 'clientRuntime', + 'miniProgramEnv', +] as const; + export function normalizeAppPath(pathname: string) { const trimmedPathname = pathname.trim().toLowerCase(); @@ -135,13 +141,12 @@ export function isKnownMainAppPagePath(pathname: string) { export function pushAppHistoryPath(path: string) { const nextUrl = new URL(path, window.location.origin); const normalizedPath = normalizeAppPath(nextUrl.pathname); - const nextSearch = - nextUrl.search || - buildPreservedAppSearch( - window.location.pathname, - normalizedPath, - window.location.search, - ); + const nextSearch = buildPreservedAppSearch( + nextUrl.search, + window.location.pathname, + normalizedPath, + window.location.search, + ); const nextRelativeUrl = `${normalizedPath}${nextSearch}`; const currentRelativeUrl = `${normalizeAppPath(window.location.pathname)}${window.location.search}`; if (currentRelativeUrl === nextRelativeUrl) { @@ -153,16 +158,39 @@ export function pushAppHistoryPath(path: string) { } function buildPreservedAppSearch( + explicitNextSearch: string, currentPathname: string, normalizedPath: string, search: string, ) { + const preservedParams = new URLSearchParams(explicitNextSearch); + if ( - !isCreationRestorePath(normalizedPath) || - !isSameCreationFlowPath(currentPathname, normalizedPath) + !explicitNextSearch && + isCreationRestorePath(normalizedPath) && + isSameCreationFlowPath(currentPathname, normalizedPath) ) { - return ''; + const creationParams = new URLSearchParams( + buildCreationUrlSearchFromParams(search), + ); + creationParams.forEach((value, key) => { + preservedParams.set(key, value); + }); } - return buildCreationUrlSearchFromParams(search); + const currentParams = new URLSearchParams(search); + // 中文注释:小程序 WebView 依赖这些宿主上下文判断登录和充值通道,不能被前端阶段导航清掉。 + APP_RUNTIME_CONTEXT_QUERY_KEYS.forEach((key) => { + if (preservedParams.has(key)) { + return; + } + + const value = currentParams.get(key)?.trim(); + if (value) { + preservedParams.set(key, value); + } + }); + + const queryString = preservedParams.toString(); + return queryString ? `?${queryString}` : ''; } diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index 068a1aed..4ca371c0 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -31,6 +31,7 @@ import { getCurrentAuthUser, getPublicAuthUserById, liftAuthRiskBlock, + isWechatMiniProgramWebViewRuntime, loginWithPhoneCode, logoutAllAuthSessions, redeemRegistrationInviteCode, @@ -80,6 +81,7 @@ function createWindowMock(overrides: Record = {}) { describe('authService', () => { beforeEach(() => { + vi.unstubAllGlobals(); vi.clearAllMocks(); vi.stubGlobal('window', createWindowMock()); clearStoredAccessToken({ emit: false }); @@ -428,6 +430,26 @@ describe('authService', () => { }); }); + it('detects mini program user agent before the WeChat bridge is ready', () => { + vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 iPhone MicroMessenger/8.0.49 NetType/WIFI Language/zh_CN miniProgram', + }); + vi.stubGlobal( + 'window', + createWindowMock({ + location: { + pathname: '/', + hash: '', + search: '', + assign: vi.fn(), + }, + }), + ); + + expect(isWechatMiniProgramWebViewRuntime()).toBe(true); + }); + it('waits for an existing WeChat JS SDK script before opening the native auth page', async () => { const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { options.success?.(); diff --git a/src/services/authService.ts b/src/services/authService.ts index 0232d51c..a8c2b7ba 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -90,9 +90,15 @@ export function isWechatMiniProgramWebViewRuntime() { } const params = new URLSearchParams(window.location.search || ''); + const userAgent = + typeof navigator === 'undefined' ? '' : navigator.userAgent || ''; + const normalizedUserAgent = userAgent.toLowerCase(); + return ( params.get('clientRuntime') === 'wechat_mini_program' || params.get('clientType') === 'mini_program' || + (normalizedUserAgent.includes('micromessenger') && + normalizedUserAgent.includes('miniprogram')) || Boolean(window.wx?.miniProgram?.postMessage) ); }