Files
Genarrative/miniprogram/shell/webView.js
T
kdletters 1cf9e6bcc6 锁定微信手机号授权取消提示
微信手机号授权取消不再向页面透出原生错误细节

微信认证测试覆盖手机号授权取消稳定提示

native shell 总门禁反查手机号授权取消边界
2026-06-20 13:36:49 +08:00

702 lines
20 KiB
JavaScript

/* global wx */
/* eslint-disable no-console */
const {
API_BASE_URL,
DEV_API_BASE_URL,
DEV_WEB_VIEW_ENTRY_URL,
MINI_PROGRAM_APP_ID,
MINI_PROGRAM_ENV,
WEB_VIEW_ENTRY_URL,
WEB_VIEW_SOURCE_QUERY,
} = require('../config');
const {
appendHashParams,
buildWebViewSharePath,
buildWebViewShareTimelineQuery,
resolveShareTargetFromWebViewMessage,
resolveWebViewUrlFromRuntimeConfig,
} = require('../host-bridge/webView');
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';
const PAY_RESULT_RECHECK_DELAY_MS = 120;
const WEB_VIEW_SHARE_TITLE = '陶泥儿';
const WECHAT_LOGIN_UNAVAILABLE_MESSAGE = '微信登录失败,请稍后重试。';
const WECHAT_BIND_PHONE_UNAVAILABLE_MESSAGE = '绑定手机号失败,请稍后重试。';
const WECHAT_BIND_PHONE_AUTH_REQUIRED_MESSAGE = '需要授权手机号后才能完成绑定。';
function showWebViewShareMenu() {
if (typeof wx.showShareMenu !== 'function') {
return;
}
wx.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline'],
});
}
function resolveNativeShareQuery(page) {
return (
(page && page._currentShareTarget) ||
(page && page._lastLaunchQuery) ||
{}
);
}
function buildWebViewShareAppMessage(query = {}) {
return {
title: WEB_VIEW_SHARE_TITLE,
path: buildWebViewSharePath(query),
};
}
function buildWebViewShareTimeline(query = {}) {
return {
title: WEB_VIEW_SHARE_TITLE,
query: buildWebViewShareTimelineQuery(query),
};
}
function isConfiguredEntryUrl(value) {
const trimmed = String(value || '').trim();
return /^https:\/\/[^/]+/i.test(trimmed);
}
function trimTrailingSlash(value) {
return String(value || '').trim().replace(/\/+$/u, '');
}
function readWebViewSourceQueryValue(key) {
return String((WEB_VIEW_SOURCE_QUERY && WEB_VIEW_SOURCE_QUERY[key]) || '').trim();
}
function isConfiguredApiBaseUrl(value) {
return /^https:\/\/[^/]+/i.test(String(value || '').trim());
}
function parseBooleanQueryFlag(value) {
return value === true || value === '1' || value === 'true' || value === 'yes';
}
function normalizeNicknameInput(value) {
return String(value || '').trim();
}
function normalizeNicknameForMatch(value) {
return normalizeNicknameInput(value).replace(/\s+/gu, '').toLowerCase();
}
function isPhoneLikeDisplayName(value) {
const normalized = normalizeNicknameForMatch(value);
if (!normalized) {
return false;
}
const digits = normalized.replace(/\D/gu, '');
return (
/^(\+?86)?1\d{10}$/u.test(normalized) ||
/^1\d{2}\*{4}\d{4}$/u.test(normalized) ||
(/[*x]/iu.test(normalized) && digits.length >= 7) ||
digits.length >= 11
);
}
function isDefaultDisplayName(value, publicUserCode) {
const normalized = normalizeNicknameForMatch(value);
const normalizedPublicUserCode = normalizeNicknameForMatch(publicUserCode);
if (!normalized) {
return true;
}
return (
normalized === '微信旅人' ||
normalized === '玩家' ||
normalized === normalizedPublicUserCode ||
/^sy-\d{8}$/iu.test(normalized) ||
/^user[_-]/iu.test(normalized) ||
isPhoneLikeDisplayName(normalized)
);
}
function shouldRequestNicknameAfterLogin(authResult) {
const user = authResult && authResult.user ? authResult.user : {};
const wechatDisplayName = normalizeNicknameInput(user.wechatDisplayName);
if (wechatDisplayName && !isDefaultDisplayName(wechatDisplayName, user.publicUserCode)) {
return false;
}
return (
authResult &&
(authResult.created ||
isDefaultDisplayName(user.displayName, user.publicUserCode) ||
(wechatDisplayName &&
isDefaultDisplayName(wechatDisplayName, user.publicUserCode)))
);
}
function normalizeMiniProgramEnv(value) {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'release') {
return 'release';
}
if (normalized === 'trial') {
return 'trial';
}
if (
normalized === 'develop' ||
normalized === 'development' ||
normalized === 'dev'
) {
return 'dev';
}
return '';
}
function readMiniProgramEnvVersion() {
if (typeof wx.getAccountInfoSync !== 'function') {
return '';
}
try {
const accountInfo = wx.getAccountInfoSync();
return (
accountInfo &&
accountInfo.miniProgram &&
accountInfo.miniProgram.envVersion
);
} catch (error) {
console.warn('[web-view] read mini program env failed', error);
return '';
}
}
function resolveMiniProgramRuntimeConfig() {
const miniProgramEnv =
normalizeMiniProgramEnv(readMiniProgramEnvVersion()) ||
normalizeMiniProgramEnv(MINI_PROGRAM_ENV) ||
'release';
const useReleaseChannel = miniProgramEnv === 'release';
const webViewEntryUrl = useReleaseChannel
? WEB_VIEW_ENTRY_URL
: DEV_WEB_VIEW_ENTRY_URL || WEB_VIEW_ENTRY_URL;
const apiBaseUrl = useReleaseChannel
? API_BASE_URL
: DEV_API_BASE_URL || API_BASE_URL;
const sourceQuery = {
...WEB_VIEW_SOURCE_QUERY,
};
if (!useReleaseChannel) {
sourceQuery.miniProgramEnv = miniProgramEnv;
}
return {
apiBaseUrl,
miniProgramEnv,
sourceQuery,
webViewEntryUrl,
};
}
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, launchQuery = {}) {
const runtimeConfig = resolveMiniProgramRuntimeConfig();
const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim();
if (!isConfiguredEntryUrl(entryUrl)) {
return '';
}
return resolveWebViewUrlFromRuntimeConfig(authResult, launchQuery, {
...runtimeConfig,
webViewEntryUrl: String(runtimeConfig.webViewEntryUrl || '').trim(),
});
}
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) {
return String(stored);
}
const nextId = `wxmp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
wx.setStorageSync(CLIENT_INSTANCE_STORAGE_KEY, nextId);
return nextId;
}
function resolveClientPlatform() {
const info = wx.getSystemInfoSync();
const platform = String(info.platform || '').toLowerCase();
if (platform === 'ios') {
return 'ios';
}
if (platform === 'android') {
return 'android';
}
return 'unknown';
}
function wxLogin() {
return new Promise((resolve, reject) => {
wx.login({
success(result) {
if (result.code) {
resolve(result.code);
return;
}
console.error('[web-view] wx.login returned no code', result);
reject(new Error(WECHAT_LOGIN_UNAVAILABLE_MESSAGE));
},
fail(error) {
console.error('[web-view] wx.login failed', error);
reject(new Error(WECHAT_LOGIN_UNAVAILABLE_MESSAGE));
},
});
});
}
function requestMiniProgramLogin(code, displayName) {
return new Promise((resolve, reject) => {
const runtimeConfig = resolveMiniProgramRuntimeConfig();
const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl);
if (!isConfiguredApiBaseUrl(apiBaseUrl)) {
reject(new Error('请先配置 API_BASE_URL'));
return;
}
wx.request({
url: `${apiBaseUrl}/api/auth/wechat/miniprogram-login`,
method: 'POST',
data: {
code,
...(displayName ? { displayName } : {}),
},
header: {
'content-type': 'application/json',
'x-client-type': readWebViewSourceQueryValue('clientType'),
'x-client-runtime': readWebViewSourceQueryValue('clientRuntime'),
'x-client-platform': resolveClientPlatform(),
'x-client-instance-id': getClientInstanceId(),
'x-mini-program-app-id': MINI_PROGRAM_APP_ID,
'x-mini-program-env': runtimeConfig.miniProgramEnv,
},
success(response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(response.data);
return;
}
console.error('[web-view] mini program login failed', response);
reject(new Error(WECHAT_LOGIN_UNAVAILABLE_MESSAGE));
},
fail(error) {
console.error('[web-view] mini program login request failed', error);
reject(new Error(WECHAT_LOGIN_UNAVAILABLE_MESSAGE));
},
});
});
}
function requestMiniProgramBindPhone(authToken, wechatPhoneCode, displayName) {
return new Promise((resolve, reject) => {
const runtimeConfig = resolveMiniProgramRuntimeConfig();
const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl);
if (!isConfiguredApiBaseUrl(apiBaseUrl)) {
reject(new Error('请先配置 API_BASE_URL'));
return;
}
wx.request({
url: `${apiBaseUrl}/api/auth/wechat/bind-phone`,
method: 'POST',
data: {
wechatPhoneCode,
...(displayName ? { displayName } : {}),
},
header: {
authorization: `Bearer ${authToken}`,
'content-type': 'application/json',
'x-client-type': readWebViewSourceQueryValue('clientType'),
'x-client-runtime': readWebViewSourceQueryValue('clientRuntime'),
'x-client-platform': resolveClientPlatform(),
'x-client-instance-id': getClientInstanceId(),
'x-mini-program-app-id': MINI_PROGRAM_APP_ID,
'x-mini-program-env': runtimeConfig.miniProgramEnv,
},
success(response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(response.data);
return;
}
console.error('[web-view] mini program bind phone failed', response);
reject(new Error(WECHAT_BIND_PHONE_UNAVAILABLE_MESSAGE));
},
fail(error) {
console.error('[web-view] mini program bind phone request failed', error);
reject(new Error(WECHAT_BIND_PHONE_UNAVAILABLE_MESSAGE));
},
});
});
}
async function resolveAuthResult(displayName) {
const code = await wxLogin();
const response = await requestMiniProgramLogin(code, displayName);
if (!response || !response.token) {
throw new Error('服务器未返回登录态');
}
return {
token: response.token,
bindingStatus: response.bindingStatus || 'pending_bind_phone',
user: response.user || null,
created: response.created === true,
};
}
function createWechatWebViewPage() {
return {
data: {
authResult: null,
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: true,
nicknameInput: '',
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage: false,
webViewUrl: '',
},
async onLoad(query = {}) {
this._lastLaunchQuery = query;
showWebViewShareMenu();
const runtimeConfig = resolveMiniProgramRuntimeConfig();
// 中文注释:web-view 只能打开已配置业务域名;未配置时展示本地提示,避免空白页误判。
if (!isConfiguredEntryUrl(runtimeConfig.webViewEntryUrl)) {
this.setData({
errorMessage: '请先在 miniprogram/config.js 填写 WEB_VIEW_ENTRY_URL。',
loading: false,
webViewUrl: '',
});
return;
}
const forcedPhoneBinding = parseBooleanQueryFlag(query.phoneBindingRequired);
const returnToPreviousPage = shouldReturnToPreviousPage(query);
if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) {
this.setData({
authResult: null,
bindingPhone: false,
errorMessage: '',
loading: false,
phoneBindingRequired: false,
returnToPreviousPage: false,
webViewUrl: resolveWebViewUrl(null, query),
});
return;
}
if (!isConfiguredApiBaseUrl(runtimeConfig.apiBaseUrl)) {
this.setData({
errorMessage: '请先在 miniprogram/config.js 填写 API_BASE_URL。',
loading: false,
webViewUrl: '',
});
return;
}
this.setData({
authResult: null,
bindingPhone: false,
errorMessage: '',
loggingIn: true,
loading: true,
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage,
webViewUrl: '',
});
await this.startAuthFlow(returnToPreviousPage, '');
},
handleNicknameInput(event) {
this.setData({
nicknameInput: event.detail ? event.detail.value : '',
});
},
async handleStartLogin() {
const displayName = normalizeNicknameInput(this.data.nicknameInput);
if (!displayName) {
this.setData({
errorMessage: '请先选择或填写微信昵称。',
});
return;
}
this.setData({
errorMessage: '',
loggingIn: true,
});
await this.startAuthFlow(this.data.returnToPreviousPage, displayName);
},
async startAuthFlow(returnToPreviousPage, displayName) {
try {
const authResult = await resolveAuthResult(displayName);
if (!displayName && shouldRequestNicknameAfterLogin(authResult)) {
this.setData({
authResult,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: true,
phoneBindingRequired: false,
returnToPreviousPage,
webViewUrl: '',
});
return;
}
if (authResult.bindingStatus === 'pending_bind_phone') {
this.setData({
authResult,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: true,
returnToPreviousPage,
webViewUrl: '',
});
return;
}
if (returnToPreviousPage) {
persistAuthResult(authResult);
this.setData({
authResult,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage,
webViewUrl: '',
});
wx.navigateBack();
return;
}
this.setData({
authResult,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage,
webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}),
});
} catch (error) {
console.error('[web-view] auth flow failed', error);
this.setData({
authResult: null,
errorMessage: WECHAT_LOGIN_UNAVAILABLE_MESSAGE,
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage,
webViewUrl: '',
});
}
},
onShow() {
const authResult = consumeAuthResult();
if (authResult) {
this.setData({
authResult,
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}),
});
}
this.consumePayResult();
setTimeout(() => {
this.consumePayResult();
}, PAY_RESULT_RECHECK_DELAY_MS);
},
consumePayResult() {
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) {
if (!this.data.authResult || !this.data.authResult.token) {
this.handleRetryLogin();
return;
}
const detail = event.detail || {};
if (!detail.code) {
console.error('[web-view] bind phone auth declined', detail);
this.setData({
errorMessage: WECHAT_BIND_PHONE_AUTH_REQUIRED_MESSAGE,
});
return;
}
this.setData({
bindingPhone: true,
errorMessage: '',
});
try {
const response = await requestMiniProgramBindPhone(
this.data.authResult.token,
detail.code,
normalizeNicknameInput(this.data.nicknameInput),
);
if (!response || !response.token) {
throw new Error('服务器未返回绑定后的登录态');
}
const nextAuthResult = {
token: response.token,
bindingStatus: 'active',
};
if (this.data.returnToPreviousPage) {
persistAuthResult(nextAuthResult);
this.setData({
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
});
wx.navigateBack();
return;
}
this.setData({
authResult: nextAuthResult,
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
webViewUrl: resolveWebViewUrl(
nextAuthResult,
this._lastLaunchQuery || {},
),
});
} catch (error) {
console.error('[web-view] bind phone failed', error);
this.setData({
bindingPhone: false,
errorMessage: WECHAT_BIND_PHONE_UNAVAILABLE_MESSAGE,
});
}
},
handleRetryLogin() {
this.setData({
authResult: null,
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: true,
nicknameInput: '',
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage: false,
webViewUrl: '',
});
this.onLoad(this._lastLaunchQuery || { authAction: AUTH_ACTION_LOGIN });
},
handleWebViewLoad(event) {
console.info('[web-view] loaded', event.detail);
},
handleWebViewError(event) {
console.error('[web-view] load failed', event.detail);
},
handleWebViewMessage(event) {
const shareTarget = resolveShareTargetFromWebViewMessage(event.detail);
if (shareTarget) {
this._currentShareTarget = shareTarget;
}
// 中文注释:支付和订阅消息都由独立 native 页面承接,web-view 消息只保留调试输出。
console.info('[web-view] message', event.detail);
},
onShareAppMessage() {
return buildWebViewShareAppMessage(resolveNativeShareQuery(this));
},
onShareTimeline() {
return buildWebViewShareTimeline(resolveNativeShareQuery(this));
},
};
}
module.exports = {
createWechatWebViewPage,
};