# 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 Hello'; } 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`:manifest 的 `id`,默认小写字母/数字/下划线/短横线,例如 `medal`。 - `token`:请求内随机串;**同一请求内所有占位符共用一个**。用 `random_bytes()` 生成 6~12 字节再 hex 化,防止把用户输入反馈中伪造的同形注释误当占位符,也避免碰撞。 - `主键ID`:待回填对象的无符号整数主键(uid、帖子 ID 等)。 - 占位符整体只能由插件按白名单生成,绝不允许直接用用户输入拼接。 **回填流程**(必须在“拿到完整整页 HTML”的钩子里完成,例如核心 `page.before_render`): 1. **提点**:逐条渲染钩子内只插入唯一占位符,并把对象主键记入请求作用域内存;页内无已启用对象时整页不埋占位符。 2. **合并取回**:在整页钩子里收集对象 ID 去重,用一条 `IN (...)` 批量取回,构建“主键 → 渲染结果”映射。 3. **一次替换**:用 `preg_replace_callback()` 以 `` 为模式(`preg_quote()` 转义 token)整页替换一次;对象不复存在或不应显示的替换为空字符串(让占位符就地消失)。 4. **收敛**:返回的最终 HTML 必须不含有未替换的占位符。 **约束与安全**: - 禁止按“用户名 / 作者名 / 任意字符串”做回填匹配;占位符自带的唯一主键是唯一可靠的定位手段,避免回填错位或破坏结构。 - 对绕过整页模板的响应(AJAX / `json_response` 直接返回局部 HTML),整页回填钩子不会触发,此时必须**就地做少量查询的直接渲染**(允许单次小查询),绝不能留下未回填占位符。 - 回填内容与普通内容一样在输出前经 `h()` 转义;占位符由前缀 + token + 数字组成,不含可注入文本。 - 优先级顺序:能整段 `IN` 预取 + 内存映射、请求级/持久缓存时优先;占位符只用于逐条钩子无法整批的例外,且必须配套整页回填钩子。 ## 生命周期与配置 - 新插件放入目录后,需要在后台“插件”页执行“同步插件”。插件注册信息保存在 `app_plugins`,普通请求不会扫描插件目录。 - 新插件默认停用;只有启用后才执行。市场安装、更新或重新安装后插件会自动停用,再次启用时执行当前版本的 `install`。 - `install` 负责建表、补列和创建索引,且必须可重复执行;Schema 函数只能由 `install` 调用,不能出现在普通请求、页面渲染或业务函数中。 - `uninstall` 只能删除插件明确拥有的表、缓存和文件。无法确认归属的用户内容或附件不得连带删除。 - 修改现有插件时,旧配置缺少新字段不能报错;读取配置时集中补默认值,并归一化布尔值、枚举、字符串长度和数值上下限。 - 保存配置前验证 `$_POST`、`$_GET` 和 JSON 输入,不能把原始请求数据直接交给数据库、文件系统或外部接口。 推荐把配置入口集中为一个函数: ```php 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 或私有编码隐藏可搜索内容。 典型写入: ```php 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` 声明。资源函数无参数并返回源码,不包含 `