From fa476af70eaba9c0628c9653df108c0f9895a303 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 18 Sep 2026 23:56:31 +0800 Subject: [PATCH] =?UTF-8?q?refactor(pay):=20=E5=85=85=E5=80=BC=E5=95=86?= =?UTF-8?q?=E5=93=81=E4=BB=A5=E5=85=85=E5=80=BC=E9=87=91=E9=A2=9D=E4=B8=BA?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E7=9C=9F=E5=80=BC=EF=BC=8C=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E9=94=80=E5=94=AE=E9=87=91=E9=A2=9D=E4=B8=8E=E6=8A=98=E6=89=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 充值商品行此前是双值模型:price 充值金额决定到账、pay_price 销售金额决定支付, pay_price 为 0 时回落到 price,否则即「付更少拿更多」的折扣。该模型把三处耦合在 一起——后台 schema 暴露销售金额输入、线格式首段携带支付额、下单时计价与到账分离。 只改其中一处就会出现「后台不再可配但前端仍在展示折扣」或「展示已合并但下单仍按 旧价扣款」的静默不一致,故本次三层一并收口,充值金额即实际支付金额。 后台 schema 有两份且必须同步:SPA 侧 recharge.js 负责渲染表单,PHP 侧 SchemaPayment 负责默认值与清洗。只改一份会让界面不再可配而默认值仍在写 pay_price。 线格式三段 "支付|到账|标题" 保持不动,仅为兼容历史已存的字符串配置:支付段不再读取 任何配置来源,恒等于充值金额,故历史配置里的折扣价会被忽略。旧数据无需迁移,pay_price 键留在库中只是不再被读取,从重建后的后台保存一次即自然消失。 行为变化(需知悉): - 已配置折扣的站点,其用户将按充值金额全额支付,即对这些用户是涨价。这是「不做优惠 和促销」的必然结果。 - parse_packages 的校验口径随之变松:有效性只由到账段决定,故 "abc|10|x" 这类首段 非法、到账段合法的行会被解析为 10 元档,而旧实现两段都作金额校验、会丢弃。理由是 支付段已不表达任何含义,因一个无用字段是垃圾就丢弃一条本来有效的档位只会让运营困惑。 既有数据不受影响——旧格式化器写出的首段恒为数字,只有手改出的脏数据才可能命中。 该取舍已写入方法文档。 - recharge.js / SchemaPayment:删除 pay_price 字段与三档默认值中的该键,help 去掉 「同时支持设置折扣」 - PaymentRuntime::format_product_lines:不再读取 pay_price,支付段由充值金额充当 - BalanceServiceCharge::parse_packages:不再产出 pay_price 键,金额唯一来源为到账段 - BalanceServiceCharge::create_charge_order:支付额与到账额同源于 price - credits-wallet.php:移除销售金额行、「省X」折扣徽标及仅服务折扣的差额计算 - BalanceServiceRest:仅注释改写,说明积分路径的 pay_price 是售价而非折扣 - 新增 tests/widget-system/recharge-product-price-contract.test.cjs:5 条跨层契约 范围边界:积分商品 pay_points_product 的 pay_price 是「积分|支付|标签」里的实际售价, 本身不含折扣语义,保留不动。契约测试以正向断言固定该边界,避免日后被误当作同类问题 一并删掉。 验证:新增契约测试 5 项通过;node --test 全量 375 项 / 375 通过(原 370 项);PHPUnit 44 项 / 106 断言通过;改动的 PHP 文件 php -l 全部通过。变异验证:把 create_charge_order 改回按 pay_price 计价、把 pay_price 重新注入模板,对应断言分别转红,证明回归测试拦得 住回退。另以反射调用真实代码做端到端执行验证 11 项全过,覆盖结构化数组 -> 线格式 -> 商品数组全链路、数组内残存 pay_price 被忽略、历史折扣字符串(98|100|旧折扣档 取 100)、 到账段为 0 或非法时跳过等边界。 部署提示:assets/admin/build 为部署时构建、未入库,后台表单的改动需在目标机执行 cd assets/admin && npm ci && npm run build 后才会生效。前端 credits.js 走 filemtime 版本号,无需手工清缓存。 --- .../src/schema/sections/payment/recharge.js | 15 +- .../user-center-renderers/credits-wallet.php | 8 +- src/Admin/Schema/SchemaPayment.php | 9 +- src/Credits/BalanceServiceCharge.php | 28 ++-- src/Credits/BalanceServiceRest.php | 3 +- src/Settings/PaymentRuntime.php | 11 +- .../recharge-product-price-contract.test.cjs | 151 ++++++++++++++++++ 7 files changed, 191 insertions(+), 34 deletions(-) create mode 100644 tests/widget-system/recharge-product-price-contract.test.cjs diff --git a/assets/admin/src/schema/sections/payment/recharge.js b/assets/admin/src/schema/sections/payment/recharge.js index 3925803e..e624e7d7 100644 --- a/assets/admin/src/schema/sections/payment/recharge.js +++ b/assets/admin/src/schema/sections/payment/recharge.js @@ -16,9 +16,9 @@ export const rechargeSchema = { pay_balance_transfer_service_charge: 0, pay_balance_transfer_desc: '', pay_balance_product: [ - { price: 50, pay_price: 0, tag: '', tag_class: '' }, - { price: 100, pay_price: 98, tag: '', tag_class: '' }, - { price: 200, pay_price: 188, tag: '推荐', tag_class: 'jb-cyan' }, + { price: 50, tag: '', tag_class: '' }, + { price: 100, tag: '', tag_class: '' }, + { price: 200, tag: '推荐', tag_class: 'jb-cyan' }, ], pay_balance_product_custom_s: true, pay_balance_product_custom_limit: 9999, @@ -83,14 +83,13 @@ export const rechargeSchema = { type: 'group', button_title: '添加充值金额选项', default: [ - { price: 50, pay_price: 0, tag: '', tag_class: '' }, - { price: 100, pay_price: 98, tag: '', tag_class: '' }, - { price: 200, pay_price: 188, tag: '推荐', tag_class: 'jb-cyan' }, + { price: 50, tag: '', tag_class: '' }, + { price: 100, tag: '', tag_class: '' }, + { price: 200, tag: '推荐', tag_class: 'jb-cyan' }, ], - help: '用户充值时候,可快速选择的充值金额,同时支持设置折扣', + help: '用户充值时候,可快速选择的充值金额;充值金额即实际支付金额', fields: [ { key: 'price', label: '充值金额', type: 'number', default: 100, min: 0, suffix: '元' }, - { key: 'pay_price', label: '销售金额', type: 'number', default: 0, min: 0, suffix: '元', help: '选填,设置不同的销售金额实现充值折扣的功能,设置为0则与充值金额一致' }, { key: 'tag', label: '小标签', type: 'input', default: '', help: '支持HTML,请注意控制长度' }, { key: 'tag_class', label: '标签颜色类', type: 'input', default: '', help: '主题标签颜色类,如 jb-cyan / jb-pink' }, ], diff --git a/inc/helpers/user-center-renderers/credits-wallet.php b/inc/helpers/user-center-renderers/credits-wallet.php index a026f616..7784c3ef 100644 --- a/inc/helpers/user-center-renderers/credits-wallet.php +++ b/inc/helpers/user-center-renderers/credits-wallet.php @@ -151,17 +151,13 @@ $charge_custom_block = static function () use ($mark, $charge_mark_html, $charge
- $pkg) : - $package_credit = $normalize_balance($pkg['price'] ?? ''); - $package_pay = $normalize_balance($pkg['pay_price'] ?? ''); - $package_saving_minor = max(0, $package_credit->minor() - $package_pay->minor()); - ?> + $pkg) : ?>
+
-
0) : ?>
diff --git a/src/Admin/Schema/SchemaPayment.php b/src/Admin/Schema/SchemaPayment.php index 38b941f1..ac4cb1d3 100644 --- a/src/Admin/Schema/SchemaPayment.php +++ b/src/Admin/Schema/SchemaPayment.php @@ -142,12 +142,11 @@ class SchemaPayment { ]], ['key' => 'products', 'title' => '充值商品选项', 'fields' => [ ['key' => 'pay_balance_product', 'label' => '充值金额选项', 'type' => 'group', 'button_title' => '添加充值金额选项', 'default' => [ - ['price' => 50, 'pay_price' => 0, 'tag' => '', 'tag_class' => ''], - ['price' => 100, 'pay_price' => 98, 'tag' => '', 'tag_class' => ''], - ['price' => 200, 'pay_price' => 188, 'tag' => '推荐', 'tag_class' => 'jb-cyan'], - ], 'help' => '用户充值时候,可快速选择的充值金额,同时支持设置折扣', 'fields' => [ + ['price' => 50, 'tag' => '', 'tag_class' => ''], + ['price' => 100, 'tag' => '', 'tag_class' => ''], + ['price' => 200, 'tag' => '推荐', 'tag_class' => 'jb-cyan'], + ], 'help' => '用户充值时候,可快速选择的充值金额;充值金额即实际支付金额', 'fields' => [ ['key' => 'price', 'label' => '充值金额', 'type' => 'number', 'default' => 100, 'min' => 0, 'suffix' => '元'], - ['key' => 'pay_price', 'label' => '销售金额', 'type' => 'number', 'default' => 0, 'min' => 0, 'suffix' => '元', 'help' => '选填,设置不同的销售金额实现充值折扣的功能,设置为0则与充值金额一致'], ['key' => 'tag', 'label' => '小标签', 'type' => 'input', 'default' => '', 'help' => '支持HTML,请注意控制长度'], ['key' => 'tag_class', 'label' => '标签颜色类', 'type' => 'input', 'default' => '', 'help' => '主题标签颜色类,如 jb-cyan / jb-pink'], ]], diff --git a/src/Credits/BalanceServiceCharge.php b/src/Credits/BalanceServiceCharge.php index e0b379f4..37e7f13b 100644 --- a/src/Credits/BalanceServiceCharge.php +++ b/src/Credits/BalanceServiceCharge.php @@ -186,7 +186,18 @@ trait BalanceServiceCharge { /** - * @return array + * 充值商品行解析("支付|到账|标题")。支付段是历史线格式的遗留位置,不再参与 + * 计算:充值金额(到账段)即实际支付金额,不设折扣。保留对三段的容忍是为了 + * 让历史已存的字符串配置仍能解析,其支付段会被忽略而非报错。 + * + * 有效性只由到账段决定:到账段必须是正数金额,否则整行跳过。支付段的内容 + * 不再校验——它已不表达任何含义,因一个无用字段是垃圾就丢弃一条本来有效的 + * 充值档位只会让运营困惑。注意这与本方法旧行为有差异:旧实现两段都作金额 + * 校验,故 "abc|10|x" 这类首段非法的行此前会被丢弃,现在会被解析为 10 元档。 + * 该差异不影响既有数据——旧格式化器写出的首段恒为数字,手改出的垃圾行才可能 + * 命中,此时按到账段生效是更合理的降级。 + * + * @return array */ public function parse_packages(string $raw): array { $out = []; @@ -202,21 +213,19 @@ trait BalanceServiceCharge { continue; } try { - $pay = \AKSRT\Ledger\AssetAmount::balance((string) $parts[0]); $credit = \AKSRT\Ledger\AssetAmount::balance((string) $parts[1]); } catch (\InvalidArgumentException $e) { continue; } $title = $parts[2] ?? ''; - if ($pay->minor() <= 0 || $credit->minor() <= 0) { + if ($credit->minor() <= 0) { continue; } $out[] = [ - 'key' => (string) $i, - 'pay_price' => $pay->decimal(), - 'price' => $credit->decimal(), - 'title' => $title !== '' ? $title : sprintf(__('充值 %s 元', 'aksrt'), $credit->decimal()), - 'tag' => $title, + 'key' => (string) $i, + 'price' => $credit->decimal(), + 'title' => $title !== '' ? $title : sprintf(__('充值 %s 元', 'aksrt'), $credit->decimal()), + 'tag' => $title, ]; $i++; } @@ -311,7 +320,8 @@ trait BalanceServiceCharge { return new \WP_Error('aksrt_invalid_package', __('充值套餐无效', 'aksrt'), ['status' => 400]); } try { - $pay_amount = \AKSRT\Ledger\AssetAmount::balance((string) $found['pay_price']); + // 充值金额即实际支付金额:不做折扣,故支付额与到账额同源。 + $pay_amount = \AKSRT\Ledger\AssetAmount::balance((string) $found['price']); $credit_amount = \AKSRT\Ledger\AssetAmount::balance((string) $found['price']); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_invalid_package', __('充值套餐金额无效', 'aksrt'), ['status' => 400]); diff --git a/src/Credits/BalanceServiceRest.php b/src/Credits/BalanceServiceRest.php index fb735666..8b12e9d5 100644 --- a/src/Credits/BalanceServiceRest.php +++ b/src/Credits/BalanceServiceRest.php @@ -324,7 +324,8 @@ trait BalanceServiceRest { * Buy points with cash-wallet balance (积分购买弹窗 · 余额支付). * * Body: { product: 'custom'|, custom?: } - * 自定义按汇率折算支付金额;商品按配置销售金额支付(参考主题同款语义)。 + * 自定义按汇率折算支付金额;预设商品按配置的售价支付(该售价即 pay_price, + * 是积分档位的唯一价格,不含折扣语义,与余额充值商品的双值模型不同)。 */ public function rest_exchange_balance_for_points(\WP_REST_Request $request) { if (!$this->balance_feature_enabled()) { diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php index de4d1024..f2becde7 100644 --- a/src/Settings/PaymentRuntime.php +++ b/src/Settings/PaymentRuntime.php @@ -607,7 +607,10 @@ class PaymentRuntime { /** * Format recharge product rows (reference-theme group shape: - * price 充值金额 / pay_price 销售金额 / tag 小标签) into "支付|到账|标题" lines. + * price 充值金额 / tag 小标签) into "支付|到账|标题" lines. + * + * 三段线格式保留「支付」前导段仅为兼容历史已存的字符串配置:该段不再读取 + * 任何配置来源,而是恒等于充值金额——充值金额即实际支付金额,不设折扣。 * * @param array $products */ @@ -618,17 +621,15 @@ class PaymentRuntime { continue; } $price = max(0, (float) ($row['price'] ?? 0)); - $pay_price = max(0, (float) ($row['pay_price'] ?? 0)); - $pay = $pay_price > 0 ? $pay_price : $price; $title = trim((string) ($row['tag'] ?? '')); - $lines[] = $pay . '|' . $price . '|' . $title; + $lines[] = $price . '|' . $price . '|' . $title; } return implode("\n", $lines); } /** * Format points product rows (reference-theme group shape: - * points 积分 / pay_price 销售金额 / tag 小标签) into "积分|支付|标签" lines. + * points 积分 / pay_price 售价 / tag 小标签) into "积分|支付|标签" lines. * * @param array $products */ diff --git a/tests/widget-system/recharge-product-price-contract.test.cjs b/tests/widget-system/recharge-product-price-contract.test.cjs new file mode 100644 index 00000000..a6bbb9dd --- /dev/null +++ b/tests/widget-system/recharge-product-price-contract.test.cjs @@ -0,0 +1,151 @@ +/** + * 充值金额为唯一真值契约(第 22 轮:移除「销售金额」与充值折扣)。 + * + * 背景:充值商品行此前是 price 充值金额 / pay_price 销售金额 的双值模型, + * pay_price 为 0 时回落到 price,否则实现「付更少拿更多」的折扣。该模型带来 + * 三处必须同时成立的耦合:后台 schema 暴露销售金额输入、线格式首段携带支付额、 + * 下单时按 pay_price 计价与到账分离。只改其中一处就会出现「后台不再可配但 + * 前端仍在展示折扣」或「展示已合并但下单仍按旧价扣款」的静默不一致。 + * + * 因此本测试把「充值金额即实际支付金额」当作跨层不变量来锁: + * 1. 后台 schema(SPA 侧 JS 与 PHP 镜像)不得再暴露销售金额字段与折扣文案; + * 2. 线格式化与解析不得再读取或产出 pay_price; + * 3. 下单的支付额与到账额必须同源于 price; + * 4. 前端充值弹窗不得再输出销售金额行与「省X」折扣徽标。 + * + * 范围边界:积分商品(pay_points_product / parse_points_packages)的 pay_price + * 是「积分|支付|标签」里的实际售价,本身不含折扣语义,故保留不动——本测试用 + * 正向断言固定住这一边界,避免日后被误当作同类问题一并删掉。 + * + * 注意:断言一律避免直接比对含反斜杠的整串。JS 字符串字面量与 PHP 源码的 + * 反斜杠层数不同(PHP 单引号串写 \\d 才得到 \d),用 includes() 比对含反斜杠 + * 的整串会静默失败;故凡涉及命名空间或正则的比对,一律改用不含反斜杠的片段 + * 或对去反斜杠后的文本做判断。切片标记也只用不含换行的锚点,避免 CRLF/LF + * 差异导致 indexOf 落空。 + */ +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 chargeSrc = read('src', 'Credits', 'BalanceServiceCharge.php'); +const chargeRest = read('src', 'Credits', 'BalanceServiceRest.php'); +const paymentRuntime = read('src', 'Settings', 'PaymentRuntime.php'); +const schemaPayment = read('src', 'Admin', 'Schema', 'SchemaPayment.php'); +const spaRecharge = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'recharge.js'); + +/** 取 [startMarker, endMarker) 之间的源码片段;标记缺失即失败,避免切片静默落空。 */ +const sliceBetween = (source, startMarker, endMarker) => { + const start = source.indexOf(startMarker); + assert.ok(start >= 0, `源码中未找到起始标记:${startMarker}`); + const end = endMarker === null ? source.length : source.indexOf(endMarker, start); + assert.ok(end > start, `起始标记之后未找到结束标记:${endMarker}`); + return source.slice(start, end); +}; + +/** + * 自方法签名起取完整方法体,按花括号配平截断。 + * + * 不能简单用「下一个方法签名」当结束标记:方法之间夹着下一个方法的文档注释, + * 而该注释里往往就写着被移除字段的名字(例如积分行的注释提到 pay_price), + * 会把本方法的断言区间污染成假失败。 + */ +const methodBody = (source, marker) => { + const start = source.indexOf(marker); + assert.ok(start >= 0, `源码中未找到方法:${marker}`); + const open = source.indexOf('{', start); + assert.ok(open > start, `方法签名之后未找到左花括号:${marker}`); + let depth = 0; + for (let i = open; i < source.length; i += 1) { + if (source[i] === '{') depth += 1; + else if (source[i] === '}') { + depth -= 1; + if (depth === 0) return source.slice(start, i + 1); + } + } + assert.ok(false, `方法花括号未配平:${marker}`); +}; + +test('后台 schema 不再暴露充值商品的销售金额字段', () => { + // SPA 侧:该文件只描述充值商品,故可整体断言。 + assert.doesNotMatch(spaRecharge, /pay_price/, 'SPA 充值 schema 不应再出现 pay_price'); + assert.doesNotMatch(spaRecharge, /销售金额/, 'SPA 充值 schema 不应再出现销售金额字段'); + assert.doesNotMatch(spaRecharge, /折扣/, 'SPA 充值 schema 不应再宣传折扣'); + + // 字段收窄为 充值金额 / 小标签 / 标签颜色类。 + const spaGroup = sliceBetween( + spaRecharge, + "key: 'pay_balance_product',", + "key: 'pay_balance_product_custom_s'", + ); + assert.match(spaGroup, /key: 'price'/); + assert.match(spaGroup, /key: 'tag'/); + assert.match(spaGroup, /key: 'tag_class'/); + + // PHP 镜像侧:SchemaPayment 还承载其他支付字段(如知支付的执行价),须限定区间。 + const phpGroup = sliceBetween( + schemaPayment, + "'key' => 'pay_balance_product',", + "'key' => 'pay_balance_product_custom_s'", + ); + assert.doesNotMatch(phpGroup, /pay_price/, 'PHP 充值商品分组不应再出现 pay_price'); + assert.doesNotMatch(phpGroup, /销售金额/, 'PHP 充值商品分组不应再出现销售金额字段'); + + // 两侧默认值必须一致,否则 SPA 渲染与 PHP 默认/清洗会脱节。 + for (const amount of [50, 100, 200]) { + assert.ok(spaRecharge.includes(`{ price: ${amount},`), `SPA 默认档位应含 price: ${amount}`); + assert.ok(phpGroup.includes(`['price' => ${amount},`), `PHP 默认档位应含 price => ${amount}`); + } +}); + +test('线格式化不再读取销售金额,支付段恒等于充值金额', () => { + const rechargeLines = methodBody(paymentRuntime, 'private static function format_product_lines'); + assert.doesNotMatch(rechargeLines, /pay_price/, '充值行格式化不应再读取 pay_price'); + // 三段线格式保留支付前导段仅为兼容历史字符串配置,其值必须等于充值金额。 + assert.ok( + rechargeLines.includes("$price . '|' . $price . '|' . $title"), + '充值行的支付段必须由充值金额直接充当', + ); +}); + +test('线解析不再产出销售金额,下单支付额与到账额同源于充值金额', () => { + const parseRecharge = methodBody(chargeSrc, 'public function parse_packages'); + assert.doesNotMatch(parseRecharge, /pay_price/, 'parse_packages 不应再产出 pay_price 键'); + // 历史字符串的支付段仍被容忍(不报错),但取值只用第 2 段(到账段)。 + assert.ok( + parseRecharge.includes('AssetAmount::balance((string) $parts[1])'), + 'parse_packages 应以到账段作为唯一金额来源', + ); + assert.ok( + !parseRecharge.includes('$parts[0]'), + 'parse_packages 不应再消费历史线格式的支付段', + ); + + const createOrder = methodBody(chargeSrc, 'public function create_charge_order'); + assert.doesNotMatch(createOrder, /pay_price/, '下单流程不应再引用 pay_price'); + const priceRefs = createOrder.match(/\$found\['price'\]/g) || []; + assert.equal(priceRefs.length, 2, '充值下单的支付额与到账额都必须取充值金额'); +}); + +test('前端充值弹窗不再输出销售金额与折扣徽标', () => { + assert.doesNotMatch(wallet, /pay_price/, '充值弹窗不应再渲染 pay_price'); + assert.doesNotMatch(wallet, /package_saving_minor/, '折扣差额计算应已移除'); + assert.doesNotMatch(wallet, /省%s/, '「省X」折扣徽标应已移除'); + // 金额只呈现一次:保留充值金额行,且不再有独立的销售金额行。 + assert.match(wallet, /
[\s\S]*?\$pkg\['price'\]/); +}); + +test('范围边界:积分商品的 pay_price 是售价而非折扣,必须保留', () => { + const pointsParse = methodBody(chargeSrc, 'public function parse_points_packages'); + assert.match(pointsParse, /'pay_price' => \$pay->decimal\(\)/, '积分商品行仍应产出 pay_price'); + + const pointsLines = methodBody(paymentRuntime, 'private static function format_points_product_lines'); + assert.match(pointsLines, /\$row\['pay_price'\]/, '积分商品行格式化仍应读取 pay_price'); + + // 积分兑换下单同样以 pay_price 计价(该值是售价本身,不存在合并问题)。 + assert.match(chargeRest, /\$found\['pay_price'\]/, '积分兑换下单仍应以 pay_price 计价'); +});