fix: 保留小程序登录上下文

This commit is contained in:
2026-06-05 20:58:40 +08:00
parent 5a6d69bebe
commit 6a03575d68
7 changed files with 112 additions and 17 deletions

View File

@@ -15,6 +15,14 @@
- 关联:相关文件、文档、提交或 Issue - 关联:相关文件、文档、提交或 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`
## 平台异步错误必须带来源弹窗,不要只显示裸错误 ## 平台异步错误必须带来源弹窗,不要只显示裸错误
- 现象:用户先后触发多个拼图或草稿生成时,旧请求失败后会在当前页面显示“图片生成失败”等裸错误,容易误判为当前正在看的拼图失败;错误文本也不便复制给开发排查。 - 现象:用户先后触发多个拼图或草稿生成时,旧请求失败后会在当前页面显示“图片生成失败”等裸错误,容易误判为当前正在看的拼图失败;错误文本也不便复制给开发排查。

View File

@@ -9,7 +9,7 @@
- H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。 - H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。
- `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。 - `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。
- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端虚拟支付消息推送写入订单为准,普通微信支付订单则继续走微信支付 V3 notify / query。虚拟支付订单的确认接口只读取本地订单真相不再用普通微信支付 V3 查单。 - 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端虚拟支付消息推送写入订单为准,普通微信支付订单则继续走微信支付 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`,会员使用 `short_series_goods`
- `short_series_coin` 只用于代币购买,后端从本次下单返回的充值中心商品快照读取 `points_amount` 并写入 `buyQuantity`;不要把 coin 商品当成道具,也不要把 `buyQuantity` 固定为 1。 - `short_series_coin` 只用于代币购买,后端从本次下单返回的充值中心商品快照读取 `points_amount` 并写入 `buyQuantity`;不要把 coin 商品当成道具,也不要把 `buyQuantity` 固定为 1。
- 后台新增的会员类充值商品会直接把商品 `productId` 作为微信 `short_series_goods` 的道具 ID例如微信后台道具 ID 为 `item01` 时,后台会员商品 `productId` 也应配置为 `item01`,且商品价格需要与微信后台道具价格一致。 - 后台新增的会员类充值商品会直接把商品 `productId` 作为微信 `short_series_goods` 的道具 ID例如微信后台道具 ID 为 `item01` 时,后台会员商品 `productId` 也应配置为 `item01`,且商品价格需要与微信后台道具价格一致。

View File

@@ -1,6 +1,6 @@
# 当前产品与工程约束 # 当前产品与工程约束
更新时间:`2026-05-15` 更新时间:`2026-06-05`
## 项目定位 ## 项目定位
@@ -46,8 +46,9 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。 3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。
4. 微信小程序 `web-view` 外壳默认不预登录,首次进入直接打开 H5并保持与 Web 端一致的未登录状态;只有 H5 触发 `openLoginModal` / `requireAuth` 等受保护入口时,才跳转小程序原生授权态。 4. 微信小程序 `web-view` 外壳默认不预登录,首次进入直接打开 H5并保持与 Web 端一致的未登录状态;只有 H5 触发 `openLoginModal` / `requireAuth` 等受保护入口时,才跳转小程序原生授权态。
5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权,再调用 `/api/auth/wechat/miniprogram-login``/api/auth/wechat/bind-phone` 换取系统登录态。 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 作为不受控启动参数传回小程序页 6. 小程序外壳注入到 H5 URL 的 `clientType``clientRuntime``miniProgramEnv` 是宿主上下文H5 内部 `pushState` / 阶段导航必须跨页面保留,避免登录和充值误判为普通浏览器;首点时微信 JS bridge 可能尚未就绪,前端还需用 `MicroMessenger + miniProgram` User-Agent 作为小程序识别兜底
7. 小程序 `web-view` 外壳运行时通过 `wx.getAccountInfoSync().miniProgram.envVersion` 自动识别版本:线上版 `release` 使用 `www.genarrative.world`,体验版 `trial` 与开发版 `develop` 使用 `dev.genarrative.world`;传给后端的 `x-mini-program-env` 分别为 `release``trial``dev` 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`
## 账户与充值 ## 账户与充值

View File

@@ -132,7 +132,37 @@ describe('appPageRoutes', () => {
expect(window.location.pathname).toBe('/creation/rpg/result'); expect(window.location.pathname).toBe('/creation/rpg/result');
expect(window.location.search).toBe( 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',
); );
}); });

View File

@@ -72,6 +72,12 @@ const ROUTE_STAGE_BY_PATH = new Map(
STAGE_ROUTE_ENTRIES.map(([stage, path]) => [path, stage] as const), STAGE_ROUTE_ENTRIES.map(([stage, path]) => [path, stage] as const),
) as Map<string, SelectionStage>; ) as Map<string, SelectionStage>;
const APP_RUNTIME_CONTEXT_QUERY_KEYS = [
'clientType',
'clientRuntime',
'miniProgramEnv',
] as const;
export function normalizeAppPath(pathname: string) { export function normalizeAppPath(pathname: string) {
const trimmedPathname = pathname.trim().toLowerCase(); const trimmedPathname = pathname.trim().toLowerCase();
@@ -135,13 +141,12 @@ export function isKnownMainAppPagePath(pathname: string) {
export function pushAppHistoryPath(path: string) { export function pushAppHistoryPath(path: string) {
const nextUrl = new URL(path, window.location.origin); const nextUrl = new URL(path, window.location.origin);
const normalizedPath = normalizeAppPath(nextUrl.pathname); const normalizedPath = normalizeAppPath(nextUrl.pathname);
const nextSearch = const nextSearch = buildPreservedAppSearch(
nextUrl.search || nextUrl.search,
buildPreservedAppSearch( window.location.pathname,
window.location.pathname, normalizedPath,
normalizedPath, window.location.search,
window.location.search, );
);
const nextRelativeUrl = `${normalizedPath}${nextSearch}`; const nextRelativeUrl = `${normalizedPath}${nextSearch}`;
const currentRelativeUrl = `${normalizeAppPath(window.location.pathname)}${window.location.search}`; const currentRelativeUrl = `${normalizeAppPath(window.location.pathname)}${window.location.search}`;
if (currentRelativeUrl === nextRelativeUrl) { if (currentRelativeUrl === nextRelativeUrl) {
@@ -153,16 +158,39 @@ export function pushAppHistoryPath(path: string) {
} }
function buildPreservedAppSearch( function buildPreservedAppSearch(
explicitNextSearch: string,
currentPathname: string, currentPathname: string,
normalizedPath: string, normalizedPath: string,
search: string, search: string,
) { ) {
const preservedParams = new URLSearchParams(explicitNextSearch);
if ( if (
!isCreationRestorePath(normalizedPath) || !explicitNextSearch &&
!isSameCreationFlowPath(currentPathname, normalizedPath) 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}` : '';
} }

View File

@@ -31,6 +31,7 @@ import {
getCurrentAuthUser, getCurrentAuthUser,
getPublicAuthUserById, getPublicAuthUserById,
liftAuthRiskBlock, liftAuthRiskBlock,
isWechatMiniProgramWebViewRuntime,
loginWithPhoneCode, loginWithPhoneCode,
logoutAllAuthSessions, logoutAllAuthSessions,
redeemRegistrationInviteCode, redeemRegistrationInviteCode,
@@ -80,6 +81,7 @@ function createWindowMock(overrides: Record<string, unknown> = {}) {
describe('authService', () => { describe('authService', () => {
beforeEach(() => { beforeEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks(); vi.clearAllMocks();
vi.stubGlobal('window', createWindowMock()); vi.stubGlobal('window', createWindowMock());
clearStoredAccessToken({ emit: false }); 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 () => { 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 }) => { const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.(); options.success?.();

View File

@@ -90,9 +90,15 @@ export function isWechatMiniProgramWebViewRuntime() {
} }
const params = new URLSearchParams(window.location.search || ''); const params = new URLSearchParams(window.location.search || '');
const userAgent =
typeof navigator === 'undefined' ? '' : navigator.userAgent || '';
const normalizedUserAgent = userAgent.toLowerCase();
return ( return (
params.get('clientRuntime') === 'wechat_mini_program' || params.get('clientRuntime') === 'wechat_mini_program' ||
params.get('clientType') === 'mini_program' || params.get('clientType') === 'mini_program' ||
(normalizedUserAgent.includes('micromessenger') &&
normalizedUserAgent.includes('miniprogram')) ||
Boolean(window.wx?.miniProgram?.postMessage) Boolean(window.wx?.miniProgram?.postMessage)
); );
} }