选中任一预设充值档位点「立即支付」,必然弹「无效参数:custom_price」。这是 WP
REST 的参数校验层拦截,请求根本没进业务回调。
原因是 WP REST 的 default 只在「键完全缺失」时生效,键存在但值为空串时 default
不参与,空串会直接送进 pattern 校验:
'custom_price' => ['type' => 'string', 'default' => '0', 'pattern' => '^(0|[1-9]\d*)(?:\.\d{1,2})?$']
而前端提交时无条件带上该字段(custom_price: custom),选预设档位时 custom 恒为
''。更隐蔽的变体:后台关掉「自定义充值金额」后模板不再渲染该输入框,customEl 为
null、custom 仍是 '',也就是只放开预设档位的配置下 100% 复现。
该字段只在 package=custom 时被消费,而金额格式与区间的业务裁决早已完整存在于
create_charge_order()(AssetAmount::balance 严格正则 + 溢出检查,再按 custom_limit
校验区间并返回本地化错误)。参数声明上的 pattern 是同一规则的第二份实现,两套口径
不同步即是本次缺陷来源。故本次按「单一口径」收口,不再采用「放宽 pattern + 回调归一化
空串 + 前端改送 '0'」的三处并行写法:
- BalanceServiceRest:custom_price 参数去掉 pattern、default 改为 ''、显式声明
sanitize_callback;rest_charge_create 改为经声明式 args 用 $request->get_param()
取值,不再手工重读 JSON 体
- credits.js:selectProduct 选中预设档位时清空自定义金额输入(否则框内残留数字会在
提交时把 package 顶回 custom,静默覆盖用户刚点的档位);customInput 声明上移,去掉
「先调用后赋值」的时序依赖
- balance-charge-modal.test.cjs:新增 2 项契约测试 / 6 条断言
清理的死代码:
- rest_charge_create 中手工 get_json_params() 并复刻 sanitize_text_field/sanitize_key
的一段:与声明的 sanitize_callback 重复,使那些声明形同虚设(实为第二套取值路径)
- credits.js 提交处理器内重复读取 payment_method 的死分支(处理器开头已读取,中间无
任何代码改动它)
放宽仅限「空串 / 键缺失」这一种情形:package=custom 而金额为空、非法或越界仍一律
400,007、abc、1e3、1.234、负数等仍被 AssetAmount::balance() 拒绝,失败方向仍是收紧。
行为变化(需知悉):
- 金额裁决权归 AssetAmount::balance(),其内部 trim() 使带首尾空格的金额被修剪后接受,
此前被参数层 pattern 直接拒绝。这是「单一裁决者」的必然结果。
- 点选预设档位会清空自定义金额输入框,是修复该档位被静默覆盖的必要行为。
验证:node --test 全量 372 项 / 372 通过(原 370 项,新增 2 项);PHPUnit 44 项 /
106 断言通过;改动 PHP 文件 php -l 通过。变异验证:把 6 条关键断言跑在修复前的文件
内容上,6/6 转红,证明回归测试拦得住此缺陷。未做视觉验收,弹窗标记与 CSS 未改动。
121 lines
6.9 KiB
JavaScript
121 lines
6.9 KiB
JavaScript
/**
|
||
* 余额充值弹窗壳契约(第 19 轮 Zibll 对齐 + 第 21 轮全页不可点击回归护栏)。
|
||
*
|
||
* 关键约束:本主题的弹窗壳是服务端静态输出,与 Zibll(refresh_modal JS
|
||
* 按需创建、且其 CSS 无 bootstrap .modal 基础规则)架构不同。若弹窗壳在
|
||
* 静止态没有被显式隐藏,main.css 的 .flex{display:flex}(后加载、与
|
||
* bootstrap.min.css 的 .modal{display:none} 同特异性、后者胜)会让
|
||
* `class="modal flex jc fade"` 壳永久 display:flex 铺满全屏,叠加 .fade
|
||
* 的 opacity:0 形成隐形遮罩 → 整页所有按钮不可点击(2026-09-17 实测故障)。
|
||
*
|
||
* 因此 credits.css 必须以 (0,2,0) 特异性压制:
|
||
* .modal[data-aksrt-charge-modal] { display: none; }
|
||
* .modal[data-aksrt-charge-modal].in { display: flex; }
|
||
* 展示态由 credits.js 自包含开关的内联 display:flex 驱动(不依赖
|
||
* jQuery/Bootstrap 运行时;block 会破坏 .flex.jc 双轴居中——
|
||
* .modal.flex .modal-dialog{margin:0} 在 block 下使对话框顶在左上角),
|
||
* 关闭后内联样式撤除即回到隐藏规则。
|
||
*/
|
||
const test = require('node:test');
|
||
const assert = require('node:assert');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const root = path.join(__dirname, '..', '..');
|
||
const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8');
|
||
|
||
const wallet = read('inc', 'helpers', 'user-center-renderers', 'credits-wallet.php');
|
||
const creditsJs = read('assets', 'js', 'credits.js');
|
||
const creditsCss = read('assets', 'css', 'routes', 'user-center', 'credits.css');
|
||
const balanceRest = read('src', 'Credits', 'BalanceServiceRest.php');
|
||
|
||
test('弹窗壳为 Zibll refresh_modal 同构静态壳并带 data 钩子', () => {
|
||
assert.match(wallet, /class="modal flex jc fade"[^>]*data-aksrt-charge-modal/);
|
||
assert.match(wallet, /data-aksrt-charge-form/);
|
||
assert.match(wallet, /data-dismiss="modal"/);
|
||
assert.match(wallet, /initiate-pay/);
|
||
});
|
||
|
||
test('弹窗壳静止态必须被 credits.css 显式隐藏(.flex 覆盖回归护栏)', () => {
|
||
// (0,2,0) 特异性压制 main.css 的 .flex{display:flex}。
|
||
assert.match(creditsCss, /\.modal\[data-aksrt-charge-modal\]\s*\{\s*display:\s*none;/);
|
||
// 打开态必须是 flex 而非 block:.modal.flex .modal-dialog{margin:0} 在
|
||
// block 下使对话框顶在左上角(无头 Chrome 几何实证),flex 下由壳的
|
||
// .flex.jc 双轴居中(Zibll 生产环境即此形态)。
|
||
assert.match(creditsCss, /\.modal\[data-aksrt-charge-modal\]\.in\s*\{\s*display:\s*flex;/);
|
||
assert.doesNotMatch(creditsCss, /\.modal\[data-aksrt-charge-modal\]\.in\s*\{\s*display:\s*block;/);
|
||
// 购买积分弹窗壳同构(服务端静态输出 .modal.flex.jc.fade),静止态同样
|
||
// 必须显式隐藏,否则隐形遮罩拦截整页点击(2026-09-18 实测回归)。
|
||
const balance = read('inc', 'helpers', 'user-center-renderers', 'credits-balance.php');
|
||
assert.match(balance, /class="modal flex jc fade"[^>]*data-aksrt-points-pay-modal/);
|
||
assert.match(creditsCss, /\.modal\[data-aksrt-points-pay-modal\]\s*\{\s*display:\s*none;/);
|
||
assert.match(creditsCss, /\.modal\[data-aksrt-points-pay-modal\]\.in\s*\{\s*display:\s*flex;/);
|
||
});
|
||
|
||
test('开关自包含:不依赖 jQuery/Bootstrap modal 运行时', () => {
|
||
assert.match(creditsJs, /function openChargeModal/);
|
||
assert.match(creditsJs, /function closeChargeModal/);
|
||
assert.match(creditsJs, /data-aksrt-charge-open/);
|
||
// 动态创建遮罩层(bootstrap.min.css 已有 .modal-backdrop 规则)。
|
||
assert.match(creditsJs, /modal-backdrop fade in/);
|
||
// 充值弹窗开关不得回退到 jQuery modal 运行时(时序异常时弹窗打不开)。
|
||
assert.doesNotMatch(creditsJs, /\.modal\(\s*['"]show['"]/);
|
||
assert.doesNotMatch(creditsJs, /hidden\.bs\.modal/);
|
||
// 打开前移除旧缓存 hidden 标记;关闭走 300ms fade-out 收尾。
|
||
assert.match(creditsJs, /removeAttribute\('hidden'\)/);
|
||
assert.match(creditsJs, /void modal\.offsetWidth/);
|
||
// 打开态内联 display 必须是 flex(block 会破坏 .flex.jc 双轴居中)。
|
||
assert.match(creditsJs, /modal\.style\.display = 'flex'/);
|
||
assert.doesNotMatch(creditsJs, /modal\.style\.display = 'block'/);
|
||
});
|
||
|
||
/**
|
||
* 余额充值提交契约(预设档位被 REST 参数层拦截的回归护栏)。
|
||
*
|
||
* custom_price 只在 package=custom 时被消费,选中预设档位时前端必带该键且值为
|
||
* 空串。WP REST 的 default 仅在「键完全缺失」时生效:键存在而值为空串时 default
|
||
* 不参与,空串直接进 pattern 校验 → 请求在参数层就被拦下(rest_invalid_param
|
||
* 「无效参数:custom_price」),业务回调根本不执行。
|
||
*
|
||
* 因此参数声明只保留 type/default/清洗,金额格式与区间的唯一裁决留在
|
||
* create_charge_order()(AssetAmount::balance + custom_limit 区间校验)。
|
||
*/
|
||
test('充值请求参数:custom_price 空串放行,金额格式只由业务层裁决', () => {
|
||
const chargeRoute = balanceRest.slice(
|
||
balanceRest.indexOf("'/pay/balance/charge'"),
|
||
balanceRest.indexOf("'/pay/transfer'"),
|
||
);
|
||
assert.match(chargeRoute, /'custom_price'\s*=>\s*\['type'\s*=>\s*'string',\s*'default'\s*=>\s*''/);
|
||
assert.doesNotMatch(
|
||
chargeRoute,
|
||
/custom_price'[\s\S]*?'pattern'/,
|
||
'custom_price must not carry a second amount rule: the empty string is a valid "not applicable" value',
|
||
);
|
||
|
||
const createCallback = balanceRest.slice(
|
||
balanceRest.indexOf('public function rest_charge_create'),
|
||
balanceRest.indexOf('public function rest_transfer'),
|
||
);
|
||
assert.match(createCallback, /\$custom_price\s*=\s*\(string\) \$request->get_param\('custom_price'\)/);
|
||
assert.match(createCallback, /\$payment_method\s*=\s*\(string\) \$request->get_param\('payment_method'\)/);
|
||
// 手工重读请求体会让声明的 default/sanitize_callback 形同虚设(第二套取值口径)。
|
||
assert.doesNotMatch(createCallback, /get_json_params\(\)|->get_params\(\)/);
|
||
});
|
||
|
||
test('充值弹窗:选中预设档位清空自定义金额,提交路径无重复读取', () => {
|
||
// 选中预设档位即放弃自定义金额;否则输入框里残留的数字会在提交时按「合法自定义
|
||
// 金额」把 package 顶回 custom,用户刚点的档位被静默忽略。
|
||
assert.match(creditsJs, /if \(value !== 'custom' && customInput\) \{[\s\S]{0,60}customInput\.value = '';/);
|
||
// 支付方式在提交处理器开头已读取,选档位不会改动它,故充值弹窗内只应存在一处读取
|
||
// (积分购买弹窗是同构的另一套初始化,各自一处,不在此断言范围)。
|
||
const chargeInit = creditsJs.slice(
|
||
creditsJs.indexOf('function initBalanceCharge'),
|
||
creditsJs.indexOf('function initTransfer'),
|
||
);
|
||
assert.strictEqual(
|
||
[...chargeInit.matchAll(/form\.querySelector\('input\[name="payment_method"\]'\)/g)].length,
|
||
1,
|
||
'the submit handler must not re-read payment_method (dead duplicate guard)',
|
||
);
|
||
});
|