fix: 保留小程序登录上下文
This commit is contained in:
@@ -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`。
|
||||||
|
|
||||||
## 平台异步错误必须带来源弹窗,不要只显示裸错误
|
## 平台异步错误必须带来源弹窗,不要只显示裸错误
|
||||||
|
|
||||||
- 现象:用户先后触发多个拼图或草稿生成时,旧请求失败后会在当前页面显示“图片生成失败”等裸错误,容易误判为当前正在看的拼图失败;错误文本也不便复制给开发排查。
|
- 现象:用户先后触发多个拼图或草稿生成时,旧请求失败后会在当前页面显示“图片生成失败”等裸错误,容易误判为当前正在看的拼图失败;错误文本也不便复制给开发排查。
|
||||||
|
|||||||
@@ -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`,且商品价格需要与微信后台道具价格一致。
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|
||||||
## 账户与充值
|
## 账户与充值
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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}` : '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?.();
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user