颜力刚Ligang Yan

Vibe Coding 指南:踩过的坑和给 AI 的自查清单

和 Claude 合写 787 个提交、踩过四十多个坑之后的总结。每一条附一段自查 prompt,把整篇贴给你的 AI,它就能对照你的项目逐条检查和修复。

aivibe-codingclaude-codeengineeringcloudflareseo

English version: A Vibe Coding Guide: Mistakes I Made and a Self-Check List for Your AI

这是我最近做的一个项目,四个月里仓库一共 787 个提交。去掉合并提交,剩下的 566 个里有 547 个带着 Co-Authored-By: Claude,大约 97%。350 个 PR 合了 336 个,有提交的日子加起来只有 40 天。技术栈是一个 TypeScript monorepo:后端是 Cloud Run 上的一个 Node 进程;前端是 React 单页应用加预渲染,发布在 Cloudflare Workers 上;API 前面挡着一个 Worker,做白名单、缓存和夜间任务;数据库是 Neon 的 Postgres;流量几乎全部来自 Google 搜索。

这篇把四个月里踩过的坑收在一处,分两类:和 AI 协作的方法,以及平台上的坑。语言和框架怎么选不在里面,那些因项目而异。大家都知道的常识(密钥别提交、SQL 要参数化)我一条也没写,只挑不显然的。

每一条后面跟一段「AI 自查」,是写给 AI 的。我设想的用法是:你把这篇整个贴给自己的 AI,让它对着你的项目查一遍。人当然也可以从头读,每一节前半段是我这边出了什么事,后半段的自查块是给 AI 执行的。

怎么把这篇交给你的 AI

把整篇文章贴给你的编码 agent(Claude Code、Cursor、Codex 都行),或者把链接给它,再加一句「按文中的总指令执行」。总指令就是下面这段:

给 AI 的总指令

这是一份 Vibe Coding 指南。正文里的叙述是作者项目的经历,只当背景看:里面出现的时间窗、配置值和阈值都属于作者的项目,不是对我的项目的要求。要执行的只有标题为「AI 自查」的块,一共 45 条,编号从 A1 到 G8。大部分分成「适用 / 检查 / 修复」三段;C4 和 F7 约束的是你自己排查问题和引用数据的方式,在这次工作里照做即可;A11、D8、G8 是几条小检查合在一起,汇报时拆开编号(A11-1、A11-2……)。

请对我当前的项目逐条执行:

  1. 先读项目结构、依赖、部署配置和 agent 指令文件(CLAUDE.md、AGENTS.md、.cursorrules 等),判断每一条是否适用。D 到 G 组针对具体平台(Cloudflare、Cloud Run、Postgres 和 Neon、Google 搜索),没用到的平台整组跳过,写一句理由即可。
  2. 适用的,按「检查」去看代码和配置,给出具体的文件和行号。不要凭印象下结论。需要线上环境、云控制台或生产数据库才能确认的,写出你打算运行的命令或要看的页面,标成「需要人确认」,先别自己去连。
  3. 汇总成一张表:编号、结论(没问题 / 有问题 / 需确认 / 不适用)、证据、建议的修改、大致工作量。按影响从大到小排序。
  4. 先给我看这张表,等我确认再改。删除数据、执行迁移、改部署配置、改权限这几类改动,每一处都单独问我。工作量大的项(比如 B1 的植入 bug 审计、C3 的体检脚本),先问我做不做、做到多大范围。
  5. 改完之后,凡是能写成 lint 规则或测试的检查项,顺手写成规则或测试,让它下次自动报错。
  6. 文末有一段「放进 CLAUDE.md / AGENTS.md 的规则」。只挑适用于这个项目的,和现有规则合并,不要整段照抄;只和某一类文件有关的,放进按路径加载的规则文件(见 A1)。

下面是正文。编号前缀:A 是协作和规则,B 是测试,C 是静默失败,D 是 Cloudflare,E 是 Cloud Run,F 是 Postgres 和 Neon,G 是 Google 搜索。D 到 G 的每一条在标题后标出它属于哪一类坑:

标记 含义
平台 平台本身反直觉,文档里没写或者写得很隐蔽
默认值 正确做法查得到,AI 选了一个看起来合理的默认值
指令 仓库里的规则、文档、测试或监控把错误固定了下来,或者让它多活了几天

A. 和 AI 协作:规则写在哪,写成什么

A1. 指令文件越写越长

CLAUDE.md 会被塞进 agent 的每一轮对话。我很长时间没把这句话当真,所以它一直在长。

最长的那一天,它被改了 16 次,其中三个提交的标题都是「更新当前阶段」。第二天清点了一遍:一半以上的内容(目录结构、每个服务有哪些文件)从代码里就能读出来;「当前阶段」一节是 issue 状态的快照,写下去就开始过期。那次瘦身把 12,445 字砍到 3,801 字,其余规则按目录下沉到 .claude/rules/*.md,每个文件用 paths: 声明自己管哪些路径,agent 碰到那些文件时才加载。九天后它又长回 10,642 字(这时已经换成英文,同样的内容字符数会多一些),新增的有一部分是开发命令旁边越写越长的注释,本该下沉,我没忍住。

现在的判断标准:

放在哪 放什么
顶层指令文件 每一轮都必须知道、读代码读不出来的东西:模块边界、响应格式、分支规则、部署窗口
按路径加载的规则 只在改某类文件时才需要的约定:迁移流程、测试写法、页面约定
lint 和测试 能被机器判定对错的规则
issue 进度、路线图、设计讨论

AI 自查 A1

  • 适用:项目里有 CLAUDE.md、AGENTS.md、.cursorrules 或类似的 agent 指令文件。
  • 检查:统计它的长度;标出三类内容:从代码就能读出来的(目录树、文件清单)、会过期的状态(「当前阶段」「TODO」「本周在做」)、只和某个子目录有关的约定。
  • 修复:第一类删掉,第二类挪到 issue 或任务系统,第三类挪进按路径加载的规则文件(如果工具支持)。改完的顶层文件里只留每一轮都需要的硬约束。

A2. 指令用中文写,输出也会偏向中文

瘦身那天,CLAUDE.md 还从中文换成了英文。原因是中文的指令会让 agent 的输出偏向中文,而这个仓库要求代码注释、界面文案和开发文档都用英文。顺便量了一下 token:同样的内容中文约 1,827 个,英文约 1,716 个,只差 6%,省 token 算不上理由。

AI 自查 A2

  • 适用:项目对代码注释、文案或文档的语言有要求。
  • 检查:agent 指令文件用的语言和项目要求的输出语言是否一致;最近的提交里有没有混进不该出现的语言。
  • 修复:指令文件改用目标语言书写;需要保留的另一种语言(比如面向中文读者的内容)在指令里明确列为例外。

A3. 过期的文档会把 agent 带偏

开工第三天,我删掉了整个 docs/ 目录:四份文档,1,084 行,理由只有一句,docs 经常和代码对不上。仍然成立的约定搬进了规则文件,设计讨论和进度从那天起只写在 issue 里。agent 会相信仓库里的每一个字,文档过期了,它照样按文档去做。四个月后回头看,几次排查被带偏都是因为过期文档:部署文档里写着「CPU 常驻」,配置早改了;健康检查的说明里,头号嫌疑是一个早就修掉的函数。

AI 自查 A3

  • 适用:所有项目。
  • 检查:找出 README、docs/、部署文档、agent 指令文件里描述具体行为的句子(端口、超时、配置项、某个函数做什么、某个服务部署在哪),逐条和当前代码、配置核对。
  • 修复:列出和代码矛盾的句子及证据。能删的删,只描述「为什么」的保留,进度类的挪去 issue。

A4. 能让机器查的规则,就别只写在文档里

直到写这篇的五天前,这个仓库都没有 linter。架构规则全靠文字和 review:服务端的某个域不能直接 import 另一个域的函数,打进浏览器的代码不能碰 Node 内置模块,环境变量只能在一个文件里读,后端不许出现 console.*。agent 大部分时候会遵守,但「大部分时候」意味着每次 review 都要再查一遍。

那天的一个 PR 把这些写成了 8 条自定义 ESLint 规则。比规则本身更有用的,是定下的几条约束:

  • 不开推荐规则集。九万行代码一开就是几千条告警,告警多了谁都不看。
  • 规则只有 error 和 off,没有 warn。
  • 一条规则上线时违规数必须是零,先修干净再开。
  • 每条报错信息写明它来自哪份规则文档,agent 看到报错可以直接去读原文。
  • eslint-disable 必须写理由,review 时专门问它。

上线前的普查找到一处真违规,还有一个文件把纯函数和网络请求混在一起导出。

AI 自查 A4

  • 适用:项目有写成文字的架构约定(在指令文件、README 或注释里)。
  • 检查:把每条约定分成「机器能判定」(import 方向、禁用的 API、某类调用只能出现在某个文件)和「需要人判断」两类。对能判定的,看是否已有 lint 规则或测试覆盖。
  • 修复:为没覆盖的写自定义 lint 规则(no-restricted-imports、no-restricted-syntax 往往就够),全部设为 error,报错信息里引用规则出处。先修到零违规再启用。

A5. 同一份事实写在两处,就加一个测试比对

有些东西没法写成 lint:健康检查脚本是 bash,它的任务表是从 TypeScript 的注册表抄过来的;.env.example 要和代码实际读取的环境变量一一对应。这类都加了一个测试,把两份放在一起比,一边改了另一边没改,测试就红。

AI 自查 A5

  • 适用:同一份事实(任务列表、路由表、环境变量清单、端口、限额常量)在两个以上地方各写一份,尤其是跨语言的(shell、YAML、CI 配置和应用代码)。
  • 检查:列出这些重复,逐对比较当前是否一致。
  • 修复:能改成从单一来源生成的就生成;不能的,写一个测试读取两边并断言相等。

A6. 构建期检查只判定能量出来的东西

有一次加过一个构建期的措辞检查:扫描产出的 HTML,碰到禁用词就让构建失败。四天后删了。复盘有三条:词表来自直觉,没有一条来自真实的误用;单复数就能决定过不过;客户端渲染的页面它根本扫不到,于是「构建通过」被理解成了「页面干净」,这比没有检查还糟。同一周加的 CI 性能预算留了下来,它判定的是一个能量出来的数。

性能预算自己也出过一次错:CI 里某个环境变量没设,打包器把它内联成空字符串,据此推断登录 SDK 永远用不到,整个删掉了。预算量出来是 640 kB,实际发出去的是 724 kB。现在 CI 里给它填了一个永远不会被访问的占位 URL。

AI 自查 A6

  • 适用:CI 里有构建期检查、预算或质量门。
  • 检查:每个门判定的是一个可测量的量,还是一个需要判断的语义?它的覆盖范围是否和它让人以为的范围一致(比如只扫静态 HTML,却被当成「全站已检查」)?CI 构建用的环境变量是否和生产构建一致,有没有因为变量为空导致代码被摇树掉,使 CI 构建的产物和生产不同?
  • 修复:判断类的门改成 review 清单;覆盖不全的门,在名字和输出里写明它只覆盖什么;给 CI 补齐会影响打包结果的环境变量,必要时用占位值。

A7. 让 LLM 做判断,把结果存成文件

有一类工作需要判断:给几百个实体逐个分类、挑选关联项。我的做法是让本地会话里的 AI 做完判断,产出一个生成文件,每一条附一行理由,人看过 diff 再合并。服务器上不跑任何 LLM,只读这张表;表和主数据对不上,测试就红。

AI 自查 A7

  • 适用:项目在请求路径或定时任务里调用 LLM 做分类、打标签、挑选这类结果相对稳定的判断。
  • 检查:这些判断是否每次运行都重算;结果能否复现、能否被人审查;没有 LLM 时系统还能不能工作。
  • 修复:改为离线生成一份带理由字段的数据文件,纳入版本控制并由人审查;运行时只读文件;加一个测试保证文件覆盖了所有需要的实体。

A8. 发给 agent 的 API token,权限要小

账号功能回来以后,要让外部 agent 能写文章。身份服务用的是托管的 Neon Auth,它当不了 OAuth 授权服务器,浏览器的 JWT 15 分钟就过期,所以没走 MCP,而是自己发个人 API token,走普通 REST:

  • 数据库只存 SHA-256。token 本身是 32 字节随机数,用不着 bcrypt 这类口令派生函数:它们防的是弱口令被暴力猜中,随机数没有这个问题。
  • token 带固定前缀,中间件看前缀决定走哪条验证路径。两条都试的话,一堆垃圾 bearer 就会变成一堆数据库查询。
  • 用户 id 只从凭证里来,路由从不读 body、query 或 header 里的用户 id。
  • 管理类路由拒绝 token,只认浏览器会话。交给 agent 的 token 不能发布,也不能启动任务。
  • 周报有一个专门的 scope:持有它的 agent 可以起草、自检、给自己发测试信,但没有群发接口。最后点发送的必须是人。
  • 写作接口收整篇 Markdown,不做字段级补丁,因为一篇文章本来就是模型一次完整的输出;带 dryRun 先自检。

AI 自查 A8

  • 适用:项目给脚本、agent 或第三方发 API token。
  • 检查:token 是否明文存储;验证路径能否被无效 token 放大成数据库查询;有没有任何路由从请求体或参数里读取用户 id;token 能否调用发布、删除、群发、改权限这类不可逆操作;token 能否签发权限更高的 token。
  • 修复:只存哈希;用前缀区分凭证类型;用户 id 只取自认证结果;不可逆操作要求交互式会话或单独的 scope,并且默认不给。

A9. 同一条校验只写一份

标题和描述的长度上限曾经写在两个文件里,构建用一个,内容规范用另一个。结果一个 162 字符的描述在一处通过、在另一处被截断。后来网站构建和投稿接口改成共用同一份校验代码。

AI 自查 A9

  • 适用:同一条校验同时存在于前端和后端,或者存在于提交入口和构建流程。
  • 检查:找出重复定义的限额、正则和枚举,比较是否一致。
  • 修复:抽到一个共享模块,两边都从那里导入;加测试断言两边引用的是同一个常量。

A10. 删代码要比写代码更小心

2.0 是这个仓库最大的一个提交:196 个文件,删掉 17,863 行,丢掉 28 张表。删之前打了 v1.0.0 的 tag,删错了随时能找回来。会删表的迁移 SQL 生成了,但没有执行,PR 里写着「删的是真数据,由人决定」,人先做了 pg_dump。更早删旧前端(281 个文件)时,用三个 agent 分头找残留,再加一个 agent 专门挑它们的错。大的重构用构建产物验收:拆五个超长 UI 文件时,前后 5,817 个产物逐字节一致(屏蔽文件名里的 hash)。即便这样,把博客挪到边缘渲染时,上线前的实测还是找出了四个单元测试抓不到的问题,其中一个是 RSS 切换后会从 12 篇变成 0 篇。

AI 自查 A10

  • 适用:准备做大规模删除、重命名、重构或破坏性迁移。
  • 检查:删除前是否打了 tag 或分支;破坏性迁移是否和代码改动分开执行,执行前是否有备份;有没有办法证明行为没变(构建产物比对、快照测试、接口录制回放)。
  • 修复:先打 tag;迁移只生成不执行,留给人;选一个可比对的产物做前后 diff;删除后再搜一遍对已删符号、路径和环境变量的引用。

A11. 几条小规矩

AI 自查 A11

  • Git:新建分支前先 fetch,从远端主分支拉;往已有分支加提交前,确认它的 PR 没有合并。这个仓库第一天就写了这条,没过多久照样有一组叠加 PR 被合进了另一个特性分支,提交被晾在外面,靠 cherry-pick 救回。
  • 开发服务器:启动前清理占用端口的进程时要杀进程组。像 wrangler dev 这样的监督进程,只杀子进程它会马上再起一个;agent 的 shell 是非交互的,新旧两次运行可能在同一个进程组里,要有一个只向上一层的回退。
  • 外部输入:不要用类型断言把外部数据变成领域类型。这里曾经把错误字段断言成字符串,实际是 {code, message} 对象,任何一个 4xx 都会让页面白屏。检查所有对 HTTP 响应、第三方 SDK 返回值的断言,改成运行时校验。
  • CI 全红:先看是不是根本没分到 runner(几秒内失败、没有日志)。这里连续三天半都是这样,是账户问题。不要反复重跑,在本地跑完 lint、类型检查和测试,在 PR 里写明。

B. 测试:全绿不等于有用

B1. 植入的 bug 只抓到 44%

前几天做了一次测试审计。当时有 19,502 个测试,全部通过。做法很土:在源码里手工植入 341 个真实可能出现的 bug(把 > 改成 >=、丢掉一项、反转一个符号、把除数换成上一期的),每植入一个跑一遍对应的测试,看它红不红。结果是 151 个被抓到,44%。最核心的计算模块只有 30%。

漏掉的那些集中在几种写法上,几乎都是 AI 写测试时的默认习惯:

  • 期望值和实际值取自同一个对象,形如 expect(total).toBe(r.a + r.b),函数把 b 算成 0 测试照样过。
  • 拿生产函数当标准答案,expect(header).toBe(cacheHeader())。
  • 夹具里被测的变量从来不变。某个量在每一期都相等,和它有关的 bug 永远测不出来。
  • 只测形状:toBeDefined()、> 0、一个从 30 到 500 的宽区间,实际值是 68.41。
  • 循环里的断言一次也没执行。四个多语言链接测试遍历页面列表,页面挪了位置以后它们对着零个页面循环,一直是绿的。
  • if (…) expect(…):分支没走到,测试就空跑通过。

审计顺手修了 9 个生产 bug。其中一个是上游数据源一年多以前改了一个枚举值的拼写,从那以后所有新记录都被静默丢弃,测试一直是绿的。测试数量从一万九千降到一千八,是因为一万八千个逐条 it.each 被合并成少数几个聚合测试:把所有失败收集成 [对象, 原因] 列表,最后断言它等于空数组。补完测试后,再植入同样的 341 个 bug,抓到 337 个,99%。

AI 自查 B1

  • 适用:所有有测试的项目。
  • 检查:挑项目里最重要的 5 到 10 个函数(计算、权限、计费、数据转换)。对每个函数植入 3 到 5 个现实的 bug:比较符翻转、丢掉一项、符号取反、边界差一、用错字段。每植入一个就跑它的测试,记录有没有变红,然后还原。另外搜索上面列出的六种写法。
  • 修复:报告存活的 bug 和对应的测试缺口。补的测试要用独立算出的期望值,并在注释里写出算式;每个阈值在边界上和两侧各一个用例;遍历类测试断言实际遍历的数量大于零。补完再植入一次,确认会红。

B2. AI 写的测试和监控,会把它的假设一起固定下来

代码和测试出自同一次推理的时候,测试证明不了什么。这个项目里好几次,是测试或监控本身把 bug 当成了正确行为:一个测试断言 API 域名的 robots.txt 是 Disallow: /(见 G1);线上探针断言未知路径返回 200(见 G2);健康检查靠 sitemap 里的日期判断有没有重新构建(见 G3);_redirects 的计数检查按错误的理解计数(见 D1)。

AI 自查 B2

  • 适用:所有项目,尤其是测试和实现由 AI 同时写出的部分。
  • 检查:对每个关于外部平台行为的断言(状态码、响应头、限额、配置值),问一句:期望值从哪来?如果来源只是「代码现在就是这样」,标出来。对监控和健康检查,问:如果现在这个行为本身就是 bug,这个检查会不会报?
  • 修复:期望值改为来自平台文档原文、错误信息或一次真实测量,并在注释里写出处;监控断言的应当是「应该是什么」,例如未知路径应返回 404。

C. 静默失败:最贵的坑都不报错

C1. 任务报告成功,其实只写进去 2%

第一个全量的夜里,数据库撞上了存储上限,第五批写入全部失败。任务返回 ok: true,Workflow 每一步都是绿的,汇总里写着 104 条只成功 2 条。之后任务的汇总多了一个 partial 状态:失败列表非空,或者成功数不到九成,就不算成功。

AI 自查 C1

  • 适用:项目有批处理、定时任务、队列消费者。
  • 检查:任务的成功与否由什么决定?是不是「没抛异常就算成功」?部分失败(逐条 try/catch 后继续)的情况,返回值和日志里是否能看出来?
  • 修复:任务汇总里返回总数、成功数、失败列表;定义部分成功的阈值,低于阈值时状态标为 partial 或 failed,并让调度方和告警认得这个状态。

C2. 失败被藏起来了

上线登录那晚(见 F1),JWT 验证失败只打 debug 日志,生产环境不输出;token 列表页请求失败时显示「No tokens yet」,这个空状态连 AI 自己都骗过一次。还有一次,Drizzle 在语句失败时把所有参数一起打了出来,Cloud Logging 一截断,最后那行真正的错误(数据库满了)就没了,是在 psql 里复现才看到的。

AI 自查 C2

  • 适用:所有项目。
  • 检查:搜索 catch 块,找出吞掉错误、只打 debug 日志、或者把失败渲染成空状态(「暂无数据」「列表为空」)的地方。检查错误日志里是否会带上超长的参数或负载,导致根因被截断。
  • 修复:服务端问题(下游不可用、配置错误、密钥失效)至少记 warn 并带上关键上下文;调用方自己的错误可以留在 debug。前端区分「加载失败」和「确实为空」。错误日志只保留根因的第一行和必要标识,限制长度。

C3. 健康检查要跑在被检查对象之外

后来写了一个 528 行的 check.sh,挨个检查边缘入口、缓存里每个数据包的新鲜度、夜间任务的运行记录、Cloud Run 日志、数据库迁移记录和用量,每一行输出 OK、WARN 或 FAIL。配套的 skill 让 AI 跑完脚本只解释不 OK 的那几行,并给出下一步。第一次跑就发现了三件事:有一组缓存只有 1/24 的条目在,四个任务已经八天没跑,有个监控每 20 秒打一次源站。它一开始是我桌面上的定时任务,只成功过一次;后来移到云上每天跑四次,告警从两个地方发。bash 脚本没有退役,因为只有它不跑在被检查的系统上。

AI 自查 C3

  • 适用:有线上服务的项目。
  • 检查:现在有没有一个命令能一次性回答「线上是否正常」?它检查的是存活,还是数据新鲜度和任务的实际产出?它运行在哪里,被检查的系统挂了它还能不能跑、能不能报警?
  • 修复:写一个只读的体检脚本,每项输出 OK、WARN、FAIL 和一句原因,覆盖入口可达、关键数据的最新时间、定时任务的最近一次结果、错误日志数量、配额用量。让它在另一套基础设施上定时运行,并保留本地手动运行的方式。

C4. 第一个诊断往往合理,但错了

IndexNow 一直 429,第一个诊断是「量太大」,量降了九成还是 429(见 G4);GA4 的会话数对不上,第一个诊断是「小样本阈值」,其实是重复计数(见 G7);改完 sitemap 日期后第一次测量是「每晚 12 个 URL」,其实是本地环境指向了一个没起来的端口。每次推翻第一个诊断的,都是一个能区分两种原因的小实验:只发 1 个 URL,比一下浏览量,在生产环境再量一次。

AI 自查 C4

  • 适用:排查任何线上问题时。这一条约束的是 AI 自己的工作方式。
  • 做法:在动手修之前,写出至少两个候选原因,为它们设计一个能区分开的最小实验(换一个来源、只发一条、换一个环境),先跑实验,再按结果修。修完用同一个实验验证。

D. Cloudflare

D1. _redirects 的动态规则上限,从第一个占位符开始算 〔平台〕

Workers 静态资源的 _redirects 允许 2,000 条静态规则、100 条动态规则(带 :name 或 * 的)。手写文件里第 7 条规则带了一个占位符,构建又在后面追加了 1,554 条静态 301。构建脚本自己数了一遍:动态规则 13 条,没问题。直到有一天部署失败:Line 147: Maximum number of dynamic _redirects rules limit of 100 exceeded。原来从第一个占位符开始,后面所有规则都不能再走哈希查找,Cloudflare 把它们全算成动态的。

另外,Workers 静态资源上的 _redirects 不管有没有匹配到文件都会执行,所以 Pages 时代常见的 /* /index.html 200 会把每一个预渲染页面都遮住。

AI 自查 D1

  • 适用:部署在 Cloudflare Workers 静态资源或 Pages,并且有 _redirects 文件。
  • 检查:找到第一条含 : 或 * 的规则,从它开始数到文件末尾的规则总数(包括构建时追加的);静态规则总数;有没有 /* /index.html 200 这样的兜底规则。
  • 修复:把所有占位符规则移到文件末尾,最好由构建生成;在构建里按「首个占位符之后的规则数」计数并在超过 100 时失败;删掉兜底规则,改用 not_found_handling(见 G2)。

D2. Workflow 自带的定时调度,会悄悄不触发 〔平台〕

为了绕开免费计划每个账号 5 个 cron 的限制,夜间任务改用了 Cloudflare Workflow 的 schedules。有连续五天,四组任务根本没有创建实例:没有报错,后端一个请求都没收到,71 个缓存数据包超过 26 小时没更新。后来改回普通的 Cron Trigger,它只负责创建 Workflow 实例,实例 id 用「调度 + 应触发的那一分钟」拼成,重复投递也只会有一个实例,创建失败三次就发 Telegram。

AI 自查 D2

  • 适用:使用 Cloudflare Workflows 或任何平台自带的定时调度。
  • 检查:定时任务有没有「应该跑而没跑」的检测?依赖的是平台调度还是自己的 cron 入口?重复触发会不会启动两份?
  • 修复:用最朴素的 cron 入口只负责创建实例;实例 id 由调度名和应触发时间拼成,保证幂等;创建失败重试并告警;另有一个检查比对「最近一次运行时间」和预期间隔。

D3. Workflow 恢复时会重放整个 run() 〔默认值〕

计时器写在 step.do 外面,量到的是最后一次重放:一个跑了约 21 分钟、分五批的任务被记成 126 毫秒,公开状态页上每个任务都显示 0 秒。文档写了 step 外的代码会重复执行,AI 还是按普通的命令式代码写了计时。

AI 自查 D3

  • 适用:使用 Cloudflare Workflows、Temporal、Durable Functions 或其他会重放的持久化执行框架。
  • 检查:在 step 之外有没有计时、计数、发通知、写外部存储、生成随机数或取当前时间这类有副作用或不确定的代码。
  • 修复:把它们移进 step 内部,或者作为 step 的返回值;计时在每个 step 内测量后汇总。

D4. 慢源站:125 秒后 524,源站其实跑完了 〔平台〕

夜间任务由 Worker 调 Cloud Run 上的接口,任务要跑三分多钟。Cloud Run 日志里是 192 秒后的 200,Worker 收到的是 125.4 秒的 524,后面的缓存预热因此被跳过。代理超时大家都知道,但它同样适用于 Worker 发往源站的子请求,这一点容易漏。改法是任务一开始就返回 200 的响应头,每 10 秒写一个换行当心跳,最后写 JSON(JSON 允许前导空白)。

AI 自查 D4

  • 适用:Worker、CDN 或任何反向代理后面有执行时间可能超过 100 秒的请求。
  • 检查:列出耗时最长的接口和它们的实际耗时;调用方的超时是多少;超时后调用方是否把「源站仍在执行」误判为失败。
  • 修复:长任务改成流式响应加心跳,或者改成异步:提交返回任务 id,调用方轮询结果。

D5. 部署「部分成功」 〔平台〕

cron 从 4 个加到 6 个,超过了免费计划的 5 个。wrangler 的提示是 Trigger configuration … was only partially updated:脚本已经上线,定时器没注册上。看起来一切都部署好了,那两个任务却从此没被调度过,直到下一次部署才发现。另一个相关细节:配置里干脆不写 triggers.crons,线上旧的定时器会原样保留,要清空必须显式写空数组。

AI 自查 D5

  • 适用:使用 Cloudflare Workers 的 cron triggers,或任何有账户级配额的部署配置。
  • 检查:当前配置里的 cron 数量和账户计划的上限;最近的部署日志里有没有 partially updated 一类的提示;配置删掉的定时器是否真的从线上消失了。
  • 修复:加一个测试断言 cron 数量不超过计划上限;部署流程把「部分更新」当作失败处理。

D6. 共享出口 IP 撞上第三方的按 IP 限流 〔平台〕

IndexNow 每晚从 Worker 提交,全部 429。按 IP 限流的第三方接口,从 Worker 调用时用的是 Cloudflare 的共享出口 IP,配额早被别人用掉了。细节见 G4。

AI 自查 D6

  • 适用:从 Workers、Vercel Edge、Lambda 这类共享出口的环境调用第三方 API。
  • 检查:这些 API 是否按来源 IP 限流(看文档,或看是否出现与请求量不成比例的 429)。
  • 修复:把这类调用移到有独立出口 IP 的服务上;429 记为部分成功并告警,不要在同一个出口上重试。

D7. 打包到边缘运行时,解析到了浏览器版本的依赖 〔平台〕

博客改成由 Worker 在边缘渲染之后,一个 Markdown 依赖被打包器解析到它的浏览器版本,模块顶层调用 document.createElement,Worker 一启动就崩。全局改解析条件又会把 Node 端的预渲染弄坏,最后单独给 Worker 一份打包配置,写上 workerd、worker 条件。本地开发服务器根本不执行 Worker,所以本地看不出来,后来专门加了一个用 wrangler dev 跑的开发命令。

AI 自查 D7

  • 适用:同一份代码要打包到浏览器、Node 和边缘运行时(Workers、Deno)中的两个以上。
  • 检查:每个目标的打包配置里,导出条件(browser、worker、workerd、node)是什么;产物里有没有引用 document、window 或 Node 内置模块;本地开发流程是否真的执行了边缘运行时的代码。
  • 修复:每个运行时单独一份打包配置;加一个启动冒烟测试(在 wrangler dev 或等价环境里请求一次);提供一个在真实运行时下跑的本地开发命令。

D8. 几个小的

AI 自查 D8

  • HEAD 请求:缓存判断和缓存头是否只认 GET?拨测和爬虫大量用 HEAD,会全部打到源站。把 HEAD 和 GET 同样对待(回源时用 GET)。
  • _headers:最多 100 条规则,超出的静默丢弃。统计规则数并在构建里断言。
  • 无扩展名文件(比如 /.well-known/api-catalog)默认按 application/octet-stream 发出,需要在 _headers 里声明类型。
  • Worker 的明文变量会被下一次部署的 vars 覆盖,在控制台手填的值要放进 Secret。
  • pnpm 的 pnpm --filter 包名 deploy 执行的是 pnpm 自带的 deploy 命令,跳过了 package.json 里的同名脚本,要写 run deploy。
  • Worker 的名字要和控制台里实际存在的一致,否则 wrangler deploy 会建出一个没有路由的新 Worker。

E. Cloud Run

E1. 返回响应之后,CPU 就没了 〔平台,后来变成指令〕

min-instances 为 0、CPU 只在请求期间分配时,返回响应之后再做的事随时会被冻结。早期一段「先返回再后台写库」的代码,表现是数据库报 Connection terminated unexpectedly。部署文档里一直写着「min 1 + CPU 常驻」,配置早就改了,文档没改,过期的文档又误导了几次排查。同一次订正还发现,默认 300 秒的请求超时会切断夜间任务,改成了 1,800 秒。

AI 自查 E1

  • 适用:部署在 Cloud Run、Lambda、Cloud Functions 或任何按请求分配 CPU 的平台。
  • 检查:搜索在发送响应之后仍在执行的代码:不 await 的 Promise、setTimeout、事件触发的后台写入、日志或遥测的异步上报。核对平台的请求超时和最长任务的实际耗时。
  • 修复:要么 await 完再返回,要么交给真正的队列或任务服务;把请求超时设到最长任务的两倍以上;在部署文档里写清楚 CPU 分配方式。

E2. 换 revision 会切断正在跑的批处理 〔平台〕

第一个全量的夜里,第五批被两次后端部署打断。Workflow 按设计重试了,但那一轮白跑。现在指令文件里写着:UTC 01:00–01:45 和 03:00–04:30 之间不合并会部署后端的 PR,agent 写的每个 PR 描述都会重复一遍这个窗口。

AI 自查 E2

  • 适用:有定时批处理,并且合并主分支会自动部署。
  • 检查:批处理的时间窗口;部署是否会中断正在执行的请求;任务能否从中断点续跑。
  • 修复:在指令文件和 PR 模板里写明禁止部署的时间窗;让批处理按批次幂等、可续跑。

E3. 缓存版本该跟着接口契约走,不该跟着部署走 〔默认值〕

一次接口字段改名之后,新前端读到了 KV 里的旧数据包,首页一块面板空了大约一小时。当时的修法是用 Cloud Run 的 K_REVISION 给缓存条目打版本,旧 revision 写的一律视为 miss。结果每次部署都让全部约 680 个数据包失效,哪怕只改了一行 CSS,34 小时后才注意到。何况浏览器缓存 5 分钟、边缘缓存 1 小时,这个办法本来也保证不了什么。现在用的是一个手动维护的契约版本号,只有前端读不懂新数据时才加一。

AI 自查 E3

  • 适用:有缓存层(CDN、KV、Redis)缓存 API 响应,并且前后端分开部署。
  • 检查:缓存键或失效条件里有没有部署 id、git sha、构建时间;接口发生不兼容改动时,旧缓存能否被新前端读到。
  • 修复:引入一个显式的契约版本号,只在不兼容改动时递增并参与缓存键;兼容的新增字段不让缓存失效;加一个测试,防止有人把部署 id 重新接进去。

E4. /healthz 被 Google 前端占用了 〔平台〕

这个路径在 Cloud Run 的边缘直接 404,请求到不了容器,日志里什么都没有。改名 /health。

AI 自查 E4

  • 适用:部署在 Cloud Run 或其他 Google 前端后面的服务。
  • 检查:健康检查或探针路径是否用了 /healthz,或其他以 z 结尾的保留路径。
  • 修复:改名为 /health 一类的路径,并同步更新探针配置。

E5. 自定义域名、日志和构建上下文 〔平台〕

Cloud Run 的自定义域名放不到 Cloudflare 代理后面:托管证书要求直连,Cloud Armor 要一个负载均衡器,Cloudflare 改写 Host 是企业版功能。最后的方案是 API 前面再挡一个 Worker,做路径白名单,转发到 *.run.app 时带一个密钥头,源站拒绝所有不带这个头的直连。纯文本日志在 Cloud Logging 里的严重级别一律是 DEFAULT,要输出带 severity 字段的 JSON。从仓库直接部署时,构建上下文是 Dockerfile 所在的目录,monorepo 的 Dockerfile 要放在根目录。

AI 自查 E5

  • 适用:部署在 Cloud Run。
  • 检查:源站能否被绕过 CDN 直连;生产日志是否是结构化 JSON 且带严重级别;Dockerfile 的位置和构建上下文是否能拿到 monorepo 的根文件。
  • 修复:在 CDN 或边缘层加一个只有它知道的请求头,源站校验;日志输出 JSON 行并带 severity;把 Dockerfile 放到构建上下文的根目录。

F. Postgres 和 Neon

F1. 登录上线那晚,四个 PR 修了四个静默失败 〔平台 + 默认值〕

那天晚上前后大约四个半小时,按时间顺序:

  1. 测试版 SDK 的类型里没有文档上写的 getJWTToken()。AI 判断「类型漏了,文档是对的」,加了一个 typeof fn === "function" 的运行时检查来代替类型断言。但这个客户端是一个 Proxy,任何不存在的方法名都会变成一个 HTTP 路径,typeof 永远是 "function"。调用变成了 GET /get-jwt-token,404。
  2. 换成 client.token(),还是拿不到能用的 token。
  3. 绕开 SDK 直接请求 /token。JWT 在返回值的顶层,不在 {data, error} 里面。
  4. 浏览器终于发出了 JWT,API 对所有人返回 401。原因是 new URL("/.well-known/jwks.json", base):base 带路径(…/neondb/auth),以 / 开头的相对路径会把它丢掉。顺带还发现 JWT 的 iss 是不带路径的域名。

每一步都多拖了几轮,因为失败都被藏了起来(见 C2)。修完后有一个测试专门断言 JWKS 地址不等于 new URL() 的结果,注释写着:这行代码看起来太像一个顺手的简化了。

AI 自查 F1

  • 适用:所有项目,尤其是接入了认证服务或测试版 SDK 的。
  • 检查:搜索 new URL("/…", base) 这种以斜杠开头的相对路径,确认 base 不带路径或者确实需要丢掉路径;搜索对 SDK 方法做 typeof … === "function" 的检查,确认对象不是 Proxy;认证链路上每一个失败分支的日志级别和用户可见的表现。
  • 修复:拼接带路径的 URL 时显式处理斜杠,并写一个带路径 base 的测试;对 Proxy 风格的客户端只调用类型里存在的方法;认证失败中服务端的部分记 warn。

F2. 跨云读数据库,每一行都是出流量 〔默认值 + 平台〕

数据库在 AWS us-east-1,应用在 GCP,每读一行都按公网出流量计费。最早的缓存层为了判断数据新不新鲜,select * 读两三千到四千行,只看首尾两个日期。免费计划每月 5 GB,一周就用完了。改成先查 max、min、count,再增量拉取。升级付费后还是每天 3 GB。没有按小时的用量接口,也没装 pg_stat_statements,最后在数据库连接的 socket 上累加 bytesRead,给每一行访问日志加一个 db_kb 字段,数字一出来就清楚了:整个 API 一天从数据库读出约 3 GB,实际发出去的只有约 140 MB;一个数据包读出 555 KB、发出 268 KB,因为同一份约 110 KB 的快照被读了三遍;一个关联查询读出 309 KB,发出去 1 KB。

AI 自查 F2

  • 适用:使用托管数据库,尤其是数据库和应用不在同一个云或同一个区域。
  • 检查:数据库和应用各在哪个云、哪个区域;搜索 select * 和只为取少数字段或做判断而读整行的查询;同一请求里有没有重复读同一份数据;有没有办法按请求或按接口统计从数据库读了多少字节。
  • 修复:判断类查询改成聚合;只选需要的列;同一请求内缓存已读数据;在访问日志里加每请求的数据库读取字节数(可以从驱动的 socket 累加),先测量再优化。

F3. 数据库满了,任务报告成功 〔默认值 + 平台〕

第一个全量夜里撞上 512 MB 的存储上限,任务却报告成功(见 C1)。存储里有 63 MB 是一个和主键完全重复的索引,从项目第一天的脚手架里就在。

AI 自查 F3

  • 适用:使用 Postgres。
  • 检查:查询 pg_indexes,找出和主键或其他索引列完全相同的冗余索引;各表和索引的大小;离存储上限还有多少。
  • 修复:删除冗余索引(先确认没有约束依赖它);给增长最快的表加清理策略;把存储用量加进健康检查。

F4. 给边缘写的驱动,别用在长驻进程里 〔默认值〕

@neondatabase/serverless 的 WebSocket 连接池在 Cloud Run 上每次握手都失败,登录全部 500。它是给边缘运行时准备的,长驻的 Node 进程用普通的 TCP 连接池就好。连接池还要配 keepAlive、空闲超时和 pool.on("error"):pooler 会杀掉空闲连接,一个没人处理的 idle client 错误能让整个 Node 进程崩掉。

AI 自查 F4

  • 适用:Node 服务连接 Postgres。
  • 检查:用的是哪个驱动,是否是为边缘或 serverless 环境设计的;连接池是否设置了 keepAlive 和空闲超时;是否监听了 pool 的 error 事件;是否用的是数据库提供的连接池地址。
  • 修复:长驻进程用 TCP 驱动;补齐上述配置;全项目只建一个连接池。

F5. 遥测写入让数据库没法缩到零 〔平台〕

前端把 Web Vitals 写进自己的数据库,白天每一次写入都会唤醒已经缩到零的计算节点,一天多出约 1 个计算小时。现在 Vitals 改发给 GA4。

AI 自查 F5

  • 适用:数据库按计算时长计费,并且支持缩容到零。
  • 检查:哪些写入是由匿名访客触发的(埋点、遥测、计数器),频率多高。
  • 修复:把这类写入挪到专门的分析服务或日志管道,或者批量合并后低频写入。

F6. 迁移、共享库和唯一约束 〔平台 + 默认值〕

ORM 生成的迁移不一定能执行:有一次生成的迁移把新主键约束排在新列之前,执行失败。重命名需要交互式终端,agent 的 shell 没有,只能手写 ALTER … RENAME。只有一个数据库、没有测试库的时候,一个临时测试脚本用 DELETE … WHERE account_id='me' AND date IN (…) 清理,差点删掉真实数据。

还有一个很多人(包括我们最初写的规则)会弄错的点:唯一约束里有可空列时,Postgres 认为 NULL 互不相等,两行 (a, NULL) 不算冲突,ON CONFLICT 永远不会触发,upsert 退化成重复插入。主键不受影响,主键列本来就是 NOT NULL。

AI 自查 F6

  • 适用:使用 Postgres 和任何迁移工具。
  • 检查:最近生成的迁移 SQL 是否有人读过;有没有 DROP、RENAME、改类型的语句;测试和临时脚本是否连接生产库,清理语句的条件能否碰到真实数据;用于 ON CONFLICT 的唯一约束里有没有可空列。
  • 修复:迁移先生成、再审读、再执行;临时脚本用一次性的标识(比如 smoke-test-<随机串>)写入,或者在事务里做完断言后回滚;可空列参与的唯一约束改用 NULLS NOT DISTINCT(Postgres 15 及以上)或者给列设非空的哨兵默认值。

F7. 模型知道的价格是旧的 〔默认值〕

讨论要不要升级时,AI 引用的 Neon 付费计划价格是每月 19 美元,而 Neon 早在 2025 年底就改成了按用量计费。价格、额度、套餐名,训练数据里的都可能过期。

AI 自查 F7

  • 适用:所有涉及第三方服务配额、价格、限额的决定。
  • 做法:引用任何价格、配额或限额时注明来源和日期;没有当场查过官方页面的,标为「待核实」。

G. Google 搜索

G1. API 域名的 Disallow: / 让页面变成了软 404 〔平台 + 默认值 + 指令〕

API 不是网站,所以 AI 给 API 域名的 robots.txt 写了 Disallow: /,还写了一个测试断言它。可 Google 的渲染器在渲染页面时,会遵守页面所请求的每一个域名的 robots.txt。数据请求被拦,页面显示「加载失败」,同一天 Search Console 就把一个页面报成了软 404。后来改成放行公开接口的路径,同时保留 X-Robots-Tag: noindex:可以抓取和可以被索引是两回事。

AI 自查 G1

  • 适用:页面在浏览器里从另一个域名(API、CDN、图片服务)拉数据,并且关心搜索收录。
  • 检查:这些域名的 robots.txt 是否禁止了页面需要请求的路径。
  • 修复:允许抓取页面渲染需要的路径;不希望被收录的响应用 X-Robots-Tag: noindex 控制。

G2. SPA 回退:所有未知路径都返回首页,状态码 200 〔默认值 + 指令〕

迁到 Workers 时为了让深链接能用,AI 选了 not_found_handling: "single-page-application"。对一个 SPA 来说这最自然,但这个站有约 4,500 个预渲染页面,SEO 是主要流量来源。于是拼错的路径、大写的旧网址、/sign-in 这种客户端路由,全是一份 canonical: / 的首页副本,状态码 200。健康检查和线上探针还都断言「未知路径返回 200」,监控把这个 bug 当成了正常。直到写这篇的前一天,才从 Search Console 的「已抓取,尚未编入索引」里查出来:一个早已不存在的 /sign-up 有 39 次展示。

AI 自查 G2

  • 适用:单页应用,并且有希望被搜索收录的页面。
  • 检查:请求一个不存在的路径,看状态码和返回的 canonical;客户端路由(登录、账户页)直接访问时的状态码和 robots 指令;监控里有没有断言未知路径返回 200。
  • 修复:未知路径返回真 404(可以是一个能启动应用的 noindex 页面);客户端路由各自输出 200 加 noindex 的壳;监控改为断言 404。

G3. sitemap 的 lastmod 错了四次 〔默认值 + 指令〕

  1. 最早用构建时间,后来改成每页的修改日期,但留了一个回退到构建时间的分支。
  2. 新增约 200 个没有修改日期的页面,构建时间悄悄回来了。
  3. 改版之后,AI 为了「让爬虫重新抓取」手动把 lastmod 往后调。这是 Google 明确反对的做法。
  4. 改用数据包的 asOf,那是夜间任务自己的时间戳,于是 1,247 个 URL 里有 1,042 个每天都声称自己变了。

Google 会忽略一个永远是「今天」的 lastmod。现在一页的日期取的是页面上最新一条有日期的内容。健康检查原来靠 sitemap 里最新的日期判断「昨晚有没有重新构建」,日期诚实了以后,没有内容更新的那天就会误报,所以另加了一个 /build.json。

AI 自查 G3

  • 适用:有 sitemap。
  • 检查:生成 sitemap,统计 lastmod 等于今天的 URL 比例;追踪 lastmod 的来源,是内容的修改时间,还是构建、任务或部署时间;有没有回退到当前时间的分支;有没有其他系统依赖 lastmod 判断构建是否发生。
  • 修复:lastmod 取页面上实际内容最近一次变化的日期;不确定时省略,别填当前时间;构建是否发生用单独的构建信息文件判断。

G4. IndexNow 一直 429,第一次诊断是错的 〔平台〕

上面那个 lastmod 也喂给 IndexNow,所以每晚要提交 1,127 个 URL,全部 429。AI 判断「量本身不合理」,修了 lastmod,量降到一百个左右,还是 429。真正能分清原因的实验只要一行:从本地 curl 提交 1 个 URL,返回 202。IndexNow 按来源 IP 限流,Worker 的出站走的是共享 IP。现在由边缘收集 URL,交给 Cloud Run 发出。另外,Google 没有通用的 URL 提交接口(Indexing API 只收招聘和直播两类页面),对 Google 来说能用的信号只有 lastmod。

AI 自查 G4

  • 适用:使用 IndexNow 或其他提交接口。
  • 检查:提交从哪个出口发出;最近的返回码;提交的 URL 是不是真的变了(见 G3)。
  • 修复:只提交内容确实变化的 URL;从独立出口 IP 提交;429 不重试,记录并告警。

G5. canonical 只是提示,内容不同时 Google 不认 〔平台〕

为了「不稀释主页面」,一组标签页的 canonical 都指向主页面,也不进 sitemap。Search Console 的反馈是「Google 选择了不同的规范网址」。现在标签页各自 canonical,带内容预渲染,进 sitemap,每页按自己的内容打日期。

AI 自查 G5

  • 适用:有多个 URL 指向同一个 canonical。
  • 检查:这些 URL 的主体内容是否真的相同。不同的话,canonical 会被忽略。
  • 修复:只有真正的副本(打印版、嵌入版、参数排序不同)指向 canonical;内容不同的页面各自 canonical,或者合并成一个页面。

G6. 爬虫拿到的 HTML 里要有内容 〔默认值 + 指令〕

预渲染方案在不到一个月里来回改了四次:先是预渲染,构建时注入数据;第二天撤回成纯客户端拉取,PR 里写「数据页不再是 SEO 面」,没写原因;然后给数据页加没有数据的壳;后来发现 sitemap 里约 1,200 个 URL 有 1,190 个 HTML 里没有内容,又改回带数据预渲染。中间有一份方案把「构建不依赖 API」列为设计约束,而这个约束正是几天前那次撤回自己造出来的。还有一个连带问题:页面先输出预渲染的 HTML,客户端请求失败时却把它替换成了错误状态,而 Google 收录的是渲染后的 DOM,一次接口超时就可能变成软 404。

AI 自查 G6

  • 适用:希望被搜索收录的页面依赖客户端请求才能显示主要内容。
  • 检查:用 curl 取 sitemap 里随机 20 个 URL,看 HTML 里有没有页面的主要内容;客户端请求失败时,已有的预渲染内容会不会被替换掉;设计文档里的约束是否有出处。
  • 修复:需要收录的页面在服务端或构建时输出主要内容;客户端请求失败时保留已有内容,不要替换成错误状态。

G7. GA4 按页面加总会话数 〔默认值〕

第一版把各页面的会话数加起来得到 636,按渠道汇总只有 258。AI 在代码注释里把差异归因于 GA4 的「小样本阈值」。可浏览量两边完全一致,这就排除了阈值:一个会话访问了几个页面,就被算了几次。同一天还撞上几道控制台关卡:服务账号已经是查看者,还要另外在项目里启用 Analytics Data API;接口要的是数字的 property id,不是 G- 开头的衡量 id。

AI 自查 G7

  • 适用:从 GA4 或其他分析 API 拉数据做报表。
  • 检查:会话、用户这类不可加的指标有没有按页面或其他维度相加;数据接口用的 id 类型;拉取窗口是否覆盖了数据回改期(GA4 约 2 天,Search Console 约 3 天)。
  • 修复:不可加指标按它自己的维度单独查询;用数字 property id;按窗口重拉并 upsert。

G8. 几个小的

AI 自查 G8

  • Search Console API 的服务账号要作为用户加进对应的资源。
  • FAQ 等结构化数据里的文字必须和页面上可见的文字逐字一致。
  • og:image 和文章的结构化图片用 PNG、JPG 或 WebP,不用 SVG,宽度至少 1,200 像素。
  • robots.txt 里一个爬虫只匹配最具体的那一组规则,从 * 那组什么都继承不到。为某类爬虫单独写组时,要把通用的 Disallow 重复一遍。
  • 给页面生成的 Markdown 或纯文本副本,要用 Link 响应头(rel="canonical")指回原页面。
  • hreflang 必须双向,并且包含指向自己的那一条。
  • 带斜杠和不带斜杠的同一路径不能都返回 200。
  • 标题的品牌后缀统一由一个函数添加,不要有的页面手写、有的页面自动加。

回头看

把这四十多个坑摊开,最贵的那些几乎都是静默失败:任务显示成功但只写进去 2%,定时器没注册上但部署是绿的,401 只打 debug 日志,接口失败时页面写着「暂无数据」。这些 bug 修起来大多一两个 PR 就够,时间都花在了发现它们上。

写这篇的一周前做过一次产品评审,结论写在路线图的第一段:工程和 SEO 基建已经很扎实,但近期的投入集中在基建上,决定这个项目能不能活下去的几件事投入不足。这篇里的大部分内容,就是那段「投入集中在基建上」的明细。

最后是一段可以放进你自己指令文件的规则,和上面的自查项一一对应。自查做一次就完了,规则写进去以后每一轮都会被读到。挑适用的放,别整段照抄,A1 讲过指令文件越长,每一轮付出的代价越大。

放进 CLAUDE.md / AGENTS.md 的规则

  • 指令文件只写每一轮都需要、读代码读不出来的约束;进度写在 issue 里;文档和代码矛盾时以代码为准,并指出矛盾。
  • 能被机器判定的约定写成 lint 规则或测试,规则只有 error,上线时零违规。
  • 测试的期望值必须独立得出并在注释中写出算式或出处;合并前故意改坏被测代码,确认测试会失败。
  • 定时任务和批处理必须区分成功、部分成功和失败;服务端错误至少记 warn;前端区分「加载失败」和「为空」。
  • 不要在返回响应之后继续执行工作;长任务用流式心跳或异步任务。
  • 缓存版本跟随接口契约;部署 id、git sha、构建时间都不能进缓存键。
  • 破坏性操作(删数据、迁移、改权限、群发)只准备、不执行,交给人确认。
  • 排查问题时先提出至少两个假设,用一个能区分它们的最小实验验证,再修改。
  • 引用价格、配额、限额时注明来源和日期,没有查证的标为待核实。
  • 没有对应内容的路径返回真 404;sitemap 的日期只取内容变化的日期。