键名清单 */ 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', 'stack', 'coverflow', 'fade', 'peek', 'cube', 'caption'], 'category' => ['grid', 'scroll', 'card', 'sidebar', 'pills', 'mosaic', 'featured', 'tile', 'contents'], 'package' => ['card', 'scroll', 'featured', 'magazine'], 'product' => ['waterfall', 'list', 'grid'], ], 'product' => [ 'gallery' => ['swiper', 'stack', 'fullbleed', 'peek', 'fade', 'coverflow', 'mosaic', 'filmstrip'], 'price' => ['inline', 'card', 'sticky'], 'action' => ['fixed', 'inline', 'split'], ], 'list' => [ 'style' => ['card', 'table', 'timeline', 'compact', 'ticket', 'stacked'], ], 'mine' => [ 'header' => ['gradient', 'image', 'plain', 'split', 'editorial'], 'menu' => ['grid', 'list', 'card', 'tile', 'compact'], ], 'package' => [ 'list' => ['card', 'featured', 'magazine', 'list'], ], 'effect' => [ 'transition' => ['fade', 'slide', 'zoom', 'none'], 'skeleton' => ['shimmer', 'pulse', 'none'], // 卡片装饰与图标风格:小程序 ThemeCardFrame / ThemeIcon 按枚举切换 'card' => ['plain', 'elevated', 'ornament', 'canvas-frame'], 'icon' => ['line', 'fill', 'duotone', 'block'], ], ]; /** * 装修工作室可编排的页面与区块类型 */ public const PAGE_SECTION_TYPES = [ 'home' => ['search', 'hero', 'category', 'package', 'product'], 'package' => ['search', 'list'], 'catalog' => ['filter', 'list'], 'search' => ['search', 'list'], 'product' => ['gallery', 'info', 'spec', 'price', 'action'], 'cart' => ['list'], 'mine' => ['header', 'menu'], ]; /** * 各区块允许的变体(与旧 LAYOUT_SCHEMA 对齐,方便小程序复用) */ public const SECTION_VARIANTS = [ 'search' => ['bar', 'overlay', 'pill', 'float'], 'hero' => ['banner', 'carousel', 'split', 'fullscreen', 'stack', 'coverflow', 'fade', 'peek', 'cube', 'caption'], 'category' => ['grid', 'scroll', 'card', 'sidebar', 'pills', 'mosaic', 'featured', 'tile', 'contents'], 'package' => ['card', 'scroll', 'featured', 'magazine'], 'product' => ['waterfall', 'list', 'grid'], 'filter' => ['chip', 'bar', 'sidebar', 'hidden'], 'list' => [ 'card', 'table', 'timeline', 'waterfall', 'grid', 'list', 'masonry', 'featured', 'magazine', 'shelf', 'compact', 'airy', 'mosaic', 'duo', 'ticket', 'stacked', ], 'gallery' => ['swiper', 'stack', 'fullbleed', 'peek', 'fade', 'coverflow', 'mosaic', 'filmstrip'], 'info' => ['plain', 'editorial', 'split', 'overlay'], 'spec' => ['plain', 'table', 'chips', 'cards'], 'price' => ['inline', 'card', 'sticky'], 'action' => ['fixed', 'inline', 'split'], 'header' => ['gradient', 'image', 'plain', 'split', 'editorial'], 'menu' => ['grid', 'list', 'card', 'tile', 'compact'], ]; /** * 同一 type 在不同页面含义不同:列表是封面+型号,清单是收藏夹卡片 */ public const PAGE_SECTION_VARIANTS = [ 'home' => [ 'product' => ['waterfall', 'grid', 'list'], 'package' => ['card', 'scroll', 'featured', 'magazine'], ], 'package' => [ 'search' => ['bar', 'overlay', 'pill', 'float'], 'list' => ['card', 'featured', 'magazine', 'list'], ], 'catalog' => [ 'list' => ['waterfall', 'grid', 'list', 'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo'], ], 'search' => [ 'list' => ['waterfall', 'grid', 'list', 'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo'], ], 'cart' => [ 'list' => ['card', 'table', 'timeline', 'compact', 'ticket', 'stacked'], ], ]; public const PAGE_SKINS = ['shop', 'magazine']; public const PAGE_DENSITIES = ['compact', 'regular', 'airy']; public const PAGE_FRAMES = ['none', 'inset', 'ornament']; public const CHROME_TABBAR = ['plain', 'line', 'pill', 'dot']; public const CHROME_TABBAR_ANIM = ['none', 'fade', 'slide', 'spring']; public const CHROME_NAVBAR = ['solid', 'line']; public const LAYER_FACES = ['sans', 'serif', 'kai', 'script']; public const LAYER_TONES = [ 'primary', 'primary_soft', 'primary_strong', 'accent', 'surface', 'surface_soft', 'bg_soft', 'text', 'text_soft', 'text_muted', 'border', ]; /** * 工作室样式画廊用的中文名 */ public const SECTION_VARIANT_LABELS = [ 'search' => [ 'bar' => '搜索条', 'overlay' => '压在封面', 'pill' => '胶囊', 'float' => '悬浮', ], 'hero' => [ 'banner' => '横幅', 'carousel' => '轮播', 'split' => '分栏', 'fullscreen' => '全屏', 'stack' => '堆叠', 'coverflow' => '封面流', 'fade' => '叠化', 'peek' => '露边', 'cube' => '立方', 'caption' => '刊名条', ], 'category' => [ 'grid' => '宫格', 'scroll' => '横滑', 'card' => '卡片', 'sidebar' => '侧栏', 'pills' => '胶囊', 'mosaic' => '马赛克', 'featured' => '首图', 'tile' => '瓷砖', 'contents' => '刊页目录', ], 'package' => [ 'card' => '卡片', 'scroll' => '横滑', 'featured' => '首图', 'magazine' => '刊名条', ], 'product' => [ 'waterfall' => '瀑布', 'grid' => '双列', 'list' => '单列', ], 'filter' => [ 'chip' => '分类条', 'bar' => '仅搜索', 'sidebar' => '侧栏', 'hidden' => '隐藏', ], 'list' => [ 'waterfall' => '瀑布', 'grid' => '双列', 'list' => '单列', 'masonry' => '砌石', 'featured' => '首图', 'shelf' => '货架', 'compact' => '紧凑', 'airy' => '疏朗', 'mosaic' => '马赛克', 'duo' => '对开', 'card' => '卡片', 'magazine' => '刊名条', 'table' => '表格', 'timeline' => '时间线', 'ticket' => '票根', 'stacked' => '层叠', ], 'gallery' => [ 'swiper' => '轮播', 'stack' => '叠图', 'fullbleed' => '全出血', 'peek' => '露边', 'fade' => '叠化', 'coverflow' => '封面流', 'mosaic' => '拼贴', 'filmstrip' => '胶卷', ], 'info' => [ 'plain' => '常规', 'editorial' => '编辑', 'split' => '分栏', 'overlay' => '压字', ], 'spec' => [ 'plain' => '规格表', 'table' => '表格', 'chips' => '芯片', 'cards' => '卡片', ], 'price' => [ 'inline' => '跟规格走', 'card' => '独立报价卡', 'sticky' => '吸顶报价', ], 'action' => [ 'fixed' => '钉在底部', 'inline' => '跟正文走', 'split' => '左右拆开', ], 'header' => [ 'gradient' => '渐变', 'image' => '头图', 'plain' => '素底', 'split' => '分栏', 'editorial' => '编辑', ], 'menu' => [ 'grid' => '宫格', 'list' => '列表', 'card' => '卡片', 'tile' => '瓷砖', 'compact' => '紧凑', ], ]; /** * 卡片图层允许的 type / bind / font */ public const LAYER_TYPES = ['photo', 'text', 'deco', 'shape']; public const LAYER_BINDS = ['cover', 'gallery', 'upload', 'name', 'price', 'custom']; public const LAYER_FONTS = ['title', 'body', 'caption', 'price']; public const DECO_ASSETS = [ 'paper-edge', 'pearl-line', 'walnut-frame', 'gold-foil', 'washi-1', 'botanical', 'museum-mat', 'folio-line', 'wax-seal', 'ribbon', 'newsprint', 'gold-corner', 'handwriting', 'arch-mat', 'double-frame', 'inset-shadow', 'torn-edge', 'film-sprocket', 'corner-bracket', 'emboss-plate', 'circle-crop', 'vignette', 'folio-number', 'stamp-ring', 'lace-corner', 'thin-rule', 'shadow-deck', 'cap-rail', 'gutter-fold', 'pearl-bead', 'vert-caption', ]; /** * 危险片段:出现即拒绝整个值 */ 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; } /** * 清洗布局:旧枚举 + pages.sections + card_scheme * * 非法枚举在宽松模式退回第一项;strict(导入)直接报错。 * pages / card_scheme 是装修工作室写入的,必须保留,否则保存后拖拽结果会丢。 */ 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; } } $scheme = trim((string) ($layout['card_scheme'] ?? '')); if ($scheme !== '' && $this->isSafeCode($scheme)) { $clean['card_scheme'] = $scheme; } if (isset($layout['pages']) && is_array($layout['pages'])) { $clean['pages'] = $this->sanitizePages($layout['pages'], $strict); } if (isset($layout['page_presets']) && is_array($layout['page_presets'])) { $clean['page_presets'] = $this->sanitizePagePresets($layout['page_presets']); } if (isset($layout['chrome']) && is_array($layout['chrome'])) { $chrome = []; $tabbar = (string) ($layout['chrome']['tabbar'] ?? ''); $navbar = (string) ($layout['chrome']['navbar'] ?? ''); $anim = (string) ($layout['chrome']['tabbar_anim'] ?? ''); if (in_array($tabbar, self::CHROME_TABBAR, true)) { $chrome['tabbar'] = $tabbar; } elseif ($strict && $tabbar !== '') { UtilsService::getInstance()->errorThrow('chrome.tabbar 只能是:' . implode('/', self::CHROME_TABBAR)); } if (in_array($navbar, self::CHROME_NAVBAR, true)) { $chrome['navbar'] = $navbar; } elseif ($strict && $navbar !== '') { UtilsService::getInstance()->errorThrow('chrome.navbar 只能是:' . implode('/', self::CHROME_NAVBAR)); } if (in_array($anim, self::CHROME_TABBAR_ANIM, true)) { $chrome['tabbar_anim'] = $anim; } elseif ($strict && $anim !== '') { UtilsService::getInstance()->errorThrow('chrome.tabbar_anim 只能是:' . implode('/', self::CHROME_TABBAR_ANIM)); } foreach (['tabbar_color', 'tabbar_color_on'] as $colorKey) { $raw = trim((string) ($layout['chrome'][$colorKey] ?? '')); if ($raw === '') { continue; } if ($this->isSafeHexColor($raw)) { $chrome[$colorKey] = $raw; } elseif ($strict) { UtilsService::getInstance()->errorThrow('chrome.' . $colorKey . ' 必须是 #RGB 或 #RRGGBB'); } } if ($chrome !== []) { $clean['chrome'] = $chrome; } } return $clean; } /** * 清洗页面区块列表:只留白名单 type/variant,props 只留安全短值 */ public function sanitizePages(array $pages, bool $strict = false): array { $clean = []; foreach (self::PAGE_SECTION_TYPES as $page => $types) { $pageBlock = $pages[$page] ?? []; if (!is_array($pageBlock)) { continue; } $source = $pageBlock['sections'] ?? $pageBlock; if (!is_array($source)) { continue; } // 兼容 { sections: [] } 与直接数组 $list = isset($source['sections']) && is_array($source['sections']) ? $source['sections'] : $source; $sections = []; foreach ($list as $index => $item) { if (!is_array($item)) { continue; } $type = (string) ($item['type'] ?? ''); if (!in_array($type, $types, true)) { if ($strict) { UtilsService::getInstance()->errorThrow("页面 {$page} 不支持区块 {$type}"); } continue; } $allowed = $this->variantsFor($page, $type); $variant = (string) ($item['variant'] ?? $allowed[0]); // 旧杂志卡带报价,商品流收成瀑布;套餐 magazine 是刊名条,不要改 if ($variant === 'magazine' && in_array($page, ['home', 'catalog', 'search'], true) && $type !== 'package') { $variant = 'waterfall'; } if (!in_array($variant, $allowed, true)) { if ($strict) { UtilsService::getInstance()->errorThrow("区块 {$type} 变体只能是:" . implode('/', $allowed)); } $variant = $allowed[0]; } $id = $this->isSafeCode((string) ($item['id'] ?? '')) ? (string) $item['id'] : $type . '-' . $index; $sections[] = [ 'id' => $id, 'type' => $type, 'variant' => $variant, 'visible' => !empty($item['visible']) || !array_key_exists('visible', $item), 'props' => $this->sanitizeSectionProps($item['props'] ?? []), ]; } $row = ['sections' => $sections]; $skin = (string) ($pageBlock['skin'] ?? ''); $density = (string) ($pageBlock['density'] ?? ''); $frame = (string) ($pageBlock['frame'] ?? ''); if (in_array($skin, self::PAGE_SKINS, true)) { $row['skin'] = $skin; } if (in_array($density, self::PAGE_DENSITIES, true)) { $row['density'] = $density; } if (in_array($frame, self::PAGE_FRAMES, true)) { $row['frame'] = $frame; } $pageScheme = trim((string) ($pageBlock['card_scheme'] ?? '')); if ($pageScheme !== '' && $this->isSafeCode($pageScheme)) { $row['card_scheme'] = $pageScheme; } $caption = $pageBlock['card_caption'] ?? ''; if (is_string($caption) && $caption !== '' && mb_strlen($caption) <= 80 && $this->isSafeValue($caption)) { $row['card_caption'] = $caption; } $clean[$page] = $row; } return $clean; } /** * 清洗卡片图层:坐标数值化,非法 type 丢弃 */ public function sanitizeLayers(mixed $layers, bool $strict = false): array { $layers = $this->toArray($layers); $clean = []; foreach ($layers as $index => $item) { if (!is_array($item)) { continue; } $type = (string) ($item['type'] ?? ''); if (!in_array($type, self::LAYER_TYPES, true)) { if ($strict) { UtilsService::getInstance()->errorThrow('图层 type 只能是 photo/text/deco/shape'); } continue; } $id = $this->isSafeCode((string) ($item['id'] ?? '')) ? (string) $item['id'] : 'layer-' . $index; $row = [ 'id' => $id, 'type' => $type, 'x' => $this->num($item['x'] ?? 0), 'y' => $this->num($item['y'] ?? 0), 'w' => $this->num($item['w'] ?? 40), 'h' => $this->num($item['h'] ?? 40), 'rotate' => $this->num($item['rotate'] ?? 0), 'z' => (int) ($item['z'] ?? 1), 'visible' => !array_key_exists('visible', $item) || !empty($item['visible']), 'locked' => !empty($item['locked']), ]; if (!empty($item['bind']) && in_array((string) $item['bind'], self::LAYER_BINDS, true)) { $row['bind'] = (string) $item['bind']; } if (!empty($item['font']) && in_array((string) $item['font'], self::LAYER_FONTS, true)) { $row['font'] = (string) $item['font']; } foreach (['font_zh', 'font_en'] as $faceKey) { $face = (string) ($item[$faceKey] ?? ''); if ($face !== '' && in_array($face, self::LAYER_FACES, true)) { $row[$faceKey] = $face; } } if (!empty($item['asset']) && $this->isSafeCode((string) $item['asset'])) { $row['asset'] = (string) $item['asset']; } $tone = (string) ($item['tone'] ?? ''); if ($tone !== '' && in_array($tone, self::LAYER_TONES, true)) { $row['tone'] = $tone; } if (isset($item['fill']) && $this->isSafeValue($item['fill'])) { $row['fill'] = (string) $item['fill']; } if (isset($item['text']) && is_string($item['text']) && mb_strlen($item['text']) <= 80) { $row['text'] = $item['text']; } if (!empty($item['src']) && $this->isSafeUrl((string) $item['src'])) { $row['src'] = (string) $item['src']; } $clean[] = $row; } return $clean; } /** * 某页某区块允许的变体:先看页面覆盖,再回落到全局 * * @return array */ public function variantsFor(string $page, string $type): array { $override = self::PAGE_SECTION_VARIANTS[$page][$type] ?? null; if (is_array($override) && $override !== []) { return $override; } return self::SECTION_VARIANTS[$type] ?? ['plain']; } /** * 工作室拉一次就能画样式画廊,不必再写死中文名 */ public function describe(): array { $pages = []; foreach (self::PAGE_SECTION_TYPES as $page => $types) { $modules = []; foreach ($types as $type) { $variants = $this->variantsFor($page, $type); $labels = self::SECTION_VARIANT_LABELS[$type] ?? []; $modules[] = [ 'type' => $type, 'variants' => array_map(static function (string $value) use ($labels) { return [ 'value' => $value, 'label' => $labels[$value] ?? $value, ]; }, $variants), ]; } $pages[] = [ 'key' => $page, 'modules' => $modules, ]; } return [ 'tokens' => self::TOKEN_SCHEMA, 'layout' => self::LAYOUT_SCHEMA, 'pages' => self::PAGE_SECTION_TYPES, 'variants' => self::SECTION_VARIANTS, 'page_variants' => self::PAGE_SECTION_VARIANTS, 'labels' => self::SECTION_VARIANT_LABELS, 'studio' => $pages, 'page_presets' => $this->describePagePresets(), 'card_families' => $this->describeCardFamilies(), 'chrome' => [ 'tabbar' => self::CHROME_TABBAR, 'tabbar_anim' => self::CHROME_TABBAR_ANIM, 'navbar' => self::CHROME_NAVBAR, ], 'layer_faces' => self::LAYER_FACES, ]; } /** * 工作室整页预设画廊:只下发 code/name/家族,套用时再取完整 sections */ public function describePagePresets(): array { $out = []; foreach ((array) config('wx_page_presets', []) as $page => $items) { foreach ((array) $items as $item) { if (!is_array($item) || empty($item['code'])) { continue; } $out[$page][] = [ 'code' => (string) $item['code'], 'name' => (string) ($item['name'] ?? $item['code']), 'family' => (string) ($item['family'] ?? ''), 'skin' => (string) ($item['skin'] ?? 'shop'), 'card_scheme' => (string) ($item['card_scheme'] ?? ''), 'density' => (string) ($item['density'] ?? 'regular'), 'frame' => (string) ($item['frame'] ?? 'none'), 'hero' => $this->presetHeroHint($item), 'gallery' => $this->presetTypeHint($item, 'gallery'), 'list' => $this->presetListHint($item), 'sections' => is_array($item['sections'] ?? null) ? $item['sections'] : [], ]; } } return $out; } /** * 按 code 取完整预设(工作室一键套用) */ public function pagePresetByCode(string $code): ?array { foreach ((array) config('wx_page_presets', []) as $items) { foreach ((array) $items as $item) { if (is_array($item) && (string) ($item['code'] ?? '') === $code) { return $item; } } } return null; } /** * 卡片家族:工作室缩略旁标轻奢 / 欧美 / 手作 */ public function describeCardFamilies(): array { $map = []; foreach ((array) config('wx_card_schemes', []) as $item) { $code = (string) ($item['code'] ?? ''); if ($code === '') { continue; } $family = (string) ($item['family'] ?? 'lux'); $map[$code] = match ($family) { 'editorial' => '欧美', 'atelier' => '手作', default => '轻奢', }; } return $map; } /** * 只留各页合法的预设 code */ private function sanitizePagePresets(array $presets): array { $clean = []; foreach (array_keys(self::PAGE_SECTION_TYPES) as $page) { $code = trim((string) ($presets[$page] ?? '')); if ($code !== '' && $this->isSafeCode($code)) { $clean[$page] = $code; } } return $clean; } private function presetHeroHint(array $item): string { foreach ((array) ($item['sections'] ?? []) as $section) { if (($section['type'] ?? '') === 'hero') { return (string) ($section['variant'] ?? ''); } } return ''; } private function presetListHint(array $item): string { return $this->presetTypeHint($item, 'list'); } private function presetTypeHint(array $item, string $type): string { foreach ((array) ($item['sections'] ?? []) as $section) { if (($section['type'] ?? '') === $type) { return (string) ($section['variant'] ?? ''); } } return ''; } /** * 小程序侧要的扁平 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; } /** * 区块 props:只留高度/列数/间距/卡片方案,避免塞任意 CSS */ private function sanitizeSectionProps(mixed $props): array { if (!is_array($props)) { return []; } $clean = []; foreach (['height', 'cols', 'spacing'] as $key) { if (isset($props[$key]) && is_numeric($props[$key])) { $clean[$key] = (int) $props[$key]; } } if (!empty($props['card_scheme']) && $this->isSafeCode((string) $props['card_scheme'])) { $clean['card_scheme'] = (string) $props['card_scheme']; } return $clean; } /** 底栏独立色只收 hex,拒绝 CSS 语句 */ private function isSafeHexColor(string $value): bool { return preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $value) === 1; } private function isSafeCode(string $value): bool { return $value !== '' && preg_match('/^[a-zA-Z0-9_-]{1,50}$/', $value) === 1; } private function isSafeUrl(string $value): bool { if ($value === '' || mb_strlen($value) > 500) { return false; } foreach (self::FORBIDDEN as $needle) { if ($needle === 'url(') { continue; } if (stripos($value, $needle) !== false) { return false; } } return (bool) preg_match('/^https?:\\/\\//i', $value); } private function num(mixed $value): float { return is_numeric($value) ? (float) $value : 0; } 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 : []; } }