Files
wordpress-theme-aksrt/tests/widget-system/recharge-product-price-contract.test.cjs
T
kate522 fa476af70e refactor(pay): 充值商品以充值金额为唯一真值,移除销售金额与折扣
充值商品行此前是双值模型: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
版本号,无需手工清缓存。
2026-09-18 23:56:31 +08:00

152 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 充值金额为唯一真值契约(第 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, /<div class="em14">[\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 计价');
});