← 返回文章列表
技术

一篇文章从编辑到发布:链接别名、版本冲突和图片权限

记录 Boker 文章发布中的链接别名、版本冲突和图片权限设计,以及失败路径的验证。

博客后台表面上是一个 Markdown 编辑器,真正复杂的是“保存”之后的规则。文章的 URL 要稳定,两个窗口不能互相覆盖,图片不能因为知道一个地址就被任意读取。Boker 的第一轮联调把这些问题逐个暴露出来。

编辑器保存的是一份明确的数据

编辑器用 CodeMirror 6 输入 Markdown,预览与公开页共用渲染规则。提交时把标题、摘要、正文、分类、标签、封面、发布状态、可见性和当前修订号送到后台 API。新增文章使用 POST /api/admin/posts,编辑使用 PUT /api/admin/posts/:id;服务端验证输入,再在事务中保存文章及关联关系。

Markdown 原文是数据库里的正文来源。渲染时关闭原始 HTML,并为代码块、公式和标题目录做处理。曾经遇到两个同名标题生成相同锚点的问题:目录看上去有两项,点击却跳到同一处。解决办法是在整篇文章的渲染过程中维护已用 ID 集合,第二个同名标题追加序号,并用回归测试覆盖重复标题。

链接别名不能只靠前端“猜”

文章详情 URL 使用唯一的 slug。早期空 slug 会让发布失败,接口返回的字段错误又没有完整传到编辑器,用户只能看到含糊的失败提示。现在保存前会按标题生成别名:英文标题得到可读的 ASCII 片段;全中文标题无法得到 ASCII 片段时,用时间值生成 post-... 形式的兜底别名。

自动生成只解决“空值”,不能解决“重复”。数据库仍对 slug 设置唯一约束;冲突时 API 返回 409 和明确提示,编辑器显示在链接别名字段附近,并保留已经输入的正文。这里的分工是:界面给出方便的默认值,服务端决定是否合法,数据库保证并发情况下的唯一性。

用修订号避免静默覆盖

假设同一篇文章在两个标签页中打开。A 页先保存,B 页随后保存旧内容;如果只按文章 ID 更新,A 页的修改会被无声覆盖。Boker 在每篇文章中保存 revision。编辑器提交它读到的版本,服务端只更新 id 和 revision 同时匹配的记录,并把修订号加一;没有匹配记录就返回 409。

冲突发生时,编辑器保留 B 页当前输入,并提示刷新后合并。删除也要求提交当前修订号,避免用户确认删除时文章已经被别人修改。即使当前只有一个管理员,多标签页仍然会产生这种冲突,所以这不是为未来多用户预留的“空功能”。

图片是内容权限的一部分

图片上传先检查来源和登录状态,再检查字节内容、大小和支持的格式。文件使用随机存储键,媒体记录写入数据库;读取通过 /api/media/:id 进行。允许上传 JPEG、PNG、WebP、GIF,单文件上限为 10 MiB,不接受 SVG。

文章保存时,服务端会找出正文和封面引用的媒体,并核对它们是否存在、当前作者是否有权引用。联调中发现一个微妙场景:管理员给作者文章加入图片后,作者应能继续编辑这篇文章,而不能随意把其他作者的未关联图片复制进来。修复后,权限同时考虑媒体所有者和与当前文章的关系。图片从文章中移除或文章撤回公开后,匿名读取权限也会随之变化。

把失败路径当成主流程测试

这一阶段的测试不只验证“能发布”:还覆盖空 slug、重复 slug、字段错误、修订冲突、登录过期、私密媒体和图片引用。单元测试处理纯规则,临时 PostgreSQL 的集成测试验证事务与权限,端到端测试走浏览器中的登录、上传、草稿、发布和撤回。站长最终在实际后台完成登录、上传、发布、编辑和删除验收。

编辑器体验的关键不是永远不报错,而是报错时能说明原因,并让用户刚写的内容仍然在眼前。下一篇记录应用接入 Nginx 后,为什么页面有时没样式、后台又为什么把本站请求判成了无效来源。