revert 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
版本号,无需手工清缓存。
This commit is contained in:
2026-09-19 03:25:12 +00:00
parent 633ab7abe3
commit ce21cc2b9b
7 changed files with 34 additions and 191 deletions
@@ -1,151 +0,0 @@
/**
* 充值金额为唯一真值契约(第 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 计价');
});