Files
wordpress-theme-aksrt/src/Admin/SchemaProvider.php
T
kate522 aad83072a8 refactor(pay): 统一流水号,移除订单/流水/提现三类号码前缀
订单号、资金流水号、提现单号此前各自带前缀配置,同一笔业务在不同界面会
呈现多个号码口径,对账与客诉排查都要先做一次前缀换算。现收敛为单一流水号
分配器,不再按业务类型区分号码形态。

- 删除 payment_order_prefix / payment_flow_prefix /
  payment_withdrawal_prefix 三项配置项及其生成、校验链路
- BusinessNumber::allocate() 成为唯一入口,输出 {YmdHis}{6 位随机},
  共 20 位无前缀,满足 VARCHAR(32) 与网关 out_trade_no ≤ 32 的限制
- 订单、资金流水、提现、卡密渠道交易号四处生成点全部改走该分配器
- 后台支付中心表头「订单号 / 提现单号」统一为「流水号」
- 前台用户中心与订单卡片文案同步统一;前台订单详情合并为单行流水号,
  账本流水号不再下发到前台,避免同一单据出现两个对外号码
- 同步更新 financial-flow / pay-card / card-pass-order-numbers 契约测试
  与 BusinessNumberTest 断言

按用户要求不做双路逻辑与数据迁移,直接替换为统一方案,历史行不做转换。

验证:node --test tests/**/*.test.cjs 362/364 通过,新增 37 项契约;失败的
2 项在改动前的干净 HEAD 基线上同样失败(基线 327 项 / 失败 2 项),分别命中
遗留的 fa fa- 标记与 FA 版本号断言,与本次改动无关。phpunit 44 项 / 103
断言全部通过;php -l 全部改动的 PHP 文件通过。
2026-09-18 22:14:27 +08:00

1122 lines
43 KiB
PHP
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.
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Admin;
defined('ABSPATH') || exit;
class SchemaProvider {
private static $instance = null;
/**
* @var array<string, array<string, mixed>>
*/
private $domain_schemas = [];
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function normalize_domain(string $domain): string {
$domain = trim(str_replace('-', '_', $domain));
return $domain !== '' ? $domain : 'global';
}
public function normalize_section(string $section): string {
return trim($section);
}
public function get_domain_keys(): array {
return [
'global',
'page_display',
'post_list',
'permissions',
'users',
'payment',
'extensions',
'tools',
];
}
public function get_schema(string $domain): ?array {
$domain = $this->normalize_domain($domain);
if (isset($this->domain_schemas[$domain])) {
return $this->domain_schemas[$domain];
}
$builder_map = $this->get_schema_builder_map();
if (!isset($builder_map[$domain])) {
return null;
}
$builder = $builder_map[$domain];
$this->domain_schemas[$domain] = $builder::build();
return $this->domain_schemas[$domain];
}
public function get_sections(string $domain): array {
$schema = $this->get_schema($domain);
return is_array($schema['sections'] ?? null) ? $schema['sections'] : [];
}
public function get_section_schema(string $domain, string $section): ?array {
$sections = $this->get_sections($domain);
$section = $this->normalize_section($section);
return $sections[$section] ?? null;
}
public function get_option_key(string $domain, string $section = ''): string {
if ($section !== '') {
$schema = $this->get_section_schema($domain, $section);
return (string) ($schema['option_key'] ?? 'aksrt_settings');
}
$primary = [
'global' => 'aksrt_settings',
'page_display' => 'aksrt_settings',
'post_list' => 'aksrt_settings',
'permissions' => 'aksrt_settings',
'users' => 'aksrt_settings',
'payment' => 'aksrt_settings',
'extensions' => 'aksrt_settings',
'tools' => 'aksrt_settings',
];
return $primary[$this->normalize_domain($domain)] ?? 'aksrt_settings';
}
public function get_defaults(string $domain, string $section = ''): array {
if ($section !== '') {
$schema = $this->get_section_schema($domain, $section);
return $schema ? $this->collect_defaults($schema) : [];
}
$defaults = [];
foreach ($this->get_sections($domain) as $section_schema) {
$defaults = array_merge($defaults, $this->collect_defaults($section_schema));
}
return $defaults;
}
public function get_allowed_fields(string $domain, string $section = ''): array {
if ($section !== '') {
$schema = $this->get_section_schema($domain, $section);
return $schema ? $this->collect_allowed_fields($schema) : [];
}
$allowed = [];
foreach ($this->get_sections($domain) as $section_schema) {
$allowed = array_merge($allowed, $this->collect_allowed_fields($section_schema));
}
return $allowed;
}
/**
* Sanitize a schema payload with the full nested field contract.
*/
public function sanitize_payload(string $domain, array $data, string $section = ''): array {
$schema = $section !== '' ? $this->get_section_schema($domain, $section) : $this->get_schema($domain);
if (!$schema) {
return [];
}
$clean = [];
foreach ($this->get_all_fields($schema) as $field) {
$key = (string) $field['key'];
if (array_key_exists($key, $data)) {
$clean[$key] = $this->sanitize_field_value($data[$key], $field);
}
}
if (!empty($data['__clear_secrets']) && is_array($data['__clear_secrets'])) {
$requested = array_values(array_filter(array_map([$this, 'normalize_secret_path'], $data['__clear_secrets'])));
$clean['__clear_secrets'] = array_values(array_intersect($requested, $this->secret_field_paths($schema)));
}
return $clean;
}
public function validate(string $domain, array $data, string $section = ''): array {
$schemas = [];
if ($section !== '') {
$schema = $this->get_section_schema($domain, $section);
if (!$schema) {
return ['valid' => false, 'errors' => ['Unknown section']];
}
$schemas[] = $schema;
} else {
$schemas = array_values($this->get_sections($domain));
}
$errors = [];
foreach ($schemas as $schema) {
foreach ($this->get_all_fields($schema) as $field) {
$key = $field['key'];
if (!array_key_exists($key, $data)) {
continue;
}
$value = $data[$key];
if (!empty($field['required']) && ($value === '' || $value === null)) {
$errors[] = sprintf('%s is required', $field['label'] ?? $key);
continue;
}
if (($field['sanitize_type'] ?? '') === 'money_decimal') {
if ($this->normalize_money_decimal($value) === '') {
$errors[] = sprintf('%s must be a decimal string with at most two fractional digits', $field['label'] ?? $key);
continue;
}
}
$type = $field['type'] ?? 'input';
if (($type === 'number' || $type === 'slider') && !is_numeric($value)) {
$errors[] = sprintf('%s must be a number', $field['label'] ?? $key);
continue;
}
if ($type === 'checkbox' && !is_array($value)) {
$errors[] = sprintf('%s must be an array', $field['label'] ?? $key);
continue;
}
if (in_array($type, ['fieldset', 'accordion', 'sortable', 'link', 'between_number'], true) && !is_array($value)) {
$errors[] = sprintf('%s must be an object', $field['label'] ?? $key);
continue;
}
if ($type === 'group' && !is_array($value)) {
$errors[] = sprintf('%s must be an array of items', $field['label'] ?? $key);
continue;
}
if (in_array($type, ['group', 'repeater'], true) && is_array($value)) {
if (isset($field['min']) && count($value) < (int) $field['min']) {
$errors[] = sprintf('%s must contain at least %s items', $field['label'] ?? $key, $field['min']);
}
if (isset($field['max']) && count($value) > (int) $field['max']) {
$errors[] = sprintf('%s must contain at most %s items', $field['label'] ?? $key, $field['max']);
}
}
if ($type === 'repeater' && !is_array($value)) {
$errors[] = sprintf('%s must be an array of items', $field['label'] ?? $key);
continue;
}
if (isset($field['min']) && is_numeric($value) && $value < $field['min']) {
$errors[] = sprintf('%s must be at least %s', $field['label'] ?? $key, $field['min']);
}
if (isset($field['max']) && is_numeric($value) && $value > $field['max']) {
$errors[] = sprintf('%s must be at most %s', $field['label'] ?? $key, $field['max']);
}
if (in_array($type, ['select', 'radio', 'palette', 'image_select'], true) && !empty($field['options'])) {
// Optional enum fields use an empty string to mean "unset".
// This is required for clearable palettes such as the footer
// background preset, whose default is intentionally empty.
if ($value === '' && empty($field['required'])) {
continue;
}
$valid_values = array_column($field['options'], 'value');
// Multi-select: accept array values when schema.multiple is set
// (legacy / SPA may still use type=select multiple).
if (!empty($field['multiple']) && is_array($value)) {
foreach ($value as $item) {
if (!in_array($item, $valid_values, true)
&& (empty($field['case_insensitive']) || $this->normalize_case_insensitive_option($item, (array) $field['options']) === null)) {
$errors[] = sprintf('%s has an invalid value', $field['label'] ?? $key);
break;
}
}
} elseif (!in_array($value, $valid_values, true)
&& (empty($field['case_insensitive']) || $this->normalize_case_insensitive_option($value, (array) $field['options']) === null)) {
$errors[] = sprintf('%s has an invalid value', $field['label'] ?? $key);
}
}
if ($type === 'checkbox' && !empty($field['options'])) {
$valid_values = array_column($field['options'], 'value');
if (!is_array($value)) {
$errors[] = sprintf('%s must be an array', $field['label'] ?? $key);
continue;
}
foreach ((array) $value as $item) {
if (!in_array($item, $valid_values, true)) {
$errors[] = sprintf('%s has an invalid value', $field['label'] ?? $key);
break;
}
}
}
$errors = array_merge($errors, $this->validate_nested_field($field, $value, (string) ($field['label'] ?? $key)));
}
}
if ($domain === 'users' && ($section === 'oauth' || $section === '')) {
$stored = get_option('aksrt_settings', []);
$merged = is_array($stored) ? array_merge($stored, $data) : $data;
foreach ((array) ($data['__clear_secrets'] ?? []) as $secret_key) {
$merged[sanitize_key((string) $secret_key)] = '';
}
$errors = array_merge($errors, $this->validate_oauth_settings($merged, is_array($stored) ? $stored : []));
}
if ($domain === 'payment' && $section === 'withdrawal') {
$stored = get_option('aksrt_settings', []);
$merged = is_array($stored) ? array_merge($stored, $data) : $data;
$count = $merged['affiliate_daily_withdrawal_count'] ?? 3;
$min_raw = $merged['affiliate_min_withdrawal'] ?? '50.00';
$limit_raw = $merged['affiliate_daily_withdrawal_limit'] ?? '500.00';
$time = (string) ($merged['affiliate_withdrawal_reset_time'] ?? '00:00');
if (filter_var($count, FILTER_VALIDATE_INT) === false || (int) $count < 0) {
$errors[] = __('每日可提现次数必须是非负整数', 'aksrt');
}
try {
$min_minor = \AKSRT\Ledger\AssetAmount::balance(is_string($min_raw) ? $min_raw : '')->minor();
$limit_minor = \AKSRT\Ledger\AssetAmount::balance(is_string($limit_raw) ? $limit_raw : '')->minor();
} catch (\InvalidArgumentException $e) {
$min_minor = 0;
$limit_minor = 0;
}
if ($limit_minor < $min_minor) {
$errors[] = __('每日提现上限不得低于最低提现金额', 'aksrt');
}
if (preg_match('/^(?:[01]\\d|2[0-3]):[0-5]\\d$/', $time) !== 1) {
$errors[] = __('重置时间必须为 HH:MM', 'aksrt');
}
}
if ($domain === 'permissions' && $section === 'download') {
$alias = trim((string) ($data['url_alias'] ?? ''));
if ($alias === '') {
$errors[] = __('下载链接 URL 别名不能为空', 'aksrt');
} elseif (strlen($alias) > 48) {
$errors[] = __('下载链接 URL 别名长度不能超过 48 个字符', 'aksrt');
} elseif (!preg_match('/^[A-Za-z0-9_-]+$/', $alias)) {
$errors[] = __('下载链接 URL 别名只能包含英文字母、数字、连字符和下划线,禁止中文、空格和 / \\ ? # & 等特殊符号', 'aksrt');
} elseif (in_array(strtolower($alias), $this->reserved_download_aliases(), true)) {
$errors[] = __('该别名与系统保留路径冲突,请更换其他别名以避免路由 404', 'aksrt');
}
}
return ['valid' => empty($errors), 'errors' => $errors];
}
private function validate_oauth_settings(array $settings, array $stored = []): array {
if (empty($settings['oauth_s'])) {
return [];
}
$errors = [];
$mode = sanitize_key((string) ($settings['oauth_proxy_mode'] ?? 'close'));
$secret_configured = $this->oauth_secret_is_configured($settings['oauth_proxy_secret'] ?? '', $stored['oauth_proxy_secret'] ?? '');
if ($mode === 'client') {
$url = trim((string) ($settings['oauth_proxy_server_url'] ?? ''));
if (!$this->is_secure_oauth_url($url)) {
$errors[] = __('客户端代理模式需要有效的 HTTPS 服务端地址', 'aksrt');
}
if (!$secret_configured) {
$errors[] = __('客户端代理模式需要代理 HMAC 密钥', 'aksrt');
}
if (empty($settings['oauth_proxy_client_providers']) || !is_array($settings['oauth_proxy_client_providers'])) {
$errors[] = __('客户端代理模式至少需要启用一个登录平台', 'aksrt');
}
return $errors;
}
if ($mode === 'server') {
if (!$secret_configured) {
$errors[] = __('服务端代理模式需要代理 HMAC 密钥', 'aksrt');
}
$selected = is_array($settings['oauth_proxy_server_providers'] ?? null) ? $settings['oauth_proxy_server_providers'] : [];
if ($selected === []) {
$errors[] = __('服务端代理模式至少需要允许一个登录平台', 'aksrt');
}
$provider_fields = [
'github' => ['GitHub', 'github_appid', 'github_appkey'],
'gitee' => ['Gitee', 'gitee_appid', 'gitee_appkey'],
'qq' => ['QQ', 'qq_appid', 'qq_appkey'],
'google' => ['Google', 'google_client_id', 'google_client_secret'],
'weibo' => ['微博', 'weibo_appid', 'weibo_appkey'],
'weixin' => ['微信开放平台', 'weixin_appid', 'weixin_appsecret'],
];
foreach ($selected as $provider) {
if (!isset($provider_fields[$provider])) {
continue;
}
[$label, $id_key, $secret_key] = $provider_fields[$provider];
if (trim((string) ($settings[$id_key] ?? '')) === '' || !$this->oauth_secret_is_configured($settings[$secret_key] ?? '', $stored[$secret_key] ?? '')) {
$errors[] = sprintf(__('代理服务端的 %s 平台缺少完整凭据', 'aksrt'), $label);
}
}
return $errors;
}
$providers = [
'github' => ['GitHub', 'github_appid', 'github_appkey'],
'gitee' => ['Gitee', 'gitee_appid', 'gitee_appkey'],
'qq' => ['QQ', 'qq_appid', 'qq_appkey'],
'google' => ['Google', 'google_client_id', 'google_client_secret'],
'weibo' => ['微博', 'weibo_appid', 'weibo_appkey'],
'weixin' => ['微信开放平台', 'weixin_appid', 'weixin_appsecret'],
];
$enabled = 0;
foreach ($providers as $provider => [$label, $id_key, $secret_key]) {
if (empty($settings['oauth_' . $provider . '_s'])) {
continue;
}
$enabled++;
if (trim((string) ($settings[$id_key] ?? '')) === '' || !$this->oauth_secret_is_configured($settings[$secret_key] ?? '', $stored[$secret_key] ?? '')) {
$errors[] = sprintf(__('%s 登录已启用,请填写完整凭据', 'aksrt'), $label);
}
}
if (!empty($settings['oauth_weixingzh_s'])) {
$enabled++;
$gzh_type = sanitize_key((string) ($settings['weixingzh_gzh_type'] ?? 'service'));
$base_ready = $this->oauth_secret_is_configured($settings['weixingzh_token'] ?? '', $stored['weixingzh_token'] ?? '')
&& trim((string) ($settings['weixingzh_appid'] ?? '')) !== '';
$mode_ready = $gzh_type === 'subscribe'
? trim((string) ($settings['weixingzh_code_qrcode'] ?? '')) !== ''
&& trim((string) ($settings['weixingzh_code_keyword'] ?? '')) !== ''
: $this->oauth_secret_is_configured($settings['weixingzh_appsecret'] ?? '', $stored['weixingzh_appsecret'] ?? '');
if (!$base_ready || !$mode_ready) {
$errors[] = $gzh_type === 'subscribe'
? __('微信公众号订阅号登录已启用,请填写 Token、AppID、二维码和回复关键词', 'aksrt')
: __('微信公众号服务号登录已启用,请填写 Token、AppID 和 AppSecret', 'aksrt');
}
}
if (!empty($settings['clogin_s'])) {
$enabled++;
if (!$this->is_secure_oauth_url((string) ($settings['clogin_url'] ?? ''))
|| trim((string) ($settings['clogin_id'] ?? '')) === ''
|| !$this->oauth_secret_is_configured($settings['clogin_key'] ?? '', $stored['clogin_key'] ?? '')
|| empty($settings['clogin_oauth_s'])
) {
$errors[] = __('彩虹聚合登录已启用,请填写 HTTPS 接口地址、AppID、AppKey 并选择平台', 'aksrt');
}
}
if ($enabled === 0) {
$errors[] = __('启用第三方登录后,至少需要启用一个本地登录提供商', 'aksrt');
}
return $errors;
}
private function oauth_secret_is_configured($value, $stored_value = ''): bool {
$value = trim((string) $value);
if ($value === '***') {
return trim((string) $stored_value) !== '';
}
return $value !== '';
}
private function is_secure_oauth_url(string $url): bool {
$url = trim($url);
if ($url === '' || filter_var($url, FILTER_VALIDATE_URL) === false) {
return false;
}
$parts = wp_parse_url($url);
return is_array($parts)
&& strtolower((string) ($parts['scheme'] ?? '')) === 'https'
&& !empty($parts['host'])
&& empty($parts['user'])
&& empty($parts['pass'])
&& empty($parts['fragment']);
}
/**
* Top-level slugs that must never be shadowed by the download route.
*/
private function reserved_download_aliases(): array {
return [
'wp-json', 'wp-admin', 'wp-content', 'wp-includes',
'admin', 'login', 'logout', 'register', 'lostpassword',
'feed', 'search', 'author', 'page', 'comments',
'sitemap', 'sign', 'cgi-bin',
];
}
public function read_settings(string $domain, string $section = ''): array {
if ($section !== '') {
$schema = $this->get_section_schema($domain, $section);
if (!$schema) {
return [];
}
return $this->read_section_settings($schema);
}
$settings = [];
foreach ($this->get_sections($domain) as $section_schema) {
$settings = array_merge($settings, $this->read_section_settings($section_schema));
}
return $settings;
}
public function write_settings(string $domain, array $data, string $section = ''): bool {
if ($section !== '') {
$schema = $this->get_section_schema($domain, $section);
if (!$schema) {
return false;
}
return $this->write_section_settings($schema, $data);
}
$result = true;
foreach ($this->get_sections($domain) as $section_schema) {
$allowed = array_keys($this->collect_allowed_fields($section_schema));
$payload = array_intersect_key($data, array_flip($allowed));
if ($payload === []) {
continue;
}
$result = $this->write_section_settings($section_schema, $payload) && $result;
}
return $result;
}
public function reset_to_defaults(string $domain, string $section = ''): bool {
if ($section !== '') {
$defaults = $this->get_defaults($domain, $section);
$schema = $this->get_section_schema($domain, $section);
$defaults['__clear_secrets'] = $schema ? $this->secret_field_paths($schema) : [];
return $this->write_settings($domain, $defaults, $section);
}
$result = true;
foreach ($this->get_sections($domain) as $section_schema) {
$defaults = $this->collect_defaults($section_schema);
$defaults['__clear_secrets'] = $this->secret_field_paths($section_schema);
$result = $this->write_section_settings($section_schema, $defaults) && $result;
}
return $result;
}
private function get_schema_builder_map(): array {
// Builders live under AKSRT\Admin\Schema (Composer PSR-4). Relative
// Schema\*::class resolves correctly from this AKSRT\Admin namespace.
return [
'global' => Schema\GlobalSchema::class,
'page_display' => Schema\SchemaPageDisplay::class,
'post_list' => Schema\SchemaPostList::class,
'permissions' => Schema\SchemaPermissions::class,
'users' => Schema\SchemaUsers::class,
'payment' => Schema\SchemaPayment::class,
'extensions' => Schema\SchemaExtensions::class,
'tools' => Schema\SchemaTools::class,
];
}
private function read_section_settings(array $schema): array {
$option_key = (string) ($schema['option_key'] ?? 'aksrt_settings');
$stored = get_option($option_key, []);
if (!is_array($stored)) {
$stored = [];
}
$data = $this->collect_defaults($schema);
foreach ($this->get_all_fields($schema) as $field) {
$field_type = (string) ($field['type'] ?? 'text');
$has_stored = array_key_exists($field['key'], $stored);
// group/repeater 只接受数组;旧标量值(如 textarea 时代的字符串)
// 直接忽略并回退默认,避免前端把字符串按字符渲染成大量假条目。
if ($has_stored && in_array($field_type, ['group', 'repeater'], true) && !is_array($stored[$field['key']])) {
$has_stored = false;
}
if ($has_stored) {
$data[$field['key']] = $stored[$field['key']];
}
// 大小写不敏感字段:存储值归一化为选项规范写法;匹配不到回退默认,
// 避免旧自由文本值(如小写货币代码)导致枚举校验失败。
if (!empty($field['case_insensitive']) && in_array($field_type, ['select', 'radio'], true) && !empty($field['options'])) {
$normalized = $this->normalize_case_insensitive_option($data[$field['key']] ?? '', (array) $field['options']);
$data[$field['key']] = $normalized !== null ? $normalized : ($field['default'] ?? ($data[$field['key']] ?? ''));
}
if (($field['sanitize_type'] ?? '') === 'money_decimal' && array_key_exists($field['key'], $data)) {
$normalized = $this->normalize_money_decimal($data[$field['key']]);
if ($normalized !== '') {
$data[$field['key']] = $normalized;
}
}
$stored_value = $has_stored ? $stored[$field['key']] : ($data[$field['key']] ?? '');
if (!empty($field['secret'])) {
$data[$field['key']] = trim((string) $stored_value) !== '' ? '***' : '';
}
if (!empty($field['captcha_provider'])) {
$data[$field['key']] = $this->mask_captcha_provider_secrets(
(string) $field['captcha_provider'],
is_array($stored_value) ? $stored_value : []
);
}
}
// Captcha: always surface the normalized positive scene list.
if ($option_key === 'aksrt_captcha_config' && function_exists('aksrt_get_captcha_config')) {
$normalized = aksrt_get_captcha_config();
foreach (['enabled', 'active_provider', 'enabled_scenarios'] as $key) {
if (array_key_exists($key, $normalized)) {
$data[$key] = $normalized[$key];
}
}
foreach (function_exists('aksrt_captcha_providers') ? aksrt_captcha_providers() : [] as $provider) {
$data[$provider] = $this->mask_captcha_provider_secrets($provider, (array) ($normalized[$provider] ?? []));
}
}
return $data;
}
private function write_section_settings(array $schema, array $data): bool {
$option_key = (string) ($schema['option_key'] ?? 'aksrt_settings');
$stored = get_option($option_key, []);
if (!is_array($stored)) {
$stored = [];
}
$fields = [];
foreach ($this->get_all_fields($schema) as $field) {
$fields[(string) $field['key']] = $field;
}
$clear_secrets = isset($data['__clear_secrets']) && is_array($data['__clear_secrets'])
? array_values(array_filter(array_map([$this, 'normalize_secret_path'], $data['__clear_secrets'])))
: [];
foreach ($fields as $key => $field) {
if (!empty($field['secret']) && in_array($key, $clear_secrets, true)) {
unset($stored[$key]);
continue;
}
if (!empty($field['captcha_provider'])) {
$provider = (string) $field['captcha_provider'];
$current = isset($stored[$key]) && is_array($stored[$key]) ? $stored[$key] : [];
$incoming = array_key_exists($key, $data) && is_array($data[$key]) ? $data[$key] : [];
$stored[$key] = $this->merge_captcha_provider_config($provider, $current, $incoming, $clear_secrets);
continue;
}
if (!array_key_exists($key, $data)) {
continue;
}
if (!empty($field['secret'])) {
$value = is_scalar($data[$key]) ? trim((string) $data[$key]) : '';
if ($value === '' || $value === '***') {
continue;
}
}
$stored[$key] = $this->sanitize_field_value($data[$key], $field);
}
// Captcha stores a single positive scene list.
if ($option_key === 'aksrt_captcha_config' && function_exists('aksrt_captcha_scenes')) {
$stored['enabled'] = !empty($stored['enabled']);
$active_provider = sanitize_key((string) ($stored['active_provider'] ?? ''));
$stored['active_provider'] = in_array($active_provider, aksrt_captcha_providers(), true) ? $active_provider : '';
$stored['enabled_scenarios'] = function_exists('aksrt_captcha_sanitize_scene_list')
? aksrt_captcha_sanitize_scene_list($stored['enabled_scenarios'] ?? [])
: (array) ($stored['enabled_scenarios'] ?? []);
foreach (aksrt_captcha_providers() as $provider) {
$stored[$provider] = function_exists('aksrt_captcha_sanitize_provider_config')
? aksrt_captcha_sanitize_provider_config($provider, $stored[$provider] ?? [])
: (array) ($stored[$provider] ?? []);
}
}
$updated = update_option($option_key, $stored);
if ($option_key === 'aksrt_captcha_config' && function_exists('aksrt_captcha_config_reset')) {
aksrt_captcha_config_reset();
}
\AKSRT\Settings\Settings::flush_settings_cache();
if (isset($stored['site_favicon'])) {
\AKSRT\Settings\Settings::sync_native_site_icon((string) $stored['site_favicon']);
}
return $updated || true;
}
private function normalize_secret_path($path): string {
$parts = array_values(array_filter(explode('.', strtolower(trim((string) $path))), static fn(string $part): bool => $part !== ''));
$parts = array_map('sanitize_key', $parts);
return in_array('', $parts, true) ? '' : implode('.', $parts);
}
private function secret_field_paths(array $schema): array {
$paths = [];
foreach ($this->get_all_fields($schema) as $field) {
$key = sanitize_key((string) ($field['key'] ?? ''));
if ($key === '') {
continue;
}
if (!empty($field['secret'])) {
$paths[] = $key;
}
if (!empty($field['captcha_provider']) && function_exists('aksrt_captcha_provider_secret_fields')) {
foreach (aksrt_captcha_provider_secret_fields((string) $field['captcha_provider']) as $secret) {
$paths[] = $key . '.' . sanitize_key((string) $secret);
}
}
}
return array_values(array_unique($paths));
}
private function mask_captcha_provider_secrets(string $provider, array $config): array {
$clean = function_exists('aksrt_captcha_sanitize_provider_config')
? aksrt_captcha_sanitize_provider_config($provider, $config)
: $config;
foreach (function_exists('aksrt_captcha_provider_secret_fields') ? aksrt_captcha_provider_secret_fields($provider) : [] as $secret) {
$clean[$secret] = trim((string) ($config[$secret] ?? '')) !== '' ? '***' : '';
}
return $clean;
}
private function merge_captcha_provider_config(string $provider, array $stored, array $incoming, array $clear_paths): array {
$current = function_exists('aksrt_captcha_sanitize_provider_config')
? aksrt_captcha_sanitize_provider_config($provider, $stored)
: $stored;
$defaults = function_exists('aksrt_captcha_provider_defaults') ? aksrt_captcha_provider_defaults($provider) : [];
$secret_fields = function_exists('aksrt_captcha_provider_secret_fields') ? aksrt_captcha_provider_secret_fields($provider) : [];
foreach (array_keys($defaults) as $key) {
$path = $provider . '.' . $key;
if (in_array($path, $clear_paths, true)) {
$current[$key] = '';
continue;
}
if (!array_key_exists($key, $incoming) || is_array($incoming[$key]) || is_object($incoming[$key])) {
continue;
}
$value = trim((string) $incoming[$key]);
if (in_array($key, $secret_fields, true) && ($value === '' || $value === '***')) {
continue;
}
$current[$key] = $value;
}
return function_exists('aksrt_captcha_sanitize_provider_config')
? aksrt_captcha_sanitize_provider_config($provider, $current)
: $current;
}
private function secret_field_keys(array $schema): array {
return array_values(array_filter($this->secret_field_paths($schema), static fn(string $path): bool => !str_contains($path, '.')));
}
private function collect_defaults(array $schema): array {
$defaults = [];
foreach ($this->get_all_fields($schema) as $field) {
if (array_key_exists('default', $field)) {
$defaults[$field['key']] = $field['default'];
}
}
return $defaults;
}
private function collect_allowed_fields(array $schema): array {
$allowed = [];
foreach ($this->get_all_fields($schema) as $field) {
$allowed[$field['key']] = $field['sanitize_type'] ?? $this->infer_sanitize_type($field['type'] ?? 'input');
}
return $allowed;
}
private function get_all_fields(array $schema): array {
$fields = [];
foreach ($schema['groups'] ?? [] as $group) {
if (!is_array($group)) {
continue;
}
foreach ($group['fields'] ?? [] as $field) {
$fields = array_merge($fields, $this->collect_persisted_fields(is_array($field) ? $field : []));
}
}
return $fields;
}
/**
* Flatten one field definition into persisted keys. Tabbed is a UI-only
* container: its sub-fields are flat top-level option keys, so they are
* expanded into the persisted field list (notice / ui_only rows skipped).
*
* @return array<int,array<string,mixed>>
*/
private function collect_persisted_fields(array $field): array {
if (($field['type'] ?? '') === 'tabbed') {
$fields = [];
foreach ((array) ($field['tabs'] ?? []) as $tab) {
if (!is_array($tab)) {
continue;
}
foreach ((array) ($tab['fields'] ?? []) as $sub) {
$fields = array_merge($fields, $this->collect_persisted_fields(is_array($sub) ? $sub : []));
}
}
return $fields;
}
if (empty($field['key'])) {
return [];
}
// UI-only controls never become persisted settings keys.
if (!empty($field['ui_only'])) {
return [];
}
// Notice/help-only rows are UI chrome, not stored settings keys.
if (($field['type'] ?? '') === 'notice') {
return [];
}
return [$field];
}
/**
* Normalize legacy numeric money settings without accepting lossy precision.
*
* @param mixed $value
*/
private function normalize_money_decimal($value): string {
if (is_int($value)) {
$value = (string) $value;
} elseif (is_float($value)) {
if (!is_finite($value)) {
return '';
}
$value = (string) $value;
} elseif (!is_string($value)) {
return '';
}
try {
return \AKSRT\Ledger\AssetAmount::balance(trim($value))->decimal();
} catch (\InvalidArgumentException $e) {
return '';
}
}
private function sanitize_value($value, string $type) {
switch ($type) {
case 'attachment_ids':
if (!is_array($value)) {
return [];
}
$ids = array_values(array_filter(array_map('absint', $value)));
return array_values(array_unique($ids));
case 'html':
return wp_kses_post((string) $value);
case 'textarea':
return sanitize_textarea_field((string) $value);
case 'code':
return is_string($value) ? wp_unslash($value) : '';
case 'url':
return esc_url_raw((string) $value);
case 'int':
return (int) $value;
case 'bool':
return !empty($value);
case 'float':
return (float) $value;
case 'money_decimal':
return $this->normalize_money_decimal($value);
case 'array':
// 深层字符串净化,保留嵌套服务商配置(如 geetest.captcha_id)
if (!is_array($value)) {
return [];
}
$out = [];
foreach ($value as $k => $v) {
$key = is_string($k) ? sanitize_key($k) : $k;
if (is_array($v)) {
$out[$key] = $this->sanitize_value($v, 'array');
} elseif (is_bool($v)) {
$out[$key] = $v;
} elseif (is_int($v) || is_float($v)) {
$out[$key] = $v;
} else {
$out[$key] = sanitize_text_field((string) $v);
}
}
return $out;
case 'icon':
return function_exists('aksrt_sanitize_icon_value')
? aksrt_sanitize_icon_value((string) $value)
: sanitize_text_field((string) $value);
default:
return sanitize_text_field((string) $value);
}
}
/**
* 对大小写不敏感字段(case_insensitive),把值归一化为选项中的规范写法;
* 匹配不到返回 null。
*
* @param mixed $value
* @param array<int,mixed> $options
*/
private function normalize_case_insensitive_option($value, array $options): ?string {
if (!is_string($value) || $value === '') {
return null;
}
$lower = strtolower(trim($value));
foreach ($options as $opt) {
$opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt;
if (is_string($opt_value) && strtolower($opt_value) === $lower) {
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')
? aksrt_captcha_sanitize_provider_config((string) $field['captcha_provider'], $value)
: [];
}
$type = (string) ($field['type'] ?? 'text');
$sanitize_type = (string) ($field['sanitize_type'] ?? '');
if ($sanitize_type !== '') {
return $this->sanitize_value($value, $sanitize_type);
}
if ($type === 'gallery') {
return $this->sanitize_value($value, 'attachment_ids');
}
// Icon values carry inline "customsvg-<svg>" payloads that plain
// sanitize_text_field would strip down to a bare prefix. Top-level icon
// fields already route through sanitize_value('icon'); group/repeater
// sub-fields (e.g. header_slider_card_option.cards[].icon) must too.
if ($type === 'icon') {
return function_exists('aksrt_sanitize_icon_value')
? aksrt_sanitize_icon_value((string) $value)
: $this->sanitize_value($value, 'icon');
}
if (in_array($type, ['select', 'radio'], true) && !empty($field['case_insensitive']) && !empty($field['options'])) {
// 旧自由文本值(如小写货币代码)归一化为选项规范写法。
$normalized = $this->normalize_case_insensitive_option($value, (array) $field['options']);
return $normalized !== null ? $normalized : (string) ($field['default'] ?? '');
}
if (in_array($type, ['fieldset', 'accordion'], true)) {
return $this->sanitize_object_fields($value, (array) ($field['fields'] ?? []));
}
if ($type === 'between_number') {
$defaults = is_array($field['default'] ?? null) ? $field['default'] : ['min' => 0, 'max' => 0];
if (!is_array($value)) {
return ['min' => (int) ($defaults['min'] ?? 0), 'max' => (int) ($defaults['max'] ?? 0)];
}
return [
'min' => is_numeric($value['min'] ?? null) ? (int) $value['min'] : (int) ($defaults['min'] ?? 0),
'max' => is_numeric($value['max'] ?? null) ? (int) $value['max'] : (int) ($defaults['max'] ?? 0),
];
}
if (in_array($type, ['group', 'repeater'], true)) {
if (!is_array($value)) {
return [];
}
$items = [];
$limit = isset($field['max']) ? max(0, (int) $field['max']) : 100;
foreach (array_slice(array_values($value), 0, $limit ?: 100) as $item) {
if (is_array($item)) {
$items[] = $this->sanitize_object_fields($item, (array) ($field['fields'] ?? []));
}
}
return $items;
}
if ($type === 'sortable') {
if (!is_array($value)) {
return [];
}
$definitions = [];
foreach ((array) ($field['items'] ?? []) as $item_schema) {
if (!is_array($item_schema)) {
continue;
}
$item_key = sanitize_key((string) ($item_schema['key'] ?? ''));
if ($item_key !== '') {
$definitions[$item_key] = $item_schema;
}
}
// 键顺序即配置值本身:必须按提交顺序重建。若改为遍历 items
// 定义,用户排序会被静默改写回 schema 静态顺序(等于排序失效)。
$items = [];
foreach ($value as $item_key => $item_value) {
$item_key = sanitize_key((string) $item_key);
if ($item_key === '' || !isset($definitions[$item_key]) || !is_array($item_value)) {
continue;
}
$items[$item_key] = $this->sanitize_object_fields($item_value, (array) ($definitions[$item_key]['fields'] ?? []));
}
return $items;
}
if ($type === 'link') {
$value = is_array($value) ? $value : [];
return [
'url' => esc_url_raw((string) ($value['url'] ?? '')),
'target' => in_array(($value['target'] ?? ''), ['', '_self', '_blank'], true) ? (string) ($value['target'] ?? '') : '',
'text' => sanitize_text_field((string) ($value['text'] ?? '')),
];
}
if ($type === 'checkbox' || !empty($field['multiple'])) {
if (!is_array($value)) {
return [];
}
return array_values(array_map(static function ($item) {
return is_numeric($item) ? (int) $item : sanitize_key((string) $item);
}, $value));
}
if (in_array($type, ['switch', 'switcher'], true)) {
return !empty($value);
}
if (in_array($type, ['number', 'slider', 'spinner'], true)) {
return is_float($value) ? (float) $value : (int) $value;
}
if (in_array($type, ['media', 'upload', 'url'], true)) {
return esc_url_raw((string) $value);
}
return sanitize_text_field((string) $value);
}
private function sanitize_object_fields($value, array $fields): array {
if (!is_array($value)) {
return [];
}
$clean = [];
foreach ($fields as $field) {
if (!is_array($field) || empty($field['key'])) {
continue;
}
$key = (string) $field['key'];
if (array_key_exists($key, $value)) {
$clean[$key] = $this->sanitize_field_value($value[$key], $field);
}
}
return $clean;
}
private function validate_nested_field(array $field, $value, string $path): array {
$type = (string) ($field['type'] ?? 'text');
$errors = [];
if (in_array($type, ['fieldset', 'accordion'], true) && is_array($value)) {
$errors = $this->validate_object_fields((array) ($field['fields'] ?? []), $value, $path);
} elseif (in_array($type, ['group', 'repeater'], true) && is_array($value)) {
foreach (array_values($value) as $index => $item) {
if (is_array($item)) {
$errors = array_merge($errors, $this->validate_object_fields((array) ($field['fields'] ?? []), $item, $path . '[' . $index . ']'));
}
}
} elseif ($type === 'sortable' && is_array($value)) {
$items = [];
foreach ((array) ($field['items'] ?? []) as $item_schema) {
if (is_array($item_schema) && !empty($item_schema['key'])) {
$items[(string) $item_schema['key']] = $item_schema;
}
}
foreach (array_keys($value) as $item_key) {
if (!isset($items[$item_key])) {
$errors[] = sprintf('%s has an invalid item', $path);
} elseif (is_array($value[$item_key])) {
$errors = array_merge($errors, $this->validate_object_fields((array) ($items[$item_key]['fields'] ?? []), $value[$item_key], $path . '.' . $item_key));
}
}
}
return $errors;
}
private function validate_object_fields(array $fields, array $value, string $path): array {
$errors = [];
foreach ($fields as $field) {
$key = (string) ($field['key'] ?? '');
if ($key === '' || !array_key_exists($key, $value)) {
continue;
}
$type = (string) ($field['type'] ?? 'text');
if (in_array($type, ['select', 'radio', 'palette', 'image_select'], true) && !empty($field['options'])) {
$allowed = array_column($field['options'], 'value');
if (!empty($field['multiple']) && is_array($value[$key])) {
foreach ($value[$key] as $item) {
if (!in_array($item, $allowed, true)) {
$errors[] = sprintf('%s.%s has an invalid value', $path, $key);
break;
}
}
} elseif (!in_array($value[$key], $allowed, true)) {
$errors[] = sprintf('%s.%s has an invalid value', $path, $key);
}
}
if (in_array($type, ['number', 'slider', 'spinner'], true)) {
if (!is_numeric($value[$key])) {
$errors[] = sprintf('%s.%s must be a number', $path, $key);
} elseif (isset($field['min']) && $value[$key] < $field['min']) {
$errors[] = sprintf('%s.%s must be at least %s', $path, $key, $field['min']);
} elseif (isset($field['max']) && $value[$key] > $field['max']) {
$errors[] = sprintf('%s.%s must be at most %s', $path, $key, $field['max']);
}
}
$errors = array_merge($errors, $this->validate_nested_field($field, $value[$key], $path . '.' . $key));
}
return $errors;
}
private function infer_sanitize_type(string $field_type): string {
$map = [
'switch' => 'bool',
'switcher' => 'bool',
'input' => 'string',
'text' => 'string',
'textarea' => 'textarea',
'number' => 'int',
'slider' => 'int',
'select' => 'string',
'radio' => 'string',
'checkbox' => 'array',
'repeater' => 'array',
'group' => 'array',
'fieldset' => 'array',
'accordion'=> 'array',
'sortable' => 'array',
'link' => 'array',
'between_number' => 'array',
'page_select' => 'int',
'async_select' => 'array',
'json' => 'array',
// 自定义面板(验证码服务商等)按对象存储
'custom' => 'array',
'color' => 'string',
'icon' => 'icon',
'media' => 'url',
'upload' => 'url',
'gallery' => 'attachment_ids',
'code' => 'code',
];
return $map[$field_type] ?? 'string';
}
}