从 27 篇文档里"考古":一套老牌 PHP CMS 的完整开发规范是怎么还原出来的
本文记录一次真实的规范整理过程:拿到一个只有 27 篇帮助文档的国产 CMS(能鈺CMS),如何把它散落在各处的隐性规则,重建成一份可直接照着写代码的开发规范。方法可复用到任何"文档写得薄、代码写得厚"的老项目上。
写在前面
先说结论:这套 CMS 的官方文档只有 27 篇,插件开发部分正文不到 1000 字。 但你如果照着文档写插件,100% 会在第三个坑卡住。
因为真正的标准不在文档里,在官方示范插件的源码里。
我做的事情是:抓完 27 篇文档 → 下载官方示范插件包 → 解包逐行读 → 把「文档说什么」和「代码实际怎么跑」对齐 → 输出一份可执行的规范。
整理出的手册大约 40KB,下面挑最关键的部分展开。
一、先搞清楚:文档体系长什么样
| 板块 | 篇数 | 内容 |
|---|---|---|
| 安装说明 | 6 | 运行环境、目录结构、安装、支付配置 |
| 标签格式 | 11 | 内容页/列表页/单页/首页/公共/循环/判断/常量 |
| 模板设计 | 3 | 模板结构、模板说明、常见问题 |
| 二次开发 | 5 | 数据库字典、DB 操作、常用函数、安全注意 |
| 插件开发 | 2 | 插件说明、插件示范 |
| 支付设置 | 0 | 空栏目 |
第一个判断:标签格式占了 11/27。 这说明这套 CMS 的重心是模板层,不是后端框架层。也就是说,绝大部分二次开发需求,其实是用标签解决,而不是写 PHP。
第二个判断:插件开发 2 篇是这套文档最薄的地方。 薄到"插件结构"那一节直接用一张图片代替文字。
所以我专门去把示范插件包下载下来解包了。
小技巧:这类老 CMS 的文档站几乎都没有 sitemap,但文章 URL 通常是有规律的(这里是
/show/{id}.html)。直接seq 1 27循环抓,比翻菜单快得多。
二、模板设计规范:4 个文件决定生死
2.1 模板怎么被系统识别
模板放在 theme/ 下,一套一个文件夹。系统识别模板的依据只有一件事------这 4 个文件齐不齐:
theme/你的模板/
├─ _config.php 模板指向配置文件
├─ _note.php 模板文件用途及注释(后台自动生成)
├─ _theme.php 模板配置信息:名称/作者/官网
└─ theme.jpg 模板缩略图,建议宽度 390px
名字一个都不能改,少一个模板就不显示,而且后台不会给你任何报错。
这是模板作者最容易"白忙一场"的地方------目录结构全对、CSS 写得漂亮,就因为漏了个 _note.php,后台界面管理里死活看不到这套模板。
2.2 编码与后缀
- 必须 UTF-8:程序是 UTF-8,模板用 GBK 保存,前台直接乱码。
- 后缀默认
.php,不是.html。系统运行形态是「HTML + 标签」。
2.3 完整目录结构(这就是交付验收清单)
官方默认模板的目录,可以直接当 checklist 用:
模板根/
├─ index.php 首页
├─ head.php / top.php / foot.php / center.php 公共头/顶/底/会员主页
├─ block/left.php 区块文件
├─ css/ images/ js/ 静态资源
├─ mobile/ 手机站(响应式可省略)
├─ other/ label.php pay.php search.php sitemap.php tags.php
├─ page/ list.php page.php show.php comment.php
│ show_temp.php(下载类) show_video.php(视频类)
└─ user/ index.php login.php reg.php auth.php editpass.php ...
bill/ money/ order/ other/ post/ 会员各子模块
判断标准很简单 :做企业站,最少交付 index + list + page + show + search + head/top/foot;做知识付费站,user/ 必须全量补齐,还要加 show_video.php 和 show_temp.php。
2.4 UI 组件不是强制的
官方自己说了:不强制用自带 ui.css,可以用任意第三方 UI。唯一要求是所有功能点必须正常------登录、下单、分页、筛选、评论、投稿,一个都不能坏。
这句话翻译成人话:换 UI 框架可以,但换完要做功能回归。
三、标签语法:这套 CMS 真正的核心
3.1 标签体系总览
| 类别 | 形态 | 典型 |
|---|---|---|
| 变量 | {$var} / {$rs[字段]} |
{$title} |
| 常量 | {WEB_ROOT}、{USER_ID} |
12 个内置 |
| 系统变量 | {cms[web_name]} |
后台设置字段 |
| 数据循环 | {cms:rs ...} |
万能查库 |
| 数组循环 | {loop data="$arr"} |
遍历已有数组 |
| 计数循环 | {for min max step var} |
纯自增 |
| 判断 | {if}{elseif}{else}{/if} |
条件 |
| 包含/区块 | {include} / {block("key")} |
复用 |
| 转义 | {no}...{/no} |
保护 JS |
3.2 最重要的两个坑
坑一:主表查不到自定义字段。
这套 CMS 把内容拆成了两张表:
cms_show(主表) |
cms_data(副表) |
|---|---|
| 标题、缩略图、价格、销量、状态、排序 | 简介、正文、组图、视频、下载、网盘、SEO 自定义 |
php
// ✅ 查副表,能调自定义字段
{cms:rs table="cms_data" where="isshow=1" cateid="1" order="ordnum desc,id desc"}
{$i}. {$rs.title}
{/cms:rs}
// ❌ 查主表,{$rs.intro} 恒为空,还不报错
{cms:rs table="cms_show" cateid="1"}
{$i}. {$rs.title} {$rs.intro}
{/cms:rs}
坑二:一个模板只能有一个 pagesize。
文档原话:"一个模板中,只能有一个 pagesize 参数。"两个列表都想分页 → 第二个列表分页必然错乱。没有报错,就是页码乱跳。
3.3 万能循环标签 {cms:rs} 参数表
这是整套标签里最需要背下来的一个,14 个参数:
| 参数 | 作用 | 必须 | 默认 | 备注 |
|---|---|---|---|---|
table |
表名 | 是 | --- | 也可写 from |
top |
显示数量 | 否 | 10 | 0 = 全部 |
pagesize |
分页数量 | 否 | --- | 全页唯一 |
field |
查询字段 | 否 | * |
|
join |
链表 | 否 | --- | |
where |
条件 | 否 | 1=1 |
|
order |
排序 | 否 | id desc |
|
group |
分组 | 否 | --- | |
cateid |
栏目 ID | 否 | --- | 查内容时用 |
cache |
是否缓存 | 否 | false | |
cachetime |
缓存时间 | 否 | 0 | 0 = 永久,单位分钟 |
num |
分页数字间隔 | 否 | 3 | |
key |
分页自增字段 | 否 | id |
自增字段非 id 必须指定 |
auto |
循环自增变量 | 否 | i |
多层循环必须改名 |
还自带一个 {rs:eof} 兜底标签,查不到数据时显示:
{cms:rs table="cms_data" top="10"}
{rs:eof}暂无内容{/rs:eof}
{$rs.title}
{/cms:rs}
3.4 {no} 标签:救命的那个
模板引擎会把所有 {...} 当标签解析。你的 JS 里写 if(a){b()},会被引擎吃掉,然后:
页面不报错,样式正常,功能全废。
正确姿势:
{no}
<script>
if (a) { b(); }
</script>
{/no}
这个坑我见过太多次了,排查起来极其痛苦,因为控制台一片干净。
3.5 常量表(12 个,建议背)
| 常量 | 值示例 | 用途 |
|---|---|---|
{WEB_URL} |
http://www.xx.com |
站点域名 |
{WEB_ROOT} |
/ |
相对根路径 |
{THIS_LOCAL} |
/news |
当前路径 |
{THIS_URL} |
http://.../show/1.html |
当前 URL |
{PRE_URL} |
/ |
上一页 |
{M_NAME} / {C_NAME} / {A_NAME} |
home / index / index |
模块/控制器/方法 |
{WEB_THEME} |
/theme/default/ |
模板路径 |
{IS_HOME} |
--- | 是否首页 |
{USER_ID} |
0 |
未登录为 0 |
{USER_GROUP} |
--- | 会员组 ID |
一条硬规范 :所有静态资源必须写成 {WEB_ROOT}public/... 或 {WEB_THEME}css/...,禁止 硬编码 /public/...。
原因:客户把站部署在子目录(www.xx.com/cms/)时,硬编码的绝对路径全部 404,整个站光秃秃的。
3.6 模板标签 10 条铁律
写模板前背下来,能省掉 80% 的调试时间:
- 资源前缀统一
{WEB_ROOT}/{WEB_THEME} - 要自定义字段 → 查
cms_data - 一页只允许一个
pagesize - 嵌套循环换字母(
rs/rs2/rp),每层不同auto - 空数据必须给
{rs:eof} - 含
{}的 JS 全部包{no} - 文件必须 UTF-8
- 4 个
_文件 +theme.jpg齐全 - 列表分页用
$pg->totalpage,内容分页用$pagenum,别混 - 二级栏目判断
get_sonid_num(),否则空箭头
四、二次开发:能力边界在哪
4.1 官方明说的前置技能
MySQL 设计、PHP 语法、Div+Css、jQuery。文档原话是:
"如果你啥都不会,请节省大家时间,专业的事情交给专业的人处理。"
这句话挺实在的,建议直接写进给客户的报价前置说明里。
4.2 数据库 API(5 个方法)
php
$this->db->row("SQL"); // 读单条,返回一维数组
$this->db->load("SQL"); // 读多条,返回二维数组
$this->db->add("表名", [数组]); // 插入,主键取 $this->db->newid
$this->db->update("表名", "条件", [数组]); // 更新
$this->db->del("表名", "条件"); // 删除
注意 update / del 的第二个参数是直接拼进 SQL 的字符串,这是注入的高发点:
php
// ✅ 强制转整型
$id = getint(F('id'));
$this->db->update("cms_show", "id=$id", $data);
// ❌ 直接拼接用户输入
$this->db->update("cms_show", "id=" . F('id'), $data);
4.3 常用函数(9 个,够用 90% 场景)
| 函数 | 作用 |
|---|---|
config('web_name') |
读系统变量 |
T('home') |
读模板指向路径 |
F('url') |
取 GET/POST(已内置 XSS 过滤) |
enhtml($v) |
参数过滤 |
nohtml($v) |
去 HTML 取纯文本 |
getint($v, 0) |
强转整型,非法为 0 |
cut($v, 30, 1) |
截字符串,第 3 参为 1 加省略号 |
S("key", $v) |
Session 存取删 |
C("key", $v) |
Cookie 存取删 |
F() 还支持强制指定来源:F('get.url') / F('post.url')。
扩展函数写在哪? 官方明确:写进 app/fun.php,前后台控制器都能直接调。这是唯一推荐的落点。
4.4 安全红线(照做,别问)
-
所有表单前后端双验证------前端只算体验,不算安全
-
所有 POST 必须带 Token :
html<input name="token" type="hidden" value="{$token}"> -
数据获取一律走
F(),不要直接摸$_GET/$_POST -
上线前关掉
index.php里的错误提示------错误页会暴露物理路径和 SQL
4.5 二开的三条铁律
| 铁律 | 原因 |
|---|---|
不改 class/、app/app.php、app/cms.php |
升级即覆盖,改了就等着背锅 |
扩展函数进 app/fun.php |
唯一官方认可落点 |
| 开发期开错误提示,上线关 | 安全 |
五、插件开发:文档最薄,坑最深
这一节是全文重点。因为官方只给了 2 篇文档、正文不到 1000 字,剩下全靠我读示范包源码推出来的。
5.1 目录规范(官方结构图 + 示范包双重确认)
plug/
└─ test/ 插件文件夹名 = 插件唯一标识 root
├─ _config.php 【必须】配置,文件名不可改
├─ icon.jpg 【必须】图标
├─ app/ 程序代码
│ ├─ index.php 前台控制器(非必须)
│ ├─ admin.php 后台控制器(非必须)
│ └─ hook.php 钩子类(hook=1 时必须有)
└─ view/ 插件模板
└─ config.php 后台参数页
文件夹名只能英文、不能有特殊字符------因为它会变成类名和路由的一部分。
5.2 _config.php 全字段(12 个)
php
<?php
return [
'title' => '百度推送', // 插件名称
'icon' => 'icon.jpg', // 图标,相对插件目录
'intro' => '百度URL批量推送API接口', // 简介
'author' => '官方', // 作者
'url' => 'https://www.nengyu.com', // 作者网址
'version' => '1.0', // 版本
'install' => 'DROP TABLE IF EXISTS cms_plug_push@@@@CREATE TABLE ...', // 安装SQL
'delete' => 'DROP TABLE cms_plug_push', // 卸载SQL
'type' => 'tool', // 类型
'config' => '{"url":""}', // 配置数据(JSON字符串!)
'hook' => '0', // 是否用 Hook
'admin' => '1', // 是否有后台
];
三个必踩的坑:
坑 1 --- config 必须是 JSON 字符串。 不是 PHP 数组。写成 ['url' => ''] 后台读不到配置,而且不报错。
坑 2 --- install / delete 多条 SQL 用 @@@@ 分隔,不是分号。 因为建表语句内部本身含分号,用分号会把 SQL 切坏。
坑 3 --- delete 必须幂等。 用户会重复点卸载,用 DROP TABLE IF EXISTS。
type 的枚举值:pay / sms / upload / auth / login / cash / safe / tool / other。
5.3 钩子机制
类名规则:plug_插件文件夹名 (示范里是 plug_test)。
php
<?php
class plug_test
{
private $cms;
public function __construct($ref = null)
{
if ($ref) { $this->cms = $ref; } // 系统注入核心对象
// 注册:cms_hook::reg(钩子名, $this, 方法名)
cms_hook::reg('route', $this, 'route');
// 业务开关:关了就不再注册输出型钩子
$plug = config('plug');
if (isset($plug['test'])) {
$data = $plug['test']['config'];
if (getint($data['state']) == 0) { return; }
cms_hook::reg('header', $this, 'css');
cms_hook::reg('footer', $this, 'show');
}
}
// 路由钩子:自定义 URL → 控制器
function route(&$route)
{
$route = array_merge($route, [
'testplug' => 'plug/test/index/index',
]);
}
function css() { echo '<style>body{background:#f30;}</style>'; }
function show(&$id)
{
echo 'show plug test';
}
}
注册三要素: 钩子名 → 当前类 $this → 方法名。第 2、3 个参数看着多余,其实是让系统在触发时能回调到你这个实例上。
已确认的钩子只有 3 个:
| 钩子 | 时机 | 参数 |
|---|---|---|
route |
路由解析 | &$route(引用) |
header |
<head> 输出前 |
无 |
footer |
页面底部 | &$id(引用) |
这是全套文档最大的空白。 想挂到"内容详情页"或"下单成功后",官方根本没告诉你钩子叫什么。
我的处理办法(也是我要给官方的建议):去 class/ 目录下搜 cms_hook::reg 的所有调用点,自建一份钩子字典。这是二开前必须做的第一步调研,否则你会花半天找一个不存在的钩子。
5.4 保存配置的标准四步(漏一步就出 bug)
php
function Index()
{
if (IS_POST) {
// 1. 过滤入参
$state = getint(F('state'));
$db = ['state' => $state];
// 2. 写入插件配置
$this->db->update('cms_plug', "root='test'", ['config' => E($db)]);
// 3. 清插件缓存 ← 最常漏的一步
cms_cache::del('plug');
// 4. 返回
$this->success('保存成功');
}
}
第 3 步漏了会怎样? 后台显示"保存成功",前台读到的还是旧值。你会怀疑人生,去查数据库,发现数据是对的------因为缓存没清。
5.5 一个隐蔽的概念混淆
cms_plug 表里有 state 字段(1 启用 / 0 禁用),示范插件又在自己的 config JSON 里存了一个 state。
这两个 state 不是一回事:
| 字段 | 含义 |
|---|---|
cms_plug.state |
系统层面的插件装载状态 |
config.state |
插件自己的业务开关 |
混用就会出现**"后台显示已启用,前台不生效"**。这个 bug 我调过,非常反直觉。
5.6 插件后台模板的 5 条规范
php
<?php if(!defined('IN_CMS')) exit;?><!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="{WEB_ROOT}public/css/ui.css">
...
</head>
<body>
<div class="position">当前位置:应用管理 > <a href="{$plug}">插件管理</a> > <a href="{U('index')}">测试插件</a></div>
<form class="ui-form" method="post">
<input type="hidden" name="token" value="{$token}">
<button type="submit" class="ui-btn blue">保存设置</button>
<a href="{$plug}" class="ui-btn">返回</a>
</form>
</body>
</html>
| 项 | 要求 | 原因 |
|---|---|---|
| 首行 | if(!defined('IN_CMS')) exit; |
防止模板被 URL 直接访问 |
| 资源 | {WEB_ROOT}public/... |
路径可移植 |
| 链接 | {U('方法名')} |
插件内路由 |
| 返回 | {$plug} |
系统注入的管理地址 |
| 表单 | 必须有 token |
官方安全红线 |
注意 :插件模板用的是 {if $state==1} checked{/if} 这种更接近原生 PHP 的写法,和前台模板的 {cms:rs} 体系不是一套解析器。两套语法别混抄,抄错了不报错,就是不生效。
5.7 发布前自测清单
- 全新环境导入 → 表建成功
- 重复导入 → 不报错
- 卸载 → 表清干净
- 卸载后重装 → 数据不残留冲突
-
hook=1但未开伪静态 → 有明确提示 - 关掉业务开关 → 前台零输出
六、数据库规范(从 38 张表反推)
官方给了完整建表语句,但没给"怎么写表"的规范。我从 38 张表里反推出 6 条约定:
| 约定 | 规则 |
|---|---|
| 前缀 | 全表 cms_ |
| 插件表 | cms_plug_插件名 |
| 引擎 | MyISAM(无事务,这点很要命) |
| 字符集 | utf8mb4 |
| 时间字段 | bigint Unix 时间戳,不是 datetime |
| 软删除 | 使用 isshow / state 状态位 |
| 排序 | ordnum(人工)+ id desc(时序) |
MyISAM 无事务是重要风险 :插件里的"扣款 + 发券"必须自己写补偿逻辑,begin/commit 是无效的。
时间戳是 bigint 这点也要注意------直接 SELECT 出来是 1721974764 这种数字,模板里得用 {date('Y-m-d', $createdate)} 转换。
核心表关系:
cms_class(栏目树,followid→cateid,0=顶级)
└─1:N─ cms_show(内容主表)
└─1:1─ cms_data(内容副表)
cid = cms_show.id
回到第二节的坑一------这张图就是那个坑的根源。
七、文档没写、但你必须知道的 6 件事
整理完发现的空白清单:
| # | 空白 | 影响 | 我的建议 |
|---|---|---|---|
| 1 | Hook 钩子只有 3 个 | 复杂插件无从下手 | 搜 cms_hook::reg 调用点自建字典 |
| 2 | _theme.php 字段未公开 |
模板配置易写错 | 复制默认模板,只改值不改键 |
| 3 | PlugController 无接口文档 |
后台能力不可知 | 读 class/ 基类源码 |
| 4 | 无升级兼容说明 | 客户升级后插件可能失效 | 插件内绝不覆盖核心文件 |
| 5 | 无 {cms:rp} 参数表 |
只能靠示例倒推 | 按 cms:rs 参数类比 + 实测 |
| 6 | 无命名总则 | 多人协作不一致 | 沿用「bigint 时间戳 + isshow 状态位 + ordnum 排序」三约定 |
如果你在维护一个开源项目 ,这三条补上,二开沟通成本能砍掉一半:Hook 清单、_theme.php 字段表、PlugController API。
八、可以直接抄的落地检查清单
模板交付
-
_config.php/_note.php/_theme.php/theme.jpg(390px) 齐全 - 全部文件 UTF-8 无 BOM
- 资源路径全用
{WEB_ROOT}/{WEB_THEME} - 首页/列表/内容/单页/搜索/标签页全部可访问
- 子目录部署下样式正常
- 自定义字段能显示(走
cms_data) - 分页正常(列表
$showpage、内容pagelist()) - 空数据有
{rs:eof} - 含
{}的 JS 已包{no} - 响应式 or
mobile/二选一
二次开发交付
- 未修改
class/、app/app.php、app/cms.php - 自定义函数集中在
app/fun.php - 所有 POST 表单含
{$token} - 所有入参过
F()+getint()/enhtml() - SQL 条件无未过滤拼接
- 上线前错误提示已关闭
插件交付
- 目录名纯英文无特殊字符
- 12 字段完整,
config是 JSON 字符串 - SQL 用
@@@@分隔且幂等 -
hook.php类名 =plug_插件名 - 控制器继承
PlugController+isstall()检查 - 后台控制器有
check_login() - 保存配置后调
cms_cache::del('plug') - 模板首行有
IN_CMS守卫,表单有token - 关闭业务开关后前台零输出
结语
这套 CMS 的开发模型,一句话就能概括:
模板 = HTML + 标签 (
{cms:rs}查库 +{$var}取值 +{if}/{loop}控制)二开 =
$this->db操作数据 +$this->tp渲染 +app/fun.php沉淀函数插件 = 一个目录 +
_config.php注册 +hook.php挂载 + 控制器/模板
三条线共同的硬约束:UTF-8、{WEB_ROOT}、Token、F() 过滤、cms_ 前缀、不碰核心文件。
最后说一个方法论上的收获:当文档写得薄的时候,不要抱怨,去读示范代码。 官方示范插件那 6 个文件里藏的信息量,比 27 篇文档加起来还多------因为它是一份能跑通的最小可运行示例。规范的真相永远在可运行的代码里。
本文基于能鈺CMS 帮助文档(help.nengyu.com,27 篇)与官方示范插件包整理,标注「推断」的内容建议实测确认。