feat(vip): restructure membership settings into structured groups
Replace the pipe-delimited VIP configuration with structured groups and accordions, matching the reference theme (Zibll) 1:1. Settings: - 会员等级 keeps only the nav buttons and the two level switches, plus the "must enable level 1 first" guard. Level identity fields (display name, intro, benefits HTML, icon) and 会员商品 move into the 一级会员/二级会员 tabs of 会员购买选项, so each level is configured in one place. - Level tab titles bind to the configured display name via a new titleKey/titleFallback hook in AksFormRenderer. - 会员商品 is a repeater with exactly the reference fields: 执行价/原价/ 促销标签/标签颜色/会员有效时间/时长单位. The tag colour uses the full b+jb palette and now reaches the product card instead of a hardcoded jb-yellow. - 会员续费 gains the 自定义商品 type backed by per-level renew-product groups; the discount becomes a 0.1-9.9 slider with float sanitizing. - 会员升级 becomes the reference three-block accordion (month-to-month day-rate, jump to permanent, permanent to permanent), each with its own price, strikethrough price, promo tag and tag colour. - 积分兑换明细 is a repeater (points/level/duration/unit) where the duration accepts the literal Permanent. Runtime: - PaymentRuntime normalizes the structured groups into purchasable rows (money normalization, palette validation, zero-price and out-of-range rows dropped) and no longer emits any *_raw strings. - The catalog builder carries tag_class through and routes renewals to the renew-product group under 自定义商品. - The points-exchange order note carries time/unit so month durations use calendar math instead of a 30-day approximation. Dead code removed: parse_level_products, parse_packages, parse_upgrade_product_line and their pipe-splitting, plus nine zero-call PaymentRuntime proxy methods in HomeCms. No dual-format or migration path was added; SchemaProvider already falls back to defaults when a stored group value is not an array, so existing pipe strings degrade safely. Verified: php -l on every changed PHP file, 655 Node contract tests passing (14 new ones in vip-settings-structured-contract.test.cjs exercising the real schema build and runtime normalization via a PHP harness), and the admin SPA builds.
This commit is contained in:
1 parent
dcb80cff6e
commit
af8dccdc45
15 files changed
+1461
-489
No files matched your search
@@ -10,8 +10,9 @@
|
||||
* 会员介绍与开通会员更多内容默认值),SPA schema / PHP schema /
|
||||
* PaymentRuntime 回退三处一致——Settings::get_all() 不合并
|
||||
* schema 默认值,运行时回退即实际默认值;
|
||||
* 3) vip_renew_desc / vip_upgrade_desc 为死配置(AKSRT 无续费/升级
|
||||
* 卡渲染面,全仓零消费方),两份 schema 与 PaymentRuntime 均已删除;
|
||||
* 3) vip_renew_desc / vip_upgrade_desc 为参考主题的一句话简介字段,
|
||||
* 两份 schema 与 PaymentRuntime 均已定义,并由用户中心 /user/vip/
|
||||
* 档位卡渲染消费(vip-tab.php 按续费/升级动作取对应简介);
|
||||
* 4) 权益与卡密说明字段保存时保留 HTML(sanitize_type html),
|
||||
* 不再被 sanitize_textarea_field 剥掉标签。
|
||||
*/
|
||||
@@ -85,6 +86,18 @@ test('两个文案字段位于分区末尾的界面文案分组且不再属于
|
||||
// 「会员等级」组不再包含这两个字段。
|
||||
assert.doesNotMatch(slice(vipSchemaJs, "key: 'basic'", "key: 'benefit'"), /pay_user_vip_desc|pay_user_vip_more/);
|
||||
assert.doesNotMatch(slice(schemaPhp, "'key' => 'basic'", "'key' => 'benefit'"), /pay_user_vip_desc|pay_user_vip_more/);
|
||||
// 「会员等级」组只留导航按钮与两个等级开关;等级身份字段全部搬进
|
||||
// 「会员购买选项」的等级 Tab(名称/简介/权益/图标)。
|
||||
const basicJs = slice(vipSchemaJs, "key: 'basic'", "key: 'benefit'");
|
||||
const basicPhp = slice(schemaPhp, "'key' => 'basic'", "'key' => 'benefit'");
|
||||
for (const key of ['pay_user_vip_1_name', 'pay_user_vip_2_name', 'pay_user_vip_1_desc', 'pay_user_vip_2_desc', 'vip_1_img_icon', 'vip_2_img_icon']) {
|
||||
assert.ok(!basicJs.includes(key), `vip.js 会员等级组不应再含 ${key}`);
|
||||
assert.ok(!basicPhp.includes(key), `SchemaPayment.php 会员等级组不应再含 ${key}`);
|
||||
}
|
||||
assert.ok(basicJs.includes('pay_user_vip_1_s') && basicJs.includes('pay_user_vip_2_s'), 'vip.js 会员等级组应保留两个等级开关');
|
||||
assert.ok(basicPhp.includes("'key' => 'pay_user_vip_1_s'") && basicPhp.includes("'key' => 'pay_user_vip_2_s'"), 'SchemaPayment.php 会员等级组应保留两个等级开关');
|
||||
// 二级会员先于一级会员开启时的危险提示(参考主题同款)。
|
||||
assert.ok(basicJs.includes('vip_level_order_notice') && basicPhp.includes('vip_level_order_notice'));
|
||||
// 依赖:任一等级启用时显示(zibll pay_user_vip_1_s 依赖的双等级适配)。
|
||||
assert.ok(
|
||||
vipSchemaJs.includes("dependsOn: { any: [{ key: 'pay_user_vip_1_s', value: true }, { key: 'pay_user_vip_2_s', value: true }] }"),
|
||||
@@ -96,11 +109,19 @@ test('两个文案字段位于分区末尾的界面文案分组且不再属于
|
||||
);
|
||||
});
|
||||
|
||||
test('续费/升级说明死配置已从两份 schema 与运行时删除', () => {
|
||||
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp], ['PaymentRuntime.php', paymentRuntime]]) {
|
||||
assert.ok(!src.includes('vip_renew_desc'), `${name} 不应再含 vip_renew_desc`);
|
||||
assert.ok(!src.includes('vip_upgrade_desc'), `${name} 不应再含 vip_upgrade_desc`);
|
||||
test('续费/升级介绍字段存在且被档位卡消费', () => {
|
||||
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp]]) {
|
||||
assert.ok(src.includes('vip_renew_desc'), `${name} 应含 vip_renew_desc`);
|
||||
assert.ok(src.includes('vip_upgrade_desc'), `${name} 应含 vip_upgrade_desc`);
|
||||
}
|
||||
// 运行时回退默认值(get_all 不合并 schema 默认值,回退即实际默认值)。
|
||||
assert.ok(paymentRuntime.includes("$settings['vip_renew_desc'] ?? '立即续费会员,畅享VIP权益'"));
|
||||
assert.ok(paymentRuntime.includes("$settings['vip_upgrade_desc'] ?? '升级VIP会员,享更多会员权益'"));
|
||||
// 消费方:/user/vip/ 档位卡按动作取续费/升级简介,不得回退成死配置。
|
||||
assert.ok(vipTabPhp.includes("$vip_settings['renew']['desc']"));
|
||||
assert.ok(vipTabPhp.includes("$vip_settings['upgrade']['desc']"));
|
||||
assert.match(vipTabPhp, /\$card_action = 'renew'/);
|
||||
assert.match(vipTabPhp, /\$card_action = 'upgrade'/);
|
||||
});
|
||||
|
||||
test('文案默认值三处一致并对齐 zibll', () => {
|
||||
@@ -111,10 +132,12 @@ test('文案默认值三处一致并对齐 zibll', () => {
|
||||
assert.ok(vipSchemaJs.includes("pay_user_vip_2_desc: '尊享专属特权'"));
|
||||
assert.ok(vipSchemaJs.includes(`pay_user_vip_desc: '${DESC_DEFAULT}'`));
|
||||
assert.ok(vipSchemaJs.includes(`pay_user_vip_more: '${MORE_DEFAULT}'`));
|
||||
assert.ok(vipSchemaJs.includes(`default: '${EQUITY_DEFAULT}'`));
|
||||
// 权益默认值收敛为常量,两份 schema 与运行时共用同一份字面量。
|
||||
assert.ok(vipSchemaJs.includes(`const VIP_EQUITY_DEFAULT = '${EQUITY_DEFAULT}'`));
|
||||
assert.ok(vipSchemaJs.includes('default: VIP_EQUITY_DEFAULT'));
|
||||
// PHP schema 字段 default。
|
||||
assert.ok(schemaPhp.includes("'default' => '黄金会员'"));
|
||||
assert.ok(schemaPhp.includes("'default' => '钻石会员'"));
|
||||
assert.ok(schemaPhp.includes("'default' => $level === 1 ? '黄金会员' : '钻石会员'"));
|
||||
assert.ok(schemaPhp.includes(EQUITY_DEFAULT));
|
||||
assert.ok(schemaPhp.includes(`'default' => '${DESC_DEFAULT}'`));
|
||||
assert.ok(schemaPhp.includes(`'default' => '${MORE_DEFAULT}'`));
|
||||
// PaymentRuntime 运行时回退(get_all 不合并 schema 默认值,回退即实际默认值)。
|
||||
@@ -132,10 +155,9 @@ test('文案默认值三处一致并对齐 zibll', () => {
|
||||
});
|
||||
|
||||
test('权益与卡密说明字段保存时保留 HTML', () => {
|
||||
assert.ok(vipSchemaJs.includes("key: 'pay_user_vip_1_equity', label: '一级会员权益 HTML', type: 'textarea', sanitize_type: 'html'"));
|
||||
assert.ok(vipSchemaJs.includes("key: 'pay_user_vip_2_equity', label: '二级会员权益 HTML', type: 'textarea', sanitize_type: 'html'"));
|
||||
assert.ok(schemaPhp.includes("'key' => 'pay_user_vip_1_equity', 'label' => '一级会员权益 HTML', 'type' => 'textarea', 'sanitize_type' => 'html'"));
|
||||
assert.ok(schemaPhp.includes("'key' => 'pay_user_vip_2_equity', 'label' => '二级会员权益 HTML', 'type' => 'textarea', 'sanitize_type' => 'html'"));
|
||||
// 权益字段已并入「会员购买选项」的等级 Tab,标签统一为「会员权益 HTML」。
|
||||
assert.ok(vipSchemaJs.includes("key: `pay_user_vip_${level}_equity`, label: '会员权益 HTML', type: 'textarea', sanitize_type: 'html'"));
|
||||
assert.ok(schemaPhp.includes("['key' => 'pay_user_vip_' . $level . '_equity', 'label' => '会员权益 HTML', 'type' => 'textarea', 'sanitize_type' => 'html'"));
|
||||
assert.ok(rechargeJs.includes("key: 'pay_vip_pass_charge_desc', label: '卡密兑换会员说明', type: 'textarea', sanitize_type: 'html'"));
|
||||
assert.ok(schemaPhp.includes("'key' => 'pay_vip_pass_charge_desc', 'label' => '卡密兑换会员说明', 'type' => 'textarea', 'sanitize_type' => 'html'"));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* VIP 后台设置结构化契约(对照参考主题 zibll 1:1)。
|
||||
*
|
||||
* 硬约束:
|
||||
* 1) 会员商品 / 续费商品 / 升级价格 / 积分兑换明细全部为结构化 group 或
|
||||
* accordion,`|` 分隔的 textarea 配置已彻底移除(不写双路/迁移);
|
||||
* 2) 「会员等级」组只留导航按钮与两个等级开关,等级身份字段(显示名称/
|
||||
* 简介/权益HTML/图标URL)与「会员商品」同处「会员购买选项」的等级 Tab;
|
||||
* 3) 会员商品字段集恰好 6 项:执行价/原价/促销标签/标签颜色/会员有效时间/
|
||||
* 时长单位;标签颜色为参考主题 b+jb 全量 16 色;
|
||||
* 4) 续费价格类型含「自定义商品」,升级价格为参考主题同款三块 accordion;
|
||||
* 5) tag_class 一路透传到前端套餐卡;等级 Tab 标题由显示名称动态驱动。
|
||||
*/
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8');
|
||||
|
||||
const vipSchemaJs = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'vip.js');
|
||||
const schemaPhp = read('src', 'Admin', 'Schema', 'SchemaPayment.php');
|
||||
const paymentRuntime = read('src', 'Settings', 'PaymentRuntime.php');
|
||||
const packagesTrait = read('src', 'Content', 'VipServicePackages.php');
|
||||
const purchaseSource = read('src', 'Content', 'VipServicePurchase.php');
|
||||
const modalSource = read('src', 'Content', 'VipServiceModal.php');
|
||||
const rendererVue = read('assets', 'admin', 'src', 'components', 'form', 'AksFormRenderer.vue');
|
||||
const payVipJs = read('assets', 'js', 'pay-vip.js');
|
||||
const vipTabPhp = read('inc', 'helpers', 'user-center-renderers', 'vip-tab.php');
|
||||
const homeCms = read('src', 'Settings', 'HomeCms.php');
|
||||
|
||||
const TAG_PALETTE = [
|
||||
'b-red', 'b-yellow', 'b-cyan', 'b-blue', 'b-green', 'b-purple', 'b-black',
|
||||
'jb-red', 'jb-pink', 'jb-yellow', 'jb-cyan', 'jb-blue', 'jb-green', 'jb-purple',
|
||||
'jb-vip1', 'jb-vip2',
|
||||
];
|
||||
const PRODUCT_FIELDS = ['price', 'show_price', 'tag', 'tag_class', 'time', 'unit'];
|
||||
const UPGRADE_FIELDS = [
|
||||
'month_to_month_s', 'unit_price', 'unit_tag', 'unit_tag_class',
|
||||
'jump_s', 'jump_price', 'jump_show_price', 'jump_tag', 'jump_tag_class',
|
||||
'permanent_price', 'permanent_show_price', 'permanent_tag', 'permanent_tag_class',
|
||||
];
|
||||
const LEGACY_KEYS = [
|
||||
'payment_vip_1_products',
|
||||
'payment_vip_2_products',
|
||||
'payment_vip_points_packages',
|
||||
];
|
||||
|
||||
function runHarness() {
|
||||
const out = execFileSync(process.env.PHP_BINARY || 'php', [
|
||||
path.join(__dirname, 'vip-settings-structured-harness.php'),
|
||||
], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
|
||||
return JSON.parse(out);
|
||||
}
|
||||
|
||||
// 只切 VIP 设置函数:充值/积分设置里的 products_raw 与本文无关。
|
||||
function vipSettingsSlice() {
|
||||
const start = paymentRuntime.indexOf('public static function get_payment_vip_settings');
|
||||
assert.notStrictEqual(start, -1, 'missing get_payment_vip_settings');
|
||||
const end = paymentRuntime.indexOf('public static function get_payment_revenue_settings', start);
|
||||
return paymentRuntime.slice(start, end === -1 ? undefined : end);
|
||||
}
|
||||
|
||||
let harness;
|
||||
test('PHP harness 可执行并产出结构化设置', () => {
|
||||
harness = runHarness();
|
||||
assert.ok(harness && harness.schema && harness.runtime, 'harness 输出不完整');
|
||||
});
|
||||
|
||||
test('旧 `|` 分隔配置键已从两份 schema 与运行时彻底移除', () => {
|
||||
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp], ['PaymentRuntime.php', paymentRuntime]]) {
|
||||
for (const key of LEGACY_KEYS) {
|
||||
assert.ok(!src.includes(key), `${name} 不应再含 ${key}`);
|
||||
}
|
||||
}
|
||||
// 运行时不再暴露任何 *_raw 原始字符串。
|
||||
const vipRuntime = vipSettingsSlice();
|
||||
for (const key of ['products_raw', 'packages_raw']) {
|
||||
assert.ok(!vipRuntime.includes(`'${key}'`), `PaymentRuntime VIP 设置不应再产出 ${key}`);
|
||||
}
|
||||
assert.ok(!vipRuntime.includes("'product' => sanitize_textarea_field"), 'upgrade.product 管道配置应已移除');
|
||||
for (const key of LEGACY_KEYS) {
|
||||
assert.ok(!vipRuntime.includes(key), `PaymentRuntime VIP 设置不应再读 ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('会员等级组只留导航按钮与等级开关,身份字段搬进等级 Tab', () => {
|
||||
const basic = harness.schema.basic_field_keys;
|
||||
assert.deepStrictEqual(basic, [
|
||||
'nav_pay_vip', 'nav_user_pay_vip', 'pay_user_vip_1_s', 'vip_level_order_notice', 'pay_user_vip_2_s',
|
||||
]);
|
||||
const tabs = harness.schema.opt_tabs;
|
||||
assert.deepStrictEqual(Object.keys(tabs), ['vip1', 'vip2', 'renew', 'upgrade']);
|
||||
for (const level of [1, 2]) {
|
||||
const fields = Object.keys(tabs[`vip${level}`].fields);
|
||||
assert.deepStrictEqual(fields, [
|
||||
`pay_user_vip_${level}_name`,
|
||||
`pay_user_vip_${level}_desc`,
|
||||
`pay_user_vip_${level}_equity`,
|
||||
`vip_${level}_product_s`,
|
||||
`vip_${level}_product`,
|
||||
`vip_${level}_img_icon`,
|
||||
]);
|
||||
assert.strictEqual(tabs[`vip${level}`].fields[`vip_${level}_img_icon`].type, 'upload');
|
||||
}
|
||||
});
|
||||
|
||||
test('等级 Tab 标题由显示名称动态驱动', () => {
|
||||
for (const level of [1, 2]) {
|
||||
const tab = harness.schema.opt_tabs[`vip${level}`];
|
||||
assert.strictEqual(tab.titleKey, `pay_user_vip_${level}_name`);
|
||||
assert.strictEqual(tab.titleFallback, level === 1 ? '一级会员' : '二级会员');
|
||||
}
|
||||
// 会员权益的两个等级 Tab 同样动态。
|
||||
assert.strictEqual(harness.schema.benefit_tabs.vip1.titleKey, 'pay_user_vip_1_name');
|
||||
assert.strictEqual(harness.schema.benefit_tabs.vip2.titleKey, 'pay_user_vip_2_name');
|
||||
// 渲染器支持 titleKey / titleFallback。
|
||||
assert.match(rendererVue, /:name="tabName\(tab\)"/);
|
||||
assert.match(rendererVue, /:tab="tabName\(tab\)"/);
|
||||
assert.match(rendererVue, /function tabName\(tab\) \{[\s\S]*?tab\.titleKey[\s\S]*?tab\.titleFallback/);
|
||||
});
|
||||
|
||||
test('会员商品字段集恰好 6 项且标签颜色为参考主题 16 色', () => {
|
||||
const group = harness.schema.opt_tabs.vip1.fields.vip_1_product;
|
||||
assert.strictEqual(group.type, 'group');
|
||||
assert.strictEqual(group.max, 8);
|
||||
assert.strictEqual(group.button_title, '添加会员商品');
|
||||
assert.deepStrictEqual(
|
||||
group.fields.filter((f) => !f.ui_only).map((f) => f.key),
|
||||
PRODUCT_FIELDS
|
||||
);
|
||||
assert.strictEqual(group.fields.find((f) => f.key === 'price').sanitize_type, 'money_decimal');
|
||||
assert.strictEqual(group.fields.find((f) => f.key === 'show_price').sanitize_type, 'money_decimal');
|
||||
assert.strictEqual(group.fields.find((f) => f.key === 'time').type, 'spinner');
|
||||
assert.strictEqual(group.fields.find((f) => f.key === 'time').max, 3600);
|
||||
assert.deepStrictEqual(
|
||||
group.fields.find((f) => f.key === 'unit').options.map((o) => o.value),
|
||||
['day', 'month']
|
||||
);
|
||||
const palette = group.fields.find((f) => f.key === 'tag_class');
|
||||
assert.strictEqual(palette.type, 'palette');
|
||||
assert.strictEqual(palette.default, 'jb-yellow');
|
||||
assert.deepStrictEqual(palette.options.map((o) => o.value), TAG_PALETTE);
|
||||
// 标签颜色仅在填写了促销标签时出现。
|
||||
assert.deepStrictEqual(palette.dependsOn, { key: 'tag', operator: 'not_empty' });
|
||||
// 时长单位在 time 为 0(永久)时隐藏。
|
||||
assert.deepStrictEqual(group.fields.find((f) => f.key === 'unit').dependsOn, { key: 'time', operator: 'truthy' });
|
||||
// 二级会员 Tab 复用同一字段集。
|
||||
assert.deepStrictEqual(
|
||||
harness.schema.opt_tabs.vip2.fields.vip_2_product.fields.filter((f) => !f.ui_only).map((f) => f.key),
|
||||
PRODUCT_FIELDS
|
||||
);
|
||||
});
|
||||
|
||||
test('续费价格类型含自定义商品,折扣为折值滑块', () => {
|
||||
const renew = harness.schema.opt_tabs.renew.fields;
|
||||
assert.deepStrictEqual(renew.vip_renew_price_type.options.map((o) => o.value), [
|
||||
'same', 'discount', 'reduce', 'customize',
|
||||
]);
|
||||
const discount = renew.vip_renew_discount;
|
||||
assert.strictEqual(discount.type, 'slider');
|
||||
assert.strictEqual(discount.sanitize_type, 'float');
|
||||
assert.strictEqual(discount.min, 0.1);
|
||||
assert.strictEqual(discount.max, 9.9);
|
||||
assert.strictEqual(discount.step, 0.1);
|
||||
assert.strictEqual(renew.vip_renew_reduce.type, 'spinner');
|
||||
assert.strictEqual(renew.vip_renew_reduce.max, 500);
|
||||
assert.strictEqual(renew.vip_renew_reduce.step, 5);
|
||||
// 自定义续费商品组仅在 customize 时出现。
|
||||
for (const level of [1, 2]) {
|
||||
const group = renew[`vip_${level}_renew_product`];
|
||||
assert.strictEqual(group.type, 'group');
|
||||
assert.strictEqual(group.max, 8);
|
||||
assert.strictEqual(group.button_title, '添加续费商品');
|
||||
assert.deepStrictEqual(group.dependsOn, { key: 'vip_renew_price_type', value: 'customize' });
|
||||
assert.deepStrictEqual(
|
||||
group.fields.filter((f) => !f.ui_only).map((f) => f.key),
|
||||
PRODUCT_FIELDS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('升级价格为参考主题同款三块 accordion', () => {
|
||||
const upgrade = harness.schema.opt_tabs.upgrade.fields.vip_upgrade_product;
|
||||
assert.strictEqual(upgrade.type, 'accordion');
|
||||
const keys = upgrade.fields.filter((f) => !f.ui_only).map((f) => f.key);
|
||||
assert.deepStrictEqual(keys, UPGRADE_FIELDS);
|
||||
assert.strictEqual(upgrade.fields.find((f) => f.key === 'month_to_month_s').default, true);
|
||||
assert.strictEqual(upgrade.fields.find((f) => f.key === 'jump_s').default, false);
|
||||
for (const key of ['unit_price', 'jump_price', 'jump_show_price', 'permanent_price', 'permanent_show_price']) {
|
||||
assert.strictEqual(upgrade.fields.find((f) => f.key === key).sanitize_type, 'money_decimal', `${key} 应为 money_decimal`);
|
||||
}
|
||||
for (const key of ['unit_tag_class', 'jump_tag_class', 'permanent_tag_class']) {
|
||||
const field = upgrade.fields.find((f) => f.key === key);
|
||||
assert.strictEqual(field.type, 'palette', `${key} 应为 palette`);
|
||||
assert.deepStrictEqual(field.options.map((o) => o.value), TAG_PALETTE);
|
||||
}
|
||||
});
|
||||
|
||||
test('积分兑换明细为结构化 group,时长为 time + Permanent 语义', () => {
|
||||
assert.deepStrictEqual(harness.schema.points_fields, [
|
||||
'pay_vip_points_exchange_s', 'pay_vip_points_exchange_product', 'pay_vip_points_exchange_desc',
|
||||
]);
|
||||
const group = harness.schema.points_group;
|
||||
assert.strictEqual(group.type, 'group');
|
||||
assert.strictEqual(group.max, 16);
|
||||
assert.strictEqual(group.button_title, '添加积分兑换商品');
|
||||
assert.deepStrictEqual(group.fields.map((f) => f.key), ['points', 'level', 'time', 'unit']);
|
||||
assert.strictEqual(group.fields.find((f) => f.key === 'points').type, 'number');
|
||||
assert.strictEqual(group.fields.find((f) => f.key === 'level').type, 'radio');
|
||||
assert.strictEqual(group.fields.find((f) => f.key === 'time').type, 'input');
|
||||
// 永久时隐藏时长单位。
|
||||
assert.deepStrictEqual(group.fields.find((f) => f.key === 'unit').dependsOn, { key: 'time', operator: 'neq', value: 'Permanent' });
|
||||
});
|
||||
|
||||
test('运行时归一化:默认值、价格净化、零价与非法行剔除', () => {
|
||||
const defaults = harness.runtime.defaults;
|
||||
// 参考主题默认两行。
|
||||
assert.strictEqual(defaults.levels['1'].products.length, 2);
|
||||
assert.strictEqual(defaults.levels['1'].products[0].price, '99.00');
|
||||
assert.strictEqual(defaults.levels['1'].products[0].time, 3);
|
||||
assert.strictEqual(defaults.levels['1'].products[0].unit, 'month');
|
||||
assert.strictEqual(defaults.levels['1'].products[0].days, 90);
|
||||
assert.strictEqual(defaults.levels['1'].renew_products[0].price, '69.00');
|
||||
assert.strictEqual(defaults.levels['2'].renew_products[0].price, '269.00');
|
||||
// 积分兑换默认两行(含 Permanent)。
|
||||
assert.strictEqual(defaults.points_exchange.packages.length, 2);
|
||||
assert.strictEqual(defaults.points_exchange.packages[1].is_permanent, true);
|
||||
assert.strictEqual(defaults.points_exchange.packages[1].days, 0);
|
||||
// 续费/升级默认值。
|
||||
assert.strictEqual(defaults.renew.price_type, 'discount');
|
||||
assert.strictEqual(defaults.renew.discount, 8);
|
||||
assert.strictEqual(defaults.upgrade.month_to_month_s, true);
|
||||
assert.strictEqual(defaults.upgrade.jump_s, false);
|
||||
assert.strictEqual(defaults.upgrade.unit_price, '10.00');
|
||||
assert.strictEqual(defaults.upgrade.unit_tag_class, 'jb-yellow');
|
||||
assert.strictEqual(defaults.upgrade.jump_tag_class, 'jb-red');
|
||||
|
||||
const custom = harness.runtime.custom;
|
||||
// 零价行被剔除;价格规范化为两位小数。
|
||||
assert.strictEqual(custom.levels['1'].products.length, 2);
|
||||
assert.strictEqual(custom.levels['1'].products[0].price, '88.80');
|
||||
assert.strictEqual(custom.levels['1'].products[0].tag_class, 'jb-green');
|
||||
// 非法标签颜色回落为空串。
|
||||
assert.strictEqual(custom.levels['2'].products[0].tag_class, '');
|
||||
// 自定义续费价格类型与折扣。
|
||||
assert.strictEqual(custom.renew.price_type, 'customize');
|
||||
assert.strictEqual(custom.renew.discount, 7.5);
|
||||
assert.strictEqual(custom.renew.desc, '续费有礼');
|
||||
assert.strictEqual(custom.levels['1'].renew_products[0].tag_class, 'b-red');
|
||||
// 升级三块参数透传。
|
||||
assert.strictEqual(custom.upgrade.unit_price, '12.50');
|
||||
assert.strictEqual(custom.upgrade.unit_tag_class, 'jb-blue');
|
||||
assert.strictEqual(custom.upgrade.jump_s, true);
|
||||
assert.strictEqual(custom.upgrade.permanent_tag_class, 'jb-vip2');
|
||||
// 积分兑换:非法行(积分为 0 / 等级越界 / 时长为空)被剔除。
|
||||
assert.strictEqual(custom.points_exchange.packages.length, 3);
|
||||
assert.strictEqual(custom.points_exchange.packages[0].days, 30);
|
||||
assert.strictEqual(custom.points_exchange.packages[1].is_permanent, true);
|
||||
assert.strictEqual(custom.points_exchange.packages[2].days, 90);
|
||||
});
|
||||
|
||||
test('旧 `|` 管道存量值按新默认值回退,不会渲染成假条目', () => {
|
||||
const legacy = harness.runtime.legacy;
|
||||
assert.strictEqual(legacy.levels['1'].products.length, 2);
|
||||
assert.strictEqual(legacy.levels['1'].products[0].price, '99.00');
|
||||
assert.strictEqual(legacy.points_exchange.packages.length, 2);
|
||||
assert.strictEqual(legacy.upgrade.unit_price, '10.00');
|
||||
});
|
||||
|
||||
test('续费计价:折值/立减/自定义商品三种语义', () => {
|
||||
const p = harness.pricing;
|
||||
// 相同价格:不改价、不展示原价。
|
||||
assert.strictEqual(p.same.price, '100.00');
|
||||
assert.strictEqual(p.same.show_price, '0.00');
|
||||
// 8 折:执行价 80,原价划线为 100。
|
||||
assert.strictEqual(p.discount_8.price, '80.00');
|
||||
assert.strictEqual(p.discount_8.show_price, '100.00');
|
||||
assert.strictEqual(p.discount_8.tag, '8折优惠');
|
||||
// 支持小数折。
|
||||
assert.strictEqual(p.discount_7_5.price, '75.00');
|
||||
assert.strictEqual(p.discount_7_5.tag, '7.5折优惠');
|
||||
// 超出滑块上限时按 9.9 折钳制(参考主题 slider max)。
|
||||
assert.strictEqual(p.discount_10_clamped.price, '99.00');
|
||||
assert.strictEqual(p.discount_10_clamped.tag, '9.9折优惠');
|
||||
// 立减 20:执行价 80。
|
||||
assert.strictEqual(p.reduce_20.price, '80.00');
|
||||
assert.strictEqual(p.reduce_20.show_price, '100.00');
|
||||
assert.strictEqual(p.reduce_20.tag, '续费立减¥20.00');
|
||||
// 自定义商品:沿用商品自身的原价、促销标签与标签颜色。
|
||||
assert.strictEqual(p.customize.price, '100.00');
|
||||
assert.strictEqual(p.customize.show_price, '200.00');
|
||||
assert.strictEqual(p.customize.tag, '限时');
|
||||
assert.strictEqual(p.customize.tag_class, 'jb-red');
|
||||
// 开通动作不套用续费规则。
|
||||
assert.strictEqual(p.pay.price, '100.00');
|
||||
assert.strictEqual(p.pay.show_price, '0.00');
|
||||
assert.strictEqual(p.pay.tag, '');
|
||||
});
|
||||
|
||||
test('tag_class 透传到前端套餐卡与积分兑换弹窗', () => {
|
||||
assert.match(payVipJs, /item\.tag_class \|\| "jb-yellow"/);
|
||||
// 弹窗时长行按 time/unit 渲染,Permanent 走永久。
|
||||
assert.match(modalSource, /\$is_permanent = !empty\(\$package\['is_permanent'\]\)/);
|
||||
assert.match(modalSource, /\$pkg_unit === 'month' \? __\('个月', 'aksrt'\) : __\('天', 'aksrt'\)/);
|
||||
// 用户中心档位卡消费续费/升级简介。
|
||||
assert.match(vipTabPhp, /\$renew_desc = \(string\) \(\$vip_settings\['renew'\]\['desc'\]/);
|
||||
assert.match(vipTabPhp, /\$upgrade_desc = \(string\) \(\$vip_settings\['upgrade'\]\['desc'\]/);
|
||||
});
|
||||
|
||||
test('管道解析死代码已清理', () => {
|
||||
for (const [name, src] of [['VipServicePackages.php', packagesTrait], ['VipServicePurchase.php', purchaseSource]]) {
|
||||
assert.ok(!/function parse_level_products/.test(src), `${name} 不应再定义 parse_level_products`);
|
||||
assert.ok(!/function parse_packages\(/.test(src), `${name} 不应再定义 parse_packages`);
|
||||
assert.ok(!/function parse_upgrade_product_line/.test(src), `${name} 不应再定义 parse_upgrade_product_line`);
|
||||
assert.ok(!/preg_split\('\/\\r\\n\|\\r\|\\n\/'/.test(src), `${name} 不应再按行拆分管道配置`);
|
||||
}
|
||||
// HomeCms 的零调用 PaymentRuntime 代理方法已删除。
|
||||
for (const method of ['get_payment_vip_settings', 'get_payment_recharge_settings', 'get_withdrawal_settings']) {
|
||||
assert.ok(!homeCms.includes(`function ${method}`), `HomeCms 不应再含死代理 ${method}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('SPA 镜像 schema 与 PHP schema 同构', async () => {
|
||||
const mod = await import(pathToFileURL(path.join(root, 'assets/admin/src/schema/sections/payment/vip.js')).href);
|
||||
const schema = mod.vipSchema;
|
||||
assert.deepStrictEqual(schema.groups.map((g) => g.key), [
|
||||
'notice', 'basic', 'benefit', 'opt', 'points-exchange', 'modal-copy',
|
||||
]);
|
||||
const opt = schema.groups.find((g) => g.key === 'opt').fields[0];
|
||||
assert.strictEqual(opt.type, 'tabbed');
|
||||
assert.deepStrictEqual(opt.tabs.map((t) => t.key), ['vip1', 'vip2', 'renew', 'upgrade']);
|
||||
for (const level of [1, 2]) {
|
||||
const tab = opt.tabs[level - 1];
|
||||
assert.strictEqual(tab.titleKey, `pay_user_vip_${level}_name`);
|
||||
assert.deepStrictEqual(tab.fields.map((f) => f.key), [
|
||||
`pay_user_vip_${level}_name`,
|
||||
`pay_user_vip_${level}_desc`,
|
||||
`pay_user_vip_${level}_equity`,
|
||||
`vip_${level}_product_s`,
|
||||
`vip_${level}_product`,
|
||||
`vip_${level}_img_icon`,
|
||||
]);
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
opt.tabs[2].fields.find((f) => f.key === 'vip_renew_price_type').options.map((o) => o.value),
|
||||
['same', 'discount', 'reduce', 'customize']
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
opt.tabs[3].fields.find((f) => f.key === 'vip_upgrade_product').fields.filter((f) => !f.ui_only).map((f) => f.key),
|
||||
UPGRADE_FIELDS
|
||||
);
|
||||
const points = schema.groups.find((g) => g.key === 'points-exchange').fields[1];
|
||||
assert.strictEqual(points.type, 'group');
|
||||
assert.deepStrictEqual(points.fields.map((f) => f.key), ['points', 'level', 'time', 'unit']);
|
||||
// SPA 默认值与 PHP 侧一致。
|
||||
assert.strictEqual(schema.defaults.vip_1_product[0].price, 99);
|
||||
assert.strictEqual(schema.defaults.vip_1_renew_product[0].price, 69);
|
||||
assert.strictEqual(schema.defaults.vip_2_renew_product[0].price, 269);
|
||||
assert.strictEqual(schema.defaults.vip_renew_discount, 8);
|
||||
assert.strictEqual(schema.defaults.vip_upgrade_product.jump_s, false);
|
||||
// 旧键不出现在 SPA schema。
|
||||
for (const key of LEGACY_KEYS) {
|
||||
assert.ok(!vipSchemaJs.includes(key), `vip.js 不应再含 ${key}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
/**
|
||||
* Executable VIP structured-settings contract harness.
|
||||
*
|
||||
* Builds the payment schema with WordPress stubs, normalizes a stored
|
||||
* `aksrt_settings` snapshot through PaymentRuntime, and prints the result as
|
||||
* JSON so the Node contract test can assert on the real shapes.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {
|
||||
define('ABSPATH', __DIR__ . '/');
|
||||
define('DAY_IN_SECONDS', 86400);
|
||||
|
||||
function __(string $text, string $domain = ''): string { return $text; }
|
||||
function _x(string $text, string $context, string $domain = ''): string { return $text; }
|
||||
function esc_html($value): string { return (string) $value; }
|
||||
function esc_attr($value): string { return (string) $value; }
|
||||
function esc_url($value): string { return (string) $value; }
|
||||
function esc_url_raw($value): string { return trim((string) $value); }
|
||||
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? ''; }
|
||||
function sanitize_text_field($value): string { return trim(strip_tags((string) $value)); }
|
||||
function sanitize_textarea_field($value): string { return trim((string) $value); }
|
||||
function wp_kses_post($value): string { return (string) $value; }
|
||||
function wp_kses($value, $allowed = null): string { return (string) $value; }
|
||||
function trailingslashit($value): string { return rtrim((string) $value, '/\\') . '/'; }
|
||||
function get_template_directory_uri(): string { return 'https://example.test/wp-content/themes/aksrt'; }
|
||||
function absint($value): int { return max(0, (int) $value); }
|
||||
function wp_json_encode($value, int $flags = 0): string { return json_encode($value, $flags | JSON_UNESCAPED_SLASHES); }
|
||||
function is_multisite(): bool { return false; }
|
||||
function get_option(string $key, $default = false) { return $default; }
|
||||
function did_action(string $hook): int { return 0; }
|
||||
function apply_filters(string $hook, $value, ...$args) { return $value; }
|
||||
function add_action(...$args): void {}
|
||||
function add_filter(...$args): void {}
|
||||
}
|
||||
|
||||
namespace AKSRT\Settings {
|
||||
/** Test double: PaymentRuntime reads every raw settings key through this. */
|
||||
final class Settings {
|
||||
/** @var array<string,mixed> */
|
||||
public static array $snapshot = [];
|
||||
|
||||
public static function get_all(): array {
|
||||
return self::$snapshot;
|
||||
}
|
||||
|
||||
public static function get_paid_content_settings(): array {
|
||||
return ['display' => ['currency_symbol' => '¥']];
|
||||
}
|
||||
|
||||
public static function reset(): void {
|
||||
self::$snapshot = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
require dirname(__DIR__, 2) . '/src/Ledger/AssetType.php';
|
||||
require dirname(__DIR__, 2) . '/src/Ledger/AssetAmount.php';
|
||||
require dirname(__DIR__, 2) . '/src/Settings/PaymentRuntime.php';
|
||||
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPayment.php';
|
||||
require dirname(__DIR__, 2) . '/src/Content/VipServicePackages.php';
|
||||
|
||||
/** Exposes the trait's private renew-pricing math for contract assertions. */
|
||||
final class VipPricingProbe {
|
||||
use \AKSRT\Content\VipServicePackages;
|
||||
|
||||
/** @param array<string,mixed> $pkg */
|
||||
public function price(array $pkg, string $action, array $renew, int $days): ?array {
|
||||
return $this->apply_action_price($pkg, $action, $renew, [], 1, 1, 0, false, $days);
|
||||
}
|
||||
}
|
||||
|
||||
use AKSRT\Admin\Schema\SchemaPayment;
|
||||
use AKSRT\Settings\PaymentRuntime;
|
||||
use AKSRT\Settings\Settings;
|
||||
|
||||
$vip = SchemaPayment::build()['sections']['vip'];
|
||||
$groups = [];
|
||||
foreach ($vip['groups'] as $group) {
|
||||
$groups[$group['key']] = $group;
|
||||
}
|
||||
|
||||
$opt_tabs = [];
|
||||
foreach ($groups['opt']['fields'][0]['tabs'] as $tab) {
|
||||
$fields = [];
|
||||
foreach ($tab['fields'] as $field) {
|
||||
$fields[$field['key']] = $field;
|
||||
}
|
||||
$opt_tabs[$tab['key']] = [
|
||||
'title' => $tab['title'],
|
||||
'titleKey' => $tab['titleKey'] ?? null,
|
||||
'titleFallback' => $tab['titleFallback'] ?? null,
|
||||
'fields' => $fields,
|
||||
];
|
||||
}
|
||||
|
||||
$benefit_tabs = [];
|
||||
foreach ($groups['benefit']['fields'][0]['tabs'] as $tab) {
|
||||
$benefit_tabs[$tab['key']] = [
|
||||
'title' => $tab['title'],
|
||||
'titleKey' => $tab['titleKey'] ?? null,
|
||||
'titleFallback' => $tab['titleFallback'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// ---- runtime: defaults (nothing stored) -------------------------------
|
||||
Settings::reset();
|
||||
$defaults = PaymentRuntime::get_payment_vip_settings();
|
||||
|
||||
// ---- runtime: fully customised stored snapshot -------------------------
|
||||
Settings::$snapshot = [
|
||||
'pay_user_vip_1_s' => true,
|
||||
'pay_user_vip_2_s' => true,
|
||||
'pay_user_vip_1_name' => '青铜会员',
|
||||
'pay_user_vip_2_name' => '王者会员',
|
||||
'vip_renew' => true,
|
||||
'vip_renew_desc' => '续费有礼',
|
||||
'vip_renew_price_type' => 'customize',
|
||||
'vip_renew_discount' => 7.5,
|
||||
'vip_renew_reduce' => '15.00',
|
||||
'vip_upgrade' => true,
|
||||
'vip_upgrade_desc' => '升级王者',
|
||||
'vip_upgrade_product' => [
|
||||
'month_to_month_s' => true,
|
||||
'unit_price' => '12.50',
|
||||
'unit_tag' => '<i class="fa fas fa-fw fa-bolt"></i> 按天升级',
|
||||
'unit_tag_class' => 'jb-blue',
|
||||
'jump_s' => true,
|
||||
'jump_price' => '259.00',
|
||||
'jump_show_price' => '399.00',
|
||||
'jump_tag' => '<i class="fa fas fa-fw fa-bolt"></i> 直升永久',
|
||||
'jump_tag_class' => 'jb-red',
|
||||
'permanent_price' => '159.00',
|
||||
'permanent_show_price' => '259.00',
|
||||
'permanent_tag' => '<i class="fa fas fa-fw fa-bolt"></i> 王者特惠',
|
||||
'permanent_tag_class' => 'jb-vip2',
|
||||
],
|
||||
'vip_1_product' => [
|
||||
['price' => '88.80', 'show_price' => '188.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 新客', 'tag_class' => 'jb-green', 'time' => 1, 'unit' => 'month'],
|
||||
['price' => '0', 'show_price' => '', 'tag' => '', 'time' => 0, 'unit' => 'day'],
|
||||
['price' => '10', 'show_price' => '20', 'tag' => '', 'time' => 2, 'unit' => 'month'],
|
||||
],
|
||||
'vip_2_product' => [
|
||||
['price' => '300', 'show_price' => '600', 'tag' => 'x', 'tag_class' => 'not-a-real-class', 'time' => 12, 'unit' => 'month'],
|
||||
],
|
||||
'vip_1_renew_product' => [
|
||||
['price' => '60', 'show_price' => '99', 'tag' => '续费专享', 'tag_class' => 'b-red', 'time' => 1, 'unit' => 'month'],
|
||||
],
|
||||
'vip_2_renew_product' => [],
|
||||
'pay_vip_points_exchange_s' => true,
|
||||
'pay_vip_points_exchange_product' => [
|
||||
['points' => 800, 'level' => 1, 'time' => 30, 'unit' => 'day'],
|
||||
['points' => 9000, 'level' => 2, 'time' => 'Permanent'],
|
||||
['points' => 0, 'level' => 1, 'time' => 10, 'unit' => 'day'],
|
||||
['points' => 100, 'level' => 9, 'time' => 10, 'unit' => 'day'],
|
||||
['points' => 100, 'level' => 1, 'time' => '', 'unit' => 'day'],
|
||||
['points' => 120, 'level' => 2, 'time' => 3, 'unit' => 'month'],
|
||||
],
|
||||
];
|
||||
$custom = PaymentRuntime::get_payment_vip_settings();
|
||||
|
||||
// ---- legacy scalar payloads must fall back to defaults -----------------
|
||||
Settings::$snapshot = [
|
||||
'pay_user_vip_1_s' => true,
|
||||
'vip_1_product' => "30|59|1|month|限时|月卡",
|
||||
'pay_vip_points_exchange_product' => "1|500|30|积分月卡",
|
||||
'vip_upgrade_product' => "unit|1|按剩余天数升级",
|
||||
];
|
||||
$legacy = PaymentRuntime::get_payment_vip_settings();
|
||||
|
||||
echo wp_json_encode([
|
||||
'schema' => [
|
||||
'version' => $vip['version'],
|
||||
'group_keys' => array_column($vip['groups'], 'key'),
|
||||
'basic_field_keys' => array_column($groups['basic']['fields'], 'key'),
|
||||
'benefit_tabs' => $benefit_tabs,
|
||||
'opt_tabs' => $opt_tabs,
|
||||
'points_fields' => array_column($groups['points-exchange']['fields'], 'key'),
|
||||
'points_group' => $groups['points-exchange']['fields'][1],
|
||||
'tag_palette' => $opt_tabs['vip1']['fields']['vip_1_product']['fields'][3]['options'],
|
||||
],
|
||||
'runtime' => [
|
||||
'defaults' => $defaults,
|
||||
'custom' => $custom,
|
||||
'legacy' => $legacy,
|
||||
],
|
||||
'pricing' => (static function (): array {
|
||||
$probe = new VipPricingProbe();
|
||||
$base = [
|
||||
'price' => '100.00', 'show_price' => '200.00', 'tag' => '限时',
|
||||
'tag_class' => 'jb-red', 'time' => 1, 'unit' => 'month',
|
||||
];
|
||||
return [
|
||||
'same' => $probe->price($base, 'renew', ['price_type' => 'same'], 30),
|
||||
'discount_8' => $probe->price($base, 'renew', ['price_type' => 'discount', 'discount' => 8], 30),
|
||||
'discount_7_5' => $probe->price($base, 'renew', ['price_type' => 'discount', 'discount' => 7.5], 30),
|
||||
'discount_10_clamped' => $probe->price($base, 'renew', ['price_type' => 'discount', 'discount' => 10], 30),
|
||||
'reduce_20' => $probe->price($base, 'renew', ['price_type' => 'reduce', 'reduce' => '20.00'], 30),
|
||||
'customize' => $probe->price($base, 'renew', ['price_type' => 'customize'], 30),
|
||||
'pay' => $probe->price($base, 'pay', ['price_type' => 'discount'], 30),
|
||||
];
|
||||
})(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), PHP_EOL;
|
||||
}
|
||||
Reference in new issue
Block a user