键名清单 */ public const TOKEN_SCHEMA = [ 'color' => [ 'primary', 'primary_soft', 'primary_strong', 'accent', 'bg', 'bg_soft', 'surface', 'surface_soft', 'text', 'text_soft', 'text_muted', 'border', 'price', 'success', 'warning', 'danger', 'mask', ], 'font' => [ 'family', 'family_title', 'size_xs', 'size_sm', 'size_md', 'size_lg', 'size_xl', 'size_title', 'weight_normal', 'weight_bold', 'line_height', 'letter_spacing', ], 'radius' => ['none', 'sm', 'md', 'lg', 'xl', 'pill'], 'shadow' => ['none', 'sm', 'md', 'lg'], 'space' => ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'page'], 'motion' => ['fast', 'base', 'slow', 'easing', 'easing_in', 'easing_out'], ]; /** * 允许的布局键与可选值 */ public const LAYOUT_SCHEMA = [ 'home' => [ 'hero' => ['banner', 'carousel', 'split', 'fullscreen'], 'category' => ['grid', 'scroll', 'card', 'sidebar'], 'product' => ['waterfall', 'list', 'grid', 'magazine'], ], 'product' => [ 'gallery' => ['swiper', 'stack', 'fullbleed'], 'price' => ['inline', 'card', 'sticky'], 'action' => ['fixed', 'inline'], ], 'list' => [ 'style' => ['card', 'table', 'timeline'], ], 'mine' => [ 'header' => ['gradient', 'image', 'plain'], 'menu' => ['grid', 'list'], ], 'effect' => [ 'transition' => ['fade', 'slide', 'zoom', 'none'], 'skeleton' => ['shimmer', 'pulse', 'none'], ], ]; /** * 危险片段:出现即拒绝整个值 */ private const FORBIDDEN = ['<', '>', ';', '{', '}', 'url(', 'expression', 'javascript:', 'import']; public static function getInstance(): null|static { $name = get_called_class(); if (!isset(self::$_instance[$name])) { self::$_instance[$name] = new static(); } return self::$_instance[$name]; } /** * 清洗令牌:只保留白名单键,值必须安全 * * @param mixed $tokens 数组或 JSON 字符串 * @param bool $strict true 时遇到非法值直接报错(导入场景),false 时静默丢弃 */ public function sanitizeTokens(mixed $tokens, bool $strict = false): array { $tokens = $this->toArray($tokens); $clean = []; foreach (self::TOKEN_SCHEMA as $group => $keys) { $source = $tokens[$group] ?? []; if (!is_array($source)) { continue; } foreach ($keys as $key) { if (!array_key_exists($key, $source)) { continue; } $value = $source[$key]; if (!$this->isSafeValue($value)) { if ($strict) { UtilsService::getInstance()->errorThrow("模板令牌 {$group}.{$key} 的值不合法"); } continue; } $clean[$group][$key] = (string) $value; } } return $clean; } /** * 清洗布局:值必须是枚举里的选项,非法值退回该项的第一个选项 */ public function sanitizeLayout(mixed $layout, bool $strict = false): array { $layout = $this->toArray($layout); $clean = []; foreach (self::LAYOUT_SCHEMA as $page => $options) { $source = $layout[$page] ?? []; if (!is_array($source)) { continue; } foreach ($options as $key => $allowed) { if (!array_key_exists($key, $source)) { continue; } $value = (string) $source[$key]; if (!in_array($value, $allowed, true)) { if ($strict) { UtilsService::getInstance()->errorThrow("模板布局 {$page}.{$key} 只能是:" . implode('/', $allowed)); } $value = $allowed[0]; } $clean[$page][$key] = $value; } } return $clean; } /** * 小程序侧要的扁平 CSS 变量表:--color-primary 这种 */ public function toCssVariables(array $tokens): array { $vars = []; foreach ($tokens as $group => $items) { if (!is_array($items)) { continue; } foreach ($items as $key => $value) { $vars['--' . str_replace('_', '-', $group . '-' . $key)] = $value; } } return $vars; } private function isSafeValue(mixed $value): bool { if (is_int($value) || is_float($value)) { return true; } if (!is_string($value)) { return false; } $value = trim($value); if ($value === '' || mb_strlen($value) > 64) { return false; } foreach (self::FORBIDDEN as $needle) { if (stripos($value, $needle) !== false) { return false; } } return true; } private function toArray(mixed $value): array { if (is_string($value)) { $value = json_decode($value, true); if (!is_array($value)) { UtilsService::getInstance()->errorThrow('模板 JSON 解析失败'); } } return is_array($value) ? $value : []; } }