技术札记

研发 HHY CMS:把企业官网的交付经验写成可复用的产品

从 CMS 开发者的视角,复盘 HHY CMS 0.1.2 的架构与取舍:HHY Web 与 MySQL、可恢复安装、草稿发布隔离、主题边界,以及面向他人使用的编辑和维护体验。

HOUHUIYANG.COM

扫码继续阅读

正在生成…

研发 HHY CMS:把企业官网的交付经验写成可复用的产品

houhuiyang.com/zh/notes/building-hhy-cms-for-business-websites

企业官网看起来总是差不多:介绍企业,展示产品与案例,发布新闻,留下联系方式。但只要真正交付过一个网站,就会发现,页面完成之后,工作还远没有结束。

谁来修改内容?编辑到一半的文案会不会出现在官网?换一张封面需要找开发者吗?安装失败后,接手的人能不能继续?几个月后迁移服务器,究竟要备份哪些东西?

研发 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_idpublic_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,是其他人能够理解、使用并接手维护的建站产品。研发者的工作,正是把这些看似平常的动作背后的复杂性处理好。

延伸阅读

返回技术札记