首页/开发手册

开发手册

SanbbbCMS 技术栈与架构、目录结构、路由、前台控制器、模板开发、后台开发、数据库设计、升级与授权机制、二次开发示例。

适用版本:1.1.25 | 最后更新:2026-11-06

面向二次开发者与模板作者,讲清"代码怎么组织、改哪里、扩展怎么做"。使用者请看《帮助文档》。


一、技术栈与总体架构

层选型
框架ThinkPHP 8.0(多应用模式)
运行环境PHP 8.1+(推荐 8.3)、MySQL 5.7+(推荐 8.0)、Nginx/Apache
模板引擎ThinkPHP 原生模板(ThinkTemplate)
前端原生 HTML/CSS/JS,无框架依赖

多应用划分:

应用路径职责
indexapp/index/前台:首页、列表、详情、单页、搜索、会员、授权接口
manageapp/manage/后台:登录、内容、栏目、模型、模板、升级、授权管理

关键设计原则:内容与表现分离。控制器只提供"全站通用数据",页面级数据由主题在 template/{主题}/data.php 里声明,改模板不动核心代码。


二、目录结构

├── app/
│   ├── index/                  # 前台应用
│   │   ├── controller/         # Index Article Product Case Job Faq Download Page Search Member License Sitemap
│   │   ├── service/            # ThemeData 主题数据层 / ThemeContext 主题上下文 / DemoFeed / License 授权客户端
│   │   ├── route/app.php       # 前台路由(多级栏目、伪静态)
│   │   └── middleware.php
│   ├── manage/                 # 后台应用
│   │   ├── controller/         # Base + 各业务控制器
│   │   └── view/               # 后台模板(iframe 外壳 + 各页面)
│   └── common.php
├── config/                     # app database session cookie license cache ...
├── public/                     # Web 根
│   ├── index.php               # 入口
│   ├── install/                # 安装器(安装后仅保留 install.lock)
│   ├── static/                 # 全站静态资源
│   ├── uploads/                # 上传文件(运行时生成)
│   ├── upgrade/                # 升级服务端(仅官方站,不随包分发)
│   └── dl/                     # 公开下载的安装包
├── template/{主题}/            # 主题目录
├── route/                      # 全局路由
├── runtime/                    # 运行时:缓存/日志/会话/升级备份/授权回执
└── upgrade_packages/           # 官方升级包与 versions.php(仅官方站)

三、请求生命周期与路由

3.1 前台路由

入口 public/index.php → 多应用路由按 URL 第一段判断应用。前台业务路由集中在 app/index/route/app.php:

/                        → Index/index
/{栏目别名}/              → 栏目列表
/{栏目别名}/{id}.html     → 内容详情
/{父}/{子}/               → 多级栏目
/about.html              → 单页(别名.html)
/search?keyword=         → 搜索
/member/login|register   → 会员
/license/check           → 授权接口(服务端,官方站专属)
/sitemap.xml             → 站点地图

注意:多应用模式下 URL 第一段会被当作应用名,所以 /license/、/api/ 这类前缀必须显式注册路由,且要排在动态 <alias> 路由之前,否则会被栏目别名吃掉。

3.2 站点专属路由不随升级覆盖

config/app.php 会自动 include 同目录的 extra.php(1.1.13+)。站点自己的特殊路由写进 config/extra.php,升级不会覆盖,也不会随升级包分发到别的站。


四、前台控制器

4.1 基类 app/index/controller/Base.php

统一负责:

  • 读取站点配置(sanbbb_config)并 assign 到模板(site_*)
  • 构建栏目导航树(cat_tree)、侧边栏(assignSideNav)
  • 主题解析:template/{config.template}/(ThemeContext 支持按 URL 前缀切主题,模板演示站用)
  • render('article/index'):拼主题路径 → 检查模板文件是否存在 → 不存在时回退 default 主题,并执行 data.php 数据声明

踩坑备忘:render() 的模板存在性检查必须按 ThinkPHP 规则补 controller 前缀(article/index → article/index.html),pageKey 保持裸 'index',改错会导致非 default 主题首页误回退到 default。

4.2 主题数据层 app/index/service/ThemeData.php

主题在 data.php 里声明"这页需要什么数据",控制器不再为每个模板特供变量。

// template/mytheme/data.php
return [
    // 首页
    'index' => [
        'news'  => ['type' => 'article.list', 'category_alias' => 'news', 'limit' => 6, 'with_date' => true],
        'cases' => ['type' => 'case.list',    'limit' => 6],
    ],
    // 指定页面:{模板目录}.{模板名}
    'product/index' => [
        'hot' => ['type' => 'product.list', 'category_alias' => 'hot-sale', 'limit' => 8],
    ],
    // 全站通用
    '*' => [
        'links' => ['type' => 'callback', 'call' => function () { /* 自定义取数 */ }],
    ],
];

支持的 type:

type参数返回
article.listcategory_alias/name/id、limit、order、with_date文章数组
article.tabstabs(多个栏目别名)分栏文章
product.listcategory_alias/name/id、limit、order产品数组(自动含子栏目)
case.list同上案例数组
category.childrencategory_alias/name/id子栏目
category.treepid、limit栏目树
category.idcategory_alias/name/id栏目 ID(做 tab 高亮)
callbackcall 闭包任意结果

取到的数组会 assign 成同名模板变量;同名时 data.php 覆盖控制器预先 assign 的值。


五、模板开发

5.1 语法速查

{include file="common/header" /}                 <!-- 引入片段 -->
{$info.title}                                     <!-- 输出变量 -->
{$info.title|default='暂无标题'}                   <!-- 默认值 -->
{volist name="list" id="item"}...{/volist}        <!-- 列表 -->
{foreach $nav as $k => $v}...{/foreach}           <!-- 关联数组遍历 -->
{if !empty($list)}...{else /}...{/if}             <!-- 条件 -->
{$item.create_time|date='Y-m-d'}                  <!-- 格式化 -->

5.2 已知限制

  • | 过滤器不能出现在 {if} 标签里:{if $list|count == 0} 会被编译成按位或,PHP 8 直接报错。改用 {if empty($list)}。
  • volist 对关联数组不可靠,关联数组用 {foreach ... as $k => $v}。
  • 模板编译缓存位于 runtime/index/temp/,改模板不生效先清这里(命令行 php think clear 不清 temp,需手工删)。

5.3 主题信息文件

// template/mytheme/template_info.php
return [
    'name'     => '深色暖金',
    'version'  => '1.0.0',
    'author'   => '叁佰建站',
    'category' => 'company',   // company / gov / school / hospital / other
    'color'    => 'dark',
    'demo_no'  => 'dark-gold',
    'features' => ['首页', '新闻', '产品', '案例'],
    'requires' => '1.1.20',
    'cover'    => 'static/covers/dark-gold.jpg',
    'origin'   => '',
];

模板授权:站点 config.table template_licensed 控制「模板管理」里能看到、能切换哪些主题。默认只允许 default + 当前主题;* 表示不限(官方站/演示站)。


六、后台开发

6.1 控制器基类 app/manage/controller/Base.php

  • 会话校验:未登录跳 /manage/login/index;会话空闲过期 3 小时,请求自动续期
  • 权限:按角色过滤菜单与操作
  • 所有响应加 X-Robots-Tag: noindex,nofollow
  • assign 授权横幅数据(license_notice)

6.2 列表页 / 表单页约定

  • 列表:index() 分页 + 搜索;视图 view/{模块}/index.html
  • 表单:add()/edit() 复用同一模板 view/{模块}/form.html,save() 处理新增与更新
  • 删除、状态切换用 POST,返回 JSON

6.3 后台 fetch 表单必须带 Ajax 头

后台保存走 fetch + JSON。请求必须带 X-Requested-With: XMLHttpRequest:

fetch(url, {
    method: 'POST',
    headers: {'X-Requested-With': 'XMLHttpRequest'},
    body: new FormData(form)
}).then(r => r.json()).then(res => { if (res.code === 0) location.href = ... });

缺少该头时 isAjax() 判定失败,框架返回 302 跳转,前端 JSON 解析失败并静默中断——表现为"点了保存没反应但实际已保存"。排查时要同时搜 addEventListener('submit' 和 onsubmit= 两种写法。

6.4 后台界面

后台主体是 iframe 外壳(view/index/index.html),左侧菜单 + 右侧内容。改顶栏横幅、菜单只需改这一处,全后台生效。


七、数据库设计

表前缀默认 sanbbb_(安装时可改)。主要表:

表说明关键字段
article文章/专题/新闻(通用内容表)category_id title sub_title title_color images cover intro content publish_time source author status is_top sort seo_*
product产品通用内容字段 + price specs
case案例通用内容字段 + client industry
job招聘title num education experience address salary content
faq问答question answer
download下载title file file_size version count
page单页pid name alias content seo_*
category栏目pid name type model_id alias list_tpl detail_tpl redirect_url status sort
model内容模型name table_type status
model_field扩展字段定义model_id name label type options sort
config站点配置键值name value group
admin / role管理员 / 角色权限—
member / member_level / member_field会员体系—
form / form_field / form_data自定义表单—
message留言—
banner / link / custom_label / keyword轮播 / 友链 / 定制标签 / 内链关键词—
license站点授权(仅官方站)domain install_id edition features status expire_time check_count
log操作日志—

7.1 字段自愈(ensureColumn)

内容表新增的通用字段(副标题、标题颜色、轮播多图、发布时间、来源、作者等)在保存时会调用 ensureColumn 自动检测并补列,老站点无需手工执行 SQL:

$this->ensureColumn('article', 'sub_title', "VARCHAR(200) NOT NULL DEFAULT ''");
$this->ensureColumn('article', 'images',    'TEXT NULL');
$this->ensureColumn('article', 'publish_time', 'INT UNSIGNED NOT NULL DEFAULT 0');

7.2 时间字段

create_time / update_time 为 INT 时间戳;publish_time 为 INT,编辑时留空不覆盖原值。


八、在线升级机制

8.1 两端组成

端文件是否随包分发
服务端public/upgrade/check.php、download.php、config.php❌ 仅官方站
客户端app/manage/controller/Upgrade.php✅

8.2 协议

GET check.php?version=当前版本&domain=站点域名&install_id=安装标识
→ { code:0, data:{ latest_version, has_update, log, package_url, package_size, package_md5, msg } }

GET download.php?token=<RSA签名令牌>
→ 二进制包(响应头 X-Package-Md5)
  • 令牌 payload:upg|包名|到期时间戳|域名|nonce,RSA-SHA256 签名,有效期 10 分钟
  • 服务端先校验域名授权(sanbbb_license 表:status=1 且未过期),再签发令牌
  • 升级包物理放在 Web 根之外(upgrade_packages/),直链 404
  • 客户端应用前校验:包 MD5 → 逐文件 SHA256 → 备份 → 覆盖 → 更新 site_version

8.3 升级包结构

sanbbb-1.1.25.upgrade.zip
├── upgrade.json
│   {
│     "version": "1.1.25",
│     "log": "...",
│     "files": [{"path": "app/manage/controller/Upgrade.php", "action": "modify", "hash": "sha256..."}],
│     "delete": ["old/file.php"]
│   }
└── app/ config/ template/ public/ ...   # 相对路径与原站一致

8.4 发版流程(内部)

  1. 只改主站代码 → php -l → 主站实测
  2. 用 build_pkg.py 遍历 build/ 目录打包(包内路径 = 相对路径),输出到 upgrade_packages/ 与母本 upgrade_server/packages/
  3. 两处 versions.php 追加同一版本(name/version/log/release_time/package)
  4. check.php 实测 latest_version / has_update / package_md5
  5. 各站升级:后台点升级,或用等价脚本 upgrade_apply.py <站点根> <包> <库名>
  6. 发更新日志文章 → git commit + tag

铁律:先查 versions.php 尾部确认版本号未被占用;新增的 service 类若被 Base.php 等公共文件引用,必须随包分发,否则老站升级后 500。


九、站点授权机制

9.1 组成

端文件说明
服务端app/index/controller/License.php + 路由 /license/check查表 + RSA 签名回执,IP 限流 30/min
客户端app/index/service/License.php上报域名与 install_id、验签、缓存、宽限
配置config/license.phpserver、public_key、check_gap(86400)、grace_days(7)、timeout(5)、local_template
数据sanbbb_license 表域名唯一键、版本、功能、到期、状态

9.2 关键行为

  • 域名精确匹配,不做 www. 归一化:www.abc.com 与 abc.com 是两条授权
  • 回执缓存按域名分文件存 runtime/license/state_<md5(domain)>.json,防止拷站复用
  • 防时钟回拨:取 filemtime 与 time() 较大值
  • 断网/服务端故障:沿用上次回执,最多宽限 7 天
  • 本地调试:仅 localhost / 127.0.0.1 / IP 允许切模板,不允许在线升级
  • 闸门位置:manage/controller/Template::set()(切模板)、manage/controller/Upgrade::apply()(升级)

9.3 上线三件事

  1. config/license.php 的 server 改为正式授权服务域名(https)
  2. sanbbb_license 表登记每个客户域名(裸域与 www 分别登记)
  3. 生产环境不要保留 .test 测试授权行

十、二次开发示例

10.1 新增一个主题

  1. 复制 template/default 为 template/mytheme
  2. 改 template/default/template_info.php → template/mytheme/template_info.php
  3. 在 template/mytheme/data.php 声明本主题需要的数据
  4. 到后台「模板管理」切换(需该站 template_licensed 允许该主题)

10.2 页面加一块"最新产品"

不需要改控制器,直接在主题 data.php 声明:

'index' => [
    'new_products' => ['type' => 'product.list', 'limit' => 4, 'order' => 'id desc'],
],

模板里循环:

{volist name="new_products" id="p"}
<a href="{$p.url}">{$p.title}</a>
{/volist}

10.3 给产品加字段

后台「扩展字段」新增字段(如 voltage,类型单行文本)→ 内容编辑页自动出现 → 模板输出 {$item.voltage}。

10.4 站点专属路由

新建 config/extra.php(升级不覆盖):

use think\facade\Route;
Route::get('api/notice', function () { return json(['code' => 0]); });

十一、安全基线

开发与部署必须遵守:

项做法
调试开关生产 APP_DEBUG=false,异常只记日志不输出堆栈
SQL全部走 ORM/参数绑定,禁止字符串拼接
输出转义模板默认转义;富文本输出处确认来源可信
上传白名单扩展名 + 文件类型校验 + 上传目录禁止执行 PHP
后台登录失败锁定(5 次 / 15 分钟)、验证码、X-Robots-Tag: noindex
CookieHttpOnly + SameSite=Lax,HTTPS 下 Secure
安装入口安装后保留 install.lock,禁止 force=1 旁路重装
备份备份目录在 runtime/,不放在 Web 根
授权服务端 fail-close:查不到授权即拒绝签发升级令牌

十二、常见开发问题

Q:改了模板不生效? 清 runtime/index/temp/。

Q:后台保存没反应? 检查 fetch 是否带 X-Requested-With 头,见 6.3。

Q:{if} 里用 | 报错? 见 5.2,改用函数式写法如 empty($list)。

Q:新增 service 类后老站升级 500? 该类被公共文件引用却没进升级包,补进下一版包即可。

Q:runtime_path() 在 Web 与 CLI 下不一致? Web 请求返回应用级 runtime/index/,CLI 返回项目级 runtime/;要读写项目级数据请用 root_path().'runtime/'。

Q:Windows 下 openssl_pkey_new 报 error:80000003? 缺 openssl.cnf,改用命令行 openssl genpkey 生成密钥。


本文档随版本维护。涉及协议与目录结构的改动,请在发版时同步更新本手册。