Files
lgp-admin-plus-api/app/Service/business/WxTemplateSchemaService.php

806 lines
30 KiB
PHP
Raw Normal View History

<?php
namespace App\Service\business;
use App\Service\common\UtilsService;
/**
* 装修模板 JSON 的白名单校验
*
* 导入的 JSON 会被小程序当成 CSS 变量注入根节点,一份坏 JSON 能把线上小程序打成白屏,
* 所以入库前必须过白名单:键名不在表里的丢弃,值必须是安全的短字符串。
* 这里只允许「值」,不允许任何 CSS 语句片段分号、url()、表达式一律拒绝)。
*/
class WxTemplateSchemaService
{
private static mixed $_instance;
/**
* 允许的令牌键,分组 => 键名清单
*/
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' => [
2026-08-19 08:16:49 +08:00
'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' => [
2026-08-19 08:16:49 +08:00
'gallery' => ['swiper', 'stack', 'fullbleed', 'peek', 'fade', 'coverflow', 'mosaic', 'filmstrip'],
'price' => ['inline', 'card', 'sticky'],
2026-08-19 08:16:49 +08:00
'action' => ['fixed', 'inline', 'split'],
],
'list' => [
2026-08-19 08:16:49 +08:00
'style' => ['card', 'table', 'timeline', 'compact', 'ticket', 'stacked'],
],
'mine' => [
2026-08-19 08:16:49 +08:00
'header' => ['gradient', 'image', 'plain', 'split', 'editorial'],
'menu' => ['grid', 'list', 'card', 'tile', 'compact'],
],
2026-08-20 19:16:49 +08:00
'package' => [
'list' => ['card', 'featured', 'magazine', 'list'],
],
'effect' => [
'transition' => ['fade', 'slide', 'zoom', 'none'],
'skeleton' => ['shimmer', 'pulse', 'none'],
2026-08-19 08:16:49 +08:00
// 卡片装饰与图标风格:小程序 ThemeCardFrame / ThemeIcon 按枚举切换
'card' => ['plain', 'elevated', 'ornament', 'canvas-frame'],
'icon' => ['line', 'fill', 'duotone', 'block'],
],
];
2026-08-19 08:16:49 +08:00
/**
* 装修工作室可编排的页面与区块类型
*/
public const PAGE_SECTION_TYPES = [
'home' => ['search', 'hero', 'category', 'package', 'product'],
2026-08-20 19:16:49 +08:00
'package' => ['search', 'list'],
2026-08-19 08:16:49 +08:00
'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',
2026-08-20 19:16:49 +08:00
'masonry', 'featured', 'magazine', 'shelf', 'compact', 'airy', 'mosaic', 'duo',
2026-08-19 08:16:49 +08:00
'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' => [
2026-08-20 19:16:49 +08:00
'search' => ['bar', 'overlay', 'pill', 'float'],
2026-08-19 08:16:49 +08:00
'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' => '卡片',
2026-08-20 19:16:49 +08:00
'magazine' => '刊名条',
2026-08-19 08:16:49 +08:00
'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;
}
/**
2026-08-19 08:16:49 +08:00
* 清洗布局:旧枚举 + 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;
}
}
2026-08-19 08:16:49 +08:00
$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;
}
2026-08-19 08:16:49 +08:00
/**
* 清洗页面区块列表:只留白名单 type/variantprops 只留安全短值
*/
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<int, string>
*/
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;
}
2026-08-19 08:16:49 +08:00
/**
* 区块 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 : [];
}
}