Notes

为一个人写的后台

先看它是什么

服务端   1294 行(tools/admin.mjs)      28 条 API
界面     2574 行(app.js + html + css)  10 个标签页
测试      195 行(tools/admin.test.mjs) 10 项,跑在构建闸门的第 1 步(共 9 步)
依赖      0。整个站 41 个工具、7266 行,没有 node_modules

这不是一个"系统"。它没有用户表、没有角色、没有会话、没有微服务 —— 它是一个人的工作台:改照片数据、调精选顺序、写随笔、发布上线。

这篇想说的不是"我怎么做的",而是那些刻意的"没有":每砍掉一样东西,复杂度不会消失,它会换一个地方出现。后台设计的全部难点,就在"换到哪儿去了"。

一、它没有登录(有令牌,但不是安全措施)

后台跑在本机 127.0.0.1,带一个每次启动随机生成的令牌。

这个令牌不是安全措施。真要防人,正确做法是别让它监听公网 —— 那件事在别处解决(绑 127.0.0.1、不加 --host 0.0.0.0)。

令牌挡的是另一类事:同一个浏览器里另一个标签页、或者别的进程,在你没打算的时候往数据里写东西。 一次会话一个值,关掉就失效。

没有登录之后,很多设计问题直接消失了:不需要权限模型、不需要"谁是谁"、不需要会话过期。但代价是没有"操作者"这个概念 —— 于是"谁改的、什么时候改的、改之前是什么",得靠别的东西回答(第四节)。

二、复杂度去了四个地方

① 并发 → 一把任务锁

只有一个用户,所以不需要乐观锁、不需要事务、不需要冲突解决。但"一个人"也会和机器抢:构建/发布在跑的时候,你手贱去改一条数据,那份数据会和正在生成的产物打架。

做法很土:一次只允许一个任务在跑。任务在跑时,凡是写操作(PUT,以及所有上传/删除接口)一律返回 409,界面上直接禁用按钮。没有队列、没有重试、没有优先级 —— 因为排队这件事对一个人来说没有意义:你要么在等它,要么在改别的。

顺手要区分两件常被混为一谈的事:原子写(写临时文件 + rename)不是为了并发,是为了崩溃。中途断电、进程被杀,你希望那个文件是"旧的完整版"或者"新的完整版",而不是半截。这两件事目的不同,不能互相替代。

② 权限 → 按"不可逆程度"分级的护栏

没有权限系统,所以真正需要设计的不是"谁能做什么",而是"做错了能不能回来"。护栏按不可逆程度分三档:

  • 可逆(改文案、调顺序):什么都不拦。存下去就是了。
  • 半可逆(删照片):三道。① 它是不是当前首页封面(是就拒绝,先去「封面」页换一张);② 还有没有别的地方引用它(有就拒绝,并列出 文件:行号 和那一行原文);③ 要原样输入它的 id 或标题。
  • 不可逆(回滚、发布):回滚要把线上目录整个换掉,所以要求原样输入版本号;发布要先把待提交的文件清单摊在眼前,然后等一句明确的话。

第三档的措辞是刻意的:不是"确认吗?",而是"把名字打出来"。 前者可以下意识点掉,后者强迫你念一遍你要动的是哪个东西。

③ 迁移框架 → 纯文本 + 边界校验 + 逐字节验收

数据全是纯文本:data/photos.json、posts/*.md、site.json…… 没有数据库,也就没有迁移工具。那么"结构改了怎么办"?

答案是:在边界上校验,在验收上逐字节。 后台写数据前把整份结构过一遍校验(必填、枚举、唯一性、引用是否存在、格式是否合规),不合格就拒绝并说清哪里不合格。而结构性的大改(比如照片数据从一份手维护的映射文件搬进 JSON 那次),验收标准不是"看起来对了",是重建之后产物 cmp 相同。

没有迁移框架的好处是不需要维护它;坏处是每次结构变更都要自己写一遍校验。这笔账对一个人来说是划算的:校验写一次就一直用,而框架是每次都要读的。

④ 数据库 → 文件 + 原子替换 + 保住手写的东西

JSON 文件有个数据库不需要操心的细节:人会往里写注释。比如 site.json 里有一个 $comment 键,写着"这个字段为什么这么填"。

所以写入不是 JSON.stringify(读出来的对象) 那么简单 —— 那样注释全没了。实现上是一个递归合并:以旧对象为基准,只覆盖前端真正提交的字段,$comment 这种说明键原样保留(嵌套层也保),键的顺序也不动(这样 git diff 里只有真正改了的行)。

这四件事合起来是一句话:把"框架该做的事"拆成"我每次都会做的几件事",然后每一件都写成代码。

三、真正难的部分:看不见的东西

如果只允许我用一句话概括这个后台的界面设计,会是这句:

界面不是在"展示数据",是在"展示状态"。

数据(照片清单、随笔列表)当然要显示,但那不是难点。难点是这些东西:

改了还没存          → 每个标签页角标上一个点;顶栏写「已保存 / 有未保存改动」
工作区有未提交的    → 发布页列出每一个待提交文件;git 面板列分支与状态
正在跑的任务        → 日志实时流出,失败停在哪一步、为什么,都在屏幕上
图片传到了哪儿      → OSS 上有没有、本地生成物在不在,两个地方分开说
产物是不是过期的    → 构建闸门每次核对;不一致就拒绝部署
刚才那步出错了      → 错误**留一份日志**,不是弹一下就没了

最后一条值得单独说:错误提示原本是弹 9 秒就消失的浮层。这带来一个很隐蔽的后果 —— 有些错误被 catch 住、只显示成一句提示,几秒后连痕迹都没有。后来加了一份错误日志(内存里留最近 50 条,带时间和调用栈前几行),界面上看不看得到都不重要,重要的是它留下来了。事实上,之后确实靠它抓到过一个"每次渲染都抛错、但只闪一下"的 bug。

还有一个更朴素的例子:预览面板读的就是发布时会送上线的那一份生成物。不是"另一个预览环境",不是"模拟线上"。你在预览里看到的东西,和用户看到的是同一批文件 —— 这让"我本地看着好好的"这句话失去意义,也就没有了那种"上线才发现不一样"的意外。

四、没有第二个人,所以审计靠别的东西

没有用户系统,就没有审计日志。但"谁改的、改之前是什么"必须有人回答 —— 答案是把审计交给 git:

  • 发布流程里,提交排在部署之前。部署成功时,仓库里记录的就是线上那一份。所以任何时候都能回答"线上这个版本对应哪次提交"。
  • 部署前先给线上存一个回滚点(保留最近 8 个),再每天存备份快照(保留 20 个)。顺序是先存、后清空。这个顺序是"能回滚"存在的前提 —— 反过来就是一场事故。
  • 数据是纯文本,所以每一条改动在 git 里都可读:不是"某用户 14:32 修改了记录",而是"这一行从 A 变成 B"。

于是一个看似奇怪的设计就说得通了:"保存"不只是保存,是留下一条能回去的路。 这也是"删照片"为什么要那么麻烦的原因 —— 删掉的不只是文件:生成物、OSS 上的同名对象都要处理,而 OSS 那边上传工具从不删除远端对象(它只增不减)。这意味着"删掉"必须是一个显式的、被单独实现的动作,不能靠"下次上传时自然消失"。

五、这套设计付出了什么

诚实地说,代价很清楚:

  • 全部依赖一台机器和一个人。 本机是唯一能改数据的地方,服务器是唯一运行环境,密钥只有一份。没有 CI、没有第二个人复核。恢复路径是清楚的(备份 + 回滚点),但发现问题的路径很窄。
  • 校验要自己写。 没有 schema 库,108 条照片、每一篇随笔的每条规则都是手写代码。写漏一条,坏数据就会流进去。
  • 界面要自己写。 2574 行里没有一行来自组件库。好处是没有依赖要升、没有框架要追、出错时能一路读到最底层;坏处是每个交互都得自己实现一遍,包括拖拽排序这种容易出细节问题的地方。

反过来说,它换来了什么:0 个依赖。整个站 41 个工具、7266 行,没有 node_modules。三年后打开它,它还能跑,不会因为某个包不再维护而先坏掉。

六、如果只能留一条原则

不是"安全",也不是"好用",是 —— 可回退,并且看得见。

  • 可回退:任何不可逆的动作,要么有护栏,要么有回滚点,要么两者都有。
  • 看得见:任何"只在人脑子里存在"的状态(改了没存、正在跑、传没传上去、是不是最新),都要在界面上有个位置。

这两条合起来,回答的是同一个问题:一个人在没有第二个人提醒的情况下,怎么知道自己做了什么。

这也解释了为什么这个后台里,最花心思的地方都不是"功能" —— 不是加一个标签页、不是多一个字段,而是"这一刻到底发生了什么"。功能做错了,下一个版本改;状态看不见,你连自己错了都不知道。

结语

这个后台没有登录、没有权限、没有队列、没有迁移框架、没有数据库。它有的是一把锁、几道护栏、一份保留注释的写入、一堆状态显示,和一条能回去的路。

如果你也在写"只有自己用"的工具,我的建议只有一条:别把它当成一个缩小的系统来做,把它当成一个放大的记事本来做。 系统的复杂度来自"别人",记事本的复杂度来自"你自己会不会忘"。后者才是你真正要设计的东西。

Get in Touch

联系

这篇写完之后又踩了新的坑,或者你有别的看法 —— 欢迎写信来聊。