fix(mp-harmony): 修正鸿蒙元服务的url编码逻辑,对齐微信小程序行为

更新了鸿蒙元服务的_safeEncodeForPlatform实现和相关测试:
1. 修正此前错误的"鸿蒙整体encode url"假设,实际鸿蒙行为和微信一致,按&=分隔解析query
2. JS侧不再完全不编码,而是仅对&=?#和空格做encode,保留中文/字母数字等
3. 更新所有相关测试用例和文档注释,同步修复微信平台的编码逻辑说明
This commit is contained in:
2026-07-18 18:23:39 +08:00
parent 2adb574dd5
commit 02b8966a91
2 changed files with 73 additions and 53 deletions

View File

@@ -14,11 +14,14 @@
* `%E6%B2%99%E5%8F%91` 这种"已编码字符串",在 <input v-model="keyword">
* 上显示成乱码。
*
* 修复策略:
* 修复策略(2026-07-17 → 2026-07-18):
* - 微信小程序 + 鸿蒙元服务(mp-weixin / mp-harmony): `_safeEncodeForPlatform`
* 不做 encode,只做 decode roundtrip(防止上游重复 encode 入参)。让平台原生
* 去做唯一一次 encode。
* - H5 / 华为快应用: 仍然主动 encode 一次(防浏览器 query 错位)。
* 只 encode URL 分隔符 `& = ? # \s`(`_encodeUrlDelimiters`),中文 / 字母 /
* 数字 / 标点原样保留。鸿蒙之前的"完全交给平台 encode"假设是错的 —— 鸿蒙
* onLoad 行为跟微信一样, split on '&' / '=' 但不 decode value, 所以 JS
* 必须主动 encode 分隔符防 query 边界被破坏。
* - H5 / 华为快应用: 仍然主动 encode 一次(标准 encodeURIComponent, 防浏览器
* query 错位)。
*
* 跑法:
* node --test tests/search-keyword-encoding.test.js
@@ -61,24 +64,29 @@ function loadNavInSandbox() {
// =============================================================
// 1. _safeEncodeForPlatform 平台差异化
// =============================================================
test('_safeEncodeForPlatform: 鸿蒙元服务 → JS 侧不做 encode(让平台去做)', () => {
test('_safeEncodeForPlatform: 鸿蒙元服务 → JS 只 encode 分隔符(和 mp-weixin 一致)', () => {
// 2026-07-18 修正: 鸿蒙 onLoad 行为和微信一致, JS 必须 encode 分隔符。
// - 中文 / 反引号 / 其他标点 原样
// - & = ? # 空格 encode 成 %xx
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
// 中文关键词原样返回(鸿蒙原生会再做一次 encode)
// 中文原样
const r1 = api._safeEncodeForPlatform('沙发');
assert.equal(r1, '沙发', `mp-harmony 中文应原样返回,实际: ${r1}`);
assert.equal(r1, '沙发', `mp-harmony 中文应原样,实际: ${r1}`);
// 含 & 的值原样返回(不能再 encode,否则双重编码)
// & = encode 成 %xx
const r2 = api._safeEncodeForPlatform('a&b=c');
assert.equal(r2, 'a&b=c', `mp-harmony 特殊字符应原样返回,实际: ${r2}`);
assert.equal(r2, 'a%26b%3Dc', `mp-harmony 分隔符应 encode,实际: ${r2}`);
// 反引号 / 空格原样返回
// 空格 encode 成 %20(防 query 边界被破坏)
const r3 = api._safeEncodeForPlatform('`hello world`');
assert.equal(r3, '`hello world`', `mp-harmony 反引号空格应原样返回,实际: ${r3}`);
assert.equal(r3, '`hello%20world`', `mp-harmony 空格应 encode,实际: ${r3}`);
});
test('_safeEncodeForPlatform: 鸿蒙下已编码入参 → decode 一次还原(避免双编码)', () => {
// 2026-07-18 仍成立: JS 侧 decode roundtrip 在所有平台都执行,
// 解码后只 encode 分隔符, 中文 / %xx 形式 都恢复成原文。
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
@@ -135,7 +143,8 @@ test('_safeEncodeForPlatform: 华为快应用 → JS 侧主动 encode', () => {
});
test('_safeEncodeForPlatform: 已编码字符串不双编码(非微信/非鸿蒙平台)', () => {
// 2026-07-17 修正: 微信小程序也属于"不做 JS encode"平台,跟鸿蒙一样
// 2026-07-18 修正: 微信 / 鸿蒙走 _encodeUrlDelimiters 也会经过 decode roundtrip,
// 解码后只 encode 分隔符, 已编码的 %xx 形式(中文等)都恢复成原文, 不会双编码。
const nav = loadNavInSandbox();
// decode roundtrip + re-encode: 入参 %E8%A1%A8(="表")
// 还原后单次 encode 仍是 %E8%A1%A8
@@ -174,31 +183,33 @@ function buildRedirectUrl(to, param, encode) {
}
/**
* 模拟鸿蒙元服务对 url 的处理:
* - JS 传入"未编码"的 url,鸿蒙原生会先对整个 query 字符串做一次 encodeURIComponent
* (把 = & 这些分隔符也一起编码),然后解析 query 时再 decode 一次
* - 最终 onLoad(options) 拿到的 keyword 是原文
* 模拟鸿蒙元服务对 url 的处理(2026-07-18 修正):
* 鸿蒙 onLoad 行为跟微信一致 —— 不整体 encode url,只 split on '&' / '=',
* 对 value 做 decodeURIComponent。
*
* 关键: 鸿蒙原生 encode 整段 query,不会把 = 和 & 当分隔符提前 split
* (这跟浏览器/H5 的 queryString 解析方式不同)
* JS 侧 `_safeEncodeForPlatform` 已经把 `& = ? # \s` 编码成 %xx, 鸿蒙 split
* 之后直接拿到 key=value, 对 value decode 一次还原原文。
* 最终 onLoad(options) 拿到的 keyword 是原文。
*
* 关键: 鸿蒙 / 微信 都是 split-on-& + decode, 跟 H5 / 快应用(browser) 行为一致,
* 跟以前假设的"鸿蒙整体 encode"不同。
*/
function simulateHarmonyOnLoad(url) {
const qIdx = url.indexOf('?');
if (qIdx === -1) return { options: {} };
const queryStr = url.substring(qIdx + 1);
// 鸿蒙原生: 先对整段 query 字符串做 encodeURIComponent(= & 都会变 %xx)
const encodedQueryStr = encodeURIComponent(queryStr);
// 模拟解析 encoded 字符串: 把 %26 解码回 & 再 split
// 关键洞察: encodeURIComponent 后的字符串是安全的(没有裸 = &),所以可以
// 直接 decode 然后 split
const decodedBack = decodeURIComponent(encodedQueryStr);
const options = {};
decodedBack.split('&').forEach(kv => {
// 鸿蒙元服务: 不 auto-encode, 只 split on '&' / '=', 对 value decodeURIComponent
queryStr.split('&').forEach(kv => {
const eqIdx = kv.indexOf('=');
if (eqIdx === -1) return;
const k = kv.substring(0, eqIdx);
const v = kv.substring(eqIdx + 1);
options[k] = v;
const rawK = kv.substring(0, eqIdx);
const rawV = kv.substring(eqIdx + 1);
try {
options[decodeURIComponent(rawK)] = decodeURIComponent(rawV);
} catch (e) {
options[rawK] = rawV;
}
});
return { options };
}
@@ -207,7 +218,7 @@ function simulateHarmonyOnLoad(url) {
* 模拟微信小程序对 url 的处理(2026-07-17 修正):
* - JS 只 encode URL 分隔符 (& = ? # \s),中文和字母数字原样保留
* - 微信小程序: 不 encode 整体 url, 只 split on '&' / '=',对 value 做 decodeURIComponent
* - 结果: 中文 keyword 原样<E58E9F><E6A0B7>到 onLoad(不会显示乱码),
* - 结果: 中文 keyword 原样<E58E9F><E6A0B7>到 onLoad(不会显示乱码),
* src 中的 & 已被 JS encode 成 %26,微信分解析后 onLoad 拿到 %26(而非原 &),
* 但 webview 只接接收 src 作为 URL 参数,浏览器 fetch 时会 decode %26 为 &,浏览器端解析正常
*/
@@ -261,6 +272,9 @@ function simulateBrowserOnLoad(url) {
// 2.1 端到端:鸿蒙元服务(回归 Bug 的主要平台)
// =============================================================
test('端到端: 鸿蒙元服务 → 搜索关键词"沙发"在列表页 search 输入框显示原文(不乱码)', () => {
// 2026-07-18 修正: 鸿蒙 onLoad 行为和微信一致, JS 只 encode 分隔符。
// 中文不在分隔符列表里, 所以 url 里中文原样保留; 鸿蒙 split-on-& 之后
// decode 一次, options.keyword 仍是原文。
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
const encode = (v) => api._safeEncodeForPlatform(v);
@@ -269,55 +283,54 @@ test('端到端: 鸿蒙元服务 → 搜索关键词"沙发"在列表页 search
// this.$util.redirectTo('/pages_goods/list', { keyword: '沙发' });
const url = buildRedirectUrl('/pages_goods/list', { keyword: '沙发' }, encode);
// 鸿蒙下 url 不应被双重编码: keyword= 应直接是中文(没 %)
assert.ok(!url.includes('%'),
`鸿蒙下 url 不应含 %xx(JS 不应预 encode),实际 url: ${url}`);
// 鸿蒙下中文不在 encode 列表里, url 应含 keyword=沙发
assert.ok(url.includes('keyword=沙发'),
`鸿蒙下 url 应含 keyword=沙发,实际: ${url}`);
// 鸿蒙原生自动 encode 一次,然后解析时 decode 一次,最终 options.keyword = '沙发'
// 鸿蒙 split-on-& 之后 decode 一次, 最终 options.keyword = '沙发'
const { options } = simulateHarmonyOnLoad(url);
assert.equal(options.keyword, '沙发',
`鸿蒙下 onLoad(options).keyword 应是原文,实际: ${options.keyword}`);
});
test('端到端: 鸿蒙元服务 → 含中文 + 反引号 + 空格的关键词在列表页 search 显示原文', () => {
// 注: 鸿蒙元服务下 JS 侧不做 encode(让鸿蒙原生 encode),
// 对于含 URL 分隔符 (& = ? #) 的关键词,需要业务方自己 encode,
// 否则会被 native 当成 query 分隔符破坏边界。
// 本测试覆盖用户实际场景: 中文 + 反引号 + 空格(鸿蒙原生都能正常 encode 处理)
// 2026-07-18 修正: 鸿蒙 / 微信一样, JS encode 空格 → %20(防 query 边界被破坏),
// 中文 / 反引号原样; 鸿蒙 split + decode 后还原成原文。
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
const encode = (v) => api._safeEncodeForPlatform(v);
const kw = '`hello world` 沙发';
const url = buildRedirectUrl('/pages_goods/list', { keyword: kw }, encode);
assert.ok(url.includes('keyword=' + kw),
`鸿蒙下 url 应含原始 keyword,实际: ${url}`);
// mp-harmony 下 JS 会 encode 空格(防 query 边界), 中文 / 反引号 原样
assert.ok(url.includes('keyword=' + encode(kw)),
`鸿蒙下 url 应含 JS 编码后的 keyword(空格 → %20),实际: ${url}`);
const { options } = simulateHarmonyOnLoad(url);
// decode 之后应还原 keyword
assert.equal(options.keyword, kw,
`鸿蒙下 onLoad(options).keyword 应是原文,实际: ${options.keyword}`);
`鸿蒙下 onLoad(options).keyword 应是 decode 后的原文,实际: ${options.keyword}`);
});
// =============================================================
// 2.2 端到端:微信小程序(回归 Bug 的另一平台)
// =============================================================
test('端到端: 微信小程序 → 搜索关键词"沙发"在列表页 search 输入框显示原文(不乱码)', () => {
// 2026-07-17 修正: 微信小程序跟鸿蒙一样,JS 不做 encode,让平台原生去 encode
// 2026-07-18 修正: 微信 onLoad 是 split-on-& + decode 一次, JS 侧只 encode 分隔符,
// 中文原样保留, 最终 options.keyword 是原文。
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
const encode = (v) => api._safeEncodeForPlatform(v);
const url = buildRedirectUrl('/pages_goods/list', { keyword: '沙发' }, encode);
// 微信下 url 不应被双重编码: keyword= 应直接是中文(没 %)
// 微信下 url 应直接是中文(JS 不预 encode 中文)
assert.ok(!url.includes('%'),
`微信下 url 不应含 %xx(JS 不应预 encode),实际 url: ${url}`);
`微信下 url 不应含 %xx(JS 不预 encode 中文),实际 url: ${url}`);
assert.ok(url.includes('keyword=沙发'),
`微信下 url 应含 keyword=沙发,实际: ${url}`);
// 微信原生自动 encode 一次,然后解析时 decode 一次,最终 options.keyword = '沙发'
// 微信 split-on-& + decode 一次, 最终 options.keyword = '沙发'
const { options } = simulateWeixinOnLoad(url);
assert.equal(options.keyword, '沙发',
`微信下 onLoad(options).keyword 应是原文,实际: ${options.keyword}`);
@@ -390,7 +403,8 @@ test('Bug 回归场景: 商品分类页输入"沙发" → 列表页搜索框显
// 模拟 onLoad(options) 拿到的 keyword(按平台分派)
if (platform === 'mp-harmony') {
// 鸿蒙: 平台原生 encode 一次,parse 时 decode 一次
// 鸿蒙: 不 auto-encode, split on '&' / '=',对 value decodeURIComponent
// (2026-07-18 修正: 跟微信行为一致)
return simulateHarmonyOnLoad(url).options.keyword;
} else if (platform === 'mp-weixin') {
// 微信: 不 auto-encode, split on '&' / '=',对 value decodeURIComponent
@@ -448,7 +462,9 @@ test('Bug 回归场景: 长关键词 + 特殊字符,4 平台都不应乱码', ()
// =============================================================
// 4. 防御:微信/鸿蒙下不能误伤已经预 encode 的入参
// =============================================================
test('防御: 鸿蒙下已 encode 的入参 → decode 一次还原(避免平台 encode 后变成双编码)', () => {
test('防御: 鸿蒙下已 encode 的入参 → decode 一次还原(避免双编码)', () => {
// 2026-07-18 修正: 鸿蒙 / 微信 onLoad 行为一致, JS 侧 decode roundtrip
// 后只 encode 分隔符, 所以已编码入参还原成原文, 不会再被二次 encode。
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
const encode = (v) => api._safeEncodeForPlatform(v);
@@ -459,10 +475,10 @@ test('防御: 鸿蒙下已 encode 的入参 → decode 一次还原(避免平台
assert.ok(url.includes('keyword=发'),
`鸿蒙下已编码入参应被 decode 还原,实际 url: ${url}`);
// 然后鸿蒙原生 encode 一次,parse decode 一次,回到"发"
// 鸿蒙 split-on-& + decode 后回到"发"
const { options } = simulateHarmonyOnLoad(url);
assert.equal(options.keyword, '发',
`鸿蒙下 decode-encode-decode 一次后应是原文,实际: ${options.keyword}`);
`鸿蒙下 decode 一次后应是原文,实际: ${options.keyword}`);
});
test('防御: 微信下已 encode 的入参 → decode 一次还原(避免平台 encode 后变成双编码)', () => {

View File

@@ -240,13 +240,17 @@ test('_safeEncodeForPlatform: H5 / 华为快应用 → 主动 encode', () => {
}
});
test('_safeEncodeForPlatform: 鸿蒙元服务 → JS 侧不做 encode(让平台去做)', () => {
// 2026-07-17 修正: 鸿蒙整体 encode url, JS 完全不 encode
// (再 encode 会双重编码; webview src 含 & 时,鸿蒙原生 encode 会自动变 %26,webview 能正常打开)
test('_safeEncodeForPlatform: 鸿蒙元服务 → JS 只 encode 分隔符(和 mp-weixin 行为一致)', () => {
// 2026-07-18 修正: 鸿蒙元服务的 onLoad 行为跟 mp-weixin 一致 —— split on '&' / '='
// 解析 url 但不 decode value 给 onLoad, 所以 JS 必须 encode `& = ? # \s` 防
// query 边界被破坏, 但中文 / 字母 / 数字原样保留(否则 onLoad 拿到 %xx 乱码)。
// (此前假设"鸿蒙原生 encode 整段 query"是错的 —— 实际它跟微信一样 split-on-& 不 decode)
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
const r = api._safeEncodeForPlatform('a&b');
assert.equal(r, 'a&b', `mp-harmony: JS 不应预 encode,实际: ${r}`);
assert.equal(api._safeEncodeForPlatform('a&b'), 'a%26b', '& 应 encode 成 %26');
assert.equal(api._safeEncodeForPlatform('沙发'), '沙发', '中文不应 encode');
assert.equal(api._safeEncodeForPlatform('a=b'), 'a%3Db', '= 应 encode 成 %3D');
assert.equal(api._safeEncodeForPlatform('a?b'), 'a%3Fb', '? 应 encode 成 %3F');
});
test('_safeEncodeForPlatform: 微信小程序 → JS 只 encode 分隔符', () => {