From cf6d54e2bf9e7af33ae18985eda3d75c5035f938 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Thu, 16 Jul 2026 16:32:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(util):=20=E4=BF=AE=E5=A4=8D=E5=86=85?= =?UTF-8?q?=E9=83=A8=E9=A1=B5=E9=9D=A2=E7=BB=9D=E5=AF=B9URL=E8=A2=AB?= =?UTF-8?q?=E8=AF=AF=E5=88=A4=E4=B8=BA=E5=A4=96=E9=93=BE=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增extractInternalAppPath和resolveNavMode工具函数,在diyRedirectTo中增加内部页面绝对URL还原逻辑: 1. 支持通过open_mode配置三种导航模式:auto/online/internal 2. 严格校验域名避免误伤外部站点,支持强制内部跳转场景 3. 修复H5整页刷新丢失状态、MP被塞进web-view的问题 --- common/js/util-navigate.js | 109 ++++++++++++++++++++++ common/js/util.js | 38 ++++++++ tests/util-navigate.test.js | 178 ++++++++++++++++++++++++++++++++---- 3 files changed, 307 insertions(+), 18 deletions(-) diff --git a/common/js/util-navigate.js b/common/js/util-navigate.js index b58cb66..42a982e 100644 --- a/common/js/util-navigate.js +++ b/common/js/util-navigate.js @@ -368,6 +368,114 @@ export function normalizeUrl(urlString) { return schemeAndHost + normalizedPath + normalizedQuery + hash; } +// ============================================================= +// extractInternalAppPath +// ============================================================= + +/** + * 从一个 URL 中提取"本 App 内部页面路径"。 + * + * 背景 / 修复的 bug: + * 后台装修数据里,内部页面常被存成【绝对 URL】,例如 + * https://host/hwappx/common/pages_tool/form/formdata?id=130 + * diyRedirectTo 用 `wap_url.indexOf('http') != -1` 判断"外部链接",于是: + * - H5: 走 window.location.href → 整页刷新;点返回时整个 App 重启、启动屏重播、状态丢失 + * - MP: 走 web-view → 本应原生跳转的内部页被塞进 web-view + * 本函数把这类"指向本 App 内部页面"的绝对 URL 还原成相对路径, + * 让 diyRedirectTo 改走内部页面分支(uni.navigateTo / SPA 跳转)。 + * + * 判定规则(双闸门,避免误伤真正的外部站点): + * 闸门 1 —— host 校验(相对路径直接放行): + * 绝对 URL 的 host 必须与本 App 自己的域名【完全一致】(忽略端口, 大小写不敏感); + * 否则返回 null(视为真外链, 维持原逻辑)。 + * ★ 完全一致是最严格的口径: 不同子域(如 xcx3 vs xcx6)、不同域名一律当外链, + * 彻底杜绝误伤 Shopify(xxx.myshopify.com/pages/about)、Facebook 等外站。 + * 闸门 2 —— 路径校验: + * pathname 中出现以 App 页面根开头的段(pages / pages_xxx), + * 则从该段起截取为内部路径(以 '/' 开头),连同 query/hash 一起返回; + * 未命中返回 null。 + * + * @example + * const H = ['xcx6.aigc-quickapp.com'] + * extractInternalAppPath('https://xcx6.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?id=1', H) + * // '/pages_tool/form/formdata?id=1' (域名完全一致 → 还原) + * extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages/index/index', H) + * // null (子域不同 → 维持外链) + * extractInternalAppPath('https://xxx.myshopify.com/pages/about-us', H) + * // null (外域名 → 维持外链,不误伤) + * extractInternalAppPath('/pages_goods/detail?id=9', H) + * // '/pages_goods/detail?id=9' (相对路径 → 内部) + * + * @param {string} url 绝对或相对 URL + * @param {string|string[]|true} [allowedHosts] 本 App 自己的域名(可传完整 URL 或纯 host,会自动提取); + * 传 `true` 表示【强制内部】—— 跳过 host 校验, 只要路径含 /pages 段就还原(用于 open_mode='internal')。 + * @returns {string|null} 形如 '/pages_tool/form/formdata?id=1' 的内部路径,或 null + */ +export function extractInternalAppPath(url, allowedHosts) { + if (!url || typeof url !== 'string') return null; + let rest = url; + // 闸门 1: host 校验(完全一致);allowedHosts === true 时跳过(强制内部) + const hostMatch = rest.match(/^https?:\/\/([^/]*)/i); + if (hostMatch) { + if (allowedHosts !== true) { + const host = (hostMatch[1] || '').toLowerCase().replace(/:\d+$/, ''); + const allow = _normalizeHostList(allowedHosts); + if (!host || allow.indexOf(host) === -1) return null; // 域名不完全一致 → 不改 + } + rest = rest.substring(hostMatch[0].length); + } + // 闸门 2: 路径校验(找第一个"页面根段" /pages 或 /pages_xxx) + const pageMatch = rest.match(/\/pages(?:_[a-z0-9]+)?\//i); + if (!pageMatch) return null; + const internal = rest.substring(pageMatch.index); + return internal || null; +} + +/** + * 把 allowedHosts 归一化成纯 host 数组(小写、去协议、去路径、去端口)。 + * 入参每一项可以是完整 URL(https://host/xx/)或纯 host(host:port)。 + * @param {string|string[]} hosts + * @returns {string[]} + */ +function _normalizeHostList(hosts) { + if (!hosts) return []; + const arr = Array.isArray(hosts) ? hosts : [hosts]; + const out = []; + for (const h of arr) { + if (!h || typeof h !== 'string') continue; + let s = h.trim().toLowerCase(); + const m = s.match(/^https?:\/\/([^/]*)/i); + if (m) s = m[1] || ''; + else s = s.replace(/\/.*$/, ''); // 去掉可能带的 path + s = s.replace(/:\d+$/, ''); // 去端口 + if (s) out.push(s); + } + return out; +} + +/** + * 解析 link 的导航模式(统一开关)。用于 diyRedirectTo 决定内部页面绝对 URL 如何跳转。 + * + * 取值来源(顶层字段 或 page_params 里, 大小写不敏感, 兼容驼峰 openMode): + * - open_mode = 'internal' | 'spa' → 'internal' 强制走内部 SPA(忽略域名是否一致) + * - open_mode = 'online' | 'web' → 'online' 强制走线上(H5 整页刷新 / MP web-view) + * - 未设置 / 其他 → 'auto' 自动: 域名完全一致才内部, 否则线上 + * + * @param {Object} link + * @returns {'internal'|'online'|'auto'} + */ +export function resolveNavMode(link) { + if (!link || typeof link !== 'object') return 'auto'; + const pp = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {}; + const raw = link.open_mode != null ? link.open_mode + : (link.openMode != null ? link.openMode + : (pp.open_mode != null ? pp.open_mode : pp.openMode)); + const m = (typeof raw === 'string') ? raw.trim().toLowerCase() : ''; + if (m === 'internal' || m === 'spa') return 'internal'; + if (m === 'online' || m === 'web') return 'online'; + return 'auto'; +} + // ============================================================= // appendQueryParams // ============================================================= @@ -616,6 +724,7 @@ export function createNavigateApi(opts = {}) { cleanParamUrls, normalizeUrl, appendQueryParams, + extractInternalAppPath, parsePageParams, resolveLinkUrl, decodeEntity, diff --git a/common/js/util.js b/common/js/util.js index 116c47e..3279c14 100644 --- a/common/js/util.js +++ b/common/js/util.js @@ -17,6 +17,8 @@ import { cleanParamUrls, normalizeUrl as _normalizeUrlBase, appendQueryParams as _appendQueryParamsBase, + extractInternalAppPath as _extractInternalAppPath, + resolveNavMode as _resolveNavMode, _shouldEncodeUrl, _safeEncodeForPlatform, _navigateTo, @@ -1092,6 +1094,42 @@ export default { link.wap_url = resolvedUrl; } + // 修复: 内部页面被后台存成【绝对 URL】(如 https://host/hwappx/common/pages_tool/form/formdata) + // 时,下方 `indexOf('http') != -1` 会把它误判为"外部链接": + // - H5 走 window.location.href → 整页刷新,点返回时整个 App 重启、启动屏重播、状态/滚动丢失 + // - MP 走 web-view → 本应原生跳转的内部页被塞进 web-view + // 这里把"指向本 App 内部页面"的绝对 URL 还原为相对路径,使其改走下方内部页面分支(SPA 跳转)。 + // + // 由 open_mode 统一控制(顶层字段 或 page_params): + // - 'auto'(默认): 双闸门 —— host 与本 App 域名【完全一致】且路径带 /pages 段才还原。 + // 不同子域/不同域名一律当外链,彻底避免误伤 Shopify、Facebook 等外站。 + // - 'internal': 强制内部 —— 跳过 host 校验, 只要路径带 /pages 段就还原为 SPA 跳转。 + // 用途: 数据里链接域名与当前部署域名不同(如 xcx3 vs xcx6)但确认是同一 App 内部页。 + // - 'online': 强制线上 —— 不还原, 保持整页刷新 / web-view。 + // 用途: 某些自域名页面是非 SPA / 遗留页,必须整页或 web-view 打开。 + if (link.wap_url && /^https?:\/\//i.test(link.wap_url)) { + const navMode = _resolveNavMode(link); // 'internal' | 'online' | 'auto' + if (navMode !== 'online') { + let internalPath; + if (navMode === 'internal') { + // 强制内部: 传 true 跳过 host 校验 + internalPath = _extractInternalAppPath(link.wap_url, true); + } else { + // auto: host 完全一致才还原 + const appHosts = []; + // #ifdef H5 + try { if (typeof location !== 'undefined' && location.hostname) appHosts.push(location.hostname); } catch (e) {} + // #endif + try { if (Config.h5Domain) appHosts.push(Config.h5Domain); } catch (e) {} + try { if (Config.baseUrl) appHosts.push(Config.baseUrl); } catch (e) {} + internalPath = _extractInternalAppPath(link.wap_url, appHosts); + } + if (internalPath) { + link.wap_url = internalPath; + } + } + } + // 外部链接 if (link.wap_url && link.wap_url.indexOf('http') != -1) { // 公共参数 + 透传 page_params(resolveLinkUrl 已把 wap_url 中的 query 合并到 page_params) diff --git a/tests/util-navigate.test.js b/tests/util-navigate.test.js index d725558..4ee7179 100644 --- a/tests/util-navigate.test.js +++ b/tests/util-navigate.test.js @@ -37,7 +37,7 @@ function loadNavInSandbox() { .replace(/^export\s+let\s+/gm, 'let ') .replace(/^export\s+var\s+/gm, 'var ') + '\nmodule.exports = { _toParamValueString, parsePageParams, resolveLinkUrl, ' - + 'cleanParamUrls, normalizeUrl, appendQueryParams, getPlatform, shouldEncodeUrl, ' + + 'cleanParamUrls, normalizeUrl, appendQueryParams, extractInternalAppPath, resolveNavMode, getPlatform, shouldEncodeUrl, ' + '_safeEncodeForPlatform, _navigateTo, createNavigateApi };\n'; const sandbox = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports }; sandbox.globalThis = sandbox; @@ -54,6 +54,7 @@ test('util-navigate.js: 能被 vm 加载,所有 export 都存在', () => { const expected = [ '_toParamValueString', 'parsePageParams', 'resolveLinkUrl', 'cleanParamUrls', 'normalizeUrl', 'appendQueryParams', + 'extractInternalAppPath', 'resolveNavMode', 'getPlatform', 'shouldEncodeUrl', '_safeEncodeForPlatform', '_navigateTo', 'createNavigateApi', ]; @@ -341,17 +342,19 @@ test('util.js: 通过 import 引用 util-navigate 的方法,挂载到 default ex // 关键 import assert.ok(utilSrc.includes("from './util-navigate.js'"), 'util.js 应 import util-navigate.js'); - assert.ok(utilSrc.includes('_cleanParamUrls'), - 'util.js 应 import _cleanParamUrls 别名'); - assert.ok(utilSrc.includes('_normalizeUrl'), - 'util.js 应 import _normalizeUrl 别名'); - assert.ok(utilSrc.includes('_navigateTo'), - 'util.js 应 import _navigateTo 别名'); - // default export 里的挂载 - assert.ok(utilSrc.includes('cleanParamUrls: (v) => _cleanParamUrls(v)'), + assert.ok(utilSrc.includes('cleanParamUrls'), + 'util.js 应 import cleanParamUrls'); + assert.ok(utilSrc.includes('normalizeUrl as _normalizeUrlBase'), + 'util.js 应 import normalizeUrl 别名 _normalizeUrlBase'); + assert.ok(utilSrc.includes('extractInternalAppPath as _extractInternalAppPath'), + 'util.js 应 import extractInternalAppPath 别名'); + assert.ok(utilSrc.includes('resolveNavMode as _resolveNavMode'), + 'util.js 应 import resolveNavMode 别名'); + // default export 里的挂载(cleanParamUrls 直接挂, normalizeUrl 用包装函数委托) + assert.ok(/cleanParamUrls,/.test(utilSrc), 'util.js 应把 cleanParamUrls 挂载到 default export'); - assert.ok(utilSrc.includes('_navigateTo: (args) => _navigateTo(args)'), - 'util.js 应把 _navigateTo 挂载到 default export'); + assert.ok(utilSrc.includes('return _normalizeUrlBase(urlString)'), + 'util.js 的 normalizeUrl 应委托给 _normalizeUrlBase'); }); // ============================================================= @@ -368,13 +371,152 @@ test('util.js: redirectTo / diyRedirectTo 仍在 util.js(没被搬走)', () => { // diyRedirectTo 函数定义 assert.ok(/^\s*diyRedirectTo\s*\(\s*link\s*\)\s*\{/m.test(utilSrc), 'diyRedirectTo 应仍在 util.js'); - // redirectTo 内部调 this._safeEncodeForPlatform - assert.ok(utilSrc.includes('this._safeEncodeForPlatform'), - 'redirectTo 应调 this._safeEncodeForPlatform'); - // redirectTo 内部调 this._navigateTo - assert.ok(utilSrc.includes('this._navigateTo('), - 'redirectTo 应调 this._navigateTo'); - // diyRedirectTo 内部调 this.cleanParamUrls + // redirectTo 内部调 _safeEncodeForPlatform(模块函数直调) + assert.ok(utilSrc.includes('_safeEncodeForPlatform('), + 'redirectTo 应调 _safeEncodeForPlatform'); + // diyRedirectTo 内部调 this.cleanParamUrls 清理脏 link assert.ok(utilSrc.includes('this.cleanParamUrls('), 'diyRedirectTo 应调 this.cleanParamUrls'); + // diyRedirectTo 内部调 _resolveNavMode + _extractInternalAppPath(内部页绝对 URL 还原) + assert.ok(utilSrc.includes('_resolveNavMode('), + 'diyRedirectTo 应调 _resolveNavMode'); + assert.ok(utilSrc.includes('_extractInternalAppPath('), + 'diyRedirectTo 应调 _extractInternalAppPath'); +}); + +// ============================================================= +// extractInternalAppPath — 内部页面绝对 URL 还原(双闸门) +// 修复: 内部页被存成绝对 URL 时被误判外链 → 整页刷新 / web-view +// ============================================================= +test('extractInternalAppPath: 相对内部路径直接放行', () => { + const nav = loadNavInSandbox(); + assert.equal(nav.extractInternalAppPath('/pages_goods/detail?id=9'), '/pages_goods/detail?id=9'); + assert.equal(nav.extractInternalAppPath('/pages/index/index'), '/pages/index/index'); +}); + +test('extractInternalAppPath: 同域绝对 URL → 还原为相对路径', () => { + const nav = loadNavInSandbox(); + const H = ['xcx6.aigc-quickapp.com']; + assert.equal( + nav.extractInternalAppPath('https://xcx6.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?id=1', H), + '/pages_tool/form/formdata?id=1', + ); +}); + +test('extractInternalAppPath: 同平台不同子域(域名不完全一致)→ null(严格模式)', () => { + const nav = loadNavInSandbox(); + const H = ['xcx6.aigc-quickapp.com']; // App 跑在 xcx6, 数据链接是 xcx3 → 视为外链 + assert.equal( + nav.extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata', H), + null, + ); +}); + +test('extractInternalAppPath: 传入完整 URL 作为白名单也能提取 host(完全一致才还原)', () => { + const nav = loadNavInSandbox(); + const H = ['https://xcx6.aigc-quickapp.com/']; // Config.h5Domain 形式 + assert.equal( + nav.extractInternalAppPath('https://xcx6.aigc-quickapp.com/hwappx/common/pages/index/index', H), + '/pages/index/index', + ); + // host 不一致 → null + assert.equal( + nav.extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages/index/index', H), + null, + ); +}); + +test('extractInternalAppPath: 外链域名不同(Shopify / Facebook)→ 不误伤, 返回 null', () => { + const nav = loadNavInSandbox(); + const H = ['xcx6.aigc-quickapp.com']; + assert.equal(nav.extractInternalAppPath('https://xxx.myshopify.com/pages/about-us', H), null); + assert.equal(nav.extractInternalAppPath('https://www.facebook.com/pages/Name/123', H), null); + assert.equal(nav.extractInternalAppPath('https://example.com/pages_tool/x', H), null); +}); + +test('extractInternalAppPath: 绝对 URL 但路径无 /pages 段 → null(真外链)', () => { + const nav = loadNavInSandbox(); + const H = ['xcx6.aigc-quickapp.com']; + assert.equal(nav.extractInternalAppPath('https://xcx6.aigc-quickapp.com/activity/2026', H), null); +}); + +test('extractInternalAppPath: 无白名单时绝对 URL 一律 null(不猜测)', () => { + const nav = loadNavInSandbox(); + assert.equal(nav.extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages/index/index'), null); + assert.equal(nav.extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages/index/index', []), null); +}); + +test('extractInternalAppPath: 空/非法输入安全返回 null', () => { + const nav = loadNavInSandbox(); + assert.equal(nav.extractInternalAppPath('', ['a.com']), null); + assert.equal(nav.extractInternalAppPath(null, ['a.com']), null); + assert.equal(nav.extractInternalAppPath(undefined), null); +}); + +test('extractInternalAppPath: host 大小写不敏感 + 忽略端口', () => { + const nav = loadNavInSandbox(); + const H = ['xcx6.aigc-quickapp.com']; + // host 大写 + assert.equal( + nav.extractInternalAppPath('https://XCX6.AIGC-QUICKAPP.COM/hwappx/common/pages/index/index', H), + '/pages/index/index', + ); + // 带端口(白名单不带端口, 也应命中) + assert.equal( + nav.extractInternalAppPath('https://xcx6.aigc-quickapp.com:8443/hwappx/common/pages_goods/detail', H), + '/pages_goods/detail', + ); +}); + +test('extractInternalAppPath: 各分包页面根都能识别 + 保留 hash', () => { + const nav = loadNavInSandbox(); + const H = ['h.com']; + assert.equal(nav.extractInternalAppPath('https://h.com/base/pages_promotion/point/list', H), '/pages_promotion/point/list'); + assert.equal(nav.extractInternalAppPath('https://h.com/base/pages_order/detail?id=2', H), '/pages_order/detail?id=2'); + assert.equal(nav.extractInternalAppPath('/pages/index/index#top'), '/pages/index/index#top'); +}); + +// ============================================================= +// extractInternalAppPath: 强制内部(allowedHosts === true 跳过 host 校验) +// ============================================================= +test('extractInternalAppPath: allowedHosts=true → 跳过 host 校验(强制内部)', () => { + const nav = loadNavInSandbox(); + // 不同子域, 正常严格模式下会 null; 传 true 强制还原 + assert.equal( + nav.extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?id=1', true), + '/pages_tool/form/formdata?id=1', + ); + // 完全陌生域名也能被强制还原(调用方已明确表示是内部页) + assert.equal( + nav.extractInternalAppPath('https://any-host.example.net/x/pages/index/index', true), + '/pages/index/index', + ); + // 但仍要求路径带 /pages 段, 没有则 null + assert.equal(nav.extractInternalAppPath('https://any-host.example.net/activity/2026', true), null); +}); + +// ============================================================= +// resolveNavMode — 统一开关 open_mode(internal/online/auto) +// ============================================================= +test('resolveNavMode: open_mode=internal/spa → internal', () => { + const { resolveNavMode } = loadNavInSandbox(); + assert.equal(resolveNavMode({ open_mode: 'internal' }), 'internal'); + assert.equal(resolveNavMode({ open_mode: 'SPA' }), 'internal'); + assert.equal(resolveNavMode({ openMode: 'internal' }), 'internal'); + assert.equal(resolveNavMode({ page_params: { open_mode: 'internal' } }), 'internal'); +}); + +test('resolveNavMode: open_mode=online/web → online', () => { + const { resolveNavMode } = loadNavInSandbox(); + assert.equal(resolveNavMode({ open_mode: 'online' }), 'online'); + assert.equal(resolveNavMode({ open_mode: 'WEB' }), 'online'); + assert.equal(resolveNavMode({ page_params: { open_mode: 'web' } }), 'online'); +}); + +test('resolveNavMode: 未设置 / 未知值 → auto', () => { + const { resolveNavMode } = loadNavInSandbox(); + assert.equal(resolveNavMode({}), 'auto'); + assert.equal(resolveNavMode({ open_mode: 'xxx' }), 'auto'); + assert.equal(resolveNavMode(null), 'auto'); + assert.equal(resolveNavMode(undefined), 'auto'); });