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):
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 是插件系统的启动脚本,它在加载时做了三件关键事:
// 插件根目录常量
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):
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 未启用):
// 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 钩子里,只在请求确实指向本插件时才临时打开路由检查:
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() 就是这么做的:
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():
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):
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(幂等):
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
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:
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
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,看到那句话就成了。
打不开八成是这两个原因
url_route_on=false导致路由没打开——确认你在appInit()里按第 3.4 节写了\think\App::route(true)。- 插件没启用(
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()的注释。- 插件代码纳入版本管理,出问题能回滚。
相关文档
- 源码结构分析:ThinkPHP 5.0.24 架构、目录职责,以及 addons 插件机制与四个自带插件概览。
- 模板二次开发:
{maccms:...}标签引擎与模板改造(与插件是两套扩展方式)。 - 安全加固:调试关闭、后台改名、权限收紧、ThinkPHP5 RCE 防护。
- 源码安全审计:危险函数扫描清单与后门判定方法。