fix: defer mini program phone auth until login
This commit is contained in:
@@ -532,6 +532,13 @@
|
||||
- 验证方式:执行 `npm run check:encoding`、`node scripts/check-wechat-miniprogram-auth-smoke.mjs`、`cargo test -p shared-contracts wechat_bind_phone_request_accepts_mini_program_phone_code --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wechat_miniprogram_bind_phone_code_activates_pending_user --manifest-path server-rs/Cargo.toml -- --nocapture`。
|
||||
- 关联文档:`docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md`。
|
||||
|
||||
## 2026-05-26 微信小程序进入即开 H5,登录按需走原生手机号授权
|
||||
|
||||
- 背景:当前产品要求微信小程序进入后不再立刻取手机号,而是默认直接进入 `web-view`,登录状态与 Web 端统一;只有 H5 触发受保护操作时才走微信手机号授权。
|
||||
- 决策:小程序壳首次进入只打开 H5,不再把登录态当作启动前置条件;H5 侧在小程序运行态触发登录时,不展示普通登录弹窗,而是跳转到小程序原生手机号授权流程,授权结果再回灌到 H5。未触发登录时保持游客态,与 Web 端一致。
|
||||
- 影响范围:`miniprogram/pages/web-view/index.*`、`src/components/auth/AuthGate.tsx`、`src/components/auth/LoginScreen.tsx`、`src/services/authService.ts`、相关测试与说明文档。
|
||||
- 验证方式:执行 `npm run check:encoding`、`npm run typecheck`、`npx vitest run src/components/auth/AuthGate.test.tsx src/services/authService.test.ts scripts/miniprogram-web-view-auth.test.ts`。
|
||||
|
||||
## 2026-05-13 宝贝爱画先作为寓教于乐独立本地 Demo 落地
|
||||
|
||||
- 背景:第三关 `宝贝爱画` 需要默认出现在“发现 / 寓教于乐”板块下方,但本阶段只验证画板、手部绘制、绘画魔法和本地保存闭环,不进入创作模板、公开作品或正式持久化。
|
||||
|
||||
@@ -44,6 +44,8 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
|
||||
1. 主站登录弹窗必须稳定展示 `短信登录` 与 `密码登录` 两个核心入口;`GET /api/auth/login-options` 只能补充微信等环境相关入口,不能决定是否隐藏短信或密码登录。
|
||||
2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。
|
||||
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` 换取系统登录态。
|
||||
|
||||
## 账户与充值
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/* global Page, wx */
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const {
|
||||
API_BASE_URL,
|
||||
MINI_PROGRAM_APP_ID,
|
||||
@@ -10,6 +13,8 @@ const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
|
||||
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
|
||||
const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id';
|
||||
const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result';
|
||||
const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result';
|
||||
const AUTH_ACTION_LOGIN = 'login';
|
||||
|
||||
function isConfiguredEntryUrl(value) {
|
||||
const trimmed = String(value || '').trim();
|
||||
@@ -57,6 +62,18 @@ function appendHashParams(url, params) {
|
||||
return `${baseUrl}#${rawHash}${separator}${pairs.join('&')}`;
|
||||
}
|
||||
|
||||
function parseBooleanQueryFlag(value) {
|
||||
return value === true || value === '1' || value === 'true' || value === 'yes';
|
||||
}
|
||||
|
||||
function shouldStartAuthFromQuery(query) {
|
||||
return String((query && query.authAction) || '').trim() === AUTH_ACTION_LOGIN;
|
||||
}
|
||||
|
||||
function shouldReturnToPreviousPage(query) {
|
||||
return String((query && query.returnTo) || '').trim() === 'previous';
|
||||
}
|
||||
|
||||
function resolveWebViewUrl(authResult) {
|
||||
const entryUrl = String(WEB_VIEW_ENTRY_URL || '').trim();
|
||||
if (!isConfiguredEntryUrl(entryUrl)) {
|
||||
@@ -75,6 +92,38 @@ function resolveWebViewUrl(authResult) {
|
||||
});
|
||||
}
|
||||
|
||||
function persistAuthResult(authResult) {
|
||||
wx.setStorageSync(AUTH_RESULT_STORAGE_KEY, JSON.stringify(authResult));
|
||||
}
|
||||
|
||||
function consumeAuthResult() {
|
||||
const rawValue = wx.getStorageSync(AUTH_RESULT_STORAGE_KEY);
|
||||
if (!rawValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
wx.removeStorageSync(AUTH_RESULT_STORAGE_KEY);
|
||||
try {
|
||||
const parsed = JSON.parse(String(rawValue));
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = String(parsed.token || '').trim();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
bindingStatus: String(parsed.bindingStatus || 'pending_bind_phone'),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[web-view] parse auth result failed', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getClientInstanceId() {
|
||||
const stored = wx.getStorageSync(CLIENT_INSTANCE_STORAGE_KEY);
|
||||
if (stored) {
|
||||
@@ -217,10 +266,12 @@ Page({
|
||||
errorMessage: '',
|
||||
loading: true,
|
||||
phoneBindingRequired: false,
|
||||
returnToPreviousPage: false,
|
||||
webViewUrl: '',
|
||||
},
|
||||
|
||||
async onLoad() {
|
||||
async onLoad(query = {}) {
|
||||
this._lastLaunchQuery = query;
|
||||
// 中文注释:web-view 只能打开已配置业务域名;未配置时展示本地提示,避免空白页误判。
|
||||
if (!isConfiguredEntryUrl(WEB_VIEW_ENTRY_URL)) {
|
||||
this.setData({
|
||||
@@ -231,6 +282,20 @@ Page({
|
||||
return;
|
||||
}
|
||||
|
||||
const forcedPhoneBinding = parseBooleanQueryFlag(query.phoneBindingRequired);
|
||||
const returnToPreviousPage = shouldReturnToPreviousPage(query);
|
||||
if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) {
|
||||
this.setData({
|
||||
authResult: null,
|
||||
errorMessage: '',
|
||||
loading: false,
|
||||
phoneBindingRequired: false,
|
||||
returnToPreviousPage: false,
|
||||
webViewUrl: resolveWebViewUrl(null),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isConfiguredApiBaseUrl(API_BASE_URL)) {
|
||||
this.setData({
|
||||
errorMessage: '请先在 miniprogram/config.js 填写 API_BASE_URL。',
|
||||
@@ -240,6 +305,14 @@ Page({
|
||||
return;
|
||||
}
|
||||
|
||||
this.setData({
|
||||
loading: true,
|
||||
phoneBindingRequired: false,
|
||||
returnToPreviousPage,
|
||||
errorMessage: '',
|
||||
webViewUrl: '',
|
||||
});
|
||||
|
||||
try {
|
||||
const authResult = await resolveAuthResult();
|
||||
if (authResult.bindingStatus === 'pending_bind_phone') {
|
||||
@@ -248,44 +321,56 @@ Page({
|
||||
errorMessage: '',
|
||||
loading: false,
|
||||
phoneBindingRequired: true,
|
||||
returnToPreviousPage,
|
||||
webViewUrl: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (returnToPreviousPage) {
|
||||
persistAuthResult(authResult);
|
||||
wx.navigateBack();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setData({
|
||||
authResult,
|
||||
errorMessage: '',
|
||||
loading: false,
|
||||
phoneBindingRequired: false,
|
||||
returnToPreviousPage,
|
||||
webViewUrl: resolveWebViewUrl(authResult),
|
||||
});
|
||||
} catch (error) {
|
||||
this.setData({
|
||||
authResult: null,
|
||||
errorMessage:
|
||||
error && error.message
|
||||
? error.message
|
||||
: '微信登录失败,请稍后重试。',
|
||||
error && error.message ? error.message : '微信登录失败,请稍后重试。',
|
||||
loading: false,
|
||||
phoneBindingRequired: false,
|
||||
returnToPreviousPage,
|
||||
webViewUrl: '',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onShow() {
|
||||
const result = wx.getStorageSync(PAY_RESULT_STORAGE_KEY);
|
||||
if (!result || !this.data.webViewUrl) {
|
||||
return;
|
||||
const authResult = consumeAuthResult();
|
||||
if (authResult) {
|
||||
this.setData({
|
||||
webViewUrl: resolveWebViewUrl(authResult),
|
||||
});
|
||||
}
|
||||
|
||||
wx.removeStorageSync(PAY_RESULT_STORAGE_KEY);
|
||||
this.setData({
|
||||
webViewUrl: appendHashParams(this.data.webViewUrl, {
|
||||
wx_pay_result: result,
|
||||
}),
|
||||
});
|
||||
const result = wx.getStorageSync(PAY_RESULT_STORAGE_KEY);
|
||||
if (result && this.data.webViewUrl) {
|
||||
wx.removeStorageSync(PAY_RESULT_STORAGE_KEY);
|
||||
this.setData({
|
||||
webViewUrl: appendHashParams(this.data.webViewUrl, {
|
||||
wx_pay_result: result,
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async handleGetPhoneNumber(event) {
|
||||
@@ -318,6 +403,17 @@ Page({
|
||||
token: response.token,
|
||||
bindingStatus: 'active',
|
||||
};
|
||||
if (this.data.returnToPreviousPage) {
|
||||
persistAuthResult(nextAuthResult);
|
||||
this.setData({
|
||||
bindingPhone: false,
|
||||
errorMessage: '',
|
||||
loading: false,
|
||||
phoneBindingRequired: false,
|
||||
});
|
||||
wx.navigateBack();
|
||||
return;
|
||||
}
|
||||
this.setData({
|
||||
authResult: nextAuthResult,
|
||||
bindingPhone: false,
|
||||
@@ -344,9 +440,10 @@ Page({
|
||||
errorMessage: '',
|
||||
loading: true,
|
||||
phoneBindingRequired: false,
|
||||
returnToPreviousPage: false,
|
||||
webViewUrl: '',
|
||||
});
|
||||
this.onLoad();
|
||||
this.onLoad(this._lastLaunchQuery || { authAction: AUTH_ACTION_LOGIN });
|
||||
},
|
||||
|
||||
handleWebViewLoad(event) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<block wx:if="{{webViewUrl}}">
|
||||
<web-view
|
||||
id="genarrative-web-view"
|
||||
src="{{webViewUrl}}"
|
||||
bindload="handleWebViewLoad"
|
||||
binderror="handleWebViewError"
|
||||
@@ -19,6 +20,9 @@
|
||||
<view wx:if="{{errorMessage}}" class="setup-text setup-text--danger">
|
||||
{{errorMessage}}
|
||||
</view>
|
||||
<view wx:if="{{returnToPreviousPage}}" class="setup-text">
|
||||
登录完成后将自动返回。
|
||||
</view>
|
||||
<button
|
||||
class="retry-button"
|
||||
open-type="getPhoneNumber"
|
||||
|
||||
149
scripts/miniprogram-web-view-auth.test.ts
Normal file
149
scripts/miniprogram-web-view-auth.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const pageScriptPath = join(
|
||||
repoRoot,
|
||||
'miniprogram',
|
||||
'pages',
|
||||
'web-view',
|
||||
'index.js',
|
||||
);
|
||||
|
||||
type MiniProgramPage = {
|
||||
data: Record<string, unknown>;
|
||||
setData: (patch: Record<string, unknown>) => void;
|
||||
onLoad: (query?: Record<string, string>) => Promise<void>;
|
||||
};
|
||||
|
||||
function createWxMock() {
|
||||
return {
|
||||
getStorageSync: vi.fn(() => ''),
|
||||
getSystemInfoSync: vi.fn(() => ({ platform: 'ios' })),
|
||||
login: vi.fn(),
|
||||
navigateBack: vi.fn(),
|
||||
removeStorageSync: vi.fn(),
|
||||
request: vi.fn(),
|
||||
setStorageSync: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function loadWebViewPage(
|
||||
wxMock: ReturnType<typeof createWxMock>,
|
||||
configOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
let pageConfig: Record<string, unknown> | null = null;
|
||||
const source = readFileSync(pageScriptPath, 'utf8');
|
||||
const sandbox = {
|
||||
console,
|
||||
getCurrentPages: () => [],
|
||||
module: { exports: {} },
|
||||
Page(config: Record<string, unknown>) {
|
||||
pageConfig = config;
|
||||
},
|
||||
require(requestPath: string) {
|
||||
if (requestPath === '../../config') {
|
||||
return {
|
||||
API_BASE_URL: 'https://www.genarrative.world/',
|
||||
MINI_PROGRAM_APP_ID: 'wx-test-app',
|
||||
MINI_PROGRAM_ENV: 'release',
|
||||
WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world/',
|
||||
WEB_VIEW_SOURCE_QUERY: {
|
||||
clientType: 'mini_program',
|
||||
clientRuntime: 'wechat_mini_program',
|
||||
},
|
||||
...configOverrides,
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected require: ${requestPath}`);
|
||||
},
|
||||
wx: wxMock,
|
||||
};
|
||||
|
||||
vm.runInNewContext(source, sandbox, { filename: pageScriptPath });
|
||||
|
||||
if (!pageConfig) {
|
||||
throw new Error('web-view page did not call Page()');
|
||||
}
|
||||
|
||||
const page = {
|
||||
...pageConfig,
|
||||
data: { ...(pageConfig.data as Record<string, unknown>) },
|
||||
setData(patch: Record<string, unknown>) {
|
||||
Object.assign(this.data, patch);
|
||||
},
|
||||
} as MiniProgramPage;
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
describe('mini-program web-view auth page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('默认进入时直接打开 web-view,不触发微信登录请求', async () => {
|
||||
const wxMock = createWxMock();
|
||||
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 不依赖 API_BASE_URL 配置', async () => {
|
||||
const wxMock = createWxMock();
|
||||
const page = loadWebViewPage(wxMock, {
|
||||
API_BASE_URL: '',
|
||||
});
|
||||
|
||||
await page.onLoad({});
|
||||
|
||||
expect(wxMock.login).not.toHaveBeenCalled();
|
||||
expect(wxMock.request).not.toHaveBeenCalled();
|
||||
expect(page.data.errorMessage).toBe('');
|
||||
expect(page.data.webViewUrl).toBe(
|
||||
'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program',
|
||||
);
|
||||
});
|
||||
|
||||
test('H5 请求登录时才启动微信小程序登录并进入手机号授权态', 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({ authAction: 'login', returnTo: 'previous' });
|
||||
|
||||
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(page.data.webViewUrl).toBe('');
|
||||
expect(page.data.loading).toBe(false);
|
||||
expect(page.data.phoneBindingRequired).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,8 @@ const authMocks = vi.hoisted(() => ({
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthRiskBlocks: vi.fn(),
|
||||
getAuthSessions: vi.fn(),
|
||||
isWechatMiniProgramWebViewRuntime: vi.fn(() => false),
|
||||
requestWechatMiniProgramPhoneLogin: vi.fn(),
|
||||
revokeAuthSessions: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
@@ -52,10 +54,12 @@ vi.mock('../../services/authService', () => ({
|
||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||
getAuthSessions: authMocks.getAuthSessions,
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
isWechatMiniProgramWebViewRuntime: authMocks.isWechatMiniProgramWebViewRuntime,
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
|
||||
logoutAuthUser: authMocks.logoutAuthUser,
|
||||
requestWechatMiniProgramPhoneLogin: authMocks.requestWechatMiniProgramPhoneLogin,
|
||||
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
|
||||
resetPassword: authMocks.resetPassword,
|
||||
revokeAuthSessions: authMocks.revokeAuthSessions,
|
||||
@@ -152,6 +156,8 @@ beforeEach(() => {
|
||||
expiresInSeconds: 300,
|
||||
});
|
||||
authMocks.startWechatLogin.mockResolvedValue(undefined);
|
||||
authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(false);
|
||||
authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
async function acceptLegalConsent(
|
||||
@@ -412,6 +418,29 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
});
|
||||
|
||||
test('auth gate uses mini program auth bridge instead of opening login modal in mini program runtime', async () => {
|
||||
const user = userEvent.setup();
|
||||
authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(true);
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'wechat'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(authMocks.startWechatLogin).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
expect(authMocks.isWechatMiniProgramWebViewRuntime).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('login modal requires first-time legal consent before sms login', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -32,11 +32,13 @@ import {
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
getCurrentAuthUser,
|
||||
isWechatMiniProgramWebViewRuntime,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
logoutAuthUser,
|
||||
redeemRegistrationInviteCode,
|
||||
requestWechatMiniProgramPhoneLogin,
|
||||
resetPassword,
|
||||
revokeAuthSessions,
|
||||
sendPhoneLoginCode,
|
||||
@@ -276,6 +278,22 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setInitialSettingsSection(null);
|
||||
}, []);
|
||||
|
||||
const requestMiniProgramLogin = useCallback(() => {
|
||||
setWechatLoading(true);
|
||||
setError('');
|
||||
void requestWechatMiniProgramPhoneLogin()
|
||||
.catch((miniProgramError) => {
|
||||
setError(
|
||||
miniProgramError instanceof Error
|
||||
? miniProgramError.message
|
||||
: '请在微信小程序内完成登录。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setWechatLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openLoginModal = useCallback(
|
||||
(postLoginAction?: (() => void) | null) => {
|
||||
if (readyUser) {
|
||||
@@ -284,9 +302,15 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
pendingProtectedActionRef.current = postLoginAction ?? null;
|
||||
if (isWechatMiniProgramWebViewRuntime()) {
|
||||
setShowLoginModal(false);
|
||||
requestMiniProgramLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
setShowLoginModal(true);
|
||||
},
|
||||
[readyUser],
|
||||
[readyUser, requestMiniProgramLogin],
|
||||
);
|
||||
|
||||
const requireAuth = useCallback(
|
||||
@@ -425,11 +449,26 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
void hydrate(++authHydrateVersionRef.current);
|
||||
};
|
||||
|
||||
const handleAuthHashChange = () => {
|
||||
const callbackResult = consumeAuthCallbackResult();
|
||||
if (!callbackResult) {
|
||||
return;
|
||||
}
|
||||
if (callbackResult.error) {
|
||||
setError(callbackResult.error);
|
||||
return;
|
||||
}
|
||||
setStatus('checking');
|
||||
void hydrate(++authHydrateVersionRef.current);
|
||||
};
|
||||
|
||||
window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
window.addEventListener('hashchange', handleAuthHashChange);
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
window.removeEventListener('hashchange', handleAuthHashChange);
|
||||
};
|
||||
}, [restoreAuthSession]);
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import type {
|
||||
AuthLoginMethod,
|
||||
} from '../../services/authService';
|
||||
import { getStoredLastLoginPhone } from '../../services/authService';
|
||||
import {
|
||||
isWechatMiniProgramWebViewRuntime,
|
||||
} from '../../services/authService';
|
||||
import { LegalDocumentModal } from '../common/LegalDocumentModal';
|
||||
import {
|
||||
getLegalDocument,
|
||||
@@ -83,6 +86,7 @@ export function LoginScreen({
|
||||
const passwordLoginEnabled = true;
|
||||
const phoneLoginEnabled = true;
|
||||
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
|
||||
const miniProgramRuntime = isWechatMiniProgramWebViewRuntime();
|
||||
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -317,7 +321,7 @@ export function LoginScreen({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{wechatLoginEnabled ? (
|
||||
{wechatLoginEnabled && !miniProgramRuntime ? (
|
||||
<WechatButton
|
||||
loading={wechatLoading}
|
||||
disabled={submitDisabled}
|
||||
@@ -364,7 +368,8 @@ export function LoginScreen({
|
||||
|
||||
{!passwordLoginEnabled &&
|
||||
!phoneLoginEnabled &&
|
||||
!wechatLoginEnabled ? (
|
||||
!wechatLoginEnabled &&
|
||||
!miniProgramRuntime ? (
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
当前登录入口暂不可用。
|
||||
</div>
|
||||
|
||||
@@ -20,8 +20,8 @@ import { clearStoredAccessToken, getStoredAccessToken } from './apiClient';
|
||||
import {
|
||||
authEntry,
|
||||
bindWechatPhone,
|
||||
changePhoneNumber,
|
||||
changePassword,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
getAuthAuditLogs,
|
||||
getAuthLoginOptions,
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
redeemRegistrationInviteCode,
|
||||
requestWechatMiniProgramPhoneLogin,
|
||||
revokeAuthSession,
|
||||
revokeAuthSessions,
|
||||
sendPhoneLoginCode,
|
||||
@@ -408,6 +409,84 @@ describe('authService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('requests mini program phone login by opening the native auth page', async () => {
|
||||
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
|
||||
options.success?.();
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
createWindowMock({
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '?clientRuntime=wechat_mini_program',
|
||||
assign: vi.fn(),
|
||||
},
|
||||
wx: {
|
||||
miniProgram: {
|
||||
navigateTo,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await requestWechatMiniProgramPhoneLogin();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(navigateTo).toHaveBeenCalledWith({
|
||||
url: '/pages/web-view/index?authAction=login&returnTo=previous',
|
||||
success: expect.any(Function),
|
||||
fail: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
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?.();
|
||||
});
|
||||
const scriptListeners = new Map<string, EventListener>();
|
||||
const existingScript = {
|
||||
addEventListener: vi.fn(
|
||||
(type: string, listener: EventListener) => {
|
||||
scriptListeners.set(type, listener);
|
||||
},
|
||||
),
|
||||
};
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
createWindowMock({
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '?clientRuntime=wechat_mini_program',
|
||||
assign: vi.fn(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('document', {
|
||||
querySelector: vi.fn(() => existingScript),
|
||||
head: {
|
||||
appendChild: vi.fn(),
|
||||
},
|
||||
createElement: vi.fn(),
|
||||
});
|
||||
|
||||
const request = requestWechatMiniProgramPhoneLogin();
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
navigateTo,
|
||||
},
|
||||
};
|
||||
scriptListeners.get('load')?.(new Event('load'));
|
||||
|
||||
await expect(request).resolves.toBe(true);
|
||||
expect(navigateTo).toHaveBeenCalledWith({
|
||||
url: '/pages/web-view/index?authAction=login&returnTo=previous',
|
||||
success: expect.any(Function),
|
||||
fail: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('loads available login methods for the unauthenticated login screen', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'wechat'],
|
||||
|
||||
@@ -20,8 +20,8 @@ import type {
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionsResponse,
|
||||
AuthSessionSummary,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatBindPhoneRequest,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatStartResponse,
|
||||
LogoutResponse,
|
||||
PublicUserSearchResponse,
|
||||
@@ -55,6 +55,10 @@ export type ConsumedAuthCallback = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
const MINI_PROGRAM_AUTH_PAGE_URL =
|
||||
'/pages/web-view/index?authAction=login&returnTo=previous';
|
||||
|
||||
// 登录前公开认证入口不能误带旧 token,也不能先触发 refresh 探测,
|
||||
// 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。
|
||||
const PUBLIC_AUTH_REQUEST_OPTIONS = {
|
||||
@@ -80,6 +84,92 @@ export function clearRuntimeGuestTokenCache() {
|
||||
runtimeGuestTokenCache.value = null;
|
||||
}
|
||||
|
||||
export function isWechatMiniProgramWebViewRuntime() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
return (
|
||||
params.get('clientRuntime') === 'wechat_mini_program' ||
|
||||
params.get('clientType') === 'mini_program' ||
|
||||
Boolean(window.wx?.miniProgram?.postMessage)
|
||||
);
|
||||
}
|
||||
|
||||
function loadWechatMiniProgramBridge() {
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.reject(new Error('请在微信小程序内完成登录'));
|
||||
}
|
||||
|
||||
if (window.wx?.miniProgram?.navigateTo) {
|
||||
return Promise.resolve(window.wx);
|
||||
}
|
||||
|
||||
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`script[src="${WECHAT_JS_SDK_URL}"]`,
|
||||
);
|
||||
const complete = () => {
|
||||
if (window.wx?.miniProgram?.navigateTo) {
|
||||
resolve(window.wx);
|
||||
} else {
|
||||
reject(new Error('请在微信小程序内完成登录'));
|
||||
}
|
||||
};
|
||||
|
||||
if (existingScript) {
|
||||
if (window.wx?.miniProgram?.navigateTo) {
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
|
||||
existingScript.addEventListener('load', complete, { once: true });
|
||||
existingScript.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error('请在微信小程序内完成登录')),
|
||||
{ once: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = WECHAT_JS_SDK_URL;
|
||||
script.async = true;
|
||||
script.onload = complete;
|
||||
script.onerror = () => reject(new Error('请在微信小程序内完成登录'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestWechatMiniProgramPhoneLogin() {
|
||||
if (!isWechatMiniProgramWebViewRuntime()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const wxBridge = await loadWechatMiniProgramBridge();
|
||||
const miniProgram = wxBridge.miniProgram;
|
||||
const navigateTo = miniProgram?.navigateTo;
|
||||
if (typeof navigateTo !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
navigateTo({
|
||||
url: MINI_PROGRAM_AUTH_PAGE_URL,
|
||||
success() {
|
||||
resolve();
|
||||
},
|
||||
fail(error) {
|
||||
reject(
|
||||
new Error(error?.errMsg || '请在微信小程序内完成登录'),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function ensureRuntimeGuestToken() {
|
||||
if (isRuntimeGuestTokenFresh(runtimeGuestTokenCache.value)) {
|
||||
return runtimeGuestTokenCache.value!;
|
||||
|
||||
Reference in New Issue
Block a user