diff --git a/src/Admin/SchemaProvider.php b/src/Admin/SchemaProvider.php index 52d12ab5..503ae9e6 100644 --- a/src/Admin/SchemaProvider.php +++ b/src/Admin/SchemaProvider.php @@ -866,6 +866,47 @@ class SchemaProvider { return null; } + /** + * Resolve a submitted enum value to the value declared by its option. + * + * Payloads reach the sanitizer as strings (or numbers), so a strict match + * against the schema would reject a valid choice whenever the option value + * is not itself a string -- e.g. 积分兑换明细.level declares 1 / 2 while the + * SPA submits them as numbers and sanitize_text_field() then turns them into + * "1" / "2". Returning the declared value keeps the stored type aligned with + * the schema, which the radio/select controls compare with === when they + * re-render a saved setting. + * + * Returns null when nothing matches, so validation still reports the + * invalid value instead of silently rewriting the operator's input. + * + * @param mixed $value + * @param array $options + * @return mixed + */ + private function match_option_value($value, array $options) { + foreach ($options as $opt) { + $opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt; + if ($opt_value === $value) { + return $opt_value; + } + } + if (is_bool($value) || !is_scalar($value)) { + return null; + } + $needle = (string) $value; + foreach ($options as $opt) { + $opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt; + if (is_bool($opt_value) || !is_scalar($opt_value)) { + continue; + } + if ((string) $opt_value === $needle) { + return $opt_value; + } + } + return null; + } + private function sanitize_field_value($value, array $field) { if (!empty($field['captcha_provider'])) { return function_exists('aksrt_captcha_sanitize_provider_config') @@ -898,6 +939,15 @@ class SchemaProvider { return $normalized !== null ? $normalized : (string) ($field['default'] ?? ''); } + // Enum fields keep the option's declared value so the stored type always + // matches what validate() compares against with in_array(..., true). + if (in_array($type, ['select', 'radio', 'palette', 'image_select'], true) && empty($field['multiple']) && !empty($field['options'])) { + $matched = $this->match_option_value($value, (array) $field['options']); + if ($matched !== null) { + return $matched; + } + } + if (in_array($type, ['fieldset', 'accordion'], true)) { return $this->sanitize_object_fields($value, (array) ($field['fields'] ?? [])); } diff --git a/tests/widget-system/vip-settings-structured-contract.test.cjs b/tests/widget-system/vip-settings-structured-contract.test.cjs index fd5475e5..01c0797a 100644 --- a/tests/widget-system/vip-settings-structured-contract.test.cjs +++ b/tests/widget-system/vip-settings-structured-contract.test.cjs @@ -288,6 +288,30 @@ test('积分兑换明细为结构化 group,时长为 time + Permanent 语义', assert.deepStrictEqual(group.fields.find((f) => f.key === 'unit').dependsOn, { key: 'time', operator: 'neq', value: 'Permanent' }); }); +test('积分兑换明细.level 枚举净化后仍能通过校验,非法值依旧报错', () => { + const { sanitize } = harness; + + // level 的选项值是整数 1 / 2。默认两行提交数字即可保存,且净化后必须保持 + // 整数类型:前端 n-radio 用 === 比较保存值,字符串化会让单选框失活。 + assert.strictEqual(sanitize.default_rows.valid, true); + assert.deepStrictEqual(sanitize.default_rows.errors, []); + assert.deepStrictEqual(sanitize.default_rows.points_rows.map((r) => r.level), [1, 1]); + + // SPA 提交字符串等级时,同样收敛回选项声明的整数。 + assert.strictEqual(sanitize.string_level.valid, true); + assert.deepStrictEqual(sanitize.string_level.points_rows.map((r) => r.level), [2]); + + // 收敛不得掩盖真正的非法值:level 与 unit 仍各自报出精确错误。 + assert.strictEqual(sanitize.invalid_level.valid, false); + assert.deepStrictEqual(sanitize.invalid_level.errors, ['积分兑换明细[0].level has an invalid value']); + assert.strictEqual(sanitize.invalid_unit.valid, false); + assert.deepStrictEqual(sanitize.invalid_unit.errors, ['积分兑换明细[0].unit has an invalid value']); + + // 字符串型枚举(palette)不受影响。 + assert.strictEqual(sanitize.invalid_tag_class.valid, false); + assert.deepStrictEqual(sanitize.invalid_tag_class.errors, ['会员商品[0].tag_class has an invalid value']); +}); + test('运行时归一化:默认值、价格净化、零价与非法行剔除', () => { const defaults = harness.runtime.defaults; // 参考主题默认两行。 diff --git a/tests/widget-system/vip-settings-structured-harness.php b/tests/widget-system/vip-settings-structured-harness.php index f46c6aca..0de00ada 100644 --- a/tests/widget-system/vip-settings-structured-harness.php +++ b/tests/widget-system/vip-settings-structured-harness.php @@ -61,6 +61,7 @@ namespace { 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. */ @@ -171,6 +172,57 @@ namespace { ]; $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'], @@ -187,6 +239,7 @@ namespace { 'custom' => $custom, 'legacy' => $legacy, ], + 'sanitize' => $sanitize, 'accordion' => (static function (array $groups): array { $out = ['groups' => [], 'sections' => []]; foreach ($groups as $group) {