From 296a7fced9b9648813c7d8e17e20e8e2516abaed Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 26 May 2026 19:59:14 +0800 Subject: [PATCH] fix: defer mini program phone auth until login --- .hermes/shared-memory/decision-log.md | 7 + ...项目基线】当å‰äº§å“与工程约æŸ-2026-05-15.md | 2 + miniprogram/pages/web-view/index.js | 125 +++++++++++++-- miniprogram/pages/web-view/index.wxml | 4 + scripts/miniprogram-web-view-auth.test.ts | 149 ++++++++++++++++++ src/components/auth/AuthGate.test.tsx | 29 ++++ src/components/auth/AuthGate.tsx | 41 ++++- src/components/auth/LoginScreen.tsx | 9 +- src/services/authService.test.ts | 81 +++++++++- src/services/authService.ts | 92 ++++++++++- 10 files changed, 520 insertions(+), 19 deletions(-) create mode 100644 scripts/miniprogram-web-view-auth.test.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 840846b4..9855596e 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -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 è½åœ° - 背景:第三关 `å®è´çˆ±ç”»` 需è¦é»˜è®¤å‡ºçŽ°åœ¨â€œå‘现 / 寓教于ä¹â€æ¿å—下方,但本阶段åªéªŒè¯ç”»æ¿ã€æ‰‹éƒ¨ç»˜åˆ¶ã€ç»˜ç”»é­”法和本地ä¿å­˜é—­çŽ¯ï¼Œä¸è¿›å…¥åˆ›ä½œæ¨¡æ¿ã€å…¬å¼€ä½œå“æˆ–æ­£å¼æŒä¹…化。 diff --git a/docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md b/docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md index 0007216b..76e7f907 100644 --- a/docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md +++ b/docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md @@ -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` æ¢å–系统登录æ€ã€‚ ## 账户与充值 diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index 534128d3..c199300b 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -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) { diff --git a/miniprogram/pages/web-view/index.wxml b/miniprogram/pages/web-view/index.wxml index b9469f31..b8985678 100644 --- a/miniprogram/pages/web-view/index.wxml +++ b/miniprogram/pages/web-view/index.wxml @@ -1,5 +1,6 @@ {{errorMessage}} + + 登录完æˆåŽå°†è‡ªåŠ¨è¿”å›žã€‚ + - {wechatLoginEnabled ? ( + {wechatLoginEnabled && !miniProgramRuntime ? ( 当å‰ç™»å½•入壿š‚ä¸å¯ç”¨ã€‚ diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index d95502c7..8a925fac 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -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(); + 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'], diff --git a/src/services/authService.ts b/src/services/authService.ts index e7002375..0232d51c 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -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>((resolve, reject) => { + const existingScript = document.querySelector( + `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((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!;