
在线体验:https://letter.heylai.cyou
一个把「写信」变成「写一段剧本」的小工具。信件以终端打字机的形式逐字浮现,可以带分支剧情,可以阅后即焚。

引子:为什么是「信」
我一直觉得,信这种文体在数字时代被低估了。
聊天是即时的,朋友圈是广播的,邮件是任务的。只有信,是对着一个具体的人、花一段时间、说一件不急的事。它天然带有「延迟」和「郑重」两种属性。
而终端,是程序员最熟悉的抒情场所。一行 cat,一串光标,一个 $ 提示符——我们用它调试、排错、打发凌晨三点。它冷峻、朴素,却承载了太多深夜的独白。
把这两个东西接在一起,就有了这个项目:让用户在浏览器里写一封信,收信人打开链接,看到的是一台老终端在逐字敲出这封信。
一、它到底是个什么东西
先讲清楚边界:它不是聊天工具,不是邮件客户端,不是社交产品。
它是一个单机式创作工具 + 一次性托管:
核心技术栈很普通:FastAPI + Vue 3 + SQLite,没什么玄学。
真正值得讲的,是它怎么从一个有 bug 的单文件原型长成现在这样。
二、起点:一个第 458 行的 bug
项目最开始不是这样。最开始只有一个 HTML 文件,里面塞了编辑器、播放器、导出逻辑,全部手写原生 JS。
它跑得起来,也能生成信件。但它有三个致命问题:
第一个,也是最刺眼的:导出功能里有一行字符串拼接写错了——
document.getElementById('mainContent').style.display =if (...)
= 后面直接跟了 if,语法错误。也就是说,导出的 HTML 根本打不开。
有意思的是,这个问题在编辑器预览里完全看不出来,因为预览走的是另一套代码。这就是第二个问题:
播放逻辑写了两份。 playScriptSequence() 管预览,playScript() 管导出。两份代码长得像,改一份忘一份,于是「预览正常但导出白屏」变成了家常便饭。
第三个问题最要命:分支只能是一串纯文本。原型里分支内容用 <textarea> 输入,按换行切成 {type:'line', text, delay:1500}。
这意味着:
- 分支里的每一行不能单独调速、单独设停顿
- 分支里不能再放分支——没有嵌套,就没有真正的剧情
需求里明确要求「无限嵌套分支」。而这个数据结构从根上就堵死了这条路。
于是有了第一条铁律
播放引擎唯一。编辑器的预览、导出的离线 HTML、线上的阅读页——三者必须跑同一份 player.js。
这不是「最好这样」,是「必须这样」。任何「为了方便先复制一份」的念头,都会在两周后变成「导出白屏」的工单。
现在的 player.js 是 430 行纯 JS,零框架依赖。编辑器通过 iframe 里的 <script src="/static/player.js"> 加载它,导出时后端把同一个文件的内容内联进模板。三处输出,一份引擎。
三、把分支从「字符串」改成「结构」
这是整个项目最重要的一次重构。
原来的分支是这样:
// 旧:分支就是一串文本script: [ { type: 'line', text: '第一行' }, { type: 'line', text: '第二行' },]
改成这样:
// 新:分支是一个完整的节点树interface Branch { id: string label: string // 按钮文字 color?: string // 该按钮独立配色 script: Node[] // ← 关键:和主线是同一种结构}
script 里装的 Node[],和主线顶层用的是同一个类型。于是:
- 播放引擎可以递归处理
- 编辑器组件可以递归渲染
- 分支里可以再放分支,任意深度
「无限嵌套」不是写了个 while 循环实现的,是数据结构选对了自然就有的。
四、播放引擎:从「数组拼接」到「显式调用栈」
分支改结构之后,播放逻辑也得跟着改。
原型的做法很聪明,但是错的:
// 进入分支时,把分支内容和主线剩余部分拼成一个新数组playScript([...branch.script, ...sequence.slice(index + 1)], 0)
播一层还行。播两层、三层呢?每次进分支都复制一遍数组,指数级膨胀。而且拼完之后,引擎就不知道「我现在到底在主线还是在分支」了,returnToMain: false(死胡同结局)这种语义没法表达。
新引擎维护一个调用栈:
播放(sequence, index): 若 index >= sequence.length: 若栈非空: 弹出 (parentSeq, parentIdx) → 播放(parentSeq, parentIdx + 1) 否则: 触发结束流程 返回 node = sequence[index] 若 node.type == 'line': 逐字打印 node.text(速度 = node.speed ?? 全局速度) 等待 node.delay ?? 全局停顿 → 播放(sequence, index + 1) 若 node.type == 'choice': 逐字打印 node.text 显示 branches 按钮 用户点击 branch: 若 node.returnToMain: 压栈 (sequence, index) ← 记住回程路 → 播放(branch.script, 0)
进入分支时压栈,记录「主线走到哪了」;分支播完出栈,继续主线。嵌套任意深度,空间复杂度只有 O(深度)。
returnToMain: false 的语义也自然了:分支播完不压栈,直接结束——这就是一个「死胡同结局」。
还顺手修了一个隐蔽的竞态
原型只有一个 currentAnimTimer。快速切换预览时,旧打字机的定时器没被清掉,于是两个打字机同时在打,字叠在一起。
现在所有 setTimeout 的 handle 都进 this._timers 数组,stop() 时全部 clearTimeout。这是小事,但这类竞态不修,用户迟早会撞上。
五、编辑器:让写作者不写代码
数据结构和引擎搞定之后,剩下的问题是怎么让人不会写 JSON 也能用。

编辑器的布局很朴素:左边是剧本树,右边是 iframe 预览。
设计上做了这么几件事:
递归组件。 ScriptTree 里放 BranchItem,BranchItem 里又放 ScriptTree。两个组件互相引用,嵌套能力在 UI 层自然长出来了。
拖拽排序。 没用 ▲▼ 按钮,用 vuedraggable,可以跨层级拖动。
播放位置高亮。 引擎每进入一个节点就回调 onNodeEnter,编辑器据此高亮对应卡片——你能看见预览播到了哪一行。
从中间预览。 每张卡片都有「▶ 从这里预览」,改某一句不用从头看起。
还有个「树状图」视图,用来一眼看清整个分支结构:

外观面板则是另一套逻辑,所有改动实时反映到右侧预览:

六、阅后即焚:诚实比炫技重要
这是最容易吹牛、也最需要克制的一个功能。
三档模式:
关键设计在触发时机上:
用户打开 /r/{token} ↓后端检查:已发布?已删除?一次性链接且已读过?→ 是则 410 Gone ↓ 通过渲染 HTML(此时数据已经完整发到客户端了) ↓用户点「确认拆封」→ POST /api/read/{token}/open ↓soft/hard: 立即 read_count++,记录首次阅读时间one_time: 立即使链接失效 ↓用户读完 → POST /api/read/{token}/finish ↓hard: DELETE FROM letters WHERE id = ...soft: UPDATE letters SET deleted_at = now()
为什么 open 那一刻就要使链接失效?
因为防的是「收信人中途刷新」和「把链接转发给第三方」。数据在点下「拆封」的瞬间就已经完整下发到对方浏览器了,链接再有效也没有意义,反而是风险。
但必须说清楚它做不到什么
界面上、文档里,我都写了这段话,而且不打算改:
能做到的——服务器上的内容按设置删除;一次性链接第二次打开就失效;「彻底删除」是真的 DELETE,不是标记隐藏。
做不到的——对方截图、录屏、按 F12 看已经加载的内容、把页面另存到本地。这些都发生在对方的设备上,任何网站都拦不住。
导出的离线 HTML 更弱:它靠浏览器 localStorage 记「已读」,清一下浏览器数据就能重看。
「阅后即焚」是 仪式感 + 服务端清理,不是密码学意义上的安全。把这句话写在用户看得见的地方,比多做一个「安全」的标签更有价值。收信人读到"这封信会消失"时的那种郑重感是真的;但他要是想留档,技术上也真的拦不住。这两件事可以同时为真。
失效之后,收信人看到的是这个:

「这封信已经不在了。」——比「404」温柔,也比「404」准确。
七、收信人看到的样子
写了这么多实现,其实用户最后看到的只有一件事:一封信。
打开链接,先是一个拆封确认框——这是刻意的仪式感,也是 open 事件的触发点:

确认之后,终端开始打字。光标在跳,字一个一个出来:

碰到分支节点,会停下来等你选:

手机上同样能读,而且因为阅读页是后端直出的纯 HTML,没有 SPA 的水合开销,打开很快:

八、今天的部署记录
代码写完是一回事,挂到线上是另一回事。今天正好完整走了一遍部署,记几个真实的坑。
坑一:8000 端口被占了。
部署手册里写的是 uvicorn 监听 127.0.0.1:8000。结果启动就报:
ERROR: [Errno 98] error while attempting to bind on address ('127.0.0.1', 8000): address already in use
服务器上早就跑着另一个 gunicorn 应用占着 8000。没有去动那个应用(它跟我无关,动了就是事故),而是把本项目换到 8010:
sudo sed -i 's/--port 8000/--port 8010/' /etc/systemd/system/letter-studio.service
Nginx 反代同步改掉。多进程共存的环境里,「不碰别人的东西」比「让手册对得上」重要。
坑二:pip 卡死。
第一次 pip install -r requirements.txt 跑了十分钟没动静。换腾讯云镜像之后,三十多秒装完:
.venv/bin/pip install -r requirements.txt \ -i https://mirrors.cloud.tencent.com/pypi/simple
坑三:证书要走宝塔的路子。
服务器上有宝塔面板。面板签发的证书存在 /www/server/panel/vhost/cert/<域名>/,但用 certbot 签的话证书在 /etc/letsencrypt/live/<域名>/。
两条路都行,但得选一条走到底。这里取了折中:用 certbot 签(它自动续期更省心),然后把证书拷贝到宝塔的目录,并加一个续期钩子,让每次自动续期后同步过去:
#!/bin/bash# /etc/letsencrypt/renewal-hooks/deploy/letter-heylai-cyou-sync.shif [ "$RENEWED_DOMAINS" = "letter.heylai.cyou" ]; then cp "$RENEWED_LINEAGE/fullchain.pem" "$RENEWED_LINEAGE/privkey.pem" \ /www/server/panel/vhost/cert/letter.heylai.cyou/ /www/server/nginx/sbin/nginx -s reloadfi
坑四:.env 里的 SECRET_KEY 绝不能用示例值。
SECRET_KEY 决定所有登录凭证的签名。沿用示例值等于任何人都能伪造登录态。部署时强制重新生成:
python3 -c "import secrets; print(secrets.token_urlsafe(48))"
.env 权限设 600,同机其他用户读不到。
还有个小细节:SQLite 的连接串是四条斜杠——
DATABASE_URL=sqlite:////www/wwwroot/letter.heylai.cyou/backend/letter_studio.db
三条斜杠是相对路径,会跟着工作目录跑。这种细节不写清楚,日后迁移准出事。
几个数字
- 后端:FastAPI,player.js 430 行,导出服务 101 行
- 前端:Vue 3,10 个组件
- 数据库:SQLite 单文件,启动自动建表
- 守护:systemd(Restart=always),非 root 用户运行
- 部署目录:/www/wwwroot/letter.heylai.cyou/
- 域名:letter.heylai.cyou,证书有效期至 2026-12-01
九、写在最后
这个项目里没有一行特别聪明的代码。
它做的事情都很朴素:数据结构选对了、逻辑只写一份、说清楚做不到什么、部署时不碰别人的东西。
真正让我愿意花时间做完它的,是另外一件事。
当我们把信变成即时消息,我们失去了「等待」;当我们把信变成朋友圈,我们失去了「只给一个人」。而这个工具里最不起眼的一个设定——信会被焚毁——恰恰把这两种感觉还回来了一点点。
收信人会知道:这封信只为我写,而且只存在这一次。
这个认知带来的郑重,比任何加密算法都更接近「信」的本来面目。
至于技术上它到底能不能被截图——那是另一回事。诚实地说清楚边界,然后把这个仪式感做漂亮,就是我能做的全部了。
在线体验:https://letter.heylai.cyou
打开首页点「开始写信」,不用注册也能先写起来。写完可以导出一个 HTML 文件离线发给朋友,也可以生成一条链接。
想试试阅后即焚,点「生成链接」,焚毁方式选「读完彻底删除」,再勾上「链接只能打开一次」,然后用无痕窗口打开两次——第二次你会看到那行字。
写于某个调试到凌晨的晚上。














