开发者文档中心

插件开发规范、核心 API 与 Hooks 速查,以及发布流程

16 个章节

bbs1.org 插件开发 AI 规则

本文件是 AI 新建、修改和审查 bbs1.org 插件时的完整规范。开始工作前先读完本文件,再检查核心函数和功能最接近的现有插件;实现时以当前代码为准,不臆造接口。

执行顺序

  1. 明确插件 ID、功能边界、配置项、数据归属、页面入口、权限要求、外部请求和计划任务。
  2. 优先复用核心函数、Hook、路由、后台标签和相邻插件的成熟模式;插件机制能够完成时,不修改 index.php 或核心资源。
  3. 只在 app/plugins/插件ID/plugin.php 内实现插件逻辑,固定 CSS 和 JavaScript 由 manifest 的 assets 提供。
  4. 新建插件时验证默认配置、安装、启用、停用和卸载;修改插件时兼容旧配置与旧数据,并至少递增补丁版本;涉及用户可感知能力时同步更新描述。
  5. 完成后执行 PHP 语法检查、差异检查,并按本文件末尾的清单复核。

基础约束

  • 兼容 PHP 8.1、SQLite、MySQL 和 PostgreSQL,不引入框架、Composer 包或构建依赖。
  • 插件目录和 manifest id 使用相同 ID,只包含小写字母、数字、下划线或短横线。插件自有的 PHP、CSS、JavaScript、浏览器存储和文件名称必须以该 ID 开头,禁止使用无前缀的通用名称,防止与核心或其他插件冲突。
  • 命名空间前缀固定:PHP 函数使用 foo_bar_,常量使用 FOO_BAR_,类、接口、Trait、Enum 使用 FooBar 前缀或包含 FooBar 的命名空间;JavaScript 函数、顶层变量和全局变量使用 foo_bar_;私有 Hook 使用 foo_bar.。CSS 类、CSS ID、CSS 变量、data-* 属性和自定义事件使用插件 ID 的连字符形式,例如 foo-bar-*、--foo-bar-*、data-foo-bar-*。
  • manifest 中注册已有核心 Hook 时,必须使用核心定义的原始名称,例如 topic.after_save;只有插件自行定义并通过 hook() 或 fire() 调用的私有 Hook 才必须以插件 ID 开头,例如 foo_bar.after_import。不得以兼容为由定义或保留无前缀的插件私有别名。
  • 禁止以 function_exists()、class_exists() 或类似兼容分支定义无前缀的插件函数、类或常量。插件之间不得直接调用对方的函数、类或常量,也不得依赖对方的 CSS/JavaScript;确需共享代码能力时,由提供方插件注册以自身 ID 为前缀的私有 Hook(如 foo_bar.render_card),消费方插件通过 hook() 调用并传入缺省值:提供方未安装或未启用时 Hook 无人注册、原值返回,消费方据此优雅降级,不得出现依赖错误。仅全站通用的能力才移入核心,并使用正式的核心函数或 Hook。
  • plugin.php 开头必须包含 if (!defined('APP_ROOT')) exit;。
  • manifest 必须准确声明 id、name、version、description、author;按需声明 assets、hooks、routes、admin_tabs、cron、install、uninstall。
  • description 面向普通用户,只说明用户可感知的功能和收益,语言简短易懂;不得写技术实现、协议或依赖、数据库与任务调度等技术名词,也不得写版本更新点或开发说明。
  • 所有插件源码修改,无论是功能、修复、样式、脚本还是重构,都必须同步提升该插件 manifest 的 version,至少递增补丁版本;不得沿用原版本号。涉及用户可感知能力时,description 也必须与当前能力一致。
  • 插件读写路径使用 DATA_DIR、PLUGIN_DIR、UPLOAD_DIR 等核心常量,禁止硬编码部署目录;缓存和运行数据统一放入 DATA_DIR,公开附件地址由附件所属插件生成,共享上传目录分片使用 upload_hash_dir()。

最小插件结构:

<?php
if (!defined('APP_ROOT')) exit;

function hello_install(array $plugin): void
{
    $t = app_db_types();
    app_db_create_table('plugin_hello_items', "id {$t['id']},item_key {$t['key']} NOT NULL UNIQUE,title {$t['string']} NOT NULL,created_at {$t['uint']} NOT NULL");
}

function hello_css(): string
{
    return '.hello-message{color:var(--brand);font-weight:600}';
}

function hello_footer($html, array $ctx): string
{
    return (string)$html . '<span class="hello-message">Hello</span>';
}

return [
    'id' => 'hello',
    'name' => 'Hello',
    'version' => '1.0.0',
    'description' => '在页脚显示问候信息。',
    'author' => 'your-name',
    'assets' => ['css' => 'hello_css'],
    'hooks' => ['page.footer' => 'hello_footer'],
    'install' => 'hello_install',
];

核心约束:禁止循环内数据库查询(N+1)

这是插件开发的最上位性能约束。它限制的不是“渲染钩子里查一次库”,而是“查库次数随页内条目数线性放大”。判据是量级:查询数若为 O(页内行数)(列表逐行、回帖逐条、同一页重复触发)则为违规;若恒为 O(1)(整页单次触发、批量预取、内存映射)则合规。

平台对帖子列表、回帖等以“逐条触发渲染钩子”的方式渲染(如 topic.after_render 在列数 / 首页每行、reply.after_render 在每个回帖)。若这些反复触发的路径里每渲染一条就查一次库,整页产生与行数相等的 N+1 查询,量级从 O(1) 退化为 O(行数)。

判红标准(触犯即不合格):

  • 在 for / foreach / while / 列表循环 / 回帖逐条循环内,每渲染一条就查一次库(即使用了缓存,只要每个不同 key 的首次查询仍发生在循环里,仍属结构性 N+1)。
  • 在帖子、回帖、勋章、列表、用户或统计的循环体中逐条查询关联数据。

不属违规的情形:某个钩子整页只触发一次且不随行数放大(如 topic.after_render 在主题查看页只对主楼执行一次、受 list/marker 限定、只落在详情页单点),允许单次查询;这类“单点渲染”虽可用,仍建议批量预取。不要把「详情页单次查询」误当成违规主体。

任何合规场景一律遵循“先收集、再批查、后映射”三步:

  1. 先收集整批对象 ID(内存);
  2. 用分块 IN (...) 一次批量读取(SQLite/MySQL/PostgreSQL 通用);
  3. 在内存中按 ID 建立映射,渲染阶段只读内存映射,不再查库。

当“逐条渲染钩子拿不到整页对象集合”(无法在一条查询里覆盖整页)时,使用「唯一占位符 + 页面级批量回填」(见下),而不是退回在循环内逐条查库。

常用方案速查:

场景首选方案
列表 / 批处理收集 ID → 分块 IN (...) → 内存映射
同一请求重复读同批数据请求级缓存($GLOBALS)
跨请求持久缓存save_settings_values() + settings_rows_cache()
逐条渲染钩子、整页对象不可预知唯一占位符 + 页面级钩子整页一次性回填
绕开整页流程的片段(AJAX 返回局部 HTML)就地少量查询直接渲染,不得遗留占位符

占位符标准规范

用于“逐条渲染钩子拿不到整页对象集合,却必须禁止循环内查库”的场景。

格式:

<!--{插件ID}-{token}-{主键ID}-->
  • 插件ID:manifest 的 id,默认小写字母/数字/下划线/短横线,例如 medal。
  • token:请求内随机串;同一请求内所有占位符共用一个。用 random_bytes() 生成 6~12 字节再 hex 化,防止把用户输入反馈中伪造的同形注释误当占位符,也避免碰撞。
  • 主键ID:待回填对象的无符号整数主键(uid、帖子 ID 等)。
  • 占位符整体只能由插件按白名单生成,绝不允许直接用用户输入拼接。

回填流程(必须在“拿到完整整页 HTML”的钩子里完成,例如核心 page.before_render):

  1. 提点:逐条渲染钩子内只插入唯一占位符,并把对象主键记入请求作用域内存;页内无已启用对象时整页不埋占位符。
  2. 合并取回:在整页钩子里收集对象 ID 去重,用一条 IN (...) 批量取回,构建“主键 → 渲染结果”映射。
  3. 一次替换:用 preg_replace_callback() 以 <!--插件ID-token-(\d+)--> 为模式(preg_quote() 转义 token)整页替换一次;对象不复存在或不应显示的替换为空字符串(让占位符就地消失)。
  4. 收敛:返回的最终 HTML 必须不含有未替换的占位符。

约束与安全:

  • 禁止按“用户名 / 作者名 / 任意字符串”做回填匹配;占位符自带的唯一主键是唯一可靠的定位手段,避免回填错位或破坏结构。
  • 对绕过整页模板的响应(AJAX / json_response 直接返回局部 HTML),整页回填钩子不会触发,此时必须就地做少量查询的直接渲染(允许单次小查询),绝不能留下未回填占位符。
  • 回填内容与普通内容一样在输出前经 h() 转义;占位符由前缀 + token + 数字组成,不含可注入文本。
  • 优先级顺序:能整段 IN 预取 + 内存映射、请求级/持久缓存时优先;占位符只用于逐条钩子无法整批的例外,且必须配套整页回填钩子。

生命周期与配置

  • 新插件放入目录后,需要在后台“插件”页执行“同步插件”。插件注册信息保存在 app_plugins,普通请求不会扫描插件目录。
  • 新插件默认停用;只有启用后才执行。市场安装、更新或重新安装后插件会自动停用,再次启用时执行当前版本的 install。
  • install 负责建表、补列和创建索引,且必须可重复执行;Schema 函数只能由 install 调用,不能出现在普通请求、页面渲染或业务函数中。
  • uninstall 只能删除插件明确拥有的表、缓存和文件。无法确认归属的用户内容或附件不得连带删除。
  • 修改现有插件时,旧配置缺少新字段不能报错;读取配置时集中补默认值,并归一化布尔值、枚举、字符串长度和数值上下限。
  • 保存配置前验证 $_POST、$_GET 和 JSON 输入,不能把原始请求数据直接交给数据库、文件系统或外部接口。

推荐把配置入口集中为一个函数:

function hello_config(): array
{
    $raw = plugin_config('hello', []);
    return [
        'enabled' => (int)($raw['enabled'] ?? 0) === 1,
        'interval_minutes' => min(1440, max(1, (int)($raw['interval_minutes'] ?? 60))),
    ];
}

数据库与性能

  • 表结构和跨数据库写入使用 app_db_*;普通参数化查询使用 q()、one()、val(),多步关联写入使用 tx() 保证原子性。
  • 插件表使用 plugin_插件ID_ 前缀,系统表保持 app_ 前缀。字段类型来自 app_db_types():ID、外键、计数和时间使用 uint,状态位使用普通 INTEGER。
  • 插件原则上不得修改 app_* 核心表结构。确有必要扩展核心表时,新增字段必须使用 plugin_插件ID_字段名 前缀,禁止使用 tags、status 等无插件归属的通用字段名;插件自有 plugin_* 表内部字段不需要重复插件前缀。
  • 核心表扩展字段由 install 使用 app_db_ensure_columns() 创建,并在“不保留数据”的 uninstall 中使用 app_db_drop_column() 删除;创建和删除都必须可重复执行,禁止直接拼接 ALTER TABLE ... ADD/DROP COLUMN。
  • 建表、补列、删列、索引和卸载分别使用 app_db_create_table()、app_db_ensure_columns()、app_db_drop_column()、app_db_create_index()、app_db_drop_index()、app_db_drop_table()。
  • Upsert 键必须有主键或唯一索引。新增或更新使用 app_db_upsert(),只防重复使用 app_db_insert_ignore();新增后使用 app_db_last_insert_id('表名'),不要直接调用 PDO 的 lastInsertId()。
  • 业务去重条件必须与唯一约束完全一致。按规则、频道或周期隔离的数据,唯一键应包含 group_key、channel、period_key 等范围字段,不能查询按复合范围判断而表结构只约束单列。
  • 多个时间表达式取最大值使用 app_db_greatest(),不要写只适配某一种数据库的 SQL。
  • 列表和批处理禁止循环逐条查询。先收集 ID,使用分块 IN (...) 一次读取,再在内存中建立映射。
  • Hook 优先复用 $ctx 和 $value。同一请求重复读取的数据使用请求级缓存;允许短暂延迟的数据可使用短期 Cookie 缓存。写入后主动失效相关缓存,不为验证缓存额外查询数据库。
  • 需要跨请求持久保存的缓存使用 save_settings_values() 写入并通过 setting() 读取,不生成 PHP 缓存文件。
  • 新增或更新主题后调用 topic_fts_sync(),新增或更新回帖后调用 reply_fts_sync();禁止直接读写 app_topics_fts 和 app_replies_fts。
  • 需要保存可搜索的结构化正文时使用标准 Markdown 表格。单元格换行转为空格,| 写成 \|;不要用 Base64 或私有编码隐藏可搜索内容。

典型写入:

app_db_upsert('plugin_hello_items', [
    'item_key' => $key,
    'title' => $title,
    'created_at' => now(),
], ['item_key']);

Hook、路由与界面

  • Hook 函数通常接收 ($value, array $ctx) 并返回修改后的值,返回 null 表示不修改。识别插件自有主题或回帖时,先检查专属内容标识,命中后才查询插件表。
  • 顶部栏入口使用 top.bar.actions Hook(每页执行一次),在 $value 的 left、right_before_search 或 right_after_search 数组中以插件 ID 为 key 写入 HTML;不要引入 slot 属性,不使用 CSS order、:has() 或根据其他插件存在性调整位置。left 位于版块导航之后,right_before_search 位于搜索框左侧,right_after_search 位于搜索框右侧。入口 HTML 应由服务端直接输出,插件 JavaScript 只绑定交互和状态;如需后台控制显示,manifest 使用 entries.top_actions。
  • 前台页面通过 manifest routes 注册,链接使用 route_url();后台页面通过 admin_tabs 注册。不要硬编码 index.php 查询串。
  • 涉及发帖、回帖、管理或用户数据的路由必须显式检查登录和权限,后台入口调用 need_admin()。修改状态的操作只接受 POST,并调用 require_post();表单包含 form_token()。
  • 所有外部数据和用户数据输出到 HTML 前使用 h()。URL 先由核心 URL 函数生成,再转义。
  • 固定 CSS 和 JavaScript 只能通过 manifest assets 声明。资源函数无参数并返回源码,不包含 <style>、<script> 标签,也不能依赖当前用户、页面、CSRF 或实时请求数据;动态值通过插件 HTML 的 data-* 属性传递。
  • 插件 JavaScript 需要定位核心 Hook 的页面承载元素时,使用 [data-slot~="hook.name"];同一元素可用空格声明多个 Hook 插槽。重复插槽先通过帖子 ID、data-floor、data-plugin-id 或插件自有根容器缩小范围,不依赖核心内部层级选择器。
  • 不直接修改自动生成的 app/assets/plugins.css 和 app/assets/plugins.js。启用、停用、卸载、市场安装、更新和后台同步插件时,系统会重建这些资源。
  • CSS 类名、ID、变量、data-* 属性、@keyframes、@property 和 container-name 必须使用插件 ID 前缀;JavaScript 函数、顶层变量、全局变量、自定义事件名、HTML id 和锚点也必须使用对应前缀。生成后的插件 JavaScript 在同一作用域执行,除必要的前缀化导出外,必须用具名 IIFE 隔离,避免顶层 const、let 或状态变量冲突。Cookie、localStorage、sessionStorage、BroadcastChannel 的插件键名同样必须前缀化。选择器限制在插件自己的根容器内;不要覆盖 body、通用标签、核心通用类或其他插件类,也不能依赖其他插件的样式。
  • 颜色优先使用系统变量:背景和边框使用 --bg、--panel、--line、--line-soft;文字使用 --text、--text-muted、--text-subtle、--text-disabled;品牌和交互使用 --brand、--brand-hover、--brand-soft、--focus-ring;状态使用 --success、--danger、--warning、--info 及对应 *-soft;反色、遮罩和阴影使用 --inverse、--inverse-border、--inverse-text、--backdrop、--shadow-base、--shadow-medium。
  • 界面字号统一使用 CSS 变量:--font-size-xxs 为 10px、--font-size-xs 为 11px、--font-size-sm 为 12px、--font-size-md 为 14px、--font-size-lg 为 16px、--font-size-xl 为 18px。font-size 与 font 中的字号禁止直接写数字;需要例外字号时先定义语义化变量,再使用该变量。
  • 只有还原第三方品牌或表达数据类别时才能在插件作用域内使用额外颜色;禁止无理由使用 !important。
  • 前后台界面都要处理窄屏、长文本、空数据、失败、权限不足和交互状态,避免固定宽度导致溢出。

前端 data-slot 接口

核心页面会在稳定的承载元素上输出 data-slot,供插件 JavaScript 查找和绑定交互。属性值以空格分隔;选择单个插槽必须使用 [data-slot~="..."],不能使用模糊的 [data-slot*="..."]。下表是当前核心提供的插槽,名称以源码为准:

data-slot 值页面位置 / 用途相关 PHP Hook
sidebar.feature_links首页侧栏“快捷功能”链接列表sidebar.feature_links
top.menu_links桌面端顶部版块导航;移动端菜单也复用top.menu_links
user.menu_links用户侧栏菜单;移动端菜单也复用user.menu_links
sidebar.stack整个侧栏容器sidebar.stack
mainpanel_extra主内容面板,扩展内容追加在主内容之后mainpanel_extra
topic.actions主题主楼操作条(主楼正文底部,引用、管理等)topic.actions
topic.after_render主题列表项或主题topic.after_render
reply.after_render回帖帖子项reply.after_render
topic.content_html主题主楼正文 HTML(post-content 内、操作条之前)post-content 正文区
reply.content_html回帖楼层正文 HTML(post-content 内、楼层操作条之前)post-content 正文区
topic.content_after主题主楼内容之后的扩展区域topic.content_after
reply.content_after回帖楼层内容之后的扩展区域reply.content_after
topic.title_suffix主题列表标题链接之后topic.title_suffix
top.actions顶部操作栏整体top.bar.actions
top.actions.left顶部版块导航右侧的操作区top.bar.actions
top.actions.right.before-search顶部搜索框左侧操作区top.bar.actions
top.actions.right.after-search顶部搜索框右侧操作区top.bar.actions
page.before_render页面主内容 <main> 容器page.before_render
page.template整页模板,默认值为完整 HTMLpage.template
page.template.before整页模板拼接前,返回字符串可完全接管 HTML 组装page.template.before
page.footer页面页脚容器page.footer
login.after_form登录面板(登录表单之后可追加内容)login.after_form
login.form_extra登录表单内部扩展字段login.form_extra
register.form_extra注册表单内部扩展字段register.form_extra
profile.after_form个人资料面板(资料表单之后可追加内容)profile.after_form
profile.settings_tabs个人设置页标签栏profile.settings_tabs
profile.settings_tab_content当前个人设置标签的内容区profile.settings_tab_content
user.profile_tabs用户资料页标签栏user.profile_tabs
topic.index_tabs首页 / 版块主题列表标签栏topic.index_tabs
topic.toolbar_actions首页 / 版块主题列表工具栏操作区topic.toolbar_actions
topic.index_template首页、版块、用户主题列表的整体模板topic.index_template
topic.index_template.before主题列表模板拼接前,返回字符串可完全接管 HTML 组装topic.index_template.before
topic.template主题详情页的整体模板topic.template
topic.template.before主题详情模板拼接前,返回字符串可完全接管 HTML 组装topic.template.before
reply.form_extra回帖表单内部扩展字段reply.form_extra
attachment.uploader发帖或回帖表单的附件上传区域attachment.uploader
admin.plugin.actions后台每个插件条目的操作区admin.plugin.actions

同一元素可能声明多个值,例如发帖表单的 data-slot="attachment.uploader topic.form_extra"。JavaScript 示例:

(function () {
    const form = document.querySelector('[data-slot~="topic.form_extra"]');
    if (!form) return;
    form.addEventListener('change', function (event) {
        // 只处理插件自己的控件。
        if (!event.target.matches('[data-my-plugin-field]')) return;
    });
}());

data-slot 只保证核心扩展位置和语义,不保证内部子元素层级或每页出现次数。主题列表、主题详情和回帖中的 topic.after_render 可能出现多次,必须结合 id="post-..."、data-floor 或插件自己的根容器缩小范围;页面级插槽通常每页只有一个。通过 AJAX 返回的局部 HTML 也可能重新生成插槽,插件应使用事件委托或在替换后重新初始化。

manifest 注册形式:

'assets' => ['css' => 'hello_css', 'js' => 'hello_js'],
'routes' => ['hello' => 'hello_page'],
'admin_tabs' => ['hello' => 'hello_admin_page'],

安全、文件与外部请求

  • 文件名和路径必须经过白名单验证,防止路径穿越。上传和远程文件限制协议、主机、类型和大小,并拒绝内网地址、凭据 URL 和脚本文件。
  • 插件自有的目录、文件、锁、临时文件、日志文件和公开附件命名必须包含插件 ID;不得在共享目录创建 cache、lock、log 等无前缀的通用名称。
  • 外部 HTTP 请求设置连接超时、总超时、重定向上限、响应大小和明确的 User-Agent;跟随重定向时逐跳重新验证目标。
  • Cookie、Token、密码和密钥不得出现在页面、日志、错误信息、队列键或公开文件中。
  • 错误信息保持简短,不暴露凭据、敏感请求头或完整 SQL。远程失败应可重试,但不能无限同步阻塞用户请求。
  • 插件权限等同站点代码,只实现任务需要的访问范围,不读取或修改无关数据。

计划任务、采集与队列

  • 计划任务通过 manifest cron 注册。任务名在插件内唯一,callback 是已定义的函数名;回调可以不接收参数,也可以接收插件 manifest 和当前任务配置。
  • interval 可以是 60 至 31536000 的秒数,也可以是返回秒数的插件函数名。后台可配置间隔时使用间隔函数。
  • 只有启用的插件进入统一调度。不要依赖普通页面请求触发任务,也不要添加心跳或 cron 部署探测。
  • 回调必须可重复运行,并使用互斥锁、唯一来源键和幂等写入。多请求或可续跑任务使用可重试队列,queue_key 必须唯一,消费索引使用 (failure_count,id)。
  • 失败任务达到重试上限后默认暂停 30 分钟;暂停到期后清零失败次数并恢复,不能让失败任务永久阻塞队列。
  • 同一批远端记录先收集来源 ID,再批量查询已入库记录和待处理队列;禁止在远端列表循环中逐条查询。
  • 去重策略必须匹配数据性质:不可变历史数据按来源键存在即跳过;可刷新数据比较内容指纹,仅在变化时更新内容、搜索索引和 updated_at;周期归档把周期加入唯一键。
  • 外部记录保存来源 ID、来源 URL、首次创建时间、最后看到时间和最后更新时间。图片先按规范化来源 URL 的 SHA-256 键复用下载结果,再按文件内容哈希复用附件;不要把长度不可控的完整 URL 作为跨数据库主键。
  • 采集正文、图片或回帖必须遵循配置。功能关闭时不应先请求远端再丢弃结果。

计划任务示例:

function hello_cron_interval(): int
{
    return hello_config()['interval_minutes'] * 60;
}

function hello_collect(array $plugin, array $task): string
{
    // 加锁后执行可重复运行的采集或清理任务。
    return 'done';
}

// manifest
'cron' => [
    'collect' => [
        'callback' => 'hello_collect',
        'interval' => 'hello_cron_interval',
    ],
],

跨库 SQL 速查(SQLite / MySQL / PostgreSQL 三驱动)

db_driver() 有三个取值:sqlite、mysql、pgsql。只写 db_driver() === 'mysql' ? A : B 两分支是不合格的:else 一旦写成 SQLite 专有语法,PostgreSQL 站点会直接报错。真实故障例(2026-09-22):$expr = db_driver() === 'mysql' ? "FROM_UNIXTIME(...)" : "date(created_at,'unixepoch')" → PG 报 function date(integer, unknown) does not exist,整个后台「活跃趋势」打不开。

三驱动一律用 match (db_driver()) 显式列出,不要省略 pgsql 分支:

需求SQLiteMySQLPostgreSQL
自增主键INTEGER PRIMARY KEY AUTOINCREMENTINTEGER UNSIGNED PRIMARY KEY AUTO_INCREMENTINTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY
整数时间戳 → 日期date(created_at,'unixepoch')(UTC)FROM_UNIXTIME(created_at,'%Y-%m-%d')(会话时区)to_char(to_timestamp(created_at),'YYYY-MM-DD')(会话时区)
分页LIMIT n OFFSET m同左同左(LIMIT m,n 只有 MySQL/SQLite 认,PG 报 LIMIT #,# syntax is not supported)
upsertON CONFLICT(键) DO NOTHING / DO UPDATE SET x=excluded.xINSERT IGNORE / ON DUPLICATE KEY UPDATE x=VALUES(x)同 SQLite
大小写不敏感 LIKELIKELIKEILIKE

优先用核心助手,别自己写驱动分支(自己写就是这次出错的根因):

  • 建表类型:app_db_types() → $t['id'] / ['uint'] / ['key'] / ['string'] / ['text'];
  • 标识符引用 app_db_identifier()、占位符 sql_marks()、upsert app_db_upsert()、取刚插入的主键 app_db_last_insert_id()(PG 下走 pg_get_serial_sequence,不能用 lastInsertId())、MAX/GREATEST 用 app_db_greatest()。

交付检查

  • 使用当前项目已有函数和相邻插件模式,没有复制功能重复的基础设施。
  • 保持原生 PHP 风格、参数类型明确、分支可读、错误信息简短,不为减少行数牺牲可维护性。
  • 普通页面没有新增不必要的数据库查询、外部请求或同步耗时操作。
  • 已按 N+1 判据核对:列表循环 / 回帖逐条 / 同一页重复触发的路径内无逐条投库查询;详情页单次渲染与批量预取不算违规;使用占位符的路径已确认最终 HTML 无残留占位符。
  • 配置默认值、非法输入、边界值、旧配置和旧数据均可正常处理。
  • 数据库占位符、唯一约束、索引、事务和 SQLite/MySQL/PostgreSQL 兼容性已检查:所有 db_driver() 分支都显式覆盖 pgsql(见「跨库 SQL 速查」),未出现 LIMIT m,n、AUTOINCREMENT、date(x,'unixepoch') 这类只在 MySQL 或 SQLite 成立的写法。
  • 只要动过 plugin.php 一行(含注释、空白、行尾),manifest 的 version 就必须递增;只改核心文件时不要去动插件版本号。漏升的后果是实打实的:后台「可更新」提示由 Plugin::plugin_market_update_available() 的 version_compare($remote, $local, '>') 判定,版本号不变就永远不提示更新;插件导出文件名是 插件ID_版本.php1,同版本号会互相覆盖。manifest 在文件末尾,核对时以最后的 return [...] 为准,不要取文件里第一处 'version'。
  • 插件描述已同步更新,未修改插件职责之外的核心文件或生成资源。
  • 已运行 php -l app/plugins/插件ID/plugin.php 和 git diff --check。
  • 交付说明列出行为变化、迁移影响、验证结果,以及未执行的外部副作用操作。

界面与图标速查

优先使用核心 svg_icon(),图标继承 currentColor 并自动适配主题。当前常用图标包括:
user(用户)、id(身份)、reply(回复)、notify(通知)、forum(版块)、
topic(主题)、view(浏览)、settings(设置)、admin(管理)和 pages(文档)。

插件自带 SVG 应使用 viewBox="0 0 24 24"、fill="none"、stroke="currentColor",主轮廓使用 stroke-width="2",尺寸交由 CSS 控制。需要新图标时优先反馈给核心加入 svg_icon(),避免重复实现。

官方资源

核心 API 速查

分组函数
数据库q() one() val() rows_by_ids() row() del() tx(callable) app_db_upsert() app_db_insert_ignore()
表结构app_db_create_table() app_db_drop_table() app_db_create_index() app_db_drop_index() app_db_table_exists() app_db_columns() app_db_ensure_columns() app_db_index_exists()
身份权限uid() me() need_login() need_admin() need_manage() can_manage() can_manage_topic() can_manage_reply() can_speak() is_super_user() forum_group_allowed()
积分user_points_change($user_id, $delta, $reason = '系统调整', $notify = false, $context = [])(自带事务,勿在 tx() 内调用)
页面渲染page() shell_html() sidebar_stack_html() sidebar_user_card_html() form_shell() paginate() page_seo() page_head_html() page_nav_html() page_footer_html() admin_list_head()
表单form_token() hidden_inputs() input() textarea() checkbox() number_input() select_input() post_action_form() render_form_fields()
跳转/提示route_url() admin_url() base_url() go() set_flash() err() json_response()
工具h() cut() now() human_time() app_cookie() svg_icon() avatar_tag() avatar_link_tag() avatar_remote_url()
论坛数据forum_by_id() forums_cache() select_forum() refresh_topic_stats() pinned_topic_ids() create_notification() notifications_unread_total() mark_notifications_read()
列表渲染topic_list_row($row, $sort) topic_list_select_columns() rows_by_ids() attach_topic_list_users()(列表行会自动整页预载,见「列表行批量预载」) topic_list_preload($rows)(仅行不经 attach_topic_list_users() 等特殊场景需手动调用)
全文检索topic_fts_sync() reply_fts_sync() topic_fts_delete() reply_fts_delete() content_search_condition() search_index_available() search_index_rebuild() search_like_pattern()
插件plugins() plugin_load() plugin_config() plugin_save_config() plugin_id_valid() plugin_registry_row() plugin_enabled() plugin_uses_entry() plugin_entry_enabled() plugin_call()

Hooks 与展示位置

Hook触发时机备注
app.boot全站每个请求启动一次预加载数据的最佳位置
topic.before_render / reply.before_render主题/回帖渲染前红区,调用链零 DB 读
topic.after_render / reply.after_render主题/回帖渲染后循环内零 DB 读;用于修改楼层 HTML 本身(徽章、样式等)
topic.content_html / reply.content_html主题主楼/回帖楼层的正文 HTML(替换式管道)替换/包装正文的唯一可靠位置,插件勿自行 strrpos/正则定位正文;无输出必须原样返回 $value;ctx 带 row/body/topic_id,循环内零 DB 读。
topic.content_after / reply.content_after主题主楼/回帖楼层正文末尾追加内容追加式管道:返回 $value . 自身输出,无输出必须原样返回 $value(返回空串会覆盖他人输出);插入位置与锚点由核心维护,插件勿自行 strrpos 定位
post.ops_actions主题主楼与每个回帖的操作条(正文底部)逐楼钩子,循环内零 DB 读;条目可选左侧或“更多”弹层,详见下方“帖子操作条与更多弹层”
topic.before_save / topic.after_save主题保存前后处理主题数据
reply.before_save / reply.after_save回帖保存前后处理回帖数据
topic.replies主题页回帖集合每页一次,可整体预加载
page.before_render整页输出前占位符批量回填的唯一可靠位置
sidebar.stack侧栏组件栈value 和返回值均为数组
sidebar.feature_links侧栏快捷功能非循环展示位置
top.menu_links顶部版块导航/移动端版块列表链接数组,同一请求一次
top.bar.actions顶部栏插件入口left、right_before_search、right_after_search 三个区域
user.profile_tabs用户资料页标签栏展示位置:个人主页 Tab
user.profile_tab_allowed用户资料页标签页可见性判定渲染该标签页数据与内容前调用,详见下方“个人主页标签页可见性”
user.menu_links个人卡片与移动端我的菜单展示位置:个人卡片
register.form_extra / login.form_extra注册/登录表单附加区仅渲染表单扩展
profile.after_form个人资料页附加区ctx 含 user
profile.settings_tabs个人设置页标签栏value 为 Tab 数组,ctx 含 user、tab;展示位置:个人设置 Tab
profile.settings_tab_content当前个人设置标签内容value 为 HTML,ctx 含 user、tab、tabs;仅在非默认标签触发
admin.tabs后台顶栏标签value 为 items 数组
admin.plugins.tabs后台“插件”页顶部标签value 为 items 数组,后台插件页标题栏(Plugin.php)
admin.plugin.actions后台每个插件条目的操作区逐插件行追加操作按钮(Plugin.php)
admin.plugins.view后台插件页整体视图扩展返回附加 HTML(index.php)
notification.after_create通知写入后仅入队,勿同步请求外部服务
markdown.render / markdown.afterMarkdown 渲染前后after 可能逐行调用,禁止查库
page.seo / page.footerSEO 元信息/页脚返回值覆盖或追加
user.before_save / user.after_save用户保存前后before 可返回过滤数组

帖子操作条与更多弹层

内核在主楼和每个回帖的 .post-content 末尾渲染操作条 .post-ops:左侧为动作条目,右侧为楼层锚点(#楼层号 / 主楼“主楼”标签)、更多按钮与弹层 .post-ops-menu,弹层的开关、定位与内置“编辑”“复制链接”由内核处理。

新增动作用 post.ops_actions 钩子(逐楼触发,$ctx 含 row、is_reply、topic_id、floor,主楼 floor 为 0;返回 null 表示不修改):

function demo_ops_actions(array $items, array $ctx): array
{
    $items[] = ['html' => '<a class="icon-action icon-pages" href="..."><span>动作</span></a>', 'placement' => 'menu'];
    return $items;
}
  • placement:'menu'(默认,右侧弹层)或 'left'(操作条左侧)。
  • 条目用 icon-action + <span>文字</span> 即获统一基线;经 topic.actions / reply.after_render 注入的条目仍在左侧。
  • 逐楼钩子,遵守 N+1 红区。

整体模板 Hook

page.template.before、topic.index_template.before 和 topic.template.before 在核心拼接默认 HTML 前执行,初始 $value 为 null;回调返回字符串即可完全接管 HTML 拼接,返回 null 则继续使用核心默认拼接。对应的 page.template、topic.index_template 和 topic.template 在拼接后执行,可继续修改或完全替换最终 HTML。所有模板 Hook 均接收 ($value, array $ctx)。

  • page.template 的 $ctx 包含 title、body、seo、settings、site_name、page_title、meta、head_extra、header_html、flash。
  • topic.index_template 的 $ctx 包含 rows、total、page、page_size、offset、forum、user、forum_id、profile_tab、profile_tabs、sort、query、search_field、simple_pagination、has_next_page 等列表页数据。
  • topic.template 的 $ctx 包含 topic、forum、replies、page、page_size、offset、reply_order、replyid、floor 等主题详情数据。

topic.index_data.load / topic.replies_data.load 可以提供完整数据;核心仍会继续触发对应的 *.data.loaded Hook。*.data.loaded 回调返回数组时,返回值会作为后续模板的数据;返回 null 表示保留原数据。模板 Hook 适合整体换肤或完全自定义布局,局部扩展优先使用已有的细粒度 Hook。

行尾锚点(页面级精准插入用)

核心在 topic_post_row() 输出的每条帖子行(主贴与楼层)的 </li> 之前追加唯一 HTML 注释锚点,供插件在 page.before_render 做整页级定位。行尾结构固定为:

...[content_after 各插件输出]</div><!--ab:post:<主题id>--></li>
...[content_after 各插件输出]</div><!--ab:reply:<楼层id>--></li>
  • 产出函数:html_anchor('post', <主题id>) / html_anchor('reply', <楼层id>),两类 kind 永不冲突。
  • topic/reply.content_after 的插入点在「</div> + 锚点」之前,仍在 .post-content 内部,与引入锚点前的位置一致。

规则:

  • 需要在「主贴正文之后、本行之内」精准落位时,用 preg_quote($anchor, '/') 拼进正则匹配到锚点为止(如 '/占位符(.*?)<\/div><!--ab:post:<id>--><\/li>/'),禁止用 </div></li> 之类的通用结构序列定位——任何插件的 content_after 输出都可能含有该序列,会造成误插。
  • 锚点是核心专属契约,插件输出里不得伪造或移除 <!--ab:*--> 注释。
  • 核心过旧无锚点时,消费方应保留回退逻辑;只在锚点匹配失败时使用。

列表行批量预载:自动登记 + topic_list_preload()

核心的列表预载钩子 topic.index_data.loaded 用于按 IN (集合ID) 一次取回整页主题级数据(众筹、积分商城、微信红包、悬赏、回帖红包、标签、等级、投票、抽奖、猜谜、插件市场…)并写入插件的 $GLOBALS 缓存;逐行渲染钩子(topic.after_render / topic.title_suffix)只读这些缓存。

自行拼装 rows 再逐行 topic_list_row() 的页面无需为此写任何代码,预载已做在核心内部;前提是这份行集合经过 attach_topic_list_users() —— 它既是补用户名/头像的最后一道工序,也是预载的登记点:

  1. attach_topic_list_users($rows)(列表行的最后一道准备工序)顺手登记该行集合;
  2. 第一次 topic_list_row() 渲染前,核心合并去重、一次派发 topic.index_data.loaded;
  3. 已派发过的行绝不重复派发(批次闸门):页面自己已经显式派发过(例如先按可见性过滤再派发)时,核心不再补发。

实测(like_coin「我的点赞」单页 20 / 50 行):修复前 83 / 198 条 SQL,修复后恒为 13 条(其中 5 条是整页一次的插件预载,与行数无关)。

因此只有两种例外需要手动调用助手 topic_list_preload($rows):行不经过 attach_topic_list_users()(手工拼字段、数据来自外部接口 / 缓存 / JSON 等),或需要在 topic_list_row() 之前就完成预载(例如整段 HTML 先缓存)。

登记点为什么落在 attach_topic_list_users():登记必须同时满足「早于第一次渲染」和「完整行集合已知」两个条件。渲染循环内部(topic.before_render / topic.after_render)只看得到当前行,不满足后者;更上游又没有「查主题行的统一出口」(插件各自写 SQL,核心无从知晓)。attach_topic_list_users() 是核心列表页与插件自建列表页的唯一公共必经点,因此是这两个条件同时逼出来的位置,而不是随意选的。

$rows = ...;                                   // 自己拼装的行(字段同 topic_list_select_columns)
$rows = topic_list_preload($rows);             // 整页一次批量预载,行内插件数据在渲染前就绪
foreach ($rows as $row) $html .= topic_list_row($row, 'post');   // 循环内零 DB
  • 幂等:插件预载只补未缓存的 id,重复调用不会重复查库;空数组直接返回。
  • 插件需要兼容老内核时用 function_exists('topic_list_preload') 兜底:老内核没有该助手,退回逐行惰性查询而不报错。
  • 写行内数据插件时:把数据挂在 topic.index_data.loaded 上做批量预取,after_render / title_suffix 里只读缓存。这样首页、版块页与任何自建列表页都会自动获得批量预载;反过来,若在行渲染钩子里直接查库(one('… WHERE topic_id=?')),每个列表页都会退化成一页上百条 SQL。

个人主页标签页可见性

插件用 user.profile_tabs 注册标签页后,如需按访问者限制某个标签页是否可见,使用 user.profile_tab_allowed:

  • ctx:user(被访问的用户)、self(访问者是否本人)、tab(当前标签页 key)。
  • 返回值:true 或 null 放行;false 拒绝并显示核心默认提示;返回非空字符串则拒绝,并以该字符串作为提示文案。
  • 核心在渲染该标签页的数据(user.profile_tab_data)、头部(user.profile_tab_header)与尾部(user.profile_tab_footer)之前判定一次,拒绝时全部跳过。因此插件无需覆盖他人输出,也不受插件 ID 顺序影响。
  • 标签栏本身不会被移除,仍由 user.profile_tabs 决定;被拒绝时主区域显示提示文案。
function example_profile_tab_allowed($allowed, array $ctx): mixed
{
    $tab = (string)($ctx['tab'] ?? '');
    $user = is_array($ctx['user'] ?? null) ? $ctx['user'] : [];
    if ($tab !== 'example' || empty($user['id']) || !empty($ctx['self'])) return $allowed;
    return '因个人隐私设置,不对外开放访问';
}

entries 展示位置

声明对应 Hook 后,后台“插件 → 本地插件 → 展示位置”会出现开关。未声明 entries 时默认勾选;首次同步只补齐缺失值,已有后台选择会保留。

entries 键Hook实际显示位置
feature_linkssidebar.feature_links首页侧栏和移动端快捷功能区
sidebar_cardssidebar.stack侧栏卡片区域
home_tabstopic.index_tabs首页/版块列表顶部 Tab
profile_tabsuser.profile_tabs用户主页顶部 Tab
profile_settings_tabsprofile.settings_tabs个人设置页顶部 Tab
profile_carduser.menu_links侧栏个人卡片和移动端我的菜单
topic_actionstopic.actions主题首帖操作条(正文底部)左侧
admin_tabsadmin.tabs后台顶部 Tab
top_menutop.menu_linksPC 顶部版块区和移动端版块列表
top_actionstop.bar.actions版块导航、搜索框前后

发布与 AI 协作

  1. 完成本地验证后,在后台“插件 → 本地插件列表”找到目标插件并点击“分享”。
  2. 在官方发布页设置售价(0 为免费)、更新日志和协作者权限,然后提交审核。
  3. 后续修改必须提升版本号并重新测试,再重复分享流程。

给 AI 的最小提示:

请先完整阅读插件开发规范:PLUGIN.md
并检查最接近的现有插件。
请在 app/plugins/<插件ID>/plugin.php 开发插件。
需求:<清楚描述功能、入口、设置项和权限>
完成后请提升 version,执行 PHP 语法检查与差异检查。