XIUNOX 插件开发专家。 当用户要求写/改/调试 XIUNOX 插件、加 hook、加后台设置页、加路由、 加签到/标签/勋章/举报/收藏等新功能插件、创建 Service 类、写 install/uninstall 脚本、 调试插件不生效、做插件架构设计时触发。 也适用于检查现有插件是否符合规范、修复扫描器拦截的 fatal/warning、 将 jQuery/Alpine.js 旧插件迁移到 htmx 4 架构。
XIUNOX 插件开发 Skill是一项面向实际任务的技能,主要用于配套完整手册 : plugindev/ 目录有 8 个分册的完整开发手册(架构/结构/hooks/API/前端/AI 协作/运行时安全/登录安全),本 Skill 是精简版, 深入细节查 plugindev , 写代码对照本文件;完整手册下载地址 :https://github.com/domidesign/xiunox/tree/main/。
若输入条件不完整,应先补齐信息或采用保守配置,避免因错误假设导致结果偏离需求。执行过程中需要关注工具调用是否成功、接口或依赖是否可用、输出格式是否符合预期,并对异常提示、缺失字段和边界情况进行处理;
涉及批量任务时,还应保存进度,避免中断后重复操作。该技能适合用于一次性任务,也可以接入自动化工作流,与其他技能或上层代理配合完成更完整的业务链路;在组合使用时,应明确每一步的输入输出关系,并避免不同步骤之间出现参数冲突。
配套完整手册:
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 文件名(含扩展名)必须和源标记一模一样:
// hook thread_create_thread_end.php模型三层命名: __ 原始层(纯DB)→ 单 _ 业务层(缓存/计数/通知)→ _format 装饰层。插件永远调单下划线业务层。
$()、.on()、.ajax())view/js/xiuno-modern.js 暴露 window.jQuery = $),让 20 个存量插件 JS 和 .htm 模板内联 $ 代码继续工作。新插件/新代码强制用 XN API(XN.toast() / XN.ajax() / XN.confirm() / XN.$()),存量 $ 代码暂不强制重写、留待后续阶段逐个迁移$() 选择器链式、$.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+)XN.ajax() + async/await 或原生 fetch):jqXHR.done/.fail/.always 链式回调、同步 xhr(async: false)、jsonp/script dataType、$.when() 多请求编排、.animate() 队列动画x-data、x-show、x-bind、x-model 等)htmlspecialchars 裸写 → 用 esc_html() / esc_attr() / esc_js()CsrfService::input() + CsrfService::check()include → 用 _include()window.__xxxData → 状态放 DOM(data-*、hidden input)htmx:config:request 事件中修改 DOM 元素 → 该事件触发时 FormData 已构建完成,修改 DOM(如动态添加 input)不会影响请求体。必须直接修改 evt.detail.ctx.request.body(FormData 对象)来追加参数。htmx:config:request 中 document.createElement('input') + appendChildevt.detail.ctx.request.body.append('key', 'value')APP_PATH 生成 / (APP_PATH 是文件系统绝对路径,浏览器无法访问)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),就当作纯静态文件,禁止写任何 代码
view/htm/setting.htm、view/htm/xxx_admin.htm)不 include 后台 header/footer → 这些模板是独立页面,必须首尾分别 include _include(ADMIN_PATH . 'view/htm/header.inc.htm') 和 footer.inc.htm,否则页面没有后台顶部导航栏和侧边栏(光秃秃只有表单)。前台插件页面则应 include APP_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)$gid != 1 && $gid != 2 AND message(-1, '无权限'); 权限检查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 注入)保留 db_sql_find 或 保留 db_exec 关键字的注释(扫描器据此跳过报告)new Service() + 查库 N 次(用静态缓存)db_insert 后不检查返回值就 message(0, '成功')(字段不存在会静默失败)unstall.php(旧拼写) → 必须用 uninstall.php(标准拼写),核心已兼容回退但新插件禁止用旧拼写esc_attr(json_encode(array(...))) 嵌套括号写一行(易出错,先存变量再转义)/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.php hook 修改局部 $routes 变量setting_set('xnx_checkin', ...) 但从 setting_get('plugin_discover_items') 读取)→ 保存和读取必须用同一个 keysetting_set('key', xn_json_encode($arr)) 然后 setting_get() 后 xn_json_decode() → setting_set/get 原生支持数组存取,不需要 JSON 中转 相对路径 → 必须用 $conf['view_url']js/xxx.jsTablerIconPicker、bootstrap.Modal 等)lang() 多语言键,运行时解析if(cache_get())...else{compute;cache_set()} 缓存样板代码 → 用 CacheHelper::remember($key, $ttl, $callback, $plugin) 一行搞定cache_delete() 清除缓存 → 用 CacheHelper::pluginDeletePrefix($plugin) 一键清除cache_get('xnx_plugin_key') 全局键名 → 必须用 CacheHelper::pluginKey() 生成带 p_{plugin}_ 前缀的键名CacheHelper::registerKeys() 注册(后台统计面板依赖此注册)param() 默认行为 → param() 默认做 htmlspecialchars 转义会破坏密码,必须传第 3 参数 FALSE(如 param('redis_password', '', FALSE))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 字段toolbarKeys → AIEditor 的 toolbarKeys 把字符串当内置按钮ID处理(如 'bold'),自定义按钮必须以对象形式传入。注意 PHP implode(',', ['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 两处)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)return;:hook 是编译期内联到宿主函数/路由文件/模板中的,hook 内的 return; 会从被内联的整个宿主返回(不是从 hook 返回),导致宿主后续逻辑被跳过。已违反 4 次,影响范围逐次扩大:thread_create_thread_end(审核场景无提示无跳转)/ xnx_feeds user_create_post_end(注册后无提示)/ xnx_hidden post_create_htmx_reply_end(回复后 htmx 响应污染)post__create 返回 int(754) 但 post_create() 因 hook return 提前退出,返回 NULL)/ xnx_hidden model_post_create_end(同上)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 xxx.php 标记所在位置是否在任何 function/method 外xnx_fields/hook/thread_create_thread_end.php):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') 区分发帖/回复/编辑三种场景hx-get/hx-post/hx-target/hx-optimistic/hx-live)XN.toast() / XN.ajax() / XN.confirm()(xiuno-modern.js))esc_html() / esc_attr() / esc_js()plugin//static/js/ 和 plugin//static/css/ $conf['view_url'] . '../plugin//static/js/xxx.js' $conf['view_url']js/xxx.js(非相对路径 ../view/js/)plugin//static/js/ ,CSS 放 plugin//static/css/ ,禁止放 view/htm/(会被 _include() 当模板编译).htm hook(如 post_js.htm),≥50 行必须独立 .js 文件用 引用(便于浏览器缓存 + 静态版本控制)setting_get/set('plugin_discover_items', $arr)),不要存到各自插件 key 再从另一个 key 读取setting_set/get 原生支持数组存取,不需要 xn_json_encode/decode 中转TablerIconPicker、bootstrap.Modal 等),复用优先于自建lang() 多语言键,不硬编码中文$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 注入)// 保留 db_sql_find 或 // 保留 db_exec(扫描器据此跳过,10 行抑制区间)DEFAULT CHARSET=utf8mb4SHOW COLUMNS + ALTER TABLE 幂等逻辑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 后台统计页空白)Service::getCached($uid) 静态缓存避免 N 次查询getUserMedals() 用 JOIN 一次查询避免 N+1esc_attr(json_encode(...)) 先存临时变量再转义thread_url($tid))> route_url() 通用入口 > 底层 url() 函数hook/model_route_table_end.php 注册到 $routes 数组url() 函数(适配伪静态格式),禁止硬编码 .htm / .html 后缀$site_url . url("xxx"),禁止 $site_url . '/xxx.htm'CacheHelper::remember($key, $ttl, $callback, $plugin) 代替裸 cache_get/cache_setCacheHelper::pluginDeletePrefix($plugin) 一键清除(不枚举 limit 值)CacheHelper::pluginKey($key, $plugin) 生成带 p_{plugin}_ 前缀的键名CacheHelper::registerKeys($plugin, $keys) 注册缓存键清单CacheHelper::remember('list_v_'.$id, 86400, ...) + CacheHelper::set(pluginKey(...), $v+1, 86400))param($name, $default, FALSE) 关闭 htmlspecialcharsinclude_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(前台未初始化)json_encode([$btnObj])),若传变量名字符串用 implode 生成无引号格式让 JS 当变量引用,修改前用 echo 确认实际输出格式x-card class:项目统一卡片样式(无 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"
}
字段类型陷阱(高频写错):
字段
类型
错误写法
正确写法
version
string
"1.0"
"1.0.0"(三位制 X.Y.Z)
bbs_version
string
"1.0.0" / "4.5" / "2.0"
"1.0" / "1.1"(两位制 X.Y,当前 XIUNOX 核心为 1.x 系列,禁止写 4.5 或其他版本号)
hooks_rank
object
[] / {} 空对象不展示用法
{"hook_name.php": 10}(值大先执行,默认 0)
overwrites_rank
object
[] (常见错误:写成数组)
{"view/htm/header.inc.htm": 10}(值最大的覆盖文件生效,赢家通吃)
dependencies
object
[] (常见错误:写成数组)
{"xn_search": "1.0"}(key=依赖插件目录名,value=最低版本约束,支持 npm 风格 >=/^/~/*)
capabilities
array
"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 / id
Step 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")