Skip to content

MacCMS 插件开发教程:从零做一个 addons 插件 ​

想给自己的 MacCMS 站点加点官方没有的功能——比如接个 AI 自动写简介、换个后台登录背景、加个弹幕推送——又不想去改框架核心?那你需要的是「插件」(addons)。本文结合 MacCMS V10(magicblack/maccms10)真实源码,讲清插件到底长什么样、它怎么被加载、控制器怎么被访问、安装建表怎么工作,最后带你从零做一个最小插件。

读之前先了解整体架构

本文只聚焦「插件开发」。MacCMS 的整体架构(ThinkPHP 5.0.24、三入口、请求生命周期)在源码结构分析里讲过,其中addons 插件机制一节已列出源码自带的四个插件;本文在那之上展开讲「怎么自己写一个」,不重复整体介绍。模板那套 {maccms:...} 标签是另一回事,见模板二次开发。

结论来自实际读源码

下面所有路径、类名、方法名、配置项都来自实际阅读 d:\maccms10 源码(只读,未改动任何源码文件)。插件框架来自第三方库 karsonzhang/fastadmin-addons,不同发行版的库版本可能略有差异,涉及具体行号处仅作定位参考,以你本地源码为准。

一、插件机制概述:addons 是什么 ​

MacCMS V10 的扩展点有三层,别混为一谈:

扩展方式改什么典型场景本文范围
模板(template/)前台页面长相换皮肤、改列表、加版块见模板二开
模块(application/模块/)框架级业务模块(admin/api/index…)改核心业务逻辑不建议动,升级会被覆盖
插件(addons/)独立功能单元加一个相对独立的新功能✅ 本文

插件(addons)的核心特点是独立、可插拔:每个插件是 addons/ 下一个独立子目录,有自己的入口类、配置、控制器、视图,甚至自己的数据库表。启用/禁用在后台「插件管理」里点一下就行,不碰框架本体。

MacCMS 的插件框架不是自己造的,而是集成了 FastAdmin 社区的 think-addons 实现,源码在 vendor/karsonzhang/fastadmin-addons/src/ 下。关键三个文件:

  • Addons.php:插件基类 think\Addons,所有插件都继承它。
  • common.php:插件系统的「启动脚本」——注册插件路由、注册 addons 命名空间、挂载钩子,并提供 get_addon_config()、addon_url() 等一堆辅助函数。
  • addons/Route.php:插件控制器的统一路由分发器 think\addons\Route。

二、标准插件目录结构与各文件职责 ​

源码 addons/ 下自带四个插件,正好覆盖从最简单到最完整的形态:

  • adminloginbg(后台登录背景)——最小插件,几乎只有一个入口类加一个钩子方法。
  • socialws(弹幕 WebSocket 推送)——入口类 + 独立 server/ 守护进程。
  • aicontent(AI 内容助理)、mpt(AI 短视频)——最完整,controller/model/service/view/lang/assets 一应俱全。

下面以最完整的 mpt / aicontent 为样板,讲清一个标准插件的文件组成(以实际存在的目录为准,不是每个插件都全有):

addons/mpt/                      # 插件目录名 = 插件标识(小写)
├── Mpt.php                      # ★ 主插件类:类名首字母大写,必须有
├── info.ini                     # ★ 插件元信息:名称/版本/状态,必须有
├── config.php                   # 插件配置(后台「设置」读写的就是它)
├── install.sql                  # 可选:安装时执行的建表 SQL
├── controller/                  # 插件控制器(可被 URL 访问)
│   ├── Admin.php                #   后台任务台
│   └── Api.php                  #   ajax 接口
├── model/                       # 数据模型(对应自己的表)
│   └── MptTask.php
├── service/                     # 业务服务类(纯逻辑,无 HTTP)
│   ├── Safety.php               #   传输/落盘安全层
│   ├── TaskRunner.php           #   任务推进
│   └── ...
├── view/                        # 插件自己的模板(后台页面)
│   └── admin/
├── lang/                        # 多语言包
│   ├── zh-cn.php
│   └── en-us.php
└── assets/                      # 插件自带静态资源(css/js/images)
    ├── css/  js/  images/

各文件职责:

文件/目录职责必须?
Xxx.php(主类)继承 think\Addons,实现 install()/uninstall(),定义钩子方法必须
info.ini插件元信息(name/title/version/state 等),后台列表读它必须
config.php插件配置项,后台「设置」页读写有配置时需要
install.sql安装时建表的 SQL(由主类 install() 自己读取执行)需要建表时
controller/插件控制器,通过 /addons/插件名/控制器/方法 访问需要页面/接口时
model/ service/数据模型与业务逻辑类,命名空间自动解析按需
view/插件自己的后台模板(基类把 view_path 指向这里)有页面时
lang/多语言包按需
assets/插件静态资源,启用时会被拷到 static/addons/插件名/有前端资源时

info.ini 是元信息唯一来源

mpt 的主类里特意写了注释:不要在插件类上声明 public $info。插件元信息的唯一事实来源是 info.ini——Addons::getInfo()(vendor/karsonzhang/fastadmin-addons/src/Addons.php)只 parse 这个文件,全库没有一处读插件类上的 $info。aicontent 虽然声明了 $info,但那只是一份会误导人的冗余拷贝,真正生效的还是它的 info.ini。

一个真实的 info.ini(取自 addons/mpt/info.ini):

ini
name = mpt
title = AI短视频(MPT)
intro = 接入 MoneyPrinterTurbo 服务,为影片自动合成宣传短视频
author = MagicBlack
website = https://github.com/magicblack/maccms10
version = 1.0.0
state = 0
image = /static/addons/mpt/images/logo.svg
url = /addons/mpt/admin/index

其中最关键的是 state:0 = 未启用(随主库分发,默认关闭),1 = 已启用。url 是后台插件列表里「进入插件」按钮指向的地址。

三、插件注册 / 加载机制 ​

这是插件最容易绕晕的地方,把源码里的真实链路理清楚:

3.1 命名空间与路由在哪注册 ​

vendor/karsonzhang/fastadmin-addons/src/common.php 是插件系统的启动脚本,它在加载时做了三件关键事:

php
// 插件根目录常量
define('ADDON_PATH', ROOT_PATH . 'addons' . DS);

// 统一的插件路由规则
Route::any('addons/:addon/[:controller]/[:action]', "\\think\\addons\\Route@execute");

// 把 addons 根命名空间指向插件目录
Loader::addNamespace('addons', ADDON_PATH);

这三行解释了两个「为什么」:

  • 为什么插件地址长 /addons/mpt/admin/index 这样:因为路由规则就是 addons/:addon/:controller/:action,匹配到后交给 think\addons\Route::execute() 统一分发。
  • 为什么插件类的命名空间是 addons\mpt\...:因为 addons 这个根命名空间被映射到了 addons/ 目录。所以 addons\mpt\controller\Admin 这个类,文件就在 addons/mpt/controller/Admin.php,一一对应,不用你手动注册 model/service 子命名空间。

3.2 钩子(Hook)机制:public 方法就是钩子 ​

MacCMS 插件的「行为」靠**钩子(Hook)**挂进主程序。机制在 common.php 的 get_addon_autoload_config() 里,核心逻辑是:

把插件主类上的每一个 public 方法,减去基类自带的方法和 install/uninstall/enable/disable,剩下的全部登记成钩子。方法名就是钩子名(会做一次下划线命名转换)。

也就是说,你在主类里写一个 public function appInit(),它就自动成了 app_init 钩子的监听者;写 public function viewFilter(&$content),就挂上了 view_filter 钩子。真实例子(取自 addons/socialws/Socialws.php):

php
class Socialws extends Addons
{
    // app_init 钩子:每个请求引导阶段触发
    public function appInit()
    {
        $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
        if (strpos($uri, 'addons/socialws') !== false) {
            \think\App::route(true);   // 见 3.4
        }
    }

    // social_broadcast 钩子:由业务代码主动 hook('social_broadcast', ...) 触发
    public function socialBroadcast($params) { /* ... */ }
}

常见钩子(以源码里实际用到的为准):

钩子(方法名)触发时机用途举例
appInit → app_init框架引导、路由前按需打开路由检查、加载语言包、注册 shutdown 回调
actionBegin → action_begin控制器 action 执行前判断当前是不是某个编辑页,打标记
viewFilter → view_filter模板渲染完、输出前往 HTML 里注入脚本/按钮(aicontent 的「AI」按钮就是这么加的)
adminLoginInit → admin_login_init后台登录页初始化adminloginbg 换登录背景图

钩子要生效,方法必须保持 private 以外

因为「public 方法 = 钩子」,所以 mpt 把所有内部辅助方法都写成 private——否则会往 extra/addons.php 里塞一堆无意义的垃圾钩子。你写插件时也遵守这条:只有真正要当钩子的方法才用 public。

3.3 配置落盘在 extra/addons.php ​

启用插件后,系统会把「插件目录 + 钩子映射 + 路由」缓存到 application/extra/addons.php。源码里初始状态是空的(四个插件默认 state=0 未启用):

php
// application/extra/addons.php
return array (
  'autoload' => false,
  'hooks' => array ( ),
  'route' => array ( ),
);

一旦你在后台启用某个插件,这里的 hooks/route 就会被 get_addon_autoload_config() 的结果填上。这个文件由插件框架自动维护,不要手改。

3.4 一个必须知道的坑:url_route_on ​

MacCMS 默认关闭了 ThinkPHP 的路由检查(url_route_on = false),这会导致 common.php 里那条 Route::any('addons/:addon/...') 匹配不上,插件控制器直接 404。源码里四个插件的解法是一致的——在 app_init 钩子里,只在请求确实指向本插件时才临时打开路由检查:

php
public function appInit()
{
    $uri = $_SERVER['REQUEST_URI'] ?? '';
    if (strpos($uri, 'addons/你的插件名') !== false) {
        \think\App::route(true);   // 只为本插件打开,不影响全站其它 URL
    }
}

这一步几乎是必写的

如果你的插件有可访问的控制器页面/接口,又不加这段,就会出现「插件装上了却完全打不开」这种最难排查的坏法。mpt 的实现还额外兼容了关闭 PATH_INFO 的站点(index.php?s=/addons/mpt/... 形式),要支持更多部署环境可参考它的 appInit()。

四、插件控制器与路由:页面怎么被访问 ​

4.1 后台/前台入口 ​

插件控制器放在 addons/插件名/controller/ 下,继承 think\addons\Controller。访问地址由 3.1 的路由规则决定:

/index.php/addons/插件名/控制器/方法

真实例子(addons/mpt/controller/Admin.php 顶部注释写明):任务台地址是 /index.php/addons/mpt/admin/index,对应 addons\mpt\controller\Admin::index()。

分发细节在 think\addons\Route::execute()(vendor/.../src/addons/Route.php):它先用 get_addon_info() 校验插件存在且 state=1(禁用的插件访问会抛 500),再实例化控制器、调用对应 action,期间依次触发 addon_begin、addon_module_init、addon_action_begin 几个钩子。

4.2 插件视图从哪读 ​

think\Addons 基类构造时把模板引擎的 view_path 指向了插件自己的目录(addons/插件名/)。所以插件控制器 fetch() 的模板读的是插件自己的 view/,不会和后台主题打架,也不用往后台菜单 auth.php 里加东西。

启用时资源会被自动拷贝

启用插件时,框架的 Service::enable() 会把 addons/插件名/assets/ 拷到 static/addons/插件名/(web 可访问),把 view/ 拷到 application/admin/view_new/插件名/。这些是框架的固定动作。mpt 的 uninstall() 里有大段注释提醒:卸载时某些拷贝副本不会被自动清掉,需要插件自己补清理——写复杂插件时留意这个「残留」问题。

4.3 鉴权要自己做 ​

走 index.php 入口访问插件时,核心的后台登录强制(Begin 行为)不会自动生效,所以插件后台控制器要在 _initialize() 里自己校验登录态。mpt 的 Admin::_initialize() 就是这么做的:

php
protected function _initialize()
{
    parent::_initialize();
    // 自己校验后台登录
    if (session('admin_auth') !== '1' || empty(session('admin_info'))) {
        self::deny(lang('mpt/err_unauthorized_admin'));
    }
    // 再过一道节点权限
    if (!Safety::adminAllowed()) {
        self::deny(lang('mpt/err_permission_denied'));
    }
}

五、插件自带安装 / 建表怎么工作 ​

5.1 install() vs enable() ​

主类必须实现 install() 和 uninstall()(基类是抽象方法),可选实现 enable()/disable()。这里有一个容易踩的区别,mpt 的注释讲得很清楚:

  • 只有「上传 zip / 云市场安装」才会走 install()。
  • 随主库一起躺在 addons/ 里的插件,站长在后台看到的只有「启用」,走的是 enable(),install() 永远不会被调用。

所以建表、铺资源这类初始化逻辑,要放在 install() 和 enable() 都会经过的地方,并且做成幂等(重复执行不出错)。mpt 的做法是两者都调同一个 setup():

php
public function install() { return $this->setup(); }
public function enable()  { return $this->setup(); }
private function setup()  { /* 幂等的建表、生成 token 等 */ return true; }

5.2 install.sql 建表 ​

需要自己的数据库表时,把建表语句写进 install.sql,由主类读取执行。aicontent 的做法(addons/aicontent/Aicontent.php):

php
public function install(): bool
{
    $sqlFile = $this->addon_path . 'install.sql';
    if (is_file($sqlFile)) {
        $sql = file_get_contents($sqlFile);
        $statements = array_filter(array_map('trim', explode(';', $sql)));
        foreach ($statements as $statement) {
            if ($statement) {
                \think\Db::execute($statement);
            }
        }
    }
    return true;
}

对应的 addons/aicontent/install.sql 用 CREATE TABLE IF NOT EXISTS(幂等):

sql
CREATE TABLE IF NOT EXISTS `mac_ai_task` (
  `id`           int(11) NOT NULL AUTO_INCREMENT,
  `content_type` varchar(20)  NOT NULL DEFAULT 'video',
  `content_id`   int(11)      NOT NULL DEFAULT 0,
  -- ... 其余字段
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

表前缀以实际框架版本为准

aicontent 直接写死了表名 mac_ai_task,而 mpt 用 config('database.prefix') . 'mpt_task' 动态拼前缀。站点的表前缀由安装时决定(默认 mac_),稳妥做法是读 database.prefix 配置而不是写死,具体以你本地的 application/database.php 配置为准。

卸载时在 uninstall() 里 DROP TABLE,并清理拷贝出去的静态资源。aicontent 卸载会 DROP TABLE IF EXISTS mac_ai_task 并删掉 static/addons/aicontent/。

5.3 config.php 配置读写 ​

有配置项的插件用 config.php 承载。读配置用框架提供的辅助函数 get_addon_config('插件名'),写用 set_addon_config()。后台「插件管理 → 设置」页读写的就是它。

config.php 的两种形态

源码里 config.php 有两种写法:一种是 fastadmin-addons 标准的「完整声明数组」(每项含 name/title/type/value),后台保存时 set_addon_fullconfig() 会用 var_export() 把它整个拍平回写文件;另一种是 mpt 用的「只存值 + 运行时 hydrate」技巧(为了让多语言文案不被 var_export 冻死)。初学做插件先用标准形态即可,mpt 那套是为解决多语言落盘问题的进阶写法。

六、实操:从零做一个最小插件 ​

目标:做一个名为 hello 的最小插件,有一个后台页面,访问 /index.php/addons/hello/index/index 能看到一句话。只演示骨架,不建表、不加配置。

下面是「怎么写」的示范,不是让你改 maccms10 源码

本节代码仅作教学示范。真正开发时在你自己的 MacCMS 站点副本里操作,别在只读参考源码里改。

第 1 步:建目录和主类。 新建 addons/hello/Hello.php(类名首字母大写,和目录名对应):

php
<?php
namespace addons\hello;

use think\Addons;

class Hello extends Addons
{
    // 基类要求必须实现
    public function install()   { return true; }
    public function uninstall() { return true; }

    // app_init 钩子:只为本插件打开路由检查(第 3.4 节的坑)
    public function appInit()
    {
        $uri = $_SERVER['REQUEST_URI'] ?? '';
        if (strpos($uri, 'addons/hello') !== false) {
            \think\App::route(true);
        }
    }
}

第 2 步:写 info.ini。 新建 addons/hello/info.ini:

ini
name = hello
title = 示例插件
intro = 一个最小可运行的插件骨架
author = yourname
website = https://example.com
version = 1.0.0
state = 0
url = /addons/hello/index/index

第 3 步:写控制器。 新建 addons/hello/controller/Index.php:

php
<?php
namespace addons\hello\controller;

use think\addons\Controller;

class Index extends Controller
{
    public function index()
    {
        // 后台插件通常要先校验登录,示例从简,直接输出
        return '你好,这是我的第一个 MacCMS 插件';
    }
}

对照 addons/adminloginbg/controller/Index.php 可以看到,最简单的插件控制器就是这个样子。

第 4 步:后台启用。 把 addons/hello/ 整个目录放进站点 addons/ 下,进后台「插件管理」,找到「示例插件」点「启用」。启用会把 info.ini 的 state 改成 1,并把钩子/路由写进 extra/addons.php。

第 5 步:访问。 浏览器打开 你的站点/index.php/addons/hello/index/index,看到那句话就成了。

打不开八成是这两个原因

  1. url_route_on=false 导致路由没打开——确认你在 appInit() 里按第 3.4 节写了 \think\App::route(true)。
  2. 插件没启用(state=0)——Route::execute() 对未启用插件会抛 500。回后台确认已启用。

PATH_INFO 被关掉的站点,地址要用 index.php?s=/addons/hello/index/index,参考 mpt 的兼容处理。

七、插件开发注意事项与安全 ​

插件是独立于主程序的代码,一旦启用就跑在你站点的权限里,安全这块不能马虎。下面几条呼应安全加固,务必落实:

7.1 输入校验,永远不信外部数据 ​

凡是来自 $_GET/$_POST/请求体、第三方接口返回、大模型输出的数据,都当成不可信输入处理。mpt 的 Safety 服务是很好的范例:

  • URL / 路径只当路径处理:对第三方返回的地址,剥掉 scheme/host/query、拒绝 ..,再拼回自己信任的 base,防止被引到别的主机(SSRF)。
  • 类型先卡死再转换:不要直接 (string) 一个可能是数组/对象的字段,先 is_string()/is_numeric() 判一下,否则会打出 Array 这种无意义值甚至污染响应体。

7.2 权限与鉴权自己兜 ​

  • 后台控制器在 _initialize() 里校验登录态(session('admin_auth'))和节点权限,别假设框架会替你拦(走 index.php 入口时不会,见 4.3)。
  • 对会写数据/发请求的接口加频率限制(mpt 的 consumeRateLimit),避免被按住 F5 刷爆。

7.3 CSRF 与输出转义 ​

  • POST 接口加 CSRF token。aicontent 用 bin2hex(random_bytes(16)) 生成 token 存进 session,页面渲染和后续请求共用同一个值。生成随机凭据用 random_bytes(),不要用 mt_rand() 这类弱随机。
  • 往 HTML 注入内容时做转义。mpt 往页面塞脚本时用 JSON_HEX_TAG 等标志,保证值里出现 </script> 也不会提前闭合标签。

7.4 不引入危险函数 / 后门 ​

这是红线。插件代码里不要出现下面这些模式,它们是审计时判定后门的典型特征(详见源码安全审计的扫描清单):

  • 把外部输入直接喂给 eval、assert、system、exec、shell_exec、passthru 等。
  • eval(gzinflate(base64_decode(...))) 这类多层编解码混淆——正常业务几乎不需要。
  • preg_replace 带 /e 修饰符。
  • 用 $_GET/$_POST 的值拼接文件名去 file_put_contents 写 .php 文件。

合法的 base64 不是后门

源码里 aicontent 用 base64 处理图片、采集模型用 base64_decode 解附加参数,这些都是合法业务用法。审计的判据是「外部输入是否未经校验直达危险函数」,而不是「出现了 base64 就是后门」。你写插件时也一样:用这些函数没问题,关键是输入要可控、不要和代码执行组合。

7.5 幂等与可回滚 ​

  • install()/enable() 做成幂等(第 5.1 节),重复启用不出错。
  • uninstall() 清干净自己建的表和拷出去的文件;拿不准的「残留清理」参考 mpt::uninstall() 的注释。
  • 插件代码纳入版本管理,出问题能回滚。

相关文档 ​