适用版本: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,无框架依赖 |
多应用划分:
| 应用 | 路径 | 职责 |
|---|---|---|
index | app/index/ | 前台:首页、列表、详情、单页、搜索、会员、授权接口 |
manage | app/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.list | category_alias/name/id、limit、order、with_date | 文章数组 |
article.tabs | tabs(多个栏目别名) | 分栏文章 |
product.list | category_alias/name/id、limit、order | 产品数组(自动含子栏目) |
case.list | 同上 | 案例数组 |
category.children | category_alias/name/id | 子栏目 |
category.tree | pid、limit | 栏目树 |
category.id | category_alias/name/id | 栏目 ID(做 tab 高亮) |
callback | call 闭包 | 任意结果 |
取到的数组会 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 发版流程(内部)
- 只改主站代码 →
php -l→ 主站实测 - 用
build_pkg.py遍历build/目录打包(包内路径 = 相对路径),输出到upgrade_packages/与母本upgrade_server/packages/ - 两处
versions.php追加同一版本(name/version/log/release_time/package) check.php实测latest_version/has_update/package_md5- 各站升级:后台点升级,或用等价脚本
upgrade_apply.py <站点根> <包> <库名> - 发更新日志文章 → 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.php | server、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 上线三件事
config/license.php的server改为正式授权服务域名(https)sanbbb_license表登记每个客户域名(裸域与 www 分别登记)- 生产环境不要保留
.test测试授权行
十、二次开发示例
10.1 新增一个主题
- 复制
template/default为template/mytheme - 改
template/default/template_info.php→template/mytheme/template_info.php - 在
template/mytheme/data.php声明本主题需要的数据 - 到后台「模板管理」切换(需该站
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 |
| Cookie | HttpOnly + 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 生成密钥。
本文档随版本维护。涉及协议与目录结构的改动,请在发版时同步更新本手册。