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!;