◈ 夜宵吧

改坏了能退回去

写文章有个尴尬之处:改坏了就回不去了。 编辑一次覆盖一次,想找回上周的版本只能翻数据库备份——为了取一段文字把整库恢复到三天前,代价大得离谱。

于是加了两样东西:文章的历史版本(每次保存留一份快照,可对比、可回退),和一份操作日志(谁在什么时候动了什么)。两者都按归属隔离:普通账号只能碰自己的,管理员能看全部。

这篇讲快照怎么存、回退为什么要先存一份、权限要在哪几层校验,以及行级 diff 的实现取舍。

快照存在哪

新建一张表,每次保存都往里面塞一份完整快照:

post_revisions(
  id, post_id, title, content, summary,
  tags, category_id, published,
  user_id, nickname, created_at
)

存全文,不存 diff。 因为 diff 需要在「回退」时把一串补丁按顺序重放,任何一处损坏都会连带后面的所有版本。而文章最多几千字,存五十份也不过几百 KB。

保留策略是每次保存前检查条数,超过上限就删最旧的:

const REV_KEEP = 50;

五十足够覆盖「这周改来改去」的全部历史,又不会让表无限长。

回退前先存一份

这里有个容易漏的顺序问题。

点「回退到第 3 版」时,最直接的做法是把第 3 版的内容写回文章。但那样会丢掉当前版本——用户可能只是想对比,或者在回退前忘了保存。

所以回退的动作是三段:

  1. 把当前内容先存成一个新版本
  2. 再把目标版本的内容写回去
  3. 重建双链索引

第二步之后,文章内容变了,但用户仍然可以再回退到第 1 步刚存的那个版本。回退本身也是可以被回退的。

权限:只能碰自己的

需求里写得很明确:每个账号只能看、只能处理自己的记录,管理员例外。

这句话要落到三层:

查列表时按 user_id 过滤;删除某条版本时校验归属;回退时校验归属。三处都写,不能只在列表上过滤——接口是可以直接调的。

if (!isAdmin(me) && rev.user_id !== me.id) return json(res, 403);

管理员做成特例而不是「权限更高的用户」:isAdmin(me) ? null : me.id——传 null 表示不过滤。这样只有一条分支,不容易漏。

行级 diff

「比较」这件事看着简单,自己写才知道细节多。

先按行切开,再做最长公共子序列,把结果压平成三种标记:相同、新增、删除。

diffLines(oldText, newText) → [{ type: 'same'|'ins'|'del', text }, …]

几个取舍:

按行不按字符。 字符级 diff 更精确,但输出会碎成一片,人看不出改了什么。

不是最优解也能用。 标准的 LCS 是 O(n²),两篇五千字的文章大约是千万级比较,在浏览器里要几百毫秒。接受这个代价——比「不能比较」好得多。

当前版本不比。 如果选中的就是最新版,直接说「这就是当前版本」,不给回退按钮。省得有人误点。

界面上:新增一行绿底加号,删除一行红底减号,头部显示 +3 行 · −1 行。

操作日志是另一张表

历史版本回答「这篇文章改过什么」,操作日志回答「谁在什么时候动了什么」。

activity(id, user_id, nickname, action, target, detail, created_at)

记录点覆盖了所有会改数据的地方:发文、改文、删文、发布草稿、回退、删历史、建分类、删分类、建短链、删短链。

展示上做了一个小设计:普通用户进去只看到自己的,界面里连「所有人」这个开关都不出现——而不是显示了再拒绝。看不见的功能比报错的功能少一次困惑。

删文章要连带清干净

删一篇文章时,除了它自己的行,还要清掉:

  • 它的历史版本
  • 它作为「引用方」留下的双链记录
  • 它作为「被引用方」产生的反向链接

漏掉任何一项,都会留下指向空地址的悬空记录。这类记录不会立刻报错,但会在下次渲染反向链接时冒出来——一个指向 404 的链接。

一个需要说明的前提

历史从这次上线之后第一次保存才开始记。

也就是说,现存那些早就写好的文章,历史列表是空的。不是功能没生效,是那些保存动作发生在这个功能存在之前。

想给它们补一份「初始版本」,得手动跑一次全量快照。没做——因为现有内容本身就可以当作第 0 版,真要改坏了,还有数据库备份。

这套设计的代价

每次保存都多一次写。 一篇文章三千字,五十份快照就是十五万字——对这个量级的站点无所谓,对十万篇文章的站,得改成增量或压缩存储。

保存变慢了。 检查条数、插一行、必要时删最旧的,都是同步 SQLite 写入。实测 1 毫秒以内,但如果哪天做了自动保存(比如每 30 秒一次),这个开销要重新算。

diff 不是最优解。 标准的最长公共子序列是 O(n²),两篇五千字的文章大约千万级比较。我用的是简化版,会在大段重排时给出不太漂亮的差异——比「不能比较」好,但不如专业工具精确。

它还解决不了并发。 两个标签页同时编辑同一篇,后保存的会覆盖前一个,而前一个的版本只在历史里,不在冲突提示里。真要做需要乐观锁,没做。