开发文档

Zerjie(择界)系统内核的完整开发参考——主题、插件、小工具、后台扩展、核心 API、安全组件

简介

Zerjie(择界)是一款基于 纯原生 PHP 开发的主题驱动型内容管理系统。核心理念是「一核载万态 · 主题定乾坤」——内核只提供基础设施,业务逻辑完全由主题定义。

关键特点:

  • 零依赖:不依赖 Composer,不依赖任何 PHP 框架
  • 轻量:核心代码 1MB 以内,单文件入口
  • 三级扩展:主题 / 插件 / 小工具,互不耦合
  • 安全内建:CSRF、XSS、SQL 注入防护、IP 限流、分级防御、完整性校验开箱即用
  • 开箱即用:后台菜单、设置页、小工具管理、用户管理、SMTP、站点锁、CLI 命令全部内置
  • 多驱动:缓存 / 日志 / 队列都支持多驱动,可自由扩展

扩展体系一览:

类型位置职责
主题content/themes/{name}/定义整个站点的外观和前端路由
插件content/plugins/{name}/扩展功能,与主题无关
小工具content/widgets/ 或 core/widgets/可拖拽的前端内容块
内核边界:内核只提供「基础设施」——数据库、路由、认证、会话、钩子、缓存、日志、队列、安全组件。业务逻辑(比如博客、论坛、电商)一律由主题/插件实现。

环境要求

项目最低推荐
PHP7.48.1+
MySQL5.78.0+
MariaDB10.310.6+

必需扩展:pdo、pdo_mysql、json、openssl、zip。

推荐扩展:

  • intl:slug 音译(中文→拼音)
  • fileinfo:上传文件的 MIME 类型校验
  • curl:更新检查和完整性守卫优先使用,性能优于 allow_url_fopen
  • apcu:防火墙的限流可以走内存,性能更好
  • pcntl:队列 Worker 心跳与信号处理

安装与运行

上传源码到网站根目录,浏览器访问域名,自动跳转安装向导:

http://你的域名/install/install.php

安装流程(6 步):

  1. 启动界面(产品介绍)
  2. 用户协议
  3. 环境检测(PHP 版本、扩展、目录权限)
  4. 数据库配置(主机、端口、库名、用户名、密码、表前缀)
  5. 站点信息(标题、URL、管理员账号)
  6. 执行安装(创建表、写配置、生成 admin.php、删除 install/)

安装完成后:

  • 自动删除整个 install/ 目录
  • 后台入口默认为 admin.php(强烈建议重命名)
  • 配置文件写入 config/config.php(数据库)和 config/system.php(站点)
  • 站点 URL 写入 {prefix}options 表的 site_url 键
  • 核心文件的 SHA-256 清单写入 storage/manifest.json
  • 安装元数据(install_id、version)写入 storage/integrity/install.json

本地开发用 PHP 内置服务器即可:

cd /path/to/zerjie
php -S localhost:8080

目录结构

zerjie/
├── index.php                  前端入口
├── admin.php                  后台入口(可重命名)
├── console.php                CLI 入口
├── config/                    配置目录
│   ├── config.php             数据库连接(安装时生成)
│   └── system.php             站点配置(安装时生成)
│
├── core/                      内核(不可修改)
│   ├── zerjie.php             内核引导文件
│   ├── auth.php               用户认证
│   ├── register.php           用户注册
│   ├── database/              数据库层
│   │   ├── Connection.php     PDO 连接包装
│   │   ├── QueryBuilder.php   流式查询构造器
│   │   ├── Migrator.php       迁移管理器
│   │   └── Schema.php         表结构构建器
│   ├── security/              安全组件
│   │   ├── Csrf.php           CSRF 令牌
│   │   ├── Xss.php            XSS 过滤器
│   │   ├── Sanitizer.php      输入验证与清理
│   │   ├── UploadGuard.php    上传/压缩包校验
│   │   ├── Firewall.php       IP 黑名单 + 限流
│   │   ├── Defense.php        分级防御系统
│   │   └── IntegrityGuard.php 核心文件完整性守卫
│   ├── cache/                 缓存
│   │   ├── CacheManager.php
│   │   ├── FileCache.php
│   │   ├── MemoryCache.php
│   │   └── RedisCache.php
│   ├── log/                   日志
│   │   ├── Logger.php
│   │   ├── FileChannel.php
│   │   └── DatabaseChannel.php
│   ├── queue/                 队列
│   │   ├── Job.php            任务抽象类
│   │   ├── Queue.php          文件队列
│   │   └── Worker.php         Worker 进程
│   ├── router/                路由
│   │   ├── Router.php
│   │   ├── Route.php
│   │   ├── Dispatcher.php
│   │   └── UrlGenerator.php
│   ├── events/                事件系统
│   │   ├── Dispatcher.php
│   │   ├── Event.php
│   │   └── Listener.php
│   ├── http/                  HTTP 抽象
│   │   ├── Kernel.php
│   │   ├── Request.php
│   │   └── Response.php
│   ├── session/               会话管理
│   │   ├── Session.php
│   │   └── Flash.php
│   ├── i18n/                  多语言
│   │   └── Language.php
│   ├── update/                更新系统
│   │   ├── UpdateChecker.php
│   │   └── Updater.php
│   ├── console/               CLI
│   │   └── Console.php
│   ├── hook/                  钩子引擎
│   │   └── Hook.php
│   ├── exceptions/            异常处理
│   │   └── Handler.php
│   ├── widgets/               系统小工具(可扩展)
│   └── helpers/               全局辅助函数
│       ├── functions.php      核心辅助函数
│       ├── api.php            后台 / 小工具 / 设置 API
│       ├── mailer.php         SMTP 邮件
│       └── debug.php          调试工具
│
├── content/                   内容目录(用户可编辑)
│   ├── themes/                主题目录
│   ├── plugins/               插件目录
│   ├── widgets/               用户小工具目录
│   ├── languages/             用户语言包(覆盖系统)
│   ├── uploads/               上传文件
│   └── cache/                 生成的静态资源缓存
│
├── adminbackend/              后台管理界面
│   ├── AdminController.php    后台控制器
│   ├── admin-menu.php         默认后台菜单
│   ├── assets/                后台 JS / CSS
│   ├── languages/             系统语言包
│   └── view/                  后台视图
│
├── templates/                 系统级兜底模板
│   ├── activate.php           账号激活页
│   └── lock-pages/            站点锁页面模板
│
├── storage/                   运行时存储(禁止公开访问)
│   ├── cache/                 文件缓存
│   ├── logs/                  日志
│   ├── temp/                  临时文件
│   ├── queue/                 队列文件
│   ├── backups/               备份
│   ├── integrity/             完整性元数据
│   └── manifest.json          核心文件完整性清单
│
└── install/                   安装向导(安装后自动删除)

请求生命周期

每个 HTTP 请求的执行顺序(以 index.php 为例):

  1. 入口:index.php → core/zerjie.php
  2. 常量定义:ZERJIE_ROOT、ZERJIE_START、ZERJIE_CORE_DIR 等
  3. 卸载守卫:register_shutdown_function 注册 IntegrityGuard 检查钩子(如果已启用)
  4. 配置加载:config/config.php + config/system.php
  5. 自动加载器:注册 Zerjie\* 命名空间的 PSR-4 自动加载
  6. 维护模式:检测 storage/update/.maintenance 标志
  7. 会话启动:Zerjie\Session\Session::start()(仅 Web 请求)
  8. 安全响应头:X-Content-Type-Options、X-Frame-Options、CSP 等
  9. 完整性检查:比对 storage/manifest.json(任一文件被篡改 → 503)
  10. 错误处理:注册全局 error_handler 和 exception_handler
  11. 加载辅助函数:helpers/functions.php、api.php、mailer.php
  12. 数据库连接:zerjie_database_connect()
  13. 预加载配置:从 options 表读 autoload = 'yes' 的键
  14. 多语言初始化:Zerjie\I18n\Language::init()
  15. 加载内核模块:auth.php、register.php
  16. 注册核心服务:Hook、Event Dispatcher、Cache Manager、Logger
  17. 加载插件:按 active_plugins 顺序 require 主文件
  18. 加载主题:require content/themes/{active}/functions.php
  19. 加载小工具:glob content/widgets/*.php
  20. 初始化路由:Router + Dispatcher + UrlGenerator
  21. 触发 zerjie_ready 钩子 ← 所有扩展的入口点
  22. 执行防御:Zerjie\Security\Defense::enforce()(非 CLI)
  23. 匹配路由:Dispatcher 匹配 URL → 调用回调
  24. 输出响应:Response 对象 send
  25. 卸载:shutdown 钩子触发 IntegrityGuard 检查
关键点:所有主题/插件的注册代码都应该挂到 zerjie_ready 钩子。zerjie_ready 之前,内核只完成初始化,尚未加载任何业务代码。

钩子与过滤器

Zerjie 提供两套机制:Action(执行代码)和 Filter(修改数据)。这是 Wordpress 风格的经典设计。

基础用法

// 注册 Action(执行代码,无返回值)
add_action('zerjie_ready', function () {
    // 内核初始化完成后执行
}, 10);

// 注册 Filter(修改数据,必须有返回值)
add_filter('zerjie_admin_menu', function ($menus) {
    $menus[] = ['slug' => 'my-page', 'title' => '我的页面'];
    return $menus;
}, 20);

// 触发 Action
do_action('my_custom_action', $arg1, $arg2);

// 应用 Filter
$result = apply_filters('my_custom_filter', $default_value, $arg1);

函数签名

函数参数返回值
add_action($hook, $callback, $priority = 10) 钩子名、回调、优先级 无
do_action($hook, ...$args) 钩子名、任意参数 无
add_filter($hook, $callback, $priority = 10) 钩子名、回调、优先级 无
apply_filters($hook, $value, ...$args) 钩子名、初始值、额外参数 被所有回调处理后的最终值

优先级

数字越小越先执行。相同优先级按注册顺序执行。

优先级场景
0-4内核早期处理(如建表、前置检查)
5-9主题/插件基础初始化
10(默认)常规处理
11-30晚于其他处理(如覆盖菜单、最终修改)
100+最后处理(如输出、记录)

闭包 vs 命名函数

// 闭包(推荐,作用域清晰,避免命名冲突)
add_action('zerjie_ready', function () {
    // ...
});

// 命名函数(便于后续移除)
function my_plugin_init() {
    // ...
}
add_action('zerjie_ready', 'my_plugin_init');
注意:Filter 回调必须返回处理后的值。忘了 return 会导致下游收到 null。

事件系统

事件系统面向对象,适合插件做业务解耦。与钩子系统并存,二者互补。

基础用法

use Zerjie\Events\Event;

// 1. 触发事件
event('user.registered', new Event());

// 2. 监听事件
listen('user.registered', function ($event) {
    // 处理逻辑
}, 10);   // 第三个参数是优先级

携带数据

用自定义 Event 子类承载数据:

class UserRegisteredEvent extends \Zerjie\Events\Event {
    public $userId;
    public $username;
    public $email;

    public function __construct($userId, $username, $email) {
        $this->userId   = $userId;
        $this->username = $username;
        $this->email    = $email;
    }
}

// 触发
event('user.registered', new UserRegisteredEvent(123, 'alice', 'alice@example.com'));

// 监听
listen('user.registered', function ($e) {
    echo "新用户:{$e->username}(ID: {$e->userId})";
    echo "邮箱:{$e->email}";
});

中止传播

listen('order.created', function ($event) {
    if (/* 停止后续监听 */) {
        $event->stopPropagation();
    }
});

监听器类

复杂业务推荐封装为类(实现 Zerjie\Events\Listener 接口):

namespace MyPlugin;

use Zerjie\Events\Event;
use Zerjie\Events\Listener;

class OrderListener implements Listener {
    public function handle(Event $event) {
        // 处理订单事件
    }
}

// 注册时直接传类名,内核会 new 实例
listen('order.created', 'MyPlugin\OrderListener');

事件 vs 钩子的选择

场景推荐
需要修改返回值过滤器 Filter
只需执行副作用(发通知、写日志)动作 Action 或事件 Event
业务解耦、领域事件事件 Event(面向对象)
需要中止传播事件 Event(有 stopPropagation)
快速注册、简单处理钩子 Action/Filter

主题结构

在 content/themes/ 下新建目录,例如 my-theme/:

content/themes/my-theme/
├── theme.json              主题元信息(必需)
├── functions.php           主题逻辑入口
├── schema.php              私有数据表(可选)
├── templates/              模板目录
│   ├── partials/           公共片段
│   │   ├── header.php
│   │   └── footer.php
│   ├── index.php           首页
│   ├── single.php          单篇详情
│   └── 404.php             错误页
├── admin/                  后台视图(可选)
│   └── settings.php
└── assets/                 静态资源
    ├── css/style.css
    └── js/main.js

命名规范:

  • 目录名建议只用小写字母、数字、连字符,如 my-theme
  • functions.php 是主题的入口,所有钩子注册都在这里
  • templates/ 存放所有模板文件(文件名对应路由名或页面名)
  • schema.php 定义主题私有表,会随主题启用时自动创建

启用主题:

  • 后台 → 外观 → 主题 → 找到你的主题 → 点击启用
  • 或 CLI:php console.php theme:activate my-theme

theme.json

主题的身份标识。用于后台主题管理页面,以及判断主题唯一性(同一 id 视为同一主题的不同版本)。

{
    "id": "my-theme",
    "name": "我的主题",
    "version": "1.0.0",
    "author": "Your Name",
    "description": "一个简洁的博客主题",
    "homepage": "https://example.com"
}

字段说明:

字段必需说明
id是唯一标识,用于判断同一主题(同名目录不同 id 视为不同主题)
name是显示名称
version推荐语义化版本号
author否作者
description否描述
homepage否主题主页
同 id 视为同一主题:上传新版本时,系统会用 id 匹配。如果 id 一致、版本号不同,会备份旧目录并覆盖;id 不一致则拒绝(防止同名覆盖不同主题)。

模板系统

Zerjie 的内核不规定主题的目录结构,主题必须通过 filter 显式注册模板路径。这是为了让主题完全掌控自己的文件布局。

注册模板路径

<?php
if (!defined('ZERJIE_ROOT')) die('Access denied.');

add_filter('zerjie_theme_template_file', function ($file, $template, $theme) {
    if ($theme !== 'my-theme') return $file;

    // 白名单:只接管本主题提供的模板
    $allowed = ['index', 'single', 'page', '404'];
    if (!in_array($template, $allowed, true)) return $file;

    $path = __DIR__ . '/templates/' . $template . '.php';
    return file_exists($path) ? $path : $file;
}, 10, 3);

模板查找顺序

内核的 zerjie_render_theme() 查找顺序(简化为 2 步):

  1. 触发 zerjie_theme_template_file filter(主题返回自己的路径)
  2. 兜底:ZERJIE_ROOT/templates/{tpl}.php(系统级模板)

都找不到 → 输出 Template {name} does not exist.

模板中可用的辅助函数

函数说明
Helper::e($str)HTML 转义输出(防 XSS)
zerjie_get_option($key, $default)读取配置
zerjie_get_current_user()当前登录用户(未登录返回 null)
zerjie_current_user_can($cap)权限检查
zerjie_is_logged_in()是否已登录
__($text) / _e($text)多语言(返回 / 输出)
csrf_field()输出 CSRF 隐藏字段
csrf_token()返回当前 CSRF 令牌
the_widgets($area)输出指定区域的小工具
zerjie_client_ip()获取真实客户端 IP
zerjie_get_favicon_url()获取 favicon URL
flash_get($key)读取一次性 flash 消息
js_config($extra)输出全局 JS 配置属性(含 CSRF、当前用户)
js_data($data, $key)输出自定义 data-* JSON 属性

完整示例:首页模板

<?php
// templates/index.php
if (!defined('ZERJIE_ROOT')) die('Access denied.');

$site_title = zerjie_get_option('site_title', 'Zerjie');
$posts = zerjie_db_query('posts', ['status' => 'published'], ['*'], ['id' => 'DESC'], 10);

require __DIR__ . '/partials/header.php';
?>

<main class="container">
    <h1><?= Helper::e($site_title) ?></h1>

    <?php foreach ($posts as $post): ?>
        <article class="post-card">
            <h2><?= Helper::e($post['title']) ?></h2>
            <p><?= Helper::e(mb_substr(strip_tags($post['content']), 0, 200)) ?>…</p>
        </article>
    <?php endforeach; ?>
</main>

<?php require __DIR__ . '/partials/footer.php'; ?>

路由注册

在 functions.php 里通过 zerjie_ready 钩子注册路由。

基础用法

add_action('zerjie_ready', function () {
    global $zerjie_router;
    if (!$zerjie_router) return;

    // 首页
    $zerjie_router->get('/', function () {
        require __DIR__ . '/templates/index.php';
        exit;
    });

    // 关于我们
    $zerjie_router->get('/about', function () {
        require __DIR__ . '/templates/about.php';
        exit;
    });

    // 文章详情(数字 ID)
    $zerjie_router->get('/post/{id:\d+}', function ($id) {
        $post = zerjie_db_query('posts', ['id' => (int) $id]);
        if (empty($post)) {
            http_response_code(404);
            require __DIR__ . '/templates/404.php';
            exit;
        }
        $post = $post[0];
        require __DIR__ . '/templates/single.php';
        exit;
    });

    // 表单提交
    $zerjie_router->post('/contact', function () {
        if (!csrf_check()) {
            die('CSRF 校验失败');
        }
        // 处理表单
        flash_success('提交成功');
        header('Location: /contact');
        exit;
    });

    // 任意 HTTP 方法
    $zerjie_router->any('/api/data', function () {
        header('Content-Type: application/json');
        echo json_encode(['ok' => true]);
        exit;
    });
});

Route 对象方法

方法说明
->name($name)命名路由,可通过 route() 生成 URL
->middleware($mw)绑定中间件(字符串类名 / 闭包)

路由分组

$zerjie_router->group('/api/v1', function ($router) {
    $router->get('/users',  function () { /* GET /api/v1/users */ });
    $router->post('/users', function () { /* POST /api/v1/users */ });
    $router->get('/posts',  function () { /* GET /api/v1/posts */ });
});

生成 URL

// 注册时命名
$zerjie_router->get('/post/{id}', function ($id) { /* ... */ })->name('post');

// 生成 URL
$url = route('post', ['id' => 123]);   // → /post/123

参数约束

// 只匹配数字
$zerjie_router->get('/post/{id:\d+}', function ($id) { /* ... */ });

// 只匹配字母数字
$zerjie_router->get('/user/{name:[a-zA-Z0-9_]+}', function ($name) { /* ... */ });

// 默认 [^/]+(匹配除斜杠外任意字符)
$zerjie_router->get('/category/{slug}', function ($slug) { /* ... */ });

// 可选参数(?)
$zerjie_router->get('/page/{num:\d+?}', function ($num = 1) { /* ... */ });

中间件

中间件类需有 handle(Request $req, callable $next) 方法:

namespace MyApp;

use Zerjie\Http\Request;
use Zerjie\Http\Response;

class AuthMiddleware {
    public function handle(Request $req, callable $next) {
        if (!zerjie_is_logged_in()) {
            return new Response('请先登录', 401);
        }
        return $next($req);
    }
}

// 绑定到路由
$zerjie_router->get('/dashboard', function () {
    // ...
})->middleware(AuthMiddleware::class);

主题私有表

主题可以携带自己的数据表定义,随主题启用时自动创建,不污染系统安装脚本。所有表名统一加系统前缀,与内核共享同一数据库。

schema.php 格式

返回 SQL 语句数组,用 {prefix} 占位符表示表前缀:

<?php
if (!defined('ZERJIE_ROOT')) die('Access denied.');

return [
    "CREATE TABLE IF NOT EXISTS `{prefix}my_posts` (
        `id` INT AUTO_INCREMENT PRIMARY KEY,
        `title` VARCHAR(191) NOT NULL,
        `content` TEXT,
        `created_at` DATETIME NOT NULL,
        INDEX `created_idx` (`created_at`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",

    "CREATE TABLE IF NOT EXISTS `{prefix}my_comments` (
        `id` INT AUTO_INCREMENT PRIMARY KEY,
        `post_id` INT NOT NULL,
        `content` TEXT,
        INDEX `post_idx` (`post_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
];

functions.php 中触发建表

add_action('zerjie_ready', function () {
    static $done = false;
    if ($done) return;

    $db = $GLOBALS['zerjie_db'] ?? null;
    if (!$db instanceof \PDO) return;

    $file = __DIR__ . '/schema.php';
    if (!file_exists($file)) { $done = true; return; }

    $tables = include $file;
    $prefix = zerjie_table_prefix();
    foreach ($tables as $sql) {
        $sql = str_replace('{prefix}', $prefix, trim((string) $sql));
        try { $db->exec($sql); } catch (\PDOException $e) {}
    }
    $done = true;
}, 5);   // 优先级 5,早于业务钩子

注意事项

  • 用 CREATE TABLE IF NOT EXISTS,保证幂等
  • 开发阶段直接删表重建:本地开发改字段时,手动 DROP TABLE 再重新触发即可
  • 表名建议加主题前缀:如 forum_topics、shop_orders,避免与其他主题/插件冲突
  • 生产环境用迁移系统:正式发布后,字段变更应通过 database/migrations/ 管理

完整示例:最小博客主题

目录结构

content/themes/my-blog/
├── theme.json
├── functions.php
├── schema.php
├── templates/
│   ├── partials/
│   │   ├── header.php
│   │   └── footer.php
│   ├── index.php
│   └── single.php
└── assets/
    └── css/style.css

theme.json

{
    "id": "my-blog",
    "name": "我的博客",
    "version": "1.0.0",
    "author": "Your Name",
    "description": "极简博客主题"
}

schema.php

<?php
if (!defined('ZERJIE_ROOT')) die('Access denied.');

return [
    "CREATE TABLE IF NOT EXISTS `{prefix}blog_posts` (
        `id` INT AUTO_INCREMENT PRIMARY KEY,
        `title` VARCHAR(255) NOT NULL,
        `content` LONGTEXT,
        `status` ENUM('draft','published') DEFAULT 'published',
        `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        INDEX `status_created` (`status`, `created_at`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
];

functions.php

<?php
if (!defined('ZERJIE_ROOT')) die('Access denied.');

// 1. 注册模板路径
add_filter('zerjie_theme_template_file', function ($file, $template, $theme) {
    if ($theme !== 'my-blog') return $file;
    $allowed = ['index', 'single', '404'];
    if (!in_array($template, $allowed, true)) return $file;
    $path = __DIR__ . '/templates/' . $template . '.php';
    return file_exists($path) ? $path : $file;
}, 10, 3);

// 2. 自动建表
add_action('zerjie_ready', function () {
    static $done = false;
    if ($done) return;
    $done = true;

    $db = $GLOBALS['zerjie_db'] ?? null;
    if (!$db) return;

    $file = __DIR__ . '/schema.php';
    if (!file_exists($file)) return;

    $tables = include $file;
    $prefix = zerjie_table_prefix();
    foreach ($tables as $sql) {
        $sql = str_replace('{prefix}', $prefix, trim($sql));
        try { $db->exec($sql); } catch (\PDOException $e) {}
    }
}, 5);

// 3. 注册路由
add_action('zerjie_ready', function () {
    global $zerjie_router;
    if (!$zerjie_router) return;

    // 首页
    $zerjie_router->get('/', function () {
        $db = $GLOBALS['zerjie_db'];
        $prefix = zerjie_table_prefix();
        $posts = $db->query("SELECT * FROM `{$prefix}blog_posts` WHERE status = 'published' ORDER BY id DESC LIMIT 20")->fetchAll();
        require __DIR__ . '/templates/index.php';
        exit;
    });

    // 单篇
    $zerjie_router->get('/post/{id:\d+}', function ($id) {
        $db = $GLOBALS['zerjie_db'];
        $prefix = zerjie_table_prefix();
        $stmt = $db->prepare("SELECT * FROM `{$prefix}blog_posts` WHERE id = ? AND status = 'published'");
        $stmt->execute([(int) $id]);
        $post = $stmt->fetch();

        if (!$post) {
            http_response_code(404);
            require __DIR__ . '/templates/404.php';
            exit;
        }

        require __DIR__ . '/templates/single.php';
        exit;
    });
});

插件结构

在 content/plugins/ 下新建目录:

content/plugins/my-plugin/
├── plugin.json          插件元信息
├── my-plugin.php        主文件(名称必须与目录名一致)
├── includes/            辅助 PHP 文件(可选)
└── assets/              静态资源(可选)

plugin.json:

{
    "id": "my-plugin",
    "name": "我的插件",
    "version": "1.0.0",
    "author": "Your Name",
    "description": "一个简单的示例插件"
}

主文件 my-plugin.php:

<?php
/**
 * Plugin Name: 我的插件
 * Description: 一个简单的示例插件
 * Version: 1.0.0
 * Author: Your Name
 */

if (!defined('ZERJIE_ROOT')) die('Access denied.');

// 插件逻辑
add_action('zerjie_ready', function () {
    // 插件初始化
});

加载机制:内核启动时读取 active_plugins option,逐个 require 主文件。

主文件查找顺序:plugin.php → {目录名}.php → index.php。

启用/禁用:

  • 后台 → 插件管理 → 找到插件 → 点击启用/禁用
  • 或 CLI:php console.php plugin:activate my-plugin / plugin:deactivate my-plugin

钩子注册

插件最常用的扩展方式就是在 zerjie_ready 里注册各种钩子。

注册后台菜单

add_action('zerjie_ready', function () {
    add_filter('zerjie_admin_menu', function ($menus) {
        $menus[] = [
            'slug'              => 'my-plugin-page',
            'title'             => '我的插件',
            'icon'              => '🚀',
            'view_capability'   => 'manage_options',
            'manage_capability' => 'manage_options',
            'position'          => 60,
            'callback'          => 'my_plugin_render_page',
        ];
        return $menus;
    }, 20);
});

function my_plugin_render_page() {
    echo '<div style="padding:24px 30px;">';
    echo '<h2>我的插件</h2>';
    echo '</div>';
}

注入后台 CSS / JS

add_action('zerjie_admin_head', function () {
    echo '<link rel="stylesheet" href="/content/plugins/my-plugin/assets/admin.css">';
});

add_action('zerjie_admin_footer', function () {
    $ver = filemtime(__DIR__ . '/assets/admin.js');
    echo '<script src="/content/plugins/my-plugin/assets/admin.js?v=' . $ver . '"></script>';
});

注册设置字段

add_action('zerjie_ready', function () {
    setting_field_register('advanced', 'my_api_key', [
        'label'       => 'API Key',
        'type'        => 'text',
        'default'     => '',
        'description' => '第三方服务的 API 密钥',
    ]);
});

// 读取
$key = get_setting('my_api_key', '');

// 更新
update_setting('my_api_key', 'sk-xxx');

事件监听

插件可以通过事件系统监听内核或其他插件触发的事件:

add_action('zerjie_ready', function () {
    // 监听用户注册事件
    listen('user.registered', function ($e) {
        // 从事件对象取数据
        if ($e instanceof \App\Events\UserRegisteredEvent) {
            // 发送欢迎邮件
            zerjie_send_mail($e->email, '欢迎', '欢迎加入!');
        }
    });

    // 监听订单事件(假设其他插件定义)
    listen('order.paid', 'MyPlugin\OrderPaidListener');
});

完整示例:访问统计插件

目录结构

content/plugins/visit-stats/
├── plugin.json
├── visit-stats.php
└── admin-page.php

plugin.json

{
    "id": "visit-stats",
    "name": "访问统计",
    "version": "1.0.0",
    "description": "记录每天的访问量"
}

visit-stats.php

<?php
if (!defined('ZERJIE_ROOT')) die('Access denied.');

// 建表
add_action('zerjie_ready', function () {
    if (zerjie_get_option('visit_stats_installed') === '1') return;

    $db = $GLOBALS['zerjie_db'];
    $prefix = zerjie_table_prefix();

    $db->exec("CREATE TABLE IF NOT EXISTS `{$prefix}visit_stats` (
        `id` INT AUTO_INCREMENT PRIMARY KEY,
        `date` DATE NOT NULL,
        `count` INT DEFAULT 0,
        `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        UNIQUE KEY `date` (`date`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

    zerjie_update_option('visit_stats_installed', '1');
}, 5);

// 每次前台请求 +1
add_action('zerjie_ready', function () {
    if (defined('ZERJIE_ADMIN_ENTRY') && ZERJIE_ADMIN_ENTRY) return;

    $db = $GLOBALS['zerjie_db'];
    $prefix = zerjie_table_prefix();
    $today = date('Y-m-d');

    $db->prepare("INSERT INTO `{$prefix}visit_stats` (date, count) 
                  VALUES (?, 1) 
                  ON DUPLICATE KEY UPDATE count = count + 1")
       ->execute([$today]);
}, 100);

// 注册后台菜单
add_action('zerjie_ready', function () {
    add_filter('zerjie_admin_menu', function ($menus) {
        $menus[] = [
            'slug'              => 'visit-stats',
            'title'             => '访问统计',
            'icon'              => '📊',
            'view_capability'   => 'manage_options',
            'manage_capability' => 'manage_options',
            'position'          => 60,
            'callback'          => function () {
                require __DIR__ . '/admin-page.php';
            },
        ];
        return $menus;
    }, 20);
});

admin-page.php

<?php
if (!defined('ZERJIE_ROOT')) die('Access denied.');

$db = $GLOBALS['zerjie_db'];
$prefix = zerjie_table_prefix();
$rows = $db->query("SELECT * FROM `{$prefix}visit_stats` ORDER BY date DESC LIMIT 30")->fetchAll();
?>

<div style="padding:24px 30px;">
    <h2>📊 访问统计</h2>
    <table style="width:100%;border-collapse:collapse;">
        <thead>
            <tr style="background:#f8fafc;">
                <th style="padding:12px;text-align:left;">日期</th>
                <th style="padding:12px;text-align:left;">访问量</th>
            </tr>
        </thead>
        <tbody>
            <?php foreach ($rows as $r): ?>
                <tr style="border-top:1px solid #e9edf2;">
                    <td style="padding:12px;"><?= Helper::e($r['date']) ?></td>
                    <td style="padding:12px;"><?= (int) $r['count'] ?></td>
                </tr>
            <?php endforeach; ?>
        </tbody>
    </table>
</div>

注册小工具

小工具文件放在 content/widgets/ 或 core/widgets/,内核自动加载所有 .php。

简单小工具

<?php
// content/widgets/my-widget.php
if (!defined('ZERJIE_ROOT')) die('Access denied.');

add_action('zerjie_ready', function () {
    widget_register(
        'my-widget',              // 唯一 ID
        '我的小工具',              // 显示名称
        function () {             // 渲染回调
            ?>
            <div class="widget-my">
                <h3>我的小工具</h3>
                <p>这是小工具内容</p>
            </div>
            <?php
        },
        [
            'sidebar'  => 'sidebar',   // 默认区域
            'position' => 10,          // 排序位置
        ]
    );
});

参数说明

参数类型说明
$idstring唯一 ID(英文小写、连字符)
$namestring后台显示名称
$callbackcallable渲染回调,输出 HTML
$params['sidebar']string默认放置区域
$params['position']int排序位置(小的在前)
$params['editable']bool是否可编辑(默认 false)
$params['render_edit']callable返回编辑表单 HTML
$params['save_edit']callable处理编辑提交

前端输出小工具

主题模板中通过 the_widgets() 输出指定区域:

<aside class="sidebar">
    <?php the_widgets('sidebar'); ?>
</aside>

<?php if (zerjie_theme_has_widgets('hero')): ?>
    <section class="hero">
        <?php the_widgets('hero'); ?>
    </section>
<?php endif; ?>

内置区域

区域 key说明
hero英雄区(首屏大图)
main主内容区
sidebar侧边栏
unplaced未放置(预留)

主题模板中可以调用 the_widgets('自定义区域') 使用任意区域 key。

可编辑小工具

让小工具在后台可以配置参数。需要额外提供 3 个字段:

  • editable => true:标记可编辑
  • render_edit => callable:返回编辑表单 HTML
  • save_edit => callable:处理表单提交
add_action('zerjie_ready', function () {
    widget_register(
        'my-banner',
        '我的横幅',
        // 前端渲染
        function () {
            $inst = $GLOBALS['current_widget_instance'] ?? [];
            $instance_id = $inst['instance_id'] ?? 'default';
            $data = zerjie_get_option('my_banner_data', []);
            $conf = $data[$instance_id] ?? ['title' => '', 'color' => '#000'];

            echo '<div style="background:' . Helper::e($conf['color']) . ';padding:20px;">';
            echo '<h3>' . Helper::e($conf['title']) . '</h3>';
            echo '</div>';
        },
        [
            'sidebar'  => 'sidebar',
            'position' => 20,
            'editable' => true,

            // 后台编辑表单
            'render_edit' => function ($instance_id) {
                $data = zerjie_get_option('my_banner_data', []);
                $conf = $data[$instance_id] ?? ['title' => '', 'color' => '#000'];
                ob_start();
                ?>
                <div class="form-group">
                    <label>标题</label>
                    <input type="text" name="title" value="<?= Helper::e($conf['title']) ?>">
                </div>
                <div class="form-group">
                    <label>颜色</label>
                    <input type="color" name="color" value="<?= Helper::e($conf['color']) ?>">
                </div>
                <?php
                return ob_get_clean();
            },

            // 保存
            'save_edit' => function ($instance_id, $post_data) {
                $data = zerjie_get_option('my_banner_data', []);
                $data[$instance_id] = [
                    'title' => trim($post_data['title'] ?? ''),
                    'color' => trim($post_data['color'] ?? '#000'),
                ];
                zerjie_update_option('my_banner_data', $data);
                return ['success' => true, 'message' => '已保存'];
            },
        ]
    );
});

完整示例:最新文章小工具

<?php
// content/widgets/latest-posts.php
if (!defined('ZERJIE_ROOT')) die('Access denied.');

add_action('zerjie_ready', function () {
    widget_register(
        'latest-posts',
        '最新文章',
        function () {
            $inst = $GLOBALS['current_widget_instance'] ?? [];
            $instance_id = $inst['instance_id'] ?? 'default';
            $data = zerjie_get_option('latest_posts_data', []);
            $conf = $data[$instance_id] ?? ['limit' => 5, 'show_date' => true];

            $db = $GLOBALS['zerjie_db'];
            $prefix = zerjie_table_prefix();
            $limit = max(1, min(20, (int) $conf['limit']));

            $stmt = $db->query("SELECT id, title, created_at FROM `{$prefix}posts` 
                                WHERE status = 'published' 
                                ORDER BY created_at DESC 
                                LIMIT {$limit}");
            $posts = $stmt->fetchAll();

            echo '<h3 class="widget-title">最新文章</h3>';
            echo '<ul class="widget-post-list">';
            foreach ($posts as $p) {
                echo '<li>';
                echo '<a href="/post/' . (int) $p['id'] . '">' . Helper::e($p['title']) . '</a>';
                if (!empty($conf['show_date'])) {
                    echo ' <time>' . Helper::e($p['created_at']) . '</time>';
                }
                echo '</li>';
            }
            echo '</ul>';
        },
        [
            'sidebar'  => 'sidebar',
            'position' => 5,
            'editable' => true,

            'render_edit' => function ($instance_id) {
                $data = zerjie_get_option('latest_posts_data', []);
                $conf = $data[$instance_id] ?? ['limit' => 5, 'show_date' => true];
                ob_start();
                ?>
                <div class="form-group">
                    <label>显示数量</label>
                    <input type="number" name="limit" 
                           value="<?= (int) $conf['limit'] ?>" min="1" max="20">
                </div>
                <div class="form-group">
                    <label>
                        <input type="checkbox" name="show_date" value="1" 
                               <?= !empty($conf['show_date']) ? 'checked' : '' ?>>
                        显示日期
                    </label>
                </div>
                <?php
                return ob_get_clean();
            },

            'save_edit' => function ($instance_id, $post_data) {
                $data = zerjie_get_option('latest_posts_data', []);
                $data[$instance_id] = [
                    'limit'     => max(1, min(20, (int) ($post_data['limit'] ?? 5))),
                    'show_date' => !empty($post_data['show_date']),
                ];
                zerjie_update_option('latest_posts_data', $data);
                return ['success' => true, 'message' => '已保存'];
            },
        ]
    );
});

注册后台菜单

两种方式:函数式(推荐,简洁)和 filter 式(可覆盖已有菜单)。

方式 1:函数式

add_action('zerjie_ready', function () {
    admin_menu_register(
        '我的页面',                   // 菜单标题
        function () {                 // 回调(渲染页面)
            echo '<div style="padding:24px 30px;">';
            echo '<h2>我的页面</h2>';
            echo '<p>这里是页面内容</p>';
            echo '</div>';
        },
        '🚀',                         // 图标
        60,                           // 位置(越小越靠前)
        'manage_options'              // 所需权限
    );
}, 20);

方式 2:filter 式

add_filter('zerjie_admin_menu', function ($menus) {
    $menus[] = [
        'slug'              => 'my-page',
        'title'             => '我的页面',
        'icon'              => '🚀',
        'view_capability'   => 'view_my_page',
        'manage_capability' => 'manage_my_page',
        'position'          => 60,
        'callback'          => function () {
            require __DIR__ . '/admin/page.php';
        },
    ];
    return $menus;
}, 20);

菜单项字段说明

字段说明
slug唯一标识,URL 会带 ?action=slug
title菜单显示名称
iconEmoji 图标
view_capability能否看见此菜单(默认 read)
manage_capability能否执行管理操作(默认 manage_options)
position排序位置(小的靠前)
callback渲染回调(可传函数名或闭包)
children子菜单数组(可选)

子菜单

$menus[] = [
    'slug'    => 'parent-page',
    'title'   => '父菜单',
    'icon'    => '📁',
    'position'=> 50,
    'view_capability' => 'read',
    'children'=> [
        [
            'slug'              => 'child-page-1',
            'title'             => '子页面 1',
            'view_capability'   => 'view_child1',
            'manage_capability' => 'manage_child1',
        ],
        [
            'slug'              => 'child-page-2',
            'title'             => '子页面 2',
            'view_capability'   => 'view_child2',
            'manage_capability' => 'manage_child2',
        ],
    ],
];

权限名称会自动出现在 后台 → 用户 → 权限 页面供管理员分配。

注册设置字段

让插件的配置出现在后台「系统设置」页中。

add_action('zerjie_ready', function () {
    // 文本字段
    setting_field_register('advanced', 'my_api_key', [
        'label'       => 'API Key',
        'type'        => 'text',
        'default'     => '',
        'description' => '第三方服务的 API 密钥',
    ]);

    // 复选框
    setting_field_register('advanced', 'my_enabled', [
        'label'   => '启用功能',
        'type'    => 'checkbox',
        'default' => '1',
    ]);

    // 下拉框
    setting_field_register('advanced', 'my_mode', [
        'label'   => '模式',
        'type'    => 'select',
        'default' => 'auto',
        'options' => [
            'auto'   => '自动',
            'manual' => '手动',
        ],
    ]);

    // 文本域
    setting_field_register('advanced', 'my_template', [
        'label'   => '模板',
        'type'    => 'textarea',
        'default' => '默认内容',
    ]);

    // 数字
    setting_field_register('advanced', 'my_limit', [
        'label'   => '数量上限',
        'type'    => 'number',
        'default' => '10',
        'min'     => 1,
        'max'     => 100,
    ]);
}, 10);

读写设置值

// 读取(自动加 'setting_' 前缀)
$apiKey = get_setting('my_api_key', '');
$enabled = get_setting('my_enabled', '0') === '1';
$mode   = get_setting('my_mode', 'auto');

// 更新
update_setting('my_api_key', 'sk-xxxxxxxx');
update_setting('my_enabled', '1');

支持的字段类型:text、email、textarea、select、checkbox、number、password。

注入 CSS / JS

后台注入

// 注入到后台 <head>
add_action('zerjie_admin_head', function () {
    echo '<link rel="stylesheet" href="/content/plugins/my-plugin/assets/admin.css">';
});

// 注入到后台 </body> 前
add_action('zerjie_admin_footer', function () {
    $ver = filemtime(__DIR__ . '/assets/admin.js');
    echo '<script src="/content/plugins/my-plugin/assets/admin.js?v=' . $ver . '"></script>';
});

// 仅在小工具管理页注入
add_action('zerjie_admin_footer', function () {
    if (($_GET['action'] ?? '') !== 'widget-manager') return;
    echo '<script src="/content/plugins/my-plugin/assets/widgets.js"></script>';
});

可用的后台钩子

钩子类型说明
zerjie_admin_headAction后台 <head> 输出
zerjie_admin_footerAction后台 </body> 前输出
zerjie_admin_topbarAction顶栏输出
zerjie_admin_footer_beforeAction页脚前
zerjie_admin_footer_afterAction页脚后
zerjie_admin_footer_textFilter页脚文字
zerjie_admin_page_content_{action}Filter替换某个后台页面内容
zerjie_admin_layout_fileFilter替换后台整体布局
zerjie_admin_login_fileFilter替换后台登录页
zerjie_admin_header_fileFilter自定义后台顶部文件
zerjie_admin_footer_fileFilter自定义后台底部文件
zerjie_dashboard_widgetsFilter仪表盘小部件

配置读写

所有配置存储在 {prefix}options 表。读取时会缓存到内存,同一请求内多次读取零成本。

读取配置

// 读取(不存在时返回默认值)
$title = zerjie_get_option('site_title', 'Zerjie');
$color = zerjie_get_option('theme_primary_color', '#2563eb');

// 读取数组(自动反序列化)
$settings = zerjie_get_option('my_plugin_settings', []);
if (!empty($settings['enabled'])) {
    // ...
}

写入配置

// 写入字符串
zerjie_update_option('site_title', '我的网站');

// 存储数组(自动序列化)
zerjie_update_option('my_plugin_settings', [
    'enabled' => true,
    'color'   => '#2563eb',
    'items'   => [1, 2, 3],
]);

// 存储数字
zerjie_update_option('my_counter', 42);

autoload 白名单

只有下列 key 会被预加载(安装/首次请求时一次性读入内存),其他 key 首次读取时才查库:

site_title, site_url, active_plugins, nav_menu_primary,
role_caps, admin_allowed_roles, site_lock_enabled,
widget_placements, allow_registration

如需频繁读取自定义配置,可在 core/helpers/functions.php 的 $autoloadKeys 数组中添加 key。

数据库操作

方式一:辅助函数(推荐)

自动加表前缀,避免 SQL 拼接错误。

查询:zerjie_db_query()

// 签名:
// zerjie_db_query($table, $where = [], $select = ['*'], $order = [], $limit = null)

$users = zerjie_db_query(
    'users',                       // 表名(不含前缀)
    ['role' => 'admin'],           // WHERE(等值匹配,AND 连接)
    ['id', 'username', 'email'],   // SELECT
    ['id' => 'DESC'],              // ORDER BY
    10                             // LIMIT
);

// 只查所有
$all = zerjie_db_query('users');

// 只查指定字段
$names = zerjie_db_query('users', [], ['username']);

// 带排序
$recent = zerjie_db_query('users', [], ['*'], ['registered_at' => 'DESC']);

// 带限制
$top10 = zerjie_db_query('users', [], ['*'], ['login_count' => 'DESC'], 10);

插入:zerjie_db_insert()

// 签名:zerjie_db_insert($table, $data) → 返回自增 ID

$userId = zerjie_db_insert('users', [
    'username' => 'alice',
    'email'    => 'alice@example.com',
    'password' => password_hash('secret', PASSWORD_DEFAULT),
    'role'     => 'user',
    'status'   => 'active',
]);

更新:zerjie_db_update()

// 签名:zerjie_db_update($table, $data, $where) → 返回影响行数

$affected = zerjie_db_update(
    'users',
    ['status' => 'banned'],        // SET
    ['id' => 5]                    // WHERE
);

删除:zerjie_db_delete()

// 签名:zerjie_db_delete($table, $where) → 返回影响行数

$affected = zerjie_db_delete('logs', ['id' => 100]);

方式二:原生 PDO(复杂查询)

复杂查询(JOIN、子查询、聚合等)推荐直接用 PDO。

$db = $GLOBALS['zerjie_db'];
$prefix = zerjie_table_prefix();

$stmt = $db->prepare("
    SELECT u.username, COUNT(p.id) AS post_count
    FROM `{$prefix}users` u
    LEFT JOIN `{$prefix}posts` p ON p.author_id = u.id
    WHERE u.status = ?
    GROUP BY u.id
    HAVING post_count > ?
    ORDER BY post_count DESC
    LIMIT 10
");
$stmt->execute(['active', 5]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

方式三:QueryBuilder(流式)

$db = $GLOBALS['zerjie_db'];
$prefix = zerjie_table_prefix();
$qb = new \Zerjie\Database\QueryBuilder($db, 'users');

// 流式查询
$users = $qb->select(['id', 'username', 'email'])
           ->where('role', '=', 'admin')
           ->where('status', '=', 'active')
           ->orderBy('id', 'DESC')
           ->limit(10)
           ->get();

// 获取单条
$user = $qb->where('id', '=', 5)->first();

// 分页
$page = $qb->paginate(20, 1);
// → [
//     'items'        => [...],
//     'total'        => 100,
//     'per_page'     => 20,
//     'current_page' => 1,
//     'last_page'    => 5,
// ]

// 插入
$qb->insert(['username' => 'bob', 'email' => 'bob@example.com']);

// 更新(必须带 WHERE)
$qb->where('id', '=', 5)->update(['status' => 'banned']);

// 删除(必须带 WHERE)
$qb->where('id', '=', 5)->delete();

// 计数
$count = $qb->count();

// 是否存在
$exists = $qb->where('username', '=', 'alice')->exists();

事务

$db = $GLOBALS['zerjie_db'];

try {
    $db->beginTransaction();

    $db->prepare("UPDATE `{$prefix}users` SET balance = balance - ? WHERE id = ?")
       ->execute([100, $fromId]);
    $db->prepare("UPDATE `{$prefix}users` SET balance = balance + ? WHERE id = ?")
       ->execute([100, $toId]);

    $db->commit();
} catch (\PDOException $e) {
    if ($db->inTransaction()) {
        $db->rollBack();
    }
    throw $e;
}
安全提示:永远不要拼接用户输入到 SQL。所有用户输入用 ? 占位符或 bindValue() 传递。

用户与权限

登录状态

// 是否登录
if (zerjie_is_logged_in()) {
    // 已登录
}

// 获取当前用户(未登录返回 null)
$user = zerjie_get_current_user();
// [
//   'id'       => 1,
//   'username' => 'admin',
//   'email'    => 'admin@example.com',
//   'role'     => 'super_admin',
//   'status'   => 'active',
//   'last_ip'  => '127.0.0.1',
//   'last_user_agent' => 'Mozilla/5.0 ...',
//   'last_country'    => 'China',
//   ...
// ]

登录 / 登出

// 登录:成功返回 true,失败返回 false
$success = zerjie_login($username, $password);

// 失败时读取具体原因
if (!$success) {
    $reason = $GLOBALS['zerjie_login_error'];
    // 'user_not_found'   账号不存在
    // 'invalid_password' 密码错误
    // 'account_pending'  账号待激活
    // 'account_banned'   账号被封禁
}

// 登出
zerjie_logout();

登录机制

登录成功后,内核会:

  1. 生成 64 位十六进制令牌(32 字节随机数)
  2. 用 SHA-256 哈希后存储到 users.session_token 字段
  3. 把原始令牌存入 $_SESSION['session_token']
  4. 更新 last_login、login_count、last_ip、last_user_agent、last_country
  5. 调用 session_regenerate_id(true) 防会话固定
  6. 轮换 CSRF 令牌

每次请求 zerjie_get_current_user() 会校验:

  • Session 中的 user_id 是否存在
  • Session 中的 session_token 的 SHA-256 是否等于数据库中的值

不一致 → 强制下线(比如在另一台设备登录)。

角色

角色说明
super_admin超级管理员,拥有所有权限(强制)
admin管理员,权限由 role_caps 配置
user普通用户,权限由 role_caps 配置

角色常量:ROLE_GUEST、ROLE_USER、ROLE_ADMIN、ROLE_SUPER_ADMIN。

权限检查

if (zerjie_current_user_can('manage_options')) {
    // 有权限
}

// 常用权限名:
// read, view_users, manage_users,
// view_settings, manage_settings,
// manage_themes, manage_plugins, manage_widgets,
// manage_taxonomy, manage_nav_menu,
// view_cli, manage_cli, manage_options

登录失败限制

// 检查是否被锁
$check = zerjie_login_attempt_check($identifier);
// → [
//     'locked'    => bool,    是否被锁
//     'remaining' => int,     剩余锁定秒数
//     'count'     => int,     已失败次数
//     'max'       => int,     配置的最大次数
// ]

if ($check['locked']) {
    $minutes = ceil($check['remaining'] / 60);
    die("账号已锁定,请 {$minutes} 分钟后再试");
}

// 记录一次失败
zerjie_login_attempt_record($identifier);

// 登录成功时重置计数
zerjie_login_attempt_reset($identifier);

相关配置(后台 → 系统 → 登录与注册):

  • login_max_attempts:最大失败次数(默认 5)
  • login_attempt_window:统计窗口秒数(默认 900)

刷新当前用户缓存

// 修改用户资料后,让缓存失效
zerjie_flush_user_cache();

通常不需要手动调用(内核在登录/登出/修改密码时自动触发)。

用户注册

推荐用统一 API zerjie_register_user(),会自动读取后台的注册开关、默认角色、IP 限制等配置。

最简用法

$result = zerjie_register_user($username, $password, $email);

if ($result['success']) {
    $userId = $result['user_id'];
    $status = $result['status'];   // 'active' | 'pending'
    $meta   = $result['meta'];

    if (!empty($meta['auto_logged_in'])) {
        // 注册后已自动登录
    } elseif (!empty($meta['activation_email_sent'])) {
        // 已发送激活邮件
    } elseif (!empty($meta['requires_activation'])) {
        // 等待管理员审核
    }
} else {
    $errors = $result['errors'];
    // ['username' => '用户名已存在', 'email' => '邮箱格式不正确']
}

完整参数

$result = zerjie_register_user($username, $password, $email, [
    'role'            => 'user',       // 覆盖默认角色('user' 或 'admin')
    'status'          => 'active',     // 覆盖默认状态('active'/'pending'/'banned')
    'display_name'    => 'Alice',      // 显示名称
    'skip_validation' => false,        // 是否跳过内置校验
    'allow_open'      => true,         // 是否检查"开放注册"开关
    'meta'            => ['source' => 'web'],  // 附加数据
]);

注册相关配置

后台 → 系统 → 登录与注册 页面可配置:

配置 key说明
allow_registration是否开放注册('1'/'0')
default_role默认角色(user / admin)
register_status默认状态(active / pending)
username_min_length用户名最小长度(默认 5)
password_min_length密码最小长度(默认 6)
password_require_complexity密码复杂度要求(数组)
email_activation是否邮件激活('1'/'0')
auto_login_after_register注册后是否自动登录
register_ip_limit_count同 IP 最大注册数(0 = 不限)
register_ip_limit_window统计窗口(秒)

激活流程

// 生成激活令牌(24 小时有效)
$token = zerjie_generate_activation_token($userId, 86400);

// 构建激活链接
$link = zerjie_build_activation_link($token);
// → https://your-site.com/activate?token=xxx

// 发送激活邮件
zerjie_send_activation_email($userId, $username, $email);

// 用户点击后,在 /activate 路由里:
$result = zerjie_activate_user_by_token($token);
if ($result['success']) {
    // 激活成功
} else {
    echo $result['error'];
}

会话与 Flash

会话操作

// 设置
zerjie_session_set('user_pref', 'dark');

// 读取
$pref = zerjie_session_get('user_pref', 'light');

// 判断存在
if (zerjie_session_has('user_pref')) {
    // ...
}

// 删除
zerjie_session_delete('user_pref');

// 销毁(清空所有数据 + 重新生成 ID)
zerjie_session_destroy();

底层 Session 类

use Zerjie\Session\Session;

// 启动会话(内核在 Web 请求已自动调用)
Session::start([
    'cookie_httponly' => 1,
    'cookie_secure'   => 0,
    'cookie_samesite' => 'Lax',
    'use_strict_mode' => 1,
]);

// 其他方法与上面的辅助函数等价
Session::set($key, $value);
Session::get($key, $default);
Session::has($key);
Session::delete($key);
Session::clear();
Session::getId();
Session::regenerateId(true);

Flash 消息(一次性)

适合"操作成功 → 重定向 → 显示提示"的场景。读取后立即自动删除。

// 设置
flash_set('message', '操作成功');
flash_success('保存成功');
flash_error('保存失败');

// 读取(读取后立即删除)
$msg = flash_get('message');
$msg = flash_get('success');
$msg = flash_get('error');

// 判断存在(不删除)
if (Flash::has('message')) {
    // ...
}

路由与 URL

注册路由

add_action('zerjie_ready', function () {
    global $zerjie_router;

    // 静态路径
    $zerjie_router->get('/about', function () {
        echo 'About Us';
    });

    // 带数字参数
    $zerjie_router->get('/post/{id:\d+}', function ($id) {
        echo "Post #{$id}";
    });

    // 带 slug 参数(默认 [^/]+)
    $zerjie_router->get('/category/{slug}', function ($slug) {
        echo "Category: {$slug}";
    });

    // POST
    $zerjie_router->post('/contact', function () {
        // 处理表单
    });

    // PUT / DELETE
    $zerjie_router->put('/post/{id}', function ($id) { /* ... */ });
    $zerjie_router->delete('/post/{id}', function ($id) { /* ... */ });

    // 任意 HTTP 方法
    $zerjie_router->any('/api/data', function () {
        header('Content-Type: application/json');
        echo json_encode(['ok' => true]);
    });

    // 分组
    $zerjie_router->group('/api/v1', function ($router) {
        $router->get('/users', function () { /* GET /api/v1/users */ });
        $router->post('/users', function () { /* POST /api/v1/users */ });
    });
});

命名路由 + URL 生成

// 注册时命名
$zerjie_router->get('/post/{id}', function ($id) { /* ... */ })->name('post');

// 生成 URL
$url = route('post', ['id' => 123]);   // → /post/123

参数约束

写法匹配
{id}除斜杠外任意字符
{id:\d+}仅数字
{name:[a-zA-Z0-9_]+}字母、数字、下划线
{slug:[a-z0-9-]+}小写字母、数字、连字符

Dispatcher 的行为

请求进来后,Dispatcher 会:

  1. 剥离 query string(strtok($uri, '?'))
  2. 规范化 URI(去尾部斜杠)
  3. 按注册顺序匹配路由(ANY 优先匹配任意方法)
  4. 把命名参数通过反射注入到回调
  5. 自动识别 Request 类型参数,注入当前请求对象
  6. 执行中间件栈(洋葱模型)
  7. 如果返回 Response 对象则自动 send

回调参数自动注入

// 路由参数自动注入
$zerjie_router->get('/post/{id:\d+}/{slug}', function ($id, $slug) {
    // $id 和 $slug 是路由匹配到的值
});

// Request 对象自动注入
use Zerjie\Http\Request;

$zerjie_router->post('/submit', function (Request $req) {
    $name = $req->input('name');
    $ua   = $req->header('User-Agent');
    $ip   = $req->ip();
});

缓存

基础用法

// 写入(TTL 秒)
cache()->set('homepage_data', $data, 3600);   // 1 小时

// 读取
$data = cache()->get('homepage_data');

// 判断存在
if (cache()->has('homepage_data')) {
    // ...
}

// 删除
cache()->delete('homepage_data');

// 清空全部
cache()->clear();

remember 模式

// 不存在时才执行回调并缓存结果
$expensive = cache()->remember('expensive_key', function () {
    return expensive_computation();   // 只在缓存失效时执行
}, 3600);

多驱动

驱动说明适用场景
file文件缓存(默认)单机、共享主机
memory内存缓存(请求周期内)单次请求内的重复计算
redisRedis 缓存多机集群、高并发
// 使用指定的驱动
$redisCache = new \Zerjie\Cache\CacheManager('redis');
$redisCache->set('key', 'value', 3600);

// 或者
use Zerjie\Cache\CacheManager;
$manager = new CacheManager('file');
$manager->driver('redis')->set('key', 'value', 3600);

缓存的存储位置

  • FileCache:storage/cache/{md5(key)}.cache
  • MemoryCache:静态数组(请求结束即销毁)
  • RedisCache:Redis 服务器

日志

基础用法

// 常规记录
logger()->info('用户登录成功', ['user_id' => 5]);
logger()->warning('磁盘空间不足');
logger()->error('数据库连接失败', [
    'message' => $e->getMessage(),
    'file'    => $e->getFile(),
    'line'    => $e->getLine(),
]);

所有日志级别

方法级别说明
debug($msg, $context)debug调试信息
info($msg, $context)info常规信息
notice($msg, $context)notice值得注意
warning($msg, $context)warning警告
error($msg, $context)error错误
critical($msg, $context)critical严重错误
alert($msg, $context)alert必须立即处理
emergency($msg, $context)emergency系统不可用

多 channel

Channel输出位置
filestorage/logs/YYYY-MM-DD.log
database{prefix}logs 表
// 指定 channel
logger()->channel('database')->error('落库失败', ['table' => 'orders']);

// 默认 channel 在 core/zerjie.php 中初始化:
$logger = new \Zerjie\Log\Logger('file');

日志文件保留

FileChannel 默认保留最近 30 个按天分割的文件,超出后自动清理最旧的。

邮件

基于 SMTP 发送。后台 → 系统 → SMTP 页面配置发信参数。

基础用法

$result = zerjie_send_mail(
    'user@example.com',
    '欢迎加入',
    '这是邮件的正文内容'
);

if ($result['success']) {
    echo '发送成功';
} else {
    echo '发送失败:' . $result['message'];
}

HTML 邮件 + 抄送

$result = zerjie_send_mail(
    ['to1@example.com', 'to2@example.com'],   // 收件人(数组)
    '每周报告',
    '<h1>本周数据</h1><p>访问量:1234</p>',
    [
        'is_html'  => true,                    // HTML 格式(默认 true)
        'cc'       => ['cc@example.com'],      // 抄送
        'bcc'      => ['bcc@example.com'],     // 密送
        'reply_to' => 'reply@example.com',     // 回复地址
    ]
);

SMTP 配置

配置 key说明默认
smtp_hostSMTP 服务器地址''
smtp_port端口465
smtp_encryptionssl / tls / nonessl
smtp_auth是否启用认证'1'
smtp_usernameSMTP 用户名''
smtp_passwordSMTP 密码''
from_email发件人邮箱''
from_name发件人名称'Zerjie'

安全细节

  • 邮件头(Subject / From Name)自动过滤 CRLF,防注入
  • 非 ASCII 字符自动编码为 =?UTF-8?B?...?=
  • HTML 邮件自动生成纯文本替代版本(multipart/alternative)
  • 所有内容用 base64 编码,避免行长度问题

队列

基于文件系统的轻量队列,适合异步任务(发邮件、生成缩略图、同步数据)。

定义任务

namespace MyPlugin\Jobs;

use Zerjie\Queue\Job;

class SendWelcomeEmail extends Job {
    public function handle($payload) {
        $userId = (int) ($payload['user_id'] ?? 0);
        $email  = (string) ($payload['email'] ?? '');

        // 执行任务
        zerjie_send_mail($email, '欢迎', '欢迎加入!');

        // 返回 false 表示失败(会重试)
        return true;
    }
}

推送任务

use Zerjie\Queue\Queue;

$queue = new Queue('default');

// 立即执行
$queue->push('MyPlugin\Jobs\SendWelcomeEmail', [
    'user_id' => 123,
    'email'   => 'alice@example.com',
]);

// 延迟 60 秒执行
$queue->later(60, 'MyPlugin\Jobs\SendWelcomeEmail', [
    'user_id' => 123,
    'email'   => 'alice@example.com',
]);

处理任务

# 处理 1 个任务后退出
php console.php queue:work

# 处理 100 个任务
php console.php queue:work default 100

# 查看队列长度
php console.php queue:size

Worker 机制

  • 认领机制:用 rename 原子操作把 .job 文件从 queue/ 移到 processing/,防止多 Worker 抢同一任务
  • 心跳:Worker 每 30 秒 touch 处理中的文件(PCNTL 支持时)
  • 重试:任务失败自动重试(默认 3 次,间隔 5 秒)
  • 失败归档:超过重试次数后移到 failed/ 目录
  • 陈旧清理:启动时清理超过 30 分钟未完成的 processing 文件
  • 信号处理:收到 SIGTERM / SIGINT 时优雅退出(等当前任务完成)

队列目录结构

storage/queue/{queueName}/
├── *.job                 待处理任务
├── delayed/              延迟任务
├── processing/           处理中任务
└── failed/               失败归档

自定义 Worker 参数

use Zerjie\Queue\Worker;

$worker = new Worker('default');
$worker->setMaxAttempts(5)      // 最大重试次数
       ->setRetryDelay(10)      // 重试间隔秒数
       ->setSleepSeconds(5)     // 空闲时轮询间隔
       ->setHeartbeatInterval(30) // 心跳间隔
       ->setStaleTimeout(1800); // 陈旧任务超时

$processed = $worker->work(0);  // 0 = 无限循环

多语言

基础用法

// 返回翻译文本(未找到时返回原文)
$text = __('Hello World');

// 带占位符
$text = __('Welcome, {name}!', ['name' => 'Alice']);

// 直接输出
_e('Save Settings');

语言文件位置

语言包按优先级升序加载,后面的覆盖前面的:

  1. adminbackend/languages/{locale}.php(系统语言包,跟随系统更新)
  2. content/languages/{locale}.php(用户自定义,优先级更高)

可以通过 filter 追加自定义目录:

add_filter('zerjie_language_paths', function ($paths) {
    $paths[] = ZERJIE_ROOT . '/my-lang';
    return $paths;
});

语言文件格式

<?php
// content/languages/zh-CN.php
return [
    'Hello World'                  => '你好,世界',
    'Welcome, {name}!'             => '欢迎,{name}!',
    'Save Settings'                => '保存设置',
    'Password must be at least {n} characters.' => '密码至少需要 {n} 个字符。',
];

语言切换

// 设置当前语言
set_language('zh-CN');
set_language('en-US');

// 获取当前语言
$locale = get_language();   // 'zh-CN'

// 获取所有可用语言
$locales = \Zerjie\I18n\Language::getAvailableLocales();
// ['zh-CN', 'en-US', 'ja-JP', ...]

语言检测优先级

  1. $_SESSION['user_language'](用户在后台切换过)
  2. 浏览器 Accept-Language 头(匹配到存在的语言包)
  3. 系统默认语言(config/system.php 的 default_language)

语言回退规则

  1. 精确匹配当前 locale(如 en-US.php)
  2. 父语言(如 en.php)
  3. 默认语言的精确匹配(如 zh-CN.php)
  4. 默认语言的父语言(如 zh.php)
  5. 空数组(翻译函数返回原文)

CSRF 防护

表单中输出隐藏字段

<form method="post">
    <?= csrf_field() ?>
    <input type="text" name="title">
    <button>提交</button>
</form>

会输出:<input type="hidden" name="_csrf" value="...">

服务端校验

// 自动从 POST _csrf 字段或 X-CSRF-Token 头提取
if (!csrf_check()) {
    die('CSRF 校验失败');
}

// 手动指定 token
if (!csrf_verify($_POST['_csrf'] ?? '')) {
    die('CSRF 校验失败');
}

AJAX 请求头

// 方法 1:使用全局 JS 配置
fetch('/api/endpoint', {
    method: 'POST',
    headers: {
        'X-CSRF-Token': window.ZJ.cfg.csrf,
        'Content-Type': 'application/json',
    },
    body: JSON.stringify({ ... }),
});

// 方法 2:手动取值
const token = document.querySelector('meta[name="csrf-token"]').content;

CSRF 类 API

方法说明
Csrf::getToken()获取当前令牌(无则生成)
Csrf::generateToken()强制生成新令牌
Csrf::verifyToken($token)校验指定令牌
Csrf::clearToken()删除当前令牌
Csrf::rotate()清空 + 生成新令牌
Csrf::hiddenField()输出隐藏字段 HTML
Csrf::metaField()输出 <meta name="csrf-token">
Csrf::extractFromRequest()从请求中提取令牌
登录时自动轮换:zerjie_login() 成功后会调用 Csrf::rotate(),防止会话固定攻击。

XSS 防护

输出转义(最常用)

<?= Helper::e($userInput) ?>
<?= zerjie_escape_html($html) ?>
// 两者等价,内部都是 htmlspecialchars(ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')

富文本过滤

// 保留安全的 HTML 标签
$clean = zerjie_xss_filter($html, ['p', 'br', 'strong', 'a']);

zerjie_xss_filter() 使用 DOMDocument 逐节点清理:

  • 白名单外的标签直接移除
  • 白名单外的属性直接移除
  • URL 属性(href / src)检查 scheme(仅允许 http / https / mailto / tel / ftp)
  • data: 仅允许 image/gif / image/png / image/jpeg / image/webp
  • srcset 逐个 URL 校验
  • target="_blank" 自动追加 rel="noopener noreferrer"
  • style 里的 expression() / javascript: / @import 等全部剔除

默认白名单

// 允许的标签
p, br, strong, b, em, i, u, s, del, ins,
h1-h6, ul, ol, li, a, img, figure, figcaption,
blockquote, cite, q, code, pre, kbd, samp,
table, thead, tbody, tfoot, tr, th, td,
span, div, hr, sub, sup

// 允许的属性
href, title, alt, src, srcset,
class, id, target, rel,
width, height, colspan, rowspan

输入验证与清理

通用清理

// 去除首尾空白、删除 HTML 标签
$clean = zerjie_sanitize_input($_POST['comment']);

// 数组会自动递归清理
$data = zerjie_sanitize_input($_POST);

验证函数

函数说明返回
zerjie_validate_email($email)验证邮箱bool
zerjie_validate_url($url)验证 URLbool
zerjie_validate_int($value)验证整数bool
Sanitizer::validateFloat($v)验证浮点数bool
Sanitizer::validateBool($v)验证布尔bool / null
Sanitizer::validatePhone($p)验证中国大陆手机号bool
Sanitizer::validateIdCard($id)验证中国大陆身份证bool

Sanitizer 类

use Zerjie\Security\Sanitizer;

// 通用清理
$clean = Sanitizer::sanitize($input);

// 各种验证(除 zerjie_* 包裹函数外都直接用)
Sanitizer::validateEmail('a@b.com');
Sanitizer::validateUrl('https://example.com');
Sanitizer::validateInt('123');
Sanitizer::validateFloat('1.23');
Sanitizer::validateBool('true');
Sanitizer::validatePhone('13812345678');
Sanitizer::validateIdCard('110101199001011234');

文件上传安全

校验上传文件

$result = zerjie_validate_upload(
    $_FILES['file'],
    ['jpg', 'png', 'gif'],                    // 允许扩展名
    ['image/jpeg', 'image/png', 'image/gif'], // 允许 MIME
    5 * 1024 * 1024                           // 5MB 上限
);

if (!$result['valid']) {
    die($result['error']);
}

UploadGuard::validate() 会依次检查:

  1. PHP 上传错误码
  2. 文件大小是否超限
  3. 扩展名是否在白名单
  4. ZIP 文件额外做安全检查(防压缩炸弹)
  5. MIME 类型是否匹配(用 finfo 或 mime_content_type 探测)
  6. 图片文件额外用 getimagesize() 验证

生成安全文件名

$safeName = zerjie_safe_filename($_FILES['file']['name'], 'upload_');
// → upload_66f3a1b2c4d5e.jpg
// 扩展名经过白名单过滤,主名是 uniqid()

完整上传流程

if ($_FILES['file']['error'] === UPLOAD_ERR_OK) {
    // 1. 校验
    $check = zerjie_validate_upload(
        $_FILES['file'],
        ['jpg', 'png'],
        ['image/jpeg', 'image/png'],
        2097152   // 2MB
    );
    if (!$check['valid']) {
        die($check['error']);
    }

    // 2. 生成安全文件名
    $safeName = zerjie_safe_filename($_FILES['file']['name'], 'user_');

    // 3. 移动到目标目录
    $target = ZERJIE_CONTENT_DIR . '/uploads/' . $safeName;
    if (!is_dir(dirname($target))) {
        mkdir(dirname($target), 0755, true);
    }
    move_uploaded_file($_FILES['file']['tmp_name'], $target);
}

ZIP 安全解压

try {
    zerjie_safe_extract_zip('/path/to/file.zip', '/path/to/dest');
} catch (Exception $e) {
    // 检测到路径遍历或符号链接
    die($e->getMessage());
}

zerjie_safe_extract_zip() 含四道防线:

  1. 路径规范化:统一分隔符、去首尾斜杠、去 ./
  2. 禁止路径遍历:检测 ../ 片段
  3. 禁止符号链接:读 ZIP 外部属性,检测 symlink
  4. 目标目录前缀校验:解压后每个文件必须在目标目录内

防火墙与限流

IP 黑名单

// 检查当前 IP 是否被封禁
if (zerjie_is_ip_blocked()) {
    die('您的 IP 已被封禁');
}

// 手动封禁指定 IP
zerjie_block_ip('1.2.3.4');

// 解封
zerjie_unblock_ip('1.2.3.4');

// 黑名单存储位置
// {prefix}options 表的 firewall_blacklist 键(数组)

频率限制

// 签名:zerjie_rate_limit($key = null, $limit = 60, $window = 60)
// $key 为 null 时按 "IP:URI" 组合限流

// 60 秒内最多 60 次
if (!zerjie_rate_limit(null, 60, 60)) {
    http_response_code(429);
    header('Retry-After: 60');
    die('请求过于频繁');
}

// 自定义 key
if (!zerjie_rate_limit('login:' . $ip, 5, 300)) {
    // 300 秒内同一 IP 最多登录 5 次
}

驱动优先级

  1. APCu(如果启用):内存计数,最快
  2. 文件锁(兜底):storage/cache/ratelimit/{key}.lock,用 flock + ftruncate 保证原子性

清理限流文件

use Zerjie\Security\Firewall;

// 清理超过 1 小时未更新的限流文件
$count = Firewall::cleanupRateLimitFiles(3600);

防御响应系统

完整的分级防御系统,由 Zerjie\Security\Defense 提供。包含:

  • 全站请求量分级告警与响应(L1~L5)
  • 单 IP 高频访问自动封禁
  • 敏感路径扫描检测(.env / .git / wp-admin 等)
  • 邮件通知

自动启用

内核在 zerjie_ready 之后自动调用 Defense::enforce(),无需手动启用。

配置

后台 → 防御响应 → 防御设置 页面可视化配置。配置存储于 {prefix}options 表的 defense_config 键。

核心方法

方法说明
Defense::enforce()执行防御检查(内核自动调用)
Defense::getStatus()返回当前防御状态
Defense::getConfig()返回当前配置
Defense::saveConfig($config)保存配置
Defense::banIp($ip, $forcePermanent)封禁 IP
Defense::unbanIp($ip, $resetCount)解封 IP
Defense::listBannedIps()列出所有生效的封禁
Defense::isBanned($ip)检查 IP 是否被封
Defense::clear()清除全站防御等级
Defense::setLevel($level, $reason, $duration)手动设置防御等级
Defense::cleanupSlots($keepMinutes)清理旧的计数器行
Defense::cleanupExpiredBans($days)清理过期的临时封禁

分级说明

等级默认阈值作用范围响应
L160s / 5000 次全站仅记录
L260s / 10000 次全站仅记录
L360s / 50000 次写操作503,持续 60s
L4300s / 100000 次敏感路径503,持续 300s
L5300s / 500000 次全站503,需手动解除

阈值与范围可调

通过后台 → 防御响应 → 防御设置 可调整:

  • 每级阈值(窗口内的请求数)
  • 窗口秒数
  • 持续时间(0 = 永久,需手动解除)
  • 作用范围:all(全部请求)/ write(仅写操作)/ sensitive(仅敏感路径)

单 IP 自动黑名单

当单个 IP 在窗口内超过阈值,自动临时封禁。累计封禁次数达到阈值后永久封禁。

敏感路径扫描

内置一批正则匹配常见攻击路径:

/.env            配置文件泄露
/.git           版本控制
/wp-admin       后台探测
/phpmyadmin     PMA 探测
*.sql           备份文件
/composer.json  依赖清单
/Dockerfile     容器配置
CLAUDE.md       AI 工具配置(2024+ 新型探测)
...

5 分钟内同一 IP 命中 N 次(默认 5 次)自动封禁。可通过 filter 调整:

add_filter('zerjie_defense_suspicious_patterns', function ($patterns) {
    $patterns[] = '#/my-secret-path#i';
    return $patterns;
});

add_filter('zerjie_defense_scan_threshold', function () {
    return 3;   // 5 分钟内 3 次就封
});

邮件通知

可配置防御事件发生时给指定用户发邮件。后台 → 防御响应 → 防御设置 → 邮件通知。

订阅事件:

  • ban_temp 临时封禁
  • ban_perm 永久封禁
  • unban 解封
  • level_alert 等级告警(L3+ 触发)
  • manual_clear 手动清除等级
  • manual_freeze 手动进入 L5

节流:同一事件类型在 throttle 秒内只发一封,防止攻击期间邮件轰炸。

数据表

  • {prefix}defense_slots:全站每分钟请求数
  • {prefix}defense_ip_slots:每 IP 每分钟请求数
  • {prefix}defense_ip_bans:IP 封禁记录
  • {prefix}defense_scan_log:敏感路径扫描日志

完整性守卫

Zerjie\Security\IntegrityGuard 周期性比对核心文件与官方清单,发现被篡改的文件会自动从官方服务器下载原始版本覆盖。

启用 / 关闭

# 启用(会二次确认,警告"会覆盖你修改过的核心文件")
php console.php integrity:on

# 查看状态
php console.php integrity:status

# 手动触发检查
php console.php integrity:check

# 关闭
php console.php integrity:off

工作方式

  1. 启用时生成 storage/integrity/.enabled 锁文件
  2. 每次请求结束时(shutdown hook)检查距上次检查是否超过 1 小时
  3. 超过则调用 checkAndRestore():
    • 从官方服务器拉取清单
    • 逐个文件比对 SHA-256
    • 不一致的文件从官方下载覆盖
  4. 检查时间戳写入 storage/integrity/.last_check

官方清单的排除项

清单不会包含以下路径(防止覆盖站点私有数据):

install/                安装后会被删除
config/                 每站的数据库配置不同
storage/                运行时数据
content/                主题、插件、小工具、上传文件
templates/lock-pages/   用户可上传自定义锁页模板
admin.php               站点独有,安装时生成,用户可重命名
⚠️ 安全提示:启用后系统会周期性从 zerjie.com 拉取官方原始文件并覆盖本地核心文件。如果你自己修改过核心文件,请勿开启此功能。

IntegrityGuard 类 API

方法说明
IntegrityGuard::enable()启用
IntegrityGuard::disable()关闭
IntegrityGuard::isEnabled()是否已启用
IntegrityGuard::status()返回状态信息
IntegrityGuard::checkAndRestore()执行检查
IntegrityGuard::shouldCheck()是否需要检查(间隔判断)

本地完整性检查(独立于 IntegrityGuard)

安装时生成的 storage/manifest.json 会在每次请求开始时用于比对核心文件。任一文件被修改或缺失 → 立即返回 503。

通过 CLI 重建清单:

# 重新生成清单(修改核心文件后手动调用)
php console.php security:manifest

# 检查核心文件是否完整
php console.php security:check

检查更新

Zerjie\Update\UpdateChecker 从官方服务器检查是否有新版本。

CLI

php console.php update:check

PHP API

use Zerjie\Update\UpdateChecker;

// 检查(带缓存,默认 1 小时)
$info = UpdateChecker::check();

// 强制检查(忽略缓存)
$info = UpdateChecker::check(true);

// 返回值:
// [
//     'status'       => 'update_available' | 'up_to_date' | 'network_error'
//                     | 'invalid_response' | 'no_versions' | 'unknown_error',
//     'current'      => '1.0.0',
//     'latest'       => '1.2.0',           // status = update_available
//     'released_at'  => '2026-01-15T...',
//     'file_count'   => 128,
//     'size'         => 1048576,           // 字节
//     'sha256'       => 'abc...',
//     'min_php'      => '7.4.0',
//     'changelog'    => '新增 xxx\n修复 yyy',
//     'download_url' => 'https://...',
// ]

// 快捷判断
if (UpdateChecker::hasUpdate()) {
    // 有新版本
}

// 清理缓存
UpdateChecker::clearCache();

缓存

  • 缓存文件:storage/update/.latest.json
  • 缓存时间:UpdateChecker::CACHE_TTL(默认 3600 秒)

安全措施

  • URL 必须在可信 base 下(防止 SSRF)
  • HTTPS 强制
  • 不跟随重定向
  • SSL 证书验证
  • 只允许 CURLPROTO_HTTPS

下载与安装

Zerjie\Update\Updater 负责下载更新包、解压、校验、备份、安装。

CLI

# 下载最新更新包(不安装)
php console.php update:download

# 下载 + 安装(一步到位)
php console.php update:apply

PHP API(分步执行)

use Zerjie\Update\UpdateChecker;
use Zerjie\Update\Updater;

// 1. 检查
$info = UpdateChecker::check(true);
if ($info['status'] !== 'update_available') {
    die('无可用更新');
}

// 2. 下载(保存到 storage/update/packages/)
$dl = Updater::download($info);
if (!$dl['success']) {
    die($dl['error']);
}
// → ['success' => true, 'file' => '/path/to/zerjie-1.2.0.zip', 'size' => ...]

// 3. 解压 + 校验
$prep = Updater::prepare($dl['file']);
if (!$prep['success']) {
    die($prep['error']);
}
// → ['success' => true, 'dir' => '/path/to/extracted',
//     'files' => [...], 'version' => '1.2.0']

// 4. 应用
$apply = Updater::apply($prep['dir'], $prep['files'], '1.2.0');
if (!$apply['success']) {
    die($apply['error']);
}
// → ['success' => true, 'backup' => '/path/to/backup', 'applied' => 128]

安装过程

  1. 加锁:storage/update/.lock(防止并发安装)
  2. 进入维护模式:写 storage/update/.maintenance,访客看到 503
  3. 备份:把被覆盖的文件复制到 storage/backups/updates/{timestamp}/
  4. 备份旧 meta:.old-meta.json
  5. 复制文件:从解压目录逐个复制到站点(仅允许的路径)
  6. 更新 install.json:写入新版本号
  7. 清理更新缓存:删除 .latest.json
  8. 重建清单:重新生成 storage/manifest.json
  9. 运行迁移:Migrator::run()(如果有数据库变更)
  10. 退出维护模式:删除 .maintenance
  11. 释放锁

允许更新的路径

更新包只能修改以下路径,其余一概忽略:

core/           核心
adminbackend/   后台
assets/         系统级静态资源
templates/      兜底模板
index.php
console.php
favicon.ico
favicon.png

更新包不能修改:

admin.php               站点独有
templates/lock-pages/   用户自定义
config/                 站点配置
storage/                运行时数据
content/                主题、插件、小工具

失败自动回滚

安装过程中任何一步失败,会自动从备份目录恢复所有被覆盖的文件。

回滚

列出所有备份

php console.php update:backups

回滚到指定备份

# 备份名形如 20260115_143022
php console.php update:rollback 20260115_143022

PHP API

use Zerjie\Update\Updater;

// 列出备份
$list = Updater::listBackups();
// → [
//     ['path' => '...', 'name' => '20260115_143022', 'time' => '2026-01-15 14:30:22', 'meta' => [...]],
//     ...
// ]

// 回滚
$result = Updater::rollback('20260115_143022');
// → ['success' => true, 'restored' => 128]

回滚过程

  1. 遍历备份目录,把每个文件复制回原位
  2. 恢复 install.json(版本号回滚)
  3. 清理更新缓存
  4. 重建清单
备份目录不会被自动清理。建议定期手动清理 storage/backups/updates/,或写定时任务。

CLI 命令

通过 php console.php <command> 在服务器终端执行。

查看所有命令

php console.php          # 或
php console.php help
php console.php list

命令速查

命令说明
用户管理
user:list列出所有用户
user:add <用户名> <密码> <邮箱> [角色]添加用户
user:delete <ID>删除用户
user:password <ID或用户名> <新密码> [--force]改密码
user:promote <ID或用户名> <角色> [--force]改角色
user:count统计各角色用户数
主题
theme:list列出已安装主题
theme:activate <name>启用主题
theme:info <name>主题详情
插件
plugin:list列出插件
plugin:activate <name>启用插件
plugin:deactivate <name>禁用插件
plugin:info <name>插件详情
缓存 / 日志
cache:clear清空文件缓存
cache:stats缓存统计
log:clear清空日志
log:tail [n]查看最新 n 行日志
log:list列出所有日志文件
数据库
db:list-tables列出所有表
db:table-info <name>表结构
db:size数据库/表大小
db:optimize优化系统表
db:backup备份数据库
迁移
migrate执行所有待执行的迁移
migrate:rollback [steps]回滚最近 n 次迁移
migrate:reset回滚所有迁移
系统
system:info系统信息
system:health系统健康检查
system:debug运行环境调试信息
system:routes列出所有路由
system:env服务器环境变量
system:time当前时间
system:clear-temp清理临时文件
调试模式
debug:on / debug:off开关调试模式
debug:status调试模式状态
debug:toggle切换调试模式
队列
queue:work [name] [max]启动队列 worker
queue:size [name]队列长度
安全
security:manifest生成完整性清单
security:check检查核心文件完整性
defense:status防御状态
defense:clear清除防御等级
defense:cleanup清理旧计数器
integrity:on / integrity:off开关完整性守卫
integrity:status完整性守卫状态
integrity:check手动触发完整性检查
更新
update:check检查更新
update:download下载更新包
update:apply下载并应用更新
update:backups列出更新备份
update:rollback <name>回滚更新

示例

# 添加管理员
php console.php user:add alice secret123 alice@example.com admin

# 清空缓存并查看日志
php console.php cache:clear
php console.php log:tail 50

# 备份数据库
php console.php db:backup

# 检查系统健康
php console.php system:health

# 启用主题
php console.php theme:activate my-theme

# 执行待处理的迁移
php console.php migrate

数据表结构

系统表都以 {prefix} 为前缀(默认 wp_),安装时自定义。

表名用途
{prefix}users用户账户
{prefix}options系统配置(键值对)
{prefix}logs数据库日志(DatabaseChannel)
{prefix}migrations迁移记录
{prefix}taxonomies分类
{prefix}term_relationships内容-分类关联
{prefix}menus导航菜单
{prefix}menu_items菜单项
{prefix}invite_codes邀请码(站点锁)
{prefix}register_logIP 注册日志
{prefix}login_attempts登录失败记录
{prefix}defense_slots全站每分钟请求数
{prefix}defense_ip_slots每 IP 每分钟请求数
{prefix}defense_ip_bansIP 封禁记录
{prefix}defense_scan_log敏感路径扫描日志

users 表字段

字段类型说明
idINT主键
usernameVARCHAR(60)用户名(唯一)
emailVARCHAR(100)邮箱(唯一,可空)
passwordVARCHAR(255)密码哈希(bcrypt)
display_nameVARCHAR(100)显示名
roleENUMsuper_admin / admin / user
statusENUMactive / pending / banned
registered_atTIMESTAMP注册时间
last_loginTIMESTAMP最后登录
login_countINT登录次数
session_tokenVARCHAR(255)会话令牌(SHA-256 哈希)
last_ipVARCHAR(45)最后登录 IP
last_user_agentVARCHAR(500)最后登录 UA
last_countryVARCHAR(100)最后登录国家

options 表字段

字段类型说明
option_keyVARCHAR(191)主键
option_valueLONGTEXT值(数组/对象序列化存储)
autoloadENUM('yes','no')是否预加载
updated_atTIMESTAMP更新时间

新增自定义表

插件/主题可以自己建表,建议:

  • 表名前缀使用 {prefix},与系统保持一致
  • 首次安装时用 zerjie_get_option() 做标记,避免重复建表
  • 用 CREATE TABLE IF NOT EXISTS 保证幂等
add_action('zerjie_ready', function () {
    if (zerjie_get_option('my_plugin_installed') === '1') return;

    $db = $GLOBALS['zerjie_db'];
    $prefix = zerjie_table_prefix();

    $db->exec("CREATE TABLE IF NOT EXISTS `{$prefix}my_data` (
        `id` INT AUTO_INCREMENT PRIMARY KEY,
        `name` VARCHAR(100),
        `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

    zerjie_update_option('my_plugin_installed', '1');
}, 5);

钩子速查表

内核钩子

钩子类型说明
zerjie_readyAction内核初始化完成(主题、插件主入口)
zerjie_admin_menuFilter后台菜单注册
zerjie_admin_headAction后台 <head> 输出
zerjie_admin_footerAction后台 </body> 前输出
zerjie_admin_topbarAction顶栏输出
zerjie_admin_footer_beforeAction页脚前
zerjie_admin_footer_afterAction页脚后
zerjie_admin_footer_textFilter页脚文字
zerjie_admin_page_content_{action}Filter替换某个后台页面内容
zerjie_admin_layout_fileFilter替换后台整体布局
zerjie_admin_login_fileFilter替换后台登录页
zerjie_admin_header_fileFilter自定义后台顶部文件
zerjie_admin_footer_fileFilter自定义后台底部文件
zerjie_theme_template_fileFilter主题模板路径注册
zerjie_widgetsFilter小工具注册
zerjie_dashboard_widgetsFilter仪表盘小部件
zerjie_settings_fieldsFilter设置字段注册
zerjie_language_pathsFilter多语言包路径追加
zerjie_theme_headAction前端 <head> 输出(主题触发)
zerjie_theme_footerAction前端 </body> 前(主题触发)

注册相关

钩子类型说明
zerjie_registration_openFilter是否开放注册
zerjie_default_registration_roleFilter默认角色
zerjie_default_registration_statusFilter默认状态
zerjie_username_availableFilter用户名可用性
zerjie_email_availableFilter邮箱可用性
zerjie_username_min_lengthFilter用户名最小长度
zerjie_password_min_lengthFilter密码最小长度
zerjie_password_strengthFilter密码强度校验
zerjie_register_argsFilter注册参数
zerjie_register_validationFilter注册数据校验
zerjie_register_user_dataFilter最终插入数据
zerjie_register_resultFilter注册结果
zerjie_before_registerAction注册前
zerjie_after_registerAction注册成功后
zerjie_user_activatedAction用户激活后
zerjie_user_pendingAction待审核用户
zerjie_registration_failedAction注册失败

防御系统

钩子类型说明
zerjie_defense_suspicious_patternsFilter敏感路径正则列表
zerjie_defense_scan_thresholdFilter敏感路径扫描触发封禁的阈值
zerjie_defense_suspicious_requestAction命中敏感路径时广播(插件可订阅)

常见问题

Q1:修改主题文件后不生效?

可能是浏览器缓存了旧 CSS / JS。在引用资源时加版本号强制刷新:

<link rel="stylesheet" href="/content/themes/my-theme/assets/css/style.css?v=<?= filemtime(__DIR__ . '/assets/css/style.css') ?>">

Q2:后台入口忘了叫什么?

查看 config/system.php 中的 admin_entry 字段;或执行:

php console.php system:info

Q3:用户登录状态频繁失效?

可能是 session_token 校验失败。原因:

  • 其他设备登录了同一账号(会强制下线)
  • 密码被修改(会重置 session_token)
  • 会话超时(默认 24 小时)

Q4:如何让插件覆盖后台菜单的默认项?

add_filter('zerjie_admin_menu', function ($menus) {
    foreach ($menus as &$m) {
        if (($m['slug'] ?? '') === 'dashboard') {
            $m['title'] = '我的仪表盘';
        }
    }
    return $menus;
}, 20);   // 优先级 20,晚于默认的 10

Q5:如何查看日志?

# 查看最新 50 行
php console.php log:tail 50

# 或直接读文件
tail -f storage/logs/$(date +%Y-%m-%d).log

Q6:debug 模式下调试代码

php console.php debug:on

// 代码中
dump($variable);    // 打印变量
dd($variable);      // 打印后终止

Q7:升级数据库表结构

用迁移系统:

// 在 database/migrations/ 下新建 20260101_add_column.php
<?php
return [
    'up' => function ($db, $prefix) {
        $db->exec("ALTER TABLE `{$prefix}users` ADD COLUMN `nickname` VARCHAR(60)");
    },
    'down' => function ($db, $prefix) {
        $db->exec("ALTER TABLE `{$prefix}users` DROP COLUMN `nickname`");
    },
];

// 执行
php console.php migrate

Q8:CSP 拦截了内联脚本?

Zerjie 的 CSP 策略是 script-src 'self',禁止内联脚本。解决方案:

  1. 把脚本写到独立 .js 文件中,通过 <script src="..."></script> 引用
  2. 不要用 <script>...代码...</script> 内联
  3. 不要用 onclick="..." 等内联事件,改用事件委托

Q9:主题首页显示"Template index does not exist"?

主题没有注册 zerjie_theme_template_file filter。解决方案:

  1. 在 functions.php 里注册 filter,返回主题的模板路径
  2. 或者直接注册 / 路由,明确处理首页
// 方式 1:filter
add_filter('zerjie_theme_template_file', function ($file, $template, $theme) {
    if ($theme !== 'my-theme') return $file;
    $path = __DIR__ . '/templates/' . $template . '.php';
    return file_exists($path) ? $path : $file;
}, 10, 3);

// 方式 2:路由(推荐,更明确)
add_action('zerjie_ready', function () {
    global $zerjie_router;
    $zerjie_router->get('/', function () {
        require __DIR__ . '/templates/index.php';
        exit;
    });
});

Q10:小工具拖拽到区域后前台不显示?

检查三点:

  1. 主题模板是否调用了 the_widgets('区域名')
  2. 区域名是否与后台拖拽时的一致
  3. 小工具的 sidebar 参数默认值是否匹配(只是默认,最终由拖拽决定)

Q11:开启完整性守卫后站点 503?

可能是清单中某个核心文件与你本地的 SHA-256 不一致。检查步骤:

  1. 查看 storage/logs/security.log 找出被标记的文件
  2. 如果你故意修改过核心文件,应该关闭完整性守卫:php console.php integrity:off
  3. 如果是意外篡改,用 php console.php security:check 定位
  4. 需要临时放行,重新生成清单:php console.php security:manifest

Q12:如何修改防御系统的敏感路径规则?

add_filter('zerjie_defense_suspicious_patterns', function ($patterns) {
    // 追加规则
    $patterns[] = '#/my-secret-url#i';

    // 移除某条规则(比如想放行 .env)
    $patterns = array_filter($patterns, function ($p) {
        return strpos($p, '.env') === false;
    });

    return array_values($patterns);
});

Q13:邮件发送失败怎么排查?

  1. 后台 → 系统 → SMTP 页面,检查配置是否完整(host / username / password / from_email)
  2. 用「发送测试」按钮验证 SMTP 连接
  3. 查看 storage/logs/YYYY-MM-DD.log,搜索 [DEFENSE-EMAIL] 或 mail
  4. 常见问题:端口被云服务商封锁(改用 587 + TLS)、密码用的是授权码而非登录密码(Gmail / QQ 邮箱)

Q14:如何切换缓存驱动到 Redis?

在 core/zerjie.php 里修改初始化代码:

// 原代码
$cacheManager = new Zerjie\Cache\CacheManager('file');

// 改为
$cacheManager = new Zerjie\Cache\CacheManager('redis');

并在 RedisCache 构造时传入配置(host / port / password / database / prefix)。