Zsens Admin 插件配置页:当 `serialize` 遇上对象嵌套,我是如何用"扁平化预校验"终结配置保存白屏的

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 56 浏览 0 回复

上周给插件加"多站点邮件模板"配置时,后台点保存直接 500,Nginx error log 里只有一句 unserialize(): Error at offset 0 of 1234 bytes。跟了两天才发现,问题根本不在反序列化,而在保存前的数据结构就已经埋雷了。

先说场景。我的配置页有个动态表单,运营可以添加 N 个邮件模板,每个模板包含标题、内容、变量列表。前端用 Vue 渲染,提交时把整个 templates 数组 JSON.stringify 后塞进一个 hidden input。PHP 端收到后 json_decodeserialize 存进配置表——这是 Zsens Admin 的标准套路,对吧?

坑在于:用户把某个模板的 variables 字段留空了,前端发过来的是 {"name":"", "default":null}。我偷懒没做深度过滤,直接丢进 serialize。更隐蔽的是,另一个字段存了从富文本编辑器出来的 HTML,里面嵌了个 <img src="data:image/png;base64,...">,base64 字符串 80KB+。

配置表字段类型是 TEXT,65535 字节上限。serialize 后的总长度刚好踩线,截断存储。下次读取时反序列化直接炸,整个配置页白屏,连重置按钮都出不来——因为读取配置的代码在页面渲染之前。

现在我的保存链路改成这样了,贴关键段:

// 1. 扁平化预校验:先把嵌套结构拍平,逐层过筛
public function flattenAndValidate(array $input): array
{
    $flat = [];
    foreach ($input['templates'] ?? [] as $idx => $tpl) {
        // 强制类型白名单,拒绝一切对象/资源
        $flat["tpl_{$idx}_name"] = substr(trim((string)($tpl['name'] ?? '')), 0, 64);
        $flat["tpl_{$idx}_content"] = $this->purifyHtml((string)($tpl['content'] ?? ''));
        
        // 变量列表单独抽出来二次序列化前检查
        $vars = array_filter((array)($tpl['variables'] ?? []), fn($v) => !empty($v['name']));
        $flat["tpl_{$idx}_vars"] = json_encode($vars, JSON_THROW_ON_ERROR);
    }
    return $flat;
}

// 2. 长度预算:serialize 前预估总大小,超阈值提前拦截
public function estimateSerializedSize(array $flat): int
{
    // 快速估算:字符串长度 + 类型标记开销
    $size = 0;
    foreach ($flat as $k => $v) {
        $size += strlen($k) + strlen((string)$v) + 10; // 10 为序列化格式开销
    }
    return $size;
}

第三步是存储分片。如果单条配置预估超过 32KB,我把大字段拆出来走单独的 plugin_config_large 表,主表只存索引键。读取时用 array_replace_recursive 合并回来,对业务代码透明。

缓存刷新这块也有个细节。Zsens Admin 的 saveConfig 会清 APCu,但我之前是清完立刻 getConfig 做回显,结果触发了缓存穿透——另一个请求恰好在清缓存和写数据库之间进来,读到了旧数据又回填进 APCu,导致"保存成功但刷新后还是旧值"的灵异现象。

现在的做法:保存时先写数据库,再写一个叫 config_version:{plugin_name} 的标记位(就是一个时间戳),最后清 APCu。读取配置时先比对这个标记位,如果本地缓存的版本号低于标记位,强制走数据库。相当于用版本号替代了全量缓存失效,避免竞态窗口。

// 读取侧伪代码
public function getConfig(string $key)
{
    $version = apcu_fetch("config_version:my_plugin");
    $cached = apcu_fetch("config:my_plugin:{$key}");
    
    if ($cached && $cached['v'] >= $version) {
        return $cached['data'];
    }
    
    // 版本落后或不存在,走库
    $data = $this->db->where('key', $key)->getOne('plugin_config');
    apcu_store("config:my_plugin:{$key}", ['v' => $version, 'data' => $data], 3600);
    return $data;
}

这套搞完之后,配置页再也没出过 500。但还有个遗留问题:如果用户 base64 图片实在太大,分表也扛不住,是不是该直接拒绝或者转存 OSS?你们插件里遇到这种"用户把配置当文件存储"的情况,一般怎么限流?

评论0
回复 · 0
还没有回复
微信客服 微信客服