Merge pull request 'codex/wechat-mini-program-auth-flow' (#39) from codex/wechat-mini-program-auth-flow into master

Reviewed-on: #39
This commit was merged in pull request #39.
This commit is contained in:
2026-05-26 20:17:59 +08:00
10 changed files with 520 additions and 19 deletions

View File

@@ -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 落地
- 背景:第三关 `宝贝爱画` 需要默认出现在“发现 / 寓教于乐”板块下方,但本阶段只验证画板、手部绘制、绘画魔法和本地保存闭环,不进入创作模板、公开作品或正式持久化。

View File

@@ -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` 换取系统登录态。
## 账户与充值

View File

@@ -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) {

View File

@@ -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"

View 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);
});
});

View File

@@ -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();

View File

@@ -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]);

View File

@@ -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>

View File

@@ -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'],

View File

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