← 返回博客

af_ 系统运维灾难复盘:我把 7 个仓库交给 AI,丢了两次线上功能

别让 AI 在没版本控制的机器上改代码,你会哭着补回来的

af_ 系统运维灾难复盘:我把 7 个仓库交给 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 里的真相长这样:

plaintext
nDI       STOPPED
nDI-v2    RUNNING   pid xxxxx   :9090

V1 早就停了,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 表好端端地在,00130016 号 migration 全都应用了。

只是没有任何一行代码再去读它了。

所以从那天起,同一个 IP 可以在 5 秒里注册 100 个号,登录爆破防护为零。而线上一切正常,监控一片绿。

前端也有同一种病。上一轮性能优化——CSS Link 预载头、Rolldown chunk 合并、hljs.css 按路由加载——只存在于 VPS 上的 /root/svaf-next。那不是个 git 仓库。本地仓库对这些改动一无所知,我下一次在本地构建、上传,就会把它们全部盖掉。

两条编辑线共用一个没有版本的文件,后写的赢。就这么简单。

教训二号:改完并且真的上线了,立刻提交。

别攒着。攒着的下场就是下次不小心整体撤销,一次丢一大片。


第三集:一对引号,让通知系统从出生那天起就是死的

重构的时候要统一 /internal/notify/internal/notify/email 两个端点。鉴权是常量时间比对,写得很规矩:

typescript
const expected = String(env.NOTIFICATION_SERVICE_TOKEN || "");
const token = auth.slice(7); // 去掉 "Bearer "
if (token.length !== expected.length) return 401;

.dev.vars 里是这么写的:

plaintext
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 的根本差异:

typescript
// 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 同时调了 sendEmailByTemplatenotifyByType——前者直接发邮件,后者内部发邮件。

所以我每发一篇博客,订阅的人都收到两封。 一直如此。

教训四号:Workers 里做 fire-and-forget 必须 ctx.waitUntil(),而且永远别写空的 .catch(() => {})

那不叫容错,那叫毁尸灭迹。


第五集:这一集是我自己画蛇添足

通知系统的设计本来很清楚:调用方负责文案,论坛只做鉴权 + 偏好检查 + 渠道分派。

但我重构 /internal/notify 的时候手贱,在论坛这一侧加了个 switch,按 type 把消息重写了一遍:

typescript
case "lora":
  emailSubject = "LoRA 模型审核结果";
  emailBody = `<p>你的 LoRA 模型《${dName}》${dAction}。</p>`;
  break;
case "recommendation":
  emailSubject = "你的图片被推荐了";
  // ...

生图后台原本发过来的是这个:

用户 #1 推荐了精选图片 图片: /path/img.png 理由: 构图很好

到了论坛这边,变成了「你的图片《精选图片》已被推荐」。

图片路径没了,推荐理由也没了。 我亲手把有用的信息揉成了一句废话。

改回来只花了五分钟:删掉整个 switch,全部走 default 透传,notifyLoranotifyRecommendation 改回传原始的 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 会非常高效地把你想做的事做完,它不负责告诉你这件事不该做。 这部分只能自己扛。

现在那张表写在仓库最上面。下次它至少不会再改错机器了。

Community notes

评论

评论默认折叠,打开后才会连接 GitHub。