The 积分兑换明细.level radio declares its options as the integers 1 and 2, but sanitize_field_value() ran every radio value through sanitize_text_field(), so the sanitized payload held the string 1. validate() then compared with in_array(..., true) and rejected both default rows, which made the VIP payment section impossible to save. Resolve enum values to the value declared by their option during sanitization, covering select, radio, palette and image_select. A value that matches no option still falls through to the previous string coercion, so invalid input keeps producing a precise validation error. This also keeps the stored type aligned with the schema, which the naive-ui radio group compares with === when it re-renders a saved setting. Verified with an end-to-end sanitize, validate, write and read round-trip, and locked in with a contract test in the VIP structured-settings suite. The widget-system, admin, front-submit and navigation suites all pass (658 tests).
307 lines
12 KiB
PHP
307 lines
12 KiB
PHP
<?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/Admin/SchemaProvider.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();
|
|
|
|
// ---- sanitize -> validate round-trip -----------------------------------
|
|
// 复现并锁定:积分兑换明细.level 的选项值为整数,而 SPA 提交的可能是数字
|
|
// 或字符串;净化必须把值收敛回选项声明的类型,否则严格 in_array 校验会以
|
|
// "has an invalid value" 拒绝一次完全合法的保存。
|
|
$sanitize = (static function (): array {
|
|
$provider = \AKSRT\Admin\SchemaProvider::get_instance();
|
|
$cases = [
|
|
'default_rows' => [
|
|
'pay_vip_points_exchange_s' => true,
|
|
'pay_vip_points_exchange_product' => [
|
|
['points' => 500, 'level' => 1, 'time' => 30, 'unit' => 'day'],
|
|
['points' => 5000, 'level' => 1, 'time' => 'Permanent'],
|
|
],
|
|
],
|
|
'string_level' => [
|
|
'pay_vip_points_exchange_s' => true,
|
|
'pay_vip_points_exchange_product' => [
|
|
['points' => 800, 'level' => '2', 'time' => 3, 'unit' => 'month'],
|
|
],
|
|
],
|
|
'invalid_level' => [
|
|
'pay_vip_points_exchange_s' => true,
|
|
'pay_vip_points_exchange_product' => [
|
|
['points' => 500, 'level' => 9, 'time' => 30, 'unit' => 'day'],
|
|
],
|
|
],
|
|
'invalid_unit' => [
|
|
'pay_vip_points_exchange_s' => true,
|
|
'pay_vip_points_exchange_product' => [
|
|
['points' => 500, 'level' => 1, 'time' => 30, 'unit' => 'year'],
|
|
],
|
|
],
|
|
'invalid_tag_class' => [
|
|
'vip_1_product' => [
|
|
['price' => '88.80', 'show_price' => '99', 'tag' => 'x', 'tag_class' => 'nope', 'time' => 1, 'unit' => 'month'],
|
|
],
|
|
],
|
|
];
|
|
$out = [];
|
|
foreach ($cases as $name => $payload) {
|
|
$clean = $provider->sanitize_payload('payment', $payload, 'vip');
|
|
$result = $provider->validate('payment', $clean, 'vip');
|
|
$out[$name] = [
|
|
'points_rows' => $clean['pay_vip_points_exchange_product'] ?? null,
|
|
'valid' => $result['valid'],
|
|
'errors' => $result['errors'],
|
|
];
|
|
}
|
|
return $out;
|
|
})();
|
|
|
|
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,
|
|
],
|
|
'sanitize' => $sanitize,
|
|
'accordion' => (static function (array $groups): array {
|
|
$out = ['groups' => [], 'sections' => []];
|
|
foreach ($groups as $group) {
|
|
foreach ($group['fields'] as $top) {
|
|
if (($top['type'] ?? '') !== 'tabbed') {
|
|
continue;
|
|
}
|
|
foreach ($top['tabs'] as $tab) {
|
|
foreach ($tab['fields'] as $field) {
|
|
if (($field['type'] ?? '') === 'group') {
|
|
$out['groups'][$tab['key'] . '/' . $field['key']] = [
|
|
'collapsible' => $field['collapsible'] ?? null,
|
|
'defaultCollapsed' => $field['defaultCollapsed'] ?? null,
|
|
'listPreview' => $field['listPreview'] ?? null,
|
|
];
|
|
}
|
|
if (($field['type'] ?? '') === 'accordion') {
|
|
$out['sections'][$tab['key'] . '/' . $field['key']] = array_map(
|
|
static fn(array $section): array => [
|
|
'title' => $section['title'],
|
|
'keys' => $section['keys'],
|
|
],
|
|
$field['sections'] ?? []
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
foreach ($groups as $group) {
|
|
if ($group['key'] !== 'points-exchange') {
|
|
continue;
|
|
}
|
|
foreach ($group['fields'] as $field) {
|
|
if (($field['type'] ?? '') !== 'group') {
|
|
continue;
|
|
}
|
|
$out['groups']['points-exchange/' . $field['key']] = [
|
|
'collapsible' => $field['collapsible'] ?? null,
|
|
'defaultCollapsed' => $field['defaultCollapsed'] ?? null,
|
|
'listPreview' => $field['listPreview'] ?? null,
|
|
];
|
|
}
|
|
}
|
|
return $out;
|
|
})($groups),
|
|
'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;
|
|
}
|