Files
nl-admin-api/app/Service/WechatThemeService.php
李琦 6e2769c528
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
优化部分代码,增加微信公众号模板
2026-08-13 19:02:05 +08:00

188 lines
6.9 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
namespace App\Service;
use App\BaseApp\BaseService;
use App\Models\wechat\WechatThemeModel;
/**
* 自定义排版主题服务:用户上传的模板 JSON 落库,供图文编辑器「我的模板」使用
*
* 安全设计两端后端同口径GF 版为 internal/logic/wechattheme
* - styles 只允许白名单元素键container/h1/h2/p/blockquote 等 21 个)
* - 每个样式值清洗:去掉 <>"\ 字符(防止逃逸 style 属性注入标签)、
* 去掉 javascript: / expression( 危险片段,长度截断 2000
* - palette 仅接受合法 HEX 色值数组(最多 8 个)
*/
class WechatThemeService extends BaseService
{
/** 样式表允许的元素键(与前端 ThemeStyleSheet 类型一一对应) */
private const ALLOWED_STYLE_KEYS = [
'container', 'h1', 'h2', 'h3', 'p', 'blockquote', 'blockquoteP',
'ul', 'ol', 'li', 'strong', 'em', 'a', 'hr', 'img',
'code', 'pre', 'preCode', 'table', 'th', 'td',
];
/** 自定义主题数量上限(防止无限膨胀拖慢编辑器加载) */
private const MAX_THEME_COUNT = 100;
public function __construct()
{
parent::__construct();
$this->model = WechatThemeModel::class;
}
/**
* 全量列表(编辑器主题面板一次性加载,非分页)
* palette/styles 解码成数组返回,前端拿到即可注册渲染
*/
public function list(): array
{
$rows = WechatThemeModel::where('deleted_at', 0)
->orderByDesc('id')
->limit(self::MAX_THEME_COUNT)
->get(['id', 'name', 'palette', 'styles', 'created_by', 'created_at', 'updated_at'])
->toArray();
foreach ($rows as &$row) {
$row['palette'] = $this->decodeJson($row['palette'] ?? '');
$row['styles'] = $this->decodeJson($row['styles'] ?? '');
}
unset($row);
return $rows;
}
/**
* 导入模板:清洗校验后入库,返回新 id
*/
public function create($params): mixed
{
[$name, $palette, $styles] = $this->validatePayload($params);
$count = WechatThemeModel::where('deleted_at', 0)->count();
if ($count >= self::MAX_THEME_COUNT) {
$this->utils->errorThrow('自定义模板已达上限(' . self::MAX_THEME_COUNT . ' 个),请先删除不用的模板');
}
$id = WechatThemeModel::insertGetId([
'name' => $name,
'palette' => $this->encodeJson($palette),
'styles' => $this->encodeJson($styles),
'created_by' => $this->userId,
'created_at' => time(),
'updated_at' => 0,
'deleted_at' => 0,
]);
return ['id' => $id];
}
/**
* 更新模板(名称/色板/样式整体覆盖,与导入同一套清洗)
*/
public function update($id, $params): mixed
{
$id = (int) $id;
$info = WechatThemeModel::where('id', $id)->where('deleted_at', 0)->first();
if (empty($info)) {
return $this->utils->notFound('模板不存在');
}
[$name, $palette, $styles] = $this->validatePayload($params);
return WechatThemeModel::where('id', $id)->update([
'name' => $name,
'palette' => $this->encodeJson($palette),
'styles' => $this->encodeJson($styles),
'updated_at' => time(),
]);
}
/**
* 批量软删除
*/
public function delete($id): mixed
{
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
if (empty($ids)) {
$this->utils->errorThrow('参数错误');
}
return WechatThemeModel::whereIn('id', $ids)->where('deleted_at', 0)->update([
'deleted_at' => time(),
'updated_at' => time(),
]);
}
/**
* 入参校验 + 清洗:返回 [name, palette, styles]
* 为什么集中在一处create/update 共用,保证任何入口都过同一套白名单
*/
private function validatePayload(array $params): array
{
$name = mb_substr(trim((string) ($params['name'] ?? '')), 0, 50);
if ($name === '') {
$this->utils->errorThrow('请填写模板名称');
}
$styles = $params['styles'] ?? [];
if (is_string($styles)) {
$styles = $this->decodeJson($styles);
}
if (!is_array($styles) || empty($styles)) {
$this->utils->errorThrow('模板样式styles不能为空');
}
$cleanStyles = [];
foreach (self::ALLOWED_STYLE_KEYS as $key) {
if (!array_key_exists($key, $styles)) {
continue;
}
$value = $this->cleanStyleValue((string) $styles[$key]);
if ($value !== '') {
$cleanStyles[$key] = $value;
}
}
if (empty($cleanStyles)) {
$this->utils->errorThrow('styles 里没有可用的元素样式(支持键:' . implode('/', self::ALLOWED_STYLE_KEYS) . '');
}
$palette = $params['palette'] ?? [];
if (is_string($palette)) {
$palette = $this->decodeJson($palette);
}
$cleanPalette = [];
foreach (is_array($palette) ? $palette : [] as $colorItem) {
$colorItem = trim((string) $colorItem);
// 只收合法 HEX#rgb/#rrggbb/#rrggbbaa防止色板被塞入任意字符串
if (preg_match('/^#[0-9a-fA-F]{3,8}$/', $colorItem)) {
$cleanPalette[] = strtolower($colorItem);
}
if (count($cleanPalette) >= 8) {
break;
}
}
return [$name, $cleanPalette, $cleanStyles];
}
/**
* 单个样式值清洗:内联 CSS 会被前端注入 style="" 属性,
* 必须去掉能逃逸属性/注入脚本的字符与片段
*/
private function cleanStyleValue(string $value): string
{
// 去掉双引号/尖括号/反斜杠:双引号会截断 style 属性,尖括号可注入标签
$value = str_replace(['"', '<', '>', '\\'], '', $value);
// 去掉历史遗留的危险 CSS 片段(旧 IE expression、javascript 伪协议)
$value = preg_replace('/javascript\s*:/i', '', $value) ?? '';
$value = preg_replace('/expression\s*\(/i', '', $value) ?? '';
return mb_substr(trim($value), 0, 2000);
}
/** JSON 解码兜底:解不开返回空数组,不让脏数据打崩接口 */
private function decodeJson($raw): array
{
if (is_array($raw)) {
return $raw;
}
$decoded = json_decode((string) $raw, true);
return is_array($decoded) ? $decoded : [];
}
/** JSON 编码(保留中文与斜杠原样,方便库内直读排查) */
private function encodeJson(array $data): string
{
return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
}
}