From 0d617d88c92dd63b42945b65292a7b0e9bdaceaa Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 19 Sep 2026 11:34:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(pay):=20=E4=BD=99=E9=A2=9D=E5=85=85?= =?UTF-8?q?=E5=80=BC=E9=A2=84=E8=AE=BE=E6=A1=A3=E4=BD=8D=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=A2=AB=20custom=5Fprice=20=E5=8F=82=E6=95=B0=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E6=8B=A6=E6=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 选中任一预设充值档位点「立即支付」,必然弹「无效参数: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 未改动。 --- assets/js/credits.js | 15 +++--- src/Credits/BalanceServiceRest.php | 20 +++++--- .../balance-charge-modal.test.cjs | 51 +++++++++++++++++++ 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/assets/js/credits.js b/assets/js/credits.js index 582638fd..2280e585 100644 --- a/assets/js/credits.js +++ b/assets/js/credits.js @@ -355,12 +355,18 @@ }); // Zibll data-for/data-value 选中模型:余额档位(.vip-product.product-box) // 选中变化不产生 change 事件(隐藏 input 由程序赋值),需显式重置幂等键。 + var customInput = modal.querySelector('input[name="custom_price"]'); function selectProduct(value) { modal.querySelectorAll('[data-for="balance_product"]').forEach(function (el) { el.classList.toggle('active', el.getAttribute('data-value') === value); }); var hidden = modal.querySelector('input[name="balance_product"]'); if (hidden) hidden.value = value; + // 选中预设档位即代表放弃自定义金额:清空输入框,否则框里残留的数字会在 + // 提交时按「合法自定义金额」把 package 顶回 custom,静默覆盖刚点的档位。 + if (value !== 'custom' && customInput) { + customInput.value = ''; + } if (form) resetOperationKey(form); } modal.querySelectorAll('[data-for="balance_product"]').forEach(function (el) { @@ -368,7 +374,6 @@ selectProduct(el.getAttribute('data-value')); }); }); - var customInput = modal.querySelector('input[name="custom_price"]'); if (customInput) { customInput.addEventListener('input', function () { if (String(customInput.value || '').trim() !== '') { @@ -444,12 +449,6 @@ notyf('请选择或输入充值金额', 'danger'); return; } - var methodEl = form.querySelector('input[name="payment_method"]'); - var method = methodEl ? methodEl.value : ''; - if (!method) { - notyf('暂无可用支付方式', 'danger'); - return; - } busy = true; notyf('正在创建充值订单…', 'load', '', 'aksrt-balance-charge-toast'); api('/pay/balance/charge', { @@ -457,6 +456,8 @@ headers: { 'Idempotency-Key': operationKey(form) }, body: JSON.stringify({ package: pkg, + // 预设档位下该字段为空串:服务端仅在 package=custom 时消费它, + // 空串表示「未使用自定义金额」,不是非法值。 custom_price: custom, payment_method: method }) diff --git a/src/Credits/BalanceServiceRest.php b/src/Credits/BalanceServiceRest.php index 56a10b3b..0ff7be39 100644 --- a/src/Credits/BalanceServiceRest.php +++ b/src/Credits/BalanceServiceRest.php @@ -30,7 +30,13 @@ trait BalanceServiceRest { 'permission_callback' => [$this, 'check_asset_rest'], 'args' => [ 'package' => ['type' => 'string', 'default' => 'custom', 'sanitize_callback' => 'sanitize_text_field'], - 'custom_price' => ['type' => 'string', 'default' => '0', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'], + // 仅 package=custom 时被消费;选预设档位时前端送空串,空串即 + //「未使用自定义金额」,属正常取值而非非法值。金额格式与区间的 + // 唯一裁决在 create_charge_order()(AssetAmount::balance + custom_limit), + // 这里再挂一份 pattern 会形成第二套口径:WP 的 default 只在键缺失时 + // 生效,键存在而值为空串时 pattern 会直接判失败,把预设档位误报为 + // rest_invalid_param(无效参数:custom_price)。 + 'custom_price' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field'], 'payment_method' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key'], ], ], @@ -137,13 +143,11 @@ trait BalanceServiceRest { if (is_wp_error($operation_key)) { return $operation_key; } - $params = $request->get_json_params(); - if (!is_array($params)) { - $params = $request->get_params(); - } - $package_key = sanitize_text_field((string) ($params['package'] ?? 'custom')); - $custom_price = isset($params['custom_price']) ? (string) $params['custom_price'] : '0'; - $payment_method = sanitize_key((string) ($params['payment_method'] ?? 'alipay')); + // 单一取值口径:直接读注册时声明的 args(type / default / sanitize_callback + // 均已声明),不再手工重读 JSON 体并复刻一遍清洗逻辑。 + $package_key = (string) $request->get_param('package'); + $custom_price = (string) $request->get_param('custom_price'); + $payment_method = (string) $request->get_param('payment_method'); $result = $this->create_charge_order($user_id, $package_key, $custom_price, $payment_method, $operation_key); if (is_wp_error($result)) { diff --git a/tests/widget-system/balance-charge-modal.test.cjs b/tests/widget-system/balance-charge-modal.test.cjs index 3b94b7c8..d6e001ce 100644 --- a/tests/widget-system/balance-charge-modal.test.cjs +++ b/tests/widget-system/balance-charge-modal.test.cjs @@ -27,6 +27,7 @@ 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/); @@ -67,3 +68,53 @@ test('开关自包含:不依赖 jQuery/Bootstrap modal 运行时', () => { 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)', + ); +});