在 PbootCMS 3.2.5 上实现企业级二级内容审核系统——从设计到落地的完整复盘
关键词:PbootCMS、PHP、SQLite、内容审核、工作流状态机、二次开发
适用场景:政府/企事业单位网站信息发布管控、多人协作审核
前言:为什么要在 CMS 上加审核?
很多中小型官网用的是轻量级 CMS(如 PbootCMS、帝国CMS、织梦等),这些系统原生只支持"发布/不发布"两种状态。但在实际业务中,尤其是政府和企业的对外网站,一篇内容从写好到上线,往往需要经过多级复核。
- 普通编辑写了稿子 → 部门主管审一遍 → 宣传部再审一遍 → 才能发布
- 被打回的稿件要能看到拒绝原因,修改后重新提交
- 所有操作要有日志留痕,方便事后追溯
这就是一个典型的多级审批工作流需求。本文以 PbootCMS 3.2.5 + SQLite 为例,完整记录如何在现有 CMS 上叠加一套二级人工审核系统,以及过程中踩过的坑。
一、需求分析
1.1 核心需求拆解
| 需求 | 描述 |
|---|---|
| 多级审核 | 文章需经过一级和两级审核才能发布到前端 |
| 状态机管理 | 每个操作只能从特定合法状态发起,防止越权操作 |
| 角色权限区分 | 不同角色(管理员、一审员、二审员)有不同的操作能力 |
| 全程留痕 | 所有审核操作记录到独立日志表,可追溯 |
| 被拒可重提交 | 拒绝的文章可以查看拒绝原因,修改后重新进入审核流程 |
| 特殊角色免审 | 超级管理员和高级审核员可以直接发布,不走审核 |
1.2 业务流程图
普通用户新建文章 → 状态=草稿(0)
→ 点击"提交审核"
→ 状态=待一级审核(1)
→ 一级审核员操作
├─ 通过 → 状态=待二级审核(2)
│ → 二级审核员操作
│ ├─ 通过 → 状态=已发布(4) ✅ 前端可见
│ └─ 拒绝 → 状态=二级拒绝(5) → 可重新提交
└─ 拒绝 → 状态=一级拒绝(3) → 可重新提交
超级管理员 / 二级审核员:新建即直接发布,跳过上述全部流程
二、技术方案设计
2.1 数据库设计
(1)在原有 ay_content 表追加审核字段
PbootCMS 使用 SQLite,不支持 ALTER COLUMN 改类型,但支持 ADD COLUMN 加新列:
-- 追加 7 个审核字段
ALTER TABLE ay_content ADD COLUMN audit_status INTEGER DEFAULT 0; -- 审核状态码
ALTER TABLE ay_content ADD COLUMN audit1_user VARCHAR(30); -- 一级审核人
ALTER TABLE ay_content ADD COLUMN audit1_time VARCHAR(20); -- 一级审核时间
ALTER TABLE ay_content ADD COLUMN audit1_remark VARCHAR(500); -- 一级审核备注
ALTER TABLE ay_content ADD COLUMN audit2_user VARCHAR(30); -- 二级审核人
ALTER TABLE ay_content ADD COLUMN audit2_time VARCHAR(20); -- 二级审核时间
ALTER TABLE ay_content ADD COLUMN audit2_remark VARCHAR(500); -- 二级审核备注
(2)新建审核日志表 ay_audit_log
CREATE TABLE IF NOT EXISTS ay_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_id INTEGER NOT NULL, -- 关联文章ID
audit_level INTEGER NOT NULL, -- 操作类型码(见下表)
old_status INTEGER NOT NULL, -- 操作前状态
new_status INTEGER NOT NULL, -- 操作后状态
operator VARCHAR(30), -- 操作人用户名
remark VARCHAR(500), -- 操作备注
operate_time VARCHAR(20) -- 操作时间
);
audit_level 操作类型码定义:
| 值 | 含义 |
|---|---|
| 1 | 一级审核-通过 |
| 2 | 二级审核-通过(发布) |
| 4 | 一级审核-拒绝 |
| 5 | 二级审核-拒绝 |
| 9 | 管理员强制发布 |
设计思路:用一个整型字段而非字符串来标识操作类型,查询效率更高,也便于后续扩展。
2.2 状态码定义
┌──────┬──────────┬────────┬──────────┬──────────────────────┐
│ code │ 含义 │ status │ 前端可见? │ 可执行的操作 │
├──────┼──────────┼────────┼──────────┼──────────────────────┤
│ 0 │ 草稿 │ 0 │ ❌ │ 提交审核 / 编辑 / 删除 │
│ 1 │ 待一级审核 │ 0 │ ❌ │ 一级通过 / 一级拒绝 │
│ 2 │ 待二级审核 │ 0 │ ❌ │ 二级通过 / 二级拒绝 │
│ 3 │ 一级拒绝 │ 0 │ ❌ │ 重新提交审核 │
│ 4 │ 已发布 │ 1 │ ✅ │ 编辑(特权用户直接发布) │
│ 5 │ 二级拒绝 │ 0 │ ❌ │ 重新提交审核 │
└──────┴──────────┴────────┴──────────┴──────────────────────┘
注意这里用了两个字段配合:
audit_status标记审核进度(0~5)status标记是否在前台展示(0 或 1)
只有当 audit_status=4 AND status=1 时,文章才对前台可见。这样设计的好处是解耦了审核状态和展示状态,即使后续需要"定时发布"、"下架"等功能也不会冲突。
三、核心代码实现
3.1 文件架构总览
项目根目录/
├── apps/admin/controller/content/ContentController.php # 内容主控制器(修改)
├── apps/admin/controller/AuditController.php # 审核列表+日志控制器(新建)
├── apps/admin/model/content/AuditLogModel.php # 审核日志模型(新建)
├── apps/admin/view/default/content/audit.html # 审核列表页模板(新建)
├── apps/admin/view/default/content/audit_log.html # 日志查看页模板(新建)
├── audit_action.php # 审核操作接口(新建,独立脚本)
├── audit_submit.php # 提交审核按钮(新建,独立脚本)
└── audit_remarks.php # 拒绝备注弹窗(新建,独立脚本)
共 8 个文件,其中 3 个是修改原有文件,5 个是新增文件。下面挑重点讲。
3.2 新增/编辑时的审核联动
在 ContentController 的 add() 方法中,根据当前登录用户的身份决定文章初始状态:
// apps/admin/controller/content/ContentController.php — add() 方法
$audit_status = post('audit_status', 'int');
// 超级管理员(id==1)或二级审核员(cc) → 直接发布
if (session('id') == 1 || session('username') == 'cc') {
$data['audit_status'] = 4; // 已发布
$data['status'] = 1; // 前端可见
$data['audit2_user'] = session('username');
$data['audit2_time'] = date('Y-m-d H:i:s');
$data['audit2_remark'] = '直接发布';
} else {
// 其他用户 → 只能存草稿或提交审核
$data['audit_status'] = ($audit_status == 1) ? 1 : 0;
$data['status'] = 0; // 不发布到前端
}
关键点:
- 判断依据是
session('id') == 1(最高管理员)或用户名匹配cc(二审员) - 特权用户的新文章直接设为已发布状态,无需任何额外操作
mod()(编辑方法)里做了同样的处理,保持逻辑一致
3.3 审核操作接口 —— 为什么用独立 PHP 脚本?
这是整个方案中最关键的架构决策。
问题背景:PbootCMS 的加密内核 Kernel.php 对 /admin.php 下的所有 POST 请求做了全局 formcheck 校验。任何自定义控制器的 POST 方法都会被拦截,报错:"表单提交校验失败"。
尝试过的方案:
| 方案 | 结果 |
|---|---|
| 继承 AdminController | ❌ 它内置 formcheck 注入,POST 同样被拦 |
| 反编译 Kernel.php 绕过 | ❌ 加密代码无法修改;升级即失效 |
| 在控制器中手动调用验证 | ❌ 校验逻辑在请求入口层,控制器层面无法绕过 |
最终方案:独立 PHP 脚本 + GET 请求
// audit_action.php — 核心审核操作脚本
// ① Session 配置必须与 PbootCMS 完全一致
session_name('PbootSystem');
ini_set('session.save_path', '1;' . dirname(__FILE__) . '/runtime/session/');
session_start();
// ② 登录检查
if (empty($_SESSION['id'])) {
exit('<script>alert("请先登录"); location.href="/admin.php";</script>');
}
// ③ 接收 GET 参数
$id = intval($_GET['id']);
$level = intval($_GET['level']); // 1=一级, 2=二级, 99=管理员
$action = $_GET['action']; // pass/reject/publish
$remark = trim(urldecode($_GET['remark']));
核心状态转换逻辑:
if ($level === 1) {
// 一级审核:只能从状态 0 或 1 出发
if ($action === 'pass') {
$newStatus = 2; // → 待二级审核
$db->exec("UPDATE ay_content SET audit_status=2,
audit1_user='$uname', audit1_time=datetime('now','localtime')
WHERE id=$id");
} elseif ($action === 'reject') {
$newStatus = 3; // → 一级拒绝
$db->exec("UPDATE ay_content SET audit_status=3, status=0 WHERE id=$id");
}
} elseif ($level === 2) {
// 二级审核:只能从状态 2 出发
if ($action === 'pass') {
$newStatus = 4; // → 已发布!
$db->exec("UPDATE ay_content SET audit_status=4, status=1,
audit2_user='$uname', audit2_time=datetime('now','localtime'),
date=datetime('now','localtime') WHERE id=$id");
}
}
// ④ 写入审核日志(全程留痕)
$db->exec("INSERT INTO ay_audit_log(content_id, audit_level, old_status,
new_status, operator, remark, operate_time) VALUES(...)");
// ⑤ 成功后跳转回审核列表
header('Location: /admin.php?p=/Audit/level' . $level);
为什么用 GET 而非 POST?
后台管理系统本身有登录保护 + Session 校验,CSRF 风险极低。GET 请求的参数虽然出现在 URL 中,但所有敏感操作都有二次确认弹窗(如拒绝时必须填写原因),安全性足够。而 GET 最大的好处是完全绕过框架的 POST 拦截机制。
3.4 审核列表页面 —— 动态按钮显隐
审核列表页 audit.html 的设计思路是:所有按钮先隐藏,由 JS 根据当前行状态动态显示。
<table class="layui-table">
<tbody>
{foreach $contents(key,value)}
<tr data-id="[value->id]" data-status="[value->audit_status]">
<td>[value->id]</td>
<td>[value->title]</td>
<!-- 状态列由JS渲染 -->
<td class="status-cell" data-status="[value->audit_status]"></td>
<!-- 所有按钮默认隐藏 display:none -->
<td>
<!-- 一级审核按钮 -->
<button class="btn-lv1 btn-pass" style="display:none;"
onclick="doAudit([value->id],1,'pass')">通过</button>
<button class="btn-lv1 btn-reject" style="display:none;"
onclick="doAudit([value->id],1,'reject')">拒绝</button>
<!-- 二级审核按钮 -->
<button class="btn-lv2 btn-pass" style="display:none;"
onclick="doAudit([value->id],2,'pass')">通过发布</button>
<!-- 管理员专属 -->
<button class="btn-admin" style="display:none;"
onclick="doPublish([value->id])">强制发布</button>
</td>
</tr>
{/foreach}
</tbody>
</table>
JS 控制逻辑:
var auditLevel = parseInt(body.getAttribute('data-level')); // 1 或 2
var isAdmin = body.getAttribute('data-admin') === '1';
document.querySelectorAll('.status-cell').forEach(function(cell) {
var st = cell.getAttribute('data-status');
var tr = cell.closest('tr');
// 一级审核页 + 状态为"待一审" → 显示一级按钮
if (auditLevel === 1 && st === '1') {
tr.querySelector('.btn-lv1.btn-pass').style.display = '';
tr.querySelector('.btn-lv1.btn-reject').style.display = '';
}
// 二级审核页 + 状态为"待二审" → 显示二级按钮
if (auditLevel === 2 && st === '2') {
tr.querySelector('.btn-lv2.btn-pass').style.display = '';
tr.querySelector('.btn-lv2.btn-reject').style.display = '';
}
// 管理员 → 始终显示强制发布按钮
if (isAdmin) {
tr.querySelector('.btn-admin').style.display = '';
}
});
这种"服务端渲染数据 + JS 控制行为"的模式,既利用了模板引擎的数据绑定能力,又避开了模板语法不支持 {elseif} 的限制。
四、踩坑记录(血泪教训)
坑 1:PbootCMS 模板引擎不支持 {elseif}
这是最容易踩的坑。PbootCMS 自研的模板引擎没有 elseif 语法:
<!-- ❌ 错误写法——elseif 会当纯文本输出 -->
{if($status==1)}
已发布
{elseif($status==2)}
待审核
{/if}
正确做法——用独立的 if + 互斥前置守卫:
<!-- ✅ 正确——每个条件都排除前面已经匹配的情况 -->
{if($status==1)}
已发布
{/if}
{if($status!=1 && $status==2)}
待审核
{/if}
{if($status!=1 && $status!=2)}
其他状态
{/if}
坑 2:{php} 块内禁止数组访问语法
<!-- ❌ 错误——[$var] 会被错误编译 -->
{php}
echo [$arr]['key']; // 编译结果: $arr$this->getVar() → Fatal Error
{/php}
<!-- ✅ 正确——先用 getVar 取出 -->
{php}
$tmp = $this->getVar('arr');
echo $tmp['key'];
{/php}
坑 3:模型字段列表硬编码
PbootCMS 的 ContentModel 中,getList()、findContent() 等方法的 SELECT 字段是硬编码数组:
// ContentModel.php 中的原始代码(简化)
$field = array(
'id', 'scode', 'title', 'content', 'status',
// ... 原有字段,没有 audit_status!
);
后果:数据库加了 audit_status 字段,但模型查不出来 → 模板拿到的值永远是 null。
解决:每新增自定义字段,必须手动在这几个方法里追加字段名。这是 PbootCMS 最经典的坑之一。
坑 4:构造函数不能调 session/get_uniqid
// ❌ 错误——框架未初始化,Fatal Error 导致白屏变404
public function __construct() {
$user = session('username'); // 这里会崩
}
// ✅ 正确——构造函数只做简单实例化
public function __construct() {
$this->model = new MyModel();
}
public function index() {
$user = session('username'); // 方法体内安全使用
}
坑 5:View 单例重复注入检测
// ❌ 第二次 assign 同名变量会报错
$this->assign('list', $data1);
$this->assign('list', $data2); // 报错:"模板变量$list出现重复注入!"
// ✅ 先检查再赋值
if (!$this->getVar('list')) {
$this->assign('list', $data);
}
坑 6:Session 配置不一致导致独立脚本无法识别登录态
独立 PHP 脚本需要读取 PbootCMS 的 Session 来判断登录状态,三个参数必须完全一致:
// 必须与框架 core/basic/Basic.php 中的配置一模一样:
session_name('PbootSystem'); // 名称
ini_set('session.save_path', '1;' . $savePath); // 目录深度=1!
session_set_cookie_params(0, '/', null, null, false); // Cookie参数
特别是 save_path 前面的 1;(目录深度),漏掉的话 Session 文件找不到,永远判定为未登录。
五、效果展示
5.1 一级审核列表
- 展示所有处于「待一级审核」和「一级拒绝」状态的文章
- 每行显示:标题、日期、审核状态、操作按钮
- 状态为"待一审"时出现「通过」和「拒绝」按钮
- 拒绝时弹出输入框要求必填拒绝原因
5.2 二级审核列表
- 展示所有处于「待二级审核」状态的文章
- 额外显示「一级审核人」列(谁在一审通过的)
- 通过后文章立即发布到前端
5.3 审核日志
- 分页展示所有审核操作记录
- 每条记录包含:文章ID、操作类型、状态变更、操作人、时间、备注
- 管理员可按天数清理或清空日志
5.4 拒绝备注弹窗
- iframe 弹窗形式展示最新拒绝操作的详细信息
- 包含拒绝原因、操作人、操作时间
- 作者据此修改后可重新提交
六、总结与反思
6.1 方案优势
- 零侵入性:不修改 PbootCMS 核心文件,全部通过扩展实现,升级不影响
- 状态机驱动:每个操作都有前置合法性校验,不会出现非法状态流转
- 全程留痕:所有操作记录到独立日志表,审计友好
- 灵活的角色体系:通过简单的条件判断即可扩展更多角色类型
6.2 可优化方向
- 将硬编码的用户名判断(
'cc')改为数据库角色字段,支持动态配置 - 增加邮件/消息通知功能,审核状态变更时通知相关人员
- 支持移动端审核,通过 API 接口暴露审核能力
- 增加审核时效统计,分析平均审核耗时
- 考虑引入工作流引擎(如简单的状态机库),使流程可配置化
6.3 写在最后
在现有 CMS 上做二次开发,最难的从来不是"怎么写代码",而是理解框架的运行机制和隐含限制。PbootCMS 虽然是一个轻量级框架,但它有自己的路由规则、模板语法、POST 校验机制——这些"看不见的墙"才是真正的挑战。
希望这篇文章能给同样需要在轻量 CMS 上实现审核功能的开发者一些参考。有问题欢迎交流讨论!
相关源码:本项目基于 PbootCMS 3.2.5,数据库为 SQLite,UI 框架 Layui 2.x。
如果觉得有帮助,欢迎点赞收藏!有什么问题可以在评论区留言讨论 🎯