【在 PbootCMS 3.2.5 上实现企业级二级内容审核系统】

0 阅读8分钟

在 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 新增/编辑时的审核联动

ContentControlleradd() 方法中,根据当前登录用户的身份决定文章初始状态:

// 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 方案优势

  1. 零侵入性:不修改 PbootCMS 核心文件,全部通过扩展实现,升级不影响
  2. 状态机驱动:每个操作都有前置合法性校验,不会出现非法状态流转
  3. 全程留痕:所有操作记录到独立日志表,审计友好
  4. 灵活的角色体系:通过简单的条件判断即可扩展更多角色类型

6.2 可优化方向

  • 将硬编码的用户名判断('cc')改为数据库角色字段,支持动态配置
  • 增加邮件/消息通知功能,审核状态变更时通知相关人员
  • 支持移动端审核,通过 API 接口暴露审核能力
  • 增加审核时效统计,分析平均审核耗时
  • 考虑引入工作流引擎(如简单的状态机库),使流程可配置化

6.3 写在最后

在现有 CMS 上做二次开发,最难的从来不是"怎么写代码",而是理解框架的运行机制和隐含限制。PbootCMS 虽然是一个轻量级框架,但它有自己的路由规则、模板语法、POST 校验机制——这些"看不见的墙"才是真正的挑战。

希望这篇文章能给同样需要在轻量 CMS 上实现审核功能的开发者一些参考。有问题欢迎交流讨论!


相关源码:本项目基于 PbootCMS 3.2.5,数据库为 SQLite,UI 框架 Layui 2.x。


如果觉得有帮助,欢迎点赞收藏!有什么问题可以在评论区留言讨论 🎯