Files
lgp-admin-plus-api/app/Service/business/WxTemplateSchemaService.php
LQ bcf54c2727 初始化
缺陷:主题配色需要优化整体的同风格
2026-08-14 23:21:21 +08:00

187 lines
6.1 KiB
PHP
Raw Permalink 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
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' => [
'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 : [];
}
}