XIUNOX 插件开发专家。 当用户要求写/改/调试 XIUNOX 插件、加 hook、加后台设置页、加路由、 加签到/标签/勋章/举报/收藏等新功能插件、创建 Service 类、写 install/uninstall 脚本、 调试插件不生效、做插件架构设计时触发。 也适用于检查现有插件是否符合规范、修复扫描器拦截的 fatal/warning、 将 jQuery/Alpine.js 旧插件迁移到 htmx 4 架构。
XIUNOX 插件开发 Skill
功能概述
XIUNOX 插件开发 Skill是一项面向实际任务的技能,主要用于配套完整手册 : plugindev/ 目录有 8 个分册的完整开发手册(架构/结构/hooks/API/前端/AI 协作/运行时安全/登录安全),本 Skill 是精简版, 深入细节查 plugindev , 写代码对照本文件;完整手册下载地址 :https://github.com/domidesign/xiunox/tree/main/。
核心要点
- 它将相关步骤、工具调用和结果整理方式集中到统一流程中,帮助使用者更快完成目标并减少重复操作。
- 从功能定位来看,该技能强调把分散的操作要求整理成清晰、可复用的处理流程,使用户能够围绕既定目标快速准备输入、选择执行方式并获得结构化结果。
- 实际使用前应先确认任务范围、数据来源、运行环境、必要权限和关键参数,再依据技能说明逐步执行;
使用与执行
若输入条件不完整,应先补齐信息或采用保守配置,避免因错误假设导致结果偏离需求。执行过程中需要关注工具调用是否成功、接口或依赖是否可用、输出格式是否符合预期,并对异常提示、缺失字段和边界情况进行处理;
结果检查与注意事项
涉及批量任务时,还应保存进度,避免中断后重复操作。该技能适合用于一次性任务,也可以接入自动化工作流,与其他技能或上层代理配合完成更完整的业务链路;在组合使用时,应明确每一步的输入输出关系,并避免不同步骤之间出现参数冲突。
XIUNOX 插件开发 Skill
配套完整手册:
plugindev/目录有 8 个分册的完整开发手册(架构/结构/hooks/API/前端/AI 协作/运行时安全/登录安全),本 Skill 是精简版,深入细节查 plugindev,写代码对照本文件。完整手册下载地址:https://github.com/domidesign/xiunox/tree/main/docs/plugindev (本项目不自带
plugindev/目录时,从该 GitHub 仓库下载后放到docs/plugindev/即可使用本 Skill 的全部交叉引用)
核心架构(必须先理解)
插件是编译时合并,不是运行时分发。 把 hook 文件放到 plugin/ 就等于注册了 hook,没有 add_hook() 函数。_include() 编译时把 hook 内容物理拼进源文件,缓存到 tmp/。
hook 文件名(含扩展名)必须和源标记一模一样:
- PHP 源码标记:
// hook thread_create_thread_end.php - 模板标记:
模型三层命名: __ 原始层(纯DB)→ 单 _ 业务层(缓存/计数/通知)→ _format 装饰层。插件永远调单下划线业务层。
硬规则(不可违反)
禁止
- ❌ jQuery(
$()、.on()、.ajax())- 07-17 起项目内置 jQuery 兼容 shim(
view/js/xiuno-modern.js暴露window.jQuery = $),让 20 个存量插件 JS 和 .htm 模板内联$代码继续工作。新插件/新代码强制用 XN API(XN.toast()/XN.ajax()/XN.confirm()/XN.$()),存量$代码暂不强制重写、留待后续阶段逐个迁移 - shim 已覆盖(存量代码可继续用):
$()选择器链式、$.fn.*(BS5 桥接 modal/dropdown/tooltip/tab/collapse + loading/checked/serializeObject 等 17 个扩展)、$.ajax/$.get/$.post/$.getJSON、$.alert/$.confirm/$.ajax_modal、$.Event、xn.*PHP 函数库(intval/json_encode/url 等 30+) - shim 不支持(写了会静默失败或报错,必须改用
XN.ajax()+async/await或原生fetch):jqXHR.done/.fail/.always链式回调、同步 xhr(async: false)、jsonp/scriptdataType、$.when()多请求编排、.animate()队列动画
- 07-17 起项目内置 jQuery 兼容 shim(
- ❌ Alpine.js(
x-data、x-show、x-bind、x-model等) - ❌ idiomorph / alpine-morph 扩展
- ❌
htmlspecialchars裸写 → 用esc_html()/esc_attr()/esc_js() - ❌ POST 表单不带
CsrfService::input()+CsrfService::check() - ❌ 裸
include→ 用_include() - ❌
window.__xxxData→ 状态放 DOM(data-*、hidden input) - ❌ 后台(admin/)用 htmx
- ❌ htmx 4 的
htmx:config:request事件中修改 DOM 元素 → 该事件触发时 FormData 已构建完成,修改 DOM(如动态添加 input)不会影响请求体。必须直接修改evt.detail.ctx.request.body(FormData 对象)来追加参数。- ❌ 错误写法:在
htmx:config:request中document.createElement('input')+appendChild - ✅ 正确写法:
evt.detail.ctx.request.body.append('key', 'value')
- ❌ 错误写法:在
- ❌ 用
APP_PATH生成/(APP_PATH是文件系统绝对路径,浏览器无法访问) - ❌ 把 JS/CSS 放在
view/htm/目录(应放static/js/、static/css/) - ❌
static/*.js文件中写 PHP 代码(等)→.js是纯静态文件不会被 PHP 解析,浏览器看到' 单引号闭合后,xxx变成裸标识符触发Uncaught SyntaxError: Unexpected identifier。翻译字符串必须通过引入该 JS 的 hook 模板(hook 是.htm会被_include()编译执行)注入到window.XXX_I18N全局对象,JS 文件改用var I18N = window.XXX_I18N || {fallback};读取。⚠️ 已违反 1 次(xnx_poll poll_post.js 3 处 + poll.js 7 处,导致发帖页开启投票后选项列表不显示 + 帖子详情页投票卡按钮失效)- ✅ 正确范式(hook 模板注入 + JS 读取):
// hook/post_js.htm(.htm 会被 PHP 解析) echo ''; echo '';
// static/js/poll_post.js(纯静态) var I18N = window.XNX_POLL_I18N_POST || { options: '选项', min_options: '至少需要 2 个选项' }; XN.toast(I18N.min_options, 'warning'); - 💡 判断规则:只要文件扩展名是
.js(非.htm/.php),就当作纯静态文件,禁止写任何代码
- ✅ 正确范式(hook 模板注入 + JS 读取):
- ❌ 后台插件设置页/管理页模板(
view/htm/setting.htm、view/htm/xxx_admin.htm)不 include 后台 header/footer → 这些模板是独立页面,必须首尾分别include _include(ADMIN_PATH . 'view/htm/header.inc.htm')和footer.inc.htm,否则页面没有后台顶部导航栏和侧边栏(光秃秃只有表单)。前台插件页面则应 includeAPP_PATH . 'view/htm/header.inc.htm'(非 ADMIN_PATH)。⚠️ 已违反 1 次(xnx_poll setting.htm + poll_admin.htm 两个模板都漏 include)- ✅ 正确范式:...
- 💡 参考:
xnx_related/view/htm/setting.htm、xnx_appcenter/view/htm/setting.htm已有正确 include
- ✅ 正确范式:
- ❌
.htm模板 hook 文件以开头(会白屏!只能用) - ❌
.htm模板 hook 文件用.php扩展名(文件名必须和源码标记一模一样) - ❌ 数据库表用
utf8(必须utf8mb4支持 emoji) - ❌ 后台 setting.php 缺少
$gid != 1 && $gid != 2 AND message(-1, '无权限');权限检查 - ❌ SQL 拼接用户输入(必须
intval()转义) - ❌ 裸用
db_sql_find/db_sql_find_one/db_exec做普通查询 → 优先用db_find/db_find_one/db_count/db_find_group/db_find_one_group(防 SQL 注入) - ❌ 保留复杂 SQL(JOIN/系统表/复杂 DML)时不加注释 → 必须加包含
保留 db_sql_find或保留 db_exec关键字的注释(扫描器据此跳过报告) - ❌ 帖子列表页 hook 中
new Service()+ 查库 N 次(用静态缓存) - ❌
db_insert后不检查返回值就message(0, '成功')(字段不存在会静默失败) - ❌ 卸载脚本用
unstall.php(旧拼写) → 必须用uninstall.php(标准拼写),核心已兼容回退但新插件禁止用旧拼写 - ❌ 数据库结构变更在 install.php/setting.php 加字段自愈代码 → 必须走 upgrade.php 幂等迁移 + 递增 conf.json.version
- ❌
esc_attr(json_encode(array(...)))嵌套括号写一行(易出错,先存变量再转义) - ❌ 硬编码 URL 后缀(如
/thread-{tid}.htm、/forum-{fid}.htm)→ 必须用url()函数适配伪静态格式(url_rewrite_on 支持 0~5 六种模式) - ❌ 拼接
$site_url . '/xxx.htm'做缓存刷新/跳转等 → 必须用$site_url . url("xxx")适配伪静态 - ❌ 单行注释
//和#中包含?>:PHP 会把单行注释中的?>当作 PHP 代码块结束标签,导致后续代码被当作纯文本输出,触发「headers already sent」错误,页面直接显示代码- 错误示例:
// 模板中调用: - 正确写法:
// 模板中调用示例:echo thread_url($tid);(去掉和?>) - 块注释
/* */中可以包含?>,不受影响
- 错误示例:
- ❌ 模板里硬编码
url("thread-$tid")字符串拼接 → 必须用命名快捷函数(thread_url($tid))或route_url('thread', ['tid'=>$tid]) - ❌ 插件自定义路由直接写
url("myplugin-$id")→ 必须通过hook/model_route_table_end.php注册到路由表 - ❌ 插件直接调用
route_table()修改返回值(会破坏静态缓存)→ 只能通过model_route_table_end.phphook 修改局部$routes变量 - ❌ 跨插件共享配置时,保存和读取用不同的存储键(如存到
setting_set('xnx_checkin', ...)但从setting_get('plugin_discover_items')读取)→ 保存和读取必须用同一个 key - ❌
setting_set('key', xn_json_encode($arr))然后setting_get()后xn_json_decode()→setting_set/get原生支持数组存取,不需要 JSON 中转 - ❌ 模板中
相对路径 → 必须用$conf['view_url']js/xxx.js - ❌ 重复造轮子(自建图标选择弹窗、日期选择器等)→ 开发前先检索项目中已有组件(如
TablerIconPicker、bootstrap.Modal等) - ❌ 注册表/默认配置中硬编码中文文本 → 必须用
lang()多语言键,运行时解析 - ❌ 手写
if(cache_get())...else{compute;cache_set()}缓存样板代码 → 用CacheHelper::remember($key, $ttl, $callback, $plugin)一行搞定 - ❌ 枚举所有 limit 值逐个
cache_delete()清除缓存 → 用CacheHelper::pluginDeletePrefix($plugin)一键清除 - ❌ 裸用
cache_get('xnx_plugin_key')全局键名 → 必须用CacheHelper::pluginKey()生成带p_{plugin}_前缀的键名 - ❌ 插件数据缓存不注册缓存键 → Service 类构造函数必须调用
CacheHelper::registerKeys()注册(后台统计面板依赖此注册) - ❌ 密码/Token 等敏感配置用
param()默认行为 →param()默认做 htmlspecialchars 转义会破坏密码,必须传第 3 参数FALSE(如param('redis_password', '', FALSE)) - ❌ 插件 Service 调用
new CreditsService()等核心 Service 前不include_once→ 生产环境(DEBUG=0)走tmp/model.min.php合并加载,类加载顺序不可预测,不 include 会抛Class not found;开发环境(DEBUG=1)因其他插件先执行而"碰巧能用" - ❌ 前台代码用
global $plugins; $plugins[$dir]['enable']判断插件是否启用 → 前台$plugins全局变量未初始化(plugin_init()仅在 admin/upgrade 调用),始终为空数组,判断永远为 false。必须改用plugin_paths_enabled()(读 conf.json,前台兼容)或直接读plugin/的/conf.json enable+installed字段 - ❌ PHP 端把自定义按钮名作为字符串下发给 JS 的
toolbarKeys→ AIEditor 的toolbarKeys把字符串当内置按钮ID处理(如'bold'),自定义按钮必须以对象形式传入。注意 PHPimplode(',', ['a'])生成无引号a,json_encode(['a'])生成带引号["a"],两种方式 JS 端得到的完全不同(前者是变量引用,后者是字符串数组),修改前必须确认 PHP 实际输出格式 - ❌ 同一类常量被多处入口引用时,先访问静态属性/常量再
include_once→ 例如if(!in_array($gid, UserBanService::ADMIN_GIDS, true)) { include_once APP_PATH.'lib/UserBanService.php'; ... }会先访问ADMIN_GIDS触发类加载,若类未加载就崩溃。必须改成if(!class_exists('UserBanService')) { include_once ... } if(!in_array(..., UserBanService::ADMIN_GIDS, true)) {...}顺序,所有调用点保持一致(生产环境 DEBUG=0 走 min.php 合并加载,类加载顺序不可预测) ⚠️ 已违反 1 次(route/user.php login/resetpw 两处) - ❌ 新增入口级安全检查(IP 黑名单/封禁检查)只补主入口 → 必须用 Grep 全局搜索所有「用户可发表内容/可登录」入口(login/create/resetpw 发帖回帖改密找密)逐个补齐。Spec checklist 验证阶段比单元测试更能发现这类遗漏 ⚠️ 已违反 1 次(route/thread.php create 缺 IP 黑名单检查)
- ❌ 用户未指定插件前缀时使用
xn_或xnx_前缀 → 这两个是官方预留前缀(如xn_url、xnx_checkin等核心/官方插件已占用),第三方插件禁止使用。应改用插件目录名或其缩写作为前缀(如插件目录my_plugin则用my_前缀)。即使用户指定了目录名,若该目录名以xn_或xnx_开头,也应提醒用户更换为其他前缀 - ❌ 插件层用
db_find('user', ...)获取用户信息后直接取username字段显示 →db_find('user')绕过核心层,返回的数据不含display_name字段(display_name由核心层model/user.func.php的user_format()生成:nickname优先,为空 fallback 到username)。直接取username会显示登录用户名而非用户可修改的昵称。必须改用user_find_by_uids(implode(',', $uids))/user_read($uid)/user_read_cache($uid)等核心函数(自动调用user_format()),模板显示时取$user['display_name']。⚠️ 已违反 1 次,影响 5 个插件(xnx_duel / xnx_dice / xnx_invite / xnx_attach_access / xnx_landing),根因是$u['display_name'] ?? $u['username']这种 fallback 写法在db_find结果上无效(display_name键根本不存在,始终 fallback 到username) - ❌ 所有 hook 文件(路由层 + model 层 + view 层)禁止使用
return;:hook 是编译期内联到宿主函数/路由文件/模板中的,hook 内的return;会从被内联的整个宿主返回(不是从 hook 返回),导致宿主后续逻辑被跳过。已违反 4 次,影响范围逐次扩大:- 路由层:xnx_feeds
thread_create_thread_end(审核场景无提示无跳转)/ xnx_feedsuser_create_post_end(注册后无提示)/ xnx_hiddenpost_create_htmx_reply_end(回复后 htmx 响应污染) - model 层:xnx_feeds 6 个 model_*_end hook(待审回帖 pid 丢失事故,
post__create返回 int(754) 但post_create()因 hook return 提前退出,返回 NULL)/ xnx_hiddenmodel_post_create_end(同上) - model 层严重 bug:xnx_maintenance
model_check_runlevel_start.php中 5 个 return 会跳过原check_runlevel()函数的 runlevel 1/2/3/4 拦截逻辑,导致插件启用后即使维护模式关闭,站点关闭/限访功能也全部失效 - 违规写法:
if (!class_exists('XxxService', false)) return;/if (empty($list)) return;/if ($audit_status != 1) return; - 正确写法:用
if (条件) { ...全部逻辑... }包裹整个 hook 内容 - 例外(允许
exit;):终止性操作(API 503 响应、页面渲染、302 重定向、维护页输出)后必须exit;终止请求,但必须用 if 包裹整个拦截逻辑且加// ponytail:注释说明 exit 的正当性 - 闭包回调内部的
return只作用于闭包本身,合法(如array_filter的 lambda) - 判断 hook 是否在顶层作用域:Grep 核心文件中
// hook xxx.php标记所在位置是否在任何function/method外 - ✅ 正确范式(参考
xnx_fields/hook/thread_create_thread_end.php): - ❌ 错误写法(裸 return 退出整个路由):
- 路由层:xnx_feeds
- ❌ 发帖/回复共用
post.htm模板的 hook 不区分场景 →view/htm/post.htm同时用于发帖页($route == 'thread' && $action == 'create')和高级回复页($route == 'post' && $action == 'create')。插件功能若只需发帖场景(如隐藏内容、投票、抽奖等主题级功能),hook 必须加if ($route == 'thread' && $action == 'create') { ... }判断,否则回复页也会加载不需要的卡片/Modal/JS,造成界面污染和资源浪费。涉及场景区分的常见 hook:post_ref_thread_after.htm(右侧卡片)、post_ref_thread_after_mobile.htm(手机端卡片)、post_end.htm(底部 Modal)、post_js.htm(底部 JS)。⚠️ 已违反 1 次(xnx_hidden 4 个 hook 未区分,回复页右侧显示隐藏内容模块)- ✅ 正确写法:
- 💡 判断变量来源:
$route和$action是index.inc.php解析路由时设置的全局变量,post.htm模板顶部已用($route == 'thread' && $action == 'create')区分发帖/回复/编辑三种场景
- ✅ 正确写法:
必须
- ✅ htmx 4 属性(
hx-get/hx-post/hx-target/hx-optimistic/hx-live) - ✅
XN.toast()/XN.ajax()/XN.confirm()(xiuno-modern.js) - ✅ Bootstrap 5.3 + Tabler Icons(
) - ✅
esc_html()/esc_attr()/esc_js() - ✅ 所有命名带插件前缀(表/变量/语言键/JS/CSS/setting)
- ✅ 插件静态资源(JS/CSS)放在
plugin/和/static/js/ plugin//static/css/ - ✅ 引用插件静态资源用
$conf['view_url'] . '../plugin//static/js/xxx.js' - ✅ 引用核心静态资源用
$conf['view_url']js/xxx.js(非相对路径../view/js/) - ✅ 新插件 JS 放
plugin/,CSS 放/static/js/ plugin/,禁止放/static/css/ view/htm/(会被_include()当模板编译) - ✅ JS 代码量约束:单文件 <50 行可内联到
.htmhook(如post_js.htm),≥50 行必须独立.js文件用引用(便于浏览器缓存 + 静态版本控制) - ✅ 跨插件共享配置时,统一使用一个存储键读写(如
setting_get/set('plugin_discover_items', $arr)),不要存到各自插件 key 再从另一个 key 读取 - ✅
setting_set/get原生支持数组存取,不需要xn_json_encode/decode中转 - ✅ 开发新组件前先检索项目已有组件(Grep 搜索
TablerIconPicker、bootstrap.Modal等),复用优先于自建 - ✅ 注册表/默认配置中的文本用
lang()多语言键,不硬编码中文 - ✅ 后台 setting.php 开头加
$gid != 1 && $gid != 2 AND message(-1, '无权限'); - ✅ 优先使用
db_find/db_find_one/db_count/db_find_group/db_find_one_group替代db_sql_find/db_sql_find_one(防 SQL 注入) - ✅ 保留复杂 SQL(JOIN/系统表/GREATEST/子查询/INSERT IGNORE SELECT)时加注释
// 保留 db_sql_find或// 保留 db_exec(扫描器据此跳过,10 行抑制区间) - ✅ install.php 所有表用
DEFAULT CHARSET=utf8mb4 - ✅ install.php 字段升级用
SHOW COLUMNS+ALTER TABLE幂等逻辑 - ✅ install.php 末尾必须清理
tmp/model.min.php:生产环境(DEBUG=0)走model.min.php合并加载,该文件只在不存在时才重新生成。插件文件升级后未重新启用时,旧model.min.php不含新插件的 Service 类,导致class_exists返回 false → fatal error 白屏。正确写法:if (isset($conf['tmp_path']) && function_exists('xn_unlink')) { @xn_unlink($conf['tmp_path'].'model.min.php'); }。后台启用/禁用插件已由plugin_enable()/plugin_disable()自动清理,但 install.php 执行场景(覆盖代码部署后)不会触发启用流程,必须手动清理。⚠️ 已违反 1 次(xnx_hidden install.php 未清理,导致 admin 后台统计页空白) - ✅ 帖子列表页 hook 用
Service::getCached($uid)静态缓存避免 N 次查询 - ✅
getUserMedals()用 JOIN 一次查询避免 N+1 - ✅
esc_attr(json_encode(...))先存临时变量再转义 - ✅ 新增功能同时在导航/个人中心添加入口 hook
- ✅ conf.json 的 hooks_rank 键名必须和 hook 文件名(含扩展名)完全一致
- ✅ URL 生成优先级:命名快捷函数(
thread_url($tid))>route_url()通用入口 > 底层url()函数 - ✅ 插件自定义路由必须通过
hook/model_route_table_end.php注册到$routes数组 - ✅ 所有 URL 生成必须用
url()函数(适配伪静态格式),禁止硬编码.htm/.html后缀 - ✅ 缓存刷新/跳转等需拼接完整 URL 时用
$site_url . url("xxx"),禁止$site_url . '/xxx.htm' - ✅ 插件数据缓存用
CacheHelper::remember($key, $ttl, $callback, $plugin)代替裸cache_get/cache_set - ✅ 清除插件缓存用
CacheHelper::pluginDeletePrefix($plugin)一键清除(不枚举 limit 值) - ✅ 缓存键通过
CacheHelper::pluginKey($key, $plugin)生成带p_{plugin}_前缀的键名 - ✅ Service 类构造函数调用
CacheHelper::registerKeys($plugin, $keys)注册缓存键清单 - ✅ 列表类缓存用版本号机制(
CacheHelper::remember('list_v_'.$id, 86400, ...)+CacheHelper::set(pluginKey(...), $v+1, 86400)) - ✅ 敏感配置参数(密码/Token/Secret)用
param($name, $default, FALSE)关闭 htmlspecialchars - ✅ 插件 Service 调用核心 Service(CreditsService 等)前必须
include_once对应文件:if (!class_exists('CreditsService')) { include_once APP_PATH . 'lib/CreditsService.php'; }再new CreditsService($db, $conf) - ✅ 前台代码判断插件是否启用用
plugin_paths_enabled()(返回已启用插件的 conf.json 数组,前台兼容)或直接读plugin/的/conf.json enable+installed字段,禁止用global $plugins(前台未初始化) - ✅ PHP 端向 JS 传递自定义按钮配置时,优先直接传对象 JSON(
json_encode([$btnObj])),若传变量名字符串用implode生成无引号格式让 JS 当变量引用,修改前用echo确认实际输出格式 - ✅ Card 组件必须加
x-cardclass:项目统一卡片样式(无 border + 阴影 + 圆角,dark mode 才加 border,定义在view/css/theme.css的.x-card),禁止裸用border/border-*工具类给卡片加边框,列表项之间也禁止用border-bottom临时分隔- ✅ 正确:
/...(纯留白分隔) - ❌ 错误:/
/- 所有 border 都尽量不用:卡片用
x-card自带阴影分隔,列表项之间用纯py-*留白分隔(不加 border-bottom),需要视觉分隔时优先用py-*/mb-*/mt-*/gap-*等间距工具类,或用(已有.thread-list-divider hr样式可复用)- ✅ 发帖页新增功能放右侧栏(PC)并使用"引用帖子"卡片样式:发帖辅助功能(投票/话题/附件权限/楼主可见/抽奖等)必须挂到
post_ref_thread_after.htm(PC 右侧栏)+post_ref_thread_after_mobile.htm(手机端表单内),禁止挂到post_subject_after.htm(标题下方)或post_message_after.htm(编辑器下方)。卡片结构必须与"引用帖子"卡片完全一致:x-card card+card-header bg-transparent border-bottom-0 py-2++标题
card-body pt-0。手机端不渲染 card-header。⚠️ 已遵循:xnx_tag/xnx_fields/xnx_attach_access/xnx_private_reply/xnx_lottery;已违反 1 次(xnx_private_reply 初版挂到 post_subject_after.htm,已迁移)- ✅ 插件层获取用户信息用于显示时,用核心函数
user_find_by_uids(implode(',', $uids))/user_read($uid)/user_read_cache($uid)(自动调用user_format()生成display_name:nickname优先,为空 fallback 到username),禁止用db_find('user', ...)绕过核心层。模板显示用户名统一取$user['display_name'],禁止直接取$user['username'](username是登录名,nickname才是用户可修改的显示名)。已存在db_find('user')场景如需保留,必须在结果上手动user_format($user)补display_name字段插件开发工作流
1. conf.json → 2. install.php → 3. uninstall.php → 3.5. upgrade.php(结构变更用) → 4. Service 类 → 5. hook/ 注册文件 → 6. route/ 路由 → 7. view/htm/ 模板 → 8. setting.php 后台 → 9. lang hook → 10. 清 tmp/ 测试
Step 1: conf.json
完整字段表与 zip 打包规范见 plugindev/02-plugin-structure.md。本节只列核心字段与高频错误。
{ "name": "插件名称", "brief": "插件简介,支持 HTML", "version": "1.0.0", "bbs_version": "1.0", "installed": 0, "enable": 0, "hooks_rank": { "model_inc_file.php": 10, "thread_subject_after.htm": 10 }, "overwrites_rank": { "view/htm/header.inc.htm": 10 }, "dependencies": { "xn_search": "1.0" }, "capabilities": [], "type": "plugin", "author": "作者", "id": "my_plugin" }字段类型陷阱(高频写错):
字段 类型 错误写法 正确写法 versionstring "1.0""1.0.0"(三位制 X.Y.Z)bbs_versionstring "1.0.0"/"4.5"/"2.0""1.0"/"1.1"(两位制 X.Y,当前 XIUNOX 核心为 1.x 系列,禁止写 4.5 或其他版本号)hooks_rankobject []/{}空对象不展示用法{"hook_name.php": 10}(值大先执行,默认 0)overwrites_rankobject [](常见错误:写成数组){"view/htm/header.inc.htm": 10}(值最大的覆盖文件生效,赢家通吃)dependenciesobject [](常见错误:写成数组){"xn_search": "1.0"}(key=依赖插件目录名,value=最低版本约束,支持 npm 风格>=/^/~/*)capabilitiesarray "lowercase.dots"字符串["lowercase.dots"](权限沙箱声明,扫描器强制校验lowercase.dots格式)⚠️
bbs_version误判高发区:XIUNOX 是基于 Xiuno BBS 4.x 的现代化分支,但bbs_version字段值跟随 XIUNOX 核心版本号(1.x 系列),不是 Xiuno BBS 4.5。合法取值示例:"1.0"、"1.1"。写成"4.5"会被核心兼容性校验拒绝。审查插件时遇到bbs_version: "1.x"是正确的,不要标记为错误。必需字段:
name/brief/version/bbs_version/installed/enable(后两个系统维护,勿手填) 可选字段:hooks_rank/overwrites_rank/dependencies/capabilities/type(默认"plugin")/author/idStep 2: install.php(建表 + 默认设置)
tablepre}my_plugin ( id int unsigned NOT NULL AUTO_INCREMENT, uid int unsigned NOT NULL DEFAULT 0, content text NOT NULL, created int unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY uid (uid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); setting_set('my_plugin', [ 'enabled' => 1, 'max_count' => 10, ]); // 字段升级示例(幂等:检查字段是否存在再添加) $cols = db_sql_find("SHOW COLUMNS FROM {$db->tablepre}my_plugin"); $has_new_field = false; if ($cols) { foreach ($cols as $col) { if ($col['Field'] == 'new_field') $has_new_field = true; } } if (!$has_new_field) { db_exec("ALTER TABLE {$db->tablepre}my_plugin ADD COLUMN new_field varchar(255) NOT NULL DEFAULT '' AFTER content"); } // 清理废弃字段(幂等) if ($has_old_field) { db_exec("ALTER TABLE {$db->tablepre}my_plugin DROP COLUMN old_field"); } // 清理 model.min.php 编译缓存,确保本插件 Service 类被加载 // 场景:插件文件升级后未重新启用,旧 model.min.php 不含本插件 model → fatal error 白屏 if (isset($conf['tmp_path']) && function_exists('xn_unlink')) { @xn_unlink($conf['tmp_path'].'model.min.php'); }⚠️ install.php 只负责首次安装的建表 + 默认配置。数据库结构变更(加字段/改字段)必须走
upgrade.php机制,禁止在 install.php 或 setting.php 加字段自愈代码。原因:存量用户覆盖代码后不重新安装时,install.php 不会执行,字段不会补齐导致 INSERT 失败。Step 3: uninstall.php(镜像清理)
tablepre}my_plugin"); kv_delete('my_plugin');⚠️ 卸载脚本文件名必须用
uninstall.php(标准拼写),禁止用unstall.php(旧拼写)。核心admin/route/plugin.php卸载入口已改为优先找uninstall.php,回退unstall.php(向后兼容第三方旧插件)。用错文件名会导致卸载时不执行脚本(表不删、配置不清)。Step 3.5: upgrade.php(数据库结构迁移,幂等)
数据库结构变更(加字段/改字段/加索引)必须走 upgrade.php 机制,配合 conf.json.version 版本号递增,触发核心层「需升级」提示。流程:
- 创建
upgrade.php包含幂等字段迁移逻辑 - 递增
conf.json.version(如 1.0 → 1.1) - 核心层
plugin_list.htm对比conf.json.version与bbs_plugin.version不一致时显示「需升级」徽章+红色按钮 - 用户点升级后执行
upgrade.php并同步db.version为新版本
tablepre}my_plugin"); if ($cols) { $col_names = array(); foreach ($cols as $col) { $col_names[$col['Field']] = 1; } if (!isset($col_names['new_field'])) { db_exec("ALTER TABLE {$db->tablepre}my_plugin ADD COLUMN new_field varchar(255) NOT NULL DEFAULT '' AFTER content"); } if (!isset($col_names['status'])) { db_exec("ALTER TABLE {$db->tablepre}my_plugin ADD COLUMN status tinyint(1) NOT NULL DEFAULT 0 AFTER new_field"); } } // 可选:清理废弃字段(幂等) if (isset($col_names) && isset($col_names['deprecated_field'])) { db_exec("ALTER TABLE {$db->tablepre}my_plugin DROP COLUMN deprecated_field"); } // 清理 tmp 缓存(确保新代码生效) plugin_clear_tmp_dir();注意事项:
- ⚠️ upgrade.php 必须幂等(可重复执行不报错),用
SHOW COLUMNS检查字段是否存在再 ALTER - ⚠️ 每次结构变更都要递增
conf.json.version,否则存量用户看不到「需升级」提示 - ⚠️ 禁止在 install.php 或 setting.php 加字段自愈代码,统一走 upgrade.php
- ⚠️ 核心
plugin_init()已幂等检测bbs_plugin.version字段并自动 ALTER 补齐,插件无需关心该字段是否存在
Step 4: Service 类(静态方法模式)
$uid, 'content' => $content, 'created' => time(), ]); } public static function findByUid($uid, $page = 1, $pagesize = 20) { $cond = ['uid' => $uid]; $total = db_count('my_plugin', $cond); $list = db_find('my_plugin', $cond, ['created' => -1], $page, $pagesize, 'id'); return ['list' => $list, 'total' => $total]; } public static function delete($id) { return db_delete('my_plugin', ['id' => $id]); } }注册到
hook/model_inc_file.php:Step 5: Hook 文件(两种类型,开头写法不同,写错会白屏!)
类型 源码标记 文件扩展名 开头写法 原因 PHP hook // hook xxx.php.php防止直接访问 模板 hook .htm编译拼进模板执行, exit会白屏PHP hook(源码注入点,如 model/route/lang):
HTM hook(模板注入点,编译拼进 .htm 模板执行):
' . esc_html($data['label']) . ''; } } ?>路由 hook(PHP 类型):
语言 hook(PHP 类型):
Step 6: route 文件
注意: 路由
'myplugin_list'必须先通过hook/model_route_table_end.php注册到路由表(详见下方「路由表扩展」章节)。Step 7: setting.php(后台)
intval(param('enabled', 1)), 'max_count' => intval(param('max_count', 10)), ]; MyPluginService::saveSettings($settings); // 优先用命名函数:admin_plugin_setting_url('my_plugin') // 或通用入口:route_url('admin_plugin_setting', ['dir' => 'my_plugin']) message(0, '设置已保存', ['redirect_url' => admin_plugin_setting_url('my_plugin')]); } } $settings = MyPluginService::getSettings(); include _include(APP_PATH.'plugin/my_plugin/view/htm/setting.htm');Step 8: setting.htm 表单
路由表扩展(核心路由系统)
XIUNOX 引入了集中式路由表 + 命名快捷函数架构。路由表定义在
model/route.func.php中,模板和插件代码通过命名函数(thread_url($tid))或route_url()通用入口生成 URL,最终调用底层url()处理伪静态格式。调用链
thread_url($tid) [命名快捷函数,优先用] ↓ route_url('thread', ['tid'=>$tid]) [通用入口] ↓ url('thread-' . $tid) [底层 url(),处理伪静态格式]路由表结构
路由表为
key => 模板字符串的关联数组,模板字符串中用{xxx}占位符表示参数:'thread' => 'thread-{tid}', 'thread_page' => 'thread-{tid}-{page}', 'thread_like' => 'thread-like-{tid}-{pid}', 'user_thread' => 'user-thread-{uid}',4 个 Hook 点
Hook 文件 位置 用途 hook/model_route_start.php路由文件开头 初始化路由相关变量 hook/model_route_table_end.php路由表末尾 插件扩展路由主入口(修改/新增 $routes数组)hook/model_route_func_end.php命名函数定义后 动态注册命名函数(不推荐,PHP 函数不能重复定义) hook/model_route_end.php路由文件末尾 路由系统整体扩展 插件扩展路由示例
新增插件路由:
// plugin/my_plugin/hook/model_route_table_end.php
修改核心路由格式(SEO 优化示例):
// plugin/seo/hook/model_route_table_end.php
注意事项:
- ⚠️ 修改核心路由格式必须同步更新伪静态规则(
.htaccess/ nginx 配置) - ⚠️ 路由表用静态缓存,hook 只在首次访问时执行,修改后需清
tmp/缓存 - ⚠️ 不要在 hook 中调用
route_table()(会触发递归),直接修改$routes变量 - ⚠️ 不要在 hook 中使用
route_url()或命名函数(同样会触发递归)
在模板和路由中使用
模板中(推荐用命名函数):
帖子标题 用户名 版块名 第 2 页 ">帖子标题
路由 PHP 中:
// ✅ 推荐 message(0, '成功', ['redirect_url' => thread_url($tid)]); $pagination = pagination(route_url('myplugin_list_page', []), $total, $page, 20); // ✅ 保留 url() 的特殊场景 $pagination = pagination(url("thread-$tid-{page}$keywordurl"), $total, $page, $pagesize); // ↑ 复杂分页带额外变量,命名函数无法表达何时保留 url() 调用
以下场景保留
url()调用,不替换为命名函数:- 分页模板字符串带额外变量:
url("thread-$tid-{page}$keywordurl")
目录更多热门AI工具
更多相关专题
Go-Zero API开发使用教程本专题聚焦Go-Zero的API开发,拆解.api描述文件的语法结构、路由与分组定义、参数绑定与校验规则,说明统一响应体、JWT鉴权、跨域与接口文档生成的落地方式,并给出网关聚合与接口测试的实践建议。
0
2026.11.12
Go-Zero安装与goctl环境配置教程本专题围绕Go-Zero的安装与工具链配置展开,讲清Go版本要求、goctl安装方式、代理镜像、protoc依赖与环境变量设置,说明多版本共存、升级回退与CI流水线安装的做法,并整理安装阶段的常见报错与验证步骤。
0
2026.11.12
Go-Zero零基础入门教程本专题整理Go-Zero的入门知识,从框架定位、环境准备到跑通第一个API服务,讲清目录分层、配置加载、日志与中间件的基本用法,并对比Gin、Kratos的差异,帮助Go开发者建立整体认知,判断它是否适合当前项目。
0
2026.11.12
Postman身份认证怎么配置本专题从实际API接口调试场景出发,讲解Postman身份认证的完整配置流程,包括常见认证协议介绍、授权参数设置、访问令牌填写、OAuth 2.0授权及认证失败排查,帮助用户正确完成接口授权与访问验证。
0
2026.11.06
Postman请求头Headers怎么设置围绕Postman HTTP请求头配置展开,讲解Headers选项的具体使用方式,包括Key和Value参数填写、请求头信息添加、常见Header字段设置及请求头修改方法,帮助用户解决接口调试过程中遇到的请求头配置问题。
0
2026.11.06
Postman接口请求发送教程详细介绍Postman接口请求的创建与发送方法,涵盖GET和POST请求、URL配置、请求参数、Headers请求头、Body请求体设置以及响应结果查看,帮助用户掌握Postman接口请求的完整操作流程。
0
2026.11.06
DBeaver怎么导入数据DBeaver数据导入专题,整理Excel、CSV、SQL、TXT等常见文件的数据导入方法,涵盖目标数据库选择、数据表创建、字段映射、数据类型设置及导入参数配置等操作,帮助用户将本地文件中的数据写入数据库,并了解批量导入、重复数据处理和导入失败等常见问题的解决方法。
0
2026.11.05
DBeaver安全防护教程大全DBeaver安全防护专题,围绕数据库连接安全、账号密码保护和访问权限管理展开,整理SSL/TLS加密连接、SSH隧道、数据库凭据保存及证书验证等配置方法,同时介绍敏感信息保护、远程连接风险和高权限账号使用等注意事项,帮助用户提高数据库日常管理的安全性。
0
2026.11.05
DBeaver连接创建与配置教程本专题聚焦DBeaver数据库连接配置与管理,覆盖从新建连接、选择数据库类型到填写连接参数和验证连接的完整流程。针对MySQL、PostgreSQL、SQL Server等常见数据库,整理相应的连接设置方法,并提供驱动缺失、认证失败和连接异常等问题的排查方向。
0
2026.11.04
更多热门下载
更多相关下载
更多精品课程
相关推荐/热门推荐/最新课程更多最新文章
- 所有 border 都尽量不用:卡片用
- ✅ 正确: