af_ 系统运维灾难复盘:我把 7 个仓库交给 AI,丢了两次线上功能
别让 AI 在没版本控制的机器上改代码,你会哭着补回来的

⛔ 注意
本文使用 Claude Opus 5 编写。
先说结论
7 月 28 日那天早上,我的计划是「给全站做一轮性能优化,顺便把论坛的通知系统重构掉」。预计半天收工。
实际上干了一整天,中间两次差点把线上功能丢光。
事后复盘,五个坑长得完全不一样,但根子是同一个:我脑子里那张架构图,从来没有落到任何一个文件里。 我知道哪台机器跑的是哪份代码、哪个目录是活的、哪个是尸体——但这些东西 AI 一个字都读不到。于是它只能猜。
而它猜得还挺自信的。
这篇不是教程,是验尸报告。
案发现场:7 个仓库,2 台机器,0 个 remote
af_ 是我这个站的全部家当,全站 SSR,由 7 个 GitHub 仓库组成:
| 仓库 | 干什么的 | 跑在哪 |
|---|---|---|
af_frontend |
主站前端 | 公网 VPS,Express + supervisor |
af_blog-data |
博客正文和图 | 边缘 |
af_forum-backend |
论坛后端,Worker + D1 | 公网 VPS,wrangler dev |
af_draw-backend |
AI 生图后端 | 局域网那台 GPU 盒子 |
af_friends-data |
友链 / 赞助 | 边缘 |
af_files-data |
文件列表 | 边缘 |
af_comments-data |
giscus 评论 | 不部署,纯数据 |
写出来挺规整的对吧。
问题是,这张表是我在事故之后才写出来的。当天早上动手的时候,它只存在于我脑子里。
更要命的是那天早上的真实状态:
- 论坛后端的
src/index.ts不在任何 git 仓库里。所谓版本控制,是cp src/index.ts src/index.ts.bak-1785041280这样手工存快照,一共存了 8 个。哪个是线上正在跑的?不知道,看时间戳猜呗。 - 前端本地倒是有个 git 仓库,但没有任何 remote。全世界唯一一份源码在我这台 Windows 的硬盘上。
这些 AI 全都不知道。它只看得见你给它的那个目录。
第一集:改了半小时,发现改的是一具尸体
当天任务列表里有一条是「AI 生图端点检查」。
生图后端历史上经历过一次 JSON → SQLite 的大重构:旧代码在 node-server/(V1),新代码在 node-server-v2/(V2)。supervisor 里的真相长这样:
nDI STOPPED
nDI-v2 RUNNING pid xxxxx :9090V1 早就停了,V2 在跑。
但 GitHub 上的 af_draw-backend 仓库里是另一幅景象:V1 目录是个不完整的残骸(notify.ts 这种关键文件从来就没被提交过),V2 目录根本没进过版本控制——它的源码只存在于那台机器的 /root/nDI/node-server-v2/ 里。
于是 Claude Code 打开仓库,看见一个叫 node-server/ 的目录,非常合理地开始改里面的通知代码。
改完,部署,测试。
毫无反应。
再改,再部署,再测试。还是毫无反应。
半小时后我去 supervisorctl status 看了一眼才反应过来:它改了半天的是一具尸体。 而真正在跑的 V2 是用 tsx 直接跑源码的,改完还得 supervisorctl restart 才生效——就算改对了地方,不重启也一样没反应。
教训一号:源码只存在本地,被部署方只放构建产物。
远端放着源码、又不跟 git 同步的时候,AI 会从几个「看起来像」的目录里随机挑一个开始改。它没办法知道哪个是活的——因为这个信息压根不在代码里。
第二集:43 行的窟窿
论坛通知系统的改造是那天的重头戏。
重构 notifyByType 的时候,Claude Code 顺手删掉了一个废弃函数,TS 报错从 74 条掉到 65 条。看起来是好事。
但提交前我扫了一眼 diff 的统计:文件从 6120 行变成 6077 行,净减 43 行。
删一个废弃函数能删掉 43 行?不对劲。
翻 src/index.ts.bak-* 那堆手工快照,一路往前查,最后查明白了:早些时候做 QQ 绑定功能,是在一个不含限流逻辑的旧基线上开发的。开发完了整份文件覆盖上线。被那次覆盖悄悄抹掉的东西包括:
- 6 项限流配置(注册 IP 冷却、验证邮件重发间隔、登录失败锁定阈值……)
/api/user/sessions那三个会话管理端点readGeoFromHeaders(从 CF 头里取访客地理位置)
最阴间的地方在于:数据库那边一点毛病都没有。ip_rate_limits 表好端端地在,0013 到 0016 号 migration 全都应用了。
只是没有任何一行代码再去读它了。
所以从那天起,同一个 IP 可以在 5 秒里注册 100 个号,登录爆破防护为零。而线上一切正常,监控一片绿。
前端也有同一种病。上一轮性能优化——CSS Link 预载头、Rolldown chunk 合并、hljs.css 按路由加载——只存在于 VPS 上的 /root/svaf-next。那不是个 git 仓库。本地仓库对这些改动一无所知,我下一次在本地构建、上传,就会把它们全部盖掉。
两条编辑线共用一个没有版本的文件,后写的赢。就这么简单。
教训二号:改完并且真的上线了,立刻提交。
别攒着。攒着的下场就是下次不小心整体撤销,一次丢一大片。
第三集:一对引号,让通知系统从出生那天起就是死的
重构的时候要统一 /internal/notify 和 /internal/notify/email 两个端点。鉴权是常量时间比对,写得很规矩:
const expected = String(env.NOTIFICATION_SERVICE_TOKEN || "");
const token = auth.slice(7); // 去掉 "Bearer "
if (token.length !== expected.length) return 401;.dev.vars 里是这么写的:
NOTIFICATION_SERVICE_TOKEN="ns_c800e73e..."看出来了吗?
wrangler 读 .dev.vars 的规矩是「等号后面全都是值,不剥引号」。所以 expected 的真实值是 "ns_c800e73e..."——连那对双引号一起,70 个字符。调用方传过来的是 67 个。
长度比对第一步就挂了。直接 401。
这意味着从通知系统上线的那天开始,所有外部通知请求就没有一个通过过鉴权。
那用户为什么一直能收到 QQ 通知?因为那是 sendQQNotify 那条直连 OneBot 的旁路发的,走的是另一个 token。邮件呢?一封都没发出去过。 老的 /internal/notify/email 被同一对引号拦在门外。
这个坑跟那天的任何改动都没关系,它从配置那天起就躺在那儿。
只是从来没有人问过一句「用户真的收到了吗」。
教训三号:部署后的第一条验证不是「接口返回 200 了吗」,是「人真的收到了吗」。
第四集:ctx.waitUntil,Workers 里的无声杀手
通知重构完,QQ 能收到了,邮件死活不发。
日志干干净净,一条报错都没有。这是最讨厌的一种 bug。
查到最后,是 Workers 运行时和 Node 的根本差异:
// Node 里这样写完全没问题,Promise 会在后台自己跑完
sendEmail(email, subject, body, env).catch(() => {});
return jsonResponse({ ok: true });
// Workers 里,response 一返回,运行时立刻回收还没跑完的 Promise。
// 必须显式说一句「等我跑完」
ctx.waitUntil(sendEmail(email, subject, body, env).catch(...));sendQQNotify 恰好加过 ctx.waitUntil,所以 QQ 活着;sendEmail 没加,所以邮件被运行时无声无息地砍掉了。
而那句 .catch(() => {}) 又把所有异常吞得干干净净——既不发邮件,也不留痕迹。
顺手全量 grep 了一遍 ctx.waitUntil,又捞出来一个:/api/webhook/posts(博客文章广播)里的 sendOne 同时调了 sendEmailByTemplate 和 notifyByType——前者直接发邮件,后者内部也发邮件。
所以我每发一篇博客,订阅的人都收到两封。 一直如此。
教训四号:Workers 里做 fire-and-forget 必须
ctx.waitUntil(),而且永远别写空的.catch(() => {})。那不叫容错,那叫毁尸灭迹。
第五集:这一集是我自己画蛇添足
通知系统的设计本来很清楚:调用方负责文案,论坛只做鉴权 + 偏好检查 + 渠道分派。
但我重构 /internal/notify 的时候手贱,在论坛这一侧加了个 switch,按 type 把消息重写了一遍:
case "lora":
emailSubject = "LoRA 模型审核结果";
emailBody = `<p>你的 LoRA 模型《${dName}》${dAction}。</p>`;
break;
case "recommendation":
emailSubject = "你的图片被推荐了";
// ...生图后台原本发过来的是这个:
用户 #1 推荐了精选图片 图片: /path/img.png 理由: 构图很好
到了论坛这边,变成了「你的图片《精选图片》已被推荐」。
图片路径没了,推荐理由也没了。 我亲手把有用的信息揉成了一句废话。
改回来只花了五分钟:删掉整个 switch,全部走 default 透传,notifyLora 和 notifyRecommendation 改回传原始的 subject + body。
教训五号:API 边界不清楚的时候,最容易做多。
透传比生成安全一万倍。消息内容永远由数据的生产者决定。
收尾:先把仓库治好,再谈别的
一整天下来,耗时最长的其实是两件跟业务代码毫无关系的事。
第一件,建仓库。 前端没有 remote,论坛后端根本不是 git 仓库。7 个仓库统一加 af_ 前缀,按「后端源码必须私有」重设可见性,把 VPS 上那两份现役代码推上 GitHub。前端推之前还发现历史里躺着一个 166MB 的 zip 误提交,git filter-repo 清了一轮。
第二件,写 AGENTS.md。 就是上面那张表,加上部署形态、对外域名、可见性,以及两条铁律。以后任何一个 AI 会话在这个仓库里打开,第一件事就是把这张表读完。
这两件事本来应该是第 0 天做的,我拖到了第 N 天。
后日谈:第二天我又把站拆了,然后又拼了回去
以为写完 AGENTS.md 就天下太平了?天真。
7 月 29 号早上,我决定把站按业务拆成五个域:www 做门户、blog 放文章、forum 放论坛、ai 放生图、2x.nz 保持完整。听起来很清爽——每块业务一个域,路由表按目标裁剪,静态的三个丢到 Cloudflare Worker,论坛另起一个 :3010 进程,部署改成推 GitHub 触发 webhook。
上午就干完了。技术上全都跑通了。
然后我开始一条一条地发现代价:
- 路由表一裁,站内
<Link>就废了。 别的业务的路由压根不在这份 bundle 里,跨业务的链接只能全部手写成绝对地址。 - 登录态是按 origin 隔离的。 于是我得为
ai.2x.nz专门做一套跨域授权交接,token 走 URL fragment、redirect_uri走白名单——为了拆域,凭空多出一套安全攸关的代码。 - 静态站的预渲染清单必须穷举。 漏一条就是线上真 404,因为背后没有服务器给你兜底。
- 最离谱的是:博客切出去之后,
2x.nz/posts变成了 404。老用户最习惯的入口,就这么断了。
下午我把整套东西全撤了。撤的时候还踩了两个新坑:
其一,回退是从分域之前那个提交 checkout 回来的,那份 run.sh 的 mode 是 100644——可执行位丢了。supervisor 直接 exec 它,报 ERROR (file is not executable),站挂了几十秒。在 Windows 上开发、往 Linux 上部署的经典节目。
其二,删子域 DNS 记录的时候,忘了这个 zone 上还挂着一条 *.2x.nz 通配 CNAME。删掉记录不等于这个域不存在了,而是它落到了通配指向的第三方 CDN 上——然后那边回了个指向自己的 301,死循环。
所以最后那五个子域没有删记录,而是留着代理、挂一条 301 按路径把请求收回 2x.nz。指望「删掉就没了」是不成立的。
教训六号:架构改造的成本,从来不在「能不能做成」。
分域这件事,AI 一个上午就给我做完了,而且做得挺漂亮。问题是它做得越顺,我越晚意识到这个方向本身是错的。做得快,不代表该做。
现在的全貌
- 论坛通知统一入口
/internal/notify,纯透传{user_id, type, subject, body},按用户偏好分派 QQ / 邮件 - 生图后台的 LoRA 审核、图片推荐全部走论坛这个统一接口,QQ 直推走 OneBot 旁路
- 博客广播打
/api/webhook/posts,一次请求同时触发邮件 + QQ(不再发两遍了) - 前端
/forum/me恢复了 QQ 绑定 + 7 类事件 × 2 渠道的高粒度通知偏好 /posts页面总重量 3.3MB → 313KB(封面缩略图、头像压缩、CSS 预加载、chunk 合并、图标按需、论坛配图按需缩放)- 全站禁用 JavaScript 仍然能用,零 Suspense 流式占位
- 域名回到一个:
2x.nz。全部业务由 VPS 上一个 SSR 进程提供
所以
如果你也打算在这种多机多仓、还带着生产数据的环境里让 AI 改代码,我只有一条建议:
先把所有仓库建好、推上去、把架构写进 AGENTS.md,然后再开始改。
省下来的不是一两个小时,是一整天,和两条差点丢掉的线上功能。
至于第二天那次拆域——那个坑 AGENTS.md 救不了你。AI 会非常高效地把你想做的事做完,它不负责告诉你这件事不该做。 这部分只能自己扛。
现在那张表写在仓库最上面。下次它至少不会再改错机器了。
评论
评论默认折叠,打开后才会连接 GitHub。