> 来源：https://mac-cms.com/cms/cms-plugin-dev
> 简介：结合 MacCMS V10 真实源码讲清 addons 插件开发：插件目录结构、think\Addons 基类、info.ini / config.php、钩子（Hook）机制、插件控制器路由（/addons/:addon/:controller/:action）、install.sql 建表，再手把手做一个最小插件，并给出输入校验与安全红线。
> 更新：2026-11-02

# MacCMS 插件开发教程：从零做一个 addons 插件

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

::: tip 读之前先了解整体架构
本文只聚焦「插件开发」。MacCMS 的整体架构（ThinkPHP 5.0.24、三入口、请求生命周期）在[源码结构分析](/cms/cms-source-analysis)里讲过，其中[addons 插件机制](/cms/cms-source-analysis#addons-插件机制)一节已列出源码自带的四个插件；本文在那之上展开讲「怎么自己写一个」，不重复整体介绍。模板那套 `{maccms:...}` 标签是另一回事，见[模板二次开发](/cms/cms-template-dev)。
:::

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

## 一、插件机制概述：addons 是什么

MacCMS V10 的扩展点有三层，别混为一谈：

| 扩展方式 | 改什么 | 典型场景 | 本文范围 |
|----------|--------|----------|----------|
| **模板**（`template/`） | 前台页面长相 | 换皮肤、改列表、加版块 | 见[模板二开](/cms/cms-template-dev) |
| **模块**（`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/插件名/` | 有前端资源时 |

::: tip 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` 换登录背景图 |

::: tip 钩子要生效，方法必须保持 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
    }
}
```

::: warning 这一步几乎是必写的
如果你的插件有可访问的控制器页面/接口，又不加这段，就会出现「插件装上了却完全打不开」这种最难排查的坏法。`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` 里加东西。

::: tip 启用时资源会被自动拷贝
启用插件时，框架的 `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;
```

::: warning 表前缀以实际框架版本为准
`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()`。后台「插件管理 → 设置」页读写的就是它。

::: tip 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` 能看到一句话。只演示骨架，不建表、不加配置。

::: danger 下面是「怎么写」的示范，不是让你改 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`，看到那句话就成了。

::: warning 打不开八成是这两个原因
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` 的兼容处理。
:::

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

插件是独立于主程序的代码，一旦启用就跑在你站点的权限里，安全这块不能马虎。下面几条呼应[安全加固](/cms/cms-security)，务必落实：

### 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 不引入危险函数 / 后门

这是红线。插件代码里**不要**出现下面这些模式，它们是审计时判定后门的典型特征（详见[源码安全审计](/cms/cms-security-audit)的扫描清单）：

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

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

### 7.5 幂等与可回滚

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

## 相关文档

- [源码结构分析](/cms/cms-source-analysis)：ThinkPHP 5.0.24 架构、目录职责，以及 [addons 插件机制](/cms/cms-source-analysis#addons-插件机制)与四个自带插件概览。
- [模板二次开发](/cms/cms-template-dev)：`{maccms:...}` 标签引擎与模板改造（与插件是两套扩展方式）。
- [安全加固](/cms/cms-security)：调试关闭、后台改名、权限收紧、ThinkPHP5 RCE 防护。
- [源码安全审计](/cms/cms-security-audit)：危险函数扫描清单与后门判定方法。
