From c1a0eade38216fbcfbf603f7bd4690a07555d66a Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 18 Sep 2026 23:41:27 +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=80=89=E4=B8=AD=E9=A2=84=E8=AE=BE=E6=A1=A3=E4=BD=8D?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=9B=A0=E7=A9=BA=20custom=5Fprice=20?= =?UTF-8?q?=E8=A2=AB=20REST=20=E6=8B=92=E7=BB=9D?= 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' => ['default' => '0', 'pattern' => '^(0|[1-9]\d*)(?:\.\d{1,2})?$'] 而前端提交时无条件带上该字段(custom_price: custom),选预设档位时 custom 恒为 '',于是必然校验失败。另一个更隐蔽的变体:后台若关掉「自定义充值金额」,模板里 根本不渲染该输入框,customEl 为 null,custom 仍是 '',同样报错——也就是说这个 缺陷在只放开预设档位的配置下 100% 复现。 同文件的积分兑换路径(/pay/balance/points-exchange)早已写成 custom: validCustom ? custom : '0',余额充值这条只是漏了同样的处理。 - BalanceServiceRest:custom_price 的 pattern 改为整体可选分组 ^(?:(?:0|[1-9]\d*)(?:\.\d{1,2})?)?$,显式放行空串;rest_charge_create 把空串 归一化为 '0',与参数声明的 default 语义对齐,避免 AssetAmount::balance() 拿到 空串后抛 balance_decimal_invalid - credits.js:非自定义金额时显式送 '0',与积分兑换路径写法一致 - 契约测试补 4 条断言:模式可选、允许空串、回调归一化、前端送 '0' 两层都改是有意的:JS 会被浏览器与 CDN 缓存,旧缓存仍会发空串,后端放行兜住; 后端放行则保证任何客户端都不再被这个字段卡住。放宽仅针对空串,金额格式校验 不放松——007、abc、-1、1e3、1.234 与带首尾空格的值仍一律拒绝。 验证:新 pattern 逐例实测,''、0、1、9.99、9999.99 放行,007、abc、-1、1e3、 1.234、带空格全部拒绝;变异验证把 pattern 改回旧写法后该用例立刻转红,证明回归 测试确实拦得住此缺陷;node --test 全量 370 项 / 370 通过;PHPUnit 44 项 / 106 断言通过。断言未直接比对含反斜杠的整串——JS 字面量与 PHP 单引号串的反斜杠层数 不同会静默比对失败,故拆为不含反斜杠的片段。 --- assets/js/credits.js | 4 +++- src/Credits/BalanceServiceRest.php | 11 +++++++-- .../asset-ledger-contract.test.cjs | 24 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/assets/js/credits.js b/assets/js/credits.js index 582638fd..1cff6d57 100644 --- a/assets/js/credits.js +++ b/assets/js/credits.js @@ -457,7 +457,9 @@ headers: { 'Idempotency-Key': operationKey(form) }, body: JSON.stringify({ package: pkg, - custom_price: custom, + // 预设档位路径不消费该字段;送空串会被 REST 的 pattern 拒绝 + // (WP 仅在键缺失时应用 default),故非自定义金额时显式送 '0'。 + custom_price: validCustom ? custom : '0', payment_method: method }) }).then(function (r) { diff --git a/src/Credits/BalanceServiceRest.php b/src/Credits/BalanceServiceRest.php index 56a10b3b..fb735666 100644 --- a/src/Credits/BalanceServiceRest.php +++ b/src/Credits/BalanceServiceRest.php @@ -30,7 +30,10 @@ 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})?$'], + // 选中预设档位时 custom_price 无意义,但前端仍会带上该键(空串或不存在), + // 而 WP REST 只在「键缺失」时应用 default,空串会直接进 pattern 校验, + // 因此模式必须显式放行空串,否则报 rest_invalid_param(无效参数:custom_price)。 + 'custom_price' => ['type' => 'string', 'default' => '0', 'pattern' => '^(?:(?:0|[1-9]\\d*)(?:\\.\\d{1,2})?)?$'], 'payment_method' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key'], ], ], @@ -142,7 +145,11 @@ trait BalanceServiceRest { $params = $request->get_params(); } $package_key = sanitize_text_field((string) ($params['package'] ?? 'custom')); - $custom_price = isset($params['custom_price']) ? (string) $params['custom_price'] : '0'; + // 空串与键缺失等价(预设档位路径不消费本字段),归一化为 '0' 以对齐参数声明的 default。 + $custom_price = trim((string) ($params['custom_price'] ?? '')); + if ($custom_price === '') { + $custom_price = '0'; + } $payment_method = sanitize_key((string) ($params['payment_method'] ?? 'alipay')); $result = $this->create_charge_order($user_id, $package_key, $custom_price, $payment_method, $operation_key); diff --git a/tests/widget-system/asset-ledger-contract.test.cjs b/tests/widget-system/asset-ledger-contract.test.cjs index 8b3bb079..f70be396 100644 --- a/tests/widget-system/asset-ledger-contract.test.cjs +++ b/tests/widget-system/asset-ledger-contract.test.cjs @@ -290,6 +290,30 @@ test('asset purchases share the order center and refresh every wallet surface', assert.match(balanceRest, /'order_number'\s*=>\s*\$order_number/) assert.match(chargeCard, /self::TYPE_CREDITS[\s\S]*?ORDER_PRODUCT_POINTS/) + // 选中预设档位时 custom_price 既可能「键存在但值为空串」(自定义输入框存在但未填), + // 也可能根本不存在于表单(后台关闭了自定义金额)。WP REST 仅在键缺失时应用 default, + // 空串会被送进 pattern 校验并被拒,表现为 rest_invalid_param「无效参数:custom_price」。 + // 因此:模式必须放行空串,回调必须把空串归一化为声明的 default,前端必须显式送 '0'。 + // + // 注意:不要拿含反斜杠的整串去 includes()——JS 字面量与 PHP 单引号串的转义层数不同, + // 会静默比对失败。这里拆成不含反斜杠的片段断言,语义等价且与转义层数无关。 + assert.ok( + balanceRest.includes("'pattern' => '^(?:(?:0|[1-9]"), + 'custom_price pattern must be optional (the old bare pattern rejected the empty string)', + ) + assert.ok( + balanceRest.includes("{1,2})?)?$'"), + 'custom_price pattern must allow the empty string so preset packages pass validation', + ) + assert.ok( + balanceRest.includes("if ($custom_price === '') {"), + 'rest_charge_create must normalise an empty custom_price to the declared default', + ) + assert.ok( + creditsJs.includes("custom_price: validCustom ? custom : '0',"), + 'balance charge must send 0 rather than an empty string when a preset package is selected', + ) + assert.match(creditsJs, /function refreshAssetBalances\(/) assert.match(creditsJs, /querySelectorAll\('\[data-aksrt-wallet-value\]'/) assert.match(creditsJs, /querySelectorAll\('\[data-aksrt-points-value\]'/)