企业官网看起来总是差不多:介绍企业,展示产品与案例,发布新闻,留下联系方式。但只要真正交付过一个网站,就会发现,页面完成之后,工作还远没有结束。
谁来修改内容?编辑到一半的文案会不会出现在官网?换一张封面需要找开发者吗?安装失败后,接手的人能不能继续?几个月后迁移服务器,究竟要备份哪些东西?
研发 HHY CMS 时,我关心的是这些问题。我希望把建站过程中反复出现的内容管理、发布和交付能力写进一个产品,让其他人能够在自己的环境里安装、管理,并继续维护一套企业官网。
这篇文章以当前 0.1.2 的源码为基础,记录我如何划分系统职责,以及为什么一些看起来很小的约束,值得在第一版就认真实现。
把网站交付给别人,意味着把日常更新的能力、操作的边界和出错后的恢复路径一起交出去。
先确定交付的最小完整产品
第一版的范围,是一套企业官网和与之配套的管理后台:首页、关于、产品、案例、新闻、联系页,以及分类、媒体、站点设置和首页区块管理。
这个范围决定了数据模型。产品、案例、新闻和页面用 kind 区分,共用标题、URL 标识、摘要、正文、分类、封面和 SEO 字段。品牌、联系方式、导航和页脚属于站点设置;首页区块单独管理顺序、可见性与文案。
我没有在这个阶段引入任意内容类型建模、自由拖拽布局、多租户或插件市场。每增加一种自由度,都需要相应的校验、兼容和维护机制。先把常见的企业表达组织好,才能知道哪些差异值得成为产品能力。
这也让“可复用”变得具体:不同企业可以更换自己的品牌、内容与首页组织,而内容保存、发布、鉴权和媒体管理沿用同一套实现。
让 HHY 承担完整的服务端职责
HHY CMS 建立在 HHY Web 和 MySQL 之上。安装器、路由、权限、数据库访问与服务端页面都使用 HHY 实现;浏览器端用 CSS 和 JavaScript 增强编辑体验。部署 CMS 本身不需要 Node.js,也没有前端构建步骤。
当前环境约定是 HHY 1.5.0、官方 database 1.0.0 扩展、MySQL 8.x 或兼容版本,以及 OpenSSL 3 和系统 file 工具。这些依赖写进交付文档,比一句“开箱即用”更有价值。
代码按业务边界拆分:
| 模块 | 承担的职责 |
|---|---|
lib/application.hhy | 注册路由、分发请求、统一错误响应 |
lib/install.hhy | 安装验证、迁移记录、初始化与恢复 |
lib/auth.hhy | 登录、退出与登录限流 |
lib/content.hhy | 内容编辑、保存、发布、下线与预览 |
lib/manage.hhy | 站点设置、首页区块、分类与媒体 |
lib/site.hhy | 组织公开页面数据与 sitemap |
themes/default/theme.hhy | 默认主题的展示逻辑 |
这套划分让我能沿着业务动作阅读系统。修改发布规则时看内容模块;调整视觉表达时看主题;安装失败时沿安装状态检查。它也为后来接手代码的人提供了入口。
HHY Web 的组合方式在应用入口中很直接,下面是源码中的静态资源注册片段:
let mut app = hhyweb.minimal()
|> hhyweb.static_files("/assets",path("public"))
|> hhyweb.static_files("/media",c.file("uploads"))
这个映射同时表达了部署边界:静态资源和上传图片可以公开,私有配置、安装令牌与临时文件不能因为“方便”而一起暴露。
把安装器当作一段需要恢复的工作流
给自己使用的项目,往往可以靠手动建表和补配置启动。交给别人之后,安装过程本身就是产品的一部分。
HHY CMS 首次启动生成安装令牌,安装向导再收集数据库、站点和管理员信息。数据库必须由部署者预先准备,首次安装会检查是否为空;浏览器里填写的数据库地址,还必须落在部署环境配置的 CMS_DB_ALLOW 范围内。
这样,安装表单负责收集信息,允许连接哪些数据库则由部署者决定。
安装也不能假设所有步骤一次成功。当前实现先保存 install-pending.json,再执行迁移与初始化;cms_migrations 记录已执行的步骤,数据库锁用于避免迁移阶段并发执行。中断后沿用首次提交的安装信息继续,完成后才写正式配置。
这里我刻意区分了“继续同一次安装”和“覆盖一个已有站点”。数据库中的安装标识用于识别归属,已有站点的安装入口会关闭。恢复不应该变成一次隐藏的重装。
这些机制针对的是当前安装流程,并不等于已经具备完整的跨版本升级系统。后续的模式变更、历史版本兼容与升级回退,仍然需要独立设计。
草稿与公开版本必须在数据上分开
内容管理最容易让使用者失去信任的情况,是点击“保存”之后,未完成的改动直接出现在官网。
HHY CMS 把可编辑字段和 public_json 公开快照放在同一条内容记录中。保存草稿只更新编辑态;发布时才替换公开快照。公开页面读取快照,后台预览读取当前编辑内容。
| 操作 | 编辑内容 | 官网展示 |
|---|---|---|
| 新建并保存草稿 | 保存新内容 | 仍不公开 |
| 修改已发布内容并保存草稿 | 保存新修改 | 保持上一次发布版本 |
| 发布 | 保存当前内容并更新快照 | 展示新的公开版本 |
| 下线 | 保留可编辑内容 | 移除公开版本 |
下面是 lib/content.hhy 中更新发布快照的实际代码:
if f.intent == "publish" {
let snapshot = put(item,"revision",c.str(revision+1))
c.exec(tx,"UPDATE cms_content SET public_json=?,public_category_id=NULLIF(?,0),public_cover_id=NULLIF(?,0),published_at=UTC_TIMESTAMP() WHERE id=?",[encode_json(snapshot),item.category_id,item.cover_id,saved_id])
}
public_category_id 和 public_cover_id 也要随发布同步。否则正文虽然保持旧版本,分类筛选或封面引用却可能提前跟随草稿变化,发布边界仍然是不完整的。
这份快照针对的是内容记录。站点设置和首页区块另有保存路径,不能把它理解为整个站点都支持草稿发布;当前也没有完整的历史版本回滚。
单管理员,也需要处理并发编辑
第一版只有单管理员,但同一个人也会打开两个编辑页。旧页面覆盖新修改,并不需要两个不同的账号才会发生。
保存时,内容模块在事务内通过 SELECT ... FOR UPDATE 读取记录,再比较表单中的 revision 与数据库版本。版本不一致时,返回明确的冲突提示,而不是默默覆盖。
if current != null and (to_int(current.revision) != revision or current.kind != item.kind) {
throw("内容已被其他操作修改,请重新打开编辑页")
}
字段校验、分类与媒体存在性检查、内容写入、发布快照和操作记录都围绕同一个事务组织。失败进入回滚路径,成功才跳转回编辑页。
已发布内容还限制修改 URL 标识。这是在当前能力范围内保护链接稳定性的选择;真正支持修改公开 URL,还需要重定向等后续机制,不能只放开一个输入框。
主题负责表达,发布规则留在应用里
默认主题的作用,是把企业信息呈现成可阅读的官网。公开数据由 site.hhy 组织,再交给展示逻辑;主题不负责管理员鉴权,也不决定什么时候替换公开版本。
这种边界对 CMS 的研发很重要。企业之间最常变化的是视觉和信息组织,如果每次换外观都要重写权限与发布流程,系统就很难复用。
首页采用预定义区块,提供排序、显示隐藏和内容编辑。它给使用者足够明确的调整空间,同时让布局组合保持可理解。第一版只有一套默认主题,这个代码边界为后续扩展留了位置,但还不能称为成熟的主题生态。
编辑体验要贴近日常动作
到 0.1.2,我补上了可搜索、分页的媒体选择器和所见即所得正文编辑器。这里真正要改善的,是编辑者完成一次内容更新的连续性。
封面和 Logo 可以在当前编辑页选择或上传,上传后立即预览;选中素材只更新表单,保存后才使关联生效。图片选择不应该把正在编辑的正文清空,也不应该要求使用者记住媒体编号。
媒体也有独立于页面的生命周期。上传限制为 PNG/JPEG、单张不超过 2 MiB,并使用系统 file 检查实际类型。删除前检查草稿、公开版本和 Logo 引用,避免后台删掉一张图,官网却留下损坏的封面。
正文编辑使用本地 Quill 2.0.3。编辑器的 Delta 经转换后成为受限 HTML,支持标题、加粗、斜体、列表与引用;服务端继续执行自己的无属性标签白名单处理。
这意味着工具栏并不是内容安全边界。即使请求绕过浏览器编辑器,服务端的输出规则仍然成立。编辑器加载失败时保留 HTML 文本框;前端增强可以失效,基本编辑入口仍然存在。
源码还刻意避免打开编辑页时就重写未修改的历史正文。对于 CMS,显示内容与改写内容必须是两件明确的事。
交付质量藏在失败路径里
鉴权、MySQL 会话、CSRF 校验、参数化 SQL、字段长度限制、登录限流,以及不向页面暴露底层数据库错误,都是当前实现的一部分。它们需要出现在每条实际请求路径里,才有意义。
测试也应当保护这些行为。仓库中的校验用例覆盖 HTML 转义、富文本事件属性、非法 URL 标识、CSRF 不匹配、超长字段等;编辑器序列化另有测试。导航换行的回归用例则提醒我,浏览器表单里的 CRLF 这样的小差异,也会变成用户眼中的“保存不了”。
这些测试并不替代完整的交付验收。我更关注的验收场景是:安装中断能否继续、旧编辑页能否覆盖新内容、草稿是否泄露到官网、仍被公开内容引用的图片是否能被删除。它们直接对应使用者愿不愿意放心把内容交给系统管理。
交付文档同样要说明运行边界。当前默认监听本机,公开部署需要反向代理与 HTTPS;登录限流按直接连接 IP 计算,代理后的共享 IP 影响需要部署者理解。备份则要同时包含数据库、私有配置和上传文件,仅复制源码无法恢复一个已运营的网站。
下一步,让每项能力都能被解释和维护
HHY CMS 目前还是一个范围明确的早期版本:单管理员、一套默认主题,不包含多租户、商城、多语言内容管理或插件市场。这篇文章提供中英文版本,也不代表 CMS 已经实现多语言内容能力。
继续研发时,我会优先考虑升级与恢复路径、更多真实交付场景的验证,以及内容和主题边界的稳定性。新增一种角色或一种内容模型之前,要先回答它如何影响权限、发布、迁移和兼容。
对我来说,这个项目也把 HHY Language、Web Runtime 和 Database 串成了一个具体应用。语言能运行服务,数据库能提交事务,最终都要落到一个人修改正文、保存草稿、确认发布的日常动作上。
我希望交付的 HHY CMS,是其他人能够理解、使用并接手维护的建站产品。研发者的工作,正是把这些看似平常的动作背后的复杂性处理好。