懒人导航 - 使用与开发文档

懒人导航基于原生 PHP + MySQL 构建,不依赖任何第三方框架。本文档面向三类读者:

角色关注章节目标
普通用户 / 站长第一章、第五章安装部署、日常运营、按需启用插件
主题开发者第二章、第三章自定义页面布局和视觉风格
插件开发者第二章、第四章扩展功能、注册钩子、管理数据库
核心设计理念

懒人导航采用按需加载架构:初始安装只创建核心表(sites、categories、settings 等 9 张),所有插件默认关闭。插件启用时才自动创建其所需的表、字段和配置——做到真正的插件单独安装、按需启用。

第一章 快速入门

1.1 系统要求

组件最低版本推荐版本说明
PHP7.48.0+PDO、GD、cURL、mbstring、JSON、OpenSSL、Session
MySQL5.78.0utf8mb4 字符集
Web 服务器Nginx / ApacheNginx需支持伪静态

PHP 扩展要求:PDO_MYSQL(必需)、GD(必需)、cURL(必需)、mbstring(必需)、JSON(必需)、OpenSSL(必需)、Session(必需)、fileinfo(推荐)。

可通过 php -m 命令查看已安装的扩展列表。

1.2 安装部署

1.2.1 上传文件

  1. 将程序压缩包解压,上传到 Web 服务器根目录或子目录
  2. 确保以下目录可写:data/logs/data/cache/templates/plugins/
  3. config.php 设置为可写(安装程序会自动写入数据库配置)

1.2.2 运行安装向导

  1. 浏览器访问 http://你的域名/install/
  2. 按提示填写数据库主机、库名、用户名、密码、表前缀
  3. 设置管理员账号和密码
  4. 点击安装,系统自动创建核心表、写入默认配置、生成 config.php
  5. 安装完成后删除 install/ 目录或重命名
说明

初始安装只创建 9 张核心表和基础配置。所有插件默认关闭,需要到后台「插件管理」中按需启用。插件启用时会自动创建插件所需的表、字段和配置。

1.2.3 伪静态配置(推荐)

安装完成后,进入后台「基础设置 - 伪静态」,选择伪静态模式并复制自动生成的服务器规则到 .htaccess(Apache)或 Nginx 配置文件中。

模式示例 URL说明
dynamic/index.php?route=category&slug=tech动态模式,无需服务器配置
rewrite/category/tech/伪静态模式,需配置服务器规则

1.3 后台使用

安装完成后访问 http://你的域名/admin/ 登录后台。

菜单功能说明
仪表盘站点概况、最近操作、统计数据概览
站点管理站点增删改查、审核、推荐设置、批量操作
分类管理分类增删改、排序、SEO 字段设置
待审核审核前台提交和自动收录的站点
推荐管理设置全局推荐和分类推荐
基础设置站点名称、SEO、主题、伪静态、日志开关、各插件配置 Tab
插件管理启用/停用/卸载插件,查看插件数据库信息
统计报表站点浏览、点击、评分等数据统计
基础设置中的 Tab

基础设置页面采用 Tab 面板设计。核心设置(站点信息、SEO、主题等)固定显示,插件注入的设置 Tab(如广告管理、伪静态格式等)仅在对应插件启用后出现。

1.4 插件管理

后台「插件管理」页面展示所有已扫描到的插件。每个插件有三种状态操作:

操作效果
启用自动执行 ensureSchema():创建插件声明的表、向已有表添加字段、写入默认配置。然后加载插件代码并注册钩子
停用仅修改启用状态为关闭,保留所有数据库表和配置数据。再次启用时无需重新安装
卸载停用 + 删除插件自建表 + 删除插件添加的字段 + 清除插件配置。共享表智能判断:仅当所有声明该表的插件都卸载时才删表

插件列表中还会显示每个插件的数据库信息,格式如 📋 articles · settings(2配置),表示该插件创建了 articles 表并写入了 2 条配置。

提示

所有内置插件默认关闭。安装完成后,根据需要到插件管理页面逐个启用。插件之间的依赖关系极低,可以按任意顺序启用。

1.5 主题切换

  1. 进入后台「基础设置」
  2. 在「主题设置」区域选择已安装的主题
  3. 保存后前台立即生效

主题文件放在 templates/{主题名}/ 目录下。系统会自动扫描所有含 theme.json 的目录并显示在后台主题列表中。

第二章 系统概述

2.1 目录结构

.
  index.php 前台统一入口
  go.php 跳转中间页(记录点击统计后跳转)
  config.php 数据库配置(安装时自动生成)
  core/ 核心类库
    bootstrap.php 应用引导(Session、自动加载、调试模式)
    Database.php PDO 单例 + 预处理封装
    Security.php 安全模块(XSS/CSRF/频率限制/HTML清洗)
    Route.php 路由分发器
    Rewrite.php 伪静态系统(URL 解析与生成)
    Theme.php 主题系统(扫描/加载/渲染/资源引用)
    Plugin.php 插件系统核心(扫描/启用/schema/钩子)
    Logger.php 日志工具类
    helpers.php 前台辅助函数库
    SiteModel.php 站点模型
    CategoryModel.php 分类模型
    SettingsModel.php 设置模型
    FeatureModel.php 推荐模型
    WormholeModel.php 虫洞模型
  templates/ 主题目录
    default/ 默认主题
  plugins/ 插件目录
    ad/ 广告管理
    article/ 文章发布
    auto-link/ 友链自动收录
    wormhole/ 虫洞联盟
    rewrite/ 伪静态设置
    notify/ 邮箱通知
    ... 其他插件
  admin/ 后台管理
  api/ API 接口入口
  install/ 安装程序
  data/ 数据目录(日志/缓存/文档)

2.2 核心类一览

文件职责
Databasecore/Database.phpPDO 单例,提供 query()queryOne()execute()insert()table()、事务
Securitycore/Security.php输入过滤、输出转义、CSRF、频率限制、HTML 清洗
Routecore/Route.php路由分发:解析 URL 参数,分发到对应模板
Rewritecore/Rewrite.php伪静态:URL 解析、生成、服务器规则自动生成
Themecore/Theme.php主题扫描、加载、渲染、资源引用、布局片段
Plugincore/Plugin.php插件扫描、启用/停用/卸载、schema 安装、钩子系统
Loggercore/Logger.php按日期分目录、按频道分文件的日志写入
SettingsModelcore/SettingsModel.php键值对配置读写(settings 表)

2.3 数据库表概览

初始安装创建以下核心表(不含插件表):

表名说明
sites站点主表:名称、URL、分类、权重、状态、提交者邮箱(submit_email)等
categories分类表:名称、slug、图标、排序、SEO 字段
settings配置表:键值对存储全站配置
site_features推荐关联表:分类推荐站点排序
admins管理员账号(bcrypt 密码哈希)
site_ratings用户评分(IP 防刷)
site_feedback站点反馈(URL变更/打不开/内容错误)
deleted_idsID 回收队列(删除站点后复用 ID)
site_daily_stats站点每日统计
notify_logs邮件通知发送记录(notify 插件)
插件管理的表

插件声明的表(如 articlesblacklist)和插件向已有表添加的字段(如 sites 表的 wormhole_status 等)不在初始安装时创建,而是在插件启用时由 Plugin::ensureSchema() 自动安装。卸载插件时会自动清理。

2.4 辅助函数速查(core/helpers.php)

函数名参数说明
setting()$key, $default=null读取配置值(别名 getConfig()
redirect()$urlHTTP 重定向
isInstalled()检查系统是否已安装
isDebug()判断调试模式
parseDomain()$url从 URL 提取域名
normalizeSiteUrl()$url补全 URL 协议头
formatNumber()$num格式化数字(1k, 1M)
formatDate()$date, $format格式化日期
parseTags()$json解析 JSON 标签为数组
renderPagination()$current, $total, $urlTemplate生成分页 HTML
getMaxBr()$site获取站点最高权重

第三章 主题开发

3.1 主题目录结构

主题放在 templates/{主题名}/ 目录下:

templates/mytheme/
  theme.json 主题信息文件(必需)
  index.php 首页模板(必需)
  category.php 分类页模板
  site.php 站点详情页模板
  search.php 搜索页模板
  submit.php 提交站点页模板
  header.php 公共头部(可选,通过 partial 加载)
  footer.php 公共底部(可选,通过 partial 加载)
  404.php 404 错误页
  css/ 样式文件
  js/ 脚本文件
  screenshot.png 主题截图(可选,后台展示用)

3.2 theme.json

每个主题必须包含 theme.json

{
  "name": "mytheme",
  "title": "我的主题",
  "version": "1.0",
  "author": "你的名字",
  "description": "主题简介",
  "support": ["index", "category", "site", "search", "submit"]
}
字段必填说明
name目录名(必须与文件夹一致)
title显示名称
version版本号
author作者
description简介
support支持的页面类型列表

3.3 模板文件与注入变量

Route::dispatch() 解析请求后,通过 Theme::render() 将变量注入模板作用域(extract()),模板中直接使用变量。

模板文件路由注入变量
index.php/$categories, $activeCats, $featuredSites, $currentSites, $settings, $seoTitle, $seoDesc, $ranking, $showWeight, $siteStats
category.phpcategory/{slug}/$category, $sites, $slug, $sort, $total, $totalPages, $showWeight
site.phpsite/{id}/$site, $category, $related, $domain, $tags, $ratingStats
search.phpsearch/$keyword, $results, $total, $totalPages, $perPage
submit.phpsubmit/$enable, $needReview, $categories

3.4 Theme 类方法

方法参数说明
Theme::current()获取当前主题名
Theme::set()$name切换主题
Theme::scan()扫描所有可用主题
Theme::render()$template, $vars=[]渲染模板(注入变量 + require)
Theme::partial()$name, $vars=[]加载布局片段(如 header/footer),自动回退 default
Theme::e()$valueHTML 实体转义(等价 Security::e()
Theme::eAttr()$value属性值转义
Theme::url()$type, $params=[]生成 URL(自动适配伪静态模式)
Theme::asset()$file获取主题资源路径(如 css/style.css)

3.5 钩子列表

主题模板中通过 Plugin::hook('钩子名') 输出插件注入的内容。以下是系统内置的所有前台钩子:

钩子名位置参数用途
before_headerheader.php 顶部在 HTML head 之前输出内容
after_headerheader.php 底部在 body 开头输出内容
search_bar_after首页搜索栏之后搜索栏下方注入内容
sidebar_top侧边栏顶部侧边栏上方广告/内容
sidebar_bottom侧边栏底部侧边栏下方内容
site_list_before站点列表前列表上方广告/内容
site_list_after站点列表后列表下方广告/内容
before_content站点详情内容前[$site]详情内容上方
after_content站点详情内容后[$site]详情内容下方
before_footerfooter.php 顶部页脚之前
after_footerfooter.php 底部页脚之后(常用于 JS 注入)

后台钩子:

钩子名位置参数用途
admin_sidebar后台侧边栏注入后台菜单项
admin_settings_nav基础设置页 Tab 导航[$activeTab]注入设置 Tab 标签
admin_settings_tabs基础设置页 Tab 面板[$activeTab]注入设置 Tab 内容
使用方式

在模板中调用 <?php Plugin::hook('sidebar_top'); ?> 即可。钩子名是约定好的,主题开发者只需在对应位置放置钩子调用,插件会自动注入内容。

3.6 URL 生成与资源引用

// URL 生成(自动适配伪静态)
<?= Theme::url('home') ?>                              // 首页
<?= Theme::url('category', ['slug' => $cat['slug']]) ?>  // 分类页
<?= Theme::url('site', ['id' => $site['id']]) ?>          // 站点详情
<?= Theme::url('search') ?>                   // 搜索页
<?= Theme::url('submit') ?>                   // 提交页

// 静态资源引用
<link rel="stylesheet" href="<?= Theme::asset('css/style.css') ?>">
<script src="<?= Theme::asset('js/script.js') ?>"></script>
<img src="<?= Theme::asset('images/logo.png') ?>">

Theme::asset() 返回的路径基于当前主题目录,例如 /templates/mytheme/css/style.css

3.7 实战案例:创建一个主题

下面以默认主题 default 的实际代码为例,展示从零创建一个主题的完整流程。

步骤 1:创建目录和 theme.json

templates/mytheme/theme.json:
{
  "name": "mytheme",
  "title": "极简主题",
  "version": "1.0",
  "author": "懒人导航",
  "description": "极简风格,专注内容",
  "support": ["index", "category", "site", "search", "submit"]
}

步骤 2:编写 header.php(公共头部)

头部负责 DOCTYPE、meta 标签、CSS 引用和插件钩子。以下是默认主题的实际写法:

<?php
// 确保 SEO 变量有默认值
$fallbackSiteName = $settings['site_name'] ?? '懒人导航';
if (!isset($seoTitle)) $seoTitle = $fallbackSiteName;
if (!isset($seoDesc)) $seoDesc = '';
if (!isset($seoKeywords)) $seoKeywords = '';
?>
<?php Plugin::hook('before_header'); ?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= Theme::e($seoTitle) ?></title>
<?php if (!empty($seoDesc)): ?>
<meta name="description" content="<?= Theme::eAttr($seoDesc) ?>">
<?php endif; ?>
<link rel="stylesheet" href="<?= Theme::asset('css/common.css') ?>">
</head>
<body>
<?php Plugin::hook('after_header'); ?>
关键点
  • Plugin::hook('before_header')Plugin::hook('after_header') 是必须的,插件依赖这些钩子注入内容
  • 所有输出使用 Theme::e()Theme::eAttr() 转义,防止 XSS
  • CSS 通过 Theme::asset() 引用,路径自动适配当前主题

步骤 3:编写 index.php(首页)

<?php
Theme::partial('header');
?>

<div class="container">
  <!-- 搜索栏 -->
  <div class="search-bar">
    <input type="search" id="searchInput" placeholder="搜索站点...">
  </div>
  <?php Plugin::hook('search_bar_after'); ?>

  <!-- 侧边栏 -->
  <aside class="sidebar">
    <?php Plugin::hook('sidebar_top'); ?>
    <?php foreach ($categories as $cat): ?>
      <a href="<?= Theme::url('category', ['slug' => $cat['slug']]) ?>">
        <?= Theme::e($cat['name']) ?>
      </a>
    <?php endforeach; ?>
    <?php Plugin::hook('sidebar_bottom'); ?>
  </aside>

  <!-- 站点列表 -->
  <main class="site-list">
    <?php Plugin::hook('site_list_before'); ?>
    <?php foreach ($currentSites as $site): ?>
      <a href="<?= Theme::url('site', ['id' => $site['id']]) ?>" class="card">
        <span class="card-title"><?= Theme::e($site['name']) ?></span>
        <span class="card-desc"><?= Theme::e($site['description']) ?></span>
      </a>
    <?php endforeach; ?>
    <?php Plugin::hook('site_list_after'); ?>
  </main>
</div>

<?php Theme::partial('footer'); ?>

注意模板中放置了 5 个钩子调用:search_bar_aftersidebar_topsidebar_bottomsite_list_beforesite_list_after。这些钩子让广告插件、文章插件等能在对应位置注入内容。

步骤 4:编写 footer.php(公共底部)

<?php Plugin::hook('before_footer'); ?>
<footer class="site-footer">
  <nav class="footer-nav">
    <a href="<?= Theme::url('home') ?>">首页</a>
    <a href="<?= Theme::url('submit') ?>">提交站点</a>
  </nav>
  <p><?= Theme::e($settings['site_name'] ?? '') ?></p>
</footer>
<?php Plugin::hook('after_footer'); ?>
</body>
</html>

步骤 5:编写其他页面

按同样模式编写 category.phpsite.phpsearch.phpsubmit.php。每个页面通过 Theme::partial('header')Theme::partial('footer') 引入公共布局,中间放置页面内容。变量由 Route::dispatch() 自动注入(见 3.3 节)。

步骤 6:添加 CSS

templates/mytheme/css/common.css 中编写样式。可以通过 Theme::asset('css/common.css') 引用。

步骤 7:后台切换

进入后台「基础设置 - 主题设置」,选择 极简主题 并保存,前台立即生效。

第四章 插件开发

4.1 插件目录结构

插件放在 plugins/{插件名}/ 目录下。一个完整的插件包含以下文件:

plugins/myplugin/
  plugin.json 元数据声明(必需)
  include.php 主文件:函数定义 + 前台钩子注册(必需)
  main.php 后台设置面板:注册设置 Tab 钩子(可选)
  schema.php 数据库声明:表、字段、配置(可选)
  admin.php 独立后台管理页面(可选,通过 /admin/plugin.php?p=myplugin 访问)
  css/ 插件样式(可选)
  js/ 插件脚本(可选)
加载机制
  • include.php:仅在插件启用时由 Plugin::init() 加载,定义类/函数并注册前台钩子
  • main.php:仅在插件启用时加载,注册后台设置 Tab 钩子(admin_settings_nav + admin_settings_tabs
  • schema.php:在插件启用和卸载时由 Plugin::loadSchema() 加载,返回数组声明
  • admin.php:通过 /admin/plugin.php?p=插件名 访问时,由分发器独立加载。注意:admin.php 不会自动加载 include.php,如果需要用 include.php 中的函数/类,必须在 admin.php 顶部手动 require_once __DIR__ . '/include.php'
  • 未启用的插件完全不加载,不注册任何钩子,不执行任何代码

4.2 plugin.json

{
    "name": "myplugin",
    "title": "我的插件",
    "version": "1.0",
    "author": "你的名字",
    "description": "插件功能描述",
    "main_file": "include.php",
    "config_file": "main.php",
    "schema_file": "schema.php",
    "hooks": ["sidebar_top", "after_footer"],
    "tables": ["mytable"],
    "builtin": false
}
字段必填说明
name插件目录名(必须与文件夹一致)
title显示名称
version版本号
author作者
description功能描述
main_file主文件名(默认 {name}.php,通常设为 include.php
config_file设置面板文件名(如 main.php
schema_file数据库声明文件名(固定 schema.php
hooks声明使用的钩子列表(用于文档展示,不影响实际注册)
tables声明创建的表名列表(用于卸载时的共享表判断)
builtin是否为内置插件(默认 true)

4.3 schema.php 机制

schema.php 是插件数据库声明文件,返回一个包含三个部分的数组:

<?php
return [
    // 1. 独立表:插件启用时自动 CREATE TABLE IF NOT EXISTS
    'tables' => [
        'mytable' => "CREATE TABLE IF NOT EXISTS `{prefix}mytable` (
            id INT PRIMARY KEY AUTO_INCREMENT,
            title VARCHAR(200) NOT NULL,
            content TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            INDEX idx_title (title)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;",
    ],

    // 2. 向已有表添加字段:插件启用时自动 ALTER TABLE ADD COLUMN(跳过已存在)
    'columns' => [
        'sites' => [
            'my_field' => "VARCHAR(100) DEFAULT '' COMMENT '自定义字段'",
        ],
    ],

    // 3. 默认配置项:插件启用时写入 settings 表(仅当配置项不存在时写入)
    'config' => [
        'plugin_myplugin_count'  => '5',
        'plugin_myplugin_enable' => '1',
    ],
];

ensureSchema() 执行流程

当插件被启用时,Plugin::setEnabled($name, true) 会自动调用 Plugin::ensureSchema($name)

  1. 创建独立表:遍历 tables,通过 information_schema 检查表是否存在,不存在则执行 SQL({prefix} 占位符替换为实际表前缀)
  2. 添加字段:遍历 columns,通过 information_schema.columns 检查字段是否存在,不存在则 ALTER TABLE ADD COLUMN
  3. 写入配置:遍历 config,检查 settings 表中是否已存在该 key,不存在则写入默认值
幂等安全

所有操作都是幂等的:表已存在则跳过,字段已存在则跳过,配置已存在则跳过。因此重复启用插件不会出错,也不会覆盖用户已修改的配置。

配置项命名规范

插件配置项使用 plugin_{插件名}_{配置键} 的命名格式存储在 settings 表中。例如插件 myplugincount 配置存储为 plugin_myplugin_count

但有些历史插件(如 wormhole、auto-link)使用不带 plugin_ 前缀的简短键名(如 wormhole_enable)。这两种方式都能正常工作,新插件建议使用 plugin_ 前缀格式。

4.4 include.php 与钩子注册

include.php 是插件主文件,负责定义函数/类和注册钩子:

<?php
// 安全检查:阻止直接访问
if (!defined('APP_VERSION') && !class_exists('Database')) {
    die('Forbidden');
}

/**
 * 输出插件内容
 */
function myplugin_render(): void
{
    $count = (int)Plugin::config('myplugin', 'count', '5');
    // ... 业务逻辑
    echo '<div class="myplugin">' . $content . '</div>';
}

// 注册前台钩子
Plugin::registerHook('sidebar_top', function () {
    myplugin_render();
});

// 注册后台侧边栏菜单(如有 admin.php)
Plugin::registerHook('admin_sidebar', function () {
    $cls = ($GLOBALS['currentPage'] ?? '') === 'myplugin' ? 'active' : '';
    echo '<a href="/admin/plugin.php?p=myplugin" class="nav-item ' . $cls . '">'
       . '<i class="ti ti-star"></i><span>我的插件</span></a>';
});
关键点
  • 文件开头必须有安全检查,阻止直接访问
  • 通过 Plugin::registerHook(钩子名, 回调函数) 注册钩子
  • 通过 Plugin::config('插件名', '配置键', 默认值) 读取插件配置
  • 钩子回调函数中的 echo 内容会直接输出到页面
  • 可注册多个钩子,同一钩子可被多个插件注册

4.5 main.php 设置面板

main.php 通过注册两个后台钩子,在基础设置页面注入自定义 Tab:

<?php
if (!defined('APP_VERSION') && !class_exists('Database')) {
    die('Forbidden');
}

// 钩子1:注入 Tab 导航标签
Plugin::registerHook('admin_settings_nav', function ($activeTab) {
    $cls = $activeTab === 'myplugin' ? 'active' : '';
    echo '<a href="#tab-myplugin" class="settings-tab ' . $cls . '"'
       . ' onclick="switchTab(\'myplugin\', this)">我的插件</a>';
});

// 钩子2:注入 Tab 内容面板
Plugin::registerHook('admin_settings_tabs', function ($activeTab) {
    $cls = $activeTab === 'myplugin' ? 'active' : '';
    ?>
<div id="tab-myplugin" class="tab-panel <?= $cls ?>">
  <div class="card">
    <div class="card-header"><span class="card-title">我的插件设置</span></div>
    <form method="POST" action="/admin/settings.php">
      <input type="hidden" name="csrf_token" value="<?= Security::eAttr($_SESSION['csrf_token'] ?? '') ?>">
      <input type="hidden" name="section" value="myplugin">
      <input type="hidden" name="tab" value="myplugin">

      <div class="form-group">
        <label>显示数量</label>
        <input type="number" name="plugin_myplugin_count"
               value="<?= Security::e(Plugin::config('myplugin', 'count', '5')) ?>">
      </div>

      <button type="submit" class="btn btn-primary">保存</button>
    </form>
  </div>
</div>
<?php
});
注意
  • 表单 POST 到 /admin/settings.php,需要带 csrf_tokensection 字段
  • 配置项的 name 属性必须与 schema.php 中声明的配置键一致
  • Tab 的 id 必须为 tab-{插件名},与导航标签的 href 对应
  • switchTab() 是后台已有的 JS 函数,用于 Tab 切换

4.6 Plugin 类 API

方法参数说明
Plugin::isEnabled()$name检查插件是否已启用
Plugin::setEnabled()$name, $enabled启用/停用插件(启用时自动 ensureSchema)
Plugin::ensureSchema()$name执行建表、加字段、写配置
Plugin::loadSchema()$name加载 schema.php 返回数组
Plugin::uninstall()$name卸载:停用 + 删表 + 删字段 + 清配置
Plugin::registerHook()$hook, $callback, $priority=10注册钩子回调
Plugin::hook()$hook, $args=[]执行动作钩子(输出模式)
Plugin::filter()$hook, $value, $args=[]执行过滤钩子(返回模式)
Plugin::config()$plugin, $key, $default=null读取插件配置
Plugin::setConfig()$plugin, $key, $value写入插件配置
Plugin::asset()$plugin, $file获取插件资源 URL
Plugin::getDir()$name获取插件目录路径
Plugin::scan()扫描所有可用插件
Plugin::getInfo()$name获取插件元数据

4.7 共享表与卸载

当多个插件声明同一张表时(如 blacklist 表被 wormholeauto-link 共享),系统会智能处理:

  • 启用时ensureSchema() 使用 CREATE TABLE IF NOT EXISTS,重复执行安全
  • 卸载时Plugin::uninstall() 检查是否还有其他插件声明该表。如果有,则跳过删表(仅清除当前插件的配置和字段)
  • 全部卸载时:当最后一个声明该表的插件被卸载时,才真正执行 DROP TABLE

卸载操作还会:

  1. 删除插件向已有表添加的字段(ALTER TABLE DROP COLUMN
  2. 清除插件的所有配置项(通过 LIKE 'plugin_{name}_%' 通配匹配 + schema.php 声明的 key)
  3. 记录卸载日志(删了哪些表、哪些字段、清了多少条配置)

4.8 实战案例:每日一言插件

下面创建一个完整的「每日一言」插件,展示从 plugin.json 到 schema.php、include.php、main.php 的完整开发流程。

功能:前台侧边栏显示每日一条名言,后台可管理名言列表和显示设置。

4.8.1 创建目录

plugins/daily-quote/
    plugin.json
    schema.php
    include.php
    main.php
    admin.php

4.8.2 plugin.json

{
    "name": "daily-quote",
    "title": "每日一言",
    "version": "1.0",
    "author": "懒人导航",
    "description": "前台侧边栏显示每日名言,后台可管理名言列表。",
    "main_file": "include.php",
    "config_file": "main.php",
    "schema_file": "schema.php",
    "hooks": ["sidebar_bottom", "admin_sidebar"],
    "tables": ["quotes"],
    "builtin": false
}

4.8.3 schema.php

声明一张独立的 quotes 表和 2 个默认配置项:

<?php
/**
 * 每日一言插件 - 数据库声明
 * 启用插件时自动创建 quotes 表并写入默认配置
 */

return [
    // 独立表
    'tables' => [
        'quotes' => "CREATE TABLE IF NOT EXISTS `{prefix}quotes` (
            id INT PRIMARY KEY AUTO_INCREMENT,
            content VARCHAR(500) NOT NULL COMMENT '名言内容',
            author VARCHAR(100) DEFAULT '' COMMENT '作者',
            status TINYINT DEFAULT 1 COMMENT '状态(1=启用,0=禁用)',
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            INDEX idx_status (status)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='每日言表';",
    ],

    // 向已有表添加字段(无)
    'columns' => [],

    // 默认配置项
    'config' => [
        'plugin_daily-quote_count'    => '1',
        'plugin_daily-quote_template' => 'card',
    ],
];
说明
  • {prefix} 占位符在执行时被替换为实际表前缀(如 nav_
  • 配置项 key 使用 plugin_{插件名}_{配置键} 格式
  • 表和字段声明中包含 COMMENT 方便数据库管理

4.8.4 include.php

定义 Model 类、注册前台钩子和后台侧边栏入口:

<?php
/**
 * 每日一言插件 - 主文件
 */

if (!defined('APP_VERSION') && !class_exists('Database')) {
    die('Forbidden');
}

/**
 * 每日一言模型
 */
class DailyQuoteModel
{
    /**
     * 获取随机名言
     */
    public function getRandom(int $limit = 1): array
    {
        $tbl = Database::table('quotes');
        return Database::query(
            "SELECT * FROM {$tbl} WHERE status = 1 ORDER BY RAND() LIMIT ?",
            [$limit]
        );
    }

    /**
     * 获取全部名言
     */
    public function getAll(): array
    {
        $tbl = Database::table('quotes');
        return Database::query("SELECT * FROM {$tbl} ORDER BY id DESC");
    }

    /**
     * 创建名言
     */
    public function create(string $content, string $author = ''): int
    {
        $tbl = Database::table('quotes');
        return Database::insert(
            "INSERT INTO {$tbl} (content, author) VALUES (?, ?)",
            [Security::cleanString($content, 500), Security::cleanString($author, 100)]
        );
    }

    /**
     * 删除名言
     */
    public function delete(int $id): bool
    {
        $tbl = Database::table('quotes');
        return Database::execute("DELETE FROM {$tbl} WHERE id = ?", [$id]) > 0;
    }

    /**
     * 切换状态
     */
    public function toggleStatus(int $id): bool
    {
        $tbl = Database::table('quotes');
        return Database::execute(
            "UPDATE {$tbl} SET status = 1 - status WHERE id = ?",
            [$id]
        ) > 0;
    }
}

// ========== 钩子注册 ==========

// 前台侧边栏:显示每日一言
Plugin::registerHook('sidebar_bottom', function () {
    $count = (int)Plugin::config('daily-quote', 'count', '1');
    $model = new DailyQuoteModel();
    $quotes = $model->getRandom($count);

    if (empty($quotes)) {
        return;
    }
    ?>
    <div class="daily-quote-widget" style="margin-top:16px;padding:12px;border-radius:8px;background:#f0f4ff;">
        <div style="font-size:13px;color:#999;margin-bottom:8px;">
            <i class="ti ti-quote"></i> 每日一言
        </div>
        <?php foreach ($quotes as $q): ?>
        <div class="quote-item" style="margin-bottom:8px;">
            <p style="font-size:14px;color:#333;line-height:1.6;">
                <?= Theme::e($q['content']) ?>
            </p>
            <?php if (!empty($q['author'])): ?>
            <p style="font-size:12px;color:#999;text-align:right;">
                —— <?= Theme::e($q['author']) ?>
            </p>
            <?php endif; ?>
        </div>
        <?php endforeach; ?>
    </div>
    <?php
});

// 后台侧边栏:注入管理入口
Plugin::registerHook('admin_sidebar', function () {
    $cls = ($GLOBALS['currentPage'] ?? '') === 'daily-quote' ? 'active' : '';
    echo '<a href="/admin/plugin.php?p=daily-quote" class="nav-item ' . $cls . '">'
       . '<i class="ti ti-quote"></i><span>每日一言</span></a>';
});

4.8.5 main.php(后台设置 Tab)

<?php
/**
 * 每日一言插件 - 设置面板
 */

if (!defined('APP_VERSION') && !class_exists('Database')) {
    die('Forbidden');
}

// 注入 Tab 导航
Plugin::registerHook('admin_settings_nav', function ($activeTab) {
    $cls = $activeTab === 'daily-quote' ? 'active' : '';
    echo '<a href="#tab-daily-quote" class="settings-tab ' . $cls . '"'
       . ' onclick="switchTab(\'daily-quote\', this)">每日一言</a>';
});

// 注入 Tab 内容
Plugin::registerHook('admin_settings_tabs', function ($activeTab) {
    $cls = $activeTab === 'daily-quote' ? 'active' : '';
    ?>
<div id="tab-daily-quote" class="tab-panel <?= $cls ?>">
  <div class="card">
    <div class="card-header"><span class="card-title">每日一言设置</span></div>
    <form method="POST" action="/admin/settings.php">
      <input type="hidden" name="csrf_token" value="<?= Security::eAttr($_SESSION['csrf_token'] ?? '') ?>">
      <input type="hidden" name="section" value="daily-quote">
      <input type="hidden" name="tab" value="daily-quote">

      <div class="form-group">
        <label>显示数量</label>
        <input type="number" name="plugin_daily-quote_count" min="1" max="10"
               value="<?= Security::e(Plugin::config('daily-quote', 'count', '1')) ?>">
        <p class="form-help">侧边栏每次显示几条名言</p>
      </div>

      <div class="text-right">
        <button type="submit" class="btn btn-primary">保存设置</button>
      </div>
    </form>
  </div>
</div>
<?php
});

4.8.6 admin.php(名言管理页面)

<?php
/**
 * 每日一言插件 - 后台管理页面
 * 通过 /admin/plugin.php?p=daily-quote 访问
 */

$model = new DailyQuoteModel();

// 处理 POST 操作
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!Security::verifyCSRFToken($_POST['csrf_token'] ?? '')) {
        die('CSRF 校验失败');
    }

    $action = $_POST['action'] ?? '';
    if ($action === 'create') {
        $model->create($_POST['content'] ?? '', $_POST['author'] ?? '');
        echo '<div class="alert alert-success">添加成功</div>';
    } elseif ($action === 'delete') {
        $model->delete((int)($_POST['id'] ?? 0));
        echo '<div class="alert alert-success">删除成功</div>';
    } elseif ($action === 'toggle') {
        $model->toggleStatus((int)($_POST['id'] ?? 0));
    }
}

$quotes = $model->getAll();
?>

<div class="card">
  <div class="card-header"><span class="card-title">添加名言</span></div>
  <form method="POST">
    <input type="hidden" name="csrf_token" value="<?= Security::eAttr($_SESSION['csrf_token'] ?? '') ?>">
    <input type="hidden" name="action" value="create">
    <div class="form-group">
      <label>内容</label>
      <textarea name="content" rows="3" required></textarea>
    </div>
    <div class="form-group">
      <label>作者(可选)</label>
      <input type="text" name="author">
    </div>
    <button type="submit" class="btn btn-primary">添加</button>
  </form>
</div>

<div class="card" style="margin-top:16px;">
  <div class="card-header"><span class="card-title">名言列表</span></div>
  <table>
    <thead><tr><th>ID</th><th>内容</th><th>作者</th><th>状态</th><th>操作</th></tr></thead>
    <tbody>
    <?php foreach ($quotes as $q): ?>
      <tr>
        <td><?= (int)$q['id'] ?></td>
        <td><?= Security::e($q['content']) ?></td>
        <td><?= Security::e($q['author']) ?></td>
        <td><?= $q['status'] ? '启用' : '禁用' ?></td>
        <td>
          <form method="POST" style="display:inline">
            <input type="hidden" name="csrf_token" value="<?= Security::eAttr($_SESSION['csrf_token'] ?? '') ?>">
            <input type="hidden" name="action" value="delete">
            <input type="hidden" name="id" value="<?= (int)$q['id'] ?>">
            <button type="submit" onclick="return confirm('确认删除?')">删除</button>
          </form>
        </td>
      </tr>
    <?php endforeach; ?>
    </tbody>
  </table>
</div>

4.8.7 启用插件

  1. daily-quote 目录放入 plugins/
  2. 进入后台「插件管理」,找到「每日一言」
  3. 点击「启用」——系统自动创建 quotes 表、写入 2 条默认配置、加载插件代码
  4. 前台首页侧边栏底部出现每日一言
  5. 后台侧边栏出现「每日一言」管理入口,可添加/删除名言
  6. 后台「基础设置」出现「每日一言」Tab,可修改显示数量
完整流程回顾

这个案例展示了插件开发的完整流程:plugin.json 声明元数据 → schema.php 声明数据库 → include.php 定义业务逻辑和钩子 → main.php 后台设置面板 → admin.php 后台管理页面。启用后自动安装,卸载后自动清理。

第五章 内置插件

5.1 插件一览

系统内置 10 个插件,全部默认关闭。安装后根据需要在后台「插件管理」中启用。

插件功能数据库影响
广告管理 (ad)后台配置广告位 HTML,前台多个位置展示6 条配置,无独立表
文章发布 (article)后台文章管理,前台文章列表和详情articles 表 + 2 条配置
虫洞联盟 (wormhole)站点互推、随机传送、定时检测blacklist 表(共享)+ sites 表 5 字段 + 5 条配置
友链自动收录 (auto-link)检测来路、验证回链、自动收录blacklist 表(共享)+ 4 条配置
伪静态设置 (rewrite)URL 格式配置,自动生成服务器规则10 条配置
提交网站收录 (submit)前台提交入口、表单、审核流程6 条配置
站点地图 (sitemap)自动生成 sitemap.xml1 条配置
图片灯箱 (lightbox)详情页图片点击放大无数据库影响
图片ALT (auto-alt)自动给图片添加 alt 属性无数据库影响
邮箱通知 (notify)SMTP 发送站点提交/审核/反馈邮件通知notify_logs 表 + sites.submit_email 字段 + 11 条配置

5.2 广告管理 (ad)

后台可配置 6 个广告位的 HTML 代码,前台在对应位置展示。

广告位钩子位置
首页列表前site_list_before
首页列表后site_list_after
侧边栏顶部sidebar_top
侧边栏底部sidebar_bottom
详情内容前before_content
详情内容后after_content

启用后在后台「基础设置 - 广告管理」Tab 填写 HTML 代码。广告 HTML 经过 Security::cleanHtml() 过滤后输出。

主题模板中的挂接代码

广告插件需要主题在对应位置放置钩子调用。以下是默认主题 templates/default/ 中的实际代码示例:

1. before_header / after_header(公共头部)

文件:templates/default/header.php

<?php Plugin::hook('before_header'); ?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    ...
</head>
<body>
<?php Plugin::hook('after_header'); ?>

2. search_bar_after(搜索栏后)

文件:templates/default/index.php

<div class="search-bar">
    <div class="search-wrap">
        <i class="ti ti-search"></i>
        <input type="search" placeholder="搜索..." id="searchInput">
    </div>
    <div class="hot-tags">
        ...
        <?php Plugin::hook('search_bar_after'); ?>
    </div>
</div>

3. sidebar_top / sidebar_bottom(侧边栏)

文件:templates/default/index.php

<aside class="sidebar">
    <div class="sidebar-header">全部分类</div>
    <?php Plugin::hook('sidebar_top'); ?>
    <?php foreach ($categories as $cat): ?>
        <a href="...">...</a>
    <?php endforeach; ?>
    <?php Plugin::hook('sidebar_bottom'); ?>
</aside>

4. site_list_before / site_list_after(站点列表)

文件:templates/default/index.php

<div class="card-grid" id="site-grid">
    <?php Plugin::hook('site_list_before'); ?>
    <?= renderSiteCards($currentSites ?? [], $showWeight) ?>
    <?php Plugin::hook('site_list_after'); ?>
</div>

5. before_content / after_content(详情内容)

文件:templates/default/site.php

<div class="site-details">
    <?php Plugin::hook('before_content', [$site ?? []]); ?>
    <div class="detail-row">
        <div class="detail-label">描述:</div>
        <div class="detail-value">...</div>
    </div>
    ...
</div>

<?php Plugin::hook('after_content', [$site ?? []]); ?>

6. before_footer / after_footer(页脚)

文件:templates/default/footer.php

<?php Plugin::hook('before_footer'); ?>
<footer class="site-footer">
    ...
</footer>
<?php Plugin::hook('after_footer'); ?>
</body>
</html>
自定义主题注意事项

开发自定义主题时,需要在上述 8 个位置放置对应的 Plugin::hook() 调用,否则广告插件启用后也不会显示广告。钩子参数说明:

  • before_contentafter_content 需要传入站点数组 [$site ?? []],方便插件访问当前站点数据
  • 其他钩子不需要参数,直接调用 Plugin::hook('钩子名') 即可

5.3 文章发布 (article)

启用后前台侧边栏出现「文章专栏」入口,后台侧边栏出现「文章管理」菜单。支持 Markdown/HTML 内容、分类、标签、草稿/发布状态。

数据库:创建 articles 表(id、title、slug、content、excerpt、author、category、tags、status、views、created_at、updated_at)+ 2 条配置(每页显示数、是否允许投稿)。

钩子与模板挂接

钩子位置说明
sidebar_bottom前台在侧边栏底部注入「文章专栏」入口链接
admin_sidebar后台在后台侧边栏注入「文章管理」导航链接

sidebar_bottom 挂接代码

文件:templates/default/index.php

<aside class="sidebar">
    <div class="sidebar-header">全部分类</div>
    <?php Plugin::hook('sidebar_top'); ?>
    ...
    <?php Plugin::hook('sidebar_bottom'); ?>
</aside>
注意事项

sidebar_bottom 被多个插件共享使用(广告、虫洞联盟、每日一言等都在此处注册)。主题只需调用一次 Plugin::hook('sidebar_bottom'),所有注册的插件内容都会按优先级顺序输出。

5.4 虫洞联盟 (wormhole)

站点互推机制:联盟成员在页面嵌入 JS,互相展示链接实现流量互传。

状态说明
none未加入联盟
manual后台手动加入(不检测)
autoJS 上报自动加入(每日检测)
pending待审核
broken连续检测失败 3 次,已移出

钩子与模板挂接

钩子位置说明
sidebar_bottom前台在侧边栏底部注入虫洞联盟入口链接("🌀 虫洞联盟")
admin_sidebar后台在后台侧边栏注入「虫洞联盟」管理入口

sidebar_bottom 挂接代码

文件:templates/default/index.php

<aside class="sidebar">
    ...
    <?php Plugin::hook('sidebar_bottom'); ?>
</aside>

虫洞联盟入口(主题中硬编码链接)

默认主题在 footer.php 中还硬编码了一个虫洞联盟入口链接:

<!-- templates/default/footer.php -->
<a href="<?= Theme::eAttr(Rewrite::url('wormhole')) ?>" target="_blank">🌀 虫洞联盟</a>

外站嵌入代码

联盟成员需要在页面中嵌入以下 JS:

<script>
(function(){
    var d=document,s=d.createElement('script');
    s.src='https://你的主站/api/?endpoint=wormhole.js';
    s.async=1;
    d.body.appendChild(s);
})();
</script>

定时检测:每天凌晨 3 点执行 core/cron_wormhole_check.php,抓取 auto 成员检查是否包含联盟代码。

0 3 * * * php /path/to/core/cron_wormhole_check.php

5.5 友链自动收录 (auto-link)

当用户从挂了导航站友链的外站点击进入时,系统自动检测来路、验证回链、抓取 TDK、检查违禁词,通过后自动收录。

工作流程:PHP 渲染首页时捕获 Referer → 过滤本站和搜索引擎 → JS 延迟 2 秒发送 /api/?endpoint=auto-link&ref=xxx → 后端抓取对方首页验证回链 → 抓取 TDK → 检查违禁词 → 插入数据库。

配置项:autolink_enable(开关)、autolink_review(是否需要审核)、autolink_cat_id(默认分类)、autolink_ban_words(违禁词)、block_all_ip(全局IP屏蔽)。

安全机制:前端 Referer 预过滤、搜索引擎排除、内网地址防护、黑名单检查、频率限制(6小时3次)、回链验证、违禁词检查、重复域名检查。

钩子与模板挂接

钩子位置说明
after_footer前台在页面底部注入自动收录检测 JS(延迟2秒异步执行)
admin_settings_nav后台在基础设置页注入「友链收录」Tab 导航
admin_settings_tabs后台在基础设置页注入「友链收录」Tab 内容面板

after_footer 挂接代码

文件:templates/default/footer.php

<?php Plugin::hook('before_footer'); ?>
<footer class="site-footer">...</footer>
<?php echo $chartJs; ?>
<?php echo $siteJs; ?>
<?php Plugin::hook('after_footer'); ?>
</body>
</html>

自动收录 JS 注入效果

插件通过 after_footer 钩子注入一段延迟执行的 JS:

<script>
(function(){
    var referer = '捕获的HTTP_REFERER值';
    if (!referer) return;
    setTimeout(function(){
        var img = new Image();
        img.src = '/api/?endpoint=auto-link&ref=' + referer + '&_t=' + Date.now();
    }, 2000);
})();
</script>
主题集成要点

主题 footer.php 中必须调用 Plugin::hook('after_footer'),否则自动收录插件启用后不会注入检测 JS,导致功能无法工作。钩子需要放在 </body> 标签之前。

5.6 伪静态设置 (rewrite)

URL 伪静态模式配置,支持动态和伪静态两种模式。可自定义各页面 URL 格式,自动生成 Apache .htaccess 和 Nginx 配置规则。

启用后在后台「基础设置 - 伪静态」Tab 配置。支持子目录部署和自定义 URL 格式。

Rewrite.php 内置 URL 格式默认值($defaults 数组),不依赖 settings 表,插件启用时才将配置写入数据库。

5.7 提交网站收录 (submit)

前台提交网站入口,支持表单提交、验证码、TDK 自动获取、审核流程控制。

配置项:提交开关、是否需要审核、默认分类、违禁词、频率限制、提交说明文本。

钩子与模板挂接

钩子位置说明
search_bar_after前台在搜索栏右侧注入「提交站点」按钮

search_bar_after 挂接代码

文件:templates/default/index.php

<div class="hot-tags">
    <span class="hot-tag" onclick="searchTag('AI')">AI</span>
    ...
    <?php Plugin::hook('search_bar_after'); ?>
</div>

插件注入效果

插件注册 search_bar_after 钩子,输出「提交站点」按钮 HTML:

<a href="/submit/" class="submit-btn">
    <i class="ti ti-plus"></i> 提交站点
</a>

5.8 站点地图 (sitemap)

自动生成 sitemap.xml,帮助搜索引擎收录站点。配置项:是否包含已下线站点。

此插件无前台钩子,在后台「基础设置」中通过 admin_settings_navadmin_settings_tabs 注入设置 Tab。访问 /sitemap.xml 即可获取动态生成的站点地图。

5.9 图片灯箱 (lightbox)

纯前端钩子插件,无数据库影响。详情页图片点击放大,自动给图片加 data-lightbox 属性。内置轻量灯箱实现,无需外部依赖。

钩子与模板挂接

钩子位置说明
after_footer前台在页面底部注入灯箱 CSS + JS

after_footer 挂接代码

文件:templates/default/footer.php

<?php Plugin::hook('before_footer'); ?>
<footer class="site-footer">...</footer>
<?php Plugin::hook('after_footer'); ?>
</body>
</html>

插件注入效果

灯箱插件通过 after_footer 钩子注入一段 CSS + JS,自动将 .site-details img.article-content img 的点击事件绑定为灯箱展开:

<style>
.lightbox-overlay { display:none; position:fixed; ... }
.lightbox-overlay.active { display:flex; }
</style>
<div class="lightbox-overlay" id="lightboxOverlay">...</div>
<script>
(function(){
    var selector = '.site-details img, .article-content img';
    var imgs = document.querySelectorAll(selector);
    imgs.forEach(function(img){
        img.setAttribute('data-lightbox', 'plugin');
        img.style.cursor = 'zoom-in';
        img.addEventListener('click', function(e){
            openLightbox(this.src);
        });
    });
})();
</script>

支持键盘 ESC 键关闭灯箱,无需额外配置。

5.11 邮箱通知 (notify)

通过原生 PHP socket 实现 SMTP 邮件发送,在站点提交、审核通过/拒绝、用户反馈时自动通知管理员和提交者。

触发场景

场景触发钩子通知对象说明
前台提交站点site_submitted管理员(always)始终通知站长,不受邮箱配置影响
审核通过site_approved管理员 + 提交者(如有邮箱)"通过"按钮或"编辑并发布"都会触发
审核拒绝site_rejected管理员 + 提交者(如有邮箱)拒绝操作触发,提交者无邮箱则跳过
用户反馈feedback_submitted管理员(always)收到反馈后通知站长

数据库影响

影响说明
notify_logs记录每次邮件发送的状态、收件人、主题和失败原因
sites.submit_email向 sites 表添加字段,存储前台提交者填写的联系邮箱
11 条配置项SMTP 服务器、端口、用户名、密码、发件人、收件人、4个通知开关

SMTP 配置

启用后进入后台「基础设置 - 邮箱通知」Tab 配置:

配置项说明
SMTP 服务器如 smtp.qq.com、smtp.163.com
端口常用 465(SSL)或 587(TLS)
用户名/密码邮箱账号和授权码(非登录密码)
加密方式ssl / tls / none
发件人邮箱/名称邮件中显示的发件人
收件人邮箱管理员通知邮箱,多个用英文逗号分隔
通知开关分别控制提交/反馈/通过/拒绝四种通知

测试发送

插件管理页面(/admin/plugin.php?p=notify)提供:

  • SMTP 测试发送:输入任意邮箱地址,立即发送测试邮件验证配置是否正确
  • 发送日志:分页查看所有通知记录,支持按类型/状态筛选
  • 清空日志:一键清空历史发送记录

通知行为细节

  • 无邮箱的提交者:通过/拒绝通知只发送给管理员,不尝试通知提交者(不会报错或空发)
  • 审核通过:在「待审核」页直接点「通过」、点「编辑并发布」、或在「站点管理」编辑 pending 站点为 published,都会触发 site_approved 钩子
  • HTML 邮件模板:内置响应式邮件模板,含站点名称、URL、审核结果和操作按钮
  • 失败重试:SMTP 连接失败时记录错误日志但不中断页面流程

提交者邮箱采集

前台提交表单(/templates/default/submit.php)已增加邮箱输入框。API 端点 /api/?endpoint=submit 接收并写入 sites.submit_email 字段。没有邮箱的提交不影响正常收录。

第六章 参考文档

6.1 API 接口

所有 API 通过 /api/?endpoint={端点名} 访问。

端点方法说明CSRF
sitesGET获取分类站点列表
featuredGET获取推荐站点
siteGET获取站点详情
searchGET搜索站点
submitPOST提交站点
clickPOST记录点击
fetch-tdkPOST获取 TDK + 权重
update-metaPOST更新站点 TDK + 权重
ratePOST提交评分
feedbackPOST提交反馈
wormholeGET联盟成员列表(需启用虫洞插件)
wormhole.jsGET联盟 JS 脚本
wormhole-teleportGET虫洞传送
wormhole-joinGET加入上报
auto-linkGET友链自动收录(需启用插件)
插件守护

wormhole、auto-link、submit 相关的 API 端点在对应插件未启用时会返回 403 或透明 GIF,不会报错。

6.2 日志系统

通过 Logger::log($channel, $message) 写入日志,按日期分目录、按频道分文件存储。

// 写单条日志
Logger::log('admin_site', "[编辑] 站点ID={$siteId},结果=成功");

// 批量写日志
Logger::logs('wormhole_check', [
    "检测通过:site_id=1",
    "检测失败:site_id=3,原因=404",
]);

// 获取日志文件路径
$file = Logger::getLogFile('wormhole_join');
// 返回:data/logs/20260808/wormhole_join.log

日志目录:data/logs/YYYYMMDD/{channel}.log

全局开关:log_global(设为 0 关闭所有日志)。各频道独立开关:log_{channel}

常用频道:admin_auth(登录审计)、admin_site(站点操作)、database_error(SQL错误)、security_ratelimit(频率限制)、wormhole_check(虫洞检测)、autolink(自动收录)、plugin_error(插件错误)、plugin_info(插件信息)。

6.3 伪静态配置

伪静态系统支持两种模式:

模式首页分类页详情页
dynamic//index.php?route=category&slug=tech/index.php?route=site&id=1
rewrite//category/tech//site/1/

启用 rewrite 插件后,后台自动生成 Apache .htaccess 和 Nginx 配置规则,可一键复制。

6.4 安全规范

  • 输出转义:所有输出到 HTML 的内容必须使用 Theme::e()Security::e();属性值使用 Theme::eAttr()
  • URL 生成:必须使用 Theme::url() 生成 URL,不能硬编码
  • CSRF 防护:所有 POST 表单必须包含 Security::csrfField(),后端使用 Security::verifyCSRFToken() 校验
  • 输入过滤:使用 Security::cleanString() 清洗字符串、Security::int() 清洗整数、Security::cleanHtml() 清洗 HTML
  • 频率限制:使用 Security::rateLimit($key, $maxCount, $windowSeconds) 防止刷接口
  • SQL 注入:所有数据库查询使用 PDO 预处理(Database::query()Database::execute() 等),禁止拼接 SQL
  • 文件安全data/logs/ 目录不可通过 Web 直接访问(已默认受 .htaccess / Nginx 规则保护)
文档结束

如需了解更多细节,请查阅 core/ 目录下的源代码,或查看 plugins/ 目录中内置插件的实际实现。